跳到主要内容

如何处理低网速下的白屏问题

问题

在低网速环境下,用户访问 SPA(单页应用)时经常会遇到长时间白屏的问题。这是因为浏览器需要下载、解析并执行 JavaScript 后才能渲染页面内容。如何优化这种体验?

面试速答版

低网速下 SPA 白屏的根本原因? 常见情况是 SPA 的 HTML 只有一个空 <div id="root">,必须等 JS 下载、解析、执行完才能产生业务内容;但线上“白屏”还可能是资源 404、JS 异常、Hydration 失败、Service Worker 版本错配或接口永久 pending:

  • 网速慢的时候,JS bundle(动辄几百 KB)下载就要好几秒,期间用户只能看白屏。
  • 体现在指标上就是 FCPLCP 都被拉长,TTFB 反而正常——说明问题在客户端而不是服务端。

应该怎么优化白屏体验? 按「让用户更早看到东西」和「让东西更早能用」两个方向来:

  • 早看到:HTML 里提供轻量、稳定的初始壳或品牌占位,关键 CSS 内联;骨架屏应与最终布局一致,避免它自己成为错误的 LCP 或引发 CLS。
  • 更快加载:JS 用 defer 不阻塞解析、按路由做代码分割、用 <link rel="preload"> 提前抓核心资源。
  • 架构级:SSR/SSG/流式渲染可以让关键内容更早出现,但仍要控制客户端 JS、Hydration 和服务端 TTFB;Service Worker 只有在版本与更新策略正确时才有帮助。
  • 检测与恢复:在 bootstrap-startbundle-loadedapp-mountedroute-ready 等确定节点打点,结合资源错误、全局异常和超时判断;不要轮询 document.body.innerHTML,因为空壳、骨架屏、弹窗和浏览器插件都可能误判。

答案

白屏要先分成两类:慢启动是页面最终能出现,但 FCP/LCP 很晚;启动失败是关键资源、运行时或数据流程出错,页面永远无法进入可用状态。前者做性能优化,后者需要可观测、容错、恢复和发布治理。

一、白屏产生的原因

不要把所有白屏都归因于 SPA

空 HTML 壳只是慢启动的一种原因。SSR 页面也可能因为服务端超时、流式响应中断、Hydration 异常或静态资源版本错配而白屏;一个带骨架屏的页面也可能业务区域永远没有 ready。

类型典型证据优先处理
HTML/TTFB 慢Navigation Timing、服务端 traceCDN、缓存、服务端并行取数、超时降级
关键资源慢Network waterfall、LCP discovery压缩、优先级、缓存、CDN、减少首屏 JS
资源失败捕获阶段 error、404/5xx、CSP/SRI容灾地址、重试、版本回滚、部署完整性
JS 启动异常errorunhandledrejection、入口未执行错误边界、兼容性、监控 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. 资源预加载

利用 preloadprefetchpreconnect 提前加载关键资源。

<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 vs prefetch
  • 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;
});
})
);
});
不要对 HTML 和入口脚本无脑 Cache First

上面的代码只用于解释 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”更可靠的方式,是定义应用启动状态机并记录确定事件:

bootstrap-monitor.ts
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);
});

恢复面板至少应提供:

  1. 重新请求数据,而不是只能整页刷新。
  2. 检测到 chunk 版本错误时,清理对应缓存并进行一次受控刷新,防止刷新死循环。
  3. 核心模块失败时显示可操作的降级内容、客服入口或状态页。
  4. 上报 build id、路由、启动阶段、失败资源、网络状态和错误堆栈,但不要采集用户敏感输入。

三、优化效果量化

使用 LighthouseWebPageTest 测量优化效果:

指标含义目标值
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%
核心要点总结
  1. 骨架屏:提供即时视觉反馈,减少用户焦虑感
  2. SSR/SSG/Streaming:关键内容更早出现,但仍需控制 TTFB、JS 和 Hydration
  3. 代码分割:按需加载,减少首屏 JS 体积
  4. 资源预加载:提前加载关键资源
  5. CDN + 缓存:加速资源分发,减少重复下载
  6. Service Worker:按资源类型设计缓存、更新和版本恢复
  7. 图片优化:懒加载 + 渐进式加载 + 现代格式
  8. 启动状态机:确定性打点、超时、错误上报和可操作恢复

常见面试问题

Q1: 为什么 SPA 应用容易出现白屏?

答案

SPA(单页应用)的 HTML 通常只包含一个空的根节点:

<!DOCTYPE html>
<html>
<body>
<div id="root"></div> <!-- 空的! -->
<script src="app.bundle.js"></script>
</body>
</html>

白屏原因

  1. 浏览器必须先下载、解析、执行 JavaScript
  2. JavaScript 再通过框架(React/Vue)动态生成 DOM
  3. 这个过程中,页面没有任何可展示的内容

低网速下更严重:因为 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.5sRUM、Lighthouse
INP交互到下一帧≤ 200msRUM、Performance 面板
CLS累积布局偏移≤ 0.1RUM、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骨架屏 500ms200ms 后显示骨架屏 300ms

相关链接