背景
![](https://file.jishuzhan.net/article/1723214020497903617/06e209bd1c8754f151fb35b6ace53a1b.webp)
2023 年 6 月,当时还是 Meta 公司 React 团队成员的 Dan Abramov 在 Github reactwg/server-components repo 的讨论区发布了一篇深入介绍 React 服务器组件的文章《RSC From Scratch. Part 1: Server Components》。
"从头学 React 服务器组件(RSC From Scratch)"是一个系列,就像你看到的,这个系列第 1 篇的主题是介绍服务器组件(Server Component)。由于篇幅实在太长,内容量又极其丰富,因此我计划将这篇文章拆分成 6 个短篇来翻译介绍,主要采用"改写"的方式进行翻译,以期达到比较好的传达效果。
有能力的同学,推荐直接阅读原文进行学习。地址:github.com/reactwg/ser...。
当然,学习之前需要大家有一定的 React 使用经验,而且这个系列并不着眼于介绍如何使用服务器组件,而是通过实现一个低配版本的服务器组件来讲解其原理,理解引入它的背景和目的。
回到过去
如果将时间的齿轮拨回到 2003 年,那时的 Web 开发还处在一个比较早期的阶段,设备和工具都还很简陋。那个时候,用 PHP 来开发一个博客网站,是一种时尚,也是一种潮流。
PHP 版本
在网上查找一些资料后,了解了 PHP 的一些基本语法,写出了类似下面这样一个网页。
php
<?php
$author = "Jae Doe";
$post_content = file_get_contents("./posts/hello-world.txt");
?>
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr>
</nav>
<article>
<?php echo htmlspecialchars($post_content); ?>
</article>
<footer>
<hr>
<p><i>(c) <?php echo htmlspecialchars($author); ?>, <?php echo date("Y"); ?></i></p>
</footer>
</body>
</html>
注意:为了方便阅读、理解,我们使用了在 HTML 5 中定义的一些语意化标签(
<nav>
、<article>
和<footer>
),我们假装它们当时已经存在就行了。
不熟悉 PHP 的同学,也没有关系,我做个简单的说明。
- 这个
.php
文件会被上传到服务器,使用浏览器访问时,这个文件会首先在服务中进行编译,具体说就是将 PHP 文件转成纯 HTML,然后发送回浏览器进行解析展示 <?php //... >
中的内容,就是会在服务器执行的 PHP 代码- 我们定义了两个变量
$author
和$post_content
,表示博客作者,以及博文内容。其中$post_content
的内容是从服务器本地的hello-world.txt
文件中读取的(使用 PHP 内置函数file_get_contents()
) - 我们还使用了内置函数
htmlspecialchars()
对$author
和$post_content
的内容中的特殊字符,转换成 HTML 实体,确保文本里的内容不会意外当做 HTML;另外,还使用了date()
函数获取当前年份 echo xxx
用来将后面的返回值替换当前代码,并最终打印到生成的 HTML 页面上
我们假设 hello-world.txt
的内容如下。
csharp
Hi everyone! This is my first blog post. I <3 React.
最终,这个 .php
文件返回到浏览器的内容是这样的。
html
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr>
</nav>
<article>
Hi everyone! This is my first blog post. I <3 React.
</article>
<footer>
<hr>
<p><i>(c) Jae Doe, 2023</i></p>
</footer>
</body>
</html>
浏览器访问 http://locahost:8080/hello-world
,看到的效果。
![](https://file.jishuzhan.net/article/1723214020497903617/1ef78b98cb23a8225ffd9f89314cce62.webp)
Node.js 版本
当然,如果是用 Node.js 来编写这个应用的话,是下面这样。
javascript
import { createServer } from 'http';
import { readFile } from 'fs/promises';
import escapeHtml from 'escape-html';
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(
res,
`<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<article>
${escapeHtml(postContent)}
</article>
<footer>
<hr>
<p><i>(c) ${escapeHtml(author)}, ${new Date().getFullYear()}</i></p>
</footer>
</body>
</html>`
);
}).listen(8080);
function sendHTML(res, html) {
res.setHeader("Content-Type", "text/html");
res.end(html);
}
注意:介绍当时的 CD-ROM("光盘只读存储器") 上能顺利跑我们的 Node.js 引擎。
相比较于 PHP 来说,Node.js 的实现会更加简单。启动一个服务器后,直接以模板字符串的形式组织网页,并将内容以 HTML 格式发送回浏览器。效果与 PHP 实现一样。
![](https://file.jishuzhan.net/article/1723214020497903617/1ef78b98cb23a8225ffd9f89314cce62.webp)
(这里有一个 线上 demo,点击查看)
这个时候,从未来过来的你,如果要把 React 编码范式带过来,你会以什么样的顺序添加功能?
发明 JSX!
回顾一下 Node.js 版本的代码实现,一个不太理想的地方是在操作 HTML 的时候。 我们使用纯字符串的形式合成最终的 HTML 代码,还要调用 escapeHtml(postContent) 确保文本里的内容不会意外当做 HTML。
这个时候我们会想到引入一种模板语言,将计算逻辑跟模板分离,提供一种方法来为文本和属性注入动态值,同时又能在模板内安全地转义文本内容,还支持使用某种条件判断和循环的语法。这也是 2003 年一些最流行的以服务器为中心的框架所采用的方法。
当然,有了 React 使用经验的我们,会想到这样做。
引入 JSX
javascript
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(
res,
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<article>
{postContent}
</article>
<footer>
<hr />
<p><i>(c) {author}, {new Date().getFullYear()}</i></p>
</footer>
</body>
</html>
);
}).listen(8080);
是不是有点 React 的味道了?我们的"模板"不再是字符串了。我们在 JavaScript 中引入了一个类似 XML 的语法子集(也就是 JSX),避免在使用字符串插值时,可能会出现的标记不匹配(如:没有写闭合标签)或忘记转义文本内容的问题。
在底层,JSX 会生成一个由对象组成的树结构,看起来像这样。
javascript
// Slightly simplified
{
$$typeof: Symbol.for("react.element"), // Tells React it's a JSX element (e.g. <html>)
type: 'html',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'head',
props: {
children: {
$$typeof: Symbol.for("react.element"),
type: 'title',
props: { children: 'My blog' }
}
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'body',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'nav',
props: {
children: [{
$$typeof: Symbol.for("react.element"),
type: 'a',
props: { href: '/', children: 'Home' }
}, {
$$typeof: Symbol.for("react.element"),
type: 'hr',
props: null
}]
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'article',
props: {
children: postContent
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'footer',
props: {
/* ...And so on... */
}
}
]
}
}
]
}
}
JSX 节点类型说明
这个树结构种每一个节点类似这样:
javascript
{
$$typeof: Symbol.for("react.element"),
type: 'head',
props: {
children: {}
}
}
说明如下:
$$typeof
属性值固定为Symbol.for("react.element")
,表示这个元素结构是符合 React 节点规范的type
表示标记名称,目前仅支持字符串类型,比如"head"
、"body"
、"nav"
、"a"
等等props
则是用来表示标记上的 attribute,比如id
、className
之类的,没有即为null
props.children
是一个特殊属性,表示当前元素的所有子元素,可以是:- 一个字符串、数字(表示仅包含一个文本)
undefind
/null
/布尔(表示没有子元素,当前已经是叶子元素了)- 一个数组(表示包含多个子元素)
- 一个对象(表示仅包含一个子元素)
虽然我们现在能成功使用这种树结构表示 JSX 了,但我们需要发送到浏览器的是 HTML,而不是 JSON 树(当然,不一定。不过,至少目前是这样)。
编写 JSX 渲染函数
由此,我们就需要编写一个函数,将 JSX 所代表的底层对象转换成 HTML 字符串。为此,我们需要对不同类型的节点(字符串、数字、数组或带子节点(有 .children
属性)的 JSX 节点)做判断分别处理,并最终转换为 HTML 片段。
我们将渲染函数定义成 renderJSXToHTML(jsx)
,来看下它的实现。
javascript
function renderJSXToHTML(jsx) {
if (typeof jsx === "string" || typeof jsx === "number") {
// 是一个文本节点。转义处理之后可以直接放到 HTML 字符串里
return escapeHtml(jsx);
} else if (jsx == null || typeof jsx === "boolean") {
// 表示一个空节点,返回一个空字符串就行
return "";
} else if (Array.isArray(jsx)) {
// 处理多个子元素。递归调用 renderJSXToHTML() 函数,每个元素单独处理返回 HTML 字符串片段,并最终拼接到一起
return jsx.map((child) => renderJSXToHTML(child)).join("");
} else if (typeof jsx === "object") {
// 渲染 React JSX 元素 (e.g. <div />).
if (jsx.$$typeof === Symbol.for("react.element")) {
// 拼接 HTML tag.
let html = "<" + jsx.type;
// 处理除 .children 外的所有其他 prop,映射成对应标记上的 attribute
for (const propName in jsx.props) {
if (jsx.props.hasOwnProperty(propName) && propName !== "children") {
html += " ";
html += propName;
html += "=";
html += escapeHtml(jsx.props[propName]); // 对 prop 值进行到 HTML 实体的转义
}
}
html += ">";
// 处理 .children prop
html += renderJSXToHTML(jsx.props.children);
html += "</" + jsx.type + ">";
return html;
} else throw new Error("Cannot render an object.");
} else throw new Error("Not implemented.");
}
实现原理就是针对 JSX 节点所有可能的类型分别进行判断处理,最终返回一个表示 HTML 字符串的过程。
这里提供了一个线上 demo,打开看看,我们的 JSX 是如何被渲染 HTML 字符串提供出去的!
![](https://file.jishuzhan.net/article/1723214020497903617/1ef78b98cb23a8225ffd9f89314cce62.webp)
将 JSX 转换为 HTML 字符串通常称为"服务器端渲染(Server-Side Rendering,简称SSR)"。需要注意的是,RSC 和 SSR 是两个非常不同的东西(不过通常又会一起使用)。在本系列的文章中,我们从 SSR 开始,因为这是我们在服务器环境中,首先会想到并会做的一个尝试。这只是我们诸多过程里第一步,后面会看到实现上的明显差别。
总结
本文我们首先回顾了 2000 年早期,使用 PHP 编写博客网页的例子。接着写出了同功能的 Node.js 版本。然后,依靠我们携带的 React 先进思想,引入了 JSX------这是一种比传统模板工具具有更好表现力的 JS 扩展,写起来也更便捷------让我们可以在 JS 种直接编写 HTML 标记。不过,JSX 底层的对象树结构表示并不能给浏览器直接使用,所以我们又引入了将 JSX 结构转换为 HTML 字符串的、一个简单版本的渲染函数 renderJSXToHTML()
。
有了 JSX,下一节我们将介绍组件,这是一种复用代码的策略,能极大提升网页开发效率。当然,这就需要我们对现有的 renderJSXToHTML()
进行扩展来支持。
到此为止,下一篇再说,再见!