先说个我真实遇过的场景。有位刚入职的同事提交了一个弹窗组件,模板里写的是<div class="modal-mask"/>,他认为“标签里没内容,自闭合天经地义”。结果页面一打开,整个弹窗后面的兄弟节点全部被吞进了这个 div,样式全乱、点击事件全部失效,最后定位到根因就是这四个字符:/>。从那时起我就发现,很多前端新人不是不知道“空元素”这个词,而是完全没搞懂它在 HTML 规范里的准确定义,以及自闭合在不同环境下的行为差异。
这篇文章就把“空元素”一次讲透:哪些标签是真正的空元素、自闭合标签和空元素到底什么关系、新人最容易在哪些场景翻车。后面还会结合 React/Vue 的空渲染、JS 判空、面试答题框架和一张避坑自查表,把“空”这个话题从 HTML 延伸到日常开发的全链路。内容偏实战,适合刚入行的前端,也适合准备面试的初级中级同学,建议收藏后边看边试。
1. 先把“空元素”这顶帽子和 HTML 规范对齐:谁是真空,谁是假空
1.1 空元素完整清单:规范里“不能有内容”的标签就这些
HTML 规范里有个专门的术语叫 void elements,中文翻译就是“空元素”。注意这里的“空”不是指元素当前有没有内容,而是规范上禁止它有内容——它不能包裹文本,不能包裹子元素,也没有结束标签。这是从 HTML 4 DTD 时代就定下的规则,和 CSS、JavaScript 没有关系。
完整的 void elements 清单如下,来自 HTML Living Standard:
- 资源与媒体类:
img、embed、source、track - 表单与交互类:
input、area、col - 换行与分割类:
br、hr、wbr - 文档元数据类:
base、link、meta - 参数传递类:
param
记忆上可以分组记:<br>换行、<hr>分割线、<wbr>软换行、<img>图片、<input>输入框、<link>引入样式、<meta>元信息、<source>给 video/audio 提供资源、<track>给 video 提供字幕、<embed>嵌入外部内容、<area>定义图片热区、<col>定义表格列属性、<base>设置基础 URL、<param>给插件传参。
如果你去翻 HTML 4 时代的历史文档,还能看到basefont、bgsound、frame、keygen这些已经被移除或废弃的 void element。现在写业务代码用不到,但面试时提一嘴能显得知识体系完整。
一个看似冷门但很实用的验证方法:想知道某个标签到底是不是空元素,直接看这个元素的“内容模型”(content categories)。MDN 每个标签页都有这个字段,写着 “None” 的,就是 void elements。比如div的内容模型是 Flow content,所以它是可以有子元素的普通元素;而img的内容模型就是 None,它是真正的空元素。
1.2 自闭合标签不等于空元素:四个环境的差异一张表讲清
新人混淆的根源,是把“自闭合标签”和“空元素”当成同一个东西。实际上这是两个维度的事:
- 空元素是规范定义的元素类别,决定它能不能有内容。
- 自闭合是一种书写方式,用
/>收尾。
同一个<div/>,在不同解析环境里的行为完全不一样。这是整篇内容里最值得先记住的一张表:
| 环境 | 解析行为 | 结论 |
|---|---|---|
| 纯 HTML / 浏览器解析器 | HTML5 tokenizer 会忽略开始标签末尾的/,把<br/>当成<br>;对<div/>也只当作<div>开始标签,后续节点会被吞进去 | 空元素写不写/都行,非空元素严禁自闭合 |
| XHTML / XML 文档 | 所有标签必须闭合,空元素必须写成<br/>,非空元素必须写成<div></div> | XML 语法下自闭合是强制的 |
| JSX(React) | JSX 语法要求所有标签闭合,<div />合法,编译成 React.createElement 时会自动补全 | 组件和原生标签都可以自闭合 |
| Vue SFC 模板 | 编译器会把<div/>自动修正为<div></div>,模板不会崩,但新手容易误以为 HTML 里也安全 | 写法上可以,但别复制到 HTML 环境 |
纯 HTML 里<div/>为什么会出问题?因为 HTML 的 tokenizer 读到标签名后,会继续解析属性。它确实有一个“自闭合标志”的概念,但 HTML5 语法明确规定了:只有 void elements 能带这个标志,非 void elements 遇到/>时,这个斜杠会被当作 parse error 并直接丢弃。标签仍然是“未闭合的开始标签”,浏览器只能继续往后找结束标签,直到遇到</div>或文档结束。
我用 innerHTML 做个快速验证:
const div = document.createElement('div'); div.innerHTML = '<div class="a"/><p>后面的兄弟</p>'; console.log(div.innerHTML); // 实际输出:<div class="a"><p>后面的兄弟</p></div>看明白了吗?<p>变成了<div>的子节点,整个结构被改写。如果你在原生 HTML、字符串模板、后端返回的 HTML 片段、innerHTML 拼接入参里看到<div/>、<span/>、<li/>这类写法,基本可以断定是 bug 温床。
2. 前端新人最容易翻车的四个空元素现场
2.1<div/>的灵魂失踪:把非空元素写成自闭合的后果
很多新人是在写模板时养成的习惯。Vue、React、小程序里,组件确实可以<MyComponent />这样写,编译器会帮你处理。但这个习惯一旦被带进 HTML 环境,就很容易炸。
有一个常见场景是后端渲染模板,比如 Python 的 Jinja2、Java 的 Thymeleaf,里面拼 HTML 片段,写了个<div class="mask"/>,你原本想要一个空的遮罩层。结果页面上所有遮罩层之后的 DOM 全部变成了这个 div 的内容,遮罩层的背景色、定位属性作用到了整片页面,滚动条还莫名失效。
排查这种问题最快的办法,不是盯着源码看,而是打开 DevTools 查看解析后的 DOM 树:
document.querySelector('.mask').outerHTML;如果输出的结果是<div class="mask">...一大串后续节点...</div>,那基本就是这个原因。修复方式也简单:非空元素永远写显式的开始和结束标签,<div class="mask"></div>。空元素则按规范来,不写结束标签:<img src="x.png">而不是<img src="x.png"></img>——</img>属于结束标签,HTML parser 看到后会直接忽略。
2.2:empty匹配不到“看起来空”的元素:空格和换行都是元凶
CSS 伪类:empty的规则很严格:它匹配的是没有任何子节点的元素,包括文本节点。这里最坑的地方是——空格、换行、制表符这些空白字符,在 DOM 里会被当成文本节点。
我之前写过这样一段样式:
.empty-tip:empty { display: none; }模板返回:
<div class="empty-tip"> </div>看起来是空的,对吧?但中间包含一个换行符,这个换行就构成了文本节点,:empty不会匹配。样式完全不生效。
另一个坑是注释节点。按照 CSS Selectors Level 4 的新规范,注释节点不再影响:empty的判定,但各个浏览器的实现历史不一致,为了保险我一般建议:空状态元素的标签里不要放任何东西,包括注释和空白。要写成<div class="empty-tip"></div>,而不是:
<div class="empty-tip"> <!-- 暂无内容 --> </div>如果你用格式化工具,经常自动把标签拆成多行,就容易踩这个坑。我自己现在写空状态判断,更倾向于用:not(:empty)做有内容时的样式,把空态兜底交给min-height或者 JS 判断,少跟空白文本节点较劲。
顺便说一句,<input>、<img>这类 void element 本身是“空”的,但:empty对它们没有实际意义,因为input的 value 并不算子节点,img的 src 也不算,你没法用:empty判断输入框有没有值。判断表单空值请老老实实用 JS。
2.3input和img的伪元素陷阱:为什么::before不生效
这是 CSS 里一个非常典型的知识盲区。img、input、object、embed、textarea、select、video、iframe这类标签属于替换元素(replaced elements),它们的内容由外部资源或表单控件替代,而不是由普通子节点组成。CSS 规范中,::before和::after是在元素内部的开头和结尾生成内容,替换元素内部没有这个位置,所以默认情况下伪元素不生效。
有同学想在 input 前面加个搜索图标,直接写:
.search-input::before { content: ''; background: url(./search.svg); }结果图标怎么都不显示。正确做法是包一层容器,把伪元素加在容器上:
<div class="search-box"> <input class="search-input" type="text" placeholder="搜索"> </div>.search-box { position: relative; } .search-box::before { content: ''; position: absolute; left: 10px; top: 50%; width: 16px; height: 16px; transform: translateY(-50%); background: url(./search.svg) no-repeat center / contain; } .search-input { padding-left: 34px; }img的伪元素更特殊。在 Chrome 中,当图片加载失败时,浏览器可能会用伪元素来展示 alt 文本,但这是浏览器私有的降级行为,不能当作跨浏览器特性来依赖。如果你想给图片做加载失败的占位效果,稳妥方案仍然是在外层包容器,把背景、边框、伪元素全部放在容器上。
2.4 flex 布局里的空元素:它真的会占位
很多新人不理解 flex 容器里的空元素会占据空间。因为 flex 布局中,文本节点和元素节点都会被包装成匿名 flex item——只要它存在于 DOM 树里,哪怕内容是空的、没有宽高,也会参与布局计算。
这既是坑也是技巧。常见的坑是模板里两个标签之间换行产生的空白文本节点,在某些情况下会造成莫名的间距。比如:
<div class="btn-group"> <button>确定</button> <button>取消</button> </div>如果两个 button 之间有换行,flex 容器里就会多一个宽度为 0 的匿名 flex item,虽然多数时候肉眼无感,但结合gap或justify-content: space-between时,会出现诡异的对齐偏差。排查时可以把换行去掉,或者给子元素设置margin而不是依赖容器gap。
技巧则是反向利用:空元素可以做 flex 占位,实现双端对齐的按钮组:
<div class="footer-bar"> <button class="confirm">确认</button> <span class="spacer"></span> <button class="cancel">取消</button> </div>.footer-bar { display: flex; } .spacer { flex: 1; }这样确认按钮靠左、取消按钮靠右,中间空元素自动填充剩余空间,比给取消按钮写死margin-left: auto更灵活。在移动端底部操作栏里这个方案很常用,而且省掉了一个原本必须存在的包裹层。
3. 把“空”用起来:占位、空状态与判空兜底的真实方案
3.1 零 DOM 图标与占位符的实现
理解了伪元素和空元素的关系之后,“空”就不再是 bug 来源,而是优化 DOM 结构的手段。最常见的做法是:用元素的伪元素来实现图标,而不额外创建<i>、<span>这类标签。
以 loading 图标为例,常规写法可能是:
<div class="loading"> <span class="loading-icon"></span> 加载中... </div>用伪元素可以省掉那个 span:
<div class="loading">加载中...</div>.loading::before { content: ''; display: inline-block; width: 16px; height: 16px; margin-right: 8px; vertical-align: -3px; border-radius: 50%; border: 2px solid #ddd; border-top-color: transparent; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }这样组件模板干净很多,也减少了 DOM 层级。类似的还有表单校验的 “勾” 和 “叉” 图标、列表项的箭头、面包屑的分隔符,都推荐用伪元素实现。记住:伪元素生成的内容默认是内联元素,要用display: inline-block或block赋予尺寸才能显示背景图。
3.2:empty与:has()联动,让空状态样式自动出现
:empty的正确打开方式是做数据列表的空状态。一个列表容器本身是动态渲染的,数据为空时它就是一个没有子元素的空标签,这时候可以直接用 CSS 让空状态自动出现:
.list { min-height: 160px; position: relative; } .list:empty::after { content: '暂无数据'; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #999; font-size: 14px; }这样数据为空时自动展示“暂无数据”,有数据时伪元素自然消失,不用在 JS 里写一堆if (list.length === 0)的模板分支。不过要注意:伪元素里的文案不能依赖它做无障碍和 SEO,部分读屏器不一定读 content 属性,核心提示文案最好还是用真实文本加视觉隐藏。
:has()可以组合出更高级的空态判断,比如某个模块没有图片时自动隐藏装饰边框:
.article-card { border: 1px solid #eee; } .article-card:not(:has(img)) { border: none; padding: 0; }这个能力依赖浏览器对:has()的支持,Safari 在 16.4 之后才对基础用法有稳定支持,桌面软件和 C 端网页可以视目标浏览器决定要不要用。
3.3 JS 判空:[]和{}都是真值,不能靠typeof一把梭
从“空元素”延伸到“空的判定”,是前端面试和实际开发都绕不开的点。新人最容易犯的错是把空数组、空对象当 falsy 处理:
if (data) { // 以为 data 是空数组就不会进这里 }实际上[]是 truthy,{}也是 truthy。Boolean([]) 的结果是 true,因为对象在 ToBoolean 时一律转成 true,只有null、undefined、0、NaN、''、false这六个值才是 falsy。
一个相对完整的判断空值函数,我建议这样写:
function isEmpty(value) { if (value === null || value === undefined) { return true; } if (typeof value === 'string') { return value.trim().length === 0; } if (Array.isArray(value)) { return value.length === 0; } if (value instanceof Map || value instanceof Set) { return value.size === 0; } if (typeof value === 'object') { // Date、RegExp、Promise 等类型不算“空对象” if (value instanceof Date || value instanceof RegExp) { return false; } // Reflect.ownKeys 能拿到不可枚举属性和 Symbol 键 return Reflect.ownKeys(value).length === 0; } return false; }几个值得注意的边界:
Object.keys(new Date())的结果是空数组,直接用Object.keys(obj).length === 0判断会把 Date 误判成空。Reflect.ownKeys能检查到不可枚举属性和 Symbol 键,比Object.keys更严谨。Object.create(null)创建的对象没有原型链,不能直接调用.hasOwnProperty(),用Reflect.ownKeys可以规避。
4. Vue 和 React 里的空渲染:注释节点、0 和空数组别被 F12 骗了
4.1 React 中{0 && <组件/>}会在页面上渲染一个 0
这是 React 社区维护了多年的经典坑。新人写条件渲染时,很喜欢用&&短路:
function OrderList({ orders }) { return ( <div> {orders.length && <OrderItem data={orders} />} </div> ); }当orders是空数组时,orders.length等于 0,表达式变成0 && (...),短路后的结果是0。React 会把数字 0 渲染成文本节点,页面上多出一个孤零零的 “0”。
正确的写法是:
{orders.length > 0 && <OrderItem data={orders} />} // 或者 {orders.length ? <OrderItem data={orders} /> : null}React 渲染假值的规则是:null、undefined、false、true不渲染任何内容,但0、NaN、空字符串''会被渲染成文本。NaN会渲染成 “NaN”,空字符串通常看不见但会在 DOM 里留下一个空文本节点。这条规则在传参时也适用,接口请求参数里,count如果为 0,if (params.count)会直接把 0 吞掉,导致参数丢失,这也是“0 与空”的经典业务坑。
4.2 Vue 的v-if="false"会留下<!---->,这是设计不是 bug
如果你写 Vue 3,打开 DevTools 看到一堆<!---->注释节点,先别慌。这是v-if在条件为 false 时留下的锚点注释。Vue 的 diff 算法依赖这些锚点来标记可复用的位置,从而在条件切换时更精准地移动或删除节点。它不是页面污染,而是框架工作的正常痕迹。
React 里条件渲染返回null时不产生任何 DOM 节点,F12 里是干净的;Vue 则会留一个注释节点。两种处理方式没有优劣,只是机制不同。但新人排查问题时,如果看到 DOM 里的<!---->,很容易误以为是脏数据。如果你实在不想看到注释节点,可以用v-show,但v-show的原理是display: none,DOM 仍然保留,不会移除节点,两者适用场景不一样:
- 频繁切换显示状态,用
v-show更省性能。 - 初始条件为 false 且后期很少切换,用
v-if更合适。
4.3 空数组 map、骨架屏占位数组和 key 的注意事项
React 或 Vue 里对一个空数组做.map/v-for,不会渲染任何子节点,这是正常行为。但如果数组来自接口,经常需要先过滤掉空值:
const filtered = list.filter(Boolean); // 或者过滤掉 null 和 undefined const filtered = list.filter((item) => item != null);另一个相关场景是骨架屏。想渲染三行占位,可以用Array.from({ length: 3 })生成空数组再 map:
{Array.from({ length: 3 }).map((_, index) => ( <SkeletonLine key={index} /> ))}这里用 index 做 key 在静态骨架屏里问题不大,但如果是动态列表,还是建议用稳定 ID。空数组本身不会触发 key 警告,但数组项被过滤后残留的 index key 会造成状态错位,实战中要避免。
5. 面试被问到“空元素”时,考官想听到的完整作答
5.1 “哪些标签是空元素”这个送分题,怎么答出区分度
前端面试里这类基础题出现的频率相当高,特别是针对校招和初级岗。别把它当成死记硬背的送分题,答得好能拉开差距。
低分答案通常是:br、img、input。能列举全,但解释不清楚。
有区分度的回答框架:
- 给出定义:空元素(void elements)是内容模型不允许有子节点的元素,规范上没有结束标签,也不能包裹文本。
- 完整列举:
area、base、br、col、embed、hr、img、input、link、meta、param、source、track、wbr。 - 提一嘴历史元素:
basefont、frame、keygen已经被移除或废弃,可以展示知识广度。 - 解释自闭合的差异:HTML5 中
<br/>和<br>等价,斜杠会被忽略;在 XHTML、JSX、Vue SFC 中,闭合规则不同。 - 举一个反例:
<div/>在纯 HTML 中不是自闭合,它会被解析成未闭合的<div>,后续节点会被吞进去。
这样一套下来,既展示了记忆能力,又展示了原理理解,还暴露了工程经验,面试官想追问都只能往深了追。
5.2 手写判空函数:边界测试才是真正的考点
面试官如果让你手写一个isEmpty,想考的不是你会不会用if (!obj),而是你怎么处理这些边界:
| 输入 | 常见错误判断 | 正确结果 |
|---|---|---|
null/undefined | 直接报错或返回 false | true |
''或' ' | 只看长度,空白字符串被判非空 | true |
[] | 被当成 true | true |
{} | 被当成 true | true |
new Map()/new Set() | 没有 length 属性,被忽略 | true |
new Date() | Object.keys(...).length === 0误判为空 | false |
0/false/NaN | 误判为空 | false |
把第 3.3 节那张函数的边界情况背熟,回答时按“类型逐步展开”的思路讲:先处理null/undefined,再处理字符串和数组,接着是 Map / Set,最后是普通对象,并主动提出Reflect.ownKeys和Object.keys的差异。面试官甚至会追问Object.create(null)和Object.hasOwn的使用场景,能接住就稳了。
5.3 空元素的 SEO 与可访问性:内容为空不代表标签不重要
空元素和 SEO、可访问性放在一起聊,很少人注意,但这是拉高答案层次的好角度。
<img alt="">表示这张图是装饰性的,读屏器会跳过;<img>不写 alt,读屏器会尝试读文件名,体验很差。<meta name="description">是 void element,但它的内容直接决定搜索结果摘要,不能因为“空”就忽略。- 按钮内容为空时,读屏器只会念“按钮”,用户不知道它干嘛用。正确做法是补充
aria-label:
<button aria-label="关闭"> <span class="close-icon"></span> </button>- 纯装饰性的空元素或伪元素内容,要加
aria-hidden="true",避免读屏器读取无意义信息。
“空元素”在 HTML 里是结构属性,但在用户感知里,内容为空不等于信息为空。一个 alt、一个 aria-label,体现了前端工程师是否真正理解无障碍设计。
6. 空元素避坑自查表(写完代码照着过一遍)
6.1 场景对照表
这张表是我日常 code review 时必查的项目,直接贴出来,写代码或审代码时逐条对一遍:
| 场景 | 常见错误 | 正确做法 | 原因 |
|---|---|---|---|
| 纯 HTML 里的 div/span | <div/>、<span/> | <div></div>、<span></span> | 非 void element 的/会被忽略,开始标签不闭合,吞掉后续节点 |
| 纯 HTML 里的 br/img | <br/>或<img></img> | <br>、<img src="..."> | HTML5 规范不要求斜杠,结束标签会被忽略 |
| JSX / 组件 | <Component></Component> | <Component /> | JSX 要求闭合,自闭合更简洁 |
| CSS 空状态 | 标签内有空格或换行 | <div class="tip"></div> | 空白字符是文本节点,:empty不匹配 |
| 替换元素伪元素 | input::before/img::after | 外层包裹容器后再加伪元素 | 替换元素内部不生成伪元素内容 |
| React 条件渲染 | {list.length && <List />} | {list.length > 0 && <List />} | 0 会被 React 渲染成文本节点 |
| Vue v-if false | 把注释节点当异常 | 不用处理 | 注释节点是 diff 锚点,属于设计行为 |
| JS 判空 | if (data)判断空数组/空对象 | 写完整 isEmpty 函数 | 对象都是 truthy,Boolean([]) 为 true |
6.2 我最后想对新人补一句
带新人的这几年,我最大的感受是:空元素相关的 bug,10 个里有 9 个是把不同类型的解析环境混在一起导致的。在 JSX 里写惯了<Component />,就顺手在 HTML 里也写<div />;在组件库里见过<el-input />,就以为所有标签都能自闭合。
我的建议是,学习阶段就在脑子里分清四张桌子:HTML、XHTML、JSX、Vue SFC。写代码前先问一句“我现在在哪个环境里”,再决定标签怎么写。遇到渲染结果不符合预期时,不要只盯着源码猜,打开 DevTools 看解析后的 DOM 树和 outerHTML,往往一眼就能定位问题。空元素的坑虽然隐蔽,但只要把规则理清,它就是最不值得让你加班的那类 bug。