实现组件 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

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

识别组件 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,真正的差异更新仍然交给前面已经实现过的 patchpatchElementpatchChildren

总结

  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