SPA首屏加载速度优化
文章类型:实战
发布者:hp
发布时间:2026-07-10
单页应用(SPA)因其良好的用户体验而广受欢迎,但首屏加载慢是其最大痛点。本文将系统性地介绍20+种优化方法,帮助你打造秒开的SPA应用。
SPA加载流程:
1. 请求HTML → 200ms
2. 下载HTML → 50ms
3. 解析HTML → 10ms
4. 请求CSS/JS → 100ms
5. 下载CSS/JS → 800ms 瓶颈
6. 解析执行JS → 500ms 瓶颈
7. 渲染首屏 → 200ms
8. 请求API数据 → 300ms 瓶颈
9. 渲染完整内容 → 100ms
总计:2.26秒(用户才能看到完整页面)
| 优化方向 | 具体方法 | 优化效果 | 实施难度 |
|---|---|---|---|
| 减少体积 | 代码分割(Code Splitting) | 中 | |
| Tree Shaking | 低 | ||
| 压缩混淆 | 低 | ||
| 移除无用代码 | 中 | ||
| 并行加载 | 资源预加载(Preload) | 低 | |
| DNS预解析 | 低 | ||
| HTTP/2多路复用 | 低 | ||
| 缓存优化 | 强缓存 | 低 | |
| Service Worker | 中 | ||
| CDN加速 | 低 | ||
| 渲染优化 | 骨架屏 | 中 | |
| SSR服务端渲染 | 高 | ||
| 预渲染 | 中 |
app.js: 2.5MB
├─ React: 200KB
├─ Vue Router: 50KB
├─ Vuex: 30KB
├─ Axios: 20KB
├─ Moment.js: 300KB
├─ Lodash: 70KB
├─ 业务代码: 1.83MB
首屏必须下载全部2.5MB!
main.js: 300KB(首屏必需)
├─ React: 200KB
├─ Router: 50KB
├─ 首页代码: 50KB
其他页面按需加载:
├─ about.chunk.js: 80KB
├─ product.chunk.js: 150KB
├─ user.chunk.js: 120KB
首屏只需300KB!// React + React Router
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// 不好:全部导入
// import Home from './pages/Home';
// import About from './pages/About';
// import Product from './pages/Product';
// 好:懒加载
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Product = lazy(() => import('./pages/Product'));
const User = lazy(() => import('./pages/User'));
function App() {
return (
Loading...}> } /> } /> } /> } /> ); }// Vue Router
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/',
name: 'Home',
component: () => import('./views/Home.vue') // 懒加载
},
{
path: '/about',
name: 'About',
component: () => import('./views/About.vue'),
// 路由级代码分割,命名chunk
// 生成:about.[hash].js
},
{
path: '/product/:id',
name: 'Product',
component: () => import(
/* webpackChunkName: "product" */
'./views/Product.vue'
)
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
export default router;// React
import { lazy, Suspense } from 'react';
// 大组件懒加载
const HeavyChart = lazy(() => import('./components/HeavyChart'));
const VideoPlayer = lazy(() => import('./components/VideoPlayer'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
Dashboard
setShowChart(true)}>
显示图表
{showChart && (
加载图表中...}>
)}
); } // Vue 3// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// 第三方库单独打包
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
reuseExistingChunk: true
},
// React相关库
react: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
name: 'react-vendor',
priority: 20
},
// UI组件库
ui: {
test: /[\\/]node_modules[\\/](antd|@ant-design)[\\/]/,
name: 'ui-vendor',
priority: 15
},
// 公共代码
common: {
minChunks: 2,
priority: 5,
reuseExistingChunk: true
}
}
},
// 运行时代码单独提取
runtimeChunk: {
name: 'runtime'
}
}
};// 按需加载功能模块
async function handleExport() {
// 只在需要时才加载Excel导出库
const XLSX = await import('xlsx');
const workbook = XLSX.utils.book_new();
// ... 导出逻辑
}
// 按需加载国际化
async function changeLanguage(lang) {
const messages = await import(`./i18n/${lang}.json`);
i18n.setMessages(lang, messages.default);
}
// 按需加载图表库
async function renderChart() {
const echarts = await import('echarts');
const chart = echarts.init(document.getElementById('chart'));
// ... 图表配置
}// 不好:导入整个库
import _ from 'lodash';
import moment from 'moment';
import * as antd from 'antd';
_.debounce(fn, 300);
moment().format('YYYY-MM-DD');
// 好:按需导入
import debounce from 'lodash/debounce';
import dayjs from 'dayjs'; // 用dayjs替代moment,体积小90%
import { Button, Modal } from 'antd';
debounce(fn, 300);
dayjs().format('YYYY-MM-DD');
// package.json添加sideEffects
{
"sideEffects": false,
// 或指定有副作用的文件
"sideEffects": ["*.css", "*.scss"]
}| 原库 | 体积 | 替代方案 | 体积 | 减少 |
|---|---|---|---|---|
| Moment.js | 288KB | Day.js | 7KB | -97% |
| Lodash | 70KB | lodash-es(按需) | 5-10KB | -85% |
| Axios | 13KB | Fetch API | 0KB | -100% |
| React | 140KB | Preact | 4KB | -97% |
// 安装Day.js替代Moment.js
npm uninstall moment
npm install dayjs
// 使用
import dayjs from 'dayjs';
dayjs().format('YYYY-MM-DD');
dayjs().add(1, 'day');
dayjs().diff(dayjs('2020-01-01'), 'day');// Ant Design按需导入
// 安装插件
npm install babel-plugin-import -D
// .babelrc
{
"plugins": [
["import", {
"libraryName": "antd",
"libraryDirectory": "es",
"style": "css"
}]
]
}
// 使用时自动按需导入
import { Button, Modal, Table } from 'antd';
// Element Plus(Vue)
// vite.config.js
import { defineConfig } from 'vite';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({
plugins: [
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
});// webpack配置
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true, // 移除console
drop_debugger: true, // 移除debugger
pure_funcs: ['console.log'] // 移除特定函数
}
}
})
]
}
};// 使用WebP格式
<picture>
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="图片">
</picture>
// 压缩图片
npm install image-webpack-loader -D
// webpack配置
{
test: /\.(png|jpe?g|gif|svg)$/,
use: [
'file-loader',
{
loader: 'image-webpack-loader',
options: {
mozjpeg: { progressive: true, quality: 65 },
optipng: { enabled: false },
pngquant: { quality: [0.65, 0.90], speed: 4 },
gifsicle: { interlaced: false },
webp: { quality: 75 }
}
}
]
}
// 使用CDN托管图片
const imageUrl = 'https://cdn.example.com/images/banner.jpg';// Nginx配置
server {
# Gzip压缩
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss;
# Brotli压缩(更高压缩率)
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css text/xml text/javascript
application/json application/javascript;
}
// Webpack插件
const CompressionPlugin = require('compression-webpack-plugin');
plugins: [
new CompressionPlugin({
algorithm: 'gzip',
test: /\.(js|css|html|svg)$/,
threshold: 10240, // 只压缩>10KB的文件
minRatio: 0.8
})
]<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- DNS预解析 -->
<link rel="dns-prefetch" href="https://api.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">
<!-- 预连接(DNS + TCP + TLS) -->
<link rel="preconnect" href="https://api.example.com">
<!-- 预加载关键资源 -->
<link rel="preload" href="/static/js/main.js" as="script">
<link rel="preload" href="/static/css/app.css" as="style">
<link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" crossorigin>
<!-- 预获取下一页资源 -->
<link rel="prefetch" href="/static/js/about.chunk.js">
<!-- 关键CSS内联 -->
<style>
/* 首屏关键CSS */
body { margin: 0; font-family: sans-serif; }
.header { height: 60px; background: #333; }
</style>
<!-- 非关键CSS异步加载 -->
<link rel="preload" href="/app.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/app.css"></noscript>
</head>
<body>
<div id="app"></div>
<script src="/static/js/main.js"></script>
</body>
</html>// React Router预加载
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
// 路由预加载映射
const routePreloadMap = {
'/': () => import('./pages/Home'),
'/about': () => import('./pages/About'),
'/product': () => import('./pages/Product')
};
function NavLink({ to, children }) {
const preloadRoute = () => {
if (routePreloadMap[to]) {
// 鼠标悬停时预加载
routePreloadMap[to]();
}
};
return (
{children}
);
}
// 使用
<NavLink to="/about">关于我们</NavLink>// 图片懒加载
function LazyImage({ src, alt }) {
const imgRef = useRef(null);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setIsLoaded(true);
observer.disconnect();
}
});
}, {
rootMargin: '50px' // 提前50px加载
});
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
);
}
// 组件懒加载
function LazyComponent({ importFunc, children }) {
const [Component, setComponent] = useState(null);
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
importFunc().then(mod => {
setComponent(() => mod.default);
observer.disconnect();
});
}
});
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return {Component ? : children};
}// React骨架屏组件
function SkeletonCard() {
return (
);
}
// CSS
.skeleton-card {
padding: 20px;
border-radius: 8px;
}
.skeleton-image,
.skeleton-title,
.skeleton-desc {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
.skeleton-image {
width: 100%;
height: 200px;
border-radius: 8px;
margin-bottom: 15px;
}
.skeleton-title {
height: 24px;
width: 60%;
border-radius: 4px;
margin-bottom: 10px;
}
.skeleton-desc {
height: 16px;
width: 100%;
border-radius: 4px;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// 使用
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchProducts().then(data => {
setProducts(data);
setLoading(false);
});
}, []);
if (loading) {
return (
);
}
return products.map(p => );
}// 使用page-skeleton-webpack-plugin
npm install page-skeleton-webpack-plugin -D
// webpack.config.js
const SkeletonPlugin = require('page-skeleton-webpack-plugin');
plugins: [
new SkeletonPlugin({
pathname: path.resolve(__dirname, './shell'),
staticDir: path.resolve(__dirname, './dist'),
routes: ['/', '/about', '/product']
})
]
// 自动生成首屏骨架屏<!-- 在index.html中添加Loading -->
<body>
<div id="app">
<div class="loading-wrapper">
<div class="spinner"></div>
<p>加载中...</p>
</div>
</div>
</body>
<style>
.loading-wrapper {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: white;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>1. 返回空HTML
2. 下载JS
3. 执行JS
4. 请求数据
5. 渲染页面
首屏时间:2-3秒
SEO: 不友好
1. 服务器渲染HTML
2. 返回完整HTML
3. 直接显示页面
4. 下载JS(hydration)
5. 页面可交互
首屏时间:0.5-1秒
SEO: 友好// 安装
npx create-next-app@latest my-app
// pages/index.js
export default function Home({ posts }) {
return (
博客列表
{posts.map(post => (
{post.title}
{post.excerpt}
))}
);
}
// 服务端获取数据
export async function getServerSideProps() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return {
props: { posts }
};
}
// 或使用静态生成(更快)
export async function getStaticProps() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return {
props: { posts },
revalidate: 60 // 60秒后重新生成
};
}// 安装
npx nuxi init my-app
// pages/index.vue
<template>
商品列表
{{ product.name }}
{{ product.price }}
</template>
<script setup>
// 服务端数据获取
const { data: products } = await useFetch('https://api.example.com/products');
</script>// prerender-spa-plugin
npm install prerender-spa-plugin -D
// webpack.config.js
const PrerenderSPAPlugin = require('prerender-spa-plugin');
const path = require('path');
plugins: [
new PrerenderSPAPlugin({
staticDir: path.join(__dirname, 'dist'),
routes: ['/', '/about', '/contact'],
renderer: new PrerenderSPAPlugin.PuppeteerRenderer({
renderAfterTime: 5000 // 等待5秒后渲染
})
})
]
// 优势:构建时生成静态HTML,无需服务器运行时渲染// Nginx配置
location ~* \.(js|css)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location ~* \.(jpg|png|gif|svg|webp)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
location = /index.html {
add_header Cache-Control "no-cache";
etag on;
}// sw.js
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
'/',
'/static/css/main.css',
'/static/js/main.js',
'/static/images/logo.png'
];
// 安装时缓存
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
);
});
// 拦截请求
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
});// 使用CDN托管静态资源
// webpack.config.js
output: {
publicPath: 'https://cdn.example.com/assets/'
}
// 多CDN容灾
const CDN_HOSTS = [
'https://cdn1.example.com',
'https://cdn2.example.com',
'https://cdn3.example.com'
];
function getOptimalCDN() {
// 根据用户地理位置或健康检查选择最优CDN
return CDN_HOSTS[0];
}// 监控首屏时间
window.addEventListener('load', () => {
const perfData = performance.timing;
const pageLoadTime = perfData.loadEventEnd - perfData.navigationStart;
const domReadyTime = perfData.domContentLoadedEventEnd - perfData.navigationStart;
const firstPaintTime = performance.getEntriesByType('paint')
.find(entry => entry.name === 'first-contentful-paint')?.startTime;
console.log('页面加载时间:', pageLoadTime + 'ms');
console.log('DOM就绪时间:', domReadyTime + 'ms');
console.log('首次内容绘制:', firstPaintTime + 'ms');
// 上报到监控系统
reportMetrics({
pageLoadTime,
domReadyTime,
firstPaintTime
});
});// Chrome DevTools
// 1. 打开开发者工具
// 2. 切换到Lighthouse标签
// 3. 点击"Generate report"
// CLI使用
npm install -g lighthouse
lighthouse https://example.com --view
// 性能指标
// - First Contentful Paint (FCP): <1.8s
// - Largest Contentful Paint (LCP): <2.5s
// - Time to Interactive (TTI): <3.8s
// - Total Blocking Time (TBT): <200ms
// - Cumulative Layout Shift (CLS): <0.1// 安装
npm install webpack-bundle-analyzer -D
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: true
})
]
// 运行后查看可视化报告,找出体积大的模块// 第1周:基础优化
1. 配置路由懒加载
2. Webpack代码分割
3. 启用Gzip压缩
4. 配置CDN
// 第2周:体积优化
5. 分析Bundle体积
6. 替换大体积库
7. Tree Shaking
8. 图片压缩
// 第3周:加载优化
9. 添加preload/prefetch
10. 配置强缓存
11. 实现骨架屏
12. 图片懒加载
// 第4周:进阶优化
13. Service Worker
14. 考虑SSR方案
15. 性能监控
16. 持续优化
暂无评论,快来发表第一条评论吧~