01 · HTML 与 Web 标准(详解版)
一句话定位:HTML 是网页的「骨架 + 说明书」。它既告诉浏览器「这块内容是什么」,也告诉读屏软件、搜索引擎、浏览器自动化工具「该怎么理解它」。
读完本篇你应该能做到
- 拿到任何一张设计稿,5 分钟内画出它的语义结构树与键盘操作路径;
- 手写一个被读屏软件正确朗读、纯键盘可操作的对话框、选项卡、下拉菜单;
- 解释「为什么这段 HTML 会被浏览器解析成另一个 DOM 结构」,并能自己排查。
0. 领域导论
0.1 这个领域解决什么问题
初学者常以为 HTML 只是「一堆标签,把文字图片放上去」。实际上它解决三个真实问题:
- 机器如何理解内容:搜索引擎要判断哪段是正文、哪段是导航;读屏软件要知道「这是一个按钮还是一个链接」;浏览器自动化测试要稳定地找到元素。这些都依赖语义,而不是 CSS 长相。
- 默认行为从哪来:
<button>天然可聚焦、支持回车触发、能被读屏朗读为按钮;<div>什么都不带。你写的每个交互组件,本质上是在「用 JS 补齐 HTML 已经免费给你的东西」。 - 浏览器如何容错:你写的 HTML 几乎从不「报错」,浏览器会按一套固定算法把错误结构修正成合法 DOM。不理解这套算法,就会出现「明明写了却渲染错位」的怪现象。
0.2 一个类比
把网页想成一份公文:
- HTML 是公文的格式规范——标题、章节、条款、表格、附件各有其位,阅读者(浏览器/读屏器/搜索引擎)按规范理解。
- CSS 是排版与印刷——字体、间距、颜色。
- JavaScript 是办事流程——点击后发生什么。
一份格式混乱但排版漂亮的公文,人眼也许能读,但归档系统(搜索引擎)、盲文转换器(读屏器)、审批系统(自动化工具)都会出错。语义就是给机器看的格式。
0.3 学习地图
本篇(HTML 语义与标准)
├─ 必须先会:无(本篇是起点)
├─ 紧接着学:02 CSS(同一份 DOM 的另一种表达)
│ 03 JavaScript(给结构加行为)
├─ 交叉领域:05 浏览器原理(解析与渲染)
│ 12 安全(sandbox / Trusted Types)
│ 16 多媒体(video / audio / track)
└─ 长期影响:17 设计系统(组件语义规范)
建议顺序:0 → 1(L1)→ 2(L2)→ 立刻动手写三个复合组件(对话框/选项卡/下拉)→ 再回来读 L3。
0.4 分级标尺
| 级别 | 能力特征 | 典型产出 | 自检标准 |
|---|---|---|---|
| L1 基础 | 会写结构正确的页面,会用原生表单 | 静态页面、常规业务模块 | 能解释标签的语义与默认行为 |
| L2 进阶 | 语义化 + 无障碍落地,理解 Web Components | 可复用的无障碍组件、SEO 友好页面 | 能用键盘与读屏完整走通主流程 |
| L3 高级 | 理解解析算法、AX 树映射、自定义元素深层机制 | 组件库无障碍规范、SSR 语义一致性方案 | 能解释浏览器为什么这样解析 |
| L4 专家 | 标准与合规层面的决策者 | 组织无障碍标准、跨端语义一致性方案 | 能判断规范演进与兼容风险 |
1. L1 基础:把结构写对
1.1 文档骨架:浏览器认识你的第一句话
最小的合法 HTML 长这样:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>页面标题(浏览器标签 + 搜索结果标题)</title>
</head>
<body>
<h1>页面主标题</h1>
</body>
</html>
这四行里每一行都有实际作用,缺一个都会出问题:
<!DOCTYPE html>:告诉浏览器「用标准模式渲染」。没有它,浏览器会进入怪异模式(quirks mode),盒模型计算、行高、百分比高度等一堆行为都会变成 20 年前的老规则。这是「为什么我的布局跟教程不一样」的经典原因。lang="zh-CN":影响三件事——读屏器的发音语言(英文内容用中文音库念会非常怪)、浏览器断词与标点排版、翻译工具的识别。子元素可以局部覆盖,例如<span lang="en">HTML</span>。<meta charset="UTF-8">:必须在<head>最前面(前 1024 字节内)。放晚了浏览器可能已经用错误编码解析了部分内容,出现乱码。乱码的另一个常见原因是文件实际保存编码与声明不一致。<meta name="viewport">:移动端不加这一行,浏览器会按 980px 的虚拟宽度渲染再缩小,文字变得极小、点击目标难以命中。这一行是移动端适配的前置条件(详见 15 跨端)。
常见坑:
<title>写在<body>里也能显示,但那是解析器的容错行为,不要依赖。 自检:把<!DOCTYPE html>删掉,页面可能出现什么变化?请说出至少两个。——① 进入怪异模式(quirks mode),盒模型退化为「width 含 padding 与 border」,布局整体偏移;② 百分比高度、行高、表格内字号继承等按旧规则计算(例如表格字号不再从祖先继承);③document.compatMode变成BackCompat,依赖标准模式做特性判断的库可能走错分支。
1.2 语义化标签:不是「高级写法」,是「给内容定性」
语义化的判断标准只有一句:这块内容在文档结构里扮演什么角色? 而不是「它长什么样」。
<body>
<header>
<nav aria-label="主导航">…</nav>
</header>
<main>
<article>
<h1>文章标题</h1>
<p>正文段落…</p>
<section aria-labelledby="comments-title">
<h2 id="comments-title">评论</h2>
<p>…</p>
</section>
</article>
</main>
<aside aria-label="相关推荐">…</aside>
<footer>…</footer>
</body>
选标签的决策链:
| 你想表达 | 用 | 不要用 |
|---|---|---|
| 页面唯一主内容区 | <main>(每页只能一个) | <div id="main"> |
| 独立可分发的内容单元 | <article> | <section> 包一层 |
| 有主题的内容分组(通常带标题) | <section> | <div> |
| 与主内容弱相关的补充 | <aside> | 右下角浮层 <div> |
| 导航链接组 | <nav> | <ul> 无包裹 |
| 页面/区块头部 | <header> | <div class="header"> |
| 标题 | <h1>~<h6>(层级,不是字号) | <div class="title"> |
| 强调(语气加强) | <strong> | <b> |
| 强调(视觉突出,无语义) | <em> / <b>(注意区别) | 乱用 <span> |
为什么标题层级重要:读屏软件用户可以「按 H 键」在标题间跳转,这是他们浏览长文的主要方式。如果全站只有 <div class="h2">,这个能力就消失了。标题跳级(h2 直接到 h4)也会让大纲混乱——它表示的是结构,不是字号,字号交给 CSS。
常见坑:一个页面多个
<h1>在 HTML5 里语法合法,但会削弱主题识别,实践上建议每页一个主标题。 自检:<section>和<div>的区别是什么?如果去掉<section>改<div>,对机器有什么损失?——<section>表示「一个有主题的内容分区」,通常配标题,进入文档大纲与区域导航;<div>只是无意义的容器。改成div后机器丢失分区语义:读屏的区域跳转、结构化数据与 SEO 的内容切分都会变弱,只剩下视觉分组。
1.3 文本、列表、表格:三件最容易被写坏的东西
列表要区分语义,不是样式:
<ul> <!-- 无序:项目之间没有先后 -->
<li>苹果</li>
</ul>
<ol> <!-- 有序:步骤、排名 -->
<li>打开设置</li>
<li>点击保存</li>
</ol>
<dl> <!-- 名词-解释对:术语表、键值信息 -->
<dt>HTTP</dt>
<dd>超文本传输协议,浏览器与服务器通信的规则。</dd>
</dl>
读屏器会朗读「列表,3 项」,这让用户知道规模。用 <div> 堆出来的「列表」丢失这个信息。
表格必须带结构,这是最容易被写坏的:
<table>
<caption>2026 年 Q1 各区域销售额(万元)</caption>
<thead>
<tr>
<th scope="col">区域</th>
<th scope="col">一月</th>
<th scope="col">二月</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">华东</th>
<td>120</td>
<td>135</td>
</tr>
</tbody>
</table>
<caption>是表格的标题,读屏器会先读它,告诉用户「接下来是什么表」。<th scope="col|row">让读屏器在读到某个单元格时,能同时播报「区域:华东,一月:120」,否则用户听到的只是一串数字。- 表格只用于二维数据,不要用它做页面布局(2005 年的做法,现在会导致语义污染与响应式困难)。
常见坑:省略
<tbody>时浏览器会自动插入(见 1.7 解析规则),所以table > tr的 CSS 选择器可能不生效。 自检:为什么<th>比<td><strong>对读屏用户更友好?——因为<th>在可访问性树里是「表头」角色,配合scope让读屏在单元格间移动时播报「行表头 + 列表头 + 值」;<td><strong>只是加粗的普通单元格,strong只表达强调,不建立行列关系,用户听到的是一串没有上下文的数字。
1.4 链接与图片:两个最常被忽略的语义
链接:
<!-- 正确:链接就是「跳转到别处」 -->
<a href="/docs/start">开始使用</a>
<!-- 错误:这是「执行动作」,应该用 button -->
<a href="#" onclick="submitForm()">提交</a>
href="#" 是反模式:它会让页面跳到顶部、污染 URL、让「在新标签打开」的右键菜单失效,而且读屏器会念成「链接」而用户以为是按钮。
新窗口打开必须加 rel,否则新页面能通过 window.opener 反向控制原页面(一种叫 tabnabbing 的攻击):
<a href="https://example.com" target="_blank" rel="noopener noreferrer">外部链接</a>
现代浏览器已默认对 target="_blank" 施加 noopener,但显式写上更安全,也说明意图。
图片的 alt 有四种写法,不是「随便描述一下」:
<!-- 1. 信息性图片:描述它传达的信息(不是「一张图」) -->
<img src="chart.png" alt="2026 年 Q1 销售额环比增长 12%,华东贡献最高" />
<!-- 2. 装饰性图片:空 alt,让读屏器跳过 -->
<img src="divider.png" alt="" />
<!-- 3. 功能性图片(按钮内的图标):描述动作,不是画面 -->
<button><img src="search.svg" alt="搜索" /></button>
<!-- 4. 复杂图片(图表/示意图):alt 给摘要,详细描述放在别处 -->
<figure>
<img src="arch.png" alt="系统架构示意图:浏览器经 CDN 访问 BFF,再调用三个下游服务" />
<figcaption>图 1:整体架构。<a href="/docs/arch-detail">查看文字版说明</a></figcaption>
</figure>
判断方法:把图片遮住,这段话还成立吗? 如果图片只是让页面好看,alt="";如果图片承载了信息,alt 必须把那信息写出来。
响应式图片(一次讲清三个属性):
<img
src="photo-800.jpg"
srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, 50vw"
width="800"
height="600"
loading="lazy"
decoding="async"
alt="团队合影"
/>
srcset提供候选图 + 它们的固有宽度(w描述符)。sizes告诉浏览器「这个图在不同屏幕下会占多宽」,浏览器据此挑选最合适的文件。width/height必须写:它们让浏览器提前算出宽高比,避免图片加载后页面跳动(CLS)。loading="lazy"延迟加载屏外图片;首屏 LCP 图片绝对不要加,否则反而拖慢最大内容绘制(详见 11 性能)。
常见坑:
loading="lazy"+ 图片在首屏 = LCP 变差;alt写「图片」「logo」等于没写。 自检:一个纯装饰的分隔线图片,alt应该写什么?为什么?——写alt="":保留属性、值为空,明确告诉读屏「这是装饰,跳过」。完全省略alt反而可能被念出文件名或 URL,制造噪音。
1.5 表单:交互密度最高、语义最容易崩的区域
<form action="/api/orders" method="post">
<fieldset>
<legend>收货信息</legend>
<div>
<label for="name">收件人</label>
<input id="name" name="name" type="text" required autocomplete="name" />
</div>
<div>
<label for="phone">手机号</label>
<input
id="phone"
name="phone"
type="tel"
inputmode="numeric"
required
pattern="1[3-9]\d{9}"
autocomplete="tel"
aria-describedby="phone-hint"
/>
<p id="phone-hint">11 位大陆手机号,用于接收配送通知</p>
</div>
</fieldset>
<button type="submit">提交订单</button>
</form>
必须掌握的点:
<label for>与id一一对应。点击文字聚焦输入框、读屏器朗读字段名,都靠它。也可以把 input 包在 label 里,但嵌套深时容易误绑,推荐for/id。<button>的默认type是submit。表单里放一个「取消」按钮却忘记写type="button",点它就会提交表单——这是新手最常踩的 bug 之一。name才决定提交的字段名,id只是 DOM 标识。没有name的表单控件不会被提交。- 原生校验免费可用:
required、pattern、min/max/step、minlength/maxlength、type="email|url|number"。配合 CSS:valid/:invalid做样式,配合setCustomValidity()做自定义错误。 autocomplete不是可选项:它决定密码管理器、地址自动填充、支付信息填充是否可用。autocomplete="one-time-code"还能让短信验证码自动填入。写错或省略会直接损害转化率。inputmode控制移动端弹哪种键盘(numeric数字键盘),与type分开:type="tel"语义正确,inputmode="numeric"体验更好。
FormData 读取与提交:
/**
* 把表单转成普通对象,便于打印或调试。
* @param {HTMLFormElement} form 表单元素
* @returns {Record<string, FormDataEntryValue>} 字段名到值的映射
*/
function formToObject(form) {
const data = new FormData(form);
return Object.fromEntries(data.entries());
}
常见坑:
<input type="number">在手机上难以输入小数、会吞掉前导 0(比如邮编),金额建议用type="text" inputmode="decimal"。 自检:表单里有个「清空」按钮,点击后整个表单提交了,为什么?——<button>在表单内的默认type是submit,不写type就等于提交按钮。「清空」按钮必须显式写type="button"(做重置才用type="reset"),否则点击即触发表单提交与校验。
1.6 资源加载:defer、async 到底差在哪
浏览器解析 HTML 时遇到 <script> 会停下解析去下载并执行(因为脚本可能 document.write)。三个属性改变这个行为:
<script src="a.js"></script> <!-- 阻塞解析:下载 + 执行完才继续解析 -->
<script src="b.js" defer></script> <!-- 并行下载,解析完成后、DOMContentLoaded 前按顺序执行 -->
<script src="c.js" async></script> <!-- 并行下载,下载完立即执行(可能打断解析),顺序不保证 -->
时序对比(关键差异):
普通: [解析]---[下载+执行]---[继续解析] ← 首屏被拖慢
defer: [解析............]--[按顺序执行] ← 不阻塞,保序
async: [解析]---[下载]--[执行]---[继续解析] ← 不阻塞,不保序
结论:
- 有依赖关系的脚本(框架、业务代码)用
defer; - 完全独立的第三方脚本(统计、广告)用
async; - 现代模块脚本(
<script type="module">)默认就是defer行为。
另外三个容易混的提示:
| 指令 | 作用 | 典型场景 |
|---|---|---|
<link rel="preload" as="font" crossorigin> | 提前以高优先级下载当前页马上要用的资源 | 首屏字体、LCP 图片 |
<link rel="prefetch"> | 空闲时下载下一个页面可能用的资源 | 详情页预取 |
<link rel="preconnect"> / dns-prefetch | 提前建连 / 提前解析 DNS | 第三方域名、CDN |
常见坑:
preload的as与实际资源类型不一致(例如字体没写as="font" crossorigin),浏览器会重复下载,白忙一场。 自检:一个统计脚本和一个依赖 jQuery 的业务脚本,分别用哪个属性?——统计脚本独立、不依赖其他脚本,用async(下载完立即执行,不阻塞解析);依赖 jQuery 的业务脚本用defer,保证 DOM 解析完成且按文档顺序执行,jQuery 一定先于业务代码就绪。
1.7 HTML 的容错解析:为什么「写了却没生效」
浏览器不会因为标签写错而拒绝渲染,它有一套固定算法把错误结构修成合法 DOM。常见修正:
<!-- 你写的 -->
<p>第一段<div>块元素</div></p>
<!-- 浏览器实际生成的(p 不能包含块级元素,遇到 div 就自动闭合 p) -->
<p>第一段</p>
<div>块元素</div>
<p></p>
<!-- 你写的 -->
<table><tr><td>1</td></tr></table>
<!-- 实际生成的(tr 必须在 tbody 里,浏览器自动补) -->
<table><tbody><tr><td>1</td></tr></tbody></table>
<!-- 你写的 -->
<ul><li>a<li>b</ul>
<!-- 实际生成的(li 可以省略结束标签,浏览器自动补全) -->
<ul><li>a</li><li>b</li></ul>
影响:
document.querySelector('table > tr')找不到元素——因为中间多了tbody。p里塞div的布局在innerHTML拼接时表现异常(浏览器照样帮你「修正」,结果和你以为的结构不同)。- Vue/React 模板编译到 DOM 时,某些非法嵌套会被浏览器提前修正,导致「虚拟 DOM 与真实 DOM 不一致」(水合警告的来源之一)。
记忆点:浏览器永远在「猜你的意图」。写得规范,猜的结果才可控。
1.8 从字节到 DOM:一次解析的完整链路
浏览器拿到 HTML 后不是「一次性读懂」,而是分五段接力。每一段都可能出问题,且症状各不相同。
| 阶段 | 输入 | 输出 | 典型故障 | 现象 |
|---|---|---|---|---|
| 字节 → 字符 | Uint8Array | 字符串 | 编码判定错误 | 中文变「锟斤拷」「」 |
| 字符 → 词素 | 字符串 | token 流 | 非法字符被插入 | 奇怪的注释 <!--?--> |
| 词素 → 节点 | token 流 | DOM 节点 | 标签嵌套非法 | 结构被「自动修正」 |
| 树构建 | 节点 | DOM 树 | 插入模式不匹配 | <tr> 外多了 <tbody> |
| 样式/脚本挂接 | DOM 树 | 渲染阻塞 | 脚本位置不当 | 白屏、FOUC |
编码是如何被决定的(按优先级从高到低,先命中者生效):
- BOM(
EF BB BF→ UTF-8,FF FE→ UTF-16LE); - HTTP 响应头
Content-Type: text/html; charset=...; <meta charset>——只在前 1024 字节内有效,且必须出现在任何非 ASCII 内容之前;- 都没有时,按语言环境猜测(中文环境常被判为 GBK)。
<!-- 错误:meta charset 位于大量内联样式之后,已超出前 1024 字节 -->
<style>/* 一堆内联样式 */</style>
<meta charset="utf-8">
<!-- 正确:charset 必须是 head 的第一个子元素 -->
<head>
<meta charset="utf-8">
</head>
服务端要同时设置 HTTP 头。只写 meta 时,如果响应头给的编码不同,浏览器以响应头为准——这就是「本地正常、线上一半乱码」的典型成因。
脚本为什么阻塞解析:解析器遇到同步 <script> 会暂停建树,等待下载并执行完毕。原因是脚本可能调用 document.write 或读取尚未解析的节点,浏览器无法预测,只能保守地停下。defer/async 的本质就是把这个「停」变成「不停」。
1.9 quirks mode:缺失 doctype 的连锁反应
<!-- 触发 quirks mode -->
<html><body>...</body></html>
<!-- 标准模式 -->
<!DOCTYPE html>
<html><body>...</body></html>
检测方式:
/**
* 判断当前文档的渲染模式,排查「同一份 CSS 表现不一致」时第一步看这里。
* @returns {'CSS1Compat' | 'BackCompat'} 标准模式返回 CSS1Compat,怪异模式返回 BackCompat
*/
const mode = document.compatMode;
怪异模式下会被改掉的渲染规则(只列会真正造成线上 bug 的):
| 行为 | 标准模式 | quirks mode |
|---|---|---|
| 盒模型 | width 不含 padding/border | width 包含 padding/border(接近 border-box) |
| 行内元素内块级子元素 | 参与行盒 | 触发怪异布局,高度塌陷 |
line-height: normal | 约 1.2 | 按字体实际行高,跨字体差异明显 |
| 百分比高度 | 父元素无高度则失效 | 相对视口计算,出现「高度莫名撑满」 |
| 表格内字体 | 不继承 | 继承自表格而非文档 |
| 图片底部间隙 | 有基线间隙 | 无 |
实践结论:如果一份组件在宿主页面里「盒子变宽了 20px」,先检查宿主页面是否漏了 doctype,而不是改 CSS。
1.10 <head> 里的元素顺序与首屏
<head> 的书写顺序会实打实影响首屏时间,因为解析器是按顺序发现资源的。
推荐的顺序(从左到右即执行顺序):
| 顺序 | 元素 | 作用 | 写错的后果 |
|---|---|---|---|
| 1 | <meta charset> | 尽早确定编码 | 乱码、重复解析 |
| 2 | <meta name="viewport"> | 移动端视口 | 移动端按 980px 渲染 |
| 3 | <title> | 标题、SEO | 搜索结果缺标题 |
| 4 | preconnect / dns-prefetch | 提前建连 | 第三方域名多付 100–300ms |
| 5 | 关键 CSS(<link rel="stylesheet">) | 阻塞渲染 | 首屏无样式闪烁 |
| 6 | preload(字体、首屏大图) | 提前下载 | LCP 变慢 |
| 7 | defer 脚本 | 不阻塞解析 | 白屏时间变长 |
| 8 | script type="application/ld+json" | 结构化数据 | 富摘要丢失 |
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>订单详情</title>
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link rel="dns-prefetch" href="https://api.example.com">
<link rel="preload" href="/fonts/app.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/app.css">
<script src="/js/app.js" defer></script>
</head>
两个高频错误:
preload了却不crossorigin:字体和跨域资源会被下载两次(一次匿名、一次带凭据)。preload了 CSS 又用<link rel="stylesheet">引一次但属性不一致:浏览器控制台报「资源被预加载但未使用」。
1.11 语义化的反面清单:这些写法到底错在哪
| 写法 | 问题 | 正确做法 |
|---|---|---|
<div onclick> 当按钮 | 键盘不可达、无 role、无回车/空格响应 | <button type="button"> |
<a href="#"> 当按钮 | 会被读屏念成「链接」、可被新标签打开 | 用 <button>;确需导航才用 <a> |
<a href="javascript:void(0)"> | 违反 CSP、无法新窗口打开、语义丢失 | <button> |
标题从 h1 直接跳到 h4 | 读屏大纲断裂 | 按层级递进,样式用 class 控制 |
<section> 无标题 | 生成一个无名地标,读屏列表全是空白项 | 加标题或改用 <div> |
<b> 表示强调 | 只有视觉加粗,无语义 | <strong>(重要性)、<em>(强调) |
<br> 做段落间距 | 语义是换行,不是分段 | <p> + margin |
<table> 做布局 | 读屏会按表格朗读,焦点顺序错乱 | Grid/Flex |
| 图标按钮无文本 | 无名字,读屏只念「按钮」 | 加 aria-label 或视觉隐藏文本 |
<!-- 错误 -->
<div class="btn" onclick="submit()">提交</div>
<!-- 正确:原生按钮自带键盘、焦点、表单语义 -->
<button type="button" class="btn" onclick="submit()">提交</button>
<!-- 图标按钮:文本可以视觉隐藏,但不能缺失 -->
<button type="button" aria-label="关闭对话框">
<svg aria-hidden="true" focusable="false"><!-- ... --></svg>
</button>
表格的无障碍写法。复杂表格靠 scope 与 headers 建立「单元格 → 表头」的关联,读屏用户才能听懂「这个 3 是「第二季度 / 华东区」的值」:
<table>
<caption>2026 年各区域营收(万元)</caption>
<thead>
<tr>
<th scope="col">区域</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">华东</th>
<td>120</td>
<td>135</td>
</tr>
</tbody>
</table>
响应式表格三种做法及取舍:
| 做法 | 实现 | 优点 | 代价 |
|---|---|---|---|
| 横向滚动 | 外层 overflow-x:auto | 保结构、保语义 | 需提示「可滑动」 |
| 卡片化重排 | display:block + ::before 注入标签 | 移动端可读 | 丢表格语义,读屏混乱 |
| 优先列 | 隐藏次要列 + 「展开」 | 首屏信息密度高 | 逻辑复杂,需保证隐藏列仍可访问 |
1.12 链接与图片:几个容易翻车的细节
rel 的常用取值:
| 值 | 作用 | 场景 |
|---|---|---|
noopener | 新页面拿不到 window.opener | 所有 target="_blank" |
noreferrer | 不发送 Referer | 隐私敏感外链 |
noopener noreferrer | 两者都要 | 用户生成内容里的外链 |
nofollow | 不传递权重 | 广告、UGC |
preconnect / dns-prefetch | 提前建连 | 第三方资源 |
canonical | 声明规范地址 | 多路径同内容 |
现代浏览器对
target="_blank"默认已隐含noopener,但兼容老内核与「显式优于隐式」的原则,仍建议手写。
srcset / sizes 的选图算法:浏览器先按 sizes 算出布局宽度(CSS 像素),乘以 DPR 得到需要的物理像素,再从候选里挑不小于该值的最小图;都不够就取最大图。
<!-- 布局宽 50vw;DPR=2 时 375px 视口需要 375*0.5*2≈375px 的图 -->
<img
src="/img/hero-800.jpg"
srcset="/img/hero-400.jpg 400w, /img/hero-800.jpg 800w, /img/hero-1600.jpg 1600w"
sizes="(max-width: 600px) 50vw, 800px"
width="800" height="450"
alt="秋季新品主视觉"
fetchpriority="high">
loading="lazy" 与 fetchpriority 的冲突:首屏大图若同时写了 lazy,浏览器会推迟到布局完成才请求,LCP 直接变差。规则是——首屏图 fetchpriority="high" + 不用 lazy;折叠线以下才用 lazy。
1.13 表单:原生已经给你的能力
label 的三种关联方式,优先级从高到低:
<!-- 1. for + id:最可靠,可点击区域与控件分离 -->
<label for="email">邮箱</label>
<input id="email" name="email" type="email">
<!-- 2. 包裹:无需 id,但可点击区域过大时容易误触 -->
<label>邮箱 <input name="email" type="email"></label>
<!-- 3. aria-label:没有可见文本时使用 -->
<input name="search" type="search" aria-label="搜索订单">
autocomplete 的关键 token(写对了才能真正减少输入):
| token | 含义 |
|---|---|
name / given-name / family-name | 姓名各部分 |
email / tel | 联系方式 |
street-address / postal-code / address-level2 | 地址分层 |
cc-number / cc-exp / cc-csc | 支付信息 |
one-time-code | 短信验证码(移动端可自动填充) |
new-password / current-password | 让密码管理器正确介入 |
type 决定移动端键盘,inputmode 用于 type 不够用时微调:
| 需求 | 写法 |
|---|---|
| 纯数字、允许前导零 | type="text" inputmode="numeric" |
| 小数、允许负号 | inputmode="decimal" |
| 电话号码 | type="tel" |
| 搜索 | type="search" enterkeyhint="search" |
原生校验 API 的正确用法——自定义错误要先 setCustomValidity,清空后必须重置,否则表单会永久无法提交:
/**
* 给输入框挂自定义校验:失焦时校验,输入时清除旧错误。
* @param {HTMLInputElement} input
* @param {(value: string) => string} validate 返回错误文案,空串表示通过
*/
function attachValidation(input, validate) {
input.addEventListener('blur', () => {
const message = validate(input.value);
input.setCustomValidity(message);
if (message) input.reportValidity();
});
input.addEventListener('input', () => {
// 必须清空,否则下一次提交仍然被上一次的错误拦住
input.setCustomValidity('');
});
}
1.14 脚本加载时序:defer、async、module 到底怎么排
| 写法 | 下载 | 执行时机 | 顺序保证 | 适用 |
|---|---|---|---|---|
<script> | 阻塞解析 | 下载完立即执行 | 保证 | 必须尽早执行的埋点、document.write |
<script defer> | 并行 | 解析完成后、DOMContentLoaded 前 | 保证 | 业务主包(推荐默认) |
<script async> | 并行 | 下载完立即执行(可能打断解析) | 不保证 | 独立统计脚本 |
<script type="module"> | 并行 | 相当于 defer | 保证(模块图内) | 现代构建产物 |
<script nomodule> | 并行 | 支持 ESM 的浏览器忽略 | — | 老浏览器降级包 |
时序关系(同一页面同时存在三类脚本时):
HTML 解析 ────┬── 遇到 sync script → 停下 → 下载 → 执行 → 继续
├── 遇到 async script → 继续解析 →(下载完)随时执行
└── 遇到 defer script → 继续解析 → 记录顺序
解析完成 → defer 脚本按序执行 → DOMContentLoaded → 图片/字体/子框架加载 → load
// 验证顺序:把下面四段日志的期望顺序记住,面试常考
document.addEventListener('DOMContentLoaded', () => console.log('DOMContentLoaded'));
window.addEventListener('load', () => console.log('load'));
期望顺序:defer 脚本 → DOMContentLoaded → load。若在 defer 脚本里等待图片尺寸,会拿到 0,因为图片还没加载完。
1.15 L1 小结与自检
| 知识点 | 最该记住的一句 |
|---|---|
| doctype / lang / charset / viewport | 四件套缺一个都会出问题 |
| 语义化标签 | 判断依据是「角色」不是「长相」 |
| 列表与表格 | 结构标签(caption/th/scope)是为了让机器理解 |
| 链接与图片 | 动作不用 <a>,装饰图 alt="" |
| 表单 | label for、button type、name、autocomplete |
| 脚本加载 | 有依赖用 defer,独立用 async |
| 容错解析 | 浏览器会修正结构,别依赖「写错也能跑」 |
自检题(附答案要点)
- 为什么移动端不加 viewport 会「字很小」?——浏览器以 980px 虚拟宽度渲染,再等比缩小到屏幕宽度。
<button>不写 type 会怎样?——默认submit,可能意外提交表单。- 首屏大图加了
loading="lazy"有什么后果?——延迟加载拖慢 LCP。 - 为什么
alt="图片"等于没写?——没有传达任何信息,读屏用户听到噪音。 defer与async谁保证执行顺序?——defer。
2. L2 进阶:让页面被机器与所有人正确使用
2.1 语义化与 SEO:搜索引擎到底看什么
搜索引擎抓取页面后做三件事:理解主题、判断质量、决定展示形态。前端能直接影响的是第一和第三。
必须掌握的标签:
<head>
<title>前端知识图谱 · 四级能力库</title>
<meta name="description" content="20 个前端领域,从基础到专家的四级能力清单。" />
<link rel="canonical" href="https://example.com/knowledge-map" />
<!-- 社交平台分享卡片 -->
<meta property="og:title" content="前端知识图谱" />
<meta property="og:description" content="20 个领域 × 4 个级别" />
<meta property="og:image" content="https://example.com/og.png" />
<meta name="twitter:card" content="summary_large_image" />
</head>
title:搜索结果的主标题,最重要。写法:「具体内容 - 站点名」,控制在 30 个汉字内。description:不直接影响排名,但影响点击率。写「用户为什么要看这页」,不要堆关键词。canonical:告诉搜索引擎「这页的正式地址是哪个」。多域名、带参数的 URL、HTTP/HTTPS 并存时必须配置,否则权重被分散。robots/robots.txt/sitemap.xml:分别控制单页、整站抓取范围、以及给搜索引擎的页面清单。
结构化数据(JSON-LD) 决定了能否出现富媒体结果(评分星级、面包屑、FAQ 展开):
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "前端知识图谱",
"datePublished": "2026-10-30",
"author": { "@type": "Person", "name": "张三" }
}
</script>
常见坑:SPA 用 CSR 渲染时,HTML 初始内容是空的,爬虫可能抓不到内容→必须用 SSR/SSG(见 08)。 自检:两个 URL 内容完全相同,为什么需要
canonical?——同一份内容常能通过多个 URL 访问(带追踪参数、www与非www、大小写、尾斜杠、HTTP 与 HTTPS),搜索引擎会当成重复页面分散权重;canonical声明首选 URL,把排名信号集中到一处。注意它是提示而非强制指令。
2.2 为什么必须做可访问性(不是「道德选项」)
先破除一个误解:无障碍不是只服务视障用户。它覆盖:
- 视力障碍(读屏、放大、高对比度);
- 运动障碍(只能键盘、语音控制、眼动);
- 认知障碍(需要清晰结构、可暂停动画);
- 临时性障碍(手受伤、强光下看屏幕、网络慢);
- 环境性障碍(开车时用语音、用鼠标坏了)。
也就是说,每个人都会在某个时刻成为「残障用户」。此外还有现实约束:很多行业(政务、金融、医疗、教育)有合规要求,海外有 ADA/EN 301 549,国内有信息无障碍规范。
可访问性的四条底层原则(POUR):
| 原则 | 含义 | 落地例子 |
|---|---|---|
| 可感知 Perceivable | 信息不能只靠一种感官 | 图片有 alt、视频有字幕、颜色不是唯一区分 |
| 可操作 Operable | 所有功能都能用键盘/辅助技术完成 | 焦点可见、无键盘陷阱、有跳过导航链接 |
| 可理解 Understandable | 行为可预期、错误可理解 | 表单错误说清怎么改、导航顺序一致 |
| 健壮 Robust | 能被各种辅助技术解析 | 语义正确、ARIA 使用正确 |
最小可行清单(今天就能做):
- 所有交互元素用原生标签(
button/a/input),div加role+tabindex是下策。 :focus-visible有清晰焦点环,别outline: none了事。- 图片
alt分级(见 1.4)。 - 颜色对比度 ≥ 4.5:1(正文)、3:1(大字)。
- 表单有
label,错误有文字说明(不只靠红框)。 - 页面有唯一
h1,标题层级不乱跳。
自检:拔掉鼠标,只用 Tab / Shift+Tab / Enter / Esc / 方向键,能完成你产品的核心流程吗?——这不是知识题而是验收动作,标准是「全程不碰鼠标能完成核心流程」。做不到就说明键盘可达性不达标:要检查 Tab 顺序是否与视觉一致、焦点是否可见、Esc 与方向键是否符合 APG 约定、是否存在键盘陷阱。
2.3 ARIA:只在原生标签不够时使用
ARIA(Accessible Rich Internet Applications)是一组属性,用来补充「这个 div 到底是什么」。它分三类:
| 类别 | 例子 | 作用 |
|---|---|---|
| role(角色) | role="dialog"、role="tablist" | 告诉辅助技术这是什么控件 |
| state(状态) | aria-expanded、aria-checked、aria-selected | 当前处于什么状态,会变化 |
| property(属性) | aria-label、aria-describedby、aria-controls | 与其它元素的关系或说明 |
ARIA 第一规则:不要用 ARIA。 能用原生元素就用原生元素——原生元素自带角色、状态、键盘行为和读屏兼容性。ARIA 只补语义,不补行为:你写 role="button" 不会让它能按回车触发,也不会让它可聚焦。
正确用法示例(折叠面板):
<h3>
<button
type="button"
aria-expanded="false"
aria-controls="panel-1"
id="acc-btn-1"
>
常见问题
</button>
</h3>
<div id="panel-1" role="region" aria-labelledby="acc-btn-1" hidden>
<p>这里是答案。</p>
</div>
/**
* 折叠面板:同步 aria-expanded 与内容可见性。
* @param {HTMLButtonElement} button 触发按钮
* @param {HTMLElement} panel 内容面板
*/
function toggleAccordion(button, panel) {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
panel.hidden = expanded;
}
几个高频坑:
aria-label与aria-labelledby同时存在时,aria-labelledby优先;两者都别和可见文本冲突(会违反「标签包含可见文本」规则,语音控制无法点按)。aria-hidden="true"不能加在可聚焦元素上:元素对读屏隐藏、对键盘仍可见,用户会陷入「看不见但能聚焦」的困境。要隐藏整块内容交互,用inert。aria-live用于动态提示(如「已保存」),polite等当前朗读结束,assertive立即打断(慎用)。
自检:一个自定义开关(div 模拟),要让它被读屏正确识别为「开关、已开启」,需要哪些 role/state?还需要补什么行为?——需要
role="switch"加aria-checked="true",再补可访问名称(aria-label或aria-labelledby);行为上要可聚焦(tabindex="0")、支持 Space 切换(Enter 可选),并在状态变化时同步aria-checked。若要参与表单提交,还需formAssociated或内部放一个隐藏 checkbox。开关必须用aria-checked,不能用aria-pressed(那是切换按钮)。
2.4 四个必会的无障碍复合组件
这是一道分水岭:能独立写对这四个,L2 就扎实了。
(1)对话框 / 模态框
原生 <dialog> 已经帮你解决了焦点陷阱、Esc 关闭、::backdrop、顶层渲染:
<dialog id="confirm-dialog" aria-labelledby="dlg-title">
<h2 id="dlg-title">确认删除?</h2>
<p>删除后不可恢复。</p>
<form method="dialog">
<button value="cancel">取消</button>
<button value="confirm">删除</button>
</form>
</dialog>
/**
* 打开模态框,并把焦点移入。
* @param {HTMLDialogElement} dialog 对话框元素
*/
function openDialog(dialog) {
dialog.showModal(); // 自带焦点陷阱 + Esc 关闭 + 遮罩
}
手写版本必须补齐:打开时焦点移入第一个可聚焦元素、Tab 循环限制在框内、Esc 关闭、关闭后焦点回到触发按钮、背景内容 inert。
(2)选项卡 Tabs
<div role="tablist" aria-label="设置分组">
<button role="tab" aria-selected="true" aria-controls="p1" id="t1" tabindex="0">常规</button>
<button role="tab" aria-selected="false" aria-controls="p2" id="t2" tabindex="-1">高级</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
<div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>…</div>
键盘约定:左右方向键在 tab 间移动(只有当前选中项 tabindex="0",其余 -1),Home/End 跳到首尾,Tab 直接进入面板。
(3)下拉菜单 Menu
role="menu" + role="menuitem",触发按钮加 aria-haspopup="menu" 与 aria-expanded。键盘:上下键移动、Enter 激活、Esc 关闭并把焦点还给触发按钮。注意:如果只是「一组链接」,用普通 <nav><ul> 更合适,不要滥用 menu 角色。
(4)组合框 Combobox(可搜索下拉)
这是最难的:输入框 + 弹出列表,需要 aria-expanded、aria-controls、aria-activedescendant(虚拟焦点)或真实焦点管理,且要处理 IME 输入法组合中不响应方向键。
建议:这四个组件不要自己从零发明,参考 W3C 的 APG 模式(ARIA Authoring Practices Guide),或直接使用 Radix / Headless UI / Ark UI 这类 headless 库(它们已处理大量边界)。
2.5 Web Components:浏览器原生的组件封装
它由四块技术组成:
- Custom Elements:定义自己的标签;
- Shadow DOM:样式与 DOM 的封装边界;
- HTML Templates:
<template>+<slot>; - ES Modules:分发方式。
一个完整例子:
<user-card avatar="/a.png">
<span slot="name">张三</span>
</user-card>
<script type="module">
class UserCard extends HTMLElement {
/** 需要监听的属性 */
static get observedAttributes() {
return ['avatar'];
}
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>
:host { display: flex; gap: 12px; align-items: center; }
::slotted([slot="name"]) { font-weight: 600; }
</style>
<img part="avatar" alt="" />
<slot name="name"></slot>
`;
}
/** 元素插入文档时触发 */
connectedCallback() {
this.shadowRoot.querySelector('img').src = this.getAttribute('avatar') || '';
}
/**
* 监听属性变化,保持内部状态同步。
* @param {string} name 属性名
* @param {string|null} oldValue 旧值
* @param {string|null} newValue 新值
*/
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'avatar') {
this.shadowRoot.querySelector('img').src = newValue || '';
}
}
}
customElements.define('user-card', UserCard);
</script>
四个生命周期:constructor(创建)→ connectedCallback(插入文档)→ attributeChangedCallback(属性变化)→ disconnectedCallback(移出文档),另有 adoptedCallback(跨文档移动)。
代价(必须知道):
- 样式隔离是双刃剑:外部无法用普通选择器覆盖内部样式,只能通过 CSS 自定义属性(
--x)和::part暴露主题能力。 - SSR 不友好:Shadow DOM 在服务端无法渲染出内部结构,首屏可能空白。
- 可访问性需要自己做:自定义元素没有内置语义,必须加
role或使用ElementInternals。 - 表单集成:默认不参与表单提交,需要
formAssociated(见 L3)。
自检:为什么 Shadow DOM 内的元素不能被外部
.card img { }选中?怎么让外部改它的颜色?——因为 Shadow DOM 建立了样式作用域边界,外部文档的普通选择器无法穿透影子树,.card img匹配不到内部节点。要让外部改颜色,内部用:host或自定义属性消费主题:外部设.card { --brand: red },内部color: var(--brand)(自定义属性会继承穿透边界);要精确改内部结构则用part="x"配::part(x)。
2.6 表单进阶:提交编码、错误关联、自动填充
三种提交编码:
enctype | 格式 | 适用 |
|---|---|---|
application/x-www-form-urlencoded(默认) | a=1&b=2 | 普通文本字段 |
multipart/form-data | 分段二进制 | 文件上传 |
text/plain | 纯文本 | 调试用 |
错误提示必须和字段关联,否则读屏用户不知道哪条错误属于哪个输入框:
<label for="email">邮箱</label>
<input
id="email"
name="email"
type="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<p id="email-error" role="alert">请输入有效的邮箱地址,例如 name@example.com</p>
role="alert" 让错误出现时立即被朗读(等价于 aria-live="assertive")。视觉上除了红框,必须给文字说明——色盲用户看不出红框。
必填的双重表达:视觉上加星号,同时 required + aria-required="true",并在表单顶部说明「标 * 为必填」。
2.7 国际化基础:不只是翻译文案
<html lang="zh-CN" dir="ltr">
<body>
<p>中文与 English 混排</p>
<p>阿拉伯语与数字混排:<bdi>مرحبا 123</bdi></p>
</body>
</html>
lang:读屏发音、断词、翻译提示都依赖它。局部内容语言不同要局部标。dir="rtl":从右到左语言(阿拉伯语、希伯来语)。布局要用逻辑属性(margin-inline-start而不是margin-left),否则整个布局会错位。<bdi>:隔离双向文本,防止用户输入的内容把周围标点顺序打乱(安全 + 排版双重作用)。- 数字/日期/货币用
Intl,不要手写格式化:
/**
* 按用户语言格式化金额。
* @param {number} amount 金额
* @param {string} locale BCP 47 语言标签
* @returns {string} 本地化后的金额字符串
*/
function formatMoney(amount, locale = 'zh-CN') {
return new Intl.NumberFormat(locale, { style: 'currency', currency: 'CNY' }).format(amount);
}
2.8 抓取与索引:SEO 的工程细节
搜索引擎不是「看页面」,而是走一条固定流水线:发现 URL → 抓取 → 渲染(可能排队)→ 索引 → 排序 → 出展示形态。前端能干预的是「发现」「渲染」「展示」三段。
| 环节 | 机制 | 前端能做/不能做 |
|---|---|---|
| 发现 | 站内链接、sitemap.xml、外链 | 内链结构、分页不要只用 JS 跳转 |
| 抓取 | 抓取预算按站点权重分配 | 减少参数化重复 URL、noindex 掉低价值页 |
| 渲染 | 无头浏览器排队渲染,有延迟 | 首屏内容尽量在 HTML 里,不靠二次请求 |
| 索引 | 去重、canonical 归并 | 正确设置 canonical,避免多路径同内容 |
| 展示 | 标题、描述、结构化数据 | title、description、JSON-LD |
robots.txt 与 meta robots 的分工:
| 手段 | 作用范围 | 效果 |
|---|---|---|
robots.txt | 全站路径规则 | 阻止抓取;被阻止的页面仍可能因外链被索引(无摘要) |
<meta name="robots" content="noindex"> | 单页 | 允许抓取但不进索引(推荐用于隐私页) |
X-Robots-Tag 响应头 | 非 HTML 资源 | 对 PDF、图片生效 |
常见错误:用 robots.txt 屏蔽测试环境后忘了删——线上整站从此不被抓取,且恢复需要数周。
canonical 的三个误用:
<!-- 错误 1:A 页 canonical 指向 B,B 页 canonical 指向 A,形成环 -->
<!-- 错误 2:分页列表第 2 页 canonical 全部指向第 1 页,导致后续内容不索引 -->
<!-- 错误 3:canonical 写成相对路径且带查询串,随参数变化而漂移 -->
<link rel="canonical" href="https://example.com/product/123">
结构化数据(JSON-LD)示例,只写页面上真实存在的信息,否则属于违规:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "轻薄羽绒服",
"sku": "DJ-2026-01",
"offers": {
"@type": "Offer",
"price": "599.00",
"priceCurrency": "CNY",
"availability": "https://schema.org/InStock"
}
}
</script>
2.9 焦点管理:单页应用最常丢的东西
键盘用户和读屏用户完全依赖焦点。SPA 路由切换后若不处理焦点,用户会停在「已经被移除的按钮」上,读屏读出空白。
三个必须处理的场景:
| 场景 | 问题 | 处理 |
|---|---|---|
| 路由切换 | 焦点留在旧 DOM | 把焦点移到新页面主标题(tabindex="-1") |
| 打开弹层 | 焦点仍可跑到背后内容 | inert 或焦点陷阱 |
| 关闭弹层 | 焦点丢失到 body | 关闭后把焦点还给触发元素 |
| 删除列表项 | 焦点所在节点被移除 | 移到相邻项或列表容器 |
/**
* 路由切换后把焦点移到页面主标题,让读屏从新内容开头朗读。
* @param {string} [selector='h1'] 标题选择器
*/
function focusMainHeading(selector = 'h1') {
const heading = document.querySelector(selector);
if (!heading) return;
// 标题本身不可聚焦,需要临时 tabindex
heading.setAttribute('tabindex', '-1');
heading.focus({ preventScroll: true });
}
tabindex 的三种取值,只有一种该用:
| 值 | 含义 | 建议 |
|---|---|---|
负值(-1) | 可编程聚焦,不进 Tab 序列 | 弹层容器、标题锚点 |
0 | 进入 Tab 序列,按 DOM 顺序 | 自定义控件的根节点 |
| 正值 | 抢到序列最前 | 禁用,会打乱整页顺序 |
可见焦点样式也不要直接删:
/* 错误:全局抹掉焦点环,键盘用户彻底失去位置感 */
:focus { outline: none; }
/* 正确:只在鼠标操作时隐藏,键盘操作时保留 */
:focus:not(:focus-visible) { outline: none; }
:focus-visible { outline: 2px solid #1677ff; outline-offset: 2px; }
2.10 aria-live:动态内容怎么被「念出来」
单页应用大量内容靠 JS 更新,但读屏不会自动播报。aria-live 就是告诉读屏「这块区域会变,变了要念」。
| 值 | 行为 | 适用 |
|---|---|---|
off(默认) | 不播报 | 装饰性区域 |
polite | 空闲时播报,不打断 | 表单校验提示、搜索结果数 |
assertive | 立即打断当前朗读 | 报错、会话即将过期 |
配套角色:
| 角色 | 隐含 aria-live | 用途 |
|---|---|---|
role="status" | polite | 保存成功、加载完成 |
role="alert" | assertive | 严重错误 |
role="log" | polite | 聊天记录 |
<!-- 容器必须常驻 DOM,内容后插入才会被播报;动态创建整个容器往往不播报 -->
<div role="status" aria-live="polite" class="sr-only"></div>
/**
* 向常驻的 live region 写入提示,读屏会朗读这段文字。
* @param {string} message
*/
function announce(message) {
const region = document.querySelector('[role="status"]');
if (!region) return;
// 先清空再写入,保证连续相同文案也会触发播报
region.textContent = '';
requestAnimationFrame(() => {
region.textContent = message;
});
}
滥用后果:把整个列表容器设成 assertive,每次轮询都会打断用户当前朗读,体验比不播报更差。
2.11 复合组件的键盘契约
自定义组件不是「长得像」就行,必须满足键盘用户的预期操作。
Dialog:
| 按键 | 期望行为 |
|---|---|
Esc | 关闭(除非是必须确认的流程) |
Tab / Shift+Tab | 焦点在弹层内循环,不逃逸 |
| 打开时 | 焦点进入第一个可聚焦元素或标题 |
| 关闭后 | 焦点回到触发按钮 |
<!-- 原生 dialog 已内置模态焦点管理与 Esc 关闭 -->
<dialog id="confirm-dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">确认取消订单?</h2>
<p>取消后金额将在 1–3 个工作日退回。</p>
<button type="button" data-action="cancel">再想想</button>
<button type="button" data-action="ok">确认取消</button>
</dialog>
const dialog = document.getElementById('confirm-dialog');
// showModal 会加 ::backdrop、限制焦点、支持 Esc
dialog.showModal();
Tabs:
| 按键 | 期望行为 |
|---|---|
← / → | 在页签间移动(自动激活,或需 Enter 激活) |
Home / End | 跳到第一个 / 最后一个 |
Tab | 从页签列表跳到面板内容 |
需要维护的关系:role="tablist" / role="tab" / aria-selected / aria-controls / role="tabpanel" / aria-labelledby。漏掉 aria-controls,读屏无法从页签跳到面板。
Combobox(带建议的输入框):输入时 aria-expanded 表示建议框是否展开,aria-activedescendant 指向高亮项——焦点始终留在输入框,这是与「用 Tab 在选项间移动」的关键区别。
| 属性 | 位置 | 易错点 |
|---|---|---|
role="combobox" | 输入框 | 写成 searchbox 会导致建议不被识别 |
aria-expanded | 输入框 | 未随展开状态更新 |
aria-controls | 输入框 | 指向的列表 id 不存在 |
aria-activedescendant | 输入框 | 指向的选项 id 在重渲染后失效 |
role="option" + aria-selected | 每个选项 | 用 aria-checked 代替 |
2.12 Shadow DOM 的边界:哪些样式能进,哪些不能
| 能力 | 能否穿透边界 | 说明 |
|---|---|---|
| 外部普通选择器 | 不能 | 组件内部样式自洽 |
继承属性(color、font) | 能 | 继承是跨边界的 |
| CSS 自定义属性 | 能 | 主题化的主通道 |
::part() | 能(组件显式暴露) | part="label" 才可被外部选中 |
::slotted() | 只能选中直接子节点 | 不能选中插槽内容的深层节点 |
:host / :host-context() | 组件内部使用 | 依赖宿主环境时慎用 |
/**
* 定义一个带样式隔离的标签组件,外部可通过自定义属性换主题色。
*/
class TagElement extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { display: inline-flex; padding: 2px 8px; border-radius: 4px; }
:host([tone="danger"]) { background: var(--tag-danger-bg, #fff1f0); }
.text { color: var(--tag-fg, #333); }
</style>
<span class="text" part="text"><slot></slot></span>
`;
}
}
customElements.define('x-tag', TagElement);
生命周期回调的调用顺序,写错会导致「首次渲染拿不到属性」:
| 回调 | 时机 | 常见错误 |
|---|---|---|
constructor | 实例化,尚无属性/子节点 | 在这里读 this.getAttribute() |
connectedCallback | 插入文档 | 可能被多次调用(移动节点时) |
attributeChangedCallback | 属性变化(需先声明 observedAttributes) | 忘声明,回调永不触发 |
disconnectedCallback | 移出文档 | 忘记清理监听导致泄漏 |
adoptedCallback | 跨文档移动 | 少见,但迁移 iframe 时会遇到 |
2.13 国际化:Intl 能替你做的比想象中多
/**
* 按用户语言格式化金额,避免手写千分位与货币符号。
* @param {number} amount
* @param {string} [locale] 例如 'zh-CN'、'en-US'
*/
function formatMoney(amount, locale = navigator.language) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'CNY',
maximumFractionDigits: 2,
}).format(amount);
}
| 需求 | API | 坑 |
|---|---|---|
| 金额/数字 | Intl.NumberFormat | 不同 locale 的小数分隔符不同,别自己拼 |
| 日期 | Intl.DateTimeFormat | 时区不传会用运行环境时区 |
| 相对时间 | Intl.RelativeTimeFormat | 复数规则已内置 |
| 排序(含中文拼音) | Intl.Collator | 直接用 < 比较会按码点排,中文顺序错 |
| 复数/选择 | Intl.PluralRules | 英文 one/other、阿拉伯语有六种 |
| 文本分段 | Intl.Segmenter | 中文按词切分,做搜索高亮很有用 |
文档层面还要配合的两件事:<html lang> 必须是当前语言(读屏据此选发音),双向文本用 dir="rtl" 加 <bdi> 隔离用户输入的阿拉伯语片段,避免整段排版反转。
2.14 L2 小结与自检
| 主题 | 关键结论 |
|---|---|
| SEO | title/description/canonical/JSON-LD,CSR 内容抓不到 |
| 无障碍 | POUR 四原则;原生 > ARIA |
| ARIA | 只补语义不补行为;hidden 不能放可聚焦元素 |
| 复合组件 | 对话框用 <dialog>,其余参考 APG |
| Web Components | 封装强,但 SSR / 样式主题 / 无障碍要额外做 |
| 表单 | 错误要与字段关联,必填要双重表达 |
| i18n | lang / dir / bdi / Intl |
自检题
aria-label和可见文本不一致会有什么问题?——违反「标签包含可见文本」,语音控制用户无法通过念可见文字点击。- 原生
<dialog>帮你解决了哪些问题?——焦点陷阱、Esc、遮罩、顶层渲染。 - Web Components 为什么 SSR 麻烦?——Shadow DOM 内部结构服务端无法输出。
3. L3 高级:进入规范与实现层
3.1 HTML 解析算法:两阶段状态机
浏览器解析 HTML 分两个阶段(WHATWG 规范 13.2 节):
字节流 → [Tokenizer 词法分析] → token 流 → [Tree Construction 树构建] → DOM
Tokenizer 是一个状态机:数据状态、标签打开状态、属性名状态、属性值状态、注释状态、CDATA 状态……它不关心嵌套关系,只负责把字符切成 token(开始标签、结束标签、文本、注释、DOCTYPE)。
Tree Construction 才是决定结构的地方。它维护一个「开放元素栈」和一组插入模式(insertion mode):
| 模式 | 触发条件 | 特殊行为 |
|---|---|---|
initial | 文档开始 | 处理 doctype |
before html / before head / in head | 依次推进 | 自动补 html/head |
in body | 主要内容 | 大多数标签在此处理 |
in table | 遇到 <table> | 只允许特定元素,其余寄养(foster parenting)到表格前 |
in template | 遇到 <template> | 内容进入独立的内容文档(content document),不渲染 |
in select / in frameset | 特定元素 | 严格限制允许的子元素 |
几个能解释真实 bug 的结论:
<p>遇到块级元素会被自动闭合:所以<p><div>x</div></p>变成三个节点。- 表格里的非法内容会被「寄养」:
<table><div>x</div></table>中 div 会被移到表格前面,而不是报错。 <template>的内容不在主 DOM 树里,要用template.content访问,克隆用importNode(template.content, true)。<svg>/<math>会切换命名空间:其中的标签不再是 HTML 元素,foreignObject又能切回 HTML 命名空间——这是「为什么 SVG 里的 div 行为怪异」的原因。
实战价值:写 SSR 模板、做 HTML 消毒(sanitize)、实现富文本编辑器、排查「DOM 结构和模板不一致」,都需要这套模型。
3.2 从 DOM 到可访问性树(AX Tree)
读屏软件读的不是 DOM,而是浏览器生成的可访问性树。理解两者的差异能解释大量「为什么读屏读不到」。
| 写法 | 在 AX 树中 | 能被读屏读到 | 能被 Tab 聚焦 |
|---|---|---|---|
display: none | 不生成节点 | 否 | 否 |
visibility: hidden | 不生成节点 | 否 | 否 |
opacity: 0 | 生成节点 | 是 | 是(如果本来可聚焦) |
aria-hidden="true" | 不生成节点 | 否 | 是(若可聚焦,属于错误用法) |
inert | 不生成节点 | 否 | 否(整棵子树) |
hidden 属性 | 不生成节点 | 否 | 否 |
三条实用结论:
- 用
opacity: 0做「淡出后隐藏」的动画,动画结束必须补visibility: hidden或hidden,否则元素还在被读屏朗读、还能被 Tab 聚焦。 - 隐藏弹层用
inert或hidden,不要只用aria-hidden——后者不会移除键盘可达性。 - 不要把
aria-hidden加在含可聚焦元素的容器上,也不要加在<body>上(会导致整页对读屏消失)。
自检:一个抽屉关闭后用了
opacity: 0; pointer-events: none,会有什么残留问题?——opacity: 0的元素仍在渲染树与可访问性树里:读屏仍会朗读、Tab 仍能聚焦(pointer-events: none只挡鼠标,不挡键盘),而且仍占布局;opacity < 1还会创建层叠上下文与合成层。动画结束要补visibility: hidden,或用hidden/inert真正移出交互。
3.3 自定义元素进阶:ElementInternals 与表单关联
普通自定义元素不会参与表单提交,也无法被 <label> 关联。formAssociated 解决这个问题:
class RatingInput extends HTMLElement {
/** 声明参与表单 */
static formAssociated = true;
constructor() {
super();
this._internals = this.attachInternals();
this._value = '0';
}
/** 元素进入文档 */
connectedCallback() {
this.setAttribute('role', 'slider');
this.setAttribute('tabindex', '0');
this._internals.setFormValue(this._value);
}
/**
* 更新取值并同步到表单。
* @param {string} next 新值
*/
setValue(next) {
this._value = next;
this._internals.setFormValue(next);
this.setAttribute('aria-valuenow', next);
}
}
customElements.define('rating-input', RatingInput);
internals 还提供 setValidity()(接原生校验 UI)、ariaLabel 反射、checkValidity()。这让自定义控件真正成为「一等公民」,而不是靠隐藏 input 打补丁。
3.4 流式 HTML 与预渲染提示
服务端可以边生成边发送 HTML,浏览器会渐进解析并渲染(这就是流式 SSR 能更快出首屏的原理)。相关能力:
speculationrules(Chrome):声明式预渲染/预取,比prefetch更激进:
<script type="speculationrules">
{
"prerender": [{ "where": { "href_matches": "/product/*" }, "eagerness": "moderate" }]
}
</script>
注意:预渲染会执行页面 JS、消耗资源,且需要处理「预渲染页与实际页的状态差异」(例如曝光埋点重复上报)。
<link rel="prerender">已废弃,被 speculation rules 取代。- 流式响应下的
<script>顺序:defer脚本在文档解析完成后才执行,流式场景下要理解「执行时机可能远晚于下载完成」。
3.5 安全相关属性:sandbox、allow、referrerpolicy
嵌入第三方内容(iframe)时必须收敛能力:
<iframe
src="https://third-party.example/widget"
sandbox="allow-scripts allow-forms"
allow="clipboard-write; geolocation 'none'"
referrerpolicy="no-referrer"
loading="lazy"
title="第三方客服组件"
></iframe>
sandbox:白名单式收敛。allow-scripts+allow-same-origin同时给等于「几乎没限制」(内容可自己解除沙箱),要谨慎。allow(Permissions Policy):控制摄像头、麦克风、地理位置等能力。referrerpolicy:控制请求头里的来源信息,防止泄露内部 URL。title:iframe 必须有可读标题,否则读屏用户不知道里面是什么——这是最常见的无障碍遗漏。
另外:innerHTML 与 Trusted Types 是冲突面。启用 require-trusted-types-for 'script' 后,所有 innerHTML 赋值必须经过策略函数,这能系统性堵住 DOM XSS,但存量代码迁移成本高(详见 12 安全)。
3.6 多语言工程:hreflang 集群
<link rel="alternate" hreflang="zh-CN" href="https://example.com/zh/" />
<link rel="alternate" hreflang="en" href="https://example.com/en/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/" />
hreflang告诉搜索引擎「这些是同一内容的不同语言版本」,避免被判为重复内容。x-default是兜底版本(语言不匹配时展示)。- 页面里的
<html lang>与响应头Content-Language必须一致,否则信号冲突。
3.7 插入模式:为什么你的标签被「搬家」了
HTML 解析器内部维护一个「插入模式」栈,不同模式允许的标签不同。遇到不允许的标签,它不会报错,而是先补全再插入。
以下三类现象都源自这里:
<!-- 1. 表格内的非表格元素被「提到」表格前面 -->
<table>
<div>标题</div>
<tr><td>1</td></tr>
</table>
<!-- 实际 DOM:<div>标题</div><table><tbody><tr><td>1</td></tr></tbody></table> -->
<!-- 2. <p> 是「自动闭合」的,遇到块级元素就结束 -->
<p>第一段<div>块</div>
<!-- 实际 DOM:<p>第一段</p><div>块</div> -->
<!-- 3. <select> 只认 option/optgroup/script/template,其余内容直接丢弃 -->
<select>
<option>北京</option>
<span>提示</span>
</select>
<!-- 实际 DOM:<span> 被丢弃 -->
| 插入模式 | 触发场景 | 允许的典型标签 | 意外行为 |
|---|---|---|---|
in body | 普通内容 | 绝大多数 | <p> 自动闭合 |
in table | <table> 内 | caption/colgroup/tbody/tr | 其它节点被移到表格前 |
in select | <select> 内 | option/optgroup | 其它节点被丢弃 |
in template | <template> 内 | 惰性解析,不产生渲染 | 需要 content 才拿到节点 |
排查方法:用 document.querySelector 找不到元素时,先在 Elements 面板看真实 DOM,而不是怀疑选择器写错。React/Vue 的「水合不匹配」警告有一半来自这种「模板合法但 HTML 非法」的嵌套。
3.8 可访问名称(accname)是怎么算出来的
读屏念出的「这个名字」不是 aria-label 独享的,而是一套按优先级取第一个非空值的算法:
| 优先级 | 来源 | 适用元素 |
|---|---|---|
| 1 | aria-labelledby(可引用多个 id,按顺序拼接) | 任意 |
| 2 | aria-label | 任意 |
| 3 | 原生标签机制(<label for>、<caption>、alt、title 属性) | 表单控件、表格、图片 |
| 4 | 内容文本 | 按钮、链接、标题 |
| 5 | placeholder(仅作最后兜底) | 输入框 |
| 6 | 无 → 读屏念「按钮」 | 兜底失败 |
三个高频错误:
<!-- 错误 1:aria-labelledby 引用了不存在的 id(重渲染后 id 变了),名称退化为空 -->
<button aria-labelledby="title-123">×</button>
<!-- 错误 2:aria-label 覆盖了可见文本,用户说「删除」但读屏念「移除该项」 -->
<button aria-label="移除该项">删除</button>
<!-- 错误 3:placeholder 当标签用,输入后提示消失,且对比度通常不足 -->
<input placeholder="请输入手机号">
正确姿势:可见文本优先,视觉隐藏文本兜底,aria-label 只在没有可见文本时使用。
<button type="button">
<svg aria-hidden="true"><!-- 图标 --></svg>
<span class="sr-only">关闭</span>
</button>
3.9 ElementInternals:让自定义元素真正参与表单
formAssociated 元素可以像原生控件一样被 FormData 收集、参与校验、被 <label> 关联:
/**
* 一个最小可用的自定义滑块控件,能参与表单提交与原生校验。
*/
class RatingInput extends HTMLElement {
static formAssociated = true;
/** 让属性变化能触发回调 */
static observedAttributes = ['value'];
/** @type {ElementInternals} 提供表单关联与 ARIA 反射能力 */
#internals = this.attachInternals();
connectedCallback() {
this.setAttribute('role', 'slider');
this.#internals.ariaValueMin = '0';
this.#internals.ariaValueMax = '5';
this.#sync();
}
attributeChangedCallback() {
this.#sync();
}
/** 同步值与校验状态到表单 */
#sync() {
const value = Number(this.getAttribute('value') ?? 0);
// 第二个参数是「提交给表单的值」,第三个是给表单恢复用的状态
this.#internals.setFormValue(String(value));
this.#internals.ariaValueNow = String(value);
if (value < 1) {
// 与原生控件一致:valueMissing 的样式钩子会生效
this.#internals.setValidity(
{ valueMissing: true },
'请至少选择 1 星',
this, // 锚点元素,用于 reportValidity 时定位提示
);
} else {
this.#internals.setValidity({});
}
}
}
customElements.define('x-rating', RatingInput);
对照表:
| 需要的能力 | 原生控件 | 自定义元素方案 |
|---|---|---|
| 提交值 | name + value | setFormValue |
| 校验 | required/pattern | setValidity + reportValidity |
被 label 关联 | for/id | formAssociated + labels |
| 读屏语义 | 原生语义 | internals.role / aria* 反射 |
| 表单重置 | 自动 | 监听 formResetCallback |
3.10 流式 HTML:分块到达时解析器在干什么
服务端分块输出 HTML 时,浏览器是边收边解析边渲染的,这正是 SSR 流式渲染能改善 FCP 的原理。
| 分块边界 | 解析器行为 | 对性能的影响 |
|---|---|---|
切在标签中间(<di + v>) | 缓冲等待,不报错 | 无影响 |
| 切在文本中间 | 先渲染已到达文本 | 可能出现文字逐段出现 |
切在 <script> 内 | 继续缓冲直到 </script> | 该块不执行前不阻塞解析 |
切在 <style> 内 | 缓冲,样式不生效 | 首屏可能闪一下无样式 |
<template> 内 | 惰性解析,不建渲染树 | 适合「稍后再用」的片段 |
/**
* 消费流式 HTML(如 SSR 分块响应),把到达的片段交给解析器。
* @param {Response} response fetch 的流式响应
* @param {HTMLElement} target 挂载容器
*/
async function streamHtml(response, target) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 用 insertAdjacentHTML 让浏览器做增量解析,比 innerHTML += 更省
target.insertAdjacentHTML('beforeend', decoder.decode(value, { stream: true }));
}
}
注意:insertAdjacentHTML 会重新解析片段,但不执行其中的 <script>(安全特性),需要靠后续 hydration 补交互。
3.11 安全相关属性矩阵
sandbox 的 token(iframe 默认什么都不允许):
| token | 放开的能力 | 风险 |
|---|---|---|
allow-scripts | 执行脚本 | 高 |
allow-same-origin | 保留源,可访问同源存储 | 与 allow-scripts 同时使用等于没有沙箱 |
allow-forms | 提交表单 | 中 |
allow-popups | window.open | 中 |
allow-top-navigation | 顶层跳转 | 高(钓鱼) |
allow-modals | alert/confirm | 低 |
<!-- 嵌入不可信第三方内容的标准写法 -->
<iframe
src="https://partner.example.com/widget"
sandbox="allow-scripts allow-forms"
allow="clipboard-write; fullscreen"
referrerpolicy="no-referrer"
loading="lazy"
title="合作方组件">
</iframe>
referrerpolicy 取值与效果:
| 值 | 发送的 Referer |
|---|---|
no-referrer | 不发送 |
origin | 只发源(https://a.com/) |
strict-origin-when-cross-origin(默认) | 同源发完整,跨域只发源 |
unsafe-url | 完整 URL(含参数,慎用) |
Permissions Policy(allow 属性)控制 iframe 能用哪些能力:camera、microphone、geolocation、clipboard-write、fullscreen。默认全部关闭,必须显式授权。
3.12 多语言站点的 hreflang 集群
hreflang 解决的是「同一个商品页有 zh/en/ja 三个版本,别让搜索引擎当成重复内容」:
<link rel="alternate" hreflang="zh-CN" href="https://example.com/cn/product/1">
<link rel="alternate" hreflang="en-US" href="https://example.com/us/product/1">
<link rel="alternate" hreflang="ja-JP" href="https://example.com/jp/product/1">
<link rel="alternate" hreflang="x-default" href="https://example.com/product/1">
四条硬性规则:
- 互相引用:每个语言版本都要列出全部版本(含自己),单边引用无效。
x-default必须有:语言未匹配时的兜底页。- 语言代码用
语言-地区,zh-CN与zh-Hans是不同维度,不要混。 - 不要用
hreflang做重定向,它只是提示,不阻止用户访问。
3.13 L3 小结与自检
- 为什么
<table><div>里的 div 会被移到表格外?——插入模式的 foster parenting 规则。 opacity: 0和display: none在 AX 树上有什么区别?——前者仍在树中,可被朗读与聚焦。formAssociated解决了什么?——自定义元素参与表单提交、校验与 label 关联。
4. L4 专家:标准、合规与组织治理
4.1 标准组织与特性成熟度判断
- WHATWG 维护 HTML Living Standard(持续更新,无版本号);W3C 维护 ARIA、WCAG、CSS 等。
- 提案流程:issue → explainer → 实现(Chrome/Safari/Firefox)→ 规范落地 → 进入 Baseline(跨浏览器可用)。
- 判断一个特性能不能用,看三件事:Baseline 状态、目标用户浏览器分布、降级成本。不要看博客标题里的「新特性」。
决策模板:
| 问题 | 判断 |
|---|---|
| 目标用户中不支持的比例? | < 2% 且非核心功能 → 直接采用 |
| 能否渐进增强(不支持也不影响主流程)? | 能 → 采用 + 特性检测 |
| 不支持会导致功能不可用? | 需要 polyfill,评估体积与语义损失 |
| polyfill 会改变语义/AX 表现? | 谨慎,可能需要替代方案 |
4.2 WCAG 2.2 与合规落地
WCAG 分 A / AA / AAA 三级,AA 是行业默认目标。2.2 新增几条常被忽略的:
- 2.4.11 Focus Not Obscured:键盘焦点不能被吸底工具栏/浮层遮住(移动端吸底按钮最常见)。
- 2.5.7 Dragging Movements:拖拽操作必须有非拖拽替代(如上下按钮排序)。
- 2.5.8 Target Size (Minimum):点击目标至少 24×24 CSS px(移动端建议 44×44)。
- 3.3.7 Redundant Entry:同一流程内不要让用户重复输入已提供的信息。
合规产出物:VPAT/ACR 文档(逐条说明符合/部分符合/不符合),通常由产品 + 前端 + 法务共同产出。真实项目里,合规是「可审计的证据链」,不是「我们做了无障碍」这句话。
4.3 无障碍治理机制:从「个人自觉」到「系统保障」
单点修复无法持续,需要三层机制:
- 设计层:设计系统里固化对比度、焦点环、触摸目标、动效降级(
prefers-reduced-motion)的规范与 token。 - 代码层:组件库内置正确语义;ESLint 插件(
eslint-plugin-jsx-a11y)拦截明显错误;CI 跑axe-core自动扫描。 - 流程层:需求评审含无障碍验收项;上线前用键盘 + 读屏走查核心流程;季度人工审计抽样。
自动化只能覆盖约 30%-50% 的问题(对比度、标签缺失等),键盘与读屏的人工走查不可替代。
4.4 跨引擎差异:同一份 HTML 的不同命运
| 差异点 | Blink(Chrome) | WebKit(Safari) | Gecko(Firefox) |
|---|---|---|---|
<dialog> | 完整支持 | 较新版本支持 | 完整支持 |
inert | 支持 | 支持 | 支持 |
| 表单控件样式 | 可深度定制 | 限制较多(日期选择器) | 中等 |
| AX 树暴露细节 | 较完整 | 有历史差异 | 有差异 |
应对:能力检测优先于 UA 判断;建立真机/多浏览器矩阵;对关键组件准备降级实现。
4.5 语义与性能的权衡
- 过度语义化(每个区块都
section+aria-labelledby)会增加 DOM 与 AX 树规模,长列表场景影响明显。 <details>/<summary>原生折叠比自己实现的 div 更轻、更可访问,但样式定制受限。- 语义结构影响水合成本:SSR 输出的结构越复杂,客户端水合越慢(详见 08)。
判断原则:语义服务于「机器理解」与「可操作性」,不是越多越好。列表项、表单控件、标题层级、地标区域必须有语义;纯装饰容器用 div 即可。
4.6 跨端语义一致性
同一套语义在 Web / 小程序 / RN / 原生容器中被映射成不同东西:
| Web | 小程序 | RN | 语义损失 |
|---|---|---|---|
<button> | <button> | <Pressable> | 需手动补 accessibilityRole |
<h1> | <view> + 样式 | <Text> | 标题层级信息完全丢失 |
<nav> | 无对应 | 无对应 | 地标区域消失 |
工程做法:在设计系统层定义「语义组件」(Heading level={1}、Button、Landmark region="nav"),由各端适配层负责映射,而不是让业务直接写平台标签。
4.7 标准的产生过程与「能不能用」的判断
一个特性从想法到能上线,要经过固定关卡。了解它,能避免「教程里有、线上不能上」的判断错误。
| 阶段 | 归属 | 特征 | 能否用于生产 |
|---|---|---|---|
| Incubation | WICG | 提案、语法会变 | 不能 |
| Working Draft | WHATWG / W3C WG | 有规范文本,浏览器开始试验 | 谨慎,需特性检测 |
| Candidate Recommendation | W3C | 实现基本齐备,收集实现报告 | 可用,需回退方案 |
| Recommendation | W3C | 定稿 | 可用 |
| Living Standard | WHATWG | 持续更新,无固定版本号 | 按 Baseline 判断 |
Baseline 分级(2023 年后业界通用的选型口径):
| 级别 | 含义 | 决策 |
|---|---|---|
| Baseline Newly available | 主流引擎全部支持未满 30 个月 | 可用,但要评估老设备 |
| Baseline Widely available | 全部支持满 30 个月 | 可放心使用 |
| Limited availability | 部分引擎缺失 | 必须 @supports / 特性检测 |
判断流程:查 Baseline → 看目标用户浏览器分布 → 决定是否加降级 → 用 @supports 而不是 UA 判断。
/* 渐进增强:不支持时自动退回基础样式,不要用 UA 判断 */
@supports (container-type: inline-size) {
.card-wrap { container-type: inline-size; }
}
4.8 WCAG 2.2 新增条款的落地检查
2.2 相比 2.1 新增了 9 条,其中三条会直接打到前端实现:
| 条款 | 要求 | 落地检查 |
|---|---|---|
| 2.4.11 Focus Not Obscured(AA) | 键盘焦点不能被固定头/浮层完全遮挡 | 锚点跳转与 Tab 时元素是否被吸顶导航盖住 |
| 2.5.7 Dragging Movements(AA) | 拖拽操作要有单点替代方案 | 拖拽排序是否提供「上移/下移」按钮 |
| 3.3.8 Accessible Authentication(AA) | 登录不能强制认知测试 | 是否有验证码、是否支持粘贴密码/密码管理器 |
2.4.11 的常见触发点:
/* 吸顶导航 + 锚点跳转 = 焦点被遮挡,必须给滚动留出补偿 */
html { scroll-padding-top: 72px; }
/* 跳出被遮挡的焦点元素时,用 scroll-margin 让浏览器自动留白 */
:target, [tabindex="-1"]:focus { scroll-margin-top: 72px; }
2.5.7 的最小改造:拖拽列表旁边补一组「上移/下移/移到顶部」按钮,或提供「进入排序模式后按方向键移动」。
3.3.8 的最小改造:允许粘贴(不要 onpaste="return false")、允许密码管理器自动填充(autocomplete 写对)、验证码提供替代通道(语音/邮件)。
4.9 治理机制:让无障碍变成「系统保障」
靠个人自觉的团队,半年后一定回退。可用的机制有四种,成本递增:
| 机制 | 覆盖问题 | 成本 | 局限 |
|---|---|---|---|
| ESLint 插件(jsx-a11y 等) | 明显错误:alt 缺失、href="#"、点击事件挂在 div | 低 | 只看静态代码,不理解运行态 |
| 组件库统一封装 | 键盘交互、焦点管理只写一次 | 中 | 业务绕过封装就失效 |
| CI 中跑 axe-core | 对比度、ARIA 引用、地标结构 | 中 | 只能查约 30%–50% 的问题 |
| 人工 + 读屏抽测 | 语义失真、朗读顺序、操作流程 | 高 | 依赖测试者能力 |
自动化查不到的典型问题(必须人工):
- 可访问名称「有但不达意」(
aria-label="按钮"); - 键盘操作顺序与视觉顺序相反;
- 语音提示与视觉反馈冲突;
- 动态更新没有播报。
/**
* 在 E2E 中对关键页面跑一次 axe 扫描,把违规项抛给 CI。
* @param {import('axe-core').AxeResults} results
*/
function assertNoSeriousViolations(results) {
// 只拦截 serious/critical,minor 先记录不阻断,避免老页面全面红
const blocking = results.violations.filter(
(v) => v.impact === 'serious' || v.impact === 'critical',
);
if (blocking.length) {
throw new Error(
blocking.map((v) => `${v.id}: ${v.nodes.length} 处`).join('\n'),
);
}
}
4.10 跨引擎的语义与解析差异
同一份 HTML 在不同引擎上并非完全等价,以下四类差异需要知道:
| 差异点 | 表现 | 规避 |
|---|---|---|
表格隐式 tbody | 老 WebKit 某些场景不补 | 手写 tbody |
| 表单控件的可访问名称 | 引擎对 placeholder 兜底策略不同 | 显式 label |
dialog 的焦点循环 | 旧版 WebKit 焦点可逃逸 | 补焦点陷阱 |
| 日期/数字输入的本地化 UI | 各引擎展示差异大 | 自定义控件或接受差异 |
原则:可访问性依赖「规范保证」的部分放心用,依赖「引擎实现细节」的部分自己要兜住。
4.11 语义与性能的权衡
语义标签不是零成本的,但成本通常远小于收益:
| 选择 | 收益 | 成本 | 结论 |
|---|---|---|---|
<details> 替代 JS 折叠 | 零 JS、原生可访问 | 展开动画难定制、::details-content 支持不齐 | 内容型折叠优先用它 |
<dialog> 替代自制弹层 | 模态、焦点、Esc 原生 | 样式需覆盖 ::backdrop、老版本缺失 | 新项目首选 |
加 role/aria | 读屏可用 | 写错比不写更糟 | 能用原生就别加 ARIA |
| 深层嵌套语义 | 大纲清晰 | DOM 更深,样式选择器更长 | 用 <div> 兜结构,不要为了语义堆标签 |
判断标准:如果一个语义标签能替掉一段 JS,它几乎总是更优选择;如果它只是让 DOM 多一层,收益就有限。
4.12 L4 自检
- 新特性进入 Baseline 后,你的采纳流程是什么?——先查目标用户浏览器分布与 Baseline 状态,在 1–2 个非关键页面灰度,用
@supports与特性检测做渐进增强,埋点看错误率与性能,最后进规范与 lint 并移除 polyfill。 - 要让整条产品线达到 WCAG 2.2 AA,你的路线图、验收方式与责任划分?——先 axe 扫描加键盘/读屏走查定基线,按「阻塞操作 > 信息缺失 > 体验瑕疵」分级修;验收看关键流程可纯键盘完成、无 critical 问题;设计负责对比度与焦点态,前端负责语义与键盘,QA 负责回归清单,产品把 a11y 写进验收标准。
- 跨端语义丢失,应该在哪一层解决?——在组件适配层解决,不在业务代码里加平台分支;维护「Web 语义标签 / 小程序组件属性 / RN
accessibilityRole」的映射表,业务只描述意图。
5. 综合实战:从设计稿到可访问页面
任务:实现一个「订单详情页」,包含导航、订单状态、商品表格、评价表单、客服浮窗。
步骤 1 · 结构先行(10 分钟,不写 CSS)
body
├─ a.skip-link(跳过导航,键盘用户第一个焦点)
├─ header > nav[aria-label=主导航]
├─ main
│ ├─ h1 订单详情
│ ├─ section[aria-labelledby=status-title]
│ │ └─ h2#status-title 订单状态 + 进度条(role=progressbar)
│ ├─ section[aria-labelledby=items-title]
│ │ └─ h2 + table(caption/thead/th scope)
│ └─ section[aria-labelledby=review-title]
│ └─ h2 + form(label/fieldset/aria-describedby/error role=alert)
├─ aside[aria-label=客服] > button(浮窗,注意不遮挡焦点)
└─ footer
步骤 2 · 键盘路径检查
Tab 顺序:跳过链接 → 主导航 → 订单状态(若可操作)→ 商品表格(可滚动区域需 tabindex="0")→ 评价表单各字段 → 提交 → 客服按钮。检查:焦点是否可见、浮窗是否遮挡焦点、是否有键盘陷阱。
步骤 3 · 读屏走查
开启 VoiceOver / NVDA,闭眼操作一遍:能否听出页面结构(地标、标题、列表、表格)?表单错误能否被朗读?提交成功是否有 aria-live 提示?
步骤 4 · SEO 与分享
title、description、canonical、OG 卡片、订单页加 robots: noindex(隐私页面不应被索引)。
步骤 5 · 验收清单
- 每页一个
h1,层级不跳 - 所有图片
alt分级正确 - 表单字段全部有 label,错误关联
aria-describedby - 对比度达标,焦点环可见
- 纯键盘可完成全部流程
- 表格有 caption 与 scope
- iframe/第三方组件有 title 与 sandbox
5.1 验收矩阵:四类用户 × 六个场景
清单打勾只说明「写了」,矩阵才说明「能用」。逐格过一遍,空格就是缺陷。
| 场景 \ 用户 | 纯键盘 | 读屏 | 200% 缩放 / 小屏 | 强制高对比 |
|---|---|---|---|---|
| 进入页面 | 首个 Tab 是否落在跳过链接 | 是否念出页面标题与地标数量 | 导航是否折叠成可展开菜单 | 焦点环是否仍可见 |
| 浏览订单状态 | 进度条是否可读(需 aria-valuenow) | 是否念出「第 2 步,共 4 步」 | 进度条是否溢出容器 | 状态色是否仍可区分(不能只靠颜色) |
| 读商品表格 | 滚动区域可用方向键 | 是否念出「行/列表头」 | 表格是否降级为卡片或横向滚动 | 斑马纹是否消失导致串行 |
| 填评价表单 | 错误提示是否可聚焦定位 | 错误是否通过 aria-live 播报 | 弹出键盘是否遮挡输入框 | 错误边框是否仍可见 |
| 提交反馈 | 提交后焦点去向 | 成功提示是否被朗读 | 提示条是否被底部安全区遮挡 | 成功/失败图标是否可辨 |
| 客服浮窗 | Esc 能否关闭 | 弹层是否声明 role="dialog" | 浮窗是否盖住提交按钮 | 阴影是否足以区分层级 |
5.2 上线前的「三遍走查」
- 断样式走查:禁用 CSS,页面仍应有正确的阅读顺序与可操作的表单——这说明结构语义没写错。
- 断 JS 走查:禁用 JS,核心内容仍可见、链接仍可点——这说明没有把内容藏在客户端渲染里。
- 断网走查:弱网下首屏文字是否先出现、图片是否有占位——这检验的是加载策略而不是视觉。
三遍走查各 10 分钟,能覆盖掉大部分「上线后才发现」的问题。
6. 常见误区对照表
| 误区 | 事实 |
|---|---|
| 「语义化只是为了 SEO」 | 首先服务可访问性与可维护性,SEO 是副产品 |
| 「加了 role 就是无障碍」 | 错误 role 比不写更糟,原生元素自带完整语义与键盘行为 |
「tabindex="0" 能解决所有焦点问题」 | 它只把元素塞进 Tab 序列,不提供交互与读屏语义 |
「alt 随便写个描述」 | 装饰图必须 alt="",否则读屏念噪音 |
| 「Web Components 一定更好」 | 封装带来主题、SSR、无障碍三方面额外成本 |
「aria-hidden 能隐藏交互」 | 它不阻止键盘聚焦,应用 inert 或 hidden |
「opacity: 0 等于隐藏」 | 元素仍在 AX 树中,仍可被朗读与聚焦 |
7. 面试高频题(附答题思路)
Q1:HTML5 新特性有哪些? 不要背清单,按「语义 / 表单 / 多媒体 / 存储 / 图形 / 通信」分类说,每类举一个你真正用过的例子,并说明解决了什么问题。
Q2:defer 和 async 的区别?
先说阻塞点(脚本会阻塞解析),再说下载与执行时机,最后给选型建议(有依赖 defer,独立 async,module 默认 defer)。
Q3:为什么要语义化? 三层回答:机器理解(搜索引擎/读屏/自动化)、默认行为(可聚焦、可键盘操作)、可维护性(结构即文档)。
Q4:如何实现一个无障碍的模态框?
优先 <dialog> + showModal();手写要覆盖焦点陷阱、Esc、焦点归还、背景 inert、aria-modal、标题关联。
Q5:<script> 放在 head 和 body 末尾有什么区别?
现代浏览器预加载扫描器会弱化差异,但解析阻塞仍存在;推荐 defer + head。
8. 面试深挖
这一节按「面试官会怎么继续问」组织:先给评分维度,再给三层追问链,最后是手写题与场景设计。前面的第 7 节是「第一问怎么答」,这里是「第二问、第三问怎么接」。
8.1 面试官在考察什么
| 维度 | 看什么 | 加分回答 | 减分回答 |
|---|---|---|---|
| 规范理解 | 能否引用具体规范/行为,而非印象 | 「按 HTML 解析算法,这里会触发 foster parenting」 | 「反正浏览器会自动修」 |
| 用户视角 | 是否考虑键盘、读屏、SEO | 主动提焦点、aria-*、结构化数据 | 只谈「能显示」 |
| 工程判断 | 知道方案的代价 | 「<dialog> 省事,但老 Safari 要降级」 | 只会说「最佳实践」 |
| 排错能力 | 能否从现象推到解析/渲染机制 | 用 DevTools 的 Accessibility 树验证 | 靠猜 |
| 表达结构 | 分层、有取舍、有例子 | 先结论后展开 | 背清单 |
8.2 追问链
链 1 · 语义化 → SEO → 爬虫渲染
- 面试官:为什么要语义化?
- 要点:机器可理解(搜索引擎/读屏/自动化)、原生行为(可聚焦、可键盘操作)、结构即文档。
- 追问:语义化对 SEO 的具体影响是什么?
- 要点:标题层级帮助理解内容结构;
<a>提供可抓取链接;结构化数据(JSON-LD)补充实体信息;但语义化本身不是排名因子,内容质量与可抓取性才是。 - 再追问:SPA 的 SEO 怎么做?
- 要点:CSR 首屏是空壳,爬虫虽能执行 JS 但有预算与延迟;方案是 SSR/SSG、预渲染、动态渲染(按 UA 分流,注意 cloaking 风险);再补
sitemap、robots.txt、canonical。
- 要点:CSR 首屏是空壳,爬虫虽能执行 JS 但有预算与延迟;方案是 SSR/SSG、预渲染、动态渲染(按 UA 分流,注意 cloaking 风险);再补
- 要点:标题层级帮助理解内容结构;
- 追问:
<div>加role="button"和直接用<button>有什么区别?- 要点:
<button>自带键盘激活(Enter/Space)、焦点、禁用语义、表单行为;role只补语义,不补行为,必须自己实现键盘与焦点,且容易漏。
- 要点:
链 2 · defer / async → 执行顺序 → 动态脚本
- 面试官:
defer和async的区别?- 要点:都不阻塞解析;
async下载完立即执行(顺序不保证),defer在 DOM 解析完后按文档顺序执行;type="module"默认 defer。 - 追问:两个
defer脚本之间有依赖,能保证顺序吗?- 要点:能,
defer按文档顺序执行;async不能,依赖场景要用 defer 或显式 Promise 编排。 - 再追问:动态插入的
<script>呢?- 要点:动态创建的 script 默认
async = true;要顺序执行需显式设script.async = false。
- 要点:动态创建的 script 默认
- 要点:能,
- 追问:
defer的脚本什么时候执行,和水合(hydration)什么关系?- 要点:在
DOMContentLoaded之前执行;水合发生在框架脚本执行后,所以「HTML 可见」到「可交互」之间仍有窗口,这正是 TBT/INP 的来源。
- 要点:在
- 要点:都不阻塞解析;
链 3 · 无障碍模态框 → 焦点管理 → inert
- 面试官:如何实现一个无障碍模态框?
- 要点:优先原生
<dialog>+showModal();手写需覆盖焦点陷阱、Esc 关闭、关闭后焦点归还触发元素、aria-modal="true"、标题用aria-labelledby关联。 - 追问:焦点陷阱怎么实现?
- 要点:监听
keydown的 Tab/Shift+Tab,在首尾元素间循环;或用focusin把逃逸的焦点拉回;注意动态内容与display:none元素不可聚焦。 - 再追问:背景内容怎么让读屏忽略?
- 要点:
inert属性最优(同时禁止焦点与读屏);兼容不足时用aria-hidden="true"(但仍需处理焦点),不要只加aria-hidden而忘了 Tab 能进去。
- 要点:
- 要点:监听
- 追问:为什么不用
display:none隐藏背景?- 要点:会丢失布局与滚动位置;
inert只禁用交互,不改变渲染。
- 要点:会丢失布局与滚动位置;
- 要点:优先原生
8.3 手写题
题 1 · 用原生元素实现可访问的 Tabs
考察点:语义角色、aria-controls、roving tabindex、键盘行为。
<!-- 结构:tablist 里只允许一个 tab 处于 tabindex=0 -->
<div role="tablist" aria-label="订单详情">
<button role="tab" id="tab-info" aria-controls="panel-info" aria-selected="true" tabindex="0">详情</button>
<button role="tab" id="tab-log" aria-controls="panel-log" aria-selected="false" tabindex="-1">日志</button>
</div>
<div role="tabpanel" id="panel-info" aria-labelledby="tab-info" tabindex="0">…</div>
<div role="tabpanel" id="panel-log" aria-labelledby="tab-log" tabindex="0" hidden>…</div>
/**
* 键盘导航:左右方向键切换、Home/End 跳首尾,焦点跟随选中项。
* @param {HTMLElement} tablist
*/
function initTabs(tablist) {
const tabs = [...tablist.querySelectorAll('[role="tab"]')];
const activate = (next) => {
tabs.forEach((tab, i) => {
const selected = tab === next;
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
document.getElementById(tab.getAttribute('aria-controls')).hidden = !selected;
});
next.focus();
};
tablist.addEventListener('keydown', (e) => {
const i = tabs.indexOf(document.activeElement);
if (i < 0) return;
const map = {
ArrowRight: (i + 1) % tabs.length,
ArrowLeft: (i - 1 + tabs.length) % tabs.length,
Home: 0,
End: tabs.length - 1,
};
if (!(e.key in map)) return;
e.preventDefault();
activate(tabs[map[e.key]]);
});
tablist.addEventListener('click', (e) => {
const tab = e.target.closest('[role="tab"]');
if (tab) activate(tab);
});
}
边界与追问:
- 为什么不给每个 tab 都
tabindex="0"?→ Tab 键会逐个停留,键盘用户要按很多次;roving tabindex 让整组只占一个 Tab 位。 - 面板为什么要
tabindex="0"?→ 面板内可能没有可聚焦元素,读屏用户需要能进入阅读。 - 垂直 tabs 要支持上下键,并设
aria-orientation="vertical"。
题 2 · 手写焦点陷阱
/**
* 焦点陷阱:Tab 在容器内循环,Esc 关闭并归还焦点。
* @param {HTMLElement} container
* @param {() => void} onClose
*/
function trapFocus(container, onClose) {
const selector = 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
const previouslyFocused = document.activeElement;
const onKeydown = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key !== 'Tab') return;
const items = [...container.querySelectorAll(selector)].filter((el) => el.offsetParent !== null);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
container.addEventListener('keydown', onKeydown);
container.querySelector(selector)?.focus();
return () => {
container.removeEventListener('keydown', onKeydown);
previouslyFocused?.focus?.(); // 焦点归还
};
}
常见扣分点:只处理 Tab 不处理 Shift+Tab;用 offsetParent 判可见时忽略了 position: fixed;关闭后忘记归还焦点;把 aria-hidden 加在容器自身。
8.4 场景设计题
题:一个运营后台有 30 个页面,全是 <div> 拼的点击区域,现在要做「可访问性达标 + SEO 优化」,你如何推进?
评估点:是否先量化、是否能分级、是否给出可执行路径、是否考虑回归。
参考方案:
- 先审计:用 Lighthouse/axe 扫一遍,按问题类型与影响面排序(点击不可键盘操作 > 图片缺 alt > 标题层级乱)。
- 定基线:先修「键盘可达 + 表单可标注 + 图片 alt」三类,成本低收益高。
- 组件层收敛:把
div + onClick替换为<button>/<a>,在代码规范与 ESLint(jsx-a11y)里拦截新增。 - SEO 分层:登录后的后台不需要 SEO;对外营销页单独做 SSR/SSG + 结构化数据,别把整站 SSR 化。
- 回归:关键页面加 axe 自动化与键盘走查清单;把可访问性写进 DoD。
加分:指出「div 点击区域还会丢失右键新开、拖拽、长按菜单」等原生行为;提出「先用读屏走一遍主流程」。
减分:直接说「全部换成 button」而不区分链接与按钮;忽略焦点顺序与视觉焦点样式。
8.5 最容易答错的点
| 说法 | 纠正 |
|---|---|
| 「语义化能提升 SEO 排名」 | 它帮助理解与可抓取,排名取决于内容与页面质量 |
「async 一定比 defer 快」 | 下载都快;async 执行时机不确定,有依赖时必须 defer |
「role 能替代原生标签」 | 只补语义,不补行为 |
「aria-hidden 能禁用交互」 | 不能,Tab 仍可聚焦;要用 inert |
「<dialog> 全兼容」 | 老版本需 polyfill 或降级 |
「图片 alt 随便写」 | 装饰图用 alt="",信息图要描述内容 |
「表单 label 可有可无」 | 缺 label 会导致读屏与点击区域问题 |
| 「HTML 容错所以随便写」 | 容错会生成意外 DOM,影响布局与脚本 |
「<div> 加 role="button" 就等价于按钮」 | 缺少空格/回车响应、disabled 语义、表单提交行为 |
「aria-label 写什么都行」 | 名称必须描述动作与对象;覆盖可见文本会导致语音与视觉不一致 |
「placeholder 可以代替 label」 | 输入后消失、对比度低、读屏兼容不一致 |
「loading="lazy" 越多越好」 | 首屏图加 lazy 会拖慢 LCP,且可能造成布局跳动 |
「<table> 加 role="presentation" 就安全了」 | 会连带删除单元格的表头关联,读屏彻底失去上下文 |
| 「SPA 不需要处理焦点」 | 路由切换后焦点停留在已移除节点,键盘用户直接迷路 |
「inert 与 aria-hidden 一样」 | inert 同时阻止聚焦与交互,aria-hidden 只对辅助技术隐藏 |
| 「SEO 靠堆关键词」 | 现代检索看内容质量与结构,堆词会被降权 |
「hreflang 写一边就够」 | 必须互相引用,单边声明无效 |
| 「doctype 只是历史遗留」 | 缺失会进入 quirks mode,盒模型与高度计算全变 |
「sandbox 加上就安全」 | 同时给 allow-scripts 与 allow-same-origin 等于没加 |
8.6 代码级细节题
题 1 · 可访问的自动完成输入框(combobox)
考察点:ARIA 状态、键盘模型、焦点策略。
<label for="city">城市</label>
<input
id="city"
role="combobox"
aria-expanded="false"
aria-controls="city-listbox"
aria-autocomplete="list"
aria-activedescendant=""
autocomplete="off"
/>
<ul id="city-listbox" role="listbox" aria-label="城市候选"></ul>
/**
* 绑定 combobox 的键盘与 ARIA 状态。
* 焦点始终留在输入框,用 aria-activedescendant 指向高亮项。
* @param {HTMLInputElement} input
* @param {HTMLElement} listbox
* @param {string[]} options
*/
function initCombobox(input, listbox, options) {
let activeIndex = -1;
/** 同步高亮项与展开状态 */
const render = () => {
listbox.innerHTML = options
.map(
(text, i) =>
`<li role="option" id="opt-${i}" aria-selected="${i === activeIndex}">${text}</li>`
)
.join('');
input.setAttribute('aria-expanded', String(options.length > 0 && listbox.hidden === false));
input.setAttribute('aria-activedescendant', activeIndex >= 0 ? `opt-${activeIndex}` : '');
};
const move = (delta) => {
if (options.length === 0) return;
activeIndex = (activeIndex + delta + options.length) % options.length;
render();
document.getElementById(`opt-${activeIndex}`)?.scrollIntoView({ block: 'nearest' });
};
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
move(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
move(-1);
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
input.value = options[activeIndex];
listbox.hidden = true;
input.setAttribute('aria-expanded', 'false');
} else if (e.key === 'Escape') {
listbox.hidden = true;
input.setAttribute('aria-expanded', 'false');
}
});
input.addEventListener('input', () => {
activeIndex = -1;
listbox.hidden = input.value.length === 0;
render();
});
render();
}
追问:
- 为什么焦点不移动到选项上?→ 移走会让输入框失焦、输入法中断;
aria-activedescendant让读屏「以为」焦点在选项上,实际仍在输入框。 - 为什么
aria-expanded要跟着列表可见性变?→ 它描述的是「弹出层是否展开」,状态不一致会让读屏播报错误。 - 输入法组合期间能响应方向键吗?→ 不能,要先判断
e.isComposing,否则会打断候选词选择。
题 2 · 表单错误汇总与字段关联
<form novalidate>
<div role="alert" id="form-error" hidden></div>
<label for="phone">手机号</label>
<input id="phone" name="phone" aria-describedby="phone-hint phone-error" />
<p id="phone-hint">用于接收通知,不会公开</p>
<p id="phone-error" hidden></p>
<button type="submit">提交</button>
</form>
/**
* 提交时校验,错误写入汇总区并关联到字段。
* @param {HTMLFormElement} form
*/
function bindValidation(form) {
const summary = form.querySelector('#form-error');
const rules = {
phone: (v) => (/^1\d{10}$/.test(v) ? '' : '请输入 11 位手机号'),
};
form.addEventListener('submit', (e) => {
e.preventDefault();
const errors = [];
Object.entries(rules).forEach(([name, validate]) => {
const field = form.elements[name];
const errorEl = form.querySelector(`#${name}-error`);
const message = validate(field.value);
field.setAttribute('aria-invalid', String(Boolean(message)));
errorEl.textContent = message;
errorEl.hidden = !message;
if (message) errors.push({ name, message, field });
});
summary.hidden = errors.length === 0;
summary.innerHTML = errors.length
? `<p>有 ${errors.length} 个字段需要修改</p><ul>${errors
.map((err) => `<li><a href="#${err.name}">${err.message}</a></li>`)
.join('')}</ul>`
: '';
errors[0]?.field.focus();
});
}
追问:
- 错误为什么既放汇总又放字段旁?→ 汇总让读屏用户一次听全,字段旁让视觉用户就近修改;两者用链接关联。
- 为什么用
role="alert"而不是aria-live="polite"?→ 提交失败需要立即打断播报;非紧急提示才用polite。 - 校验时机怎么定?→ 首次提交前不打扰,提交后改为「输入即校验」,避免用户一边输入一边被报错。
8.7 模拟面试:45 分钟全流程
| 阶段 | 时长 | 面试官动作 | 你要给出的信号 |
|---|---|---|---|
| 开场 | 3 分钟 | 让你挑一个最熟悉的项目 | 一句话说清业务、你的角色、可量化结果 |
| 基础快问 | 10 分钟 | 语义化、defer/async、alt、label | 定义准确 + 一个反例 |
| 追问深挖 | 15 分钟 | 沿一个点连续追问三层 | 每层都能落到规范或浏览器行为 |
| 白板题 | 12 分钟 | tabs / dialog / 表单校验 | 先讲语义与键盘模型,再写代码 |
| 反问 | 5 分钟 | 让你提问 | 问团队的无障碍现状与验收方式 |
评分表(满分 100):
| 维度 | 权重 | 及格线 | 加分表现 |
|---|---|---|---|
| 概念准确性 | 25 | 术语不混用 | 能指出常见错误说法 |
| 规范熟悉度 | 20 | 知道 WCAG/ARIA 的适用范围 | 能引用具体条款与浏览器行为 |
| 代码质量 | 25 | 语义正确、键盘可用 | 处理边界与降级 |
| 归因能力 | 20 | 能定位到具体原因 | 有量化或复现路径 |
| 沟通表达 | 10 | 结构清晰 | 主动说明取舍与未知 |
自检:如果「规范熟悉度」低于 12 分,说明你还在背结论;如果「代码质量」低于 15 分,说明键盘模型没真正写过。
8.8 现场追问:从现象倒推到规范
面试官常从「一个现象」起手,看你能否一路推到规范层。以下三条链,答到第三层才算过关。
链 1 · 「页面上有个按钮,键盘 Tab 不过去」
- 第一问:可能的原因有哪些?
- 要点:元素不是可聚焦元素(
div)且无tabindex;tabindex="-1";display:none/visibility:hidden;祖先有inert;被aria-hidden但仍在 Tab 序列(说明是「假隐藏」);被浮层遮挡但可聚焦(也是问题)。
- 要点:元素不是可聚焦元素(
- 第二问:
aria-hidden="true"之后为什么还能 Tab 到?- 要点:
aria-hidden只从可访问性树移除,不动焦点系统。正确做法是inert(同时禁用聚焦与交互)或直接hidden。
- 要点:
- 第三问:自定义控件要满足什么才能「像原生按钮」?
- 要点:
role="button"+tabindex="0"+ 回车与空格两种键响应 +aria-disabled时阻止响应但仍可聚焦 + 焦点样式;能用<button>就别自定义。
- 要点:
链 2 · 「同一个组件在 A 页面正常,在 B 页面盒子高 4px」
- 第一问:先看什么?
- 要点:B 页面是否缺 doctype(quirks mode);是否被
line-height继承影响;是否图片底部基线间隙;是否box-sizing不同。
- 要点:B 页面是否缺 doctype(quirks mode);是否被
- 第二问:图片底部那 4px 从哪来?
- 要点:
<img>是替换型行内元素,按基线对齐,基线下留有字体降部空间。修法:display:block、vertical-align:middle/top、或容器font-size:0。
- 要点:
- 第三问:为什么改了
vertical-align有的场景无效?- 要点:
vertical-align只对行内/表格单元格有效,且参照物是所在行盒的基线;父元素变成 flex/grid 项后该属性失效。
- 要点:
链 3 · 「读屏念不出表单错误」
- 第一问:错误的可见提示是怎么做的?
- 要点:常见是红边框 + 下方红字。红边框对读屏等于不存在。
- 第二问:怎么让错误被朗读?
- 要点:错误文本与控件用
aria-describedby关联;提交后把错误汇总区设为role="alert";焦点移到第一个出错字段。
- 要点:错误文本与控件用
- 第三问:为什么有时
role="alert"也不播报?- 要点:alert 容器若是「随内容一起新建」的,插入瞬间无变化事件;必须先有常驻容器再改文本。另外
display:none的元素在显示时也不一定触发播报。
- 要点:alert 容器若是「随内容一起新建」的,插入瞬间无变化事件;必须先有常驻容器再改文本。另外
9. 延伸资源
- MDN HTML elements reference:每个标签的默认行为与可访问性说明,遇到疑问第一站。
- WHATWG HTML Standard 第 13 章 Parsing:解析算法的权威描述。
- W3C ARIA Authoring Practices Guide (APG):复合组件的标准实现与键盘约定。
- web.dev Learn Accessibility:系统的无障碍课程。
- WebAIM:对比度检查器、读屏用户调研。
- HTML Spec 的 "Authoring conformance" 章节:什么写法是合规的。
- 工具:axe DevTools、Lighthouse Accessibility、VoiceOver(macOS)、NVDA(Windows)、Playwright 的
getByRole定位器。
下一步:读完本篇后,用一天时间做两件事——(1) 把你手上任意一个页面的 DOM 结构画出来,标出语义错误;(2) 只用键盘完成它的核心流程。这两件事能暴露 80% 的 L1 缺口。