这是"从头学服务器组件"系列的第 4 篇文章。这个系列的文章的来自于 Dan Abramov 所写的《RSC From Scratch. Part 1: Server Components》这篇长文,为了方便理解和学习,我将这篇长文拆分成了一个系列进行讲解。
回顾
在上一篇文章《从头学服务器组件#3:添加路由》中,我们为博客站点添加了路由,引入了博客首页。实现过程中,我们还将博客主页和详情页的共享布局提取成了单独的布局组件 BlogLayout
。
在结束的时候,我们总结了 2 个发现的问题。
- 具体来说
BlogIndexPage
和BlogPostPage
主体的内容和结构有些重复了 - 另外,获取数据的逻辑也重复了,暴露在了组件之外
下面,我们就着手解决。
抽象异步组件
抽象 Post
组件
关于 BlogIndexPage
和 BlogPostPage
组件中重复的部分,我们可以提取出一个 Post
组件来。
jsx
function Post({ slug, content }) { // Someone needs to pass down the `content` prop from the file :-(
return (
<section>
<h2>
<a href={"/" + slug}>{slug}</a>
</h2>
<article>{content}</article>
</section>
)
}
这是我们首先会想到的提取方式。
不过,我们仍然需要从外部将博文内容(content
)传递进来。目前,获取博文内容的逻辑还在组件外部(也就是下面框出来的 2 个地方)。
这两块逻辑是重复的,都使用了 readFile
API 来获取博文内容。而之所以把这块逻辑放在外面,是因为获取内容是一个异步行为,目前我们的组件还不支持异步渲染。
注意:案例中的
readFile
API 其实是模拟从数据库获取数据。虽然readFile
API 有同步版本,但并不符合真实场景,因此我们有意忽略了。另外,到目前为止,我们只针对服务器环境进行实现,因此我们选用readFile
API 了来模拟数据的获取。
增加组件异步渲染支持
下面,让我们为组件增加异步支持。首先,移除 content
prop,将 Post
组件从 function Post() {}
改成 function async Post() {}
,也就是改成 async
函数,这样就可以在组件内部使用 await readFile()
加载博文内容了。
jsx
async function Post({ slug }) {
let content;
try {
content = await readFile("./posts/" + slug + ".txt", "utf8");
} catch (err) {
throwNotFound(err);
}
return (
<section>
<h2>
<a href={"/" + slug}>{slug}</a>
</h2>
<article>{content}</article>
</section>
)
}
同理,我们将 BlogIndexPage
也改成 async
函数,内部通过 await readdir()
获取博文列表。
jsx
async function BlogIndexPage() {
const postFiles = await readdir("./posts");
const postSlugs = postFiles.map((file) =>
file.slice(0, file.lastIndexOf("."))
);
return (
<section>
<h1>Welcome to my blog</h1>
<div>
{postSlugs.map((slug) => (
<Post key={slug} slug={slug} />
))}
</div>
</section>
);
}
抽象 <Router>
组件
现在, Post
和 BlogIndexPage
都改成在内部获取内容了。接着,再用 <Router>
组件来替换之前的 matchRoute()
方法。
jsx
function Router({ url }) {
let page;
if (url.pathname === "/") {
page = <BlogIndexPage />;
} else {
const postSlug = sanitizeFilename(url.pathname.slice(1));
page = <BlogPostPage postSlug={postSlug} />;
}
return <BlogLayout>{page}</BlogLayout>;
}
旧版,对比查看。
jsx
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);
}
}
}
会发现,代码上会简洁很多。最后,createServer
中将渲染任务委托给 <Router>
:
jsx
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);
旧版,对比查看。
jsx
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
await sendHTML(res, <Router url={url} />);
} catch (err) {
console.error(err);
res.statusCode = err.statusCode ?? 500;
res.end();
}
}).listen(8080);
renderJSXToHTML()
的异步渲染支持
组件现在换成 async
函数的写法了,但 renderJSXToHTML()
并没有对此做兼容。
javascript
} else if (typeof jsx.type === "function") {
const Component = jsx.type;
const props = jsx.props;
const returnedJsx = Component(props); // <--- 组件还是以同步方式处理的
return renderJSXToHTML(returnedJsx);
} else throw new Error("Not implemented.");
为了支持异步组件渲染,我们需要在调用组件时使用 await
。
javascript
// ...
const returnedJsx = await Component(props);
// ...
如此,renderJSXToHTML()
也要改成 async
函数了(配合内部 await
)。
javascript
async function renderJSXToHTML(jsx) {
// ...
}
这样修改后,树中的任何组件都可以是 async
的,而且也能"等待"最终 HTML 字符串的生成。
需要注意的是,最新的 BlogIndexPage
中只是获取了博文列表的标题(slug),具体博文内容还是在 Post
中获取的。
还有BlogPostPage
,改起来也挺简单。
jsx
// 从
function BlogPostPage({ postSlug, postContent }) {
return (
<section>
<h2>
<a href={"/" + postSlug}>{postSlug}</a>
</h2>
<article>{postContent}</article>
</section>
);
}
// 改成 ->
function BlogPostPage({ postSlug }) {
return <Post slug={postSlug} />;
}
这样,就修改完所有的地方了。现在重新访问,查看效果(线上 demo 地址)。
主页:
详情页:
依然成功展示了,说明我们的修改是没问题的。
需要注意的是,
await
实现方式并不理想,因为除非所有 HTML 字符串都成功了,否则永远不会发送回 HTML 给浏览器,也就是渲染过程本身是"阻塞"的。理想情况下,我们希望服务器负载会流式传输给浏览器,也就是一边生成内容,一边发送回浏览器。这个实现比较复杂,现在我们只用只关注数据流向,流式传输不会在"从头学服务器组件"这一系列中讨论,不过可以在这里简单讲讲概念。关于流式传输,后续支持的过程中,不需要我们对组件本身进行任何更改。每个组件只使用
await
来等待自己的数据(这是不可避免的),但父组件不需要await
子组件------即便子组件也是async
的------ 这就是为什么 React 可以在子组件完成渲染前就能流出父组件的原因。
总结
引入了异步组件后,我们解决了渲染内容重复的问题,代码更加内聚,博文内容的获取统一安排在了 Post
组件中,整体代码也变得清爽很多。
到目前为止,我们已经实现了具备首页和详情页浏览功能的博客站点。不过,现在的页面渲染模式还很复古,页面与页面之间的 DOM 树和状态是隔离的------在主页和详情页之间切换时,都是一次全新的请求、响应、渲染页面的过程。而从最终的渲染结果来看,主页和详情页的主题布局是完全一样的,那么是否能够做到在主页和详情页之间切换时,布局 DOM 树的复用呢?简单说就是实现页面局部刷新的能力;又或者在某个共同的位置如果存在某个输入框数据,在不做任何处理的情况下,页面切换也会导致输入数据(即页面状态)的丢失,那又该如何应对呢?
这些都是可以解决的,也是下一篇要探讨"在导航中保留状态(preserve state on navigation)"的内容,就会讲到。
本文就先说到这里,再见。