feat(stages): 添加性能测试和虚拟DOM实现阶段

添加stage04-perf用于真实DOM操作性能对比,包含三个不同的更新策略:
- innerHTML全量更新
- createElement全量重建
- 精确单节点更新

添加stage04用于模拟异步接口数据获取后的渲染演示

添加stage05实现虚拟DOM基础功能,提供VNode对象描述DOM树结构和递
归渲染函数
This commit is contained in:
zzy
2026-05-02 12:36:53 +08:00
parent fc87cf4622
commit 845789cd60
7 changed files with 483 additions and 0 deletions

43
stage04-perf/index.css Normal file
View File

@@ -0,0 +1,43 @@
.list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-bottom: 1px solid #eee;
}
.avatar {
width: 40px;
height: 40px;
background: #ddd;
border-radius: 50%;
text-align: center;
line-height: 40px;
}
.content h4 {
margin: 0;
font-size: 16px;
}
.content p {
margin: 4px 0;
font-size: 14px;
color: #555;
}
.tag {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.active {
background: #d4edda;
color: #155724;
}
.inactive {
background: #f8d7da;
color: #721c24;
}

28
stage04-perf/index.html Normal file
View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stage 04-perf - 真实 DOM 操作代价</title>
<link rel="stylesheet" href="index.css" />
</head>
<body>
<h2>真实 DOM 更新性能对比(控制台查看详细耗时)</h2>
<div>
<label for="rangeN">数据量 N<span id="nValue">10000</span></label>
<br>
<input type="range" id="rangeN" min="100" max="200000" step="100" value="10000" />
<button id="btnApplyN">应用此数量</button>
</div>
<div class="stats" id="stats">点击按钮对比耗时</div>
<div id="compareDisplay" style="margin: 8px 0; font-family: monospace;"></div>
<div>
<button id="btnFullInnerHTML">全量 innerHTML 更新</button>
<button id="btnFullCreate">全量 createElement 更新</button>
<button id="btnExact">精确单节点更新</button>
<button id="btnReset">重置列表</button>
</div>
<ul id="list"></ul>
<script src="./index.js"></script>
</body>
</html>

210
stage04-perf/index.js Normal file
View File

@@ -0,0 +1,210 @@
/**
* Stage 04-perf真实 DOM 操作性能实证(耗时对比版)
* 动态调整数据量,复杂节点,三种更新策略耗时一目了然
*/
const listEl = document.getElementById("list");
const statsEl = document.getElementById("stats");
const compareDisplay = document.getElementById("compareDisplay");
const rangeN = document.getElementById("rangeN");
const nValueSpan = document.getElementById("nValue");
// ---------- 当前数据量(与滑块同步)----------
let N = 10000;
let items = [];
// 记录最近一次各方案耗时ms用于页面展示
const lastResult = {
innerHTML: "-",
createElement: "-",
exact: "-",
};
// ---------- 工具函数:生成初始数据 ----------
function generateItems(count) {
return Array.from({ length: count }, (_, i) => ({
id: i,
title: `Item ${i}`,
desc: `Description for item ${i}`,
tag: i % 3 === 0 ? "active" : "inactive",
}));
}
// ---------- 复杂节点构造 ----------
/**
* 生成带子元素的 <li>,模拟真实 UI 片段
* @param {Object} item
* @returns {HTMLLIElement}
*/
function createComplexItem(item) {
const li = document.createElement("li");
li.className = "list-item";
const avatar = document.createElement("div");
avatar.className = "avatar";
avatar.textContent = item.id % 100;
const content = document.createElement("div");
content.className = "content";
const title = document.createElement("h4");
title.textContent = item.title;
const desc = document.createElement("p");
desc.textContent = item.desc;
const tag = document.createElement("span");
tag.className = "tag " + item.tag;
tag.textContent = item.tag;
content.appendChild(title);
content.appendChild(desc);
content.appendChild(tag);
li.appendChild(avatar);
li.appendChild(content);
return li;
}
/**
* 构建整个列表的 HTML 字符串innerHTML 方案)
* @param {Object[]} data
* @returns {string}
*/
function buildHTML(data) {
let html = "";
for (let i = 0; i < data.length; i++) {
const item = data[i];
html += `<li class="list-item">
<div class="avatar">${item.id % 100}</div>
<div class="content">
<h4>${item.title}</h4>
<p>${item.desc}</p>
<span class="tag ${item.tag}">${item.tag}</span>
</div>
</li>`;
}
return html;
}
/**
* 全量 createElement 重建列表
* @param {Object[]} data
*/
function rebuildWithCreateElement(data) {
listEl.innerHTML = "";
for (let i = 0; i < data.length; i++) {
listEl.appendChild(createComplexItem(data[i]));
}
}
/**
* 精确更新单个项目的内容(只改文字)
* @param {number} index
* @param {Object} newItem
*/
function updateOneItemExact(index, newItem) {
const li = listEl.children[index];
if (!li) return;
const h4 = li.querySelector("h4");
if (h4) h4.textContent = newItem.title;
const p = li.querySelector("p");
if (p) p.textContent = newItem.desc;
const span = li.querySelector("span");
if (span) {
span.textContent = newItem.tag;
span.className = "tag " + newItem.tag;
}
}
// ---------- 更新页面上的耗时对比显示 ----------
function updateCompareDisplay() {
compareDisplay.innerHTML = `
最近耗时对比N = ${N}<br>
innerHTML 全量:<b>${lastResult.innerHTML}ms</b> |
createElement 全量:<b>${lastResult.createElement}ms</b> |
精确更新:<b>${lastResult.exact}ms</b>
`;
}
// ---------- 重置列表到当前 N ----------
function resetList() {
items = generateItems(N);
console.time(`reset (${N} 项 createElement)`);
rebuildWithCreateElement(items);
console.timeEnd(`reset (${N} 项 createElement)`);
statsEl.textContent = `列表已重置(${N} 项)`;
// 清空耗时记录
lastResult.innerHTML = "-";
lastResult.createElement = "-";
lastResult.exact = "-";
updateCompareDisplay();
}
// ---------- 滑块事件 ----------
// 滑块仅更新显示数值,不自动重建
rangeN.addEventListener("input", (e) => {
N = parseInt(e.target.value, 10);
nValueSpan.textContent = N;
});
// 点击“应用”按钮才重建列表
document.getElementById("btnApplyN").addEventListener("click", () => {
resetList();
});
// ---------- 按钮事件 ----------
document.getElementById("btnReset").addEventListener("click", resetList);
document.getElementById("btnFullInnerHTML").addEventListener("click", () => {
const newItems = items.map((item, i) =>
i === 5000 ? { ...item, title: "CHANGED" } : item,
);
console.time(`innerHTML 全量 (${N} 项)`);
listEl.innerHTML = buildHTML(newItems);
console.timeEnd(`innerHTML 全量 (${N} 项)`);
// 从控制台取不到精确时间,用 performance.now 手动记录
const start = performance.now();
listEl.innerHTML = buildHTML(newItems);
const end = performance.now();
lastResult.innerHTML = (end - start).toFixed(2);
items = newItems;
statsEl.textContent = "innerHTML 更新完成";
updateCompareDisplay();
});
document.getElementById("btnFullCreate").addEventListener("click", () => {
const newItems = items.map((item, i) =>
i === 5000 ? { ...item, title: "CHANGED" } : item,
);
const start = performance.now();
rebuildWithCreateElement(newItems);
const end = performance.now();
lastResult.createElement = (end - start).toFixed(2);
console.log(`createElement 全量耗时: ${lastResult.createElement}ms`);
items = newItems;
statsEl.textContent = "createElement 全量完成";
updateCompareDisplay();
});
document.getElementById("btnExact").addEventListener("click", () => {
const targetIndex = Math.min(5000, N - 1);
const newItems = [...items];
newItems[targetIndex] = { ...newItems[targetIndex], title: "CHANGED" };
const start = performance.now();
updateOneItemExact(targetIndex, newItems[targetIndex]);
const end = performance.now();
lastResult.exact = (end - start).toFixed(2);
console.log(`精确更新耗时: ${lastResult.exact}ms`);
items = newItems;
statsEl.textContent = "精确更新完成";
updateCompareDisplay();
});
// ---------- 启动 ----------
resetList();

14
stage04/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang='zh-cn'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>Stage 04: 模拟异步接口</title>
</head>
<body>
<input type="number" id="userId" placeholder="用户 ID" />
<button id="loadBtn">加载用户</button>
<div id="minivue"></div>
<script src="./index.js"></script>
</body>
</html>

103
stage04/index.js Normal file
View File

@@ -0,0 +1,103 @@
/**
* Stage 04模拟异步接口
* 演示数据从接口获取后再渲染
*/
/**
* 根据用户数据生成卡片 DOM 节点
* @param {{ name: string, age: number, tag: string }} user
* @returns {HTMLDivElement}
*/
function generateUser(user) {
const card = document.createElement("div");
card.className = "user-card";
const name = document.createElement("h2");
name.textContent = "姓名: " + user.name;
const age = document.createElement("p");
age.textContent = "年龄: " + user.age;
const tag = document.createElement("span");
tag.textContent = user.tag;
card.appendChild(name);
card.appendChild(age);
card.appendChild(tag);
return card;
}
/**
* 将用户卡片挂载到指定容器
* @param {{ name: string, age: number, tag: string }} user
* @param {HTMLElement} container
*/
function renderUser(user, container) {
container.appendChild(generateUser(user));
}
/**
* 渲染用户列表(先清空容器)
* @param {{ name: string, age: number, tag: string }[]} users
* @param {HTMLElement} container
*/
function renderUserList(users, container) {
container.innerHTML = ""; // 清空原有内容
for (let i = 0; i < users.length; i++) {
renderUser(users[i], container);
}
}
// 模拟数据库
const multiUser = [
{ name: "小明", age: 18, tag: "学生" },
{ name: "小王", age: 19, tag: "学生" },
{ name: "小李", age: 20, tag: "学生" },
{ name: "小孙", age: 56, tag: "校长" },
{ name: "小赵", age: 43, tag: "老师" },
];
/**
* 模拟异步获取用户数据
* @param {number} id
* @returns {Promise<{ name: string, age: number, tag: string }[]>}
*/
async function getUser(id) {
await new Promise((resolve) => setTimeout(resolve, 500));
const count = Math.min(Math.max(1, id), multiUser.length);
return multiUser.slice(0, count);
}
const root = document.getElementById("minivue");
const loadBtn = /** @type {HTMLButtonElement} */ (
document.getElementById("loadBtn")
);
const userIdInput = /** @type {HTMLInputElement} */ (
document.getElementById("userId")
);
userIdInput.value = "1";
loadBtn.addEventListener("click", async () => {
const rawValue = userIdInput.value.trim();
const id = parseInt(rawValue, 10);
if (isNaN(id) || id < 1) {
alert("请输入一个大于 0 的数字 ID");
return;
}
loadBtn.disabled = true;
loadBtn.textContent = "加载中...";
try {
const users = await getUser(id);
renderUserList(users, root);
} catch (err) {
console.error("加载失败", err);
alert("加载失败,请重试");
} finally {
loadBtn.disabled = false;
loadBtn.textContent = "加载用户";
}
});

12
stage05/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang='zh-cn'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>Stage 05: 虚拟 DOM 初体验</title>
</head>
<body>
<div id="minivue"></div>
<script src="./index.js"></script>
</body>
</html>

73
stage05/index.js Normal file
View File

@@ -0,0 +1,73 @@
/**
* Stage 05虚拟 DOM 初体验
* 用纯 JavaScript 对象描述 DOM 树,并编写递归渲染函数
*/
/**
* @typedef {Object} VNode
* @property {string} type - 元素标签或 'text'
* @property {Object<string, string>} [props] - 属性
* @property {(VNode|string)[]} [children] - 子节点
* @property {string} [value] - 文本内容(仅 type='text' 时使用)
*/
/**
* 渲染 VNode 树为真实 DOM 并挂载到容器
* @param {VNode} vnode
* @param {HTMLElement} container
* @returns {HTMLElement | Text} 挂载的真实 DOM
*/
function render(vnode, container) {
let el;
if (vnode.type === "text") {
el = document.createTextNode(vnode.value);
} else {
el = document.createElement(vnode.type);
// 设置属性
if (vnode.props) {
for (const key in vnode.props) {
el.setAttribute(key, vnode.props[key]);
}
}
// 递归渲染子节点
if (vnode.children) {
vnode.children.forEach((child) => {
// 子节点可能是字符串 (自动转为文本 VNode)
if (typeof child === "string") {
render({ type: "text", value: child }, el);
} else {
render(child, el);
}
});
}
}
container.appendChild(el);
return el;
}
/**
* 手动构建一个卡片 VNode
* @type {VNode}
*/
const userNode = {
type: "div",
props: { class: "user-card" },
children: [
{
type: "h2",
children: [{ type: "text", value: "姓名: 小明" }],
},
{
type: "p",
children: [{ type: "text", value: "年龄: 18" }],
},
{
type: "span",
children: [{ type: "text", value: "学生" }],
},
],
};
const root = document.getElementById("minivue");
render(userNode, root);