☰
DOM增删改与节点遍历:从写死数据到动态刷新的实践之路
2026/10/13 4:22:05 网站建设 项目流程

从写死数据到动态刷新:Web APIs Day3的DOM增删改与遍历实践

我印象特别深的一次经历,是做某个后台管理系统的列表页时,产品经理过来说:“这个表格的数据不要写死,要能动态增删行。”当时我第一反应是“这有什么难的”,结果真上手才发现,把一个按钮的点击事件、DOM节点的创建、插入位置、以及后续数据的更新串起来,远没有想象中那么简单。也就是从那天起,我意识到Web APIs里关于DOM操作的内容,绝对是非凡不可的硬功夫。

今天这篇就围绕Web APIs学习的第三天来聊。Day3的核心内容大致是:元素创建、插入、替换、删除,以及节点遍历与集合操作。如果前两天的内容解决的是“怎么把页面上的元素找出来”、“怎么给元素绑定事件”,那第三天解决的则是“怎么让页面上的内容真正动起来、变起来”。这篇适合所有刚开始系统学习前端、或者做了几个月页面但一直靠复制粘贴操作DOM的同学。我会把原理、步骤、坑和实际项目里的用法都过一遍,尽量让读完的人能直接上手。

1. 从“调接口拿数据”到“把数据渲染上页面”:Day3到底在解决什么问题

1.1 一个最典型的场景:动态列表的刷新

很多初学者有这样一个动作:拿到后端返回的数组,直接在JS里用document.write往页面里塞内容,或者用innerHTML拼接一大段字符串。结果页面是出来了,但只要数据一变,整个页面的内容就要重新生成一遍,用户滑到一半的滚动位置、输入框里的内容、选中的状态,全都丢了。

真正做项目的时候,我们需要的通常是这种能力:后端返回了10条数据,我把它渲染成一个有序列表。过一会儿数据更新成12条,我只把变化的那部分列表项加进去或删掉,而不是把整个列表推倒重来。这种“精准修改”的能力,就是Day3的核心——对DOM节点做增、删、改、查。

举个例子,你有一个待办清单:

<ul id="todoList"> <li>写周报</li> <li>修BUG</li> </ul>

现在用户提交了一条新待办“预约会议室”,前端要做的事是:创建一个新的li节点,把文字内容填进去,再把这个节点追加到ul里。在这个过程中,createElement、textContent、appendChild这三个API就会全部用上。而如果用户勾选了某个待办,要把它删掉,那就需要removeChild或者remove方法。

1.2 Day3是前面两天的“集结点”

如果把Web APIs的学习拆成几天来看:前期你在学习怎么通过querySelector、getElementById之类的选择器找到DOM节点,紧接着又要学习怎么通过addEventListener给节点绑定交互事件,而Day3的重点则是深入操作DOM本身的结构。这三块串联起来之后,你才算真正具备了“前端交互开发”的完整链路:事件触发 → 找到目标节点 → 修改结构。

这个能力,是后面所有框架学习的基础。你可能听说过Vue或者React有虚拟DOM、有diff算法,它们做的事情本质上也是“创建节点、比较节点、更新节点”。如果你连原生的节点操作都不够熟悉,直接去学框架的diff逻辑,会非常吃力。相反,把原生API吃透了,再去看框架的源码或者设计思路,你会觉得很多理念是可以一一对应的。

2. 元素的创建、插入与删除:一套完整的增删改操作逻辑

2.1 创建节点不是只有createElement一种方式

很多初学者一上来就先学document.createElement(),这当然是标准做法,但我建议你先意识到一点:创建节点其实有几种完全不同的思路,各自有各自的适用场景。

第一种是createElement配合appendChild或者insertBefore,这种方式是最“精细”的。你可以先创建一个li,再创建一个span,把span放进li里,再把li放进ul里。每一步都清清楚楚,适合节点结构复杂、需要单独绑定事件或者设置属性的场景。

第二种是insertAdjacentHTML。这个方法是我个人在项目里非常高频使用的,它接收两个参数,第一个是插入位置,第二个是HTML字符串。位置有四个可选值:beforebegin、afterbegin、beforeend、afterend,分别代表元素自身前后和内部首尾。比如ul.insertAdjacentHTML('beforeend', '<li>预约会议室</li>'),一行代码就能完成创建和插入两个动作。

第三种是innerHTML。这个方法适合整块替换内容,但用在局部增删上要格外小心。你给一个div重新赋值innerHTML,相当于把它内部所有的旧节点先清空,再根据字符串重新解析创建一批新节点。在列表频繁变更的场景下,它会造成不必要的性能损耗,而且会把事件绑定一并丢掉。为什么很多新人不理解“为什么我绑定的事件失效了”?十有八九就是因为在绑定完事件之后,又用innerHTML重绘了那个容器的内容。

三种方式我用一个简单的对比如下:

方法适用场景需要注意的点
createElement结构复杂、需要精确控制、要绑定事件代码量稍大,步骤多
insertAdjacentHTML插入一段确定的HTML字符串注意字符串拼接的转义问题
innerHTML整块替换容器内容会丢失容器内已有节点的事件绑定

2.2 插入位置的精确控制:insertBefore与appendChild的关系

新手经常忽略一个点:appendChild只能把节点追加到父元素的末尾,但实际页面里我们要插入的位置往往不是末尾,而是“某个已有元素的前面”。这个时候就需要insertBefore了。

insertBefore(newNode, referenceNode)接收两个参数,第一个是新节点,第二个是参照节点。如果参照节点传null,它的效果就跟appendChild一样,插到末尾。很多项目里的排序功能,本质上就是先把节点从当前位置移除,然后通过insertBefore插到目标位置前面。

我这里有一段实际写排序功能时用过的思路:

// 假设sortableList是一个ul,item是要移动的li,targetItem是目标位置的li sortableList.removeChild(item); sortableList.insertBefore(item, targetItem);

这个操作有个很明显的特征:removeChild和insertBefore配合使用的时候,被移动的节点不需要重新创建。因为节点本身还是同一个DOM节点,只是换了个位置。这里有一个特别容易被忽略的细节——同一个节点被插入到新位置时,它会自动从原来的位置消失。也就是说,你不必先手动把它从旧位置删除,再插入新位置,直接insertBefore到新位置就可以了。我当时第一次做拖拽排序时,就是栽在这个细节上:删了一遍,又插了一遍,结果节点直接消失了。后来才明白,节点的appendChild和insertBefore操作本身就带有“移动”语义。

2.3 删除节点:removeChild和remove,到底用哪个

在比较老的教学资料里,删除节点一般是先找到父节点,再通过parentNode.removeChild(childNode)来删。但现代浏览器里,节点自身也有一个remove()方法,可以直接把自己从DOM树中删掉。这两种方式有什么区别?

removeChild需要你先取得父节点,再进行删除,最后会返回被删除的节点,意味着你还可以在删除后把它重新插入到其他地方,实现“移动”操作。remove()则更直接,没有返回值,调用之后节点就销毁了。就日常做项目来说,我更推荐优先使用remove(),代码更简洁,可读性也好。但如果你需要保留被删除节点的引用,稍后要复用,那就用removeChild,因为它把节点返回给你了。

还有一个新手很容易忽略的细节:remove()虽然把节点从DOM树里移除了,但这个节点上绑定的事件监听器并不会因此自动解除。如果页面里有很多被反复创建和删除的节点,并且每个节点都绑定过事件,那么时间一长,内存里就可能堆积大量失去引用的监听器,造成内存泄漏。现代V8引擎的垃圾回收机制通常能处理一部分情况,但在极端场景下,还是建议在删除节点前手动removeEventListener,或者把事件监听统一挂在容器上,用事件委托的方式集中处理。关于事件委托,后面我会详细讲。

3. 节点集合与遍历:HTMLCollection和NodeList都是“类数组”

3.1 children与childNodes的核心差异

当我们通过document.querySelectorAll获取一组节点时,得到的是一个NodeList;通过element.children获取子元素时,得到的则是一个HTMLCollection。这两个集合类型很容易混,因为它们都是“类数组”,都可以用length属性、可以用下标访问,也都支持forEach(现代浏览器中HTMLCollection也支持了,但在某些旧环境里可能不支持)。

但它们有一个本质区别必须记住:HTMLCollection是动态的(live),NodeList通常是静态的。这个“动态”意味着,当DOM结构发生变化时,HTMLCollection的成员会自动更新,而你不需要重新获取。反过来,querySelectorAll得到的NodeList是当你调用它那一瞬间的快照,之后DOM怎么变,它都不会跟着变。

我直接给你一个平常编码中出现过的对比示例:

const div = document.getElementById('container'); const children = div.children; // HTMLCollection,动态 const nodeList = div.querySelectorAll('div'); // NodeList,静态 console.log(children.length); // 比如 1 const newDiv = document.createElement('div'); div.appendChild(newDiv); console.log(children.length); // 变成 2,HTMLCollection自动更新 console.log(nodeList.length); // 还是 1,NodeList没有感知到新节点

了解这个差异对写代码很有帮助。比如你用children做遍历,如果遍历过程中又新增了子节点,动态集合的长度会一直变化,可能导致死循环。这就是为什么在通过HTMLCollection遍历并同时操作节点结构时,一定要非常小心。

再往下拆一个容易混淆的点:children只包含元素节点,而childNodes包含所有子节点,包括文本节点、注释节点。我们日常遍历子元素时,基本都应该优先用children。想想看:ul > li结构里,如果li之间写了换行和空格,那childNodes里就会有一堆文本节点,看起来是一堆空白的text节点。你要是没意识到这一点,直接遍历childNodes去做操作,很容易被这些空白节点干扰。

3.2 firstElementChild、parentNode这些导航属性怎么用

除了通过集合一次性拿到所有子节点,DOM还提供了一系列导航属性,方便你在节点之间跳转。parentNode、firstChild、lastChild、nextSibling、previousSibling这些是包含文本节点的版本;而firstElementChild、lastElementChild、nextElementSibling、previousElementSibling则是专门跳过文本节点、只关注元素节点的版本。

做实际页面的时候,强烈建议优先使用带Element后缀的那一套。原因跟上面一样,HTML里的换行和空格会产生文本节点,如果使用不带Element的属性,处理起来会有很多干扰。

举个例子,有一份结构是这样的:

<ul> <li id="first">苹果</li> <li>香蕉</li> </ul>

document.getElementById('first').nextSibling很可能返回的是一个文本节点(因为</li>和<li>之间有个换行符),而nextElementSibling才是你真正想要的“香蕉”那个li。这个细节说实话,我在工作初期被坑过不下三次,每次都是调试半天,最后才发现是换行符创造的空白文本节点在捣乱。

3.3 遍历时最容易踩的死循环和越界问题

有了动态集合的概念之后,就能理解为什么遍历DOM同时修改结构是一件危险的事了。我之前做某个模拟项目时,想实现一个功能:把列表里所有li中的文本替换成大写。最直观的思路是:获取所有li的集合,遍历每一个,改掉textContent。

如果我用的是getElementsByTagName('li')得到的HTMLCollection,遍历过程中一旦我向列表里新增了一个li(哪怕是在遍历过程之前新增的,只要遍历还没结束),这个集合的length就会跟着变化。假如你在遍历时执行appendChild,集合长度增加,循环次数也会增加,极端情况下会陷入无限循环。

更安全的做法是先把集合转成静态数组,再对数组做遍历:

const items = Array.from(document.querySelectorAll('li')); items.forEach(item => { // 在这里做增删操作都不会影响集合本身 });

这种“先快照、再遍历”的思路不仅适用于DOM操作,在很多处理动态数据的场景里都是通用的规则:不要在遍历的过程中修改你正在遍历的容器。

4. 案例实战:用原生API做一个可增删的待办列表

4.1 需求拆解和页面结构

前面讲了不少方法与原理,但技术点不落到项目里始终是空的。我们用待办列表这个经典场景来串一串。需求是这样:页面上有一个输入框、一个“添加”按钮和一个列表。用户输入内容后点击按钮,内容作为新的一项出现在列表底部;每一项的右侧有一个“删除”按钮,点击后该项消失;如果某项的文本被点击,则给它加一个删除线,表示已完成。

页面结构先这样规划:

<div class="todo-app"> <input id="todoInput" type="text" placeholder="输入待办事项" /> <button id="addBtn">添加</button> <ul id="todoList"></ul> </div>

注意,列表一开始是空的,所有项都是运行时由JS创建的。这样才符合Day3“动态创建元素”的训练目标。

4.2 添加逻辑:创建、填充、插入

“添加”按钮的事件处理程序里,我们需要做四件事:读取输入框的值、判断值是否为空、创建列表项、把列表项插入列表末尾。

如果用createElement来实现,大概是这样:

const input = document.getElementById('todoInput'); const addBtn = document.getElementById('addBtn'); const list = document.getElementById('todoList'); addBtn.addEventListener('click', () => { const text = input.value.trim(); if (!text) return; const li = document.createElement('li'); li.textContent = text; const deleteBtn = document.createElement('button'); deleteBtn.textContent = '删除'; deleteBtn.classList.add('delete-btn'); li.appendChild(deleteBtn); list.appendChild(li); input.value = ''; input.focus(); });

代码本身不复杂,但有几个值得注意的地方。第一个是trim(),它能把输入框首尾的空格去掉,防止用户只输入空格时依然创建了一个空白项。第二个是input.focus(),添加完之后让焦点自动回到输入框,方便连续录入。这两个小细节是很多教学代码不会讲的,但放到实际页面里体验差异特别明显。

换成insertAdjacentHTML的写法会更紧凑:

list.insertAdjacentHTML('beforeend', ` <li> ${text} <button class="delete-btn">删除</button> </li> `);

这段模板字符串看起来方便,但有一个隐患需要提醒:如果用户输入的文本包含HTML标签,比如<img src=x onerror=...>,直接拼进innerHTML或insertAdjacentHTML里会触发意外的问题。这类问题在项目里属于安全红线。所以在用HTML字符串方式插入用户输入内容之前,至少要做一个简单的转义:

function escapeHtml(str) { return str.replace(/[&<>"']/g, (m) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m])); }

之前在某个内部项目中,我就是没做转义,直接把用户输入的字符串插进了页面里,结果一个用户提交了一段包含特殊字符的内容,页面上直接解析出了奇怪的DOM结构,排查了很久才发现问题根源。从那以后,凡是涉及用户输入和HTML字符串拼接的场景,我都会先包一层转义函数。如果使用的是createElement加textContent的方案,其实天然就规避了这个问题,因为textContent只会把内容当作纯文本,不会解析HTML。

4.3 删除与完成状态:如何利用事件委托统一处理

接下来是删除按钮。如果按照最直观的思路,在添加每一项的时候就给删除按钮绑定click事件,那代码会变成这样:

deleteBtn.addEventListener('click', () => { li.remove(); });

这种方式本身没有错,但存在一个扩展性隐患:如果列表项数量很大,每一项都单独绑定一个监听器,内存里就会积累大量事件处理函数。更好的做法是把监听器统一绑定到ul容器上,利用事件冒泡机制,在事件到达ul时判断实际触发事件的元素是谁。这种模式就是事件委托。

用事件委托改写的删除逻辑和完成逻辑如下:

list.addEventListener('click', (event) => { const target = event.target; if (target.classList.contains('delete-btn')) { target.closest('li').remove(); return; } if (target.tagName === 'LI') { target.classList.toggle('done'); } });

这里用到event.target来获取实际被点击的元素,再用closest('li')从按钮向上查找到所属的列表项。之所以要closest而不是直接target.parentNode,是为了防止未来在li内部嵌套更多结构,比如增加一个图标或者一个span,到那时候target.parentNode不一定还是li,但closest('li')百分百能往上找到正确的项。

这里我特别建议你用classList.toggle而不是classList.add来实现完成状态的切换,因为toggle自带“有则移除、无则添加”的语义,天然支持第二次点击取消完成状态,不需要写额外的判断分支。

4.4 这个案例背后隐藏的编程思路

很多人会觉得待办列表太“教学化”,实际项目不会这么简单。但我想说的是,这个案例背后的编程思路比功能本身重要得多。

第一,“数据变化驱动界面更新”这种事,用原生DOM操作来写虽然繁琐,但会让你逐步建立起“界面是数据的一种映射”的意识。每一次改动节点之前,先问自己:对应的数据源是什么?数据源变了,界面应该怎么跟着变?第二,事件委托是一个普适性的设计模式,不管你是用原生JS还是框架,理解事件传播机制都是调试复杂交互的基础。第三,安全边界意识,上面提到的HTML转义,看起来不起眼,但一旦线上出了问题就是事故级别。

5. 性能与细节:批量插入、重绘重排和文档碎片的使用

5.1 为什么循环插入节点会让页面卡顿

如果在一个循环里用appendChild插入1000个列表项,浏览器会执行1000次DOM插入操作。每一次插入都可能导致浏览器重新计算页面布局(重排)和重新绘制(重绘),页面会明显卡顿,尤其是在低配设备上。

为什么?因为浏览器渲染页面是一个流水线过程,DOM结构改变之后,它需要重新计算每个元素的位置和大小,然后绘制到屏幕上。如果循环里每次都触发这个过程,浏览器就是在反复做全套重活。

要解决这个问题,一个非常经典的工具是DocumentFragment。你可以把它理解成一个“虚拟的轻量级文档容器”,先把所有新节点放进这个容器里,最后再把这个容器一次性插入到真实DOM中。浏览器只需要处理一次真实DOM的变更,性能提升非常明显。

const fragment = document.createDocumentFragment(); for (let i = 0; i < 1000; i++) { const li = document.createElement('li'); li.textContent = `条目 ${i + 1}`; fragment.appendChild(li); } list.appendChild(fragment);

注意,fragment本身并不会出现在DOM里,它只是一个中转站。当list.appendChild(fragment)执行完,fragment里的子节点会全部被转移进list,fragment变为空。这个机制让代码看起来优雅,性能上又避免了循环插入带来的反复重排。

5.2 字符串拼接与多次重排的取舍

除了DocumentFragment,还有一种常见做法是先用数组收集HTML字符串,最后一次性通过innerHTML赋值。比如:

let html = ''; for (let i = 0; i < 1000; i++) { html += `<li>条目 ${i + 1}</li>`; } list.innerHTML = html;

这种方式性能也很好,因为只触发了一次重排,但代价是原列表里的事件绑定会全部丢失。如果列表里没有任何事件需要保留,用这种方式完成一次性大批量渲染是可接受的。但凡是涉及到交互的列表,比如点击行有响应、行内有按钮,我都会优先考虑DocumentFragment,因为节点本身不会丢失事件。

还有一个务实层面的建议:别盲目追求极限性能。对一个实际的项目来说,列表量可能在几十到几百之间,用appendChild循环插入其实也不会有什么体感卡顿。真正需要用到DocumentFragment或字符串拼接优化的,是那种一次性渲染几千甚至上万节点的场景。把文档碎片用对地方,比在每一行代码里都优化要有意义得多。

5.3 批量更新的另一个思路:先离线修改再上屏

这里分享我在某个项目里用过的偏门但很有效的技巧。如果某个页面中的数据联动比较复杂,比如一次操作要同时更新列表里的多个文本、多个样式类,可以先把整个列表容器从DOM中摘下来,在内存里完成所有修改,再把它放回去。

const parent = list.parentNode; parent.removeChild(list); // 在这里对list做任意的增删改,因为它已经不在页面中了 // 浏览器不会因为这些改动触发重排 parent.appendChild(list);

这样做的好处非常明显:离屏状态下DOM节点依然可以正常操作,但不会触发页面渲染。等你把节点放回去时,浏览器一次性渲染最终结果,省掉了中间所有过渡状态的计算。这个技巧在做大规模表格重排、复杂筛选联动时很管用。当然,它也会带来一个副作用:操作期间如果有用户交互事件发生在该区域内,会因为节点不在DOM树里而暂时失效。所以在使用前要评估好操作的耗时和用户的实时交互需求,大多数情况下这类批量更新都非常快,用户几乎感知不到节点被摘走又放回的过程。

6. 一个真实排错经历:为什么列表项一直新增失败

6.1 问题表现

说一个我实际遇到的排查过程,也帮大家巩固一下这一天的知识点。当时我在写一个模拟项目X,结构类似:页面上有一组选项卡,每个选项卡对应一个列表。点击某个选项卡,就从这个列表里读取数据并渲染。一开始功能正常,后来我加了另一个功能:每次点击选项卡时,如果当前列表为空,就自动插入一条占位信息。结果快把我逼疯的现象发生了:占位信息有时候插入成功,有时候怎么点都没反应,而且控制台没有任何报错。

6.2 排查链路

先检查事件绑定是不是正常,点了选项卡之后回调函数有没有进入。通过在函数第一行打印日志,确认回调是进入了,但代码执行到插入逻辑时,列表却没有变化。

再次检查插入逻辑中用到的节点引用,发现我用一个变量保存了列表容器的引用,这个变量是页面加载时通过querySelector获取的。问题就出在这里:后来我在另一个函数里,用innerHTML重新给这个列表容器赋了一次值。这一赋,容器内部的节点全部被替换了,但我的变量引用的依然是那个容器本身。按道理innerHTML只是换掉了子节点,容器引用仍然有效……除非,容器本身也被替换了。

继续追查,发现代码某处直接把整个容器替换成了一个新的元素:

const newList = document.createElement('ul'); oldList.parentNode.replaceChild(newList, oldList);

因为替换之后旧容器已经不在DOM树里了,而我保存的变量依然指向旧容器。往一个不在页面中的节点里插入子节点,页面自然看不到任何变化。浏览器也不报错,因为它觉得你的操作是合法的,只是这个容器不在可视的DOM结构里了。

6.3 最终的修复和教训

修复的办法其实很简单:在需要插入操作的时候,不要依赖页面加载时保存的旧引用,而是每次都重新通过document.getElementById或querySelector获取当前DOM树里的真实节点。

这次排查让我牢牢记住了一个原则:保存DOM节点引用时,一定要意识到这个引用可能因为容器的替换、移动而失效。尤其是做动态渲染较多的页面时,别以为一个变量保存了节点就能永远放心使用。另外,排查这类问题时,不妨用一个直接的方法验证节点是否还在DOM树里,比如在控制台打印node.isConnected,它返回true表示节点还在文档中,false表示已经被移除了。这个小属性对调试这类问题非常高效,比反复查看页面效果靠谱多了。

再看一遍那个最容易被忽略的知识点,replaceChild替换的是“子节点”,但它如果被用来替换整个容器,那外部指向旧容器的引用就全部作废了。这提醒我们操作DOM时永远要有一个意识:操作之前,搞清楚你手里的引用是哪个层级上的节点,操作之后,哪些引用会继续有效,哪些会变成悬空的。

7. 深挖两个高频面试点:类数组转数组与for...of遍历

7.1 为什么NodeList不能直接调用map

document.querySelectorAll('li')可以返回一个NodeList,支持forEach,但早期的浏览器实现里,它并不支持map、filter这些数组方法。于是很多面试题会问:怎么把一个类数组转成真正的数组?

在ES6之后,最优雅的做法是Array.from():

const items = Array.from(document.querySelectorAll('li')); items.map(item => item.textContent);

或者使用展开运算符:

const items = [...document.querySelectorAll('li')];

这两种方式原理类似,都是把可迭代对象展开成一个新数组。区别在于,Array.from还接收第二个参数,一个映射函数,可以顺带完成数组元素的转换:

const texts = Array.from(document.querySelectorAll('li'), li => li.textContent);

这个写法在项目里非常常见,比如你要一次性拿到所有列表项的文本,或者给每一项加一个序号,都可以直接写在Array.from的第二个参数里,比先转数组再map省一步。

7.2 for...of与forEach在DOM遍历里的区别

NodeList支持forEach是好事,但forEach存在一个限制:没法在循环中使用break或continue来提前跳出或跳过。而你用for...of遍历NodeList时,就可以正常使用这两个关键字。

for (const li of document.querySelectorAll('li')) { if (li.textContent === '不需要的项') continue; li.classList.add('visible'); }

这个区别在工作量不大时可能不明显,但一旦列表中需要做条件筛选、提前中断,for...of就是更合适的遍历方案。另外,for...of天然支持await,如果你需要在遍历过程中逐个做异步请求,forEach做不到,for...of加上await可以。这些细节不值一提,但关键时刻能省你很多事。

7.3 数组方法在DOM操作中的组合用法

最后分享一个结束前的实用组合。把类数组转成数组之后,很多数组操作就都能施展了。

比如一键删除所有选中的项:

Array.from(document.querySelectorAll('li.selected')) .forEach(li => li.remove());

比如批量修改某项的样式:

Array.from(document.querySelectorAll('li')) .filter(li => li.dataset.status === 'expired') .map(li => li.classList.add('danger'));

又比如取到列表中特定数据结构的项,再做进一步展示更新。这些操作,每天写页面时都在重复,而它们的根基,就是“把类数组转成真数组”这一步。

8. 把这些API串起来的工程化思维

8.1 三个开发中必须养成的习惯

总的来说,Day3的知识点密度并不高,靠死记硬背API列表是撑不起实际开发的。回想我自己做过的项目,能稳定高效地使用DOM操作,靠的是三个习惯。

第一,操作前先确认数据形态。你要增删改的节点是从哪里来的?是接口返回的数组,是用户输入的内容,还是页面里原本就有的结构?数据源决定了你用创建新节点的方式,还是用现有节点的引用。第二,修改后注意引用的有效性。特别是涉及替换、移动、重绘之后,旧引用很可能已经失效。第三,批量操作时先考虑性能组织方式。是用DocumentFragment集中插入,还是离屏操作完再挂回来,或者干脆字符串拼接一次赋值。这些都应该是操作之前就决定的事,而不是出了问题再回头优化。

8.2 为什么说原生DOM是理解现代框架的钥匙

我之前有一阵子在带一个刚入行半年的同事,他一开始学React,总觉得生命周期和渲染机制很难理解。我建议他先回头把原生DOM操作再过一遍,搞清楚appendChild到底把节点怎么移来移去、DocumentFragment为什么能提升性能、事件委托又是怎么工作的。他补完这些再去写React,很多困惑瞬间解开了。比如React里用key就是为了准确识别哪些节点需要复用,而创建、插入、删除节点的底层逻辑,还是那些原生API。

我始终觉得,框架是解决复杂业务场景的利器,但底层能力应该长在你自己身上。你只要能徒手用原生API把一块数据渲染成一个可交互的列表,理解起来任何框架都会更轻松。相反,如果一开始就只想靠框架,遇到复杂交互时容易陷入“文档翻遍也不知道从哪改起”的困境。

8.3 一个简单但很实用的自查清单

最后,送你一个自检清单。每次你用DOM完成一个交互功能之后,对照着问一遍自己:

  • 数据从哪来?如果数据变了,界面是否需要同步变化?怎么同步?
  • 新增的节点上,有没有带用户输入的内容?如果有,我有没有做转义?
  • 用到的节点引用,是不是DOM树里当前有效的引用?
  • 如果操作涉及批量插入,有没有考虑使用DocumentFragment或离屏更新?
  • 事件绑定是给每个节点单独加,还是委托给了统一容器?
  • 删除节点后,有没有遗留的事件监听器?

这些问题在Day3的学习阶段可能覆盖不全,但在实际项目中,它们会反复出现。我可以在日常练习中刻意用原生API做一些小场景,比如动态表格、卡片列表、拖拽排序的简化版,手感就会慢慢出来。等你哪一天写完功能不再需要频繁刷新页面验证,能下意识预判这个操作会不会引发布局抖动、引用会不会失效,那就算真正把Day3学到位了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询