跳到主要内容

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

问题

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

答案

白屏问题本质上是首屏渲染时间(FCP)过长导致的用户体验问题。解决方案需要从减少资源体积加快资源加载优化渲染策略三个维度入手。

一、白屏产生的原因

白屏的根本原因

SPA 应用的 HTML 文件通常只有一个空的 <div id="root"></div>,所有内容都依赖 JavaScript 动态生成。在 JS 加载完成前,页面没有任何可展示的内容。

二、解决方案

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;
});
})
);
});

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;

三、优化效果量化

使用 LighthouseWebPageTest 测量优化效果:

指标含义目标值
FCP (First Contentful Paint)首次内容绘制< 1.8s
LCP (Largest Contentful Paint)最大内容绘制< 2.5s
TTI (Time to Interactive)可交互时间< 3.8s
TBT (Total Blocking Time)总阻塞时间< 200ms
核心要点总结
  1. 骨架屏:提供即时视觉反馈,减少用户焦虑感
  2. SSR/SSG:服务端直出 HTML,消除 JS 依赖
  3. 代码分割:按需加载,减少首屏 JS 体积
  4. 资源预加载:提前加载关键资源
  5. CDN + 缓存:加速资源分发,减少重复下载
  6. Service Worker:离线可用,秒开体验
  7. 图片优化:懒加载 + 渐进式加载 + 现代格式

相关链接