从头学服务器组件#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!

相关推荐
别拿曾经看以后~1 小时前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
我要洋人死1 小时前
导航栏及下拉菜单的实现
前端·css·css3
川石课堂软件测试1 小时前
性能测试|docker容器下搭建JMeter+Grafana+Influxdb监控可视化平台
运维·javascript·深度学习·jmeter·docker·容器·grafana
科技探秘人1 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人1 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR1 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香1 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q2498596931 小时前
前端预览word、excel、ppt
前端·word·excel
小华同学ai1 小时前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
problc2 小时前
Flutter中文字体设置指南:打造个性化的应用体验
android·javascript·flutter