前端性能优化实践
性能是用户体验的核心支柱。在移动优先和多端适配的今天,首屏加载速度和交互响应流畅度直接影响用户留存与转化。
1. 性能优化全景
1.1 性能优化的三个维度
前端性能优化可以从三个维度切入,分别对应不同的用户感知阶段:
| 维度 | 关注点 | 关键指标 | 影响面 |
|---|---|---|---|
| 加载性能 | 首屏显示速度 | FCP、LCP、TTI | 用户第一印象、跳出率 |
| 运行时性能 | 交互响应流畅度 | INP、FPS、长任务耗时 | 操作体验、任务完成率 |
| 视觉稳定性 | 页面布局变化 | CLS | 视觉连贯性、误触率 |
1.2 Core Web Vitals 核心指标
Google 将以下三项指标作为网页体验的黄金标准:
| 指标 | 全称 | 衡量内容 | 合格值 | 良好值 |
|---|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容元素渲染时间 | < 2.5s | < 1.8s |
| INP | Interaction to Next Paint | 交互到下次绘制延迟 | < 200ms | < 100ms |
| CLS | Cumulative Layout Shift | 累计布局偏移分数 | < 0.1 | < 0.05 |
用户感知阶段:
DNS 解析 → TCP 连接 → SSL 握手 → TTFB → FCP → LCP → TTI → 交互流畅
(加载性能) (运行时性能)
2. 首屏加载优化
2.1 资源加载时序分析
性能优化的第一步是测量。使用 Chrome DevTools 的 Performance 和 Network 面板分析资源加载时序。
Network 面板关键字段解读:
┌──────────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ Queued │Stalled│ DNS │ TCP │ SSL │ TTFB │Content│
│ │ │Lookup│Connect│Negot.│ │Download│
├──────────┼──────┼──────┼──────┼──────┼──────┼──────┤
│ 排队等待 │连接前 │DNS │TCP │SSL │首字节 │内容 │
│ 资源请求 │耗时 │解析 │握手 │协商 │时间 │下载 │
└──────────┴──────┴──────┴──────┴──────┴──────┴──────┘
常见瓶颈与对策:
| 瓶颈阶段 | 可能原因 | 优化手段 |
|---|---|---|
| Stalled 过长 | 浏览器并发限制(同域最大 6 连接) | 域名分片、HTTP/2 多路复用 |
| DNS 耗时高 | DNS 解析慢 | <link rel="dns-prefetch"> |
| TTFB 过长 | 后端响应慢 | CDN 缓存、服务端优化 |
| Content Download 过大 | JS/CSS 资源体积大 | 代码分割、gzip/Brotli 压缩 |
2.2 路由懒加载
单页应用将所有代码打入一个 bundle 会导致首屏体积爆炸。路由懒加载让每个页面独立分包,访问时按需下载。
方案对比:
| 方案 | Vue 3 | React 18+ |
|---|---|---|
| 静态导入 | import Page from './Page.vue' |
import Page from './Page' |
| 动态导入 | () => import('./Page.vue') |
React.lazy(() => import('./Page')) |
| 加载指示器 | <Suspense> + 插槽 |
<Suspense fallback={...}> |
| 预加载 | 路由元数据配合 v-preload |
preload 属性 |
✅ 良好实践:路由懒加载
Vue 3 + Vue Router
<!-- router/index.ts -->
<script setup lang="ts">
import { createRouter, createWebHistory } from 'vue-router'
import { defineAsyncComponent } from 'vue'
import LoadingComponent from '@/components/RouteLoading.vue'
import ErrorComponent from '@/components/RouteError.vue'
const routes = [
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue')
},
{
path: '/user/list',
name: 'UserList',
component: defineAsyncComponent({
loader: () => import('@/views/user/UserList.vue'),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200,
timeout: 10000
})
},
{
path: '/order/detail/:id',
name: 'OrderDetail',
component: () => import('@/views/order/OrderDetail.vue')
}
]
export default createRouter({
history: createWebHistory(),
routes
})
</script>
React 18 + React Router v6
// router/index.tsx
import { lazy, Suspense } from 'react'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { Spin, Result, Button } from 'antd'
import type { RouteObject } from 'react-router-dom'
const Dashboard = lazy(() => import('@/views/Dashboard'))
const UserList = lazy(() => import('@/views/user/UserList'))
const OrderDetail = lazy(() => import('@/views/order/OrderDetail'))
// 统一的 Suspense fallback
const LazyLoad = ({ children }: { children: React.ReactNode }) => (
<Suspense fallback={
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '60vh' }}>
<Spin size="large" tip="页面加载中..." />
</div>
}>
{children}
</Suspense>
)
const routes: RouteObject[] = [
{
path: '/dashboard',
element: <LazyLoad><Dashboard /></LazyLoad>
},
{
path: '/user/list',
element: <LazyLoad><UserList /></LazyLoad>
},
{
path: '/order/detail/:id',
element: <LazyLoad><OrderDetail /></LazyLoad>
}
]
export const router = createBrowserRouter(routes)
❌ 反面实践:全量打包
// ❌ 所有页面一次性导入,首屏 bundle 体积爆炸
import Dashboard from '@/views/Dashboard'
import UserList from '@/views/user/UserList'
import OrderDetail from '@/views/order/OrderDetail'
import SystemConfig from '@/views/system/SystemConfig'
// ... 数十个页面全部同步导入
2.3 组件异步加载与骨架屏
路由懒加载解决页面级别的问题,组件异步加载解决模块级别的问题。
适用场景:
| 场景 | 说明 | 推荐方案 |
|---|---|---|
| 首屏不可见的弹窗/抽屉 | 用户操作后才展示 | defineAsyncComponent / lazy() |
| 第三方组件库(富文本、图表) | 体积大且非首屏必需 | 动态导入 + CDN |
| 条件渲染的复杂组件 | 特定权限/角色才展示 | 异步加载 + 骨架屏占位 |
✅ 良好实践:图表组件异步加载
Vue 3
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import { ElSkeleton } from 'element-plus'
const EChartsChart = defineAsyncComponent({
loader: () => import('@/components/EChartsChart.vue'),
loadingComponent: {
template: `<el-skeleton :rows="5" animated />`
},
delay: 300
})
</script>
<template>
<EChartsChart :options="chartOptions" />
</template>
React 18
import { lazy, Suspense } from 'react'
import { Skeleton } from 'antd'
const EChartsChart = lazy(() => import('@/components/EChartsChart'))
function DashboardPage() {
return (
<Suspense fallback={<Skeleton active paragraph={{ rows: 5 }} />}>
<EChartsChart options={chartOptions} />
</Suspense>
)
}
2.4 图片优化
图片通常是页面体积占比最大的资源,优化图片是首屏速度提升最直接的手段。
图片格式对比:
| 格式 | 压缩率(相对 PNG) | 浏览器支持 | 适用场景 |
|---|---|---|---|
| JPEG | 基准 | 全部 | 照片、复杂渐变 |
| PNG | 无损(大) | 全部 | 图标、透明背景 |
| WebP | 减小 25-35% | 现代浏览器 96%+ | 通用替代 JPEG/PNG |
| AVIF | 减小 50%+ | Chrome/Firefox 90%+ | 高质量图片需求 |
| SVG | 矢量格式 | 全部 | 图标、Logo、插画 |
✅ 良好实践:响应式图片 + 懒加载
HTML / Vue 模板
<!-- 响应式图片:不同屏幕加载不同尺寸 -->
<img
src="photo-320w.webp"
srcset="
photo-320w.webp 320w,
photo-640w.webp 640w,
photo-1280w.webp 1280w
"
sizes="(max-width: 640px) 320px, (max-width: 1280px) 640px, 1280px"
loading="lazy"
alt="产品展示图"
decoding="async"
/>
Vue 3 图片懒加载指令
<script setup lang="ts">
// directives/lazyload.ts
import type { Directive } from 'vue'
const lazyLoad: Directive<HTMLImageElement, string> = {
mounted(el, binding) {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.src = binding.value
observer.unobserve(el)
}
},
{ rootMargin: '200px' }
)
observer.observe(el)
}
}
</script>
<template>
<img v-lazy="'https://cdn.example.com/photo.webp'" alt="lazy" />
</template>
React 组件 + 自定义 Hook
import { useRef, useEffect, useState } from 'react'
function useLazyLoad(src: string, rootMargin = '200px') {
const ref = useRef<HTMLImageElement>(null)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.src = src
observer.unobserve(el)
setLoaded(true)
}
},
{ rootMargin }
)
observer.observe(el)
return () => observer.disconnect()
}, [src, rootMargin])
return { ref, loaded }
}
function ProductImage({ src, alt }: { src: string; alt: string }) {
const { ref, loaded } = useLazyLoad(src)
return (
<div style={{ minHeight: 200, background: '#f0f0f0' }}>
{!loaded && <div className="skeleton" />}
<img
ref={ref}
data-src={src}
alt={alt}
loading="lazy"
decoding="async"
style={{ display: loaded ? 'block' : 'none' }}
/>
</div>
)
}
2.5 字体优化
自定义字体是 CLS 的一大元凶(FOUT/FOIT 导致布局偏移)。
字体加载策略对比:
| 策略 | font-display |
行为 | 适用场景 |
|---|---|---|---|
| 阻塞 | block |
字体加载前隐藏文本(FOIT),最多 3s | 品牌字体必须准确渲染 |
| 交换 | swap |
先用系统字体显示,加载后再切换(FOUT) | 一般 UI 字体 |
| 后备 | fallback |
短时间阻塞(100ms),加载失败用系统字体 | 相对重要的装饰字体 |
| 可选 | optional |
短时间阻塞,加载失败不再切换 | 非关键装饰字体 |
✅ 良好实践
/* 推荐:font-display: swap,搭配字体子集化 */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/CustomFont.woff2') format('woff2');
font-display: swap; /* 先显示后备字体,加载后替换 */
font-weight: 400;
unicode-range: U+4E00-9FFF; /* 仅加载中文子集,大幅减小字体包 */
}
2.6 关键 CSS 内联与非关键 CSS 延迟加载
原理: CSS 是渲染阻塞资源——浏览器必须下载并解析完 CSS 才会渲染页面。将首屏必需的 CSS 内联到 HTML 中,非关键 CSS 延迟加载可显著提升 FCP。
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 关键 CSS 内联 | <style> 标签插入 |
零额外请求、立即生效 | 增加 HTML 体积 |
| 非关键 CSS 延迟 | media="print" onload="this.media='all'" |
不阻塞渲染 | 兼容性处理略复杂 |
| 预加载 CSS | <link rel="preload" as="style"> |
高优先级下载 | 仍需等待解析 |
| 全量同步 | 传统 <link> |
简单 | 阻塞渲染 |
<!-- 1. 关键 CSS 内联(构建工具自动提取) -->
<style>
/* 首屏必需的样式,通常 < 14KB */
.header { ... }
.hero { ... }
.nav { ... }
</style>
<!-- 2. 非关键 CSS 延迟加载 -->
<link
rel="stylesheet"
href="/css/non-critical.css"
media="print"
onload="this.media='all'"
/>
<noscript>
<link rel="stylesheet" href="/css/non-critical.css" />
</noscript>
2.7 资源提示(Resource Hints)
通过 <link> 标签的 rel 属性告知浏览器提前执行某些操作:
| 提示 | 作用 | 典型场景 | 示例 |
|---|---|---|---|
dns-prefetch |
提前解析 DNS | 第三方域名 | <link rel="dns-prefetch" href="//cdn.example.com"> |
preconnect |
提前建立 TCP + TLS 连接 | 关键第三方 | <link rel="preconnect" href="//api.example.com"> |
prefetch |
低优先级预取下次导航资源 | 下一个页面 JS | <link rel="prefetch" href="/next-page.js"> |
preload |
高优先级预取当前页资源 | 字体、首屏大图 | <link rel="preload" href="/font.woff2" as="font"> |
prerender |
在后台完整渲染页面 | 用户很可能访问的页面 | <link rel="prerender" href="/next-page"> |
<!-- preconnect:提前连接到 CDN 和 API 服务 -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<link rel="preconnect" href="https://api.example.com" crossorigin />
<!-- preload:关键资源提前加载(注意:as 属性必须正确) -->
<link rel="preload" href="/fonts/Inter-var.woff2" as="font" crossorigin />
<link rel="preload" href="/hero-banner.webp" as="image" />
<!-- dns-prefetch:非关键第三方 DNS 提前解析 -->
<link rel="dns-prefetch" href="//hm.baidu.com" />
<link rel="dns-prefetch" href="//www.googletagmanager.com" />
❌ preload 常见踩坑
<!-- ❌ 错误:as 缺失或错误,浏览器忽略 preload -->
<link rel="preload" href="/app.js" />
<!-- ❌ 错误:crossorigin 不匹配(字体资源必须加 crossorigin) -->
<link rel="preload" href="/fonts/custom.woff2" as="font" />
<!-- ✅ 正确用法 -->
<link rel="preload" href="/fonts/custom.woff2" as="font" crossorigin="anonymous" />
2.8 HTTP/2 多路复用
在 HTTP/1.1 时代,浏览器对同一域名有 6 个并发连接限制,前端常用域名分片(将资源分散到多个子域名)来突破瓶颈。HTTP/2 的多路复用允许多个请求在一个 TCP 连接中并行传输,无需域名分片。
| 特性 | HTTP/1.1 | HTTP/2 |
|---|---|---|
| 连接模型 | 每个请求一个 TCP | 多路复用单一连接 |
| 头部压缩 | 无 | HPACK 压缩 |
| Server Push | 不支持 | 支持 |
| 域名分片 | 需要(突破 6 连接) | 不需要(反模式) |
| 资源合并(雪碧图) | 需要 | 不需要 |
HTTP/2 实践建议:
├── ✅ 停止域名分片,享受单连接多路复用
├── ✅ 停止 CSS Sprites,改用独立的 SVG/WebP
├── ✅ 停止合并 JS/CSS 文件,利用多路复用同时传输
├── ✅ 启用 Brotli 压缩(比 gzip 小 20-30%)
└── ❌ 不要轻易使用 Server Push(可能导致带宽浪费)
3. 运行时性能优化
3.1 大列表优化:虚拟滚动
渲染数千条 DOM 节点会导致严重的性能问题(卡顿、内存上涨)。虚拟滚动只渲染可视区域内的节点,滚动时复用 DOM 元素。
方案对比:
| 方案 | Vue 3 | React | 优点 | 缺点 |
|---|---|---|---|---|
| vue-virtual-scroller | 原生支持 | - | Vue 生态最成熟 | 仅 Vue |
| react-window | - | 原生支持 | 轻量(~3KB) | API 较底层 |
| @tanstack/virtual | 支持 | 支持 | 框架无关、SSR 友好 | 需要自行封装 |
| react-virtuoso | - | 支持 | 开箱即用、自动尺寸 | 包体积较大 |
✅ 良好实践:使用 @tanstack/virtual(跨框架方案)
Vue 3
<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
const parentRef = ref<HTMLDivElement>()
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
label: `Item ${i}`
}))
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 50 // 预估行高
})
</script>
<template>
<div ref="parentRef" style="height: 500px; overflow: auto;">
<div
:style="{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}"
>
<div
v-for="row in virtualizer.getVirtualItems()"
:key="row.key"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${row.size}px`,
transform: `translateY(${row.start}px)`
}"
>
{{ items[row.index].label }}
</div>
</div>
</div>
</template>
React 18
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList() {
const parentRef = useRef<HTMLDivElement>(null)
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
label: `Item ${i}`
}))
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50
})
return (
<div ref={parentRef} style={{ height: 500, overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${row.size}px`,
transform: `translateY(${row.start}px)`
}}
>
{items[row.index].label}
</div>
))}
</div>
</div>
)
}
3.2 减少重排重绘
布局(Layout/Reflow)和绘制(Paint)是浏览器渲染中最耗时的阶段。频繁触发重排会导致页面卡顿(俗称"jank")。
触发重排的操作:
| 操作 | 说明 | 替代方案 |
|---|---|---|
读写 offsetTop/scrollTop |
强制同步布局(Forced Reflow) | 缓存值、使用 getBoundingClientRect |
修改 width/height/margin |
触发布局计算 | 使用 transform 代替 |
修改 top/left |
触发布局计算 | 使用 transform: translate() |
修改 display: none |
触发重排重绘 | 使用 visibility: hidden(不触发布局) |
读取 getComputedStyle |
强制同步样式 | 减少频次、合并读取 |
渲染属性对比:
| CSS 属性 | 触发阶段 | 开销 | 适用场景 |
|---|---|---|---|
transform |
Composite (合成) | 最低 | 位移、缩放、旋转 |
opacity |
Composite (合成) | 最低 | 透明度变化 |
filter |
Composite/Paint | 中等 | 滤镜效果 |
width/height |
Layout + Paint + Composite | 最高 | 非动画场景 |
top/left |
Layout + Paint + Composite | 最高 | 避免动画中使用 |
✅ 良好实践:使用 transform 优化动画
/* ❌ 不好:触发重排 */
.card {
position: absolute;
transition: top 0.3s, left 0.3s;
}
/* ✅ 好:仅触发合成,性能提升 10x+ */
.card {
position: absolute;
transition: transform 0.3s;
/* will-change 提前告知浏览器创建独立合成层 */
will-change: transform;
}
// ❌ 不好:频繁读写 DOM 布局属性
function BadAnimation() {
const handleScroll = () => {
const el = document.querySelector('.sticky')
// ❌ 每次滚动都强制触发 reflow
const top = document.querySelector('.header').offsetHeight
el.style.top = `${top}px`
}
}
// ✅ 好:使用 requestAnimationFrame 批量更新
function GoodAnimation() {
const ticking = useRef(false)
const handleScroll = () => {
if (!ticking.current) {
requestAnimationFrame(() => {
const el = document.querySelector('.sticky')
const top = document.querySelector('.header').getBoundingClientRect().height
el.style.transform = `translateY(${top}px)`
ticking.current = false
})
ticking.current = true
}
}
}
3.3 防抖与节流
高频触发的事件(滚动、输入、resize)需要限流。
| 策略 | 行为 | 适用场景 |
|---|---|---|
| 防抖(Debounce) | 最后一次触发后等待 N 毫秒执行 | 搜索输入、自动保存 |
| 节流(Throttle) | 每 N 毫秒最多执行一次 | 滚动加载、resize |
| requestAnimationFrame | 跟随浏览器帧率(约 16.7ms) | 动画、布局更新 |
// utils/performance.ts
/** 防抖:连续触发只执行最后一次 */
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
/** 节流:固定频率执行 */
export function throttle<T extends (...args: any[]) => any>(
fn: T,
interval: number
): (...args: Parameters<T>) => void {
let last = 0
return (...args: Parameters<T>) => {
const now = Date.now()
if (now - last >= interval) {
last = now
fn(...args)
}
}
}
Vue 3 使用场景
<script setup lang="ts">
import { debounce } from '@/utils/performance'
import { ElInput, ElMessage } from 'element-plus'
// ✅ 搜索防抖
const search = debounce(async (keyword: string) => {
if (!keyword.trim()) return
// 永远不要信任后端返回的数据,做好防御
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
// 防御式处理:校验后端返回数据
if (!Array.isArray(data?.results)) {
ElMessage.warning('搜索返回格式异常')
return
}
// 渲染逻辑...
} catch (err) {
ElMessage.error('搜索失败,请稍后重试')
}
}, 300)
</script>
<template>
<ElInput @input="search" placeholder="搜索..." />
</template>
React 18 使用场景
import { useState, useCallback } from 'react'
import { debounce } from '@/utils/performance'
import { Input, message } from 'antd'
import { useNavigate } from 'react-router-dom'
function SearchBox() {
const [loading, setLoading] = useState(false)
const handleSearch = useCallback(
debounce(async (value: string) => {
const keyword = value.trim()
if (!keyword) return
setLoading(true)
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
// 防御式编程:校验返回值结构
if (!data || typeof data !== 'object') {
message.warning('服务器返回异常')
return
}
// 渲染结果...
} catch (err) {
message.error('搜索失败,请稍后重试')
} finally {
setLoading(false)
}
}, 300),
[]
)
return <Input.Search loading={loading} onSearch={handleSearch} placeholder="搜索..." />
}
3.4 Web Worker:CPU 密集任务
JavaScript 是单线程的,CPU 密集任务会阻塞主线程导致页面无法响应用户操作。Web Worker 在后台线程运行,不阻塞主线程。
适用场景:
| 场景 | 是否适合 Worker | 原因 |
|---|---|---|
| 大 JSON 解析(> 10MB) | ✅ 适合 | 避免主线程冻结 |
| 数据加密/解密 | ✅ 适合 | CPU 密集、无 DOM 操作 |
| 图片处理算法 | ✅ 适合 | 计算密集 |
| 正则解析大量文本 | ✅ 适合 | 可能耗时较长 |
| DOM 操作/渲染 | ❌ 不适合 | Worker 不能访问 DOM |
| 频繁的小型计算 | ❌ 不适合 | Worker 通信开销超过收益 |
// workers/data-processor.ts
// ✅ 良好实践:Worker 处理大 JSON 解析
self.onmessage = (event: MessageEvent) => {
const { rawData, taskId } = event.data
try {
// 防御式处理:校验数据
if (typeof rawData !== 'string') {
self.postMessage({ error: 'Invalid data format', taskId })
return
}
const parsed = JSON.parse(rawData)
// 数据转换逻辑...
const result = parsed.map((item: any) => ({
id: item.id,
name: item.name,
// 防御式:字段不存在时给默认值
status: item.status ?? 'unknown',
amount: Number(item.amount) || 0
}))
self.postMessage({ result, taskId })
} catch (err) {
self.postMessage({ error: `Parse failed: ${err}`, taskId })
}
}
// Vue 3 中使用 Worker
// composables/useWorker.ts
import { ref, onUnmounted } from 'vue'
export function useWorker<T = any>() {
const workerRef = ref<Worker | null>(null)
const result = ref<T | null>(null)
const error = ref<string | null>(null)
const loading = ref(false)
const initWorker = () => {
if (workerRef.value) return
workerRef.value = new Worker(
new URL('@/workers/data-processor.ts', import.meta.url),
{ type: 'module' }
)
workerRef.value.onmessage = (e: MessageEvent) => {
loading.value = false
const { result: data, error: err } = e.data
if (err) {
error.value = err
return
}
result.value = data
}
}
const postTask = (data: any, taskId: string) => {
initWorker()
loading.value = true
workerRef.value?.postMessage({ rawData: data, taskId })
}
onUnmounted(() => {
workerRef.value?.terminate()
})
return { result, error, loading, postTask }
}
3.5 Vue 3 专有优化
| 特性 | 作用 | 使用频率 | 适用场景 |
|---|---|---|---|
shallowRef |
只追踪 .value 变化,不深层响应 |
高 | 大数据列表、不可变对象 |
v-memo |
根据依赖缓存 DOM 片段 | 中 | 列表中有条件渲染的重组件 |
v-once |
仅渲染一次,后续跳过 | 中 | 静态内容 |
shallowReactive |
浅层响应式 | 低 | 顶层属性替换场景 |
<script setup lang="ts">
import { shallowRef, ref } from 'vue'
import type { TableColumn } from 'element-plus'
// ❌ 不好:数据量大时深层响应开销大
const items = ref<Item[]>([])
// ✅ 好:大数据使用 shallowRef,减少 Proxy 开销
const largeList = shallowRef<Item[]>([])
// 更新时整体替换,触发视图更新
const updateList = (newItems: Item[]) => {
largeList.value = newItems
}
// v-memo + v-for:缓存列表渲染,仅依赖变化时重渲染
const columns = ref<TableColumn[]>([
{ key: 'name', title: '名称' },
{ key: 'status', title: '状态' },
{ key: 'amount', title: '金额' }
])
</script>
<template>
<!-- v-once:静态内容只渲染一次 -->
<header v-once>
<h1>数据报表系统</h1>
<p>版本 2.0.0</p>
</header>
<!-- v-memo:仅当 id 和 status 变化时重渲染 -->
<div v-for="item in largeList" :key="item.id" v-memo="[item.id, item.status]">
<span>{{ item.name }}</span>
<span>{{ item.status }}</span>
<span>{{ item.amount }}</span>
</div>
</template>
3.6 React 专有优化
| 特性 | 作用 | 滥用风险 | 适用场景 |
|---|---|---|---|
React.memo |
浅比较 props 跳过渲染 | 对比开销可能 > 渲染开销 | 纯展示组件 |
useMemo |
缓存计算结果 | 内存占用增加 | 复杂计算、引用稳定 |
useCallback |
缓存函数引用 | 下游配合 React.memo 才有意义 |
传递给 memo 组件的回调 |
useTransition |
标记低优先级更新 | 状态管理复杂度增加 | 搜索过滤、批量更新 |
import { useState, useMemo, useCallback, memo, useTransition } from 'react'
// ✅ 好:React.memo 缓存纯展示组件
const UserCard = memo(function UserCard({
user,
onSelect
}: {
user: { id: number; name: string; avatar: string }
onSelect: (id: number) => void
}) {
return (
<div className="user-card" onClick={() => onSelect(user.id)}>
<img src={user.avatar} alt={user.name} />
<span>{user.name}</span>
</div>
)
})
function UserList({ users }: { users: Array<{ id: number; name: string }> }) {
const [search, setSearch] = useState('')
const [isPending, startTransition] = useTransition()
// ✅ 好:useMemo 缓存过滤结果
const filtered = useMemo(
() => users.filter((u) => u.name.includes(search)),
[users, search]
)
// ✅ 好:useCallback 配合 memo 组件
const handleSelect = useCallback((id: number) => {
console.log('selected:', id)
}, [])
// ✅ 好:useTransition 处理高优先级输入 + 低优先级列表更新
const handleSearch = (value: string) => {
startTransition(() => {
setSearch(value)
})
}
return (
<div>
<input onChange={(e) => handleSearch(e.target.value)} placeholder="搜索" />
{isPending && <div>更新中...</div>}
<div>
{filtered.map((user) => (
<UserCard key={user.id} user={user} onSelect={handleSelect} />
))}
</div>
</div>
)
}
❌ 反面实践:useMemo/useCallback 滥用
// ❌ 不好:简单计算不需要 useMemo(对比开销 > 计算开销)
const double = useMemo(() => count * 2, [count])
// ❌ 不好:传递给普通 DOM 元素的回调不需要 useCallback
const handleClick = useCallback(() => {
setCount((c) => c + 1)
}, [])
// <div onClick={handleClick}> // div 不是 memo 组件,useCallback 无意义
// ✅ 好:只有传给 memo 子组件才需要 useCallback
3.7 列表渲染 key 的重要性
key 帮助框架精确识别哪些元素发生了变更,避免不必要的 DOM 操作。
| key 值 | 效果 | 示例 |
|---|---|---|
| 唯一 ID | 最佳,精确定位元素 | :key="item.id" |
| 索引 index | 列表无增删时可用 | :key="index" |
| 随机值/Math.random | 最差,每次全量销毁重建 | :key="Math.random()" |
❌ 反面实践:滥用 index 作为 key
<!-- ❌ 不好:index 作为 key 导致列表状态错乱 -->
<template v-for="(item, index) in list" :key="index">
<input v-model="item.name" />
</template>
<!-- 当 list 头部插入新项时,所有 input 的状态都会错位 -->
4. 性能分析工具
4.1 Chrome DevTools Performance
Performance 面板使用流程:
1. 打开 DevTools → Performance 面板
2. 点击录制按钮(或 Cmd+E)
3. 执行待分析的用户操作
4. 停止录制
5. 分析火焰图
火焰图解读要点:
├── 红色长条 → 长任务(> 50ms),需要优化
├── 紫色条 → 渲染(Paint),不合理时出现
├── 黄色条 → JavaScript 执行
├── 绿色条 → 布局(Layout/Reflow)
└── 灰色条 → 闲置(Idle)
常见火焰图模式:
| 模式 | 表现 | 问题 | 对策 |
|---|---|---|---|
| 长 JS 任务 | 连续黄色条 > 50ms | 主线程被阻塞 | Web Worker、任务拆分 |
| 频繁 Layout | 绿色条密集出现 | 强制同步布局 | 批量读写、使用 transform |
| 大范围 Paint | 大面积紫色 | 大面积重绘 | will-change、独立合成层 |
| 内存锯齿 | 锯齿形内存曲线 | 频繁 GC | 减少临时对象、对象池 |
4.2 Lighthouse / PageSpeed Insights
# CLI 方式运行 Lighthouse
npx lighthouse https://example.com --view --preset=desktop
npx lighthouse https://example.com --view --preset=perf # 仅性能审计
评分维度与权重:
| 维度 | 权重 | 核心指标 |
|---|---|---|
| Performance | 40% | LCP / TBT / CLS |
| Accessibility | 25% | ARIA 属性、颜色对比度 |
| Best Practices | 20% | HTTPS、安全依赖 |
| SEO | 15% | meta 标签、可索引性 |
4.3 Vue DevTools 性能面板
Vue DevTools 性能面板功能:
├── Component Render 追踪
│ ├── 每个组件的渲染时间
│ └── 渲染次数统计
├── Timeline
│ ├── 组件生命周期事件
│ └── Pinia/Composition API 状态变更
└── 性能预算
└── 设定组件渲染耗时阈值,超限告警
使用技巧:
- 关注渲染次数异常的组件(不应频繁重渲染)
- 检查组件的渲染耗时柱状图,定位耗时的子组件
- 配合 Pinia devtools 追踪状态变更导致的连锁渲染
4.4 React Profiler
import { Profiler } from 'react'
function onRenderCallback(
id: string, // 组件标识
phase: string, // mount / update
actualDuration: number, // 实际渲染耗时 (ms)
baseDuration: number, // 子组件纯渲染耗时
startTime: number,
commitTime: number
) {
// 将耗时数据上报到监控系统
if (actualDuration > 16) { // 超过一帧(60fps)
reportMetric({ id, phase, actualDuration })
}
}
function App() {
return (
<Profiler id="Dashboard" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
)
}
React DevTools Profiler 使用流程:
1. 打开 React DevTools → Profiler 面板
2. 点击录制按钮
3. 执行操作
4. 停止录制
5. 查看渲染火焰图
火焰图解读:
├── 灰色 → 未重新渲染(性能良好)
├── 绿色 → 重新渲染但耗时短
├── 黄色 → 需要关注
└── 红色 → 严重需要优化
5. 打包体积优化
5.1 Tree Shaking 保障
Tree Shaking 依赖 ES Module 的静态分析特性,移除未使用的导出。
配置检查清单:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
"sideEffects" in package.json |
false 或具体列表 |
告知打包器哪些文件有副作用 |
"module" in package.json |
"./dist/index.esm.js" |
保留 ES Module 入口 |
| TypeScript target | ES2015+ |
保留 import/export,不转译为 CommonJS |
| Vite 默认 | 已启用 treeshake | Vite 基于 Rollup,Tree Shaking 开箱即用 |
// package.json
{
"sideEffects": [
"**/*.css",
"**/*.scss",
"**/polyfill.js"
]
}
5.2 按需导入
UI 组件库按需导入:
// ❌ 不好:全量导入 Element Plus(~1.6MB 压缩前)
import ElementPlus from 'element-plus'
app.use(ElementPlus)
// ✅ 好:按需导入 + 自动解析(配合 unplugin-vue-components)
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default {
plugins: [
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
]
}
// ❌ 不好:全量导入 Ant Design
import { Button, Table, Modal, Form, Input, Select, DatePicker, message } from 'antd'
// ✅ 好:Ant Design v5 支持 Tree Shaking,无需手动按需
// ❌ 但一些工具函数仍需注意
import { debounce } from 'lodash' // ❌ 全量导入 lodash
import debounce from 'lodash/debounce' // ✅ 只导入 debounce
5.3 代码分割与懒加载
构建产物优化策略:
├── 路由级分割:每个页面一个 chunk
├── 组件级分割:大弹窗/图表异步加载
├── 第三方包分割:vendors chunk 独立缓存
└── 公共库提取:webpack splitChunks / Vite rollupOptions
Vite 配置示例:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-ui': ['element-plus', '@element-plus/icons-vue'],
'vendor-echarts': ['echarts', 'vue-echarts'],
// 大体积图表库独立分包
'vendor-xlsx': ['xlsx']
}
}
},
// 启用 CSS 代码分割
cssCodeSplit: true,
// 生成源码映射(生产环境建议关闭)
sourcemap: false,
// 启用 Brotli 压缩报告
reportCompressedSize: true
}
})
5.4 依赖替换
| 原依赖 | 替换方案 | 体积减少 | 说明 |
|---|---|---|---|
| moment | dayjs | ~90%(200KB → 6KB) | API 兼容性高 |
| lodash | lodash-es | ~80%(按需导入) | ES Module 版本 |
| axios | 原生 fetch | 无依赖 | 现代浏览器内置 |
| jQuery | 原生 API | 100% | 已无使用必要 |
| prettier | 只在构建时使用 | 排除在生产包外 | devDependencies |
| @ant-design/icons | 仅导入使用的图标 | 按需 | Tree Shaking 生效 |
// ✅ 好: dayjs 替代 moment
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
console.log(dayjs('2026-06-29').fromNow()) // "刚刚"
6. 内存泄漏排查与修复
6.1 常见内存泄漏场景
| 泄漏场景 | 原因 | 检测方法 | 修复方案 |
|---|---|---|---|
| 未清理的定时器 | setInterval 持续执行 |
Memory 快照对比 | onUnmounted/useEffect 清理 |
| 未移除的事件监听 | addEventListener 后未 remove |
Elements ➝ Event Listeners | onUnmounted/useEffect 清理 |
| 闭包引用 | 回调函数持有大对象引用 | Memory 快照 | 释放引用、使用 WeakRef |
| DOM 引用泄漏 | ref 引用了已移除的 DOM |
Heap 快照搜索 Detached | 确保及时置空 |
| 全局变量 | window.xxx = data |
Console 搜索 | 避免全局变量 |
✅ 良好实践:Vue 3 中彻底清理
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const count = ref(0)
// 定时器
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
timer = setInterval(() => {
count.value++
}, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
// 事件监听
const handleResize = () => {
console.log('resize:', window.innerWidth)
}
onMounted(() => window.addEventListener('resize', handleResize))
onUnmounted(() => window.removeEventListener('resize', handleResize))
// DOM 引用
const chartRef = ref<HTMLElement | null>(null)
const observer = new ResizeObserver((entries) => {
console.log('元素尺寸变化:', entries[0].contentRect)
})
onMounted(() => {
if (chartRef.value) observer.observe(chartRef.value)
})
onUnmounted(() => {
observer.disconnect()
})
</script>
✅ 良好实践:React 18 中彻底清理
import { useEffect, useRef } from 'react'
function PerformanceMonitor() {
const chartRef = useRef<HTMLDivElement>(null)
const observerRef = useRef<ResizeObserver | null>(null)
useEffect(() => {
// 定时器
const timer = setInterval(() => {
console.log('heartbeat')
}, 30000)
// ResizeObserver
observerRef.current = new ResizeObserver((entries) => {
console.log('chart resized:', entries[0].contentRect)
})
if (chartRef.current) {
observerRef.current.observe(chartRef.current)
}
// 清理函数:组件卸载时统一清理
return () => {
clearInterval(timer)
observerRef.current?.disconnect()
}
}, [])
return <div ref={chartRef} style={{ width: '100%', height: 400 }} />
}
6.2 Chrome Memory 面板分析
Memory 面板使用流程:
1. 切换到 Memory 面板
2. 选择 Heap Snapshot(堆快照)
3. 点击 Take Snapshot(第一次,建立基准)
4. 执行可能泄漏的操作
5. 再次 Take Snapshot
6. 选择 Comparison 视图
Comparison 视图解读:
├── # New(新增对象数)
├── # Deleted(已删除对象数)
├── # Delta(差值 = New - Deleted)
└── Alloc. Size(新增内存总大小)
重点关注:
├── Detached DOM tree → JS 引用导致 DOM 无法被 GC
├── (string) → 大量字符串泄漏
├── (closure) → 闭包持有过大引用
└── Array / Object 数量持续增长不回落
常见排查步骤:
1. 录制快照 → 操作 → 录制快照
2. 筛选 Detached DOM tree
3. 查看 retainers(保留者)链
4. 定位到泄漏的代码位置
5. 修复后重复验证(确认 Delta 归零)
7. 性能优化检查清单
加载性能
- Bundle 体积分析(
vite-bundle-visualizer/webpack-bundle-analyzer) - 路由懒加载:每个页面独立 chunk
- 大组件异步加载:弹窗、图表、富文本编辑器
- 图片优化:WebP/AVIF 格式,srcset 响应式,loading="lazy"
- 字体优化:font-display: swap,子集化(unicode-range)
- 关键 CSS 内联,非关键 CSS 延迟加载
- 资源提示:preconnect / preload / prefetch
- 启用 Brotli 压缩(比 gzip 小 20-30%)
- CDN 缓存策略:静态资源长缓存 + 内容 hash
- HTTP/2 / HTTP/3 支持
运行时性能
- 列表超过 100 项使用虚拟滚动
- 动画使用 transform / opacity(仅触发合成)
- 高频事件使用 debounce / throttle
- CPU 密集任务使用 Web Worker
- Vue: shallowRef 处理大数据,v-memo 缓存列表
- React: useMemo/useCallback 配合 React.memo
- 列表渲染使用唯一 key,避免 index
- 避免强制同步布局(offsetTop/scrollTop 批量读取)
分析工具
- Chrome DevTools Performance 火焰图分析
- Lighthouse 评分 > 90 分
- Vue DevTools / React Profiler 渲染耗时检测
- 长任务监控(Long Tasks API)
打包体积
- Tree Shaking 生效(sideEffects 配置正确)
- UI 组件库按需导入
- 大依赖替换(moment → dayjs, lodash → lodash-es)
- 代码分割(manualChunks 配置)
- 生产环境 sourcemap 关闭
- 无用代码去除(功能下线后删除对应代码)
内存管理
- 定时器/事件监听在组件卸载时清理
- ResizeObserver / MutationObserver 正确断开
- 无 Detached DOM tree 残留
- 路由切换后内存回落到基准水平
- 无全局变量持有大对象引用
8. 常见踩坑点总结
8.1 防御式处理前端数据
// ❌ 不好:直接信任后端数据
const { data } = await api.getUserList()
userList.value = data
// ✅ 好:防御式校验
const { data } = await api.getUserList()
// 安全兜底:确保 data 是数组
userList.value = Array.isArray(data) ? data.map((item: any) => ({
id: Number(item.id) || 0,
name: String(item.name ?? ''),
status: item.status ?? 'inactive',
amount: Number(item.amount) || 0
})) : []
8.2 媒体查询与性能
/* ❌ 不好:transform animation 在低端设备导致卡顿 */
@media (prefers-reduced-motion: reduce) {
/* 用户开启"减少动效"时关闭动画 */
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
8.3 滚动容器不要绑定到 body
// ❌ 不好:虚拟滚动绑定到 document.body
const virtualizer = useVirtualizer({
getScrollElement: () => document.body, // ❌ body 滚动行为不可控
count: items.length,
estimateSize: () => 50
})
// ✅ 好:使用独立的滚动容器
const containerRef = ref<HTMLDivElement>()
const virtualizer = useVirtualizer({
getScrollElement: () => containerRef.value,
count: items.length,
estimateSize: () => 50
})
8.4 注意 requestAnimationFrame 的嵌套
// ❌ 不好:递归 rAF 未考虑页面不可见
function startAnimation() {
function loop() {
updatePosition()
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
}
// ✅ 好:页面不可见时暂停
let running = true
function startAnimation() {
function loop() {
if (!running) return
// 只在页面可见时更新
if (document.visibilityState === 'visible') {
updatePosition()
}
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
}
document.addEventListener('visibilitychange', () => {
running = document.visibilityState === 'visible'
})
总结
前端性能优化的核心思路:
- 测量先行:优化前必须量化指标,优化后必须验证效果
- 二八法则:20% 的优化手段解决 80% 的性能问题(懒加载、图片优化、虚拟滚动、防抖节流)
- 分层优化:加载性能 → 运行时性能 → 内存管理,逐层深入
- 持续监控:将性能指标集成到 CI/CD 流水线,防止回归
- 用户感知优先:让用户"感觉快"比实际快更重要(骨架屏、渐进式加载、乐观更新)
最后更新:2026/06/29