沐鳴代理:_從 Vue3 源碼中再談nextTick

開始之前先看下官方對其的定義

定義: 在下次 DOM 更新循環結束之後執行延遲回調。在修改數據之後立即使用這個方法,獲取更新后的 DOM

看完是不是有一堆問號?我們從中找出來產生問號的關鍵詞

  • 下次 DOM 更新循環結束之後?
  • 執行延遲回調?
  • 更新后的 DOM?

從上面三個疑問大膽猜想一下

  • vue 更新DOM是有策略的,不是同步更新
  • nextTick 可以接收一個函數做為入參
  • nextTick 后能拿到最新的數據

好了,問題都拋出來了,先來看一下如何使用

import { createApp, nextTick } from 'vue'
const app = createApp({
  setup() {
    const message = ref('Hello!')
    const changeMessage = async newMessage => {
      message.value = newMessage
      // 這裏獲取DOM的value是舊值
      await nextTick()
      // nextTick 后獲取DOM的value是更新后的值
      console.log('Now DOM is updated')
    }
  }
})

<a href=”https://vue3js.cn/run/nextTick” target=”_blank”>親自試一試</a>

那麼 nextTick 是怎麼做到的呢?為了後面的內容更好理解,這裏我們得從 js 的執行機制說起

JS執行機制

我們都知道 JS 是單線程語言,即指某一時間內只能幹一件事,有的同學可能會問,為什麼 JS 不能是多線程呢?多線程就能同一時間內干多件事情了

是否多線程這個取決於語言的用途,一個很簡單的例子,如果同一時間,一個添加了 DOM,一個刪除了 DOM, 這個時候語言就不知道是該添還是該刪了,所以從應用場景來看 JS 只能是單線程

單線程就意味着我們所有的任務都需要排隊,後面的任務必須等待前面的任務完成才能執行,如果前面的任務耗時很長,一些從用戶角度上不需要等待的任務就會一直等待,這個從體驗角度上來講是不可接受的,所以JS中就出現了異步的概念

概念

  • 同步 在主線程上排隊執行的任務,只有前一個任務執行完畢,才能執行后一個任務
  • 異步 不進入主線程、而進入”任務隊列”(task queue)的任務,只有”任務隊列”通知主線程,某個異步任務可以執行了,該任務才會進入主線程執行

運行機制

  • (1)所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)。

  • (2)主線程之外,還存在一個”任務隊列”(task queue)。只要異步任務有了運行結果,就在”任務隊列”之中放置一個事件。

  • (3)一旦”執行棧”中的所有同步任務執行完畢,系統就會讀取”任務隊列”,看看裏面有哪些事件。那些對應的異步任務,於是結束等待狀態,進入執行棧,開始執行。

  • (4)主線程不斷重複上面的第三步

nextTick

現在我們回來vue中的nextTick

實現很簡單,完全是基於語言執行機制實現,直接創建一個異步任務,那麼nextTick自然就達到在同步任務后執行的目的

const p = Promise.resolve()
export function nextTick(fn?: () => void): Promise<void> {
  return fn ? p.then(fn) : p
}

<a href=”https://vue3js.cn/run/nextTick-demo-1.html” target=”_blank”>親自試一試</a>

看到這裏,有的同學可能又會問,前面我們猜想的 DOM 更新也是異步任務,那他們的這個執行順序如何保證呢?

別急,在源碼中nextTick還有幾個兄弟函數,我們接着往下看

queueJob and queuePostFlushCb

queueJob 維護job列隊,有去重邏輯,保證任務的唯一性,每次調用去執行 queueFlush queuePostFlushCb 維護cb列隊,被調用的時候去重,每次調用去執行 queueFlush

const queue: (Job | null)[] = []
export function queueJob(job: Job) {
  // 去重 
  if (!queue.includes(job)) {
    queue.push(job)
    queueFlush()
  }
}

export function queuePostFlushCb(cb: Function | Function[]) {
  if (!isArray(cb)) {
    postFlushCbs.push(cb)
  } else {
    postFlushCbs.push(...cb)
  }
  queueFlush()
}

queueFlush

開啟異步任務(nextTick)處理 flushJobs

function queueFlush() {
  // 避免重複調用flushJobs
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    nextTick(flushJobs)
  }
}

flushJobs

處理列隊,先對列隊進行排序,執行queue中的job,處理完后再處理postFlushCbs, 如果隊列沒有被清空會遞歸調用flushJobs清空隊列

function flushJobs(seen?: CountMap) {
  isFlushPending = false
  isFlushing = true
  let job
  if (__DEV__) {
    seen = seen || new Map()
  }

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child so its render effect will have smaller
  //    priority number)
  // 2. If a component is unmounted during a parent component's update,
  //    its update can be skipped.
  // Jobs can never be null before flush starts, since they are only invalidated
  // during execution of another flushed job.
  queue.sort((a, b) => getId(a!) - getId(b!))

  while ((job = queue.shift()) !== undefined) {
    if (job === null) {
      continue
    }
    if (__DEV__) {
      checkRecursiveUpdates(seen!, job)
    }
    callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
  }
  flushPostFlushCbs(seen)
  isFlushing = false
  // some postFlushCb queued jobs!
  // keep flushing until it drains.
  if (queue.length || postFlushCbs.length) {
    flushJobs(seen)
  }
}

好了,實現全在上面了,好像還沒有解開我們的疑問,我們需要搞清楚 queueJob 及 queuePostFlushCb 是怎麼被調用的

//  renderer.ts
function createDevEffectOptions(
  instance: ComponentInternalInstance
): reactiveEffectOptions {
  return {
    scheduler: queueJob,
    onTrack: instance.rtc ? e => invokeArrayFns(instance.rtc!, e) : void 0,
    onTrigger: instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0
  }
}

// effect.ts
const run = (effect: ReactiveEffect) => {
  ...

  if (effect.options.scheduler) {
    effect.options.scheduler(effect)
  } else {
    effect()
  }
}

看到這裡有沒有恍然大悟的感覺?原來當響應式對象發生改變后,執行 effect 如果有 scheduler 這個參數,會執行這個 scheduler 函數,並且把 effect 當做參數傳入

繞口了,簡單點就是 queueJob(effect),嗯,清楚了,這也是數據發生改變后頁面不會立即更新的原因effect傳送門

為什麼要用nextTick

一個例子讓大家明白

{{num}}
for(let i=0; i<100000; i++){
	num = i
}

如果沒有 nextTick 更新機制,那麼 num 每次更新值都會觸發視圖更新,有了nextTick機制,只需要更新一次,所以為什麼有nextTick存在,相信大家心裏已經有答案了。

總結

nextTick 是 vue 中的更新策略,也是性能優化手段,基於JS執行機制實現

vue 中我們改變數據時不會立即觸發視圖,如果需要實時獲取到最新的DOM,這個時候可以手動調用 nextTick

站長推薦

1.雲服務推薦: 國內主流雲服務商,各類雲產品的最新活動,優惠券領取。地址:阿里雲騰訊雲華為雲

2.廣告聯盟: 整理了目前主流的廣告聯盟平台,如果你有流量,可以作為參考選擇適合你的平台點擊進入

鏈接: http://www.fly63.com/article/detial/9942