如何构建一个JavaScript虚拟DOM库?
- 内容介绍
- 文章标签
- 相关推荐
:为什么需要虚拟 DOM?
痛点一:直接操作真实 DOM 代码冗长。逻辑分散,维护成本高,
痛点二:频繁的 DOM 更新导致页面卡顿,性能难以保障。
痛点三:手动比对新旧节点时容易出错,调试过程异常痛苦。
虚拟 DOM 正是为了解决上述问题而生——它用轻量的 JavaScript 对象描述 UI 树。找出最小变更,再一次性更新真实 DOM,从而提高开发效率和渲染性能。
说到主要概念,VNode 与 h 函数
什么是 VNode?话说回来,
每个 VNode是对真实 DOM 元素的抽象描述,包含以下三个关键属性:
-
tag标签名,如'div' -
props属性对象,如{ id: 'app',className: 'container' } -
children子节点数组。既可以是字符串也可以是其他 VNode
创建 VNode 的 h 函数
/**
* 创建虚拟节点
* @param {string} tag 标签名
* @param {object} props 属性对象
* @param {Array} children 子节点数组
* @returns {VNode}
*/
function h {
// 统一处理 children 为数组
const normalizedChildren = Array.isArray?children :,return { tag,props。children: normalizedChildren };}
// 示例:
const vApp = h('div'。{ id: 'app' },)
]);
Diff 算法:找出新旧 VNode 的差异
diffVNode 基本实现
/**
* 对比两个 VNode。返回需要补丁的对象
* @param {VNode} oldVNode 旧节点
* @param {VNode} newVNode 新节点
* @returns {object|null} 差异描述或 null
*/
function diffVNode {
// 1️⃣ 标签不同 → 完全替换
if {
return { type: 'REPLACE',newVNode };}
// 2️⃣ 属性对比
const propPatches = {};const allProps = new Set,...Object.keys]);for {
const oldVal = oldVNode.props;const newVal = newVNode.props;if {
propPatches = newVal;老实说,}
}
// 3️⃣ 子节点对比
const childPatches =;const maxLen = Math.max;for {
const oldChild = oldVNode.children;话说回来,const newChild = newVNode.children;if {
// 新增子节点
childPatches.push;} else if {
// 删除子节点
childPatches.push;} else if {
// 文本节点变化
if {
childPatches.push;}
} else {
// 递归比较子树
const nestedPatch = diffVNode;if {
childPatches.push;}
}
}
if .length === 0 && childPatches.length === 0) {
return null;// 没有差异
}
return { type: 'PATCH'。props: propPatches,children: childPatches };}
为什么这里省略了 key 调整?
在实际项目中,为了避免列表重排带来的不必要的删除/新增操作,需要为每个列表项提供唯一的 key. 这篇文章聚焦主要原理。后续可自行
Patching:把差异映射到真实 DOM 上
applyPatch 实现要点
/**
* 将差异对象应用到真实 DOM 节点上
* @param {HTMLElement} dom 当前真实 DOM 节点
* @param {object} patch 差异描述对象
*/
function applyPatch {
if return;switch {
case 'REPLACE':
const newDom = createElement;dom.parentNode.replaceChild;break,case 'PATCH':
// 更新属性
for ) {
if {
dom.removeAttribute;
} else {
dom.setAttribute;}
}
// 更新子节点
patch.children.forEach(childPatch => {
const childDom = dom.childNodes;switch {
case 'ADD':
dom.appendChild);break,case 'REMOVE':
dom.removeChild;break,case 'TEXT':
childDom.textContent = childPatch.text;break,case 'UPDATE':
applyPatch;break,}
});break,老实说,default:
console.warn;}
}
/**
* 根据 VNode 创建对应的真实 DOM 元素
*/
function createElement {
if return document.createTextNode;const el = document.createElement;// 设置属性
for ) {
el.setAttribute;}
// 渲染子节点
vnode.children.forEach));return el,}
从完整示例来看,从创建 VNode 到渲染、更新整个流程
// 第一步先:建立初始虚拟树
let state = { count: 0 };function view {
return h('div'。{ id: 'counter' },),h => setState },)
]);}
// 接下来:首次渲染到页面
const rootEl = document.getElementById;let oldVDOM = view;
rootEl.appendChild);
// 然后:状态更新驱动 Diff + Patch
function setState {
state = { ...state,...newState };不过,const newVDOM = view;const patches = diffVNode;applyPatch,// rootEl.firstChild 即旧的根元素
oldVDOM = newVDOM;// 保存最新的 VDOM 用于下次比较
}
常见痛点回顾与解决思路
- 手写 Diff 太繁琐? 可以先使用这篇文章提供的简化版实现,在业务需求增长时逐步加入 key、列表复用等调整。
- 事件绑定在哪里?其实, 在本例中通过属性 `onClick` 手动绑定;实际库会把事件抽象成统一的事件程序,以便批量处理。
- Patching 时出现“无法找到父节点”错误? 确保在首次渲染后保存根节点引用,并始终通过该根节点进行补丁操作。
- 大规模更新仍然卡顿? 考虑使用 requestAnimationFrame 合并多次 setState 调用,实现批量更新。
从零实现到实战落地
找差异;c)Patch 将差异写回真实 DOM;d)状态驱动视图刷新,
虽然这篇文章省略了高级特性,但这些都可以在已有基础上逐步迭代。只要理解了「声明式 UI + 最小化更新」这套思路。你就能在实际项目中自如地替换手写 DOM 操作,从而明显提高开发效率和页面性能。
接下来建议这方面。
-
为列表渲染添加唯一
- 封装事件程序,使得所有事件统一注册在根元素上,提高冒泡效率。
:为什么需要虚拟 DOM?
痛点一:直接操作真实 DOM 代码冗长。逻辑分散,维护成本高,
痛点二:频繁的 DOM 更新导致页面卡顿,性能难以保障。
痛点三:手动比对新旧节点时容易出错,调试过程异常痛苦。
虚拟 DOM 正是为了解决上述问题而生——它用轻量的 JavaScript 对象描述 UI 树。找出最小变更,再一次性更新真实 DOM,从而提高开发效率和渲染性能。
说到主要概念,VNode 与 h 函数
什么是 VNode?话说回来,
每个 VNode是对真实 DOM 元素的抽象描述,包含以下三个关键属性:
-
tag标签名,如'div' -
props属性对象,如{ id: 'app',className: 'container' } -
children子节点数组。既可以是字符串也可以是其他 VNode
创建 VNode 的 h 函数
/**
* 创建虚拟节点
* @param {string} tag 标签名
* @param {object} props 属性对象
* @param {Array} children 子节点数组
* @returns {VNode}
*/
function h {
// 统一处理 children 为数组
const normalizedChildren = Array.isArray?children :,return { tag,props。children: normalizedChildren };}
// 示例:
const vApp = h('div'。{ id: 'app' },)
]);
Diff 算法:找出新旧 VNode 的差异
diffVNode 基本实现
/**
* 对比两个 VNode。返回需要补丁的对象
* @param {VNode} oldVNode 旧节点
* @param {VNode} newVNode 新节点
* @returns {object|null} 差异描述或 null
*/
function diffVNode {
// 1️⃣ 标签不同 → 完全替换
if {
return { type: 'REPLACE',newVNode };}
// 2️⃣ 属性对比
const propPatches = {};const allProps = new Set,...Object.keys]);for {
const oldVal = oldVNode.props;const newVal = newVNode.props;if {
propPatches = newVal;老实说,}
}
// 3️⃣ 子节点对比
const childPatches =;const maxLen = Math.max;for {
const oldChild = oldVNode.children;话说回来,const newChild = newVNode.children;if {
// 新增子节点
childPatches.push;} else if {
// 删除子节点
childPatches.push;} else if {
// 文本节点变化
if {
childPatches.push;}
} else {
// 递归比较子树
const nestedPatch = diffVNode;if {
childPatches.push;}
}
}
if .length === 0 && childPatches.length === 0) {
return null;// 没有差异
}
return { type: 'PATCH'。props: propPatches,children: childPatches };}
为什么这里省略了 key 调整?
在实际项目中,为了避免列表重排带来的不必要的删除/新增操作,需要为每个列表项提供唯一的 key. 这篇文章聚焦主要原理。后续可自行
Patching:把差异映射到真实 DOM 上
applyPatch 实现要点
/**
* 将差异对象应用到真实 DOM 节点上
* @param {HTMLElement} dom 当前真实 DOM 节点
* @param {object} patch 差异描述对象
*/
function applyPatch {
if return;switch {
case 'REPLACE':
const newDom = createElement;dom.parentNode.replaceChild;break,case 'PATCH':
// 更新属性
for ) {
if {
dom.removeAttribute;
} else {
dom.setAttribute;}
}
// 更新子节点
patch.children.forEach(childPatch => {
const childDom = dom.childNodes;switch {
case 'ADD':
dom.appendChild);break,case 'REMOVE':
dom.removeChild;break,case 'TEXT':
childDom.textContent = childPatch.text;break,case 'UPDATE':
applyPatch;break,}
});break,老实说,default:
console.warn;}
}
/**
* 根据 VNode 创建对应的真实 DOM 元素
*/
function createElement {
if return document.createTextNode;const el = document.createElement;// 设置属性
for ) {
el.setAttribute;}
// 渲染子节点
vnode.children.forEach));return el,}
从完整示例来看,从创建 VNode 到渲染、更新整个流程
// 第一步先:建立初始虚拟树
let state = { count: 0 };function view {
return h('div'。{ id: 'counter' },),h => setState },)
]);}
// 接下来:首次渲染到页面
const rootEl = document.getElementById;let oldVDOM = view;
rootEl.appendChild);
// 然后:状态更新驱动 Diff + Patch
function setState {
state = { ...state,...newState };不过,const newVDOM = view;const patches = diffVNode;applyPatch,// rootEl.firstChild 即旧的根元素
oldVDOM = newVDOM;// 保存最新的 VDOM 用于下次比较
}
常见痛点回顾与解决思路
- 手写 Diff 太繁琐? 可以先使用这篇文章提供的简化版实现,在业务需求增长时逐步加入 key、列表复用等调整。
- 事件绑定在哪里?其实, 在本例中通过属性 `onClick` 手动绑定;实际库会把事件抽象成统一的事件程序,以便批量处理。
- Patching 时出现“无法找到父节点”错误? 确保在首次渲染后保存根节点引用,并始终通过该根节点进行补丁操作。
- 大规模更新仍然卡顿? 考虑使用 requestAnimationFrame 合并多次 setState 调用,实现批量更新。
从零实现到实战落地
找差异;c)Patch 将差异写回真实 DOM;d)状态驱动视图刷新,
虽然这篇文章省略了高级特性,但这些都可以在已有基础上逐步迭代。只要理解了「声明式 UI + 最小化更新」这套思路。你就能在实际项目中自如地替换手写 DOM 操作,从而明显提高开发效率和页面性能。
接下来建议这方面。
-
为列表渲染添加唯一
- 封装事件程序,使得所有事件统一注册在根元素上,提高冒泡效率。

