01

HTML 与 Web 标准

2,337 行约 134 分钟70 个代码块52 张表格更新于 2026-11-03

01 · HTML 与 Web 标准(详解版)

一句话定位:HTML 是网页的「骨架 + 说明书」。它既告诉浏览器「这块内容是什么」,也告诉读屏软件、搜索引擎、浏览器自动化工具「该怎么理解它」。

读完本篇你应该能做到

  1. 拿到任何一张设计稿,5 分钟内画出它的语义结构树与键盘操作路径;
  2. 手写一个被读屏软件正确朗读、纯键盘可操作的对话框、选项卡、下拉菜单;
  3. 解释「为什么这段 HTML 会被浏览器解析成另一个 DOM 结构」,并能自己排查。

0. 领域导论

0.1 这个领域解决什么问题

初学者常以为 HTML 只是「一堆标签,把文字图片放上去」。实际上它解决三个真实问题:

  1. 机器如何理解内容:搜索引擎要判断哪段是正文、哪段是导航;读屏软件要知道「这是一个按钮还是一个链接」;浏览器自动化测试要稳定地找到元素。这些都依赖语义,而不是 CSS 长相。
  2. 默认行为从哪来:<button> 天然可聚焦、支持回车触发、能被读屏朗读为按钮;<div> 什么都不带。你写的每个交互组件,本质上是在「用 JS 补齐 HTML 已经免费给你的东西」。
  3. 浏览器如何容错:你写的 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>

必须掌握的点:

  1. <label for> 与 id 一一对应。点击文字聚焦输入框、读屏器朗读字段名,都靠它。也可以把 input 包在 label 里,但嵌套深时容易误绑,推荐 for/id。
  2. <button> 的默认 type 是 submit。表单里放一个「取消」按钮却忘记写 type="button",点它就会提交表单——这是新手最常踩的 bug 之一。
  3. name 才决定提交的字段名,id 只是 DOM 标识。没有 name 的表单控件不会被提交。
  4. 原生校验免费可用:required、pattern、min/max/step、minlength/maxlength、type="email|url|number"。配合 CSS :valid / :invalid 做样式,配合 setCustomValidity() 做自定义错误。
  5. autocomplete 不是可选项:它决定密码管理器、地址自动填充、支付信息填充是否可用。autocomplete="one-time-code" 还能让短信验证码自动填入。写错或省略会直接损害转化率。
  6. 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>

影响:

  1. document.querySelector('table > tr') 找不到元素——因为中间多了 tbody。
  2. p 里塞 div 的布局在 innerHTML 拼接时表现异常(浏览器照样帮你「修正」,结果和你以为的结构不同)。
  3. Vue/React 模板编译到 DOM 时,某些非法嵌套会被浏览器提前修正,导致「虚拟 DOM 与真实 DOM 不一致」(水合警告的来源之一)。

记忆点:浏览器永远在「猜你的意图」。写得规范,猜的结果才可控。

1.8 从字节到 DOM:一次解析的完整链路

浏览器拿到 HTML 后不是「一次性读懂」,而是分五段接力。每一段都可能出问题,且症状各不相同。

阶段输入输出典型故障现象
字节 → 字符Uint8Array字符串编码判定错误中文变「锟斤拷」「」
字符 → 词素字符串token 流非法字符被插入奇怪的注释 <!--?-->
词素 → 节点token 流DOM 节点标签嵌套非法结构被「自动修正」
树构建节点DOM 树插入模式不匹配<tr> 外多了 <tbody>
样式/脚本挂接DOM 树渲染阻塞脚本位置不当白屏、FOUC

编码是如何被决定的(按优先级从高到低,先命中者生效):

  1. BOM(EF BB BF → UTF-8,FF FE → UTF-16LE);
  2. HTTP 响应头 Content-Type: text/html; charset=...;
  3. <meta charset>——只在前 1024 字节内有效,且必须出现在任何非 ASCII 内容之前;
  4. 都没有时,按语言环境猜测(中文环境常被判为 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/borderwidth 包含 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搜索结果缺标题
4preconnect / dns-prefetch提前建连第三方域名多付 100–300ms
5关键 CSS(<link rel="stylesheet">)阻塞渲染首屏无样式闪烁
6preload(字体、首屏大图)提前下载LCP 变慢
7defer 脚本不阻塞解析白屏时间变长
8script 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>

两个高频错误:

  1. preload 了却不 crossorigin:字体和跨域资源会被下载两次(一次匿名、一次带凭据)。
  2. 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
容错解析浏览器会修正结构,别依赖「写错也能跑」

自检题(附答案要点)

  1. 为什么移动端不加 viewport 会「字很小」?——浏览器以 980px 虚拟宽度渲染,再等比缩小到屏幕宽度。
  2. <button> 不写 type 会怎样?——默认 submit,可能意外提交表单。
  3. 首屏大图加了 loading="lazy" 有什么后果?——延迟加载拖慢 LCP。
  4. 为什么 alt="图片" 等于没写?——没有传达任何信息,读屏用户听到噪音。
  5. 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 使用正确

最小可行清单(今天就能做):

  1. 所有交互元素用原生标签(button/a/input),div 加 role+tabindex 是下策。
  2. :focus-visible 有清晰焦点环,别 outline: none 了事。
  3. 图片 alt 分级(见 1.4)。
  4. 颜色对比度 ≥ 4.5:1(正文)、3:1(大字)。
  5. 表单有 label,错误有文字说明(不只靠红框)。
  6. 页面有唯一 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:浏览器原生的组件封装

它由四块技术组成:

  1. Custom Elements:定义自己的标签;
  2. Shadow DOM:样式与 DOM 的封装边界;
  3. HTML Templates:<template> + <slot>;
  4. 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 小结与自检

主题关键结论
SEOtitle/description/canonical/JSON-LD,CSR 内容抓不到
无障碍POUR 四原则;原生 > ARIA
ARIA只补语义不补行为;hidden 不能放可聚焦元素
复合组件对话框用 <dialog>,其余参考 APG
Web Components封装强,但 SSR / 样式主题 / 无障碍要额外做
表单错误要与字段关联,必填要双重表达
i18nlang / dir / bdi / Intl

自检题

  1. aria-label 和可见文本不一致会有什么问题?——违反「标签包含可见文本」,语音控制用户无法通过念可见文字点击。
  2. 原生 <dialog> 帮你解决了哪些问题?——焦点陷阱、Esc、遮罩、顶层渲染。
  3. 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 的结论:

  1. <p> 遇到块级元素会被自动闭合:所以 <p><div>x</div></p> 变成三个节点。
  2. 表格里的非法内容会被「寄养」:<table><div>x</div></table> 中 div 会被移到表格前面,而不是报错。
  3. <template> 的内容不在主 DOM 树里,要用 template.content 访问,克隆用 importNode(template.content, true)。
  4. <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 属性不生成节点否否

三条实用结论:

  1. 用 opacity: 0 做「淡出后隐藏」的动画,动画结束必须补 visibility: hidden 或 hidden,否则元素还在被读屏朗读、还能被 Tab 聚焦。
  2. 隐藏弹层用 inert 或 hidden,不要只用 aria-hidden——后者不会移除键盘可达性。
  3. 不要把 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 独享的,而是一套按优先级取第一个非空值的算法:

优先级来源适用元素
1aria-labelledby(可引用多个 id,按顺序拼接)任意
2aria-label任意
3原生标签机制(<label for>、<caption>、alt、title 属性)表单控件、表格、图片
4内容文本按钮、链接、标题
5placeholder(仅作最后兜底)输入框
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 + valuesetFormValue
校验required/patternsetValidity + reportValidity
被 label 关联for/idformAssociated + 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-popupswindow.open中
allow-top-navigation顶层跳转高(钓鱼)
allow-modalsalert/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">

四条硬性规则:

  1. 互相引用:每个语言版本都要列出全部版本(含自己),单边引用无效。
  2. x-default 必须有:语言未匹配时的兜底页。
  3. 语言代码用 语言-地区,zh-CN 与 zh-Hans 是不同维度,不要混。
  4. 不要用 hreflang 做重定向,它只是提示,不阻止用户访问。

3.13 L3 小结与自检

  1. 为什么 <table><div> 里的 div 会被移到表格外?——插入模式的 foster parenting 规则。
  2. opacity: 0 和 display: none 在 AX 树上有什么区别?——前者仍在树中,可被朗读与聚焦。
  3. 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 无障碍治理机制:从「个人自觉」到「系统保障」

单点修复无法持续,需要三层机制:

  1. 设计层:设计系统里固化对比度、焦点环、触摸目标、动效降级(prefers-reduced-motion)的规范与 token。
  2. 代码层:组件库内置正确语义;ESLint 插件(eslint-plugin-jsx-a11y)拦截明显错误;CI 跑 axe-core 自动扫描。
  3. 流程层:需求评审含无障碍验收项;上线前用键盘 + 读屏走查核心流程;季度人工审计抽样。

自动化只能覆盖约 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 标准的产生过程与「能不能用」的判断

一个特性从想法到能上线,要经过固定关卡。了解它,能避免「教程里有、线上不能上」的判断错误。

阶段归属特征能否用于生产
IncubationWICG提案、语法会变不能
Working DraftWHATWG / W3C WG有规范文本,浏览器开始试验谨慎,需特性检测
Candidate RecommendationW3C实现基本齐备,收集实现报告可用,需回退方案
RecommendationW3C定稿可用
Living StandardWHATWG持续更新,无固定版本号按 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% 的问题
人工 + 读屏抽测语义失真、朗读顺序、操作流程高依赖测试者能力

自动化查不到的典型问题(必须人工):

  1. 可访问名称「有但不达意」(aria-label="按钮");
  2. 键盘操作顺序与视觉顺序相反;
  3. 语音提示与视觉反馈冲突;
  4. 动态更新没有播报。
/**
 * 在 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 自检

  1. 新特性进入 Baseline 后,你的采纳流程是什么?——先查目标用户浏览器分布与 Baseline 状态,在 1–2 个非关键页面灰度,用 @supports 与特性检测做渐进增强,埋点看错误率与性能,最后进规范与 lint 并移除 polyfill。
  2. 要让整条产品线达到 WCAG 2.2 AA,你的路线图、验收方式与责任划分?——先 axe 扫描加键盘/读屏走查定基线,按「阻塞操作 > 信息缺失 > 体验瑕疵」分级修;验收看关键流程可纯键盘完成、无 critical 问题;设计负责对比度与焦点态,前端负责语义与键盘,QA 负责回归清单,产品把 a11y 写进验收标准。
  3. 跨端语义丢失,应该在哪一层解决?——在组件适配层解决,不在业务代码里加平台分支;维护「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 上线前的「三遍走查」

  1. 断样式走查:禁用 CSS,页面仍应有正确的阅读顺序与可操作的表单——这说明结构语义没写错。
  2. 断 JS 走查:禁用 JS,核心内容仍可见、链接仍可点——这说明没有把内容藏在客户端渲染里。
  3. 断网走查:弱网下首屏文字是否先出现、图片是否有占位——这检验的是加载策略而不是视觉。

三遍走查各 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。
    • 追问:<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。
    • 追问: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 优化」,你如何推进?

评估点:是否先量化、是否能分级、是否给出可执行路径、是否考虑回归。

参考方案:

  1. 先审计:用 Lighthouse/axe 扫一遍,按问题类型与影响面排序(点击不可键盘操作 > 图片缺 alt > 标题层级乱)。
  2. 定基线:先修「键盘可达 + 表单可标注 + 图片 alt」三类,成本低收益高。
  3. 组件层收敛:把 div + onClick 替换为 <button>/<a>,在代码规范与 ESLint(jsx-a11y)里拦截新增。
  4. SEO 分层:登录后的后台不需要 SEO;对外营销页单独做 SSR/SSG + 结构化数据,别把整站 SSR 化。
  5. 回归:关键页面加 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 不同。
  • 第二问:图片底部那 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 的元素在显示时也不一定触发播报。

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 缺口。