Vue2非同步更新及nextTick原理

柏成發表於2023-04-09

vue 官網中是這樣描述 nextTick 的

在下次 DOM 更新迴圈結束之後執行延遲迴調。在修改資料之後立即使用這個方法,可以獲取更新後的 DOM。

在學習 nextTick 是如何實現之前,我們要先了解下 JavaScript 的執行機制

JavaScript 執行機制

瀏覽器是多執行緒的,例如GUI渲染執行緒、JS引擎執行緒、事件監聽執行緒等。。。

javascript 執行機制就是借用瀏覽器的多執行緒機制,再基於 Event Loop 事件迴圈機制實現的。其實現了單執行緒非同步效果

Event Loop 步驟大致如下:

  1. 瀏覽器載入頁面時,除了開闢堆疊記憶體外,還會建立兩個佇列
    • Web API:任務監聽佇列,監測非同步任務是否可以執行
    • Task Queue:任務佇列,分為非同步宏任務佇列和非同步微任務佇列
  2. 當主執行緒自上而下執行程式碼過程中,如果遇到非同步程式碼,則把非同步任務放到 Web API 中去監聽
    • 瀏覽器會開闢新的執行緒去監聽是否可以執行
    • 不會阻礙主執行緒的渲染,它會繼續向下執行同步程式碼
  3. 當非同步任務被監測為可以執行了(有了執行結果),也不會立即去執行,而是在 task queue 中放置一個事件,排隊等待執行
    • 根據微任務還是宏任務,放在不同的佇列中
    • 誰先進來排隊的,誰在各自隊伍的最前面
  4. 執行棧中的所有同步任務執行完畢,主執行緒空閒下來,此時會去 task queue 中把正在排隊的事件,按照順序取出來,進入主執行緒執行
    • 微任務優先順序比較高。當執行棧為空時,先去執行微任務佇列中的事件,直到微任務佇列為空,才會去執行宏任務佇列中的事件
  5. 上述過程會不斷重複,也就是常說的事件迴圈(Event Loop)

task 又分為宏任務(macro task)和微任務(micro task)兩大類,在瀏覽器環境中

  1. 常見的 macro task 有 script(整體程式碼)、setTimeout/setInterval/setImmediateXMLHttpRequest/fetch,DOM事件(如滑鼠點選、滾動頁面、放大縮小等),渲染事件(解析 DOM、計算佈局、繪製)
  2. 常見的 micro task 有 Promise.then/catch/finallyasync/awaitMutationObserver

需要注意的是!!!如果處理微任務的過程中有新的微任務新增進來了,新增的速度一直比執行快,則永遠執行微任務

下面的程式碼永遠不會列印宏任務輸出

function macroFn(){
   setTimeout(() => {
     console.log('>>>>MA')
   },0)
}
function microFn(){
    Promise.resolve().then(() => {
        console.log('mi')
        microFn()
    })
}
macroFn()
microFn()

nextTick實現原理

vue2.7 原始碼中,有一個單獨的檔案src/core/util/next-tick.js去維護 nextTick,有興趣的同學可以自行去觀看

vue2.7 原始碼中,nextTick並沒有直接使用某個 API ,而是採用了優雅降級的方案去實現非同步更新

在內部會嘗試使用原生的Promise.then (IE不支援)MutationObserversetImmediate (高版本IE專享),如果執行環境還不支援的話,則會採用 setTimeout(fn, 0)

需要注意的是,我們維護了一個 callbacks,用於儲存 nextTick 回撥

這樣就保證了在同一個 tick 內多次呼叫 nextTick,只需建立一個非同步任務,就可以依次執行 callbacks 中的所有 nextTick 回撥。而不是去開啟多個非同步任務去處理。

let callbacks = [] // 儲存 nextTick 回撥
let waiting = false // 防抖

// 按照順序依次執行 callbacks 中的方法
function flushCallbacks() {
  let cbs = callbacks.slice(0)
  waiting = false
  callbacks = []
  cbs.forEach(cb => cb()) 
}


let timerFunc;
if (Promise) {
    timerFunc = () => {
        Promise.resolve().then(flushCallbacks)
    }
}else if(MutationObserver){
    let observer = new MutationObserver(flushCallbacks); // 這裡傳入的回撥是非同步執行的
    let textNode = document.createTextNode(1);
    observer.observe(textNode,{
        characterData:true
    });
    timerFunc = () => {
        textNode.textContent = 2;
    }
}else if(setImmediate){
    timerFunc = () => {
       setImmediate(flushCallbacks);
    }
}else{
    timerFunc = () => {
        setTimeout(flushCallbacks);
     }
}

export function nextTick(cb) {
  callbacks.push(cb) // 維護 nextTick 中的 cakllback 方法
  
  if (!waiting) {
    timerFunc()
    waiting = true
  }
}

非同步更新

vue 內部的非同步更新渲染也使用了 nextTick

在 Watcher 類的 update 更新方法中,我們呼叫了 queueWatcher 非同步佇列更新方法,該方法在 vue2.7原始碼中的 src/core/util/scheduler.js 檔案中維護

import { queueWatcher } from './scheduler'

class Watcher {
	...
  // 重新渲染
  update() {
    console.log('watcher-update')
    queueWatcher(this) // watcher 非同步更新
  }
}

src/core/util/scheduler.js

import { nextTick } from '../util/next-tick'

/**
 * @name QueueWatcher,內部 watcher 非同步更新
 * @decs 把當前的 watcher 暫存起來,在一個tick週期內,不管我們的 update 執行多少次,只會執行一輪重新整理操作
 */

let queue = []
let has = {}
let pending = false // 防抖

function flushSchedulerQueue() {
  let flushQueue = queue.slice(0)
  queue = []
  has = {}
  pending = false
  flushQueue.forEach(q => q.run()) // 在重新整理的過程中可能還有新的 watcher,重新放到 queue 中
}

// 在一個tick週期內,不管我們的 update 執行多少次,只會執行一輪重新整理操作
export function queueWatcher(watcher) {
  const id = watcher.id
  if (!has[id]) {
    queue.push(watcher)
    has[id] = true
    if (!pending) {
      nextTick(flushSchedulerQueue)
      pending = true
    }
  }
}

常見問題

1. nexTick 是非同步還是同步?

這個不能一概而論,nextTick 內部既有同步程式碼又有非同步程式碼。

例如 維護 callbacks 佇列是同步任務;執行佇列中的方法是非同步任務

2. nextTick 回撥的執行是微任務還是宏任務?

針對 vue2.7 來說,nextTick並沒有直接使用某個 API ,而是採用了優雅降級的方案去實現非同步更新。
在內部會嘗試使用原生的Promise.then (微任務)MutationObserver (微任務)setImmediate (宏任務),如果執行環境還不支援的話,則會採用 setTimeout (宏任務)

可以理解為 99% 的場景下都是微任務,只有在不支援 Promise 和 MutationObserver API的瀏覽器中,才會是宏任務,例如 IE9 、IE10

3. 為什麼要封裝 nextTick?而不是使用某個具體的 API?

優雅降級。儘量使用微任務,儘可能縮短渲染週期

保證統一性。nextTick 可以暴露給使用者,保證使用者在修改資料之後立即使用這個方法,可以獲取更新後的 DOM

this.name = 'libc'

this.$nextTick(()=>{
  console.log(document.querySelector('.user').innerHTML)
});

參考檔案

這一次,徹底弄懂 JavaScript 執行機制

相關文章