🧭 本文结构(先看目录,便于定位)
- 虚拟 DOM(VNode)深入:数据结构、优缺点、渲染流程
- 从渲染器(render)到挂载(mount):把 VNode 变成真实 DOM 的每一步
- Diff 的精确定义与策略:为什么这么比、不能怎么比
- 逐步详解同级 diff(节点、props、文本、children)
- 列表 diff(key 的细节、常见错误、移动检测)
- 高级优化:最小移动算法(LIS)、批量/合并更新、时间片(Fiber)
- Patch 的执行细节:DOM API 到底怎调用,如何减少重排/重绘
- 记忆与空间复杂度、性能权衡
- 手写迷你实现(完整代码,带注释):render, diff, patch,含 keyed list diff(简单版+LIS版)
- 面试高频问答与标准答案
- 实战练习题(带解答思路)
1.1 本质(再明确一次)
虚拟 DOM(VNode)就是一种数据结构,用 JS 对象来表示节点、属性、子节点和文本。优点是:可在 JS 层自由比较、复制、快照,而不触发浏览器昂贵的布局/重绘。
常见 VNode 结构(现实框架变体很多,这里用最通用的)
// 文本节点可以用 type = null 或 type = "text"
{
type: "div", // 节点类型:'div' | 'span' | function/component | null(text)
props: { id: "app", style: {...}, onClick: f }, // 属性/事件
children: [ vnode | string ] // 子节点数组,字符串表示文本
key: "唯一标识(列表用)" // 可选:用于列表 diff 的稳定 id
}
1.2 为什么要把 DOM 变成数据?
- 可比较(diff) :两个对象树可以递归比较,生成“变化集合(patch)”。
- 可序列化/可复用:VNode 是普通对象,可序列化、存储状态、在服务器端渲染(SSR)再送到客户端。
- 跨渲染端:相同的 VNode 可以渲染到 DOM、Canvas、Native 组件等,只需替换渲染层(renderer)。
1.3 常见实现差异(真实框架 vs 学习实现)
- 框架会把组件(函数/类)和普通 DOM 节点都视为一种“VNode”,并在挂载阶段区分组件与宿主节点(host node)。
- 事件处理真实框架会做事件委托(把事件挂在根上),而不是给每个 vnode 添加原生监听器(性能和内存优化)。
- props 会有更多分类处理:style、class、事件、ref、dangerouslySetInnerHTML 等。
2 从 VNode 到真实 DOM:render / mount 的细节(步步为营)
假设你有一个 vnode,render 的过程分成两步:创建实际 DOM 节点(mount)并把它插入到父容器。
2.1 文本节点 vs 元素节点的创建
- 文本节点:
document.createTextNode(text) - 元素节点:
document.createElement(type),然后对 props 做处理(setAttribute、className、addEventListener等),最后递归处理 children 并 appendChild。
2.2 props 的处理顺序(很重要)
- 事件处理(通常做委托):不要直接
el.addEventListener每次更新都解绑/重绑;框架通常 bind 一次。 - 属性/属性与 DOM 属性区别:
class->el.className,for->el.htmlFor,style-> 逐个设置el.style.xxx,boolean 属性要特别处理(checked,disabled)。 - dataset/data-*:需要
el.dataset映射。 - ref 处理:组件会把 DOM 引用返回调用方。
2.3 示例代码(挂载)
function mount(vnode, container) {
if (typeof vnode === 'string' || typeof vnode === 'number') {
const textNode = document.createTextNode(String(vnode));
container.appendChild(textNode);
return textNode;
}
const el = document.createElement(vnode.type);
// props
if (vnode.props) {
for (const [k, v] of Object.entries(vnode.props)) {
setProp(el, k, v);
}
}
// children
(vnode.children || []).forEach(child => mount(child, el));
container.appendChild(el);
return el;
}
(setProp 需处理事件名、style、class 等细节)
3 Diff:精确定义与为什么要那样比
3.1 问题定义(严谨)
输入:oldVNode、newVNode 与挂载好的 oldDom
输出:一组patch 操作(描述对真实 DOM 的最小修改)
目标:以最少的真实 DOM 操作完成从 old -> new 的更新,同时保证最终 UI 与直接重渲染 newVNode 到 DOM 的结果一致。
3.2 为什么不直接替换整个 subtree?
替换整棵子树是最简单的做法(删除旧 DOM、创建新 DOM 并挂上),但代价很大:
- 会移除并重建所有子节点,丢失子节点状态(例如 input 的焦点、表单值、视频的播放位置、组件内部状态)。
- 性能差:多次 createElement/append 会触发多次布局/重绘(若不合并,会卡顿)。
所以目标是 只改变需要变的节点。
4 逐项详解同级 diff(节点、props、文本、children)
我把 diff 的比较拆成最小的核:节点比较(type)→ props 比较 → children 比较。
4.1 节点比较(type)
- 若
old.type !== new.type:直接替换(REPLACE)。
例外:某些框架允许 type 不同但可以复用 DOM(极少数情况下),但通用做法是替换。
复杂度:O(1)
4.2 props 比较(updateProps)
做法:
- 遍历 new.props,若某 prop 在 old.props 中不存在或值不同,就 set(或更新)。
- 遍历 old.props,若某 prop 在 new.props 中不存在,就 remove(或设为 null/false)。
注意点与细节:
style是对象,需要内部比较(逐属性 diff)。- 事件处理(如
onClick)在大多数框架中不直接绑定在节点上,而是做事件委托;在实现简单版本时,直接绑定也可,但更新时必须先移除旧 listener 再添加新 listener。 - 布尔类型(
checked,disabled)通常需要设置 DOM 属性而非 setAttribute。
复杂度:O(p) (p = props 数量)
4.3 文本节点(text)
文本节点比对是最简单的:oldText !== newText 就 textNode.nodeValue = newText。
注意:字符串与 number 都要转成 string 比较和写入。
4.4 children 比较(最复杂)
children 分为两类情况:
- 非列表(或非同类) :直接递归 diff 每一项(按照索引对比)。
- 有序列表(像 的 li) :需要 key 才能做高效 diff,否则只能按位置对比,容易造成移动误判。
非 key 列表(naive):
- 依次对比
old.children[i]与new.children[i],如果 old 没有了,就 mount 新节点;如果 new 少了,就 remove 多余的旧节点。简单但会在节点移动时产生大量无谓操作。
Key 优化(重要):
-
每个子节点在 vnode 上带一个稳定、唯一的 key(例如数据库 id)。
-
通过 key 建立索引表(oldKeyToIndex),快速判断 new 子节点是否在 old 中存在。常见策略:
- 遍历 newChildren,若 key 在旧中存在就复用该 DOM 并移动到合适位置;若不存在就新建。
- 遍历 oldChildren,若 key 不在 new 中则删除。
-
移动操作的优化可以结合 Longest Increasing Subsequence(LIS)算法减少实际 DOM move 的次数(见第6章)。
复杂度:
- naive 按顺序比较:O(n) 比较次数,但在移动大量节点时会导致大量 DOM 操作。
- keyed 建索引:O(n) 时间(构建 hashmap + 遍历),移动次数可以通过 LIS 优化到最少。
5 列表 diff:key 的细节与常见坑(必须看)
5.1 key 的语义 & 选择
-
语义:key 表示“这个子节点的身份/恒等标识”,应该对同一项在不同渲染间保持稳定。
-
常见选择:
- 优先:后端提供的 id(最稳定)
- 其次:数据库主键
- 不要:索引
i(除非列表永远不会 reorder/insert/delete)
5.2 为什么不能用索引作为 key(常见错误)
举例:
old: [A(id=1), B(id=2), C(id=3)] => keyed by index: 0,1,2
new: [D, A, B] (在头部插入 D)
如果用索引为 key,diff 会认为:
- old[0] 对应 new[0] → A 变为 D → 更新 A 的 DOM 成 D(实际上 A 应该被移动)
- old[1] -> new[1] → B 变成 A (错误)
产生大量误操作。
所以:索引作为 key 只在静态列表(不会 reorder/insert/delete)下安全,现实情况很少满足。
5.3 列表 diff 的核心思路(带 key)
伪流程:
-
构建
oldKeyIndexMap:旧 children 的 key -> index。 -
遍历
newChildren:- 如果 key 在 old map 中:复用该 DOM(并记录 oldIndex),并对其进行 diff(递归)。
- 如果 key 不在 old map:创建新节点并插入。
-
遍历 old children,删除那些 key 不在 new 中的 DOM。
-
根据 oldIndex 序列决定最小移动操作(LIS)。
步骤 2 得到一个 source 数组,表示 newChildren 对应于 old 的索引(不存在的用 -1),例如 [3, -1, 0, 2]。在对 source 做 LIS,非 LIS 元素需要移动/插入。LIS 保证最少移动次数。
6 高级优化(LIS / 批量 / Fiber)
6.1 Longest Increasing Subsequence(LIS)
- 问题:已知
source(new -> old index),要最少移动 DOM 使序列升序(升序表示位置不需动)。 - LIS 返回长度最长的递增子序列,那些在 LIS 里的元素可以复用并保持顺序,其他元素需要移动(或创建)。
- LIS 算法(n log n)常用于 Vue/React 的 keyed list diff 优化。
6.2 批量更新(batching)
- 如果你在事件里多次 setState,框架会把多次状态合并成一次渲染(批量),避免多次执行 diff/patch。
- 手动优化提示:在同一次任务里尽量合并变更。
6.3 时间片/Fiber(React)
- React Fiber 将渲染拆成小任务,按优先级调度,避免长时间阻塞主线程(避免 UI 卡顿)。
- 核心思想:把更新工作拆成多帧执行,允许中断恢复(cooperative scheduling)。
7 Patch 的执行细节(到 DOM API 的每一步都讲清楚)
实际 patch 最常见操作有:
createElement(type)/createTextNodesetAttribute/removeAttributeel.addEventListener/el.removeEventListener(或事件委托)insertBefore/appendChild/replaceChild/removeChildtextNode.nodeValue = ...
7.1 如何减少重排(reflow)与重绘(repaint)
- 尽量减少 DOM 读写混合:避免
el.offsetHeight这样的读操作穿插在写操作中(读会强制回流)。把写操作合并到一起。 - 使用 DocumentFragment 批量插入:先把新节点 append 到 fragment,再一次性 append 到 DOM,可减少渲染次数。
- 避免频繁改 style 的宽高:改动可能触发 layout。尽量用
transform做动画(不会引起 layout)。
7.2 插入位置选择
使用 parent.insertBefore(newNode, referenceNode) 能够在 O(1) 时间插入任意位置(referenceNode 为 null 等同 appendChild)。要注意 referenceNode 的计算要基于最新 DOM 状态。
8 空间复杂度与性能权衡(实务建议)
- 虚拟 DOM 占内存(因为要保存对象树),小页面影响不大,大应用需留意内存。
- Diff 算法不是免费的:频繁更新但改动很小也可能导致不必要的遍历(但总体比直接修改 DOM 更划算)。
- 对于超复杂、超多节点的场景(比如表格上万行),考虑虚拟化(windowing) :只渲染可见区域。
9 手写迷你实现(实战代码)——我给你两个版本:
- 版本 A:最小、可运行、便于理解(不支持组件,仅 DOM vnode)
- 版本 B:带 keyed 列表 diff + 简单 LIS 优化(真实可用的教学级实现)
说明:下面代码为教学目的,尽量清晰而非极端性能优化。可直接在浏览器控制台粘贴运行(配合 demo 页面)。
9.A 迷你版:render / mount / diff / patch(无 key)
// 简单 VNode 工厂
function h(type, props, children = []) {
return { type, props: props || {}, children: Array.isArray(children) ? children : [children] };
}
// 挂载 vnode 到 container(返回真实DOM)
function mount(vnode, container) {
if (typeof vnode === 'string' || typeof vnode === 'number') {
const text = document.createTextNode(String(vnode));
container.appendChild(text);
return text;
}
const el = document.createElement(vnode.type);
// props
for (const [k, v] of Object.entries(vnode.props || {})) {
setProp(el, k, v);
}
// children
vnode.children.forEach(child => mount(child, el));
container.appendChild(el);
return el;
}
function setProp(el, key, val) {
if (key.startsWith('on')) {
const event = key.slice(2).toLowerCase();
el.addEventListener(event, val);
} else if (key === 'style' && typeof val === 'object') {
Object.assign(el.style, val);
} else {
el.setAttribute(key, val);
}
}
// diff(oldVnode, newVnode, parentEl, index)
// 这里只做最基础的按索引 diff(不处理 key),patch 直接应用到 parentEl.childNodes
function diff(oldVnode, newVnode, parentEl) {
// 如果没有 oldVnode,直接 mount new
if (!oldVnode) {
mount(newVnode, parentEl);
return;
}
// 如果没有 newVnode,remove old DOM
if (!newVnode) {
parentEl.removeChild(parentEl.childNodes[0]); // 简化:在实际中需要定位具体 child
return;
}
// 文本节点比较
if (typeof oldVnode === 'string' || typeof oldVnode === 'number') {
if (String(oldVnode) !== String(newVnode)) {
// 假设第0个子节点为目标(示例)
parentEl.childNodes[0].nodeValue = String(newVnode);
}
return;
}
// type 不同 -> replace
if (oldVnode.type !== newVnode.type) {
// replace whole node
const newEl = mount(newVnode, document.createElement('div')); // 临时挂载到 fragment
parentEl.replaceChild(newEl, parentEl.childNodes[0]);
return;
}
// 同类型 -> 更新 props
const el = parentEl.childNodes[0];
updateProps(el, oldVnode.props || {}, newVnode.props || {});
// 递归 children - 按位置对比(示例)
const maxLen = Math.max(oldVnode.children.length, newVnode.children.length);
for (let i = 0; i < maxLen; i++) {
diff(oldVnode.children[i], newVnode.children[i], el);
}
}
function updateProps(el, oldProps, newProps) {
// set or update
for (const [k, v] of Object.entries(newProps)) {
if (oldProps[k] !== v) {
setProp(el, k, v);
}
}
// remove
for (const k of Object.keys(oldProps)) {
if (!(k in newProps)) {
if (k.startsWith('on')) {
// can't remove specific listener easily in this minimal example
} else {
el.removeAttribute(k);
}
}
}
}
这个版本演示最基础流程:mount -> diff -> updateProps -> recurse。注意:这个“按 parent.childNodes[0]”的定位是示例化简,不要直接用于真实项目。真实实现需要把 vnode 与真实 DOM 做对应映射(比如把 dom 引用保存在 vnode.el)。
9.B 进阶版:带 keyed 列表 diff + LIS(可直接复制到浏览器调试)
下面我给出一个比较完整的 keyed list diff 版本(稍长),会说明每一步,并在关键处用注释解释。
// ---------- VNode 工具 ----------
function h(type, props, children = []) {
return { type, props: props || {}, children: Array.isArray(children) ? children : [children], el: null, key: props && props.key };
}
// ---------- mount ----------
// 返回挂载的 DOM el
function mount(vnode, container) {
if (typeof vnode === 'string' || typeof vnode === 'number') {
const text = document.createTextNode(String(vnode));
container.appendChild(text);
vnode.el = text;
return text;
}
const el = document.createElement(vnode.type);
vnode.el = el;
// props
for (const [k, v] of Object.entries(vnode.props || {})) {
setProp(el, k, v);
}
// children
vnode.children.forEach(child => mount(child, el));
container.appendChild(el);
return el;
}
function setProp(el, key, val) {
if (key === 'key') return;
if (key.startsWith('on')) {
const event = key.slice(2).toLowerCase();
el.addEventListener(event, val);
} else if (key === 'style' && typeof val === 'object') {
Object.assign(el.style, val);
} else if (key in el) {
try { el[key] = val } catch(e) { el.setAttribute(key, val) }
} else {
el.setAttribute(key, val);
}
}
// ---------- updateProps ----------
function updateProps(el, oldProps = {}, newProps = {}) {
// set or update
for (const [k, v] of Object.entries(newProps)) {
if (k === 'key') continue;
if (oldProps[k] !== v) {
setProp(el, k, v);
}
}
// remove
for (const k of Object.keys(oldProps)) {
if (k === 'key') continue;
if (!(k in newProps)) {
if (k.startsWith('on')) {
// In a production lib we'd keep a map of listeners to remove the exact one
} else {
el.removeAttribute(k);
}
}
}
}
// ---------- diff (主流程) ----------
// oldVnode and newVnode are roots already mounted
function patch(oldVnode, newVnode, parent) {
// 如果 old 不存在直接 mount
if (!oldVnode) {
mount(newVnode, parent);
return;
}
// 如果 new 不存在 -> remove
if (!newVnode) {
parent.removeChild(oldVnode.el);
return;
}
// 文本节点
if (typeof oldVnode === 'string' || typeof oldVnode === 'number' ||
typeof newVnode === 'string' || typeof newVnode === 'number') {
if (String(oldVnode) !== String(newVnode)) {
const newEl = document.createTextNode(String(newVnode));
parent.replaceChild(newEl, oldVnode.el);
newVnode.el = newEl;
} else {
newVnode.el = oldVnode.el;
}
return;
}
// type 不同 -> replace
if (oldVnode.type !== newVnode.type) {
const newEl = mount(newVnode, document.createElement('div'));
parent.replaceChild(newEl, oldVnode.el);
newVnode.el = newEl;
return;
}
// 相同类型 -> 更新 props + children
const el = (newVnode.el = oldVnode.el);
updateProps(el, oldVnode.props, newVnode.props);
// children diff(支持 keyed 优化)
patchChildren(oldVnode.children, newVnode.children, el);
}
// ---------- children diff(keyed 版,有 LIS 优化) ----------
function patchChildren(oldChildren, newChildren, parentEl) {
// normalize children (strings -> vnode text)
oldChildren = oldChildren || [];
newChildren = newChildren || [];
// 快速长度相同且无 key 的场景:按序对比
const hasKey = newChildren.some(c => c && c.key != null);
if (!hasKey) {
const commonLen = Math.min(oldChildren.length, newChildren.length);
for (let i = 0; i < commonLen; i++) {
patch(oldChildren[i], newChildren[i], parentEl);
}
if (newChildren.length > oldChildren.length) {
for (let i = commonLen; i < newChildren.length; i++) mount(newChildren[i], parentEl);
} else if (newChildren.length < oldChildren.length) {
for (let i = commonLen; i < oldChildren.length; i++) parentEl.removeChild(oldChildren[i].el);
}
return;
}
// keyed diff:
const oldKeyIndex = new Map();
for (let i = 0; i < oldChildren.length; i++) {
const k = oldChildren[i] && oldChildren[i].key;
if (k != null) oldKeyIndex.set(k, i);
}
const newIndexToOldIndexMap = new Array(newChildren.length).fill(-1);
// 1) 创建或复用节点
for (let i = 0; i < newChildren.length; i++) {
const newV = newChildren[i];
const k = newV && newV.key;
if (k != null && oldKeyIndex.has(k)) {
const oldIndex = oldKeyIndex.get(k);
newIndexToOldIndexMap[i] = oldIndex;
patch(oldChildren[oldIndex], newV, parentEl);
} else {
// 新节点,插入到正确位置(暂时 append,后面会移动)
mount(newV, parentEl);
}
}
// 2) 删除旧节点中不在 new 中的
for (let i = 0; i < oldChildren.length; i++) {
const old = oldChildren[i];
if (old && old.key != null && !newChildren.some(n => n.key === old.key)) {
parentEl.removeChild(old.el);
}
}
// 3) 最少移动:根据 newIndexToOldIndexMap 做 LIS
// newIndexToOldIndexMap 有 -1(new created)或 oldIndex 数字
// 我们需要把不在 LIS 的元素移动到正确位置
const seq = lis(newIndexToOldIndexMap);
let s = seq.length - 1;
for (let i = newChildren.length - 1; i >= 0; i--) {
const curVnode = newChildren[i];
const nextPos = i + 1 < newChildren.length ? newChildren[i + 1].el : null;
if (newIndexToOldIndexMap[i] === -1) {
// 新创建的节点,插入到 reference 前
mount(curVnode, parentEl);
if (nextPos) parentEl.insertBefore(curVnode.el, nextPos);
} else {
// 已存在的节点,检查是否在 LIS(可保持位置)
if (s < 0 || i !== seq[s]) {
// 需要移动
parentEl.insertBefore(curVnode.el, nextPos);
} else {
// 在 LIS 中,不需要移动
s--;
}
}
}
}
// LIS (Longest Increasing Subsequence) for array of numbers with -1 as "new"
// returns indices of LIS in terms of the positions in the array (i values)
function lis(arr) {
const n = arr.length;
const p = arr.slice();
const result = [];
let len = 0;
for (let i = 0; i < n; i++) {
const val = arr[i];
if (val === -1) continue; // skip new nodes when computing LIS
let low = 0, high = len;
while (low < high) {
const mid = (low + high) >> 1;
if (arr[result[mid]] < val) low = mid + 1;
else high = mid;
}
if (low === len) {
result.push(i);
len++;
} else {
result[low] = i;
}
p[i] = low > 0 ? result[low - 1] : -1;
}
// reconstruct indices
let res = [];
let k = result[len - 1];
for (let i = len - 1; i >= 0; i--) {
res[i] = k;
k = p[k];
}
return res;
}
说明:
- 这个实现把 vnode.el 存在 vnode 上,便于 patch 时直接使用 DOM 引用(真实框架会用 fiber/instance 结构)。
patchChildren中的第 1 步:对于 new child,如果在 old map 中则 patch(复用),否则 mount。- 第 3 步使用 LIS 来减少移动次数。这里的 LIS 返回的是 newChildren 的索引序列(是实现细节的一种方式),目的是避免不必要的 insertBefore(因为 insertBefore 对 DOM 来说是移动操作)。
10 面试高频问答与标准答案(精炼可背)
- 问:什么是虚拟 DOM?
答:虚拟 DOM 是表示 UI 的 JS 对象树,用来在内存中计算 UI 变更,最后把最小变更应用到真实 DOM 上。 - 问:Diff 的核心策略是什么?
答:同层比较、先比较 type(不同则替换),列表使用 key 做优化;对 props 做增量更新,对 children 递归 diff。 - 问:key 是什么?为什么要用?
答:key 是标识每个子节点恒等性的唯一 ID。在列表 diff 中使用 key 能让算法快速定位复用节点,避免误把移动当做删除+创建。 - 问:如何用最少的 DOM move 来处理列表?
答:构建 new 到 old 的索引映射并计算 LIS(最长递增子序列),LIS 中的元素可以保留顺序不动,其他元素需要移动或插入。 - 问:虚拟 DOM 是否总是比直接操作 DOM 快?
答:不是。少量简单更新时直接操作 DOM 可能更快。但在频繁或大量更新场景下,virtual DOM 提供批量比较与最小 patch,整体更优。 - 问:如何减少 patch 导致的重排?
答:合并写操作、使用 DocumentFragment 批量插入、避免读写混合、用 transform 做动画等。
11 实战练习题(强烈建议自己实现并调试)
题目1(基础):手写 h, mount, patch(不处理 key),并在页面上实现一个计数器(点击按钮增加数字),观察 DOM 更新过程,打印每次 mount/patch 的日志。
题目2(进阶):在题1 的基础上,把一个数组渲染成 <ul>,实现删除/添加元素操作,用索引作为 key,再改成 id 作为 key,观察区别(在控制台记录 DOM 操作次数)。
题目3(挑战):实现一个支持 keyed 列表并使用 LIS 优化的 patchChildren。测试场景:大量随机 reorder、插入、删除,比较没有 LIS 与有 LIS 的实际 DOM move 次数差异。