
聊聊JavaScript DOM操作这件事。我知道很多人现在的日常工作基本被各种框架包圆了日常开发里手写 DOM 操作的机会确实少了很多。但恰好是这些少有机会的场景最容易卡住人——你可能只是想在组件里改个滚动条样式、做一次点击波纹定位、或者手动管理一下某个容器的高度结果发现无从下手。原因很简单框架帮我们做掉了绝大多数 DOM 操作原生这套东西反而生疏了。而框架本身的底层其实也就是这些 DOM API 在跑所以这玩意儿从头到尾都是绕不开的基础功。这篇内容我把日常开发中最常用、也最容易踩坑的 DOM 操作核心技巧整理了一遍从元素查询、属性管理、节点增删到事件绑定和性能优化每一块都配了代码示例和实操说明。适合刚接触原生 DOM 的同学系统过一遍也适合写框架写多了、偶尔需要徒手操刀的开发者查漏补缺。1. 先把DOM的基础认知捋清楚1.1 别把DOM文档结构想得太玄它就是一棵树DOMDocument Object Model本质上就是把 HTML 文档结构映射成一棵树形结构每个 HTML 标签、属性、文本内容都是这棵树里的一个节点。我们平时写的document.querySelector、element.appendChild本质上就是在对这棵树做读取和修改。搞清楚这一点对写代码有直接的帮助。比如你在做列表删除功能时其实做的是从树中摘掉某个节点做动态加载更多时做的是向树的某个分支追加节点。明白了这个模型很多 DOM 操作的思路就顺畅了——你操作的不是一段字符串而是一棵有层级关系的树。节点之间的关系主要有三种父子关系parent-child、兄弟关系sibling和后代关系descendant。掌握关系之后你能用parentElement、children、nextElementSibling等属性在节点之间游走。我在实际开发中用这些关系属性写得最多的是动态表格的逻辑比如点击一行高亮、然后获取这一行的相邻行数据利用兄弟节点关系往往比重新去查一遍数据源更直接。还有一个点容易忽略DOM 树里的节点类型不只有元素节点还有文本节点和注释节点。比如你写div.textContent hello这个hello其实是一个文本节点挂在 div 下面。多数场景下我们只关心元素节点所以要特别注意别混淆children只含元素节点和childNodes包含文本节点、注释节点等这两个属性。1.2 为什么框架时代还得手写DOM操作有人会问既然 React、Vue 都帮我们管好了 DOM为什么还要学原生这套原因很实际——框架抽象不了所有场景。我遇到过不少例子你需要做一个第三方图表库的适配层图表库要求传入原生 DOM 节点。你需要监听某个高度变化去做复杂布局而框架的响应式数据模型覆盖不到。你在写一个自定义指令或插件需要直接操作目标节点的样式或位置。某些性能极限场景比如大表格的局部刷新用原生 DOM 手动 patch 比通过框架状态更新更快。另外框架层的虚拟 DOM 算法本质上也是通过原生 DOM 操作来落地真正渲染的。所以不管从哪个角度讲原生 DOM 基础都不能丢。掌握了这套核心技巧你在遇到框架管不到的场景时才不会抓瞎。通常我写原生 DOM 操作代码时会刻意保持代码的局部性——能只在局部改动的就绝不影响整棵子树。这个习惯是从调性能的实战里养成的后面我会专门展开聊。2. 元素查询与选择的关键细节2.1 querySelector系列最推荐的选择器方式现在写原生 DOM 查询我几乎只会用querySelector和querySelectorAll。这两个方法的参数是标准 CSS 选择器字符串日常写的#app、.item、[data-id]、.list li:first-child都能直接塞进去。它们最大的好处是语法简洁、功能强大不需要记一堆带特定历史包袱的方法。这里直接给你们看几个高频用法// 获取单个元素如果匹配不到返回 null const header document.querySelector(.page-header); // 获取多个元素返回静态 NodeList const buttons document.querySelectorAll(.btn-group button); // 在某个 DOM 节点内部查询 const modal document.getElementById(modal); const confirmBtn modal.querySelector(.modal-confirm);document.getElementById我也经常混着用尤其在明确知道一个元素的 id 时它的性能确实更快写法也更简短。在巨型页面里getElementById 的查找效率会明显优于 querySelector但一般页面体量下差异可忽略。我自己习惯在局部函数里优先用getElementById但如果要按 class 那么就用querySelectorAll。有一点必须提醒querySelectorAll返回的是 NodeList而且是一个静态快照。这意味着你保存这个结果后再往页面里添加新的匹配元素这个 NodeList 不会自动更新需要重新查询一次。这和getElementsByClassName、getElementsByTagName返回的动态 HTMLCollection有本质区别。动态集合的坑在于你遍历它的时候如果同时修改了 DOM遍历行为可能出乎意料。以下这段代码就是经典反例// 危险写法动态集合边遍历边删除 const items document.getElementsByClassName(item); for (let i 0; i items.length; i) { items[i].remove(); // 每次 remove 后集合长度都在变化会导致跳项 }这种情况我推荐直接用querySelectorAll并且使用Array.from()或展开运算符转换成真正的数组再遍历省心得多。2.2 选择器性能和数据绑定data-* 属性用起来在业务代码里经常需要在点击某个元素时知道它是哪一行、哪条记录。最干净的做法是用>// HTML: div classuser-row>row.dataset.status done; // HTML 里会变成>const missingEl document.querySelector(.not-exist); if (!missingEl) { // ok确认不存在可以执行兜底逻辑 } const missingList document.querySelectorAll(.not-exist); if (missingList.length 0) { // ok这里也确认不存在 }这个坑虽然基础但真能浪费不少调试时间。特别是从 jQuery 时代过渡过来的同学因为$(.xxx)即使没有匹配项返回的对象也是 truthy但原生 API 的返回语义完全不同。3. 属性操作很多问题出在分不清 attribute 和 property3.1 关键必须分清attribute 和 property 是两套系统这是 DOM 操作里最容易混淆、也最容易踩坑的一个点。attribute特性是 HTML 源代码里写在标签上的东西比如input disabled里的disabled、a href#里的href。你可以通过setAttribute、getAttribute、removeAttribute来读写。property属性是 DOM 对象作为 JavaScript 对象时自带的东西比如input.disabled、input.value、img.src这些。大多数时候两者有联动关系设置 property 会同步到 attribute反之亦然。但有几类属性是特例比如表单元素的value。你手动设置input.setAttribute(value, abc)并不会改变用户在页面上看到的输入框当前值因为视图上显示的更多是 property 的行为而不是 attribute 的初始值。这类问题最常见的场景是写一个表单回填逻辑用 setAttribute 设置了输入框的值结果发现界面没变化排查半天才知道应该直接赋 propertyinput.value abc。所以我的原则很简单读和写标准属性能用点号访问的优先用 property。操作自定义属性、ARIA 属性如role、aria-expanded时才用 attribute 系列方法。有时候需要区分初始值和当前值比如input.defaultValue存的是 HTML 初始值input.value存的是当前值用途不同。3.2 class 操作用 classList 优雅管理维护元素的 CSS 类名是我几乎每天都做的事。虽然也可以用className读写但它是一个字符串要增删多个类名时拼来拼去很麻烦容易出错。推荐一律用classList。const box document.querySelector(.box); // 添加、移除、切换 box.classList.add(active); box.classList.remove(disabled); box.classList.toggle(visible); // 判断存在 if (box.classList.contains(active)) { // ... } // 批量添加 box.classList.add(a, b, c); // 带第二个参数强制指定状态 box.classList.toggle(open, true); // 等同于 add box.classList.toggle(open, false); // 等同于 removetoggle的第二个参数在实际业务里很好用不想自己写 if-else 的时候直接传一个布尔条件进去函数自动决定加上还是移除。比如根据状态值切换类名status.classList.toggle(approved, order.status approved); status.classList.toggle(rejected, order.status rejected);还有一个小技巧classList是可以被for...of遍历的因为它是类数组对象支持迭代器。有时需要判断多个类名里是否包含某一个可以写成const clsList Array.from(box.classList); if (clsList.includes(some-class)) { /* do something */ }不过一般contains就够了不必每次都转数组。3.3 style 操作赋值字符串还是逐条设置修改元素的行内样式主要有两种做法一是直接操作element.style的某个属性二是通过element.style.cssText整体赋值字符串。单条设置比较标准el.style.display block; el.style.width 100px; el.style.backgroundColor #2d6cdf;注意因为 CSS 属性名里带连字符background-colorJavaScript 对象里就用驼峰写法backgroundColor。这是个老生常谈的点但经常有人踩。遇到带-webkit-前缀的属性也要把前缀变成驼峰比如WebkitTransform。批量设置样式时用cssText或直接拼接属性就快得多el.style.cssText position: absolute; top: 10px; left: 20px; width: 200px; height: 120px;;但要小心cssText是整体覆盖之前行内设置的其它样式会被清掉。实际开发中我一般不会直接塞一大段cssText而是用Object.assign配合自定义工具函数逐条设置多个属性。这样不影响已有样式Object.assign(el.style, { display: flex, alignItems: center, justifyContent: space-between });也要区分一个场景如果你只是想切换显隐优先操作classList用 CSS 类来控制样式而不是在 JS 里写死颜色、尺寸。样式需求一变改 CSS 文件比改 JS 逻辑要安全得多。这是我从项目维护角度出发的一条经验。3.4 一些特殊属性的读写小抄我在项目里整理过一张表直接贴出来给你们参考按这张表用能省不少查文档的时间操作意图推荐写法注意事项读写标准值el.value/el.checked/el.disabled不要轻易用 setAttribute 代替自定义数据el.dataset.xxx命名转驼峰样式类名el.classList.add/remove/toggle不要用 className 拼字符串内联样式el.style.xxx或Object.assign(el.style, {})批量赋值建议用后者表单初始值el.defaultValue回显时用 value 而不是 attribute无障碍属性el.setAttribute(aria-expanded, true)标准属性里这几个改用 attribute 更稳自定义属性el.setAttribute/el.removeAttribute不要用来存复杂对象这个表在每次写 DOM 操作代码前看一眼能避掉大量隐形 bug。4. 节点创建、插入与删除的实操细节4.1 创建节点避免字符串拼接的混乱创建一个新的 DOM 节点有两种主流方式拼接 HTML 字符串后innerHTML解析或者用document.createElement创建元素节点再用textContent设置内容。只从代码可读性和安全性来说我更推荐后者。特别是内容里包含用户输入时用innerHTML拼接容易引入 XSS 风险。来看两种写法对比// 不推荐内容里有用户数据时风险大 const container document.querySelector(.container); container.innerHTML div classcard${userContent}/div; // 推荐用 createElement textContent 安全兜底 const card document.createElement(div); card.className card; card.textContent userContent; // textContent 自动转义不会被当 HTML 解析 container.appendChild(card);创建元素后要注意新元素必须插入到 DOM 树中才能显示。很多新手在 createElement 后直接设置样式发现页面没反应就是因为没执行appendChild或类似插入方法。如果确实需要创建大量 HTML 片段并且内容完全可控比如静态模板、服务端渲染好的数据用insertAdjacentHTML会更高效。它可以指定插入位置且不会影响其内部已有的 DOM 节点引用const list document.querySelector(.list); // beforebegin | afterbegin | beforeend | afterend list.insertAdjacentHTML(beforeend, li classlist-item新增项/li);这个 API 的四个位置参数适合很多场景想在元素前面插入一个提示条、想在列表末尾快速追加一条数据都比手动 createElement 简洁得多。但依然要强调别把不可信的用户输入直接拼进字符串再塞给 insertAdjacentHTML。4.2 插入与移动的四种姿势DOM 的插入方法很多傻傻分不清就容易写出混乱代码。我平时用得最多的是这四个parent.appendChild(child); // 追加到最后一个子节点 parent.insertBefore(child, ref); // 插入到 ref 之前 parent.prepend(child); // 插入到第一个子节点 el.insertAdjacentElement(beforebegin, child); // 插到 el 本身前面值得留意的是把一个已存在于 DOM 中的节点插入到新位置它会先被自动从原位置移除。这个特性让我写出过很简洁的列表置顶功能const item document.querySelector(.list-item); const list document.querySelector(.list); list.prepend(item); // 自动把 item 从原位置摘掉放到最前面也有同学会问append和appendChild的区别。append是较新的 API支持一次传入多个节点或字符串局限性更小但兼容性需要考虑。而appendChild是传统 API只接受一个节点参数二者大多数场景都能互相替换。对老项目兼容性有要求的话我会保守地使用appendChild。关于删除比较现代的做法是element.remove(); parent.removeChild(child); // 老式写法remove()不需要通过父节点来找子节点了代码更直观。虽然它是相对较新的方法但现在的主流浏览器环境用起来基本没问题。唯一要注意的是如果元素有事件监听器或动画绑定直接用remove()移除后相关监听器能否被正常回收要留意尤其对于长期运行的单页应用。必要时先解绑事件、取消动画再做移除。4.3 用 DocumentFragment 批量更新DOM说到批量插入DocumentFragment是我很推荐的一个工具。它是一个离线的 DOM 容器可以在内存里先组装好一批子节点再一次性塞进真实 DOM 树中。这样做的好处很直接减少了触达真实 DOM 的次数从而减少浏览器重排和重绘。const list document.querySelector(.list); const fragment document.createDocumentFragment(); for (let i 0; i 100; i) { const li document.createElement(li); li.textContent 条目 ${i 1}; fragment.appendChild(li); } // 一次性插入DOM 只更新一次 list.appendChild(fragment);因为 Fragment 的子节点在插入时会整体转移进真实 DOM中间过程不触发连续的重排性能明显更优。每次循环直接appendChild到真实 DOM 也能用但是当数据量上涨、页面结构复杂时卡顿和布局抖动就会出现。我做过一次粗测同样插入 1000 条列表项直接循环 append 比用 Fragment 要慢好几倍而且页面滚动过程会出现明显的跳动感。数据量越大Fragment 的优势越明显。4.4 隐藏与显隐切换的三种方案显隐控制虽然简单但选错方案会影响布局和动画表现。常用的有三套display: none元素彻底不占空间也不可见。最常用但如果频繁切换显隐会引起布局重排。visibility: hidden元素不可见但仍然占空间。适合目标区域尺寸不希望变化、需要有稳定布局的场景。opacity: 0配合pointer-events: none视觉隐藏但保留元素存在适合做过渡动画或覆盖层效果。我在实现弹窗、下拉面板时习惯用display: none做默认隐藏动画出现前再切到可见状态并配合requestAnimationFrame或 CSS transition。而做骨架屏、加载占位时就会用 visibility 来避免内容闪烁导致布局跳动。很多人图省事用el.hidden true这会设置display: none但要注意如果组件自己在样式里设置了display: flexhidden属性会被样式表覆盖。这个虽然是个边角料但我确实遇到过 bug因此特意记在这里。5. 事件处理与委托的正确姿势5.1 addEventListener 的常用参数与 this 问题绑定事件首选addEventListener它比起老的onclick fn有两个明显好处同一元素可以绑定多个处理器可以指定捕获还是冒泡阶段执行。基本写法const btn document.querySelector(.btn); btn.addEventListener(click, function (e) { console.log(clicked, e); });函数里的this指向绑定监听器的元素——前提是你用普通函数而不是箭头函数。箭头函数不会绑定自己的this它会沿用定义位置的 this。这在 React/Vue 组件里经常因为 this 指向问题造成混乱btn.addEventListener(click, () { // this 不指向 btn而是外面的作用域 });如果你需要在事件处理器里拿到当前元素更好的做法是使用e.currentTarget它始终指向监听器所绑定的元素不怕 this 被环境改变。监听器第三个参数也很有讲究el.addEventListener(click, handler, false); // 默认冒泡阶段 el.addEventListener(click, handler, true); // 捕获阶段 el.addEventListener(click, handler, { once: true }); // 触发一次后自动移除once这个选项在实现首次点击提示这类需求时非常实用不用手动解绑平台自动帮你清理掉监听器。这里提一个真实经历某次我做一个公告弹层要求用户首次点击关闭后不再自动弹出当时用once: true一行就解决了。如果用老代码还要先绑定再在回调里执行removeEventListener容易遗漏上下文导致清理失败。5.2 事件对象里必须掌握的几个字段事件处理函数拿到的事件对象有很多信息可以用。日常操作中我最常读这几个字段e.target事件实际触发的元素可能是某个子元素e.currentTarget事件绑定所在的元素e.preventDefault()取消浏览器默认行为e.stopPropagation()阻止事件继续传播e.clientX / e.clientY鼠标相对视口的坐标区分target和currentTarget是关键。监听器绑在父容器上点击子元素时target是子元素currentTarget才是父容器。做事件委托时通常通过e.target判断哪个子元素触发了事件用e.currentTarget获取统一处理逻辑。5.3 事件委托让动态节点也能响应事件事件委托是我最推荐的一个 DOM 操作技巧。它的核心思想是不在每个子元素上单独绑定事件而是把监听器放到共同的父元素上利用事件冒泡机制统一处理。动态新增的子元素也天然能通过父层的监听器响应不需要重新绑定。来看一个具体案例一个不断动态追加行项目的列表点击每行需要打印对应行号。const list document.querySelector(.list); list.addEventListener(click, (e) { const item e.target.closest(.list-item); if (!item) return; // 点的是列表空白区域 console.log(点击了, item.dataset.id); });这里closest很关键它从当前元素开始向上查找匹配选择器的祖先节点。因为e.target可能是项目里的文本节点、图片甚至某个内部的小按钮用closest能快速收敛到目标行。这种做法在和动态渲染列表配合时特别省心不管新增多少行都不用再绑定事件。用委托时要记住如果子元素上有自己的事件监听器并且调用了e.stopPropagation()委托就会失效。还有一个容易忽略的性能点事件委托时回调函数的查找逻辑越精准越好别在父层每次都去匹配一个巨大的选择器字符串必要时可以先判断tagName再走精确选择器。5.4 removeEventListener 的重难点函数引用必须一致解绑事件的难点在于removeEventListener必须传入与绑定时完全相同的函数引用。如果你用匿名函数绑定是解不掉的// 无法移除 el.addEventListener(click, function () { /* handler */ }); el.removeEventListener(click, function () { /* handler */ }); // 无效正确做法是把处理函数提出来function onDialogClick(e) { // ... } dialog.addEventListener(click, onDialogClick); // 需要移除时 dialog.removeEventListener(click, onDialogClick);实践里还要注意协调AbortController这个更现代的方案。它可以一次性取消多个事件绑定对多事件的临时监听非常方便const controller new AbortController(); window.addEventListener(resize, handler, { signal: controller.signal }); window.addEventListener(scroll, handleScroll, { signal: controller.signal }); // 一次性解绑 controller.abort();在单页应用里组件卸载时利用 AbortController 批量清理事件监听能有效避免潜在的内存泄漏和重复触发问题。这个技巧现在写前端的同学值得学会代码会干净很多。5.5 事件委托的常见陷阱与解决方案尽管事件委托好用但有几个常见的坑这里一并说明在mousemove、scroll这类高频事件上做委托回调如果逻辑过重容易造成性能问题。建议配合节流throttle或防抖debounce使用。使用closest时如果目标 DOM 层级很深选择器要以最靠近移动端可点击元素为准避免匹配到外层无限冒泡的容器。委托父层若同时存在多个子元素需要不同响应处理函数内要根据条件分流不要所有子元素都跑同一段逻辑。6. 性能优化与常见坑位避让6.1 重排重绘一次修改带来的连锁反应浏览器在 DOM 结构或样式变化后需要重新计算布局layout并绘制页面paint。每次这样的计算都有成本如果在一个循环里反复操作 DOM 触发多次重排页面的性能会明显下降。我常用的优化手段包括批量修改样式不要逐条改style的多个属性合并成一次cssText或类名切换。批量修改 DOM优先用DocumentFragment见前面 4.3。减少强制同步布局在循环里读 offsetWidth、clientWidth 等属性会强制触发布局计算。写完一堆修改操作后再统一读比改一下读一下性能好。用 transform/opacity 做动画这类属性可以走 GPU 合成不触发重排动画更流畅。给一个反面例子// 低效每次循环都修改了布局 for (let i 0; i boxes.length; i) { boxes[i].style.height (i * 10) px; const width boxes[i].offsetWidth; // 这里强制同步布局 boxes[i].style.width width / 2 px; }改成先算好所有尺寸再统一赋值或尽量不用同步读取性能会好很多。6.2 innerHTML 与 createElement 的选型逻辑innerHTML用起来很爽但要知道它的代价。设置innerHTML时浏览器要解析传入的 HTML 字符串并重建元素的子节点树。这意味着如果用这个 API 频繁更新复杂区域解析开销和内存重新分配的压力都不小还容易让原有节点上保存的事件监听器失效。所以选型逻辑一般是一次性渲染大段静态 HTMLinnerHTML或insertAdjacentHTML都行方便。需要更新已有节点内容且内容带有用户数据用textContent。需要反复更新动态列表或交互组件用createElement配合DocumentFragment。只更新文本绝不碰innerHTML用textContent。你可能好奇textContent和innerText的区别。textContent会取到所有文本节点包括脚本和样式标签里的文本且不关心渲染结果innerText会尽量模拟用户看到的文本会考虑 CSS 样式如 display: none 下取不到文本。一般建议用textContent行为更可控。6.3 频繁读写导致的内存泄漏隐患JavaScript 开发中内存泄漏的来源很多时候不是普通对象而是被遗忘的 DOM 引用和事件监听器。比如从一个列表里移除了一行但如果还有一个 JavaScript 对象持有这行 DOM 节点的引用这个节点就不会被垃圾回收。时间长了页面内存占用不断攀升最终卡顿。一个稳妥的习惯是动态生成 DOM 并绑定事件后移除时顺手把事件解绑用完的临时对象置为 null。用了AbortController管理监听的统一调用abort()。如果确实需要在 DOM 节点上存储数据用 WeakMap 而不是在节点上自定义属性去挂大对象。实际当中我写过一个无限滚动列表一开始每加载一批就给所有可见节点绑定滚动和点击事件滚动过程中大量监听器堆积最终滚动越来越卡。后来改成全列表只挂一个滚动监听配合事件委托两个问题同时解决性能提到流畅节点移除时也没留下一堆监听器。6.4 常见问题速查表快速定位你的Bug根据我自己的踩坑经验把常见问题和排查思路整理成一个表。以后遇到类似情况建议先对照一遍问题现象可能原因解决方案查到了元素但改样式没生效元素可能是display: none、被其它元素遮挡或样式优先级不够检查 CSS 优先级确认元素视觉存在表单 value 回填后界面没变化用了 setAttribute(value) 而非 property直接el.value xxxquerySelectorAll 结果不更新NodeList 是静态快照每次需要时重新查询动态添加的元素点击无响应事件绑定在元素创建之前或没有使用事件委托在父容器上委托事件移除 DOM 后事件仍触发监听器未解绑或者页面有全局监听用 removeEventListener 或 AbortController 清理classList 操作无效class 名称写错、类名大小写不同检查 HTML 里 class 名是否完全匹配点击坐标不对使用了 e.screenX / screenY 而非 clientX / clientY按需要选择坐标系页面大幅重排卡顿循环里频繁读写样式批量修改、用 CSS 类切换或 DocumentFragmentinnerHTML 拼接的内容不转义用户输入直接插入有 XSS 风险优先 textContent / createElement7. 实际案例从零实现一个可复用任务列表组件理论讲了不少这里用一个完整的小案例把核心技巧串起来。设想一个需求实现一个任务列表支持动态添加任务、点击任务行切换完成状态、删除任务且全程使用原生 DOM 操作不借助任何框架。先规划一下 DOM 结构div classtodo-app input classtodo-input typetext placeholder输入新任务 / button classtodo-add添加/button ul classtodo-list/ul /div第一步查询并绑定事件const todoInput document.querySelector(.todo-input); const addBtn document.querySelector(.todo-add); const todoList document.querySelector(.todo-list); addBtn.addEventListener(click, addTask); todoList.addEventListener(click, handleListClick);第二步实现添加任务。这里用 createElement 构建节点并利用>let taskId 0; function addTask() { const title todoInput.value.trim(); if (!title) { alert(任务内容不能为空); return; } const li document.createElement(li); li.className todo-item; li.dataset.id String(taskId); li.dataset.completed false; const titleSpan document.createElement(span); titleSpan.className todo-title; titleSpan.textContent title; const deleteBtn document.createElement(button); deleteBtn.className todo-delete; deleteBtn.textContent 删除; li.append(titleSpan, deleteBtn); todoList.appendChild(li); todoInput.value ; todoInput.focus(); }第三步利用事件委托统一处理行内的点击和删除逻辑function handleListClick(e) { const item e.target.closest(.todo-item); if (!item) return; // 点击了删除按钮 if (e.target.classList.contains(todo-delete)) { item.remove(); // 直接从 DOM 树移除 return; } // 点击了标题区域切换完成状态 const isCompleted item.dataset.completed true; item.dataset.completed String(!isCompleted); item.classList.toggle(completed, !isCompleted); }第四步样式里配合.completed类.todo-item.completed .todo-title { text-decoration: line-through; color: #999; }这个案例虽然简单但覆盖了不少核心技巧querySelector 查询、createElement 建节点、append 插入、dataset 读写、事件委托、closest 查找、classList 切换、remove 删除节点。实际写业务代码时这些基本都是天天在用的组件。我也是实测过这个案例的在不做任何优化的情况下连续添加几百条任务页面交互依然流畅但如果把添加任务改成每次用 innerHTML 重刷整个列表很快就能感觉到输入框失去焦点和滚动条跳动。可见细节上的取舍对最终体验影响很大。整个 DOM 操作体系的核心不在于死记硬背 API而在于理解背后的文档树模型、属性系统和事件模型。把这几块打通了再花哨的操作也能拆成简单的查询、修改、插入、删除几步。这套基本功无论你之后用不用框架、用哪个框架都是不会被淘汰的底层能力。