手写 vue 源码 === runtime-dom 实现
目录
1. 渲染器(Renderer)的作用与原理
2. 创建 runtime-dom 包的基本骨架
3. 实现常用的节点操作(nodeOps)
关键点讲解
4. 比对属性方法(patchProp)
4.1 操作类名:patchClass
4.2 操作样式:patchStyle
4.3 操作事件:patchEvent
4.4 操作普通属性:patchAttr
5. 组装并导出渲染器
5.1 在 index.js 中引用并组合
5.2 使用示例
6. 整体流程回顾与核心要点
7. 总结
1. 渲染器(Renderer)的作用与原理
在设计现代前端框架时,往往会先把用户编写的模板或 JSX 编译成「虚拟 DOM」(vnode)。虚拟 DOM 本质上是一个 JavaScript 对象树,用来表示页面结构、属性、事件绑定等信息。但在浏览器环境中,我们必须把这棵虚拟 DOM 树「真正地渲染」成浏览器认可的真实 DOM 元素,才能让用户看到视觉效果。
渲染器(Renderer) 就是完成这一步的核心模块:
-
它接收一个虚拟节点(vnode),
-
递归地比对(diff)旧 vnode 与新 vnode,
-
生成一系列 DOM 操作(增删改)并最终更新到真实 DOM 上。
理论上,渲染器本身并不直接依赖于浏览器 API,而是通过一些抽象化的方法(如 createElement
、insert
、setElementText
等)来完成平台相关的操作。这样做带来了两点好处:
-
跨平台兼容:同一套核心 diff 算法可以复用于浏览器端(DOM)、Native 端(比如 Weex、NativeScript)等。只要实现一套对应平台的「DOM 操作 API」,就可以复用同一个渲染器核心。
-
职责分离:渲染器只关心如何比较 vnode 树,并推导出最小化的「变更指令」,而不关心具体怎么把指令挂到真是平台上。这样代码可维护性更高,框架也更灵活。
以 Vue 3 为例,它的核心有两个部分:
-
runtime-core:包含最核心的虚拟 DOM 描述、diff 算法、组件生命周期等,完全与平台无关。
-
runtime-dom:负责把 runtime-core 里产出的变更指令(比如「在 container 中插入一个
<div>
」「把这个元素的文本改为 X」)映射成浏览器的真实 DOM API 调用。
下面就从无到有讲解,如何为浏览器环境构建一个简化版的 runtime-dom
。
2. 创建 runtime-dom
包的基本骨架
首先,我们在项目里创建一个文件夹 runtime-dom
,用来存放与浏览器 DOM 相关的全部实现。其目录结构示例:
runtime-dom/
├─ package.json
├─ src/
│ ├─ index.js
│ ├─ nodeOps.js
│ ├─ patchProp.js
│ └─ (其他可能用到的工具文件)
└─ dist/└─ (打包后生成的文件)
在 package.json
中填写基本信息,示例内容如下:
{"name": "@vue/runtime-dom","version": "1.0.0", // 自行定"main": "index.js","module": "dist/runtime-dom.esm-bundler.js","unpkg": "dist/runtime-dom.global.js","buildOptions": {"name": "VueRuntimeDOM","formats": ["esm-bundler", "cjs", "global"]},"dependencies": {"@vue/shared": "^x.x.x" // 取决于 runtime-core 所需的 shared 工具库}
}
重点说明:
runtime-dom
并不是一个孤立的包,它和runtime-core
(以及shared
等)是紧密配合的。本示例中假设你已经有一套基本的runtime-core
,或者至少知道 createRenderer、h、render 这些 API 的签名和用法。
在完成 package.json
之后,需要安装依赖(以 pnpm 为例):
pnpm install @vue/shared@workspace --filter @vue/runtime-dom
这样就保证 @vue/shared
的工具函数(比如 isOn
、isReservedProp
、extend
、camelize
等)可以被我们在 runtime-dom
中引用。
3. 实现常用的节点操作(nodeOps)
在浏览器环境下,所有操作最终都会落到原生的 DOM API 上。这里我们把常见的 DOM 操作用一个对象 nodeOps
封装好,让渲染器的核心只负责调用这些抽象方法,而不直接写 document.createElement
、parentNode.appendChild
等。
在 runtime-dom/src/nodeOps.js
中可以这样实现:
// runtime-dom/src/nodeOps.jsexport const nodeOps = {// 在 parent 节点里,把 child 插入到 anchor 之前。如果 anchor 是 null,则相当于 appendChildinsert: (child, parent, anchor) => {parent.insertBefore(child, anchor || null);},// 从父节点里移除 childremove: (child) => {const parent = child.parentNode;if (parent) {parent.removeChild(child);}},// 创建一个普通元素节点,例如 'div'、'span' 等createElement: (tag) => document.createElement(tag),// 创建一个文本节点createText: (text) => document.createTextNode(text),// 为一个文本节点设置文本内容setText: (node, text) => {node.nodeValue = text;},// 为一个元素节点设置文本内容(覆盖原先的子节点)setElementText: (el, text) => {el.textContent = text;},// 获取 node 的父节点parentNode: (node) => node.parentNode,// 获取 node 的下一个兄弟节点nextSibling: (node) => node.nextSibling,// 根据选择器查找 DOM 元素querySelector: (selector) => document.querySelector(selector)
};
关键点讲解
-
insert:之所以接收
anchor
参数,是为了支持在列表渲染时,能够在指定位置进行插入,而不只是简单的appendChild
。如果不提供anchor
,则等同于把child
插到parent
的最后。 -
remove:要先判断
child.parentNode
是否存在,避免对已经被移除的节点再次调用removeChild
。 -
createText / setText:区分文本节点和元素节点,可以让渲染器在 patch 过程中对文本做更细粒度的更新(比如文本节点直接用
nodeValue
修改)。 -
querySelector:某些场景(例如用户调用
render(h(...), selectorString)
)需要先将字符串选择器解析到真实容器节点,再开始渲染。
这套 nodeOps
只是最基础的一套。你可以根据需要扩展,例如对 SVG 环境下的 createElementNS
或者事件委托等做兼容。
4. 比对属性方法(patchProp)
在虚拟 DOM 比对(diff)过程中,当节点类型相同而属性或事件发生变化时,渲染器会调用一个叫做 patchProp
的方法,把新旧值对比,然后决定对真实 DOM 做哪些更新。我们需要在 runtime-dom/src/patchProp.js
中实现这个逻辑。
// runtime-dom/src/patchProp.jsimport { patchClass } from './patchClass';
import { patchStyle } from './patchStyle';
import { patchEvent } from './patchEvent';
import { patchAttr } from './patchAttr';export const patchProp = (el, key, prevValue, nextValue) => {if (key === 'class') {// 操作类名patchClass(el, nextValue);} else if (key === 'style') {// 操作样式patchStyle(el, prevValue, nextValue);} else if (/^on[^a-z]/.test(key)) {// 以 "onXxx" 开头,并且 X 必须大写,比如 onClick、onMouseoverpatchEvent(el, key, nextValue);} else {// 普通属性或自定义属性,比如 id、src、data-*patchAttr(el, key, nextValue);}
};
4.1 操作类名:patchClass
将 class
属性单独剥离,目的是处理两种情况:
-
当
value
为null
或者undefined
时,要移除class
属性。 -
否则,直接把
el.className
赋值为字符串,让浏览器统一处理 class 列表。
// runtime-dom/src/patchClass.jsexport function patchClass(el, value) {if (value == null) {el.removeAttribute('class');} else {el.className = value;}
}
细节说明
如果你写的是纯 CSS 类名(例如
"btn primary"
),直接给el.className = "btn primary"
就会由浏览器自动拆分为对应的classList
。如果框架里允许
:class
绑定一个对象或数组,需要在上层把它转换为字符串后再传入。这里的patchClass
假设接收到的就是最终要写入的字符串。
4.2 操作样式:patchStyle
style
可能是一个对象(如 { color: 'red', fontSize: '20px' }
),也可能是 null
。在更新时有两种场景:
-
新旧
style
都存在:先遍历新对象,把所有属性直接设置到el.style
上;再遍历旧对象,把新对象没有的属性清空(赋值为null
)。 -
新
style
为null
,旧style
不为null
:要把旧的所有内联样式清空。
下面是一个示例实现:
// runtime-dom/src/patchStyle.jsexport function patchStyle(el, prev, next) {const style = el.style;if (next) {// 把 next 中所有属性写入 stylefor (const key in next) {style[key] = next[key];}}if (prev) {// 把 prev 中存在但 next 中不存在的属性清除for (const key in prev) {if (next == null || next[key] == null) {style[key] = '';}}}
}
细节说明
style[key] = ''
相当于移除了该行内联样式。如果不清空,旧样式会一直残留,导致样式错误。这里假设
prev
/next
都是普通对象(不是字符串)。在框架模板编译阶段,如果用户写了:style="{ color: isRed ? 'red' : 'blue' }"
,最终就会得到一个 JS 对象,交给patchStyle
。
4.3 操作事件:patchEvent
事件更新是最复杂的一块。原理是在 DOM 元素 el
上挂一个私有属性 _vei
(Vue Event Invokers),用于存储用户真正的事件回调函数。整体思路如下:
当第一次给 el
绑定事件(nextValue
存在,existingInvoker
不存在),创建一个「可更新的调用器」invoker:
function createInvoker(initialValue) {const invoker = (e) => invoker.value(e);invoker.value = initialValue; // 把用户的回调函数挂到 invoker.valuereturn invoker;
}
-
这样子,我们实际给 DOM 调用
addEventListener
的回调是invoker
,而不是initialValue
,好处是:-
当之后需要更新事件处理函数时,只需要改变
invoker.value = newHandler
,而不必频繁地removeEventListener
+addEventListener
。 -
如果多次更新同一个事件(如
@click="onA"
→@click="onB"
),可以复用同一个invoker
,只替换.value
即可。
-
-
当
nextValue
为空(用户移除了事件),就调用removeEventListener
并清空缓存。
// runtime-dom/src/patchEvent.jsfunction createInvoker(initialValue) {const invoker = (e) => invoker.value(e);invoker.value = initialValue;return invoker;
}export function patchEvent(el, rawName, nextValue) {// el._vei: { [eventName: string]: invokerFunction }const invokers = el._vei || (el._vei = {});const existingInvoker = invokers[rawName];// event name 形如 "onClick",我们要把它转换为小写的 "click"// rawName.slice(2) 表示去掉前两位 "on",然后转小写const name = rawName.slice(2).toLowerCase();if (nextValue && existingInvoker) {// 1. 已经有 invoker,只替换它的 valueexistingInvoker.value = nextValue;} else if (nextValue) {// 2. 第一次绑定该事件const invoker = createInvoker(nextValue);invokers[rawName] = invoker;el.addEventListener(name, invoker);} else if (existingInvoker) {// 3. nextValue 不存在,但 existingInvoker 存在 → 用户移除了该事件el.removeEventListener(name, existingInvoker);invokers[rawName] = undefined;}
}
细节说明
正则检查
rawName
是否以on
开头且第二个字符不是小写字母,主要是为了避免把oncapture
、onmousewheelfirefox
等非法事件错误识别。
el._vei
上缓存的是对应rawName
的 invoker 函数,而非原始用户传入的回调。内部调用时,invoker(e)
会在运行时去读取invoker.value
(即最新的用户回调)。这样既能保证更新回调时性能更高,也避免了多次 remove/add 事件监听器可能带来的额外开销。
4.4 操作普通属性:patchAttr
在处理非 class
、非 style
、非事件的属性时,我们直接把它当成普通的 DOM 属性来处理即可。如果 value
为 null
或 undefined
,就移除该属性;否则,使用 setAttribute
设置即可。
// runtime-dom/src/patchAttr.jsexport function patchAttr(el, key, value) {if (value == null) {el.removeAttribute(key);} else {el.setAttribute(key, value);}
}
细节说明
对于布尔属性(如
disabled
、checked
等),如果你希望精准控制其布尔值,可以在上层做特定逻辑处理(例如el.disabled = true/false
)。这里patchAttr
只是最基础、最通用的「增删属性」逻辑。某些属性(例如
value
)并非用setAttribute
写到属性上,而是要写到元素实例上 (el.value = 'some text'
)。如果需要更精细的行为,可以在这里做特殊分支:if (key === 'value' && el.tagName === 'INPUT') {el.value = value; } else {el.setAttribute(key, value); }
这里只是示例,最简单的实现先用
setAttribute
覆盖大部分场景。
5. 组装并导出渲染器
完成了 nodeOps
、patchProp
及其子方法的实现后,我们就可以把它们组装成一个「渲染选项对象(renderOptions)」,再把它交给 runtime-core
的核心函数 createRenderer
。这样就能得到一个完整的浏览器渲染器。
假设 runtime-core
已经导出了如下 API:
-
createRenderer(options: RendererOptions)
:创建一个渲染器实例,返回一个对象,拥有.render(vnode, container)
方法。 -
h
:用于生成虚拟节点。 -
render
:内置渲染器直接调用createRenderer(options).render(...)
的快捷方式(可选)。
5.1 在 index.js
中引用并组合
// runtime-dom/src/index.jsimport { createRenderer } from '@vue/runtime-core'; // 假设 runtime-core 已经通过别名导入
import { nodeOps } from './nodeOps';
import { patchProp } from './patchProp';// 把 nodeOps 和 patchProp 组合到一起,形成渲染时所需的完整方法集
const renderOptions = Object.assign({ patchProp }, // 处理属性更新nodeOps // 节点增删改等操作
);// 导出自定义渲染器函数:
// Vue 程序在调用时,只要传入虚拟节点和容器,就能把 vnode 渲染到 DOM
export function render(vnode, container) {// createRenderer 接受一个选项对象,返回一个渲染器实例// 渲染器实例有一个 .render 方法,接收 (vnode, container)return createRenderer(renderOptions).render(vnode, container);
}// 同时导出 h 函数,方便用户直接写
export { h } from '@vue/runtime-core';
细节说明
Object.assign({ patchProp }, nodeOps)
:
{ patchProp }
中的 key 是patchProp
;
nodeOps
中包含:insert
、remove
、createElement
、createText
、setText
、setElementText
、parentNode
、nextSibling
、querySelector
。最终
renderOptions
就有这 9+1(patchProp)个方法:{insert, remove, createElement, createText,setText, setElementText, parentNode, nextSibling,querySelector, patchProp }
createRenderer(renderOptions)
:其实底层做了两件事:
把
renderOptions
里的方法注入到 diff 算法中,让它在进行 vnode 比对时,遇到需要增加节点、删除节点、修改文本、更新属性时,都调用我们传入的「DOM 操作方法」。返回一个包含
render(vnode, container)
的对象;调用render()
时,会先把旧 vnode(如果有)和新 vnode 进行比对,生成最小化的「DOM 更新指令」,并调用renderOptions
中的方法把真实 DOM 更新到页面上。
5.2 使用示例
在浏览器端,你可以通过两种方式来使用你的渲染器:
-
自定义渲染器
如果你想改写某些 DOM 行为,或者做一些特殊平台适配,可以直接使用createRenderer
:
<script type="module">import { createRenderer, h } from '/path/to/runtime-core.js';import * as runtimeDOM from '/path/to/runtime-dom.js';// 自定义渲染器const renderer = createRenderer({createElement(tag) {console.log('创建元素:', tag);return document.createElement(tag);},setElementText(el, text) {console.log('设置文本:', text);el.innerHTML = text;},insert(el, parent) {console.log('插入元素到父容器', parent);parent.appendChild(el);},/* 其余操作也要实现,或者从 nodeOps 拷贝进来 */// ...nodeOps,patchProp: runtimeDOM.patchProp});const vnode = h('h1', { class: 'title' }, 'Hello Custom Renderer');renderer.render(vnode, document.getElementById('app'));
</script>
内置渲染器
如果你只想拿到默认的浏览器端渲染器,直接调用 runtime-dom
暴露出的 render
即可:
<script type="module">import { h } from '/path/to/runtime-core.js';import { render } from '/path/to/runtime-dom.js';const vnode = h('h1', { style: { color: 'blue' } }, 'Hello Vue Runtime DOM');render(vnode, document.getElementById('app'));
</script>
上述代码等同于:
const renderer = createRenderer(renderOptions);
renderer.render(vnode, container);
6. 整体流程回顾与核心要点
-
虚拟 DOM (vnode) 生成
-
用户书写模板、JSX 或直接调用
h()
辅助函数,得到一个描述 DOM 结构的 JavaScript 对象树。 -
每个 vnode 上包含节点类型(字符串或组件)、属性 (
props
)、子节点等信息。
-
-
调用
render(vnode, container)
-
如果是首次渲染,老的 vnode 树是
null
,渲染器会一路往下创建真实元素并插入到container
。 -
如果是更新,渲染器会把「旧 vnode」和「新 vnode」树进行比对(Diff),只针对发生变化的地方生成对应的 DOM 操作。
-
-
Diff 过程中调用
renderOptions
方法-
例如:
-
"创建元素" →
nodeOps.createElement(tag)
-
"设置文本" →
nodeOps.setElementText(el, text)
-
"更新属性" →
patchProp(el, key, oldVal, newVal)
-
"插入节点" →
nodeOps.insert(el, parent, anchor)
-
"删除节点" →
nodeOps.remove(el)
-
-
因此,渲染器核心只关心「时机」和「最小化更新」,而不关心「怎么把更新落到平台上」。
-
-
patchProp 细化不同属性的更新
-
class
→ 走patchClass
-
style
→ 走patchStyle
-
事件
onXxx
→ 走patchEvent
-
其余属性 → 走
patchAttr
-
-
事件 invoker 技巧
-
引入一个「事件调用封装器」(
invoker
) 作为真实的监听器,内部通过invoker.value(e)
调用用户回调。 -
更新事件处理函数时,只替换
invoker.value
,避免了反复增删监听器带来的额外开销。
-
-
nodeOps 插件化设计
-
如果后续需要在其他平台(比如 SSR、Weex、Native)运行,只要编写一套针对目标平台的
nodeOps
即可复用核心 diff 算法。
-
7. 总结
本文从实现原理和具体代码出发,手把手演示了如何在浏览器环境下搭建一个简化版的 runtime-dom
:
-
明确渲染器的职责,把虚拟节点渲染成真实 DOM。
-
创建包结构,写好
package.json
,引入必要依赖。 -
实现
nodeOps
,把所有常用的 DOM 操作封装成一组方法。 -
实现
patchProp
,并拆分出patchClass
、patchStyle
、patchEvent
、patchAttr
等细节逻辑,确保能灵活更新元素的属性、样式、事件。 -
组装渲染选项,把
nodeOps
和patchProp
合并后传给createRenderer
,最终获得.render(vnode, container)
方法。
通过以上步骤,你就能理解:
-
在 Vue 3 体系下,
runtime-core
负责纯算法(虚拟 DOM diff、组件生命周期管理等), -
而
runtime-dom
只需提供「平台相关的方法」,并注入给核心渲染算法即可。
如此一来,整个渲染管道既保持了「高性能」、「可定制」,又能实现「一次编写,跨平台复用」。希望这篇文章能帮助你彻底搞懂 runtime-dom
的实现细节与设计思路。