通过例题学习JS的Event Loop(事件循环)

前言

Event Loop大家都有所耳闻,但是,由于我们日常业务中并不需要写那么复杂的异步任务,所以很多人对Event Loop都一知半解,今天就讨论一下。

Event Loop概念

Event loop是一个JS引擎执行模型,在不同的地方有不同的实现。浏览器和NodeJS基于不同的技术实现了各自的Event Loop。

微任务队列和宏任务队列

微队列,microtask,也叫jobs。 另一些异步任务的回调会依次进入micro task queue,等待后续被调用,这些异步任务包括:

  • process.nextTick (Node独有)
  • Promise的then的回调(注意,new Promise()的函数参数是同步代码,then的回调才是异步)
  • await下方的代码和赋值代码(注意,await后方的函数是同步代码)
  • Object.observe(草案已废弃,这里仅做记录)
  • MutationObserver

宏队列,macrotask,也叫tasks。 一些异步任务的回调会依次进入macro task queue,等待后续被调用,这些异步任务包括:

  • setTimeout的回调
  • setInterval的回调
  • setImmediate的回调
  • requestAnimationFrame (浏览器独有)
  • I/O
  • UI rendering (浏览器独有)

如果你致力于弄懂Event Loop,请背过这些任务名,至少要记住:then的回调、await下面的代码属于微队列,setTimeout的回调、setInterval的回调属于宏队列。要记牢。

调用栈

可以理解为JS引擎的工作台。任务会从宏任务队列或者微任务队列放到调用栈,然后再执行这些任务。

异步方法

setTimeout这些都是window的异步方法,注意,异步方法本身是同步执行的,也就是说,下面代码中,setTimeout是立即执行的,但是setTimeout的回调函数就不会立即执行,而是1秒之后执行。

console.log(1);
setTimeout(() => {}, 1000);
console.log(2);

JavaScript代码执行的具体流程

  1. 执行全局Script同步代码,这些同步代码有一些是同步语句(new Promise()的函数参数是同步代码),有一些是异步语句(比如setTimeout等);
  2. 异步语句的回调任务会根据上方介绍的规定,放入微任务队列或宏任务队列。
  3. 全局Script代码执行完毕后,调用栈Stack会清空;
  4. 从微队列(microtask queue)中取出位于队首的回调任务,放入调用栈Stack中执行,执行完后microtask queue长度减1;
  5. 继续取出位于微队列队首的任务,放入调用栈Stack中执行,以此类推,直到把微队列中的所有任务都执行完毕。注意,如果在执行微队列任务的过程中,如果又产生了新的微任务,那么会加入到微任务队列的末尾,也会在这个周期被调用执行;
  6. 微任务队列中的所有任务都执行完毕,此时微任务队列为空队列,调用栈Stack也为空;
  7. 取出宏队列(macrotask queue)中位于队首的任务,放入Stack中执行;
  8. 继续观察微队列是否有任务,如果有,则全部执行微队列任务;
  9. 重复7-8步骤;
  10. 宏队列执行完毕后,调用栈Stack为空;
  11. 重复第4-8个步骤;
  12. ......

通俗说:微队列就是牛逼,优先级就是高,每执行一串微队列,则执行一个宏队列任务,然后又去执行微队列,重复重复再重复下去。。。这就好比VIP通道跟普通通道,VIP全部优先,服务完了之后看看普通通道有没有人,有则服务一个,然后赶紧看看VIP是不是又来人了,没有的话再服务一个普通人,然后再看看VIP是不是又来人了。周而复始。

例题1

试着自己回答一下这道题,求打印顺序:

console.log(1);

setTimeout(() => {
  console.log(2);
  Promise.resolve().then(() => {
    console.log(3)
  });
});

new Promise((resolve, reject) => {
  console.log(4)
  resolve(5)
}).then((data) => {
  console.log(data);
})

setTimeout(() => {
  console.log(6);
})

console.log(7);

解答:

第1步,console.log(1);,打印1

位置 任务
调用栈 console.log(1);
微任务
宏任务

第2步,setTimeout,不打印

位置 任务
调用栈 setTimeout
微任务
宏任务 setTimeout的回调

第3步,new Promise的函数参数,注意,Promise的函数参数是同步执行的,打印4

位置 任务
调用栈 new Promise的函数参数
微任务 new Promise的then的回调
宏任务 setTimeout 的回调

第4步,第二个setTimeout,不打印

位置 任务
调用栈 第二个setTimeout
微任务 new Promise的then的回调
宏任务 setTimeout 的回调,第二个setTimeout的回调

第5步,console.log(7);,打印7

位置 任务
调用栈 console.log(7);
微任务 new Promise的then的回调
宏任务 setTimeout 的回调,第二个setTimeout的回调

第6步,第一个微任务,即new Promise的then的回调,打印5

位置 任务
调用栈 new Promise的then的回调
微任务
宏任务 setTimeout的回调,第二个setTimeout的回调

第7步,第一个宏任务,即setTimeout的回调,打印2,同时,Promise.resolve().then()的回调进入微队列

位置 任务
调用栈 setTimeout的回调
微任务 Promise.resolve().then()的回调
宏任务 第二个setTimeout的回调

第8步,第一个微任务,即Promise.resolve().then()的回调,打印3

位置 任务
调用栈 Promise.resolve().then()的回调
微任务
宏任务 第二个setTimeout的回调

第9步,第一个宏任务,即第二个setTimeout的回调,打印6

位置 任务
调用栈 第二个setTimeout的回调
微任务
宏任务

是不是跟你演算的一样呢?

例题2

考察then方法链的执行顺序:

new Promise((resolve, reject) => {
  console.log(1)
  resolve(2)
}).then((data) => {
  // 1号回调
  console.log(data);
  return 3
}).then((data) => {
  // 2号回调
  console.log(data);
})

new Promise((resolve, reject) => {
  console.log(5)
  resolve(6)
}).then((data) => {
  // 3号回调
  console.log(data);
  return 7;
}).then((data) => {
  // 4号回调
  console.log(data);
})

你以为会打印1 2 3 5 6 7吗?错!你以为会打印1 5 2 3 6 7吗?也错!

  1. new Promise的函数参数是同步代码,所以先打印1。同时将第一个then的回调(1号回调)放入微队列。

  2. 同理,打印5,将第二个new Promise的第一个then的回调(3号回调)放入微队列。

  3. 执行1号回调,打印2,将2号回调放入微队列。

  4. 执行3号回调,打印6,将4号回调放入微队列。

  5. 执行2号回调,打印3。

  6. 执行4号回调,打印7。

所以结果是1 5 2 6 3 7,你演算对了吗?

例题3

有如下代码:

setTimeout(() => {
  console.log(1)
  setTimeout(() => {
    console.log(2)
  })
})

不许修改这段代码,只允许在外层作用域添加代码,如何实现在打印1跟打印2中间插入打印3

其实这个题跟例题2类似,考察的也是基本知识:

setTimeout(() => {
  console.log(1)
  setTimeout(() => {
    console.log(2)
  })
})

setTimeout(() => {
  console.log(3)
  setTimeout(() => {
    console.log(4)
  })
})

结果:打印 1 3 2 4。

原因:同例题2。

例题4

这次考察对async/await的执行顺序的理解:

console.log('script start');

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

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

async1()

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

new Promise((resolve, reject) => {
    console.log('promise start');
    resolve()
})
.then(() => console.log('promise end'))

console.log('script end')
  1. 打印script start

  2. 执行async1()async1的第一行是await async2(),这一句应拆开考察,其中async2()会同步执行,同时把await下方的所有代码放到微队列。

  3. 执行setTimeout,不打印。同时把回调放入宏队列。

  4. 执行new Promise的函数参数,打印promise start,同时把then的回调放入微队列 ,此时,微队列有2块代码,分别是await下面的代码(即console.log('async1 end');),以及console.log('promise end')

  5. console.log('script end'),打印script end

  6. 执行微队列第一个,也就是打印async1 end

  7. 执行微队列第二个,也就是打印promise end

  8. 执行宏任务,也就是打印setTimeout

注意:低版本的Chrome浏览器(大约是70版本之前)会先打印promise end,后打印async1 end,原因我忘却了,大致是Chrome的早期实现里有Bug,其实原因也不重要,既然高版本的浏览器纠正归来了,就行了。

例题5

证明await的赋值操作是异步任务:

let x = 5;
let y;
function ret() {
    x += 1;
    console.log('x是', x);
    console.log('y是', y);
    return x;
}
async function a() {
    y = await ret();
    console.log(y);
}
async function b() {
    y = await ret();
    console.log(y);
}

a()
b()

得到:

x是 6
y是 undefined
x是 7
y是 undefined
6
7

我们用反证法,假定await赋值是同步操作,那么a()y = await ret()会同步执行,之后才执行b()ret(),此时y有值,不应该是undefined,但事实是undefined,说明await赋值是异步操作。

例题6

证明new Promise()的函数参数和await后面的函数是同步任务,证明很简单:

var i;
for (i = 0; i < 20; i++) {
  new Promise(resolve => {
    console.log(i)
  })
}

也是反证法,假如new Promise()的函数参数是异步任务,那么应该像setTimeout一样打印20个20,然而事实上会打印等差数列。

注意,不要用for(let i = 0;)这种写法,因为这会形成一个特殊作用域,不能反证出我们的结论。

function log(x) { console.log(x) }
async function a(i) {
  await log(i)
}
var i;
for (i = 0; i < 20; i++) {
    a(i);
}
function log(x) { console.log(x) }
var i;
for (i = 0; i < 20; i++) {
    async function a() {
        await log(i)
    }
    a();
}

同理,因为上面2段代码的结果也都是等差数列,所以await后面的语句是同步任务。

例题7

证明await会暂停for循环:

var i;
function ret() {
    console.log('ret里的i', i);
    return 100;
}
async function a() {
    for (i = 0; i < 20; i++) {
        await ret();
        console.log(i);
    }
}
a();

得到等差数列。

虽然微队列放入了20个任务,但是由于await会暂停循环,也就是说i并不会像setTimeout时一样自顾自的增长为20,而是会等待每一个微任务执行完毕,由此证明await会暂停for循环。

相反,setTimeout不会阻止for循环:

var i;
function ret() {
    console.log('ret里的i', i);
    return 100;
}
async function a() {
    for (i = 0; i < 20; i++) {
        setTimeout(() => {
            ret();
            console.log(i);
        });
    }
}
a();

总结

  1. 随时遇到异步,随时放入队列,这是起码的准则。

  2. 先同步任务,同步任务有异步回调的时候,根据规则放入队列。

  3. 同步任务都完成了就执行微队列。有异步则继续放入队列。

  4. 微队列都完成了就执行宏队列第一个。有异步则继续放入队列。

  5. 观察微队列,有则执行,没则执行宏队列第二个。

  6. 重复3/4/5步骤。

你可能感兴趣的:(通过例题学习JS的Event Loop(事件循环))