使用Express和Babel对React 18应用进行服务器端渲染。
将Create React App项目转换为使用Express和Babel的服务器端渲染:renderToString、hydrateRoot、MemoryRouter,以及Remix和Gatsby实现SSR的方式。
来源: A Hands-on Guide for a Server-Side Rendering React 18 App
自 React 18 推出以来,React 的服务器端渲染功能发生了很大变化。以下指南针对 React 18、Create React App 5 以及 React Router 6,从一个标准的 Create React App 项目开始,逐步进行转换。由于渲染需要在服务器上执行,因此需要一个 Node 进程。普通的 Node 即可使用,但在开发过程中 nodemon 更为方便,因为它会在文件发生变化时自动重启应用。全局安装方式如下:
% npm install -g nodemon
几句话讲清CSR与SSR的区别
在客户端渲染(CSR)模式下,浏览器首先收到几乎为空的HTML页面,再由JavaScript填充内容。首次加载速度可能较慢,但后续导航很快,且由于工作由JavaScript库完成,服务器负担较轻。其缺点是不利于搜索引擎优化,因为爬虫找不到可读取的静态内容。
在服务器端渲染模式下,浏览器收到的是服务器已渲染好的完整HTML页面。这样有利于SEO,且首次显示速度很快。但缺点是后续修改往往需要重新加载整个页面,这会增加服务器的负载。
这里有一些相关术语可供参考:
- 单页应用(SPA)采用CSR技术。它不会为每个路由提供独立的HTML文件,而是在浏览器中动态生成每个页面的内容。
实际上,服务器端渲染比客户端渲染出现得更早。它以基于通用 JavaScript 的现代形式重新出现,这就是现在人们通常所说的服务器端渲染。
一个基础的客户端渲染应用
创建一个新的项目:
% yarn create react-app react-ssr
% cd react-ssr
使用 npm start 启动它。在浏览器的 Elements 标签页中,旋转的标志和文字会以 JavaScript 生成的 HTML 形式显示。
这正是典型的客户端渲染:脚本负责生成 HTML 内容。而 Network 标签页则显示服务器实际发送的内容。
页面主体中仅包含 JavaScript 模块包,没有实际内容,因此爬虫几乎找不到有价值的信息可供索引。
提供生产环境构建版本
服务端渲染需要生产环境构建的代码,因此首要任务是搭建一个小型 Express 服务器来提供编译后的 React 输出。之前介绍的适用于生产环境的 React 配置已为此奠定了基础。服务器文件有两种形式:一种是 CommonJS 格式的 server/index.js,另一种是 ES 模块格式的 server/index.mjs。
运行 npm run build 以生成构建文件,然后使用 nodemon server 或 nodemon server/index.mjs 启动服务器。此时 Network 标签页会显示一个 JavaScript 捆绑文件(第12行)以及空的标记文件(第17行)。由于尚未进行服务器端渲染,因此仍然是 CSR 模式。
Express 中实现 SSR 的三个步骤
要在 Express 服务器中实现 SSR,需要完成三个步骤。
第一步:在客户端进行水合处理。 ReactDOM.hydrateRoot()与ReactDOM.createRoot()类似,其签名为hydrateRoot(container, element[, options])。它适用于那些HTML已由ReactDOMServer生成的容器。由于该节点已经包含服务器端渲染的标记,React只需为其添加事件处理程序,从而实现快速的首次加载。该调用位于src/index.js的第7行。
第二步:在服务器端进行渲染。React提供了ReactDOMServer对象,用于将组件转换为静态标记,这样浏览器就能直接获取已包含数据的页面。由于React代码是通用的JavaScript,相同的组件可在服务器端和客户端运行。
第三步:处理页面特有的需求。如后续章节所示,每个路由可能需要单独的处理。
让 Node 理解 JSX
有多种包可以实现 SSR 功能。@babel/register 是其中最简单的之一。请将其与 babel-plugin-transform-assets 一起安装:
% yarn add -D @babel/register
% yarn add -D babel-plugin-transform-assets
两者都会被放入devDependencies中:
"devDependencies": {
"@babel/register": "^7.18.9",
"babel-plugin-transform-assets": "^1.0.2"
}
@babel/register 可以即时编译文件,还能用于转换 JSX 代码和资源。但它有一个限制:无法即时编译原生 Node.js ES 模块,因为目前还不存在用于挂载 ES 模块加载功能的稳定 API。正因如此,这里的 SSR 实验都使用 CommonJS 文件。
React 18 对其服务器端 API 进行了重构,将其移至 react-dom/server 下。虽然 renderToString 仍然可用,但其对 Suspense 的支持较为有限。而新的 API,比如 renderToPipeableStream,则能在服务器端完全支持 Suspense 功能以及流式 SSR。
SSR 服务器详解
下面将按行号逐一解释更新后的 server/index.js 代码。
- 第 1 行到第 15 行通过
require钩子配置 Babel,从而使文件在加载时即可被编译。
@babel/preset-env允许代码使用最新的JavaScript特性,而无需为每个目标环境手动选择语法转换规则。@babel/preset-react则会自动导入JSX编译所需的函数。babel-plugin-transform-assets,该插件决定了静态媒体文件该如何被转换。如果没有它,使用svg标签时会引发错误。React,第17行需要ReactDOMServer,第18行则需要src/App.js的默认导出内容。顺序很重要。第47行的app.use()必须在第25行到第45行的app.get()之后执行。如果先执行app.use(),它就会为根路径提供静态文件,包括index.html,这样app.get()就永远没有机会被调用。
第26行会记录每个请求的URL。对于Create React App构建的项目,列表看起来是这样的:
Request URL = /
Request URL = /static/js/main.b92cc87b.js
Request URL = /static/css/main.073c9b0a.css
Request URL = /static/media/logo.06e73328.svg
Request URL = /static/css/main.073c9b0a.css.map
Request URL = /static/js/main.b92cc87b.js.map
Request URL = /static/media/logo.6ce24c58023cc2f8fd88fe9d219db6c6.svg
Request URL = /favicon.ico
Request URL = /manifest.json
Request URL = /logo192.png
第27行到第29行确保只有根路由由app.get()处理,而静态资源则传递给第47行的下一个中间件。
第30行调用ReactDOMServer.renderToString(element)在服务器端生成HTML。直接写成ReactDOMServer.renderToString()固然更自然,但这样JSX就必须放在与配置了@babel-register的文件不同的位置,因此才需要使用单独的元素。
第31行输出服务器生成的标记内容:
<div class="App"><header class="App-header"><img src="static/media/logo.06e73328.svg" class="App-logo" alt="logo"/><p>Edit <code>src/App.js</code> and save to reload.</p><a class="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">Learn React</a></header></div>
第33行加载生产环境的index.html,第34行到第44行则读取其内容。如果没有错误,生成的标记(第42行)会被注入到根标签中,最终得到的index.html就会作为对首次请求的响应。
验证功能是否正常
启动nodemon server,再次查看Network标签页。此时下载的文档已包含服务器渲染的标记。正文中的第17行到第27行包含了整个页面内容,这样SEO工具就有实际的内容可以分析。
为了进行更严格的测试,打开chrome://settings/content/javascript并将JavaScript功能关闭。
即使没有JavaScript,页面依然能够正常渲染。
这就是SSR的体现。
添加路由
单页应用实现起来很简单,接下来的问题就是如何处理多路由。首先需要安装react-router-dom:
% yarn add react-router-dom
它被添加到 dependencies 中:
"dependencies": {
"react-router-dom": "^6.11.2"
}
接着编辑 src/App.js。第1行导入了SSR所需的 React。第2行用 MemoryRouter 替换了 BrowserRouter,因为浏览器路由依赖HTML5的pushState历史API,而Node.js并不提供该功能。MemoryRouter会将URL历史记录保存在内存中,不会影响地址栏,因此非常适合测试和非浏览器环境。react-router-dom/server 中的 StaticRouter也是另一种选择,但它并不适合处理复杂的路由结构。第34行到第40行用于实际使用 MemoryRouter。
再次执行 npm run build 进行构建,启动 nodemon server,然后访问 http://localhost:8080。
SSR功能可以正常工作,但有一个问题:地址栏中的URL不会更新。
手动实现SSR时的困境
路由功能在满足该前提条件下方可正常工作,此外还有数据获取和Redux等问题有待解决。服务器端的工作比客户端更为复杂,每个页面可能都需要特定的处理方式。
无需手动解决所有问题,因为已有许多框架和库提供了SSR功能。Remix是一个全栈Web框架,从Web基础开始逐步构建到用户界面,内置SSR等功能且无需编写样板代码。其客户端代码在第4行调用hydrateRoot,服务器端代码也在第4行调用renderToPipeableStream。
React 18 引入了 renderToPipeableStream(element, options) 函数。传入一个 React 元素后,它会生成初始的 HTML 并返回一个流对象。该流支持使用 pipe(res) 方法继续发送输出内容,以及通过 abort() 方法取消请求。它完全支持悬停加载功能,且由于内联的 <script> 标签会在之后填充空白,因此不会出现内容延迟加载后突然出现的状况。
在 Network 标签页中,Remix 的首页内容已经完整呈现,因此属于服务端渲染。
Gatsby、Next.js、NuxtJS、Quasar 和 SvelteKit 也都无需额外编写代码即可直接提供服务端渲染及相关功能,不过它们对服务端渲染的定义略有不同。Gatsby 的首页在 Network 标签页中也会显示完整的页面内容。
Gatsby 是一款快速且稳定的静态站点生成器,它使用 React 来渲染静态内容,并允许你在服务器端渲染过程中修改静态 HTML。
SSR 或静态渲染
当页面仅包含静态数据时,静态渲染速度更快。如果响应是动态的,则 SSR 是更好的选择。有时混合方案最为合适。SSR 会在每次请求文件时按需执行,而静态渲染则仅在构建阶段进行一次。两种方式都对 SEO 友好,因为在这两种情况下 HTML 正文都包含完整内容。
总结
在 Create React App 中设置 SSR 实际上只需完成三件事:
- 使用
ReactDOM.hydrateRoot()显示服务器端渲染的标记。 - 利用
ReactDOMServer对象将组件渲染为静态标记,该对象的 API 同时支持 SSR 和静态渲染。
在实际项目中,明智的选择是使用已经具备此功能的框架或库。
Want to Connect?
If you are interested, check out my directory of web development articles.