资讯详情

JavaScript DOM操作核心解析:从内容操作到节点管理

📅 2026/9/15 2:25:08 | 华诺云谱 👁 阅读
JavaScript DOM操作核心解析:从内容操作到节点管理
做前端这几年JavaScript 的 DOM 操作是我写的最多的代码也是我踩坑最多的地方。你可能也遇到过这种情况同一个页面有人用 innerHTML 拼接一串模板字符串就把数据渲染出来了有人却坚持 createElement 一个一个建节点两套写法背后其实是操作内容和操作节点两条完全不同的思路。这篇内容我会从内容写入手段的底层差异讲起理清 textContent、innerHTML、createElement 这些核心 API 各自的适用边界再顺着 DOM 树的真实结构带你搞清楚节点关系、增删操作、事件绑定和性能优化之间的关系。文章会结合实际的列表渲染、局部更新、动态删除等场景适合刚接触 DOM 或写过一些 DOM 但总被奇怪 bug 卡住的开发者。1. 内容操作的底层差异textContent 与 innerHTML 到底改了什么1.1 textContent 的本质只动文本节点刚入行的时候我给一个元素设置显示文本基本都是这样写// 第一版习惯性用 innerHTML document.getElementById(price).innerHTML ${price};后来项目里遇到一个让我印象很深的问题——用户输入了一段包含img srcx onerror...的内容我在评论区里直接 innerHTML 渲染了出去当场就爆了一个 XSS 漏洞。从那以后凡是展示纯文本的场景我只用 textContent。textContent 的行为其实很单纯它不解析 HTML只把当前元素里的所有文本节点合并成一个字符串读出来或者把我们赋给它的字符串整体替换成新的文本节点。换句话说你给什么它就按纯文本显示什么b不会加粗script不会执行。这是它和 innerHTML 最本质的区别。提示textContent 读取时会拼接所有后代文本这意味着它拿到的字符串里不会包含任何标签标记这一点在做信息提取、字数统计时非常可靠。1.2 innerHTML 的本质解析 HTML 字符串并重建节点树innerHTML 就完全不是改文本的操作它本质上是把字符串丢给 HTML 解析器解析出一棵完整的节点树然后再替换掉当前元素的所有子节点。这背后有 tokenization、tree construction 两个阶段跟你把页面从服务器加载回来时浏览器做的事情是同一条流水线。所以它有两大特性能创建带结构的 DOM 节点这是动态生成复杂 UI 时的利器有性能开销和安全隐患尤其是把不可信数据拼接进来时。我后来处理富文本预览就是用 innerHTML 渲染编辑器输出的 HTML 片段但前提是这段 HTML 必须经过白名单过滤或者由受控的编辑器产生绝不能直接信任用户粘贴的内容。1.3 什么时候用哪个一段真实业务场景的选型我做过一个订单列表页每行订单要显示订单号、商品名、金额、状态。一开始是每一行都用模板字符串拼好再整体 innerHTML 赋值到 tbodyfunction renderOrders(orders) { const tbody document.querySelector(#ordersBody); tbody.innerHTML orders.map(o tr td classorder-no${o.no}/td td${o.name}/td td${o.amount}/td td${o.status 1 ? 待付款 : 已完成}/td /tr ).join(); }跑起来没问题数据量几百行也很快。直到后来状态列要加一个点击切换状态的功能每行还要绑定事件。再用字符串拼 innerHTML你就得在渲染之后重新 querySelectorAll 去找每一行再逐个 addEventListener非常别扭。换成节点操作之后逻辑清晰很多function createOrderRow(order) { const tr document.createElement(tr); const tdNo document.createElement(td); tdNo.textContent order.no; tr.appendChild(tdNo); const tdName document.createElement(td); tdName.textContent order.name; tr.appendChild(tdName); const tdAmount document.createElement(td); tdAmount.textContent order.amount; tr.appendChild(tdAmount); const tdStatus document.createElement(td); const statusBtn document.createElement(button); statusBtn.textContent order.status 1 ? 待付款 : 已完成; statusBtn.className status-btn; tdStatus.appendChild(statusBtn); tr.appendChild(tdStatus); return tr; }这两种风格不冲突关键看场景场景推荐方案原因一次性渲染静态模板innerHTML代码短、性能可接受需要绑定事件/局部更新createElement appendChild节点引用直接可用展示用户输入/不可信数据textContent安全可控不解析 HTML渲染富文本/受控编辑器内容innerHTML 白名单过滤保留结构但需防 XSS选型逻辑一句话你是在设置内容还是在管理节点。前者 textContent / innerHTML 随手就做后者必须走节点 API。2. 从内容到节点理解 DOM 树的构成与关系2.1 DOM 树里不只是元素节点很多人说DOM 就是标签其实不全对。DOM 树里至少有三类常见节点元素节点ELEMENT_NODE值为 1就是div、p、span这种。文本节点TEXT_NODE值为 3元素里面的一串字符。注释节点COMMENT_NODE值为 8就是!-- 注释 --。举一个例子div idbox Hello !-- 这里有个注释 -- spanWorld/span /divdocument.getElementById(box).childNodes返回的不只是span而是五个节点文本节点\n Hello\n 注释节点 这里有个注释 文本节点\n 元素节点span文本节点\n第一次看懂 childNodes 输出的人大概率会惊呼这是什么鬼。但这就是 DOM 的真实结构它不因为你看不习惯就改变。写脚本的人如果只盯着标签看遇到这种输出就会懵理解了整个树里每个位置都有名分你才能知道为什么有些操作会莫名多出空白节点。2.2 childNodes 与 children 的差别为什么空白会捣乱childNodes 返回所有子节点文本、注释、元素都算children 只返回元素子节点。所以前面那个例子里box.children只有[span]干净利落。这个差异在实际开发里非常致命。你如果遍历 childNodes 做处理很容易被文本节点里的换行和空格坑到。比如判断某个节点是不是我要找的目标// 这段代码会被空白节点坑到 const kids box.childNodes; for (let i 0; i kids.length; i) { if (kids[i].nodeName SPAN) { // 只有走到第4个才命中 } }注意在真实项目中凡是遍历子元素的需求优先用 children只有你明确需要操作文本节点的时候才用 childNodes。很多人还会在遍历的时候用for...of配合Array.from去处理节点集合因为childNodes和children返回的是 NodeList虽然支持 forEach但直接对它做 filter、map 会报错。我一般会先Array.from(nodeList)转成真正数组再走正常的数组操作熟练之后很少再被这类小坑卡住。2.3 节点关系的血缘图DOM 节点之间有一组关系属性类似家庭成员parentNode父节点可能是元素也可能是 DocumentFragmentparentElement父元素非元素父节点会返回 nullpreviousSibling / nextSibling上一个/下一个兄弟节点包含文本和注释previousElementSibling / nextElementSibling上一个/下一个兄弟元素更常用firstChild / lastChild第一个/最后一个子节点含文本firstElementChild / lastElementChild第一个/最后一个子元素我在做表格行删除时最常见的操作就是function deleteRow(btn) { const tr btn.closest(tr); tr.parentNode.removeChild(tr); // 或者更简洁tr.remove(); }这里用 parentNode 而不直接用父元素因为 tr 的父节点一定是 tbody 这类元素用 parentNode 完全够用。但如果你写过父节点可能是 DocumentFragment的通用工具函数就必须检查 parentNode 存在性否则会报错。还有一点容易被忽略closest()方法可以从任意元素向上匹配选择器比一层层手动parentNode判断要省事得多。尤其处理表格、列表里的事件委托e.target.closest(tr)这一句能顶掉你自己写的好几行关系遍历逻辑。3. 节点的增删操作从 appendChild 到现代 API3.1 创建和插入一个节点的完整流程创建节点有很多种方式除了日常的 createElement还有几个容易被忽略但很有用的document.createTextNode(字符串)单独创建文本节点document.createDocumentFragment()创建片段容器element.insertAdjacentHTML(position, html)以四种位置插入 HTMLelement.insertAdjacentElement(position, element)以四种位置插入元素看一个场景给一个列表ul#list动态增加一项 new item。传统写法const ul document.getElementById(list); const li document.createElement(li); li.textContent new item; ul.appendChild(li);用 insertAdjacentHTML 的话ul.insertAdjacentHTML(beforeend, linew item/li);insertAdjacentHTML 的位置参数有四个参数插入位置beforebegin作为前一个兄弟节点afterbegin作为第一个子节点beforeend作为最后一个子节点afterend作为后一个兄弟节点这个 API 的好处是它不像 innerHTML 那样把整个容器的子节点都重建一遍而是只解析并插入你给的那一小段性能开销更小对已有节点的影响也更低。我经常用它来在某个模块后面快速插入一个提示条比手动 createElement insertBefore 简短很多。3.2 删除节点的两种姿势与坑删除节点看起来简单node.parentNode.removeChild(node); // 或 node.remove();node.remove() 是现代浏览器支持的原生方法Internet Explorer 除外实现简单了不少。但这里有个容易忽略的坑你持有的节点引用不等于它还挂在页面上。有个经典 bug我写过一个批量删除商品的功能点删除后先调用接口再在回调里执行 removeChild结果因为同一行被重复点击或者数据状态已变更持有的 node 早就脱离主文档了parentNode是 null直接抛错。提示删除前最好判断 node.isConnected。isConnected 返回 true 表示节点仍在文档中false 则说明已经被移除了可以避免很多奇怪的报错。if (node.isConnected) { node.remove(); }想得更细一点不仅删除前要判断往节点里插入内容前也最好判断一下它是否还在文档里尤其是在 SPAs 或组件化项目里组件卸载和异步回调的时机交错很容易让节点成为孤儿。3.3 克隆节点的隐患事件绑定丢失DOM 提供了一个 cloneNode 方法布尔参数深拷贝。我会想当然地以为克隆一个带事件监听的按钮新按钮也会有事件。实际上 cloneNode 只克隆结构不克隆通过 addEventListener 绑定的事件。举个例子const btn document.querySelector(#submitBtn); btn.addEventListener(click, handler); const copy btn.cloneNode(true); document.body.appendChild(copy); // 点击 copy 不会触发 handler之前做工程模板的时候需要复制一组表单控件我直接深克隆然后替换原控件结果复制出来的控件完全无响应。排查了很久才发现事件没有跟着走。解决方式无非两种要么用事件委托监听父容器而不是按钮本身要么在克隆之后手动重新绑定事件。事件委托在这个场景下更省心document.body.addEventListener(click, (e) { if (e.target.closest(#submitBtn)) { handler(); } });这里顺便提一下cloneNode 还有一个隐藏用途——在内存里复制一个节点做修改预览等确认之后再整个替换掉旧节点页面不会在中间状态闪烁。4. 实战链路一个列表组件的动态渲染与更新4.1 初次渲染字符串拼接还是 createElement我们来写一个真实的待办列表组件。需求很简单能渲染任务列表、能切换完成状态、能删除任务。初次渲染时我倾向于对简单结构用 createElement 配合 textContent理由有两个一是后续局部更新方便二是能避开 HTML 注入风险。代码大致长这样function renderTask(task) { const li document.createElement(li); li.className task.done ? task done : task; li.dataset.id task.id; const span document.createElement(span); span.textContent task.title; span.className task-title; li.appendChild(span); const statusBtn document.createElement(button); statusBtn.textContent task.done ? 恢复 : 完成; statusBtn.className toggle-btn; li.appendChild(statusBtn); const delBtn document.createElement(button); delBtn.textContent 删除; delBtn.className del-btn; li.appendChild(delBtn); return li; } const listEl document.querySelector(#todoList); tasks.forEach(task { listEl.appendChild(renderTask(task)); });如果数据量大我会把listEl.appendChild的那些调用合并先创建一个 DocumentFragment把所有 li 塞进去最后挂到列表里。这也是下一小节我们要集中聊的点。4.2 局部更新通过节点关系定位并修改当用户点完成按钮时我不想把整个列表重新渲染因为那样会丢失滚动位置、输入焦点造成明显的界面闪烁。更好的做法是只改了那一行的状态。这里有个很实用的定位技巧给数据源里每项一个>li.dataset.id task.id;事件委托时listEl.addEventListener(click, (e) { const btn e.target.closest(button); if (!btn) return; const li e.target.closest(li); const id li.dataset.id; const task tasks.find(t t.id id); if (btn.classList.contains(toggle-btn)) { task.done !task.done; li.querySelector(.task-title).classList.toggle(done, task.done); btn.textContent task.done ? 恢复 : 完成; } else if (btn.classList.contains(del-btn)) { li.remove(); tasks.splice(tasks.indexOf(task), 1); } });这样一来每次操作只触达目标 li 内部不重建兄弟节点交互成本降到最低。这里有一点值得体会把数据状态和 DOM 状态绑定起来是有额外成本的。用dataset.id把两者关联既有唯一标识又不用在闭包里保存一长串引用。4.3 性能观察批量操作时一定要用 DocumentFragment有一次我给一个图表页面灌 1 万行数据。直接用 for 循环逐个 appendChild页面卡到滚动都掉帧。后来用 DocumentFragment 优化效果立竿见影。DocumentFragment 是一个虚拟容器它存在内存中不属于文档树。你去操作 fragment 里的节点时不会触发任何重排。把整组节点挂到 fragment 里之后只做一次真实的 appendChild浏览器只重排一次。const fragment document.createDocumentFragment(); tasks.forEach(task { fragment.appendChild(renderTask(task)); }); listEl.appendChild(fragment);这样浏览器不用在每个循环里更新布局和绘制从1 万次重排变成了1 次重排性能差出一个量级。我在内部性能分享会上专门给团队演示过这个差异用一个 10000 条数据的表格无 fragment 版本大约耗时 120ms有 fragment 版本只要 15ms 左右差距很明显。优化的原则其实很简单减少对文档树的直接操作次数凡是能先攒起来再统一挂载的就不要立刻插入。5. 避坑与调试踩过的 DOM 坑复盘5.1 把 HTML 字符串传给 textContent 的后果这个错误几乎每个新手都会犯。你写下el.textContent div classcardHello/div;页面上出现的不是卡片而是一行带尖括号的字符串。因为 textContent 完全不做解析。反过来如果你把纯文本塞进 innerHTML也可能出问题。比如const userInput img srcx onerroralert(1); el.innerHTML userInput;浏览器会真的创建 img 元素并执行 onerror 里的代码。这个就是 XSS 的常见入口很多实战项目在代码审计里被标记为高危漏洞。我在项目里定了一个铁律用户可控的数据一律进 textContent能进 innerHTML 的必须经过转义或白名单。一个实用的转义函数可以直接复用function escapeHtml(str) { const div document.createElement(div); div.textContent str; return div.innerHTML; }它借助 textContent 的不解析特性把字符串里的、、、引号统统转成实体。这个方法简单、可靠我在团队里推荐过很多次。5.2 动态插入节点后又被重置的排查链路有一次做异步加载接口返回后我把数据渲染进一个列表但页面总是先显示内容紧接着又变回空列表。现象看起来像是被清空了。排查过程我记得很清楚先看网络面板确认接口只请求了一次响应正常。在渲染函数里打日志确认节点确实 append 进去了。查看控制台有没有报错结果发现一个第三方库的警告原来是某个全局脚本在文档加载完成后调用了一次document.body.innerHTML ...。找到那个脚本发现是某个统计脚本在动态插入自己的挂载点时用 innerHTML 整体覆盖了 body。这个坑的根本原因不在我的代码但也给了我一个教训不要在 body 上直接覆盖 innerHTML任何全局脚本都不该这样做。如果你维护的页面里有人这么干了后续所有节点操作都可能在某个瞬间被 wipe 掉。排查这类问题有一个通用思路先确认数据对不对和节点是否插入成功再去查有没有别的代码把节点整个删掉或替换掉顺序不能反。5.3 节点引用失效与陈旧引用还有一种隐蔽问题叫陈旧引用。你把某个节点存到变量里之后这个节点被其他代码用 innerHTML 整体替换了。你手里那个变量仍然指向旧节点但旧节点已经不在文档了。你往旧节点里 append 内容页面不会有任何变化。let list document.querySelector(#list); // 后边有人写了 document.querySelector(#wrap).innerHTML div idlist/div; // 你手里的 list 还是旧的append 没有效果 list.appendChild(li); // 白干了遇到这种问题检查变量是否还在文档中直接判断list.isConnected。如果为 false就需要重新执行 querySelector 获取新节点。这条经验让我后来写工具函数时都会加一个获取最新节点的步骤函数不直接依赖外部传入的旧引用而是每次用 document.getElementById 重新查一遍代价很小却可以消除很多因为陈旧引用导致的诡异 bug。5.4 遍历时删除节点会跳过兄弟节点还有一个非常常见的坑用 for 循环倒序遍历或者正序遍历的同时删除节点。// 正序遍历删除会漏删 const items list.children; for (let i 0; i items.length; i) { list.removeChild(items[i]); i--; // 忘了这一步就会漏 }正确做法是倒序遍历或者先把要保留的节点收集到一个数组里再统一操作。这个坑几乎每个做过动态表格的人都踩过原因就是 NodeList 是动态集合live collection结构一变索引就乱。理解这一点之后遍历时不要直接操作集合本身就成了我写 DOM 代码的一条戒律。6. 把这些经验沉淀成一组顺手的小工具函数6.1 日常必备的封装三个函数覆盖了我 90% 的日常需求// 安全设置文本内容 function setText(el, text) { while (el.firstChild) { el.removeChild(el.firstChild); } el.appendChild(document.createTextNode(text)); } // 清空所有子元素 function empty(el) { while (el.firstChild) { el.removeChild(el.firstChild); } } // 批量创建子元素 function appendChildren(el, children) { const fragment document.createDocumentFragment(); children.forEach(child { if (child instanceof Node) { fragment.appendChild(child); } else { fragment.appendChild(document.createTextNode(String(child))); } }); el.appendChild(fragment); }这些函数不复杂但能保证每次写 DOM 操作时思路统一。团队里定了规范纯文本渲染走 setText整批替换走 appendChildren结构渲染走模板引擎/受控 innerHTML。规范一旦定下来XSS 面小了很多。6.2 更安全的内容更新策略先构建再替换最后还有一个我觉得很实用的思路与其东一榔头西一棒子地去改现有节点不如把一批新节点完整地在内存里构建好最后一次性替换旧节点。const oldList document.querySelector(#list); const newList oldList.cloneNode(false); // 只克隆空壳 tasks.forEach(task { newList.appendChild(renderTask(task)); }); oldList.parentNode.replaceChild(newList, oldList);这种方式适用于列表整体无状态或状态可由数据完全恢复的场景。它天然避免了陈旧引用也避免了遍历过程中修改结构导致下标错乱的问题。实际测试中这种方式的重排次数比逐条修改少很多代码可读性也更高。不过我通常会补充一个判断如果列表里恰好有正在输入的表单控件、很细的滚动位置等不能被破坏的 UI 状态那就不能整个替换还是得走局部更新那条路。选哪种方案要看业务能不能接受一次结构重建。按我这几年写 DOM 的经验大多数问题最后都能归结到两个问题上你到底是操作文本内容还是操作节点结构你到底持有的是真实的文档节点还是一个脱离了文档的旧引用把这两点想明白至少一半的 DOM bug 可以提前避免。剩下的一半靠日志、isConnected 判断和规范的代码结构去兜底基本就能稳稳接住。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。