
先说个我真实遇过的场景。有位刚入职的同事提交了一个弹窗组件模板里写的是div classmodal-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/divXML 语法下自闭合是强制的JSXReactJSX 语法要求所有标签闭合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 classa/p后面的兄弟/p; console.log(div.innerHTML); // 实际输出div classap后面的兄弟/p/div看明白了吗p变成了div的子节点整个结构被改写。如果你在原生 HTML、字符串模板、后端返回的 HTML 片段、innerHTML 拼接入参里看到div/、span/、li/这类写法基本可以断定是 bug 温床。2. 前端新人最容易翻车的四个空元素现场2.1div/的灵魂失踪把非空元素写成自闭合的后果很多新人是在写模板时养成的习惯。Vue、React、小程序里组件确实可以MyComponent /这样写编译器会帮你处理。但这个习惯一旦被带进 HTML 环境就很容易炸。有一个常见场景是后端渲染模板比如 Python 的 Jinja2、Java 的 Thymeleaf里面拼 HTML 片段写了个div classmask/你原本想要一个空的遮罩层。结果页面上所有遮罩层之后的 DOM 全部变成了这个 div 的内容遮罩层的背景色、定位属性作用到了整片页面滚动条还莫名失效。排查这种问题最快的办法不是盯着源码看而是打开 DevTools 查看解析后的 DOM 树document.querySelector(.mask).outerHTML;如果输出的结果是div classmask...一大串后续节点.../div那基本就是这个原因。修复方式也简单非空元素永远写显式的开始和结束标签div classmask/div。空元素则按规范来不写结束标签img srcx.png而不是img srcx.png/img——/img属于结束标签HTML parser 看到后会直接忽略。2.2:empty匹配不到“看起来空”的元素空格和换行都是元凶CSS 伪类:empty的规则很严格它匹配的是没有任何子节点的元素包括文本节点。这里最坑的地方是——空格、换行、制表符这些空白字符在 DOM 里会被当成文本节点。我之前写过这样一段样式.empty-tip:empty { display: none; }模板返回div classempty-tip /div看起来是空的对吧但中间包含一个换行符这个换行就构成了文本节点:empty不会匹配。样式完全不生效。另一个坑是注释节点。按照 CSS Selectors Level 4 的新规范注释节点不再影响:empty的判定但各个浏览器的实现历史不一致为了保险我一般建议空状态元素的标签里不要放任何东西包括注释和空白。要写成div classempty-tip/div而不是div classempty-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 classsearch-box input classsearch-input typetext 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 classbtn-group button确定/button button取消/button /div如果两个 button 之间有换行flex 容器里就会多一个宽度为 0 的匿名 flex item虽然多数时候肉眼无感但结合gap或justify-content: space-between时会出现诡异的对齐偏差。排查时可以把换行去掉或者给子元素设置margin而不是依赖容器gap。技巧则是反向利用空元素可以做 flex 占位实现双端对齐的按钮组div classfooter-bar button classconfirm确认/button span classspacer/span button classcancel取消/button /div.footer-bar { display: flex; } .spacer { flex: 1; }这样确认按钮靠左、取消按钮靠右中间空元素自动填充剩余空间比给取消按钮写死margin-left: auto更灵活。在移动端底部操作栏里这个方案很常用而且省掉了一个原本必须存在的包裹层。3. 把“空”用起来占位、空状态与判空兜底的真实方案3.1 零 DOM 图标与占位符的实现理解了伪元素和空元素的关系之后“空”就不再是 bug 来源而是优化 DOM 结构的手段。最常见的做法是用元素的伪元素来实现图标而不额外创建i、span这类标签。以 loading 图标为例常规写法可能是div classloading span classloading-icon/span 加载中... /div用伪元素可以省掉那个 spandiv classloading加载中.../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如果为 0if (params.count)会直接把 0 吞掉导致参数丢失这也是“0 与空”的经典业务坑。4.2 Vue 的v-iffalse会留下!----这是设计不是 bug如果你写 Vue 3打开 DevTools 看到一堆!----注释节点先别慌。这是v-if在条件为 false 时留下的锚点注释。Vue 的 diff 算法依赖这些锚点来标记可复用的位置从而在条件切换时更精准地移动或删除节点。它不是页面污染而是框架工作的正常痕迹。React 里条件渲染返回null时不产生任何 DOM 节点F12 里是干净的Vue 则会留一个注释节点。两种处理方式没有优劣只是机制不同。但新人排查问题时如果看到 DOM 里的!----很容易误以为是脏数据。如果你实在不想看到注释节点可以用v-show但v-show的原理是display: noneDOM 仍然保留不会移除节点两者适用场景不一样频繁切换显示状态用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直接报错或返回 falsetrue或 只看长度空白字符串被判非空true[]被当成 truetrue{}被当成 truetruenew Map()/new Set()没有 length 属性被忽略truenew Date()Object.keys(...).length 0误判为空false0/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 namedescription是 void element但它的内容直接决定搜索结果摘要不能因为“空”就忽略。按钮内容为空时读屏器只会念“按钮”用户不知道它干嘛用。正确做法是补充aria-labelbutton aria-label关闭 span classclose-icon/span /button纯装饰性的空元素或伪元素内容要加aria-hiddentrue避免读屏器读取无意义信息。“空元素”在 HTML 里是结构属性但在用户感知里内容为空不等于信息为空。一个 alt、一个 aria-label体现了前端工程师是否真正理解无障碍设计。6. 空元素避坑自查表写完代码照着过一遍6.1 场景对照表这张表是我日常 code review 时必查的项目直接贴出来写代码或审代码时逐条对一遍场景常见错误正确做法原因纯 HTML 里的 div/spandiv/、span/div/div、span/span非 void element 的/会被忽略开始标签不闭合吞掉后续节点纯 HTML 里的 br/imgbr/或img/imgbr、img src...HTML5 规范不要求斜杠结束标签会被忽略JSX / 组件Component/ComponentComponent /JSX 要求闭合自闭合更简洁CSS 空状态标签内有空格或换行div classtip/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 函数对象都是 truthyBoolean([]) 为 true6.2 我最后想对新人补一句带新人的这几年我最大的感受是空元素相关的 bug10 个里有 9 个是把不同类型的解析环境混在一起导致的。在 JSX 里写惯了Component /就顺手在 HTML 里也写div /在组件库里见过el-input /就以为所有标签都能自闭合。我的建议是学习阶段就在脑子里分清四张桌子HTML、XHTML、JSX、Vue SFC。写代码前先问一句“我现在在哪个环境里”再决定标签怎么写。遇到渲染结果不符合预期时不要只盯着源码猜打开 DevTools 看解析后的 DOM 树和 outerHTML往往一眼就能定位问题。空元素的坑虽然隐蔽但只要把规则理清它就是最不值得让你加班的那类 bug。