前端JavaScript常见面试题(二)
this以及call/apply/bind函数
this关键字
- this 是执行上下文中的一个属性,它指向最后一次调用这个方法的对象。在实际开发中,this 的指向可以通过四种调用模式来判断。
第一种是函数调用模式,当一个函数不是一个对象的属性时,直接作为函数来调用时,this 指向全局对象。
- 全局上下文
console.log(window === this); // true var a = 1; this.b = 2; window.c = 3; console.log(a + b + c); // 6
- 函数上下文
function foo(){
return this;
}
console.log(foo() === window); // true- 箭头函数
function Person(name){
this.name = name;
this.say = () => {
var name = "xb";
return this.name;
}
}
var person = new Person("axuebin");
console.log(person.say()); // axuebin第二种是方法调用模式,如果一个函数作为一个对象的方法来调用时,this 指向这个对象。
var person = {
name: "axuebin",
getName: function(){
return this.name;
}
}
console.log(person.getName()); // axuebin这里有一个需要注意的地方
var name = "xb";
var person = {
name: "axuebin",
getName: function(){
return this.name;
}
}
var getName = person.getName;
console.log(getName()); // xb第三种是构造器调用模式,如果一个函数用 new 调用时,函数执行前会新创建一个对象,this 指向这个新创建的对象。
第四种是apply 、 call 和 bind 调用模式,这三个方法都可以显示的指定调用函数的 this 指向。其中 apply 方法接收两个参数:一个是 this 绑定的对象,一个是参数数组。call 方法接收的参数,第一个是 this 绑定的对象,后面的其余参数是传入函数执行的参数。也就是说,在使用 call() 方法时,传递给函数的参数必须逐个列举出来。bind 方法通过传入一个对象,返回一个 this 绑定了传入对象的新函数。这个函数的 this 指向除了使用 new 时会被改变,其他情况下都不会改变。
var person = {
name: "axuebin",
age: 25
};
function say(job) {
console.log(this.name + ":" + this.age + " " + job);
}
say.call(person, "FE"); // axuebin:25 FE
say.apply(person, ["FE"]); // axuebin:25 FE
var sayPerson = say.bind(person, "FE");
sayPerson(); // axuebin:25 FE这四种方式,使用构造器调用模式的优先级最高,然后是 apply、call 和 bind 调用模式,然后是方法调用模式,然后是函数调用模式。
call()/apply()/bind()函数
- call、apply、bind 的区别 call 和 apply、bind 都是可以修改 this 的指向,只是 bind 修改 this 的指向而并不会立即执行,call 和 apply 都会立即执行并修改 this 指向,只是他们俩的唯一区别是传给函数的参数 call 是 fn.call(ctx, arg1, arg2,……)的形式,apply是fn.apply(ctx, [arg1,arg2,……])
手写 call()
Function.prototype.myCall = function(context) {
// 判断调用对象
if (typeof this !== "function") {
console.error("type error");
}
// 获取参数
let args = [...arguments].slice(1),
result = null;
// 判断 context 是否传入,如果未传入则设置为 window
context = context || window;
// 将调用函数设为对象的方法
context.fn = this;
// 调用函数
result = context.fn(...args);
// 将属性删除
delete context.fn;
return result;
};手写 apply()
Function.prototype.myApply=function(context){
// 获取调用`myApply`的函数本身,用 this 获取,如果 context 不存在,则为 window
var context = context || window;
var fn = Symbol();
context[fn] = this;
//获取传入的数组参数
var args = arguments[1];
if (args == undefined) { //没有传入参数直接执行
// 执行这个函数
context[fn]()
} else {
// 执行这个函数
context[fn](...args);
}
// 从上下文中删除函数引用
delete context.fn;
}手写bind()
Function.prototype.myBind = function(context) {
// 判断调用对象是否为函数
if (typeof this !== "function") {
throw new TypeError("Error");
}
// 获取参数
var args = [...arguments].slice(1),
fn = this;
return function Fn() {
// 根据调用方式,传入不同绑定值
return fn.apply(
this instanceof Fn ? this : context,
args.concat(...arguments)
);
};
};异步编程
异步编程的实现方式
- 回调函数 的方式,使用回调函数的方式有一个缺点是,多个回调函数嵌套的时候会造成回调函数地狱,上下两层的回调函数间的代码耦合度太高,不利于代码的可维护。
- Promise 的方式,使用 Promise 的方式可以将嵌套的回调函数作为链式调用。但是使用这种方法,有时会造成多个 then 的链式调用,可能会造成代码的语义不够明确。
- generator 的方式,它可以在函数的执行过程中,将函数的执行权转移出去,在函数外部还可以将执行权转移回来。当遇到异步函数执行的时候,将函数执行权转移出去,当异步函数执行完毕时再将执行权给转移回来。因此在 generator 内部对于异步操作的方式,可以以同步的顺序来书写。使用这种方式需要考虑的问题是何时将函数的控制权转移回来,因此需要有一个自动执行 generator 的机制,比如说 co 模块等方式来实现 generator 的自动执行。
- async 函数 的方式,async 函数是 generator 和 promise 实现的一个自动执行的语法糖,它内部自带执行器,当函数内部执行到一个 await 语句的时候,如果语句返回一个 promise 对象,那么函数将会等待 promise 对象的状态变为 resolve 后再继续向下执行。因此可以将异步逻辑,转化为同步的顺序来书写,并且这个函数可以自动执行。
什么是 Promise?我们用 Promise 来解决什么问题?
什么是 Promise
Promise是异步编程的一种解决方案,它是一个对象,可以获取异步操作的消息,他的出现大大改善了异步编程的困境,避免了地狱回调,它比传统的解决方案回调函数和事件更合理和更强大。
所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。
Promise的实例有三个状态:
- Pending(进行中)
- Resolved(已完成)
- Rejected(已拒绝)
当把一件事情交给promise时,它的状态就是Pending,任务完成了状态就变成了Resolved、没有完成失败了就变成了Rejected。
Promise的实例有两个过程:
- pending -> fulfilled : Resolved(已完成)
- pending -> rejected:Rejected(已拒绝)
Promise的特点:
- 对象的状态不受外界影响。promise对象代表一个异步操作,有三种状态,pending(进行中)、fulfilled(已成功)、rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态,这也是promise这个名字的由来——“承诺”;
- 一旦状态改变就不会再变,任何时候都可以得到这个结果。promise对象的状态改变,只有两种可能:从pending变为fulfilled,从pending变为rejected。这时就称为resolved(已定型)。如果改变已经发生了,你再对promise对象添加回调函数,也会立即得到这个结果。这与事件(event)完全不同,事件的特点是:如果你错过了它,再去监听是得不到结果的。
Promise解决了什么问题
如题
let fs = require('fs')
fs.readFile('./a.txt','utf8',function(err,data){
fs.readFile(data,'utf8',function(err,data){
fs.readFile(data,'utf8',function(err,data){
console.log(data)
})
})
})上面的代码有如下缺点:
- 后一个请求需要依赖于前一个请求成功后,将数据往下传递,会导致多个ajax请求嵌套的情况,代码不够直观。
- 如果前后两个请求不需要传递参数的情况下,那么后一个请求也需要前一个请求成功后再执行下一步操作,这种情况下,那么也需要如上编写代码,导致代码不够直观。
使用promise方法对代码进行改进
let fs = require('fs')
function read(url){
return new Promise((resolve,reject)=>{
fs.readFile(url,'utf8',function(error,data){
error && reject(error)
resolve(data)
})
})
}
read('./a.txt').then(data=>{
return read(data)
}).then(data=>{
return read(data)
}).then(data=>{
console.log(data)
})总结:promise可以解决了地狱回调的问题。
Promise方法
then()
- then方法可以接受两个回调函数作为参数。第一个回调函数是Promise对象的状态变为resolved时调用,第二个回调函数是Promise对象的状态变为rejected时调用。其中第二个参数可以省略。
then方法返回的是一个新的Promise实例(不是原来那个Promise实例)。因此可以采用链式写法,即then方法后面再调用另一个then方法。
let promise = new Promise((resolve,reject)=>{
ajax('first').success(function(res){
resolve(res);
})
})
promise.then(res=>{
return new Promise((resovle,reject)=>{
ajax('second').success(function(res){
resolve(res)
})
})
}).then(res=>{
return new Promise((resovle,reject)=>{
ajax('second').success(function(res){
resolve(res)
})
})
}).then(res=>{
})catch()
- 该方法相当于then方法的第二个参数,指向reject的回调函数。不过catch方法还有一个作用,就是在执行resolve回调函数时,如果出现错误,抛出异常,不会停止运行,而是进入catch方法中。
p.then((data) => {
console.log('resolved',data);
},(err) => {
console.log('rejected',err);
}
);
p.then((data) => {
console.log('resolved',data);
}).catch((err) => {
console.log('rejected',err);
});all()
- 返回一个新的 promise, 只有所有的 promise 都成功才成功。
javascript
let promise1 = new Promise((resolve,reject)=>{
setTimeout(()=>{
resolve(1);
},2000)
});
let promise2 = new Promise((resolve,reject)=>{
setTimeout(()=>{
resolve(2);
},1000)
});
let promise3 = new Promise((resolve,reject)=>{
setTimeout(()=>{
resolve(3);
},3000)
});
Promise.all([promise1,promise2,promise3]).then(res=>{
console.log(res);
//结果为:[1,2,3]
})race()
- 返回一个新的 promise, 第一个完成的 promise 的结果状态就是最终的结果状态(并非是数组中的第一个,而是第一个完成的 promise)
let promise1 = new Promise((resolve,reject)=>{
setTimeout(()=>{
reject(1);
},2000)
});
let promise2 = new Promise((resolve,reject)=>{
setTimeout(()=>{
resolve(2);
},1000)
});
let promise3 = new Promise((resolve,reject)=>{
setTimeout(()=>{
resolve(3);
},3000)
});
Promise.race([promise1,promise2,promise3]).then(res=>{
console.log(res);
//结果:2
},rej=>{
console.log(rej)};
)finally()
- finally方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。
promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···});上面代码中,不管promise最后的状态,在执行完then或catch指定的回调函数以后,都会执行finally方法指定的回调函数。
(拓展)手写 promise.all()
- 这个在大厂面试中可能会出现,在中小厂很少问,而且写法多样有复杂简单之分,可以当做额外学习吧(= =)
function PromiseAll(promises) {
//返回一个 Promise 对象
return new Promise((resolve, reject) => {
//判断传入的参数是否为数组
if (!Array.isArray(promises)) {
return reject(new Error("传入的参数不是数组"))
}
const res = [];
//设置一个计时器
let count = 0;
for (let i = 0; i < promises.length; i++) {
Promise.resolve(promises[i]).then(value => {
res[i] = value;
if (++count === promises.length) {
resolve(res)
}
}).catch(e => reject(e))
}
})
}
PromiseAll([1, 2, 3]).then(o => console.log(o))
PromiseAll([1,Promise.resolve(3)]).then(o=>console.log(o))
PromiseAll([1,Promise.reject(3).then(o=>console.log(o))])async/await对比Promise的优势
- 代码读起来更加同步,Promise虽然摆脱了回调地狱,但是then的链式调⽤也会带来额外的阅读负担
- Promise传递中间值⾮常麻烦,⽽async/await⼏乎是同步的写法,⾮常优雅
- 错误处理友好,async/await可以⽤成熟的try/catch,Promise的错误捕获⾮常冗余
- 调试友好,Promise的调试很差,由于没有代码块,你不能在⼀个返回表达式的箭头函数中设置断点,如果你在⼀个.then代码块中使⽤调试器的步进(step-over)功能,调试器并不会进⼊后续的.then代码块,因为调试器只能跟踪同步代码的每⼀步。
面向对象程序设计
- 这里主要考点是创建对象和继承的知识,由于创建对象的相关知识点在面试过程较少提及,为了避免篇幅多长,这里就不展开了,详细见《JavaScript高级程序设计》一书。
继承
- 继承这块知识点算是JavaScript中比较重要且比较难理解的知识,需要读者仔细学习
原型链继承
- 简单理解就是将父类的实例作为子类的原型
function Parent() {
this.isShow = true
this.info = {
name: "yhd",
age: 18,
};
}
Parent.prototype.getInfo = function() {
console.log(this.info);
console.log(this.isShow); // true
}
function Child() {};
Child.prototype = new Parent();
let Child1 = new Child();
Child1.info.gender = "男";
Child1.getInfo(); // {name: "yhd", age: 18, gender: "男"}
let child2 = new Child();
child2.getInfo(); // {name: "yhd", age: 18, gender: "男"}
child2.isShow = false
console.log(child2.isShow); // false- 优点:父类方法可以复用。
- 缺点:父类的所有引用属性会被所有子类共享,更改一个子类的引用属性,其他子类也会受影响;子类型实例不能给父类型构造函数传参。
借用构造函数
- 在子类构造函数中调用父类构造函数,可以在子类构造函数中使用call()和apply()方法
function Parent() {
this.info = {
name: "yhd",
age: 19,
}
}
function Child() {
Parent.call(this)
}
let child1 = new Child();
child1.info.gender = "男";
console.log(child1.info); // {name: "yhd", age: 19, gender: "男"};
let child2 = new Child();
console.log(child2.info); // {name: "yhd", age: 19} 通过使用call()或apply()方法,Parent构造函数在为Child的实例创建的新对象的上下文执行了,就相当于新的Child实例对象上运行了Parent()函数中的所有初始化代码,结果就是每个实例都有自己的info属性。
1、传递参数
相比于原型链继承,盗用构造函数的一个优点在于可以在子类构造函数中像父类构造函数传递参数。
function Parent(name) {
this.info = { name: name };
}
function Child(name) {
//继承自Parent,并传参
Parent.call(this, name);
//实例属性
this.age = 18
}
let child1 = new Child("yhd");
console.log(child1.info.name); // "yhd"
console.log(child1.age); // 18
let child2 = new Child("wxb");
console.log(child2.info.name); // "wxb"
console.log(child2.age); // 18在上面例子中,Parent构造函数接收一个name参数,并将他赋值给一个属性,在Child构造函数中调用Parent构造函数时传入这个参数, 实际上会在Child实例上定义name属性。为确保Parent构造函数不会覆盖Child定义的属性,可以在调用父类构造函数之后再给子类实例添加额外的属性。
- 优点:可以在子类构造函数中向父类传参数;父类的引用属性不会被共享。
- 缺点:子类不能访问父类原型上定义的方法(即不能访问Parent.prototype上定义的方法),因此所有方法属性都写在构造函数中,每次创建实例都会初始化。
组合继承
- 组合继承综合了原型链继承和盗用构造函数继承(构造函数继承),将两者的优点结合了起来,基本的思路就是使用原型链继承原型上的属性和方法,而通过构造函数继承实例属性,这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。
function Parent(name) {
this.name = name
this.colors = ["red", "blue", "yellow"]
}
Parent.prototype.sayName = function () {
console.log(this.name);
}
function Child(name, age) {
// 继承父类属性
Parent.call(this, name)
this.age = age;
}
// 继承父类方法
Child.prototype = new Parent();
Child.prototype.sayAge = function () {
console.log(this.age);
}
let child1 = new Child("yhd", 19);
child1.colors.push("pink");
console.log(child1.colors); // ["red", "blue", "yellow", "pink"]
child1.sayAge(); // 19
child1.sayName(); // "yhd"
let child2 = new Child("wxb", 30);
console.log(child2.colors); // ["red", "blue", "yellow"]
child2.sayAge(); // 30
child2.sayName(); // "wxb"
上面例子中,Parent构造函数定义了name,colors两个属性,接着又在他的原型上添加了个sayName()方法。Child构造函数内部调用了Parent构造函数,同时传入了name参数,同时Child.prototype也被赋值为Parent实例,然后又在他的原型上添加了个sayAge()方法。这样就可以创建 child1,child2两个实例,让这两个实例都有自己的属性,包括colors,同时还共享了父类的sayName方法。
- 优点:父类的方法可以复用;可以在Child构造函数中向Parent构造函数中传参;父类构造函数中的引用属性不会被共享。
原型式继承
- 对参数对象的一种浅复制
function objectCopy(obj) {
function Fun() { };
Fun.prototype = obj;
return new Fun()
}
let person = {
name: "yhd",
age: 18,
friends: ["jack", "tom", "rose"],
sayName:function() {
console.log(this.name);
}
}
let person1 = objectCopy(person);
person1.name = "wxb";
person1.friends.push("lily");
person1.sayName(); // wxb
let person2 = objectCopy(person);
person2.name = "gsr";
person2.friends.push("kobe");
person2.sayName(); // "gsr"
console.log(person.friends); // ["jack", "tom", "rose", "lily", "kobe"]- 优点:父类方法可复用。
- 缺点:父类的引用会被所有子类所共享;子类实例不能向父类传参。
寄生式继承
- 使用原型式继承对一个目标对象进行浅复制,增强这个浅复制的能力。
function objectCopy(obj) {
function Fun() { };
Fun.prototype = obj;
return new Fun();
}
function createAnother(original) {
let clone = objectCopy(original);
clone.getName = function () {
console.log(this.name);
};
return clone;
}
let person = {
name: "yhd",
friends: ["rose", "tom", "jack"]
}
let person1 = createAnother(person);
person1.friends.push("lily");
console.log(person1.friends);
person1.getName(); // yhd
let person2 = createAnother(person);
console.log(person2.friends); // ["rose", "tom", "jack", "lily"]寄生式组合继承
function objectCopy(obj) {
function Fun() { };
Fun.prototype = obj;
return new Fun();
}
function inheritPrototype(child, parent) {
let prototype = objectCopy(parent.prototype); // 创建对象
prototype.constructor = child; // 增强对象
Child.prototype = prototype; // 赋值对象
}
function Parent(name) {
this.name = name;
this.friends = ["rose", "lily", "tom"]
}
Parent.prototype.sayName = function () {
console.log(this.name);
}
function Child(name, age) {
Parent.call(this, name);
this.age = age;
}
inheritPrototype(Child, Parent);
Child.prototype.sayAge = function () {
console.log(this.age);
}
let child1 = new Child("yhd", 23);
child1.sayAge(); // 23
child1.sayName(); // yhd
child1.friends.push("jack");
console.log(child1.friends); // ["rose", "lily", "tom", "jack"]
let child2 = new Child("yl", 22)
child2.sayAge(); // 22
child2.sayName(); // yl
console.log(child2.friends); // ["rose", "lily", "tom"]- 优点:只调用一次父类构造函数;Child可以向Parent传参;父类方法可以复用;父类的引用属性不会被共享。
- 寄生式组合继承可以算是引用类型继承的最佳模式
垃圾回收机制
- 《JavaScript高级程序设计》提到两种垃圾回收机制
标记清除
- JavaScript 中最常用的垃圾收集方式是标记清除( mark-and-sweep)。当变量进入环境(例如,在函数中声明一个变量〉时,就将这个变量标记为“进入环境"。从逻辑上讲,永远不能释放进人环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。
引用计数
- 另外一种垃圾回收机制就是引用计数,这个用的相对较少。引用计数就是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型赋值给该变量时,则这个值的引用次数就是1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数就减1。当这个引用次数变为0时,说明这个变量已经没有价值,因此,在在机回收期下次再运行时,这个变量所占有的内存空间就会被释放出来。
- 这种方***引起循环引用的问题:例如: obj1和obj2通过属性进行相互引用,两个对象的引用次数都是2。当使用循环计数时,由于函数执行完后,两个对象都离开作用域,函数执行结束,obj1和obj2还将会继续存在,因此它们的引用次数永远不会是0,就会引起循环引用。
function fun() {
let obj1 = {};
let obj2 = {};
obj1.a = obj2; // obj1 引用 obj2
obj2.a = obj1; // obj2 引用 obj1
}这种情况下,就要手动释放变量占用的内存:
obj1.a = null obj2.a = null
哪些情况会导致内存泄漏
以下四种情况会造成内存的泄漏:
- 意外的全局变量: 由于使用未声明的变量,而意外的创建了一个全局变量,而使这个变量一直留在内存中无法被回收。
- 被遗忘的计时器或回调函数: 设置了 setInterval 定时器,而忘记取消它,如果循环函数有对外部变量的引用的话,那么这个变量会被一直留在内存中,而无法被回收。
- 脱离 DOM 的引用: 获取一个 DOM 元素的引用,而后面这个元素被删除,由于一直保留了对这个元素的引用,所以它也无法被回收。
- 闭包: 不合理的使用闭包,从而导致某些变量一直被留在内存当中。
事件流
- DOM (文档对象模型)结构是一个树型结构,当一个 HTML 元素产生一个事件时,该事件会在元素节点与根结点之间的路径传播,路径所经过的结点都会收到该事件,这个传播过程可称为 DOM 事件流。
- 传播按顺序分为三个阶段:捕获阶段、目标阶段、冒泡阶段
传播过程
捕获阶段
- 事件的处理将从 DOM 层次的根开始,而不是从触发事件的目标元素开始,事件被从目标元素的所有祖先元素依次往下传递。在这个过程中,事件会被从文档根到事件目标元素之间各个继承派生的元素所捕获,如果事件监听器在被注册时设置了 useCapture 属性为 true (默认为 falsy 值): element.addEventListener(eventType, fn, useCapture)那么它们可以被分派给这期间的任何元素以对事件做出处理;否则,事件会被接着传递给派生元素路径上的下一元素,直至目标元素。
目标阶段
- 事件到达目标元素后,进入当前目标阶段,执行函数代码,之后它会接着通过 DOM 节点再进行冒泡。
冒泡阶段
- 当事件在某一 DOM 元素被触发时,例如用户在客户名字节点上点击鼠标,事件将跟随着该节点继承自的各个父节点冒泡穿过整个的 DOM 节点层次,直到它遇到依附有该事件类型处理器的节点。在冒泡过程中的任何时候都可以终止事件的冒泡(调用事件的 stopPropagation 方法),如果不停止事件的传播,事件将一直通过 DOM 冒泡直至到达文档根。
事件委托
- 不给每个子节点单独设置事件监听器,而是设置在其父节点上,然后利用冒泡原理设置每个子节点。
事件委托的作用
- 只操作了一次 DOM,提高了程序的性能。
为什么要事件委托
- 在 JavaScript 中,添加到页面上的事件处理程序数量将直接关系到页面的整体运行性能,因为需要不断的操作 dom,那么引起浏览器重绘和回流的可能也就越多,页面交互的事件也就变的越长,这也就是为什么要减少 dom 操作的原因。每一个事件处理函数,都是一个对象,那么多一个事件处理函数,内存中就会被多占用一部分空间。如果要用事件委托,就会将所有的操作放到 js 程序里面,只对它的父级(如果只有一个父级)这一个对象进行操作,与 dom 的操作就只需要交互一次,这样就能大大的减少与 dom 的交互次数,提高性能。
事件委派应用
- 这里会出现常见的面试题,读者可跳过前方知识点,直接上题
给 ul 注册点击事件,然后利用事件对象的 target 来找到当前点击的 li,因为点击 li,事件会冒泡到 ul 上,ul 有注册事件,就会触发事件监听器,这里只操作了一次 DOM ,提高了程序的性能。
<body>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
<script>
// 事件委托的核心原理:给父节点添加侦听器, 利用事件冒泡影响每一个子节点
var ul = document.querySelector('ul');
ul.addEventListener('click', function(e) {
// alert('点我应有弹框!');
// e.target 这个可以得到我们点击的对象
e.target.style.backgroundColor = 'pink';
})
</script>
</body>这里面试官可能会继续出题,如果通过按钮新增一个新的li标签,如何监听事件
事件循环机制/Event Loop
- Event Loop 即事件循环,是指浏览器或 Node 的一种解决 javaScript 单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。
- 在 JavaScript 中,任务被分为两种,一种宏任务(MacroTask)也叫 Task,一种叫微任务(MicroTask)。
MacroTask(宏任务)
- script 全部代码、setTimeout、setInterval、setImmediate(浏览器暂时不支持,只有 IE10 支持,具体可见 MDN)、I/O、UI Rendering。
MicroTask(微任务)
- Process.nextTick(Node 独有)、Promise、Object.observe(废弃)、MutationObserver
浏览器中的 Event Loop
- Javascript 有一个 main thread 主线程和 call-stack 调用栈(执行栈),所有的任务都会被放到调用栈等待主线程执行。
- 事件循环中的异步队列有两种:宏任务队列可以有多个,微任务队列只有一个。
- 一开始执行栈空,我们可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。micro 队列空,macro 队列里有且只有一个 script 脚本(整体代码)。 全局上下文(script 标签)被推入执行栈,同步代码执行。在执行的过程中,会判断是同步任务还是异步任务,通过对一些接口的调用,可以产生新的 macro-task 与 micro-task,它们会分别被推入各自的任务队列里。同步代码执行完了,script 脚本会被移出 macro 队列,这个过程本质上是队列的 macro-task 的执行和出队的过程。 上一步我们出队的是一个 macro-task,这一步我们处理的是 micro-task。但需要注意的是:当 macro-task 出队时,任务是一个一个执行的;而 micro-task 出队时,任务是一队一队执行的。因此,我们处理 micro 队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。
- 执行渲染操作,更新界面
- 检查是否存在 Web worker 任务,如果有,则对其进行处理
- 上述过程循环往复,直到两个队列都清空
我们总结一下,每一次循环都是一个这样的过程:
- 当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。
node 中的事件循环的顺序
- timers 阶段:这个阶段执行 timer(setTimeout、setInterval)的回调
- I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
- idle, prepare 阶段:仅 node 内部使用
- poll 阶段:获取新的 I/O 事件, 适当的条件下 node 将阻塞在这里
- check 阶段:执行 setImmediate() 的回调
- close callbacks 阶段:执行 socket 的 close 事件回调
总结:浏览器环境下,microtask 的任务队列是每个 macrotask 执行完之后执行。而在 Node.js 中,microtask 会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask 队列的任务。
如题
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function () {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function () {
console.log('promise2')
})
}, 0)- 浏览器端运行结果:timer1=>promise1=>timer2=>promise2
- Node 端运行结果:timer1=>timer2=>promise1=>promise2
微任务和宏任务的区别
- 宏任务:DOM 渲染后触发, 如 setTimeout; 是由浏览器规定的
- 微任务:DOM 渲染前触发,如 Promise;是 ES6 规定的
分割线
- 有关JavaScript常见面试题就这么多啦,如果觉得还有哪些高频面试题可以私聊我,我再帮大家整理出来。再次,整理不易,期待你的点赞和收藏!
- 祝