如何处理低网速下的白屏问题
问题
在低网速环境下,用户访问 SPA(单页应用)时经常会遇到长时间白屏的问题。这是因为浏览器需要下载、解析并执行 JavaScript 后才能渲染页面内容。如何优化这种体验?
低网速下 SPA 白屏的根本原因?
常见情况是 SPA 的 HTML 只有一个空 <div id="root">,必须等 JS 下载、解析、执行完才能产生业务内容;但线上“白屏”还可能是资源 404、JS 异常、Hydration 失败、Service Worker 版本错配或接口永久 pending:
- 网速慢的时候,JS bundle(动辄几百 KB)下载就要好几秒,期间用户只能看白屏。
- 体现在指标上就是
FCP和LCP都被拉长,TTFB 反而正常——说明问题在客户端而不是服务端。
应该怎么优化白屏体验? 按「让用户更早看到东西」和「让东西更早能用」两个方向来:
- 早看到:HTML 里提供轻量、稳定的初始壳或品牌占位,关键 CSS 内联;骨架屏应与最终布局一致,避免它自己成为错误的 LCP 或引发 CLS。
- 更快加载:JS 用
defer不阻塞解析、按路由做代码分割、用<link rel="preload">提前抓核心资源。 - 架构级:SSR/SSG/流式渲染可以让关键内容更早出现,但仍要控制客户端 JS、Hydration 和服务端 TTFB;Service Worker 只有在版本与更新策略正确时才有帮助。
- 检测与恢复:在
bootstrap-start、bundle-loaded、app-mounted、route-ready等确定节点打点,结合资源错误、全局异常和超时判断;不要轮询document.body.innerHTML,因为空壳、骨架屏、弹窗和浏览器插件都可能误判。
答案
白屏要先分成两类:慢启动是页面最终能出现,但 FCP/LCP 很晚;启动失败是关键资源、运行时或数据流程出错,页面永远无法进入可用状态。前者做性能优化,后者需要可观测、容错、恢复和发布治理。
一、白屏产生的原因
空 HTML 壳只是慢启动的一种原因。SSR 页面也可能因为服务端超时、流式响应中断、Hydration 异常或静态资源版本错配而白屏;一个带骨架屏的页面也可能业务区域永远没有 ready。
| 类型 | 典型证据 | 优先处理 |
|---|---|---|
| HTML/TTFB 慢 | Navigation Timing、服务端 trace | CDN、缓存、服务端并行取数、超时降级 |
| 关键资源慢 | Network waterfall、LCP discovery | 压缩、优先级、缓存、CDN、减少首屏 JS |
| 资源失败 | 捕获阶段 error、404/5xx、CSP/SRI | 容灾地址、重试、版本回滚、部署完整性 |
| JS 启动异常 | error、unhandledrejection、入口未执行 | 错误边界、兼容性、监控 source map |
| 框架未挂载/Hydration 失败 | 有 HTML 但没有 app-mounted | 对比服务端与客户端输出、隔离异常组件 |
| 数据永久等待 | 请求超时、Abort、状态机不结束 | 超时、空态、重试、部分内容降级 |
| SW/缓存版本错配 | 刷新后恢复、旧 HTML 引用不存在 chunk | 导航网络优先、hash 资源不可变、更新与回滚流程 |
二、解决方案
1. 骨架屏(Skeleton Screen)
骨架屏是在页面数据加载完成前,先展示页面的大致结构,给用户一个视觉预期。
// components/Skeleton.tsx
import React from 'react';
import styles from './Skeleton.module.css';
interface SkeletonProps {
rows?: number;
avatar?: boolean;
title?: boolean;
}
const Skeleton: React.FC<SkeletonProps> = ({
rows = 3,
avatar = false,
title = true
}) => {
return (
<div className={styles.skeleton}>
{avatar && <div className={styles.avatar} />}
<div className={styles.content}>
{title && <div className={styles.title} />}
{Array.from({ length: rows }).map((_, index) => (
<div
key={index}
className={styles.row}
style={{ width: index === rows - 1 ? '60%' : '100%' }}
/>
))}
</div>
</div>
);
};
export default Skeleton;
/* Skeleton.module.css */
.skeleton {
display: flex;
padding: 16px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.content {
flex: 1;
margin-left: 16px;
}
.title,
.row {
height: 16px;
margin-bottom: 12px;
border-radius: 4px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.title {
width: 40%;
height: 20px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
为了避免骨架屏本身也需要等待 JS 加载,可以将骨架屏的 HTML 和 CSS 直接内联到 index.html 中:
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<style>
.skeleton-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.skeleton-header {
height: 60px;
background: #f0f0f0;
margin-bottom: 20px;
animation: pulse 1.5s infinite;
}
.skeleton-content {
height: 200px;
background: #f0f0f0;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
</head>
<body>
<div id="root">
<!-- 内联骨架屏,JS 加载完成后会被替换 -->
<div class="skeleton-container">
<div class="skeleton-header"></div>
<div class="skeleton-content"></div>
</div>
</div>
<script src="main.js"></script>
</body>
</html>
骨架屏延迟策略
当数据加载很快(< 200ms)时,骨架屏会一闪而过,这种闪烁反而会让用户感觉不流畅。延迟策略可以解决这个问题:只有当加载时间超过一定阈值时,才显示骨架屏。
// hooks/useDelayedLoading.ts
import { useState, useEffect, useRef } from 'react';
interface UseDelayedLoadingOptions {
delay?: number; // 延迟显示骨架屏的时间
minDuration?: number; // 骨架屏最少显示时间
}
function useDelayedLoading(
isLoading: boolean,
options: UseDelayedLoadingOptions = {}
): boolean {
const { delay = 200, minDuration = 300 } = options;
const [showSkeleton, setShowSkeleton] = useState(false);
const loadingStartTime = useRef<number | null>(null);
useEffect(() => {
let delayTimer: ReturnType<typeof setTimeout>;
let minDurationTimer: ReturnType<typeof setTimeout>;
if (isLoading) {
loadingStartTime.current = Date.now();
// 延迟 200ms 后才显示骨架屏
delayTimer = setTimeout(() => {
setShowSkeleton(true);
}, delay);
} else {
// 数据加载完成
if (showSkeleton && loadingStartTime.current) {
const elapsed = Date.now() - loadingStartTime.current;
const remaining = minDuration - (elapsed - delay);
// 确保骨架屏至少显示 minDuration
if (remaining > 0) {
minDurationTimer = setTimeout(() => {
setShowSkeleton(false);
}, remaining);
} else {
setShowSkeleton(false);
}
}
loadingStartTime.current = null;
}
return () => {
clearTimeout(delayTimer);
clearTimeout(minDurationTimer);
};
}, [isLoading, delay, minDuration, showSkeleton]);
return showSkeleton;
}
export default useDelayedLoading;
使用示例:
// components/DataList.tsx
import useDelayedLoading from '../hooks/useDelayedLoading';
import Skeleton from './Skeleton';
const DataList: React.FC = () => {
const { data, isLoading } = useFetchData();
const showSkeleton = useDelayedLoading(isLoading, {
delay: 200, // 加载超过 200ms 才显示骨架屏
minDuration: 300 // 骨架屏至少显示 300ms
});
if (showSkeleton) {
return <Skeleton rows={5} />;
}
return <List data={data} />;
};
| 加载耗时 | 无延迟策略 | 有延迟策略 |
|---|---|---|
| 100ms | 骨架屏闪一下 → 内容 | 直接显示内容 |
| 500ms | 骨架屏 500ms → 内容 | 等 200ms → 骨架屏 300ms → 内容 |
| 2s | 骨架屏 2s → 内容 | 等 200ms → 骨架屏 1.8s → 内容 |
- delay(延迟阈值):通常 150-300ms,超过这个时间才显示骨架屏
- minDuration(最小显示时间):骨架屏一旦显示,至少持续 300-500ms,避免快速切换造成的闪烁
这种策略在 Ant Design 的 Spin 组件 中也有应用(delay 属性)。
2. 服务端渲染(SSR)
SSR 让服务器直接返回渲染好的 HTML,用户可以立即看到页面内容。
// Next.js 示例 - pages/index.tsx
import { GetServerSideProps } from 'next';
interface HomeProps {
articles: Article[];
}
interface Article {
id: number;
title: string;
summary: string;
}
export const getServerSideProps: GetServerSideProps<HomeProps> = async () => {
const res = await fetch('https://api.example.com/articles');
const articles: Article[] = await res.json();
return {
props: {
articles,
},
};
};
const Home: React.FC<HomeProps> = ({ articles }) => {
return (
<main>
<h1>文章列表</h1>
<ul>
{articles.map((article) => (
<li key={article.id}>
<h2>{article.title}</h2>
<p>{article.summary}</p>
</li>
))}
</ul>
</main>
);
};
export default Home;
| 渲染方式 | 首屏速度 | SEO | 服务器压力 | 适用场景 |
|---|---|---|---|---|
| CSR | 慢 | 差 | 低 | 后台管理系统 |
| SSR | 快 | 好 | 高 | 内容型网站、电商 |
| SSG | 最快 | 好 | 最低 | 博客、文档站 |
3. 代码分割与懒加载
减少首屏需要加载的 JavaScript 体积。
// React.lazy + Suspense 实现路由懒加载
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Skeleton from './components/Skeleton';
// 懒加载组件
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
// 带有预加载的懒加载
const Settings = lazy(() => import(
/* webpackChunkName: "settings" */
/* webpackPrefetch: true */
'./pages/Settings'
));
const App: React.FC = () => {
return (
<BrowserRouter>
<Suspense fallback={<Skeleton rows={5} avatar title />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
};
export default App;
4. 资源预加载
利用 preload、prefetch、preconnect 提前加载关键资源。
<head>
<!-- 预连接:提前建立与关键域名的连接 -->
<link rel="preconnect" href="https://api.example.com" />
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<!-- DNS 预解析 -->
<link rel="dns-prefetch" href="https://analytics.example.com" />
<!-- 预加载:当前页面一定会用到的关键资源 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/critical.css" as="style" />
<link rel="preload" href="/hero-image.webp" as="image" />
<!-- 预获取:下一页可能用到的资源(低优先级) -->
<link rel="prefetch" href="/pages/about.js" />
</head>
- preload:当前页面一定会用到的资源,高优先级加载
- prefetch:未来可能会用到的资源,浏览器空闲时加载
5. CDN 加速与缓存策略
// vite.config.ts - 配置资源输出和缓存
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
// 根据文件内容生成 hash,利于缓存
entryFileNames: 'js/[name].[hash].js',
chunkFileNames: 'js/[name].[hash].js',
assetFileNames: (assetInfo) => {
const info = assetInfo.name?.split('.') ?? [];
const ext = info[info.length - 1];
if (/\.(png|jpe?g|gif|svg|webp|ico)$/i.test(assetInfo.name ?? '')) {
return 'images/[name].[hash][extname]';
}
if (/\.(woff2?|eot|ttf|otf)$/i.test(assetInfo.name ?? '')) {
return 'fonts/[name].[hash][extname]';
}
return 'assets/[name].[hash][extname]';
},
},
},
},
});
# Nginx 缓存配置
server {
# 带 hash 的静态资源,长期缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# HTML 文件不缓存或短期缓存
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
}
6. Service Worker 离线缓存
// sw.ts - Service Worker
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/static/js/main.js',
'/static/css/main.css',
];
// 安装时缓存静态资源
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
});
// 请求时优先使用缓存
self.addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(
caches.match(event.request).then((response) => {
// 缓存命中,直接返回
if (response) {
return response;
}
// 缓存未命中,发起网络请求
return fetch(event.request).then((networkResponse) => {
// 将新资源加入缓存
if (networkResponse.status === 200) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return networkResponse;
});
})
);
});
上面的代码只用于解释 Cache API。生产环境如果长期缓存 /index.html 和无内容哈希的 /static/js/main.js,很容易出现旧 HTML 引用已删除 chunk 的版本错配。更稳妥的策略是:导航请求网络优先并准备离线兜底;带内容哈希的静态资源 Cache First;发布时保留一段时间的旧资源,并设计 Service Worker 更新提示与失败后的注销/刷新恢复流程。
7. 图片优化
// components/OptimizedImage.tsx
import React, { useState, useRef, useEffect } from 'react';
interface OptimizedImageProps {
src: string;
alt: string;
width: number;
height: number;
placeholder?: string;
}
const OptimizedImage: React.FC<OptimizedImageProps> = ({
src,
alt,
width,
height,
placeholder = 'data:image/svg+xml,...', // base64 占位图
}) => {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin: '100px' } // 提前 100px 开始加载
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div
style={{
position: 'relative',
width,
height,
backgroundColor: '#f0f0f0',
}}
>
<img
ref={imgRef}
src={isInView ? src : placeholder}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
onLoad={() => setIsLoaded(true)}
style={{
opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.3s ease',
}}
/>
</div>
);
};
export default OptimizedImage;
8. 启动状态监控与恢复
比“页面里有没有 HTML”更可靠的方式,是定义应用启动状态机并记录确定事件:
type BootstrapStage =
| 'bootstrap-start'
| 'bundle-loaded'
| 'app-mounted'
| 'route-ready';
const startedAt = performance.now();
let currentStage: BootstrapStage = 'bootstrap-start';
export function markBootstrap(stage: BootstrapStage): void {
currentStage = stage;
performance.mark(stage);
if (stage === 'route-ready') {
clearTimeout(whiteScreenTimer);
}
}
const whiteScreenTimer = window.setTimeout(() => {
// 例如在中端移动设备、模拟 4G 的关键页面上,可先用 8 秒作为告警起点,
// 再根据真实用户 P75/P95 和页面类型校准,不应把 8 秒当成统一标准。
reportBootstrapFailure({
stage: currentStage,
elapsed: performance.now() - startedAt,
buildId: window.__BUILD_ID__,
route: location.pathname,
});
showRecoveryPanel();
}, 8_000);
window.addEventListener('error', (event) => {
const element = event.target;
if (element instanceof HTMLScriptElement || element instanceof HTMLLinkElement) {
reportResourceFailure(element.src || element.href);
}
}, true);
window.addEventListener('unhandledrejection', (event) => {
reportBootstrapError(event.reason);
});
恢复面板至少应提供:
- 重新请求数据,而不是只能整页刷新。
- 检测到 chunk 版本错误时,清理对应缓存并进行一次受控刷新,防止刷新死循环。
- 核心模块失败时显示可操作的降级内容、客服入口或状态页。
- 上报 build id、路由、启动阶段、失败资源、网络状态和错误堆栈,但不要采集用户敏感输入。
三、优化效果量化
使用 Lighthouse 或 WebPageTest 测量优化效果:
| 指标 | 含义 | 目标值 |
|---|---|---|
| FCP (First Contentful Paint) | 首次内容绘制 | < 1.8s |
| LCP (Largest Contentful Paint) | 最大内容绘制 | < 2.5s |
| INP (Interaction to Next Paint) | 真实交互响应 | ≤ 200ms(页面访问 P75) |
| TBT (Total Blocking Time) | 总阻塞时间 | < 200ms |
| 启动成功率 | 在业务超时内到达 route-ready 的访问占比 | 根据页面等级设 SLO,例如 ≥ 99.9% |
- 骨架屏:提供即时视觉反馈,减少用户焦虑感
- SSR/SSG/Streaming:关键内容更早出现,但仍需控制 TTFB、JS 和 Hydration
- 代码分割:按需加载,减少首屏 JS 体积
- 资源预加载:提前加载关键资源
- CDN + 缓存:加速资源分发,减少重复下载
- Service Worker:按资源类型设计缓存、更新和版本恢复
- 图片优化:懒加载 + 渐进式加载 + 现代格式
- 启动状态机:确定性打点、超时、错误上报和可操作恢复
常见面试问题
Q1: 为什么 SPA 应用容易出现白屏?
答案:
SPA(单页应用)的 HTML 通常只包含一个空的根节点:
<!DOCTYPE html>
<html>
<body>
<div id="root"></div> <!-- 空的! -->
<script src="app.bundle.js"></script>
</body>
</html>
白屏原因:
- 浏览器必须先下载、解析、执行 JavaScript
- JavaScript 再通过框架(React/Vue)动态生成 DOM
- 这个过程中,页面没有任何可展示的内容
低网速下更严重:因为 JS bundle 通常很大(几百 KB 到几 MB),下载时间长。
面试时还应补一句:如果页面一直不出现,不能只回答“包太大”,还要排查 chunk 404、CSP、运行时异常、Hydration、请求超时和 Service Worker 版本错配,并通过启动阶段打点判断卡在哪里。
Q2: 骨架屏和 Loading 动画有什么区别?应该选择哪个?
答案:
| 对比项 | 骨架屏 | Loading 动画 |
|---|---|---|
| 视觉效果 | 页面布局占位 | 简单的转圈/进度条 |
| 用户感知 | 页面"正在加载内容" | 页面"正在处理" |
| 心理效果 | 感知等待时间更短 | 可能产生焦虑 |
| 实现复杂度 | 较高 | 简单 |
| 适用场景 | 内容型页面 | 简单操作、提交表单 |
选择建议:
- 首屏加载:使用骨架屏(给用户"即将看到内容"的预期)
- 按钮提交:使用 Loading(短暂操作)
- 列表加载更多:使用骨架屏(与已有内容风格统一)
Q3: SSR、CSR、SSG 分别是什么?如何选择?
答案:
// CSR - Client Side Rendering(客户端渲染)
// 特点:JS 在浏览器执行,动态生成 HTML
// 代表:Create React App、Vue CLI
// SSR - Server Side Rendering(服务端渲染)
// 特点:服务器生成完整 HTML,发送到浏览器
// 代表:Next.js、Nuxt.js
// SSG - Static Site Generation(静态站点生成)
// 特点:构建时生成静态 HTML,无需服务器动态渲染
// 代表:Next.js、Gatsby、VitePress
| 方式 | 首屏速度 | SEO | 服务器压力 | 适用场景 |
|---|---|---|---|---|
| CSR | 慢 | 差 | 低 | 后台管理、复杂交互应用 |
| SSR | 快 | 好 | 高 | 电商、新闻、需要 SEO |
| SSG | 最快 | 好 | 无 | 博客、文档、营销页 |
Q4: 什么是代码分割?如何实现?
答案:
代码分割将大的 bundle 拆分成小的 chunks,按需加载:
// 1. 路由级分割(React.lazy)
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
// 2. 组件级分割
const HeavyChart = lazy(() => import('./components/HeavyChart'));
// 3. Webpack 魔法注释
const Settings = lazy(() => import(
/* webpackChunkName: "settings" */
/* webpackPrefetch: true */
'./pages/Settings'
));
效果:
- 首屏只加载必要代码
- 其他页面按需加载
- 减少初始 bundle 大小
Q5: preload、prefetch、preconnect 有什么区别?
答案:
<!-- preconnect:提前建立连接(DNS、TCP、TLS) -->
<link rel="preconnect" href="https://api.example.com" />
<!-- dns-prefetch:仅 DNS 预解析(兼容性更好) -->
<link rel="dns-prefetch" href="https://cdn.example.com" />
<!-- preload:当前页面一定会用到的资源,高优先级 -->
<link rel="preload" href="/critical.js" as="script" />
<!-- prefetch:未来可能用到的资源,低优先级 -->
<link rel="prefetch" href="/next-page.js" />
| 类型 | 优先级 | 用途 | 时机 |
|---|---|---|---|
| preconnect | 高 | 建立连接 | 跨域 API/CDN |
| preload | 高 | 加载资源 | 当前页关键资源 |
| prefetch | 低 | 预获取 | 下一页可能用到 |
Q6: 如何判断优化是否有效?有哪些指标?
答案:
Core Web Vitals(核心指标):
| 指标 | 含义 | 目标值 | 测量工具 |
|---|---|---|---|
| LCP | 最大内容绘制 | ≤ 2.5s | RUM、Lighthouse |
| INP | 交互到下一帧 | ≤ 200ms | RUM、Performance 面板 |
| CLS | 累积布局偏移 | ≤ 0.1 | RUM、Lighthouse |
其他重要指标:
- FCP(首次内容绘制):例如 ≤ 1.8s,可作为“是否尽早出现像素”的辅助阈值
- TBT(总阻塞时间):例如在 Lighthouse 移动端配置中控制在 200ms 内
- 启动成功率:是否在页面定义的超时时间内到达
route-ready - 白屏率:应按版本、路由、设备和网络分群,而不是只看全站平均值
测量方法:
// 使用 Performance API
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.name}: ${entry.startTime}ms`);
}
});
observer.observe({ entryTypes: ['largest-contentful-paint', 'paint'] });
INP、CLS 等生产指标优先使用官方 web-vitals 采集,不要通过已退出 Core Web Vitals 的 first-input 代替 INP。
Q7: 骨架屏延迟显示策略是什么?为什么需要它?
答案:
问题:如果数据加载很快(< 200ms),骨架屏会一闪而过,反而影响体验。
解决方案:延迟显示骨架屏
function useDelayedLoading(isLoading: boolean, delay = 200, minDuration = 300) {
const [showSkeleton, setShowSkeleton] = useState(false);
useEffect(() => {
let delayTimer: NodeJS.Timeout;
let minTimer: NodeJS.Timeout;
if (isLoading) {
// 延迟 200ms 后才显示骨架屏
delayTimer = setTimeout(() => setShowSkeleton(true), delay);
} else if (showSkeleton) {
// 骨架屏至少显示 300ms
minTimer = setTimeout(() => setShowSkeleton(false), minDuration);
}
return () => {
clearTimeout(delayTimer);
clearTimeout(minTimer);
};
}, [isLoading]);
return showSkeleton;
}
| 加载时间 | 无延迟策略 | 有延迟策略 |
|---|---|---|
| 100ms | 骨架屏闪现 | 直接显示内容 |
| 500ms | 骨架屏 500ms | 200ms 后显示骨架屏 300ms |