diff --git a/stage04-perf/index.css b/stage04-perf/index.css
new file mode 100644
index 0000000..90907c2
--- /dev/null
+++ b/stage04-perf/index.css
@@ -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;
+}
diff --git a/stage04-perf/index.html b/stage04-perf/index.html
new file mode 100644
index 0000000..7361c81
--- /dev/null
+++ b/stage04-perf/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+ Stage 04-perf - 真实 DOM 操作代价
+
+
+
+ 真实 DOM 更新性能对比(控制台查看详细耗时)
+
+
+
+
+
+
+ 点击按钮对比耗时
+
+
+
+
+
+
+
+
+
+
+
diff --git a/stage04-perf/index.js b/stage04-perf/index.js
new file mode 100644
index 0000000..9ceb42d
--- /dev/null
+++ b/stage04-perf/index.js
@@ -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",
+ }));
+}
+
+// ---------- 复杂节点构造 ----------
+/**
+ * 生成带子元素的 ,模拟真实 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 += `
+ ${item.id % 100}
+
+
${item.title}
+
${item.desc}
+
${item.tag}
+
+ `;
+ }
+ 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})
+ innerHTML 全量:${lastResult.innerHTML}ms |
+ createElement 全量:${lastResult.createElement}ms |
+ 精确更新:${lastResult.exact}ms
+ `;
+}
+
+// ---------- 重置列表到当前 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();
diff --git a/stage04/index.html b/stage04/index.html
new file mode 100644
index 0000000..df8571d
--- /dev/null
+++ b/stage04/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Stage 04: 模拟异步接口
+
+
+
+
+
+
+
+
diff --git a/stage04/index.js b/stage04/index.js
new file mode 100644
index 0000000..a0e85e2
--- /dev/null
+++ b/stage04/index.js
@@ -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 = "加载用户";
+ }
+});
diff --git a/stage05/index.html b/stage05/index.html
new file mode 100644
index 0000000..9a2b28c
--- /dev/null
+++ b/stage05/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Stage 05: 虚拟 DOM 初体验
+
+
+
+
+
+
diff --git a/stage05/index.js b/stage05/index.js
new file mode 100644
index 0000000..008bab3
--- /dev/null
+++ b/stage05/index.js
@@ -0,0 +1,73 @@
+/**
+ * Stage 05:虚拟 DOM 初体验
+ * 用纯 JavaScript 对象描述 DOM 树,并编写递归渲染函数
+ */
+
+/**
+ * @typedef {Object} VNode
+ * @property {string} type - 元素标签或 'text'
+ * @property {Object} [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);