这是一门实战导向的单页应用加载优化课程。我们将从一个真实可跑的 SPA 项目出发,手把手带你完成 12 个渐进式实战任务:从建立性能基线、代码分割、资源预加载,到 Tree Shaking、Brotli 压缩、SSR 改造、缓存策略、运行时调优,最后形成一套可量化的性能监控体系。每个任务都配有可运行的代码、验证步骤和验收标准,90 分钟跟着做,你就能把"理论"变成"肌肉记忆"。
项目准备:搭建你的实验场
开始优化之前,先创建一个可复现的 SPA 项目。我们使用 Vite + Vue 3 构建一个包含首页、仪表盘、设置页三个路由的最小应用,打包后足以模拟真实场景。
任务 0:初始化项目与性能基线
npm create vite@latest spa-perf-lab -- --template vue
cd spa-perf-lab
npm install
npm install vue-router axios lodash echarts
# 启动开发服务器
npm run dev
# 生产构建(先看看未优化的体积)
npm run build
# 记录 dist/assets 下最大的 JS 文件大小
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{ path: '/', component: () => import('../views/Home.vue') },
{ path: '/dashboard', component: () => import('../views/Dashboard.vue') },
{ path: '/settings', component: () => import('../views/Settings.vue') }
];
export default createRouter({
history: createWebHistory(),
routes
});
验收标准
记录当前 npm run build 后最大 JS 文件的体积(通常 > 1MB),并使用 Lighthouse 记录 Performance 分数与 LCP 值。这就是你的"优化前基线"。
实战一:路由级代码分割
把"一个巨型 bundle"拆成"多个按需 chunk",是 SPA 优化收益最快的一步。我们将用三种方式实践:路由懒加载、组件懒加载、第三方库按需引入。
任务 1:拆分路由与重组件
import Xxx from './views/Xxx.vue' 改为 () => import('./views/Xxx.vue')。重新构建,观察 chunk 数量变化。
import Home from './views/Home.vue';
import Dashboard from './views/Dashboard.vue';
import Settings from './views/Settings.vue';
/* 改造后:按需加载 */
const Home = () => import(
/* webpackChunkName: "home" */
'./views/Home.vue'
);
const Dashboard = () => import(
/* webpackChunkName: "dashboard" */
'./views/Dashboard.vue'
);
const Settings = () => import(
/* webpackChunkName: "settings" */
'./views/Settings.vue'
);
defineAsyncComponent 包裹,只有进入仪表盘才加载。
import { defineAsyncComponent } from 'vue';
const ChartPanel = defineAsyncComponent({
loader: () => import('../components/ChartPanel.vue'),
loadingComponent: LoadingSkeleton,
delay: 200,
timeout: 5000
});
// ChartPanel.vue 中按需引入 echarts
import * as echarts from 'echarts/core';
import { BarChart } from 'echarts/charts';
import { GridComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([BarChart, GridComponent, CanvasRenderer]);
import _ from 'lodash' 改成 import debounce from 'lodash/debounce'。
验收清单
- 入口 chunk 体积下降 50% 以上
- Network 面板中能看到按路由拆分的独立 chunk
- 进入 /dashboard 时才会加载 echarts 相关 chunk
- 首次加载不再下载 lodash 全量代码
实战二:资源预加载与预连接
代码拆开之后,用户点击路由时才开始下载 chunk 会有一瞬间的等待。用 preload / prefetch / preconnect 把加载时机提前,让"点击即渲染"成为可能。
任务 2:给关键资源加"加速器"
index.html 中给字体和首屏关键脚本加 preload。
<link rel="preload" as="font" type="font/woff2"
href="/fonts/noto-sans-sc.woff2" crossorigin />
<!-- 预连接后端 API 与 CDN -->
<link rel="preconnect" href="https://api.yoursite.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.yoursite.com" />
webpackPrefetch,让浏览器空闲时提前下载后续路由 chunk。
const Dashboard = () => import(
/* webpackPrefetch: true */
'./views/Dashboard.vue'
);
/* 预加载:与父 chunk 并行下载 */
const Settings = () => import(
/* webpackPreload: true */
'./views/Settings.vue'
);
<router-link
to="/dashboard"
@mouseenter="preload('dashboard')"
>仪表盘</router-link>
const preloadMap = {
dashboard: () => import('./views/Dashboard.vue'),
settings: () => import('./views/Settings.vue')
};
function preload(name) {
preloadMap[name]?.();
}
验收标准
在 DevTools Network 面板中,勾选 "Disable cache",刷新页面后观察:首屏资源是否标记为 Highest 优先级;切换到 /dashboard 时,该 chunk 是否已经在悬停时开始下载。
实战三:Tree Shaking 与 Chunk 策略
拆包之后还要继续"瘦身"。Tree Shaking 删除未引用代码,合理分包让 vendor 长期缓存,两者结合能把首屏体积再压一个量级。
任务 3:开启 Tree Shaking + 分包策略
package.json 中声明 sideEffects,告诉打包器哪些文件有副作用。
"name": "spa-perf-lab",
"sideEffects": [
"*.css",
"./src/polyfill.js",
"./src/global-register.js"
]
}
vite.config.js 中配置 manualChunks,把 vue、echarts、lodash 拆成独立 chunk。
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router'],
charts: ['echarts/core', 'echarts/charts'],
utils: ['lodash/debounce', 'axios']
}
}
}
}
};
npm install -D terser
// vite.config.js
build: {
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
},
sourcemap: false // 生产环境关闭,或独立上传监控平台
}
验收清单
- 构建产物中出现独立的 vue / charts / utils chunk
- 修改业务代码后,vendor chunk 的 hash 不变
- 浏览器控制台不再出现未使用的 lodash 代码警告
- 对比构建前后的总体积与首屏加载体积
实战四:Brotli 压缩与缓存头配置
代码再小,传输慢也白搭。本实战带你配置 Brotli + Gzip 双压缩、静态资源长缓存与 HTML 协商缓存。
任务 4:配置生产级 Nginx
server {
listen 443 ssl http2;
server_name yoursite.com;
# Brotli 压缩
brotli on;
brotli_comp_level 6;
brotli_types text/css application/javascript application/json image/svg+xml;
# Gzip 兜底
gzip on;
gzip_comp_level 5;
gzip_types text/css application/javascript application/json;
# 带 hash 的静态资源:一年长缓存
location ~* \.(js|css|woff2|avif|webp)$ {
root /var/www/spa/dist;
expires 1y;
add_header Cache-Control "public, immutable";
}
# HTML:协商缓存
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
curl 验证压缩与缓存是否生效。
curl -I -H "Accept-Encoding: br" https://yoursite.com/assets/app.js
# 期望:Content-Encoding: br
# 检查缓存头
curl -I https://yoursite.com/assets/vue.a1b2c3.js
# 期望:Cache-Control: public, immutable
# 对比压缩前后大小
curl -o /dev/null -s -w "%{size_download}\n" https://yoursite.com/assets/app.js
验收标准
同一 JS 文件,开启 Brotli 后相比原始体积减少 70% 以上;带 hash 的资源返回 immutable 缓存头,刷新页面时不重新下载。
实战五:SSR 改造与首屏直出
纯 CSR 的白屏问题,最彻底的解法是服务端渲染。本实战把前面的 SPA 改造成 SSR 应用,让首屏 HTML 直出。
任务 5:从 CSR 到 SSR
npm install -D @vitejs/plugin-vue vite-plugin-ssr
// server.js
import express from 'express';
import { createServer } from 'vite';
import { render } from './src/entry-server.js';
const app = express();
const vite = await createServer({ server: { middlewareMode: true } });
app.use(vite.middlewares);
app.get('*', async (req, res) => {
try {
const html = await render(req.url);
const template = await vite.transformIndexHtml(req.url, html);
res.status(200).set({ 'Content-Type': 'text/html' }).end(template);
} catch (e) {
res.status(500).end(e.stack);
}
});
app.listen(3000);
import { createApp } from './main';
const { app, router } = createApp();
router.isReady().then(() => {
app.mount('#app'); // 水合,而非重新渲染
});
renderToString 在服务端生成完整 HTML。
import { createApp } from './main';
import { renderToString } from 'vue/server-renderer';
export async function render(url) {
const { app, router } = createApp();
router.push(url);
await router.isReady();
const html = await renderToString(app);
return html;
}
验收清单
- 查看网页源代码,能看到完整的首屏 HTML 内容
- 禁用 JavaScript 后,首屏内容仍然可见(SSR 生效)
- Lighthouse 的 FCP / LCP 相比纯 CSR 明显改善
- 水合过程无 Vue 警告(客户端与服务端 DOM 结构一致)
实战六:图片与媒体资源优化
SPA 首屏的大图往往是 LCP 的"罪魁祸首"。本实战用 AVIF/WebP、响应式尺寸与懒加载,把图片对性能的影响降到最低。
任务 6:优化首屏大图与列表图片
picture + fetchpriority="high",避免懒加载。
<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<img
src="hero.jpg"
width="1200" height="600"
fetchpriority="high"
alt="首屏主视觉"
/>
</picture>
<img
data-src="product-1.webp"
loading="lazy"
decoding="async"
width="400" height="300"
alt="商品图"
/>
// 组件内统一初始化懒加载
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-src]')
.forEach(img => observer.observe(img));
验收标准
用 Lighthouse 检查 CLS 是否低于 0.1;Network 面板中图片是否按需加载;首屏 LCP 图片是否被标记为 High 优先级。
实战七:接口并行、SWR 与多级缓存
JS 加载完了,数据还在路上,用户依然在转圈。本实战用接口并行、SWR 缓存与 Service Worker,让二次访问接近"零加载"。
任务 7:数据层优化实战
const user = await getUser();
const orders = await getOrders();
const coupons = await getCoupons();
/* ✅ 改造后:并行,总耗时 ≈ 1s */
const [user, orders, coupons] = await Promise.all([
getUser(),
getOrders(),
getCoupons()
]);
import { ref, watchEffect } from 'vue';
const cache = new Map();
export function useSWR(key, fetcher) {
const data = ref(cache.get(key) || null);
const loading = ref(!data.value);
watchEffect(async () => {
try {
const fresh = await fetcher(key);
data.value = fresh;
cache.set(key, fresh);
} catch (e) {
console.error('SWR fetch error', e);
} finally {
loading.value = false;
}
});
return { data, loading };
}
const CACHE = 'spa-v1';
const ASSETS = ['/', '/index.html', '/assets/app.js'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then(c => c.addAll(ASSETS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(res => {
const copy = res.clone();
caches.open(CACHE).then(c => c.put(event.request, copy));
return res;
});
})
);
});
验收清单
- 接口请求从串行变为并行,Network 面板可见并发
- 二次进入页面时,数据立即显示(SWR 缓存命中)
- 断网后刷新,页面仍能展示基本内容(Service Worker)
实战八:运行时性能与主线程优化
加载优化解决"进得来",运行时优化解决"用得顺"。本实战处理长列表、重计算与频繁事件,让页面持续流畅。
任务 8:解决运行时卡顿
const itemHeight = 50;
const visibleCount = 20;
const scrollTop = ref(0);
const startIndex = computed(() =>
Math.floor(scrollTop.value / itemHeight)
);
const visibleItems = computed(() =>
list.slice(startIndex.value, startIndex.value + visibleCount)
);
const offsetY = computed(() =>
startIndex.value * itemHeight
);
self.onmessage = (e) => {
const result = heavyCompute(e.data);
self.postMessage(result);
};
// 组件内使用
const worker = new Worker(
new URL('../workers/calc.worker.js', import.meta.url),
{ type: 'module' }
);
worker.postMessage(bigData);
worker.onmessage = (e) => render(e.data);
window.addEventListener('scroll', throttle(onScroll, 100));
/* 空闲时执行埋点上报 */
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && queue.length) {
queue.shift()();
}
});
验收标准
用 Performance 面板录制交互过程,确认没有超过 50ms 的长任务;滚动列表时 FPS 稳定在 55 以上。
实战九:性能监控与持续度量
优化不是一次性动作。本实战搭建一套轻量级 RUM 监控,把 LCP、CLS、INP 等指标持续上报,防止性能劣化回归。
任务 9:搭建性能监控 SDK
const metrics = {};
/* LCP */
new PerformanceObserver((list) => {
const entries = list.getEntries();
metrics.LCP = entries[entries.length - 1].startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
/* CLS */
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) cls += entry.value;
}
metrics.CLS = cls;
}).observe({ type: 'layout-shift', buffered: true });
/* 首字节与资源加载 */
const nav = performance.getEntriesByType('navigation')[0];
metrics.TTFB = nav.responseStart;
router.beforeEach((to, from, next) => {
performance.mark('route-start');
next();
});
router.afterEach(() => {
performance.mark('route-end');
performance.measure(
'route-change',
'route-start',
'route-end'
);
});
if (navigator.sendBeacon) {
navigator.sendBeacon(
'/api/perf',
JSON.stringify(data)
);
} else {
fetch('/api/perf', {
method: 'POST',
body: JSON.stringify(data),
keepalive: true
});
}
}
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
report(metrics);
}
});
验收清单
- 控制台能看到 LCP / CLS / TTFB 等指标输出
- 路由切换耗时被正确测量并上报
- 上报请求在页面关闭时仍能发出(sendBeacon)
- 指标数据能在后端接收并展示
综合实战:从 2.4MB 到 680KB 的完整优化
把前面所有实战串起来,形成一套完整的优化流水线。这是你真正的"毕业项目"。
任务 10:端到端优化演练
1. 路由级代码分割 // 2.4MB → 1.2MB
2. 重组件懒加载 // 1.2MB → 980KB
3. Tree Shaking // 980KB → 820KB
4. 第三方库按需引入 // 820KB → 680KB
5. Brotli 压缩 // 680KB → 206KB(传输)
6. CDN + 长缓存 // 二次访问 ≈ 0 下载
7. SSR 首屏直出 // FCP 3.8s → 0.9s
8. 数据并行 + SWR // TTI 7.2s → 1.8s
9. 运行时优化 // 长任务清零
10. 性能监控 // 防止劣化回归
优化前:
Bundle : 2.4 MB
FCP : 3.8 s
LCP : 4.2 s
TTI : 7.2 s
Score : 32
优化后:
Bundle : 680 KB // -71%
FCP : 0.9 s // -76%
LCP : 1.2 s // -71%
TTI : 1.8 s // -75%
Score : 96 // +64
毕业验收清单
- Lighthouse Performance 分数达到 90+
- LCP 低于 2.5s,CLS 低于 0.1,TBT 低于 200ms
- 首屏加载体积相比优化前下降 60% 以上
- 二次访问时静态资源全部命中缓存
- 性能监控数据正常上报,能形成持续观测
- 所有优化点都能在代码中找到对应实现
实战避坑指南
优化过程中有不少"看起来对、实际有坑"的做法。这里总结最常见的 6 个陷阱,帮你少走弯路。
// 把十几个资源都 preload,反而抢占首屏带宽
<link rel="preload" as="script" href="a.js" />
<link rel="preload" as="script" href="b.js" />
<link rel="preload" as="script" href="c.js" />
/* ✅ 正确做法:只 preload 首屏关键资源 */
<link rel="preload" as="script" href="app.js" />
/* ❌ 陷阱 2:所有路由都懒加载 */
// 首页也懒加载 → 多一次往返请求
const Home = () => import('./views/Home.vue');
/* ✅ 正确做法:首页直接引入,其余懒加载 */
import Home from './views/Home.vue';
const Dashboard = () => import('./views/Dashboard.vue');
/* ❌ 陷阱 3:忽略懒加载失败兜底 */
const Bad = () => import('./views/Bad.vue');
/* ✅ 正确做法:提供错误组件与重试 */
const Safe = defineAsyncComponent({
loader: () => import('./views/Safe.vue'),
errorComponent: ErrorTip,
timeout: 5000
});
优化的前提是"不引入新的问题"
持续学习与进阶路线
完成本课程后,你已经掌握了 SPA 加载优化的核心技能。下面是一条继续深入的学习路线,帮你从"会优化"进阶到"能架构"。
学习 Webpack / Vite 底层原理,掌握自定义插件与 Loader 开发,理解 bundle 分析工具(webpack-bundle-analyzer、rollup-plugin-visualizer)。
深入 SSR / SSG / ISR / 流式渲染 / 岛屿架构(Islands Architecture),对比 Next.js、Nuxt、Astro 等框架的取舍。
搭建完整的 RUM + APM 体系,结合 Sentry、Grafana、Prometheus 实现性能指标的可视化与告警。
关注 Edge Runtime、Server Components、Partial Hydration、Speculation Rules API 等新技术,持续拓展性能优化的边界。