最近在用 App Router 版的 Next.js 写 Blog,可是发现一个奇怪的问题,生成的页面总是 λ,也就是每次请求都会 SSR。Blog 内容使用 MDX 编写,也就是扩展版的 Markdown,也没有根据制定请求生成页面的必要。这导致访问的速度不是很理想,我想着就先 Dynamic,然后我做个 Suspense,正好尝试一下这个在 App Router 里的新特性,因为后面如果有针对用户的内容需要加载,或者是评论区这样的动态内容,使用 Suspense 是个不错的解决方案。

Route (app)                              Size     First Load JS
┌ λ /                                    939 B          98.1 kB
├ λ /_not-found                          884 B          85.2 kB
├ λ /about                               145 B          84.4 kB
├ λ /api/trpc/[trpc]                     0 B                0 B
├ λ /link                                145 B          84.4 kB
├ λ /posts                               145 B          84.4 kB
└ λ /posts/[slug]                        32.7 kB         130 kB
+ First Load JS shared by all            84.3 kB
  ├ chunks/250-4f9a1f7897fb6bb3.js       28.9 kB
  ├ chunks/3b4da568-bebe9edd41b6a5b6.js  53.4 kB
  └ other shared chunks (total)          1.95 kB
λ  (Dynamic)  server-rendered on demand using Node.js

Suspense

关于 Suspense 和 Streaming,文档里很清楚,这里 SSR 比较耗时的部分主要是 Parse Blog 的内容,因为我配合 mdx-bundler 自定义了一些块,所以可能会比较耗时。另外 mdx-bundler 在将部分依赖更新适配 MDX 3.0 后,有一些 Type 问题,直接编译会报错,要么忽略 any type 提示,要么手动 as 类型,我也提了一个 PR 来尝试修复这个问题,有时间写一篇 Blog 说一说,顺便看一下 bundler 的源码。

实现 Streaming with Suspense 有个关键性步骤:

将所需要的 Suspense 块抽出来,单独做一个 Server Component,然后使用 <Suspense> 包裹,并为其指定一个 fallback Component。

<Suspense fallback={<PostContentSkeleton />}>
    <PostContent slug={params.slug} />
</Suspense>

这里最关键的是使用 Server Component,如果准备包裹的是一个 Client Component,那么需将其抽出来放在一个新的 Server Component 中。这么做的原因是因为,我们需要在 Server Component 中获取数据,而只有将这个动作与原来在 Page 获取分离开来,才能保证这个 Component 能被 Suspense,其他内容才不会被这个「获取数据」的过程阻塞。而 Next.js 中,只有 Server Component 才能是 async 的,在 Server Component 中获取数据也非常合理,所以我将原来的 Client Component 抽出,并为其套了一层 PostContent。顺便也将目录迁移到了里面,因为目录需要等页面渲染好后,再通过 dom 查询,所以需要等数据获得完后再构建目录。

如果是个动态页面或者是从 CMS fetch 数据的话,这个效果其实还凑合,但作为一个静态 Blog 这个效果其实有点搞笑。

SSG

又去看了一眼 next.js doc,突然发现 Dynamic Routes 忘了写 generateStaticParams 了,这个应该是用来生成 SSG 的,用上了之后跑了一下 build:

Route (app)                                     Size     First Load JS
┌ λ /                                           175 B          91.4 kB
├ λ /_not-found                                 884 B          85.3 kB
├ λ /about                                      146 B          84.5 kB
├ λ /api/trpc/[trpc]                            0 B                0 B
├ λ /link                                       146 B          84.5 kB
├ λ /posts                                      146 B          84.5 kB
└ ● /posts/[slug]                               33.9 kB         131 kB
    ├ /posts/2023-04-27-mdx-syntax-admonitions
    ├ /posts/2023-04-27-mdx-syntax-basic
    ├ /posts/2023-04-27-mdx-syntax-code-block
    └ [+4 more paths]
+ First Load JS shared by all                   84.4 kB
  ├ chunks/5a11ab70-f5b3777b9f14b98f.js         53.4 kB
  ├ chunks/688-1b1ad56bf17d4f61.js              29 kB
  └ other shared chunks (total)                 1.99 kB
●  (SSG)      prerendered as static HTML (uses getStaticProps)
λ  (Dynamic)  server-rendered on demand using Node.js

果然,有限的 Posts 被列出,生成了 SSG,但是明显其他的那些页面不应是动态的,问题出在这个项目是用 t3 构建的,其 App Router 版本有一个 tRPC 的 Provider,当时我为了配合后续的增量,选用了 t3,其 Proivder 在 rootLayout 里,使用了 header,导致其包裹的部分都是 Dynamic,在暂时不用 tPRC 的情况下,去掉后,生成的结果恢复了正常:

Route (app)                                     Size     First Load JS
┌ ○ /                                           175 B          91.3 kB
├ ○ /_not-found                                 884 B          85.2 kB
├ ○ /about                                      146 B          84.5 kB
├ λ /api/trpc/[trpc]                            0 B                0 B
├ ○ /link                                       146 B          84.5 kB
├ ○ /posts                                      146 B          84.5 kB
└ ● /posts/[slug]                               33.8 kB         131 kB
    ├ /posts/2023-04-27-mdx-syntax-admonitions
    ├ /posts/2023-04-27-mdx-syntax-basic
    ├ /posts/2023-04-27-mdx-syntax-code-block
    └ [+2 more paths]
+ First Load JS shared by all                   84.3 kB
  ├ chunks/5a11ab70-f5b3777b9f14b98f.js         53.4 kB
  ├ chunks/688-1b1ad56bf17d4f61.js              29 kB
  └ other shared chunks (total)                 1.9 kB
○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses getStaticProps)
λ  (Dynamic)  server-rendered on demand using Node.js

后续用上 tRPC 时,再寻找解决方案,使用 Provider 的原因估计也是因为 client 和 server component。

但是虽说用上了 generateStaticParams 让 Blog 完全静态化,但我其实是不太想用它,如果想要及时的修改 Post 内容,不使用它是个不错的选择,这时候配合 Suspense 和 Cache,体验其实也可以。