从头学服务器组件#6:代码清理与服务端拆分

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

  1. 发明 JSX[2]
  2. 发明组件[3]
  3. 添加路由[4]
  4. 发明异步组件[5]
  5. 在导航间保留状态[6]
  6. 代码清理与服务端拆分(本文)

回顾

在上一篇文章《从头学服务器组件#5:在导航间保留状态》[7]中,我们为服务端增加了返回 JSX 数据的支持,并使用 React 在客户端进行消费,实现基于 JSX  的结构的页面初始化和页面局部更新。

在最后这篇文章,我们将对现在的代码做一些清理工作,修复一些缺陷,并对服务端进行拆分,为下一波功能添加做好准备。

第 1 步:避免重复工作

再看一下我们初始生成 HTML 的代码[8]

async function sendHTML(res, jsx{
  // We need to turn <Router /> into "<html>...</html>" (a string):
  let html = await renderJSXToHTML(jsx);

  // We *also* need to turn <Router /> into <html>...</html> (an object):
  const clientJSX = await renderJSXToClientJSX(jsx);

假设 jsx 值为 <Router url="https://localhost:3000" />

首先,我们调用 renderJSXToHTML,它会在创建 HTML 字符串时递归调用 Router 和其他组件。但我们还需要发送初始客户端 JSX — 因此之后还要调用 renderJSXToClientJSX,这会再次调用 Router 和所有其他组件。也就是说,每个组件都调用了两次!这不仅很慢,而且还可能发生错误。例如,如果我们渲染的是一个 Feed 组件,那么前后的调用输出可能是不同的。因此,我们需要重新思考数据的流动方式。

如果我们先生成客户端 JSX 树如何?

async function sendHTML(res, jsx{
  // 1. Let's turn <Router /> into <html>...</html> (an object) first:
  const clientJSX = await renderJSXToClientJSX(jsx);

到这里,我们所有的组件都执行一遍了。然后,将 clientJSX 直接带入 renderJSXToHTML 就能生成 HTML 了!

async function sendHTML(res, jsx{
  // 1. Let's turn <Router /> into <html>...</html> (an object) first:
  const clientJSX = await renderJSXToClientJSX(jsx);
  // 2. Turn that <html>...</html> into "<html>...</html>" (a string):
  let html = await renderJSXToHTML(clientJSX);
  // ...

这样,程序依然能正常运转,同时每个组件也调用一次。

点击这里的 线上 demo[9] 查看效果。

第 2 步:使用 React 来渲染HTML

我们的自定义函数 renderJSXToHTML 最初引入是为了正确执行组件调用。例如,对 async 函数的处理。现在,renderJSXToHTML 接收到的已经时预先计算好的客户端 JSX 树,所以无需再执行处理,保留它也没有意义了。因此,我们删除 renderJSXToHTML,并使用 React 内置的 renderToString进行替换。

import { renderToString } from 'react-dom/server';

// ...

async function sendHTML(res, jsx{
  const clientJSX = await renderJSXToClientJSX(jsx);
  let html = renderToString(clientJSX);
  // ...

这块代码跟客户端代码有些的相似。尽管我们已经实现了新功能(例如 async 组件),但仍然可以使用现有的 React API(例如 renderToStringhydrateRoot),只不过使用方式不同而已。

在传统的服务器渲染 React 应用程序中,我们可以使用根 <App /> 组件调用 renderToStringhydrateRoot。但在我们的方法中,我们首先使用了 renderJSXToClientJSX 计算“服务器”JSX 树,并将其输出传递给 React API。

在传统的服务器渲染 React 应用程序中,组件在服务器和客户端上是以相同方式执行的。但在我们的方法中,像 RouterBlogIndexPageFooter 这样的组件实际上仅适用于服务器(至少目前是这样)。

renderToStringhydrateRoot 来说,RouterBlogIndexPageFooter 组件似乎从来就没存在过。因为,等到调用它们时,这些服务端组件已经从 JSX 树上处理掉了,只留下最后的纯客户端产物。

第 3 步:拆分服务端代码

在上一步中,我们将运行组件与生成 HTML 做了解耦:

  • 首先,调用 renderJSXToClientJSX 函数执行我们的组件来生成客户端 JSX
  • 然后,调用 renderToString 函数将客户端 JSX 转换成 HTML

由于这些步骤是独立的,因此它们不必在同一进程中完成,甚至不必在同一台机器上完成。

为了演示这一点,我们将 server.js 拆分为两个文件:

  • server/rsc.js[10]:这个服务器用来运行我们的组件。总是对外输出 JSX — 不输出 HTML。如果我们的组件正在访问数据库,那么在靠近数据中心的位置运行这个服务器是有意义的,这样会降低延迟
  • server/ssr.js[11]:这个服务器用来生成 HTML,提供静态资源

我们在 package.json 中启动这两个服务:

{
  "scripts": {
    "start""concurrently "npm run start:ssr" "npm run start:rsc"",
    "start:rsc""nodemon -- --experimental-loader ./node-jsx-loader.js ./server/rsc.js",
    "start:ssr""nodemon -- --experimental-loader ./node-jsx-loader.js ./server/ssr.js"
  },
}

咱们这个 demo 里,两个服务位于同一台计算机上,但实际上时可以单独进行托管的。

RSC 服务器是渲染我们的组件的服务器,只提供 JSX 输出:

// server/rsc.js

createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    await sendJSX(res, <Router url={url} />);
  } catch (err) {
    console.error(err);
    res.statusCode = err.statusCode ?? 500;
    res.end();
  }
}).listen(8081);

function Router({ url }{
  // ...
}

// ...
// ... All other components we have so far ...
// ...

async function sendJSX(res, jsx{
  // ...
}

function stringifyJSX(key, value{
  // ...
}

async function renderJSXToClientJSX(jsx{
  // ...
}

另一个是 SSR 服务器。SSR 服务器也是我们用户会访问的服务器,向 RSC 服务器发出 JSX 请求,然后再将 JSX 作为字符串提供(用于页面之间的导航),或者将其转换为 HTML(用于初始加载):

// server/ssr.js

createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    if (url.pathname === "/client.js") {
      // ...
    }
    // Get the serialized JSX response from the RSC server
    const response = await fetch("http://127.0.0.1:8081" + url.pathname);
    if (!response.ok) {
      res.statusCode = response.status;
      res.end();
      return;
    }
    const clientJSXString = await response.text();
    if (url.searchParams.has("jsx")) {
      // If the user is navigating between pages, send that serialized JSX as is
      res.setHeader("Content-Type""application/json");
      res.end(clientJSXString);
    } else {
      // If this is an initial page load, revive the tree and turn it into HTML
      const clientJSX = JSON.parse(clientJSXString, parseJSX);
      let html = renderToString(clientJSX);
      html += `<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;
      html += JSON.stringify(clientJSXString).replace(/</g"\u003c");
      html += `</script>`;
      // ...
      res.setHeader("Content-Type""text/html");
      res.end(html);
    }
  } catch (err) {
    // ...
  }
}).listen(8080);

点击这里的 线上 demo[12] 查看效果。

在后续系列,我们将 RSC 和“其他部分”(SSR 和用户计算机)之间保持分离。分离的好处会在后续向这两块添加新功能显现。

(严格来说,技术上可以在同一进程中运行 RSC 和 SSR,但它们的模块环境必须相互隔离。这是一个高级主题,超出了本系列的讨论范围。)

数据流向说明

终于,我们终于实现了一个简易版本的 RSC 实现。看起来我们好像写了很多代码,但实际上真没有多少。

  • server/rsc.js[13]:共 160 行代码,其中 80 行都是组件代码
  • server/ssr.js[14]:共 60 行代码
  • client.js[15]:也是 60 行代码

通读一遍。也为了更好的帮助我们理解数据流向,这里画了 2 张图表。

这是页面首次加载期间的数据流向:

从头学服务器组件#6:代码清理与服务端拆分

当在页面之间导航时的数据流向:

从头学服务器组件#6:代码清理与服务端拆分

最后,总结一下图表里用到一些术语:

  • React Server(或是 Server)只用来指 RSC 服务器环境。只存在于 RSC 服务器上的组件(这个系列里我们写得所有组件都是)称为服务器组件(Server Components)
  • React Client(或是 Client)是指任何消费 React Server 输出产物的环境。如你所见,SSR 只是一个 React 客户端[16],浏览器也是如此。目前,我们还不支持在客户端上的组件,也就是所谓的客户端组件(Client Components),后续我们会实现。

总结

作为本系列的最后一篇,我们移除了之前的 renderJSXToHTML 函数实现,并使用 React API renderToString 替代。另外,我们还将服务端代码一份成二,拆分成可单独部署的 RSC 服务与 SSR 服务,把执行服务器组件与生成 HTML 解耦了,也为后续继续添加功能带来便捷。

感谢你的阅读,Happy Coding!

参考资料

[1]

《RSC From Scratch. Part 1: Server Components》: https://github.com/reactwg/server-components/discussions/5

[2]

发明 JSX: https://juejin.cn/post/7299745570812821558

[3]

发明组件: https://juejin.cn/post/7299849645207158795

[4]

添加路由: https://juejin.cn/post/7301558181058658345

[5]

发明异步组件: https://juejin.cn/post/7302330573380616204

[6]

在导航间保留状态: https://juejin.cn/post/7304973928040005673

[7]

《从头学服务器组件#5:在导航间保留状态》: https://www.yuque.com/zhangbao/blog/server-component-05

[8]

初始生成 HTML 的代码: https://codesandbox.io/p/sandbox/vigorous-lichterman-i30pi4?file=%2Fserver.js%3A118%2C1-119%2C53

[9]

线上 demo: https://codesandbox.io/p/sandbox/serverless-morning-ith5fg?file=%2Fserver.js

[10]

server/rsc.js: https://codesandbox.io/p/sandbox/agitated-swartz-4hs4v1?file=%2Fserver%2Frsc.js

[11]

server/ssr.js: https://codesandbox.io/p/sandbox/agitated-swartz-4hs4v1?file=%2Fserver%2Fssr.js

[12]

线上 demo: https://codesandbox.io/p/sandbox/agitated-swartz-4hs4v1?file=%2Fserver%2Fssr.js

[13]

server/rsc.js: https://codesandbox.io/p/sandbox/agitated-swartz-4hs4v1?file=%2Fserver%2Frsc.js

[14]

server/ssr.js: https://codesandbox.io/p/sandbox/agitated-swartz-4hs4v1?file=%2Fserver%2Fssr.js

[15]

client.js: https://codesandbox.io/p/sandbox/agitated-swartz-4hs4v1?file=%2Fclient.js%3A1%2C1

[16]

SSR 只是一个 React 客户端: https://github.com/reactwg/server-components/discussions/4


原文始发于微信公众号(写代码的宝哥):从头学服务器组件#6:代码清理与服务端拆分

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/243689.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!