liubinis86 / blog

Some experience summary
9 stars 1 forks source link

浏览器中的事件循环 #1

Open liubinis86 opened 6 years ago

liubinis86 commented 6 years ago

EVENT LOOP in Browser

讲到 javascript 的事件循环。首先,必须知道的是 javascript 是一门单线程的语言。因此,javascript 是没有真正意义上的异步任务的。javascript 所有的异步任务都是通过同步去模拟实现的。 虽然 H5 新标准中的 web worker 充分利用了多核 CPU 的计算能力,被允许 javascript 创建多个线程,但是子线程是完全受到主线程控制的,且在 web worker 中无法访问到 window,document 等对象。因此,javascript 还是没有改变单线程的本质。

为什么 JavaScript 是单线程?

此处引入一段阮一峰的话:

JavaScript 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什 JavaScript 不能有多个线程呢?这样能提高效率啊。JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

事件循环

单线程就意味着所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就得一直等着,直到前一个任务执行完才会执行。为了解决这个问题,js 有了同步任务(synchronous)与异步任务(asynchronous)之分。

1. 同步任务与异步任务:

同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。如图 任务队列

console.log(1); // 同步任务
setTimeout(() => {
    console.log(2);
}, 0); // 异步任务
ele.addEventListener("click", () => {
    console.log(3);
}); // 回调也属于异步任务,ele触发点击的时候才会执行
console.log(4); // 同步任务
ele.click(); // 此处模拟点击的话,其实是直接把点击事件的回调函数拿来执行了。相当于同步任务,只有当用户在页面主动触发的时候,才是按照异步的流程走
console.log(5); // 同步任务
// log 1 4 3 5 2

代码执行的顺序:

  1. 执行console.log(1)
  2. 生成定时器,将其回调函数加入到event table中,delay 为 0,意味着马上将其回调函数马上加入到event queue中。
  3. 为 ele 元素绑定了点击事件,将其回调函数事件加入到event table中。
  4. 执行console.log(4)
  5. 触发 ele 元素的点击事件,将其回调函数加入到event queue中。
  6. 主线程空闲,去寻找任务队列中 ele 元素的回调事件,立即执行console.log(3)
  7. 主线程空闲,去寻找任务队列中是否有可执行的任务,发现定时器的回调函数,执行 console.log(2)

关于定时器,定时器可以设置执行的时间间隔。然而,有些时候并不能直接完美的按照我们设置的执行间隔来运行。因为定时器的回调函数是放到任务队列里面的,只有当主线程空闲的时候才会去执行任务队列里面的任务。举个例子:

// 假如此处的doSomething执行需要30ms的时间。那么定时器中的回调得等到30ms后才会触发,设定的20ms的delay是不生效的。
setTimeout(() => {
    console.log(1);
}, 20);
doSomething();

setInterval虽说是每隔一段时间就执行一次,但是如果其回调函数执行的时间超过了设定的时间间隔,实际上是无法实现每隔一段时间就执行一次的。举个例子:

let start = new Date().getTime();
setInterval(() => {
    for (let i = 0; i < 10000; i++) {
        const pEle = document.createElement("p");
        pEle.innerText = i;
        document.body.appendChild(pEle);
        if (i === 0) {
            const end = new Date().getTime();
            console.log(end - start); // 22 384 209
            start = end;
        }
    }
}, 20);
// 虽然设定了20ms的delay.但是每次执行到log的实际间隔是大于20ms的

因此,不到万不得已,不建议在日常开发中使用setTimtoutsetInterval。若是做动画效果,可以使用requestAnimationFrame(callback),requestAnimationFrame 每 16ms(即 1000/60)执行一次,与浏览器显示页面的刷新频率一致,是性能最好的一个定时 API。

2.宏观任务(macro-task)与微观任务(micro-task):

js 代码又可以分为宏观任务(整体代码、定时器)与微观任务(promise 与 node.js 中专有的 proces.nextTick); js 的执行顺序是,一开始先执行宏观任务,然后再查看微观任务队列中是否有任务可执行,若有则执行,执行结束后开始新的宏观任务,若无则直接开始新的宏观任务,循环往复如此。如图: 事件循环:宏观任务与微观任务 需要值得注意的是,异步的微观任务执行是优先于异步的宏观任务的。举个例子:

console.log(1);
setTimeout(() => {
    console.log(2);
}, 0);
new Promise(resolve => {
    console.log(3);
    resolve();
}).then(() => {
    console.log(4);
});
console.log(5);
// log 1 3 5 4 2

代码执行的顺序:

  1. 执行console.log(1)
  2. 生成定时器,将其回调函数加入到event table中,delay 为 0,意味着马上将其回调函数马上加入到宏观任务队列中。
  3. 初始化 promise.且执行里面的代码,执行console.log(3),将后续操作的代码(此处的.then)加入到event table中。当promise内的异步代码执行完毕后,会将后续操作的代码从event table中移除,加入到微观任务队列中。
  4. 执行console.log(5)
  5. 主线程宏观任务执行完毕,查看微观任务队列是否有可执行的任务,发现 promise 的 then。执行console.log(4)
  6. 微观任务执行完毕,查看宏观任务队列是否有可执行的任务,发现定时器的回调函数。执行console.log(2)

再举个例子:

Tips:async函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
async function foo() {
console.log(3);
await setTimeout(() => {
console.log(4);
}, 20);
console.log(5);
}
foo();
console.log(6);
// log 1 3 6 5 2 4

代码执行的顺序:

  1. 执行console.log(1)
  2. 生成定时器,将其回调函数加入到 event table 中,delay 为 0,意味着马上将其回调函数马上加入到event queue中。
  3. 执行 foo 函数。执行console.log(3)。遇到 await,执行 await 的函数,且将后面的代码加入到event table中,等到 await 异步执行完毕后,将后面的代码加入到微观任务的event queue中。foo 函数暂停。
  4. 执行console.log(6)
  5. 主线程宏观任务执行完毕,查看微观任务队列是否有可执行的任务,发现 foo 函数对应的代码块。执行console.log(5)
  6. 微观任务执行完毕,查看宏观任务队列是否有可执行的宏观任务,发现定时器的回调函数。执行console.log(2)console.log(4)

结语:

日常开发中理解了本文中所讲的浏览器中的事件循环就足够了。往深了研究还可以研究一堆东西。 布置个课后作业吧,判断下面的代码执行顺序,再去浏览器中执行,看看自己是否真的理解了吧!

console.log(1);
setTimeout(() => {
    console.log(2);
}, 0);
new Promise(resolve => {
    console.log(3);
    resolve();
}).then(() => {
    console.log(4);
});
async function foo() {
    console.log(5);
    await bar();
    console.log(6);
}
function bar() {
    console.log(7);
    setTimeout(() => {
        console.log(8);
    }, 0);
}
console.log(9);
foo();

本文参考:

junyo commented 6 years ago

写得很好啊,我也没有系统学习过这个,之前都是一知半解