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);