做前端这几年,最绕不开的就是 DOM 操作,而 DOM 查询又是所有操作的第一步。你说一个页面几十个节点,想改样式、绑事件、拿数据,不先把元素找出来,后面全是空谈。所以我把自己的项目里最长写的“javascript之Dom查询操作”整理成系列,这是第一篇,重点讲清楚了 querySelector 系、getElement 系这些查询 API 到底该怎么用、什么时候选哪个、有哪些坑是文档里不会写的。
这篇文章适合刚入门前端、或者已经写了一阵子但靠查资料应付的人,也适合回头想系统梳理 DOM 查询知识的朋友。我会从底层原理讲到实际场景,再给你几段可以直接抄的代码和一套排查问题的思路,保证你看完能知道自己每次“选中元素”时,浏览器背后到底发生了什么事。
1. DOM查询的整体思路与方案选型
1.1 为什么“查询”是DOM操作的核心
浏览器把 HTML 解析成一棵树,也就是 DOM 树,每个标签、文本、注释都是树上的一个节点。前端要动态改页面,本质上就是对这棵树做“增删改查”。而查是所有动作的前置条件——你连节点都拿不到,改谁?所以 DOM 查询能力的强弱,直接决定你写脚本的效率。
很多初学者容易陷入一个误区,就是把 jQuery 时代的 $ 选择器当作理所当然,换到原生 JavaScript 后反而不知道用什么。实际上现在的原生 API 已经非常完善,完全覆盖了绝大多数场景,而且性能不差,只是你不会用而已。
1.2 querySelector 与 getElement 两大家族的取舍
原生 DOM 查询大致分成两个流派。一派是 getElementById、getElementsByClassName 这些老牌 API,另一派是 querySelector、querySelectorAll 这两个后来的通用选择器。两者的核心区别在于:getElement 系接收的是“类型”参数,querySelector 系接收的是 CSS 选择器字符串。
从使用角度说,querySelector 系更灵活,因为你可以用任意 CSS 选择器语法,比如 .container > .item:first-child、input[name="username"],一次定位到深层元素。而 getElementById 只能按 id 查,getElementsByTagName 只能按标签查。所以新项目我几乎都用 querySelector 系。
但别急着放弃 getElement 系。我实测下来,在旧版本浏览器环境下,getElementById 的性能要比 querySelector('#id') 稍快一点,而且它返回的是 live 集合,后续 DOM 变化会自动更新。不过这点性能差异在现代浏览器里已经微乎其微,选型时优先考虑可读性和功能,而不是盲目追求“快”。
1.3 静态集合还是动态集合,先想清楚再动手
这是最容易踩坑的地方。getElementsByClassName、getElementsByTagName 返回的是 HTMLCollection,是动态的,而 querySelectorAll 返回的是 NodeList,在绝大多数情况下是静态的。什么意思?就是你通过 class 拿到的元素列表,如果后续往同一个 class 里追加了新元素,这个集合会自动多出新元素;而 querySelectorAll 的结果在调用那一刻就固定了,后续新增不会出现在快照里。
动态集合在少数场景下有优势,比如你要持续监视某个 class 下所有元素的变化,不用重新查询。但更多时候它带来的是困扰,因为你在循环里操作 DOM,可能因为集合长度不断变化导致死循环。所以我个人默认会用 querySelectorAll,除非明确需要动态特性,否则静态快照好排查得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 常用查询API逐个拆解
2.1 getElementById:最简单但限制最严
getElementById 只做一件事:按 id 精确匹配元素。它接收一个字符串,返回匹配到的第一个元素,或者没有匹配时返回 null。id 在 HTML 规范里是唯一的,所以这个方法通常返回一个元素而不是集合。
使用时有三个细节要注意。第一个是 id 大小写敏感,你必须精确匹配。第二个是如果 HTML 里有重复 id,虽然不合法,但浏览器不会报错,getElementById 会返回第一个匹配到的元素,这时候行为依赖 DOM 树顺序,容易出 bug。第三个是 id 中包含特殊字符时,比如 . 或 [],用 querySelector 语法需要转义,但 getElementById 直接传原始字符串就行,这也是它少有的优势之一。
javascript复制const box = document.getElementById('main-box');
if (box) {
box.style.backgroundColor = '#f0f0f0';
}
为什么一定要判断空?因为如果脚本执行时元素还没解析到,比如脚本放在 head 里,或者你拼写错了 id,box 就是 null,直接访问 box.style 会报 Cannot read properties of null。这算是新手最常见的运行时错误之一了。
2.2 getElementsByClassName:批量获取的动态陷阱
getElementsByClassName 接收一个或多个类名,返回 HTMLCollection。注意它匹配的是“包含所有这些类”的元素,而不是精确等于某个 class 属性。比如 document.getElementsByClassName('a b') 会匹配 class="a b" 也匹配 class="b a",只要同时有 a 和 b 就行。
这个 API 返回的是动态集合。我遇到过这么个真实问题:我想给一个容器里的所有 .item 加点击事件,用 getElementsByClassName 拿到列表后写了个 for 循环,循环里又往容器里插入了一个新 .item 节点。结果循环索引越界,因为列表长度在循环中变大了,而且新插入的节点是未绑定事件的。后来我改用 querySelectorAll,或者先把长度存下来,问题就清晰了。
javascript复制const items = document.getElementsByClassName('item');
for (let i = items.length - 1; i >= 0; i--) {
// 倒序遍历,动态集合增删时就安全得多
}
2.3 getElementsByTagName:标签查询的边界问题
getElementsByTagName 按标签名查询,同样返回动态 HTMLCollection。它有个有趣的行为:如果你传入 *,会返回所有元素节点,这在某些需要遍历全量节点的场景下很有用。
当你想找某个容器下所有 div 时,一般不会写 document.getElementsByTagName('div'),而是从具体容器开始找:
javascript复制const container = document.getElementById('container');
const divs = container.getElementsByTagName('div');
这里有个隐藏细节:如果容器本身也是 div,它会把自己也包含进去吗?不会。因为 getElementsByTagName 是从调用者元素的“后代”里搜索,不包含调用者自身。但如果你调用的不是元素而是 document,那 document.documentElement 这个 html 节点如果符合条件也会被包含进去。比如 document.getElementsByTagName('html') 能查到 html 节点,这倒是符合直觉。
2.4 querySelector:用CSS语法精确制导
querySelector 接收一个 CSS 选择器字符串,返回第一个匹配元素。它的强项在于你能把复杂的 CSS 选择器拿来当索引工具。比如想选 ul.list > li.active 下的第一个 span,一行代码就能拿到。
javascript复制const span = document.querySelector('ul.list > li.active span');
但选择器语法是有合法性的,如果你传入一个无法解析的选择器,浏览器会直接抛 SyntaxError,导致脚本中断。比如类名写成了 .item[ 这种未闭合的属性选择器。所以用动态拼接选择器时要格外小心,尤其是从用户输入来的字符串,必须在拼接前做校验或转义。
querySelector 只返回第一个匹配项,如果你需要所有匹配项,得用 querySelectorAll。注意到这两者定位“第一个”的规则是 DOM 树的“先序深度优先”顺序,不是视觉上的从上到下。也就是说,如果一个元素在背景层但 DOM 位置靠前,querySelector 会先找到它,而不是你在屏幕上看到的最上层元素。这个坑偶尔会在弹屏、浮层这类场景下出现。
2.5 querySelectorAll:静态快照与遍历
querySelectorAll 返回一个 NodeList,支持 forEach、entries、keys 等方法,用起来很舒服。它匹配所有符合条件的元素,但不会包含调用者本身,只查后代。
默认它是静态快照。我做一个“筛选列表”功能时就依赖这个特性:先 querySelectorAll 拿到所有卡片,然后根据用户输入过滤,再操作这个固定列表,不会因为中间删掉了某个卡片而影响后续遍历。如果你的场景要求集合实时反映 DOM 变化,就得考虑 getElementsByClassName 或者手动监听 DOM 变化重新查询。
还有个细节,querySelectorAll 支持用逗号分隔多个选择器,比如 document.querySelectorAll('.a, .b') 会把 class 为 a 或 b 的元素都查回来。这是 CSS 选择器本身的能力,并不额外消耗多少性能,很适合一次处理多类元素的情况。
3. 不同查询API的使用场景对比
3.1 从代码可读性维度选择
代码是写给人看的,选择器越接近语义越好。我倾向的原则是:通过 id 定位唯一模块时用 getElementById,因为语义明确;其他一律用 querySelector/querySelectorAll。getElementsByClassName 这种 API,现在基本只在需要动态集合时才特意使用,否则没必要给自己埋变量。
举个例子,如果页面上有个“搜索按钮”,它只有一个,用 document.getElementById('search-btn') 比 document.querySelector('#search-btn') 更直观。而且 getElementById 对 id 特殊字符的处理更简单,不需要转义。反过来,如果我要找一个表单里的“第一个必填输入框”,querySelector 一行搞定,getElementsByClassName 就做不到这么精准。
3.2 从性能维度选择
在绝大多数业务场景下,查询性能差异可以忽略。真正影响性能的是“查询后是否频繁操作 DOM”。比如循环里多次调用 getElementById 和 querySelector,都会造成布局抖动。更好的做法是先把查询结果缓存到变量里:
javascript复制// 不推荐:循环里反复查询
for (let i = 0; i < 1000; i++) {
document.querySelector('.item').innerHTML = i;
}
// 推荐:缓存查询结果
const item = document.querySelector('.item');
for (let i = 0; i < 1000; i++) {
item.innerHTML = i;
}
我的经验是,如果选择器很简单,比如只有 id,getElementById 会稍微快一点;选择器一复杂,querySelector 内部也要经过解析,但损耗在毫秒级。与其纠结这点时间,不如关注是否触发了不必要的重排。如果你是做大型表格、长列表渲染,性能瓶颈普遍在渲染和内存,而不是查询本身。
3.3 从兼容性维度选择
现代浏览器对 querySelector 的支持已经非常全面,可以放心使用。如果你需要兼容 IE8 以下的老古董,那就只能用 getElementById 和 getElementsByClassName。不过现在这种需求已经很少见了,我在实际项目中直接默认使用 querySelector 系,除非团队规范里明确限制。
兼容性上还有一个容易被忽略的点:NodeList 的 forEach 方法在非常老的浏览器里不支持,如果想统一处理,可以先转数组 Array.from(document.querySelectorAll('.a')),或者用展开运算符 [...document.querySelectorAll('.a')]。现在大多数项目都有 Babel 加持,这个其实不是大问题。
4. 实操:从零开始用原生JavaScript完成复杂查询
4.1 场景描述与目标
假设我们要实现一个常见的商品筛选页:一个商品列表,每个商品有名称、价格、分类标签,页面上方有筛选按钮,按“全部”“电子”“家居”“服饰”分类切换,并显示商品总数。
这个场景不算复杂,但足够体现多个查询 API 的组合使用。我们先在 HTML 里定义好结构和数据,再用 JavaScript 完成整个交互。
4.2 HTML结构准备
html复制<div id="app">
<div class="filter-bar">
<button class="filter-btn active" data-category="all">全部</button>
<button class="filter-btn" data-category="electronics">电子</button>
<button class="filter-btn" data-category="home">家居</button>
<button class="filter-btn" data-category="clothing">服饰</button>
</div>
<div class="stats">共 <span id="count">0</span> 件商品</div>
<div id="product-list" class="product-list">
<!-- 商品卡片动态生成 -->
</div>
</div>
商品数据我放在 JavaScript 里,这样能更专注演示查询逻辑,实际项目里可能是从接口拿到的。
4.3 JavaScript实现与查询技巧
javascript复制const products = [
{ id: 1, name: '机械键盘', price: 399, category: 'electronics' },
{ id: 2, name: '护眼台灯', price: 199, category: 'home' },
{ id: 3, name: '纯棉T恤', price: 89, category: 'clothing' },
{ id: 4, name: '蓝牙耳机', price: 299, category: 'electronics' },
{ id: 5, name: '懒人沙发', price: 899, category: 'home' }
];
const list = document.querySelector('#product-list');
const countSpan = document.querySelector('#count');
const buttons = document.querySelectorAll('.filter-btn');
function render(category) {
const filtered = category === 'all'
? products
: products.filter(p => p.category === category);
countSpan.textContent = filtered.length;
// 关键查询:用 map 生成结构后拼接字符串
list.innerHTML = filtered.map(p => `
<div class="product-card" data-id="${p.id}" data-category="${p.category}">
<h3>${p.name}</h3>
<p class="price">¥${p.price}</p>
</div>
`).join('');
}
function bindEvents() {
buttons.forEach(btn => {
btn.addEventListener('click', () => {
// 移除所有按钮的 active 类
buttons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const category = btn.dataset.category;
render(category);
});
});
}
render('all');
bindEvents();
这段代码中用到了多个查询 API:querySelector 查单个元素、querySelectorAll 查多个按钮。classList 操作虽然不属于查询,但也依赖元素被正确查到。我特意没有用 getElementById,因为项目里这类查询用 querySelector 足够直白。
4.4 给这个场景增加“搜索商品名”功能
筛选功能之外,常见需求还有关键字搜索。我加一个输入框,当用户输入时,动态过滤商品列表。这时的查询要点在于:实时读取输入内容,并注意输入频率过高时的性能问题。
javascript复制const searchInput = document.querySelector('#search-input');
searchInput.addEventListener('input', () => {
const keyword = searchInput.value.trim().toLowerCase();
const cards = document.querySelectorAll('.product-card');
cards.forEach(card => {
const name = card.querySelector('h3').textContent.toLowerCase();
card.style.display = name.includes(keyword) ? '' : 'none';
});
});
这里我选择“先渲染全部卡片,再通过 DOM 查询逐个控制显示隐藏”,而不是重新渲染列表,因为前者能保持滚动位置和状态,体验更好。每次 input 都会触发对 .product-card 的查询,实时性足够,也不需要防抖。如果卡片上千个,那就得考虑事件委托加防抖了,不过那是另一个话题。
4.5 事件委托:把查询次数降到最低
如果商品列表里每个卡片都需要处理点击事件,你会怎么写?新手通常会在 render 里挨个绑定:
javascript复制cards.forEach(card => {
card.addEventListener('click', () => { ... });
});
但如果列表经常重绘,这种方式会反复创建监听器,内存浪费严重。更推荐事件委托:只在列表容器上绑定一次,通过事件对象判断点击的是哪个子元素。判断“哪个子元素”本身也是一次查询,但这次查询只在我需要时执行一次,开销远小于绑定 N 个监听器。
javascript复制list.addEventListener('click', (e) => {
const card = e.target.closest('.product-card');
if (!card) return;
console.log('点击了商品 id:', card.dataset.id);
});
closest 是一个很实用的查询方法,它从当前元素向上查找,匹配到最近的符合选择器的祖先元素。这里我用它处理点击到了卡片内部文本或图片的情况,保证不管是点在卡片哪个位置,都能拿到正确的商品 id。这比在 render 里绑一堆事件优雅太多,也是我强烈推荐给所有人的模式。
5. 常见问题与排查技巧实录
5.1 为什么我查到的元素是 null?
这是出现频率最高的报错。原因基本就是三个:脚本执行时 DOM 还没渲染完、选择器写错了、元素不在 document 下而在某个 iframe 或 shadow DOM 里。
解决思路很直接:先确认脚本位置。如果 script 标签放在 head 里,并且没有加 defer 或 async,那么浏览器解析到这里时 body 的内容还没生成,document.querySelector 自然查不到任何东西。把 script 挪到 body 底部,或者用 DOMContentLoaded 事件包裹代码,就能解决绝大多数问题。
javascript复制document.addEventListener('DOMContentLoaded', () => {
const box = document.querySelector('.box');
// 此时 DOM 一定可用
});
5.2 querySelectorAll 的结果为什么是空的?
和 null 不同,querySelectorAll 查不到结果时返回一个空 NodeList,不是 null。所以你不能用 if (result) 去判断有没有结果,因为空数组和空 NodeList 都是 truthy,if 恒成立。正确做法是判断 result.length > 0。
我见过有人写:
javascript复制const items = document.querySelectorAll('.item');
if (items) { // 永远为 true,没意义
items.forEach(...);
}
如果希望空集合时不做任何事,直接 forEach 也安全,因为空 NodeList forEach 不会报错。但如果你需要根据有没有元素做分支,一定要用 length。
5.3 选择器里有特殊字符怎么办?
class 或 id 里如果出现点号、冒号、方括号等,在 CSS 语法里是特殊字符,直接传进 querySelector 会解析失败甚至报错。比如一个元素 id 叫 foo.bar,document.querySelector('#foo.bar') 会被理解成“id 为 foo 且 class 为 bar”,结果就错了。正确方式是转义:
javascript复制const el = document.querySelector('#foo\\.bar');
写起来很麻烦,所以遇到这类命名不规范的 id,直接 getElementById 更省心:
javascript复制const el = document.getElementById('foo.bar');
这也是为什么我建议在团队规范里约定 id/class 命名使用字母、数字、连字符和下划线,不引入点号,能少很多问题。
5.4 动态集合遍历时出现死循环
这个坑被问得很多。典型代码如下:
javascript复制const items = document.getElementsByClassName('item');
for (let i = 0; i < items.length; i++) {
// 如果循环内创建了新的 .item 元素
const newItem = document.createElement('div');
newItem.className = 'item';
document.body.appendChild(newItem);
}
因为 items 是动态集合,每追加一个新元素,items.length 就加一,循环永远不会结束。如果业务上确实需要动态集合,就先把长度固定下来:
javascript复制const items = document.getElementsByClassName('item');
const total = items.length;
for (let i = 0; i < total; i++) {
// 基于固定长度的循环
}
但我更建议用 querySelectorAll 代替,静态快照不会让 length 变化,逻辑更安全。动态集合的好处在这个场景里并不明显。
5.5 如何确认自己查到了预期的元素?
调试阶段最直接的方式是用 console 打印出来。但在浏览器控制台里,console.log 一个 DOM 元素会显示成 HTML 标签,不太直观。我习惯用:
javascript复制console.dir(el);
这样会以对象形式展开元素的全部属性和方法,能看到 childNodes、dataset、classList 这些细节。另外,也可以在控制台直接对某个变量输入 $0,这是 Chrome 开发者工具提供的“当前选中的元素”快捷方式,配合元素面板选取非常方便。
还有个小技巧:在 Sources 面板打断点时,Watch 面板里输入 document.querySelector('.box'),能实时看到表达式结果。这样不用写死打印语句,排查选择器问题时效率高很多。
5.6 查询结果返回了但操作没效果?
具体表现是元素找到了,但改样式不生效、绑定事件没反应。这时候先检查你操作的是不是同一个元素,尤其注意 id 唯一性。如果页面里有多个重复 id,querySelector 和 getElementById 都只会返回第一个,而你可能想操作第二个,那自然没效果。
另一种情况是节点被重新渲染了。你之前查询到的引用,在 innerHTML 被重新赋值后,旧引用指向的节点已经被移除,你对它做任何操作都不会体现在页面上。需要重新查询一遍。这也是动态渲染框架如 React、Vue 中不推荐手动改 DOM 的原因,但原生场景下记住“DOM 变了,旧引用可能失效”就够了。
6. 进阶技巧:查询与框架实践
6.1 在React/Vue中什么时候还需要原生查询
很多人写 Vue 或 React 久了,觉得 JavaScript 的 DOM 查询没用了。其实不是。组件内部用 ref 访问 DOM 是更符合框架理念的做法,但在全局事件监听、集成第三方库、操作 Canvas、读取元素尺寸时,你还是需要拿到真正的 DOM 节点。
以 Vue 3 为例,模板 ref 返回的就是元素或组件实例,拿到后依然可以用原生 API 查询其子元素。比如我给某个组件封装图表时,经常会这样:
javascript复制const chartRef = ref(null);
const containerEl = chartRef.value; // 这是 DOM 元素
const parentWidth = containerEl.parentElement.clientWidth;
这里 parentElement 虽然不算复杂查询,但本质上还是 DOM 导航的一种。框架解决的是数据和视图的绑定,不是把你和 DOM 彻底隔离。遇到没有声明式方案的问题,原生 API 还是兜底手段。
6.2 用选择器配合CSS实现无JavaScript的查询效果
有时候我为了减少脚本量,会把一些“状态切换”交给 CSS,JavaScript 只负责切换 class。查询在这种模式下变成了“最小”的:只需要找到触发元素和容器,剩下的都交给样式。比如一个手风琴菜单,点击按钮后,我用 querySelector 找到对应内容区,再 toggle 一个 class,展开收起由 CSS 过渡完成。
javascript复制const trigger = document.querySelector('.accordion-trigger');
const content = document.querySelector('.accordion-content');
trigger.addEventListener('click', () => {
content.classList.toggle('open');
});
这种写法的好处是逻辑简单、状态清晰,也方便后续扩展动画。查询只负责“找到门”,至于门怎么开,完全交给 CSS。这也是我越来越喜欢的组合:尽量少用 JS 控制样式细节,用 class 语义化表达状态。
6.3 使用 Element.closest 解决动态列表的点击查询
前面提到过 closest,这里再深入一点。它非常适合用来处理无限滚动列表、表格行点击这些场景。比如一个表格,点击任意单元格都得知道是哪一行,如果给每个单元格绑事件,内存受不了。正确做法是在 table 上监听点击,然后用 closest('tr') 拿到行。
javascript复制document.querySelector('table').addEventListener('click', (e) => {
const row = e.target.closest('tr[data-id]');
if (row) {
console.log('当前行 id:', row.dataset.id);
}
});
为什么用 tr[data-id] 而不是 tr?因为表格里可能还有表头行,表头没有 data-id。这样选择器天然过滤掉不需要处理的行,比在 JS 里写额外判断更简洁。
closest 的兼容性也很好,现代浏览器完全支持。它本质上是一个从当前节点向祖先方向遍历的查询,和平时向下的查询方向正好相反,但它仍然是查询家族里很高效的一员。
7. 性能优化与最佳实践总结
7.1 查询时机:越晚越好,越少越好
这里的“晚”不是说拖到最后一刻,而是不要在数据还没准备好的时候就查询。比如页面初始化后你的数据是异步加载的,等到渲染完再查询对应 DOM 才有意义。如果过早查询,拿到 null 或者旧节点,后面操作就会出问题。
“少”指的是减少不必要的反复查询,尤其是每次事件处理里都重新查一遍相同的元素。如果元素相对稳定,可以在初始化时缓存。如果元素会随数据变化,那也不能死守缓存,要分清动态和静态的区别。
javascript复制// 静态元素缓存一次
const app = document.querySelector('#app');
// 动态元素每次渲染后重新获取
function render(list) {
app.innerHTML = list;
const cards = app.querySelectorAll('.card');
cards.forEach(...);
}
7.2 组合选择器和层级选择器的性能平衡
document.querySelectorAll('.a .b') 会遍历所有后代节点,如果结构非常深,性能会比直接给每个元素加一个类然后查 .b 要差一些。但现代浏览器优化已经很好了,实际项目中我更看重语义清晰。只有遇到真正性能瓶颈,比如列表超过几千项,才需要考虑给元素添加数据驱动类名来加速查询。
一个可行的优化思路是缩小查询范围。能用 container.querySelector 就不要用 document.querySelector,因为后者的搜索范围是全文档。虽然差距不大,但养成这个习惯会让代码更有边界感,也避免查到你预料之外的节点。
7.3 别在渲染循环里做查询和样式修改
高频渲染最容易导致布局抖动。比如用 setInterval 更新一组数据,每个更新都要修改多个元素的样式,如果每改一个样式就触发一次布局计算,页面就会卡顿。解决办法是把样式修改合并到一次,比如先修改所有元素的类名,再一次读 offsetHeight 来强制刷新,或者干脆用 requestAnimationFrame 包住。
从查询角度看,render 循环里的 querySelectorAll 本身是不会造成重排的,问题出在你读取和修改交错进行。比如你循环里先 el.style.width = '100px',下一行又 el.offsetWidth 读取,这时候为了拿到准确布局,浏览器必须同步计算,性能就掉下来了。所以记住:读和写分开,不要交错。
7.4 一套我自己的代码规范
写到这里,我把自己的 DOM 查询相关规范分享出来,都是实战后沉淀下来的:
- 能用
const就不要用let,防止后续意外改掉引用。 - id 查询用 getElementById,其他统一用 querySelector/querySelectorAll。
- 复杂选择器超过三层时,考虑给目标元素加个语义化 class,别让选择器变成天书。
- 所有可能有空结果的查询,操作前必须判空或判断 length。
- 动态生成的元素,统一用事件委托处理点击,不在渲染内部挨个绑事件。
- 在模块初始化时集中做静态查询,动态部分在每次 render 后重新查询。
- 如果发现同一个选择器被反复使用,抽成一个函数或常量。
这些规范不是死规则,但按它们写出来的代码,无论自己去维护还是交接给别人,都能省很多力。
8. 我踩过的几个典型坑
8.1 把NodeList当真数组用
曾经有个项目要遍历一组复选框并读取 checked 属性,我用 querySelectorAll 拿到输入框后,直接调用了 .map() 方法,结果报错 items.map is not a function。原因就是 NodeList 虽有 forEach,但不支持 map、filter。解决方法是先转数组:
javascript复制const inputs = [...document.querySelectorAll('input[type="checkbox"]')];
const checkedValues = inputs.filter(i => i.checked).map(i => i.value);
这个坑现在我基本不会踩了,但经常看到新人遇到,所以专门提一句。
8.2 动态添加内容后忘了重新查询
早期做一个待办清单,点击删除按钮后,我把数据从数组中移除,重新渲染列表。但删除事件绑在旧节点上,重新渲染后旧节点的引用还指向被移除的 DOM,点击新列表的删除按钮死活不触发。后来我改成事件委托,就是先在列表容器上绑一个事件,再用 closest 找到当前点击的删除按钮的父节点,取 data-id,再更新数据。从那以后我再也没有在动态列表里逐项绑过事件。
8.3 在 iframe 里查询主文档元素
还有一次做后台编辑器,页面里嵌了一个 iframe,我在 iframe 的 script 里写了 document.querySelector('.toolbar'),结果怎么查都是 null。后来才意识到,iframe 内部的 document 和主页面 document 不一样,查询时需要考虑当前上下文。如果要在父页面操作 iframe 里的内容,得先拿到 iframe 的 contentDocument;反过来,在 iframe 里操作父页面,得用 parent.document。跨文档查询的细节很容易被忽略,一旦牵涉到 iframe,先搞清楚“当前 document 到底是哪个”再动手。
8.4 千万不要用 javascript: 协议来执行查询或代码
很多老旧的网页里能看到 javascript:void(0) 或 javascript:; 这种写法,用来阻止链接默认跳转。这实际上是在地址栏或 href 里执行 JavaScript,既不规范,也存在安全隐患,容易被恶意注入。现代开发中完全不该使用,应该用事件处理函数里 preventDefault() 代替。如果你是在维护老代码里看到这种写法,请务必找到机会替换掉,避免成为安全漏洞的入口。
9. 写在后面的经验之谈
我把这些内容整理下来,其实也是对自己日常编码习惯的一次梳理。DOM 查询看着简单,但很多老手偶尔也会被动态集合、静态集合、跨文档查询这些边角问题绊住。我的体会是,不要背 API 列表,而是要清楚“什么时候该用哪个”,并且理解每个 API 返回的数据结构是什么、生命周期是什么。这两点打通了,遇到再怪的查询需求也能自己推导出方案。
如果你最近正在做一个需要频繁操作元素的项目,建议你把自己用到的查询方式列一个表,标记哪些是静态的、哪些是动态的,哪些是每次渲染后需要重新获取的。这种“事先建模”的习惯,能让你少写很多 bug。
之后如果有机会,我再把更进阶的 DOM 导航、属性操作、节点增删、性能优化这些内容单独写一写。这一篇就先到这里,希望对你有实在的帮助。
