[译] 深入理解 JavaScript 事件循环(二)— task and microtask
引言
microtask 这一名词是 JS 中比较新的概念,几乎所有人都是在学习 ES6 的 Promise 时才接触这一新概念,我也不例外。当我刚开始学习 Promise 的时候,对其中回调函数的执行方式特别着迷,于是乎便看到了 microtask 这一个单词,但是困难的是国内很少有关于这方面的文章,有一小部分人探讨过不过对其中的原理和机制的讲解也是十分晦涩难懂。直到我看到了 Jake Archibald 的文章,我才对 microtask 有了一个完整的认识,所以我便想把这篇文章翻译过来,供大家学习和参考。
本篇文章绝大部分翻译自 Jake Archibald 的文章 Tasks, microtasks, queues and schedules。有英文功底的同学建议阅读原著,毕竟人家比我写的好...
适合人群:有一定的 JavaScript 开发基础,对 JavaScript Event Loop 有基本的认识,掌握 ES6 Promise 。
初识 Microtask
让我们先来看一段代码,猜猜它将会以何种顺序输出:
console.log('script start'); setTimeout(function() {
console.log('setTimeout');
}, 0); Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
}); console.log('script end');
你可以在这里查看输出结果:
正确的答案是:'script start'、'script end'、'promise1'、'promise2'、'setTimeout'。但是不同的浏览器可能会出现不同的输出顺序。
Microsoft Edge, FireFox 40, iOS Safari 以及 Safari 8.0.8 将会在 'promise1' 和 'promise2' 之前输出 'setTimeout'。但是奇怪的是,FireFox 39 和 Safari 8.0.7 却又是按照正确的顺序输出。
为什么?
要理解上面代码的输出原理,你就需要了解 JavaScript 的 event loop 是如何处理 tasks 以及 microtasks,当你第一次看到这一堆概念的时候,相信你也是和我一样的一头雾水,别急,让我们先深呼吸一下,然后开始我们的 microtask 之旅。
每一个“线程”都有一个独立的 event loop,每一个 web worker 也有一个独立的 event loop,所以它可以独立的运行。如果不是这样的话,那么所有的窗口都将共享一个 event loop,即使它们可以同步的通信。event loop 将会持续不断的,有序的执行队列中的任务(tasks)。每一个 event loop 都有着众多不同的任务来源(task source),这些 task source 能够保证其中的 task 能够有序的执行(参见标准 Indexed Database API 2.0)。不过,在每一轮事件循环结束之后,浏览器可以自行选择将哪一个 source 当中的 task 加入到执行队列当中。这样也就使得了浏览器可以优先选择那些敏感性的任务,例如用户的的输入。(看完这段话,估计大部分人都晕了,别急... be patient)
Task 是严格按照时间顺序压栈和执行的,所以浏览器能够使得 JavaScript 内部任务与 DOM 任务能够有序的执行。当一个 task 执行结束后,在下一个 task 执行开始前,浏览器可以对页面进行重新渲染。每一个 task 都是需要分配的,例如从用户的点击操作到一个点击事件,渲染HTML文档,同时还有上面例子中的 setTimeout。
setTimeout 的工作原理相信大家应该都知道,其中的延迟并不是完全精确的,这是因为 setTimeout 它会在延迟时间结束后分配一个新的 task 至 event loop 中,而不是立即执行,所以 setTimeout 的回调函数会等待前面的 task 都执行结束后再运行。这就是为什么 'setTimeout' 会输出在 'script end' 之后,因为 'script end' 是第一个 task 的其中一部分,而 'setTimeout' 则是一个新的 task。这里我们先解释了 event loop 的基本原理,接下来我们会通过这个来讲解 microtask 的工作原理。
Microtask 通常来说就是需要在当前 task 执行结束后立即执行的任务,例如需要对一系列的任务做出回应,或者是需要异步的执行任务而又不需要分配一个新的 task,这样便可以减小一点性能的开销。microtask 任务队列是一个与 task 任务队列相互独立的队列,microtask 任务将会在每一个 task 任务执行结束之后执行。每一个 task 中产生的 microtask 都将会添加到 microtask 队列中,microtask 中产生的 microtask 将会添加至当前队列的尾部,并且 microtask 会按序的处理完队列中的所有任务。microtask 类型的任务目前包括了 MutationObserver 以及 Promise 的回调函数。
每当一个 Promise 被决议(或是被拒绝),便会将其回调函数添加至 microtask 任务队列中作为一个新的 microtask 。这也保证了 Promise 可以异步的执行。所以当我们调用 .then(resolve, reject) 的时候,会立即生成一个新的 microtask 添加至队列中,这就是为什么上面的 'promise1' 和 'promise2' 会输出在 'script end' 之后,因为 microtask 任务队列中的任务必须等待当前 task 执行结束后再执行,而 'promise1' 和 'promise2' 输出在 'setTimeout' 之前,这是因为 'setTimeout' 是一个新的 task,而 microtask 执行在当前 task 结束之后,下一个 task 开始之前。
下面这个 demo 将会逐步的分析 event loop 的运作方式:
通过以上的 demo 相信大家对 microtask 的运作方式有了了解了吧,不得不说我十分佩服 Jake Archibald ,人家自己一个字一个字的码了一个事件轮循器出来。作为一位膜拜者,我也一个字一个字的码了一个出来!...详情可参见引言中贴出的文章。
浏览器的兼容性
有一些浏览器会输出:'script start'、'script end'、'setTimeout'、'promise1'、'promise2'。这些浏览器将会在 'setTimeout' 之后输出 Promise 的回调函数,这看起来像是这类浏览器不支持 microtask 而将 Promise 的回调函数作为一个新的 task 来执行。
不过这一点也是可以理解的,因为 Promise 是来自于 ECMAScript 而不是 HTML。ES 当中有一个 “jobs” 的概念,它和 microtask 很相似,不过他们之间的关系目前还没有一个明确的定义。不过,普遍的共识都认为,Promise 的回调函数是应该作为一个 microtask 来运行的。
如果说把 Promise 当做一个新的 task 来执行的话,这将会造成一些性能上的问题,因为 Promise 的回调函数可能会被延迟执行,因为在每一个 task 执行结束后浏览器可能会进行一些渲染工作。由于作为一个 task 将会和其他任务来源(task source)相互影响,这也会造成一些不确定性,同时这也将打破一些与其他 API 的交互,这样一来便会造成一系列的问题。
Edge 浏览器目前已经修复了这个问题(an Edge ticket),WebKit 似乎始终是标准的,Safari 终究也会修复这个问题,在 FireFox 43 中这个问题也已被修复。
如何判断 task 和 microtask
直接测试输出是个很好的办法,看看输出的顺序是更像 Promise 还是更像 setTimeout,趋向于 Promise 的则是 microtask,趋向于 setTimeout 的则是 task。
还有一种明确的方式是查看标准。例如,timer-initialisation-steps 标准的第 16 步指出 “Queue the task task”。(注意原文中指出的是 14 步,正确是应该是 16 步。)而 queue-a-mutation-record 标准的第 5 步指出 “Queue a mutation observer compound microtask”。
同时需要注意的是,在 ES 当中称 microtask 为 “jobs”。比如 ES6标准 8.4节当中的 “EnqueueJob” 意思指添加一个 microtask。
现在,让我们来一个更复杂的例子...
进阶 microtask
在此之前,你需要了解 MutationObserver 的使用方法
<div class="outer">
<div class="inner"></div>
</div>
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner'); // 给 outer 添加一个观察者
new MutationObserver(function() {
console.log('mutate');
}).observe(outer, {
attributes: true
}); // click 回调函数
function onClick() {
console.log('click'); setTimeout(function() {
console.log('timeout');
}, 0); Promise.resolve().then(function() {
console.log('promise');
}); outer.setAttribute('data-random', Math.random());
} inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
先试着猜猜看程序将会如何输出,你可以在这里查看输出结果:
猜对了吗?不过在这里不同的浏览器可能会有不同的结果。
Chrome | FireFox | Safari | Edge |
click | click | click | click |
promise | mutate | mutate | click |
mutate | click | click | mutate |
click | mutate | mutate | timeout |
promise | timeout | promise | promise |
mutate | promise | promise | timeout |
timeout | promise | timeout | promise |
timeout | timeout | timeout |
谁是正确答案?
click 的回调函数是一个 task,而 Promise 和 MutationObserver 是一个 microtask,setTimeout 是一个 task,所以让我们一步一步的来:
通过以上 demo 我们可以看出,Chrome 给出的是正确答案,这里有一点与之前 demo 不同之处在于,这里的 task 是一个回调函数而不是当前执行的脚本,所以我们可以得出结论:用户操作的回调函数也是一个 task ,并且只要一个 task 执行结束且 JS stack 为空时,这时便检查 microtask ,如果不为空,则执行 microtask 队列。我们可以参见 HTML 标准:
If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3
Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…
注意在 ES 当中称 microtask 为 jobs。
为什么不同的浏览器表现不同?
通过上面的例子可以测试出,FireFox 和 Safari 能够正确的执行 microtask 队列,这一点可以通过 MutationObserver 的表现中看出,不过 Promise 被添加至事件队列中的方式好像有些不同。 这一点也是能够理解的,由于 jobs 和 microtasks 的关系以及概念目前还比较模糊,不过人们都普遍的期望他们都能够在两个事件监听器之间执行。这里有 FireFox 和 Safari 的 BUG 记录。(目前 Safari 已经修复了这一 BUG)
在 Edge 中我们可以明显的看出其压入 Promise 的方式是错误的,同时其执行 microtask 队列的方式也不正确,它没有在两个事件监听器之间执行,反而是在所有的事件监听器之后执行,所以才会只输出了一次 mutate 。Edge bug ticket (目前已修复)
驾驭 microtask
到了这里,相信大家已经习得了 microtask 的运行机制了吧,不过我们用以上的例子再做一点点小变化,比如我们运行一个:
inner.click();
看看会发生什么?
同样,这里不同的浏览器表现也是不一样的:
Chrome | FireFox | Safari | Edge |
click | click | click | click |
click | click | click | click |
promise | mutate | mutate | mutate |
mutate | timeout | promise | timeout |
promise | promise | promise | promise |
timeout | promise | timeout | timeout |
timeout | timeout | timeout | promise |
奇怪的是,在 Chrome 的个别版本里可能会得到不同的结果,究竟谁是正确答案?让我们一步一步的分析:
从上面 demo 可以看出,正确的答案应该是:'click'、'click'、'promise'、'mutate'、'promise'、'timeout'、'timeout'。所以看来 Chrome 给出的是正确答案。
在前一个 demo 中,microtask 将会在两个 click 时间监听器之间运行,但是在这个 demo 中,由于我们调用 .click() ,使得事件监听器的回调函数和当前运行的脚本同步执行而不是异步,所以当前脚本的执行栈会一直压在 JS 执行栈 当中。所以在这个 demo 中 microtask 不会在每一个 click 事件之后执行,而是在两个 click 事件执行完成之后执行。所以在这里我们可以再次的对 microtask 的检查点进行定义:当执行栈(JS Stack)为空时,执行一次 microtask 检查点。这也确保了无论是一个 task 还是一个 microtask 在执行完毕之后都会生成一个 microtask 检查点,也保证了 microtask 队列能够一次性执行完毕。
总结
关于 microtask 的讲解就到此结束了,同学们有没有一种渐入佳境的感觉呢?现在我们来对 microtask 进行一下总结:
- microtask 和 task 一样严格按照时间先后顺序执行。
- microtask 类型的任务包括 Promise callback 和 Mutation callback。
- 当 JS 执行栈为空时,便生成一个 microtask 检查点。
JS 的 Event Loop 一直以来都是一个比较重要的部分,虽然在学完了过后一下子感觉不出有什么具体的卵用...但是,一旦 Event Loop 的运行机制印入了你的脑海里之后,对你的编程能力和程序设计能力的提高是帮助很大的。关于 Event Loop 的知识很少有相关的书籍有写到,一是因为这一块比较晦涩难懂,短时间内无法领略其精髓,二是因为具体能力提升不明显,不如认识几个 API 来的快,但是这却是我们编程的内力,他能在潜意识中左右着我们编程时思考问题的方式。
本文的 demo 都放在了 jsfiddle 上面,可随意转载(还是注明一下出处吧...)。
[译] 深入理解 JavaScript 事件循环(二)— task and microtask的更多相关文章
- 深入理解 JavaScript 事件循环(二)— task and microtask
引言 microtask 这一名词是 JS 中比较新的概念,几乎所有人都是在学习 ES6 的 Promise 时才接触这一新概念,我也不例外.当我刚开始学习 Promise 的时候,对其中回调函数的执 ...
- 深入理解 JavaScript 事件循环(一)— event loop
引言 相信所有学过 JavaScript 都知道它是一门单线程的语言,这也就意味着 JS 无法进行多线程编程,但是 JS 当中却有着无处不在的异步概念 .在初期许多人会把异步理解成类似多线程的编程模式 ...
- 深入理解JavaScript事件循环机制
前言 众所周知,JavaScript 是一门单线程语言,虽然在 html5 中提出了 Web-Worker ,但这并未改变 JavaScript 是单线程这一核心.可看HTML规范中的这段话: To ...
- c#封装DBHelper类 c# 图片加水印 (摘)C#生成随机数的三种方法 使用LINQ、Lambda 表达式 、委托快速比较两个集合,找出需要新增、修改、删除的对象 c# 制作正方形图片 JavaScript 事件循环及异步原理(完全指北)
c#封装DBHelper类 public enum EffentNextType { /// <summary> /// 对其他语句无任何影响 /// </summary> ...
- JavaScript 事件循环
JavaScript 事件循环 事件循环 任务队列 async/await 又是如何处理的呢 ? 定时器问题 阻塞还是非阻塞 实际应用案例 拆分 CPU 过载任务 进度指示 在事件之后做一些事情 事件 ...
- JavaScript 事件循环及异步原理(完全指北)
引言 最近面试被问到,JS 既然是单线程的,为什么可以执行异步操作? 当时脑子蒙了,思维一直被困在 单线程 这个问题上,一直在思考单线程为什么可以额外运行任务,其实在我很早以前写的博客里面有写相关的内 ...
- 一篇文章图文并茂地带你轻松学完 JavaScript 事件循环机制(event loop)
JavaScript 事件循环机制 (event loop) 本篇文章已经默认你有了基础的 ES6 和 javascript语法 知识. 本篇文章比较细致,如果已经对同步异步,单线程等概念比较熟悉的读 ...
- 理解JavaScript继承(二)
理解JavaScript继承(二) 5.寄生式继承 function object(o) { function F() {} F.prototype = o; return new F(); } fu ...
- JavaScript 事件循环 — event loop
引言 相信所有学过 JavaScript 都知道它是一门单线程的语言,这也就意味着 JS 无法进行多线程编程,但是 JS 当中却有着无处不在的异步概念 .在初期许多人会把异步理解成类似多线程的编程模式 ...
随机推荐
- [转] 关于QT的系统总结
出处:http://www.cnblogs.com/wangqiguo/p/4625611.html 阅读目录 编译环境与开发流程 QT项目的构成及原理 QT中的布局 QT中的通用控件 QVarian ...
- java用substring函数截取string中一段字符串
在String中有两个substring()函数,如下: 一:String.substring(int start) 参数: start:要截取位置的索引 返回: 从start开始到结束的字符串 例如 ...
- linux环境中通过useradd命令,创建用户的时候指定用户的base-dir
需求说明: 今天一个同事,问了一个这样的问题,在linux环境中,创建用户的时候,默认的是在/home目录下创建一个与用户名相同的家目录, 如何能够将这个/home更换成一个其他的,比如/opt/ap ...
- Python对象(上)
Python是一门面向对象的语言,在Python中一切都是对象,函数是对象,类型也是对象. 下面就看看Python中对象相关的内容. Python对象基本概念 Python对象有三个基本的要素: 身份 ...
- Jquery easyui 重置按钮,easyui 清空表单,Jquery easyui 重置表单
Jquery easyui 重置按钮,easyui 清空表单,Jquery easyui 重置表单 >>>>>>>>>>>>&g ...
- 【AI】卷积
一 边界补充 1 补零填充 2 边界复制填充 3 镜像填充 4 块填充 二 卷积核 1 平滑均值滤波 2 高斯平滑 3 图像锐化 4 梯度Prewitt 5 Soble边缘检测:垂直梯度水平梯度 6 ...
- Linux CentOS6.5上搭建环境遇到的问题
1.卸载CentOS自带的JDK 查看centos上 安装的jdk:rpm -qa|grep jdk 出现如下: java-1.7.0-openjdk-1.7.0.45-2.4.3.3.el6.x86 ...
- C语言中一个字符数组里面的所有元素变成一个字符串
#include <string.h> int main() // 这里为了方便直接用main函数 { char array[] = { 'h', 'e', 'l', 'l', ' ...
- codeforces水题100道 第十七题 Codeforces Beta Round #25 (Div. 2 Only) A. IQ test (brute force)
题目链接:http://www.codeforces.com/problemset/problem/25/A题意:在n个书中找到唯一一个奇偶性和其他n-1个数不同的数.C++代码: #include ...
- 详解SQL中的GROUP BY语句
下面为您介绍SQL语句中GROUP BY 语句,GROUP BY 语句用于结合合计函数,根据一个或多个列对结果集进行分组. 希望对您学习SQL语句有所帮助. SQL GROUP BY 语法 SELEC ...