做前端这几年,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 src=x 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 赋值到 tbody:
function renderOrders(orders) { const tbody = document.querySelector('#ordersBody'); tbody.innerHTML = orders.map(o => ` <tr> <td class="order-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 id="box"> Hello <!-- 这里有个注释 --> <span>World</span> </div>document.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:父节点(可能是元素,也可能是 DocumentFragment)
- parentElement:父元素(非元素父节点会返回 null)
- previousSibling / 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 到现代 API
3.1 创建和插入一个节点的完整流程
创建节点有很多种方式,除了日常的 createElement,还有几个容易被忽略但很有用的:
- document.createTextNode('字符串'):单独创建文本节点
- document.createDocumentFragment():创建片段容器
- element.insertAdjacentHTML(position, html):以四种位置插入 HTML
- element.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', '<li>new 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 class="card">Hello</div>';页面上出现的不是卡片,而是一行带尖括号的字符串。因为 textContent 完全不做解析。
反过来,如果你把纯文本塞进 innerHTML,也可能出问题。比如:
const userInput = "<img src=x onerror='alert(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 id="list"></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 判断和规范的代码结构去兜底,基本就能稳稳接住。