目录
[1. 渲染器(Renderer)的作用与原理](#1. 渲染器(Renderer)的作用与原理)
[2. 创建 runtime-dom 包的基本骨架](#2. 创建 runtime-dom 包的基本骨架)
[3. 实现常用的节点操作(nodeOps)](#3. 实现常用的节点操作(nodeOps))
[4. 比对属性方法(patchProp)](#4. 比对属性方法(patchProp))
[4.1 操作类名:patchClass](#4.1 操作类名:patchClass)
[4.2 操作样式:patchStyle](#4.2 操作样式:patchStyle)
[4.3 操作事件:patchEvent](#4.3 操作事件:patchEvent)
[4.4 操作普通属性:patchAttr](#4.4 操作普通属性:patchAttr)
[5. 组装并导出渲染器](#5. 组装并导出渲染器)
[5.1 在 index.js 中引用并组合](#5.1 在 index.js 中引用并组合)
[5.2 使用示例](#5.2 使用示例)
[6. 整体流程回顾与核心要点](#6. 整体流程回顾与核心要点)
[7. 总结](#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.js
export const nodeOps = {
// 在 parent 节点里,把 child 插入到 anchor 之前。如果 anchor 是 null,则相当于 appendChild
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null);
},
// 从父节点里移除 child
remove: (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.js
import { 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、onMouseover
patchEvent(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.js
export 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.js
export function patchStyle(el, prev, next) {
const style = el.style;
if (next) {
// 把 next 中所有属性写入 style
for (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.value
return invoker;
}
-
这样子,我们实际给 DOM 调用
addEventListener
的回调是invoker
,而不是initialValue
,好处是:-
当之后需要更新事件处理函数时,只需要改变
invoker.value = newHandler
,而不必频繁地removeEventListener
+addEventListener
。 -
如果多次更新同一个事件(如
@click="onA"
→@click="onB"
),可以复用同一个invoker
,只替换.value
即可。
-
-
当
nextValue
为空(用户移除了事件),就调用removeEventListener
并清空缓存。// runtime-dom/src/patchEvent.js
function 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,只替换它的 value
existingInvoker.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.js
export 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.js
import { 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 行为,或者做一些特殊平台适配,可以直接使用
<script type="module"> import { createRenderer, h } from '/path/to/runtime-core.js'; import * as runtimeDOM from '/path/to/runtime-dom.js';createRenderer
:// 自定义渲染器
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 算法。
- 如果后续需要在其他平台(比如 SSR、Weex、Native)运行,只要编写一套针对目标平台的
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
的实现细节与设计思路。