1. 程式人生 > 其它 >vue原始碼中的nextTick是怎樣實現的

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 = 1counter 做文字節點的內容。

執行 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 的順序為 PromiseMutationObserversetTimeout

在2.5.0版本中實現 timerFunc 的順序改為 setImmediateMessageChannelsetTimeout
在這個版本把建立微任務的方法都移除,原因是微任務優先順序太高了,其中一個 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 都改為用建立巨集任務的方法實現,其順序是 setImmediateMessageChannelsetTimeout,這樣 nextTick 是個巨集任務。

點選事件是個巨集任務,當點選事件執行完後觸發的 nextTick(巨集任務)上的更新,只會在下一個事件迴圈中進行,這樣其事件冒泡早已執行完畢。就不會出現 BUG 中的情況。

但是過不久,實現 timerFunc 的順序又改為 PromiseMutationObserversetImmediatesetTimeout,在任何地方都使用巨集任務會產生一些很奇妙的問題,其中代表 issue 編號為 #6813,程式碼就打出來,可以看這裡。
這裡有兩個關鍵的控制

  • 媒體查詢,當頁面寬度大於 1000px 時,li 顯示型別為行內框,小於1000px時,顯示型別為塊級元素。
  • 監聽頁面縮放,當頁面寬度小於 1000px 時,ul 用 v-show="showList" 控制隱藏。

初始狀態:

當快速拖動網頁邊框縮小頁面寬度時,會先顯示下面第一張圖,然後快速的隱藏,而不是直接隱藏。

那為出現這種BUG,首先要了解一個概念,UI Render (UI渲染)的執行時機,如下所示:

    1. macro 取一個巨集任務。
    1. micro 清空微任務佇列。
    1. 判斷當前幀是否值得更新,否則重新進入1步驟
    1. 一幀欲繪製前,執行requestAnimationFrame佇列任務。
    1. UI更新,執行 UI Render。
    1. 如果巨集任務佇列不為空,重新進入步驟

這個過程也比較好理解,之前執行監聽視窗縮放是個巨集任務,當視窗大小小於 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.timeStampattachedTimestamp 小,如果讓冒泡事件執行,就會導致 #6566 BUG,所以只有冒泡事件的觸發比 nextTick 的執行晚才會避免此 BUG,故 e.timeStampattachedTimestamp 大才能執行冒泡事件。