从头学服务器组件#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)"的内容,就会讲到。

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

相关推荐
学习路上的小刘1 分钟前
vue h5 蓝牙连接 webBluetooth API
前端·javascript·vue.js
&白帝&1 分钟前
vue3常用的组件间通信
前端·javascript·vue.js
小白小白从不日白12 分钟前
react 组件通讯
前端·react.js
罗_三金23 分钟前
前端框架对比和选择?
javascript·前端框架·vue·react·angular
Redstone Monstrosity30 分钟前
字节二面
前端·面试
东方翱翔37 分钟前
CSS的三种基本选择器
前端·css
Fan_web1 小时前
JavaScript高级——闭包应用-自定义js模块
开发语言·前端·javascript·css·html
yanglamei19621 小时前
基于GIKT深度知识追踪模型的习题推荐系统源代码+数据库+使用说明,后端采用flask,前端采用vue
前端·数据库·flask
千穹凌帝1 小时前
SpinalHDL之结构(二)
开发语言·前端·fpga开发
dot.Net安全矩阵1 小时前
.NET内网实战:通过命令行解密Web.config
前端·学习·安全·web安全·矩阵·.net