1. 同步模式异步模式

想要了解事件循环,首先要了解JavaScript的同步模式和异步模式。

众所周知,目前主流的javaScript环境都是以单线程的模式执行javaScript代码。javaScript采用单线程工作的原因与他最早的设计初衷有关。

最早javaScript这门语言单是一门运行在浏览器端的脚本语言,他的目的是为了实现页面上的动态交互。而实现页面交互的核心就是dom操作,这也就决定了他必须使用单线程模型,否则就会出现很复杂的线程同步问题。

可以设想一下,假定在javaScript中同时有多个线程工作,其中一个线程修改了某一个dom元素,而另外一个线程同时又删除了这个元素,此时浏览器就无法明确该以哪一个线程的工作结果为准。

为了避免这种线程同步的问题,从一开始javaScript就被设计成了单线程模式工作,这也就成为了这门语言最为核心的特性之一。

这里所说的单线程指的是在js的执行环境当中,负责执行代码的线程只有一个。

你可以想象成,在内部只有一个人按照代码去执行任务。因为只有一个人所以同时也就只能执行一个任务,如果有多个任务的话就必须要排队,然后一个任务一个任务的依次去完成。

这种模式最大的优点是安全简单,缺点也同样明显,如果遇到特别耗时的任务,后面的这些任务都必须要排队,等待这个任务的结束。

console.log('foo');

for (let i = 0; i < 100000; i++) {
    console.log('耗时操作');
}

console.log('等待耗时操作结束');

这也就导致整个程序的执行会被拖延,出现假死的情况。为了解决耗时任务阻塞执行的问题,javaScript语言将任务的执行模式分成了两种。同步模式(Synchronous)和异步模式(Asynchronous)。

这里就了解了JS在执行的时候是分为同步任务和异步任务。上面的循环例子实际上并不准确,一般异步任务指的都是ajax请求或者定时器。

2. 事件循环

在事件循环中有两个比较重要的概念,宏任务和微任务。宏任务和微任务都是指代异步任务,这一点要搞清楚。

JavaScript是自上而下执行的,在执行过程中涉及到执行栈和任务队列两个东西。执行中的代码会放在执行栈中执行,宏任务和微任务会放在任务队列中"等待"执行。

比如下面的一段代码,js自上而下执行,首先声明变量name并且赋值为yd,然后执行setTimeout定时器,由于setTimeout是一个异步任务,所以setTimeout中的函数会延时执行,这里就会将这个定时器中的函数放入到任务队列中等待1s

等待只是定时器中的函数在等待,余下代码继续向下执行,打印出name的值,由于此时异步函数还没有执行,所以打印出来的值仍然是yd

1s之后,浏览器中挂载的定时器到了执行时机并且开始触发,就会将任务队列中的setTimeout中的函数放入到执行栈中执行name='zd'操作。

let name = 'yd';

setTimeout(function() {
    name = 'zd';
}, 1000);

console.log(name);

上面代码的执行机制比较简单,js首先自上而下执行,当遇到异步任务会将任务加入到任务队列当中,等到当前js栈执行完毕,再去检查任务队列中是否存在可以被执行的任务,如果存在就把任务从队列中取出来放入到执行栈中执行。

3. 宏任务

浏览器为了能够使js的内部task(任务)DOM任务有序的执行,会在前一个task执行完毕后并且在下一个task执行开始前,对页面进行重新渲染(render),这里说的task就是指宏任务。

task -> rander -> task

浏览器中宏任务一般包括:

1. setTimeout, setInterval

定时器大家都知道他的作用和用法,这里就不举例了。

2. MessageChannel

消息通道, 兼容性不太好,实例如下。

const channel = new MessageChannel();
// 在端口号上添加消息, 发送消息
channel.port1.postMessage('我爱你');
// 注册接收事件, 后绑定的接收函数,还是可以接收的到,所以可以看出是异步执行
channel.post2.onmessage = function(e) {
    console.log(e.data);
};
console.log('hello'); // 先走的hello,后出现我爱你.

3. postMessage

消息通信机制,也不过多介绍了。

4. setImmediate

立即执行定时器,不可以设置时间, 只在IE浏览器中实现了。

setImmediate(function() {
    console.log('立即执行定时器,不可以设置时间')
})

以上几种就是常见的宏任务,其实宏任务中还包含点击事件等机制。

4. 微任务

微任务通常来说就是在当前task执行结束后立即执行的任务,比如对一系列动作做出反馈,或者是需要异步的执行任务但是又不需要分配一个新的task,这样可以减小一点性能的开销。

只要执行栈中没有其他JS代码正在执行或者每个宏任务执行完,微任务队列会立即执行。

如果在微任务执行期间微任务队列中加入了新的微任务,就会把这个新的微任务加入到队列的尾部,之后也会被执行。

微任务包括:

1. promise.then,

Promisethen方法就是一个微任务。

2. async await。

async函数的await之后的内容也是以微任务的形式来执行。

3. MutationObserver

MutationObserver的作用是监控dom变化,dom变化了就会执行, 时间节点是等待所有代码都执行完,才执行该监控

const observer = new MutationObserver(() => {
    console.log('节点已经更新');
    console.log(document.getElementById('app').children.length);
});
observer.observe(document.getElementById('app'), {
    'childList': true,
});
for (let i = 0; i < 20; i++) {
    document.getElementById('app').appendChild(document.createElement('p'));
}
for (let i = 0; i < 20; i++) {
    document.getElementById('app').appendChild(document.createElement('span'));
}

5. EventLoop

通过下面代码的执行顺序来说明白事件循环。

setTimeout(() => {
    console.log('timeout');
}, 0);

Promise.resolve().then(data => {
    console.log('then');
});

console.log('start');

首先js代码是自上而下开始执行,首先遇到setTimeout会立即被执行,但他的执行结果会产生一个异步宏任务,放入到宏任务队列中,等待一定的时间后执行,这里设置的0秒,但是0秒也不会立即执行,因为任务队列是一定要等到当前执行栈执行完毕才会考虑执行的。

接着代码执行到Promise.resolve().then这里,这句代码并不是任务代码所以会立即被执行,不过Promise.then会产生一个微任务放入到微任务队列当中等待主执行栈执行完毕执行。

代码继续向下执行console.log('start'),打印出start,执行栈执行完毕。

这时宏任务队列中存在console.log('timeout');因为定时器时间为0所以已经到了执行的时机,微任务队列中console.log('then');也到了执行时机,那他们谁先被执行呢?

JavaScript执行机制很简单,主栈执行完成之后,会执行微任务队列,先进入的微任务先执行,所有微任务执行完毕后,也就是微任务队列被清空之后再开始检查宏任务队列。将需要执行的宏任务执行掉。

所以这里会先打印出then,再打印出timeout

总结一句话就是: 先执行同步代码,再执行微任务,再检查宏任务是否到达时间,到达时间再执行。

主执行栈执行完毕之后会清空微任务队列,也就是所有的微任务全部被执行,那如果多个宏任务到达执行时机会如何执行呢?比如下面的代码。

setTimeout首先创建了一个宏任务,宏任务中又创建了一个Promise.resolve().then微任务。然后接着Promise.resolve().then又创建了一个宏任务。来看一下这段打印顺序如何。

setTimeout(() => {
    console.log('timeout1');
    Promise.resolve().then(data => {
        console.log('then1');
    });
}, 0);

Promise.resolve().then(data => {
    console.log('then2');
    setTimeout(() => {
        console.log('timeout2');
    }, 0);
});

首先setTimeout执行结束后创建了一个宏任务,放入到宏任务队列中。这个任务并没有执行,所以内部的Promise也不会执行,代码继续向下。

执行到下面的Promise创建了一个微任务,放入到微任务队列中。

// setTimeout(() => {
    console.log('timeout1');
    Promise.resolve().then(data => {
        console.log('then1');
    });
// }, 0);

// Promise.resolve().then(data => {
    console.log('then2');
    setTimeout(() => {
        console.log('timeout2');
    }, 0);
// });

此时宏任务队列中存在一个宏任务,微任务队列中存在一个微任务,这两个任务都到了执行时机。前面说过主执行栈执行完毕会先清空微任务,所以会将微任务拿到执行栈中执行。这里会打印then2,然后执行setTimeout生成一个新的宏任务,加入到宏任务队列中。微任务执行完毕。

此时宏任务队列中存在两个任务,由于定时器时间都是0,所以他们都到了执行时机。队列的机制是先加入的先执行,所以这里会将第一个加入的任务也就是上面的setTimeout拿到执行栈中执行,会打印timeout1,然后又创建了一个Promise.then的微任务。

这时宏任务队列中存在一个console.log('timeout2');任务,微任务队列中存在一个console.log('then1');任务。

根据前面的经验可知,执行栈执行完毕之后,会清空微任务队列,所以这里并不会继续执行第二个宏任务,而是再次清空微任务队列。打印then1。微任务执行完毕之后,再去宏任务中拿出需要执行的宏任务放入执行栈中执行,打印timeout2

所以上面代码的打印顺序是 then2 -> timeout1 -> then1 -> timeout2

事件循环的执行顺序说起来也比较简单。首先JavaScript代码从上到下执行每遇到定时器等宏任务会将任务放在宏任务队列中,遇到Promise.then等微任务会将任务放入到微任务队列中。等到主执行栈中的代码执行完毕,会清空微任务队列,先加入的先执行后加入的后执行,然后再去检查宏任务队列,将可执行的宏任务拿到执行栈中执行,每次只取出一个宏任务,执行完毕再次清空微任务队列,清空完毕再去检查宏任务队列,以此类推。

6. 事件循环面试题

1. 简单

const p = new Promise(function(resolve, reject){
    reject();
    resolve();
});
p.then(function() {
    console.log('成功');
}, function() {
    console.log('失败');
});

// 失败

只会打印失败,因为Promise的状态只会变化一次。

2. 入门

const promise = new Promise((resolve, reject) => {
    console.log(1);
    resolve();
    console.log(2);
});
promise.then(() => {
    console.log(3);
});

// 1, 2, 3

new Promise传入的函数是同步代码,立刻就会被执行,所以会打印出12Promise.then是微任务,当代码自行结束,会清空微任务队列,打印出3

3. 进阶

Promise.resolve(1)
.then(res => 2)
.catch(err => 3)
.then(res => console.log(res));

// 2

因为返回的是resolve,所以会走入成功,成功的then又返回了2,会走后面的then而不是catch,后面的then会打印出前面返回的2

4. 复杂

Promise.resolve(1)
.then((x) => x + 1)
.then(x => { throw new Error('My Error')})
.catch(() => 1)
.then(x => x + 1)
.then(x => console.log(x))
.catch(console.error);

// 2

首先then中抛出异常,会走入catchcatch中有正常的返回值,会进入到后面的thenthen中又返回了x+1也就是2,走入下一个then,然后中输出2,这个then中并没有抛错,所以不会走入最后的catch

5. 深入

setTimeout(function() {
    console.log(1);
}, 0);

new Promise(function(resolve) {
    console.log(2);
    for (var i = 0; i < 10; i++) {
        i == 9 && resolve();
    }
    console.log(3);
}).then(function() {
    console.log(4);
});

console.log(5); 

// 2, 3, 5, 4, 1

第一行的setTimeout会创建一个宏任务,放入宏任务队列中;new Promise中的函数是同步代码立即会被执行,打印23,同时修改了Promise的状态(意味着执行栈结束后对应的微任务就可以立即执行了)。

Promise.then创建了微任务,放入到微任务队列中。

代码执行到到最后一行打印了数字5,执行栈执行完毕。接着就要清空微任务队列,微任务队列中会打印数字4,微任务执行结束后,宏任务开始执行,打印数字1,所以打印结果是2, 3, 5, 4, 1

6. 贯通

async function async1() {
    console.log('async1 start');
    await async2();
};

async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0);

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});

console.log('script end2');

// script start,async1 start,async2, promise1,script end2,promise2, setTimeout

首先前两段代码创建了函数,创建函数不等于函数执行,需要等待调用的时候才会执行,这里是个迷惑,不要踩坑。

然后console.log('script start');打印了script start,所以首先被打印的是script start

接着setTimeout创建了一个宏任务setTimeout放入到宏任务队列中,时间是0,表示立即可以执行。

再然后调用async1函数,会打印 async1 start,然后await async2相当于new Promise传入的函数,也会直接执行打印async2

紧接着new Promise会直接执行function中的代码打印promise1,并且Promise状态改为resolve

Promise.then会创建一个微任务promise2放入到微任务队列中。

最后一行直接打印script end2。执行栈结束,开始清空微任务队列,打印promise2,清空之后执行宏任务队列打印setTimeout

这里比较有迷惑的是asyncawait,其实说起来也简单,asyncawait就是Promise的语法糖,Promise.then会创建微任务,那么async函数在什么时候会创建微任务呢?

async函数中的await的函数相当于Promise实例化时传入的那个函数,会立即被执行。await那一行下面的代码会作为微任务放入到微任务队列中。

我们知道Promise.then需要等到Promise传入的函数执行了resolve或者reject后才会进入执行序列。同理await后面的代码也需要等到await的那个函数执行之后才会进入执行序列。语法糖只是写法不同,原理还是相同的。

我们将这道题简单改造来深入理解一下。

async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2')
}

console.log('script start')
setTimeout(function() {
  console.log('setTimeout')
}, 0)

async1(); 

new Promise( function( resolve ) {
 console.log('promise1')
 resolve();
}).then( function() {
 console.log('promise2')
})

首先前两个函数同样是创建了两个函数,然后console.log('script start')在执行时会打印script start

接着setTimeout创建了一个可以被执行的宏任务。

async1()调用了函数async1,首先打印async1 start,然后await async2(),这相当于new Promise传入的函数,会立即执行,所以打印async2

await下面的代码console.log('async1 end')会被作为微任务放入到微任务队列中。因为async2已经执行完了,所以这个微任务也是一个可以被执行的微任务。

这样async1函数执行完毕,继续向下new Promise会打印promise1,并且修改Promise状态,再次创建一个可以被执行的微任务。

至此执行栈执行完毕,此时宏任务队里存在一个setTimeout任务,微任务队列存在async1 endpromise2的两个微任务。

清空微任务队列,根据队列先进先出的原则,先打印async1 end再打印promise2。最后执行宏任务队列,打印setTimeout

所以输出结果为 script start -> async1 start -> async2 -> promise1 -> async1 end -> promise2 -> setTimeout

转载须知

如转载必须标明文章出处文章名称文章作者,格式如下:

转自:【致前端 - zhiqianduan.com】 浏览器事件循环  "隐冬"
请输入评论...