JavaScript 事件循环

事件循环

JavaScript

引擎的一般算法:

  1. 当有任务时:从最先进入的任务开始执行。
  2. 休眠直到出现任务,然后转到第 1 步。
JavaScript

任务示例:

设置任务 —— 引擎处理它们 —— 然后等待更多任务(即休眠,几乎不消耗 CPU 资源)。

一个任务到来时,引擎可能正处于繁忙状态,那么这个任务就会被排入队列。

多个任务组成了一个队列,即所谓的“宏任务队列”(v8 术语):

scriptmousemovesetTimeout
scriptmousemovesetTimeout

例如:

引擎执行任务时永远不会进行渲染(render)。如果任务执行需要很长一段时间也没关系。仅在任务完成后才会绘制对 DOM 的更改。

如果一项任务执行花费的时间过长,浏览器将无法执行其他任务,例如处理用户事件。因此,在一定时间后,浏览器会抛出一个如“页面未响应”之类的警报,建议你终止这个任务。这种情况常发生在有大量复杂的计算或导致死循环的程序错误时。

任务队列

JavaScriptMacrotask Queue(Task Queue) 宏任务Microtask Queue 微任务
Macrotask
setTimeoutsetIntervalsetImmediateI/O
Microtask
Promiseprocess.nextTickObject.observe

那么,两者有什么具体的区别呢?或者说,如果两种任务同时出现的话,应该选择哪一个呢?

每个宏任务之后,引擎会立即执行微任务队列中的所有任务,然后再执行其他的宏任务,或渲染,或进行其他任何操作。

其实事件循环执行流程如下:

MacrotaskMacrotaskMicrotaskMicrotaskmicrotaskmacrotask
MacrotaskMicrotask

我们先来看一段代码:

 console.log(1)
setTimeout(function() {
//settimeout1
console.log(2)
}, 0);
const intervalId = setInterval(function() {
//setinterval1
console.log(3)
}, 0)
setTimeout(function() {
//settimeout2
console.log(10)
new Promise(function(resolve) {
//promise1
console.log(11)
resolve()
})
.then(function() {
console.log(12)
})
.then(function() {
console.log(13)
clearInterval(intervalId)
})
}, 0); //promise2
Promise.resolve()
.then(function() {
console.log(7)
})
.then(function() {
console.log(8)
})
console.log(9)

你觉得结果应该是什么呢?

chrome
1
9
7
8
2
3
10
11
12
13

在上面的例子中

  • 第一次事件循环:
console.log(1)1settimeout1macrotasksetinterval1macrotassettimeout2macrotaskpromise2thenmicrotaskconsole.log(9)9microtaskconsole.log(7)console.log(8)78microtaskmacrotasksettimeout1setinterval1settimeout2
  • 第二次事件循环:
macrotasksettimeout12microtaskmacrotasksetinterval1settimeout2
  • 第三次事件循环:
macrotasksetinterval13setinterval1macrotaskmicrotaskmacrotasksettimeout2setinterval1
  • 第四次事件循环:
macrotasksettimeout210new Promisenew Promise11thenmicrotaskmicrotaskmicrotask1213setinterval1
microtaskmacrotaskmacrotaskmacrotaskmicrotask
因为一开始js主线程中跑的任务就是macrotask任务macrotaskmicrotask
microtaskmicrotaskmicrotaskmicrotaskmacrotaskUI渲染/IO操作/ajax请求nodejsprocess.nexttick

async/await 又是如何处理的呢 ?

大家看看这段代码在浏览器上的输出是什么?

async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
async/await
async/awaitPromiseGeneratorPromise
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
// 其实就是
async function async1() {
console.log('async1 start');
Promise.resolve(async2()).then(()=>console.log('async1 end'))
}

那我们再看看转换后的整体代码

async function async1() {
console.log('async1 start');
Promise.resolve(async2()).then(()=>console.log('async1 end'))
}
async function async2() {
console.log('async2');
}
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');

这下就很明白了吧,输出的结果如下:

/**
* async1 start
* async2
* promise1
* script end
* async1 end
* promise2
* */

定时器问题

setTimeout(task, 100)100

我们看这个栗子

const s = new Date().getSeconds();
setTimeout(function() {
// 输出 "2",表示回调函数并没有在 500 毫秒之后立即执行
console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
}, 500); while(true) {
if(new Date().getSeconds() - s >= 2) {
console.log("Good, looped for 2 seconds");
break;
}
}
setTimeout50022macrotasksetTimeout
setTimeout(task,100)100macrotaskmicrotasksetTimeout

阻塞还是非阻塞

关于 js 阻塞还是非阻塞的问题我们先理解下同步、异步、阻塞还是非阻塞的解释,在网上看到一段描述的非常好,引用下

我要看足球比赛,但是妈妈叫我烧水,电视机在客厅,烧水要在厨房。家里有2个水壶,一个是普通的水壶,另一个是水开了会叫的那种水壶。我可以:

  1. 用普通的水壶烧,人在边上看着,水开了再去看球。**(同步,阻塞)**这个是常规做法,但是我看球不爽了。
  2. 用普通水壶烧,人去看球,隔几分钟去厨房看看。**(同步,非阻塞)**这个又大问题,万一在我离开的几分钟水开了,我就麻烦了。
  3. 用会叫的水壶,人在边上看着。**(异步,阻塞)**这个没有问题,但是我太傻了。
  4. 用会叫的水壶,人去看球,听见水壶叫了再去看。**(异步,非阻塞)**这个应该是最好的。

等着看球的我:阻塞

看着电视的我:非阻塞

普通水壶:同步

会叫的水壶:异步

所以,异步往往配合非阻塞,才能发挥出威力。

js 核心还是同步阻塞的,比如看这段代码

while (true) {
if (new Date().getSeconds() - s >= 2) {
console.log("Good, looped for 2 seconds");
break;
}
}
console.log('end')
console.log('end')while
jsnodejs事件循环任务队列libuvjs

实际应用案例

拆分 CPU 过载任务

假设我们有一个 CPU 过载任务。

CPU
DOM
100setTimeout100
11000000000
JS
let i = 0;
let start = Date.now();
function count() {
// 做一个繁重的任务
for (let j = 0; j < 1e9; j++) {
i++;
} alert("Done in " + (Date.now() - start) + 'ms');
} count();

浏览器甚至可能会显示一个“脚本执行时间过长”的警告。

setTimeout
let i = 0;
let start = Date.now();
function count() {
// 做繁重的任务的一部分 (*)
do {
i++;
} while (i % 1e6 != 0); if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
} else {
setTimeout(count); // 安排(schedule)新的调用 (**)
} } count();

现在,浏览器界面在“计数”过程中可以正常使用。

count(*)(**)
i=1...1000000i=1000001..2000000
onclickcountJavaScript
setTimeout

为了使两者耗时更接近,让我们来做一个改进。

schedulingcount()
let i = 0;
let start = Date.now();
function count() {
// 将调度(scheduling)移动到开头
if (i < 1e9 - 1e6) {
setTimeout(count); // 安排(schedule)新的调用
} do {
i++;
} while (i % 1e6 != 0); if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
} } count();
count()count()

如果你运行它,你很容易注意到它花费的时间明显减少了。

为什么?

setTimeout4ms04ms

最后,我们将一个繁重的任务拆分成了几部分,现在它不会阻塞用户界面了。而且其总耗时并不会长很多。

进度指示

对浏览器脚本中的过载型任务进行拆分的另一个好处是,我们可以显示进度指示。

DOM

从一方面讲,这非常好,因为我们的函数可能会创建很多元素,将它们一个接一个地插入到文档中,并更改其样式 —— 访问者不会看到任何未完成的“中间态”内容。很重要,对吧?

i
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
} count();
</script>

……但是我们也可能想在任务执行期间展示一些东西,例如进度条。

setTimeout

这看起来更好看:

<div id="progress"></div>
<script>
let i = 0; function count() { // 做繁重的任务的一部分 (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0); if (i < 1e7) {
setTimeout(count);
} } count();
</script>
divi

在事件之后做一些事情

setTimeout
menu-opensetTimeoutclick
menu.onclick = function() {
// ... // 创建一个具有被点击的菜单项的数据的自定义事件
let customEvent = new CustomEvent("menu-open", {
bubbles: true
}); // 异步分派(dispatch)自定义事件
setTimeout(() => menu.dispatchEvent(customEvent));
};