2026.01.22

组件异步更新 -> Scheduler 与 nextTick 的设计与实现

在上一章中,我们实现了 Setup 返回渲染函数的能力。然而,在响应式数据多次变更时,每次变更都会触发一次组件更新,这会带来不必要的性能开销。

在上一章中,我们实现了 Setup 返回渲染函数的能力。然而,在响应式数据多次变更时,每次变更都会触发一次组件更新,这会带来不必要的性能开销。

Vue 通过异步更新机制来解决这个问题,将多次状态变更合并为一次更新。

问题场景

让我们先看一个多次触发渲染的典型场景:

<button id="btn">count++</button>
<div id="app"></div>
<script type="module">
    import { h, createApp, ref } from '../dist/vue.esm.js'

    const Comp = {
        setup(props, { attrs }) {
            const count = ref(0)

            btn.onclick = () => {
                // 连续两次修改 count
                count.value++
                count.value++
            }

            return () => {
                console.count('render') // 会打印两次
                return h('div', count.value)
            }
        }
    }

    createApp(Comp, { msg: 'msg', count: 0, a: 1 }).mount('#app')
</script>

核心问题:

  • 连续两次修改 count.value 会触发两次渲染
  • 控制台会打印两次 render
  • 第一次渲染是无效的,浪费性能

解决思路:

将同步的渲染调用改为异步,在同一个事件循环中多次触发更新时,只执行最后一次渲染。

当前渲染机制分析

在之前的实现中,mountComponent 的逻辑如下:

/**
 * 挂载组件
 */
function mountComponent(vnode, container, anchor) {
  const instance = createComponentInstance(vnode)
  setupComponent(instance)

  /**
   * 组件更新函数
   */
  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      const subTree = instance.render.call(instance.proxy)
      patch(null, subTree, container, anchor)
      instance.subTree = subTree
      instance.isMounted = true
    }
    else {
      const prevSubTree = instance.subTree
      const subTree = instance.render.call(instance.proxy)
      patch(prevSubTree, subTree, container, anchor)
      instance.subTree = subTree
    }
  }

  /**
   * 创建响应式 effect
   * 1. 将 componentUpdateFn 传递给 ReactiveEffect
   * 2. 在调用 run 时,会执行 componentUpdateFn
   * 3. componentUpdateFn 调用 render,render 访问了响应式属性
   * 4. 响应式属性变化时,render 会重新执行
   * 5. 这个过程是同步的
   */
  const effect = new ReactiveEffect(componentUpdateFn)
  effect.run()
}

工作流程:

ReactiveEffect.run()
  └─> componentUpdateFn()
       └─> render()
            └─> 访问响应式属性 (count.value)
                 └─> 触发依赖收集

响应式属性变化
  └─> 立即执行 componentUpdateFn (同步)
       └─> 重新渲染

问题所在:

  • 响应式属性变化时,会立即执行 componentUpdateFn
  • 每次属性变化都会触发一次渲染
  • 无法合并多次变更

引入调度器

Scheduler 基本概念

Vue 使用 Scheduler(调度器) 来控制组件更新的时机,将同步渲染改为异步渲染。

function mountComponent(vnode, container, anchor) {
  const instance = createComponentInstance(vnode)
  setupComponent(instance)

  const componentUpdateFn = () => {
    // ...
  }

  const effect = new ReactiveEffect(componentUpdateFn)
  const update = effect.run.bind(effect)

  /**
   * 设置调度器
   * 响应式属性变化时,不会立即执行 update
   * 而是通过 Promise 微任务异步执行
   */
  effect.scheduler = () => {
    Promise.resolve().then(() => {
      update()
    })
  }

  update()
}

核心设计:

  • effect.scheduler:当响应式属性变化时,不会立即执行副作用函数,而是执行调度器
  • Promise.resolve().then():使用微任务实现异步,确保在当前事件循环结束后执行
  • 同一事件循环中多次调用 scheduler,只会执行一次渲染

异步更新的优势

// 同步更新(之前)
count.value++ // 立即渲染 1
count.value++ // 立即渲染 2

// 异步更新(现在)
count.value++ // 调度渲染(未执行)
count.value++ // 调度渲染(未执行)
// 微任务队列执行:渲染 2(只渲染一次)

性能提升:

  • 同一事件循环中的多次状态变更,只触发一次渲染
  • 避免无效的中间状态渲染

新问题:DOM 访问时机

异步更新带来了新的问题:在修改响应式数据后立即访问 DOM,拿不到最新的内容。

问题场景

<button id="btn">count++</button>
<div id="app"></div>
<script type="module">
    import { h, createApp, ref } from '../dist/vue.esm.js'

    const Comp = {
        setup(props, { attrs }) {
            const count = ref(0)

            btn.onclick = () => {
                count.value++
                count.value++

                const container = document.querySelector('#container')
                console.log(container.textContent) // 输出旧值,因为渲染是异步的
            }

            return () => {
                return h('div', { id: 'container' }, count.value)
            }
        }
    }

    createApp(Comp, { msg: 'msg', count: 0, a: 1 }).mount('#app')
</script>

问题分析:

  • count.value++ 触发异步渲染
  • console.log 是同步执行的
  • 此时 DOM 还未更新,输出的是旧值

解决方案:nextTick

Vue 提供了 nextTick API,让开发者能够在 DOM 更新后执行回调函数。

btn.onclick = () => {
  count.value++
  count.value++

  nextTick(() => {
    const container = document.querySelector('#container')
    console.log(container.textContent) // 输出新值
  })
}

抽离代码

创建 scheduler 模块

我们将调度相关的代码抽取到独立模块 scheduler.ts 中:

/**
 * 调度任务到微任务队列
 * @param job - 要执行的任务函数
 */
export function queueJob(job) {
  Promise.resolve().then(() => {
    job()
  })
}

/**
 * 在下一个 DOM 更新周期后执行回调
 * @param fn - 要执行的回调函数
 */
export function nextTick(fn) {
  return Promise.resolve().then(() => fn.call(this))
}

核心设计:

  • queueJob:将任务调度到微任务队列
  • nextTick:返回一个 Promise,在微任务队列中执行回调
  • 使用 fn.call(this) 保留 this 指向

更新 mountComponent

/**
 * 挂载组件
 */
function mountComponent(vnode, container, anchor) {
  const instance = createComponentInstance(vnode)
  setupComponent(instance)

  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      const subTree = instance.render.call(instance.proxy)
      patch(null, subTree, container, anchor)
      instance.subTree = subTree
      instance.isMounted = true
    }
    else {
      const prevSubTree = instance.subTree
      const subTree = instance.render.call(instance.proxy)
      patch(prevSubTree, subTree, container, anchor)
      instance.subTree = subTree
    }
  }

  const effect = new ReactiveEffect(componentUpdateFn)
  const update = effect.run.bind(effect)

  /**
   * 将 update 保存到实例,供 $forceUpdate 使用
   */
  instance.update = update

  /**
   * 设置调度器
   * 响应式属性变化时,通过 queueJob 调度更新
   */
  effect.scheduler = () => {
    queueJob(update)
  }

  /**
   * 首次挂载,立即执行更新
   */
  update()
}

添加公共 API:$nextTick 与 $forceUpdate

Vue 提供了实例方法 $nextTick$forceUpdate,我们需要在公共属性映射表中添加它们。

更新公共属性映射表

/**
 * 公共属性映射表
 * 将 $attrs、$slots、$refs、$nextTick、$forceUpdate 映射到实例对应的属性
 */
const publicPropertiesMap = {
  $attrs: instance => instance.attrs,
  $slots: instance => instance.slots,
  $refs: instance => instance.refs,
  $nextTick: (instance) => {
    return nextTick.bind(instance)
  },
  $forceUpdate: (instance) => {
    return () => instance.update()
  }
}

API 说明:

  • this.$nextTick:在下一个 DOM 更新周期后执行回调
    • 通过 nextTick.bind(instance) 保留 this 指向组件实例
  • this.$forceUpdate:强制组件重新渲染
    • 返回一个函数,调用时执行 instance.update()

使用示例

<script type="module">
    import { h, createApp, ref } from '../dist/vue.esm.js'

    const Comp = {
        setup(props, { attrs }) {
            const count = ref(0)

            return {
                count,
                handleClick() {
                    this.count++
                    this.count++

                    // 使用 this.$nextTick
                    this.$nextTick(() => {
                        console.log('DOM 已更新:', this.count)
                    })

                    // 使用 this.$forceUpdate
                    // this.$forceUpdate()
                }
            }
        },
        render() {
            return h('div', { id: 'container', onClick: this.handleClick }, this.count)
        }
    }

    createApp(Comp, { msg: 'msg', count: 0, a: 1 }).mount('#app')
</script>

总结

至此,我们完成了 Vue 组件异步更新机制的核心实现:

1. 异步更新机制

  • 使用 scheduler 调度器控制渲染时机
  • 响应式属性变化时,不立即执行渲染
  • 通过 Promise.resolve().then() 实现微任务调度

2. 性能优化

  • 同一事件循环中的多次状态变更,只触发一次渲染
  • 避免无效的中间状态渲染
  • 提升组件更新性能

3. nextTick 实现

  • 提供 nextTick API,在 DOM 更新后执行回调
  • 使用微任务队列确保执行时机
  • 保留 this 指向,支持在回调中访问组件实例

4. 公共 API 扩展

  • $nextTick:组件实例的 nextTick 方法
  • $forceUpdate:强制组件重新渲染
  • 通过 publicPropertiesMap 映射表管理公共 API

5. 代码重构

  • 将调度相关逻辑抽取到 scheduler.ts
  • queueJob:调度任务到微任务队列
  • nextTick:在 DOM 更新后执行回调

这套异步更新机制让 Vue 能够高效地处理状态变更,避免不必要的渲染开销,同时通过 nextTick 提供了在 DOM 更新后执行回调的能力,是 Vue 3 性能优化的重要的一部分。