我平时看别人写的页面代码,最先注意的往往不是用了什么框架,而是它对 DOM 的访问方式。原因是这个环节最容易暴露对浏览器渲染模型的理解深度。明明只是取一个元素、改一段文本、给某个节点挂个事件,处理不好照样会把页面搞卡、把数据搞错,甚至在安全上开一个口子。这个话题很基础,但它从选择器性能一路延伸到异步渲染时机、动态列表、XSS 防护,水很深。
这篇文章我会借几个真实项目里碰过的问题,把 DOM 访问从“查节点”这层表象往下拆。先讲访问策略的整体取舍,再讲性能层面的坑,然后用一个完整的 Ajax 数据渲染场景把 JSON、HTML、XML 三类常见数据的落地方式串起来,顺带解析一遍 echarts 取不到容器宽高的经典报错,最后专门聊聊 DOM 型 XSS 为什么会在访问和写入之间产生。适合正准备把原生 JavaScript 用扎实、想搞清“为什么这里会慢/会炸/会有漏洞”的前端开发者。
1. 整体取舍:还没写代码前,先想清“从哪里访问”
1.1 原生选择器不是越新越好:getElementById 与 querySelector 的定位差异
DOM 访问第一件事是选节点。很多新手习惯拿着 querySelector 一套到底,因为 CSS 选择器写起来方便,能一次选到嵌套很深的目标。我承认在普通交互里这两种写法的差距肉眼几乎看不出来,但你要知道背后的运行逻辑是不同的。
getElementById 这类接口本质上是浏览器维护的一张哈希映射,按 id 直接定位,查找成本基本是常量。querySelector 则要先把选择器字符串做解析,再按选择器的结构去遍历候选节点。当你只在某个按钮点击事件里执行一次时,两者相差几乎可以忽略;一旦这种调用发生在循环、滚动回调或高频动画里,差距就会被放大。
举个实际例子。一次我在改造一个老项目,页面有一张几千行的表格,每一行都要根据状态更新某个单元格的文本。原代码在循环里用 querySelector 重复查找:
js复制for (let i = 0; i < rows.length; i++) {
const statusCell = rows[i].querySelector('.status-cell');
statusCell.textContent = statusMap[rows[i].dataset.id];
}
这段代码在数据量小时没毛病,但几千行的表格在低端机上明显卡顿。我的处理是把行状态映射先收集好,再直接通过行内的元素结构取子节点,或者先把每行的状态单元格用 getElementsByClassName 做一次扁平化查询,避免每行都重新做一次完整选择器解析。改造后体感顺畅很多。
我的结论是:优先使用目标明确的简单选择器或 getElementById;确需后代选择时再上 querySelector。querySelector 最忌出现“#app div ul li .active”这种一长串路径,选择器的可维护性和性能都不理想。
1.2 HTMLCollection 和 NodeList,访问前先分清“活”还是“死”
DOM 访问过程中很多人会被一个隐藏特性质疑:从 children、getElementsByTagName 返回的集合,和 querySelectorAll 返回的集合,行为并不完全一致。
children 和 getElementsBy* 返回的是 HTMLCollection,特点是实时集合。页面结构一旦变化,集合会自动更新。这个特性在某些场景是好用的,比如你要持续追踪一类元素的数量。但它也会产生一个经典问题:遍历时删除元素,下标会错乱。
看这段代码:
js复制const items = document.getElementsByClassName('item');
for (let i = 0; i < items.length; i++) {
if (shouldRemove(items[i])) {
items[i].remove();
}
}
问题很明显,每次 remove 都会让集合实时变化,length 变小、后面的元素下标前移,结果会跳过一些本该处理的元素。而 querySelectorAll 返回的是静态 NodeList,它获取的是调用那一刻的快照,遍历时怎么改页面都不会影响这份集合的内容。
childNodes 又是一个特殊情况,它虽然是 NodeList,但属于活集合。所以不能仅凭集合类型判断,要看接口语义。我的经验是:凡是遍历过程中会修改 DOM 的,尽量先把集合转成普通数组,或者用 while 循环从末尾往前删,避免集合动态性带来的下标问题。
1.3 访问前确认目标节点真的“在场上”
真实的项目里,null 错误是出现频率最高的问题之一。常见报错是 Cannot read properties of null,翻译成大白话就是:你想访问一个不存在的节点。
这类问题的根源大多不是你的选择器写错了,而是脚本执行时节点还没被解析出来。比如把 script 放在 head 里直接操作 body 下的元素,或者第三方脚本在页面加载早期就去访问底部区块。以前常用的办法是把脚本挪到 body 结束前或等 DOMContentLoaded,现在更推荐给 script 加 defer 属性。
我自己的习惯是:如果这个节点不是页面骨架里的必需部分,访问前加一次存在性判断;如果它必须存在,那应该通过时间和事件保证它已挂载,而不要只在后面用一个 if 兜底。存在性判断可以作为最终防线,但真正的解决办法是理解脚本执行时机与 DOM 解析顺序的关系。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 性能细节:查询慢和布局抖动都藏在不起眼的位置
2.1 把访问结果缓存起来,不要在循环里反复找同一个节点
前面提到过“循环内勿做复杂查询”,这一节展开说缓存的价值。DOM 访问本质是浏览器内部的一次查找过程,虽然接口很快,但在高频逻辑中仍然会被反复放大。比如在 mousemove、scroll、requestAnimationFrame 回调里每次都去 getElementById,完全不必要。
我最常用的优化手段是 Map 缓存。访问过一次的节点,先存进 Map,后续直接取;只有已知节点被替换或移除时才清理对应缓存。这样能把多次重复访问压成一次:
js复制const nodeCache = new Map();
function getNode(selector, root = document) {
if (!nodeCache.has(selector)) {
nodeCache.set(selector, root.querySelector(selector));
}
return nodeCache.get(selector);
}
需要注意一点,缓存节点会建立一条从 Map 到 DOM 节点的引用。如果页面经常做局部刷新,旧节点已经被 remove,Map 里仍然留着引用,不会造成严重内存泄漏,因为你整体还持有页面容器,但会带来逻辑上的“访问到旧节点”。我一般在删除区块时会同步调用 cache.delete 清理,保证缓存与真实 DOM 一致。
2.2 读属性也可能引发性能灾难:强制同步布局
不少开发者以为只有修改 DOM 才影响性能,读取不是没成本吗?其实读取某些几何属性时会触发强制同步布局。
浏览器为了提高渲染效率,一般会把样式和布局操作批量处理。当你修改样式后,浏览器不会立刻计算布局,而是攒一批再一次执行。但如果你紧接着读取 offsetWidth、offsetTop、getBoundingClientRect 这类几何值,浏览器为了返回准确结果,就不得不立即执行一次布局计算。如果这种“写后读”在循环里反复出现,就可能造成布局抖动,性能呈指数级恶化。
典型反例是这种写法:
js复制for (let i = 0; i < boxes.length; i++) {
boxes[i].style.width = boxes[i].offsetWidth + 20 + 'px';
}
每一轮先改宽度,下一轮循环又立刻读取最新宽度,浏览器无法批量处理,只能反复计算。正确的做法是先把需要读取的值存进普通变量,再统一写入:
js复制const widths = boxes.map((box) => box.offsetWidth);
boxes.forEach((box, i) => {
box.style.width = widths[i] + 20 + 'px';
});
需要注意,这个原则不只针对循环。在自定义组件里,如果一个回调函数中频繁读取布局信息再写入样式,也需要警惕。总体思路是:读取操作集中做,写入操作集中做,两者尽量不要交叉。
2.3 大规模批量访问与更新,用 DocumentFragment 减少页面重排
如果你要一次性往某个容器里插入几十甚至几百个新节点,最忌讳的做法是在循环里逐个 appendChild。每 append 一次,页面都可能触发一次重排,节点数量越多成本越高。无论你访问节点的过程有多快,最终写入方式不对都会把性能优势抵消掉。
我的基操是:先在循环里创建元素并把内容配置好,然后统一 append 到一个 DocumentFragment 上,最后再把片段一次性挂到真实 DOM。DocumentFragment 是一个独立于文档树的轻量容器,往里面塞子节点不会引起页面重排。等整个片段都构造完,一次挂载只会触发一次重排,性能提升非常明显。
如果你写的是框架,类似的逻辑通常由虚拟 DOM 帮你处理了。但在原生 JavaScript、jQuery 维护的旧项目里,DocumentFragment 仍是最值得掌握的技巧之一。
3. 场景实战:从 Ajax 返回数据到渲染进 DOM 的完整链路
3.1 JSON、HTML 片段、XML 三种数据源的访问姿势
日常开发中最常见的链路是 ajax 请求接口,拿到 JSON,再把数据渲染到页面已有容器里。这个过程听上去简单,但数据源不同,处理姿势并不相同。
接口返回 JSON 时,你的重点应该放在“如何把数据安全地结构化成 HTML”。很多初学者喜欢拼接字符串,比如:
js复制container.innerHTML = list.map(item => `<li>${item.name}</li>`).join('');
这在纯内部数据场景可以跑通,但一旦数据包含用户输入的名字、备注等字段,就会引入 XSS 风险。我现在的习惯是用 createElement 去构造节点,文本内容一律走 textContent,除非是在处理完全可信的静态模板。
接口返回 HTML 片段时,可以先把字符串交给 template 标签解析。template 内容不会立即激活,也不会触发资源加载,是安全解析 HTML 的好工具。解析完成后可以通过 content.querySelector 去访问片段里的目标节点,再做挂载。
接口返回 XML 时,浏览器没有像后端 libxml 那种统一的解析库,但它内置了 DOMParser。用 DOMParser 可以把 XML 文本解析成 Document 对象,之后就能用 querySelector、getElementsByTagName 等标准接口访问节点。这套方案在兼容性和能力上完全够用。
下面给一段完整的代码,演示从 ajax 拿 JSON 渲染类列表。注意它的核心不是代码本身,而是“访问”、“构造”、“挂载”三个阶段被明确分开:
js复制async function renderGoodsList(containerId, apiUrl) {
const container = document.getElementById(containerId);
if (!container) return;
let goodsList = [];
try {
const res = await fetch(apiUrl);
goodsList = await res.json();
} catch (e) {
container.textContent = '加载失败';
return;
}
const fragment = document.createDocumentFragment();
goodsList.forEach((item) => {
const li = document.createElement('li');
li.className = 'goods-item';
li.dataset.id = item.id;
const name = document.createElement('span');
name.className = 'goods-name';
name.textContent = item.name;
const price = document.createElement('span');
price.className = 'goods-price';
price.textContent = `¥${Number(item.price).toFixed(2)}`;
li.append(name, price);
fragment.append(li);
});
container.append(fragment);
}
这里每个新建元素都统一用 textContent 写入文本,不留下 innerHTML 拼接的注入空间。名字和价格字段来自接口,假如接口曾经被脏数据污染,也不会直接形成可执行脚本。
3.2 初始化类库时取不到尺寸:以 echarts 报错为例
很多前端都见过这个控制台报错:log.js:72 [Echarts] can't get dom width or height. please check dom.clientWidth and dom.clientHeight。这句话的意思是初始化图表时,你传进去的容器节点宽度或高度为 0,导致图表无法计算画布尺寸。
这不是 echarts 的 bug,而是使用时机问题。我排查过几次,原因基本集中在三类。
第一种最常见:容器被放在了默认隐藏的弹层或标签页里。display: none 的节点没有布局尺寸,clientWidth 和 clientHeight 都是 0,你在打开弹层前就调用了 echarts.init,必然报错。解决办法是把初始化动作推迟到弹层显示之后,或者监听弹层内部的内容加载完成事件。
第二种是容器已经插入文档,但父级使用了 flex 布局或百分比宽度,在某个状态下父级自己还没有被撑开,子节点拿到的宽度为 0。这种情况需要确认的是:你访问容器的时机,是否已经过了布局计算。最简单的方法是把初始化放进 requestAnimationFrame,等到当前帧布局完成后再读取。
第三种比较隐蔽,容器节点本身没问题,但你传给 echarts 的是一个刚从某个方法里创建但还没挂载到文档的孤儿节点。这样一个节点本身不可能有布局尺寸。如果你确实需要动态创建容器再初始化,顺序应该是先把节点挂载到页面上,再执行 echarts.init。
实际项目里还有一个细节:如果你在弹层每次打开时都重新 init,一定记得在关闭时 dispose 实例,否则旧实例还持有对隐藏容器的引用,后续会出现内存占用增大和图表重复创建的问题。这里我不把 echarts 单独当文档讲,只是借这个高频报错说明一件事:访问 DOM 节点元素时,除了确认“节点存在”,还要确认“节点可见且有尺寸”。
3.3 动态列表上使用事件委托,减少重复访问和重复绑定
动态内容场景还有一个更优雅的访问方式:事件委托。比如一个列表由 Ajax 数据渲染,列表里的每个按钮都有各自的删除、编辑操作。如果不做委托,你可能要在每次渲染后重新给所有按钮绑定监听函数,维护成本很高。
事件委托的要点是:只在列表的父容器上绑定一次事件,利用事件冒泡机制捕获所有子元素触发的事件。通过 e.target.closest('.delete-btn') 能拿到最近匹配选择器的按钮,再通过按钮的 dataset 属性找到对应数据。这是减少 DOM 访问次数最直接的手段,尤其适合会频繁刷新内容的列表。
js复制listContainer.addEventListener('click', (e) => {
const deleteBtn = e.target.closest('.delete-btn');
if (!deleteBtn) return;
const id = deleteBtn.dataset.id;
removeItemById(id);
});
这样做的好处不只是少绑定几次事件,更重要的是一份事件监听可以覆盖未来出现的所有新节点。只要动态内容还在同一个容器内刷新,你都不需要重复访问新老按钮。
4. 安全访问:DOM 操作里最容易被忽略的 XSS 问题
4.1 为什么纯前端拼接字符串会演变成 DOM 型 XSS
提到 DOM 访问,安全是绕不开的部分。DOM 型 XSS 不同于服务端反射型 XSS,它的整个攻击链都发生在浏览器里:恶意输入通过 URL 参数、localStorage、postMessage 等渠道进入页面,然后前端代码把这串内容作为 HTML 片段插入到 DOM 中,脚本就执行了。
举一个很典型的例子,一个简单欢迎页:
js复制const userName = new URLSearchParams(location.search).get('user');
document.getElementById('welcome').innerHTML = '欢迎 ' + userName;
如果有人在地址栏传入 ?user=<img src=x onerror=alert(document.cookie)>,页面加载后这段内容会被当成 HTML 解析,onerror 事件会触发脚本。核心不是字符串拼接本身,而是你访问到了容器节点后,使用了 innerHTML 作为写入通道。innerHTML 会让浏览器把字符串按 HTML 文本解析,任何标签、事件属性都可能被激活。
这是 DOM 访问中的安全视图:访问容器本身没有错,错在写入数据的“通道”和“内容信任级别”不匹配。凡是可能包含外部输入的文本,都不应该走 innerHTML。
4.2 安全写入的三个正确姿势
我在页面上输出动态文本时有一个非常死板的清单。
第一,纯文本内容一律用 textContent。textContent 会把内容当作纯文本处理,哪怕里面带了 <script>,也只会显示成一行字符,不会执行。
第二,需要构造带结构和事件的动态块,使用 createElement 配合 addEventListener。所有事件通过代码绑定而非内联属性,可以避免很多注入路径。
第三,如果确实需要渲染一份富文本,比如允许用户填写的超链接和加粗文本,必须经过白名单过滤或成熟的 sanitizer 库。不要自己写一个简单的正则替换,HTML 的解析规则非常复杂,正则很容易被绕过。
有些场景你只负责从一个 JSON 取值输出,数据听起来是可信的,但错误往往出在上游。数据可能在服务端被组合、被第三方写入,也可能被另一个本页功能污染。输出环节保持 sanitize 习惯,是前端最后一道防线。
4.3 用 MutationObserver 观察 DOM 变化,反向定位异常访问
安全维护还有一个进阶玩法:利用 MutationObserver 监控页面里是否有可疑节点被插入。当线上出现奇怪弹窗、被插入跳转脚本时,我通常会在调查阶段临时加一段检测脚本,观察哪个元素被添加、添加前后的 parentNode 是谁。
js复制new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches('script, iframe[src^="http"]')) {
console.warn('可疑节点插入', node, document.location.href);
}
});
});
}).observe(document.body, { childList: true, subtree: true });
这个方案不只能用于检测攻击,也适合调试框架中的动态渲染顺序。当你不知道是哪个模块把容器内容替换掉时,MutationObserver 配合 console.trace 能迅速抓到源头。访问 DOM 不只是提前规划好查询路径,观察 DOM 的变化本身也是访问的一种手段。
5. 常见问题与排查技巧实录
5.1 高频报错与定位思路速查表
下面这些问题是团队里出现最多的 DOM 访问相关报错,整理成速查表:
| 现象 | 一般原因 | 处理方式 |
|---|---|---|
| Cannot read properties of null | 脚本执行时节点尚未渲染 | 脚本增加 defer,或放入 DOMContentLoaded 回调 |
| echarts can't get dom width or height | 容器隐藏或未挂载导致尺寸为 0 | 等容器可见后初始化,传入有布局尺寸的节点 |
| 遍历删除时漏删了一部分元素 | HTMLCollection 实时性导致下标变化 | 反转遍历,或先转 Array 快照 |
| 动态列表点击事件不生效 | 新节点没有绑定旧事件 | 改用事件委托 |
| 获取的文本内容不对 | 同时存在文本节点和子元素 | 分清 textContent 与 innerText 的差异 |
| 修改样式后读取尺寸反复闪烁 | 循环中写读交叉引发强制布局 | 先统一读,再统一写 |
真实排障时,先复现,再到 elements 面板里检查元素状态,最后回到代码看执行时机。大部分“页面卡了一下才出现”的问题都能用这个顺序解决。
5.2 强制发下的三条调试技巧
Elements 面板上右键点击某个 DOM 节点,选择 Break on,可以针对节点子树修改、属性修改、节点移除设置断点。这是我最推荐的调试技巧。它的价值在于:当你不知道是谁改动了某个区域时,断点会直接停在触发修改的 JavaScript 代码位置。
第二个技巧是使用更细的日志定位,保留字段访问链。比如报警提示某个属性读不到,不要只打一个 console.log(el),而是把 el、el.parentNode、el.offsetParent 一起打出来。节点在不在文档里,看 offsetParent 是否为 null 就很直观。
第三个技巧是用 requestAnimationFrame 观察渲染时序。遇到节点已经存在但拿不到正确尺寸时,把结果分别打印在同步代码和下一帧,能很快判断出是布局尚未计算还是 CSS 资源尚未加载完成。
5.3 踩过几次坑之后,我对 DOM 访问最关键的三条心得
第一,先判断“访问时机”再写语法。很多选择器错误不是语法问题,而是节点确实还没生成。框架里的生命周期函数就承担了这个职责——必须在组件挂载完成后才能访问真实 DOM,本质上就是对访问时机的约束。
第二,性能优化不要只盯着单次查询耗时,把访问放入循环和布局读取的场景里审视,往往才是瓶颈所在。单个 getElementById 再快,也扛不住在频繁回调里反复执行。
第三,写入方式决定了安全性。DOM 访问和修改是一体两面。可以把页面想象成一座房子,查询是找到门,写入是把东西搬进去。如果箱子里的内容不可信,你就不该用“允许打开包装”的 innerHTML 方式搬进去。
这几年在原生脚本、旧系统维护和各种框架并存的代码里来回穿行,我越发觉得 DOM 访问的观念差异,会把代码分成两种风格。一种是不管拿到什么节点,拿完就改,很自由但也很容易失控。另一种是先厘清访问路径、确认节点状态、评估数据的可信度,再决定写入方式。后面这种初看要多写几行,但它在运行稳定性和安全性上的收益,能帮你省下大量埋单成本。希望这篇记录能给你一些实践经验层面的参考。
