从头学服务器组件#4:发明异步组件

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

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

回顾

在上一篇文章《从头学服务器组件#3:添加路由》中,我们为博客站点添加了路由,引入了博客首页。实现过程中,我们还将博客主页和详情页的共享布局提取成了单独的布局组件 BlogLayout

在结束的时候,我们总结了 2 个发现的问题。

  1. 具体来说 BlogIndexPageBlogPostPage 主体的内容和结构有些重复了
  2. 另外,获取数据的逻辑也重复了,暴露在了组件之外

下面,我们就着手解决。

抽象异步组件

抽象 Post 组件

关于 BlogIndexPageBlogPostPage 组件中重复的部分,我们可以提取出一个 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> 组件

现在, PostBlogIndexPage 都改成在内部获取内容了。接着,再用 <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)"的内容,就会讲到。

本文就先说到这里,再见。

相关推荐
风骏时光牛马25 分钟前
稳定性治理及疑难问题深度剖析与实战复盘
前端
JeffongTan9 小时前
在LWC中镶嵌VF Page获取用户IP
前端·javascript·salesforce
不cong明的亚子11 小时前
web live多屏互动平台
javascript·经验分享·音视频
陆枫Larry11 小时前
Astro 是什么
前端
是立不是利12 小时前
写给设计师的 CSS 博客美学指南
前端·css
dsyyyyy110113 小时前
JS复习,难点,易错点总结
开发语言·javascript·ecmascript
默_笙13 小时前
🏝 Docker 就是"房地产开发":从施工图纸到小区物业的容器化指南
前端·javascript
计算机魔术师13 小时前
Rohan Paul 谈用时间跨度衡量智能体能力,并引用 OpenAI 自动化研究实习生里程碑
前端
kyriewen13 小时前
我手写了个极简版 React Router——才搞懂 v6 为什么砍了这么多 API
前端·javascript·程序员
a11177613 小时前
Shirone 二次元博客主题 开源
前端·开源