-
Notifications
You must be signed in to change notification settings - Fork 20
Description
概述
我们都知道JS引擎是单线程、同步执行的了,但是对于I/O业务密集型的前端,没有异步编程是不行的,会非常影响用户的体验。
异步模块并不属于V8 引擎范围,它是浏览器内核提供的Web API,供开发者使用的,用于提供用户体验的模块,比如经常使用到的setTimeout、XMLHttpRequest、Fetch、Promise等等API。
定义
1. 同步和异步、阻塞与非阻塞的关联和区别
举个例子
同步就是烧开水,需要自己去轮询(每隔一段时间去看看水开了没),异步就是水开了,然后水壶会通知你水已经开了,你可以回来处理这些开水了。
同步和异步是相对于操作结果来说,是自动去询问操作有结果了么,还是等待操作结束了带着结果告知予你。
阻塞就是说在煮水的过程中,你不可以去干其他的事情,非阻塞就是在同样的情况下,可以同时去干其他的事情。
阻塞和非阻塞是相对于线程是否被阻塞,是等待操作结果返回,还是接着往下执行代码。
所以,JS的异步非阻塞场景就是,主线程遇到一个需要长时间等待结果操作,可以先不等它返回结果,继续执行其他操作;等待该操作产生结果了,会带着结果来通知主线程。那么JS是怎么实现这样的效果呢?答案就是异步编程和事件循环。
2. 浓缩成一段话
对于主线程需要异步的操作(长时间操作),都交给异步处理模块(通常是另一条线程),等到处理结果出来了,由异步处理模块把处理结果和回调传回给主线程的任务队列(task-queue),主线程会不停地去任务队列取回调来执行,这就是事件循环。
这篇博客涉及到的概念
- setTimeout和setInterval
- 任务队列
- 事件循环机制
- 研究VueJS的nextTick源码
- Promise异步处理
- 寻找跟任务队列交互的API
1. setTimeout和setInterval
这是定时器线程提供的两个api,两者的常用api如下:
setTimeout(func,[delay,param1,param2,····]);
setInterval(func, delay[,param1, param2, ...]);
A. setTimeout方法
setTimeout方法会让定时器线程在过了delay时间后,把回调(func)放入JS引擎线程的任务队列中,很多博客都会提到setTimeout的一些缺点,就是这个api不能保证在过了delay时间后执行,其实原理很简单,我们知道JS引擎会在主线程执行完毕之后才会到任务队列中提取回调到主线程中执行,所以定时器线程能保证delay时间之后把回调放入任务队列,但是不能保证这个时候主线程是空闲的。
B. setInterval方法
setInterval方法,在nodejs环境和浏览器环境下是表现不一样的。
1. NodeJs环境下
setInterval的回调在delay时间后,被放入JS引擎线程的任务队列中,等待被JS引擎线程放入执行上下文栈中执行,被执行完毕之后,会调用自身一次,也就是请求定时器线程在delay时间后再次把回调放入任务队列中。用setTimeout来表示这个逻辑的话:
setTimeout(function(){
// some code
setTimeout(arguments.callee, interval);
}, interval);
2. 浏览器环境下
每隔delay时间后,定时器线程会把回调放入任务队列中,如果任务队列中已经存在回调还没被执行的话会被覆盖掉。换句话说,如果你想要6秒内执行3次(delay = 2000),但是回调执行时间太长,比如执行了4秒,那么这段时间内会被插入两个回调,那么只有第二个回调会被执行。
3. 测试上面的两个结论代码:
(function inter(num) {
setInterval(() => {
var t = +new Date();
++num;
console.log(num, new Date());
while (+new Date() - t < 1000); // 阻塞一秒
}, 2000) // 间隔两秒
})(0)
NodeJs output(回调间隔3秒):
1 2018-05-09T01:55:54.841Z
2 2018-05-09T01:55:57.842Z
3 2018-05-09T01:56:00.842Z
4 2018-05-09T01:56:03.842Z
5 2018-05-09T01:56:06.842Z
浏览器 output(回调间隔2秒):
1 Wed May 09 2018 09:56:28 GMT+0800 (中国标准时间)
2 Wed May 09 2018 09:56:30 GMT+0800 (中国标准时间)
3 Wed May 09 2018 09:56:32 GMT+0800 (中国标准时间)
4 Wed May 09 2018 09:56:34 GMT+0800 (中国标准时间)
5 Wed May 09 2018 09:56:36 GMT+0800 (中国标准时间)
2. 任务队列
任务队列分宏任务队列(macro-task-queue)和微任务队列(micro-task-queue),如下:
- 宏任务队列:script(JS文件)、setTimeout、setInterval、setImmediate、I/O、ajax、eventListener、UI rendering
- 微任务队列:process.nextTick、Promise、Object.observe()、MutationObserver
Object.observe(obj, callback[, acceptList])已废弃,可以使用Proxy对象代替。
MutationObserver接口提供了监视对DOM树所做更改的能力。它被设计为旧的Mutation Events功能的替代品,该功能是DOM3 Events规范的一部分。
一个网页有若干个宏任务队列,根据任务源来分类,大概有如下任务源:
- DOM操作任务源
- 用户交互任务源,比如鼠标事件之类
- 网络任务源,例如ajax
- history traversal任务源,例如history.back()
每一个宏任务都有一个微任务队列,在宏任务结束之前会把微任务队列里的任务执行完毕。
3. 事件循环机制
1. 事件循环的步骤
- 取出macro-task-queue中最老的一个task(最开始是script)到主线程中依次执行
- 执行micro-task-queue中的所有task,直到为空
- 查看将来最近一帧(浏览器每秒60帧),如果有需要的DOM更新,则进行UI Render更新
- 回到1
2. 网页渲染时机
- 浏览器一般是一秒60帧刷新页面的,也就是16.7ms一帧,在这16.7ms期间收集到的所有DOM变更(repaint或reflow)将会通过GPU进程在下一帧中更新到页面上。
- DOM的变更操作在一次执行上下文中仅保留最终态(意思是就算每次修改一个dom,也不会立马渲染到页面上),等到所在的执行上下文执行完了之后DOM的最终态将被GUI线程收集。如果一个执行上下文执行时间过长,会延迟DOM的更新。下面的代码说明这一点:
setTimeout(()=>{
var demoDiv = document.getElementById('test')
demoDiv.innerText = 'hello world'; // 变更 DOM
var t = +new Date();
while(+new Date() - t < 3000) ; // 阻塞3秒,才会把变更的dom穿给GUI线程
})
3. requestAnimationFrame
这是浏览器专门为动画提供的API,用系统时间间隔,保持最佳绘制效率,不会因为间隔时间过短,造成过度绘制,增加开销;也不会因为间隔时间太长,使用动画卡顿不流畅,让各种网页动画效果能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果。
举个例子
如果有一个需求,一秒钟往ul标签里插入1000个li标签,可以借助这个api,每帧(1秒有60帧)插入17个li标签,那么用户会觉得画面很顺畅
4. 研究VueJS的nextTick源码
简单介绍:Vue对象的数据变更并不是执行了就会立马更新到DOM上,而是把所有变更存储起来等待事件循环来处理,为了得到立马更新的DOM,需要调用nextTick这个API。
1. 需要着重讲一下VueJs中数据的三种状态
- 更新到dom属性前,在一个执行上下文还未执行结束前,数据都是这个状态
<div id="example">{{message}}</div>
new Vue({
el: '#example',
data: {
message: '123'
}
methods: {
updateMessage: () => {
vm.message = 'new message' // 更改数据
console.log(vm.$el.textContent) // '123',因为还未更新到dom属性
console.log(vm.message) // 'new message'
}
}
})
- 更新到dom属性
- 渲染到页面上
2. nextTicke源码
大致过程是这样的:优先使用(Promise > setImmediate > MessageChannel > setTimeout)来作为它的实现方式,用一个数组callbacks来存储一个执行上下文中多次调用的nextTick的回调,经过特殊处理后,并不是每个nextTick都产生一个micro-task(或者macro-task),而是在一个micro-task(或者macro-task)中把callbacks全部循环执行了。
3. 如何保证nextTick里能确切拿到更新后的dom属性呢?
VueJs官网关于异步更新队列,有下面一句话
Vue 在内部尝试对异步队列使用原生的 Promise.then 和 MessageChannel,如果执行环境不支持,会采用 setTimeout(fn, 0) 代替。
可以看得出来,如果异步更新队列使用的是micro-task方式更新的,那么nextTick也会是同样的micro-task方式实现的,所以只需要保证异步更新队列先放入micro-task-queue,nextTick后放入的话,nextTick里的回调是可以正确取到dom属性的值。(如果是使用macro-task的方式,那么nextTick就会被安排到下一次tick,也可以正确取到dom属性的值)
5. Promise异步处理
1. 介绍promise
前面说过Promise是一个异步处理框架。
在JS发展史里,最早广泛使用Promise的是jQuery.Deffered()。Promise/A+标准规定了一系列API,先是NodeJs有了开源的Promise库,后来ES6直接整合这个标准,NodeJs支持ES6的时候也不需要开源的Promise库了。
2. Promise的API
Promise.all()
Promise.prototype.catch()
Promise.prototype.finally()
Promise.prototype.then()
Promise.race()
Promise.reject()
Promise.resolve()
3. ES6的Promise
作为一个异步处理框架,提供API给前端工程师使用,但是内部实现不详。
4. 模仿实现ES6的Promise
以ES6的Promise为参考,使用timeout和ES6的语法(可选)实现。
由于篇幅过多,我把它独立成一个项目 —— 用代码讲述Promise原理——每个人都应该有自己的Promise
6. 寻找跟任务队列交互的API
我曾经想过找到这个api,然后每个前端工程师都可以写自己的异步框架了。
想象中的这个api应该是这样的:
push-task-into-queue(callback, type)
// 其中的callback是放入队列的回调,type是队列的类型:macro-task还是micro-task
但是一些网友告诉我,这个api是浏览器内部实现的,不会公开的。略感失望。
写在最后
JS相关的异步编程和事件队列涉及到的东西真不少,这篇博客有误的地方请指正。
参考链接
VueJs的异步更新队列
Promise API
Promises/A+ 规范
The Node.js Event Loop, Timers, and process.nextTick()
深入理解js事件循环机制(Node.js篇)
深入理解js事件循环机制(浏览器篇)
https://www.cnblogs.com/George1994/p/6702084.html
https://juejin.im/post/599ff3d5f265da24843e6276
https://segmentfault.com/a/1190000011198232
https://www.cnblogs.com/xiaohuochai/p/5777186.html
https://github.com/aooy/blog/issues/5
https://html.spec.whatwg.org/multipage/webappapis.html#event-loops

