vue原始碼中的nextTick是怎樣實現的
一、Vue.nextTick 內部邏輯
在執行 initGlobalAPI(Vue)
初始化 Vue 全域性 API 中,這麼定義 Vue.nextTick
。
function initGlobalAPI(Vue) {
//...
Vue.nextTick = nextTick;
}
可以看出是直接把 nextTick
函式賦值給 Vue.nextTick
,就可以了,非常簡單。
二、vm.$nextTick 內部邏輯
Vue.prototype.$nextTick = function (fn) {
return nextTick(fn, this)
};
可以看出是 vm.$nextTick
nextTick
函式。
三、前置知識
nextTick
函式的作用可以理解為非同步執行傳入的函式,這裡先介紹一下什麼是非同步執行,從 JS 執行機制說起。
1、JS 執行機制
JS 的執行是單執行緒的,所謂的單執行緒就是事件任務要排隊執行,前一個任務結束,才會執行後一個任務,這就是同步任務,為了避免前一個任務執行了很長時間還沒結束,那下一個任務就不能執行的情況,引入了非同步任務的概念。JS 執行機制簡單來說可以按以下幾個步驟。
- 所有同步任務都在主執行緒上執行,形成一個執行棧(execution context stack)。
- 主執行緒之外,還存在一個任務佇列(task queue)。只要非同步任務有了執行結果,會把其回撥函式作為一個任務新增到任務佇列中。
- 一旦執行棧中的所有同步任務執行完畢,就會讀取任務佇列,看看裡面有那些任務,將其新增到執行棧,開始執行。
- 主執行緒不斷重複上面的第三步。也就是常說的事件迴圈(Event Loop)。
2、非同步任務的型別
nextTick
函式非同步執行傳入的函式,是一個非同步任務。非同步任務分為兩種型別。
主執行緒的執行過程就是一個 tick,而所有的非同步任務都是通過任務佇列來一一執行。任務佇列中存放的是一個個的任務(task)。規範中規定 task 分為兩大類,分別是巨集任務(macro task)和微任務 (micro task),並且每個 macro task 結束後,都要清空所有的 micro task。
用一段程式碼形象介紹 task的執行順序。
for (macroTask of macroTaskQueue) {
handleMacroTask();
for (microTask of microTaskQueue) {
handleMicroTask(microTask);
}
}
在瀏覽器環境中,
常見的建立 macro task 的方法有
- setTimeout、setInterval、postMessage、MessageChannel(佇列優先於setTimeiout執行)
- 網路請求IO
- 頁面互動:DOM、滑鼠、鍵盤、滾動事件
- 頁面渲染
常見的建立 micro task 的方法 - Promise.then
- MutationObserve
- process.nexttick
在 nextTick
函式要利用這些方法把通過引數 cb
傳入的函式處理成非同步任務。
三、 nextTick 函式
var callbacks = [];
var pending = false;
function nextTick(cb, ctx) {
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();
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(function(resolve) {
_resolve = resolve;
})
}
}
可以看到在 nextTick
函式中把通過引數 cb
傳入的函式,做一下包裝然後 push 到 callbacks
陣列中。
然後用變數 pending
來保證執行一個事件迴圈中只執行一次 timerFunc()
。
最後執行 if (!cb && typeof Promise !== 'undefined')
,判斷引數 cb
不存在且瀏覽器支援 Promise,則返回一個 Promise 類例項化物件。例如 nextTick().then(() => {})
,當 _resolve
函式執行,就會執行 then 的邏輯中。
來看一下 timerFunc
函式的定義,先只看用 Promise 建立一個非同步執行的 ztimerFunc
函式 。
var timerFunc;
if (typeof Promise !== 'undefined' && isNative(Promise)) {
var p = Promise.resolve();
timerFunc = function() {
p.then(flushCallbacks);
if (isIOS) {
setTimeout(noop);
}
};
}
在其中發現 timerFunc
函式就是用各種非同步執行的方法呼叫 flushCallbacks
函式。
來看一下flushCallbacks
函式
var callbacks = [];
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]();
}
}
執行 pending = false
使下個事件迴圈中能nextTick
函式中呼叫 timerFunc
函式。
執行 var copies = callbacks.slice(0);callbacks.length = 0;
把要非同步執行的函式集合 callbacks
克隆到常量 copies
,然後把 callbacks
清空。
然後遍歷 copies
執行每一項函式。回到 nextTick
中是把通過引數 cb
傳入的函式包裝後 push 到 callbacks
集合中。來看一下怎麼包裝的。
function() {
if (cb) {
try {
cb.call(ctx);
} catch (e) {
handleError(e, ctx, 'nextTick');
}
} else if (_resolve) {
_resolve(ctx);
}
}
邏輯很簡單。若引數 cb
有值。在 try 語句中執行 cb.call(ctx)
,引數 ctx
是傳入函式的引數。
如果執行失敗執行 handleError(e, ctx, 'nextTick')
。
若引數 cb
沒有值。執行 _resolve(ctx)
,因為在nextTick
函式中如何引數 cb
沒有值,會返回一個 Promise 類例項化物件,那麼執行 _resolve(ctx)
,就會執行 then 的邏輯中。參考 Vue面試題詳細解答
到這裡 nextTice
函式的主線邏輯就很清楚了。定義一個變數 callbacks
,把通過引數 cb
傳入的函式用一個函式包裝一下,在這個中會執行傳入的函式,及處理執行失敗和引數 cb
不存在的場景,然後 新增到 callbacks
。呼叫 timerFunc
函式,在其中遍歷 callbacks
執行每個函式,因為 timerFunc
是一個非同步執行的函式,且定義一個變數 pending
來保證一個事件迴圈中只調用一次 timerFunc
函式。這樣就實現了 nextTice
函式非同步執行傳入的函式的作用了。
那麼其中的關鍵還是怎麼定義 timerFunc
函式。因為在各瀏覽器下對建立非同步執行函式的方法各不相同,要做相容處理,下面來介紹一下各種方法。
1、Promise 建立非同步執行函式
if (typeof Promise !== 'undefined' && isNative(Promise)) {
var p = Promise.resolve();
timerFunc = function() {
p.then(flushCallbacks);
if (isIOS) {
setTimeout(noop);
}
};
isUsingMicroTask = true;
}
執行 if (typeof Promise !== 'undefined' && isNative(Promise))
判斷瀏覽器是否支援 Promise,
其中 typeof Promise
支援的話為 function ,不是 undefined,故該條件滿足,這個條件好理解。
來看另一個條件,其中 isNative
方法是如何定義,程式碼如下。
function isNative(Ctor) {
return typeof Ctor === 'function' && /native code/.test(Ctor.toString())
}
當 Ctor
是函式型別時,執行 /native code/.test(Ctor.toString())
,檢測函式 toString 之後的字串中是否帶有 native code 片段,那為什麼要這麼監測。這是因為這裡的 toString 是 Function 的一個例項方法,如果是瀏覽器內建函式呼叫例項方法 toString 返回的結果是function Promise() { [native code] }
。
若瀏覽器支援,執行 var p = Promise.resolve()
,Promise.resolve()
方法允許呼叫時不帶引數,直接返回一個resolved狀態的 Promise 物件。
那麼在 timerFunc
函式中執行 p.then(flushCallbacks)
會直接執行 flushCallbacks
函式,在其中會遍歷去執行每個 nextTick
傳入的函式,因 Promise 是個微任務 (micro task)型別,故這些函式就變成非同步執行了。
執行 if (isIOS) { setTimeout(noop)}
來在 IOS 瀏覽器下新增空的計時器強制重新整理微任務佇列。
2、MutationObserver 建立非同步執行函式
if (!isIE && typeof MutationObserver !== 'undefined' &&
(isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
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;
}
MutationObserver() 建立並返回一個新的 MutationObserver 它會在指定的 DOM 發生變化時被呼叫,IE11瀏覽器才相容,故乾脆執行 !isIE
排除 IE瀏覽器。執行 typeof MutationObserver !== 'undefined' && (isNative(MutationObserver)
判斷,其原理在上面已介紹過了。執行 MutationObserver.toString() === '[object MutationObserverConstructor]')
這是對 PhantomJS 瀏覽器 和 iOS 7.x版本瀏覽器的支援情況進行判斷。
執行 var observer = new MutationObserver(flushCallbacks)
,建立一個新的 MutationObserver 賦值給常量 observer
, 並且把 flushCallbacks
作為回到函式傳入,當 observer
指定的 DOM 要監聽的屬性發生變化時會呼叫 flushCallbacks
函式。
執行 var textNode = document.createTextNode(String(counter))
建立一個文字節點。
執行 var counter = 1
,counter
做文字節點的內容。
執行 observer.observe(textNode, { characterData: true })
,呼叫 MutationObserver 的例項方法 observe
去監聽 textNode
文字節點的內容。
這裡很巧妙利用 counter = (counter + 1) % 2
,讓 counter
在 1 和 0 之間變化。再執行 textNode.data = String(counter)
把變化的 counter
設定為文字節點的內容。這樣 observer
會監測到它所觀察的文字節點的內容發生變化,就會呼叫 flushCallbacks
函式,在其中會遍歷去執行每個 nextTick
傳入的函式,因 MutationObserver 是個微任務 (micro task)型別,故這些函式就變成非同步執行了。
3、setImmediate 建立非同步執行函式
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = function() {
setImmediate(flushCallbacks);
};
}
setImmediate
只相容 IE10 以上瀏覽器,其他瀏覽器均不相容。其是個巨集任務 (macro task),消耗的資源比較小
4、setTimeout 建立非同步執行函式
timerFunc = function() {
setTimeout(flushCallbacks, 0);
}
相容 IE10 以下的瀏覽器,建立非同步任務,其是個巨集任務 (macro task),消耗資源較大。
5、建立非同步執行函式的順序
Vue 歷來版本中在 nextTick
函式中實現 timerFunc
的順序時做了幾次調整,直到 2.6+ 版本才穩定下來
第一版的 nextTick
函式中實現 timerFunc
的順序為 Promise
,MutationObserver
,setTimeout
。
在2.5.0版本中實現 timerFunc
的順序改為 setImmediate
,MessageChannel
,setTimeout
。
在這個版本把建立微任務的方法都移除,原因是微任務優先順序太高了,其中一個 issues 編號為 #6566, 情況如下:
<div class="header" v-if="expand"> // block 1
<i @click="expand = false;">Expand is True</i> // element 1
</div>
<div class="expand" v-if="!expand" @click="expand = true;"> // block 2
<i>Expand is False</i> // element 2
</div>
按正常邏輯 點選 element 1 時,會把 expand
置為 false
,block 1 不會顯示,而 block 2 會顯示,在點選 block 2 ,會把 expand
置為 false
,那麼 block 1 會顯示。
當時實際情況是 點選 element 1 ,只會顯示 block 1。這是為什麼,什麼原因引起這個BUG。Vue 官方是這麼解釋的
點選事件是巨集任務,
<i>
上的點選事件觸發 nextTick(微任務)上的第一次更新。在事件冒泡到外部div之前處理微任務。在更新過程中,將向外部div新增一個click偵聽器。因為DOM結構相同,所以外部div和內部元素都被重用。事件最終到達外部div,觸發由第一次更新新增的偵聽器,進而觸發第二次更新。為了解決這個問題,您可以簡單地給兩個外部div不同的鍵,以強制在更新期間替換它們。這將阻止接收冒泡事件。
當然當時官方還是給出瞭解決方案,把 timerFunc
都改為用建立巨集任務的方法實現,其順序是 setImmediate
,MessageChannel
,setTimeout
,這樣 nextTick 是個巨集任務。
點選事件是個巨集任務,當點選事件執行完後觸發的 nextTick(巨集任務)上的更新,只會在下一個事件迴圈中進行,這樣其事件冒泡早已執行完畢。就不會出現 BUG 中的情況。
但是過不久,實現 timerFunc
的順序又改為 Promise
,MutationObserver
,setImmediate
,setTimeout
,在任何地方都使用巨集任務會產生一些很奇妙的問題,其中代表 issue 編號為 #6813,程式碼就打出來,可以看這裡。
這裡有兩個關鍵的控制
- 媒體查詢,當頁面寬度大於 1000px 時,li 顯示型別為行內框,小於1000px時,顯示型別為塊級元素。
- 監聽頁面縮放,當頁面寬度小於 1000px 時,ul 用
v-show="showList"
控制隱藏。
初始狀態:
當快速拖動網頁邊框縮小頁面寬度時,會先顯示下面第一張圖,然後快速的隱藏,而不是直接隱藏。
那為出現這種BUG,首先要了解一個概念,UI Render (UI渲染)的執行時機,如下所示:
-
- macro 取一個巨集任務。
-
- micro 清空微任務佇列。
-
- 判斷當前幀是否值得更新,否則重新進入1步驟
-
- 一幀欲繪製前,執行requestAnimationFrame佇列任務。
-
- UI更新,執行 UI Render。
-
- 如果巨集任務佇列不為空,重新進入步驟
這個過程也比較好理解,之前執行監聽視窗縮放是個巨集任務,當視窗大小小於 1000px 時,showList
會變為 flase
,會觸發一個 nextTick 執行,而其是個巨集任務。在兩個巨集任務之間,會進行 UI Render ,這時,li 的行內框設定失效,展示為塊級框,在之後的 nextTick 這個巨集任務執行了,再一次 UI Render 時,ul 的 display 的值切換為 none,列表隱藏。
所以 Vue 覺得用微任務建立的 nextTick 可控性還可以,不像用巨集任務建立的 nextTick 會出現不可控場景。
在 2.6 + 版本中採用一個時間戳來解決 #6566 這個BUG,設定一個變數 attachedTimestamp
,在執行傳入 nextTick
函式中的 flushSchedulerQueue
函式時,執行 currentFlushTimestamp = getNow()
獲取一個時間戳賦值給變數 currentFlushTimestamp
,然後再監聽 DOM 上事件前做個劫持。其在 add
函式中實現。
function add(name, handler, capture, passive) {
if (useMicrotaskFix) {
var attachedTimestamp = currentFlushTimestamp;
var original = handler;
handler = original._wrapper = function(e) {
if (
e.target === e.currentTarget ||
e.timeStamp >= attachedTimestamp ||
e.timeStamp <= 0 ||
e.target.ownerDocument !== document
) {
return original.apply(this, arguments)
}
};
}
target.addEventListener(
name,
handler,
supportsPassive ? {
capture: capture,
passive: passive
} : capture
);
}
執行 if (useMicrotaskFix)
,useMicrotaskFix
在用微任務建立非同步執行函式時置為 true
。
執行 var attachedTimestamp = currentFlushTimestamp
把 nextTick 回撥函式執行時的時間戳賦值給變數 attachedTimestamp
,然後執行 if(e.timeStamp >= attachedTimestamp)
,其中 e.timeStamp
DOM 上的事件被觸發時的時間戳大於 attachedTimestamp
,這個事件才會被執行。
為什麼呢,回到 #6566 BUG 中。由於micro task的執行優先順序非常高,在 #6566 BUG 中比事件冒泡還要快,就會導致此 BUG 出現。當點選 i
標籤時觸發冒泡事件比 nextTick 的執行還早,那麼 e.timeStamp
比 attachedTimestamp
小,如果讓冒泡事件執行,就會導致 #6566 BUG,所以只有冒泡事件的觸發比 nextTick 的執行晚才會避免此 BUG,故 e.timeStamp
比 attachedTimestamp
大才能執行冒泡事件。