语义化与可访问性
问题
什么是 HTML 语义化?为什么需要关注可访问性(A11Y)?如何让网页对所有用户(包括残障用户)友好?
答案
一、HTML 语义化
语义化是指使用含义正确的 HTML 标签来表达内容结构,而非全部用 <div> + CSS 堆叠。
常用语义标签
| 标签 | 语义 | 替代了 |
|---|---|---|
<header> | 页头/区块头部 | <div class="header"> |
<nav> | 导航区域 | <div class="nav"> |
<main> | 页面主内容(唯一) | <div class="main"> |
<article> | 独立完整内容(文章/帖子) | <div class="post"> |
<section> | 主题性区块 | <div class="section"> |
<aside> | 侧边栏/附属内容 | <div class="sidebar"> |
<footer> | 页脚/区块底部 | <div class="footer"> |
<figure> / <figcaption> | 图片/图表及说明 | <div class="img-wrap"> |
<time> | 时间/日期 | <span> |
<mark> | 高亮文本 | <span class="highlight"> |
<details> / <summary> | 可折叠内容 | JS 手写折叠 |
正确的页面结构
<body>
<header>
<nav aria-label="主导航">
<ul>
<li><a href="/">首页</a></li>
<li><a href="/about">关于</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>文章标题</h1>
<time datetime="2026-02-28">2026年2月28日</time>
<section>
<h2>章节一</h2>
<p>内容...</p>
</section>
<figure>
<img src="chart.png" alt="2026年Q1销售数据柱状图" />
<figcaption>图1:2026年Q1销售数据</figcaption>
</figure>
</article>
<aside>侧边栏内容</aside>
</main>
<footer>
<p>© 2026</p>
</footer>
</body>
语义化的好处
- 可访问性 — 屏幕阅读器能准确识别页面结构,盲人用户可直接跳转到
<nav>或<main> - SEO — 搜索引擎更准确地理解内容层级和权重
- 可维护性 — 代码自描述,开发者一眼看懂结构
- 响应式基础 — 语义结构天然适合不同设备的布局调整
二、可访问性(A11Y)
A11Y(Accessibility 的缩写,a 和 y 之间 11 个字母)是指让所有人都能使用网页,包括视觉、听觉、运动、认知障碍用户。
WCAG 标准
WCAG(Web Content Accessibility Guidelines)定义了四大原则:
| 原则 | 说明 | 示例 |
|---|---|---|
| 可感知 | 内容可被用户感知 | 图片有 alt、视频有字幕 |
| 可操作 | 界面可被用户操作 | 键盘可导航、有足够点击区域 |
| 可理解 | 内容和操作可被理解 | 清晰的错误提示、一致的导航 |
| 健壮性 | 兼容各种辅助技术 | 正确使用 ARIA、语义化标签 |
合规等级:A(最低)→ AA(常见要求)→ AAA(最高)。大多数项目要求达到 AA 级别。
三、ARIA 属性
当原生 HTML 语义不够时,用 ARIA(Accessible Rich Internet Applications)补充语义信息。
能用原生 HTML 就不用 ARIA。<button> 自带按钮语义和键盘支持,不需要 <div role="button">。ARIA 是给自定义组件用的"语义补丁"。
常用 ARIA 属性
<!-- 角色 -->
<div role="dialog" aria-modal="true">弹窗</div>
<div role="tablist">
<button role="tab" aria-selected="true">Tab 1</button>
<button role="tab" aria-selected="false">Tab 2</button>
</div>
<!-- 状态 -->
<button aria-expanded="false" aria-controls="menu-1">菜单</button>
<ul id="menu-1" aria-hidden="true">...</ul>
<!-- 标签 -->
<input aria-label="搜索" type="search" />
<div aria-labelledby="dialog-title" role="dialog">
<h2 id="dialog-title">确认删除</h2>
</div>
<!-- 实时区域(屏幕阅读器自动播报变化) -->
<div aria-live="polite">搜索到 42 条结果</div>
<div aria-live="assertive">表单提交失败!</div>
| 属性 | 用途 | 场景 |
|---|---|---|
role | 声明元素角色 | 自定义组件 |
aria-label | 提供文本标签 | 图标按钮 |
aria-labelledby | 关联可见标签 | Dialog 标题 |
aria-describedby | 关联描述文本 | 表单错误提示 |
aria-expanded | 展开/折叠状态 | 下拉菜单 |
aria-hidden | 对辅助技术隐藏 | 装饰性图标 |
aria-live | 动态内容播报 | 搜索结果、通知 |
aria-required | 标记必填 | 表单字段 |
aria-disabled | 标记禁用 | 按钮 |
四、键盘导航
所有交互操作都必须可通过键盘完成:
| 按键 | 行为 |
|---|---|
Tab | 在可交互元素间顺序移动焦点 |
Shift + Tab | 反向移动焦点 |
Enter / Space | 激活按钮、链接 |
Escape | 关闭弹窗、下拉菜单 |
Arrow Keys | 在菜单、Tab、列表内移动 |
function Dropdown({ items }: { items: string[] }) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, items.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
break;
case 'Enter':
selectItem(items[activeIndex]);
break;
case 'Escape':
setOpen(false);
break;
}
};
return (
<div onKeyDown={handleKeyDown}>
<button
aria-expanded={open}
aria-haspopup="listbox"
onClick={() => setOpen(!open)}
>
选择...
</button>
{open && (
<ul role="listbox">
{items.map((item, i) => (
<li
key={item}
role="option"
aria-selected={i === activeIndex}
tabIndex={i === activeIndex ? 0 : -1}
>
{item}
</li>
))}
</ul>
)}
</div>
);
}
五、焦点管理
function useFocusTrap(ref: React.RefObject<HTMLElement>, active: boolean) {
useEffect(() => {
if (!active || !ref.current) return;
const element = ref.current;
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = element.querySelectorAll<HTMLElement>(focusableSelector);
const firstEl = focusableElements[0];
const lastEl = focusableElements[focusableElements.length - 1];
// 打开时聚焦到第一个可交互元素
firstEl?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
// Tab 到最后一个元素时,跳回第一个(循环)
if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
// Shift+Tab 到第一个元素时,跳到最后一个
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
}
};
element.addEventListener('keydown', handleKeyDown);
return () => element.removeEventListener('keydown', handleKeyDown);
}, [active, ref]);
}
六、常见可访问性问题
- 图片没有
alt— 屏幕阅读器无法描述图片内容 - 用
<div>做按钮 — 没有键盘支持、没有按钮语义,应使用<button> - 颜色对比度不足 — 文字与背景对比度低于 4.5:1,视弱用户看不清
- 只用颜色传达信息 — 红色表示错误但色盲用户无法区分,需要图标或文字辅助
- 缺少跳转链接 — 键盘用户每次都要 Tab 过整个导航栏才能到主内容
- 自动播放媒体 — 屏幕阅读器用户被干扰,应提供暂停控制
- 表单缺少
<label>— 屏幕阅读器不知道输入框的用途
<!-- 视觉隐藏,Tab 时显示,让键盘用户跳过导航直达内容 -->
<a href="#main-content" class="skip-link">跳转到主内容</a>
<nav>...</nav>
<main id="main-content">...</main>
<style>
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
padding: 8px 16px;
background: #000;
color: #fff;
}
</style>
七、可访问性测试
| 工具 | 类型 | 说明 |
|---|---|---|
| axe DevTools | 浏览器插件 | 自动检测 WCAG 违规 |
| Lighthouse | 浏览器内置 | Accessibility 评分 |
| VoiceOver (macOS) / NVDA (Windows) | 屏幕阅读器 | 真实体验测试 |
| jest-axe | 单元测试 | CI 中自动检测 |
| 键盘测试 | 手动 | 拔掉鼠标,只用键盘操作 |
常见面试问题
Q1: 什么是 HTML 语义化?有什么好处?
答案:
语义化是使用含义正确的 HTML 标签来表达内容结构,比如用 <nav> 表示导航而非 <div class="nav">。好处:
- 可访问性 — 屏幕阅读器可识别页面结构,用户可直接跳转到导航、主内容等
- SEO — 搜索引擎更准确理解内容层级,有助于排名
- 可维护性 — 代码自描述,团队协作效率更高
- 跨设备 — 语义结构天然适合不同设备的渲染和布局
Q2: <section> 和 <div> 有什么区别?<article> 和 <section> 呢?
答案:
<div>— 无语义,纯粹的容器,用于布局和 CSS 分组<section>— 有主题的区块,通常有标题,表示页面中的一个逻辑部分<article>— 独立完整的内容,可以脱离上下文独立存在(如一篇博客、一条评论)
判断标准:内容能独立分享/订阅吗?能 → <article>;是页面的一个主题区块?→ <section>;纯布局需要?→ <div>。
Q3: 什么是 ARIA?什么时候需要使用?
答案:
ARIA(Accessible Rich Internet Applications)是一组 HTML 属性,用于给自定义组件补充语义信息。
使用原则:优先用原生 HTML(<button>、<input>、<select>),只在原生元素无法满足时才用 ARIA。比如自定义 Tab 组件需要 role="tablist"、role="tab"、aria-selected 等属性。
常见属性:role(角色)、aria-label(标签)、aria-expanded(展开状态)、aria-hidden(隐藏)、aria-live(动态内容播报)。
Q4: 如何让一个自定义组件(如 Dropdown)支持键盘操作?
答案:
- 触发按钮用
<button>(原生键盘支持),设置aria-expanded和aria-haspopup - 选项列表用
role="listbox",每个选项role="option"+aria-selected - 监听
onKeyDown:ArrowDown/ArrowUp移动焦点,Enter选中,Escape关闭 - 用
tabIndex管理哪个选项可聚焦(roving tabindex 模式) - 打开时焦点进入列表,关闭时焦点回到触发按钮
Q5: 什么是焦点陷阱(Focus Trap)?什么场景需要?
答案:
焦点陷阱是指限制 Tab 焦点只在特定区域内循环,不会跳到背后的页面元素。
需要的场景:Modal/Dialog、全屏抽屉、确认弹窗。当这些覆盖层打开时,用户不应该 Tab 到被遮挡的内容上。
实现:拦截 Tab 事件,到最后一个元素时跳回第一个,Shift+Tab 到第一个时跳到最后一个。关闭时恢复之前的焦点。
Q6: alt 属性应该怎么写?什么时候可以为空?
答案:
- 信息性图片 —
alt描述图片内容:alt="2026年Q1销售数据柱状图" - 功能性图片(如链接内的图片) —
alt描述功能:alt="返回首页" - 装饰性图片 —
alt=""留空(不是省略 alt 属性),屏幕阅读器会跳过它 - 复杂图表 — 简短
alt+aria-describedby指向详细描述
<!-- 信息性 -->
<img src="chart.png" alt="2026年销售额同比增长23%" />
<!-- 功能性 -->
<a href="/"><img src="logo.png" alt="返回首页" /></a>
<!-- 装饰性 -->
<img src="divider.svg" alt="" />
Q7: aria-live 的作用是什么?polite 和 assertive 有什么区别?
答案:
aria-live 让屏幕阅读器自动播报区域内容的变化,无需用户手动导航到该区域。
polite— 等用户当前操作完成后再播报(如搜索结果数量更新)assertive— 立即打断当前播报(如表单提交失败的错误消息)off— 不播报(默认)
Q8: WCAG 的 AA 级别要求颜色对比度是多少?如何检测?
答案:
- 普通文字:对比度 ≥ 4.5:1
- 大文字(≥ 18px 粗体或 ≥ 24px):对比度 ≥ 3:1
- UI 组件和图形:对比度 ≥ 3:1
检测工具:Chrome DevTools(检查元素时显示对比度)、axe DevTools 插件、WebAIM Contrast Checker。