
1. 一次“看不见”的排查地标缺失的页面到底多难用去年接了一个后台管理系统的无障碍改造工单用户的反馈只有一句话“订单列表到底在哪我按了十几分钟Tab都没找到。”一开始产品经理还以为是数据权限问题结果我用VoiceOver实际过了一遍页面才发现问题根本不是功能缺失而是这个页面的所有区块都是用裸div堆出来的从头到尾没有任何地标Landmark。对鼠标用户来说页面有清晰的视觉分层顶部导航、侧边栏、内容区、底部状态栏但对屏幕阅读器用户来说这些视觉信息完全不存在他们面前只有一条接一条的链接、按钮、输入框组成的“信息长河”只能靠Tab键从头摸到尾。如果一个页面有上百个可聚焦元素想找到某个区域就只能硬着头皮挨个听这个体验有多糟糕可想而知。Landmarks Pattern 就是专门解决这个问题的。它的核心思想是在无障碍树Accessibility Tree里给页面的主要区域打上语义标签让屏幕阅读器用户可以像鼠标用户扫视页面一样快速“跳转”到他们想要的区块。VoiceOver的转子Rotor、NVDA的元素列表、JAWS的对话框全都是基于地标来组织导航的。也就是说地标不是给视觉用户看的而是给“页面空间感”服务的。这个模式适用于所有需要构建复杂页面的场景后台管理系统、电商页面、内容门户、SaaS产品、表单流程……凡是页面里存在“区域划分”的地方就有Landmarks的用武之地。尤其是组件化开发越来越普及的今天地标如果不在组件层就规划好等到页面拼装完成后再去补就处处受制于组件结构的限制改起来伤筋动骨。这篇文章我就围绕实际项目里怎么落地Landmarks Pattern把原理、实操、坑点和验证方法一次说透。2. 从规范到实践Landmarks的底层机制与浏览器行为2.1 ARIA Landmark角色的完整体系ARIA规范里定义了一套地标角色它们描述的是“页面中的一个有意义的语义区块”。我先把常用角色和它们对应的HTML原生标签整理成一张表方便对照ARIA角色HTML原生等价物典型用途页面中的数量限制bannerheader作为body直接子元素时网站全局头部页面级最多1个navigationnav主导航、侧边导航、页脚导航可多个需用可访问名称区分mainmain页面核心内容区页面级最多1个complementaryaside作为body直接子元素时侧边栏、相关推荐、辅助信息可多个contentinfofooter作为body直接子元素时版权、备案、底部信息页面级最多1个search无原生等效搜索表单区域建议最多1个formform需有可访问名称页面级核心表单可多个regionsection需有可访问名称一般性重要区块、分组可多个这里有个特别容易混淆的点header不是在任何位置都等于banner。规范里的原话是只有当header是body直接子元素、没有被任何分区内容article、section、nav、aside等包裹时它才映射为banner地标。同理footer只有在body层级才映射为contentinfo。如果你在article里面放了一个header那它不具备任何地标语义只是文章内部的一个区块标题。这个坑我在下面“踩坑记录”部分会详细展开。2.2 隐式地标与显式地标的取舍实践中你会发现很多原生HTML元素自带地标语义这就是隐式地标。比如你写了一个nav屏幕阅读器的地标列表里就会多出一个“导航”条目不需要额外加任何ARIA属性。那为什么还要用role属性去显式声明呢因为不是所有场景都有合适的原生标签可以选。最常见的例子是搜索区域。HTML里没有search这样的原生标签新版HTML规范曾在讨论这个元素但兼容性远不如div rolesearch稳妥所以你只能用form rolesearch或者div rolesearch声明。另一个典型场景是当你用前端框架渲染单页应用时路由切换只替换中间内容区不想因此重新渲染整个页面骨架。这个时候Frame层面的banner、navigation、contentinfo往往在根组件里就固定了内容区用一个main包裹但React Router的Outlet /或Vue的router-view切换后内容区里的地标结构可能需要动态调整这种场景下显式地标比隐式地标更可控。我的原则是能用原生标签就用原生标签原生语义满足不了再补role。因为原生标签不仅提供地标语义还自带角色、状态等一堆无障碍属性比如nav本身就有一个隐含的navigationrole屏幕阅读器还会把它识别为可跳转的“ landmark ”。如果你非要写div classnav rolenavigation效果一样但代码语义上就退化了。唯一需要留意的是原生标签的隐式角色受上下文影响就是上面说的header/footer问题而显式role可以强制声明不受嵌套层级干扰。2.3 屏幕阅读器用户实际怎么“用”地标理解地标不能只停留在“写上role就完事”的层面得知道读屏软件到底怎么消费这些信息。以VoiceOver为例在iOS和macOS上用户通过转子手势或旁白快捷键打开“地标”列表就能看到页面上所有地标区域的列表直接选择即可跳转。NVMe上对应的功能是NVDAF7打开元素列表可以切换到“地标”选项卡。JAWS则提供“导航地标”对话框。这些列表里展示的信息就是地标角色 可访问名称Accessible Name。所以地标能否被高效使用取决于两件事一是角色正确二是名称清晰。举个例子一个页面有两个nav一个在顶部一个在页脚如果都没有可访问名称读屏列表里会出现两条一模一样的“导航”用户根本分不清哪个是主导航、哪个是页脚导航。这时候就需要给它们分别加aria-label主导航和aria-label页脚导航。我见过很多团队只写了rolenavigation却不写名称等于给用户看了两扇没有门牌的门。还有一个关键行为读屏的“跳转下一个地标”快捷键比如NVDAD默认只跳一级地标而不会进入地标的内部元素。也就是说地标是一个“容器级”导航单元它帮用户先定位到大概区域进入区域后用户仍需要依靠标题层级、链接列表等继续细逛。因此Landmarks Pattern通常和Heading Pattern是配套使用的。很多无障碍最佳实践清单里都强调“先地标、再标题”就是这个原因——它们分别解决“页面有哪些大区”和“每个大区里是什么内容”的问题。3. 组件化落地在真实项目中定义Landmarks的实操方案3.1 页面骨架级地标的组件化封装在实际的前端项目里我推荐的做法是把地标语义固化到骨架组件中而不是每次写页面时临时手写。假设你用的是React一个典型的后台管理系统布局组件大概长这样function AppLayout({ children }) { return ( div classNameapp-layout header classNamelayout-header rolebanner span classNamelogo某某管理后台/span nav aria-label主导航 {/* 菜单项 */} /nav /header div classNamelayout-body aside classNamelayout-aside aria-label侧边栏 {/* 侧边导航 */} /aside main classNamelayout-main idmain-content tabIndex{-1} {children} /main /div footer classNamelayout-footer rolecontentinfo {/* 版权信息 */} /footer /div ); }这里有几个很容易被忽略的细节。第一header作为body子组件渲染出来后如果它在组件树里的位置确实处于body顶层原生语义已经是banner了所以其实rolebanner可以不加。但我在实际项目中还是会显式加上原因很简单框架的组件树和最终DOM树不一定完全一致某些布局容器可能会改变语义上下文显式声明能避免上层变更时误伤地标结构。第二idmain-content加上tabIndex{-1}是为了配合“跳转到主要内容”的跳过链接Skip Link。点击跳过链接会触发element.focus()如果不设置tabIndexmain本身不可聚焦焦点只会移到main内的第一个可聚焦元素而不是整块内容区。这是我踩过的真实问题用户点击“跳到内容”后焦点没有落在主要内容开头而是直接跳到了正文里的第一个表格按钮上读屏直接开始朗读表格内容用户一头雾水。Vue项目里思路一模一样只是组件命名习惯略有差异。你可以把骨架组件设计成插槽形式把地标语义固化在插槽容器上template div classapp-layout header classlayout-header rolebanner slot nameheader / /header nav classlayout-nav :aria-labelnavLabel slot namenav / /nav main classlayout-main idmain-content tabindex-1 slot / /main footer classlayout-footer rolecontentinfo slot namefooter / /footer /div /template关键点是地标语义应该由布局容器自己声明而不是由使用方传入。如果让每个业务页自己决定“我这个页面要不要main”很容易出现有的页面漏写、有的页面写了一堆嵌套main的情况。骨架层统一声明业务页只需要关心内容本身地标结构自然保持一致。3.2 交互组件内的二级地标处理骨架层的banner、main、contentinfo解决了页面级别的大分区问题但往下一层比如一个复杂表单页表单内部有“基础信息、收货地址、支付方式”三个区块这种场景要不要用地标我的判断标准是这个区块是否值得用户在读屏的地标列表里单独看到并快速跳转如果用户需要反复在几个区块之间切换填写那就值得用region或form地标。一个比较规范的写法是给每个区块的section加上可访问名称section aria-labelledbybasic-info-heading h2 idbasic-info-heading基础信息/h2 !-- 表单项 -- /section section aria-labelledbyshipping-heading h2 idshipping-heading收货地址/h2 !-- 表单项 -- /section这里的section因为带了可访问名称aria-labelledby关联到标题所以会被映射为region地标。这一点特别重要没有可访问名称的section在无障碍树里只是普通的分区节点不构成region地标。你可以在浏览器的Accessibility面板里验证一个不带标题的section根本不会出现在地标列表里。所以当你想用section来构建地标时务必给它一个标题或者aria-label。对于form也一样form默认只有它拥有可访问名称通过aria-labelledby、aria-label或关联legend的fieldset时才映射为form地标。如果一个form没有任何名称它在地标列表里不会出现。这里有个需要权衡的点页面上如果有多个普通表单比如搜索框、筛选器、问卷填写它们都会成为form地标导致地标列表条目过多。所以规范建议form地标只用于页面级的主要表单普通子表单用region或直接不用地标。3.3 可访问名称的确定规则可访问名称的优先级顺序是aria-labelledbyaria-label 原生属性比如alt、title 元素内容文本。我给导航、区域、表单命名的时候遵循一个简单原则优先用页面上可见的文本避免用屏幕阅读器用户看不见的aria-label。举个例子侧边栏区块如果页面上已经有个“筛选条件”的标题那就用aria-labelledby关联它而不是写死一个aria-label筛选。原因是当页面视觉文本和读屏名称不一致时视障用户和明眼人协作讨论页面时可能对不上号。我在一次产品验收时遇到过一个真实的冲突开发者在侧边栏写了个aria-label过滤条件页面视觉标题却是“高级筛选”结果产品经理问“怎么读屏里叫过滤条件”就是因为这两套命名没有对齐。当然有些区域确实没有可见标题可关联此时用aria-label也是合理的。比如三个平级的导航区块没有可见的文本标签就可以用aria-label页脚导航这样的方式区分。命名时注意简洁读屏会把“角色名称”一起读出来比如“导航主导航”所以名称里不要重复“导航”两个字避免读成“导航主导航导航”。4. 踩坑记录构建Landmarks时遇到过的典型问题与修复链路4.1 路由切换后main地标“消失”或变成多个这是我在SPA项目里遇到最多的一个坑。当时的情况是页面同时存在两套布局一套给已登录用户用一套给游客用。登录态切换时React把整棵布局树卸载重新挂载此时新旧两个布局在切换的瞬间都存在于DOM里导致页面出现了两个main。屏幕阅读器对重复地标容忍度很低用户在地标列表里看到两个“主要内容”非常困惑。排查链路是这样的我先在VoiceOver的转子列表里数了一下确实出现了两个“主要”。然后回到Chrome DevTools的Accessibility面板逐个查看两个main节点的计算角色结果都是main。这时候我把问题定位到布局卸载和挂载的交错时机上加了过渡动画包了一层延迟渲染的容器导致旧树在卸载后的一段时间内仍然占着DOM位置。修复方案有两个一是如果项目里有无需过渡动画的布局切换就让React用key分别管理两棵布局树确保卸载和挂载是同步的二是如果必须保留过渡就给非活动布局的wrapper加aria-hiddentrue和inert属性让它完全从无障碍树中移除。我这里多说一句inert它比aria-hidden更彻底——aria-hidden只是让读屏忽略但焦点仍然可以Tab进去存在“幽灵焦点”风险inert会让整个子树既不可见也不可聚焦是处理过渡动画/离屏面板的正确姿势。4.2 卡在视觉布局里的contentinfo嵌套问题另一个让我记忆深刻的坑来自一个营销落地页。设计师把版权信息放在了一个div classfooter-wrapper里面然后在它内部用了一个footer。在DOM里这个footer不再是body的直接子元素因为它被一层div包裹了。但按规范footer要映射为contentinfo地标要求它必须是body的直属子元素也就是说div这层包裹会让它失去contentinfo语义。结果就是我在读屏的地标列表里完全找不到“页脚信息”这个地标用户只能一路Tab到底才能听到版权信息。说实话这个问题特别隐蔽因为视觉上没有任何变化。排查时我一开始怀疑是JS动态渲染问题后来用Accessibility面板看了它的计算角色发现是generic也就是普通div这才意识到是嵌套层级问题。修复方法很简单把footer从内层div里提出来或者干脆给这个内层footer补一个rolecontentinfo。我最后选择了后者因为布局层级的限制有时候动起来牵扯到整个样式系统显式声明角色比调整DOM结构成本低很多。这类问题还提醒我一件事不要假设原生标签会自动给你想要的地标语义尤其是在现代前端框架的组件嵌套下。代码审查时我通常会专门检查header、footer、aside这些标签在DOM树里的实际位置确认它们没有被无意中夹在非语义容器之间变成通用区块。4.3 标题层级和名称命名打架导致地标名称混乱有一种情况是一个主导航区块视觉上有标题“产品中心”但导航组件内部还有个h3产品中心/h3用来做视觉装饰开发在同一个元素上既写了aria-label产品中心又用aria-labelledby去关联这个h3结果aria-labelledby优先而h3恰好被aria-hidden包裹了读屏最终读不到任何名称整个导航在转子列表里显示为无名称的“导航”。这类问题的根因是对可访问名称计算规则不够熟悉。我现在的经验是一个地标区域只保留一种命名来源。要么用可见的标题文本关联aria-labelledby要么用私有音频说明aria-label不要两手都抓。同时要特别警惕aria-hidden的“传染性”。屏幕阅读器计算可访问名称时如果引用的节点本身被aria-hidden隐藏了那aria-labelledby的引用会失效。这也是为什么我强烈建议在做无障碍审查时打开浏览器的Accessibility面板把Accessibility Tree里的名称和角色逐条对比因为视觉检查和代码检查都不一定能抓到这种隐性关联失效。4.4 移动端抽屉导航带来的重复地标问题移动端最常见的模式是桌面端的顶部导航在移动端被收纳进了一个抽屉但你在DOM里仍然保留桌面端导航的代码可能用CSS隐藏而抽屉里又渲染了一份导航。最终同一个页面出现了两个navigation地标名称还都叫“主导航”。读屏用户打开地标列表两条完全相同的条目跳哪个都对不上视觉位置。这个问题的修复思路是隐藏的那份导航不能只靠CSS如display:none之外的方式隐藏还要确认它真的从无障碍树里消失了。准确说display: none或visibility: hidden的元素确实不会出现在无障碍树里但有个经典的反例是很多团队喜欢用clip-path、position: absolute; left: -9999px之类的方式“视觉隐藏”文字或区块尤其是有SEO或排版需求时。这种元素仍然暴露在无障碍树里屏幕阅读器能听到它们。我在一个项目里就遇到过一次桌面导航的一个子菜单用了left: -9999px做离屏处理结果VoiceOver用户在地标列表里听到了一个隐藏在屏幕外的二级导航点过去后焦点跑到页面左侧看不见的地方体验完全是灾难。所以移动端抽屉场景下我推荐的方案是桌面导航和抽屉导航不要都渲染或至少有条件渲染其中一份如果因为交互需要必须同时渲染那么不可见的那份一定要加aria-hiddentrue和inert。但更稳妥的做法是结合媒体查询和JS状态来判断渲染哪一份而不是让两份同时存在。这个经验对使用Tailwind之类的CSS框架时尤其重要因为hidden md:block这种响应式工具类很容易让两份导航同时挂在DOM上视觉上没问题但无障碍树里就乱套了。5. 验证闭环工具检测与真实读屏的配合5.1 自动化工具能管到哪一步、管不到哪一步Landmarks的问题一部分可以用自动化工具扫出来一部分必须靠人工。axe DevTools在这方面是目前我用下来最顺手的它有一条专门的规则叫landmark-unique能检查同类型的多个地标是否都有唯一的可访问名称。还有region规则检查内容区块是否都包在有效的region地标里。你可以在Chrome的Lighthouse里也开一下无障碍审计它会报告页面“缺少地标”之类的问题。但自动化工具的盲区也很明显它只能告诉你“有没有写role”很难告诉你“这个role写在这里合不合理”。比如它检测到页面有两个main会报错但检测不到main里嵌套了一个拖拽组件内部的roledialog干扰了读屏的焦点顺序——这些语义上的粒度判断还是需要人来做。另一个自动化工具经常漏掉的问题是地标本身存在但包裹它的父级被脚本动态修改了CSS比如父级在失去焦点时被隐藏导致读屏用户跳转到地标后焦点无处存放这种状态型问题自动化工具基本发现不了。所以我习惯的验证顺序是先用axe做一轮全面扫描把明显的结构性错误清掉然后打开Chrome DevTools的Accessibility面板手动选择页面上的地标节点逐个确认角色和名称最后切到真实读屏环境用用户的路径去走一遍。5.2 真实读屏环境的操作路径不同平台我推荐的组合是macOS上用VoiceOverSafariWindows上用NVDAFirefox或JAWSChrome。你的目标环境如果偏Windows企业场景NVDA和JAWS必须至少测一个因为两者在某些属性支持上存在差异。这里我建议团队准备好一套固定的“地标巡检脚本”把重复性工作标准化。我自己的脚本是这样的打开目标页面等待加载完成。用读屏的“地标列表”快捷键VoiceOver是VOU选“Landmarks”NVDA是NVDAF7选“地标”列出所有地标。逐条记录地标列表中的条目角色名称和产品设计稿的区域划分进行比对确认没有缺失、重复、命名不清。在地标列表里选择跳转到“主要内容”确认焦点落点合理且随后可以用Tab键顺序进入内容区的可交互元素。如果页面包含动态更新比如路由切换、弹窗打开、列表加载重复第2步确认地标列表没有随状态变化出现“幽灵”地标。整套走一遍大概二十分钟。对一个中大型后台系统来说二十分钟换一次“用户视角安全确认”我觉得非常值。5.3 把地标检查纳入组件验收的回归策略最后聊一下团队协作层面的落地。单独一个人盯地标是盯不牢的因为地标的破坏往往来自后续迭代的“顺手动一下”——比如某个基础组件加了半透明遮罩层、某个弹窗组件被塞进了布局容器里这些改动都能悄悄改变无障碍树的结构。所以我在团队里推行了两条硬性规则。第一组件库的核心骨架组件都必须提供一个JSON形式的“地标清单”比如一个布局组件应该在哪些条件下暴露哪些地标写清楚并在组件文档里展示。这样业务方在使用组件时不需要重新理解无障碍语义只需要保证传入的数据不破坏地标结构。第二核心布局相关的PR强制要求附带一次读屏巡检的结果截图或记录。听起来有点苛刻但正是这个“麻烦”的流程挡住过好多次看起来人畜无害、实际上会破坏地标结构的改动。我个人的体感是Landmarks Pattern是所有无障碍模式里性价比最高的一种。它不像焦点管理那样充斥着各种时序和边界问题也不像ARIA状态那样需要精确的同步逻辑它的核心就是“为页面建立清晰的区域坐标系”。只要在骨架组件里把地标结构立住把命名规范定清楚然后把验证流程嵌进日常开发里这块基本就不会再出大问题。对了最后分享一个小技巧排查地标问题时先把rolemain、rolebanner这些关键地标元素加上一个临时的视觉边框比如outline: 2px solid red你就能在页面上“看见”读屏用户听到的地标结构很多嵌套和重复问题一眼就能抓出来。这个土办法我用了很久比反复切面板效率高得多。