Vue3源碼解析:nextTick

前言

本文是本人的一些拙見,若有錯誤請觀衆老爺們指出。vue

nextTick

💭爲何須要nextTick?

咱們爲何須要nextTick?考慮以下的場景。若是每一次foo的變化,都會同步的觸發watch的更新。那麼,若是watch中包含了大量耗時的操做,則會形成嚴重的性能問題。因此在Vue的源碼中,watch的更新發生在nextTick以後。數組

const Demo = createComponent({
  setup() {
    const foo = ref(0)
    const bar = ref(0)
    const change = () => {
      for (let i = 0; i < 100; i++) {
        foo.value += 1
      }
    }
    watch(foo, () => {
      bar.value += 1
    }, {
      lazy: true
    })
    return { foo, bar, change }
  },

  render() {
    const { foo, bar, change } = this
    return (
      <div>
        <p>foo: {foo}</p>
        <p>bar: {bar}</p>
        {/* 點擊按鈕,bar實際上只會更新一次 */}
        <button onClick={change}>change</button>
      </div>
    )
  }
})
複製代碼

單元測試

快速瞭解源碼的最好辦法是閱讀對應的單元測試。能夠幫助咱們快速的瞭解,每個函數,每個變量的具體含義和用法,以及一些邊界狀況的處理。markdown

nextTick源碼的文件目錄位置:packages/runtime-core/src/scheduler.tsasync

nextTick單元測試文件的文件目錄位置:packages/runtime-core/__tests__/scheduler.spec.tside

第一個單測

nextTick會建立一個微任務。當宏任務job2執行完成後,清空微任務隊列,執行job1。此時calls數組的長度等於2。函數

it('nextTick', async () => {
  const calls: string[] = []
  const dummyThen = Promise.resolve().then()
  const job1 = () => {
    calls.push('job1')
  }
  const job2 = () => {
    calls.push('job2')
  }
  nextTick(job1)
  job2()
  expect(calls.length).toBe(1)
  // 等待微任務隊列被清空
  await dummyThen
  expect(calls.length).toBe(2)
  expect(calls).toMatchObject(['job2', 'job1'])
})
複製代碼

第二個單測

這裏涉及到一個新的函數,queueJob。目前還不清楚其內部的實現,不過咱們能夠從單測中能夠看出來。queueJob接受一個函數做爲參數,queueJob會將參數按順序保存到一個隊列中,當宏任務執行完成後,微任務開始執行時,依次執行隊列中的函數。post

it('basic usage', async () => {
  const calls: string[] = []
  const job1 = () => {
    calls.push('job1')
  }
  const job2 = () => {
    calls.push('job2')
  }
  queueJob(job1)
  queueJob(job2)
  expect(calls).toEqual([])
  await nextTick()
  // 按照順序執行
  expect(calls).toEqual(['job1', 'job2'])
})
複製代碼

第三個單測

queueJob會避免同一個函數(job),屢次push到隊列之中。queueJob包含了去重的處理。性能

it('should dedupe queued jobs', async () => {
  const calls: string[] = []
  const job1 = () => {
    calls.push('job1')
  }
  const job2 = () => {
    calls.push('job2')
  }
  queueJob(job1)
  queueJob(job2)
  queueJob(job1)
  queueJob(job2)
  expect(calls).toEqual([])
  await nextTick()
  expect(calls).toEqual(['job1', 'job2'])
})
複製代碼

第四個單測

若是queueJob(job2)的調用發生在job1的內部。job2將會在job1以後同一時間執行。不會等到下一次執行微任務時。單元測試

it('queueJob while flushing', async () => {
  const calls: string[] = []
  const job1 = () => {
    calls.push('job1')
    queueJob(job2)
  }
  const job2 = () => {
    calls.push('job2')
  }
  queueJob(job1)
  await nextTick()
  // job2會在同一個微任務隊列執行期間被執行
  expect(calls).toEqual(['job1', 'job2'])
})
複製代碼

第五個單測

這裏又出現了一個新的函數queuePostFlushCb。目前依然尚不清楚其內部的實現,不過咱們能夠從單測中能夠看出來,queuePostFlushCb接受函數做爲參數,或者由函數組成的數組做爲參數。測試

queuePostFlushCb, 會將參數依次保存到一個隊列中,當宏任務執行完成後,清空微任務隊列時,會依次執行隊列中的每個函數。

it('basic usage', async () => {
  const calls: string[] = []
  const cb1 = () => {
    calls.push('cb1')
  }
  const cb2 = () => {
    calls.push('cb2')
  }
  const cb3 = () => {
    calls.push('cb3')
  }
  queuePostFlushCb([cb1, cb2])
  queuePostFlushCb(cb3)
  expect(calls).toEqual([])
  await nextTick()
  // 按照添加隊列的順序,依次執行函數
  expect(calls).toEqual(['cb1', 'cb2', 'cb3'])
})
複製代碼

第六個單測

queuePostFlushCb不會將相同的函數,重複添加到隊列之中。

it('should dedupe queued postFlushCb', async () => {
  const calls: string[] = []
  const cb1 = () => {
    calls.push('cb1')
  }
  const cb2 = () => {
    calls.push('cb2')
  }
  const cb3 = () => {
    calls.push('cb3')
  }

  queuePostFlushCb([cb1, cb2])
  queuePostFlushCb(cb3)

  queuePostFlushCb([cb1, cb3])
  queuePostFlushCb(cb2)

  expect(calls).toEqual([])
  await nextTick()
  expect(calls).toEqual(['cb1', 'cb2', 'cb3'])
})
複製代碼

第七個單測

若是queuePostFlushCb(cb2)的調用發生在cb1的內部。cb2將會在cb1以後同一時間執行。不會等到下一次執行微任務時。

it('queuePostFlushCb while flushing', async () => {
  const calls: string[] = []
  const cb1 = () => {
    calls.push('cb1')
    queuePostFlushCb(cb2)
  }
  const cb2 = () => {
    calls.push('cb2')
  }
  queuePostFlushCb(cb1)
  await nextTick()
  expect(calls).toEqual(['cb1', 'cb2'])
})
複製代碼

第八個單測

容許在queuePostFlushCb中嵌套queueJob

it('queueJob inside postFlushCb', async () => {
  const calls: string[] = []
  const job1 = () => {
    calls.push('job1')
  }
  const cb1 = () => {
    calls.push('cb1')
    queueJob(job1)
  }
  queuePostFlushCb(cb1)
  await nextTick()
  expect(calls).toEqual(['cb1', 'job1'])
})
複製代碼

第九個單測

job1的執行順序高於cb2queueJob的優先級高於queuePostFlushCb

it('queueJob & postFlushCb inside postFlushCb', async () => {
  const calls: string[] = []
  const job1 = () => {
    calls.push('job1')
  }
  const cb1 = () => {
    calls.push('cb1')
    queuePostFlushCb(cb2)
    queueJob(job1)
  }
  const cb2 = () => {
    calls.push('cb2')
  }
  queuePostFlushCb(cb1)
  await nextTick()
  expect(calls).toEqual(['cb1', 'job1', 'cb2'])
})
複製代碼

第十個單測

容許在queueJob中嵌套queuePostFlushCb

it('postFlushCb inside queueJob', async () => {
  const calls: string[] = []
  const job1 = () => {
    calls.push('job1')
    queuePostFlushCb(cb1)
  }
  const cb1 = () => {
    calls.push('cb1')
  }
  queueJob(job1)
  await nextTick()
  expect(calls).toEqual(['job1', 'cb1'])
})
複製代碼

第十一個單試

job2將在cb1以前執行。queueJob優先級高於postFlushCb

it('queueJob & postFlushCb inside queueJob', async () => {
  const calls: string[] = []
  const job1 = () => {
    calls.push('job1')
    queuePostFlushCb(cb1)
    queueJob(job2)
  }
  const job2 = () => {
    calls.push('job2')
  }
  const cb1 = () => {
    calls.push('cb1')
  }
  queueJob(job1)
  await nextTick()
  expect(calls).toEqual(['job1', 'job2', 'cb1'])
})
複製代碼

總結

  1. nextTick接受函數做爲參數,同時nextTick會建立一個微任務。
  2. queueJob接受函數做爲參數,queueJob會將參數push到queue隊列中,在當前宏任務執行結束以後,清空隊列。
  3. queuePostFlushCb接受函數或者又函數組成的數組做爲參數,queuePostFlushCb會將將參數push到postFlushCbs隊列中,在當前宏任務執行結束以後,清空隊列。
  4. queueJob執行的優先級高於queuePostFlushCb
  5. queueJobqueuePostFlushCb容許在清空隊列的期間添加新的成員。

話很少說,咱們接下來直接看源碼。

源碼解析

// ErrorCodes 內部錯誤的類型枚舉
// callWithErrorHandling 包含了錯誤處理函數執行器
import { ErrorCodes, callWithErrorHandling } from './errorHandling'
import { isArray } from '@vue/shared'

// job隊列,queueJob函數會將參數添加到queue數組中
const queue: Function[] = []
// cb隊列,queuePostFlushCb函數會將參數添加到postFlushCbs數組中
const postFlushCbs: Function[] = []
// Promise對象狀態爲resolve
const p = Promise.resolve()
複製代碼

nextTick

nextTick很是簡單,建立一個微任務。在當前宏任務結束後,執行fn。

function nextTick(fn?: () => void): Promise<void> {
  return fn ? p.then(fn) : p
}
複製代碼

queueJob

job添加到queue隊列中。調用queueFlush開始處理隊列。

function queueJob(job: () => void) {
  // 避免重複的job添加到隊列中,實現了去重
  if (!queue.includes(job)) {
    queue.push(job)
    queueFlush()
  }
}
複製代碼

queuePostFlushCb

cb添加到postFlushCbs隊列中。調用queueFlush開始處理隊列。

function queuePostFlushCb(cb: Function | Function[]) {
  // 注意這裏,postFlushCbs隊列暫時沒有作去重的處理
  if (!isArray(cb)) {
    postFlushCbs.push(cb)
  } else {
    // 若是cb是數組,展開後。添加到postFlushCbs隊列中。
    postFlushCbs.push(...cb)
  }
  queueFlush()
}
複製代碼

queueFlush

queueFlush會調用nextTick開啓一個微任務。在當前宏任務執行完成後,使用flushJobs處理隊列queuepostFlushCbs

// isFlushing,isFlushPending做爲開關
let isFlushing = false
let isFlushPending = false

queueFlush() {
  if (!isFlushing && !isFlushPending) {
    // 將isFlushPending置爲true,避免queueJob和queuePostFlushCb重複調用flushJobs
    isFlushPending = true
    // 開啓微任務,宏任務結束後,flushJobs處理隊列
    nextTick(flushJobs)
  }
}
複製代碼

flushJobs中,會優先處理queue隊列,而後纔是postFlushCbs隊列

function flushJobs(seen?: CountMap) {
  isFlushPending = false
  isFlushing = true
  let job
  if (__DEV__) {
    seen = seen || new Map()
  }
  // 1. 清空queue隊列
  while ((job = queue.shift())) {
    if (__DEV__) {
      // 若是是開發環境,檢查job的調用次數是否超過最大遞歸次數
      checkRecursiveUpdates(seen!, job)
    }
    // 使用callWithErrorHandling執行器,執行queue隊列中的job
    // 若是job拋出錯誤,callWithErrorHandling執行器會對錯誤進行捕獲
    callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
  }
  // 2. 調用flushPostFlushCbs,處理postFlushCbs隊列
  flushPostFlushCbs(seen)
  isFlushing = false
  // 若是沒有queue,postFlushCbs隊列沒有被清空
  // 遞歸調用flushJobs清空隊列
  if (queue.length || postFlushCbs.length) {
    flushJobs(seen)
  }
}
複製代碼

flushPostFlushCbs會對postFlushCbs隊列進行去重。並清空postFlushCbs隊列。

// 使用Set,對postFlushCbs隊列進行去重
const dedupe = (cbs: Function[]): Function[] => [...new Set(cbs)]

function flushPostFlushCbs(seen?: CountMap) {
  if (postFlushCbs.length) {
    // postFlushCbs隊列去重
    const cbs = dedupe(postFlushCbs)
    postFlushCbs.length = 0
    if (__DEV__) {
      seen = seen || new Map()
    }
    // 清空postFlushCbs隊列
    for (let i = 0; i < cbs.length; i++) {
      if (__DEV__) {
        // 若是是開發環境,檢查cb的調用次數是否超過最大遞歸次數
        checkRecursiveUpdates(seen!, cbs[i])
      }
      // 執行cb
      cbs[i]()
    }
  }
}
複製代碼

checkRecursiveUpdates會是用Map,對job或者cb的調用次數進行記錄,若是同一個job或者cb的調用次數超過了100次,則認爲超過了最大遞歸次數,並拋出錯誤。

// 最大遞歸層數
const RECURSION_LIMIT = 100

type CountMap = Map<Function, number>

function checkRecursiveUpdates(seen: CountMap, fn: Function) {
  if (!seen.has(fn)) {
    seen.set(fn, 1)
  } else {
    const count = seen.get(fn)!
    // 若是調用次數超過了100次,拋出錯誤
    if (count > RECURSION_LIMIT) {
      throw new Error(
        'Maximum recursive updates exceeded. ' +
          "You may have code that is mutating state in your component's " +
          'render function or updated hook or watcher source function.'
      )
    } else {
      // 調用次數加一
      seen.set(fn, count + 1)
    }
  }
}
複製代碼

💭爲何須要使用checkRecursiveUpdates,對job或者cb的調用次數作檢查?

在Vue3中,watch的callback會在依賴更新後,被push到queue隊列中,在nextTick以後執行。考慮以下的代碼。foo的更新會致使watch的callback(update),反覆被push到queue隊列中,隊列永遠沒法被清空,這種狀況顯然是錯誤的。因此咱們須要使用checkRecursiveUpdates檢查遞歸的層數,及時的拋出錯誤。

const foo = ref(0)

const update = () => {
  foo.value += 1
}

watch(foo, update, {
  lazy: true
})

foo.value += 1
複製代碼
相關文章
相關標籤/搜索