Skip to content
白屏优化
概述
白屏时间指用户输入 URL 到浏览器完成首次绘制(First Paint)的时间间隔。这段时间内浏览器没有向屏幕输出任何像素,用户的屏幕上始终是空白。优化本质是缩短“发起请求 → 首帧可见”这条关键路径。
影响这条路径的因素可归入四个层面:网络层、资源层、渲染层、构建层。下面按请求—响应的执行链路逐一展开。
网络层
网络层的优化目标是尽快把资源送到浏览器。涉及的环节包括 DNS 解析、连接建立、传输压缩和内容分发。
DNS 预解析
工作原理
浏览器在解析到 <link rel="dns-prefetch"> 时立即对指定域名发起 DNS 查询,而不是等到后续遇到该域名的真实资源 URL。查询结果由浏览器缓存(通常跟随操作系统提供的 DNS TTL),后续资源请求可以直接使用已缓存的 IP 地址,省去一次 DNS 解析的延迟。DNS 解析的典型耗时在 20–120ms。
基本用法
html
<link rel="dns-prefetch" href="//cdn.jsdelivr.net" />
<link rel="dns-prefetch" href="//fonts.googleapis.com" />适用于 CDN、字体服务、数据统计 SDK 等第三方域名。
注意点
dns-prefetch只做 DNS 查询,不建立 TCP 或 TLS 连接。- 对于当前页面几乎确定会请求的域名,使用
preconnect更彻底;对于可能但不一定用到的域名(如用户可能点击的链接指向的域名),dns-prefetch是更轻量的选择。
资源提示优先级
除了 dns-prefetch 和 preconnect,浏览器还提供了 prefetch 和 preload 两种资源提示。它们在关键请求路径上分处不同层级:
| 提示 | 解析域名 | 建立连接 | 下载资源 | 适用场景 |
|---|---|---|---|---|
dns-prefetch | ✓ | ✗ | ✗ | 可能访问的第三方域名 |
preconnect | ✓ | ✓ | ✗ | 确定即将请求的域名 |
prefetch | ✓ | ✓ | ✓(低优先级) | 下一页面可能用到的资源 |
preload | ✓ | ✓ | ✓(高优先级) | 当前页面必定需要的资源 |
关键区别在于优先级。preload 以高优先级下载当前页面必需但可能被浏览器延迟发现的资源(如字体文件、通过 @import 引入的 CSS)。prefetch 以最低优先级下载未来导航可能用到的资源,浏览器会在空闲时处理,不影响当前页面的关键请求。错误地用 prefetch 去加载首屏关键资源并不会提升性能;错误地用 preload 去预加载非关键资源则会挤占带宽,反而推迟了真正关键资源的到达时间。
preconnect 是 dns-prefetch 的超集:除了 DNS 解析,还会完成 TCP 三次握手和 TLS 握手(对 HTTPS 请求)。当浏览器真正发起资源请求时,连接已经就绪,省去了完整的建连耗时。
基本用法:
html
<link rel="preconnect" href="https://api.example.com" />注意点:
preconnect会消耗服务端的连接资源,只应在确认页面即将(10 秒内)请求该域名时使用。- 对确定性不高的域名使用
preconnect可能造成资源浪费。通常遵循:高确定性 →preconnect,低确定性 →dns-prefetch。
CDN 分发
工作原理
内容分发网络将静态资源缓存在全球边缘节点上。用户发起请求时,DNS 根据其 IP 地理信息将请求导向最近的节点。节点若已缓存该资源则直接返回;未命中时回源到源站拉取,缓存后再返回。
配置要点
静态资源(JavaScript、CSS、图片、字体)适合从 CDN 加载,并设置长缓存时间:
Cache-Control: max-age=31536000, immutableHTML 文档通常设置短缓存或不缓存(
max-age=0, must-revalidate),以保证内容更新能即时生效。资源文件名使用内容哈希(如
app.a1b2c3d.js)。更新文件时文件名随之变化,自然实现缓存失效,不再依赖Cache-Control的过期机制。
传输压缩
Gzip
Nginx 中启用 Gzip 的典型配置:
nginx
gzip on;
gzip_comp_level 6;
gzip_min_length 1000;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_vary on;
gzip_proxied any;各项参数的含义:
gzip_comp_level 6是在压缩率与 CPU 开销之间取平衡的常用值。1–4 级压缩率提升明显、CPU 开销增加有限;5–6 级压缩率进入平台期;7–9 级 CPU 开销陡增而压缩率提升不足 5%。gzip_min_length 1000:小于 1KB 的资源不值得压缩——压缩本身的开销可能超过节省的传输时间。gzip_vary on:给响应加上Vary: Accept-Encoding头,告知中间缓存应根据客户端是否接受压缩来区分缓存版本。
Brotli
Brotli 在现代浏览器中得到广泛支持,压缩率通常比 Gzip 高 15–25%,但压缩速度更慢。更适合在构建阶段对静态资源进行预压缩,而不是在服务器上实时压缩。服务端需要额外模块(如 ngx_brotli)来支持 br 编码。
图片延迟加载
基本用法
html
<img src="photo.jpg" loading="lazy" alt="" />loading="lazy" 在 Chrome 77+ 和 Firefox 75+ 中原生支持。浏览器会在图片接近视口时自动开始加载,视口外的阈值距离约为 500–1250px(具体值由浏览器根据当前网络状况动态调整)。
示例(手动控制)
需要自定义占位符或加载动画时,可以手动控制加载时机:
javascript
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));当图片进入视口时,从 data-src 取值赋给 src 发起加载,并取消对该图片的观察。observer.unobserve(img) 避免了回调在后续滚动中被重复触发。
资源层
资源层的优化目标是减少首屏需要加载的代码量——浏览器只下载渲染当前页面必须的资源。
关键 CSS 内联
工作原理
外部样式表(<link rel="stylesheet">)是渲染阻塞资源:浏览器必须等待 CSS 下载完成并构建出 CSSOM 之后,才会进行布局计算。将首屏渲染所需的最小样式直接内联在 <head> 的 <style> 标签中,可以消除一次网络往返,使浏览器在接收 HTML 的同时获得完整 CSSOM。
基本用法
html
<head>
<style>
/* 首屏关键样式:布局、字体、首屏可见元素的样式 */
</style>
<!-- 非关键 CSS 异步加载 -->
<link rel="preload" href="/styles/full.css" as="style" onload="this.rel='stylesheet'" />
<!-- 降级:不支持 preload 或禁用 JS 时直接加载 -->
<noscript><link rel="stylesheet" href="/styles/full.css" /></noscript>
</head>执行逻辑:
- 浏览器解析到内联
<style>时立即构建首屏 CSSOM。 <link rel="preload">以高优先级下载full.css,但不阻塞渲染。- 下载完成后,
onload事件将rel切换为stylesheet,应用完整样式。 <noscript>保证在禁用 JavaScript 或preload不被支持时,样式表仍能正常加载。
注意点
- 内联的关键 CSS 应控制在约 14KB 以内(一个 TCP 初始拥塞窗口的大小),避免显著增加 HTML 体积、拖慢首字节时间。
- 内联样式不能被浏览器单独缓存,因此只应放置每次页面访问都可能需要的最小样式集。
代码分包
工作原理
构建工具默认将所有模块打包进一个 JavaScript bundle。如果不配置分包,即使只访问首页,浏览器也需要加载整个站点的代码。通过分包,可以将代码拆分为多个 chunk,按需加载。
Vite 配置
ts
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-ui': ['antd'],
'vendor-utils': ['lodash-es', 'dayjs'],
}
}
}
}
});manualChunks 将指定的第三方依赖单独拆分为独立 chunk。这些 chunk 在应用代码变化时不会重新生成哈希,可以利用浏览器缓存长期保留。
Webpack 配置
javascript
optimization: {
splitChunks: {
chunks: 'all',
maxInitialRequests: 25,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
reuseExistingChunk: true,
},
common: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
}maxInitialRequests: 25 允许初始请求切分出更多的 chunk,在某些多入口大型项目中可能需要调高默认值(25)以避免不必要的合并。
路由懒加载
将页面组件拆分为独立 chunk,仅在访问对应路由时才加载:
javascript
// Vue
const Home = () => import('@/views/Home.vue');
// React
const Home = lazy(() => import('@/pages/Home'));路由懒加载是代码分包中收益最高的一种手段:首屏不需要的页面代码根本不会出现在初始 bundle 中。
Tree Shaking
工作原理
Tree Shaking 依赖 ES Module 的静态 import/export 语法,使构建工具能够在编译阶段识别出哪些导出没有被引用,并将其移除。CommonJS 的动态 require 无法做到这一点,因为依赖关系在运行时才能确定。
生效条件
- 源代码使用 ES Module 语法(
import/export)。 - 在
package.json中设置"sideEffects": false,或明确指出哪些文件具有副作用。 - 使用生产模式构建(
mode: 'production')。
Vite 和 Webpack 在生产模式下默认启用 Tree Shaking,通常不需要额外配置。但如果某个依赖库的 sideEffects 标记有误,可能导致工具误删掉带有副作用(如全局注册 CSS)的模块。
HTTP/2 多路复用
工作原理
HTTP/2 在单个 TCP 连接上以流的形式复用多个请求与响应,消除了 HTTP/1.1 的队头阻塞问题(同一连接上一个请求未完成时,后续请求必须排队等待)。
命令(Nginx)
nginx
listen 443 ssl http2;要求 Nginx ≥ 1.9.5。浏览器默认只在 TLS 上启用 HTTP/2。
限制
由于所有请求共享同一个 TCP 连接,一旦发生丢包,该连接上所有流都会受到影响。在丢包率较高的弱网环境中,HTTP/2 的表现可能不如 HTTP/1.1 的多连接并行策略。根据 Google 在 QUIC 提案中引用的实验数据,丢包率达到 2% 时,HTTP/2 的页面加载时间可能劣化 15% 以上;在丢包率 5% 时,劣化幅度超过 30%。对于面向全球不同网络条件用户的站点,需要根据实际 RUM 数据评估收益。
渲染层
渲染层的优化目标是让浏览器在收到响应后更快地产出首帧像素。
服务端渲染(SSR)
工作原理
服务端渲染在服务器上执行前端组件代码,生成包含完整内容的 HTML 页面返回给浏览器。浏览器收到 HTML 后可以立即开始渲染,不必等待 JavaScript 下载和执行。
SSR 与客户端渲染(CSR)的核心差异:
| 指标 | CSR | SSR |
|---|---|---|
| 首次内容绘制(FCP) | 需等 JS 执行完成 | HTML 到达后即可绘制 |
| 可交互时间(TTI) | JS 执行完毕即可交互 | 需额外的 hydration 步骤 |
| 搜索引擎抓取 | 爬虫可能只看到一个空壳 | HTML 中已有完整内容 |
| 服务器负载 | 低(只需提供静态文件) | 高(每次请求需执行组件代码) |
Vue 项目可选用 Nuxt,React 项目可选用 Next.js,两者都内置了 SSR 能力。
静态站点生成(SSG)
工作原理
对内容不频繁变化的页面(如博客、文档、营销站),在构建阶段拉取数据并预渲染为静态 HTML。用户请求时直接返回预渲染好的 HTML,不需要服务器实时执行组件代码,也不需要运行 Node 服务。
相关命令
| 框架 | 生成方式 |
|---|---|
| Next.js | npx next build(搭配 getStaticProps) / npx next export |
| Nuxt | npx nuxt generate |
| VitePress | npx vitepress build |
骨架屏
骨架屏不减少白屏时间的绝对值,而是改变用户的时间感知。通过显示一个与预期内容布局相似的占位图形(灰色块 + 微光动画),暗示内容正在加载。
示例
html
<div class="skeleton-card">
<div class="skeleton-avatar"></div>
<div class="skeleton-lines">
<div class="skeleton-line skeleton-line--short"></div>
<div class="skeleton-line skeleton-line--medium"></div>
<div class="skeleton-line skeleton-line--long"></div>
</div>
</div>css
.skeleton-card {
display: flex;
gap: 12px;
padding: 16px;
}
.skeleton-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: #e0e0e0;
}
.skeleton-line {
height: 12px;
margin-bottom: 8px;
border-radius: 4px;
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line--short { width: 40%; }
.skeleton-line--medium { width: 75%; }
.skeleton-line--long { width: 100%; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}动画通过移动 background-position 制造一道从左向右滑过的微光。background-size: 200% 100% 让渐变的半周期正好占满元素宽度,保证动画循环时视觉连贯。
注意点
- 骨架屏的渲染成本极低,仅由静态 DOM 与 CSS 构成。
- 实施时应避免引发大规模布局计算:可使用
position: absolute或content-visibility: auto将占位元素的影响控制在首帧渲染之外。
预渲染
预渲染针对 SPA 的少数高优先级路由(如首页、落地页),在构建时生成静态 HTML。请求时直接返回对应的静态文件,其余路由继续走客户端渲染。与全站 SSG 的区别在于:它只覆盖部分路由,适用于只需提升首屏性能与 SEO 的场景。
配置示例
以 Vue CLI 中使用 prerender-spa-plugin 为例:
javascript
// vue.config.js
const PrerenderSPAPlugin = require('prerender-spa-plugin');
const path = require('path');
module.exports = {
configureWebpack: {
plugins: [
new PrerenderSPAPlugin({
staticDir: path.join(__dirname, 'dist'),
routes: ['/', '/about'],
}),
],
},
};该插件在 npm run build 时会启动无头浏览器访问 / 和 /about,将生成的 HTML 保存为静态文件。用户访问这两个路由时直接加载预渲染的 HTML,其余路由仍由客户端动态渲染。
延迟激活(Hydration)
SSR 页面在客户端进行 hydration 时,不需要一次性激活所有组件。非首屏的组件可以推迟激活:
- 使用
requestIdleCallback调度非关键组件的激活。 - 仅在组件进入视口时才执行 hydration(类似懒加载逻辑)。
- 对于纯静态且无交互需求的区块(如页脚),可以完全跳过 hydration。
示例:Vue 中使用 requestIdleCallback 延迟激活
vue
<script setup>
import { ref, onMounted } from 'vue';
import ExpensiveComponent from './ExpensiveComponent.vue';
const showComponent = ref(false);
onMounted(() => {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
showComponent.value = true;
});
} else {
setTimeout(() => {
showComponent.value = true;
}, 200);
}
});
</script>
<template>
<div>
<ExpensiveComponent v-if="showComponent" />
</div>
</template>requestIdleCallback 在浏览器空闲时段才执行回调,确保该组件的激活不与首屏渲染竞争主线程资源。setTimeout(fn, 200) 作为降级方案,在不支持该 API 的浏览器中延迟激活。
构建层
构建层的优化是在项目打包阶段直接削减产物体积,或通过缓存机制加速构建与二次访问。
代码压缩与混淆
- Terser(Webpack 默认):移除注释、缩短变量名、消除不可达代码。
- esbuild(Vite 默认):压缩速度比 Terser 快数十倍,压缩率接近。
- CSSNano:CSS 专用压缩工具,合并规则、缩短颜色值、移除空白。
构建缓存
构建工具一般支持对中间的编译结果进行缓存,避免每次构建时重复分析未变更的模块。Vite 默认启用文件系统缓存;Webpack 5 通过 cache 配置项支持持久化缓存,可以显著缩短二次构建时间。
javascript
// webpack.config.js
module.exports = {
cache: {
type: 'filesystem', // 将缓存写入 node_modules/.cache
allowCollectingMemory: true,
},
};在 CI/CD 环境中,若支持缓存目录持久化,可以进一步利用该缓存减少构建耗时,间接提升发布效率。
静态资源内联限制
构建工具提供将小文件内联为 Data URL 或内联文本的能力,减少 HTTP 请求数量。
javascript
// vite.config.js
export default {
build: {
assetsInlineLimit: 4096, // 小于 4KB 的资源会被内联为 base64
},
};需要注意:
- 内联会增大 JS/CSS bundle 的体积。如果滥用,可能导致资源缓存粒度变差——单个小图片的改动本可以只影响该图片文件,但内联后会导致整个 bundle 的哈希变化,强制浏览器重新下载所有内联的 bundle。
- 对于低频的小图标,内联收益明显;对于多处复用的图片,独立文件配合缓存可能更优。
现代语法与双版本产物
利用 <script type="module"> 加载使用现代 JavaScript 语法的产物,同时配合 <script nomodule> 为旧浏览器提供降级版本。
html
<script type="module" src="/app.es2020.js"></script>
<script nomodule src="/app.legacy.js"></script>支持 ES Module 的现代浏览器加载体积更小、运行更快的现代语法版本;旧浏览器忽略 type="module" 而加载 legacy 版本,忽略 nomodule 的现代浏览器则不会重复加载。Vite 通过 @vitejs/plugin-legacy 可以自动生成这套双版本构建产物。
效果衡量
优化效果应以量化的指标来衡量。以下是与白屏、渲染相关的常用性能指标及参考目标值。
| 指标 | 含义 | 目标 |
|---|---|---|
| FP(First Paint) | 首次绘制(任意像素) | 小于 1 秒 |
| FCP(First Contentful Paint) | 首次内容绘制 | 小于 1.8 秒 |
| LCP(Largest Contentful Paint) | 最大内容绘制 | 小于 2.5 秒 |
| TTI(Time to Interactive) | 可交互时间 | 小于 3.8 秒 |
| TBT(Total Blocking Time) | 主线程阻塞总时长 | 小于 300 毫秒 |
本地开发时可以使用 Chrome DevTools → Lighthouse 面板进行测量。线上持续监测通常借助 web-vitals 等工具收集真实用户监控(RUM)数据。
