服务端渲染的项目组织形式

服务端渲染的方式主要有两种:无服务器支持的服务端渲染和有服务器支持的服务端渲染。这两种服务端渲染方式的区别主要在于项目最终部署的形态,在项目开发过程中,一个用于支持服务端渲染的 NodeJS 服务器还是必不可少的。

如果从服务端渲染的入口文件接管程度来区分,则可以分为部分文档接管和全部文档接管两种。这两套项目组织形式相互之间并不冲突,在使用的时候不要将其强行归类。

是否需要服务端支持

与客户端渲染不同,SSR 需要一个 NodeJS 服务运行在服务器上,用来为客户端的运行提供所需的 HTML 片段。但是根据应用所需要内容的不同,这个 NodeJS 服务其实并不是绝对必需的。

无服务端支持的 SSR

在本章的引言中曾经说到,SSR 的一个优势就是可以直接访问服务端的数据库来获取数据,其实,SSR 的服务端代码还可以通过 NodeJS 提供的功能,直接从文件系统中获取文件内容或者静态数据。

如果 SSR 项目的数据访问获取的全部都是这种内容,那么这个 SSR 项目在部署的时候就不必使用一个 NodeJS 服务来负载。SSR 项目在编译的时候,可以直接将磁盘上的文件内容和其他静态内容直接打包编译进项目的发布文件中。这种模式最常见的用法是纯静态化的网站,例如博客、消息系统等。

例如以下这个组件,在编译的时候就可以在编译的时候提前生成所有的内容。

import marked from "marked";
import fs from "node:fs";
import sanitizeHtml from "sanitize-html";

async function Page({ page }) {
  const content = await fs.readFile(`./pages/${page}.md`, "utf-8");

  return <div>{sanitizeHtml(marked(content))}</div>;
}

如果项目中都是由这样的组件组成,那么这个项目在部署的时候就无需一个额外的 NodeJS 服务,而是直接与传统 SPA 式的项目一样进行部署即可。

如果不使用 SSR,那么上面这个组件的功能可能就需要使用以下两个分别运行在客户端和服务端的脚本组成。

// 客户端用于渲染Page内容的组件
import marked from "marked";
import { useEffect, useState } from "react";
import sanitizeHtml from "sanitize-html";

function Page({ page }) {
  const [content, setContent] = useState("");

  useEffect(() => {
    fetch(`/api/pages/${page}.md`)
      .then((res) => res.text())
      .then((markdown) => setContent(marked(markdown)));
  }, [page]);

  return <div>{sanitizeHtml(marked(content))}</div>;
}
// 服务端用于读取文件的API服务
import fs from "fs";

app.get("/api/pages/:page", async (req, res) => {
  const page = req.params.page;
  const content = await fs.readFile(`./pages/${page}.md`, "utf-8");
  res.send(content);
});

有服务端支持的 SSR

有服务端支持的 SSR 在日常使用中会出现的更加频繁,毕竟不是所有的项目都只需要访问文件系统或静态内容就可以完成其业务功能的。例如常见的从数据库中读取数据,并展示在页面上。例如传统的读取一组 Post 的功能,使用客户端渲染的方式,就需要同时编写客户端部分和从数据库获取数据的部分。

例如以下这个在传统客户端渲染中常见的例子。

// 服务端运行用于从数据库读取数据的API服务
import db from "../database";

app.get("/api/posts/:id", async (req, res) => {
  const post = await db.posts.get(id);

  res.send({ post });
});
// 客户端运行用于将服务端API传递来的数据展示到UI的组件
import { useEffect, useState } from "react";

function Post({ id }) {
  const [post, setPost] = useState({ content: "", author: "" });

  useEffect(() => {
    fetch(`/api/posts/${id}`)
      .then((res) => res.json())
      .then((data) => setPost(data.post));
  }, [id]);

  return (
    <div>
      <p>{post.content}</p>
      <p>{post.author}</p>
    </div>
  );
}

这种需要从数据库读取数据的动态组件是无法在编译的时候是无法通过穷举生成所有相关内容组件的 HTML 片段的。所以它在转换成 SSR 形式的时候,就需要使用一个 NodeJS 服务托管。不过这样一来,上面这个示例就可以大大简化了,会变成下面的形式。

import db from "../database";

async function Post({ id }) {
  const post = await db.posts.get(id);

  return (
    <div>
      <p>{post.content}</p>
      <p>{post.author}</p>
    </div>
  );
}

对比一下这个示例的两种实现形式,SSR 的形式是不是更加简单直观?

Tip

如果应用有固定的API服务,不需要使用数据库访问的功能,那么从实际应用角度上说,SSR能够提供的优势就仅在SEO优化上了。不过即便是在服务端使用fetch来访问其他的API服务,那么代码的简单程度也是比传统客户端渲染要简单很多的。

如何接管启动文档

对于文档的接管形式,主要是在于用于负载和引导应用加载的index.html文件在项目中是如何存在的。在客户端获取到index.html文件以后,就可以完成 React 组件树向 HTML DOM 插入的操作了,这个过程无论是对于 SSR 还是客户端渲染,都是一样的。

Tip

官网和网络上的大部分示例都是采用的NodeJS运行时 + Express框架的服务组织形式,本书中将采用Bun运行时 + Elysia框架的高性能服务组织形式。

部分文档的接管

部分文档的接管模式,其实还是跟客户端渲染的形式是一样的,项目中还是有一个index.html文件的,只是 NodeJS 服务需要从磁盘中读取index.html文件的内容,并将其提供出去。

例如在下一节中会提到 NodeJS 服务的启动文件server.ts就需要这样来读取index.html的内容,并完成 React 根组件的拼入。这里先提供这个server.ts文件的全貌。

import Elysia from "elysia";
import { connect } from "elysia-connect-middleware";
import { createElement } from "react";
import { renderToReadableStream } from "react-dom/server";
import { createServer } from "vite";
import { App } from "./src/App";

const isProduction = process.env.NODE_ENV === "production";
const base = process.env.BASE || "/";

let template = "";
if (!isProduction) {
  const templateFile = Bun.file("index.html");
  template = await templateFile.text();
}

const app = new Elysia();
const vite = await createServer({
  server: { middlewareMode: true },
  appType: "custom",
});
app.use(connect(vite.middlewares));
app.get("/", async ({ request }) => {
  const url = request.url.replace(base, "");
  const app = createElement(App);
  const rootComponent = await renderToReadableStream(app);
  const rootComponentString = await Bun.readableStreamToText(rootComponent);

  if (!isProduction) {
    template = await vite.transformIndexHtml(url, template);
  }

  const html = template.replace(`<!--ssr-outlet-->`, () => rootComponentString);

  return new Response(html, {
    headers: {
      "Content-Type": "text/html",
    },
  });
});

app.listen(3000);

console.log(
  `Server running at http://${app.server?.hostname}:${app.server?.port}`
);

这个server.ts文件中读取的index.html文件的内容如下:

<!doctype html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Vite + React + TS</title>
</head>

<body>
  <div id="root"><!--ssr-outlet--></div>
  <script type="module" src="/src/main.tsx"></script>
</body>

</html>

可以看出来这个用于 SSR 的index.html文件的内容与传统客户端渲染用的index.html文件的内容基本上没有什么区别。

server.ts的模板处理中,使用template.replace()index.html中的<!--ssr-outlet-->标记替换成了 React 应用的根组件渲染内容。

这里渲染 React 根组件使用的是 React DOM 提供的函数renderToReadableStream,这个可以将 React 的 DOM 渲染成为一个ReadableStream。但是需要注意的是,如果使用的是 NodeJS 运行时,ReadableStream是不被支持的,需要换成使用renderToPipeableStream来获取 NodeJS 支持的PipeableStream完成后续的操作。ReadableStream一般仅在使用 Bun 或者 Deno 运行时的情况下使用。

Tip

React DOM提供的渲染函数还有两个,分别是renderToStaticMarkuprenderToString,这两个都支持将React组件树直接渲染为字符串,其中renderToStaticMarkup渲染出来的字符串是不能够被水合的,所以renderToString相对更加常用一些。

React DOM 提供的渲染函数的使用及其配置项目可以参考 React 官网的说明,不过其中最常用的配置项是下一节中接管全部文档时提供客户端脚本载入用的。

全部文档的接管

如果使用接管全部文档的形式,那么index.html这个文件就不再需要了。所有的变化实际上在上一节中没有出现的App.tsx文件中。

如果由App.tsx文件接管index.html的全部内容,那么App.tsx文件的内容就会变成以下样子。

import { StrictMode } from "react";
import { Route, Routes, StaticRouter } from "react-router-dom";
import { MainLayout } from "./pages/MainLayout";

export const App = () => {
  return (
    <StrictMode>
      <html lang="zh-CN">
        <head>
          <meta charSet="utf-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1" />
          <title>React SSR App</title>
          <link rel="stylesheet" href="/src/index.css" />
        </head>
        <body>
          <StaticRouter>
            <Routes>
              <Route path="/" element={<MainLayout />} />
            </Routes>
          </StaticRouter>
        </body>
      </html>
    </StrictMode>
  );
};

在之前一节的示例中,App.tsx中的内容仅包括了<body><StaticRouter>的内容。但是在这个示例中,App.tsx中直接包括了整个 HTML 文档的内容。

此时对于应用的根路径的访问就不需要从文件系统中读取index.html了,而是需要使用 React DOM 提供的函数来完成组件树的渲染。不过这个函数在上一个示例中我们已经见到过了,这里只不过是将上面示例中的内容再精简一下。以下是server.ts的全部内容。

import Elysia from "elysia";
import { connect } from "elysia-connect-middleware";
import { createElement } from "react";
import { renderToReadableStream } from "react-dom/server";
import { createServer } from "vite";
import { App } from "./src/App";

const app = new Elysia();
const vite = await createServer({
  server: { middlewareMode: true },
  appType: "custom",
});
app.use(connect(vite.middlewares));
app.get("/", async () => {
  const app = createElement(App);
  const stream = await renderToReadableStream(app, {
    bootstrapModules: ["/src/main.tsx"],
  });
  return new Response(stream, {
    headers: {
      "Content-Type": "text/html",
    },
  });
});

app.listen(8000);

console.log(
  `Server running at http://${app.server?.hostname}:${app.server?.port}`
);

这样一对比,是不是相比部分接管的形式更加简单了。至于以上示例中各个步骤的功能与用途,下一节中再详细说明。