feat(stages): 添加性能测试和虚拟DOM实现阶段
添加stage04-perf用于真实DOM操作性能对比,包含三个不同的更新策略: - innerHTML全量更新 - createElement全量重建 - 精确单节点更新 添加stage04用于模拟异步接口数据获取后的渲染演示 添加stage05实现虚拟DOM基础功能,提供VNode对象描述DOM树结构和递 归渲染函数
This commit is contained in:
43
stage04-perf/index.css
Normal file
43
stage04-perf/index.css
Normal 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
28
stage04-perf/index.html
Normal 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
210
stage04-perf/index.js
Normal 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
14
stage04/index.html
Normal 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
103
stage04/index.js
Normal 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
12
stage05/index.html
Normal 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
73
stage05/index.js
Normal 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);
|
||||||
Reference in New Issue
Block a user