文章首发于公众号 code进化论,欢迎关注。
前言
近些年来,前端已经涌现出数十个框架,例如 Vue、React、SoildJS、Svelte、lit等,虽然框架层出不穷但是他们的渲染 DOM 的原理却几乎没有变化,我总结了以下三种渲染 DOM 的方式:
- Dirty Checking(脏检查,典型代表有 Angular、lit)
- Virtual DOM(虚拟DOM,典型代表有Vue、React)
- Fine-Grained(细力度更新,典型代表有SoildJS)
对于开发者来说理解框架的 DOM 渲染机制就能轻松理解框架的整体工作原理,下面将会通过手动实现这三种 DOM 渲染方式来理解其工作原理。
什么是模板(Template)?
在 JavaScript 前端框架中都会有模板(template) 这个概念,模板 是用来描述 UI 结构的声明性语法。它就像是"组件的蓝图",其中包含了静态 HTML 和动态绑定数据的"占位符"(即 holes)。框架通过模板生成最终的 DOM 内容,实现数据和视图的同步,而开发者只需要关注数据和数据的变化。
JavaScript 框架的模板语法经历了一个漫长的演进过程。最早的模板方式就是通过字符串拼接或模板替换将数据嵌入 HTML,例如早期的模板引擎 Handlebars 和 EJS(Embedded JavaScript Templates):
jsx
// EJS 示例
<h1><%= title %></h1>
<% if (items.length > 0) { %>
<ul>
<% items.forEach(function(item) { %>
<li><%= item %></li>
<% }); %>
</ul>
<% } %>
这种方式最大的局限在于如何高效的进行字符串的拼接,然后逐步发展出更复杂、更高效的模板引擎和响应式编译机制,例如在 Vue 中,其模板语法如下:
jsx
<template>
<div class="card">
<h1>Welcome</h1> // 静态
<p>i am {{ name }}</p> // 动态
</div>
</template>
<script>
export default {
data() {
return { name: 'Vue' }
}
}
</script>
在 React 中,其 JSX 模板语法如下:
jsx
function App() {
const [name, setName] = useState('react');
return (
<div>
<h1>Welcome</h1> // 静态
<p>Hello, {name}</p> // 动态
</div>
);
}
虽然不同框架其模板语法会有所差异,但其结构还是一致,即由静态部分和动态部分组成,最终交由渲染引擎进行渲染,因此我们所要关注的是如何将这些模板数据渲染出来。
DOM渲染
Render by Replacement(替换渲染)
替换渲染最典型的例子就是模板引擎 EJS,它会将模板转换成 HTML 并通过 innerHTML 将拼接好的子字符串嵌入到页面中:
jsx
<h1><%= title %></h1>
<% if (items.length > 0) { %>
<ul>
<% items.forEach(function(item) { %>
<li><%= item %></li>
<% }); %>
</ul>
<% } %>
下面是一个完成的实例:
jsx
import { Framework } from "./framework"
class App extends Framework {
constructor() {
super({ count: 0 });
setInterval(() => this.setState({ count: this.state.count + 1 }), 1000);
}
template() {
return `<div>${this.state.count}</div>`;
}
}
在构造函数中通过定时器该修改状态,在 template 中读取状态并通过模板字符串的方式进行拼接,下面是其核心原理:
jsx
export class Framework {
state: Record<string, any> = {};
constructor(initState: Record<string, any>) {
this.state = initState;
this._template();
}
template(): string {
throw new Error("must be implemented");
}
setState(newState: Record<string, any>) {
this.state = newState;
this._template();
}
_template() {
document.getElementById("app")!.innerHTML = this.template();
}
}
更新状态后会再次调用 template 获取返回的模板字符串,在通过 innerHTML 将内容嵌入到页面,可在线调试。

虽然这种更新方式有效,但是其性能表现上是非常的糟糕的,通过 innerHTML 更新 DOM 意味着他会先卸载父节点下面的所有子节点,再将新的子节点添加进去,如果模板内容越多,那么每次更新状态后卸载和创建的 DOM 元素就越多,这并不高效。
那我们该怎么避免 DOM 的反复创建和卸载呢?有些同学可能就想到了 diff,我们可以检查 DOM 元素是否有值并和新的值对比,如果变了我们再更新它。 但事实证明,这种方式也可能导致性能问题,因为 DOM 上有很多属性一旦读取它们,就会触发重绘(paint)或布局计算(layout),例如 offsetWidth、clientHeight、scrollTop等,因为这些属性在读取时浏览器需要保证获取到的是最新的。
所以我们也能得出了一个结论,DOM 操作本身不慢,因为所有框架最终都得操作 DOM,慢的原因是不正当的访问。那如果我们既不想重建 DOM,又不想频繁读取 DOM,那我们该怎么办?
Dirty Checking(脏检查)
Dirty Checking 不是一种响应式的更新机制,而是显示检测变化的机制,每次状态发生变化之后,需要遍历所有动态绑定,逐个比较旧值与新值,判断哪些需要更新。
下面是一个基于 lit 的完整示例:
jsx
import { html, Framework } from "./framework";
class App extends Framework {
constructor() {
super({ count: 0 });
setInterval(() => this.setState({ count: this.state.count + 1 }), 1000);
}
template() {
// 使用html函数来处理模板数据,收集动态绑定数据
return html`<div>${this.state.count}</div>`;
}
}
jsx
export class Framework {
state: Record<string, any> = {};
constructor(initState: Record<string, any>) {
this.state = initState;
document.getElementById("app")!.append(this.template());
}
template(): Node {
throw new Error("must be implemented");
}
setState(newState: Record<string, any>) {
this.state = newState;
this.template();
}
}
type Binding = { type: string; ref: Node; value: any };
/** 用来缓存每个DOM节点及其绑定的动态数据信息 */
const cache = new Map<
TemplateStringsArray,
{
node: Node;
bindings: Binding[];
}
>();
export function html(template: TemplateStringsArray, ...holes: any[]) {
let prev;
// 如果是首次渲染,需要缓存模板及其节点信息,用于下次更新时做比对
if (!(prev = cache.get(template))) {
const t = document.createElement("template");
/**
template.join("<!>")的意思是使用注释节点来占位动态绑定的数据,例如例子中传进来的
template是['<div>', '</div>'],转换后变成<template><div><!----></div></template>
便于后续更新
*/
t.innerHTML = template.join("<!>");
cache.set(
template,
(prev = {
node: t.content.firstChild!, // node是DOM节点
bindings: createBindings(t.content.firstChild!), // bindings是当前dom节点的信息描述,其中包括绑定的动态数据
}),
);
}
// 遍历所有的动态绑定数据,查看是否发生变化
diff(holes, prev.bindings);
return prev.node;
}
// 获取节点的所有动态绑定数据
function createBindings(element: Node) {
const bindings = [];
let tw = document.createTreeWalker(element, NodeFilter.SHOW_COMMENT, null),
comment;
/** 遍历所有的注释节点,首次渲染时value默认设置为undefined */
while ((comment = tw.nextNode())) {
bindings.push({ type: "insert", ref: comment, value: undefined });
}
return bindings;
}
function diff(holes: any[], bindings: Binding[]) {
// 遍历所有的动态绑定数据,查看是否发生变化
for (let i = 0; i < holes.length; i++) {
const binding = bindings[i];
// 比较新旧值
if (holes[i] !== binding.value) {
if (binding.type === "insert") {
// 如果是首次渲染,则插入新的文本节点
if (binding.value == null) {
binding.ref.parentNode!.insertBefore(
document.createTextNode(holes[i]),
binding.ref,
);
} else {
// 如果是更新状态,则直接更新节点的值
binding.ref.previousSibling!.nodeValue = holes[i]
};
}
// other cases;
binding.value = holes[i];
}
}
}
在这个例子中我们用了注释节点来作为占位符,描述动态绑定的位置,便于后续比对更新。
在 Dirty Checking 模式下,首先会收集 Template 中的所有动态绑定的状态,当下次发生状态更新时会去遍历所有的动态绑定的状态,去和新的状态比对,如果发生变化则更新 DOM,可在线调试。

Virtual DOM(虚拟DOM)
虚拟 DOM(Virtual DOM)本质上是 JS 和 DOM 之间的一个映射缓存,它在形态上表现为一个能够描述 DOM 结构及其属性信息的 JS 对象。如下图所示:

当状态更新时,框架底层会借助算法先对比出具体哪些真实 DOM 需要被改变,然后再将这些改变作用于真实 DOM。下面是一个简单的例子:
jsx
import { h, Framework } from "./framework"
class App extends Framework {
constructor() {
super({ count: 0 });
setInterval(() => this.setState({ count: this.state.count + 1 }), 1000);
}
template() {
return h("div", this.state.count);
}
}
这里的 template 参考了 React,例如下面 React 的 jsx 语法:
jsx
<div className='class' title='title'>
<p>xiling</p>
</div>
编译之后如下(在React17 之后已经用了新的 JSX 转换方式):
jsx
React.createElement("div", {
className: "class",
title: "title"
},React.createElement("p", null, "xiling"));
Framework 类的实现如下,核心功能就是 h 和 diff 方法,一个是虚拟 DOM 的构造方法,一个是常说的 dif 算法。
jsx
type VNode = {
type: string;
attrs?: Record<string, string>;
children?: VNode[];
value?: string;
_el?: Node;
};
export class Framework {
state: Record<string, any> = {};
_node: VNode | undefined;
constructor(initState: Record<string, any>) {
this.state = initState;
this._diff(this.template());
}
template(): VNode {
throw new Error("must be implemented");
}
setState(newState: Record<string, any>) {
this.state = newState;
this._diff(this.template());
}
_diff(newNode: VNode) {
diff(this._node, newNode, document.getElementById("app")!);
this._node = newNode;
}
}
export function h(...args: any[]) {
let node: VNode | null = null;
function item(value: any) {
if (value == null) {
} else if ("string" === typeof value) {
/** value类型为字符串要么是 DOM 节点,要么是 DOM 节点的文本内容,需要作为其子节点 */
if (!node) node = { type: value, attrs: {}, children: [] };
else node.children!.push({ type: "#text", value });
} else if (
"number" === typeof value ||
"boolean" === typeof value ||
value instanceof Date
) {
/** 将数字或者日期类型的数据作为dom节点的子节点处理 */
node!.children!.push({ type: "#text", value: value.toString() });
} else if (Array.isArray(value)) value.forEach(item);
else if ("object" === typeof value) {
/** 对于object类型的值,可能是子dom节点,可能是dom节点的属性 */
if (value.type) {
node!.children!.push(value);
} else {
// attributes
for (var k in value) {
node!.attrs![k] = value[k];
}
}
}
}
while (args.length) item(args.shift());
return node!;
}
/** 查找变更点并更新真实DOM */
function diff(node: VNode | undefined, newNode: VNode, root: Node) {
let element;
// diff element
if (!node || node.type !== newNode.type) {
if (node && node._el) (node._el as Element).remove();
element =
newNode.type === "#text"
? document.createTextNode(newNode.value!)
: document.createElement(newNode.type);
(root as Element).append(element);
} else element = node._el!;
newNode._el = element;
if (newNode.type === "#text") {
element.textContent = newNode.value!;
return;
}
// diff attributes
for (let k in newNode.attrs) {
if (node?.attrs![k] !== newNode.attrs[k]) {
(element as Element).setAttribute(k, newNode.attrs[k]);
}
}
// diff children
if (newNode.children) {
for (let i = 0; i < newNode.children.length; i++) {
diff(node?.children?.[i], newNode.children[i], element);
}
}
}
在虚拟 DOM 的模式下,首先会将模板内容转换成虚拟 DOM,当下次状态发生变化时会将新旧虚拟 DOM 进行对比,通过算法找到最小的更新集,最终更新真实 DOM,可在线调试。

Fine-Grained(细力度更新)
Fine-Grained(细粒度) 更新策略是指在状态更新时,不对整个组件进行整体重新渲染,例如像 React 这种基于虚拟 DOM 的方案,当状态发生变化时需要重新执行 template 方法获取新的虚拟 DOM,而 Fine-Grained 是只精确更新那些真正发生变化的部分。这种策略提高了性能,减少了不必要的计算和重绘,这才实现了真正意义上的响应式。
下面是根据 soildjs 实现的一个简单的例子,我们可以先看一下 soildjs 的实现:

soildjs 官方提供了一个 playground 支持开发者在线调试 soildjs 代码,并在右侧的 output 中可以直接查看编译后的结果。
从编译结果中可以看出,soildjs 组件返回的 jsx 会被提取到组件外,并调用 template 函数返回对应的 DOM 节点,在函数内部通过 insert 将动态数据插入到节点中,从而建立绑定关系。下面是对其简单的实现:
jsx
import { createSignal, createEffect } from "./framework"
function App() {
const [count, setCount] = createSignal(0);
setInterval(() => setCount(count() + 1), 1000);
const el = document.createElement("div");
/** $insert方法内部的核心代码,当触发setCount操作后会执行该回调函数 */
createEffect(() => {
el.textContent = String(count());
});
return el;
}
document.getElementById("app")!.append(App());
jsx
type Observer = () => void;
let Observer: Observer | null = null;
export function createSignal<T>(value: T): [() => T, (v: T) => void] {
const observers = new Set<Observer>();
return [
() => {
if (Observer) observers.add(Observer);
return value;
},
(v: T) => {
value = v;
for (let o of observers) {
o();
}
},
];
}
export function createEffect(fn: () => void) {
function compute() {
try {
/** */
Observer = compute;
fn();
} finally {
Observer = null;
}
}
return compute();
}j
createSignal 会创建一个 observers 集合,用来存储该变量的依赖者,当更新变量时通知所有的依赖者进行更新,对应到代码里就是 el.textContent = String(count())
操作,从而实现细粒度的更新,可在线调试。

总结
本篇文章主要介绍了当前前端框架中所使用的三种 DOM 渲染方式,显然每种 DOM 渲染方式都有其优势和劣势,像虚拟 DOM 一个最大的优势就是支持跨平台,而 Fine-Grained 在性能上表现突出,开发者最终选择哪种框架也需要结合自己的实际场景。