這篇文章跟大家介紹的內容是關於Vue源碼中批量異步更新與nextTick原理的解析,有著一定的參考價值,有需要的朋友可以參考一下。
vue已是目前國內前端web端三分天下之一,同時也作為本人主要技術棧之一,在日常使用中知其然也好奇著所以然,另外最近的社區湧現了一大票vue源碼閱讀類的文章,在下藉這個機會從大家的文章和討論中汲取了一些營養,同時對一些閱讀源碼時的想法進行總結,出產一些文章,作為自己思考的總結
#目標Vue版本:2.5.17-beta.0
vue原始碼註解:https://github.com/SHERlocked93/vue-analysis
聲明:文章中原始碼的語法都使用Flow,並且原始碼根據需要都有刪節(為了不被迷糊@_@),如果要看完整版的請進入上面的github地址,本文是系列文章,文章地址見底部~
我們在依賴收集原理的響應式化方法defineReactive
中的setter
存取器中有派發更新dep .notify()
方法,這個方法會挨個通知在dep
的subs
中收集的訂閱自己變動的watchers執行update。一起來看看update
方法的實作:
// src/core/observer/watcher.js /* Subscriber接口,当依赖发生改变的时候进行回调 */ update() { if (this.computed) { // 一个computed watcher有两种模式:activated lazy(默认) // 只有当它被至少一个订阅者依赖时才置activated,这通常是另一个计算属性或组件的render function if (this.dep.subs.length === 0) { // 如果没人订阅这个计算属性的变化 // lazy时,我们希望它只在必要时执行计算,所以我们只是简单地将观察者标记为dirty // 当计算属性被访问时,实际的计算在this.evaluate()中执行 this.dirty = true } else { // activated模式下,我们希望主动执行计算,但只有当值确实发生变化时才通知我们的订阅者 this.getAndInvoke(() => { this.dep.notify() // 通知渲染watcher重新渲染,通知依赖自己的所有watcher执行update }) } } else if (this.sync) { // 同步 this.run() } else { queueWatcher(this) // 异步推送到调度者观察者队列中,下一个tick时调用 } }
如果不是computed watcher
也非sync
會把呼叫update的目前watcher推送到調度者佇列中,下一個tick時調用,看看queueWatcher
:
// src/core/observer/scheduler.js /* 将一个观察者对象push进观察者队列,在队列中已经存在相同的id则 * 该watcher将被跳过,除非它是在队列正被flush时推送 */ export function queueWatcher (watcher: Watcher) { const id = watcher.id if (has[id] == null) { // 检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验 has[id] = true queue.push(watcher) // 如果没有正在flush,直接push到队列中 if (!waiting) { // 标记是否已传给nextTick waiting = true nextTick(flushSchedulerQueue) } } } /* 重置调度者状态 */ function resetSchedulerState () { queue.length = 0 has = {} waiting = false }
這裡使用了一個has
的哈希map用來檢查是否當前watcher的id是否存在,若已存在則跳過,不存在則就push到queue
隊列中並標記哈希表has,用於下次檢驗,防止重複添加。這就是一個去重的過程,比每次查重都要去queue中找要文明,在渲染的時候就不會重複patch
相同watcher的變化,這樣就算同步修改了一百次視圖中用到的data,異步patch
的時候也只會更新最後一次修改。
這裡的waiting
方法是用來標記flushSchedulerQueue
是否已經傳遞給nextTick
的標記位,如果已經傳遞則只push到隊列中不傳遞flushSchedulerQueue
給nextTick
,等到resetSchedulerState
重置調度者狀態的時候waiting
會被置回false
允許flushSchedulerQueue
被傳遞給下一個tick的回調,總之保證了flushSchedulerQueue
回調在一個tick內只允許被傳入一次。來看看傳遞給nextTick
的回呼flushSchedulerQueue
做了什麼:
// src/core/observer/scheduler.js /* nextTick的回调函数,在下一个tick时flush掉两个队列同时运行watchers */ function flushSchedulerQueue () { flushing = true let watcher, id queue.sort((a, b) => a.id - b.id) // 排序 for (index = 0; index MAX_UPDATE_COUNT) { // 持续执行了一百次watch代表可能存在死循环 warn() // 进入死循环的警告 break } } } resetSchedulerState() // 重置调度者状态 callActivatedHooks() // 使子组件状态都置成active同时调用activated钩子 callUpdatedHooks() // 调用updated钩子 }
在nextTick
方法中執行flushSchedulerQueue
# 方法,這個方法挨個執行queue
中的watcher的run
方法。我們看到在首先有個queue.sort()
方法把佇列中的watcher按id從小到大排了個序,這樣做可以保證:
元件更新的順序是從父元件到子元件的順序,因為父元件總是比子元件先建立。
一個元件的user watchers(偵聽器watcher)比render watcher先運行,因為user watchers往往比render watcher更早創建
如果一個元件在父元件watcher運作期間被銷毀,它的watcher執行將會被跳過
在挨個執行佇列中的for迴圈中,index 這裡沒有將length進行緩存,因為在執行處理現有watcher物件期間,更多的watcher物件可能會被push進queue。
那麼資料的修改從model層反映到view的過程:資料變更-> setter -> Dep -> Watcher -> nextTick -> patch -> 更新視圖
這裡就來看看包含著每個watcher執行的方法被當作回呼傳入nextTick
之後,nextTick
對這個方法做了什麼。不過首先要了解瀏覽器中的EventLoop
、macro task
、micro task
幾個概念,不了解可以參考JS與Node.js中的事件循環這篇文章,這裡就用一張圖來表示一下後兩者在主執行緒的執行關係:
解釋一下,當主執行緒執行完同步任務後:
引擎先從macrotask queue中取出第一個任務,執行完畢後,將microtask queue中的所有任務取出,依序全部執行;
然後再從macrotask queue中取下一個,執行完畢後,再次將microtask queue中的全部取出;
循環往復,直到兩個queue中的任務都取完。
浏览器环境中常见的异步任务种类,按照优先级:
macro task
:同步代码、setImmediate
、MessageChannel
、setTimeout/setInterval
micro task
:Promise.then
、MutationObserver
有的文章把 micro task
叫微任务,macro task
叫宏任务,因为这两个单词拼写太像了 -。- ,所以后面的注释多用中文表示~
先来看看源码中对 micro task
与 macro task
的实现: macroTimerFunc
、microTimerFunc
// src/core/util/next-tick.js const callbacks = [] // 存放异步执行的回调 let pending = false // 一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送 /* 挨个同步执行callbacks中回调 */ function flushCallbacks() { pending = false const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i { setImmediate(flushCallbacks) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || MessageChannel.toString() === '[object MessageChannelConstructor]' // PhantomJS )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = flushCallbacks macroTimerFunc = () => { port.postMessage(1) } } else { macroTimerFunc = () => { setTimeout(flushCallbacks, 0) } } // 微任务 if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() microTimerFunc = () => { p.then(flushCallbacks) } } else { microTimerFunc = macroTimerFunc // fallback to macro }
flushCallbacks
这个方法就是挨个同步的去执行callbacks中的回调函数们,callbacks中的回调函数是在调用 nextTick
的时候添加进去的;那么怎么去使用 micro task
与 macro task
去执行 flushCallbacks
呢,这里他们的实现 macroTimerFunc
、microTimerFunc
使用浏览器中宏任务/微任务的API对flushCallbacks
方法进行了一层包装。比如宏任务方法 macroTimerFunc=()=>{ setImmediate(flushCallbacks) }
,这样在触发宏任务执行的时候 macroTimerFunc()
就可以在浏览器中的下一个宏任务loop的时候消费这些保存在callbacks数组中的回调了,微任务同理。同时也可以看出传给 nextTick
的异步回调函数是被压成了一个同步任务在一个tick执行完的,而不是开启多个异步任务。
注意这里有个比较难理解的地方,第一次调用 nextTick
的时候 pending
为false,此时已经push到浏览器event loop中一个宏任务或微任务的task,如果在没有flush掉的情况下继续往callbacks里面添加,那么在执行这个占位queue的时候会执行之后添加的回调,所以 macroTimerFunc
、microTimerFunc
相当于task queue的占位,以后 pending
为true则继续往占位queue里面添加,event loop轮到这个task queue的时候将一并执行。执行 flushCallbacks
时 pending
置false,允许下一轮执行 nextTick
时往event loop占位。
可以看到上面 macroTimerFunc
与 microTimerFunc
进行了在不同浏览器兼容性下的平稳退化,或者说降级策略:
macroTimerFunc
:setImmediate -> MessageChannel -> setTimeout
。首先检测是否原生支持 setImmediate
,这个方法只在 IE、Edge 浏览器中原生实现,然后检测是否支持 MessageChannel,如果对 MessageChannel
不了解可以参考一下这篇文章,还不支持的话最后使用 setTimeout
;
为什么优先使用 setImmediate
与 MessageChannel
而不直接使用 setTimeout
呢,是因为HTML5规定setTimeout执行的最小延时为4ms,而嵌套的timeout表现为10ms,为了尽可能快的让回调执行,没有最小延时限制的前两者显然要优于 setTimeout
。
microTimerFunc
:Promise.then -> macroTimerFunc
。首先检查是否支持 Promise
,如果支持的话通过 Promise.then
来调用 flushCallbacks
方法,否则退化为 macroTimerFunc
;
vue2.5之后 nextTick
中因为兼容性原因删除了微任务平稳退化的 MutationObserver
的方式。
最后来看看我们平常用到的 nextTick
方法到底是如何实现的:
// src/core/util/next-tick.js export function nextTick(cb?: Function, ctx?: Object) { let _resolve callbacks.push(() => { if (cb) { try { cb.call(ctx) } catch (e) { handleError(e, ctx, 'nextTick') } } else if (_resolve) { _resolve(ctx) } }) if (!pending) { pending = true if (useMacroTask) { macroTimerFunc() } else { microTimerFunc() } } if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) } } /* 强制使用macrotask的方法 */ export function withMacroTask(fn: Function): Function { return fn._withTask || (fn._withTask = function() { useMacroTask = true const res = fn.apply(null, arguments) useMacroTask = false return res }) }
nextTick
在这里分为三个部分,我们一起来看一下;
首先 nextTick
把传入的 cb
回调函数用 try-catch
包裹后放在一个匿名函数中推入callbacks数组中,这么做是因为防止单个 cb
如果执行错误不至于让整个JS线程挂掉,每个 cb
都包裹是防止这些回调函数如果执行错误不会相互影响,比如前一个抛错了后一个仍然可以执行。
然后检查 pending
状态,这个跟之前介绍的 queueWatcher
中的 waiting
是一个意思,它是一个标记位,一开始是 false
在进入 macroTimerFunc
、microTimerFunc
方法前被置为 true
,因此下次调用 nextTick
就不会进入 macroTimerFunc
、microTimerFunc
方法,这两个方法中会在下一个 macro/micro tick
时候 flushCallbacks
异步的去执行callbacks队列中收集的任务,而 flushCallbacks
方法在执行一开始会把 pending
置 false
,因此下一次调用 nextTick
时候又能开启新一轮的 macroTimerFunc
、microTimerFunc
,这样就形成了vue中的 event loop
。
最后检查是否传入了 cb
,因为 nextTick
还支持Promise化的调用:nextTick().then(() => {})
,所以如果没有传入 cb
就直接return了一个Promise实例,并且把resolve传递给_resolve,这样后者执行的时候就跳到我们调用的时候传递进 then
的方法中。
Vue源码中 next-tick.js
文件还有一段重要的注释,这里就翻译一下:
在vue2.5之前的版本中,nextTick基本上基于micro task
来实现的,但是在某些情况下micro task
具有太高的优先级,并且可能在连续顺序事件之间(例如#4521,#6690)或者甚至在同一事件的事件冒泡过程中之间触发(#6566)。但是如果全部都改成macro task
,对一些有重绘和动画的场景也会有性能影响,如 issue #6813。vue2.5之后版本提供的解决办法是默认使用micro task
,但在需要时(例如在v-on附加的事件处理程序中)强制使用macro task
。
为什么默认优先使用 micro task
呢,是利用其高优先级的特性,保证队列中的微任务在一次循环全部执行完毕。
强制 macro task
的方法是在绑定 DOM 事件的时候,默认会给回调的 handler 函数调用 withMacroTask
方法做一层包装 handler = withMacroTask(handler)
,它保证整个回调函数执行过程中,遇到数据状态的改变,这些改变都会被推到 macro task
中。以上实现在 src/platforms/web/runtime/modules/events.js 的 add
方法中,可以自己看一看具体代码。
刚好在写这篇文章的时候思否上有人问了个问题 vue 2.4 和2.5 版本的@input事件不一样 ,这个问题的原因也是因为2.5之前版本的DOM事件采用 micro task
,而之后采用 macro task
,解决的途径参考 中介绍的几个办法,这里就提供一个在mounted钩子中用 addEventListener
添加原生事件的方法来实现,参见 CodePen。
说这么多,不如来个例子,执行参见 CodePen
<p> <span>{{ name }}</span> <button>change name</button> </p><p></p> <script> new Vue({ el: '#app', data() { return { name: 'SHERlocked93' } }, methods: { change() { const $name = this.$refs.name this.$nextTick(() => console.log('setter前:' + $name.innerHTML)) this.name = ' name改喽 ' console.log('同步方式:' + this.$refs.name.innerHTML) setTimeout(() => this.console("setTimeout方式:" + this.$refs.name.innerHTML)) this.$nextTick(() => console.log('setter后:' + $name.innerHTML)) this.$nextTick().then(() => console.log('Promise方式:' + $name.innerHTML)) } } }) </script>
执行以下看看结果:
同步方式:SHERlocked93 setter前:SHERlocked93 setter后:name改喽 Promise方式:name改喽 setTimeout方式:name改喽
为什么是这样的结果呢,解释一下:
同步方式: 当把data中的name修改之后,此时会触发name的 setter
中的 dep.notify
通知依赖本data的render watcher去 update
,update
会把 flushSchedulerQueue
函数传递给 nextTick
,render watcher在 flushSchedulerQueue
函数运行时 watcher.run
再走 diff -> patch
那一套重渲染 re-render
视图,这个过程中会重新依赖收集,这个过程是异步的;所以当我们直接修改了name之后打印,这时异步的改动还没有被 patch
到视图上,所以获取视图上的DOM元素还是原来的内容。
setter前: setter前為什麼還要列印原來的是原來內容呢,是因為nextTick
在被呼叫的時候把回調挨個push進callbacks數組,之後執行的時候也是for
循環出來挨個執行,所以是類似於隊列這樣一個概念,先入先出;在修改name之後,觸發把render watcher填入schedulerQueue
隊列並且把他的執行函數flushSchedulerQueue
傳遞給nextTick
,此時callbacks隊列中已經有了setter前函數
了,因為這個cb
是在setter前函數
之後被push進callbacks佇列的,那麼先入先出的執行callbacks中回呼的時候先執行setter前函數
,這時並未執行render watcher的watcher.run
,所以印製DOM元素仍然是原來的內容。
setter後: setter後這時已經執行完flushSchedulerQueue
,這時render watcher已經把改變#patch
到視圖上,所以此時取得DOM是改過之後的內容。
Promise方式: 相當於 Promise.then
的方式執行這個函數,此時DOM已經改變。
setTimeout方式: 最後執行macro task的任務,此時DOM已經更改。
注意,在執行setter前函數
這個非同步任務之前,同步的程式碼已經執行完畢,異步的任務都還未執行,所有的 $nextTick
函數也執行完畢,所有回呼都被push進了callbacks佇列中等待執行,所以在setter前函數
執行的時候,此時callbacks佇列是這樣的:[ setter前函數
,flushSchedulerQueue
,setter後函數
,Promise方式函數
],它是一個micro task佇列,執行完畢之後執行macro task setTimeout
,所以印出上面的結果。
另外,如果瀏覽器的巨集任務佇列裡面有setImmediate
、MessageChannel
、setTimeout/setInterval
各種類型的任務,那麼會依照上面的順序挨個依照加入到event loop中的順序執行,所以如果瀏覽器支援MessageChannel
, nextTick
執行的是macroTimerFunc
,那麼如果macrotask queue中同時有nextTick
新增的任務和使用者自己新增的setTimeout
類型的任務,會優先執行nextTick
中的任務,因為MessageChannel
的優先權比setTimeout
的高,setImmediate
同理。
相關推薦:
以上是Vue源碼中批次非同步更新與nextTick原理的解析的詳細內容。更多資訊請關注PHP中文網其他相關文章!