为什么需要 Virtual DOM
在直接操作 DOM 的时代,频繁的 DOM 操作是性能瓶颈的主要来源。每次修改 DOM 都可能触发浏览器的重排(reflow)和重绘(repaint),而这两个过程的代价非常高昂。Virtual DOM 的核心思路是:用 JavaScript 对象来描述真实 DOM 的结构,在内存中完成新旧状态的对比,只将必要的变更应用到真实 DOM 上。
这样做带来两个明显的好处:一是减少了对真实 DOM 的无谓操作;二是让开发者可以用声明式的方式描述 UI,而不必关心具体的 DOM 操作细节。
设计 VNode 结构
Virtual DOM 的最小单元是虚拟节点(VNode)。一个 VNode 本质上就是一个普通的 JavaScript 对象,用来描述某个真实 DOM 元素应该长什么样。
function createElement(tag, props, children) {
return {
tag, // 标签名,如 'div'、'span'
props, // 属性对象,如 { class: 'box', id: 'app' }
children, // 子节点数组,元素是 VNode 或字符串
};
}
这个结构非常简洁,但已经足够表达一棵完整的 DOM 树。例如,<div class="box"><span>hello</span></div> 可以表示为:
createElement('div', { class: 'box' }, [
createElement('span', null, ['hello'])
]);
将 VNode 渲染为真实 DOM
有了 VNode 之后,我们需要一个函数把它转换成真实的 DOM 节点。
function render(vnode) {
// 文本节点直接创建文本
if (typeof vnode === 'string') {
return document.createTextNode(vnode);
}
const el = document.createElement(vnode.tag);
// 设置属性
if (vnode.props) {
for (const key in vnode.props) {
el.setAttribute(key, vnode.props[key]);
}
}
// 递归渲染子节点
if (vnode.children) {
vnode.children.forEach(child => {
el.appendChild(render(child));
});
}
return el;
}
这个 render 函数是递归的,它会深度优先地创建整棵 DOM 树。但仅仅有渲染还不够——真正的价值在于当 VNode 发生变化时,如何高效地更新真实 DOM。
Diff 算法的核心思想
Diff 算法要解决的问题是:给定新旧两棵 VNode 树,找出最小的变更操作集合。完整的树 Diff 算法时间复杂度为 O(n³),在实际框架中不可接受。React 等框架通过三条启发式策略将复杂度降到 O(n):
- 只比较同层节点,不跨层级比较。如果某个节点在新树中消失了,整个子树会被删除,不会去尝试复用它的子节点。
- 类型不同则直接替换。如果新旧节点的
tag不同,直接销毁旧的、创建新的,不再深入比较。 - 通过 key 标识同层节点,帮助算法识别哪些节点是"同一个",从而支持节点的移动和复用。
我们这里实现一个简化版本,覆盖前两条策略,并加入基础的 key 支持。
实现 patch 函数
patch 函数是整个 Diff 算法的入口,它接收真实 DOM 节点、旧 VNode 和新 VNode,完成更新。
function patch(el, oldVnode, newVnode) {
// 情况一:新旧都是文本节点
if (typeof oldVnode === 'string' && typeof newVnode === 'string') {
if (oldVnode !== newVnode) {
el.textContent = newVnode;
}
return;
}
// 情况二:类型不同,直接替换
if (typeof oldVnode !== typeof newVnode || oldVnode.tag !== newVnode.tag) {
const newEl = render(newVnode);
el.parentNode.replaceChild(newEl, el);
return;
}
// 情况三:类型相同,更新属性和子节点
updateProps(el, oldVnode.props, newVnode.props);
updateChildren(el, oldVnode.children, newVnode.children);
}
更新属性
属性更新需要处理三种情况:新增属性、删除属性、修改属性值。
function updateProps(el, oldProps = {}, newProps = {}) {
// 删除旧属性中不再存在的
for (const key in oldProps) {
if (!(key in newProps)) {
el.removeAttribute(key);
}
}
// 设置新增或修改的属性
for (const key in newProps) {
if (oldProps[key] !== newProps[key]) {
el.setAttribute(key, newProps[key]);
}
}
}
更新子节点
子节点的更新是 Diff 算法中最复杂的部分。我们实现一个基于 key 的简化版本,核心逻辑是:先建立旧子节点的 key 到索引的映射,然后遍历新子节点,对于每个新节点,如果能在旧节点中找到相同 key 且类型相同的节点,就复用并递归 patch,否则创建新节点。
function updateChildren(el, oldChildren = [], newChildren = []) {
const oldMap = new Map();
oldChildren.forEach((child, index) => {
const key = child.props && child.props.key
? child.props.key
: `__index_${index}`;
oldMap.set(key, { child, index });
});
newChildren.forEach((newChild, i) => {
const key = newChild.props && newChild.props.key
? newChild.props.key
: `__index_${i}`;
const matched = oldMap.get(key);
if (matched && matched.child.tag === newChild.tag) {
// 复用旧节点,递归更新
const realEl = el.childNodes[matched.index];
patch(realEl, matched.child, newChild);
oldMap.delete(key);
} else {
// 创建新节点并插入
const newEl = render(newChild);
if (el.childNodes[i]) {
el.insertBefore(newEl, el.childNodes[i]);
} else {
el.appendChild(newEl);
}
}
});
// 删除未复用的旧节点
oldMap.forEach(({ index }) => {
const node = el.childNodes[index];
if (node) el.removeChild(node);
});
}
这个实现虽然简化,但已经体现了 Diff 的核心思路:通过 key 匹配可复用的节点,只对真正变化的节点执行创建或删除操作。
完整示例
把上面的代码组合起来,就可以实现一个最小可用的 Virtual DOM 系统:
const oldTree = createElement('ul', { class: 'list' }, [
createElement('li', { key: 'a' }, ['Apple']),
createElement('li', { key: 'b' }, ['Banana']),
]);
const newTree = createElement('ul', { class: 'list' }, [
createElement('li', { key: 'b' }, ['Banana']),
createElement('li', { key: 'c' }, ['Cherry']),
]);
const root = document.getElementById('app');
root.appendChild(render(oldTree));
patch(root.firstChild, oldTree, newTree);
局限与延伸
这个简易实现省略了许多生产级框架中的优化,例如:没有处理事件绑定与解绑、没有实现双端比较(React 的 reconcileChildren 或 Vue 的双端 diff)、没有处理 key 为数字类型时的边界情况、没有支持 Fragment 和组件节点。此外,我们使用 childNodes 索引来定位真实节点,在节点移动场景下可能不够精确——更健壮的做法是维护 VNode 到真实 DOM 的映射关系。
但理解了这个简化版本之后,再去阅读 React 的 reconcileChildren 或 Vue 的 patchKeyedChildren,思路会清晰很多。Virtual DOM 的本质并不神秘,它就是用 JavaScript 对象描述 UI 结构,用 Diff 算法找出最小变更,再批量应用到真实 DOM 上。掌握了这个核心,剩下的都是工程上的优化与取舍。
未经允许不得转载:任鹏个人博客 » 从零实现一个简易的 Virtual DOM 与 Diff 算法


朋友圈点赞图在线生成源码