阅读 1208

Vue3源码解析:nextTick

前言

本文是本人的一些拙见,如有错误请观众老爷们指出。

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>
    )
  }
})
复制代码

单元测试

快速了解源码的最好办法是阅读对应的单元测试。可以帮助我们快速的了解,每一个函数,每一个变量的具体含义和用法,以及一些边界情况的处理。

nextTick源码的文件目录位置:packages/runtime-core/src/scheduler.ts

nextTick单元测试文件的文件目录位置:packages/runtime-core/__tests__/scheduler.spec.ts

第一个单测

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会将参数按顺序保存到一个队列中,当宏任务执行完成后,微任务开始执行时,依次执行队列中的函数。

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
复制代码
关注下面的标签,发现更多相似文章
评论