单页应用加载优化

张玥 2026年9月17日 阅读时间 90分钟
实战课程 项目演练 单页应用 加载优化 性能调优
JavaScript编程之单页应用加载优化

这是一门实战导向的单页应用加载优化课程。我们将从一个真实可跑的 SPA 项目出发,手把手带你完成 12 个渐进式实战任务:从建立性能基线、代码分割、资源预加载,到 Tree Shaking、Brotli 压缩、SSR 改造、缓存策略、运行时调优,最后形成一套可量化的性能监控体系。每个任务都配有可运行的代码、验证步骤和验收标准,90 分钟跟着做,你就能把"理论"变成"肌肉记忆"。

项目准备:搭建你的实验场

开始优化之前,先创建一个可复现的 SPA 项目。我们使用 Vite + Vue 3 构建一个包含首页、仪表盘、设置页三个路由的最小应用,打包后足以模拟真实场景。

任务 0:初始化项目与性能基线

Step 1: 创建项目并安装依赖
# 创建项目
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 文件大小
Step 2: 创建三个路由页面,并在首页故意引入 echarts 和 lodash 全量
// src/router/index.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:拆分路由与重组件

Step 1: 路由懒加载 —— 把 import Xxx from './views/Xxx.vue' 改为 () => import('./views/Xxx.vue')。重新构建,观察 chunk 数量变化。
/* 改造前:所有页面打进入口 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'
);
Step 2: 重组件懒加载 —— 在 Dashboard 中把 echarts 用 defineAsyncComponent 包裹,只有进入仪表盘才加载。
// src/views/Dashboard.vue
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]);
Step 3: lodash 按需引入 —— 把 import _ from 'lodash' 改成 import debounce from 'lodash/debounce'。

验收清单

  • 入口 chunk 体积下降 50% 以上
  • Network 面板中能看到按路由拆分的独立 chunk
  • 进入 /dashboard 时才会加载 echarts 相关 chunk
  • 首次加载不再下载 lodash 全量代码

实战二:资源预加载与预连接

代码拆开之后,用户点击路由时才开始下载 chunk 会有一瞬间的等待。用 preload / prefetch / preconnect 把加载时机提前,让"点击即渲染"成为可能。

任务 2:给关键资源加"加速器"

Step 1: 在 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" />
Step 2: 在路由配置中使用 webpackPrefetch,让浏览器空闲时提前下载后续路由 chunk。
/* 预获取:浏览器空闲时下载 */
const Dashboard = () => import(
  /* webpackPrefetch: true */
  './views/Dashboard.vue'
);

/* 预加载:与父 chunk 并行下载 */
const Settings = () => import(
  /* webpackPreload: true */
  './views/Settings.vue'
);
Step 3: 给导航链接加鼠标悬停预加载,进一步缩短点击后的等待时间。
<!-- 路由链接悬停时预加载对应 chunk -->
<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 + 分包策略

Step 1: 在 package.json 中声明 sideEffects,告诉打包器哪些文件有副作用。
{
  "name": "spa-perf-lab",
  "sideEffects": [
    "*.css",
    "./src/polyfill.js",
    "./src/global-register.js"
  ]
}
Step 2: 在 vite.config.js 中配置 manualChunks,把 vue、echarts、lodash 拆成独立 chunk。
// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vue: ['vue', 'vue-router'],
          charts: ['echarts/core', 'echarts/charts'],
          utils: ['lodash/debounce', 'axios']
        }
      }
    }
  }
};
Step 3: 开启生产环境压缩与 sourcemap 策略。
/* Vite 默认使用 esbuild 压缩,可切换 terser 获得更小体积 */
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

Step 1: 编写完整的 Nginx 配置,包含压缩、缓存、SPA fallback。
# /etc/nginx/conf.d/spa.conf
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;
  }
}
Step 2: 使用 curl 验证压缩与缓存是否生效。
# 检查是否返回 br 压缩
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

Step 1: 安装 SSR 依赖,创建服务端入口。
npm install express
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);
Step 2: 客户端水合(Hydration)—— 服务端 HTML 只是"静态快照",需要客户端 JS 接管事件。
// src/entry-client.js
import { createApp } from './main';

const { app, router } = createApp();

router.isReady().then(() => {
  app.mount('#app'); // 水合,而非重新渲染
});
Step 3: 使用 renderToString 在服务端生成完整 HTML。
// src/entry-server.js
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:优化首屏大图与列表图片

Step 1: 首屏 LCP 图片使用 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>
Step 2: 列表图片使用懒加载 + 异步解码,配合 IntersectionObserver。
<!-- 列表图片:懒加载 -->
<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:数据层优化实战

Step 1: 把串行请求改成并行,减少总等待时间。
/* ❌ 改造前:串行,总耗时 = 1 + 1 + 1 = 3s */
const user = await getUser();
const orders = await getOrders();
const coupons = await getCoupons();

/* ✅ 改造后:并行,总耗时 ≈ 1s */
const [user, orders, coupons] = await Promise.all([
  getUser(),
  getOrders(),
  getCoupons()
]);
Step 2: 实现一个简易的 SWR(stale-while-revalidate)缓存 Hook。
// src/hooks/useSWR.js
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 };
}
Step 3: 注册 Service Worker,实现静态资源离线缓存。
// public/sw.js
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:解决运行时卡顿

Step 1: 实现一个简易虚拟列表,只渲染可视区域。
// src/components/VirtualList.vue
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
);
Step 2: 用 Web Worker 处理大数据计算,避免主线程阻塞。
// src/workers/calc.worker.js
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);
Step 3: 事件节流与 requestIdleCallback 处理低优先级任务。
/* 滚动节流 */
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

Step 1: 用 PerformanceObserver 采集核心 Web Vitals。
// src/monitor/vitals.js
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;
Step 2: 自定义打点测量路由切换耗时。
// router/index.js
router.beforeEach((to, from, next) => {
  performance.mark('route-start');
  next();
});

router.afterEach(() => {
  performance.mark('route-end');
  performance.measure(
    'route-change',
    'route-start',
    'route-end'
  );
});
Step 3: 使用 sendBeacon 上报,避免影响页面卸载。
function report(data) {
  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:端到端优化演练

Step 1: 按照顺序应用所有优化手段,每一步都记录体积与性能指标变化。
/* 优化路线图 */

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. 性能监控            // 防止劣化回归
Step 2: 用 Lighthouse 做最终验证,对比优化前后的报告。
/* 优化前后对比 */

优化前:
  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 个陷阱,帮你少走弯路。

/* ❌ 陷阱 1:preload 滥用 */
// 把十几个资源都 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
});
❌ preload 滥用
抢占带宽,首屏更慢
❌ 首页也懒加载
多一次网络往返
❌ 无失败兜底
网络抖动导致白屏
✅ 按需 preload
只加速关键资源

优化的前提是"不引入新的问题"

持续学习与进阶路线

完成本课程后,你已经掌握了 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 等新技术,持续拓展性能优化的边界。