03-runtime-挂载流程
第 3 章 · runtime-core(一):从 createApp 到页面出现——挂载主干
第 3 章 · runtime-core(一):从 createApp 到页面出现——挂载主干
runtime-core 是 mini-vue 里代码量最大的包。第一遍读的纪律是:只走挂载(mount)路径,把所有"更新"分支当作不存在——具体说,凡是 patch(n1, n2) 里 n1 !== null 的分支全部跳过,留给第 5 章。
挂载是主干,更新是主干上的环,先把主干走通。
3.0 主干调用图(先背下来再进代码)
createApp(App).mount('#app')
└─ render(vnode, rootContainer)
└─ patch(null, vnode, container, parentComponent) ★ 一切的中枢
├─ type 是字符串('div')→ processElement → mountElement
│ └─ 处理 children 时 → 递归 patch 每个孩子
├─ type 是对象(组件选项)→ processComponent → mountComponent
│ ├─ createComponentInstance (建实例)
│ ├─ setupComponent (初始化 props/slots,跑 setup)
│ └─ setupRenderEffect (★ render 包进 effect,产出子树)
│ └─ patch(null, subTree, ...) → 递归回中枢
├─ type === Text → processText(纯文本节点)
└─ type === Fragment → processFragment(只渲染 children,无包裹元素)结构上这是一个递归下降:patch 是唯一入口,按 vnode 类型分发,处理孩子时递归回自己。和第 6 章的 parser、和你写的树形递归算法题,是同一个骨架。
3.1 vnode.ts:一个普通对象 + 一套位运算类型系统
vnode 是什么
就是一个描述"这里该有个什么东西"的普通对象,没有任何魔法:
// vnode.ts
export const Fragment = Symbol('Fragment')
export const Text = Symbol('Text')
export function createVNode(type, props?, children?) {
const vnode = {
type, // 'div' | 组件选项对象 | Text | Fragment
props, // 属性/事件:{ id: 'root', onClick: fn }
children, // 字符串(文本孩子)或数组(vnode 孩子)
el: null, // 挂载后回填:对应的真实 DOM(更新和 $el 都靠它)
key: props?.key, // diff 用(第 5 章)
shapeFlag: getShapeFlag(type), // ★ 类型编码,见下
}
// 追加 children 的类型信息(用 |= 合并进 shapeFlag)
if (typeof children === 'string') {
vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN
} else if (Array.isArray(children)) {
vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN
}
// 组件 + children 是对象 → 这是插槽(第 4 章)
if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
if (typeof children === 'object') vnode.shapeFlag |= ShapeFlags.SLOT_CHILDREN
}
return vnode
}
function getShapeFlag(type) {
return typeof type === 'string'
? ShapeFlags.ELEMENT
: ShapeFlags.STATEFUL_COMPONENT
}// shared/shapeFlags.ts
export const enum ShapeFlags {
ELEMENT = 1, // 0001
STATEFUL_COMPONENT = 1 << 1, // 0010
TEXT_CHILDREN = 1 << 2, // 0100
ARRAY_CHILDREN = 1 << 3, // 1000
SLOT_CHILDREN = 1 << 4, // 10000
}为什么用位运算而不是 { isElement: true, hasTextChildren: true }
一个 vnode 需要同时携带两个正交的类型信息:自己是什么(元素/组件)和孩子是什么(文本/数组/插槽)。位掩码用一个整数同时编码多个布尔:
// 一个"div 里有一串孩子"的节点:
shapeFlag = ELEMENT | ARRAY_CHILDREN // 0001 | 1000 = 1001
// 判断(& 结果非 0 即含有该标志):
if (shapeFlag & ShapeFlags.ELEMENT) ... // 是元素吗
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) ... // 孩子是数组吗比对象布尔快在哪:整数比较是 CPU 原生操作,且一个整数 vs 一个含 5 个属性的对象,内存和 GC 压力差一个量级。patch 是整个框架被调用最频繁的热路径,每个节点每次更新都要做这些判断——热路径上的怪异写法,第一嫌疑永远是性能。(同样的手法你在操作系统的文件权限 rwx、算法题的状态压缩 DP 里都见过——位掩码 = "一组正交布尔的紧凑编码",是跨领域通用件。)
el: null 这个字段值得停一秒:vnode 创建时是纯数据,挂载时 mountElement 会把创建出的真实 DOM 回填到 vnode.el。第 5 章更新时,新 vnode 靠 el = n1.el 继承它才知道"我对应页面上哪个元素";组件的 this.$el 也是它。一个"先留空、后回填"的字段,是连接两个阶段(描述阶段/落地阶段)的桥——读任何源码,见到初始为 null 的字段就要追问:谁在什么时机回填?谁在之后消费?
h:createVNode 的人体工学外壳
export function h(type, props?, children?) {
return createVNode(type, props, children)
}真源码的 h 有一堆参数重载归一化(两个参数时判断第二个是 props 还是 children 等),mini-vue 略去。读源码时把这类"参数整形层"整个跳过,直奔它归一化完调用的那个真函数。
3.2 createApp + createRenderer:为什么入口套了两层工厂
先看用户视角的入口:
// runtime-dom/index.ts —— 浏览器平台包
import { createRenderer } from '../runtime-core'
function createElement(type) { return document.createElement(type) }
function insert(child, parent, anchor) { parent.insertBefore(child, anchor ?? null) }
function remove(child) { child.parentNode?.removeChild(child) }
function setElementText(el, text) { el.textContent = text }
function patchProp(el, key, prevVal, nextVal) {
if (/^on[A-Z]/.test(key)) {
// onClick → click:约定大于配置
el.addEventListener(key.slice(2).toLowerCase(), nextVal)
} else {
if (nextVal == null) el.removeAttribute(key)
else el.setAttribute(key, nextVal)
}
}
const renderer = createRenderer({ createElement, patchProp, insert, remove, setElementText })
export function createApp(...args) {
return renderer.createApp(...args)
}再看 runtime-core 这边:
// runtime-core/renderer.ts
export function createRenderer(options) {
// ★ 解构重命名成 hostXxx,下文几百行 patch 逻辑只认识这五个函数
const {
createElement: hostCreateElement,
patchProp: hostPatchProp,
insert: hostInsert,
remove: hostRemove,
setElementText: hostSetElementText,
} = options
function render(vnode, container) {
patch(null, vnode, container, null)
}
function patch(n1, n2, container, parentComponent) { /* 3.3 */ }
// ...mountElement、mountComponent 等全在这个闭包里...
return { createApp: createAppAPI(render) }
}// runtime-core/createApp.ts
export function createAppAPI(render) {
return function createApp(rootComponent) {
return {
mount(rootContainer) {
const vnode = createVNode(rootComponent) // 根组件 → 根 vnode
render(vnode, rootContainer) // 交给 render → patch
},
}
}
}逐段解释
runtime-core 里没有一行 document.*。 所有平台操作被抽象成 5 个函数注入进来。这就是"Vue 3 支持自定义渲染器"的全部机制:注入 canvas 绘制函数得到 canvas 渲染器,注入小程序 API 得到小程序渲染器——patch/diff 那几百行核心逻辑一字不改。依赖注入 + 控制反转,一个参数对象完成跨平台。
为什么解构时重命名 hostXxx:读到下文 hostInsert(el, container) 时,前缀立刻告诉你"这是平台注入的、在包外实现的",与本文件内的函数在视觉上分层。命名前缀标注"来源层",是大型代码库里值得偷学的可读性手法。
为什么 createApp 还要再包一层 createAppAPI:createApp 内部要用 render,而 render 是 createRenderer 闭包里的局部函数。两个方案:把 render 提成模块级全局(那就只能有一个渲染器,跨平台泡汤);或者用闭包把 render 喂给 createApp 工厂。选后者。"函数需要另一个闭包里的东西"→ 高阶函数传递,看到 createXxxAPI(dep) 这种名字,基本都是这个原因。
事件的实现只有一行正则:/^on[A-Z]/ 匹配到就 addEventListener。所谓"Vue 支持 @click",在 runtime 层就是"props 里以 on 开头的键按事件处理"的约定。框架的很多"功能"其实是"约定 + 十行代码"。
3.3 patch:中枢分发器
function patch(n1, n2, container, parentComponent) {
// n1: 旧 vnode(null 表示挂载);n2: 新 vnode
const { type, shapeFlag } = n2
switch (type) {
case Fragment:
processFragment(n1, n2, container, parentComponent)
break
case Text:
processText(n1, n2, container)
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, parentComponent)
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(n1, n2, container, parentComponent)
}
}
}两个小类型先解决掉,它们是为真实需求打的补丁:
function processText(n1, n2, container) {
// 用户写 createTextVNode('hi'):纯文本作为"节点"存在
const textNode = (n2.el = document.createTextNode(n2.children))
container.append(textNode)
}
function processFragment(n1, n2, container, parentComponent) {
// 不创建包裹元素,直接把 children 挂到父容器
mountChildren(n2.children, container, parentComponent)
}Fragment 因何存在:第 4 章的插槽实现里,renderSlots 拿到的是 vnode 数组,但 patch 只接受单个 vnode——不得不包一层 h('div', {}, slotArray),页面就多出无意义的 div。Fragment 就是"逻辑上包一层、DOM 上不存在"的透明容器。先在别处踩到痛点,再回头加类型——按 git 历史读就能看到这个因果顺序,直接读最终代码则容易以为 Fragment 是天经地义的初始设计。
3.4 mountElement:vnode 落地成 DOM
function processElement(n1, n2, container, parentComponent) {
if (!n1) {
mountElement(n2, container, parentComponent) // 挂载 —— 本章
} else {
patchElement(n1, n2, parentComponent) // 更新 —— 第 5 章
}
}
function mountElement(vnode, container, parentComponent) {
// ① 创建元素,回填 vnode.el
const el = (vnode.el = hostCreateElement(vnode.type))
// ② 处理 children:文本 or 递归
const { children, shapeFlag } = vnode
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(el, children)
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(children, el, parentComponent) // ★ 注意容器是 el 不是 container
}
// ③ 处理 props
for (const key in vnode.props) {
hostPatchProp(el, key, null, vnode.props[key])
}
// ④ 一切就绪,插入父容器
hostInsert(el, container)
}
function mountChildren(children, container, parentComponent) {
children.forEach(child => patch(null, child, container, parentComponent))
}逐段说"为什么是这个顺序":
- ②在④之前:孩子先挂到内存中的 el 上,最后整棵子树一次 insert 进文档。反过来(先 insert 再逐个加孩子)每加一个孩子浏览器都可能重排。离屏构建、一次上树,和你操作 DocumentFragment 的原因相同;
mountChildren里递归调用的是 patch 而不是 mountElement——孩子可能是组件、文本、Fragment,必须回到中枢重新分发。递归永远回到分发器,而不是抄近路调具体处理函数,这是递归下降结构的纪律,第 6 章 parser 同理;- ③首次挂载时 prevVal 传 null——patchProp 的签名
(el, key, prev, next)从一开始就是为"更新"设计的,挂载只是"从 null 更新到初值"的特例。好的接口把挂载统一成更新的退化情形,第 5 章 patch(n1=null) 同理,整个框架就一个"更新"心智模型。
3.5 mountComponent:组件是"会产出 vnode 的 vnode"
组件 vnode 的 type 不是字符串,而是用户写的选项对象 { setup, render }。它不能直接变成 DOM——它要先执行,产出一棵"真正能落地"的子树(subTree),再递归 patch 子树。
function processComponent(n1, n2, container, parentComponent) {
if (!n1) mountComponent(n2, container, parentComponent)
else updateComponent(n1, n2) // 第 5 章
}
function mountComponent(vnode, container, parentComponent) {
const instance = createComponentInstance(vnode, parentComponent) // 建实例(第 4 章细讲)
setupComponent(instance) // 初始化 props/slots + 跑 setup(第 4 章)
setupRenderEffect(instance, vnode, container) // ★ 本章的高潮
}setupRenderEffect:两大系统的接线处
如果整个 mini-vue 只允许你精读一个函数,就是它。
function setupRenderEffect(instance, vnode, container) {
instance.update = effect(() => {
if (!instance.isMounted) {
// ---------- 首次挂载 ----------
const { proxy } = instance
// 执行用户的 render:this 指向公共代理(第 4 章)
// ★ render 里读了哪些响应式数据,此刻全被 track 到这个 effect 名下
const subTree = (instance.subTree = instance.render.call(proxy))
patch(null, subTree, container, instance) // 子树落地(递归回中枢)
vnode.el = subTree.el // 组件 vnode 的 el = 子树根的 el($el 的来源)
instance.isMounted = true
} else {
// ---------- 更新 ----------(第 5 章展开)
const { proxy } = instance
const subTree = instance.render.call(proxy)
const prevSubTree = instance.subTree
instance.subTree = subTree
patch(prevSubTree, subTree, container, instance)
}
}, {
scheduler() {
queueJobs(instance.update) // 第 5 章:异步批量更新
},
})
}把这段代码读透
"数据驱动视图"的全部实现就是"把 render 包进 effect"这一个动作。 推演一遍闭环:
- effect 立即执行 → render 执行 → render 里
this.count读了响应式数据 → 第 1 章的 track 把这个渲染 effect 收进 count 的 dep; - 用户代码
count.value++→ 第 1 章的 trigger 查 dep → 找到渲染 effect → 重跑; - 重跑 = render 再执行一遍 = 产出新 subTree =
patch(旧, 新)增量更新 DOM。
没有任何"通知组件更新"的显式代码。组件级精确更新是依赖收集的自然推论:每个组件一个 effect,render 读了什么就依赖什么,改哪个数据就只有依赖它的那些组件重渲染。Vue 不需要 React 的 memo/useCallback 心智负担,根源在这三行。
instance.isMounted 分支:同一个 effect 第一次跑是挂载、之后每次都是更新——因为 effect 的重跑机制天然不区分首次与后续,只能靠一个标志位区分。instance.subTree 存住上一棵子树,是更新时 patch(prev, next) 的"旧"从哪来的答案。
vnode.el = subTree.el:组件本身没有 DOM,它的 el 借用子树根节点的 el。这一行解释了 $el 是什么,也埋下一个真源码里的坑(子树根是 Fragment 时 $el 语义模糊)——mini 版不处理,记下来去真源码找答案。
scheduler 又出现了:不传它,count++ 三次就同步 render 三次。传了它,三次 trigger 只是三次"把同一个 update 函数放进队列"(去重),微任务里执行一次。第 1 章说 scheduler 是最重要的参数,组件更新是它最大的客户。
3.6 挂载主干走一遍(纸上跑一次,胜过重读三遍)
用这个最小应用,在纸上写出每一步调用:
const App = {
setup() { return { msg: ref('hi') } },
render() { return h('div', { id: 'root' }, [h('p', {}, this.msg)]) },
}
createApp(App).mount(document.querySelector('#app'))createApp(App).mount(#app)
→ createVNode(App) // 组件 vnode,shapeFlag = STATEFUL_COMPONENT
→ render(vnode, #app) → patch(null, vnode, #app, null)
→ processComponent → mountComponent
→ createComponentInstance / setupComponent // setup 跑完,msg 就位
→ setupRenderEffect:effect 立即执行
→ render.call(proxy) 读 this.msg // track:渲染 effect ← msg 的 dep
→ subTree = h('div', {id:'root'}, [h('p')...])
→ patch(null, subTree, #app, instance)
→ processElement → mountElement
→ hostCreateElement('div'),回填 subTree.el
→ ARRAY_CHILDREN → mountChildren
→ patch(null, pVnode, div元素, instance)
→ mountElement('p') → TEXT_CHILDREN → setElementText('hi')
→ hostInsert(p, div) // p 进 div(div 还在内存里)
→ patchProp(div, 'id', null, 'root')
→ hostInsert(div, #app) // 整棵树一次上屏
→ vnode.el = subTree.el;isMounted = true之后 msg.value = 'yo' 会从 trigger 开始走 3.5 的更新分支——第 5 章见。
自测
- patch 依据什么分发?为什么 mountChildren 递归调 patch 而不是 mountElement?
- ShapeFlags 为什么用位运算?
ELEMENT | ARRAY_CHILDREN的二进制值是多少? - runtime-core 如何做到零 DOM 依赖?自定义渲染器需要实现哪五个函数?
- 写出 setupRenderEffect 的三行核心伪代码,并口述"数据变化 → 视图更新"的完整闭环(要精确到 track/trigger/dep)。
vnode.el在哪里被回填?之后谁消费它(至少两个消费方)?