从头学服务器组件#1:发明 JSX

背景

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,看到的效果。

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 实现一样。

(这里有一个 线上 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,比如 idclassName 之类的,没有即为 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 字符串提供出去的!

将 JSX 转换为 HTML 字符串通常称为"服务器端渲染(Server-Side Rendering,简称SSR)"。需要注意的是,RSC 和 SSR 是两个非常不同的东西(不过通常又会一起使用)。在本系列的文章中,我们从 SSR 开始,因为这是我们在服务器环境中,首先会想到并会做的一个尝试。这只是我们诸多过程里第一步,后面会看到实现上的明显差别。

总结

本文我们首先回顾了 2000 年早期,使用 PHP 编写博客网页的例子。接着写出了同功能的 Node.js 版本。然后,依靠我们携带的 React 先进思想,引入了 JSX------这是一种比传统模板工具具有更好表现力的 JS 扩展,写起来也更便捷------让我们可以在 JS 种直接编写 HTML 标记。不过,JSX 底层的对象树结构表示并不能给浏览器直接使用,所以我们又引入了将 JSX 结构转换为 HTML 字符串的、一个简单版本的渲染函数 renderJSXToHTML()

有了 JSX,下一节我们将介绍组件,这是一种复用代码的策略,能极大提升网页开发效率。当然,这就需要我们对现有的 renderJSXToHTML() 进行扩展来支持。

到此为止,下一篇再说,再见!

相关推荐
Hellc0073 分钟前
MacOS升级ruby版本
前端·macos·ruby
前端西瓜哥12 分钟前
贝塞尔曲线算法:求贝塞尔曲线和直线的交点
前端·算法
又写了一天BUG13 分钟前
npm install安装缓慢及npm更换源
前端·npm·node.js
cc蒲公英26 分钟前
Vue2+vue-office/excel 实现在线加载Excel文件预览
前端·vue.js·excel
Java开发追求者27 分钟前
在CSS中换行word-break: break-word和 word-break: break-all区别
前端·css·word
好名字082131 分钟前
monorepo基础搭建教程(从0到1 pnpm+monorepo+vue)
前端·javascript
pink大呲花39 分钟前
css鼠标常用样式
前端·css·计算机外设
Flying_Fish_roe39 分钟前
浏览器的内存回收机制&监控内存泄漏
java·前端·ecmascript·es6
c#上位机1 小时前
C#事件的用法
java·javascript·c#
小小竹子1 小时前
前端vue-实现富文本组件
前端·vue.js·富文本