今天,我们会揭开xtJS, RemixJS 等 SSR 框架的神秘面纱。尽管它们都非常复杂,但是基本思想都很简单。由于我们无法直观地看到这些简单的步骤:使用 React Component 或者简单的 HTML/JS 构建 SSR,这是因为这些框架做了很多抽象来简化我们的工作。但是今天我们会深入探索这些抽象和这些概念本身。
什么是 SSR
服务端渲染(Server Side Rendering, SSR)由于 NextJS 而变得流行,但是什么是 SSR 呢?顾名思义,服务端渲染就是在服务端渲染你的组件。这意味着服务端在响应请求之前做了大量繁忙的工作。如果你是用 React 构建 SSR,你需要在服务端调用 ReactDOMServer.renderToString
来渲染你的组件。
TTFB(Time To First Bytes)比 CSR 更慢,因为你的服务器需要将这些组件渲染成 HTML 然后再返回它。但是和 CSR 不同的是,用户不需要等待整个 JS bundle 被解析 - 因此他们可以直接看到网页。但是他们在水合(hydrated)之前无法和网页进行交互。我们之后会解释它。
为什么我们需要 SSR 而不是 CSR
当我们使用 CSR 构建 app 时你实际上没有构建任何 HTML 文件。相反,它们在用户进入你的网站的时候才被创建,用户、搜索引擎或者爬虫进入你网站的时候实际上获得的只是下面这个著名的 index.html
文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Example CSR</title>
</head>
<body>
<div id="root">---ENTIRE CONTENT WILL BE RENDERED IN HERE ON THE CLIENT---</div>
</body>
</html>
一旦用户进入网站,DOM 元素会通过 React.createRoot
函数在 root
中创建:
<div id="root">
<div>Text-1</div>
<div>Text-2</div>
<div>Text-3</div>
</div>
这些会在进入你的网站后被动态调用。
因此,爬虫是怎样才能理解你网站的 meta data 呢?desciption, titles, tags 等?或者如果 DOM 元素比如 Text-1
, Text-2
都是静态的并不需要 JS 呢?如果你是用 CSR,所有的东西都在 JS bundle 中,SEO metada 被忽略了。但如果使用 SSR,就可以事先生成这些 HTML 元素和 SEO 标记,从而减少 JS 代码,使网站对爬虫更加友好。
什么是水合,我们为什么需要它
水合是为服务端生成的代码提供 JS 的过程,我们接下来会详细解释:
在后端使用 ReactDOMServer.renderToString
渲染组件时,事件 handler 和必要的 import 都会被添加。例如下面这个 SSR JS 输出:
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom/server.mjs";
import * as jsxRuntime from "react/jsx-runtime";
import { useState } from "react";
import { Link, Routes, Route } from "react-router-dom";
const Fragment = jsxRuntime.Fragment;
const jsx = jsxRuntime.jsx;
const jsxs = jsxRuntime.jsxs;
function About() {
return /* @__PURE__ */ jsx(Fragment, {
children: /* @__PURE__ */ jsx("h1", {
children: "About",
}),
});
}
const __vite_glob_0_0 = /* @__PURE__ */ Object.freeze(
/* @__PURE__ */ Object.defineProperty(
{
__proto__: null,
default: About,
},
Symbol.toStringTag,
{ value: "Module" }
)
);
我们更喜欢我们必需的东西,但是请注意,在 index.html
文件中我们丢失了 React 和 ReactDOM。没有这些东西,我们的 useEffect
和 useState
和事件处理器就没办法工作。
我们可以将这个页面当作干海绵:所有的 useEffect
, useState
, handlers 和 listeners 都需要水合才能工作。一旦我们使用 parsed JS 代码水合页面,所有的 UI 元素才能进行交互。就像将一个干海绵变成湿海绵,没有水可不行。
我之所以在文章中多次提到 parsed JS 代码,是为了说明一个问题。SSR 的速度很快,但是水合到页面上需要时间,因为所有的 JS 代码都需要解析才能完全水合。
为什么是 Vite 而不是 Webpack
Vite 是 VueJS 的创始者创建的,旨在提升 DX(Digital eXperience)。它使用 esbuild 来提升 bundle 的速度。由于 esbuild 因此我们有了更快的 HMR(Hot Module Replacement, 热模块替换)。相比 Webpack 基于 js 的 bundler 更快。它使用 Rollup 来进行生产构建,因为在某种方式上它比 esbuild 更成熟。
总而言之:
更好的 DX:导致了更简单的配置。
即时服务器启动:本地开发使用 ESM。
更快的 HMR。
构建 SSR
我们首先需要安装依赖:
npm i react@latest react-dom@latest react-router-dom@latest
npm i --save-dev @types/react @types/react-dom @vitejs/plugin-react compression cross-env express serve-static typescript vite
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
minify: false,
},
});
然后我们创建一个 index.html 来运行所有的 JS 代码:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR React/Typescript App</title>
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>
和其它 React app 一样,我们创建一个 src 文件夹:
src pages About.tsx Home.tsx App.tsx entry-client.tsx entry-server.tsx vite.config.js index.html server.js prerender.js package.json
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
ReactDOM.hydrateRoot(
document.getElementById('app')!,
<BrowserRouter>
<App />
</BrowserRouter>,
);
和其他 React app 中的 index.ts
作用类似,entry-client.tsx
使用 ReactDOM.hdrateRoot
和 root id 来将整个应用渲染到一个 div 上。和 index.ts
唯一的不同是我们将 ReactDOM.createRoot
替换成了 ReactDOM.hydrateRoot
,因为我们渲染的是服务端生成的代码而不是直接在客户端调用。
接下来是 entry-server.tsx
:
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { App } from './App';
export function SSRRender(url: string | Partial<Location>) {
return ReactDOMServer.renderToString(
<StaticRouter location={url}>
<App />
</StaticRouter>,
);
}
要将我们的 React 应用渲染到节点上,我们需要调用 ReactDOMServer.renderToString
,然后使用带有 location 的 StaticRouter 替换 BrowserRouter。
是时候看看我们客户端的关键部分了:
import { Link, Route, Routes } from 'react-router-dom';
const PagePathsWithComponents = import.meta.glob('./pages/*.tsx', { eager: true });
const routes = Object.keys(PagePathsWithComponents).map((path: string) => {
const name = path.match(/\.\/pages\/(.*)\.tsx$/)![1];
return {
name,
path: name === 'Home' ? '/' : `/${name.toLowerCase()}`,
component: PagePathsWithComponents[path].default,
};
});
export function App() {
return (
<>
<nav>
<ul>
{routes.map(({ name, path }) => {
return (
<li key={path}>
<Link to={path}>{name}</Link>
</li>
);
})}
</ul>
</nav>
<Routes>
{routes.map(({ path, component: RouteComp }) => {
return <Route key={path} path={path} element={<RouteComp />} />;
})}
</Routes>
</>
);
}
我们使用了 NextJS 样式的路由系统:它基于文件结构来创建路由。因此我们使用 Vite 的 import.meta.glob
。这允许我们一次性导入多个模块。输出类似于这样:
const modules = {
"./pages/About.tsx": () => import("./pages/About.js"),
"./pages/Home.tsx": () => import("./pages/Home.tsx"),
};
之后我们收集所有的路由,然后用它们为导航创建路由和链接。
让我们创建我们的网页:
export default function Home() {
const [counter, setCounter] = useState(0);
return (
<>
<h1>Home</h1>
<br />
<div>Button clicked {counter} times</div>
<button onClick={() => setCounter((prevState) => prevState + 1)}>Click me!</button>
</>
);
}
export default function About() {
return <h1>About</h1>;
}
这些都只包含了基础的 JSX 代码。对于 Home.tsx
,我们使用了一些 React magic 来测试 useState
在没有水合的情况下如何工作。我们不久就会做这个。
现在,使用 Vite 和 ExpressJS,我们在 server 上启动我们的 SSR app:
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "url";
import express from "express";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
export async function createServer() {
const resolve = p => path.resolve(__dirname, p);
let vite = null;
app.use((await import("compression")).default());
app.use(
(await import("serve-static")).default(resolve("dist/client"), {
index: false,
})
);
app.use("*", async (req, res) => {
const url = "/";
const template = fs.readFileSync(resolve("dist/client/index.html"), "utf-8");
const render = (await import("./dist/server/entry-server.js")).SSRRender;
const appHtml = render(url); //Rendering component without any client side logic de-hydrated like a dry sponge
const html = template.replace(`<!--app-html-->`, appHtml); //Replacing placeholder with SSR rendered components
res.status(200).set({ "Content-Type": "text/html" }).end(html); //Outputing final html
});
return { app, vite };
}
createServer().then(({ app }) =>
app.listen(3033, () => {
console.log("http://localhost:3033");
})
);
我们首先读取我们的 index.html
并替换 root id 中渲染的 components。然后我们调用 entry-server.js
中的 SSRRender
函数并将初始 URL 传递给它。在我们的场景下它是 homepage。最后我们使用渲染的内容替换了 <!--app-html-→
。
很简单,对吧。这就是大多数 SSR 框架做的内容。当然,他们添加了各种类型的特性和优化来完善这个工作流。
让我们尝试构建和启动我们的应用。但是后弦,我们需要创建一个 package.json 文件:
{
"name": "ssr-react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
"generate": "vite build --outDir dist/static && npm run build:server && node prerender",
"serve": "cross-env NODE_ENV=production node server"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.3.0"
},
"devDependencies": {
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.0",
"compression": "^1.7.4",
"cross-env": "^7.0.3",
"express": "^4.18.1",
"serve-static": "^1.15.0",
"typescript": "^4.7.4",
"vite": "^3.0.0",
"prettier": "^2.7.1"
}
}
然后:
$ npm run build && npm run serve
好了。我们有了一个全新的 SSR 应用程序。现在,为了理解为什么水合如此重要,我们来了解一下 SSR 在没有客户端 Javascript 文件的情况下是如何工作的。
编辑 dist\client\index.html
文件并删除 script 标签:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR React/Typescript App</title>
<script type="module" crossorigin src="/assets/index.6581eae8.js"></script>
--> Delete this
</head>
<body>
<div id="app"><!--app-html--></div>
</body>
</html>
然后重新启动你的应用看看你的 counter 按钮(这个按钮我们使用了 useState
)是否工作。
是的,没有客户端的逻辑:水合。网页整个就是静态的,无法进行任何交互。但是如果我们已经知道网页是静态的并希望独立渲染每个页面呢?是的,我们需要预渲染 - SSG。
SSG on top of SSR
由于我们已经了解了这些概念,因此接下来很简单:
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const toAbsolute = p => path.resolve(__dirname, p);
const template = fs.readFileSync(toAbsolute("dist/static/index.html"), "utf-8");
const render = (await import("./dist/server/entry-server.js")).SSRRender;
// determine routes to pre-render from src/pages
const routesToPrerender = fs.readdirSync(toAbsolute("src/pages")).map(file => {
const name = file.replace(/\.tsx$/, "").toLowerCase();
return name === "home" ? `/` : `/${name}`;
});
(async () => {
// pre-render each route...
for (const url of routesToPrerender) {
const appHtml = render(url);
const html = template.replace(`<!--app-html-->`, appHtml);
const filePath = `dist/static${url === "/" ? "/index" : url}.html`;
fs.writeFileSync(toAbsolute(filePath), html);
}
})();
该功能使用与 SSR 相同的逻辑,为每个页面创建单独的文件,这些文件是提前知道的,但在构建阶段后我们将无法更改任何内容。这就是它被称为 "静态 "的原因。对于博客、文档网站、电子商务产品列表等网站来说,采用这种方法是非常有价值的。
然后运行脚本:
$ npm run generate
然后使用一个服务器比如 Serve 托管生成的代码:
$ serve dist/static
我希望我们已经揭示了 SSR 和 SSG 的一些内在逻辑,并帮助你更好地理解其思维过程。