Oat UI语义化骨架屏:role=status与aria-busy实践
我在几个内容型项目里都接过加载态这块早期做法很粗暴一个转圈图标走天下。直到有一次被人指着屏幕问了一句用户点开列表转了几百毫秒内容回来页面高度从 0 撑到一千多像素他的手指正好悬在下面那个按钮上按钮跑了。这件事之后我才认真去做骨架屏。Oat UI 这套东西值得单独拿出来讲是因为它把骨架屏当成一个语义问题处理而不是纯粹的样式问题——视觉上的灰色块谁都会写难的是让读屏用户知道这块区域正在加载、加载完了该说什么、以及怎么避免那块区域被反复播报成噪音。它的答案藏在rolestatus这个看起来毫无存在感的 ARIA 角色里。这篇内容适合三类人看正在用 Oat UI 或者类似的无 class/低 class 语义化组件库的前端自己写过骨架屏但没管过无障碍的开发者以及被 CLS累计布局偏移指标追着跑、想把首屏体验抠细一点的人。你不需要预先懂 ARIA我会把rolestatus、aria-busy、live region 这些概念用生活化的方式讲清楚也不需要你从零设计一套样式体系文章里给出的结构和参数可以直接拿去改。1. 骨架屏到底解决了什么问题以及它不适合解决什么1.1 从一次首屏抖动说起先说清楚骨架屏的定位。它不是加载动画的升级版它解决的核心问题是预期管理和布局稳定性这两件事动画只是顺带。用户点击进入新页面到内容出现之间有一段真空期。这段真空期里如果什么都不显示用户会怀疑是不是没点上如果显示一个居中的转圈用户知道在加载但完全不知道加载完之后会出来什么东西、有多长、是列表还是卡片。骨架屏的做法是提前把内容的形状画出来用灰色块占位等于告诉用户这里会有三行标题下面是一张图再下面是一个两列的列表。用户的注意力被结构接住了等待感会明显下降。更实际的一层价值是布局稳定。内容从零高度突然撑开会把下方所有元素往下推用户正在阅读的内容会跳走正在瞄准的按钮会移位。这是 CLS 指标最典型的扣分项。骨架屏在内容到达之前就把空间占住了等内容真的回来只是把灰色块换成文字位置不动。1.2 和转圈、进度条、空态的分工这几种加载态不是互相替代的关系各有各的地盘混用会显得很乱。加载态形式适用场景关键特征常见误用骨架屏首屏、列表页、卡片流、详情页结构可预测占位高度固定用在高度不确定的内容上反而制造抖动转圈图标按钮内联操作、弹窗内提交、局部刷新动作短、区域小、结构不重要全屏加载用转圈用户不知道在等什么进度条上传、导入、批量处理进度可量化假进度条跑到 90% 卡住不动空态请求成功但数据为空不是加载态是结果态把空态画成骨架屏用户一直等我的判断标准很简单如果用户能大致猜到内容长什么样就用骨架屏猜不到或者区域很小就用转圈耗时超过两三秒且有明确进度才上进度条。还有一个容易忽略的点骨架屏只在内容结构可预测时才是加分项。比如一个富文本详情页正文长度从两百字到八千字不等你画一个固定高度的骨架加载完内容还是会把页面撑开抖动一点没省反而多了一次视觉切换。这种场景老老实实用一个细长的顶部进度条或者干脆留白加淡入体验更干净。注意骨架屏不是越多越好。一个页面上同时出现三块骨架屏、各自闪各自的节奏观感比一个转圈更糟。分区加载时尽量让骨架动画的节奏统一。1.3 为什么选择在 Oat UI 里做这件事Oat UI 这类组件库的设计取向很鲜明尽量用原生 HTML 元素承载语义样式通过元素选择器和属性选择器挂上去少写 class。这带来一个直接后果——它天然适合做骨架屏因为骨架屏本质上就是给一个容器加上状态标记然后按状态切换内部结构这正是属性选择器最擅长的活。传统的 class 方案要维护.skeleton、.skeleton--active、.skeleton__line一堆命名还要处理状态和样式的同步。属性方案则是[aria-busytrue]直接就是选择器状态标记和视觉表现是同一份数据不会出现DOM 上写着加载中样式却是加载完的错位。我选它做骨架屏还有第二个理由这套约定强迫你在写视觉之前先想清楚语义。你得回答这个区域当前是什么状态这个状态该不该被读屏播报切换的时候播报什么内容。这些问题在纯 class 方案里很容易被跳过在属性驱动的方案里跳不过去。2. 组件结构设计把视觉和语义拆成两层2.1 三层结构容器、占位块、播报区写骨架屏最容易犯的错是把所有东西糊成一个 div。我的做法固定拆成三层职责互不重叠。第一层是容器也就是被替换的那块区域。它承担两个职责撑住高度防止 CLS以及挂载状态标记aria-busy。第二层是占位块纯视觉一组灰色矩形全部标aria-hiddentrue因为读屏用户不需要知道这里有一个 320×16 的矩形。第三层是播报区一个视觉上不可见、但存在于无障碍树里的文本节点外面包着rolestatus。三层分开之后每层改起来都不牵连别人。想调骨架的颜色只动第二层想改播报文案只动第三层想换高度只动第一层。!-- 容器负责撑高和状态标记 -- section classcard-list aria-busytrue aria-label文章列表 !-- 播报区给读屏用户的一句话 -- p rolestatus classsr-only正在加载文章列表/p !-- 占位块纯视觉 -- div classskeleton-stack aria-hiddentrue div classskeleton-item div classskeleton-thumb/div div classskeleton-lines div classskeleton-line style--w: 82%/div div classskeleton-line style--w: 64%/div div classskeleton-line style--w: 40%/div /div /div /div /section注意aria-label用的是文章列表这种业务名不是骨架屏。读屏用户听到的是文章列表正在加载文章列表语义连贯如果写成骨架屏加载中那是在描述实现不是在描述内容。.sr-only这个工具类要自己准备一份核心是position: absolute加 1px 尺寸加clip-path让元素在视觉上消失但保留在无障碍树里。千万别用display: none或者visibility: hidden那会把它从无障碍树里一起抹掉rolestatus也就失效了。2.2 为什么占位块必须 aria-hidden这一点值得单独讲因为很多人漏掉。占位块通常是十几个甚至几十个 div。如果它们暴露在无障碍树里读屏用户点进这块区域会听到一连串分组、分组、分组完全不知道在读什么。更糟的是这会让整个区域的语义变得混乱rolestatus的播报文本被淹没在一堆空节点里。给占位块的最外层加一个aria-hiddentrue就够了子树会被整体隐藏不需要逐个子节点标。我见过有人在每个.skeleton-line上都写一遍能跑但没必要。提示aria-hiddentrue只能用在没有可聚焦元素、没有可交互元素的子树上。骨架屏里如果放了假的按钮形状那个假按钮一定要是div而不是button否则会出现焦点跳到隐藏元素这种更麻烦的问题。2.3 状态标记挂在哪一层aria-busy挂的位置有讲究挂在正在更新的那个区域上而不是挂在最外层容器上。举例一个页面里有列表区和侧边栏只有列表区在加载那aria-busytrue就应该挂在列表区。挂在body上等于告诉辅助技术整个页面都在变代价是读屏用户在当前区域的任何操作都可能被延迟——因为它认为这块内容还没稳定不该被朗读。aria-busytrue的作用是让辅助技术暂缓播报这块区域的变动等内容稳定了再一次性播报。所以它在批量插入 DOM 的场景下特别有用你可以先设aria-busytrue一口气插入二十条数据删掉骨架再把aria-busy去掉读屏用户听到的是一次完整的更新而不是二十次碎片化的插播。这跟rolestatus是配合关系不是重复关系。aria-busy管什么时候说rolestatus管说什么、以什么语气说。3. rolestatus 的语义细节以及它和 aria-live 的关系3.1 live region 的三种角色怎么选rolestatus属于 ARIA 的 live region 家族这个家族的核心概念是一块区域的内容发生变化时辅助技术主动播报不需要用户把焦点移过去。三种常见角色区别在于打扰强度。角色隐含 aria-live隐含 aria-atomic打扰强度典型用途rolestatuspolitetrue低等当前朗读结束再播加载状态、保存成功、搜索无结果rolealertassertivetrue高立刻打断当前朗读表单校验失败、操作不可撤销的警告rolelogpolitefalse低只播新增部分聊天消息流、实时日志骨架屏选rolestatus几乎是唯一正确答案。加载不是紧急事件没必要打断用户正在听的内容rolealert那种啪一下切过去的体验用一个加载提示去触发是非常粗暴的。顺带说一句rolelog的aria-atomicfalse。它只播报新增的节点适合流式追加。如果你的场景是列表不断追加新条目那在列表容器上挂rolelog比挂rolestatus更合适能避免每次追加都重播整段文字。不过这是另一个话题骨架屏本身不涉及。3.2 一个关键事实初始内容不会被播报这是最容易踩坑的地方也是我调试了很久才搞明白的。rolestatus区域在页面初始渲染时里面已有的文本不会被播报。只有页面加载完成之后、在运行期发生的内容变化才会触发播报。这意味着如果你把p rolestatus正在加载/p直接写死在服务端返回的 HTML 里读屏用户进入页面时一个字也不会听到。他会听到文章列表这个aria-label然后是一片静默完全不知道发生了什么。不同的实现路径处理方式不一样服务端渲染首屏骨架rolestatus里可以先留空等 JS 接管的第一个微任务里再写入正在加载文本这样构成了一次变化会触发播报。或者更稳妥的做法是首屏用aria-busytruearia-label组合让读屏用户在进入区域时就知道这块正在加载不依赖 live region 播报。客户端路由切换骨架是在页面已经运行之后才插入的插入动作本身就会触发播报反而不用额外处理。我的实际做法是两条腿走路首屏靠aria-busyaria-label后续切换靠rolestatus的文本更新。这样无论用户从哪个入口进来都能拿到有效信息。3.3 播报文案怎么写才不烦人文案这块看着小事但用久了差异很明显。几个原则第一说业务不说实现。正在加载文章列表优于骨架屏已显示正在加载第 2 页优于正在请求数据。用户关心的是内容不是你的技术方案。第二不要报进度除非进度是真实的。正在加载 37%这种编号是最糟糕的因为它是假的用户在等一个永远到不了 100% 的数字。如果没有真实进度就说正在加载句号。第三加载完成后要有收尾。这一点很多人漏掉。骨架消失、内容出现视觉上是连续的变化但读屏用户需要一个明确的信号加载完成共 12 条。如果不给他会一直以为自己还在等待。第四不要反复播报。如果你的加载态是轮询触发的或者组件因为父级重渲染而反复重建骨架rolestatus就会隔几秒播一次正在加载非常烦。解法是判断是否已有内容有内容时不再渲染骨架或者对播报区做去重文本没变就不写 DOM。// 简单的播报去重内容相同就不碰 DOM function announce(el, text) { if (!el || el.textContent text) return; el.textContent text; }这几行代码看着平平无奇但它救了我好几次。特别是在一个带轮询的看板页面里每秒一次的刷新会把 live region 变成一个复读机加上这个判断之后立刻干净了。4. 完整实现从 HTML 结构到 CSS 参数4.1 骨架块的尺寸怎么标定骨架屏做得好不好八成取决于尺寸标定。灰色块的高度、间距、圆角如果和真实内容对不上加载完成的那一瞬间就是一个跳变比不用骨架屏还难看。我的做法是先在真实内容上量三个数行高、行间距、块间距。然后按下面的规则换算文字行骨架高度 真实行高 × 0.72。留出余量是因为灰色块比文字看起来更重满高度会显得笨重。骨架行间距 真实行高 × 0.45。比真实行间距略小视觉上更紧凑。图片、缩略图骨架按真实宽高比写死用aspect-ratio而不是固定像素这样响应式下不会变形。骨架圆角跟真实元素保持一致别一个 8px 一个 4px。举个具体的正文font-size: 16px; line-height: 1.7实际行高 27.2px。骨架条高度取 27.2 × 0.72 ≈ 19.6px取整 20px。行间距取 27.2 × 0.45 ≈ 12px。每行占位整体高度 32px跟真实的一行 27.2px 差 4.8px三行累积偏差 14.4px在这个量级上肉眼基本看不出来。偏差容忍度有个经验值整块骨架的总高度与真实内容总高度误差控制在 5% 以内。超过这个数加载完成时的跳动就会比较明显了。4.2 CSS 变量驱动的样式实现用 CSS 变量而不是硬编码颜色是为了让骨架能跟着主题走包括暗色模式。:root { --skeleton-base: #eceff3; --skeleton-shine: #f7f9fb; --skeleton-radius: 6px; --skeleton-line-height: 20px; --skeleton-line-gap: 12px; --skeleton-speed: 1.4s; } media (prefers-color-scheme: dark) { :root { --skeleton-base: #24282e; --skeleton-shine: #31363d; } } .skeleton-line, .skeleton-thumb { background-color: var(--skeleton-base); border-radius: var(--skeleton-radius); } .skeleton-line { height: var(--skeleton-line-height); width: var(--w, 100%); } .skeleton-lines { display: flex; flex-direction: column; gap: var(--skeleton-line-gap); flex: 1; } .skeleton-thumb { width: 96px; aspect-ratio: 4 / 3; flex: none; } .skeleton-item { display: flex; gap: 16px; padding: 16px 0; }这里有几个细节值得一提。--w变量配合width: var(--w, 100%)是个挺省事的小技巧不同长度的线条直接在行内写style--w: 64%视觉上就有了长短错落比全部 100% 宽度自然得多——真实的一段文字本来就是长短不一的。aspect-ratio在缩略图上用替代固定宽高响应式下容器变窄高度自动跟着缩不会出现拉伸。4.3 动画实现与性能取舍骨架动画最常见的是扫光一道浅色渐变从左往右扫过。实现方式有两种性能差不少。第一种是给background-position做动画.skeleton-line { background-image: linear-gradient( 90deg, var(--skeleton-base) 25%, var(--skeleton-shine) 50%, var(--skeleton-base) 75% ); background-size: 200% 100%; animation: shimmer 1.4s ease-in-out infinite; } keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }能跑效果也对但background-position的动画会触发重绘在低端设备上叠加十几个骨架块时帧率会掉。更要命的是它没法用 GPU 合成层。第二种是用伪元素遮罩加transform.skeleton-line { position: relative; overflow: hidden; background-color: var(--skeleton-base); } .skeleton-line::after { content: ; position: absolute; inset: 0; transform: translateX(-100%); background-image: linear-gradient( 90deg, transparent, var(--skeleton-shine), transparent ); animation: sweep var(--skeleton-speed) ease-in-out infinite; will-change: transform; } keyframes sweep { to { transform: translateX(100%); } }transform走的是合成层只做位移不做布局和绘制帧率稳定得多。代价是每个骨架块多了一个伪元素DOM 上没增加但渲染树里有。在实际项目里一屏骨架块在十几个的量级这个开销可以接受。4.4 必须处理的 reduced motion这是我见过最常被忽略的一步。对于对闪烁敏感的用户持续运动的扫光可能引起不适系统层面的prefers-reduced-motion就是为这个准备的。media (prefers-reduced-motion: reduce) { .skeleton-line::after { animation: none; display: none; } .skeleton-line { background-color: var(--skeleton-base); opacity: 0.85; } }关掉动画之后骨架就变成一个静态的灰色块。信息量一点没少——用户依然能看到这里有内容在加载只是不再有移动的光带。提示这里的选择是animation: none而不是把速度调到很慢。慢速的持续动画对前庭敏感的用户来说伤害和快速动画是一样的减少运动就该彻底停。5. 状态切换与首屏闪现的控制5.1 骨架该不该延迟显示有个细节你可能也遇到过本地开发时接口飞快骨架屏闪了一下二三十毫秒就消失了。这个一闪而过的灰块观感比什么都不显示更差——用户感觉页面抖了一下。业界通行的做法是加一个显示延迟同时设定最短显示时长。延迟看门狗请求发出后等 150~200ms如果这期间数据回来了直接渲染内容骨架压根不出现。最短展示如果骨架已经出现了就让它至少待 300ms 再消失。避免出现出现 20ms 又消失的抖动。这两个参数的取值有讲究。延迟太短比如 50ms挡不住快请求延迟太长比如 400ms会让用户在真正需要骨架时先看到一段白屏。我实测下来150ms 启动 300ms 最小展示是个比较稳的组合覆盖了大多数场景。const SHOW_DELAY 150; const MIN_SHOW 300; async function loadWithSkeleton(container, fetcher, render) { const start performance.now(); let shown false; const timer setTimeout(() { container.setAttribute(aria-busy, true); container.classList.add(is-loading); shown true; }, SHOW_DELAY); let data; try { data await fetcher(); } finally { clearTimeout(timer); } if (shown) { const elapsed performance.now() - start; if (elapsed MIN_SHOW) { await new Promise(r setTimeout(r, MIN_SHOW - elapsed)); } container.classList.remove(is-loading); container.removeAttribute(aria-busy); } render(container, data); announce(container.querySelector([rolestatus]), 加载完成共 ${data.length} 条); }这段代码里有几个点是我调试出来的。计时起点用performance.now()而不是从setTimeout内部开始算因为要算的是整个请求周期不是骨架出现之后的时长。aria-busy的设置和移除要跟视觉状态严格同步不能只加 class 不管属性否则会出现视觉上加载完了但辅助技术认为还在加载的错位。try/finally包住请求是为了异常路径也能清掉定时器否则请求失败时骨架可能永远显示着。5.2 首屏骨架放服务端还是客户端如果骨架是首屏内容比如直接打开一个列表页一定要让服务端返回的 HTML 里就带着骨架。原因很直白如果是等 JS 加载完再插入骨架那用户看到的是白屏 → 骨架 → 内容三段式中间那次切换会额外付出一次渲染成本而且白屏时长被拉长了。服务端渲染骨架的做法就是把上面的 HTML 结构直接写进模板aria-busytrue一起带上JS 接管后负责替换内容并移除标记。这时候rolestatus的初始文本不会被播报前面讲过所以首屏的无障碍信息主要靠容器的aria-label和aria-busy这是可接受的取舍。有一个反向的坑如果骨架在服务端渲染但 JS 因为某种原因加载失败用户会永远停留在骨架屏状态盯着一个永远不动的灰块。所以我的做法是给首屏骨架加一个兜底的超时比如 10 秒后如果还没被替换就把骨架换成一段加载遇到问题点这里重试的提示同时把aria-busy置为 false。这个兜底不常触发但触发了就是救命。6. 常见问题与排查实录6.1 问题速查表现象可能原因排查动作处理方式读屏完全不播报加载状态rolestatus内文本是初始渲染时就有的检查该文本是否在页面加载时就存在改为运行期写入或改用aria-busyaria-label播报打断用户正在听的内容误用了rolealert检查角色换成rolestatus每隔几秒重复播报正在加载组件反复重建、轮询未去重观察 DOM 变更频率加文本去重判断有内容时不再渲染骨架骨架消失时页面往下跳骨架总高度小于真实内容对比骨架块与真实内容的累计高度补足行数或加min-height骨架消失时页面往上跳骨架行数多于真实内容同上按分页大小调整骨架条数暗色模式下骨架是一块刺眼的白颜色硬编码检查是否使用变量用 CSS 变量 prefers-color-scheme扫光动画卡顿用background-position做动画看 Performance 面板是否大量重绘改为伪元素 transform骨架闪一下就消失接口过快无延迟策略看网络面板耗时加 150ms 延迟与 300ms 最小展示加载完了读屏还在等缺收尾播报检查完成时是否更新播报文本补加载完成共 N 条6.2 几条踩坑换来的经验经验一骨架条数要和分页大小挂钩但不用一一对应。每页 20 条没必要画 20 个骨架第一屏能露出几个就画几个多画的部分用户根本看不到纯浪费渲染。我的做法是按一屏可见数量乘 1.2 取整比如一屏露出 5 个就画 6 个。这个数在移动端和桌面端差别很大可以用媒体查询给两组值。经验二aria-busy别忘了摘。这是最难查的一类问题因为视觉上一切正常但辅助技术那边一直认为这块内容还在变先别读。表现是读屏用户在这个区域里操作什么都听不到。我现在的习惯是把设置aria-busy和移除aria-busy写在同一个函数的开头和finally里物理上保证成对出现。经验三骨架的动画节奏要和整体页面统一。一个页面上如果有个按钮在转圈、有个区域在扫光两者周期不同步视觉上会很躁。把动画时长统一成一个 CSS 变量--skeleton-speed其他地方也引用它整体就整齐了。经验四不要给骨架块加交互。见过给骨架加了 hover 效果的鼠标一移上去变个色。骨架块是占位不是元素任何交互暗示都会误导用户去点击。经验五测试时把网络节流打开。骨架屏在本地开发环境里几乎看不到Fast 3G 才能看到它的真实表现。Chrome 开发者工具的 Network 面板里设成 Slow 4G再做一次完整的页面切换你会发现自己之前漏掉了多少问题——尤其是布局抖动在高速网络下根本显现不出来。7. 性能边界与可访问性的最后一公里7.1 别让无障碍标记变成性能负担rolestatus和aria-busy都是轻量属性本身开销可以忽略。真正需要注意的是频繁更新 live region 的代价每次内容变化辅助技术会被唤醒并播报这个过程在某些实现里是有成本的而且会打断用户当前的朗读。所以原则是live region 的更新次数要和用户的心智模型匹配不是和你的渲染次数匹配。用户点一次下一页听到一次正在加载再听到一次加载完成这是对的。用户点一次下一页听到十五次播报那就是 bug。具体做法上我给播报区加了一层封装所有写入都走同一个函数函数内部做去重和节流。节流的窗口设成 1000ms同一个区域一秒内最多播报一次。这个数字没什么理论依据纯粹是实测下来的体感值。7.2 骨架屏和真实内容的衔接最后说说衔接。骨架到内容的切换如果处理得生硬瞬间替换会有一种闪断的感觉。我的做法是给真实内容加一个很短的上场动画.card-list .card-item { animation: contentIn 180ms ease-out both; } keyframes contentIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } media (prefers-reduced-motion: reduce) { .card-list .card-item { animation: none; } }180ms 是个比较舒服的时长短到用户感觉不到延迟长到能消除闪断感。超过 300ms 就会显得拖沓用户会觉得页面在慢慢吐出来。同时记得在这个prefers-reduced-motion分支里也关掉它。我见过有的项目只在骨架那边做了 reduced motion 处理结果内容出现时的动画还在跑等于做了半套。7.3 什么情况下应该放弃骨架屏最后给一个反向的判断。以下几种情况我会选择不用骨架屏内容高度差异极大且无法预估比如富文本详情页。用顶部细进度条更稳。加载时间是毫秒级的局部刷新比如点击一个收藏按钮。用一个按钮内的微转圈骨架纯属多余。加载失败率高、经常需要重试的场景。用户会反复看到骨架闪来闪去不如直接给一个明确的加载中文字加取消按钮。内容为空是常态比如筛选结果页。骨架屏会强化这里应该有东西的预期结果等来一个空态落差更大。我个人的判断逻辑是骨架屏的价值 结构可预测性 × 等待时长 × 加载频率。三个乘数里任何一个接近零骨架屏就不划算。列表页、卡片流、仪表盘这三类场景乘数都比较高做骨架屏收益明显其他的先把前面两项做扎实再考虑要不要补这一块。踩过几次坑之后我的体会是骨架屏这种东西做出来不难做对很难。难的不是 CSS是那几个看不见的属性——role、aria-busy、aria-live。视觉上用户看到的是一个漂亮的扫光读屏用户听到的是一句得体的正在加载文章列表两边都照顾到了这块才算真正做完。下次你写骨架屏的时候先别急着调颜色和动画时长把播报文案和状态切换的逻辑在纸上写一遍那部分想清楚了剩下的都是体力活。