性能优化胡说指南
一次意外的跑分,就让我的工作内容围绕了性能优化好多天。总结下一些内容,由于本身不懂优化那块,所以接下来就是胡说八道的部分了!
首先是跑分,跑分用的是pageSpeed为主,其次是webPageTest,以下是一张跑分的结果,也是目前最终的结果。测试分为五项的指标。FCP、LCP、TBT、CLS、SI,每一项各占一定比例的分数,大头是TBT,其次是CLS和LCP。

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

接下来就步入正题了,优化的归因各有不同,请谨慎参考。
CLS部分
首先是CLS的部分,因为CLS是五个里面最好归因的。而且原因只有一个,就是你写的代码8行。这个问题在移动端会被放的很大…
这部分我就拿身为负面教材的自己来讲吧。如图,清晰可见CLS在十一月初的时候飙的比打CSGO给一枪头还快。

问题的由来是重写了UI,移动端需要一个侧栏替代藏桌面端的Navbar,至于代码的写法如下
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(window.innerWidth < 1024);
}, []);
if (isMobile) {
return <MobileSidebar />;
}
return <DesktopNavbar />;
最后结果移动端的效果初始是这样的

乍一看代码似乎没啥问题,不过用了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的时候就可以停停了。真羡慕这种神仙网站的分数。

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

综上不要过于在意移动端的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的问题,这个归因也难,至少把前四项优化好了它也自然好了…