深刻理解nextTick

nextTick何時用

道理講再多,不如帶你真真切切的踩個坑。以下圖所示,一個門店後廚的統計頁面,在第一個「後廚出菜統計」模塊,要繪製每一個廚師出菜比例的餅圖。javascript

既然Vue是雙向數據綁定的,咱們直接把響應數據賦值給data後,視圖理應就會更新,可咱們緊接着經過DOM對象繪製圖表的時候,卻報錯找不到DOM元素。顯然Vue的數據賦值後,沒有當即去更新DOM。html

<template>
  <div>
    <p class="title">後廚出菜統計</p>
  </div>
  <div class="cook-item" v-for="(cook,index) in cookList" :key="'chef_list_'+cook.oid">
    <!-- 出菜信息 -->
    <div></div>
    <!-- 出菜圖表 -->
    <div style="flex: 1;background-color: #f8f8f8;border-radius: 6px;">
      <canvas :id="'pie_' + cook.oid"></canvas>
    </div>
  </div>
</template>


<script> import F2 from '@antv/f2'; const axios = require('axios').default; // 動態構造餅圖 function makePieChart(list, containerId) { // .... const pieChart = new F2.Chart({ id: containerId, pixelRatio: window.devicePixelRatio, padding: [20, 'auto'] }); // ... pieChart.render(); } export default { name: 'cookReport', props: { storeCode: String, // 門店編碼 date: String // 統計日期 }, data() { return { cookList: [] } }, mounted() { // 掛載完成後,發起請求數據 axios.get(`${process.env.VUE_APP_BASE_URL}/report?date=${this.date}&storeCode=${this.storeCode}`).then(res => { // 賦值 this.cookList = res.data // 直接調用 this.cookList.forEach(cook => { makePieChart(cook.pieDataList, `pie_${cook.oid}`) }) }) } } </script>
複製代碼

F2圖表繪製報錯:vue

Uncaught (in promise) TypeError: Cannot read property 'currentStyle' of null
    at getStyle (f2.js?e004:765)
    at getWidth (f2.js?e004:769)
    at Canvas._initCanvas (f2.js?e004:9848)
    at new Canvas (f2.js?e004:9801)
    at createCanvas (f2.js?e004:10031)
    at Chart._initCanvas (f2.js?e004:10490)
    at Chart._init (f2.js?e004:10567)
    at new Chart (f2.js?e004:10606)
    at makePieChart (StoreReport.vue?8e92:161)
    at eval (StoreReport.vue?8e92:293)
複製代碼

這是由於Vue採起的是異步更新策略,咱們把圖表繪製的代碼寫在nextTick裏,能正常運行了。java

mounted() {
  // 掛載完成後,發起請求數據
  axios.get(`${process.env.VUE_APP_BASE_URL}/report?date=${this.date}&storeCode=${this.storeCode}`).then(res => {
      // 賦值
      this.cookList = res.data
      // 等待DOM更新完成後,再調用
      this.$nextTick(() => {
        this.cookList.forEach(cook => {
          makePieChart(cook.pieDataList, `pie_${cook.oid}`)
        })
      });
  })
}
複製代碼

nextTick()的本質

  • 結合上面的例子,再理解官方文檔的解釋,就一目瞭然了:

在下次 DOM 更新循環結束以後執行延遲迴調。在修改數據以後當即使用這個方法,獲取更新後的 DOM。node

那麼問題來了,咱們不由要思考nextTick回調函數的本質是什麼?react

  1. nextTick,單詞next我知道是"下一個",那什麼是tick?
  2. nextTick明明是JS代碼,它怎麼就知道DOM何時更新呢?又是誰去通知它呢?
  3. nextTick是鉤子函數嘛,可否像mounted/watch生命週期函數的方式來使用?反正DOM更新就會通知調用不是嘛?

結合EventLoop理解nextTick()

解答第一個問題,就不得不提事件循環機制。ios

衆所周知,事件循環(EventLoop)是JS協調異步程序的機制,異步程序中又分爲宏任務和微任務,宏任務一般是和瀏覽器級別的,好比定時器/輪詢器/XHR等;微任務一般是ECMAScript級別的,好比promise/await+async/mutationObserver/processe.nextTick等; 宏任務和微任務都是以隊列(queue)的數據結構存在,宿主環境輪詢檢查執行棧(CallStack)的清空狀況,而後逐個放入宏任務執行,當前宏任務完後執行緊接着會執行微任務。git

如今能夠回答第一個問題了,每執行一個宏任務,就是一個tick。而nextTick()在語境上,能夠理解成"在下一個宏任務執行以前的回調函數"。github

nextTick()經過MutataionObsever監聽DOM的更新

接着解答第二個問題,DOM的增刪改查操做是同步執行的,對於DOM變化的監聽提供的MutationObsever API是異步的,而且屬於微任務。 一個簡單的MutationObsever示例:canvas

<body>
    <div id="container"></div>
    <script> // 廚師列表 let cookList = [{ name: '斯蒂芬周', oid: 10081 },{ name: '雞姐', oid: 10082 },{ name: '唐牛', oid: 10083 }]; // 模板容器 let $container = document.getElementById('container'); // 當容器內的chart DOM插入後,打印日誌 new MutationObserver(() => { console.log('大廚已就緒,能夠進行下一步圖表繪製工做!') }).observe($container, { childList: true, subtree: true }); // 遍歷數組,插入chart DIV cookList.forEach(cook => { let cookDiv = document.createElement('div'); cookDiv.setAttribute('id','cook_' + cook.oid); cookDiv.textContent = cook.name; $container.appendChild(cookDiv); }); </script>
</body>
複製代碼

nextTick()本質就是往微任務隊列中追加執行函數

接下來看第三個問題:若是像生命週期函數同樣,提早註冊nextTick是否可行呢?答案是否認的

mounted() {
  // 先註冊nextTick
  this.$nextTick(() => {
    console.log('DOM ready')
  });

  axios.get(`${process.env.VUE_APP_BASE_URL}/report?date=${this.date}&storeCode=${this.storeCode}`).then(res => {
      // 響應後對數據進行賦值
      this.cookList = res.data
      console.log('data assign')
  })
}
複製代碼

輸出的結果:沒等數據賦值,nextTick()就先執行了

> DOM ready
> data assign
複製代碼

結論:MutataionObsevernextTick都是微任務,在隊列中按順序執行。因此在業務代碼中,數據賦值與nextTick()老是成對出現,而且nextTick必須在數據賦值後面,不能像鉤子函數同樣提早註冊。

經過對比,認清本身

nextTickprocess.nextTick()的區別

process.nextTick是node.js的API,和Vue.nextTick同樣,都是往微任務追加執行函數


nextTickjQuery.ready()的區別

當我和後端小哥用廚師圖表渲染的例子,講解nextTick時,他當下反應是,「哦!這個我知道,和jQuery.ready()同樣嘛!」,我當時是一臉的黑人問號表情。這二者仍是不同的,jQuery.ready()是jQuery實例掛載到window的回調函數,函數裏面就能夠經過jQuery來操做DOM了。而Vue.nextTick()是響應數據綁定的視圖更新後的回調函數,函數裏面能夠操做DOM。


全局的Vue.nextTick與組件內this.nextTick的區別

當前組件和與全局Vue實例都指向src/core/util/next-tick.js同一個nextTick()函數


nextTickrequestAnimationFrame()的關係

仍是要回到EventLoop機制:

  1. 執行棧(Call Stack)清空後,放入宏任務隊列排在最前面的task,並執行它;
  2. 執行當前宏任務後,檢查是否存在微任務(Microtask),若是有,則輪詢執行,直到清空微任務隊列(包含嵌套產生的微任務);
  3. 檢查是否進行瀏覽器渲染;
  4. 如需渲染,先調用requestAnimationFrame函數;
  5. 而後執行瀏覽器更新渲染;
  6. 最後執行requestIdleCallback函數;
  7. 重複以上步驟

可見,nextTick只是微任務隊列中普通的函數,就是按順序執行。而requestAnimationFrame()取決於本次輪詢是否要進行更新渲染,在須要更新渲染前,調用執行。

nextTick實現原理

源碼走讀

/* @flow */
/* globals MutationObserver */
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'

// 標識-是否使用微任務執行回調函數
export let isUsingMicroTask = false 
// 數組-回調函數列表
const callbacks = []  
// 標識-是否有回調函數在執行中
let pending = false

// 輪詢callbacks數組,執行每一個回調函數,並清空數組
function flushCallbacks () {
  pending = false
  // 複製到局部變量
  const copies = callbacks.slice(0)
  // 清空原數組
  callbacks.length = 0
  // 輪詢執行每一個回調函數
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// 定義主邏輯的timerFunc方法
let timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  /** * 判斷是否支持Promise */ 
  const p = Promise.resolve()
  // 以Promise.then來處理flushCallbacks函數
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  // Promise屬於微任務,進行標識
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  /** * 判斷是否支持MutationObsever */ 
  let counter = 1
  // 以MutationObsever的監聽來處理flushCallbacks函數
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  // MutationObsever也屬於微任務,進行標識
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  /** * 判斷是否支持setImmediate */ 
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  /** * 以上方法都不支持,則經過定時器的宏任務來處理 */ 
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// 對外暴露nextTick方法,傳遞callback回調函數,以及執行環境
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve

  // 將回調函數包裝後,放入callbacks數組
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })

  if (!pending) {
    // 有微任務在執行時,先設置pending爲true進行等待
    pending = true
    timerFunc()
  }

  // 若是支持Promise,對外返回Promise類型
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
複製代碼

總結概述

  • 整個next-tick.js文件對外只暴露一個nextTick()方法,方法的參數就是業務代碼中的回調函數;
  • 外部每調用一次nextTick(),傳入的回調函數都會放入一個callbacks數組中,而後執行timerFun()異步方法;
  • timerFun()對環境進行判斷,是否支持微任務對象Promise/MutationObsever,若是不支持則經過setImmediate/setTimeout宏任務包裝成異步方法,回調處理flushCallbacks()
  • flushCallback()如方法名所寫,作的就是按順序遍歷執行每一個回調函數,並清空數組;

參考

未解決的疑惑

  • 既然tick是指宏任務之間的間隔,nextTick爲何不直接放到下一個宏任務,而是優先放入微任務隊列?
  • queueMicrotask()也是往微任務隊列中追加執行函數,nextTick爲何要用Promise來實現?
相關文章
相關標籤/搜索