2026.01.19

组件挂载机制 -> 从创建到响应式更新

在上一章中,我们实现了 createApp API,建立了应用的初始化和挂载机制。然而,当我们尝试挂载一个组件时,会发现组件并没有真正渲染到页面上。这是因为我们还缺少组件挂载的核心逻辑。

在上一章中,我们实现了 createApp API,建立了应用的初始化和挂载机制。然而,当我们尝试挂载一个组件时,会发现组件并没有真正渲染到页面上。这是因为我们还缺少组件挂载的核心逻辑

本章将深入组件挂载的完整流程,实现从组件创建、实例化、渲染,到响应式更新的整个生命周期。

问题场景

让我们先看一个典型的组件使用场景:

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

    const Comp = {
        setup() {
            const count = ref(0)
            return {
                count
            }
        },
        render() {
            return h('div', this.count)
        }
    }

    createApp(Comp).mount('#app')
</script>
</body>

当我们运行这段代码时,会发现组件并没有渲染到页面上。这是因为我们的 patch 函数还不知道如何处理组件类型的虚拟节点

识别组件类型

扩展 ShapeFlags

首先,我们需要在创建虚拟节点时识别组件类型。在 createVNode 中增加对组件的支持:

/**
 * 标准化 children,确保数字等类型转换为字符串
 */
function normalizeChildren(children) {
  if (isNumber(children)) {
    children = String(children)
  }
  return children
}

/**
 * 创建虚拟节点,支持元素和组件类型
 */
export function createVNode(type, props?, children?) {
  children = normalizeChildren(children)

  let shapeFlag

  // 根据 type 的类型设置 shapeFlag
  if (isString(type)) {
    // 普通 HTML 元素
    shapeFlag = ShapeFlags.ELEMENT
  }
  else if (isObject(type)) {
    // 有状态组件(包含 setup、render 等方法的对象)
    shapeFlag = ShapeFlags.STATEFUL_COMPONENT
  }

  const vnode = {
    type,
    props,
    children,
    shapeFlag
    // ...
  }

  return vnode
}

设计要点:

  • 通过 ShapeFlags.STATEFUL_COMPONENT 标记组件类型
  • 区分元素 (isString) 和组件 (isObject)
  • 为后续的 patch 分发提供类型信息

实现组件渲染流程

patch 函数中的组件处理

组件的渲染与元素、文本节点一样,都需要经过挂载更新两个阶段。让我们在 patch 函数中添加组件的处理逻辑:

function patch(n1, n2, container, anchor = null) {
  // 如果新旧节点完全相同,无需处理
  if (n1 === n2)
    return

  // 如果节点类型发生变化,先卸载旧节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    unmount(n1)
    n1 = null
  }

  /**
   * 根据虚拟节点类型分发处理:文本、元素、组件
   */
  const { shapeFlag, type } = n2

  switch (type) {
    case Text:
      processText(n1, n2, container, anchor)
      break

    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // 处理普通 DOM 元素
        processElement(n1, n2, container, anchor)
      }
      else if (shapeFlag & ShapeFlags.COMPONENT) {
        // 处理组件
        processComponent(n1, n2, container, anchor)
      }
  }
}

核心设计:

  • 通过 shapeFlag 位运算快速判断节点类型
  • 统一的处理流程:processTextprocessElementprocessComponent
  • 每个 process 函数内部再区分挂载和更新

processComponent 的实现

创建 processComponent 函数来处理组件的挂载和更新:

/**
 * 组件实例结构
 */
function createComponentInstance(vnode) {
  const { type } = vnode
  return {
    type, // 组件对象(包含 setup、render 等)
    vnode, // 组件的虚拟节点
    render: null, // 渲染函数
    setupState: null, // setup 返回的响应式状态
    props: {}, // 组件 props
    attrs: {}, // 组件 attrs
    subTree: null, // 子树(render 函数返回的虚拟节点)
    isMounted: false // 是否已挂载
  }
}

/**
 * 挂载组件(初始版本)
 */
function mountComponent(vnode, container, anchor) {
  const instance = createComponentInstance(vnode)

  // 执行 setup 并获取响应式状态
  const setupResult = proxyRefs(vnode.type.setup())
  instance.setupState = setupResult
  instance.render = vnode.type.render

  // 调用 render 函数生成子树
  const subTree = instance.render.call(instance.setupState)

  // 渲染子树到页面
  patch(null, subTree, container, anchor)
  instance.subTree = subTree
}

/**
 * 处理组件的挂载和更新
 */
function processComponent(n1, n2, container, anchor) {
  if (n1 == null) {
    // 首次挂载
    mountComponent(n2, container, anchor)
  }
}

至此,组件已经可以成功挂载到页面了。

实现响应式更新

问题:响应式数据变化不触发重新渲染

让我们尝试添加一个按钮来更新组件状态:

<body>
  <div id="app"></div>
  <button id="btn">count++</button>

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

    const Comp = {
      setup() {
        const count = ref(0)

        // 点击按钮增加计数
        btn.onclick = () => {
          count.value++
        }

        return {
          count
        }
      },
      render() {
        return h('div', this.count)
      }
    }

    createApp(Comp).mount('#app')
  </script>
</body>

问题分析:

当我们点击按钮时,count.value 确实发生了变化,但页面并没有更新。这是因为:

  • 响应式数据变化时,只有包裹在 ReactiveEffect 中的函数才会重新执行
  • 我们的 render 函数目前并没有被 ReactiveEffect 包裹

解决方案:将组件渲染逻辑包裹在 effect 中

核心思路: 将 render 调用放入 ReactiveEffect,使其成为响应式副作用函数。

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

  const setupResult = proxyRefs(vnode.type.setup())
  instance.setupState = setupResult
  instance.render = vnode.type.render

  // 将渲染逻辑包装为响应式副作用
  const componentUpdateFn = () => {
    // 调用 render 生成子树,this 指向 setupState
    const subTree = instance.render.call(instance.setupState)
    // 渲染到页面
    patch(null, subTree, container, anchor)
  }

  // 创建响应式 effect
  const effect = new ReactiveEffect(componentUpdateFn)
  effect.run()
}

工作原理:

  1. ReactiveEffect 会在首次执行时收集依赖
  2. count.value 被读取时,会触发依赖收集
  3. count.value 变化时,会自动重新执行 componentUpdateFn

问题:重复渲染导致 DOM 累加

现在响应式更新可以工作了,但有一个新问题:每次更新都会在页面上追加新的 DOM 元素,而不是更新现有元素。

原因分析:

function componentUpdateFn() {
  const subTree = instance.render.call(instance.setupState)
  // 问题:每次都传递 null 作为旧节点,导致总是执行挂载而非更新
  patch(null, subTree, container, anchor)
}

区分挂载与更新

设计思路

我们需要在 componentUpdateFn 中区分两种情况:

  • 首次挂载n1 = null,执行挂载逻辑
  • 响应式更新n1 = prevSubTree,执行 patch 更新

完整实现

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

  const setupResult = proxyRefs(vnode.type.setup())
  instance.setupState = setupResult
  instance.render = vnode.type.render

  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      // === 首次挂载 ===
      const subTree = instance.render.call(instance.setupState)
      patch(null, subTree, container, anchor)

      // 保存子树和挂载状态
      instance.subTree = subTree
      instance.isMounted = true
    }
    else {
      // === 响应式更新 ===
      const prevSubTree = instance.subTree
      const subTree = instance.render.call(instance.setupState)

      // 传入旧节点进行 diff 更新
      patch(prevSubTree, subTree, container, anchor)

      // 更新子树引用
      instance.subTree = subTree
    }
  }

  const effect = new ReactiveEffect(componentUpdateFn)
  effect.run()
}

设计要点:

  • 使用 isMounted 标志位区分挂载和更新
  • 首次挂载时保存 subTree 供后续更新使用
  • 更新时传入 prevSubTree 和新的 subTree 进行 diff

代码重构:单一职责原则

架构优化

为了提高代码的可维护性,我们将组件实例相关的逻辑抽取到独立模块中:

  • 组件实例创建createComponentInstance
  • 组件初始化setupComponent

创建 component.ts

packages/runtime-core/src/component.ts 中:

import { proxyRefs } from '@vue/reactivity'

/**
 * 创建组件实例
 * @param vnode - 组件的虚拟节点
 * @returns 组件实例对象
 */
export function createComponentInstance(vnode) {
  const { type } = vnode

  const instance = {
    type, // 组件选项对象
    vnode, // 组件的虚拟节点
    render: null, // 渲染函数
    setupState: null, // setup 返回的响应式状态
    props: {}, // 组件 props
    attrs: {}, // 非 prop 的 attribute
    subTree: null, // 组件的子树虚拟节点
    isMounted: false // 是否已完成挂载
  }

  return instance
}

/**
 * 初始化组件实例
 * @param instance - 组件实例
 */
export function setupComponent(instance) {
  const { type } = instance

  // 执行 setup 并使用 proxyRefs 自动解包 ref
  const setupResult = proxyRefs(type.setup())
  instance.setupState = setupResult

  // 绑定 render 函数
  instance.render = type.render
}

设计优势:

  1. 关注点分离:组件实例管理独立于渲染逻辑
  2. 可测试性:可以单独测试组件实例的创建和初始化
  3. 可扩展性:后续添加生命周期、props 处理等功能时有清晰的入口

优化后的 mountComponent

import { createComponentInstance, setupComponent } from './component'

function mountComponent(vnode, container, anchor) {
  // 1. 创建组件实例
  const instance = createComponentInstance(vnode)

  // 2. 初始化组件(执行 setup、绑定 render)
  setupComponent(instance)

  // 3. 设置响应式副作用
  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      // 首次挂载
      const subTree = instance.render.call(instance.setupState)
      patch(null, subTree, container, anchor)
      instance.subTree = subTree
      instance.isMounted = true
    }
    else {
      // 响应式更新
      const prevSubTree = instance.subTree
      const subTree = instance.render.call(instance.setupState)
      patch(prevSubTree, subTree, container, anchor)
      instance.subTree = subTree
    }
  }

  const effect = new ReactiveEffect(componentUpdateFn)
  effect.run()
}

总结

至此,我们完成了 Vue 组件挂载和响应式更新的核心机制:

1. 组件类型识别

  • 通过 ShapeFlags.STATEFUL_COMPONENT 标记组件类型
  • patch 函数中分发到 processComponent

2. 组件实例化

  • createComponentInstance:创建组件实例
  • setupComponent:执行 setup、绑定 render

3. 响应式渲染

  • 使用 ReactiveEffect 包裹渲染逻辑
  • 响应式数据变化时自动触发重新渲染

4. 挂载与更新区分

  • 通过 isMounted 标志区分
  • 首次挂载:patch(null, subTree)
  • 响应式更新:patch(prevSubTree, subTree)

这套机制奠定了 Vue 组件系统的基础,后续我们将在此基础上实现组件的 props等特性。