这是“从头学服务器组件”系列的第 3 篇文章。这个系列的文章来自于 Dan Abramov 所写的《RSC From Scratch. Part 1: Server Components》这篇长文,为了方便理解和学习,我将这篇长文拆分成了一个系列进行讲解。
回顾
在上一篇文章《从头学服务器组件#2:发明组件》中,我们将博客详情页的 UI 拆分成 BlogPostPage
和 Footer
两个组件,并通过修改 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 地址)。
主页:
详情页:
至此,我们就完成了多路由组件渲染支持。
总结
本文,我们为博客站点增加了博客主页来展示所有博文列表,为此修改了服务器部分的实现,增加了根路由(/
)匹配逻辑,抽象在 matchRoute()
方法中。在这个过程中,我们还将博客主页和详情页的共享布局提取成了单独的布局组件 BlogLayout
。
不过,现在的代码有点冗长和重复,具体来说 BlogIndexPage
和 BlogPostPage
主体的内容和结构重复了,这里可以考虑将这块重复的内容提取成一个组件。
另外,获取数据的逻辑也重复了,还暴露在了组件外部。那么,如果把获取博文内容的代码放在组件内部,不就解决这个问题了吗?
实际上,这些问题都可以通过下一篇要介绍的 异步函数(async components) 来解决。
本文就先说到这,下一篇再见。