从零实现 Vue 3 渲染器(一):组件初始化

用一个最小的教学版渲染器,理解 VNode、patch、组件实例与元素挂载如何串成 Vue 的首次渲染流程。

修订说明:这篇文章的初稿写于 2021 年。本次重新核对了 Vue 3.5.41 的文档与源码,修正了原文中的代码错误和架构表述。文中的实现仍然是为了讲清主流程而设计的最小版本,不是 Vue 源码的逐行复刻。

runtime-core 是 Vue 里很适合用来建立整体认识的一层。组件怎样变成 VNode,VNode 怎样交给渲染器,组件的 setuprender 又怎样参与首次渲染,这些问题都能在同一条链路上找到答案。

这一篇只处理初始化。更新调度、节点复用和 keyed diff 留到下一篇。

本文参考:

先把边界说清楚

runtime-core 本身并不知道浏览器 DOM。它描述组件、VNode 和通用渲染流程;真正的 document.createElement、属性更新和事件绑定在 runtime-dom 中实现,再作为宿主操作交给核心渲染器。这层分离让同一个 runtime core 不只可以渲染到浏览器,也可以被自定义渲染器用于其他宿主环境。

为了让代码容易阅读,本文会实现一个只面向浏览器的最小渲染器,但会把 DOM 操作收在 rendererOptions 中。这样既能看见实际发生了什么,也不会把平台能力误认为 runtime core 的职责。

从模板到 VNode

我们通常在单文件组件里写模板:

<template>
  <div>{{ msg }}</div>
</template>

<script>
export default {
  setup() {
    return { msg: 'hello world' }
  },
}
</script>

浏览器不会直接执行模板。构建时,Vue compiler 会把它转换成渲染函数。忽略编译器生成的优化指令后,可以把结果近似理解为:

import { h } from 'vue'

export default {
  setup() {
    return { msg: 'hello world' }
  },
  render() {
    return h('div', null, this.msg)
  },
}

真实的编译结果会使用 openBlockcreateElementBlock 等辅助函数,而不一定直接调用 h。这里保留 h,因为它更适合解释渲染函数的输入和输出。

h() 接收元素类型或组件、props 和 children,返回一个 VNode。它不是简单的名称替换,而是一个便于手写渲染函数的入口:先处理不同的参数形式,再委托给 createVNode()

const vnode = h('div', { id: 'message' }, 'hello world')

可以把得到的对象简化成:

const vnode = {
  type: 'div',
  props: { id: 'message' },
  children: 'hello world',
  el: null,
  shapeFlag: 0,
}

VNode 的价值不只是“用对象描述 DOM”。更重要的是,渲染器可以先处理一棵普通 JavaScript 对象树,再由宿主操作决定最终创建什么。更新时,新旧两棵树也有了可以比较的中间表示。

VNode 是一棵树

考虑一个手写的渲染函数:

render() {
  return h('div', null, [
    h('p', null, 'hello'),
    h('p', null, 'world'),
  ])
}

它产生的结构可以画成一棵树:

一个 div VNode 包含两个 p 子节点,两个 p 分别包含 hello 和 world 文本
渲染不是一次性创建某个节点,而是递归处理整棵 VNode 树。

数组里的元素 VNode 会继续展开;字符串 children 在需要时会被规范化为文本,或直接作为元素的文本内容处理。真实 Vue 还要处理 FragmentCommentStaticTeleportSuspense 等类型,本文先保留两条主干:组件和普通元素。

用位标记描述节点形状

渲染器经常需要回答两个问题:这是组件还是元素?它的 children 是文本还是数组?Vue 用可组合的数字位标记 shapeFlag 保存这些信息。

教学版只需要四个标记:

export const ShapeFlags = {
  ELEMENT: 1,
  STATEFUL_COMPONENT: 1 << 1,
  TEXT_CHILDREN: 1 << 2,
  ARRAY_CHILDREN: 1 << 3,
} as const

每一位代表一个独立事实,因此元素类型和 children 类型可以同时存在:

export function createVNode(type, props = null, children = null) {
  const vnode = {
    type,
    props,
    children,
    el: null,
    shapeFlag:
      typeof type === 'string'
        ? ShapeFlags.ELEMENT
        : ShapeFlags.STATEFUL_COMPONENT,
  }

  if (typeof children === 'string' || typeof children === 'number') {
    vnode.children = String(children)
    vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN
  }
  else if (Array.isArray(children)) {
    vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN
  }

  return vnode
}

export function h(type, props = null, children = null) {
  return createVNode(type, props, children)
}

检查时使用按位与:

if (vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
  // children 是数组
}

这和把类型存成字符串不同。一个 VNode 可以同时是 ELEMENTTEXT_CHILDREN,不需要在两个标签中二选一。

从 createApp 进入渲染器

应用从根组件开始:

const App = {
  setup() {
    return { msg: 'hello world' }
  },
  render() {
    return h('div', null, this.msg)
  },
}

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

最小的 createApp 只做两件事:把根组件包装成 VNode,然后交给 render

export function createApp(rootComponent) {
  return {
    mount(rootContainer) {
      const vnode = createVNode(rootComponent)
      render(vnode, rootContainer)
    },
  }
}

export function render(vnode, container) {
  patch(null, vnode, container)
}

patch 的前两个参数分别是旧节点和新节点。首次渲染没有旧节点,所以传入 null;更新时两者会同时存在。这一对参数把初始化和更新放进了同一条主流程。

patch 负责分流

patch 先判断 VNode 的形状,再把工作交给对应的处理函数:

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

  if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
    processComponent(n1, n2, container, parentComponent)
  }
  else if (shapeFlag & ShapeFlags.ELEMENT) {
    processElement(n1, n2, container, parentComponent)
  }
}

function processComponent(n1, n2, container, parentComponent) {
  if (n1 === null) {
    mountComponent(n2, container, parentComponent)
  }
  else {
    // 更新流程留到下一篇
  }
}

function processElement(n1, n2, container, parentComponent) {
  if (n1 === null) {
    mountElement(n2, container, parentComponent)
  }
  else {
    // 更新流程留到下一篇
  }
}

到这里,初始化已经分成两条路径:组件需要先得到自己的渲染结果,元素则需要创建宿主节点。

初始化组件

组件 VNode 的 type 保存的是组件定义。直接改写用户提供的对象会让内部状态和公开配置混在一起,因此渲染器会为每次组件使用创建一个实例。

创建组件实例

真实的组件实例包含 props、attrs、slots、provides、生命周期和更新任务等大量信息。最小版本先保存当前流程需要的字段:

function createComponentInstance(vnode, parent) {
  return {
    vnode,
    type: vnode.type,
    parent,
    setupState: {},
    render: null,
    proxy: null,
    subTree: null,
    isMounted: false,
  }
}

这里的 vnode.type 是用户写下的组件定义,instance 则是这一次挂载产生的内部状态。即使同一个组件被使用多次,每个位置也会拥有自己的实例。

运行 setup 并准备 render

接下来执行组件的 setup(),保存其返回值,并确定最终使用的渲染函数:

function setupComponent(instance) {
  const Component = instance.type
  const setupResult = Component.setup?.()

  if (typeof setupResult === 'function') {
    instance.render = setupResult
  }
  else if (setupResult && typeof setupResult === 'object') {
    instance.setupState = setupResult
  }

  instance.render ??= Component.render
  instance.proxy = new Proxy(instance, PublicInstanceProxyHandlers)
}

为了让 render() 中的 this.msg 可以读取 setup() 返回的 msg,教学版使用一个很小的代理:

const PublicInstanceProxyHandlers = {
  get(instance, key) {
    if (key in instance.setupState) {
      return instance.setupState[key]
    }
  },
}

真实 Vue 的公开实例代理还会处理 props、data、context 和 $el 等公开属性,也会缓存访问类型。这里先只保留 setupState

得到组件的子树

组件自己的 render() 返回另一个 VNode。我们把它记为 subTree,再把它交还给 patch

function mountComponent(initialVNode, container, parentComponent) {
  const instance = createComponentInstance(initialVNode, parentComponent)

  setupComponent(instance)
  setupRenderEffect(instance, initialVNode, container)
}

function setupRenderEffect(instance, initialVNode, container) {
  const subTree = instance.render.call(instance.proxy)

  instance.subTree = subTree
  patch(null, subTree, container, instance)
  initialVNode.el = subTree.el
  instance.isMounted = true
}

真实实现会把这段逻辑放进响应式 effect。依赖变化后,同一个 effect 会再次运行,生成新的子树,并调用 patch(oldTree, newTree, ...)。初始化和更新因此能够共享同一条入口。

初始化元素

元素 VNode 最终要变成宿主环境中的节点。先定义浏览器版本的宿主操作:

const rendererOptions = {
  createElement(type) {
    return document.createElement(type)
  },

  insert(el, parent, anchor = null) {
    parent.insertBefore(el, anchor)
  },

  setElementText(el, text) {
    el.textContent = text
  },

  patchProp(el, key, prevValue, nextValue) {
    if (/^on[A-Z]/.test(key)) {
      const eventName = key.slice(2).toLowerCase()
      el.addEventListener(eventName, nextValue)
    }
    else if (nextValue == null) {
      el.removeAttribute(key)
    }
    else {
      el.setAttribute(key, nextValue)
    }
  },
}

事件部分依然是教学简化版。Vue 的 runtime-dom 会使用事件 invoker 来正确处理监听器更新、数组监听器和时间戳等边界情况。

有了这些操作,mountElement 只负责渲染逻辑:

const {
  createElement: hostCreateElement,
  insert: hostInsert,
  setElementText: hostSetElementText,
  patchProp: hostPatchProp,
} = rendererOptions

function mountElement(vnode, container, parentComponent) {
  const el = (vnode.el = hostCreateElement(vnode.type))
  const { props, children, shapeFlag } = vnode

  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    hostSetElementText(el, children)
  }
  else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    mountChildren(children, el, parentComponent)
  }

  if (props) {
    for (const key in props) {
      hostPatchProp(el, key, null, props[key])
    }
  }

  hostInsert(el, container)
}

function mountChildren(children, container, parentComponent) {
  for (const child of children) {
    patch(null, child, container, parentComponent)
  }
}

数组 children 会再次进入 patch。如果子节点是组件,就先运行组件的渲染函数;如果是元素,就继续创建元素。递归会一直进行到所有叶子都被处理完。

根组件 VNode 进入 patch,按组件、元素和文本类型递归挂载的流程图
教学版初始化流程。真实 Vue 还会分派 Text、Comment、Fragment、Static、Teleport 和 Suspense。

把主流程重新连起来

现在可以从入口顺着读完整个过程:

  1. createApp(App).mount(container) 创建根组件 VNode。
  2. render()patch(null, vnode, container) 开始首次渲染。
  3. patch() 根据 shapeFlag 分派组件或元素。
  4. 组件经过 createComponentInstance()setupComponent(),运行 render() 得到子树。
  5. 子树再次进入 patch()
  6. 元素通过宿主操作创建,props 和 children 被依次挂载。
  7. 数组 children 继续递归,直到整棵树完成。

这个最小实现还没有 props 与 emits、slots、生命周期、provide/inject、调度器、卸载、Fragment 和 hydration。它也没有处理任何更新。但骨架已经出现了:组件负责产生 VNode,渲染器负责解释 VNode,宿主操作负责把解释结果落到具体平台。

几年后重新读这篇文章,我发现真正值得留下来的不是某个函数当时位于源码的第几行,而是这三层关系。函数会移动,优化策略会继续变化;只要边界仍然清楚,新的实现就依然有入口可循。