第 16 章

组件挂载与更新:mountComponent → patch 的完整执行链路

第16章:组件挂载与更新——mountComponent → patch 的完整执行链路

每一次 ref.value = newValue 触发的组件更新,背后经历了至少 7 个函数调用层级,穿越了响应式系统、调度器、渲染器三个子系统——但整个过程发生在不到 1 毫秒之内。

本章核心问题:从 createApp().mount() 到第一个 DOM 节点出现,Vue 经历了哪些步骤?当响应式数据变化时,更新又是如何高效触发的?

读完本章你将理解

Level 1 · 你需要知道的(1-3年经验)

一句话理解 Vue 的更新机制

当你改变一个响应式数据时,Vue 不会立刻更新 DOM。它把更新操作放入一个队列,等当前 JavaScript 任务结束后(微任务时机),再一次性把所有积压的更新都执行完。

这就是为什么:

const count = ref(0)
count.value = 1
count.value = 2
count.value = 3
// DOM 只更新一次,不是三次
// 你看到的 DOM 里的值是 3,不是经历了 1→2→3 的三次变化

从 mount 到第一个 DOM 节点:6 个关键步骤

// 你写的代码
const app = createApp(App)
app.mount('#app')

// Vue 内部执行的关键步骤:
// 1. createApp()  → 创建应用实例,配置全局插件/组件/指令
// 2. mount()      → 找到 #app 元素,创建根 VNode,调用 render()
// 3. render()     → 调用 patch(null, rootVNode, container)
// 4. patch()      → 识别 VNode 类型,调用 mountComponent()
// 5. mountComponent() → 创建组件实例,执行 setup(),创建渲染副作用
// 6. setupRenderEffect() → 首次执行 render(),生成 subTree,递归 patch

理解异步更新队列的重要性

const { createApp, ref, nextTick } = Vue

const app = createApp({
  setup() {
    const count = ref(0)
    const domText = ref('')

    async function update() {
      count.value = 100

      // 此时 DOM 还没更新!
      console.log(document.getElementById('num').textContent) // "0"

      // 等待 DOM 更新完成
      await nextTick()

      // 现在 DOM 已更新
      console.log(document.getElementById('num').textContent) // "100"
    }

    return { count, domText, update }
  }
})

nextTick() 的本质是:在当前更新队列刷新完成后执行回调。它返回一个 Promise,你可以 await 它。

组件更新的两种触发方式

方式1:自身响应式数据变化

<script setup>
const count = ref(0)

function increment() {
  count.value++  // 触发自身更新
}
</script>

方式2:父组件传入新 props

<!-- 父组件 -->
<Child :value="parentCount" />

<!-- 当 parentCount 变化时,父组件更新 → 对 Child VNode 执行 patch
     → 如果 props 真的变了,Child 组件会被通知更新 -->

shouldUpdateComponent:优化子组件更新

Vue 不会盲目地更新所有子组件。在父组件更新时,Vue 会对比子组件的新旧 VNode,判断是否真的需要更新子组件:

// 父组件模板中
<Child :name="user.name" :age="user.age" />

// 如果 user.name 变了,但 user.age 没变
// Vue 会检查:新 VNode 的 props 和旧 VNode 的 props 是否有差异
// 有差异 → 更新子组件
// 无差异 → 跳过子组件更新(即使父组件自身更新了)

这个检查叫做 shouldUpdateComponent,它是 Vue 3 重要的性能优化点之一。


Level 2 · 它是怎么运行的(3-5年经验)

完整执行链路 ASCII 图

createApp(App).mount('#app')
│
├─► createApp(App)
│     ├── 创建 app 实例(appContext:全局配置容器)
│     ├── 注册全局组件/指令/插件
│     └── 返回 { mount, use, component, directive, ... }
│
└─► mount('#app')
      ├── hostQuerySelector('#app') → 找到宿主 DOM 元素
      ├── createVNode(App, {}) → 创建根组件 VNode
      └── render(vnode, container)
            │
            └─► patch(null, vnode, container)
                  │ n1=null 表示首次挂载
                  │ vnode.shapeFlag & COMPONENT → true
                  │
                  └─► mountComponent(vnode, container)
                        │
                        ├── createComponentInstance(vnode, parent)
                        │     └── 创建 ComponentInternalInstance 对象
                        │         { uid, type, parent, appContext,
                        │           props, slots, setupContext,
                        │           isMounted: false,
                        │           subTree: null,
                        │           effect: null, ... }
                        │
                        ├── setupComponent(instance)
                        │     ├── initProps()      → 处理 props 定义
                        │     ├── initSlots()      → 处理插槽
                        │     └── setupStatefulComponent()
                        │           ├── 设置 currentInstance = instance
                        │           ├── 调用 setup(props, setupContext)
                        │           │     → 用户的 setup() 函数在这里执行
                        │           │     → setup 返回值被处理
                        │           └── 清除 currentInstance = null
                        │
                        └── setupRenderEffect(instance, vnode, container)
                              │
                              └── 创建 ReactiveEffect(响应式副作用)
                                    │
                                    首次执行(同步):
                                    ├── 调用 componentUpdateFn()
                                    │     ├── 调用 render() → 生成 subTree VNode
                                    │     │   (在 render 执行期间,所有访问的
                                    │     │    响应式数据自动注册为依赖)
                                    │     ├── patch(null, subTree, container)
                                    │     │   → 递归挂载 subTree
                                    │     ├── 调用 onMounted 钩子
                                    │     └── instance.isMounted = true
                                    │
                                    后续执行(异步,由 scheduler 调度):
                                    └── 同上,但 patch(prevTree, subTree, ...)
                                        → 执行 diff + 精确更新

setupRenderEffect 的响应式连接原理

// packages/runtime-core/src/renderer.ts(简化)
const setupRenderEffect = (instance, initialVNode, container, anchor) => {
  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      // ─── 首次挂载 ───
      const { bm, m } = instance  // beforeMount, mounted 钩子

      // 调用 beforeMount 钩子
      if (bm) invokeArrayFns(bm)

      // 执行 render 函数,期间收集响应式依赖
      const subTree = (instance.subTree = renderComponentRoot(instance))

      // 递归挂载 subTree
      patch(null, subTree, container, anchor, instance)

      // 记录根 DOM 节点
      initialVNode.el = subTree.el

      instance.isMounted = true

      // 调用 mounted 钩子(在 nextTick 后执行)
      if (m) queuePostFlushCb(m)

    } else {
      // ─── 更新 ───
      let { next, bu, u, vnode } = instance
      // next 是父组件传来的新 VNode(props 更新场景)
      if (next) {
        next.el = vnode.el
        updateComponentPreRender(instance, next)
        // updateComponentPreRender 会更新 props 和 slots
      }

      // 调用 beforeUpdate 钩子
      if (bu) invokeArrayFns(bu)

      const nextTree = renderComponentRoot(instance)
      const prevTree = instance.subTree
      instance.subTree = nextTree

      // diff:对比新旧 subTree
      patch(prevTree, nextTree, container, anchor, instance)

      // 调用 updated 钩子
      if (u) queuePostFlushCb(u)
    }
  }

  // 创建响应式副作用
  // scheduler 控制副作用不立即重新执行,而是进入队列
  const effect = (instance.effect = new ReactiveEffect(
    componentUpdateFn,
    EMPTY_FN,
    () => queueJob(instance.update),  // scheduler:触发更新时,把 update 推入队列
    instance.scope  // 副作用作用域,用于统一清理
  ))

  const update = (instance.update = effect.run.bind(effect))
  update()  // 首次执行(同步),触发挂载
}

异步更新队列的完整实现

响应式数据变化 → 触发更新的完整路径:

count.value++
    │
    ├── reactive setter 被触发
    │
    ├── triggerEffects(dep)
    │     遍历所有订阅了 count 的 effect
    │     对于组件的 effect,不直接 run()
    │     而是调用 effect.scheduler()
    │
    └── effect.scheduler = () => queueJob(instance.update)
          │
          └── queueJob(job)
                ├── 检查 job 是否已在 queue 中(去重)
                │   使用 job.id 进行去重
                ├── 使用二分搜索按 id 排序插入
                └── 如果当前不在刷新中:
                    flushJobs() 被调度到微任务(Promise.resolve().then)

                    同一 tick 内多次修改:
                    count.value = 1 → queueJob (job已在队列,跳过)
                    count.value = 2 → queueJob (job已在队列,跳过)
                    count.value = 3 → queueJob (job已在队列,跳过)

                    微任务时机到来:
                    flushJobs() 执行 queue 中的所有 job
                    此时 count.value = 3
                    只执行一次渲染

shouldUpdateComponent 的判断逻辑

// packages/runtime-core/src/componentRenderUtils.ts(简化)
export function shouldUpdateComponent(prevVNode, nextVNode, optimized?) {
  const { props: prevProps, children: prevChildren } = prevVNode
  const { props: nextProps, children: nextChildren, patchFlag } = nextVNode

  // 有动态插槽,必须更新(插槽内容可能变化)
  if (patchFlag === PatchFlags.DYNAMIC_SLOTS) return true

  // props 引用相同(对象未变),不需要更新
  if (prevProps === nextProps) return false

  // 没有新 props,检查旧 props 是否为空
  if (!nextProps) return !!prevProps

  // 新旧 props 的 key 数量不同,必须更新
  const nextKeys = Object.keys(nextProps)
  if (nextKeys.length !== Object.keys(prevProps || {}).length) return true

  // 逐 key 对比 props 值
  for (let i = 0; i < nextKeys.length; i++) {
    const key = nextKeys[i]
    if (nextProps[key] !== prevProps![key] && !isEmitListener(prevVNode.type.emits, key)) {
      return true  // 发现 prop 变化,需要更新
    }
  }

  // 检查插槽变化
  if (prevChildren || nextChildren) {
    if (!nextChildren || !(nextChildren as any).$stable) {
      return true
    }
  }

  return false  // 所有 props/slots 都没变,跳过更新
}

patch 函数的分发逻辑

patch(n1, n2, container, anchor, parentComponent)

n2.type 判断:
┌──────────────────────────────────────────────────────────────────┐
│ switch(n2.type)                                                   │
│   case Text     → processText()                                   │
│   case Comment  → processCommentNode()                            │
│   case Fragment → processFragment()                               │
│   default:                                                        │
│     if (shapeFlag & ELEMENT)     → processElement()              │
│     if (shapeFlag & COMPONENT)   → processComponent()            │
│     if (shapeFlag & TELEPORT)    → type.process()                │
│     if (shapeFlag & SUSPENSE)    → type.process()                │
└──────────────────────────────────────────────────────────────────┘

processElement(n1, n2, container):
  if (n1 == null) → mountElement()   (首次挂载)
  else            → patchElement()   (更新)

processComponent(n1, n2, container):
  if (n1 == null) → mountComponent()   (首次挂载)
  else            → updateComponent()  (更新)

updateComponent(n1, n2):
  if (shouldUpdateComponent(n1, n2)):
    n2.component = n1.component
    instance.next = n2          ← 把新 VNode 挂到实例上
    invalidateMount(instance)   ← 标记需要更新
    instance.update()           ← 触发重渲染
  else:
    // 不需要更新,只复用旧 el
    n2.component = n1.component
    n2.el = n1.el

patchElement 的精确更新

// packages/runtime-core/src/renderer.ts(简化)
const patchElement = (n1, n2, ...) => {
  const el = (n2.el = n1.el)  // 复用真实 DOM 节点
  const oldProps = n1.props || {}
  const newProps = n2.props || {}

  // 优化路径:根据 patchFlag 精确更新
  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // 需要全量 props diff(有动态 key)
      patchProps(el, n2, oldProps, newProps, ...)
    } else {
      if (patchFlag & PatchFlags.CLASS) {
        if (oldProps.class !== newProps.class) {
          hostPatchProp(el, 'class', null, newProps.class)
        }
      }
      if (patchFlag & PatchFlags.STYLE) {
        hostPatchProp(el, 'style', oldProps.style, newProps.style)
      }
      if (patchFlag & PatchFlags.PROPS) {
        // 只对比 dynamicProps 中列出的 prop
        const propsToUpdate = n2.dynamicProps!
        for (let i = 0; i < propsToUpdate.length; i++) {
          const key = propsToUpdate[i]
          const prev = oldProps[key]
          const next = newProps[key]
          if (next !== prev || key === 'value') {
            hostPatchProp(el, key, prev, next, ...)
          }
        }
      }
    }
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  } else if (!optimized) {
    // 无 patchFlag,全量 props diff(动态 render 函数场景)
    patchProps(el, n2, oldProps, newProps, ...)
  }

  // 递归处理子节点
  patchChildren(n1, n2, el, null, ...)
}

Level 3 · 设计文档与源码(资深开发者)

ReactiveEffect 如何连接渲染器

// packages/reactivity/src/effect.ts(关键部分)
export class ReactiveEffect<T = any> {
  active = true
  deps: Dep[] = []         // 收集到的所有依赖(reactive 对象的属性)
  parent: ReactiveEffect | undefined = undefined

  constructor(
    public fn: () => T,          // 副作用函数(componentUpdateFn)
    public trigger: () => void,  // 触发时调用
    public scheduler?: EffectScheduler,  // 有 scheduler 则不立即 run
    scope?: EffectScope
  ) {
    // 注册到 scope 中,组件卸载时统一停止
    recordEffectScope(this, scope)
  }

  run() {
    if (!this.active) return this.fn()

    let parent: ReactiveEffect | undefined = activeEffect
    let lastShouldTrack = shouldTrack

    // 建立 effect 栈,支持嵌套 effect
    while (parent) {
      if (parent === this) return  // 防止循环
      parent = parent.parent
    }

    try {
      this.parent = activeEffect
      activeEffect = this   // ← 设置当前活跃 effect,所有响应式访问会注册到这里
      shouldTrack = true
      trackEffects(this.deps)  // 准备重新收集依赖
      return this.fn()         // 执行副作用函数(render)
    } finally {
      cleanupEffect(this)      // 清理旧依赖
      activeEffect = this.parent
      this.parent = undefined
      shouldTrack = lastShouldTrack
    }
  }

  stop() {
    if (this.active) {
      cleanupEffect(this)
      this.active = false
    }
  }
}

组件的 render 函数执行时,所有 reactive/ref 的访问会触发 track(),把当前 activeEffect(即组件的渲染 effect)注册到依赖中。当这些数据变化时,trigger() 调用 effect.scheduler,把更新推入队列。

组件卸载的完整链路

// packages/runtime-core/src/renderer.ts(unmountComponent)
const unmountComponent = (instance, doRemove?) => {
  const { bum, scope, update, subTree, um } = instance

  // 1. 调用 onBeforeUnmount 钩子
  if (bum) invokeArrayFns(bum)

  // 2. 停止响应式作用域(scope.stop() 停止所有 effect)
  //    包括组件自身的渲染 effect 和所有 watchEffect/watch
  scope.stop()

  // 3. 停止渲染 effect
  if (update) {
    update.active = false
    // 不再触发重新渲染
  }

  // 4. 递归卸载 subTree(触发子组件的 unmount 链)
  unmount(subTree, instance, ...)

  // 5. 调用 onUnmounted 钩子(在 DOM 移除后)
  if (um) queuePostFlushCb(um)

  // 6. 清理组件实例引用(帮助 GC)
  instance.isUnmounted = true
}

flushJobs 的排序策略:父组件先于子组件更新

// packages/runtime-core/src/scheduler.ts(关键逻辑)
function flushJobs(seen?) {
  // 按 job.id 排序:父组件的 id 小于子组件的 id(uid 递增生成)
  // 确保父组件先于子组件更新
  queue.sort((a, b) => getId(a) - getId(b))

  for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
    const job = queue[flushIndex]
    if (job && job.active !== false) {
      callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      // 注意:job 执行期间可能产生新的 job(子组件更新)
      // 这些新 job 会被追加到 queue 末尾,在当前循环中被处理
    }
  }

  // 刷新 postFlushCbs(onMounted、onUpdated 等在这里执行)
  flushPostFlushCbs(seen)
}

父组件先更新的原因:

queueJob 的去重机制

// packages/runtime-core/src/scheduler.ts
export function queueJob(job: SchedulerJob) {
  if (
    !queue.length ||
    !queue.includes(
      job,
      isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
    )
  ) {
    if (job.id == null) {
      queue.push(job)
    } else {
      // 按 id 大小插入(保持父先于子的顺序)
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
    queueFlush()
  }
}

function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    // Promise.resolve() 实现微任务调度
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

queue.includes() 是去重的关键。同一个 job(同一个组件的 update 函数)在队列中只会存在一次。


Level 4 · 边界与陷阱(全体适用)

陷阱1:在 onMounted 中同步修改响应式数据引发双重渲染

// ❌ 问题写法
onMounted(() => {
  count.value = 100  // 触发一次更新
  title.value = 'Hello'  // 再触发一次更新
  // 两次修改各触发一次 queueJob
  // 但 onMounted 在 postFlushCbs 中执行
  // 此时 flushJobs 已经在执行中
  // 这两个 job 会被追加到队列末尾,在当前循环结束前执行
  // 结果:DOM 在 onMounted 结束后会再更新一次
})

// ✓ 不那么糟糕(同一个 tick,但仍然是额外一次渲染)
onMounted(() => {
  // 如果必须在 mounted 时修改数据,考虑用 nextTick 推迟
  nextTick(() => {
    count.value = 100
  })
})

// ✓ 最佳:在 setup 中用 ref 初始化正确的值,避免在 mounted 修改

陷阱2:watch 的 flush 时机与 DOM 状态不一致

// watch 默认 flush: 'pre'(在组件更新前执行)
watch(count, (newVal) => {
  // 此时 DOM 还没更新!count 的值是新的,但 DOM 还是旧的
  console.log(document.getElementById('num').textContent) // 旧值
})

// 如果需要在 DOM 更新后访问,使用 flush: 'post'
watch(count, (newVal) => {
  // 此时 DOM 已更新
  console.log(document.getElementById('num').textContent) // 新值
}, { flush: 'post' })

// 或者直接使用 watchEffect 的别名:watchPostEffect
watchPostEffect(() => {
  console.log(document.getElementById('num').textContent)
})

陷阱3:update.active = false 导致的组件"假死"

// Vue 内部的错误处理有时会将 update.active 设为 false
// 如果组件在 render 过程中抛出异常(未被 errorCaptured 捕获)
// 组件的 effect 会被停止,组件进入"假死"状态

// 症状:响应式数据变化,但 DOM 不再更新
// 诊断:检查 instance.effect.active 是否为 false

// 正确处理异步错误:
const MyComp = defineComponent({
  setup() {
    // 这样写是错的:setup 里的 async 异常不会被捕获
    const data = await fetch('/api').then(r => r.json())  // ❌

    // 正确写法:用 ref + onMounted
    const data = ref(null)
    const error = ref(null)
    onMounted(async () => {
      try {
        data.value = await fetch('/api').then(r => r.json())
      } catch(e) {
        error.value = e
      }
    })
    return { data, error }
  }
})

陷阱4:组件更新期间修改子组件 props 的时序问题

// 场景:父组件在 onUpdated 中修改会影响子组件的数据

const parentData = ref('initial')

// 父组件
onUpdated(() => {
  // onUpdated 在子组件更新完成后才执行(postFlushCbs)
  // 在这里修改 parentData 会触发父组件的再次更新
  // 进而导致子组件再次更新
  parentData.value = 'changed in onUpdated'  // ⚠️ 可能引发无限循环

  // 安全的做法:只在特定条件下修改
  if (someCondition && parentData.value !== 'expected') {
    parentData.value = 'expected'
  }
})

陷阱5:nextTick 的 Promise 链时序

// nextTick 返回的 Promise 在 flushJobs 完成后 resolve
// 但 flushJobs 内部的 postFlushCbs(onMounted/onUpdated)是同步执行的
// 而 nextTick 的 then 回调是在 postFlushCbs 执行后的下一个微任务

// 时序:
// 1. 修改响应式数据
// 2. queueJob → isFlushPending = true
// 3. resolvedPromise.then(flushJobs) 注册微任务
// 4. 当前同步代码结束
// 5. 微任务:flushJobs() 执行(包括 postFlushCbs)
// 6. nextTick() 的 then 回调执行(在 flushJobs 内部的 Promise.resolve().then())

async function update() {
  data.value = 'new'
  await nextTick()
  // ← 此时 onMounted/onUpdated 已经执行完毕,DOM 也已更新
}

陷阱6:大量子组件同时更新时的性能问题

// 场景:一个父组件有 200 个子组件,父组件的一个数据变化
// 理论上会触发 200 个子组件的 updateComponent 检查

// Vue 的 shouldUpdateComponent 在大多数情况下能快速拒绝不需要更新的子组件
// 但"检查"本身也有成本

// 优化策略1:使用 v-memo
// <ChildComp v-memo="[item.id, item.name]" v-for="item in list" />
// 只有 item.id 或 item.name 变化时才重新渲染子组件

// 优化策略2:使用 shallowRef 代替 ref 减少深度追踪
const list = shallowRef([...])

// 优化策略3:对于纯展示组件使用 Object.freeze() 冻结 props
// frozen 对象不会被响应式系统追踪

本章小结

  1. 挂载是同步执行的createApp().mount() 触发的首次渲染是同步的,从 mountComponent 到 DOM 出现全在一个 JS 任务内完成;setupRenderEffect 内部的 update() 首次执行是同步调用(不经过 scheduler)。

  2. 更新是异步批量的:数据变化后,update job 进入 queue,通过 Promise.resolve().then(flushJobs) 在当前 task 结束后统一执行;同一组件在一个 tick 内无论触发多少次数据变化,只执行一次渲染。

  3. shouldUpdateComponent 是子组件更新的守门员:父组件更新时,Vue 会逐 prop 对比新旧 VNode,只有当 props 或 slots 真正变化时才触发子组件重渲染;这是 Vue 3 比 Vue 2 更高效的关键原因之一。

  4. patch 函数是渲染器的分发中心:它根据 VNode 的 shapeFlagtype 将工作分发给 processElement/processComponent/processFragment 等函数;patchElement 利用 patchFlag 跳过静态属性的对比,实现精确更新。

  5. 组件卸载需要停止所有副作用unmountComponent 调用 scope.stop() 停止组件作用域内的所有 effect(包括 watch、watchEffect 和渲染 effect),防止内存泄漏;onBeforeUnmount/onUnmounted 是在这个过程中按顺序调用的。

本章评分
4.5  / 5  (16 评分)

💬 留言讨论