import { useEffect, useRef } from 'react';

interface LayoutShift extends PerformanceEntry {
  value: number;
  hadRecentInput: boolean;
}

interface Metric {
  name: string;
  value: number;
  unit: string;
  level: string;
}

export const usePerformance = (enable = import.meta.env.DEV) => {
  const started = useRef(false);

  useEffect(() => {
    if (!enable || started.current) return;

    started.current = true;

    const observers: PerformanceObserver[] = [];
    const metrics = new Map<string, number>();

    const addMetric = (name: string, value: number) => {
      metrics.set(name, Number(value.toFixed(2)));
    };

    // ---------------- Navigation ----------------

    const nav = performance.getEntriesByType(
      'navigation'
    )[0] as PerformanceNavigationTiming;

    if (nav) {
      addMetric('TTFB', nav.responseStart);
      addMetric('DOM Ready', nav.domContentLoadedEventEnd);
      addMetric('Load', nav.loadEventEnd);
    }

    // ---------------- Paint ----------------

    performance
      .getEntriesByType('paint')
      .forEach((entry: PerformanceEntry) => {
        if (entry.name === 'first-paint') {
          addMetric('FP', entry.startTime);
          addMetric('白屏时间', entry.startTime);
        }

        if (entry.name === 'first-contentful-paint') {
          addMetric('FCP', entry.startTime);
        }
      });

    // ---------------- LCP ----------------

    const lcpObserver = new PerformanceObserver((list) => {
      const last = list.getEntries().at(-1);

      if (last) {
        addMetric('LCP', last.startTime);
      }
    });

    lcpObserver.observe({
      type: 'largest-contentful-paint',
      buffered: true,
    });

    observers.push(lcpObserver);

    // ---------------- CLS ----------------

    let cls = 0;

    const clsObserver = new PerformanceObserver((list) => {
      (list.getEntries() as LayoutShift[]).forEach((entry) => {
        if (!entry.hadRecentInput) {
          cls += entry.value;
        }
      });

      metrics.set('CLS', Number(cls.toFixed(3)));
    });

    clsObserver.observe({
      type: 'layout-shift',
      buffered: true,
    });

    observers.push(clsObserver);

    // ---------------- INP ----------------

    if (
      PerformanceObserver.supportedEntryTypes.includes('event')
    ) {
      const inpObserver = new PerformanceObserver((list) => {
        const max = Math.max(
          ...list.getEntries().map((e) => e.duration)
        );

        addMetric('INP', max);
      });

      inpObserver.observe({
        type: 'event',
        buffered: true,
        durationThreshold: 40,
      });

      observers.push(inpObserver);
    }

    // ---------------- 输出 ----------------

    const timer = window.setTimeout(() => {
      const getLevel = (name: string, value: number) => {
        switch (name) {
          case 'FCP':
            return value < 1800 ? '🟢' : value < 3000 ? '🟡' : '🔴';

          case 'LCP':
            return value < 2500 ? '🟢' : value < 4000 ? '🟡' : '🔴';

          case 'CLS':
            return value < 0.1 ? '🟢' : value < 0.25 ? '🟡' : '🔴';

          case 'INP':
            return value < 200 ? '🟢' : value < 500 ? '🟡' : '🔴';

          default:
            return value < 1000 ? '🟢' : value < 3000 ? '🟡' : '🔴';
        }
      };

      const table: Metric[] = [];

      metrics.forEach((value, key) => {
        table.push({
          name: key,
          value,
          unit: key === 'CLS' ? '' : 'ms',
          level: getLevel(key, value),
        });
      });

      console.groupCollapsed(
        '%c🚀 页面性能',
        'background:#1677ff;color:white;padding:4px 10px;border-radius:4px'
      );

      console.table(table);

      console.groupEnd();
    }, 2500);

    return () => {
      window.clearTimeout(timer);

      observers.forEach((o) => o.disconnect());

      started.current = false;
    };
  }, [enable]);
};

更多推荐