从头学服务器组件#3:添加路由

146 阅读3分钟

这是“从头学服务器组件”系列的第 3 篇文章。这个系列的文章来自于 Dan Abramov 所写的《RSC From Scratch. Part 1: Server Components》这篇长文,为了方便理解和学习,我将这篇长文拆分成了一个系列进行讲解。

  1. 发明 JSX
  2. 发明组件
  3. 添加路由(本文)

回顾

在上一篇文章《从头学服务器组件#2:发明组件》中,我们将博客详情页的 UI 拆分成 BlogPostPageFooter 两个组件,并通过修改 renderJSXToHTML() 函数实现,增加了对组件渲染的支持。

在这之后,我们考虑进一步为我们博客站点增加页面,引入博客主页(或叫索引页,Index Page)来展示所有博文列表。为此,我们需要增加路由功能。

定义路由

现在,访问 /hello-world 地址能看到博客详情页。我们再添加一个根路由(/)用来展示所有博博客列表,也就是博客索引页。为此,我们需要增加一个新的 BlogIndexPage 组件,与 BlogPostPage 共享布局,但包含不同内容。

提取布局组件 BlogLayout

目前,BlogPostPage 组件代表整个页面,从 <html> 标记开始写起,我们需要将这块与 BlogIndexPage 共享的页面结构(页眉和页脚)从 BlogPostPage 中提取出来,创建表示布局的 BlogLayout 组件。

function BlogLayout({ children }) {
  const author = "Jae Doe";
  return (
    <html>
      <head>
        <title>My blog</title>
      </head>
      <body>
        <nav>
          <a href="/">Home</a>
          <hr />
        </nav>
        <main>
          {children}
        </main>
        <Footer author={author} />
      </body>
    </html>
  );
}

修改 BlogPostPage 组件,只包含我们希望插入到布局里的内容。

// 新版
function BlogPostPage({ postSlug, postContent }) {
  return (
    <section>
      <h2>
        <a href={"/" + postSlug}>{postSlug}</a>
      </h2>
      <article>{postContent}</article>
    </section>
  );
}
旧版(作为对比),点击查看
function BlogPostPage({ postContent, author }) {
  return (
    <html>
      <head>
        <title>My blog</title>
      </head>
      <body>
        <nav>
          <a href="/">Home</a>
          <hr />
        </nav>
        <article>
          {postContent}
        </article>
        <Footer author={author} />
      </body>
    </html>
  );
}

下面是 <BlogPostPage> 嵌套在 <BlogLayout> 中的外观:

添加一个新的 BlogIndexPage 组件,它会展示 ./posts/*.txt 下的每个博客文件。

function BlogIndexPage({ postSlugs, postContents }) {
  return (
    <section>
      <h1>Welcome to my blog</h1>
      <div>
        {postSlugs.map((postSlug, index) => (
          <section key={postSlug}>
            <h2>
              <a href={"/" + postSlug}>{postSlug}</a>
            </h2>
            <article>{postContents[index]}</article>
          </section>
        ))}
      </div>
    </section>
  );
}

./posts 下目前只有一个 hello-world.txt 文件,我们再增加一个 vacation-time.txt 文件,内容如下:

It's me again! Haven't posted in a while because vacation.

<BlogIndexPage> 嵌套在 <BlogLayout> 里,就有了跟 BlogPostPage 一样的页眉页脚了。

增加路由导航支持

最后,修改服务器处理程序,依据 URL 提供不同的页面、加载数据、并在布局组件中呈现对应的页面。

import { createServer } from "http";
import { readFile, readdir } from "fs/promises";
import escapeHtml from "escape-html";
import sanitizeFilename from "sanitize-filename";

createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    // Match the URL to a page and load the data it needs.
    const page = await matchRoute(url);
    // Wrap the matched page into the shared layout.
    sendHTML(res, <BlogLayout>{page}</BlogLayout>);
  } catch (err) {
    console.error(err);
    res.statusCode = err.statusCode ?? 500;
    res.end();
  }
}).listen(8080);

async function matchRoute(url) {
  if (url.pathname === "/") {
    // We're on the index route which shows every blog post one by one.
    // Read all the files in the posts folder, and load their contents.
    const postFiles = await readdir("./posts");
    const postSlugs = postFiles.map((file) => file.slice(0, file.lastIndexOf(".")));
    const postContents = await Promise.all(
      postSlugs.map((postSlug) =>
        readFile("./posts/" + postSlug + ".txt", "utf8")
      )
    );
    return <BlogIndexPage postSlugs={postSlugs} postContents={postContents} />;
  } else {
    // We're showing an individual blog post.
    // Read the corresponding file from the posts folder.
    const postSlug = sanitizeFilename(url.pathname.slice(1));
    try {
      const postContent = await readFile("./posts/" + postSlug + ".txt", "utf8");
      return <BlogPostPage postSlug={postSlug} postContent={postContent} />;
    } catch (err) {
      throwNotFound(err);
    }
  }
}

function throwNotFound(cause) {
  const notFound = new Error("Not found.", { cause });
  notFound.statusCode = 404;
  throw notFound;
}

这里通过 matchRoute() 方法、依据 URL 获取主页或详情页数据。如果 url.pathname 值为 '/',就表示是渲染首页,否则按照详情页渲染。

现在,再次访问博客站点,查看效果(线上 demo 地址)。

主页:

image.png

详情页:

image.png

至此,我们就完成了多路由组件渲染支持。

总结

本文,我们为博客站点增加了博客主页来展示所有博文列表,为此修改了服务器部分的实现,增加了根路由(/)匹配逻辑,抽象在 matchRoute() 方法中。在这个过程中,我们还将博客主页和详情页的共享布局提取成了单独的布局组件 BlogLayout

不过,现在的代码有点冗长和重复,具体来说 BlogIndexPageBlogPostPage 主体的内容和结构重复了,这里可以考虑将这块重复的内容提取成一个组件。

image.png

另外,获取数据的逻辑也重复了,还暴露在了组件外部。那么,如果把获取博文内容的代码放在组件内部,不就解决这个问题了吗?

image.png

实际上,这些问题都可以通过下一篇要介绍的 异步函数(async components) 来解决。

本文就先说到这,下一篇再见。