从 JS Event Loop 机制看 Vue 中 nextTick 的实现原理
本文来自作者 大师兄 在 GitChat 上分享「从 JS Event Loop 机制看 Vue 中 nextTick 的实现原理」,「阅读原文」查看交流实录
「文末高能」
编辑 | 泰龙
作为一名前端,一直以来以精通 Javascript 为目标。其实说实话精通真的挺难,不是你记住全部的 API 就算是精通。
JavaScript 的知识非常零散而庞杂,很多情况下上周学习的知识下周或是下个月就会忘记,给人的感觉就是好难学,怎么一直没进步呢?
我们不能仅限于语言本身,我们要透过语法看到深层次的运行机制。
掌握了 Javascript 运行机制,就好比学武术,大神级别都讲究「无招胜有招」。懂得了机制就可以举一反三,灵活运用。
事件循环机制(Event Loop),则是理解运行机制最关键的一个点。我们先抛出一个面试题:
setTimeout(function() { console.log(1) }, 0); new Promise(function executor(resolve) { console.log(2); for( var i=0 ; i<10000 ; i++ ) { i == 9999 && resolve(); } console.log(3); }).then(function() { console.log(4); }); console.log(5);
先思考,这段代码会输出什么?
单线程的 JavaScript
JavaScript 是单线程的,我想大家都不会怀疑吧。那什么是单线程?
当初我对「js 是单线程」的理解仅限于 js 的代码是一行一行执行的,不会出现同时执行两行的代码的情况,可是这个理解是太浅显,遇到异步请求就懵逼了,怎么不按我的想法走呢?还用说想法有问题呗。
所谓单线程,是指在 JS 引擎中负责解释和执行 JavaScript 代码的线程只有一个。JS 运行在浏览器中,是单线程的,每个 window 一个 JS 线程。
第一个问题,为啥要是单线程,多线程不好吗?减轻 cpu 的压力。现在如果有两个线程,一个线程修改页面某一个 dom 元素,正巧另一个线程将这个元素给删除了。这不是混乱了么。所以单线程是有原因的。
那你又有疑问了,既然是单线程的,在某个特定的时刻只有特定的代码能够被执行,并阻塞其它的代码。
那不行啊,我们总不能一直等着啊,前端需要调用后端接口取数据,这个过程是需要响应时间的,那执行这个代码的时候浏览器也等着?答案是否定的。
其实还有其他很多类线程(应该叫做任务队列),比如进行ajax请求、监控用户事件、定时器、读写文件的线程(例如在NodeJS中)等等。
这些我们称之为异步事件,当异步事件发生时,将他们放入执行队列,等待当前代码执行完成。就不会长时间阻塞主线程。
等主线程的代码执行完毕,然后再读取任务队列,返回主线程继续处理。如此循环这就是事件循环机制。
总结一下:
我们可以认为某个同域浏览器上下文中 JavaScript 只有一个主线程、函数调用栈以及多个任务队列。
主线程会依次执行代码,当遇到函数时,会先将函数入栈,函数运行完毕后再将该函数出栈,直到所有代码执行完毕。
当函数调用栈为空时,即会根据事件循环(Event Loop)机制来从任务队列中提取出待执行的回调并执行,执行的过程同样会利用函数栈。
所有同属一个的窗体都共享一个事件循环,所以它们可以同步交流。不同窗体之间相互独立,互不干扰。
你如果想彻底研究清楚事件模型,那还需要了解如下知识:
Javascript 的队列数据结构
Javascript 的执行上下文
函数调用栈(call stack)
我们会分为两节来学习,队列数据结构为一节,执行上下文和函数调用栈合在一起为一节。
Javascript 的内存空间
队列数据结构
我们知道 Javascript 中有两种基本的种数据结构堆(heap)和栈(stack),还有一个队列(queue),并不是严格意义上的数据结构。
栈数据结构
在我们平时的工作过程中我们写 javascript 代码并不关心数据结构,但是它确实彻底理解某些运行机制的必不可少的部分。
JavaScript 中并没有严格的去区分栈内存与堆内存。我们平时基本都认为JavaScript 的所有数据(变量、函数)都保存在堆内存中。
但是在某些场景,我们仍然需要基于堆栈数据结构的思维(看好这里是思维)来看待,比如 JavaScript 的执行上下文。
要简单理解栈的存取方式,我们可以通过类比乒乓球盒子来分析。如下图左侧。
我们用栈存取数据的方式类比成乒乓球的存放方式,处于盒子中最顶层的乒乓球5,它一定是最后被放进去,但可以最先被使用。
而我们想要使用底层的乒乓球1,就必须将上面的4个乒乓球取出来,让乒乓球1处于盒子顶层。这就是栈空间先进后出,后进先出的特点。
堆数据结构
堆数据的存取数据的方式和与书架与书非常相似。
书架上放满了不同的书,我们只要知道书的名字我们就可以很方便的取出,而不用像从乒乓球盒子里取乒乓一样,非得将上面的所有乒乓球拿出来才能取到中间的某一个乒乓球。
在 JSON 格式的数据中,我们存储的 key-value 是可以无序的,我们并不关心顺序,我只要通过 key 取出 value 即可。
队列
在 JavaScript 中,理解队列数据结构的目的主要是为了理解事件循环(Event Loop)的机制。在后续的章节中我会详细分析事件循环机制。
队列是一种先进先出(FIFO)的数据结构。正如排队过安检一样,排在队伍前面的人一定是最先过检的人。用以下的图示可以清楚的理解队列的原理。
执行上下文 and 函数调用栈
这节我们稍微研究一下 JavaScript 中最基本的部分——执行上下文(Execution Context), 读完后,你应该清楚了解释器做了什么,为什么函数和变量能在声明前使用以及他们的值是如何决定的。
每当控制器转到可执行代码的时候,就会进入一个执行上下文。执行上下文可以理解为当前代码的执行环境,它会形成一个作用域。JavaScript 中的运行环境一般有两种:
全局环境:JavaScript 代码运行起来会首先进入该环境
函数环境:当函数被调用执行时,会进入当前函数中执行代码
其实这里还应该有一个 eval 环境,不推荐用 eval,今天也就不谈。
因此在一个 JavaScript 程序中,必定会产生多个执行上下文,JavaScript引擎会以栈的方式来处理它们,我们称其为函数调用栈(call stack)。
栈底永远都是全局上下文,而栈顶就是当前正在执行的上下文。
遇到以上两种情况,都会生成一个执行上下文,放入栈中,而处于栈顶的上下文执行完毕之后,就会自动出栈。
为了更加清晰的理解这个过程,根据下面的例子,结合图示给大家展示。
执行上下文可以理解为函数执行的环境,每一个函数执行时,都会给对应的函数创建这样一个执行环境。
modify.js
var name= 'dsx'; function modifyName() { var secondName= 'gwj'; function swapName() { var temp = secondName; secondName= name; name= temp ; } swapName(); } changeColor();
我们用 ECStack 来表示处理执行上下文组的堆栈。我们很容易知道,第一步,首先是全局上下文入栈。
全局入栈后,开始执行可执行代码,直到遇到了 modifyName(),这一句激活函数 modifyName 创建它自己的执行上下文,因此第二步就是 modifyName 的执行上下文入栈。
modifyName 入栈之后,继续执行函数内部可执行代码,遇到swapName()之后又激活了 swapName 执行上下文。因此第三步是 swapName 的执行上下文入栈。
在 swapName 的内部再没有遇到其他能生成执行上下文的代码,因此这段代码顺利执行完毕,swapName 的上下文从栈中弹出。
swapName 的执行上下文出栈后,继续执行 modifyName 其他可执行代码,也没有再遇到其他执行上下文,顺利执行完毕之后出栈。这样,ECStack中就只身下全局上下文了。
全局上下文在浏览器窗口关闭后出栈。
注意:
第一、函数中,遇到return能直接终止可执行代码的执行,因此会直接将当前上下文弹出栈。
第二、不要把执行上下文和作用域链混为一谈
如上我们演示了整个 modif.js 的执行过程。总结一下:
单线程,依次自顶而下的执行,遇到函数就会创建函数执行上下文,并入栈
同步执行,只有栈顶的上下文处于执行中,其他上下文需要等待
全局上下文只有唯一的一个,它在浏览器关闭时出栈
函数的执行上下文的个数没有限制
每次某个函数被调用,就会有个新的执行上下文为其创建,即使是调用的自身函数,也是如此。
事件循环
现在我们知道 JavaScript 的单线程,以及这个线程中拥有唯一的一个事件循环机制。那什么事件循环机制是什么?且看下文分析。
JavaScript 代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行。
根据上面的分析,任务队列的特点是先进先出。
一个 js 文件里事件循环只有一个,但是任务队列可以有多个。任务队列又可以分为 macro-task(task)与 micro-task(job)。
macro-task(task)包括:
setTimeout/setInterval
setImmediate
I/O操作
UI rendering
micro-task(job)包括:
process.nextTick
Promise
Object.observe(已废弃)
MutationObserver (html5 新特性)
浏览器中新标准中的事件循环机制与 node.js 类似,其中会介绍到几个nodejs有。
但是浏览器中没有的 API,大家只需要了解就好。比如 process.nextTick,setImmediate
我们称他们为事件源, 事件源作为任务分发器,他们的回调函数才是被分发到任务队列,而本身会立即执行。
例如,setTimeout 第一个参数被分发到任务队列,Promise 的 then 方法的回调函数被分发到任务队列(catch方法同理)。
不同源的事件被分发到不同的任务队列,其中 setTimeout 和 setInterval 属于同源
整体代码开始第一次循环。全局上下文进入函数调用栈。直到调用栈清空(只剩全局),然后执行所有的job。
当所有可执行的 job 执行完毕之后。循环再次从task开始,找到其中一个任务队列执行完毕,然后再执行所有的 job,这样一直循环下去。
无论是 task 还是 job,都是通过函数调用栈来完成。
这个时候我们是不是有一个大发现,除了首次整体代码的执行,其他的都有规律,先执行task任务队列,再执行所有的 job 并清空 job 队列。
再执行 task—job—task—job……,往复循环直到没有可执行代码。
那我们可不可以这么理解,第一次 script 代码的执行也算是一个task任务呢,如果这么理解那整个事件循环就很容易理解了。
来走一个栗子:
console.log(1); new Promise(function(resolve){ console.log(2); resolve(); }).then(function(){ console.log(3) }) setTimeout(function(){ console.log(4); process.nextTick(function(){ console.log(5); }) new Promise(function(resolve){ console.log(6); resolve() }).then(function(){ console.log(7) }) }) process.nextTick(function(){ console.log(8) }) setImmediate(function(){ console.log(9); new Promise(function(resolve){ console.log(10); resolve() }).then(function(){ console.log(11) }) process.nextTick(function(){ console.log(12); }) })
一下子写了这么多,是不是感觉有点复杂啊,不过没关系,我们一步一步来分析。
第一步,开始执行代码,global 入栈,执行到第一个 console.log(1),直接输出1。
第二步、执行遇到了 Peomise,Promise 构造函数的回调函数是同步执行,直接输出2。它的 then 方法才是任务源,将会分发一个 job 任务。
new Promise(function(resolve){ console.log(2); resolve(); }).then(function(){ console.log(3) })
第三步、执行到 setTimeout,作为 task 任务分发源,分发一个任务出去。
setTimeout(function(){ console.log(4); process.nextTick(function(){ console.log(5); }) new Promise(function(resolve){ console.log(6); resolve() }).then(function(){ console.log(7) }) })
第四步、执行遇到 process.nextTick, 一个 job 任务分发器,分发一个 job 任务。
process.nextTick(function(){ console.log(8) })
第五步、执行遇到 setImmediate, 一个 task 任务分发器,分发一个 task 任务到任务队列。并且会在 setTimeout 的任务队列之后执行。
setImmediate(function(){ console.log(9); new Promise(function(resolve){ console.log(10); resolve() }).then(function(){ console.log(11) }) process.nextTick(function(){ console.log(12); }) })
这样 script 代码的第一轮执行完毕,在执行的过程中会遇到不同的任务分发器,分发到对应的任务队列。接下来将会执行所有的job队列的任务。
注意:nextTick 任务队列会比 Promise 的队列先执行(别问为什么,我也不知道)
这阶段会依次输出 8 3。
执行完所有的 job 任务后,就会循环下一次,从 task 开始,根据图示,会先执行 Time_out1 队列。
task 任务的执行也是要借助函数栈来完成,也就是说回到主线程。
会先依次输出4和6,然后依次分发 nextTick2 和 promise_then2 两个 job 任务。
第一个 task 任务执行完不会立即执行其他 task 任务,会执行刚才被分发的job 任务
在这个过程中会依次输出5和7
现在就剩下一个 task 任务 setIm1,按照同样的方式进行再次循环 。示意图如下:
以上阶段会依次输出9和10。
最后执行job任务,依次输出12和11。
完事,这个事件循环就完成了,我想是很清楚了。最终的输出结果是:
1,2,8,3,4,6,5,7,9,10,12,11
最后我们用 node 执行一下我们写的这个例子。结果如下:
Vue 中的 nextTick() 实现原理
new Vue({ el: '#app', data: { list: [] }, mounted: function () { this.get() }, methods: { get: function () { this.$http.get('/api/article').then(function (res) { this.list = res.data.data.list // this.$refs.list引用了ul元素,我想把第一个li颜色变为红色 this.$refs.list.getElementsByTagName('li')[0].style.color = 'red' }) }, } })
我在获取到数据后赋值给 data 对象的 list 属性,然后我想引用ul元素找到第一个li把它的颜色变为红色,但是事实上,这个要报错的。
我们知道,在执行这句话时,ul 下面并没有 li,也就是说刚刚进行的赋值操作,当前并没有引起视图层的更新。
因为 Vue 的数据驱动视图更新,是异步的,即修改数据的当下,视图不会立刻更新,而是等同一事件循环中的所有数据变化完成之后,再统一进行视图更新。
因此,在这样的情况下,vue 给我们提供了 $nextTick 方法,如果我们想对未来更新后的视图进行操作,我们只需要把要执行的函数传递给 this.$nextTick 方法,vue 在更新完视图后就会执行我们的函数帮我们做事情。
$nextTick() 原理:
Vue 在内部尝试对异步队列使用原生的 Promise.then 和 MutationObserver,如果执行环境不支持,会采用 setTimeout(fn, 0) 代替。
看过上一个chat(Vue.2x 源码分析之响应式原理)的同学应该有了解,vue里面有一个watcher,用于观察数据的变化,数据有变化就会更新dom。
但是vue并不是每次数据改变都会立即触发更新dom,而是将这些操作都缓存在一个队列,如果同一个 watcher 被多次触发,只会一次推入到队列中。这样可以避免不必要的重复计算和 DOM 操作,提升性能。
那什么时候更新DOM呢?在下一个事件循环“tick”中,Vue 刷新队列并执行,统一执行(已去重的)dom的更新操作。
在前面我们花了大量篇幅来介绍javascript的事件循环机制,应该知道事件循环中有两种任务队列, macro-task(task) 和 micro-task(job)。
引擎在每个 task 执行完毕,从队列中取下一个 task 来执行之前,会先执行完所有 micro-task(job)队列中的 job。
setTimeout 任务源会分配回调到一个新的 task 中执行,而 Promise 的 then、MutationObserver 的回调都会被安排到一个新的 job 中执行,会比 setTimeout 产生的 task 先执行。
想要要创建一个新的 job,优先使用 Promise,如果浏览器不支持,再尝试 MutationObserver。实在不行,只能用 setTimeout 创建 task 了。
为啥要用 job?根据 HTML Standard,在每个 task 运行完以后,UI 都会重渲染,那么在 job 中就完成数据更新,当前 task 结束就可以得到最新的 UI 了。反之如果新建一个 task 来做数据更新,那么渲染就会进行两次。
总结 $nextTick 触发的时机:
同一事件循环中的代码执行完毕 -> DOM 更新 -> nextTick callback触发
$nextTick 源码:
//首先,这个函数是采用了一个单利模式还是什么创建的一个闭包函数 export const nextTick = (function(){ // 缓存函数的数组 var callbacks = []; // 是否正在执行 var pending = false; // 保存着要执行的函数 var timerFunc; })()
首先定义一些变量,供后面调用。接下来是一个函数:
//执行并且清空所有的回调列表 function nextTickHandler() { pending = false; //拷贝出函数数组副本 const copies = callbacks.slice(0); //把函数数组清空 callbacks.length = 0; //依次执行函数 for (let i = 0; i < copies.length; i++) { copies[i](); } }
这个函数就是 $nextTick 内实际调用的函数。
接下来,是 vue 分了三种情况来延迟调用以上这个函数,因为 $nextTick 目的就是把传进来的函数延迟到 dom 更新后再使用,所以这里依次优雅降序的使用 js 的方法来做到这一点。
利用 promise.then 延迟调用
if (typeof Promise !== 'undefined' && isNative(Promise)) { var p = Promise.resolve(); var logError = function (err) { console.error(err); }; timerFunc = function () { p.then(nextTickHandler).catch(logError); // 在部分 iOS 系统下的 UIWebViews 中,Promise.then 可能并不会被清空,因此我们需要添加额外操作以触发 if (isIOS) { setTimeout(noop); } };
如果浏览器支持 Promise,那么就用 Promise.then 的方式来延迟函数调用, Promise.then 方法可以将函数延迟到当前函数调用栈最末端,也就是函数调用栈最后调用该函数。从而做到延迟。
MutationObserver 监听变化
else if (typeof MutationObserver !== 'undefined' && ( isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]' )) { / 当 Promise 不可用时候使用 MutationObserver var counter = 1; var observer = new MutationObserver(nextTickHandler); var textNode = document.createTextNode(String(counter)); observer.observe(textNode, { characterData: true }); timerFunc = function () { counter = (counter + 1) % 2; textNode.data = String(counter); }; }
MutationObserver 是 h5 新加的一个功能,其功能是监听dom节点的变动,在所有 dom 变动完成后,执行回调函数。
具体有一下几点变动的监听:
childList:子元素的变动
attributes:属性的变动
characterData:节点内容或节点文本的变动
subtree:所有下属节点(包括子节点和子节点的子节点)的变动
可以看出,先创建了一个文本节点,来改变文本节点的内容来触发的变动,因为我们在数据模型更新后,将会引起 dom 节点重新渲染。
所以,我们加了这样一个变动监听,用一个文本节点的变动触发监听,等所有dom渲染完后,执行函数,达到我们延迟的效果。
setTimeout 延迟器
else { timerFunc = function () { setTimeout(nextTickHandler, 0); }; }
这个很简单哈。利用 setTimeout 的延迟原理,setTimeout(func, 0)会将 func 函数延迟到下一次函数调用栈的开始,也就是当前函数执行完毕后再执行该函数,因此完成了延迟功能。
但是我们看到的 $nextTick 是一个函数啊,这里就是一个自执行函数,并不是一个函数啊,没错我们还需要返回一个闭包函数才可以。往下看:
闭包函数
return function queueNextTick (cb, ctx) { var _resolve; callbacks.push(function () { if (cb) { cb.call(ctx); } if (_resolve) { _resolve(ctx); } }); // 如果没有函数队列在执行才执行 if (!pending) { pending = true; timerFunc(); } // promise化 // 如果没有传入回调,则表示以异步方式调用 if (!cb && typeof Promise !== 'undefined') { console.log('进来了') return new Promise(function (resolve) { _resolve = resolve; }) } }
这个 return 的函数就是我们实际使用的闭包函数,每一次调用$nextTick函数,都会向 callbacks 这个函数数组入栈。
然后监听当前是否正在执行,如果没有,执行函数。下面一个 if 是 promise 化,如果没有传入回调,则表示以异步方式调用。
后记
现在 Vue()的 nextTick 实现移除了 MutationObserver 的方式(兼容性原因),取而代之的是使用 MessageChannel ( https://developer.mozilla.org/zh-CN/docs/Web/API/MessageChannel )。
并且加入了 setImmediate。
有兴趣的同学可以继续去学习,原理我们已经说的很明白。源码:https://github.com/vuejs/vue/blob/dev/src/core/util/next-tick.js
近期热文
给你一个不学 Vue 的理由
「阅读原文」看交流实录,你想知道的都在这里