引言:
前面核心篇說過Vue 運行時的核心主要包括數據初始化、數據更新、異步隊列、DOM渲染這幾個部分,理解異步隊列是理解數據更新非常重要的一部分,本文講一下Vue的異步隊列的思路以及實現原理,順帶講一下 Vue 的 $nextTick。
一、Vue的異步隊列是什么?
要弄懂這個概念首先看一個例子:
<div id="example"> <div>{{ words }}</div> <input type="button" @click="clickHanler" value="click"/> </div> var vm = new Vue({ el:"#example", data: { name: "Devin", greetings: "Hello" }, computed: { words: function(){ return this.greetings + ' ' + this.name + '!' } }, methods: { clickHanler(){ this.name = 'Devinn'; this.name = 'Devinnzhang'; this.greetings = 'Morning'; } } });
由前面的分析可以知道,此時 Vue 中創建了兩個 watcher,一個是渲染 watcher,負責渲染模板,一個是 computed watcher 負責計算屬性。當點擊 clickHandler 的時候數據發生變化會通知道兩個 watcher ,watcher進行更新。這里的 watcher 更新會有兩個問題:
1、渲染watcher 和 computed watcher 同時訂閱了變化的數據,哪一個先執行,執行順序是怎么樣的?
2、在一個事件循環中 name 的變化觸發了兩次,greetings 觸發了一次,對應兩個 watcher 一共執行了幾次?DOM渲染了幾次?
可見,在數據更新的階段,需要一個管理多個 watcher 進行更新的角色,這個角色需要保證 watcher 能夠按照正確的順序執行並且盡可能的減少執行次數(對應到渲染 watcher就是DOM渲染),Vue中的這個角色就是異步隊列。
下面講一下實現原理。
二、異步隊列實現原理
Vue的異步隊列是在數據更新時開啟的,那么從數據更新的邏輯開始看:
/** * Define a reactive property on an Object. */ function defineReactive$$1 ( obj, key, val, customSetter, shallow ) { var dep = new Dep(); var property = Object.getOwnPropertyDescriptor(obj, key); if (property && property.configurable === false) { return } // cater for pre-defined getter/setters var getter = property && property.get; var setter = property && property.set; if ((!getter || setter) && arguments.length === 2) { val = obj[key]; } var childOb = !shallow && observe(val); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { var value = getter ? getter.call(obj) : val; if (Dep.target) { dep.depend(); if (childOb) { childOb.dep.depend(); if (Array.isArray(value)) { dependArray(value); } } } return value }, set: function reactiveSetter (newVal) { var value = getter ? getter.call(obj) : val; /* eslint-disable no-self-compare */ if (newVal === value || (newVal !== newVal && value !== value)) { return } /* eslint-enable no-self-compare */ if (customSetter) { customSetter(); } // #7981: for accessor properties without setter if (getter && !setter) { return } if (setter) { setter.call(obj, newVal); } else { val = newVal; } childOb = !shallow && observe(newVal); dep.notify(); } }); }
Dep.prototype.notify = function notify () { // stabilize the subscriber list first var subs = this.subs.slice(); if (!config.async) { // subs aren't sorted in scheduler if not running async // we need to sort them now to make sure they fire in correct // order subs.sort(function (a, b) { return a.id - b.id; }); } for (var i = 0, l = subs.length; i < l; i++) { subs[i].update(); //watcher.update() } };
當數據發生變化時,會調用 dep.notity() 進而通知訂閱的 watcher 進行 更新。下面進入正題,看一下 watcher 更新的邏輯:
/** * Subscriber interface. * Will be called when a dependency changes. */ Watcher.prototype.update = function update () { /* istanbul ignore else */ if (this.lazy) { this.dirty = true; } else if (this.sync) { this.run(); } else { queueWatcher(this); } };
可以看到 update 中 watcher 並沒有立即執行( 同步的除外),而是調用了 queueWatcher (將更新的 watcher 加入到了一個隊列中),看下 queueWatcher 的實現:
/** * Push a watcher into the watcher queue. * Jobs with duplicate IDs will be skipped unless it's * pushed when the queue is being flushed. */ function queueWatcher (watcher) { var id = watcher.id; console.log('watcherId='+ id + 'exporession=' + watcher.expression); if (has[id] == null) { //console.log('watcherId='+ id + 'exporession=' + watcher.expression); has[id] = true; if (!flushing) { queue.push(watcher); } else { // if already flushing, splice the watcher based on its id // if already past its id, it will be run next immediately. var i = queue.length - 1; while (i > index && queue[i].id > watcher.id) { i--; } queue.splice(i + 1, 0, watcher); } // queue the flush if (!waiting) { waiting = true; if (!config.async) { flushSchedulerQueue(); return } nextTick(flushSchedulerQueue); } } }
這里的 queueWatcher 做了兩個事:
1、將 watcher 壓入隊列,重復的 watcher 知會被壓入一次,這樣在一個事件循環中 觸發了多次的 watcher 只會被壓入隊列一次。如例子中異步隊列中只有一個 渲染 watcher 和一個computed watcher;
2、調用 nextTick(flushSchedulerQueue) 對隊列中的 watcher 進行異步執行, nextTick 實現異步,flushSchedulerQueue 對 watcher 進行遍歷執行。
看一下 nextTick 的實現:
var callbacks = [];
---
function nextTick (cb, ctx) { //cb === flushSchedulerQueue var _resolve; callbacks.push(function () { if (cb) { try { cb.call(ctx); } catch (e) { handleError(e, ctx, 'nextTick'); } } else if (_resolve) { _resolve(ctx); } }); if (!pending) { pending = true; timerFunc(); } // $flow-disable-line if (!cb && typeof Promise !== 'undefined') { return new Promise(function (resolve) { _resolve = resolve; }) } }
nextTick 首先會將想要執行的函數 放在 callback 數組中,之后調用 timerFunc() 開啟異步線程對 push 到數組中的 函數一一執行
var timerFunc; if (typeof Promise !== 'undefined' && isNative(Promise)) { var p = Promise.resolve(); timerFunc = function () { p.then(flushCallbacks); if (isIOS) { setTimeout(noop); } }; isUsingMicroTask = true; } 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) var counter = 1; var observer = new MutationObserver(flushCallbacks); var textNode = document.createTextNode(String(counter)); observer.observe(textNode, { characterData: true }); timerFunc = function () { counter = (counter + 1) % 2; textNode.data = String(counter); }; isUsingMicroTask = true; } else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { // Fallback to setImmediate. // Techinically it leverages the (macro) task queue, // but it is still a better choice than setTimeout. timerFunc = function () { setImmediate(flushCallbacks); }; } else { // Fallback to setTimeout. timerFunc = function () { setTimeout(flushCallbacks, 0); }; }
前面是一些利用 Promise、MutationObserver (microtask),后面是利用 setImmediate、setTimeout (macrotask)的兼容實現,這里都是異步,理解上直接看最后 setTimeout 就可以了
timerFunc = function () { setTimeout(flushCallbacks, 0); }; --- var pending = false; function flushCallbacks () { pending = false; var copies = callbacks.slice(0); callbacks.length = 0; for (var i = 0; i < copies.length; i++) { copies[i](); } }
可以看到異步調用就是執行前面通過 nextTick 放入 callbacks 中的函數,Vue 中就是 flushSchedulerQueue。為什么說 Vue中呢?跟 $nextTick 接口有些關系,先看完 flushSchedulerQueue 再講。
/** * Flush both queues and run the watchers. */ function flushSchedulerQueue () { currentFlushTimestamp = getNow(); flushing = true; var watcher, id; // Sort queue before flush. // This ensures that: // 1. Components are updated from parent to child. (because parent is always // created before the child) // 2. A component's user watchers are run before its render watcher (because // user watchers are created before the render watcher) // 3. If a component is destroyed during a parent component's watcher run, // its watchers can be skipped. queue.sort(function (a, b) { return a.id - b.id; }); // do not cache length because more watchers might be pushed // as we run existing watchers for (index = 0; index < queue.length; index++) { watcher = queue[index]; if (watcher.before) { watcher.before(); } id = watcher.id; has[id] = null; watcher.run(); // in dev build, check and stop circular updates. if (has[id] != null) { circular[id] = (circular[id] || 0) + 1; if (circular[id] > MAX_UPDATE_COUNT) { //MAX_UPDATE_COUNT === 100 最大循環次數為100 warn( 'You may have an infinite update loop ' + ( watcher.user ? ("in watcher with expression \"" + (watcher.expression) + "\"") : "in a component render function." ), watcher.vm ); break } } } // keep copies of post queues before resetting state var activatedQueue = activatedChildren.slice(); var updatedQueue = queue.slice(); resetSchedulerState(); // call component updated and activated hooks callActivatedHooks(activatedQueue); callUpdatedHooks(updatedQueue); // devtool hook /* istanbul ignore if */ if (devtools && config.devtools) { devtools.emit('flush'); } }
flushSchedulerQueue 中首先會將隊列中所有的 watcher 按照 id 進行排序,之后再遍歷隊列依次執行其中的 watcher,排序的原因是要保證 watcher 按照正確的順序執行(watcher 之間的數據是可能存在依賴關系的,所以有執行的先后順訊,可以看下 watcher 的初始化順序)。此時的 flushSchedulerQueue 已經通過 nextTick(flushSchedulerQueue ) 變成了異步執行,這樣做的目的是在一個事件循環(clickHandler)中讓 flushSchedulerQueue 只執行一次,避免多次執行、渲染。
以上就是異步隊列的基本上實現原理。
ps:補充一下前面說的 nextTick
首先 nextTick 中 callBacks 是支持多個 cb 的,由於 queueWatcher 的調用,第一個 cb 就是 flushSchedulerQueue 了,並且在 queueWatcher 中 flushSchedulerQueue 沒有執行完是不允許再添加入 flushSchedulerQueue 的,所以有且只有一個 flushSchedulerQueue 在異步調用中,調用完之后才會執行下一個 cb。
Vue中開了一個接口 叫 $nextTick,通過在 $nextTick中傳入 cb,就可以等待DOM渲染后執行cb 操作,$nextTick 就是這里的 nextTick 函數了,之所以傳入的 cb 是在DOM渲染后執行就是因而已經執行了 flushSchedulerQueue 完成了 watcher 的執行、DOM的渲染。在 nextTick 中等待的執行就是這樣:
[flushSchedulerQueue , cb1, cb2, ...]
三、異步隊列設計的亮點:
異步隊列我認為比較精妙的設計有兩個部分:
第一、異步執行解決同一個事件循環多次渲染的難題,簡單卻極其有效;
第二、多個事件循環通過重復壓入的方式解決隊列中已執行過,但需要重新更新的 watcher 部分,保證數據更新的完整和正確性。改動了一下上面的例子理解一下:
<div id="example"> <div>{{ words }}</div> <input type="button" @click="clickHanler" value="click"/> </div> var i = 0; var vm = new Vue({ el:"#example", data: { name: "Mr", greetings: "Hello" }, computed: { words: function(){ return this.greetings + ' ' + this.name + '!' } }, methods: { clickHanler(){ this.name = 'Mr_' + i; this.greetings = 'Morning'; } } });
例子中每次點擊都會觸發異步隊列中 computed watcher 和渲染 watcher 的更新。由於更新是異步的,那么當我多次連續點擊的時候會有一種可能,就是異步隊列在前面的遍歷執行中已經執行了隊列中的部分 watcher ,比如 computed watcher,后續的點擊又需要更新這個 watcher,這時候改怎么辦?
Vue中用重復壓入隊列的方式解決的這個問題,就是如果已經執行過,那么在對列剩下的部分中再壓入一次,這樣需要更新的 watcher 就會在當前執行 watcher 的下一個來執行。
[ 1, 2, 3, 4, 5 ] [ 1, 2, 1, 2, 3, 4, 5 ] // 1,2 已執行過,此時會再次壓入隊列
邏輯實現在 queueWatcher 中
function queueWatcher (watcher) { var id = watcher.id; console.log('watcherId='+ id + 'exporession=' + watcher.expression); if (has[id] == null) { //待執行狀態 //console.log('watcherId='+ id + 'exporession=' + watcher.expression); has[id] = true; if (!flushing) { queue.push(watcher); } else { // if already flushing, splice the watcher based on its id // if already past its id, it will be run next immediately. var i = queue.length - 1; while (i > index && queue[i].id > watcher.id) { //index: 執行到 watcher 的index i--; } queue.splice(i + 1, 0, watcher); } // queue the flush if (!waiting) { waiting = true; if (!config.async) { flushSchedulerQueue(); return } nextTick(flushSchedulerQueue); } } }
總結:
本文從數據更新的角度講了一下Vue的異步隊列設計和實現原理,主要的過程是在更新的過程通過 queueWatcher 將各個 watcher 放入隊列中,之后統一通過 nextTick(flushSchedulerQueue) 異步對隊列中的 watcher 進行更新。總得來說,異步隊列主要解決了數據更新中多次觸發、多次渲染的問題,其中單一事件循環通過異步的方式來解決,多次事件循環通過重復壓入隊列的方式來保證數據更新的正確性和完整性。最后如果需要等待DOM的更新或者當前數據更新完畢后執行某些邏輯可以調用 $nextTick來實現。