Appearance
白屏优化
白屏时间的定义:从用户输入 URL(或点击链接)到浏览器完成首次绘制(First Paint)的间隔。这段时间内用户看到的是空白屏幕——优化它的本质是缩短"从发起请求到像素可见"的关键路径。
优化的四个层面按影响权重排列:
- 网络层:减少资源到达浏览器的耗时(DNS、TCP、TLS、CDN、压缩)
- 资源层:减少首屏需要加载的代码量(内联、分包、Tree Shaking)
- 渲染层:让浏览器更快产生产出首帧像素(SSR、SSG、骨架屏)
- 构建层:在打包阶段就削减体积(压缩、混淆、现代化语法)
一、网络层
DNS 预解析(dns-prefetch)
DNS 解析的延迟通常在 20-120ms。对于第三方域名(CDN、字体、分析SDK),这个延迟发生在关键路径上。
html
<!-- 在 <head> 中提前声明,浏览器在解析 HTML 时立即发起 DNS 查询 -->
<link rel="dns-prefetch" href="//cdn.jsdelivr.net" />
<link rel="dns-prefetch" href="//fonts.googleapis.com" />dns-prefetch 只做 DNS 查询,不建立连接。浏览器在解析到 <link rel="dns-prefetch"> 时立即发起 DNS 查询,而不是等到遇到真正的资源 URL。DNS 查询结果被缓存在浏览器中(通常跟随 OS DNS 缓存 TTL),后续真正的资源请求直接使用缓存结果。
TCP/TLS 预连接(preconnect)
preconnect 是 dns-prefetch 的超集——它不仅做 DNS 解析,还完成 TCP 三次握手和 TLS 握手(HTTPS):
html
<link rel="preconnect" href="https://api.example.com" />preconnect 的开销比 dns-prefetch 大(TCP+TLS 握手消耗服务端连接资源),只在确定会用到该域名时使用。对于可能用到的域名(如用户可能点击的链接),用 dns-prefetch。
判断准则:如果 10 秒内一定会请求该域名 → preconnect;可能用到但不确定 → dns-prefetch。
CDN 加速
CDN 将静态资源缓存在全球边缘节点上。用户请求被 DNS 智能解析到最近的节点:
text
用户 → 智能 DNS(基于 IP 定位)→ 最近 CDN 边缘节点
├── 命中缓存 → 直接返回
└── 未命中 → 回源到源站 → 缓存 → 返回关键配置:
- 静态资源(JS/CSS/图片/字体)通过 CDN 分发,设置长时间的
Cache-Control: max-age=31536000, immutable - HTML 文档通常不缓存或短缓存(
max-age=0, must-revalidate),保证内容更新即时生效 - 使用内容哈希命名(
app.a1b2c3d.js)实现缓存失效,而非依赖Cache-Control过期
Gzip / Brotli 压缩
Gzip 对文本资源的压缩率通常在 60-80%。现代浏览器也支持 Brotli(br),压缩率比 Gzip 高 15-25%,但压缩速度更慢——适合构建时预压缩静态资源。
Nginx 中 Gzip 的核心配置:
nginx
gzip on;
gzip_comp_level 6; # 1-9,6 是压缩率与 CPU 的最佳平衡点
gzip_min_length 1000; # 小于 1KB 的资源不值得压缩(压缩开销 > 传输节省)
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_vary on; # 发送 Vary: Accept-Encoding 头,告诉 CDN 区分压缩/未压缩缓存
gzip_proxied any; # 对代理/CDN 的响应也压缩gzip_comp_level 6 是工程中的常用选择——1-4 压缩率提升明显而 CPU 开销增加有限,5-6 压缩率进入平台期,7-9 CPU 开销陡增但压缩率提升不到 5%。
图片懒加载
html
<!-- 浏览器原生支持,无需 JS -->
<img src="photo.jpg" loading="lazy" alt="" />loading="lazy" 在 Chrome 77+、Firefox 75+ 中原生支持。浏览器在图片进入视口的一定距离(通常 500-1250px,取决于网络状况)时才开始加载。对于需要更复杂加载逻辑的场景(如占位符、加载动画),使用 IntersectionObserver:
js
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));二、资源层
首屏关键 CSS 内联
外部 CSS(<link rel="stylesheet">)阻塞渲染——浏览器必须等待 CSS 下载并解析为 CSSOM 后才会开始 Layout。将首屏所需的关键 CSS 内联到 <head> 的 <style> 标签中,消除这次网络往返:
html
<head>
<style>
/* 首屏关键样式:布局、字体、首屏可见元素的样式 */
/* 通常 14KB 以内(一个 TCP 初始窗口的大小) */
</style>
<!-- 非关键 CSS 异步加载 -->
<link rel="preload" href="/styles/full.css" as="style" onload="this.rel='stylesheet'" />
</head>rel="preload" + onload 切换 rel 的模式让 CSS 不阻塞渲染——浏览器以高优先级下载 CSS,但不在下载期间阻塞渲染,下载完成后通过修改 rel 属性应用样式。这是目前最可靠的非阻塞 CSS 加载方式。
代码分包
Vite/Webpack 的分包策略直接影响首屏加载的 JS 体积。不配置分包时,所有代码打入一个 bundle——首屏加载了整站代码。
Vite(基于 Rollup)的分包配置:
ts
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-ui': ['antd'],
'vendor-utils': ['lodash-es', 'dayjs'],
}
}
}
}
});Webpack 的 splitChunks 配置:
js
optimization: {
splitChunks: {
chunks: 'all', // 对同步和异步 chunk 都拆分
maxInitialRequests: 25, // 入口的最大并行请求数
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
reuseExistingChunk: true,
},
common: {
minChunks: 2, // 至少被 2 个 chunk 引用才拆分
priority: -20,
reuseExistingChunk: true,
},
},
},
}路由懒加载是代码分包的最有效手段——每个路由对应的组件仅在访问时加载:
js
// Vue
const Home = () => import('@/views/Home.vue');
// React
const Home = lazy(() => import('@/pages/Home'));Tree Shaking
Tree Shaking 依赖 ES Module 的静态导入语法(import / export)。CommonJS 的 require() 是动态的,无法在构建时确定哪些代码被使用。
Tree Shaking 生效的前提:
- 使用 ES Module(
import/export) - 在
package.json中设置"sideEffects": false(或列出有副作用的文件) - 使用生产模式构建(
mode: 'production')
Vite/Webpack 默认开启 Tree Shaking,无需手动配置。
HTTP/2 多路复用
HTTP/2 的核心改进:一个 TCP 连接上可以同时传输多个请求/响应,消除 HTTP/1.1 的队头阻塞(同一连接一个请求一个响应,后续必须排队)。
Nginx 开启 HTTP/2:
nginx
listen 443 ssl http2;关键权衡:HTTP/2 所有请求共享一个 TCP 连接,一旦丢包所有请求都受影响。 在弱网环境下(丢包率 > 2%),HTTP/2 的表现可能不如 HTTP/1.1 的多连接并行策略。对国际化产品,需要按地区测试 HTTP/2 的实际收益。
http2 指令需要 Nginx 1.9.5+,且必须配合 HTTPS(浏览器默认仅在 TLS 上启用 HTTP/2)。
三、渲染层
SSR(服务端渲染)
SSR 在服务端执行组件代码,生成完整 HTML 返回给浏览器。浏览器收到 HTML 后立即可渲染——不需要等 JS 下载和执行:
| 指标 | CSR | SSR |
|---|---|---|
| FCP(首次内容绘制) | 需等 JS 执行 | HTML 到达即可绘制 |
| TTI(可交互时间) | JS 执行完即可交互 | 需额外 hydrate |
| SEO | ❌ 爬虫看到空壳 | ✅ HTML 中有完整内容 |
| 服务器压力 | 低(静态文件) | 高(每次请求执行组件) |
Vue 项目用 Nuxt,React 项目用 Next.js,都有内置 SSR 支持。
SSG(静态站点生成)
对内容不频繁变化的页面(博客、文档、营销站),SSG 在构建时预渲染为静态 HTML。访问时直接返回预渲染的 HTML——比 SSR 更快,且不需要 Node 服务器:
| 框架 | SSG 命令 |
|---|---|
| Next.js | next export / getStaticProps |
| Nuxt | nuxt generate |
| VitePress | vitepress build |
骨架屏(Skeleton Screen)
骨架屏不是减少白屏时间,而是改变用户的感知——用一个占位形状(灰色块 + 动画)"暗示"内容正在加载:
- 用户看到一个骨架屏 > 看到白屏(感知上更快)
- 骨架屏的渲染开销极低(静态 DOM + CSS)
- 注意:骨架屏不应触发 Layout(使用
position: absolute或content-visibility: auto)
预渲染
对 SPA 的首屏做预渲染,在构建时生成静态 HTML。与 SSG 的区别:预渲染通常只针对少数几个路由(首页、落地页),其余路由保持 SPA 模式。适合不需要全站 SSR/SSG 但首屏需要 SEO 的场景。
延迟 Hydrate
SSR 页面 hydration 时不需要一次性激活所有组件。非首屏组件可以延迟 hydrate:
- 使用
requestIdleCallback调度非关键组件的 hydration - 只在组件进入视口时 hydrate(类似懒加载的逻辑)
- 对纯静态内容(如页脚),可以不 hydrate——它们不需要交互
四、构建层
压缩与混淆
- Terser(Webpack 默认):移除注释、缩短变量名、删除 dead code
- esbuild(Vite 默认):比 Terser 快 20-40 倍,压缩率接近
- CSSNano:CSS 压缩(移除空白、合并规则、缩短颜色值)
现代 JS 语法
使用 type="module" 的 <script> 标签,配合 <script nomodule> 作为降级:
html
<script type="module" src="/app.es2020.js"></script>
<script nomodule src="/app.legacy.js"></script>支持 ES Module 的现代浏览器加载更小更快的现代语法版本,旧浏览器加载转译后的 legacy 版本。Vite 的 @vitejs/plugin-legacy 自动生成这套双版本方案。
优化效果的衡量
不要凭感觉判断白屏是否变快。用数据:
| 指标 | 含义 | 目标 |
|---|---|---|
| FP(First Paint) | 首次绘制(任何像素) | < 1s |
| FCP(First Contentful Paint) | 首次内容绘制 | < 1.8s |
| LCP(Largest Contentful Paint) | 最大内容绘制 | < 2.5s |
| TTI(Time to Interactive) | 可交互时间 | < 3.8s |
| TBT(Total Blocking Time) | 主线程阻塞总时长 | < 300ms |
Chrome DevTools → Lighthouse 可以直接测这些指标。线上则通常配合 Web Vitals 库(web-vitals npm 包)做 RUM(Real User Monitoring)采集。
