
一句话:异步 UI 有四种状态(
idle/loading/success/error,通常还加empty)——用状态机建模(而不是多个布尔值),并为每种状态设计明确、不闪烁的 UI。
一、为什么不要用多个布尔值
// ❌ 反过来:多个布尔值 → 会产生「非法组合」
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<Data | null>(null);
// 可能出现:
// loading=true 且 data!=null ← 到底该显示什么?
// error 有值 且 data 有值 ← 显示旧数据还是错误?
// loading=false 且 data=null ← 空状态还是没开始?
非法组合的来源:三个独立布尔值有 2³ = 8 种组合,但只有 4 种是合法的。
「允许非法状态存在」是 bug 的温床(渲染逻辑要考虑各种组合)。
// ✅ 用「判别联合」建模(状态机)
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// ✅ 渲染时用 switch —— 类型系统会强制你处理每一种
function Content<T>({ state }: { state: AsyncState<T> }) {
switch (state.status) {
case 'idle':
return null;
case 'loading':
return <Skeleton />;
case 'error':
return <ErrorView error={state.error} />;
case 'success':
return <DataView data={state.data} />; // ⭐ 只有这里能访问 data
}
}
| 方案 | 非法状态 | 类型安全 | 可读性 |
|---|---|---|---|
| 多个布尔值 | ❌ 会产生 | ❌ | 需要判断组合 |
| 判别联合 + switch | ✅ 无法表示 | ✅(穷尽检查) | ✅ |
二、加上「空状态」与「刷新中」
// ✅ 实际项目常见的五态(在四态之外区分「刷新中」)
type ViewState<T> =
| { status: 'idle' }
| { status: 'loading' } // 首次加载(显示骨架)
| { status: 'refreshing'; data: T } // 刷新中(保留旧数据)
| { status: 'success'; data: T }
| { status: 'error'; error: Error; data?: T }; // 错误时可能仍有旧数据
// ✅ 渲染时区分「首次加载」与「刷新」
function UserList() {
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
placeholderData: (prev) => prev, // ⭐ 刷新时保留上一份数据
});
if (isLoading) return <Skeleton count={5} />; // 首次
if (error) return <ErrorView error={error} onRetry={refetch} />;
return (
<div>
{isFetching && <TopProgressBar />} {/* ⭐ 刷新中:轻提示 */}
{data.length === 0 ? <EmptyState /> : <List items={data} />}
</div>
);
}
为什么要区分「首次加载」与「刷新」?
① 首次加载:没有任何数据 → 必须显示骨架(否则页面空白)
② 刷新中:已有旧数据 → 应该「保留旧数据 + 轻提示」(而不是清空重显骨架)
→ 否则页面会「闪一下变成骨架再变回来」,体验很差
三、骨架屏设计原则
// ✅ 骨架的形状要「贴合真实内容」
function UserCardSkeleton() {
return (
<div className="card">
<div className="skeleton skeleton--avatar" /> {/* 头像 */}
<div className="skeleton skeleton--line" /> {/* 标题行 */}
<div className="skeleton skeleton--line short" /> {/* 短行 */}
</div>
);
}
五条骨架屏原则:
① 【贴合结构】——骨架的行数、宽度、位置尽量接近真实内容
(否则内容出现时会有明显的位置跳动 CLS)
② 【固定高度】——给骨架容器设定固定高度(避免加载前后布局抖动)
③ 【数量一致】——骨架数量贴近预期数据量(如列表预期 5 条就渲染 5 个)
④ 【适度动效】——微弱的 shimmer/pulse 能表达「在加载」,但别太花哨
⑤ 【不要过久】——延迟 100~200ms 再显示骨架(加载很快时直接显示内容,避免闪烁)
⚠️ 不要用「转圈 spinner」代替骨架(spinner 只有「在加载」的信息,
没有「即将出现什么」的信息,体验比骨架差)。
// ⑤ 的「延迟显示」技巧(避免快速加载时的闪烁)
function useDelayedLoading(isLoading: boolean, delay = 150) {
const [show, setShow] = useState(false);
useEffect(() => {
if (!isLoading) {
setShow(false);
return;
}
const id = setTimeout(() => setShow(true), delay);
return () => clearTimeout(id);
}, [isLoading, delay]);
return show;
}
// 使用:加载很快时不会闪骨架
function List() {
const { data, isLoading } = useQuery(...);
const showSkeleton = useDelayedLoading(isLoading);
if (showSkeleton) return <Skeleton />;
if (!data) return null;
return <List items={data} />;
}
四、错误态设计
// ✅ 错误态要包含:原因、可操作性(重试)、兜底(返回/联系客服)
function ErrorView({ error, onRetry }: { error: Error; onRetry?: () => void }) {
const isNetwork = error.message.includes('网络');
return (
<div className="error-view" role="alert">
<ErrorIcon />
<h3>{isNetwork ? '网络连接失败' : '加载失败'}</h3>
<p className="error-view__detail">{error.message}</p>
<div className="error-view__actions">
{onRetry && <button onClick={onRetry}>重试</button>}
<button onClick={() => window.location.reload()}>刷新页面</button>
</div>
</div>
);
}
错误分类与对应处理:
① 网络错误(断网/CORS/超时)
→ 提示「检查网络」+ 重试
② 401 未认证
→ 跳登录(拦截器统一处理,页面一般不展示)
③ 403 无权限
→ 提示「无权限」+ 返回上一页(不要跳登录)
④ 404 资源不存在
→ 专门的「不存在」页面 + 返回列表
⑤ 4xx 业务错误(参数/校验)
→ 就地展示(如表单字段旁)
⑥ 429 限流
→ 提示「操作过于频繁,请稍后再试」+ 冷却倒计时
⑦ 5xx 服务异常
→ 提示「服务异常」+ 重试 + 上报
// ✅ 重试 UI:显示进度与次数限制
function RetryableError({ error, onRetry }: { error: Error; onRetry: () => void }) {
const [retrying, setRetrying] = useState(false);
const handleRetry = async () => {
setRetrying(true);
try {
await onRetry();
} finally {
setRetrying(false);
}
};
return (
<div role="alert">
{error.message}
<button onClick={handleRetry} disabled={retrying}>
{retrying ? '重试中…' : '重试'}
</button>
</div>
);
}
⚠️ 「自动重试」与「手动重试」的取舍:
· 自动重试(如 3 次指数退避)适合「网络抖动」→ 用户无感
· 但对 4xx(参数错误、权限问题)【不应该重试】(重试也不会成功)
· 手动重试给用户控制感(且能在「后端恢复后」重新请求)
✅ 实践:只对「网络错误 + 5xx」自动重试,其余交给用户手动重试
五、空状态设计
// ✅ 空状态要「说明 + 引导」
function EmptyState({ keyword, onCreate }: { keyword?: string; onCreate?: () => void }) {
// 区分「没有数据」与「搜索无结果」——两者的引导不同
if (keyword) {
return (
<div className="empty">
<SearchIcon />
没有找到与「{keyword}」相关的内容
<p className="empty__hint">试试更短的关键词,或检查拼写</p>
</div>
);
}
return (
<div className="empty">
<FolderIcon />
还没有任何项目
{onCreate && <button onClick={onCreate}>创建第一个项目</button>} {/* ⭐ 给出行动 */}
</div>
);
}
⭐ 空状态最容易被遗漏,但它是「新用户看到的第一屏」:
① 区分「真的没数据」与「筛选/搜索后没结果」(引导不同)
② 提供「下一步动作」(创建、导入、看示例)
③ 不要显示「暂无数据」四个字了事
六、避免「闪烁」
// ❌ 常见的闪烁来源一:刷新时清空数据
function Bad() {
const [data, setData] = useState<Data | null>(null);
const refetch = () => {
setData(null); // ⚠️ 清空 → 界面从「有内容」变「骨架」
fetchData().then(setData);
};
}
// ✅ 保留旧数据,只显示轻提示
function Good() {
const { data, isFetching, refetch } = useQuery({ ... });
return (
<>
{isFetching && <TopProgressBar />}
{data && <Content data={data} />} {/* ⭐ 旧数据继续显示 */}
</>
);
}
// ❌ 闪烁来源二:分页切换时清空列表
// ✅ 用 placeholderData / keepPreviousData 保留上一页
const { data, isPlaceholderData } = useQuery({
queryKey: ['items', page],
queryFn: () => fetchItems(page),
placeholderData: (prev) => prev, // ⭐ 翻页时显示上一页数据
});
// 可以配合「半透明」提示数据正在更新
<div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>{/* ... */}</div>
// ❌ 闪烁来源三:数据变化导致布局跳动
// ✅ 骨架/占位要有「确定高度」
<img src={src} width={40} height={40} alt="" /> {/* ⭐ 设定宽高 */}
<div style={{ minHeight: 200 }}>{content}</div> {/* ⭐ 预留空间 */}
七、局部 loading vs 全局 loading
// ❌ 全局 loading:一个请求就让整页转圈
function Bad() {
const { isLoading } = useGlobalLoading();
if (isLoading) return <FullPageSpinner />; // ⚠️ 体验差(导航栏也消失了)
return <Page />;
}
// ✅ 局部 loading:只在「数据区域」显示骨架
function Good() {
return (
<div>
<TopNav /> {/* 保持稳定 */}
<main>
<Suspense fallback={<ContentSkeleton />}>
<DataSection /> {/* ⭐ 只有这块显示骨架 */}
</Suspense>
</main>
</div>
);
}
| 场景 | 推荐 |
|---|---|
| 页面首次进入 | 局部骨架(保持布局稳定) |
| 表单提交 | 按钮 loading + 禁用(局部、明确) |
| 下拉刷新 | 顶部进度条 |
| 列表加载更多 | 列表底部 loading(不要整页) |
| 路由切换 | 顶部进度条 + 内容区骨架 |
| 「必须阻塞操作」的场景 | 才考虑全局遮罩(慎用) |
八、常见坑
// ① 用多个布尔值表示状态(非法组合)
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
// ② 忘记「空状态」(用户看到一片空白)
if (isLoading) return <Skeleton />;
if (error) return <Error />;
return <List items={data} />; // ⚠️ data.length === 0 时空白
// ③ 刷新时清空数据(导致骨架闪烁)
setData(null); // ⚠️
// ④ 分页切换没保留上一页(列表闪白)
// ⑤ 骨架「形状与真实内容不一致」(内容出现时布局跳动)
// ⑥ 骨架/图片没有固定高度(CLS 高)
// ⑦ 加载很快也显示骨架(闪烁,应该延迟显示)
// ⑧ 对 4xx 错误也自动重试(永远失败、白白重试)
// ⑨ 错误信息直接暴露后端原始 message(可能含敏感信息/难懂的技术细节)
// ⑩ 用一个全局 loading 管理所有请求
// ⑪ 提交按钮不禁用(重复提交)
// ⑫ 在「错误状态」下仍然显示旧数据但不说明(用户以为看到的是新数据)
// ✅ 用「轻提示 + 半透明」表达「正在刷新/数据可能过期」
// ⑬ 没有 `role="alert"`(无障碍:错误信息没被读屏播报)
// ⑭ 骨架动画太重(CPU 高、低端机掉帧)
九、面试延伸
- 「异步 UI 应该怎么建模状态?」
用「判别联合(状态机)」而不是多个布尔值:{ status: 'idle' | 'loading' | 'success' | 'error' },成功时带data、失败时带error。好处是:① 非法状态无法表示(不会出现「loading 且 data 有值」);② 类型系统强制穷尽处理(switch会提醒你漏了哪个分支);③ 渲染逻辑清晰(每个分支对应一种 UI)。实际项目还会区分「首次加载」与「刷新中」(后者保留旧数据)。 -
「为什么不该用多个布尔值?」
因为三个独立布尔值有 8 种组合,但只有 4 种是合法的——「允许非法状态存在」会让 bug 有生存空间(渲染逻辑要考虑「loading 且 error 且 data 都有值」该怎么办)。而判别联合从结构上禁止了非法组合,同时配合 TypeScript 的穷尽检查,能让「新增状态时忘记处理」变成编译错误。这是「让非法状态无法表示」这一设计原则的典型应用。 -
「怎么避免加载时的闪烁?」
三个来源与对应解法:① 刷新时清空数据 → 改为「保留旧数据 + 轻提示」(placeholderData/keepPreviousData/isFetching进度条);② 分页/筛选切换时列表清空 → 用placeholderData: (prev) => prev保留上一页,并可用「半透明」表达正在更新;③ 骨架与真实内容形状不一致 → 骨架要贴合结构并设固定高度(避免内容出现时布局跳动 CLS)。还有一个技巧:延迟 100~200ms 再显示骨架(加载很快时直接显示内容)。 -
「骨架屏和 spinner 有什么区别?该用哪个?」
骨架屏携带「即将出现什么」的信息(形状、数量、位置),用户能预判布局,体验更平稳;spinner 只有「在加载」这一条信息,且通常居中、会「顶掉」内容导致跳动。所以:内容型区域(列表、卡片、详情)优先用骨架屏(形状贴合真实内容);操作型场景(按钮提交、局部刷新)用 spinner 或进度条;整页级别尽量不用(会失去布局上下文)。 -
「错误态应该怎么设计?」
三个要素:① 说明原因(网络问题?无权限?服务异常?——不同原因对应不同文案与出路);② 可操作(重试按钮、返回、联系客服);③ 不暴露敏感信息(不要把后端原始堆栈/内部错误码给用户看)。此外要按错误类型分流:401跳登录(拦截器统一处理)、403提示无权限(不要跳登录)、404显示「不存在」页、4xx业务错误就地提示、5xx提示「服务异常」+ 重试。自动重试只对「网络错误 + 5xx」,4xx重试没有意义。 -
「空状态为什么重要?」
因为它是「新用户看到的第一屏」——很多产品在「没有数据时」只显示「暂无数据」四个字,用户不知道下一步做什么。好的空状态应该:① 区分场景(「真的没数据」vs「筛选/搜索后无结果」——后者的引导是「换个关键词」);② 给出行动(「创建第一个项目」「导入数据」「查看示例」);③ 传达价值(一两个词说明这个功能能做什么)。空状态是被遗漏最多、但影响首次体验最大的状态。 -
「刷新数据时为什么要保留旧数据?」
因为「清空 → 显示骨架 → 再显示数据」会让页面闪一下(视觉不稳定),而且用户会失去上下文(比如正在看列表第 3 项,刷新后位置全变了)。保留旧数据的做法是「旧数据继续显示 + 用一个轻量提示表达「正在更新」」(顶部进度条、区域半透明、小 spinner)。这是「乐观 UI」的一种温和形式,能显著提升「流畅感」。placeholderData、keepPreviousData、isFetching都是为此设计的。 -
「全局 loading 和局部 loading 怎么选?」
原则是「loading 的作用范围要尽量贴近「真正在等的那个区域」」。① 局部——页面首次进入(内容区骨架)、表单提交(按钮 loading)、列表加载更多(底部 loading)、路由切换(内容区骨架 + 顶部进度条);② 全局遮罩——只在「必须阻塞用户操作」时使用(如「提交订单时防止重复点击」「支付跳转前」),而且要有进度说明。默认选局部——全局 spinner 会让「导航栏也消失」,用户失去空间感与退路。
一句话速记
异步 UI 用「状态机(判别联合)」而不是多个布尔值——让非法状态无法表示,并区分「首次加载」与「刷新中」;四种必做状态是
idle/loading/success/error(+empty),空状态最容易被漏(要给「引导 + 行动」);避免闪烁三招是「刷新时保留旧数据 + 轻提示」「分页用placeholderData」「骨架贴合结构与固定高度(避免 CLS)」,并可延迟 150ms 再显示骨架;错误态要有原因 + 重试 + 不暴露敏感信息,按类型分流(401 跳登录、403 就地提示、4xx 不重试、5xx/网络才自动重试);loading 尽量局部(全局遮罩只在必须阻塞时用)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!