实现组件 vnode 的识别、实例创建、首次渲染和响应式更新

2025-10-29 11:35:33

前面已经实现了 createApp。

现在可以这样使用:

import { createApp, h, ref } from '../dist/vue.esm.js'

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

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

createApp(App).mount('#app')

但目前页面上还不会显示内容。

原因是 createApp(App).mount('#app') 最终会等价成:

const vnode = h(App)

render(vnode, container)

把组件对象传给 h,创建一个组件类型的 vnode,再交给 render。

之前的渲染流程只处理过普通元素,像 div、span,还没有处理组件,所以来补上组件的首次挂载。

识别组件 vnode

在创建 vnode 时,需要根据 type 区分它是普通元素还是组件。

如果 type 是字符串,说明它是普通元素:

h('div')

如果 type 是对象,说明它是一个组件:

h({
  setup() {
  },
  render() {
  }
})

所以在 createVNode 中补上组件类型的判断:

export function createVNode(type, props?, children = null) {
  let shapeFlag = 0

  if (isString(type)) {
    shapeFlag = ShapeFlags.ELEMENT
  }
  else if (isObject(type)) {
    shapeFlag = ShapeFlags.STATEFUL_COMPONENT
  }

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

  return vnode
}

patch 分发组件

有了组件类型之后,接下来需要让 patch 能识别组件。

组件和元素一样,也分为挂载和更新两个阶段,所以单独抽一个 processComponent 来处理:

function patch(n1, n2, container, anchor = null) {
  const { shapeFlag, type } = n2

  switch (type) {
    case Text:
      processText(n1, n2, container, anchor)
      break
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(n1, n2, container, anchor)
      }
      else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(n1, n2, container, anchor)
      }
  }
}

processComponent 里先处理首次挂载:

function processComponent(n1, n2, container, anchor) {
  if (n1 == null) {
    mountComponent(n2, container, anchor)
  }
  else {
    // 更新组件
  }
}

挂载组件

组件挂载大概分为三步:

  1. 创建组件实例
  2. 初始化组件状态
  3. 执行组件 render,拿到 subTree 后继续走 patch
function mountComponent(vnode, container, anchor) {
  const instance = createComponentInstance(vnode)

  setupComponent(instance)

  const subTree = instance.render.call(instance.setupState)

  patch(null, subTree, container, anchor)
}

这里有一个关键点:组件的 render 返回的不是实际 DOM,而是另一个 vnode。

比如:

const App = {
  render() {
    return h('div', 'hello')
  }
}

调用 render 后拿到的是:

h('div', 'hello')

这个返回值叫做 subTree,表示组件内部渲染出来的子树。

组件本身不直接创建真实节点,而是通过 patch(null, subTree, container, anchor) 把这棵子树挂载到页面上。

组件实例

组件相关逻辑继续放在 component.ts 中,避免 renderer.ts 越写越重。

实现 createComponentInstance:

// packages/runtime-core/src/component.ts
export function createComponentInstance(vnode) {
  const { type } = vnode

  const instance = {
    type,
    vnode,
    render: null,
    setupState: null,
    props: {},
    attrs: {},
    subTree: null,
    isMounted: false
  }

  return instance
}

这个实例是组件运行时的上下文,后面组件更新、props、插槽、生命周期等能力都会挂在它上面。

当前只需要关注两个字段:

  1. setupState:保存 setup 返回的状态
  2. render:保存组件的渲染函数

实现 setupComponent:

// packages/runtime-core/src/component.ts
import { proxyRefs } from '@vue/reactivity'

export function setupComponent(instance) {
  const { type } = instance

  const setupState = proxyRefs(type.setup())

  instance.setupState = setupState
  instance.render = type.render
}

setup 返回的对象里可能包含 ref:

setup()
{
  const count = ref(0)

  return {
    count
  }
}

如果直接在 render 中使用,就需要写成:

this.count.value

但在组件里通常希望直接写:

this.count

所以这里用 proxyRefs 包一层,让 render 访问 setupState 时可以自动解包 ref。

render 的 this

最后再看一下这行代码:

const subTree = instance.render.call(instance.setupState)

它的作用是让 render 函数里的 this 指向 setupState。

因此组件中可以这样读取状态:

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

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

执行过程是:

const setupState = proxyRefs({
  count: ref(0)
})

render.call(setupState)

所以 this.count 最终可以拿到 count.value。

组件更新

现在组件已经可以完成首次挂载了,但响应式更新还没有接上。

比如组件里有一个 count:

import { createApp, h, ref } from '../dist/vue.esm.js'

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

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

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

createApp(Com).mount('#app')

点击按钮时,count.value 确实变了,但页面不会跟着更新。

原因是:响应式数据变化后,需要通过 effect 重新执行依赖它的渲染逻辑。组件的 render 正好就是这段需要重新执行的逻辑。

所以可以把组件渲染封装成一个 componentUpdateFn,再交给 ReactiveEffect 执行:

import { ReactiveEffect } from '@vue/reactivity'

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

  setupComponent(instance)

  const componentUpdateFn = () => {
    const subTree = instance.render.call(instance.setupState)
    patch(null, subTree, container, anchor)
  }

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

这样首次执行时可以正常渲染,后续 count.value++ 时也会再次执行 componentUpdateFn。

但这时会出现一个新问题:每次更新都会往页面里追加一个新的节点。

0
1
2
3

原因是每次执行时都在调用:

patch(null, subTree, container, anchor)

第一个参数一直是 null,对 patch 来说,这就永远是一次全新的挂载,而不是更新。

所以组件实例上需要保存上一次渲染出来的 subTree,下一次更新时再把旧 subTree 和新 subTree 一起交给 patch:

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

  setupComponent(instance)

  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 prevTree = instance.subTree
      const subTree = instance.render.call(instance.setupState)

      patch(prevTree, subTree, container, anchor)

      instance.subTree = subTree
    }
  }

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

通过 isMounted 把组件渲染分成两种情况:

  1. 首次执行:没有旧节点,调用 patch(null, subTree, container, anchor) 完成挂载
  2. 后续执行:已经有旧节点,调用 patch(prevTree, subTree, container, anchor) 完成更新

组件更新本身并不直接操作 DOM,它只是重新执行 render 得到新的 subTree,真正的差异更新仍然交给前面已经实现过的 patch、patchElement 和 patchChildren。

总结

  1. h(App) 创建组件 vnode
  2. createVNode 通过对象类型标记组件 shapeFlag
  3. patch 根据 shapeFlag 分发到 processComponent
  4. mountComponent 创建实例并初始化组件状态
  5. 执行组件 render 得到 subTree
  6. 用 ReactiveEffect 包住组件渲染逻辑,让响应式数据变化时可以重新执行
  7. 首次渲染时调用 patch(null, subTree, container, anchor) 挂载组件子树
  8. 更新时调用 patch(prevTree, subTree, container, anchor) 复用旧节点并完成 diff