跳到主要内容

73-源码篇 手写 RSC(上)

前言

本篇我们从零开始,手写一个 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 呢,快开始进入下一篇吧!