前端开发里,“获取div中的span元素”这件事,听起来简单得像刚学HTML时的课后练习,可我在实际项目里见过太多在这上面翻车的人。有的拿到的是空集合,一脸懵;有的拿到的还是上一轮渲染留下的旧元素,怎么查都是上次的状态;还有的在Vue组件里写了一大堆document.getElementById,却不知道有ref这回事。说白了,span只是最常见的HTML标签,但“获取元素”这个动作背后,牵扯到原生DOM API、CSS选择器语义、动态渲染时机、框架数据绑定等一系列问题。这篇文章就围绕这一件小事,把常用的写法、他们之间的差异、常见坑和调试方法串一遍。适合刚接触DOM操作的前端新人,也适合写了一阵子业务但没系统梳理过这些API的开发者。
1. 原生方法:四种写法,先分清谁是“活的”谁是“快照”
1.1 按标签名硬取:getElementsByTagName 的动态陷阱
在原生JavaScript里,最传统的写法是:
const container = document.getElementById('container'); const spans = container.getElementsByTagName('span'); console.log(spans.length);这段代码会把container内所有后代span元素找出来,不管嵌套多深,只要是span标签就算。它返回的不是数组,而是一个HTMLCollection,这个集合最大的特点是动态的。
“动态”意味着什么?意思是只要DOM发生了变化,这个集合的内容会自动更新。比如你拿到spans的时候页面上有3个span,之后往container里又插入了1个span,再访问spans.length,它已经是4了。这个特性在某些场景下很省事,但在遍历的同时修改DOM,就容易出大问题。最常见的例子:
const spans = container.getElementsByTagName('span'); for (let i = 0; i < spans.length; i++) { if (spans[i].textContent === 'remove me') { spans[i].parentNode.removeChild(spans[i]); } }如果你真的这么写,会发现有些该删的span没删掉。因为每删一个,spans集合的长度就变小,索引也跟着变,循环走着走着就跳过了某些元素。我一开始也踩过这个坑,后来养成了习惯:只要是拿HTMLCollection做遍历,先转成数组再操作。
Array.from(spans).forEach(span => { // 可以安全删除或修改 });1.2 更推荐的选择器:querySelector 和 querySelectorAll
如果你问我现在实际项目里用哪个,我基本都会说querySelector和querySelectorAll。它们允许你用完整的CSS选择器语法来定位元素,可读性和扩展性都更好。
// 取 container 内第一个 span const firstSpan = container.querySelector('span'); // 取 container 内所有 span const allSpans = container.querySelectorAll('span'); // 取 container 内带有 class="item" 的 span const itemSpans = container.querySelectorAll('span.item'); // 取 container 内直接子元素中的 span const directChildSpans = container.querySelectorAll(':scope > span');这里有个新手容易犯的错:querySelector和querySelectorAll接受的是CSS选择器,所以空格不是装饰,而是“后代”的意思。#container span和#container > span是完全不同的两回事。前者匹配所有后代元素中的span,后者只匹配container的直接子元素span。
querySelectorAll返回的是NodeList,在现在的浏览器里,NodeList支持forEach,也可以被for...of遍历,用起来比HTMLCollection顺手得多。还需要注意,querySelectorAll返回的是一个静态快照,你查询完之后,即使页面上的DOM再变化,这个NodeList也不会自动同步。这在单页应用里是好事也是坏事,好事是循环不会因为DOM变化而出错,坏事是如果你一直持有这个集合,它不会自动变成最新状态。所以,动态列表变化频繁时,最好在需要的时候重新查询,而不是把查询结果缓存到全局变量里。
1.3 直接子元素还是所有后代:children / childNodes 的边界
有时候需求不是“找到div里的所有span”,而是“找到div的直接span子元素”。这时候除了用选择器,还可以用children属性。
const container = document.getElementById('container'); const directChildren = container.children; // 所有元素子节点children返回的是元素节点的集合,不会包含文本节点、注释节点。与之相对的childNodes返回的是所有子节点,包括文本和注释。
<div id="container"> 这里有文本 <span>A</span> <!-- 注释 --> <span>B</span> </div>这段HTML里,container.childNodes的长度可能是5(文本、span、注释、span、可能还有换行带来的文本节点),而container.children的长度是2。如果你只是想找span元素,用children再过滤就行:
Array.from(container.children).filter(el => el.tagName === 'SPAN');不过大多数情况下,container.querySelectorAll(':scope > span')更直接。这里多说一句,querySelectorAll在普通元素上使用:scope,表示以这个元素自身为锚点,配合>就能实现“直接子元素”的效果。如果不用:scope,你直接写container.querySelectorAll('> span')是无效的,因为:scope就是用来表示“当前调用的元素”。
2. 什么都没拿到?先查三个地方
2.1 脚本位置和 DOMContentLoaded:最常见的空集合原因
一个非常常见的问题:代码明明没写错,可执行的时候就拿不到元素。你第一反应是怀疑选择器,但很多时候问题出在脚本执行时机上。
看这个例子:
<!DOCTYPE html> <html> <head> <script> const container = document.getElementById('container'); const spans = container.querySelectorAll('span'); console.log(spans.length); // 0,甚至报错 </script> </head> <body> <div id="container"> <span>1</span> <span>2</span> </div> </body> </html>浏览器解析HTML是自上而下的,当head里的脚本执行时,body里的div都还没被解析到,getElementById('container')得到的只能是null,再往下就直接报错了。解决办法有三种:
- 把脚本挪到
</body>之前,让DOM先解析完; - 给
<script>加上defer属性,让脚本在DOM解析完成后执行; - 监听
DOMContentLoaded事件,在回调里再操作DOM。
其中defer是现在比较推荐的方式,它不会阻塞页面解析,又保证了脚本在DOM就绪后执行。如果你写的是模块脚本(type="module"),它默认就会延迟执行,所以不太需要担心这个问题。
2.2 动态渲染:Vue 里数据没到,DOM 里自然没有
很多前端新人从静态页面转到Vue或React之后,会带着原来的习惯直接操作DOM。比如在Vue组件的mounted里写:
mounted() { const spans = document.querySelectorAll('#container span'); console.log(spans.length); }如果这个#container是靠接口数据渲染出来的,v-for循环的数据还没返回,那么mounted执行时DOM里根本没有对应的span。这不是你选择器的问题,而是渲染时机的问题。在Vue里,数据更新之后DOM的变更不会立刻生效,往往要等nextTick。
async mounted() { this.list = await fetchList(); await this.$nextTick(); const spans = document.querySelectorAll('#container span'); console.log(spans.length); }当然,如果你完全通过数据驱动来展示,根本不需要去查DOM。但在某些必须要操作真实DOM的场景(比如封装第三方图表库、手动聚焦、读取元素尺寸),nextTick就是你的好朋友。
2.3 静态集合和动态集合:长期缓存查询结果会出问题
这一节我在前面已经提到,但值得单独拎出来说,因为它真的太容易踩坑了。
getElementsByTagName、getElementsByClassName返回的是HTMLCollection,是动态的,会跟着DOM变化自动增删元素。querySelectorAll返回的是NodeList,在目前实现里是静态的,拿到手之后不会再变。这两种行为差异,在实际业务中影响很大。
比如你在一个长列表页面里写了一个搜索框,用户输入关键词之后,列表里的span会被重新渲染。如果你在初始化时保存了一次querySelectorAll的结果,那么搜索之后这个集合还是旧的,里面的DOM元素可能已经不在页面上了。这时候如果你还去读它的textContent,拿到的可能是“幽灵数据”。
我的习惯是:用完就扔,随时需要随时查。除非有明确的性能优化需求,不要长期持有查询结果。如果必须监听DOM变化来更新引用,用MutationObserver。
const observer = new MutationObserver(() => { updateSpanList(container.querySelectorAll('span')); }); observer.observe(container, { childList: true, subtree: true });这样每次DOM结构变化后,都会重新查询一次,保证引用里面永远是最新的span列表。
3. jQuery 老项目里的写法:选择器语义不能凭感觉
3.1 后代选择器和子元素选择器:一个空格引发的血案
虽然现在新项目基本不用jQuery了,但老项目维护、开源插件二次开发时,你还是会频繁遇到。jQuery里获取span主要靠$函数和选择器:
// 所有后代 span $('#container span'); // 直接子元素 span $('#container > span');这两段代码的区别和原生CSS选择器完全一致,但实际开发中见过好几个人把>漏掉,然后发现拿到的元素比预期多。记住一句话:空格是“后代”,大于号是“儿子”。
另外,jQuery里还经常用.find()和.children():
$('#container').find('span'); // 等价于 $('#container span') $('#container').children('span'); // 等价于 $('#container > span').find()是在所有后代里找,.children()只找一层子元素。如果你在写插件,不想依赖具体选择器结构,用这两者组合会更清晰。
3.2 first 和 :first-child:看着像,实际不是一回事
热词里有个“jquery 第一个子元素”,很多新手会写成$('#container span:first-child'),然后发现有时候好用,有时候不好用。原因很简单:
$('#container span')匹配到的是多个span的集合;.first()是取这个集合里的第一个,属于集合操作;:first-child是CSS伪类,它匹配的条件是“这个元素必须是它父元素的第一个子节点”。
看这段HTML:
<div id="container"> <p>段落</p> <span>第一个 span</span> <span>第二个 span</span> </div>$('#container span:first-child')返回的是空,因为这个span不是container的第一个子元素,第一个是p。而$('#container span').first()返回<span>第一个 span</span>。所以,想取集合里的第一个,用.first();想取每个父元素下的第一个span子元素,用:first-child。两个需求别搞混。
3.3 获取 jQuery 集合中的原生 DOM 元素
还有一个很常见的困惑:$('#container span')返回的到底是个什么东西?
它是一个jQuery对象,内部封装了匹配到的DOM元素数组。你可以像数组一样用索引去取原生DOM元素:
const spans = $('#container span'); const firstNativeSpan = spans[0]; console.log(firstNativeSpan.textContent);也可以用.get(index):
const firstNativeSpan = spans.get(0);但要注意,spans[0]是原生DOM元素,没有jQuery的.text()、.css()等方法;想用jQuery方法处理单个元素,可以.eq(index):
spans.eq(0).css('color', 'red');这里eq返回的还是jQuery对象,可以继续链式调用。搞清楚这三者的区别,老项目维护能少踩很多坑。
4. Vue 场景:能不用 DOM 查找就不用,非要用就 ref
4.1 给元素挂 ref,然后直接读 $refs
在Vue里,我见过最“暴力”的写法是直接在组件里写document.getElementById。不能说不能用,但一旦组件被复用、组件位置变化,全局ID冲突的风险就来了。Vue本身提供了ref,用途之一就是让你在组件内部安全地拿到某个DOM元素。
模板里:
<template> <div ref="container"> <span>内容</span> </div> </template>Options API里:
this.$refs.container.querySelector('span');Composition API(<script setup>)里:
import { ref, onMounted } from 'vue'; const container = ref(null); onMounted(() => { const span = container.value.querySelector('span'); });注意,container变量名要和模板里的ref="container"保持一致。ref绑定在普通DOM元素上,对应的值就是该DOM元素;绑定在子组件上,对应的就是子组件实例。
4.2 v-for 循环里的 ref:拿到的不一定是数组
如果你在v-for里给span也加了ref:
<div id="list"> <span v-for="(item, index) in items" :ref="'span' + index" :key="index"> {{ item }} </span> </div>在this.$refs.span0就能拿到对应的span元素。如果用的是同一个ref名字,比如:ref="'spanItem'",那this.$refs.spanItem会是一个数组,里面是按渲染顺序排列的DOM元素。
这里有个细节,this.$refs.spanItem的顺序通常和items数组顺序一致,但如果你对列表做了过滤、排序,或者有v-if参与控制,顺序不一定可靠。最稳妥的方式还是在v-for里用:key来标识,并通过:ref生成带索引的名字。
还有一个常见的问题是:v-for渲染的数据是异步获取的,在mounted里访问this.$refs.span0可能是undefined。因为此时数据还没到,列表还没渲染出来。需要在拿到数据后,使用await this.$nextTick(),或者直接监听数据变化后再访问。
4.3 事件处理时用 $event 获取当前 span:更快更稳
为了获取一个span,去全局查找其实绕了远路。如果你只是想在点击某个span时拿到它,直接在模板里绑定事件,然后在回调里取$event.currentTarget,这是更符合框架习惯的做法。
<template> <div> <span @click="handleClick">点我</span> </div> </template>function handleClick(event) { const span = event.currentTarget; // 这里的 span 就是绑定了 @click 的元素 console.log(span.textContent); }这里为什么用currentTarget而不是target?currentTarget是当前事件监听器所绑定的元素,也就是那个span;而target是事件真正发生的源元素。如果span里面还嵌套了<em>或者<b>,点击到子元素时,target会是子元素,currentTarget仍然是span。你如果非要用target,记得加closest:
const span = event.target.closest('span');这样即使点击的是span内部的文本节点或嵌套标签,也能找到对应的span。
4.4 别混了:HTML 的 span 和 OpenTelemetry 的 Span 是两码事
搜索“span元素”的时候,偶尔会看到“OpenTelemetry span”之类的词混进来。它们的名字一样,但完全不是一个领域的东西。
HTML里的<span>是行内元素,用来包裹文本、给局部内容做样式;OpenTelemetry里的Span是链路追踪中的一个基本单元,代表一个操作片段,包含名称、开始时间、结束时间、属性等。前端代码里如果引入了@opentelemetry/api,你可能会写span.end()来结束一个追踪段,这和操作DOM里的<span>标签没有任何关系。
所以如果你在帖子或者搜索结果里看到“span操作”这几个字,先确认上下文是DOM还是监控。搞混了会写出很奇怪代码,比如把document.querySelector('span')拿出来的元素当成追踪对象去调用.end(),控制台直接报错。
5. 进阶场景:iframe、Shadow DOM 和 XPath 查法
5.1 iframe 里的 span:先拿 contentDocument
如果div不在当前页面文档,而在一个iframe内部,用普通的querySelector是拿不到的。得先获取iframe的document。
const iframe = document.getElementById('myIframe'); const iframeDoc = iframe.contentDocument; const spans = iframeDoc.querySelectorAll('div span');这里有个硬性限制:跨域情况下,浏览器会因为同源策略阻止你访问contentDocument,得到null。你的页面和iframe里的页面如果不在同一个域名、协议、端口下,除非iframe允许跨域访问(比如通过postMessage通信),否则拿不到里面的元素。实际操作中,跨域iframe内部的DOM操作基本做不了,只能在iframe内部页面里执行脚本,再通过postMessage把结果传出来。
5.2 Shadow DOM 里的 span:普通选择器穿透不了
在使用Web Component或一些封装组件库时,元素可能被放进Shadow DOM里。比如一个自定义组件的内部结构:
<my-widget> #shadow-root (open) <div> <span>shadow span</span> </div> </my-widget>此时在页面顶层执行document.querySelector('my-widget span')是找不到的,因为普通CSS选择器不会穿透shadow边界。你需要先拿到shadow host元素,再访问它的shadowRoot:
const host = document.querySelector('my-widget'); const shadowRoot = host.shadowRoot; const span = shadowRoot.querySelector('span');注意,shadowRoot只有在mode: 'open'模式下才可以通过外部访问到。如果组件创建时用了closed模式,host.shadowRoot会是null,那就真的拿不到了。所以遇到“明明页面上有span,就是查不到”的情况,看看是不是在shadow DOM里。
5.3 XPath 和 TreeWalker:非主流但偶尔好用
如果CSS选择器写得很痛苦,或者你想按轴关系(比如“找当前div后面所有span”)来获取,可以试试XPath。
const result = document.evaluate( '//div//span', document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null ); for (let i = 0; i < result.snapshotLength; i++) { console.log(result.snapshotItem(i)); }这段代码会返回整个文档里所有div里的span。XPath的//div//span对应CSS的div span,//div/span对应div > span。XPath很强大,但日常业务代码里用它反而增加维护成本,更多出现在爬虫或自动化测试脚本里。你只需要知道有这个方法,真遇到复杂定位时能想到它就行。
还有一个偏底层的是TreeWalker,它可以在DOM树上自由行走,比如遍历所有元素节点,判断tagName === 'SPAN'再收集。性能和灵活性都还行,但代码量不小,一般场景不值得。
6. 调试与避坑:控制台技巧和一份问题速查表
6.1 控制台三步定位法
我遇到“span获取不到”的问题时,不会干瞪眼,直接在浏览器控制台按顺序查:
- 先在Elements面板里手动选中那个div容器,然后在控制台输入
$0,回车。$0代表当前在Elements面板中选中的元素,这个特性在Chrome和Edge里都支持。 - 输入
$0.querySelectorAll('span'),看看命中了什么。如果返回空,说明div下面确实没有span,或者span在更深的嵌套里,再考虑是不是shadow DOM/iframe的问题。 - 如果选择器很复杂,拆开测试。比如先查
$0.querySelectorAll('div'),看看有多少div,再多一层层往下查,利用控制台补全提示确认类名、id拼写。
控制台里还有快捷函数$$('div span'),等价于document.querySelectorAll('div span'),少打很多字。$$在Elements面板和Console面板都能用,不过它是浏览器控制台提供的高级功能,不要在页面代码里依赖它。
6.2 常见问题速查表
下面这份表格是我自己整理出来的,碰到问题可以直接对着查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
container是null | 当前DOM中没有对应id,或脚本执行过早 | 检查id拼写;把脚本放到DOM就绪后;用DOMContentLoaded |
querySelectorAll返回空的NodeList | 选择器层级不对,或者元素在v-if未渲染区域 | 检查CSS选择器空格和>;用nextTick等待渲染 |
getElementsByTagName遍历时漏删元素 | HTMLCollection动态更新,索引变化 | 先把集合转成数组再遍历 |
| 能查到一个span,但修改后页面没变化 | 可能操作的是静态快照,不是最新DOM | 确认是不是querySelectorAll的结果被缓存;重新查询 |
| iframe内部查不到 | 跨域或被contentDocument安全策略限制 | 在iframe内部执行脚本,通过postMessage传数据 |
| shadow DOM里的span查不到 | 普通选择器无法穿透shadow边界 | 先拿shadowRoot再查内部 |
$('#container span:first-child')取到的不是第一个span | 伪类:first-child要求元素是父元素的第一个子节点 | 改用.first()或:first-of-type |
6.3 三个独家避坑经验
第一个经验是关于“元素集合的生命周期”。在单页应用里,列表数据一变,DOM元素可能全部重建。如果你在页面初始化时用一个全局变量保存了所有span的引用,等数据更新后,这些引用可能指向已经不在文档里的“孤儿节点”。这不是API的bug,而是静态快照和动态页面的天然矛盾。我的做法是尽量让DOM查询发生在事件回调或者异步数据到达之后,而不是组件初始化时,更不要长期缓存查询结果。
第二个经验是“事件委托比给每个span绑定事件更稳”。如果你要为一组span添加点击事件,别一个个去绑定:
spans.forEach(span => { span.addEventListener('click', handler); });更好的做法是把监听器挂在容器上,利用事件冒泡:
container.addEventListener('click', (e) => { const span = e.target.closest('span'); if (span && container.contains(span)) { handler(span); } });这样做的好处是,以后新增的span不用再单独绑定事件了,只要它们在容器内就能自动被处理。
第三个经验是关于选择器性能的。我以前也纠结过getElementsByTagName比querySelectorAll快多少。实测下来,在几百个元素的页面里,性能差异微乎其微。真正影响性能的反而是频繁的全局查询和布局抖动。所以我的建议是:优先写querySelectorAll,代码可读性更好;只有在明确了热点路径、确实需要极致优化时,才去考虑用getElementsByTagName这种底层集合。大多数业务瓶颈不在这种毫秒级差异上。
7. 写在最后:方法论比 API 细节更重要
如果你完整看到这里,应该已经发现,“获取div中的span元素”这个需求本身不难,难的是在不同场景里选择合适的获取方式,并且搞清楚返回结果到底是动态还是静态、是当前页面还是iframe、是普通DOM还是shadow DOM。
我个人在实际项目中的体会是:原生API里,querySelectorAll是默认选项;jQuery老代码里记住“后代、子元素、.first()和:first-child”的区别;Vue里尽量用ref和事件参数,少用全局查找。遇到拿不到元素的问题,不要急着怀疑代码,先按顺序查三件事:DOM渲染出来了吗?选择器语义对吗?元素是不是在同一个文档树里?这三步能解决我遇到的90%问题。剩下的10%,多半是时序问题和旧缓存引用,耐心断点调试一下,都会有答案。