React Server Component 初体验与实践 —— 将博客迁移到 Next.js App Router

React Server Component 初体验与实践 —— 将博客迁移到 Next.js App Router

笔记本约 7.4 千字

去年的这个时候,我把博客从 Hexo 迁移到 Next.js。现在,随着 Next.js 13 App Router 已经稳定,我又把博客从 Next.js Pages Directory 迁移到了 Next.js 13 的 App Router 和 React Server Component。

内容管理:从 Hexo 获取数据

我曾经使用 Hexo 驱动博客达三年之久,Hexo 的核心是一个 JSON based NoSQL 关系型数据库 warehouse,在数据库中维护文章、标签、分类。除了通过 CLI 调用、直接生成静态 HTML,Hexo 还暴露了一系列 JS API 用于获取文章数据。因此从 Hexo 迁移到 Next.js 以后,我在博客的架构中仍然保留了 Hexo 作为 CMS。

为了调用 Hexo 中的文章数据,首先需要初始化 Hexo 实例、让 Hexo 加载插件、从文件系统读取所有的文章、构建标签和分类的数据结构,最后渲染文章:

import { cache } from 'react';
import Hexo from 'hexo';

let __SECRET_HEXO_INSTANCE__: Hexo | null = null;

export const initHexo = cache(async (): Promise<Hexo> => {
  if (__SECRET_HEXO_INSTANCE__) {
    return __SECRET_HEXO_INSTANCE__;
  }
  const hexo = new Hexo(process.cwd(), {
    silent: true,
    // 在本地开发环境中,启用 Hexo 的草稿模式(加载并渲染标记为草稿的文章)
    drafts: process.env.NODE_ENV !== 'production'
  });
  // Hexo 的 warehouse 的 JSON 写入文件系统的路径
  const dbPath = join(hexo.base_dir, 'db.json');
  // 在本地开发环境中,删除 Hexo 的 warehouse 的 JSON 文件,确保本地开发环境使用最新的数据。
  if (process.env.NODE_ENV !== 'production') {
    if (await fileExists(dbPath)) {
      await fs.promises.unlink(dbPath);
    }
  }

  await hexo.init();
  await hexo.load();
  // 在生产环境中,将 Hexo 的 warehouse 中的数据写入文件系统
  if (hexo.env.init && hexo._dbLoaded) {
    if (process.env.NODE_ENV === 'production') {
      if (!(await fileExists(dbPath))) {
        await hexo.database.save();
      }
    }
  }
  __SECRET_HEXO_INSTANCE__ = hexo;
  return hexo;
});

React 18.3 新增了 React.cache() API。React.cache() 只接受一个类型为函数的参数,返回一个相同签名的函数;在调用时,如果参数不变的话,React.cache() 会返回第一次的结果。以上述 initHexo() 为例,initHexo() 会返回一个签名是 Promise<Hexo> 的 Promise 实例;由于 initHexo() 不接受参数,因此在调用 initHexo 时,React.cache() 会返回 同一个 Promise 实例。

加载数据:在 React Server Component 中直接使用 Hexo API 和传递 Hexo 内部数据类型

在使用 Next.js Pages Router 时,所有 React 组件都是 React Client Component,SSR / SSG 需要通过 getStaticProps/getServerSideProps 获取数据、序列化成 JSON、然后才能在浏览器中加载、以组件 prop 的形式 populate;而 Hexo 的 API 返回的数据类型大多是 warehouse 的 DocumentQuery 实例,warehouse 为了优化性能,DocumentQuery 实例上的属性大多都是 getter、只有在实际使用时才会 evaluate;因此,在使用 Next.js Pages Router 时,在服务端调用 Hexo 的 API 加载数据时,我需要额外写很多序列化步骤:

// src/hexo/index.ts
const getHexoPostBySlug = async (slug: string) => {
  const hexo = await initHexo();
  const urlFor = url_for.bind(hexo);

  const post = hexo.database.model('Post').findOne({ path: `post/${slug}/` });

  // 需要将
  return {
    title: post.title, // post.title 是一个 getter、需要显式调用得到 string
    date: post.date.toISOString(), // post.date 是一个 Moment.js 的实例、不能被直接序列化、需要 toISOString()
    updated: post.updated.toISOString(), // post.updated 是一个 Moment.js 的实例、不能被直接序列化、需要 toISOString()
    content: serializePost(post.content),
    permalink: post.permalink, // post.permalink 也是一个 getter、需要显式调用得到 string
    // post.prev 和 post.next 都是 nullable 的 Hexo warehouse 的 Document 实例的引用、需要手动显式序列化需要用到的字段:title 和 url
    prev: post.prev ? {
      title: post.prev.title ?? '',
      url: post.prev.path
    } : null,
    next: post.next ? {
      title: post.next.title ?? '',
      url: post.next.path
    } : null,
  }
};

// src/pages/post/[slug].tsx
import { getHexoPostBySlug } from 'src/hexo';

export const getStaticProps = (params: { slug: string }) => {
  return getHexoPostBySlug(params.slug);
}

在 Next.js App Router 中,所有 Layout 和 Page 默认都是 React Server Component。我可以直接在 React Server Component 中调用 Hexo 和 Node.js 的 API、不需要额外的序列化:

// src/app/post/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function PostPage({ params }: { params: { slug: string } }) {
  const hexo = await initHexo();
  // 直接在 React 组件中调用 Hexo 的 API
  const post = hexo.database.model('Post').findOne({ path: { eq: `post/${slug}/` } });
  if (!post) {
    // 找不到 post 时返回 404 not found
    return notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      {toJsx(post.content)}
    </article>
  );
}

在 React Server Component 中,除了可以直接调用 Hexo 的 API,还可以直接把 Hexo API 返回的 warehouse 的 DocumentQuery 实例用 prop 传来传去:

// src/app/tags/[tag]/page.tsx
export default async function TagPostsList({ params }: { params: { tag: string } }) {
  const hexo = await initHexo();
  // 直接在 React 组件里调用 Hexo 的 API
  // 根据标签的 name 在数据库中寻找对应的 Tag 对象
  const tag = hexo.database.model('Tag').findOne({ name: decodeURIComponent(params.tag) }, { lean: true }));
  // 在 Post - Tag 交叉索引数据库中寻找所有包含当前 Tag 的 _id 的文章
  const postIds = hexo.database.model('PostTag').find({ tag_id: tag._id }).map(item => item.post_id);
  // 利用 $in query 寻找 postIds 中的所有文章、并按照日期排序
  const posts = hexo.database.model('Post').find({ _id: { $in: postIds } }).sort('-date');

  return (
    <div>
      {posts.map(post => {
        // 直接把 Query 实例迭代后得到的 Document 实例(post)直接作为 prop 传给另一个 React Server Component
        return <PostEntry post={post} />
      })}
    </div>
  );
}

页面布局:使用 Next.js App Router 实现 Nested Layout

我的博客布局由 Header、Footer、两侧的 Sidebar 和中间的内容组成;Header 和 Footer 是所有页面共享的;两侧的 Sidebar 随着当前所在页面不同而显示不同的内容(文章页面额外显示 ToC、相关文章的 Widget)。

refactor-my-blog-using-nextjs-app-router/blog-layout-screenshot.png

在 Next.js Page Router 中,需要服务端加载的所有数据被声明在每个页面的 getServerSidePropsgetStaticProps 或者 getInitialProps 中、再从 React Tree 顶部自上而下 populate;所有页面只共享同一个 _app.tsx、因此所有的 Layout 都需要声明在 _app.tsx 中:

         ┌──────────┐    pageProps
         │ _app.tsx │◄────────────────────┐
         └───┬──┬───┘                     │
             │  │                         │
      data   │  │                         │
    ┌────────┘  └────────┐                │
    │                    │                │
    ▼                    ▼                │
┌────────┐      ┌──────────────────┐      │
│ Layout │      │ pages/[slug].tsx │ getStaticProps
└────────┘      └──────────────────┘
// src/_app.tsx
import type { AppProps } from 'next/app';
import Layout from './components/layout';

export default function MyApp({ Component, pageProps }: AppProps) {
  const { layoutDataA, layoutDataB, pageType, ...restProps } = pageProps;
  return (
    <Layout layoutDataA={layoutDataA} layoutDataB={layoutDataB} type={pageType}>
      <Component {...restProps} />
    </Layout>
  );
}

// src/components/layout.tsx
export default function Layout({ children, pageType, layoutDataA, layoutDataB }: React.PropsWithChildren<LayoutProps>) {
  return (
    <>
      <Navbar />
        <main>
          {children}
        </main>
        {
          pageType === 'post'
            ? <SidebarLeftForPost data={layoutDataA} />
            : <SidebarLeft data={layoutDataA} />
        }
        {
          pageType === 'post'
            ? <SidebarRightForPost data={layoutDataB} />
            : <SidebarRight data={layoutDataB} />
        }
      <Footer />
    </>
  );
}

// src/pages/post/[slug].tsx
export const async getStaticProps = () => {
  // do things
}

关于侧边栏声明在 <main /> 之后的原因,可以参见我另一篇文章「优化博客的累计布局偏移(CLS)问题

Next.js App Router 则提供了第一方的 Nested LayoutRoute Groups 支持,我将所有页面公有的部分(<Header /><Footer />)封装成一个顶层的 Root Layout,然后将所有不是文章的页面全部归并到 (non-post) 的 Route Group 之中、共享一个 Layout,文章页面单独使用一个 Layout:

┌────────────────┐              ┌─► <Header />
│ app/layout.tsx ├──────────────┤
└─┬──────────────┘              └─► <Footer />
  │
  │  ┌────────────────────────┐ ┌─► <SidebarLeft />
  ├─►│ (non-post)/layout.tsx  ├─┤
  │  └────────────────────────┘ └─► <SidebarRight />
  │
  │  ┌────────────────────────┐ ┌─► <SidebarLeftForPost />
  └─►│ post/[slug]/layout.tsx ├─┤
     └────────────────────────┘ └─► <SidebarRightForPost />
// src/app/layout.tsx
export default function RootLayout({ children }: React.PropsWithChildren) {
  return (
    <html>
      <body>
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  );
}

// src/app/(non-post)/layout.tsx
export default function NonPostLayout({ children }: React.PropsWithChildren) {
  return (
    <>
      <main>
        {children}
      </main>
      <SidebarLeft />
      <SidebarRight />
    </>
  );
}

// src/app/post/layout.tsx
export default function PostLayout({ children, params }: React.PropsWithChildren<{ params: { slug: string } }>) {
  return (
    <>
      <main>
        {children}
      </main>
      <SidebarLeftForPost slug={slug} />
      <SidebarRightForPost slug={slug} />
    </>
  );
}

而且,由于所有的 Layout、SidebarLeftForPost 和 SidebarRightForPost 全部都是 React Server Component,我可以直接在 React 组件中加载我需要的数据:

// src/post/components/sidebar-left.tsx
export default function SidebarLeftForPost({ slug }: SidebarLeftForPostProps) {
  return (
    <aside>
      <ToCWidget slug={slug} />
      <CatrgoryWidget />
      <TagsWidget />
    </aside>
  );
}

// src/post/components/toc-widget
export default async function ToCWidget({ slug }: ToCWidgetProps) {
  const hexo = await initHexo();
  const post = hexo.database.model('Post').findOne({ path: { eq: `post/${slug}/` } });
  const toc = getToCFromPost(post.content);

  return (
    {/** ToC */}
  )
}

为了避免渲染文章页面时、获取文章内容和获取 ToC 需要索引两次 Hexo 数据库,我封装了 getHexoPostBySlug 函数并包裹在前文提到的 React.cache API 中:

export const getHexoPostBySlug = cache(async (slug: string) => {
  const hexo = await initHexo();
  return hexo.database.model('Post').findOne({ path: { eq: `post/${slug}/` } });
});

// src/post/components/toc-widget
export default async function ToCWidget({ slug }: ToCWidgetProps) {
  const post = await getHexoPostBySlug(slug);
  const toc = getToCFromPost(post.content);

  return (
    {/** ToC */}
  );
}

// src/app/post/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getHexoPostBySlug(slug);
  if (!post) {
    return notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      {toJsx(post.content)}
    </article>
  );
}

性能优化:使用 React Server Component 削减 Client JS Bundle 体积

在 React 18 之前,所有的 React 组件都是 Client Component。浏览器需要下载 Client Component 的 JS Bundle、然后在浏览器中执行、渲染出 DOM 节点;即使使用了 Server-Side Rendering、使得浏览器在 Client JS Bundle 全部下载完成之前就可以渲染界面,React 仍然需要等待页面所需的 JS 全部下载完成后才能开始 Hydration、使得整个应用可以被交互。

React Server Component(以下简称 RSC)的执行(包括组件中的数据加载)和渲染只发生在服务端,所以在 RSC 中可以直接使用依赖 Node.js API 的 Hexo。和 Client Component 相同,RSC 的返回值也是 React.ReactNode(或者是 Promise<React.ReactNode>);但是和 Client Component 不同的是,浏览器从服务端获取到的只有 RSC 的执行结果(返回的 React.ReactNode)、不包括 RSC 的具体实现代码(JS Bundle)。

RSC 的核心是 RSC Payload(又被称为 React Flight Directive 或 React Server Component Protocol),你可以在 React 的 GitHub 仓库中找到 React 用来解析 React Server Component Payload 的源码。服务端上的 React 执行 RSC,React Server DOM 将 RSC 的返回值序列化成 RSC Payload 发送给浏览器。

假设有一个 RSC 返回了如下所示的 JSX:

<div>
  There is a fox wagging its tail.
  <ClientComponent />
</div>

React 在服务端执行 RSC 后会得到这样的 React.ReactNode

{
  $$typeof: Symbol.for('react.element'),
  type: 'div',
  props: {
    children: [
      'There is a fox wagging its tail.',
      // client compoennt
      {
        $$typeof: Symbol(react.module.reference),
        type: {
          name: 'ClientComponent',
          filename: 'path/to/ClientComponent.js'
        },
      },
    ]
  },
}

然后 React Server DOM(webpack binding)在服务端会将上述 React.ReactNode 转换成如下的 Payload:

// Sever Component Ouptut
["$","div",null,{"children":["There is a fox wagging its tail.", ["$","$L1",null,{}]]}]
// Client Component Reference
1:I{"id":"path/to/ClientComponent.js","chunks":["chunk-[hash].js"],"name":"ClientComponent","async":false}

这些 Payload 会以 Stream 的方式、逐行 从服务端发送到浏览器。浏览器只需要下载 Server-Side Rendering 出来的 HTML 和 RSC Payload、不需要下载和执行 JS;由于 RSC Payload 是逐行发送的、浏览器可以逐行解析 RSC Payload 并渐进式渲染和 hydrate 整个页面。

由于 RSC 不在浏览器中执行,因此 RSC 不能直接响应用户交互(不能绑定类似 onClick 这样的事件监听器)、不能包含自己的状态(不能使用 useStateuseReducer)。但是 RSC 可以 import Client Component。在上述例子中,RSC Payload 中 $L1 就是 <ClientComponent /> 的引用,React 在接收到 RSC Payload 之后会下载 Client Component 的 JS Bundle(chunk-[hash].js)、然后在浏览器中执行 Client Component、将结果渲染在 $L1 的位置上。

┌────────────────────────────────────┐
│                                    │
│       React Server Component       │
│                                    │
│    ┌──────────────────────────┐    │
│  ┌─┤ import 'ClientComponent' ├─┐  │
│  │ └──────────────────────────┘ │  │
│  │                              │  │
│  │    React Client Component    │  │
│  │                              │  │
│  └──────────────────────────────┘  │
│                                    │
└────────────────────────────────────┘

虽然 RSC 中可以 import Client Compoennt,但是反之并不亦然。Client Component 中不能 import RSC,不仅仅是因为 RSC 可能调用了服务端的 API(Node.js、访问数据库)、而且 RSC 完全不会被打包到 Client JS Bundle 中。但是这并不是说 Client Component 中完全不能渲染 RSC。因为 RSC 的返回值 React.ReactNode 是可以被序列化的,因此可以将 RSC 的返回值以通过 Prop 传入 Client Component:

┌─────────────────────────────────────────────────────────┐
│                                                         │
│                 React Server Component                  │
│                                                         │
│ const jsx: React.ReactNode = <AnotherServerComponent /> │
│        │                                                │
│        │                                                │
│        │                 ┌──────────────────────────┐   │
│        │               ┌─┤ import 'ClientComponent' ├─┐ │
│        │               │ └──────────────────────────┘ │ │
│        ▼               │                              │ │
│       ┌────────────────┴──┐                           │ │
│       │       prop        │  React Client Component   │ │
│       │ { children: jsx } │                           │ │
│       └────────────────┬──┘                           │ │
│                        │                              │ │
│                        └──────────────────────────────┘ │
│                                                         │
└─────────────────────────────────────────────────────────┘
// src/components/theme-toggle.tsx
'use client';
// 在 Client Component 中使用 Client Only Hook 如 useContext
import { useContext } from 'react';
import { ThemeContext } '@/contexts/theme';

interface ThemeToggleProps {
  sunIcon: React.ReactNode,
  moonIcon: React.ReactNode,
  children: React.ReactNode
}

export default function ThemeToggle({ sunIcon, moonIcon, children }: ThemeToggleProps) {
  const theme = useContext(ThemeContext);
  const toggleTheme = useContext(ThemeDispatcherContext);

  return (
    <button onClick={toggleTheme}>
      {theme === 'light' ? moonIcon : sunIcon }
      {children}
    </button>
  );
}

// src/layout/components/header.tsx
// 在 RSC 中 import 一个使用了 Client Only Hook 的 Client Component
import ThemeToggle from '@/components/theme-toggle';
// 在 RSC 中 import 另外一个 RSC。FancyParagraph、SunIcon 和 MoonIcon 都不包含用户交互逻辑,所以它们都不必是 Client Component
import FancyParagraph from '@/components/fancy-paragraph';
import SunIcon from '@/components/sun-icon';
import MoonIcon from '@/components/moon-icon';

export default function Header() {
  return (
    <header>
      <ThemeToggle sunIcon={<SunIcon />} moonIcon={<MoonIcon />}>
        <FancyParagraph>Toggle Theme</FancyParagraph>
      </ThemeToggle>
    </header>
  );
}

在上面这个例子中,虽然 ThemeToggle 使用了 Client Only 的 React Hook useContext 并接受用户交互(onClick),所以是一个 Client Component。虽然 ThemeToggle 不能直接 import RSC、但是 RSC 的渲染结果(<SunIcon /><MoonIcon /><FancyParagraph>Toggle Theme</FancyParagraph>)可以以 prop 的形式传给 ThemeToggle。对于 ThemeToggle 来说,props.sunIconprops.moonIconprops.children 不过是可以直接渲染的 React.ReactNode

在 Next.js App Router 中可以利用 React.ReactNode 可以被序列化为 RSC Payload、可以通过 prop 传递给 Client Component 的性质,使用 Context Provider:

// src/contexts/theme.tsx
// createContextState 是一个 Client Only Function(`foxact/context-state` 之中声明了 `client-only`)
'use client';
// createContextState 是一个 useState + createContext + useContext 的封装,更多细节可以参考 https://foxact.skk.moe/context-state
import { createContextState } from 'foxact/context-state';

const [ThemeProvider, useTheme, useSetTheme] = createContextState<'light' | 'dark' | 'auto'>('auto');

export { ThemeProvider, useTheme, useSetTheme };

// src/app/layout.tsx
import { ThemeProvider } from '@/contexts/theme';

export default function RootLayout({ children }: React.PropsWithChildren) {
  return (
    <html>
      <body>
        <ThemeProvider>
          <Header />
            {children}
          <Footer />
        </ThemeProvider>
      </body>
    </html>
  )
}

关于如何组合 RSC 和 React Client Component 的更多细节,可以参考 Next.js 文档中的 Composing Client and Server Components 章节

通过上述方法,我将绝大部分组件都实现为了 RSC。剩下的少数需要接受用户交互的 Client Component 也被尽可能原子化、其中不涉及到用户交互的部分也被拆分为 RSC、通过 prop 传递进 Client Component,从而大幅削减了 Client JS Bundle 的体积。

RSC Payload 还帮助我解决了另一个问题。过去,我需要将 Hexo 渲染得到的 HTML 序列化成 JSON-based AST(基于 PostHTML)塞进 getStaticProps、在 React Hydration 的时候再在浏览器中将 PostHTML AST 转换为 React.ReactNode 渲染;而有了 RSC,我可以直接将整篇文章变成 RSC,直接在服务端完成 HTML to React 的转换、直接将 RSC Payload ship 到浏览器中,大幅提升了 React Hydration 的性能。

饭后甜点:迁移、重构和拥抱 React 18.3

迁移 style9 到 Next.js App Router

style9 是一个受到 Meta 的 stylex 启发的 AoT Atomic CSS-in-JS 实现。我在「使用 Next.js + Hexo 重构我的博客」一文中详细介绍了我是怎么使用 style9 优化 CSS 的。简单来说,style9 通过编译器、在构建时收集声明的样式、并将动态的函数调用变成静态的 className,因此最终 Client JS Bundle 中只需要携带不到 100 byte 的 Runtime;而由于 Atomic CSS 自带去重和 CSS 体积对数增长的优势,我将需要递送的 CSS 从 50 KiB 减少到 17.5 KiB。

Next.js 在编译产物时,会启动至少三个 webpack compiler 实例,分别编译 Client-Side 代码、输出的 Bundle 会被 ship 到浏览器,编译 Edge Runtime 和 Node.js 的 Server-Side 代码、只在服务端(或边缘)执行。但是,style9 官方提供的 webpack 插件并不能在 Next.js App Router 中使用:

  • style9 官方的 webpack 插件依赖于 webpack-virtual-modules,而 webpack-virtual-modules 并不支持在多个 webpack compiler 实例之间共享。在 Next.js Pages Router 中,所有组件都是 React Client Component、所有的样式都只存在于 Client-Side 代码中,因此只需要在 Client-Side 的 webpack compiler 实例中编译样式;而在 Next.js App Router 中,RSC 中也可以 import CSS、为 RSC 添加样式,因此同时需要在 Server-Side 和 Client-Side 的 webpack compiler 实例中编译样式。
  • style9 官方的 webpack 插件依赖 webpack loader 动态为 React 组件添加 CSS import,并在 CSS import 语句中使用 inline webpack loader 以便进行更多处理;而 Next.js 在收集 Server-Side 的 CSS(即 serverCSS)import 时,只会保留 CSS import 的 resourcePath 和 resourceQuery、而不会保留 inline 在 import 语句中的 webpack loader 和 webpack loader option

为了克服上述限制,我实现了一个 style9 的第三方 webpack 插件 style9-webpack

  • 不依赖 webpack-virtual-modules,因此同一个 Style9Plugin 实例可以在多个 webpack compiler 实例之间共享
  • CSS import 中的额外信息存在 resourceQuery 里、而不使用 inline webpack loader,确保 Next.js App Router 可以正确收集 Server-Side 所有的 CSS,使 style9 可以同时在 React Client Component 和 RSC 中使用

style9-webpack 的受益者并不只有我的博客;通过开源 style9-webpack,我还帮助了许多正在使用 style9 和 Next.js、希望迁移到 App Router 的站点和项目。

迁移 next/head 到 Next.js Metadata

过去,Next.js Pages Router 使用 next/head<head> 中插入 <title><meta> 等标签。next/head 的工作原理与 React Helmet 和 React Helmet Async 接近,使用 React.Children API 获取 <Head /> 组件的 children prop。虽然这种写法非常直观,但是有非常多局限性:

  • 不能在 <Head /> 中使用自己声明的 React 组件:
import NextHead from 'next/head';

<NextHead>
  {/** This is not gonna work */}
  <SharedOpenGraph />
</NextHead>
  • 不能在 <Head /> 中嵌套两层及以上的 Fragment:
import NextHead from 'next/head';

<NextHead>
  {
    conditionA && (
      <>
        <title>Page Title</title>
        <meta name="description" content="Page Description" />
        {/** 这么写完全行不通... */}
        {
          conditionB && (
            <>
              <meta property="og:title" content="Page Title" />
              <meta property="og:description" content="Page Description" />
            </>
          )
        }
        {/** ...必须要这么写 */}
        {conditionB && <meta property="og:title" content="Page Title" />}
        {conditionB && <meta property="og:description" content="Page Description" />}
      </>
    )
    <link rel="canonical" href="https://blog.skk.moe" />
  }
</NextHead>

为了克服 next/head 的这些局限性、完全兼容 React 18,Next.js App Router 推出了全新的 metadata API。放弃在 <Head /> 中使用 JSX 声明标签,而是使用静态导出 JSON、或是导出异步的 generateMetadata 函数;metadata API 和 React 组件完全分离,Next.js App Router 无需 React 就可以知道每个页面都需要在 <head /> 中插入哪些标签。

Next.js App Router 中的 Layout 和 Page 中声明 metadata 导出,Next.js 会按照 RootLayout -> Layout -> Page -> Layout -> Page 的顺序进行 shallow merge。

// src/layout.tsx
// RootLayout
export const metadata: Metadata = {
  // 整个站点共用的 metadata
  viewport: 'width=device-width, initial-scale=1, viewport-fit=cover',
  formatDetection: {
    telephone: false
  },
  // 默认 title,在 shallow merge 时可被覆盖
  title: 'Sukka\'s Blog'
};

// src/post/[slug]/page.tsx
// 除了可以静态导出,还可以通过异步函数生成 metadata
export const generateMetadata = ({ params }) => {
  const hexoPost = await getHexoPostBySlug(params.slug);

  return {
    description: makePageDescription(makeExcerptForPost(hexoPost.slug, hexoPost.content, hexoPost.excerpt)),
    // 具体的 Page 会覆盖 Layout 的 title
    title: hexoPost.title
  };
}
┌──────────────────────────┐
│     src/layout.tsx       │ export const metadata: Metadata ──────────────────────────────┬──► Metadata
└──────────────────────────┘                                                               │
                                                                                           │
┌──────────────────────────┐                                                               │
│   src/post/layout.tsx    │ export const metadata: Metadata ──────────────────────────────┤
└──────────────────────────┘                                                               │
                                                                                           │
┌──────────────────────────┐                                                               │
│ src/post/[slug]/page.tsx │ export const generateMetadata = async (): Proimse<Metadata> ──┘
└──────────────────────────┘

Next.js + React Concurrent Rendering

2016 年、React 16 发布前夕,Dan Abramov 在 JSConf Island 上的演讲正式介绍了 Concurrent React,货真价实地彻底颠覆了前端框架。不论是什么框架,老牌的 Angular、React 的 Stack Rendering、Vue,还是新兴的 Svelte、Solid.js、Qwik,不论这些框架使用 Virtual DOM 还是直接操作 DOM,不论框架的使用者多么小心的拆分出原子组件、在单项数据流中不断下放组件状态,都 完全无法避免「更新大量组件」的需求,例如:

  • 依赖 React Context 或 Vue Provide 等框架 API、不仅仅依靠 CSS 实现暗色模式的网站,在模式切换的时候需要更新整个应用
  • 大型表格或列表的搜索、筛选和排序,即使使用 Virtual Scrolling(Windowing)的方案,也至少需要更新 20 到 50 行左右的数据
  • Facebook、Twitter、Mastodon 等社交网站的时间轴、通知列表
  • SPA Navigation,Router 几乎需要卸载页面上几乎所有组件、渲染一整个新的页面

浏览器的主线程(Main Thread)是单线程的、当 re-render 占据了主线程时,浏览器无法进行布局、绘制、响应用户交互。而对于现在其它所有前端框架和 React 的 Synchronous Rendering 下,在 re-render 大量内容的时候,整个页面都会被冻结卡住、直到 re-render 结束、更新被 commit 到 DOM 为止,在页面被冻结期间(可能会持续数秒钟),用户完全无法进行任何操作、无法点击页面上任何按钮或者链接、无法在文本框输入任何内容,导致糟糕的用户体验。而 Debounce 不仅仅只能解决大型列表搜索筛选的 只能解决一半的问题:

  • 每隔 wait/timeout(例如 200ms、300ms)依然会触发 re-render,从每次点击/输入时页面冻结一次、变成每隔一段时间页面冻结一次
  • 照顾了低性能设备(如移动端、旧设备)、却牺牲了高性能设备用户的流畅体验

React Concurrent Rendering 使用 Fiber Architecture 尝试解决上述问题、同时克服 Debounce 的局限性。React re-render 可以被简单粗暴地分为三个阶段:

  • Render:执行渲染函数、通过渲染函数的返回值(使用 createElement 或 jsx-runtime 生成的 ReactElement 对象)构建一颗新的 React Tree
  • Reconcilation:对比新旧 React Tree,找出需要更新的部分(构建需要执行的更新列表,在 React 中称为 effect list)
  • Commit:将更新 commit 到 DOM
  • Afterphase:当 React re-render 完成以后,React 开始执行 callback refuseLayoutEffectuseEffectcomponentDidUpdate 等「副作用」

其中,除了 Commit 过程是不能被打断的(在 DOM 上生效变更的过程一旦被暂停,用户就会在页面上同时看到过时的数据和更新的数据、也就是 Tearing 撕裂和 Inconsistency 不一致),其它阶段都可以被打断、或者重新开始。React Concurrent Rendering 通过将 RenderReconcilation 过程分为多个小任务(也就是 Fiber),然后 React 内部的调度器(即 npm 上的 scheduler 包)会计算出当前主线程剩余的空闲时间、然后调度器会根据优先级(Priority)和剩余时间(Deadline)来决定下一个要执行的 Fiber,并在 Fiber 执行完成后让出主线程,使浏览器可以响应用户交互、并在用户交互结束后 继续下一个 Fiber 的执行。

由于 Safari 至今拒绝实现 requestIdleCallback API、加上 requestIdleCallback API 并不适用于渲染界面这样的高频率任务调度,所以 React 自行实现了一个基于 setImmediate(在非 Node.js 或 IE 的浏览器环境中则使用 MessageChannel 模拟 setImmediate,因为 setTimeout 有 4ms cap 的限制)的调度器。在 Chrome 上,React 还会利用 Chrome 试验性的 navigator.scheduling.isInputPending API,提供更流畅的用户体验。

在 React 受控组件的 pattern 中,用户的交互常常意味着受控组件状态的更新。如果更新并不重要,React 会把这个更新放在队列里、继续执行剩余的 Fiber、直到当前的 re-render 结束,然后 React 再开始处理这个低优先级的更新、开始新一次的 re-render;如果更新非常紧急、优先级很高,React 会直接丢弃之前的 Fiber 的执行结果,因为如果使用更新后的状态 继续执行剩余的 Fiber、也会导致 Tearing 和 Inconsistency,因此 React 需要重新从头执行所有 Fiber、重新开始 Render 和 Reconcilation。Lin Clark 在 React Conf 2017 的演讲 中详细介绍了更多关于 React 的 Fiber Architecture 的实现细节,非常值得一看。

在当时,通过使用 ReactDOM.createRoot API 即可使得整个 React 应用 opt-in React Concurrent Mode;而等到 React 18 正式发布时,为了降低迁移难度,ReactDOMClient.createRoot 只会 opt in Automatic Batching 等普通特性、显式使用了 <Suspense /><OffScreen />useTransitionuseDeferredValue 等 API 才会 opt in Concurrent Rendering)

Next.js App Router 完全构建于 React Concurrent Rendering 之上。Next.js App Router 使用 React.use() 加载 React Flight Directive、用 <Suspense /> 实现 Stream SSR 和 Loading UI、在 next/link 组件中使用 startTransition 确保页面切换是 Concurrent 的、未来 Next.js App Router 还会使用 React Concurrent Rendering 的另一个特性 <OffScreen /> 实现离屏路由渲染和状态保持。

在迁移到 Next.js App Router 之前,我博客搜索的行为是,输入搜索关键词之后、按下回车或点击「搜索」按钮,然后开始搜索;在迁移到 Next.js App Router 以后,我基于 React Concurrent Rendering 重构了搜索:

// src/app/search/page.tsx
import { SearchQueryProvider } from './context/search';
import { SearchPageComponent } from './components/search-page-client';

export default function SearchPage() {
  return (
    <SearchQueryProvider>
      <SearchPageComponent />
    </SearchQueryProvider>
  );
}

// src/app/search/component/input.tsx
import { useCallback } from 'react';
// useCompositionInput 是一个用于构建 CJK 输入法友好的 不受控 <input /> 的 Hook,
// 更多细节可以参考 https://foxact.skk.moe/use-composition-input
import { useCompositionInput } from 'foxact/use-composition-input';

interface SearchInputProps {
  onChange(value: string): void
}

export default function SearchInput({ onChange }: SearchInputProps) {
  return <input type="search" {...useCompositionInput(onChange)} />;
}

然后是最核心的关键组件 SearchPageComponent

// src/app/search/component/search-page-client.tsx
import { useTransition } from 'react';
import SearchInput from './input';
import { useSearchQuery, useSetSearchQuery } from '../context/search';
import { useLayoutEffect } from 'foxact/use-isomorphic-layout-effect';

export default function SearchPageComponent() {
  const searchQuery = useSearchQuery();
  const setSearchQuery = useSetSearchQuery();

  const [isPending, startTransition] = useTransition();
  const [startClientSearch, setStartClientSearch] = useState(false);

  const handleChange = useCallback((value: string) => {
    // 使用 React 18 的 useTransition Hook 启用 React Concurrent Rendering
    // 将不受控的 <SearchInput /> 组件引起的 React 更新标记为低优先级的 Transition 更新
    startTransition(() => {
      setSearchQuery(value || '');
      // 随着用户输入,更新 URL 中的 search query
      window.history.replaceState(null, '', value.length > 0 ? `/search/?s=${value}` : '/search/');
    });
  }, [setSearchQuery]);

  useLayoutEffect(() => {
    // 从服务端渲染切换到客户端渲染
    setStartClientSearch(true);

    // 当首次访问搜索页面时、将 URL 中的 search query 同步进 React 中
    const queryFromUrl = new URL(window.location.href).searchParams.get('s')?.trim();
    if (queryFromUrl && queryFromUrl.length > 0) {
      setSearchQuery(queryFromUrl);
    }
  }, [setSearchQuery]);

  return (
    <>
      <SearchInput onChange={handleChange} />
      {isPending && <Searching />}
      {
        (startClientSearch && searchQuery)
          ? <SearchResult searchQuery={searchQuery} />
          : intro
      }
    </>
  )
}

由于我实现的博客搜索是纯函数,因此我将其放在 <SearchResult /> 组件中的 render phase 期间执行。当用户输入时,isPending 的更新属于高优先级更新,可以及时向用户展示「搜索正在进行中」的交互反馈;而 searchQuery 的更新被包裹在 startTransition 之中、属于低优先级更新,因此 searchQuery 引起的 <SearchResult /> re-render 也是低优先级的,用户不仅可以流畅地继续输入、打断 <SearchResult /> re-render,而且之前已经显示的搜索结果也不会消失。

尾声

早在两个月前我就完成了博客的迁移和重构。在这两个月里,Next.js App Router 从尝鲜的 Alpha 过渡到 API 稳定可用,我也在不断试验最前沿的前端技术、将它们 adopt 到我的博客之中;对博客的每一次打磨,就是一次对前端最前沿的技术的实践、更进一步改善博客的性能和用户体验。

虽然我的博客是闭源的,以后也不会开源,借着这篇文章,我也分享了部分博客中实际使用的代码、以及我是如何一步一步实现这些特性的,希望能够满足这部分读者的好奇心。在迁移和重构博客的同时,我也产出了大量开源成果:

  • style9-webpack,文中提到的支持 Next.js App Router 的 style9 webpack 插件
  • foxact,一个超级轻量、类型安全、完全兼容 React 18 Concurrent Rendering 的 React Custom Hooks 集合
  • forgetti-loader,一个用于编译、优化、压缩、缓存 React Functional Component 和 Hook 的 webpack loader 和 Next.js plugin

除此以外,我还给 Next.js 和 forgetti(一个受到 Meta 的 React Forget 影响的 React Functional Component 和 Hook 的编译器、优化器)贡献了数个 PR,修复了迁移和重构博客的过程中我遇到的问题。

以上。

React Server Component 初体验与实践 —— 将博客迁移到 Next.js App Router
本文作者
Sukka
发布于
2023-06-25
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
如果你喜欢我的文章,或者我的文章有帮到你,可以考虑一下打赏作者
评论加载中...