Qwik 和 React Hydration 的区别:为什么它强调可恢复性

现代前端框架经常讨论一个问题:页面已经在服务端渲染成 HTML 了,为什么浏览器还要下载一大包 JavaScript,再把页面“接管”一遍?

这就是 Hydration 相关讨论的核心。

在 2024 年,这个话题变得更现实了。React Server Components、Astro 的 Islands Architecture、Qwik 的 Resumability 都在尝试回答同一个问题:首屏到底应该做多少客户端工作?用户点开页面的那一刻,真正需要的是可读内容、可点击按钮,还是完整应用运行时?

Qwik 提出的 Resumability,也就是“可恢复性”,就是为了解决这类启动成本。它不只是一个新名词,更像是一种重新分配工作的方式:服务端已经做过的事情,客户端尽量不要重做。

React Hydration 做了什么

React 服务端渲染会先在服务端生成 HTML。浏览器拿到 HTML 后,页面可以很快显示出来,这对 SEO 和首屏体验都有帮助。

但 HTML 本身没有 React 组件状态,也不知道哪个按钮对应哪个事件处理函数。如果要让页面具备交互能力,React 还需要下载对应的 JavaScript,在客户端重新建立组件树、比对服务端生成的 DOM,并绑定事件。这个过程就是 Hydration。

我刚开始做 SSR 的时候,对 Hydration 一直有个误解,以为它会“复用”服务端那棵已经渲染好的 DOM,省下一次渲染。后来在 React 18 之前用 ReactDOM.hydrate 的时候踩了坑才搞明白:Hydration 不会省掉组件的一次执行,它真正省的只是 DOM 节点的创建。也就是说,组件函数还是要从头跑一遍,useStateuseMemo 这些都会重新算一次,只是 React 在跑完之后发现“DOM 已经在了,那我就不重新 createElement、insertBefore,只把事件挂上去”。所以那种“服务端渲染过了客户端就不用算了”的期待,从根上就是错的。这也是为什么后面 Qwik 的思路会让我觉得耳目一新——它针对的恰恰是这次“白跑一遍”的执行成本。

一个简化的例子是这样的:

1export default function LikeButton({ initialCount }) {
2  const [count, setCount] = useState(initialCount)
3
4  return (
5    <button onClick={() => setCount(count + 1)}>
6      Like {count}
7    </button>
8  )
9}

服务端可以把它渲染成:

1<button>Like 12</button>

但是浏览器只看到一个普通按钮。只有当 React 在客户端恢复组件逻辑后,onClick 才会真正生效。

这个模型的好处是统一:同一套组件代码既能在服务端生成 HTML,也能在客户端继续运行。开发者心智负担比较低,React 生态也围绕这个模型积累了大量工具。

往底层再挖一层会更清楚 Hydration 到底慢在哪。React 在客户端走的是 hydrateRoot,它内部会用一套叫 hydrate fiber 的逻辑:从根节点开始构建 fiber 树,每遇到一个真实 DOM 节点,就尝试和服务端那棵 DOM 做“认领”(claim)——把已有的 DOM node 关联到对应 fiber 上,而不是新建。这个认领过程是深度优先、按文档顺序逐个 DOM 节点比对的,所以它对 DOM 顺序非常敏感,服务端多吐一个空白文本节点、少一个 <!-- --> 注释标记,都可能让认领错位。React 18 之前这个过程是不可中断的同步任务,一旦 fiber 树深、组件多,主线程就被一口气占满,这也是 ReactDOM.hydrate 时代“点击没反应”的根因。React 18 把它放进了并发调度,认领过程可以让出主线程(yield),但单个组件函数的执行本身仍然是同步原子的——它能切的是组件与组件之间的缝隙,切不开一个组件内部的渲染。

我专门做过一个对照实验来验证“组件会白跑一遍”:在一个 SSR 组件的函数体顶部塞一句 if (typeof window !== 'undefined') console.count('client render'),刷新页面,控制台第一次 hydration 就实打实地打出了计数。这条 log 是颠覆我直觉的关键证据——服务端明明已经渲染过的组件,客户端为了重建 fiber 树和读出 hooks 的初始值,必须把整个函数从头执行一遍,useMemo 的工厂函数也会重新跑(hydration 阶段没有上一次的依赖可比,等同首次计算)。所以那些“在 render 里做重计算、反正 memo 了”的写法,在 SSR 首屏其实一点便宜都占不到。

而且 React 18 之后引入的 hydrateRoot 配合 Selective Hydration 已经缓解了一部分痛点。它能在用户点击某个还没 hydrate 的区域时优先 hydrate 那一块,配合 Suspense 还能让 hydration 按组件区块分片进行,不再是过去那种一锤子从根节点同步跑到底、中途无法被打断的模式。这一点确实进步很大,我在一个列表页上把每个卡片包进 Suspense 之后,TTI 抖动明显小了。但要注意,分片归分片,每个分片该执行的组件逻辑一行都不会少,它优化的是“什么时候执行、能不能被打断”,而不是“要不要执行”。

问题就在这里:成本的总量没变。页面越大,需要下载、解析和执行的 JavaScript 越多。即使用户只想读一段文章,客户端也可能为了几个暂时不会点击的交互区域提前做很多准备。对于旗舰手机和高速网络,这种成本不一定明显;但在低端 Android、弱网环境、嵌入式 WebView 中,它会直接体现在卡顿、输入延迟和电量消耗上。我们之前一个落地页接入第三方投放,流量大头是几年前的千元机,本地 MacBook 上跑 Lighthouse 分数很好看,真机一测 TBT(Total Blocking Time) 直接破 1 秒,用户在按钮上点了没反应又点一次,埋点里全是重复提交。

Hydration 的代价不只是一包 JS

很多人第一次优化 Hydration 时,会把注意力放在 bundle size 上。包体当然重要,但它只是其中一部分。

真实成本通常包括:

  • 下载 JavaScript
  • 解析和编译 JavaScript
  • 执行模块初始化逻辑
  • 重新创建组件树
  • 绑定事件和恢复状态
  • 处理 Hydration mismatch

其中最容易被忽略的是执行成本。一个页面可能只多了 80KB gzip 后的 JavaScript,但解压、解析、执行后的主线程占用并不小。用户感受到的不是“下载了多少 KB”,而是页面能不能尽快响应滚动、点击和输入。

有个经验值我一直记着:在中低端手机上,每 1MB 解压后的 JavaScript 大概要花几百毫秒去 parse 加 compile,这还没算执行。所以我后来 review 性能问题时,会习惯把 Chrome DevTools 的 Performance 面板录下来,看 Bottom-Up 里 “Evaluate Script” 和 “Compile Script” 占了多少,而不是只盯 Network 面板的传输体积。有一次发现一个 moment.js 带着全量 locale 进了首屏 bundle,传输才几十 KB(gzip 压得狠),可解压后两百多 KB,光解析就吃掉一大段主线程时间,换成 day.js 之后那段火焰图肉眼可见地塌下去了。下载体积和执行体积经常不是一回事,gzip 压得越狠,这个落差越大。

Hydration mismatch 也是实际开发里很常见的问题。比如组件里直接读取当前时间、随机数、浏览器宽度,服务端和客户端渲染结果就可能不一致:

1export default function TimeLabel() {
2  return <span>{new Date().toLocaleTimeString()}</span>
3}

服务端渲染时是一个时间,客户端 Hydration 时又变成另一个时间。轻则控制台警告,重则页面局部重新渲染。解决这类问题通常需要把不稳定逻辑放到 useEffect,或者确保服务端和客户端使用同一份初始数据。

1export default function TimeLabel({ serverTime }) {
2  const [time, setTime] = useState(serverTime)
3
4  useEffect(() => {
5    setTime(new Date().toLocaleTimeString())
6  }, [])
7
8  return <span>{time}</span>
9}

这不是 React 的错误,而是 Hydration 模型天然要求服务端和客户端对初始 UI 有一致理解。

值得多说一句的是 React 18 之后 mismatch 的“惩罚”变重了。在 React 17,遇到文本不一致它会就地用客户端值悄悄打个补丁,多数情况下你只看到一条 warning,DOM 不会大动。React 18 的并发渲染对一致性要求更严:一旦发现 hydration 不匹配,它会放弃整棵从那个节点往下的服务端 DOM,回退到客户端从零重新渲染(client render fallback)。也就是说同一个 mismatch,在 18 里可能让一整块 Suspense 包裹的内容闪一下重画。我把上面那个时区 bug 在 17 和 18 两个版本各复现过一次,17 是控制台一条黄字,18 是用户肉眼可见的内容抖动加一条 Hydration failed because the server rendered HTML didn't match the client 的红色报错,严重程度完全不是一个量级。

排查这类问题我有一套固定动作,分享一下,比盯着 warning 文案猜要高效得多:

1// 在客户端入口临时打开,定位到底是哪个节点 mismatch
2// React 会在报错对象里给出 server / client 两侧的渲染值
3window.addEventListener('error', (e) => {
4  if (String(e.message).includes('Hydration')) {
5    // 配合 React DevTools 高亮,但最有用的是下面这招:
6    // 用 onRecoverableError 拿到具体的 digest 和组件栈
7  }
8})
9
10// Next.js / 自建 SSR 都支持给 hydrateRoot 传 onRecoverableError
11import { hydrateRoot } from 'react-dom/client'
12hydrateRoot(document.getElementById('root'), <App />, {
13  onRecoverableError(error, errorInfo) {
14    console.error('hydration recoverable:', error.message)
15    console.error('component stack:', errorInfo.componentStack)
16  },
17})

onRecoverableError 里的 componentStack 才是定位 mismatch 的关键——它直接告诉你是哪个组件、哪一层节点不一致,不用再靠肉眼对 DOM。我们线上把这个回调接进了 Sentry,按 componentStack 聚合,第二天就发现绝大多数 mismatch 集中在两个组件:一个读了 Date,一个读了 Math.random() 做 key。数据一摆出来,根因一目了然。

我踩过最隐蔽的一次 mismatch 跟时间无关,是本地化。服务端 Node 进程的时区是 UTC,客户端是用户本地时区,一个 toLocaleDateString 在服务端渲染出 “3/20”,到客户端变成 “3/21”,文案就一个字符的差别,控制台报警告,整块组件被 React 丢弃重渲。更坑的是这种 bug 在开发机上根本复现不出来,因为本地服务端和浏览器是同一个时区。后来我们定了条规矩:所有跟时区、随机、windowlocalStorage 相关的逻辑,要么在服务端就把结果算好通过 props 传下来,要么老老实实放进 useEffect,绝不在 render 阶段直接读。

React 18 还提供了 suppressHydrationWarning 这个出口,但我基本不用它来“压 bug”——它只是闭嘴,不是修复,底层那次重渲染照样发生。我只在一种场景用它:明确知道某个节点(比如展示客户端本地时间)服务端客户端就是会不一致,且这是预期行为。

Qwik 的思路:不要急着恢复整个应用

Qwik 更强调按需恢复交互。它不希望页面一加载就把所有交互逻辑执行一遍,而是尽量把交互代码拆得更细,并在真正需要时再加载。

比如一个页面底部有反馈按钮,用户没有滚动到那里时,就没必要提前下载和执行对应逻辑。再比如一个复杂表单在第二屏,首屏只展示文章摘要和目录,那表单校验逻辑也不应该成为首屏成本。

Qwik 的目标可以概括成一句话:让首屏少做事。

它会把事件处理函数、组件划分、状态信息序列化到 HTML 中,让浏览器知道“如果用户触发这个交互,应该去哪里加载哪段代码”。也就是说,客户端不是从零重建整个应用,而是从服务端留下的线索继续执行。

具体到代码上,Qwik 里写交互长这样:

1import { component$, useSignal } from '@builder.io/qwik'
2
3export const LikeButton = component$(({ initialCount }: { initialCount: number }) => {
4  const count = useSignal(initialCount)
5
6  return (
7    <button onClick$={() => (count.value = count.value + 1)}>
8      Like {count.value}
9    </button>
10  )
11})

注意那个 onClick$ 的美元符号,这不是语法糖那么简单。Qwik 的编译器(Optimizer)会在构建期把 $ 标记的函数切出来,单独打成一个可以按需懒加载的 chunk,并在服务端渲染时把指向这个 chunk 的引用写进 HTML 属性,类似 <button on:click="./chunk-abc.js#LikeButton_onClick">。浏览器初始化时这段处理函数的代码根本不会下载,直到用户真的点了那个按钮,Qwikloader(一段几百字节、内联在 HTML 里的全局事件监听脚本)才去把对应 chunk 抓回来执行。

我第一次看到 count.value = ... 这种写法时也别扭——为什么不是 React 那种 setState。这是因为 Qwik 用的是细粒度的 signal,改一个 .value 只会更新订阅了它的那一个 DOM 文本节点,不存在“组件重新执行、子树 diff”这回事,连 VDOM 都省了。代价是它的响应式心智模型跟 React 不一样,团队从 hooks 切过来需要适应一段时间。

可以把传统 Hydration 想象成打开一本书后重新抄一遍目录和章节索引;Qwik 更像是服务端已经贴好了书签,客户端只在翻到某一页时读取对应内容。

我后来扒过一次 Qwik 生成的 HTML 源码,才真正理解 Resumability 不是营销词。一个最朴素的计数器按钮,View Source 出来大概长这样(删了无关属性):

1<button q:id="3" on:click="./app_component_button_onclick_8kgji0p7yg4.js#s_8kGji0P7Yg4[0]">
2  Like
3  <!--t=4-->12<!--/t=4-->
4</button>
5
6<script type="qwik/json">
7  {"refs":{"3":"0 1"},"objs":["12",{"value":"1"}],"subs":[["3 #4"]]}
8</script>
9
10<script id="qwikloader" ...>/* 约 1KB,全局只此一份 */</script>

几个细节值得拆开看。on:click 的值不是函数体,是一个“去哪里取代码”的地址:模块路径 + 导出符号 + 闭包索引 [0]<!--t=4-->...<!--/t=4--> 这对注释标出了 signal 绑定的文本范围——Qwik 靠它精确知道 count 这个值渲染到了哪段文本里,将来改 .value 时只动这两个注释之间的内容,连父节点都不碰。最关键的是 qwik/json 那段:objs 存了被序列化的状态值,subs 记录了订阅关系(id 为 3 的节点订阅了 #4 文本)。客户端不需要执行任何组件函数就能从这段 JSON “认领”出整个响应式图谱——这就是它敢说“零 hydration 执行”的底气,状态不是重算出来的,是反序列化出来的。

Qwikloader 的机制也比想象中朴素得多。它不给每个按钮单独绑 listener,而是在 document 上挂一个全局的捕获阶段监听器,利用事件冒泡:用户点击时,它顺着事件路径找带 on:click 属性的元素,读出那个地址,动态 import() 对应 chunk 再调用。这套“一个全局监听器 + 事件委托 + 按需 import”的组合,就是为什么首屏一行业务 JS 都不用下载也能保证交互不丢——监听能力本身只花了那 1KB。它甚至能处理“代码还没下载完用户就点了”的竞态:点击事件会被暂存,chunk 到位后补发。第一次知道这个细节时我挺佩服的,这才是把“延迟”做到了用户无感。

可恢复性为什么重要

可恢复性关注的是:服务端已经做过的工作,客户端能不能接着用,而不是重做一遍。

如果浏览器只需要知道“这个按钮需要交互时加载哪段代码”,而不是立即重建完整应用状态,启动成本就会下降。这对内容型页面、营销页、文档站、活动页、长页面、低端设备和弱网环境都很有意义。

尤其是内容型站点,用户进入页面后的第一件事往往是阅读,不是点击。把所有交互能力都提前恢复,很多时候是一种过度准备。我做博客页面性能优化时就遇到过类似情况:Markdown 渲染后的 HTML 本身并不拖慢首屏,拖慢它的是侧边栏、统计、评论、代码高亮、搜索框这些“看起来很小”的交互碎片叠在一起。每个组件都觉得自己只占一点成本,合起来就把主线程吃满了。

Resumability 的价值就在这里:它逼着框架默认站在首屏用户的角度思考,而不是默认站在完整应用运行时的角度思考。

不过说句公道话,Resumability 也不是没有代价。为了让客户端能“接着跑”,服务端得把足够多的状态序列化进 HTML——组件之间的引用关系、闭包里捕获的变量、signal 的订阅关系,都要落到那段 <script type="qwik/json"> 里。状态越复杂、跨组件引用越多,这段序列化数据就越大,HTML 体积会涨。它本质上是把“客户端的执行成本”换成了“传输和反序列化成本”,对内容型页面这笔账很划算,因为这类页面状态本来就少;但如果是状态高度耦合的复杂应用,这个权衡就没那么一边倒了。天下没有免费的午餐,Qwik 只是把账单挪到了更合适的位置。

第三方脚本会和 hydration 抢主线程

Hydration 的成本不光取决于自己的 bundle,还取决于它排在谁后面执行。JavaScript 是单线程的,<head> 里同步引入的第三方脚本、React 的 hydration 任务,本质上是在抢同一条主线程的执行顺位,谁先到谁先跑,后面的只能排队。

一个常见的场景是:投放方要求在 <head> 同步引入一个统计 SDK,这类 SDK 初始化时往往会同步跑几百毫秒的逻辑(拉配置、埋点上报队列初始化、指纹采集),而它执行的时机恰好排在 React 的 hydration 之前。主线程被它先占住,hydration 就被迫排队。用户看到的是最糟糕的窗口期:首屏内容(FCP)已经出来了,可交互(TTI)却被推得老远,页面渲染出来了但点哪都没反应,中间这段空窗用户往往会反复点按钮。

排查这类问题,Performance 面板的火焰图形态很典型:一长条黄色的 SDK “Evaluate Script”,紧接着才是 React 的 hydration 任务。这时候问题不在自己的 bundle 大小上,而在执行顺序上——本地开发环境往往复现不出来,因为本地没有接那个第三方脚本,只有真机连正式环境测才会暴露。

想清楚“同步脚本会抢 hydration 的主线程”这个机制之后,解法就很明确:把第三方脚本改成 async 加延迟初始化,用 Next.js 的 Script 组件显式控制加载时机:

1import Script from 'next/script'
2
3export default function Layout({ children }) {
4  return (
5    <>
6      {children}
7      {/* afterInteractive:等首屏可交互后再加载,不和 hydration 抢主线程 */}
8      <Script
9        src="https://3rd-party.example.com/sdk.js"
10        strategy="afterInteractive"
11      />
12      {/* 对完全不影响首屏的统计,直接用 lazyOnload,浏览器空闲时才跑 */}
13      <Script src="https://analytics.example.com/a.js" strategy="lazyOnload" />
14    </>
15  )
16}

strategy 这几个值的差别值得记牢:beforeInteractive 会阻塞 hydration(只有极少数必须最先执行的脚本才用,比如 polyfill);afterInteractive 在 hydration 之后注入,是大多数统计/营销 SDK 的正确选择;lazyOnload 等到 load 事件后浏览器空闲才执行。把统计 SDK 从隐式的 beforeInteractive(手写在 head 里的同步 script 等价于此)挪到 afterInteractive,是这类问题里性价比最高的一步改动,真机 TBT 通常能从秒级压到几百毫秒级。

性能排查时养成一个习惯很有用:先看主线程长任务列表里有没有不属于自己 bundle 的条目,第三方脚本的执行顺序经常比自己代码的体积更致命,而且这类问题在 Lighthouse 的实验室数据里未必暴露得出来,往往要接了真实的第三方 SDK、在真机上才会显形。这也正好反衬出 Qwik 的优势——它首屏几乎不占主线程,就算第三方脚本不懂事,也没有一个沉重的 hydration 任务在后面排队等着被拖累。

React 也能优化,但需要更多手工设计

React 当然也能优化,而且 2024 年的 React 生态已经有很多成熟手段:

  • 使用动态导入做代码分割
  • React.lazySuspense 延迟加载非首屏组件
  • 在 Next.js App Router 中使用 Server Components
  • 把客户端交互收敛到小的 Client Component
  • 对大型组件做虚拟列表和按需渲染
  • 使用 streaming SSR 改善首屏等待时间

例如在 Next.js App Router 中,可以让文章正文保持服务端组件,只把点赞按钮做成客户端组件:

1// app/posts/[slug]/page.tsx
2import LikeButton from './LikeButton'
3
4export default async function PostPage({ params }) {
5  const post = await getPost(params.slug)
6
7  return (
8    <article>
9      <h1>{post.title}</h1>
10      <div dangerouslySetInnerHTML={{ __html: post.html }} />
11      <LikeButton postId={post.id} initialCount={post.likeCount} />
12    </article>
13  )
14}
1// app/posts/[slug]/LikeButton.tsx
2'use client'
3
4import { useState } from 'react'
5
6export default function LikeButton({ postId, initialCount }) {
7  const [count, setCount] = useState(initialCount)
8
9  async function like() {
10    setCount((value) => value + 1)
11    await fetch(`/api/posts/${postId}/like`, { method: 'POST' })
12  }
13
14  return <button onClick={like}>Like {count}</button>
15}

这种写法的关键不是“用了服务端组件”这几个字,而是想清楚哪部分该留在服务端、哪部分才该进客户端:文章正文不需要客户端状态,就不要让它进入客户端 bundle;点赞按钮需要交互,就只让这个小区域承担客户端成本。

这里有个特别容易踩的坑,我自己也中过招:'use client' 的范围是会“传染”的。一旦某个组件标了 'use client',它 import 进来的所有子组件都会被一起拉进客户端 bundle,哪怕那些子组件本身根本不需要交互。我们当时有个 Layout 因为顶部一个主题切换开关标了 'use client',结果把整个 Layout 包括里面纯展示的导航、页脚全带进了客户端,bundle 莫名其妙大了一截。正确做法是把客户端组件做成叶子节点,纯展示部分通过 children 这种 props 从服务端组件传进去——服务端组件可以作为 children 渲染进客户端组件,但不会因此变成客户端组件:

1// ThemeToggle 是 'use client',但它接收的 children 仍然是服务端渲染的
2<ThemeToggle>
3  <Nav />     {/* 纯展示,保持服务端组件 */}
4  <Footer />  {/* 同上 */}
5</ThemeToggle>

记住这个“children 不传染”的规律,能省掉很多无意义的客户端代码。

但这些优化通常需要开发者主动划分哪部分归客户端、哪部分归服务端,而且分错了工具也不会报错,只会悄悄让 bundle 变胖。Qwik 则试图把“什么时候加载什么 JavaScript”变成框架默认策略,开发者不主动设计,它也默认是细粒度懒加载。这是两种框架在“默认值”上的根本差异:React 默认把交互能力打包好交给你,要省得自己动手;Qwik 默认什么都不交,要用再去取。

选型时不要只看概念

Qwik 的理念很吸引人,但选型不能只看概念。

如果团队已经深度使用 React、Next.js、组件库和内部工程体系,迁移到 Qwik 的成本并不低。生态成熟度、招聘难度、调试经验、第三方组件适配,都是实际问题。

如果项目是内容站、活动页、文档站,且对弱网首屏非常敏感,Qwik 或 Astro 这类更强调少 JavaScript 的框架值得评估。反过来,如果是后台系统、复杂编辑器、实时协作工具,页面一开始就需要大量交互,传统 SPA 或 React SSR 加精细拆分可能更现实。

还有个常被忽略的现实问题:第三方组件和 SDK。Qwik 不能直接跑 React 组件(虽然有 qwik-react 这种互操作层,但那块一旦用上去,对应区域又退化回 Hydration 模式,等于把你想躲的成本又请回来了)。很多业务离不开的地图、富文本编辑器、图表库、统计 SDK,目前基本都是为 React/Vue 生态写的。我评估的时候算过一笔账,光是适配这些第三方依赖的工作量,就足以让一个有交付压力的项目打退堂鼓。

我更倾向于把 Qwik 当成一面镜子。即使最后不使用它,它也会提醒我们重新检查页面:哪些代码真的必须在首屏执行?哪些状态真的必须在客户端恢复?哪些组件只是因为写起来方便,才被放进了客户端 bundle?

后来我把这套思路反过来用到现有的 Next.js 项目上,靠几个现成工具就能落地,不需要换框架:用 @next/bundle-analyzer 看每个路由的客户端 chunk 到底装了什么,经常能揪出几个不该进首屏的大依赖;用 React DevTools 的 Profiler 录一次首次加载,看哪些组件在 commit 阶段花了异常长的时间;再配合真机或 DevTools 的 CPU 4x/6x 降速 + Slow 4G 限速去跑,别只信本地的好数字。把这几样常态化之后,很多“要不要上 Qwik”的纠结,其实变成了“这个组件该不该是客户端组件”的具体决策,反而更可操作。

具体到工程落地,我把 bundle 分析直接接进了 CI,避免“某次 PR 不小心给首屏塞了个大依赖”这种回归悄悄溜进去。配置很简单:

1// next.config.js
2const withBundleAnalyzer = require('@next/bundle-analyzer')({
3  enabled: process.env.ANALYZE === 'true',
4})
5
6module.exports = withBundleAnalyzer({
7  experimental: {
8    // 让没真正用到的大依赖不被整包打进来,按需 import 子路径
9    optimizePackageImports: ['lodash-es', 'date-fns', '@mui/icons-material'],
10  },
11})
1# 本地随手看
2ANALYZE=true next build
3
4# CI 里加一道防线:First Load JS 超阈值就 fail
5next build | node scripts/check-bundle-budget.js
1// scripts/check-bundle-budget.js —— 解析 build 输出,给首屏 JS 设预算
2const BUDGET_KB = 130 // First Load JS 上限,超了就拦
3let input = ''
4process.stdin.on('data', (d) => (input += d))
5process.stdin.on('end', () => {
6  process.stdout.write(input) // 透传日志
7  // Next 的 build 输出里每行路由都带 First Load JS
8  const over = input
9    .split('\n')
10    .map((l) => l.match(/^\S*\s.*?(\d+(?:\.\d+)?)\s*kB.*First Load/))
11    .filter(Boolean)
12    .map((m) => parseFloat(m[1]))
13    .filter((kb) => kb > BUDGET_KB)
14  if (over.length) {
15    console.error(`\n首屏 JS 超预算:${over.join(', ')} kB > ${BUDGET_KB} kB`)
16    process.exit(1)
17  }
18})

optimizePackageImports 这个配置很多人不知道,它能把 import { debounce } from 'lodash-es' 这种 barrel import 自动改写成只引子模块,单这一项就帮我们砍掉过一个二十多 KB 的连带依赖。预算卡死在 CI 之后,团队对“往首屏加东西”这件事自然就慎重了,比口头约定管用得多。

React Hydration 和 Qwik Resumability 的区别,在于客户端启动成本的处理方式不同。

React 更偏向恢复完整应用交互模型,优点是生态成熟、模型统一、适合复杂应用;Qwik 更强调按需加载和按需恢复,优点是首屏轻、默认避免无意义的客户端执行。

理解这点之后,再看前端性能优化就不会只盯着 Lighthouse 分数。更该盯住的是那台千元机上跑出来的 TBT 数字,而不是本地机器上那份好看的实验室报告——CI 里那道 130KB 的预算线,就是为了不让下一次 PR 悄悄把这个数字推回去。