cover
2023-07-13 13:03:10

Next.js App Router初体验与实践: 将我的博客迁移到Next.js App Router与React Server Components

在今年三月初,我使用 Next.js 重构了我的博客。现在,随着 Next.js AppRouter 的稳定,我又将博客从 Next.js Pages Router 迁移到了 Next.js 13 AppRouter 与 React Server Components,同时,我也做了大量的底层重构以及样式重构。

性能优化: React Server Components

在 React 18 之前,所有的组件均为Client Components,即客户端组件。顾名思义,客户端组件中的所有逻辑均在客户端执行。浏览器渲染客户端组件需要从服务端获取 chunk,然后渲染组件到页面。即使是在 SSR 中,也需要等待客户端组件所对应的 bundle 完成加载才能让页面具有逻辑,能够交互。而我们的一些操作并不需要在客户端调用:如获取数据,大量的类似的逻辑,大大提高了 bundle 的体积,使得页面加载相对较慢,而React Server Components(以下简称RSC)的出现很好的解决了这一问题。

RSC 不同于客户端组件,RSC 在服务端的 React 执行,客户端所收到的是服务端的执行结果,也就是说,客户端不会加载任何 RSC 的逻辑代码,以此我们就能缩小 Client Bundle。因为 RSC 是在服务端执行的,所以理所当然,RSC 内能够调用 node 环境,我们也就不必通过getStaticProps等 api 获取数据。

之前的文章提到过,我将博客从 Hexo 迁移到了基于文件的 CMS,以往博客版本是这样获取数据的:

      export async function getStaticPaths() {
  const posts = getAllPosts(["slug"]);

  return {
    paths: posts.map((post) => {
      return {
        params: {
          slug: post.slug,
        },
      };
    }),
    fallback: false,
  };
}

export async function getStaticProps({ params }: any) {
  const post = getPostBySlug(params.slug, ["title", "date", "slug", "content"]);
  const content = await markdownToHtml(post.content || "");
  await generateRss();

  return {
    props: {
      post: {
        ...post,
        content,
      },
    },
  };
}
    

而在使用 RSC 后,我们可以直接获取数据:

      export default async function Post({ params }: { params: { slug: string } }) {
  const post = getPostBySlug(params.slug, [
    "title",
    "date",
    "slug",
    "cover",
    "content",
  ]);
  if (!post.title) {
    return notFound();
  }
  const content = await markdownToHtml(post.content || "");
  return (
    <>
      <Card title={post.title} cover={post.cover} label={post.date.toString()}>
        <div dangerouslySetInnerHTML={{ __html: content }} />
      </Card>
    </>
  );
}

export async function generateStaticParams() {
  const posts = getAllPosts(["slug"]);
  return posts.map((post) => ({
    slug: post.slug,
  }));
}
    

可以看见,使用 RSC 后的页面相比于不使用 RSC 的页面大幅减少了代码长度。

但正因 RSC 是在 node 环境运行的,所以我们不能在 RSC 使用浏览器的 api,也无法使用useState等 api,也就是说,RSC 不能直接响应用户的交互。而当我们需要调用浏览器api时,可以通过在组件代码前加上"use client";将组件更改为客户端组件,就能调用浏览器api。

值得一提的是,我们可以在RSC中引入客户端组件:

      <div>
  This is a RSC
  <ClientComponent />
</div>
    

但我们不能在客户端组件内引入RSC:

      <ClientComponent>
  <ServerComponent />
</ClientComponent>
    

但我们依然有办法在客户端组件内直接调用RSC。前文提到,RSC在渲染过程中需要node的参与,但是返回结果是相当于静态的,所以我们可以通过Props的方式向客户端组件传入RSC的返回值实现:

      <ServerComponent>
  const res = <AnotherSC />
  <ClientComponent aprop={res} />
</ServerComponent>
    

布局优化: Next.js App Router

在 Next.js 13 中,Next.js 新增了一种新的路由方式:App Router。它基于React Server Components开发,支持共享布局,加载状态,嵌套路由,错误处理等。其中对我而言最重要的更新就是嵌套路由。

App Router 将路由拆分为布局与内容两部分,其中布局支持嵌套,大大减少了代码冗余。例如我们要实现 AB 两个页面,二者都包含一个导航栏和一个侧边栏,B 页面在以上的基础下又包含其自身所需要的布局,即需要布局嵌套,在Pages Router中,我们需要这样:

      // A.tsx
export default function A() {
  return (
    <LayoutA>
      <Content />
    </LayoutA>
  );
}
    
      // B.tsx
export default function B() {
  return (
    <LayoutA>
      <LayoutB>
        <Content />
      </LayoutB>
    </LayoutA>
  );
}
    

只有两层情况就已经如此糟糕,倘若有三层,四层.......代码的可读性将大大降低。但在 App Router 中,我们可以这样做:

      // app/a/layout.tsx
export default RootLayout({ children }: { children: ReactNode }) {
  return (
      <html lang="zh-CN">
      <body>
        <NavBar />
        <div>
          <LayoutA>{children}</LayoutA>
        </div>
      </body>
    </html>
  )
}
    
      // app/a/b/layout.tsx
export default LayoutB({ children }: { children: ReactNode }) {
  return <LayoutA>{children}</LayoutA>
}
    

此时,b 页面的布局会自动继承 a 的布局,并在其基础上新增其自身布局,代码会十分简洁。

除了语法更加简洁,我们也可以通过AppRouter实现共享布局。

服务端: 从 SSG 迈向 SSR

「使用 Next.js 重构我的博客」一文中我提到,我将博客核心所使用的 CMS 从Hexo迁移到自研的基于文件的 CMS,在构建时使用Next.js Pages Router提供的getStaticProps等一系列 api 在构建时从本地获取文章并渲染,但在我迁移博客到Next.js App Router时这样的做法无法通过构建,原因是 App Router 并不支持使用getStaticProps等 api 获取数据。同时,在构建时渲染 Markdown 也会导致构建速度很慢,在考虑之下,我决定放弃 SSG,迈向 SSR。

但是,如果在每次访问都渲染一次文章,就会导致服务器压力激增,客户端访问速度直线上升。显然,这种做法是极其不明智的。好在,React 18 中提供了一个cache方法,被cache包裹的方法,在传参不变的情况下不会执行方法,而是直接返回缓存值,例如:

      import { cache } from "react";

const add = (a: number, b: number) => {
  return a + b;
};

const cacheAdd = cache(add);

cacheAdd(1, 2); // 3
cacheAdd(1, 2); // 3
    

如果参数不变,多次调用cacheAdd方法,并不会执行add方法,而是会直接返回缓存值。上面的代码也可以写成这样:

      import { cache } from "react";

const add = cache((a: number, b: number) => {
  return a + b;
});

add(1, 2); // 3
add(1, 2); // 3
    

接下来的事情就简单了,只需要在读取和渲染文章的方法外包裹cache就能提高性能:

      export const getPostBySlug = cache((slug: string, fields: string[] = []) => {
  // ...
});
    

顺便提一下,本人参与开发的评论系统「retalk」也大量使用了缓存提高性能

但现在依然有一个问题,现在服务端在收到请求后,会根据请求路径中的 slug 查找文章,并读取文章内容,但当文件不存在时,node 的 fs api 就会抛出错误,使getPostBySlug方法没有返回任何内容,进而导致服务端返回 500,所以我们需要在getPostBySlug方法中检测 slug 是否存在,若不存在则返回空对象而不是没有返回值:

      if (!fs.existsSync(fullPath)) {
  return items;
}
    

在调用时检测返回对象是否包含 slug:

      if (!post.title) {
  return notFound();
}
    

notFound()是 Next.js 提供的方法,可以手动返回 404。

这时,我们再尝试访问不存在的文章,服务端会返回 404,而非 500。

元数据: 从 next/head 到 Next Metadata

在 Pages Router 中,我们可以在页面中返回Head组件自定义页面的元数据:

      import Head from "next/head";

export default function PageA() {
  return (
    <Head>
      <title>Your Title</title>
    </Head>
  );
}
    

而在 App Router 中,我们可以通过导出metadata的方法设置元数据:

      import { Metadata } from "next";

export const metadata: Metadata = {
  title: "Your Title",
};
    

简介等属性同理

除了静态导出,我们还可以通过导出generateMetadata函数实现动态设置元数据:

      export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = getPostBySlug(params.slug, ["title", "content"]);
  if (!post.title) {
    return {
      title: `404 - ${config.name}`,
    };
  }
  return {
    title: `${post.title} - ${config.name}`,
    description: post.content.slice(0, 200),
  };
}
    

Markdown 渲染: 从 remark 到 marked

在以往的版本中,我使用remark完成 Markdown 的渲染,但我逐渐发现,remark 逐渐无法满足我的需求,所以更换到marked。marked 支持自定义渲染器,可以更方便的修改渲染逻辑。

本站的 markdown 渲染被封装到了一个markdownToHtml函数,所以更改渲染器十分方便:

      export default async function markdownToHtml(markdown: string) {
  const renderer = new marked.Renderer();

  renderer.code = function (code, language) {
    // 添加hljs类和data-language属性
    let lang = language ? language.toUpperCase() : "";
    if (!language) {
      lang = "TEXT";
    }
    if (language == "") {
      language = "plaintext";
    }
    const highlightedCode = highlightjs(code, language);
    return `<pre class="hljs language-${lang}" data-language="${lang}">
      <code>${highlightedCode}</code>
    </pre>`;
  };
  return marked.parse(markdown, { mangle: false, headerIds: false, renderer });
}
    

完整的渲染器代码较长,此处仅展示部分代码,可能无法单独使用

React 无法直接将 html 嵌入到组件作为子元素使用,但提供了属性dangerouslySetInnerHTML供开发者显示 html 字符串,所以在这里可以这样写:

      export default async function Post({ params }: { params: { slug: string } }) {
  const post = getPostBySlug(params.slug, [
    "title",
    "date",
    "slug",
    "cover",
    "content",
  ]);
  if (!post.title) {
    return notFound();
  }
  const content = await markdownToHtml(post.content || "");
  return (
    <>
      <Card title={post.title} cover={post.cover} label={post.date.toString()}>
        <div>
          <div>{post.desc}</div>
          <div dangerouslySetInnerHTML={{ __html: content }} />
        </div>
      </Card>
    </>
  );
}
    

代码高亮: 自定义主题

以往版本中,正文部分所使用的代码高亮主题是由github-markdown-css提供的,我逐渐发现其设计不满足我的需求,便进行了自定义。

代码高亮配色我觉得没有必要更改,但是highlight.js提供的主题不支持深色模式,我就不得不手写配色。我将包含深色模式的配色放到了全局变量中:

      :root {
  --pre: #fafafa;
  --pre-comment: #6a737d;
  --pre-string: #032f62;
  --pre-literal: #032f62;
  --pre-keyword: #d73a49;
  --pre-function: #6f42c1;
  --pre-deleted: #24292e;
  --pre-class: #22863a;
  --pre-property: #005cc5;
  --pre-namespace: #6f42c1;
  --pre-punctuation: #24292e;
}

@media (prefers-color-scheme: dark) {
  :root {
    --pre-comment: #757575;
    --pre-string: #977cdc;
    --pre-literal: #c64640;
    --pre-keyword: #77b7d7;
    --pre-function: #86d9ca;
    --pre-deleted: #fff;
    --pre-class: #dfab5c;
    --pre-property: #77b7d7;
    --pre-namespace: #86d9ca;
    --pre-punctuation: #fff;
  }
}
    

然后在 highlight.js 提供的默认主题基础上做修改,将颜色从固定值改为变量:

      .hljs {
  color: var(--text);
  background: var(--pre);
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
  color: var(--pre-keyword);
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
  color: var(--pre-function);
}
    

此处因篇幅原因只展示部分

原有代码块并没有直接显示语言,容易产生歧义。出现了以下设计方案:

两种方案

显然第一种更显眼并具有设计感。

具体实现我使用了before伪类的方式,通过attr(data-language)读取自定义渲染器所输入的语言名称:

      return `<pre class="hljs language-${lang}" data-language="${lang}">
      <code>${highlightedCode}</code>
    </pre>`;
    
      pre::before {
  color: var(--text-l);
  opacity: 0.25;
  content: attr(data-language);
  font-size: 1.625rem;
  font-weight: 700;
  position: absolute;
  right: 0.5rem;
}
    

至此完成了代码块的修改。

CSS: Atomic CSS In JS

传统的css在使用重复布局时回产生大量冗余,例如以下三个类:

      .a {
  padding: 1.125rem;
  color: pink;
}

.b {
  padding: 1.125rem;
  color: skyblue;
}

.c {
  margin: 1.125rem;
  color: pink;
}
    

可以看见,pading: 1.125remcolor: pink被在css中出现了多次,现在这个css文件共有6个属性。我们可以将它们拆分,封装成4个类:

      .p-125 {
  pading: 1.125rem;
}

.color-pink {
  color: pink;
}

.color-skyblue {
  color: skyblue;
}

.m-125 {
  margin: 1.125rem;
}
    

在html中我们可以直接使用这些类的组合实现与第一种方式相同的效果,这就是原子化css。使用原子化设计的css只出现了4个属性。不难看出,原子化css能够减少css的体积。

在实际使用中,我们往往会使用已经绑定好的原子化css库,例如tailwindcss等。

但是,这种做法会导致代码中有一大串的类名,显然不够优雅。所以,我使用了style9,实现atomic css in js,您可以打开devtools查看效果。

布局设计: 拥抱双飞翼

以往版本的博客使用单栏设计,只留出中间一栏展示所有信息,这样做实现简单,但会导致比较单调。在新博客的设计中,我采用了「双飞翼」布局,即三栏布局:

三栏布局

将布局拆分为SidebarContent,使用Next App Router可以实现路由跳转只加载Content。具体实现使用CSS Flex布局。

基础设计: Card

如你所见,本站现在的所有组件都是卡片,基础是一个具有titlelabelcontentsizecover等众多属性的组件:

布局演示

以此保证样式的统一。

深色模式: CSS变量

深色模式能够提高用户体验,为了实现深色模式,我为深色模式单独设计了配色,并通过@media (prefers-color-scheme: dark)实现根据系统设置自动切换。

友情链接: 从静态到动态

以往的友情链接是静态储存的,这样做难免会有局限性,在新版博客中,我将友情链接数据迁移到了GitHub仓库,并通过Chuqi CDN实时获取友情链接信息。

你想与101互换友情链接吗,那就看看下面的步骤吧

前置条件

  • 友情链接,先友后链,所以最好是101比较眼熟的朋友
  • 您的网站不是免费域名,包括但不限于:
    • 由Freenom公司运营的.tk.ml等免费域名(不包括付费购买的域名)
    • 由 Joshua Anderson 运营的 Afraid FreeDNS 提供的免费子域名
    • 其他不包含在 Public Suffix List 中的 免费子域名 服务
    • *.github.io*.gitee.io等域名
  • 不能是采集站!!!!!!!!!
  • 有三篇以上的原创文章(包括三篇)
  • 站点上有我的链接

我的信息

      {
  "name": "Redish101 Blog",
  "desc": "人文 科技 白日梦",
  "icon": "https://blog.redish101.top/favicon.ico",
  "link": "https://blog.redish101.top"
}
    

添加

仓库: https://github.com/Redish101/friend-links

如果你满足前置条件,就可以开始提交。

  1. Fork本仓库
  2. 更改data.json
  3. 添加你的网站
  4. 向本仓库提交pr
  5. 等待审核
  6. 审核完成后将会在Redish101 Blog下次构建时生效

尾声

感谢阅读