性能优化胡说指南

性能优化胡说指南

一次意外的跑分,就让我的工作内容围绕了性能优化好多天。总结下一些内容,由于本身不懂优化那块,所以接下来就是胡说八道的部分了!

首先是跑分,跑分用的是pageSpeed为主,其次是webPageTest,以下是一张跑分的结果,也是目前最终的结果。测试分为五项的指标。FCP、LCP、TBT、CLS、SI,每一项各占一定比例的分数,大头是TBT,其次是CLS和LCP。

img.png

讲个笑话,这个跑分的结果可能时高时低,不要过于在意这个分数某一时刻高了还是低了。所以可以点开Latest 28-day period(history)这个看看

如图,有下降的趋势就行了

img_1.png

接下来就步入正题了,优化的归因各有不同,请谨慎参考。

CLS部分

首先是CLS的部分,因为CLS是五个里面最好归因的。而且原因只有一个,就是你写的代码8行。这个问题在移动端会被放的很大…

这部分我就拿身为负面教材的自己来讲吧。如图,清晰可见CLS在十一月初的时候飙的比打CSGO给一枪头还快。

img_2.png

问题的由来是重写了UI,移动端需要一个侧栏替代藏桌面端的Navbar,至于代码的写法如下

const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
    setIsMobile(window.innerWidth < 1024);
}, []);
if (isMobile) {
    return <MobileSidebar />;
}
return <DesktopNavbar />;

最后结果移动端的效果初始是这样的

img_3.png

乍一看代码似乎没啥问题,不过用了SSR,默认是桌面端,然后要等到js加载执行,最后才是真正的移动端布局。解决方法很简单就是两个模版都渲染,然后交给CSS控制显示哪个,这样就不用交给JS加载完再控制了。关于JS的加载部分后面还有很大的篇幅。

还有一种就是例如加一个Banner那种,直接在Layout插过去,也会导致CLS,通常Banner的影响都不大,就跳过了…

尽量纯CSS,环保低碳 好!(错乱

也可以不花太多心思,毕竟分数中它的占比是1/10。对于用户体验来说的话,不像我那种很大的问题感觉都行

LCP、TBT、FCP

这三个有点不好分割,很难想到一种办法只影响其中的一项不影响另外两项。在尝试中发现LCP和TBT的问题可以互相转换。很神奇的置换反应(错乱)

首先简单是LCP问题,字面意思就是最大内容绘制,时间越小越好。但我还是没搞懂LCP,因为我实验实际在0.8s就完成了H1元素(推测出来的LCP元素),但是报告里说我5s才绘制完。在我没优化的时候移动端甚至高到了时10s,后面优化移动端降到了4-5s,感觉极限了,移动端跑分实在不行啊。

在移动端LCP浪费了很多时间,但是最后的结果还是没办法。我的经验就是移动端到4-5s的时候就可以停停了。真羡慕这种神仙网站的分数。

img_5.png

类别不同吧,我感觉这个初始要nextjs+supabase+一堆初始化带动js初始执行就长的网站,这个移动端LCP不特别离谱就不关心好点。可以看看reddit的分数爽爽,人家分数比我还低…,X的更…

img_6.png

综上不要过于在意移动端的LCP问题。

那真有了我就是想解决呢?LCP问题肯定是针对你自己网站的LCP元素进行解决。Chrome DevTools Performance录一下看CP标记是哪个元素。对该元素引用的图cWebp下、加一个preload下。单纯面向LCP的大概就这个。还有因为JS执行阻塞,字体等原因。这部分归因很混乱了,大抵是我傻了,如果有弹窗的设计实现最好在LCP时间后。

综合一点去解决这三个问题,我用的是SSG+按需水合。已经忘记了纯SSR当时的数值了,可以确定的一点是当时只SSR,结果是带动了bundle大小,延长JS的执行时长,最后全部数值都有点难看。

SSG的方式可以极大提升FCP,所以用了SSG就不怕FCP有多离谱了。不过SSG有个问题就是没法处理用户状态,比如登录后显示头像这种。方案是写两个版本,一个纯静态的SSG版本,一个带交互的完整版本。SSR时先渲染静态版本,后续按需把功能加载。

具体实现就是LazyHydrate这个组件,有三种策略:whenIdle(浏览器空闲时)、whenVisible(进入视口时)、onInteraction(用户交互时)。比如首屏的Tab用whenIdle,下面的组件这些用whenVisible,设个rootMargin提前加载。SSR时先渲染组件SSG版,用户滚动到距离800px的时候开始水合,替换成完整的组件。

大致代码代码:

<Suspense fallback={<SectionSkeleton />}>
  <CoreFeatureSection content={coreFeatures} />
</Suspense>

<Suspense fallback={<SectionSkeleton />}>
  <LazyHydrateWhenVisible
    rootMargin="500px"
    ssrOnly={<DisplaySectionSSG content={style} />}
  >
    <DisplaySection content={style} />
  </LazyHydrateWhenVisible>
  <DisplaySectionSchema content={style} />
</Suspense>

<Suspense fallback={<SectionSkeleton />}>
  <MoreTools content={moreFeatures} />
</Suspense>

<Suspense fallback={<SectionSkeleton />}>
  <LazyHydrateWhenVisible
    rootMargin="600px"
    ssrOnly={<TabsFeatureSSG content={application} />}
  >
    <TabsFeature content={application} />
  </LazyHydrateWhenVisible>
</Suspense>
'use client';

import { useEffect, useState, useRef, type ReactNode } from 'react';

interface LazyHydrateProps {
  children: ReactNode;
  /**
   * 当浏览器空闲时hydrate
   */
  whenIdle?: boolean;
  /**
   * 当元素进入视口时hydrate
   */
  whenVisible?: boolean;
  /**
   * 当用户交互时hydrate(鼠标移入、触摸、聚焦等)
   */
  onInteraction?: boolean;
  /**
   * IntersectionObserver的rootMargin,用于提前加载
   * 例如: "200px" 表示元素距离视口200px时就开始加载
   */
  rootMargin?: string;
  /**
   * SSR时的fallback内容(静态HTML)
   */
  ssrOnly?: ReactNode;
}

/**
 * LazyHydrate - 按需hydration组件
 *
 * 使用场景:
 * - whenIdle: 非关键的交互组件(如footer的一些功能)
 * - whenVisible: 首屏下方的组件(如pricing、testimonials)
 * - onInteraction: 用户主动交互才需要的组件(如模态框、下拉菜单)
 */
export function LazyHydrate({
  children,
  whenIdle = false,
  whenVisible = false,
  onInteraction = false,
  rootMargin = '200px',
  ssrOnly,
}: LazyHydrateProps) {
  const [hydrated, setHydrated] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const interactionEvents = ['mouseenter', 'touchstart', 'focusin'];

  useEffect(() => {
    // 如果没有指定任何策略,立即hydrate
    if (!whenIdle && !whenVisible && !onInteraction) {
      setHydrated(true);
      return;
    }

    let cleanup: (() => void) | undefined;

    // 策略1: 浏览器空闲时hydrate
    if (whenIdle) {
      if ('requestIdleCallback' in window) {
        const id = requestIdleCallback(
          () => setHydrated(true),
          { timeout: 2000 } // 最多等待2秒
        );
        cleanup = () => cancelIdleCallback(id);
      } else {
        // 降级方案:使用setTimeout
        const id = setTimeout(() => setHydrated(true), 1);
        cleanup = () => clearTimeout(id);
      }
    }

    // 策略2: 元素可见时hydrate
    if (whenVisible && ref.current) {
      if ('IntersectionObserver' in window) {
        const observer = new IntersectionObserver(
          (entries) => {
            if (entries[0].isIntersecting) {
              setHydrated(true);
              observer.disconnect();
            }
          },
          {
            rootMargin, // 提前加载
            threshold: 0.01, // 只要1%可见就触发
          }
        );

        observer.observe(ref.current);
        cleanup = () => observer.disconnect();
      } else {
        // 降级方案:立即hydrate
        setHydrated(true);
      }
    }

    // 策略3: 用户交互时hydrate
    if (onInteraction && ref.current) {
      const handleInteraction = () => setHydrated(true);

      interactionEvents.forEach((event) => {
        ref.current?.addEventListener(event, handleInteraction, {
          once: true,
          passive: true
        });
      });

      cleanup = () => {
        interactionEvents.forEach((event) => {
          ref.current?.removeEventListener(event, handleInteraction);
        });
      };
    }

    return cleanup;
  }, [whenIdle, whenVisible, onInteraction, rootMargin]);

  // SSR阶段或hydration前:显示静态内容
  if (!hydrated) {
    return (
      <div ref={ref}>
        {ssrOnly || children}
      </div>
    );
  }

  // Hydration后:显示完整的交互组件
  return <div ref={ref}>{children}</div>;
}

/**
 * 创建一个wrapper,专门用于visible策略
 */
export function LazyHydrateWhenVisible({
  children,
  rootMargin = '200px',
  ssrOnly
}: {
  children: ReactNode;
  rootMargin?: string;
  ssrOnly?: ReactNode;
}) {
  return (
    <LazyHydrate whenVisible rootMargin={rootMargin} ssrOnly={ssrOnly}>
      {children}
    </LazyHydrate>
  );
}

/**
 * 创建一个wrapper,专门用于idle策略
 */
export function LazyHydrateWhenIdle({
  children,
  ssrOnly
}: {
  children: ReactNode;
  ssrOnly?: ReactNode;
}) {
  return (
    <LazyHydrate whenIdle ssrOnly={ssrOnly}>
      {children}
    </LazyHydrate>
  );
}

/**
 * 创建一个wrapper,专门用于interaction策略
 */
export function LazyHydrateOnInteraction({
  children,
  ssrOnly
}: {
  children: ReactNode;
  ssrOnly?: ReactNode;
}) {
  return (
    <LazyHydrate onInteraction ssrOnly={ssrOnly}>
      {children}
    </LazyHydrate>
  );
}

如果你的首页包含一个笨重的大组件,例如核心功能那种,就采用单独创建一个给主页轻量级的,同时也用纯静态的作为SSG的组件,后续替换掉。会刷掉用户的一些已经触发的操作,不过理论上这个时间不会太久,可被接受的,毕竟要为了分数的(不是和!)视觉体验…(错乱

TBT的部分要用Next.js的bundle analysis,这个能分析client的最大的依赖库。例如我的用了Framer Motion,但是实际上可以用纯CSS动画。还有图标库最好统一。如果用了import * from这种导入方式,会影响打包工具的tree-shaking,打包的时候直接给整包带走。JS执行时长++ (滥用依赖没统一方案还用import *这种属于左脚踩右脚了,直接可以起飞)

我感觉最好的方案就是SSG+按需水合,爱来自<Suspense />

顺带补一个雷点,首页SSG要加语言参数,如果直接用i18n库,还需要在layout添加props指定locale,不然会出现SSG到SSR的页面,语言带不过去。主页一定要记得加语言参数,不然你其它语言…SEO就完蛋了…

有一些细节点忘记了,想到可能继续补充下…

至于SI的问题,这个归因也难,至少把前四项优化好了它也自然好了…