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

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

相关推荐
潜意识起点15 分钟前
精通 CSS 阴影效果:从基础到高级应用
前端·css
奋斗吧程序媛19 分钟前
删除VSCode上 origin/分支名,但GitLab上实际上不存在的分支
前端·vscode
IT女孩儿29 分钟前
JavaScript--WebAPI查缺补漏(二)
开发语言·前端·javascript·html·ecmascript
m0_748256563 小时前
如何解决前端发送数据到后端为空的问题
前端
请叫我飞哥@3 小时前
HTML5适配手机
前端·html·html5
@解忧杂货铺5 小时前
前端vue如何实现数字框中通过鼠标滚轮上下滚动增减数字
前端·javascript·vue.js
F-2H6 小时前
C语言:指针4(常量指针和指针常量及动态内存分配)
java·linux·c语言·开发语言·前端·c++
gqkmiss7 小时前
Chrome 浏览器插件获取网页 iframe 中的 window 对象
前端·chrome·iframe·postmessage·chrome 插件
m0_748247559 小时前
Web 应用项目开发全流程解析与实战经验分享
开发语言·前端·php
m0_748255029 小时前
前端常用算法集合
前端·算法