技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

手写 Vue 2 系列:初始渲染

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/79-手写 Vue2 系列 之 初始渲染
本文目录15 个章节
  1. 一、从模板到首次渲染
  2. 二、mount 和渲染 Watcher
  3. 三、vm._render 和 render helper
  4. 四、VNode 是什么
  5. 五、vm._update:首次挂载与后续更新
  6. 六、patch 的首次渲染分支
  7. 七、createElm:递归创建真实节点
  8. 八、指令和普通属性
  9. 九、创建子组件
  10. 十、保留标签和组件解析
  11. 十一、插槽的原理
  12. 十二、初始渲染结果
  13. 十三、Vue 2 与 Vue 3 的差异
  14. 总结
  15. 参考资料

手写 Vue 2 系列:初始渲染

原文试图从编译器产出的 render function 继续实现 Vue 2 的首次挂载,覆盖 render helper、VNode、patch、指令、组件和插槽。本文保留这条源码学习路线,并把抓取后粘连的代码整理为可读的教学伪实现。它对应 Vue 2.7 的历史架构,不是 Vue 3.5 的源码,也不能直接替代 Vue。

一、从模板到首次渲染

上一篇编译器文章完成了模板到 render function 的过程。得到 render function 后,首次渲染可以抽象为:

$mount
  ↓
创建渲染 watcher
  ↓
执行 updateComponent
  ↓
vm._render() 生成根 VNode
  ↓
vm._update() 调用 patch
  ↓
递归创建元素、文本和组件
  ↓
处理属性、事件、指令和插槽
  ↓
真实 DOM 插入容器并替换挂载元素

本文会实现或说明这些概念:

  • render helper 和 VNode;
  • 根组件的初始 patch;
  • 元素、文本和组件的 createElm
  • v-modelv-bindv-on 的编译后形态;
  • 子组件实例和插槽的初始化。

文章中的 src/compiler 等路径是教学项目的目录,不等于 Vue 官方仓库的精确路径。Vue 2 官方源码会把生命周期、渲染、平台 patch 模块拆到不同目录。

二、mount 和渲染 Watcher

1. mount

$mount 需要先确保组件有 render function。Vue 2 Runtime + Compiler 构建在只有 template 时会先编译模板;只有 Runtime 构建则要求调用者在构建阶段提供 render function。

export function mount(vm) {
  if (!vm.$options.render) {
    // Vue 2 Runtime + Compiler 构建:
    // vm.$options.render = compileToFunctions(vm.$options.template)
    throw new Error('当前构建需要 render function')
  }

  mountComponent(vm)
}

2. mountComponent

真正的首次渲染通常由渲染 watcher 驱动。Vue 2 源码中的 watcher 会在首次求值时执行 updateComponent,在 render 期间读取响应式数据,从而完成依赖收集。

export function mountComponent(vm) {
  const updateComponent = () => {
    vm._update(vm._render())
  }

  vm._watcher = new Watcher(
    vm,
    updateComponent,
    () => {},
    { isRenderWatcher: true },
  )
}

简化示例省略了 beforeMountmounted、hydration、错误处理和异步更新。真实 Vue 2 会在 watcher 建立后调用相应生命周期,并把后续 watcher 更新交给 scheduler 批量处理。

三、vm._render 和 render helper

1. 执行 render function

render function 中的 this 必须指向当前组件实例。Vue 2 编译器生成的代码通常会使用 _c_v_s 等 helper;用户手写 render function 则会收到 h/createElement 参数。

Vue.prototype._render = function _render() {
  const render = this.$options.render
  const vnode = render.call(this, this.$createElement)

  if (Array.isArray(vnode)) {
    throw new Error('简化实现只允许一个根 VNode')
  }

  vnode.parent = this.$vnode || null
  return vnode
}

Vue 2.7 的实际实现还要处理函数式组件、多个根节点兼容行为、错误占位 VNode、作用域 slot 和开发环境代理。这里仅保留主线。

2. 安装 helper

export function installRenderHelpers(Vue) {
  Vue.prototype._c = function createCompiledElement(tag, data, children) {
    return createElement(this, tag, data, children)
  }

  Vue.prototype._v = function createTextVNode(text) {
    return new VNode(undefined, undefined, undefined, String(text), this)
  }

  Vue.prototype._s = value => {
    if (value == null) return ''
    if (typeof value === 'object') return JSON.stringify(value)
    return String(value)
  }
}

编译器输出的 _c('div', data, children) 本质上是创建 VNode 的 helper。不要把 _c_v 当成 Vue 3 的公共 API;Vue 3 编译结果和 runtime helper 体系已经不同。

3. createElement

function createElement(context, tag, data = {}, children = []) {
  const normalizedChildren = normalizeChildren(children)
  return new VNode(tag, data, normalizedChildren, undefined, context)
}

function normalizeChildren(children) {
  if (!Array.isArray(children)) {
    return children == null ? [] : [children]
  }

  return children.flat(Infinity).map(child => {
    if (child instanceof VNode) return child
    return new VNode(undefined, undefined, undefined, String(child))
  })
}

真实 Vue 还会规范化数组、文本、组件、异步组件、Fragment 兼容节点和 key。教学实现中的 flat(Infinity) 也只是为了展示概念,不能直接当成 Vue 的 children normalization。

四、VNode 是什么

VNode 是描述节点的 JavaScript 对象,不是真实 DOM 元素:

class VNode {
  constructor(tag, data, children, text, context) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.context = context
    this.elm = undefined
    this.parent = undefined
    this.key = data?.key
    this.componentOptions = undefined
    this.componentInstance = undefined
  }
}

常见字段的作用:

  • tag:元素或组件标签;
  • data:attrs、props、on、directives、key、slot 等;
  • children:子 VNode 数组;
  • text:文本 VNode 的内容;
  • elm:挂载后对应的真实 DOM;
  • context:创建该 VNode 的组件实例;
  • componentInstance:组件 VNode 挂载后的子组件实例;
  • key:列表 Diff 时表达节点身份。

文本节点应通过 text != null 判断,不能用 if (text),因为空字符串也是合法文本内容。

五、vm._update:首次挂载与后续更新

Vue.prototype._update = function _update(vnode) {
  const previousVNode = this._vnode
  this._vnode = vnode

  if (!previousVNode) {
    // 根组件首次渲染:oldVnode 是真实挂载元素
    this.$el = this.__patch__(this.$el, vnode)
  } else {
    // 组件更新或子组件再次渲染:oldVnode 是旧 VNode
    this.$el = this.__patch__(previousVNode, vnode)
  }
}

初始化时 this.$el 通常是 #app 对应的真实元素;首次 patch 会用 VNode 创建出的 DOM 替换它。后续 patch 则比较新旧 VNode,尽量复用已有 DOM。

Vue 2 还会同步更新父子组件的 $el$vnode 和 parent/child 关系,并执行 beforeUpdate/updated 等生命周期。这里暂不展开更新流程,下一篇通常会专门介绍 Diff。

六、patch 的首次渲染分支

初始 patch 需要区分三种输入:

  • oldVnode 是空值:创建一个子树;
  • oldVnode 是真实 DOM:创建新 VNode 对应 DOM,插入并移除旧元素;
  • oldVnode 是旧 VNode:进入更新/Diff。
export function patch(oldVnode, vnode) {
  if (!vnode) {
    if (oldVnode) removeVnode(oldVnode)
    return undefined
  }

  if (!oldVnode) {
    createElm(vnode)
    return vnode.elm
  }

  const isRealElement = oldVnode.nodeType !== undefined

  if (isRealElement) {
    const oldElement = oldVnode
    const parent = oldElement.parentNode
    const anchor = oldElement.nextSibling

    createElm(vnode, parent, anchor)
    if (parent) parent.removeChild(oldElement)
    return vnode.elm
  }

  // 初始渲染文章暂不展开:这里应该调用 patchVnode(oldVnode, vnode)
  patchVnode(oldVnode, vnode)
  return vnode.elm
}

原文把参考节点固定说成 script,这并不普遍。正确的参考节点是旧挂载元素的 nextSibling,也可能为 null;父节点也不一定是 body

七、createElm:递归创建真实节点

function createElm(vnode, parent, anchor = null) {
  if (createComponent(vnode, parent, anchor)) {
    return vnode.elm
  }

  let elm

  if (vnode.text != null && (!vnode.children || vnode.children.length === 0)) {
    elm = document.createTextNode(vnode.text)
  } else {
    elm = document.createElement(vnode.tag)
    vnode.elm = elm

    setNodeData(vnode)

    for (const child of vnode.children || []) {
      createElm(child, elm)
    }
  }

  vnode.elm = elm
  if (parent) parent.insertBefore(elm, anchor)
  return elm
}

真实 Vue 2 的 createElm 还会处理注释、SVG、namespace、Transition、指令钩子、inserted 队列、范围节点和组件 hooks。组件必须先尝试创建,否则自定义组件会被错误地当成原生元素。

八、指令和普通属性

模板中的指令在编译阶段通常会转换为 VNode data 或指令钩子,不是 createElm 简单遍历字符串后就能完整实现。下面是教学化的 data 结构:

const data = {
  attrs: { id: 'app' },
  domProps: { value: 'hello' },
  on: {
    input: event => {},
  },
  directives: [
    { name: 'focus', value: true },
  ],
}
function resolveValue(value, vm) {
  return typeof value === 'function' ? value.call(vm) : value
}

function setNodeData(vnode) {
  const { elm, data = {}, context: vm } = vnode

  for (const [name, value] of Object.entries(data.attrs || {})) {
    elm.setAttribute(name, String(resolveValue(value, vm)))
  }

  for (const [name, value] of Object.entries(data.domProps || {})) {
    elm[name] = resolveValue(value, vm)
  }

  for (const [eventName, handler] of Object.entries(data.on || {})) {
    elm.addEventListener(eventName, event => handler.call(vm, event))
  }

  // 真实实现还要执行 bind/inserted/update/componentUpdated/unbind
  // 等 Vue 2 指令钩子,并维护 oldValue。
}

1. v-model

Vue 2 的 v-model 并不是一个神奇的 setter,它会根据元素类型展开为属性和事件:

<input v-model="name">
≈ :value="name" @input="name = $event.target.value"

<input type="checkbox" v-model="checked">
≈ :checked="checked" @change="checked = $event.target.checked"

<select v-model="selected">
≈ :value="selected" @change="selected = $event.target.value"

简化的原生 input 实现:

function setTextModel(elm, vm, path) {
  elm.value = getPath(vm, path) ?? ''
  elm.addEventListener('input', event => {
    setPath(vm, path, event.target.value)
  })
}

function getPath(object, path) {
  return path.split('.').reduce((value, key) => value?.[key], object)
}

function setPath(object, path, value) {
  const keys = path.split('.')
  const last = keys.pop()
  const target = keys.reduce((value, key) => value[key], object)
  target[last] = value
}

这只覆盖最简单的文本输入。真实 Vue 2 还要处理 textarea、radio、checkbox 多选、select 多选、.lazy.number.trim、组件 value/input 契约和响应式更新后的回写。

2. v-bind

v-bind 需要区分 attribute 和 DOM property。比如表单 valuechecked 通常要设置 property,而不是只调用 setAttribute

function setBoundProps(elm, vm, bindings) {
  for (const [name, expression] of Object.entries(bindings)) {
    const value = evaluatePath(vm, expression)

    if (name in elm) {
      elm[name] = value
    } else if (value == null || value === false) {
      elm.removeAttribute(name)
    } else {
      elm.setAttribute(name, String(value))
    }
  }
}

3. v-on

function setEventListeners(elm, vm, listeners) {
  for (const [eventName, methodName] of Object.entries(listeners)) {
    const method = vm[methodName] || vm.$options.methods?.[methodName]
    if (typeof method !== 'function') continue
    elm.addEventListener(eventName, event => method.call(vm, event))
  }
}

生产代码还要处理同一事件更新时移除旧 handler、修饰符、事件缓存、组件事件和 .native 等 Vue 2 行为。Vue 3 已移除 .native,组件事件应通过 emits 声明。

九、创建子组件

组件 VNode 和原生元素 VNode 的区别是:组件需要先创建一个新的组件实例,再让它执行自己的初始化和 render。

function createComponent(vnode, parent, anchor) {
  const Ctor = vnode.componentOptions?.Ctor
  if (!Ctor) return false

  const child = vnode.componentInstance = new Ctor({
    parent: vnode.context,
    propsData: vnode.componentOptions.propsData,
    _parentVnode: vnode,
  })

  child.$mount()
  vnode.elm = child.$el

  if (parent) parent.insertBefore(vnode.elm, anchor)
  return true
}

这个代码只表达流程,真实 Vue 2 会通过组件 VNode 的 init hook 创建构造器实例、初始化 props、listeners、slots、provide/inject、生命周期和渲染 watcher。组件定义通常先通过 Vue.extend 得到构造器,不能简单把任意 options 直接当作 new Vue(options) 处理。

创建完成后,父组件 patch 不需要再次手动把子组件的 VNode 当普通元素递归追加;子组件的 $mount/patch 已经负责生成 $el,父级只需正确插入并维护组件 VNode 关系。

十、保留标签和组件解析

原文用一个固定数组判断原生标签:

const reservedTags = [
  'div', 'span', 'input', 'select', 'option', 'p', 'button', 'template',
]

这只适合极简 demo。真实 Vue 2 使用平台配置中的 isReservedTag,需要覆盖完整 HTML/SVG 标签和平台差异。更重要的是:

  • 原生标签交给平台 renderer 创建;
  • 已注册组件按组件名解析;
  • 未注册的自定义标签可能被当作未知元素,具体行为受平台和配置影响;
  • Web Components、自定义元素和 Vue 组件不能只靠字符串是否包含大写来判断。

十一、插槽的原理

Vue 2 插槽的关键点是插槽内容由父组件编译和创建,但在子组件模板中渲染

<!-- Parent.vue -->
<Child>
  <template #title>
    <strong>{{ parentTitle }}</strong>
  </template>
</Child>
<!-- Child.vue -->
<template>
  <section>
    <slot name="title">默认标题</slot>
    <slot>默认内容</slot>
  </section>
</template>

可以把实现拆成三个阶段:

  1. parse:父模板解析组件子节点和 slot 名称;
  2. generate:组件 VNode data 中保存普通 slot VNode 或 scoped slot 函数;
  3. render:子组件执行 _t/renderSlot,取出父组件传入的内容,生成当前渲染上下文中的 VNode。

普通插槽可以抽象成:

function getSlotProps(vm) {
  return vm.$props || {}
}

function renderSlot(vm, name, fallback) {
  const slots = vm.$vnode?.data?.scopedSlots
  if (slots && slots[name]) {
    return slots[name](getSlotProps(vm))
  }
  return fallback
}

上面只是表达数据流,真实 Vue 2 的 $slots 是 VNode 数组,$scopedSlots 是返回 VNode 的函数;Vue 3 则统一以 slot function 为主,调用时机和内部 VNode 约定不同。

作用域插槽示例:

<!-- Child.vue -->
<slot name="row" :item="item">
  <span>{{ item.name }}</span>
</slot>
<!-- Parent.vue,Vue 2.6+ / Vue 3 均支持类似模板语法,细节以版本为准 -->
<Child>
  <template #row="slotProps">
    <strong>{{ slotProps.item.name }}</strong>
  </template>
</Child>

插槽不是把父节点的 DOM 直接搬到子组件,而是把父作用域中的 VNode/函数交给子组件指定位置渲染。

十二、初始渲染结果

当编译、render、patch、组件创建和 slot 处理都连通后,页面会从原始挂载元素变成由 VNode 创建的 DOM:

简易 Vue 2 初始渲染结果(已本地化)

检查过程时可以依次打印:

console.log(vm.$options.render)
console.log(vm._vnode)
console.log(vm.$el)
console.log(vm._vnode.componentInstance)

如果出现以下问题,可以按顺序排查:

  • render function 是否真的被调用;
  • VNode 的 textchildren 是否混淆;
  • vnode.elm 是否在 createElm 后赋值;
  • 首次 patch 的 oldVnode 是否是真实元素;
  • 组件构造器是否正确解析;
  • 子组件是否执行了自己的 $mount
  • slot data 是否在父组件 VNode 上保存;
  • input/select 的 value 是否在子节点创建后设置。

十三、Vue 2 与 Vue 3 的差异

本文的 vm._render()vm._update()vm.__patch__、render watcher、_c_v 都是 Vue 2 学习源码的语境。Vue 3 的公开入口是:

import { createApp, h, reactive } from 'vue'

const app = createApp({
  setup() {
    const state = reactive({ message: 'Vue 3' })
    return () => h('p', state.message)
  },
})

app.mount('#app')

Vue 3 使用 Proxy/reactive effect、组件更新 job、createVNode 和独立 renderer;支持 Fragment、Teleport、Suspense、SSR hydration 及编译器 Patch Flags。理解 Vue 2 的初始渲染有助于阅读历史源码,但不能把这些内部字段当作 Vue 3 的稳定 API。

总结

首次渲染的本质是:

响应式实例
  → render function
  → VNode
  → patch
  → create element/component/text
  → 真实 DOM

其中:

  • render helper 负责创建 VNode;
  • vm._update 区分首次挂载和后续更新;
  • patch 负责把真实挂载元素或旧 VNode 转换到新 VNode;
  • createElm 递归创建元素、文本和组件;
  • 指令最终会落到 property、attribute、事件和指令钩子;
  • 子组件要先创建实例并执行自己的渲染;
  • 插槽传递的是父作用域创建的 VNode 或 slot function,而不是现成 DOM。

参考资料

原文作者:李永宁。原文关于 Vue 2 初始渲染、render helper、VNode、patch、指令、组件和插槽的主线予以保留;抓取代码粘连、宣传段落、硬编码挂载位置、错误组件实例化方式和过度简化的指令实现已清理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS