loyep.com avatar loyep
  • techJanuary 8, 2025

    前端性能优化踩坑记录

    最近在项目中遇到一些性能问题,记录下排查过程和解决方案,希望能帮到有类似问题的同学

    前端性能Web Vitals优化监控JavaScript

    前端性能优化踩坑记录

    最近负责的项目用户反馈页面加载慢,老板催得紧,只能硬着头皮开始性能优化。从完全不懂到勉强能用,踩了不少坑,这里记录一下过程和解决方案。

    性能问题排查

    Core Web Vitals 监控

    老实说,一开始完全不知道怎么排查性能问题。网上搜了一圈,发现Google有个Core Web Vitals的东西,据说很重要。简单来说就是三个指标:

    • LCP (Largest Contentful Paint):最大内容绘制,页面主要内容加载完的时间
    • FID (First Input Delay):首次输入延迟,用户点击到页面响应的时间
    • CLS (Cumulative Layout Shift):累积布局偏移,页面元素乱跳的程度

    刚开始看这些英文缩写一头雾水,后来发现浏览器有现成的API可以监控:

    // Web Vitals 监控类
    class WebVitalsMonitor {
      constructor() {
        this.metrics = {};
        this.initObservers();
      }
    
      initObservers() {
        this.observeLCP();
        this.observeFID();
        this.observeCLS();
      }
    
      observeLCP() {
        const observer = new PerformanceObserver((list) => {
          const entries = list.getEntries();
          const lastEntry = entries[entries.length - 1];
    
          this.metrics.lcp = {
            value: lastEntry.startTime,
            rating: this.getRating('lcp', lastEntry.startTime)
          };
    
          console.log('LCP:', lastEntry.startTime);
        });
    
        observer.observe({ entryTypes: ['largest-contentful-paint'] });
      }
    
      observeFID() {
        const observer = new PerformanceObserver((list) => {
          const entries = list.getEntries();
          entries.forEach(entry => {
            this.metrics.fid = {
              value: entry.processingStart - entry.startTime,
              rating: this.getRating('fid', entry.processingStart - entry.startTime)
            };
    
            console.log('FID:', entry.processingStart - entry.startTime);
          });
        });
    
        observer.observe({ entryTypes: ['first-input'] });
      }
    
      observeCLS() {
        let clsValue = 0;
        const observer = new PerformanceObserver((list) => {
          const entries = list.getEntries();
          entries.forEach(entry => {
            if (!entry.hadRecentInput) {
              clsValue += entry.value;
            }
          });
    
          this.metrics.cls = {
            value: clsValue,
            rating: this.getRating('cls', clsValue)
          };
    
          console.log('CLS:', clsValue);
        });
    
        observer.observe({ entryTypes: ['layout-shift'] });
      }
    
      getRating(metric, value) {
        const thresholds = {
          lcp: { good: 2500, needsImprovement: 4000 },
          fid: { good: 100, needsImprovement: 300 },
          cls: { good: 0.1, needsImprovement: 0.25 }
        };
    
        const threshold = thresholds[metric];
        if (value <= threshold.good) return 'good';
        if (value <= threshold.needsImprovement) return 'needs-improvement';
        return 'poor';
      }
    
      getMetrics() {
        return this.metrics;
      }
    }
    
    // 初始化监控
    const monitor = new WebVitalsMonitor();

    跑了一下发现我们的LCP竟然有4秒多,难怪用户说慢。接下来就是找原因了。

    长任务监控

    发现一个很坑的事情,我们的页面有很多"长任务"(超过50ms的JavaScript执行),这些会卡住页面响应。写了个监控来抓这些任务:

    class LongTaskMonitor {
      constructor() {
        this.longTasks = [];
        this.init();
      }
    
      init() {
        if ('PerformanceObserver' in window) {
          const observer = new PerformanceObserver((list) => {
            const entries = list.getEntries();
            entries.forEach(entry => {
              const task = {
                name: entry.name,
                duration: entry.duration,
                startTime: entry.startTime,
                attribution: entry.attribution || []
              };
    
              this.longTasks.push(task);
              this.reportLongTask(task);
            });
          });
    
          observer.observe({ entryTypes: ['longtask'] });
        }
      }
    
      reportLongTask(task) {
        console.warn(`长任务检测: ${task.duration.toFixed(2)}ms`, task);
    
        this.sendToAnalytics({
          type: 'long-task',
          duration: task.duration,
          attribution: task.attribution.map(attr => ({
            name: attr.name,
            containerType: attr.containerType,
            containerSrc: attr.containerSrc
          }))
        });
      }
    
      getLongTasks() {
        return this.longTasks;
      }
    
      sendToAnalytics(data) {
        if (navigator.sendBeacon) {
          navigator.sendBeacon('/api/analytics', JSON.stringify(data));
        }
      }
    }

    资源加载优化

    资源预加载策略

    刚开始不懂,看网上说预加载好,就把所有东西都预加载了,结果更慢了。后来才知道要分轻重缓急:

    class ResourceOptimizer {
      constructor() {
        this.criticalResources = new Set();
        this.preloadQueue = [];
      }
    
      // 预加载关键资源
      preloadCriticalResources() {
        const criticalUrls = [
          '/fonts/main.woff2',
          '/css/critical.css',
          '/js/core.js'
        ];
    
        criticalUrls.forEach(url => {
          const link = document.createElement('link');
          link.rel = 'preload';
          link.href = url;
    
          if (url.endsWith('.woff2')) {
            link.as = 'font';
            link.type = 'font/woff2';
            link.crossOrigin = 'anonymous';
          } else if (url.endsWith('.css')) {
            link.as = 'style';
          } else if (url.endsWith('.js')) {
            link.as = 'script';
          }
    
          document.head.appendChild(link);
          this.criticalResources.add(url);
        });
      }
    
      // 动态加载非关键资源
      async loadNonCriticalResource(url, type = 'script') {
        return new Promise((resolve, reject) => {
          let element;
    
          if (type === 'script') {
            element = document.createElement('script');
            element.src = url;
            element.async = true;
          } else if (type === 'style') {
            element = document.createElement('link');
            element.rel = 'stylesheet';
            element.href = url;
          }
    
          element.onload = () => resolve(element);
          element.onerror = () => reject(new Error(`Failed to load ${url}`));
    
          document.head.appendChild(element);
        });
      }
    
      // 懒加载模块
      async lazyLoadModule(modulePath) {
        try {
          const module = await import(modulePath);
          return module;
        } catch (error) {
          console.error(`Failed to lazy load module: ${modulePath}`, error);
          throw error;
        }
      }
    }
    
    // 使用示例
    const optimizer = new ResourceOptimizer();
    optimizer.preloadCriticalResources();
    
    // 懒加载示例
    document.getElementById('load-chart')?.addEventListener('click', async () => {
      try {
        const { Chart } = await optimizer.lazyLoadModule('./chart.js');
        new Chart(document.getElementById('chart-container'));
      } catch (error) {
        console.error('图表加载失败:', error);
      }
    });

    图片优化实践

    我们网站图片特别多,而且都很大。一开始想着用懒加载就行了,结果发现还有很多坑要踩:

    class ImageOptimizer {
      constructor() {
        this.observer = null;
        this.imageQueue = new Map();
        this.init();
      }
    
      init() {
        // 使用 Intersection Observer 实现懒加载
        this.observer = new IntersectionObserver(
          this.handleIntersection.bind(this),
          {
            rootMargin: '50px 0px',
            threshold: 0.01
          }
        );
    
        this.observeImages();
      }
    
      observeImages() {
        const images = document.querySelectorAll('img[data-src]');
        images.forEach(img => this.observer.observe(img));
      }
    
      handleIntersection(entries) {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            this.loadImage(entry.target);
            this.observer.unobserve(entry.target);
          }
        });
      }
    
      async loadImage(img) {
        const src = img.dataset.src;
        if (!src) return;
    
        try {
          // 预加载图片
          const image = new Image();
          image.src = this.getOptimizedImageUrl(src, img);
    
          await new Promise((resolve, reject) => {
            image.onload = resolve;
            image.onerror = reject;
          });
    
          // 平滑过渡
          img.style.transition = 'opacity 0.3s';
          img.style.opacity = '0';
    
          img.src = image.src;
          img.removeAttribute('data-src');
    
          img.onload = () => {
            img.style.opacity = '1';
          };
    
        } catch (error) {
          console.error('图片加载失败:', src, error);
          // 显示占位图
          img.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZGRkIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPkltYWdlIEVycm9yPC90ZXh0Pjwvc3ZnPg==';
        }
      }
    
      getOptimizedImageUrl(src, img) {
        const devicePixelRatio = window.devicePixelRatio || 1;
        const containerWidth = img.clientWidth || img.offsetWidth;
        const optimalWidth = Math.ceil(containerWidth * devicePixelRatio);
    
        // 如果使用 CDN,可以动态调整尺寸
        if (src.includes('example-cdn.com')) {
          return `${src}?w=${optimalWidth}&f=webp&q=80`;
        }
    
        return src;
      }
    
      // 预加载关键图片
      preloadCriticalImages(urls) {
        urls.forEach(url => {
          const link = document.createElement('link');
          link.rel = 'preload';
          link.as = 'image';
          link.href = url;
          document.head.appendChild(link);
        });
      }
    }
    
    // 使用示例
    const imageOptimizer = new ImageOptimizer();
    
    // 预加载首屏关键图片
    imageOptimizer.preloadCriticalImages([
      '/images/hero-banner.webp',
      '/images/logo.svg'
    ]);

    JavaScript 性能优化

    防抖节流优化

    我们页面有个搜索框,用户一输入就去搜索,结果每打一个字就发一次请求,服务器都要被干崩了。后来学会了防抖节流:

    class PerformanceUtils {
      // 防抖:等用户停止输入再执行
      static debounce(func, wait, immediate = false) {
        let timeout;
        return function executedFunction(...args) {
          const later = () => {
            timeout = null;
            if (!immediate) func.apply(this, args);
          };
    
          const callNow = immediate && !timeout;
          clearTimeout(timeout);
          timeout = setTimeout(later, wait);
    
          if (callNow) func.apply(this, args);
        };
      }
    
      // 节流:固定频率执行
      static throttle(func, limit) {
        let inThrottle;
        return function executedFunction(...args) {
          if (!inThrottle) {
            func.apply(this, args);
            inThrottle = true;
            setTimeout(() => inThrottle = false, limit);
          }
        };
      }
    
      // 空闲时执行
      static idle(func, options = {}) {
        if ('requestIdleCallback' in window) {
          return requestIdleCallback(func, options);
        } else {
          return setTimeout(func, 1);
        }
      }
    
      // 动画帧优化
      static rafThrottle(func) {
        let isRunning = false;
        return function(...args) {
          if (isRunning) return;
          isRunning = true;
          requestAnimationFrame(() => {
            func.apply(this, args);
            isRunning = false;
          });
        };
      }
    }
    
    // 使用示例
    const searchInput = document.getElementById('search');
    
    // 搜索防抖
    const debouncedSearch = PerformanceUtils.debounce((query) => {
      console.log('搜索:', query);
      // 执行搜索逻辑
    }, 300);
    
    searchInput?.addEventListener('input', (e) => {
      debouncedSearch(e.target.value);
    });
    
    // 滚动节流
    const throttledScroll = PerformanceUtils.throttle(() => {
      console.log('滚动位置:', window.scrollY);
      // 执行滚动处理逻辑
    }, 100);
    
    window.addEventListener('scroll', throttledScroll);
    
    // RAF 优化的滚动动画
    const rafScrollHandler = PerformanceUtils.rafThrottle(() => {
      const scrollProgress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
      const progressBar = document.getElementById('progress-bar');
      if (progressBar) {
        progressBar.style.width = `${scrollProgress * 100}%`;
      }
    });
    
    window.addEventListener('scroll', rafScrollHandler);

    内存管理优化

    class MemoryManager {
      constructor() {
        this.observers = new Set();
        this.timers = new Set();
        this.eventListeners = new Map();
      }
    
      // 安全的事件监听器添加
      addEventListener(element, event, handler, options) {
        const key = `${element.constructor.name}-${event}`;
    
        if (!this.eventListeners.has(key)) {
          this.eventListeners.set(key, new Set());
        }
    
        this.eventListeners.get(key).add({ element, handler, options });
        element.addEventListener(event, handler, options);
      }
    
      // 清理事件监听器
      removeEventListener(element, event, handler) {
        const key = `${element.constructor.name}-${event}`;
        const listeners = this.eventListeners.get(key);
    
        if (listeners) {
          listeners.forEach(listener => {
            if (listener.element === element && listener.handler === handler) {
              element.removeEventListener(event, handler);
              listeners.delete(listener);
            }
          });
        }
      }
    
      // 安全的定时器管理
      setTimeout(callback, delay) {
        const timerId = setTimeout(() => {
          callback();
          this.timers.delete(timerId);
        }, delay);
    
        this.timers.add(timerId);
        return timerId;
      }
    
      setInterval(callback, interval) {
        const intervalId = setInterval(callback, interval);
        this.timers.add(intervalId);
        return intervalId;
      }
    
      clearTimer(timerId) {
        clearTimeout(timerId);
        clearInterval(timerId);
        this.timers.delete(timerId);
      }
    
      // Observer 管理
      addObserver(observer) {
        this.observers.add(observer);
        return observer;
      }
    
      // 内存使用监控
      monitorMemoryUsage() {
        if ('memory' in performance) {
          const memory = performance.memory;
          console.log({
            used: `${(memory.usedJSHeapSize / 1048576).toFixed(2)} MB`,
            total: `${(memory.totalJSHeapSize / 1048576).toFixed(2)} MB`,
            limit: `${(memory.jsHeapSizeLimit / 1048576).toFixed(2)} MB`
          });
        }
      }
    
      // 清理所有资源
      cleanup() {
        // 清理事件监听器
        this.eventListeners.forEach((listeners, key) => {
          listeners.forEach(({ element, handler }) => {
            element.removeEventListener(key.split('-')[1], handler);
          });
        });
        this.eventListeners.clear();
    
        // 清理定时器
        this.timers.forEach(timerId => {
          this.clearTimer(timerId);
        });
        this.timers.clear();
    
        // 清理 Observers
        this.observers.forEach(observer => {
          if (observer.disconnect) {
            observer.disconnect();
          }
        });
        this.observers.clear();
      }
    }
    
    // 使用示例
    const memoryManager = new MemoryManager();
    
    // 页面卸载时清理资源
    window.addEventListener('beforeunload', () => {
      memoryManager.cleanup();
    });
    
    // 定期监控内存使用
    setInterval(() => {
      memoryManager.monitorMemoryUsage();
    }, 30000);

    CSS 性能优化

    关键 CSS 内联

    /* 关键 CSS - 内联在 HTML 中 */
    .critical-above-fold {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      line-height: 1.6;
      color: #333;
    }
    
    .hero-section {
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    
    /* 使用 CSS 变量减少重复 */
    :root {
      --primary-color: #007bff;
      --secondary-color: #6c757d;
      --border-radius: 0.375rem;
      --box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
    }
    
    /* 优化选择器性能 - 避免过深嵌套 */
    .card {
      border-radius: var(--border-radius);
      box-shadow: var(--box-shadow);
    }
    
    /* 使用 transform 和 opacity 做动画 */
    .fade-in {
      opacity: 0;
      transform: translateY(20px);
      transition: opacity 0.3s ease, transform 0.3s ease;
    }
    
    .fade-in.visible {
      opacity: 1;
      transform: translateY(0);
    }
    
    /* 避免重排重绘的属性 */
    .optimized-animation {
      will-change: transform, opacity;
    }
    
    .optimized-animation:hover {
      transform: translateY(-2px) scale(1.02);
    }

    异步 CSS 加载

    // 异步加载非关键 CSS
    function loadCSS(href, before, media) {
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.href = href;
      link.media = media || 'all';
    
      if (before) {
        before.parentNode.insertBefore(link, before);
      } else {
        document.head.appendChild(link);
      }
    
      return link;
    }
    
    // 预加载 CSS
    function preloadCSS(href) {
      const link = document.createElement('link');
      link.rel = 'preload';
      link.as = 'style';
      link.href = href;
      link.onload = function() {
        this.onload = null;
        this.rel = 'stylesheet';
      };
    
      document.head.appendChild(link);
    }
    
    // 使用示例
    // 异步加载非关键样式
    loadCSS('/css/components.css');
    loadCSS('/css/utilities.css');
    
    // 预加载下一页可能需要的样式
    preloadCSS('/css/product.css');

    性能监控与测量

    性能报告生成

    class PerformanceReporter {
      constructor() {
        this.metrics = {};
        this.startTime = performance.now();
      }
    
      // 收集所有性能指标
      async collectMetrics() {
        // Navigation Timing
        const navigation = performance.getEntriesByType('navigation')[0];
        this.metrics.navigation = {
          domContentLoaded: navigation.domContentLoadedEventEnd - navigation.navigationStart,
          loadComplete: navigation.loadEventEnd - navigation.navigationStart,
          domInteractive: navigation.domInteractive - navigation.navigationStart,
          firstByte: navigation.responseStart - navigation.navigationStart
        };
    
        // Resource Timing
        const resources = performance.getEntriesByType('resource');
        this.metrics.resources = {
          count: resources.length,
          totalSize: resources.reduce((size, resource) => {
            return size + (resource.transferSize || 0);
          }, 0),
          slowestResource: resources.reduce((slowest, resource) => {
            return resource.duration > (slowest?.duration || 0) ? resource : slowest;
          }, null)
        };
    
        // Web Vitals
        this.metrics.webVitals = await this.getWebVitals();
    
        return this.metrics;
      }
    
      async getWebVitals() {
        return new Promise((resolve) => {
          const vitals = {};
          let collected = 0;
          const total = 3;
    
          const checkComplete = () => {
            collected++;
            if (collected >= total) {
              resolve(vitals);
            }
          };
    
          // LCP
          new PerformanceObserver((list) => {
            const entries = list.getEntries();
            vitals.lcp = entries[entries.length - 1]?.startTime;
            checkComplete();
          }).observe({ entryTypes: ['largest-contentful-paint'] });
    
          // FID
          new PerformanceObserver((list) => {
            const entries = list.getEntries();
            vitals.fid = entries[0]?.processingStart - entries[0]?.startTime;
            checkComplete();
          }).observe({ entryTypes: ['first-input'] });
    
          // CLS
          let clsValue = 0;
          new PerformanceObserver((list) => {
            list.getEntries().forEach(entry => {
              if (!entry.hadRecentInput) {
                clsValue += entry.value;
              }
            });
            vitals.cls = clsValue;
            checkComplete();
          }).observe({ entryTypes: ['layout-shift'] });
    
          // 超时处理
          setTimeout(() => resolve(vitals), 5000);
        });
      }
    
      // 生成报告
      generateReport() {
        const report = {
          timestamp: new Date().toISOString(),
          url: window.location.href,
          userAgent: navigator.userAgent,
          metrics: this.metrics,
          recommendations: this.generateRecommendations()
        };
    
        console.table(this.metrics.navigation);
        console.log('性能报告:', report);
    
        return report;
      }
    
      generateRecommendations() {
        const recommendations = [];
        const nav = this.metrics.navigation;
    
        if (nav?.firstByte > 500) {
          recommendations.push('服务器响应时间过长,考虑使用 CDN 或优化后端性能');
        }
    
        if (nav?.domContentLoaded > 3000) {
          recommendations.push('DOM 解析时间过长,考虑减少阻塞资源或使用代码分割');
        }
    
        if (this.metrics.resources?.totalSize > 2 * 1024 * 1024) {
          recommendations.push('资源总大小过大,考虑压缩图片和启用 gzip');
        }
    
        return recommendations;
      }
    
      // 发送报告到分析服务
      sendReport(endpoint = '/api/performance') {
        const report = this.generateReport();
    
        if (navigator.sendBeacon) {
          navigator.sendBeacon(endpoint, JSON.stringify(report));
        } else {
          fetch(endpoint, {
            method: 'POST',
            body: JSON.stringify(report),
            headers: {
              'Content-Type': 'application/json'
            }
          }).catch(console.error);
        }
      }
    }
    
    // 使用示例
    const reporter = new PerformanceReporter();
    
    // 页面加载完成后收集指标
    window.addEventListener('load', async () => {
      await reporter.collectMetrics();
      reporter.sendReport();
    });
    
    // 页面卸载时发送最终报告
    window.addEventListener('beforeunload', () => {
      reporter.sendReport();
    });

    总结与经验

    这次性能优化折腾了差不多一个月,从 LCP 4秒多优化到1.5秒,效果还是很明显的。

    主要经验

    1. 先测量后优化 - 不要凭感觉,数据说话
    2. 抓大放小 - 先解决影响最大的问题
    3. 循序渐进 - 一次改一个地方,出问题好排查
    4. 持续监控 - 上线后要持续关注性能数据

    踩坑总结

    • 预加载策略:不是越多越好,关键资源优先
    • 图片优化:收益很大,但坑也多,需要综合考虑懒加载、格式、尺寸等
    • JavaScript 性能:问题往往是最隐蔽的,长任务监控很重要
    • CSS 动画:尽量用 transformopacity,避免重排重绘

    最后的话

    希望这些踩坑经验能帮到遇到类似问题的同学。性能优化确实是个技术活,但掌握了套路后还是很有成就感的。

    记住:性能优化是一个持续的过程,不是一蹴而就的。要根据实际业务场景和用户需求,选择最合适的优化策略。

    Last updated on