前端性能实战:从代码分割、资源懒加载到 Core Web Vitals 优化的完整落地方案
前端性能优化,最怕两种情况:
- 只做零散技巧:比如“上 gzip”“开缓存”“图片转 webp”,做了不少,但用户体感没明显变化。
- 只盯 Lighthouse 分数:分数看起来不错,线上真实用户却还在抱怨首屏慢、切页面卡、点按钮没反应。
这篇文章我想换一个更“落地”的角度:把性能优化当成一条完整链路来做。从代码分割、资源懒加载、关键资源优先级控制,到最终对齐 Core Web Vitals(LCP、INP、CLS)指标,带你走一遍真正能上线的方案。
文章面向有一定项目经验的前端同学,示例会用 React + Vite,但思路同样适用于 Vue、Next.js、Webpack 等技术栈。
背景与问题
很多中型前端项目都会经历同一个阶段:
- 首屏 JS 包越来越大
- 页面功能越来越多,路由越来越重
- 图片、图标、第三方 SDK 堆上来
- 页面首屏能渲染,但很快又被 JS 执行拖慢
- 用户感觉是:白屏、卡顿、跳动、点击没反应
如果把一次页面访问拆开看,性能问题通常出在这几层:
- 资源下载太多:初始包过大、非首屏资源也提前加载
- 主线程太忙:长任务过多,导致交互延迟高
- 渲染时机不对:关键内容出现太晚,LCP 高
- 布局不稳定:图片、广告、异步内容把页面顶来顶去,CLS 高
- 缓存与更新策略混乱:用户反复下载同样资源
性能优化不是“更快加载”,而是“优先加载对的东西”
我自己踩过一个很典型的坑:
项目首页为了“省请求”,把图表库、富文本编辑器、埋点 SDK、地图 SDK 都打进主包。结果请求数看起来不多,但首屏要下载和执行的 JS 巨大,LCP 和 INP 都很差。
后来把思路改成:
- 首屏只保留必要代码
- 重功能模块按路由拆分
- 可见区域内资源优先,其他延后
- 通过真实用户监控验证优化是否生效
性能才真正开始稳定下来。
前置知识与环境准备
本文示例环境:
- Node.js 18+
- Vite 5+
- React 18+
- 浏览器:Chrome 最新版
- 测试工具:
- Chrome DevTools
- Lighthouse
web-vitals
安装一个示例项目依赖:
npm create vite@latest perf-demo -- --template react
cd perf-demo
npm install
npm install web-vitals
如果你不是 React 用户,也没关系,关注这几个概念即可:
import():动态导入- 路由级懒加载
- IntersectionObserver:视口内加载
PerformanceObserver:性能监控- 资源优先级与缓存策略
核心原理
1. 代码分割:减少首次必须下载的 JS
核心目标不是“总 JS 变少”,而是:
把“当前必须执行”的 JS 压缩到最小。
常见拆分维度:
- 路由级拆分:访问
/about时才加载 about 代码 - 组件级拆分:弹窗、图表、编辑器按需加载
- 第三方库拆分:重型依赖延迟引入
- vendor 拆分:稳定依赖单独缓存
2. 资源懒加载:不在首屏的资源,晚点再来
适合懒加载的资源:
- 图片
- iframe
- 视频
- 非关键 CSS/JS
- 评论区、推荐区、图表等次要模块
原则很简单:
- 首屏关键内容:抢优先级
- 非首屏内容:延迟加载
- 用户即将看到的内容:预加载
3. Core Web Vitals:从“感觉快”到“指标可追踪”
当前优化最值得关注的三个指标:
- LCP(Largest Contentful Paint)
- 最大可见内容何时完成渲染
- 关注首屏大图、标题、Hero 区域
- INP(Interaction to Next Paint)
- 用户交互后,界面多久给出响应
- 关注主线程阻塞、长任务、事件处理
- CLS(Cumulative Layout Shift)
- 页面是否乱跳
- 关注图片尺寸、异步插入内容、字体切换
可以把它们理解成:
- LCP:看见得快不快
- INP:点了之后灵不灵
- CLS:界面稳不稳
优化链路总览
下面这张图可以先建立整体心智模型。
flowchart TD
A[用户访问页面] --> B[HTML 到达]
B --> C[关键 CSS/字体/首屏资源发现]
C --> D[首屏内容渲染]
D --> E[LCP]
B --> F[主包 JS 下载与执行]
F --> G[路由与组件激活]
G --> H[用户交互]
H --> I[事件处理与重绘]
I --> J[INP]
D --> K[异步内容插入/图片加载/字体替换]
K --> L[CLS]
这条链路里,任何一个阶段出问题,最终都会反映到 Core Web Vitals 上。
实战代码(可运行)
下面做一个小型实战:从一个“全部打包、图片直接加载、指标未采集”的页面,改成可上线的性能版本。
步骤 1:先做路由级代码分割
假设我们有两个页面:首页和报表页,报表页依赖重型图表库。
改造前
// src/App.jsx
import Home from './pages/Home'
import Reports from './pages/Reports'
function App() {
const path = window.location.pathname
return path === '/reports' ? <Reports /> : <Home />
}
export default App
这样会导致首页访问时,也把报表页代码打进首包。
改造后:使用动态导入
// src/App.jsx
import React, { Suspense, lazy } from 'react'
const Home = lazy(() => import('./pages/Home'))
const Reports = lazy(() => import('./pages/Reports'))
function App() {
const path = window.location.pathname
return (
<Suspense fallback={<div>页面加载中...</div>}>
{path === '/reports' ? <Reports /> : <Home />}
</Suspense>
)
}
export default App
页面文件
// src/pages/Home.jsx
export default function Home() {
return (
<main>
<h1>首页</h1>
<p>这是首屏核心内容。</p>
<a href="/reports">进入报表页</a>
</main>
)
}
// src/pages/Reports.jsx
import HeavyChart from '../components/HeavyChart'
export default function Reports() {
return (
<main>
<h1>报表页</h1>
<HeavyChart />
</main>
)
}
步骤 2:把重组件继续拆小
路由拆分后,报表页本身还可能很重。比如图表、筛选器、导出模块其实不一定一起需要。
// src/components/HeavyChart.jsx
import React, { Suspense, lazy } from 'react'
const ChartImpl = lazy(() => import('./charts/ChartImpl'))
export default function HeavyChart() {
return (
<section>
<h2>销售趋势图</h2>
<Suspense fallback={<div>图表加载中...</div>}>
<ChartImpl />
</Suspense>
</section>
)
}
// src/components/charts/ChartImpl.jsx
import { useEffect, useRef } from 'react'
export default function ChartImpl() {
const ref = useRef(null)
useEffect(() => {
const el = ref.current
if (!el) return
el.innerHTML = '<div style="padding:16px;border:1px solid #ccc;">这里渲染真实图表</div>'
}, [])
return <div ref={ref} />
}
这里的意义在于:
- 报表页先出来
- 图表晚一点挂载
- 用户更早看到“页面已可用”
步骤 3:图片懒加载 + 预留尺寸,顺手解决 CLS
这是最值得立刻做的一步,见效通常很快。
// src/components/LazyImage.jsx
import { useEffect, useRef, useState } from 'react'
export default function LazyImage({ src, alt, width, height }) {
const ref = useRef(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const node = ref.current
if (!node) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setVisible(true)
observer.disconnect()
}
})
},
{
rootMargin: '100px'
}
)
observer.observe(node)
return () => observer.disconnect()
}, [])
return (
<div
ref={ref}
style={{
width: `${width}px`,
height: `${height}px`,
background: '#f3f3f3',
overflow: 'hidden'
}}
>
{visible ? (
<img
src={src}
alt={alt}
width={width}
height={height}
loading="lazy"
style={{ display: 'block', width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : null}
</div>
)
}
使用方式:
// src/pages/Home.jsx
import LazyImage from '../components/LazyImage'
export default function Home() {
return (
<main>
<h1>首页</h1>
<p>这是首屏核心内容。</p>
<LazyImage
src="https://picsum.photos/800/400"
alt="示例图片"
width={800}
height={400}
/>
<div style={{ height: '1200px' }} />
<LazyImage
src="https://picsum.photos/800/401"
alt="列表图片"
width={800}
height={400}
/>
</main>
)
}
这段代码做了两件很关键的事:
- 用
IntersectionObserver控制真正加载时机 - 提前写明
width和height,避免布局跳动
步骤 4:为首屏大图做优先加载,优化 LCP
并不是所有图片都该懒加载。
首屏 Hero 图 如果你也懒加载,LCP 往往更差。
首页关键大图应该:
- 不懒加载
- 尽早发现
- 明确尺寸
- 必要时 preload
在 HTML 中预加载关键图片
<!-- index.html -->
<link
rel="preload"
as="image"
href="https://picsum.photos/1200/600"
/>
首屏组件中直接渲染
// src/components/Hero.jsx
export default function Hero() {
return (
<section>
<h1>性能优化实战</h1>
<img
src="https://picsum.photos/1200/600"
alt="首屏大图"
width="1200"
height="600"
fetchpriority="high"
style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
/>
</section>
)
}
这里有个很实用的经验:
- 首屏大图:高优先级,不 lazy
- 列表图片:lazy
- 首屏下方但即将进入视口的图片:可结合
rootMargin提前加载
步骤 5:采集 Core Web Vitals,别只靠本地跑分
本地 Lighthouse 很重要,但它不是线上真实用户。
更稳妥的做法是:同时采集真实用户指标。
// src/webVitals.js
import { onCLS, onINP, onLCP } from 'web-vitals'
function sendToAnalytics(metric) {
console.log('[Web Vitals]', metric)
// 实际项目里可改为:
// navigator.sendBeacon('/analytics', JSON.stringify(metric))
}
export function reportWebVitals() {
onCLS(sendToAnalytics)
onINP(sendToAnalytics)
onLCP(sendToAnalytics)
}
在入口文件里调用:
// src/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { reportWebVitals } from './webVitals'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
reportWebVitals()
这样你就能看到类似输出:
[Web Vitals] { name: 'LCP', value: 1850, rating: 'good' }
[Web Vitals] { name: 'INP', value: 120, rating: 'good' }
[Web Vitals] { name: 'CLS', value: 0.03, rating: 'good' }
从指标反推优化动作
很多同学做优化时会陷入“技巧堆砌”。更好的方法是:看到哪个指标差,就逆向找原因。
flowchart LR
A[LCP 高] --> A1[首屏图过大]
A --> A2[关键资源发现晚]
A --> A3[主包阻塞渲染]
B[INP 高] --> B1[长任务过多]
B --> B2[事件回调太重]
B --> B3[大组件同步渲染]
C[CLS 高] --> C1[图片无尺寸]
C --> C2[异步内容插入]
C --> C3[字体切换造成抖动]
这个映射关系非常实用,线上排查时尤其省时间。
逐步验证清单
建议每做一步优化,都按下面这张清单验证,而不是一次改一大堆。
1. 构建产物验证
先跑构建:
npm run build
检查点:
- 主包是否明显变小
- 是否出现独立 chunk
- 路由模块是否单独产出文件
2. Network 面板验证
关注:
- 首次加载请求总量
- 首屏关键资源是否过晚发起
- 是否有不该在首页加载的 chunk
3. Performance 面板验证
关注:
- 是否存在长任务(Long Task)
- JS 执行时间是否过长
- 首次交互时主线程是否拥堵
4. Lighthouse 验证
重点看:
- LCP
- CLS
- 减少未使用 JavaScript
- 避免巨大网络负载
5. 真实用户验证
上线灰度后观察:
- P75 LCP
- P75 INP
- P75 CLS
- 首屏转化率、跳出率是否变化
交互性能优化:改善 INP 的关键做法
代码分割主要影响“加载性能”,但用户还会抱怨“点了没反应”。这时要看 INP。
常见导致 INP 高的原因
- 点击后同步计算太重
- 一次性渲染大量 DOM
- 状态更新触发过多重复渲染
- 第三方脚本占用主线程
- 事件处理函数里做了不该立刻做的事情
一个典型反例
// 不推荐
function SearchPage({ data }) {
const handleInput = (e) => {
const keyword = e.target.value
const result = data
.filter((item) => item.name.includes(keyword))
.sort((a, b) => a.name.localeCompare(b.name))
console.log(result)
}
return <input onChange={handleInput} placeholder="搜索" />
}
问题在于:每次输入都做大量同步计算。
基础优化版
import { useMemo, useState } from 'react'
function SearchPage({ data }) {
const [keyword, setKeyword] = useState('')
const result = useMemo(() => {
return data
.filter((item) => item.name.includes(keyword))
.sort((a, b) => a.name.localeCompare(b.name))
}, [data, keyword])
return (
<div>
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索"
/>
<p>结果数:{result.length}</p>
</div>
)
}
export default SearchPage
如果数据量非常大,还要继续做:
- 列表虚拟化
- Web Worker
- 防抖/节流
- 分片计算
用 requestIdleCallback 延后非关键逻辑
function trackExtraInfo(data) {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => {
console.log('空闲时再处理:', data)
})
} else {
setTimeout(() => {
console.log('降级处理:', data)
}, 0)
}
}
比如埋点补充字段、非关键日志、预测性计算,都可以放到空闲阶段做。
加载与渲染时序示意
这张时序图可以帮助你理解“为什么拆包和懒加载会改善体感”。
sequenceDiagram
participant U as 用户
participant B as 浏览器
participant S as 静态资源服务器
participant M as 主线程
U->>B: 打开页面
B->>S: 请求 HTML
S-->>B: 返回 HTML
B->>S: 请求关键 CSS / 首屏图 / 首包 JS
S-->>B: 返回关键资源
B->>M: 解析 HTML/CSS/执行首包 JS
M-->>U: 首屏内容渲染完成(LCP)
U->>B: 滚动到下方
B->>M: IntersectionObserver 触发
B->>S: 请求懒加载图片/异步 chunk
S-->>B: 返回资源
M-->>U: 次屏内容展示
U->>M: 点击交互
M-->>U: 快速反馈(INP 改善)
常见坑与排查
这部分我建议你认真看,很多优化“看起来做了”,其实因为这些坑,效果会大打折扣。
1. 把所有资源都懒加载
这是最常见的误区。
错误做法:
- 首屏 Hero 图也
loading="lazy" - 首屏关键组件也动态导入
- 首屏 CSS 也延迟
结果就是:本该优先出现的内容反而变慢。
排查方法:
- DevTools 的 Network 看首屏关键资源是否晚发起
- 看 LCP 元素到底是谁
- 检查首屏大图有没有被 lazy
2. 代码拆了,但 vendor 还是太大
路由懒加载只是第一步。
如果图表库、编辑器、日期库都还混在公共 chunk,首页仍然受影响。
排查方法:
- 用打包分析工具查看 chunk 组成
- 关注“共享依赖”是否把首页拖重
Vite 可结合 Rollup 手动拆分:
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
react_vendor: ['react', 'react-dom'],
chart_vendor: ['echarts'],
},
},
},
},
})
3. 图片懒加载了,但 CLS 还是很高
这通常不是懒加载的问题,而是没预留尺寸。
排查方法:
- 看图片、广告位、卡片骨架是否固定宽高
- 检查字体切换是否造成重排
- 检查异步模块插入时是否撑开布局
4. Lighthouse 很高,真实用户却很差
常见原因:
- 本地机器快,线上用户设备慢
- 网络环境差异大
- 第三方脚本线上才加载
- 某些页面路径没被测试覆盖
排查建议:
- 建立 RUM(真实用户监控)
- 按页面类型、设备、网络分类统计
- 重点看 P75,而不是平均值
5. 懒加载阈值设置不合理
rootMargin 太小,用户滚动到了资源还没开始请求;
rootMargin 太大,又会提前加载太多。
经验值:
- 图片列表:
100px ~ 300px - 长列表或弱网场景:适当加大
- 精准首屏控制:结合实际滚动速度调优
安全/性能最佳实践
性能优化不是单点技巧,最好形成稳定规范。
1. 首屏资源分级管理
可以把资源分成三档:
- P0:首屏必须
- 首屏 HTML、关键 CSS、主标题、LCP 图
- P1:交互必须
- 当前页基础 JS、必要状态逻辑
- P2:延迟加载
- 评论、推荐、图表、弹窗、埋点补充逻辑
如果你不先做这层分类,优化很容易失焦。
2. 为第三方脚本设置边界
第三方脚本经常是性能黑洞。
建议:
- 非必要不首屏加载
- 使用
async/defer - 拆分为用户触发后再加载
- 控制数量,定期清理无效 SDK
- 评估脚本失败时的降级行为
<script async src="https://example.com/analytics.js"></script>
3. 缓存策略要和文件指纹配合
静态资源建议使用带 hash 文件名,并配置长期缓存。
这样拆分后的 chunk 才能真正复用。
服务端可参考:
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
4. 防止性能优化引入安全问题
比如:
- 动态拼接脚本 URL
- 不可信图片地址直接注入
- 为了提速滥用内联脚本
- 监控上报时泄露用户敏感数据
建议:
- 使用可信域名白名单
- 上报数据脱敏
- 配合 CSP
- 避免不必要的
dangerouslySetInnerHTML
5. 建立性能预算
这是很多团队最容易忽略、但最有用的一步。
你可以给项目设一组简单预算:
- 首屏 JS < 200KB(gzip 后,示例值)
- LCP P75 < 2.5s
- INP P75 < 200ms
- CLS P75 < 0.1
- 单页面第三方脚本不超过 3 个核心项
没有预算,性能就会慢慢“回退”。
推荐的落地顺序
如果你现在接手的是一个线上项目,不建议一上来就“大改架构”。可以按下面顺序推进:
flowchart TD
A[采集基线数据] --> B[找出 LCP 元素与大包来源]
B --> C[做路由级代码分割]
C --> D[拆重组件与第三方依赖]
D --> E[图片懒加载与尺寸预留]
E --> F[优化首屏关键资源优先级]
F --> G[处理长任务与交互卡顿]
G --> H[建立性能预算与监控]
这个顺序的好处是:
- 前几步通常收益最大
- 风险相对可控
- 容易灰度和回滚
- 能逐步验证每一步是否有效
一个最小可运行示例目录
方便你对照落地,示例目录可以长这样:
perf-demo/
├─ index.html
├─ src/
│ ├─ main.jsx
│ ├─ App.jsx
│ ├─ webVitals.js
│ ├─ pages/
│ │ ├─ Home.jsx
│ │ └─ Reports.jsx
│ └─ components/
│ ├─ Hero.jsx
│ ├─ LazyImage.jsx
│ ├─ HeavyChart.jsx
│ └─ charts/
│ └─ ChartImpl.jsx
└─ vite.config.js
边界条件:什么时候不必过度优化?
性能优化也要讲 ROI,不是所有项目都值得上复杂方案。
以下场景可以适度简化:
- 内部后台系统,用户量小、使用设备统一
- 页面很简单,JS 包本身不大
- 页面不是流量入口,首屏体验影响有限
- 服务端渲染已经很好地解决了首屏问题
但即便如此,下面三件事仍然建议做:
- 路由级代码分割
- 图片尺寸预留
- Core Web Vitals 监控
因为它们成本低、收益稳定。
总结
如果把这篇文章压缩成一句话,那就是:
前端性能优化的核心,不是让所有资源都更早加载,而是让“当前用户最需要的内容”最先可见、可交互、且稳定。
你可以按这条主线执行:
- 先测量:Lighthouse + 真实用户监控
- 做拆分:路由级、组件级、第三方依赖拆分
- 做懒加载:图片、次屏模块、非关键逻辑延后
- 保关键:首屏 LCP 资源不要误懒加载
- 治交互:减少长任务,优化 INP
- 保稳定:预留尺寸,控制异步插入,降低 CLS
- 设预算:避免版本迭代后性能回退
如果你现在就要开始,我建议第一周只做这 4 件事:
- 找出首页 LCP 元素
- 给路由做代码分割
- 给非首屏图片做懒加载并补齐尺寸
- 接入
web-vitals采集真实指标
先把“首屏大包”和“页面乱跳”这两个高频问题拿下,通常就能看到很明显的改善。之后再继续啃 INP 和第三方脚本治理,效果会更稳。