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

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

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

回顾

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

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

第 1 步:避免重复工作

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

javascript 复制代码
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 树如何?

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

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

javascript 复制代码
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 查看效果。

第 2 步:使用 React 来渲染HTML

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

javascript 复制代码
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:这个服务器用来运行我们的组件。总是对外输出 JSX --- 不输出 HTML。如果我们的组件正在访问数据库,那么在靠近数据中心的位置运行这个服务器是有意义的,这样会降低延迟
  • server/ssr.js:这个服务器用来生成 HTML,提供静态资源

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

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 输出:

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(用于初始加载):

jsx 复制代码
// 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 查看效果。

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

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

数据流向说明

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

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

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

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

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

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

总结

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

感谢你的阅读,Happy Coding!

相关推荐
小白小白从不日白20 分钟前
react hooks--useCallback
前端·react.js·前端框架
恩婧29 分钟前
React项目中使用发布订阅模式
前端·react.js·前端框架·发布订阅模式
mez_Blog30 分钟前
个人小结(2.0)
前端·javascript·vue.js·学习·typescript
珊珊而川38 分钟前
【浏览器面试真题】sessionStorage和localStorage
前端·javascript·面试
森叶1 小时前
Electron 安装包 asar 解压定位问题实战
前端·javascript·electron
drebander1 小时前
ubuntu 安装 chrome 及 版本匹配的 chromedriver
前端·chrome
软件技术NINI1 小时前
html知识点框架
前端·html
深情废杨杨1 小时前
前端vue-插值表达式和v-html的区别
前端·javascript·vue.js
GHUIJS1 小时前
【vue3】vue3.3新特性真香
前端·javascript·vue.js
markzzw1 小时前
我在 Thoughtworks 被裁前后的经历
前端·javascript·面试