Diary如何用 Event Loop 理解异步
Posted Tyloo前端日记
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Diary如何用 Event Loop 理解异步相关的知识,希望对你有一定的参考价值。
零. 前言
Event Loop即事件循环,是指浏览器或Node的一种解决javascript单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。
一. 进程和线程
线程,是程序执行流的最小单位。线程可与同属一个进程的其他线程共享所拥有的全部资源,同一进程中的多个线程之间可以并发执行。线程有就绪,阻塞,运行三种基本状态。
阮一峰大神针对进程和线程的类比,很是形象:计算机的核心CPU,是个工厂,时刻运转着,工厂里有很多个车间(进程),一个车间开工其他车间不能开工,就是说:单核CPU一次只能运行一个进程。任何时候,CPU总是运行一个进程。
而一个车间里面有很多工人(线程),协同完成一个任务。所以:一个进程可以包括多个线程。车间空间是工人共享的,一个进程中的内存空间是所有线程共享的。
内存可以上锁,防止其他线程进来,【互斥锁】也就是防止多个线程读写同一块内存区域。
还有些共享内存比较大,可以容纳多个线程,但是也是有限的,只能容纳固定数目的线程使用。如何保证多个线程不冲突呢?内存门口有个钥匙架,如果发现钥匙架空了,那就不能再进去了【信号量】。
二. 堆、栈、队列
堆(Heap)
堆是一种数据结构,是利用完全二叉树维护的一组数据,堆分为两种,一种为最大堆,一种为最小堆,将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。堆是线性数据结构,相当于一维数组,有唯一后继。
如最大堆:
栈(Stack)
栈在计算机科学中是限定仅在表尾进行插入或删除操作的线性表。栈是一种数据结构,它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据。栈是只能在某一端插入和删除的特殊线性表。
队列(Queue)
特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。队列中没有元素时,称为空队列。
队列的数据元素又称为队列元素。在队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出(FIFO—first in first out)
三. 函数调用栈与任务队列
Javascript有一个main thread 主进程和call-stack(一个调用堆栈),在对一个调用堆栈中的task处理的时候,其他的都要等着。当在执行过程中遇到一些类似于setTimeout等异步操作的时候,会交给浏览器的其他模块(以webkit为例,是webcore模块)进行处理,当到达setTimeout指定的延时执行的时间之后,task(回调函数)会放入到任务队列之中。一般不同的异步任务的回调函数会放入不同的任务队列之中。等到调用栈中所有task执行完毕之后,接着去执行任务队列之中的task(回调函数)。
任务队列是“先进先出”的数据结构,先来的优先被主线程读取。
调用栈中遇到DOM操作、ajax请求以及setTimeout等WebAPIs的时候就会交给浏览器内核的其他模块进行处理,webkit内核在Javasctipt执行引擎之外,有一个重要的模块是webcore模块。对于图中WebAPIs提到的三种API,webcore分别提供了DOM Binding、network、timer模块来处理底层实现。等到这些模块处理完这些操作的时候将回调函数放入任务队列中,之后等栈中的task执行完之后再去执行任务队列之中的回调函数。
小结:
所有的代码都要通过函数调用栈中调用执行。
当遇到前文中提到的APIs的时候,会交给浏览器内核的其他模块进行处理。
任务队列中存放的是回调函数。
等到调用栈中的task执行完之后再回去执行任务队列之中的task。
四. 宏任务与微任务
其中setTimeout叫做macro-task(宏任务),当然如我们所想,还有如promise的micro-task(微任务)。
macro-task包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering。
micro-task包括:process.nextTick, Promise,Object.observe。
事件循环的顺序是从script开始第一次循环,随后全局上下文进入函数调用栈,碰到macro-task就将其交给处理它的模块处理完之后将回调函数放进macro-task的队列之中,碰到micro-task也是将其回调函数放进micro-task的队列之中。直到函数调用栈清空只剩全局执行上下文,然后开始执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次执行macro-task中的一个任务队列,执行完之后再执行所有的micro-task,就这样一直循环。
总结:
不同的任务会放进不同的任务队列之中。
先执行macro-task,等到函数调用栈清空之后再执行所有在队列之中的micro-task。
等到所有micro-task执行完之后再从macro-task中的一个任务队列开始执行,就这样一直循环。
当有多个macro-task(micro-task)队列时,事件循环的顺序是按上文macro-task(micro-task)的分类中书写的顺序执行的。
五. 浏览器中的Event Loop
01
同步任务和异步任务
Javascript单线程任务被分为同步任务和异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。
任务队列Task Queue,即队列,是一种先进先出的一种数据结构。
02
事件循环的进程模型
执行栈在执行完同步任务后,查看执行栈是否为空,如果执行栈为空,就会去检查微任务(microTask)队列是否为空,如果为空的话,就执行宏任务(Task),否则就一次性执行完所有微任务。每次单个宏任务执行完毕后,检查微任务(microTask)队列是否为空,如果不为空的话,会按照先入先出的规则全部执行完微任务(microTask)后,设置微任务(microTask)队列为null,然后再执行宏任务,如此循环。
先执行同步阻塞任务,同步任务会等待上一个执行完毕以后执行下一个,当同步任务执行完毕,再执行异步任务,遇到异步任务会将异步任务的回调函数注册在异步任务队列里。注意,如果主线程上没有同步任务会直接调用异步任务的微任务。
执行宏任务,遇到微任务将都添加到微任务队列里。
开始执行微任务队列,当宏任务执行完后执行微任务队列,直到微任务队列全部执行完,微任务队列为空。
执行宏任务,如果在执行宏任务期间有微任务,将微任务添加到微任务队列里,执行完宏任务之后执行微任务,直到微任务队列全部执行完。
继续执行宏任务队列。
重复2, 3, 4,5……直到宏微任务为空。
六. $nextTick 的实现原理
从字面意思理解,next 下一个,tick 滴答(钟表)来源于定时器的周期性中断(输出脉冲),一次中断表示一个 tick,也被称做一个“时钟滴答”),nextTick 顾名思义就是下一个时钟滴答。看源码,在 Vue 2.x 版本中,nextTick 在 src\core\util 中的一个单独的文件 next-tick.js ,可见 nextTick 的重要性,虽然短短 200 多行,尤大却单独创建一个文件去维护。
接下来我们来看整个文件。
声明了三个全局变量,callbacks: [],pending: Boolean,timerFunc: undefined。
声明了一个函数flushCallbacks。
一堆 if,else if 判断。
抛出了一个函数 nextTick。
01
nextTick 函数
声明一个局部变量 _resolve 。
把所有回调函数压进 callbacks 中,以栈的形式的存储所有 callback。
当 pending 为 false 时,执行 timerFunc 函数。
当没有 callback 的时候,返回一个 Promise 的调用方式,可以用 .then 接收。
02
timerFunc 函数
我们开始说了,timerFunc 为全局变量,现在调用 timerFunc ,timerFunc 是什么时候被赋值为一个函数,并且函数里执行代码又是什么?
我们看到,这段判断代码总共有四个分支,四个分支里对 timerFunc 有不同的赋值,我们先来看第一个分支。
Promise 分支
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
// In problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isios) setTimeout(noop)
}
isUsingMicroTask = true
}
判断环境是否支持 Promise 并且 Promise 是否为原生。
使用 Promise 异步调用 flushCallbacks 函数。
当执行环境是 iPhone 等,使用 setTimeout 异步调用 noop ,iOS 中在一些异常的webview 中,promise 结束后任务队列并没有刷新所以强制执行 setTimeout 刷新任务队列。
MutationObserver 分支
else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
// Use MutationObserver where native Promise is not available,
// e.g. PhantomJS, iOS7, android 4.4
// (#6466 MutationObserver is unreliable in IE11)
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
}
对非IE浏览器和是否可以使用 html5 新特性 MutationObserver 进行判断。
实例一个 MutationObserver 对象,这个对象主要是对浏览器 DOM 变化进行监听,当实例化 MutationObserver 对象并且执行对象 observe,设置 DOM 节点发生改变时自动触发回调。
把 timerFunc 赋值为一个改变 DOM 节点的方法,当 DOM 节点发生改变,触发 flushCallbacks 。(这里其实就是想用利用 MutationObserver 的特性进行异步操作)
setImmediate 分支
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// Fallback to setImmediate.
// Technically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = () => {
setImmediate(flushCallbacks)
}
}
判断 setImmediate 是否存在,setImmediate 是高版本 IE (IE10+) 和 edge 才支持的。
如果存在,传入 flushCallbacks 执行 setImmediate 。
setTimeout 分支
else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
当以上所有分支异步 api 都不支持的时候,使用 macro task(宏任务)的 setTimeout 执行 flushCallbacks 。
执行降级
我们可以发现,给 timerFunc 赋值是一个降级的过程。为什么呢,因为 Vue 在执行的过程中,执行环境不同,所以要适配环境。
03
flushCallbacks 函数
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
04
总结
nextTick 的原理就是利用 Event loop 事件线程去异步重新渲染,分支判断首要选择 Promise 的原因是当同步JS代码执行完毕,执行栈清空会首先查看 micro task (微任务)队列是否为空,不为空首先执行微任务。在我们 DOM 依赖数据发生变化的时候,会异步重新渲染 DOM ,但是比如像 echarts ,canvas……这些 Vue 无法在初始状态下收集依赖的 DOM ,我们就需要手动执行 nextTick 方法使其重新渲染。
以上是关于Diary如何用 Event Loop 理解异步的主要内容,如果未能解决你的问题,请参考以下文章
JavaScript:彻底理解同步异步和事件循环(Event Loop)
JavaScript:彻底理解同步异步和事件循环(Event Loop) (转)