ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

语义化骨架屏与 role=status 无障碍加载态实践

语义化骨架屏与 role=status 无障碍加载态实践

骨架屏这个东西,视觉上谁都会做——拉几个圆角灰块,加一层从左到右扫过的白光,看起来就像内容正在加载。但我见过太多项目,骨架屏铺满整屏、动画丝滑,打开读屏软件一听却是彻底的静默:读屏用户只知道页面卡住了,不知道是在加载,也不知道加载什么时候结束。问题的根子不在 CSS,而在语义层。Oat UI 的 Skeleton 组件把role="status"直接写进了组件骨架里,视觉占位和听觉占位由同一个组件同时负责,这才是"语义化加载占位"这几个字的真实含义。这篇内容会把 Skeleton 组件的 DOM 结构、ARIA 属性取舍、shimmer 动画实现、React/Vue 封装时机,以及我在实际项目里踩过的坑,一条线捋清楚。适合正在做组件库、正在给现有页面补无障碍能力,或者单纯想把加载态做得更靠谱的前端同学,从刚入门到有几年经验的都能拿走点东西。

1. 从一块灰条说起:role="status" 在骨架屏里到底管什么

1.1 视觉占位和听觉占位是两条平行线

大多数人做骨架屏,脑子里只有一条线:内容还没来,先用形状接近的占位块撑住布局,避免内容到位时页面跳动,顺便给用户一个"系统在干活"的暗示。这条线完全是视觉的。

还有第二条线,几乎没人主动去想:屏幕阅读器用户感知不到任何视觉占位。对他们来说,一个div套几个空div,就是一片空白。加载开始时没有提示,加载结束时内容突然刷出来,中间那几百毫秒到几秒的"真空期",他们没有任何信息可以依靠。

role="status"就是给第二条线用的。它是 WAI-ARIA 定义的一种 live region(实时区域),语义是"这里有一个不紧急的状态更新,请在不打断用户当前操作的前提下播报出来"。它的隐式属性是两个:aria-live="polite"和aria-atomic="true"。前者决定了播报不会打断用户正在读的内容,会排队等当前朗读结束;后者决定了播报时读整个区域的内容,而不是只读变化的那一小段。

把这两个能力叠加到骨架屏上,效果就是:加载开始时,读屏用户听到"正在加载文章列表";加载结束时,焦点和朗读自然过渡到新内容。整个过程不吵不闹,但信息是完整的。

1.2 role="status"、aria-busy、role="alert" 的分工边界

这三个东西经常被混着用,我见过把加载提示写成role="alert"的,用户每翻一页就被强行打断一次,体验非常糟糕。这里必须把边界划清楚。

属性/角色隐式 live 级别是否打断当前朗读适合的场景
role="status"polite否,排队播报加载中提示、保存成功、条数变化
role="alert"assertive是,立即打断表单校验失败、请求出错、危险操作确认
aria-busy="true"无(不是 live region)不播报,反而"压制"播报标记某块区域正在整体更新,中间态不必播报
aria-live="polite"裸写polite否效果接近role="status",但缺少语义名

关键在最后一行和第三行。role="status"是给"我要告诉你一件事"用的;aria-busy是给"这块区域正在翻新,你先别急着读"用的。骨架屏的默认场景属于前者——用户需要知道"正在加载"这件事本身。所以 Oat UI 把role="status"放在骨架屏容器上,是符合规范的默认选择。

至于role="alert",我的建议很直接:加载态永远不要用它。分页、下拉刷新、路由切换这些高频动作,每次都打断用户朗读,用不了几次人就想把读屏关掉了。

1.3 为什么不能给每一根骨架条都挂 status

这是我见过最常见的错误实现,也是我当年第一次写骨架屏时干的事——给每一根灰条都加上role="status",心想"这样每根条都能被感知到,多完整"。

结果是灾难性的。一个列表骨架有 8 根条,读屏软件会依次播报 8 次"正在加载",如果列表下面还有卡片骨架、侧边栏骨架,一次页面切换可能触发十几次播报,用户得听半分钟才能开始操作。更糟的是,某些读屏软件会把连续的 status 区域合并处理,播报顺序完全不可控。

正确的粒度是:一个加载区域,一个 status。整个骨架屏容器承担"我在加载"这个语义,内部所有视觉灰条统一aria-hidden="true",对辅助技术完全隐形。视觉信息交给眼睛,语义信息交给 status 的一句话。职责单一,播报干净。

2. Oat UI Skeleton 的 DOM 骨架与读屏文本该怎么摆

2.1 组件的三层结构:容器、读屏文本、视觉层

Oat UI 的 Skeleton 输出结构大致是这样的,我把它拆成三层来看:

<div class="oat-skeleton" role="status" aria-live="polite"> <span class="oat-skeleton__sr">正在加载文章列表</span> <div class="oat-skeleton__bars" aria-hidden="true"> <div class="oat-skeleton__bar" style="width: 100%"></div> <div class="oat-skeleton__bar" style="width: 92%"></div> <div class="oat-skeleton__bar" style="width: 74%"></div> </div> </div>

第一层是容器,承载role="status",是整个组件的语义出口。第二层是那个<span>,只给读屏看、视觉上完全藏起来,里面写的就是要被播报的那句话。第三层才是灰条,整块打上aria-hidden="true",让辅助技术跳过。

这个结构看起来平平无奇,但三层缺一不可。少了第一层的 status,整个组件就是哑的;少了第二层的文本,status 区域是空的,多数读屏软件不会播报空区域;少了第三层的 aria-hidden,读屏软件会尝试去读那些空 div,虽然读不出什么,但会污染朗读的节奏感。

.oat-skeleton__sr的实现就是标准的 visually-hidden 写法:

.oat-skeleton__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); clip-path: inset(50%); white-space: nowrap; border: 0; }

注意这里绝对不能用display: none或visibility: hidden。这两种方式会让元素从无障碍树里彻底消失,里面的文字自然也读不出来,整个 status 又变成哑的。这是一个非常隐蔽的坑,用浏览器开发者工具看 DOM 一切正常,只有真的打开读屏软件才能发现。

2.2 播报文案是组件 API 的一部分,不是随手写的

Oat UI 的 Skeleton 提供了label属性,默认值类似"正在加载"。这个默认值在实际项目里基本都需要覆盖,因为"正在加载"太笼统了。

文案的设计原则我总结成三条。第一,带上加载对象的名称,"正在加载评论"比"正在加载"有用得多,用户立刻知道自己等的是什么。第二,不要带省略号和感叹号,"正在加载…"里的省略号有些读屏软件会念成"点 点 点",非常出戏。第三,不要描述视觉,别写"灰色骨架屏正在显示",用户不需要知道界面上有个灰色的东西。

比较到位的写法是这几类:

  • 列表场景:正在加载文章列表
  • 详情场景:正在加载文章正文
  • 提交场景:正在提交表单
  • 分页场景:正在加载第 3 页

还有一个容易被忽略的点:文案应该由使用方传入,而不是写死组件里。因为这个字符串是业务语义,组件库不可能预知。Oat UI 把这部分做成必填的语义属性,我觉得是非常对的设计取舍,比那些只给一个aria-label="loading"的库要走心。

2.3 加载结束时的移除方式决定了最后的体验

骨架屏消失的那一刻,是整段体验的收尾。处理得糙,前面做得再好也会打折。

最粗暴的做法是加载完成后直接把整个骨架屏节点remove()掉。这在视觉上没问题,但在无障碍层面,一个正在播报或即将播报的 live region 突然被移出 DOM,某些读屏软件会直接中断播报,用户听到一半被打断,体验比不播报还差。

我的做法是两段式收尾:加载完成时,先把视觉层移除(或淡出),同时把.oat-skeleton__sr的文本改成"加载完成"或者更具体的"已加载 12 篇文章",等一个短暂延迟(大约 300 毫秒,够 live region 处理完这次变化)再整体卸载节点。

// 简化的收尾逻辑 function finishLoading(container, doneText) { const sr = container.querySelector('.oat-skeleton__sr'); const bars = container.querySelector('.oat-skeleton__bars'); if (bars) bars.remove(); if (sr) sr.textContent = doneText; setTimeout(() => container.remove(), 300); }

这里有个权衡要说清楚:如果你的场景是高频切换(比如无限滚动,每滚一屏就加载一批),每条都播报"加载完成"会变成噪音,这时候就不该给骨架屏配 status,改用aria-busy在列表容器上标记,让读屏软件静默处理中间态,只在用户主动聚焦时读当前条数。播报不是越多越好,得看场景的节奏。

3. 视觉层的三件事:闪动方式、尺寸节奏、降级开关

3.1 shimmer 动画该用 transform,不能用 background-position

骨架屏的经典效果是那道从左扫到右的白色高光。实现方式有好几种,性能差异极大。

最常见的做法是给背景加一个线性渐变,然后动background-position:

/* 不推荐 */ .oat-skeleton__bar { background: linear-gradient(90deg, #e9ecef 25%, #f5f6f7 50%, #e9ecef 75%); background-size: 200% 100%; animation: shimmer-bg 1.4s infinite; } @keyframes shimmer-bg { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }

这段代码能跑,效果也对,但它每帧都在触发重绘(repaint),而不是合成层动画。一屏上几十个骨架条一起动的时候,在中低端安卓机上掉帧非常明显,滚动时会跟着卡。

Oat UI 用的是伪元素 +transform的方案,高光单独作为一层走合成:

.oat-skeleton__bar { position: relative; overflow: hidden; background-color: var(--oat-skeleton-bg, #e9ecef); border-radius: 4px; height: 0.875em; } .oat-skeleton__bar::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background-image: linear-gradient( 90deg, transparent 0%, var(--oat-skeleton-shine, rgba(255, 255, 255, 0.65)) 50%, transparent 100% ); animation: oat-shimmer 1.4s ease-in-out infinite; } @keyframes oat-shimmer { 100% { transform: translateX(100%); } }

transform和opacity是唯二能走 GPU 合成的属性,浏览器不需要重新计算布局和绘制,只做层变换。实测下来,同样的骨架数量,这个方案的帧率能稳住,滚动时也不掉。

有一点要提醒:别顺手加will-change: transform。这个属性会让浏览器提前为元素创建合成层,一两个还行,几十个骨架条每个都加,显存和内存都会涨,反而更卡。动画本身已经能触发层提升,不需要额外提示。

3.2 灰条的数量和宽度节奏决定"像不像真的"

这是纯经验的部分,没有规范可查,但做过和没做过一眼能看出来。

很多人做骨架屏,几行灰条宽度一律 100%,整整齐齐。视觉上一眼假——真实文本的段落是参差的,每行长度不同,最后一行通常最短。这种"太整齐"的骨架屏会让用户的期待落空,内容真正出现时的跳变感反而更强。

我的做法是给一组递减的宽度序列,模拟自然段落:

行位置推荐宽度说明
第一行100%段落起始,通常最长
中间行92%~96%略微参差,制造自然感
倒数第二行78%~86%接近结尾
最后一行45%~65%明显缩短,收束感

高度方面,灰条不需要等于真实行高。真实文本的行高通常是1.5em到1.75em,但灰条应该做得比行高窄一些,0.75em到0.9em比较合适,行与行之间留出空隙。这样看起来像"文字块",而不是"一堆横条"。圆角用3px到4px就够了,圆角太大反而显得像按钮。

还有一个细节:首行如果是标题,应该单独做一根更粗更短的条,高度大概是正文条的两倍,宽度 60% 到 75%。标题和正文在视觉权重上是有区别的,骨架屏如果全部一视同仁,加载完成后内容的层次感会突然跳出来,反而显得突兀。

3.3 prefers-reduced-motion 和深色模式必须做

prefers-reduced-motion这个媒体查询,很多组件库是漏掉的。有些用户因为前庭功能障碍或者单纯的注意力敏感,会在系统里开启"减少动态效果",这时候满屏闪烁的骨架屏对他们来说是实实在在的不适感。

处理方式很简单,但必须有:

@media (prefers-reduced-motion: reduce) { .oat-skeleton__bar::after { animation: none; opacity: 0; } .oat-skeleton__bar { animation: oat-pulse 2s ease-in-out infinite; } @keyframes oat-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.72; } } }

关掉扫光,改成缓慢的整体透明度呼吸。opacity同样走合成层,性能没问题,而且呼吸比闪烁温和得多。如果你连呼吸都不想留,直接给个静态灰块也完全可以接受——信息传达的重要性远高于动效。

深色模式同样不能漏。这里最常见的坑是骨架色写死成#e9ecef,在深色主题下直接变成一片刺眼的白斑。Oat UI 通过 CSS 变量暴露了--oat-skeleton-bg和--oat-skeleton-shine,使用方在主题里覆盖即可:

:root { --oat-skeleton-bg: #e9ecef; --oat-skeleton-shine: rgba(255, 255, 255, 0.65); } @media (prefers-color-scheme: dark) { :root { --oat-skeleton-bg: #2a2d31; --oat-skeleton-shine: rgba(255, 255, 255, 0.08); } }

深色模式下高光一定要压得很低,0.08这个量级比较舒服。很多人直接沿用0.65,扫过去像一道白光剑,非常刺眼。这个数值我在几个项目里反复调过,0.06到0.1之间是比较安全的地带。

4. 封装落地:延迟显示、最短停留与三种加载场景

4.1 200 毫秒内加载完,压根不该出现骨架屏

这里有个体验问题,比技术细节重要得多:如果你的接口 80 毫秒就返回了,骨架屏一闪而过,用户看到的是一次无意义的闪烁,比什么都不显示还糟。

解决办法是给骨架屏加一个显示延迟:加载开始后等 200 毫秒,如果这期间数据回来了,就完全不显示骨架;超过 200 毫秒才显示。200 毫秒大致是人对"即时响应"的感知阈值,低于这个时间的等待,用户几乎感觉不到。

紧接着还有第二个参数:最短停留时间。如果骨架在第 210 毫秒显示出来,第 230 毫秒数据就回来了,骨架只存在了 20 毫秒,还是闪。所以一旦骨架显示了,就得让它至少待够 400 毫秒左右,凑成一个完整的视觉节拍。

这两个参数合在一起的封装,我一般这么写:

import { useEffect, useRef, useState } from 'react'; function useSkeletonVisible(loading, { delay = 200, minDuration = 400 } = {}) { const [visible, setVisible] = useState(false); const shownAt = useRef(0); const timer = useRef(null); useEffect(() => { clearTimeout(timer.current); if (loading) { timer.current = setTimeout(() => { shownAt.current = Date.now(); setVisible(true); }, delay); } else if (visible) { const elapsed = Date.now() - shownAt.current; const rest = Math.max(0, minDuration - elapsed); timer.current = setTimeout(() => { shownAt.current = 0; setVisible(false); }, rest); } return () => clearTimeout(timer.current); }, [loading, delay, minDuration, visible]); return visible; }

这段代码的逻辑不复杂,但有两个地方容易写错。第一,clearTimeout必须放在useEffect的第一行,而不是只在else分支里,否则快速切换 loading 状态时会残留定时器,出现"骨架该消失却还在"的怪异现象。第二,visible要放进依赖数组,否则else if (visible)分支拿到的永远是初始值false,最短停留逻辑直接失效。

用起来就很清爽:

function ArticleList() { const { data, loading } = useArticles(); const showSkeleton = useSkeletonVisible(loading); if (showSkeleton) { return ( <Skeleton label="正在加载文章列表" lines={5} avatar /> ); } return <List items={data} />; }

4.2 首屏、分页、局部刷新,三种场景三种处理

骨架屏不是万能贴,不同加载场景的处理方式差别很大,混用会出问题。

首屏加载是最适合骨架屏的场景。此时页面是空的,用户没有任何参照物,骨架屏能把最终布局提前"预演"一遍,减少内容到位时的布局抖动(CLS)。这种情况下role="status"应该正常播报"正在加载页面内容"。

分页切换要小心。用户点下一页,列表区域换成骨架,加载完再换回列表。这时候如果每次都播报"正在加载第 N 页",连续翻页会非常吵。我的处理是:只在第一次加载时播报,后续翻页改用aria-busy静默处理,或者干脆把播报文案换成更简短的形式。另外分页场景下,焦点管理比播报更重要——加载完成后应该把焦点移到新内容的起始位置,否则键盘用户会迷失在原来的位置。

局部刷新(比如点赞数更新、某条评论折叠展开)根本不该用骨架屏。这种几毫秒的动作,视觉上一个小 spinner 或者按钮上的 loading 态就够了,上骨架屏属于过度设计。无障碍层面更不该播报,用户点了个赞,听到"正在加载"会一脸问号。

我把这三类场景的取舍整理成一张表,方便对照:

场景骨架屏是否合适status 播报策略额外注意
首屏加载合适正常播报,文案带对象名布局要和真实内容对齐,减少 CLS
分页切换合适首次播报,后续静默加载完成需转移焦点
无限滚动部分合适用aria-busy静默只显示底部一小块骨架
局部刷新不合适不播报用按钮内联 loading
表单提交不合适用role="status"播"提交中"按钮禁用,防止重复提交

4.3 Vue 里用指令还是组件,取决于复用粒度

同样的逻辑搬到 Vue,我更推荐用组合式函数而不是指令,因为骨架屏的显示与否本质上是一个状态,不是一个 DOM 行为:

import { ref, watch, onUnmounted } from 'vue'; export function useSkeletonVisible(loadingRef, { delay = 200, minDuration = 400 } = {}) { const visible = ref(false); let shownAt = 0; let timer = null; const clear = () => { if (timer) clearTimeout(timer); timer = null; }; watch(loadingRef, (loading) => { clear(); if (loading) { timer = setTimeout(() => { shownAt = Date.now(); visible.value = true; }, delay); } else if (visible.value) { const rest = Math.max(0, minDuration - (Date.now() - shownAt)); timer = setTimeout(() => { shownAt = 0; visible.value = false; }, rest); } }); onUnmounted(clear); return visible; }

模板里用v-if="visible"控制即可。注意onUnmounted里必须清定时器,否则组件卸载后定时器还会尝试修改已经销毁的响应式数据,控制台会冒出一堆警告,这种问题在单页应用里排查起来特别费劲。

组件封装层面,我倾向于把 Skeleton 做成纯展示组件,所有时序控制交给外层的 hook。原因很实际:时序参数(延迟、最短停留)是业务相关的,不同接口的响应速度差异很大,组件库不应该替业务决定这些值。组件只负责"给我 label 和行数,我还你一块合规的骨架屏",边界清晰,复用起来也不别扭。

5. 排查实录:不播报、不消失、卡顿这三类问题的定位链路

5.1 status 不播报的四种原因,按排查顺序排

"明明加了 role=status,读屏软件就是不读",这是用得最多的反馈。我把踩过的原因按概率从高到低排一下,基本能覆盖九成情况。

第一种,live region 是"带着内容一起挂载"的。这是最隐蔽、也最常被忽略的一条。ARIA 规范对 live region 有一个隐含要求:它必须先存在于 DOM 中,之后的内容变化才会被播报。如果你在 React 里写成loading && <Skeleton label="..." />,骨架屏节点和它的文本是同一帧插入 DOM 的,很多读屏软件(尤其某些版本的 NVDA)会认为"这不是变化,这是初始内容",直接不播报。

解法是把 live region 提前挂好、留空,只更新文本内容:

// 页面根部常驻一个空 live region function App() { const [statusText, setStatusText] = useState(''); return ( <> <div className="sr-only" role="status" aria-live="polite"> {statusText} </div> <ArticleList onStatus={setStatusText} /> </> ); }

这样骨架屏只负责视觉,语义播报交给这个常驻节点,稳定性高一个数量级。

第二种,aria-hidden包错了层级。我见过把aria-hidden="true"加在整个骨架屏容器上的写法,本意是"隐藏视觉装饰",结果把里面的 status 也一起从无障碍树里摘掉了。记住:aria-hidden是向下继承的,一旦加到祖先节点,所有后代(包括 live region)全部失效。它只能加在纯视觉的那一层。

第三种,status 元素被display: none或visibility: hidden藏着。有些人为了"先不显示,需要时再露出来",给容器加了display: none。这个元素在无障碍树里根本不存在,播报自然无从谈起。要藏视觉不藏语义,只能用前面那套 visually-hidden 写法。

第四种,页面上同时有多个 status 在争抢。三个骨架屏组件各自播报,读屏软件的处理顺序不可预测,有时候干脆一个都不读。排查方式是临时把页面上的骨架屏减到一个,如果恢复正常,那就是多实例冲突,改成页面级统一播报即可。

排查手段上,Chrome DevTools 的 Accessibility 面板是我的第一站,看 status 节点的 accessible name 是不是空的、是不是被标记为 ignored。如果这里有内容,问题多半在"挂载时机"上;如果这里就是空的,问题在 DOM 结构或隐藏方式上。

5.2 骨架该消失却不消失:定时器与状态竞争

这一类 bug 的表现是随机性的——有时候正常,有时候骨架屏卡住不消失,刷新一下又好了。典型的定时器竞态。

前面那段 hook 代码里,最容易出问题的就是快速来回切换。用户在 200 毫秒内连续点了两次"重新加载",第一次的setTimeout还没触发,第二次又进来了。如果清理逻辑写得不严,两个定时器都会执行,visible先被设成true再被设成false,时序就乱了。

排查这类问题的办法很朴素:在定时器回调里打时间戳日志。

timer.current = setTimeout(() => { console.log('[skeleton] show at', Date.now()); shownAt.current = Date.now(); setVisible(true); }, delay);

然后把日志和网络请求的时间线对一遍。如果看到show打印了两次而hide只打印一次,基本就能锁定是清理不彻底。

还有一种情况是骨架屏"消失了一半"——视觉层没了,但容器还在,占着高度。这通常是组件实现里只移除了.oat-skeleton__bars,忘了处理容器本身的内边距或最小高度。检查一下容器的min-height和padding,这类残留高度在列表里会让下面的内容整体下移,非常明显。

5.3 移动端掉帧:先用 Performance 面板确认是不是动画的锅

"骨架屏一出现页面就卡",这个反馈要先验证再动手。我的流程是三步。

第一步,用 DevTools 的 Performance 面板录 3 秒,看 FPS 曲线和主线程活动。如果主线程上出现大量Recalculate Style和Paint,说明动画属性选错了,多半是background-position或者box-shadow在动。如果主线程很闲但 FPS 还是低,那是合成层过多,浏览器在 GPU 侧就卡了,通常和滥用will-change或filter有关。

第二步,把动画整个关掉,重新录一次。如果关掉就流畅了,问题确认在动画;如果关掉还卡,那是骨架屏的 DOM 数量太多,或者父容器有复杂的布局计算。

第三步,根据结论动手。动错了属性就换成transform;合成层过多就删掉will-change;DOM 太多就把骨架屏的复杂度降下来——列表很长的时候,只需要渲染首屏可见的那几块骨架,屏幕外的可以不给。

顺便提一个容易被忽略的性能点:骨架屏的很多元素其实不需要独立动画。如果一屏有 30 根灰条,每根都跑自己的animation,就是 30 个动画实例。更好的做法是把动画加在父容器的一个伪元素上,让整块区域共用一道扫光,视觉上是一样的,动画实例少了 30 倍。这个优化在中低端机上效果非常明显。

6. 几个容易被忽略的边界

聊几个我做组件时反复确认过、但文档里一般不会写的边界情况。

骨架屏不该承载真实布局的宽度。有些实现为了让骨架和内容完全对齐,直接去测量真实内容的尺寸。这在动态内容下会出问题——骨架屏阶段内容还不存在,测出来的值是 0 或者错的。正确的做法是让骨架自己有一套稳定的尺寸规则(百分比宽度 + 固定行高),和真实内容在视觉上"大致接近"就够了,不需要像素级一致。

aria-atomic="true"在长文本上要小心。status 默认是原子播报,如果里面的文案特别长(比如带了一串统计数据),每次更新会重读整段。文案控制在十几个字以内,是最稳的。

SSR 场景下 status 的初始内容要为空或者就是加载文案。如果服务端渲染出来的 status 带着一段文字,客户端又要改成另一段,中间的差异可能触发一次意外播报。要么就让服务端输出空内容,客户端挂载后再写入;要么就保持两边一致。

别忘了骨架屏的对比度。骨架屏本身不属于"必须满足对比度标准"的文字内容,但它在深色模式下的可见度还是要保证。灰块和背景的亮度差太小,用户会以为页面是空白;太大,又会喧宾夺主。我的经验值是和背景保持大约 8% 到 12% 的感知亮度差,用眼睛扫一眼能看出"这里有东西"就够了。

最后一条,也是最实在的一条:骨架屏的形状要和最终内容真的对得上。我见过一个详情页,骨架屏画了三行文字,结果加载出来是一张大图加一行文字,内容到位时整个页面剧烈跳动。这种情况下,骨架屏不但没帮上忙,反而放大了布局抖动。做骨架屏之前,先把最终内容的布局定下来,照着画;布局改了,骨架同步改。它本质上是最终布局的一张"草稿",草稿和成品对不上,草稿就失去了意义。

我在实际项目里推这套东西的时候有个体会:无障碍这块,最难的不是写代码,而是让团队意识到"看不见的用户也在用这个界面"。骨架屏加role="status"这件事,代码量不超过二十行,效果上的差别却是"这个人能不能知道页面在干嘛"。所以我的建议是把它做成组件库的默认行为,让使用方不需要记住、也不需要额外配置——默认正确,比文档里写十条注意事项管用得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进