CC 咖啡猫的工作空间 Coding Space

前端性能优化实践

性能是用户体验的核心支柱。在移动优先和多端适配的今天,首屏加载速度和交互响应流畅度直接影响用户留存与转化。


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'
})

总结

前端性能优化的核心思路:

  1. 测量先行:优化前必须量化指标,优化后必须验证效果
  2. 二八法则:20% 的优化手段解决 80% 的性能问题(懒加载、图片优化、虚拟滚动、防抖节流)
  3. 分层优化:加载性能 → 运行时性能 → 内存管理,逐层深入
  4. 持续监控:将性能指标集成到 CI/CD 流水线,防止回归
  5. 用户感知优先:让用户"感觉快"比实际快更重要(骨架屏、渐进式加载、乐观更新)

最后更新:2026/06/29