從 JS Event Loop 機制看 Vue 中 nextTick 的實現原理

GitChat技術雜談發表於2017-11-24

640.gif?wxfrom=5&wx_lazy=1

本文來自作者 大師兄 在 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中)等等。

這些我們稱之為非同步事件,當非同步事件發生時,將他們放入執行佇列,等待當前程式碼執行完成。就不會長時間阻塞主執行緒。

等主執行緒的程式碼執行完畢,然後再讀取任務佇列,返回主執行緒繼續處理。如此迴圈這就是事件迴圈機制。

總結一下:

  1. 我們可以認為某個同域瀏覽器上下文中 JavaScript 只有一個主執行緒、函式呼叫棧以及多個任務佇列。

  2. 主執行緒會依次執行程式碼,當遇到函式時,會先將函式入棧,函式執行完畢後再將該函式出棧,直到所有程式碼執行完畢。

  3. 當函式呼叫棧為空時,即會根據事件迴圈(Event Loop)機制來從任務佇列中提取出待執行的回撥並執行,執行的過程同樣會利用函式棧。

  4. 所有同屬一個的窗體都共享一個事件迴圈,所以它們可以同步交流。不同窗體之間相互獨立,互不干擾。

你如果想徹底研究清楚事件模型,那還需要了解如下知識:

  • Javascript 的佇列資料結構

  • Javascript 的執行上下文

  • 函式呼叫棧(call stack)

我們會分為兩節來學習,佇列資料結構為一節,執行上下文和函式呼叫棧合在一起為一節。

Javascript 的記憶體空間

佇列資料結構

我們知道 Javascript 中有兩種基本的種資料結構堆(heap)和棧(stack),還有一個佇列(queue),並不是嚴格意義上的資料結構。

棧資料結構

在我們平時的工作過程中我們寫 javascript 程式碼並不關心資料結構,但是它確實徹底理解某些執行機制的必不可少的部分。

JavaScript 中並沒有嚴格的去區分棧記憶體與堆記憶體。我們平時基本都認為JavaScript 的所有資料(變數、函式)都儲存在堆記憶體中。

但是在某些場景,我們仍然需要基於堆疊資料結構的思維(看好這裡是思維)來看待,比如 JavaScript 的執行上下文。

要簡單理解棧的存取方式,我們可以通過類比乒乓球盒子來分析。如下圖左側。

0?wx_fmt=png

我們用棧存取資料的方式類比成乒乓球的存放方式,處於盒子中最頂層的乒乓球5,它一定是最後被放進去,但可以最先被使用。

而我們想要使用底層的乒乓球1,就必須將上面的4個乒乓球取出來,讓乒乓球1處於盒子頂層。這就是棧空間先進後出,後進先出的特點。

堆資料結構

堆資料的存取資料的方式和與書架與書非常相似。

書架上放滿了不同的書,我們只要知道書的名字我們就可以很方便的取出,而不用像從乒乓球盒子裡取乒乓一樣,非得將上面的所有乒乓球拿出來才能取到中間的某一個乒乓球。

在 JSON 格式的資料中,我們儲存的 key-value 是可以無序的,我們並不關心順序,我只要通過 key 取出 value 即可。

佇列

在 JavaScript 中,理解佇列資料結構的目的主要是為了理解事件迴圈(Event Loop)的機制。在後續的章節中我會詳細分析事件迴圈機制。

佇列是一種先進先出(FIFO)的資料結構。正如排隊過安檢一樣,排在隊伍前面的人一定是最先過檢的人。用以下的圖示可以清楚的理解佇列的原理。

0?wx_fmt=png

執行上下文 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 來表示處理執行上下文組的堆疊。我們很容易知道,第一步,首先是全域性上下文入棧。

0?wx_fmt=png

全域性入棧後,開始執行可執行程式碼,直到遇到了 modifyName(),這一句啟用函式 modifyName 建立它自己的執行上下文,因此第二步就是 modifyName 的執行上下文入棧。

0?wx_fmt=png

modifyName 入棧之後,繼續執行函式內部可執行程式碼,遇到swapName()之後又啟用了 swapName 執行上下文。因此第三步是 swapName 的執行上下文入棧。

0?wx_fmt=png

在 swapName 的內部再沒有遇到其他能生成執行上下文的程式碼,因此這段程式碼順利執行完畢,swapName 的上下文從棧中彈出。

0?wx_fmt=png

swapName 的執行上下文出棧後,繼續執行 modifyName 其他可執行程式碼,也沒有再遇到其他執行上下文,順利執行完畢之後出棧。這樣,ECStack中就隻身下全域性上下文了。

0?wx_fmt=png

全域性上下文在瀏覽器視窗關閉後出棧。

注意:
第一、函式中,遇到return能直接終止可執行程式碼的執行,因此會直接將當前上下文彈出棧。
第二、不要把執行上下文和作用域鏈混為一談

如上我們演示了整個 modif.js 的執行過程。總結一下:

  • 單執行緒,依次自頂而下的執行,遇到函式就會建立函式執行上下文,併入棧

  • 同步執行,只有棧頂的上下文處於執行中,其他上下文需要等待

  • 全域性上下文只有唯一的一個,它在瀏覽器關閉時出棧

  • 函式的執行上下文的個數沒有限制

  • 每次某個函式被呼叫,就會有個新的執行上下文為其建立,即使是呼叫的自身函式,也是如此。

事件迴圈

現在我們知道 JavaScript 的單執行緒,以及這個執行緒中擁有唯一的一個事件迴圈機制。那什麼事件迴圈機制是什麼?且看下文分析。

JavaScript 程式碼的執行過程中,除了依靠函式呼叫棧來搞定函式的執行順序外,還依靠任務佇列(task queue)來搞定另外一些程式碼的執行。

根據上面的分析,任務佇列的特點是先進先出。

0?wx_fmt=png

一個 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。

0?wx_fmt=png

第二步、執行遇到了 Peomise,Promise 建構函式的回撥函式是同步執行,直接輸出2。它的 then 方法才是任務源,將會分發一個 job 任務。

new  Promise(function(resolve){        console.log(2);        resolve();    }).then(function(){        console.log(3)    })

0?wx_fmt=png

0?wx_fmt=png

第三步、執行到 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)        })    })

0?wx_fmt=png

第四步、執行遇到 process.nextTick, 一個 job 任務分發器,分發一個 job 任務。

process.nextTick(function(){      console.log(8) })

0?wx_fmt=png

第五步、執行遇到 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);           }) })

0?wx_fmt=png

這樣 script 程式碼的第一輪執行完畢,在執行的過程中會遇到不同的任務分發器,分發到對應的任務佇列。接下來將會執行所有的job佇列的任務。

注意:nextTick 任務佇列會比 Promise 的佇列先執行(別問為什麼,我也不知道)

這階段會依次輸出 8  3。

執行完所有的 job 任務後,就會迴圈下一次,從 task 開始,根據圖示,會先執行 Time_out1 佇列。

0?wx_fmt=png

task 任務的執行也是要藉助函式棧來完成,也就是說回到主執行緒。

會先依次輸出4和6,然後依次分發 nextTick2 和 promise_then2 兩個 job 任務。

0?wx_fmt=png

第一個 task 任務執行完不會立即執行其他 task 任務,會執行剛才被分發的job 任務

在這個過程中會依次輸出5和7

0?wx_fmt=png

現在就剩下一個 task 任務 setIm1,按照同樣的方式進行再次迴圈 。示意圖如下:

0?wx_fmt=png

以上階段會依次輸出9和10。

最後執行job任務,依次輸出12和11。

0?wx_fmt=png

完事,這個事件迴圈就完成了,我想是很清楚了。最終的輸出結果是:

1,2,8,3,4,6,5,7,9,10,12,11

最後我們用 node 執行一下我們寫的這個例子。結果如下:

0?wx_fmt=png

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 變動完成後,執行回撥函式。

具體有一下幾點變動的監聽:

  1. childList:子元素的變動

  2. attributes:屬性的變動

  3. characterData:節點內容或節點文字的變動

  4. 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

近期熱文

手把手教你如何向 Linux 核心提交程式碼

Java 實現 Web 應用中的定時任務

沉迷前端,無法自拔的人,如何規劃職業生涯?

TensorFlow 計算與智慧基礎

突破技術發展瓶頸、成功轉型的重要因素

Selenium 爬取評論資料,就是這麼簡單!

給你一個不學 Vue 的理由

0?wx_fmt=jpeg

「閱讀原文」看交流實錄,你想知道的都在這裡

相關文章