性能优化概述

前端性能优化的目标是提升用户体验,让页面加载更快、响应更及时、交互更流畅。

核心 Web 指标 (Core Web Vitals)

  • LCP (Largest Contentful Paint): 最大内容绘制,衡量加载性能,应在 2.5 秒内
  • INP (Interaction to Next Paint): 交互到下次绘制,衡量响应性,应在 200 毫秒内
  • CLS (Cumulative Layout Shift): 累积布局偏移,衡量视觉稳定性,应小于 0.1

性能优化原则

  1. 减少请求数量:合并资源、使用雪碧图
  2. 减少资源体积:压缩、Gzip、Tree Shaking
  3. 优化加载顺序:关键资源优先加载
  4. 利用缓存:浏览器缓存、CDN 缓存
  5. 延迟加载:非关键资源延迟加载
  6. 优化渲染:减少重排重绘

代码分割与懒加载

路由级别的代码分割

// Vue Router 懒加载
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'Home',
      // 使用动态导入实现懒加载
      component: () => import('@/views/Home.vue'),
    },
    {
      path: '/about',
      name: 'About',
      component: () => import('@/views/About.vue'),
    },
    {
      path: '/admin',
      name: 'Admin',
      // 预加载管理后台
      component: () => import(
        /* webpackChunkName: "admin" */
        /* webpackPrefetch: true */
        '@/views/Admin.vue'
      ),
    },
  ],
});
// React Router 懒加载
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Admin = lazy(() => import(
  /* webpackPrefetch: true */
  './pages/Admin'
));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/admin" element={<Admin />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

组件级别的懒加载

// Vue 组件懒加载
import { defineAsyncComponent } from 'vue';

// 基础用法
const AsyncComponent = defineAsyncComponent(
  () => import('./components/HeavyComponent.vue')
);

// 高级配置
const AsyncComponentWithOptions = defineAsyncComponent({
  // 工厂函数
  loader: () => import('./components/HeavyComponent.vue'),
  
  // 加载异步组件时显示的组件
  loadingComponent: LoadingComponent,
  
  // 加载失败时显示的组件
  errorComponent: ErrorComponent,
  
  // 延迟时间(毫秒)
  delay: 200,
  
  // 超时时间(毫秒)
  timeout: 3000,
  
  // 是否挂起
  suspensible: true,
  
  // 错误处理
  onError(error, retry, fail, attempts) {
    if (error.message.match(/network/) && attempts <= 3) {
      // 网络错误,重试
      retry();
    } else {
      // 其他错误或重试次数超限,失败
      fail();
    }
  },
});
// React 组件懒加载
import { lazy, Suspense, ComponentType } from 'react';

// 基础用法
const HeavyComponent = lazy(() => import('./HeavyComponent'));

// 带加载状态
function withSuspense<T extends ComponentType<any>>(
  Component: T,
  fallback = <div>Loading...</div>
) {
  return function SuspenseWrapper(props: React.ComponentProps<T>) {
    return (
      <Suspense fallback={fallback}>
        <Component {...props} />
      </Suspense>
    );
  };
}

const LazyHeavyComponent = withSuspense(HeavyComponent);

条件加载

// 根据条件动态加载组件
import { ref, onMounted } from 'vue';

const showHeavyComponent = ref(false);
const HeavyComponent = ref(null);

onMounted(async () => {
  // 用户交互后才加载
  if (userInteracted) {
    const module = await import('./HeavyComponent.vue');
    HeavyComponent.value = module.default;
    showHeavyComponent.value = true;
  }
});
// React 根据视口加载
import { useEffect, useState, useRef } from 'react';

function useInView(ref: React.RefObject<HTMLElement>) {
  const [isInView, setIsInView] = useState(false);
  
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => setIsInView(entry.isIntersecting),
      { threshold: 0.1 }
    );
    
    if (ref.current) {
      observer.observe(ref.current);
    }
    
    return () => observer.disconnect();
  }, [ref]);
  
  return isInView;
}

function LazyImage({ src, alt }: { src: string; alt: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const isInView = useInView(ref);
  const [Component, setComponent] = useState(null);
  
  useEffect(() => {
    if (isInView && !Component) {
      import('./ImageComponent').then(mod => {
        setComponent(() => mod.default);
      });
    }
  }, [isInView, Component]);
  
  return <div ref={ref}>{Component && <Component src={src} alt={alt} />}</div>;
}

资源优化

图片优化

// 响应式图片
<img
  srcset="
    image-320w.jpg 320w,
    image-480w.jpg 480w,
    image-800w.jpg 800w
  "
  sizes="(max-width: 320px) 280px,
         (max-width: 480px) 440px,
         800px"
  src="image-800w.jpg"
  alt="响应式图片"
/>

// 现代图片格式
<picture>
  <source srcset="image.avif" type="image/avif" />
  <source srcset="image.webp" type="image/webp" />
  <img src="image.jpg" alt="图片" />
</picture>
// 图片懒加载
<img loading="lazy" src="image.jpg" alt="懒加载图片" />

// 预加载关键图片
<link rel="preload" as="image" href="hero.jpg" />
<link rel="preload" as="image" href="hero-mobile.jpg" media="(max-width: 768px)" />

// 预连接 CDN
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="dns-prefetch" href="https://cdn.example.com" />
// Vue 图片懒加载组件
import { defineComponent, ref, onMounted, onUnmounted } from 'vue';

export default defineComponent({
  name: 'LazyImage',
  props: {
    src: { type: String, required: true },
    alt: { type: String, default: '' },
  },
  setup(props) {
    const imgRef = ref<HTMLImageElement>();
    const isLoaded = ref(false);
    const observer = ref<IntersectionObserver>();
    
    onMounted(() => {
      observer.value = new IntersectionObserver(
        ([entry]) => {
          if (entry.isIntersecting && imgRef.value) {
            imgRef.value.src = props.src;
            observer.value?.disconnect();
          }
        },
        { threshold: 0.1 }
      );
      
      if (imgRef.value) {
        observer.value.observe(imgRef.value);
      }
    });
    
    onUnmounted(() => {
      observer.value?.disconnect();
    });
    
    return { imgRef, isLoaded };
  },
  template: `
    <img
      ref="imgRef"
      :alt="alt"
      @load="isLoaded = true"
      :class="{ 'loaded': isLoaded }"
    />
  `,
});

字体优化

<!-- 预加载关键字体 -->
<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

<!-- 字体显示策略 -->
<style>
  @font-face {
    font-family: 'Inter';
    src: url('/fonts/inter-var.woff2') format('woff2');
    font-weight: 100 900;
    font-display: swap; /* 或 fallback, optional, block */
  }
</style>
// 字体加载 API
if ('fonts' in document) {
  const font = new FontFace('Inter', 'url(/fonts/inter-var.woff2)');
  
  font.load().then(() => {
    document.fonts.add(font);
    document.documentElement.classList.add('fonts-loaded');
  }).catch(err => {
    console.error('字体加载失败:', err);
  });
}

资源预加载策略

<!-- 预加载关键资源 -->
<link rel="preload" href="/critical.css" as="style" />
<link rel="preload" href="/main.js" as="script" />
<link rel="preload" href="/hero.jpg" as="image" />

<!-- 预获取下一页资源 -->
<link rel="prefetch" href="/next-page.js" as="script" />
<link rel="prefetch" href="/api/next-data" />

<!-- 预连接第三方域名 -->
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://analytics.example.com" />

<!-- 预渲染 -->
<link rel="prerender" href="/next-page.html" />
// 动态预加载
function preloadRouteComponents(route: string) {
  // 根据路由预加载对应的资源
  const preloadMap: Record<string, string[]> = {
    '/about': ['/about.js', '/about.css'],
    '/admin': ['/admin.js', '/admin.css'],
  };
  
  const resources = preloadMap[route] || [];
  
  resources.forEach(href => {
    const link = document.createElement('link');
    link.rel = 'preload';
    link.href = href;
    link.as = href.endsWith('.js') ? 'script' : 'style';
    document.head.appendChild(link);
  });
}

// 鼠标悬停时预加载
document.querySelectorAll('a[data-prefetch]').forEach(link => {
  link.addEventListener('mouseenter', () => {
    const href = link.getAttribute('href');
    if (href) {
      preloadRouteComponents(href);
    }
  });
});

缓存策略

HTTP 缓存配置

# Nginx 缓存配置
server {
  # HTML 文件 - 不缓存或短缓存
  location ~* \.html$ {
    add_header Cache-Control "no-cache, must-revalidate";
    add_header Pragma "no-cache";
    expires 0;
  }
  
  # JS/CSS 文件 - 长期缓存(带 hash)
  location ~* \.(js|css)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    expires 1y;
  }
  
  # 图片/字体 - 长期缓存
  location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    expires 1y;
  }
  
  # API 响应 - 短缓存
  location /api/ {
    add_header Cache-Control "public, max-age=60";
    expires 1m;
  }
}
// Service Worker 缓存策略
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/main.js',
  '/styles.css',
];

// 安装时缓存静态资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => {
      return cache.addAll(STATIC_ASSETS);
    })
  );
});

// 请求拦截
self.addEventListener('fetch', event => {
  const { request } = event;
  
  // HTML 文件 - 网络优先
  if (request.headers.get('Accept')?.includes('text/html')) {
    event.respondWith(
      fetch(request)
        .then(response => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then(cache => {
            cache.put(request, clone);
          });
          return response;
        })
        .catch(() => caches.match(request))
    );
    return;
  }
  
  // 静态资源 - 缓存优先
  if (request.url.match(/\.(js|css|jpg|png|svg|woff2)$/)) {
    event.respondWith(
      caches.match(request).then(cached => {
        return cached || fetch(request).then(response => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then(cache => {
            cache.put(request, clone);
          });
          return response;
        });
      })
    );
    return;
  }
  
  // API 请求 - 网络优先,失败时返回缓存
  event.respondWith(
    fetch(request)
      .then(response => {
        const clone = response.clone();
        caches.open('api-cache').then(cache => {
          cache.put(request, clone);
        });
        return response;
      })
      .catch(() => caches.match(request))
  );
});

Vite 缓存配置

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 文件名带 hash
        entryFileNames: 'assets/[name]-[hash].js',
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash].[ext]',
        
        // 手动分块
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 将大型库单独分块
            if (id.includes('vue') || id.includes('vue-router') || id.includes('pinia')) {
              return 'vendor-vue';
            }
            if (id.includes('lodash') || id.includes('dayjs') || id.includes('axios')) {
              return 'vendor-utils';
            }
            return 'vendor-other';
          }
        },
      },
    },
  },
});

渲染优化

虚拟滚动

// Vue 虚拟滚动实现
import { defineComponent, ref, computed, onMounted, onUnmounted } from 'vue';

export default defineComponent({
  name: 'VirtualList',
  props: {
    items: { type: Array, required: true },
    itemHeight: { type: Number, default: 50 },
    containerHeight: { type: Number, default: 400 },
  },
  setup(props) {
    const scrollTop = ref(0);
    const containerRef = ref<HTMLElement>();
    
    // 可见区域
    const visibleRange = computed(() => {
      const start = Math.floor(scrollTop.value / props.itemHeight);
      const visibleCount = Math.ceil(props.containerHeight / props.itemHeight);
      const end = Math.min(start + visibleCount + 2, props.items.length); // 多渲染 2 个
      
      return { start: Math.max(0, start - 1), end };
    });
    
    // 可见项
    const visibleItems = computed(() => {
      const { start, end } = visibleRange.value;
      return props.items.slice(start, end);
    });
    
    // 总高度
    const totalHeight = computed(() => props.items.length * props.itemHeight);
    
    // 偏移量
    const offsetY = computed(() => visibleRange.value.start * props.itemHeight);
    
    // 滚动处理
    const handleScroll = () => {
      if (containerRef.value) {
        scrollTop.value = containerRef.value.scrollTop;
      }
    };
    
    onMounted(() => {
      containerRef.value?.addEventListener('scroll', handleScroll);
    });
    
    onUnmounted(() => {
      containerRef.value?.removeEventListener('scroll', handleScroll);
    });
    
    return {
      containerRef,
      visibleItems,
      totalHeight,
      offsetY,
      itemHeight: props.itemHeight,
    };
  },
  template: `
    <div
      ref="containerRef"
      class="virtual-list"
      :style="{ height: containerHeight + 'px', overflow: 'auto' }"
    >
      <div :style="{ height: totalHeight + 'px', position: 'relative' }">
        <div :style="{ transform: 'translateY(' + offsetY + 'px)' }">
          <div
            v-for="item in visibleItems"
            :key="item.id"
            :style="{ height: itemHeight + 'px' }"
          >
            <slot :item="item"></slot>
          </div>
        </div>
      </div>
    </div>
  `,
});

避免不必要的重渲染

// Vue 优化
import { defineComponent, shallowRef, triggerRef } from 'vue';

export default defineComponent({
  setup() {
    // 使用 shallowRef 避免深度响应
    const largeData = shallowRef({
      // 大型数据对象
    });
    
    // 手动触发更新
    const updateData = () => {
      largeData.value.someProperty = 'new value';
      triggerRef(largeData);
    };
    
    return { largeData, updateData };
  },
});
// React 优化
import { memo, useMemo, useCallback } from 'react';

// 使用 memo 避免不必要的重渲染
const ExpensiveComponent = memo(function ExpensiveComponent({ data, onClick }) {
  // 组件逻辑
  return <div>{/* ... */}</div>;
});

function ParentComponent() {
  // 使用 useMemo 缓存计算结果
  const processedData = useMemo(() => {
    return heavyComputation(rawData);
  }, [rawData]);
  
  // 使用 useCallback 缓存函数
  const handleClick = useCallback(() => {
    // 处理点击
  }, [dependency]);
  
  return (
    <ExpensiveComponent
      data={processedData}
      onClick={handleClick}
    />
  );
}

减少重排重绘

// 批量修改 DOM
function badExample() {
  const element = document.getElementById('myElement');
  
  // 触发多次重排
  element.style.width = '100px';
  element.style.height = '100px';
  element.style.backgroundColor = 'red';
  element.style.border = '1px solid black';
}

function goodExample() {
  const element = document.getElementById('myElement');
  
  // 使用 class
  element.classList.add('my-style');
  
  // 或使用 cssText
  element.style.cssText = `
    width: 100px;
    height: 100px;
    background-color: red;
    border: 1px solid black;
  `;
}

// 避免强制同步布局
function badLayout() {
  const elements = document.querySelectorAll('.item');
  
  elements.forEach(el => {
    const width = el.offsetWidth; // 触发重排
    el.style.width = width + 10 + 'px'; // 修改样式
  });
}

function goodLayout() {
  const elements = document.querySelectorAll('.item');
  const widths: number[] = [];
  
  // 先读取
  elements.forEach(el => {
    widths.push(el.offsetWidth);
  });
  
  // 再写入
  elements.forEach((el, i) => {
    el.style.width = widths[i] + 10 + 'px';
  });
}

// 使用 requestAnimationFrame
function animate() {
  const element = document.getElementById('animated');
  let position = 0;
  
  function step() {
    position += 1;
    element.style.transform = `translateX(${position}px)`;
    
    if (position < 100) {
      requestAnimationFrame(step);
    }
  }
  
  requestAnimationFrame(step);
}

// 使用 will-change 提示浏览器
const element = document.getElementById('will-change');
element.style.willChange = 'transform, opacity';

// 动画结束后移除
element.addEventListener('transitionend', () => {
  element.style.willChange = 'auto';
});

网络优化

减少请求数量

// 合并请求
async function fetchAllData() {
  // 并行请求
  const [users, posts, comments] = await Promise.all([
    fetch('/api/users').then(r => r.json()),
    fetch('/api/posts').then(r => r.json()),
    fetch('/api/comments').then(r => r.json()),
  ]);
  
  return { users, posts, comments };
}

// 批量请求
async function batchRequests(ids: number[]) {
  // 单次请求获取多个资源
  const response = await fetch(`/api/items?ids=${ids.join(',')}`);
  return response.json();
}

// 请求合并(防抖)
function createBatchFetcher(apiCall: (ids: number[]) => Promise<any>) {
  let pendingIds: number[] = [];
  let timer: NodeJS.Timeout | null = null;
  
  return function(id: number): Promise<any> {
    return new Promise((resolve) => {
      pendingIds.push(id);
      
      if (timer) {
        clearTimeout(timer);
      }
      
      timer = setTimeout(async () => {
        const results = await apiCall(pendingIds);
        pendingIds = [];
        resolve(results);
      }, 100);
    });
  };
}

HTTP/2 优化

// 利用 HTTP/2 的多路复用
// 不再需要合并文件,反而应该拆分模块

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // HTTP/2 下可以拆分更细
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 每个包单独一个 chunk
            const packageName = id.split('node_modules/')[1].split('/')[0];
            return `vendor-${packageName}`;
          }
        },
      },
    },
  },
});

压缩与优化

# Gzip 压缩
gzip -k -9 file.js

# Brotli 压缩(更好的压缩率)
brotli -k -q 11 file.js
# Nginx 启用压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;

# Brotli
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
// Vite 压缩插件
import viteCompression from 'vite-plugin-compression';

export default defineConfig({
  plugins: [
    // Gzip
    viteCompression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 1024,
    }),
    
    // Brotli
    viteCompression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 1024,
    }),
  ],
});

性能监控

Web Vitals 监控

import { onCLS, onFID, onLCP, onINP } from 'web-vitals';

//  Cumulative Layout Shift
onCLS((metric) => {
  console.log('CLS:', metric.value);
  // 发送到分析服务
  sendToAnalytics('CLS', metric.value);
});

// Interaction to Next Paint
onINP((metric) => {
  console.log('INP:', metric.value);
  sendToAnalytics('INP', metric.value);
});

// Largest Contentful Paint
onLCP((metric) => {
  console.log('LCP:', metric.value);
  sendToAnalytics('LCP', metric.value);
});

function sendToAnalytics(name: string, value: number) {
  // 使用 navigator.sendBeacon 确保数据发送成功
  const data = new FormData();
  data.append('name', name);
  data.append('value', value.toString());
  
  navigator.sendBeacon('/analytics', data);
}

自定义性能监控

// 性能测量
class PerformanceMonitor {
  private marks: Map<string, number> = new Map();
  
  mark(name: string) {
    this.marks.set(name, performance.now());
  }
  
  measure(name: string, startMark: string, endMark: string) {
    const start = this.marks.get(startMark);
    const end = this.marks.get(endMark);
    
    if (start !== undefined && end !== undefined) {
      const duration = end - start;
      console.log(`${name}: ${duration.toFixed(2)}ms`);
      
      // 使用 Performance API
      performance.mark(`${name}-start`);
      performance.mark(`${name}-end`);
      performance.measure(name, `${name}-start`, `${name}-end`);
      
      return duration;
    }
    
    return -1;
  }
  
  getEntries() {
    return performance.getEntriesByType('measure');
  }
}

// 使用示例
const monitor = new PerformanceMonitor();

monitor.mark('component-mount-start');
// 组件挂载逻辑
monitor.mark('component-mount-end');
monitor.measure('Component Mount', 'component-mount-start', 'component-mount-end');

// 监控 API 请求
async function fetchWithMonitoring(url: string) {
  monitor.mark('api-start');
  
  try {
    const response = await fetch(url);
    const data = await response.json();
    
    monitor.mark('api-end');
    monitor.measure('API Request', 'api-start', 'api-end');
    
    return data;
  } catch (error) {
    monitor.mark('api-end');
    monitor.measure('API Request (Failed)', 'api-start', 'api-end');
    throw error;
  }
}

Lighthouse 审计

# 命令行运行 Lighthouse
npx lighthouse https://example.com --view

# 生成 JSON 报告
npx lighthouse https://example.com --output=json --output-path=./report.json

# CI 中使用
npx lighthouse https://example.com --output=json --output-path=./lighthouse-report.json
// lighthouserc.json
{
  "ci": {
    "collect": {
      "url": ["http://localhost:3000"],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["error", { "minScore": 0.9 }],
        "categories:best-practices": ["error", { "minScore": 0.9 }],
        "categories:seo": ["error", { "minScore": 0.9 }]
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}

性能优化清单

加载性能

  • [ ] 启用 Gzip/Brotli 压缩
  • [ ] 使用 CDN 加速静态资源
  • [ ] 配置合理的缓存策略
  • [ ] 实现代码分割和懒加载
  • [ ] 优化图片(WebP、响应式、懒加载)
  • [ ] 预加载关键资源
  • [ ] 减少 HTTP 请求数量
  • [ ] 使用 HTTP/2
  • [ ] 最小化第三方脚本
  • [ ] 实现 Service Worker 缓存

渲染性能

  • [ ] 避免不必要的重排重绘
  • [ ] 使用虚拟滚动处理大列表
  • [ ] 避免强制同步布局
  • [ ] 使用 requestAnimationFrame
  • [ ] 使用 CSS transform 代替位置属性
  • [ ] 减少 DOM 节点数量
  • [ ] 使用 will-change 提示浏览器
  • [ ] 避免长任务阻塞主线程

运行时性能

  • [ ] 使用 Web Worker 处理复杂计算
  • [ ] 防抖节流高频事件
  • [ ] 优化算法复杂度
  • [ ] 使用高效的数据结构
  • [ ] 避免内存泄漏
  • [ ] 使用事件委托

监控与分析

  • [ ] 配置 Core Web Vitals 监控
  • [ ] 定期运行 Lighthouse 审计
  • [ ] 使用 Performance API 监控关键路径
  • [ ] 设置性能预算
  • [ ] 建立性能回归检测机制