JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第71章—源码篇-手写RSC(上)

首次发表于 2024-07-29, 更新于 2024-07-29

前言

本篇我们从零开始,手写一个 React Server Component 实现。为了帮助大家理解 React Server Component 的出现背景,我们会从最原始的页面实现方式开始讲起,跟随着 React 的发展历史不断完善代码,最终实现 React Server Component。

现在就让我们开始吧。

步骤 1:实现一个博客页面

首先创建项目,运行以下命令,完成项目初始化:

mkdir react-rsc && cd react-rsc

npm init

npm i tsx --save-dev

npm i express escape-html react react-dom --save

注:在《源码篇 | 手写 React SSR》,我们通过 webpack 和自定义配置实现了 JSX 语法的编译。本篇为了更加方便,我们将直接使用 tsx 进行处理,虽然文件会命名为 .ts.tsx,但我们并不会使用 TypeScript 语法,只是借助其对 JSX 语法的编译功能。

新建文件 index.ts,代码如下:

import express from "express";
import { readFile } from "fs/promises";
import escapeHtml from 'escape-html'

const app = express();

app.get("/:route(*)", async (req, res) => {
  const html = await htmlGenerator();
  res.setHeader("Content-Type", "text/html");
  res.end(html);
});

async function htmlGenerator() {
  const author = "YaYu";
  const postContent = await readFile("./posts/hello.txt", "utf8");

  return `<html>
  <head>
    <title>My blog</title>
    <script src="https://cdn.tailwindcss.com"></script>
  </head>
  <body class="p-5">
    <nav class="flex items-center justify-center gap-10 text-blue-600">
      <a href="/">Home</a>
    </nav>
    <article class="h-40 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
      ${escapeHtml(postContent)}
    </article>
    <footer class="h-20 mt-5 flex-1 rounded-xl bg-cyan-500 text-white flex items-center justify-center">
      (c) ${escapeHtml(author)}, ${new Date().getFullYear()}
    </footer>
  </body>
</html>`;
}

app.listen(3000, (err) => {
  if (err) return console.error(err);
  return console.log(`Server is listening on 3000`);
});

博客的具体内容我们会读取 /posts/hello.txt文件,所以新建 /posts/hello.txt,随便写入一些内容,比如:

<h1>Hello World!</h1>

修改 package.json文件中的脚本命令,添加代码如下:

{
  "scripts": {
    "start": "tsx watch ./index.ts"
  }
}

运行 npm start,此时效果如下:

image.png

  1. 功能实现:React RSC 实现
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/react-rsc-1
  3. 下载代码:git clone -b react-rsc-1 git@github.com:mqyqingfeng/next-app-demo.git

效果上,我们实现的是一个博客页面的简化版,顶部是导航栏,底部是页脚,中间是具体的文章内容。

技术实现上,我们使用 express 起了一个服务器,在读取了 txt 文件的内容后,通过模板字符串的形式,直接返回了页面 HTML 内容。

值得注意的是,当我们读取完 txt 的内容后,使用了 escape-html 对内容进行了转义。这是一种常见的内容安全处理。麻烦的地方在于,所有写入内容的地方,都需要自己添加逻辑处理,难道就没有更加简单、便捷、安全的使用方式呢?

步骤 2:发明 JSX

为了解决这个问题,React 发明了 JSX。你可以把它理解成一种特殊的模板语言。使用 JSX,你可以在 JavaScript 中直接使用 HTML 标签,比如:

const 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>

其中变量使用 {}进行包裹。这种语法,无论是 JavaScript 还是 HTML 其实都不能直接识别,所以使用 JSX 语法还需要搭配编译器(比如 Babel)使用,Babel 会将代码编译成如下形式:

import { jsx } from "react/jsx-runtime";

const res = jsx("html", {
  children: [
    jsx("head", {
      children: jsx("title", {
        children: "My blog"
      })
    }), 
  jsx("body", {
    children: [...]
  })]
});

之所以编译成这种函数执行的形式,是因为我们还需要在函数运行的时候读取外边的变量(就比如上图中的 postContent 和 author)。最终该函数会返回一个描述 HTML 的 JSON 对象(为了方便,我们就简称为 JSX 对象了),类似于如下这种形式:

// 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... */
              }              
            }
          ]
        }
      }
    ]
  }
}

所以我们写代码的时候,写的是:

const res = <html>...</html>

到 JavaScript 具体执行的时候,其实是一个对象:

const res = {
  $$typeof: Symbol.for("react.element"),
  type: 'html',
  props: {
    children: [ ... ]
  }
}

但有了描述 HTML 的 JSX 对象还不够,我们还需要一个 render 函数,将 JSX 对象渲染为具体的 HTML,返回给客户端的应该是这个具体的 HTML。

我们修改 index.ts,代码如下:

import express from "express";
import { htmlGenerator } from "./generator";
const app = express();

app.get("/:route(*)", async (req, res) => {
  const html = await htmlGenerator();
  res.setHeader("Content-Type", "text/html");
  res.end(html);
});

app.listen(3000, (err) => {
  if (err) return console.error(err);
  return console.log(`Server is listening on 3000`);
});

新建 generator.tsx,代码如下:

import { readFile } from "fs/promises";
import React from 'react';
import { renderJSXToHTML } from './utils'

export async function htmlGenerator() {
  const author = "YaYu";
  const postContent = await readFile("./posts/hello.txt", "utf8");

  let jsx = <html>
  <head>
    <title>My blog</title>
    <script src="https://cdn.tailwindcss.com"></script>
  </head>
  <body className="p-5">
    <nav className="flex items-center justify-center gap-10 text-blue-600">
      <a href="/">Home</a>
    </nav>
    <article className="h-40 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
      { postContent }
    </article>
    <footer className="h-20 mt-5 flex-1 rounded-xl bg-cyan-500 text-white flex items-center justify-center">
      (c) { author }, {new Date().getFullYear()}
    </footer>
  </body>
</html>

  return renderJSXToHTML(jsx);
}

这里我们直接使用了 JSX 语法,tsx 会帮助我们进行编译,我们就不需要引入 Webpack 和 Babel 来处理了。

新建 utils.ts,代码如下:

import escapeHtml from 'escape-html'

export function renderJSXToHTML(jsx) {
  if (typeof jsx === "string" || typeof jsx === "number") {
    return escapeHtml(jsx);
  } else if (jsx == null || typeof jsx === "boolean") {
    return "";
  } else if (Array.isArray(jsx)) {
    return jsx.map((child) => renderJSXToHTML(child)).join("");
  } else if (typeof jsx === "object") {
    if (jsx.$$typeof === Symbol.for("react.element")) {
      let html = "<" + jsx.type;
      for (const propName in jsx.props) {
        if (jsx.props.hasOwnProperty(propName) && propName !== "children") {
          html += " ";
          html += propName;
          html += "=";
          html += `"${escapeHtml(jsx.props[propName])}"`;
        }
      }
      html += ">";
      html += renderJSXToHTML(jsx.props.children);
      html += "</" + jsx.type + ">";
      html = html.replace(/className/g, "class")
      return html;
    } else throw new Error("Cannot render an object.");
  } else throw new Error("Not implemented.");
}

renderJSXToHTML 的代码并不复杂,简单的来说,就是不断判断 jsx 对象节点的类型,递归处理,最终拼接得到一个 HTML 字符串。

运行 npm start,此时效果不变:

image.png

  1. 功能实现:React RSC 实现
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/react-rsc-2
  3. 下载代码:git clone -b react-rsc-2 git@github.com:mqyqingfeng/next-app-demo.git

步骤 3:发明组件

这里我们写的是一篇博客页面,但其实每个博客页面内容都是相似的,有着相同的顶部导航和页脚,为了代码能够复用,React 引入了组件的概念,将重复的内容抽离成一个组件,用到的地方直接引入使用即可。

我们来实现一下,新建 components.tsx,代码如下:

import React from 'react';

export function BlogPostPage({ postContent, author }) {
  return (
    <html>
    <head>
      <title>My blog</title>
      <script src="https://cdn.tailwindcss.com"></script>
    </head>
    <body className="p-5">
      <nav className="flex items-center justify-center gap-10 text-blue-600">
        <a href="/">Home</a>
      </nav>
      <article className="h-40 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">
        { postContent }
      </article>
      <Footer author={author} />
    </body>
  </html>
  );
}

export function Footer({ author }) {
  return (
    <footer className="h-20 mt-5 flex-1 rounded-xl bg-cyan-500 text-white flex items-center justify-center">
      (c) { author }, {new Date().getFullYear()}
    </footer>
  );
}

这里我们将页脚抽离成 Footer 组件,然后在 BlogPostPage 组件中引入使用。

修改 generator.tsx,代码如下:

import { readFile } from "fs/promises";
import React from 'react';
import { renderJSXToHTML } from './utils'
import { BlogPostPage } from './components'

export async function htmlGenerator() {
  const author = "YaYu";
  const postContent = await readFile("./posts/hello.txt", "utf8");
  return renderJSXToHTML(<BlogPostPage postContent={postContent} author={author}/>);
}

此时页面会空白,查看其 HTML 如下:

image.png

这是因为我们的 renderJSXToHTML 函数目前还只能识别普通的 HTML 标签,对于像 <BlogPostPage> 这样的组件类型并不能处理。

我们在写组件的时候,写的是一个函数,函数执行后才返回具体的 JSX 对象。所以我们在 render 的时候,需要判断节点是否是函数,如果是函数,就执行函数,渲染函数返回的 JSX 对象。

修改 utils.js中的 renderJSXToHTML 函数,完整代码如下:

import escapeHtml from 'escape-html'

export function renderJSXToHTML(jsx) {
  if (typeof jsx === "string" || typeof jsx === "number") {
    return escapeHtml(jsx);
  } else if (jsx == null || typeof jsx === "boolean") {
    return "";
  } else if (Array.isArray(jsx)) {
    return jsx.map((child) => renderJSXToHTML(child)).join("");
  } else if (typeof jsx === "object") {
    if (jsx.$$typeof === Symbol.for("react.element")) {
      // 普通 HTML 标签
      if (typeof jsx.type === "string") {
        let html = "<" + jsx.type;
        for (const propName in jsx.props) {
          if (jsx.props.hasOwnProperty(propName) && propName !== "children") {
            html += " ";
            html += propName;
            html += "=";
            html += `"${escapeHtml(jsx.props[propName])}"`;
          }
        }
        html += ">";
        html += renderJSXToHTML(jsx.props.children);
        html += "</" + jsx.type + ">";
        html = html.replace(/className/g, "class")
        return html;
      }
      // 组件类型如 <BlogPostPage> 
      else if (typeof jsx.type === "function") {
        const Component = jsx.type;
        const props = jsx.props;
        const returnedJsx = Component(props);
        return renderJSXToHTML(returnedJsx); 
      } else throw new Error("Not implemented.");
    } else throw new Error("Cannot render an object.");
  } else throw new Error("Not implemented.");
}

运行 npm start,此时效果不变:

image.png

JSX 和组件不就是 React 的基础吗?从某种角度来讲,我们已经手写了一个 React 雏形。

  1. 功能实现:React RSC 实现
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/react-rsc-3
  3. 下载代码:git clone -b react-rsc-3 git@github.com:mqyqingfeng/next-app-demo.git

步骤 4:添加路由

现在我们实现了一个博客内容页面,但我想实现的效果是,当访问 / 的时候,展示博客文章列表,访问 /hello 的时候,才展示 hello.txt 这篇文章的具体内容。

我们再添加一篇文章,新建 /posts/earth.txt,内容随意,比如:

<h1>Hello Earth!</h1>

修改 components.tsx代码如下:

import React from 'react';

export function Layout({ children }) {
  const author = "YaYu";
  return (
    <html>
    <head>
      <title>My blog</title>
      <script src="https://cdn.tailwindcss.com"></script>
    </head>
    <body className="p-5">
      <nav className="flex items-center justify-center gap-10 text-blue-600">
        <a href="/">Home</a>
      </nav>
      <main>{children}</main>
      <Footer author={author} />
    </body>
  </html>
  );
}

export function IndexPage({ slugs, contents }) {
  return (
    <section>
      <h1>Blog List:</h1>
      <div>
        {slugs.map((slug, index) => (
          <section key={slug} className="mt-4">
            <a className="text-blue-600" href={"/" + slug}>{slug}</a>
            <article className="h-40 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">{contents[index]}</article>
          </section>
        ))}
      </div>
    </section>
  );
}

export function PostPage({ slug, content }) {
  return (
    <section>
      <a className="text-blue-600" href={"/" + slug}>{slug}</a>
      <article className="h-40 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">{content}</article>
    </section>
  );
}

export function Footer({ author }) {
  return (
    <footer className="h-20 mt-5 flex-1 rounded-xl bg-cyan-500 text-white flex items-center justify-center">
      (c) { author }, {new Date().getFullYear()}
    </footer>
  );
}

这里我们新建了 4 个组件,其中 Layout 负责基础的 HTML 样式,包含顶部的导航栏和页脚,Footer 负责页脚。IndexPage 负责首页的文章样式,PostPage 负责具体文章页面的样式。

当访问 / 的时候,应该导航至 IndexPage,当访问 /xxx 的时候,应该导航至 PostPage,这个功能就叫做路由(Router),不过现在我们先用一个 matchRouter 函数实现。

修改 index.ts,代码如下:

import express from "express";
import { htmlGenerator } from "./generator";
const app = express();

app.get("/:route(*)", async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  const html = await htmlGenerator(url);
  res.setHeader("Content-Type", "text/html");
  res.end(html);
});

app.listen(3000, (err) => {
  if (err) return console.error(err);
  return console.log(`Server is listening on 3000`);
});

我们获取了当前的页面地址,并将其传入 htmlGenerator 函数。

修改 generator.tsx,代码如下:

import { readFile, readdir } from "fs/promises";
import React from 'react';
import { renderJSXToHTML } from './utils'
import { Layout, IndexPage, PostPage } from './components'

export async function htmlGenerator(url) {
  const content = await readFile("./posts/hello.txt", "utf8");
  const page = await matchRoute(url);
  return renderJSXToHTML(<Layout>{page}</Layout>);
}

async function matchRoute(url) {
  if (url.pathname === "/") {
    const files = await readdir("./posts");
    const slugs = files.map((file) => file.slice(0, file.lastIndexOf(".")));
    const contents = await Promise.all(
      slugs.map((slug) =>
        readFile("./posts/" + slug + ".txt", "utf8")
      )
    );
    return <IndexPage slugs={slugs} contents={contents} />;
  } else {
    const slug = url.pathname.slice(1);
    const content = await readFile("./posts/" + slug + ".txt", "utf8");
    return <PostPage slug={slug} content={content} />;
  }
}

我们写了一个 matchRoute 函数,根据 URL 返回不同的组件(IndexPage 或 PostPage),然后将组件作为 children 传入 Layout 组件中,得到最终的 JSX 对象。

此时交互效果如下:

react-rsc.gif

  1. 功能实现:React RSC 实现
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/react-rsc-4
  3. 下载代码:git clone -b react-rsc-4 git@github.com:mqyqingfeng/next-app-demo.git

步骤 5:异步组件与 Router

其实目前的组件抽象还是有点问题的,IndexPage 和 PostPage 的文章样式(图中紫色部分)其实是重复的,我们应该抽离一个 Post 组件,然后 IndexPage 和 PostPage 引用 Post 组件。

不仅如此,现在我们在 matchRoute 这个函数中实现了路由匹配和获取数据(readdir、readFile),但其实没有必要,因为反正都是在服务端运行,获取数据完全可以放在具体的 Post 组件中运行,这样我们就可以将获取数据的代码从 matchRoute 中分离出来,让 matchRoute 如它的函数名一样,专注于路由匹配,而非掺杂数据获取的代码。

直接说似乎有点抽象,让我们写代码吧。

修改 components.tsx,完整代码如下:

import React from 'react';
import { readFile, readdir } from "fs/promises";

export function Layout({ children }) {
  const author = "YaYu";
  return (
    <html>
    <head>
      <title>My blog</title>
      <script src="https://cdn.tailwindcss.com"></script>
    </head>
    <body className="p-5">
      <nav className="flex items-center justify-center gap-10 text-blue-600">
        <a href="/">Home</a>
      </nav>
      <main>{children}</main>
      <Footer author={author} />
    </body>
  </html>
  );
}

export async function IndexPage() {
  const files = await readdir("./posts");
  const slugs = files.map((file) =>
    file.slice(0, file.lastIndexOf("."))
  );

  return (
    <section>
      <h1>Blog List:</h1>
      <div>
        {slugs.map((slug, index) => <Post key={index} slug={slug} />)}
      </div>
    </section>
  );
}

export function PostPage({ slug }) {
  return <Post slug={slug} />;
}

async function Post({ slug }) {
  let content = await readFile("./posts/" + slug + ".txt", "utf8");
  return (
    <section>
      <a className="text-blue-600" href={"/" + slug}>{slug}</a>
      <article className="h-40 mt-5 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">{content}</article>
    </section>
  )
}

export function Footer({ author }) {
  return (
    <footer className="h-20 mt-5 flex-1 rounded-xl bg-cyan-500 text-white flex items-center justify-center">
      (c) { author }, {new Date().getFullYear()}
    </footer>
  );
}

在这段代码中,我们抽离了一个 Post 组件,并将数据读取放在了 IndexPage 和 Post 组件中实现。因此我们的 matchRouter 函数得以简化,我们将函数替换为 Router 组件,修改 generator.tsx,代码如下:

import { readFile, readdir } from "fs/promises";
import React from 'react';
import { renderJSXToHTML } from './utils'
import { Layout, IndexPage, PostPage } from './components'

export async function htmlGenerator(url) {
  return renderJSXToHTML(<Router url={url} />);
}

function Router({ url }) {
  let page;
  if (url.pathname === "/") {
    page = <IndexPage />;
  } else {
    const slug = url.pathname.slice(1);
    page = <PostPage slug={slug} />;
  }
  return <Layout>{page}</Layout>;
}

此时页面渲染失败,是因为我们的组件函数使用了 async,所以渲染的时候,也要对应进行处理,修改 utils.ts,代码如下:

import escapeHtml from 'escape-html'

export async function renderJSXToHTML(jsx) {
  if (typeof jsx === "string" || typeof jsx === "number") {
    return escapeHtml(jsx);
  } else if (jsx == null || typeof jsx === "boolean") {
    return "";
  } else if (Array.isArray(jsx)) {
    // 这里添加了 await 和 Promise.all
    const childHtmls = await Promise.all(
      jsx.map((child) => renderJSXToHTML(child))
    );
    return childHtmls.join("");
  } else if (typeof jsx === "object") {
    if (jsx.$$typeof === Symbol.for("react.element")) {
      if (typeof jsx.type === "string") {
        let html = "<" + jsx.type;
        for (const propName in jsx.props) {
          if (jsx.props.hasOwnProperty(propName) && propName !== "children") {
            html += " ";
            html += propName;
            html += "=";
            html += `"${escapeHtml(jsx.props[propName])}"`;
          }
        }
        html += ">";
        // 这里添加了 await
        html += await renderJSXToHTML(jsx.props.children);
        html += "</" + jsx.type + ">";
        html = html.replace(/className/g, "class")
        return html;
      }
      else if (typeof jsx.type === "function") {
        const Component = jsx.type;
        const props = jsx.props;
        // 这里添加了 await
        const returnedJsx = await Component(props);
        return renderJSXToHTML(returnedJsx); 
      } else throw new Error("Not implemented.");
    } else throw new Error("Cannot render an object.");
  } else throw new Error("Not implemented.");
}

此时页面正常渲染:

react-rsc.gif

  1. 功能实现:React RSC 实现
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/react-rsc-5
  3. 下载代码:git clone -b react-rsc-5 git@github.com:mqyqingfeng/next-app-demo.git

回过头来看我们的 Router 组件:

function Router({ url }) {
  let page;
  if (url.pathname === "/") {
    page = <IndexPage />;
  } else {
    const slug = url.pathname.slice(1);
    page = <PostPage slug={slug} />;
  }
  return <Layout>{page}</Layout>;
}

它接收当前 URL,然后返回对应的组件。用过 React-Router 的同学可能知道,React-Rouer 有一个 StaticRouter,用于处理 node 环境下的路由,基本用法如下:

import * as React from "react";
import * as ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import http from "http";

function requestHandler(req, res) {
  let html = ReactDOMServer.renderToString(
    <StaticRouter location={req.url}>
      {/* The rest of your app goes here */}
    </StaticRouter>
  );

  res.write(html);
  res.end();
}

http.createServer(requestHandler).listen(3000);

你可以发现非常相似,也是接收当前 URL,返回对应的组件。从某种角度来说,我们实现了一个 React-Router 的雏形。

总结

本篇我们从最原始的页面开发方式开始,讲述了 React 和 React-Router 的起源背景,手写了 React 和 React-Router 最基础的实现方式。因为这个例子的所有代码都运行在服务端,所以这其实也是 React SSR 的基础实现,甚至比 《源码篇 | 手写 React SSR》 实现的还要基础,就比如我们手写的 renderJSXToHTML 对应的其实就是 root.render 函数。

不过至此,其实还没有涉及到任何 RSC 相关的内容,因为我们的进度相当于在追溯 React 的发展历史,目前才刚发展到 React SSR,下个阶段才开始进入 React Server Components 呢,快开始进入下一篇吧!

© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru