什么是服务端渲染 服务端渲染, SSR (Server-side Rendering) ,顾名思义,就是在浏览器发起页面请求后由服务端完成页面的HTML结构拼接,返回给浏览器解析后能直接构建出有内容 的页面。
首先,我们先来回顾下页面渲染方式的发展历程。
传统的 SSR
传统的SSR也称后端模板渲染,最常见的就是jsp和php。服务端在收到客户端的页面请求后,使用模板引擎将页面模板与数据拼接成HTML进行返回。客户端接收到响应数据后能直接渲染展示,但后续的一些交互性的东西还是需要经过js去操作dom来实现。
CSR
CSR是客户端渲染,服务端收到客户端请求后,只会返回无页面内容的HTML。需要客户端另外自行加载执行JS,完成页面渲染。若需要页面首屏数据时,再去请求服务端,获取最新数据,更新视图。
同构的 SSR
所谓同构,就是在SPA应用的基础上(这里默认同构应用也是SPA),同一份代码,在服务端执行一次,生成首屏HTML和CSR脚本;再在客户端执行一次,将应用交互所需的数据、事件等绑定到HTML上,完成应用的加载,后续仍是传统SPA应用的加载模式。
得益于 Virtual DOM 和 Node 的存在,才能实现同构的服务端渲染。由于同构的 SSR 项目代码会在服务端和客户端分别执行一次,Node 提供了天然的 JavaScript 运行环境;在 Node 环境下,不能直接操作 DOM,但是由于 Virtual DOM 的存在,可以在 Node 环境下操作 Virtual DOM 生成 HTML。
React 的服务端渲染也是同构的服务端渲染的一种,本文接下来介绍的内容都是同构的服务端渲染。
为什么使用服务端渲染 上面介绍了同构的服务端渲染流程,现在来考虑一下我们为什么要用服务端渲染,什么场景下适合用服务端渲染。
服务端渲染的优势 相比于客户端渲染,服务端渲染主要有以下两个优势:
首屏时间更短 采用客户端渲染的页面,要进行JS文件拉取和JS代码执行,动态创建 DOM 结构,客户端逻辑越重,初始化需要执行的 JS 越多,首屏性能就越慢;客户端渲染前置的第三方类库/框架、polyfill 等都会在一定程度上拖慢首屏性能。Code splitting、lazy-load等优化措施能够缓解一部分,但优化空间相对有限。相比而言,服务端渲染的页面直接拉取HTMl就能显示内容,更短的首屏时间创造更多的可能性。
更好的 SEO 在别人使用搜索引擎搜索相关的内容时,你的网页排行能靠得更前,这样你的流量就有越高,这就是SEO的意义所在。那为什么服务端渲染更利于爬虫爬你的页面呢?因为对于很多搜索引擎爬虫(非google)HTML返回是什么内容就爬什么内容,而不会动态执行JS代码内容。对客户端渲染的页面来说,简直无能为力,因为返回的HTML是一个空壳。而服务端渲染返回的HTML是有内容的。
和 CSR 的性能对比 首先,我们先看一下以下几个网页性能指标
TTFB(Time To First Byte) :客户端发起网络请求到接收到从服务器返回的第一个字节的时间(网络响应时间)
FP(First Paint) :从页面加载开始到浏览器首次渲染任何内容到屏幕上的时间(页面开始渲染时间)
FCP(First Contentful Paint) :从用户发起页面加载到浏览器渲染页面上首个“内容”元素的时间(首屏渲染时间)
TTI(Time To Interactive) :从页面开始加载到达到完全可交互状态所需要的时间(页面可交互时间)
SSR可能在TTFB上比CSR更慢 ,因为服务端需要渲染和准备当前请求的HTML,而CSR只需要直接传HTML和JS即可;但后续FP和FCP都会显著快于CSR ,因为服务端本地渲染HTML会比客户端渲染更快,且客户端拿到HTML后就可以立刻渲染而无需等待JS加载完成。
服务端渲染的弊端 从上面分析可以看出,相比客户端渲染,服务端渲染有两大优势:首屏快和利于SEO ,但是服务端渲染也是有一定弊端的。
服务端渲染的弊端主要体现在以下两个方面:
代码复杂度增加 :为了实现服务端渲染,应用代码中需要兼容服务端和客户端两种运行环境,部分代码需要区分服务端和客户端,相较于客户端渲染对代码的复杂度要求较高
服务器负载增加 :服务端渲染需要服务器动态生成HTML,对服务器的CPU和内存使用增加了负担,尤其是在高流量的情况下可能需要更多的服务器资源
适合使用服务端渲染的场景
SEO优化 :对于搜索引擎优化(SEO)非常重要的应用,如博客、新闻网站、电子商务平台等,使用SSR可以确保搜索引擎能够索引到内容丰富的页面,因为内容在服务器端就已经被填充,并以完整的HTML形式提供给抓取工具。
首屏加载性能 :对于首屏加载性能高的应用,如商城、文档、新闻等,此类应用属于内容密集型应用,使用SSR可以提高网站的FCP,更快地渲染出首屏内容。
React 服务端渲染原理 核心思想 React 服务端渲染的核心思想就是同构 ,一份代码分别打包出供服务端和客户端运行的两份js产物。服务端渲染出了首屏内容后,客户端 hydrate 渲染复用服务端返回的DOM节点,进行一次类似于 render 的 hydrate 渲染过程(不会销毁重建DOM节点),把交互事件绑定到DOM节点上(此时页面可交互),并接管页面。
服务端渲染返回的
客户端注水后的
生命周期 SSR过程中组件的生命周期是不完整的,只能执行到render及之前的生命周期(constructor、getDerivedStateFromProps、render),commit阶段的生命周期不会执行。
在编写代码时,需要避免在render及之前的生命周期中使用浏览器相关的API(history、document、window等),如果实在需要使用则需加上环境判断;同时,需要避免在全局定义任何可能不断增长的数据结构,或在全局进行事件订阅,或创建不会被销毁的流,会造成内存泄漏的风险。
服务端渲染 API React 服务端渲染主要依赖于 react-dom/server 包中的几个API和 react-dom 中的 hydrate(<= React 17)和hydrateRoot(React 18)API。
renderToString
https://zh-hans.react.dev/reference/react-dom/server/renderToString
`renderToString` 不支持流式传输或等待数据。
renderToString将React树渲染为一个HTML字符串,需要配合客户端的hydrateRootAPI使用,使应用变得可交互。
renderToStaticMackup
https://zh-hans.react.dev/reference/react-dom/server/renderToStaticMarkup
`renderToStaticMarkup`输出的HTML无法进行二次渲染(不能使应用变得可交互)
renderToStaticMarkup和renderToString类似,也是将React树渲染为HTML字符串,两者最大的区别是renderToString输出的HTML可以被hydrateRoot二次渲染,变得可交互;而renderToStaticMarkup输出的是静态的HTML,无法二次渲染。
renderToPipeableStream
https://zh-hans.react.dev/reference/react-dom/server/renderToPipeableStream
renderToPipeableStream 是React18新增的API,将一个 React 组件树渲染为管道化(pipeable)的 Node.js 流 。通常配合Suspense使用,实现流式渲染。
hydrateRoot
https://zh-hans.react.dev/reference/react-dom/client/hydrateRoot
`hydrateRoot` 期望服务端返回的 HTML 和客户端渲染的结构完全相同,但是当服务端返回的 HTML 与客户端渲染结果不一致时,出于性能考虑,hydrateRoot可以弥补文本内容的差异,但并不能保证修补属性的差异,而是将错就错;只在development模式下对这些不一致的问题报 Warning,因此必须重视 SSR HydrationWarning,要当 Error 逐个解决。
hydrateRoot方法能在客户端初次渲染的时候去复用服务端返回的原本已经存在的 DOM 节点,于渲染过程中为其附加交互行为(事件监听等),而不是重新创建 DOM 节点 。
在开发模式下,如果出现服务端返回的 HTML 和客户端渲染的结果不一致时,会出现 Warning 或者 Error,主要分为以下几种情况:
HTML App.tsx <!-- 在 <div id="root">...</div> 中的 HTML 内容 由 react-dom/server 生成 --> <div id="root"><h1>Hello, world!</h1></div>import { hydrateRoot } from "react-dom/client"; function App() { return <h1 style={{ fontWeight: 600, fontSize: 30 }}>Hello, world!</h1>; } hydrateRoot(document.getElementById("root"), <App />);
会出现 Warning 提示,客户端可以渲染但是不会修补属性的差异,可以通过设置suppressHydrationWarning属性为true忽略掉 Warning 提示(但是不会修补)(强烈不建议)。
HTML App.tsx <!-- 在 <div id="root">...</div> 中的 HTML 内容 由 react-dom/server 生成 --> <div id="root"><h1>Hello, world!</h1></div>import { hydrateRoot } from "react-dom/client"; function App() { return <h1>Hello, world Client!</h1>; } hydrateRoot(document.getElementById("root"), <App />);
会出现 Error 提示,并导致 hydrateRoot 失败,可以通过设置suppressHydrationWarning属性为true使客户端尝试修补文本内容的不一致(强烈不建议)。
HTML App.tsx <!-- 在 <div id="root">...</div> 中的 HTML 内容 由 react-dom/server 生成 --> <div id="root"><h1>Hello, world!</h1></div>import { hydrateRoot } from "react-dom/client"; function App() { return ( <> <h1>Hello, world!</h1> <h2>Unexpected</h2> </> ); } hydrateRoot(document.getElementById("root"), <App />);
会出现 Error 提示,并导致 hydrateRoot 失败。
Streaming SSR
官方demo:https://codesandbox.io/s/kind-sammet-j56ro?file=/src/App.js
在 React 18 之前的版本的 SSR 有一些弊端:
服务端需要准备好所有组件的 HTML 才能返回。如果某个组件需要的数据耗时较久,就会阻塞整个 HTML 的生成。
Hydration 是一次性的,用户需要等待客户端加载所有组件的 JavaScript 并 Hydrated 完成后才能和任一组件交互。(渲染逻辑复杂时,页面首次渲染到可交互之间可能存在较长的不可交互时间)
在 React SSR 中不支持客户端渲染常用的代码分割组合React.lazy和Suspense。
而在 React 18 中新的 SSR 架构React Fizz 带来了两个主要新特性来解决上述的缺陷:Streaming HTML (流式渲染)和Selective Hydration (选择性注水)
流式渲染(Streaming HTML) 一般来说,流式渲染就是把 HTML 分块通过网络传输,然后客户端收到分块后逐步渲染,提升页面打开时的用户体验。通常是利用HTTP/1.1中的分块传输编码 (Chunked transfer encoding)机制。
React 18 推出了新的服务端渲染 API renderToPipeableStream ,结合 Suspense 可以实现流式渲染。实现的效果如下图:
选择性注水 (Selective Hydration) 有了lazy和Suspense的支持,另一个特性就是 React SSR 能够尽早对已经就绪的页面部分注水,而不会被其他部分阻塞 。
这样就可以将不需要同步加载的组件选择性地用lazy和Suspense包起来(和客户端渲染时一样)。而 React 注水的粒度取决于Suspense包含的范围,每一层Suspense就是一次注水的“层级”(要么组件都完成注水要么都没完成)。
同样的,流式传输的 HTML 也不会阻塞注水过程。如果 JavaScript 早于 HTML 加载完成,React 就会开始对已完成的 HTML 部分注水。
React 通过维护几个优先队列,能够记录用户的交互点击来优先给对应组件注水,在注水完成后组件就会响应这次交互,即事件重放(event replay)。
最佳实践 以下内容使用实际举例,结合实际代码讲述如何不依赖现成的SSR框架(Next、Edenx等)实现一个完整的SSR应用,加深对React SSR的理解。
SSR 技术栈
流程 主要分为构建 和运行时 两个主流程
构建 构建这一块用的是基于Rust编写的构建工具 Rsbuild 。通常,SSR应用需要构建出两份产物,除了客户端执行的产物外,还需要构建出给Node执行的产物。相比于客户端执行的产物,Node执行的产物有如下几点差异:
只需要构建出js资源,不需要包含css、html、img等资源
不需要拆包,打包成一个完整的js文件
将js编译成commonjs模块
【可选】js不需要压缩
Rsbuild 配置 Rsbuild 是由 Rspack 驱动的高性能构建工具,它默认包含了一套精心设计的构建配置,提供开箱即用的开发体验,并能够充分发挥出 Rspack 的性能优势。
主要的构建配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 import { defineConfig } from '@rsbuild/core' ;import { pluginReact } from '@rsbuild/plugin-react' ;import { pluginSvgr } from '@rsbuild/plugin-svgr' ;import { pluginYaml } from '@rsbuild/plugin-yaml' ;import { isEnvProduction, srcPath, publicPath, themePath, isNodeTarget, isSSRDev } from './config/constant' ;import template from './config/template' ;export default defineConfig ({ source : { entry : { index : isNodeTarget ? './src/index' : './src/index.web' , }, }, output : { assetPrefix : isNodeTarget ? '/' : 'auto' , overrideBrowserslist : isNodeTarget ? { node : ['node >= 18' ], } : undefined , targets : isNodeTarget ? ['node' ] : ['web' ], minify : isEnvProduction && !isNodeTarget, }, html : isNodeTarget ? undefined : { inject : true , template : './public/index.html' , favicon : './public/favicon.png' , title : 'API Meta' , templateParameters : { headerInjects : template.header , bodyInjects : template.body , env : process.env .NODE_ENV , }, }, tools : { rspack : { resolve : { mainFields : isNodeTarget ? ['module' , 'main' ] : ['browser' , 'module' , 'main' ], }, }, }, });
构建脚本 由于 BFF和web端不是同一个应用,在构建过程中需要将node服务和web端应用分开构建,需要将web端构建的node产物和html模版上传到node服务器中,将客户端产物上传到CDN中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 #!/bin/bash set -esource /etc/profileecho "node version is $(node -v) " npm config set registry https://bnpm.byted.org/ node common/scripts/install-run-rush.js install --bypass-policy rm -rf outputrm -rf output_resourcemkdir -p outputmkdir -p output_resource/consolemkdir -p output_resource/docnode common/scripts/install-run-rush.js build -t @lark-meta/bff --verbose node common/scripts/install-run-rush.js deploy -p @lark-meta/bff -t output -s bff --overwrite mv -f output/app/bff/output/* output/app/bffrm -rf output/app/bff/outputnode common/scripts/install-run-rush.js build -t @lark-meta/console --verbose mv ./app/frontend/dist/index.html ./output/app/bff/views/index.handlebarsmv ./app/frontend/dist/* ./output_resource/consolenode common/scripts/install-run-rush.js build -t @lark-meta/doc --verbose mv ./app/doc/dist/server/index.js ./output/app/bff/document.ssr.jsmv ./app/doc/dist/index.html ./output/app/bff/views/doc.handlebarsmv ./app/doc/dist/* ./output_resource/doccd output/app/bffnpm run manifest
运行时 同一份代码需要同时支持在node和web环境中运行,涉及到代码运行时的同构,其中主要分为路由同构 、数据同构 和渲染同构 三大部分。
路由同构 无论是服务端还是客户端,都是在用户发起请求时进行路由匹配,执行相应的路由逻辑(接口响应/渲染组件)。但是双端的路由匹配原理是不同的。
服务端:通过请求路径匹配相应的中间件进行返回
客户端:根据浏览器路径通过react-router匹配对应的路由组件并渲染
我们需要将同一份路由规则抽出,分别在服务端和客户端运行,这就是路由的同构。其中又分为数据路由 和非数据路由 。
非数据路由
详细可参考:https://reactrouter.com/en/main/guides/ssr#without-a-data-router
顾名思义,非数据路由就是在渲染路由组件前不需要额外的数据请求。
先抽离通用的路由表 routes.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import React from 'react' ;import { Navigate , RouteObject } from 'react-router-dom' ;import { Document } from './pages/document' ;import { Layout } from '@lark-meta/components' ;import { RPC_LIST_KEY } from '@lark-meta/utils' ;export const routes : RouteObject [] = [ { path : '/axe/document' , element : <Layout /> , children : [ { index : true , element : <Navigate to ={RPC_LIST_KEY} replace /> , }, { path : '*' , element : <Document /> , }, ], }, ];
服务端需要使用StaticRouter匹配路由,需要手动传递location;客户端需要使用BrowserRouter。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import React from 'react' ;import { StaticRouter } from 'react-router-dom/server' ;import { HTTPRequest } from '@gulux/gulux/lib/exports/application-http' ;import { ConfigProvider as UDConfigProvider , FloatingUIPopperSidecar } from '@universe-design/react' ;import zhCN from '@universe-design/react/es/shared/locales/zh-CN' ;import type { Router , StaticHandlerContext } from '@remix-run/router' ;import { GlobalProvider } from './components/provider' ;import { CurrentUser } from '@lark-meta/types' ;import { useRoutes } from 'react-router' ;import { routes } from './routes' ;interface AppProps { req : HTTPRequest ; router : Router ; context : StaticHandlerContext ; profile ?: CurrentUser ; } export function App (props : AppProps ) { const router = useRoutes (routes); const { context, req, profile } = props; return ( <GlobalProvider req ={req} profile ={profile} path ={req.path} > <UDConfigProvider locale ={zhCN} PopperSidecar ={FloatingUIPopperSidecar} > <StaticRouter location ={req.path} > {router}</StaticRouter > </UDConfigProvider > </GlobalProvider > ); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import React from 'react' ;import { ConfigProvider as UDConfigProvider , FloatingUIPopperSidecar } from '@universe-design/react' ;import zhCN from '@universe-design/react/es/shared/locales/zh-CN' ;import { BrowserRouter , useRoutes } from 'react-router-dom' ;import { routes } from './routes' ;import { GlobalProvider } from './components/provider' ;export function App ( ) { const router = useRoutes (routes); return ( <GlobalProvider profile ={window.user} path ={window.location.pathname} > <UDConfigProvider locale ={zhCN} PopperSidecar ={FloatingUIPopperSidecar} > <BrowserRouter > {router}</BrowserRouter > </UDConfigProvider > </GlobalProvider > ); }
数据路由 数据路由需要在渲染路由组件前请求数据。react-router v6天然集成了remix ,天然支持在route上绑定loader函数,loader函数接受三个参数:
request :fetch request实例
params :路径参数
context :服务端渲染传递的上下文参数,通过requestContext透传
在服务端渲染时会先执行loader函数再渲染路由组件,loader函数中需要异步请求路由所需要的数据并返回。客户端需要用到loader数据时使用useLoaderData方法即可。
分别对 routes.tsx、app.tsx 和 app.web.tsx 进行如下修改
新增 loader 方法,在该方法中调用服务端接口获取数据并返回
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 export const routes : RouteObject [] = [ { path : '/axe/document' , element : <Layout /> , children : [ { index : true , element : <Navigate to ={RPC_LIST_KEY} replace /> , }, { path : '*' , element : <Document /> , loader : async args => { try { const { request, params, context } = args; console .log ('Route loader' , request, params, context); const relativePath = params?.['*' ]; if (!relativePath) { return json ({}); } const data : LoaderData = {}; console .log ('Route loader data' , data); return json (data); } catch (e) { console .error ('Route loader error' , e); return json ({}); } }, }, ], }, ];
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import React from 'react' ;import { ConfigProvider as UDConfigProvider , FloatingUIPopperSidecar } from '@universe-design/react' ;import zhCN from '@universe-design/react/es/shared/locales/zh-CN' ;import { RouterProvider , createBrowserRouter } from 'react-router-dom' ;import { routes } from './routes' ;import { GlobalProvider } from './components/provider' ;export function App ( ) { const router = createBrowserRouter (routes); return ( <GlobalProvider profile ={window.user} path ={window.location.pathname} > <UDConfigProvider locale ={zhCN} PopperSidecar ={FloatingUIPopperSidecar} > <RouterProvider router ={router} /> </UDConfigProvider > </GlobalProvider > ); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import React from 'react' ;import { StaticRouterProvider } from 'react-router-dom/server' ;import { HTTPRequest } from '@gulux/gulux/lib/exports/application-http' ;import { ConfigProvider as UDConfigProvider , FloatingUIPopperSidecar } from '@universe-design/react' ;import zhCN from '@universe-design/react/es/shared/locales/zh-CN' ;import type { Router , StaticHandlerContext } from '@remix-run/router' ;import { GlobalProvider } from './components/provider' ;import { CurrentUser } from '@lark-meta/types' ;interface AppProps { req : HTTPRequest ; router : Router ; context : StaticHandlerContext ; profile ?: CurrentUser ; } export function App (props : AppProps ) { const { router, context, req, profile } = props; return ( <GlobalProvider req ={req} profile ={profile} path ={req.path} > <UDConfigProvider locale ={zhCN} PopperSidecar ={FloatingUIPopperSidecar} > <StaticRouterProvider router ={router} context ={context} /> </UDConfigProvider > </GlobalProvider > ); }
这里需要注意的是createFetchHandler方法,这个方法将服务端框架(gulux)的请求转化为fetch的请求,供staticHandler.query调用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 export async function renderApp ( publicCDNDomain : string , cdnPathPrefix : string , req : HTTPRequest , ogwDomain : string , profile ?: CurrentUser , ): Promise <string > { globalThis.__webpack_public_path__ = `${publicCDNDomain} /${cdnPathPrefix} ` ; const canSSR = matchPath ({ path : '/document' , caseSensitive : true , end : false }, req.path ); if (!canSSR) { return '' ; } const handler = createStaticHandler (routes); const fetchRequest = createFetchRequest (req); const context = (await handler.query (fetchRequest, { requestContext : { headers : req.headers ?? req.header , ogwDomain }, })) as StaticHandlerContext ; const router = createStaticRouter (handler.dataRoutes , context); const rootContent = renderToString (<App req ={req} router ={router} context ={context} profile ={profile} /> ); return rootContent; }
数据同构 完成路由同构后,刷新页面,我们会发现页面会闪一下,打开调试台抓一下请求,会发现浏览器在渲染页面后又重新发起了一次请求。这显然是不符合预期的,因为首屏的数据在 node 侧已经获取过一次了,客户端接管页面后应该复用服务端获取的数据,客户端切换路由时则从客户端发起请求。
数据的注水和脱水 如何让客户端复用服务端已经获取过的数据,这就涉及到了数据的注水和脱水。所谓的数据注水就是服务端获取数据后,将”水分”(数据)注入HTML中;数据脱水就是客户端接管页面后将“水分”(数据)从HTML中脱出,用来渲染首屏。
Loader React-router-dom v6 中的loader方法集成了数据注水和脱水的能力。
服务端渲染时会执行loader方法,该方法返回的数据会以全局变量挂载在window对象上以实现数据的注水。
在需要使用数据的时候调用useLoaderData方法即可实现数据的脱水。
Mobx 同构 前端代码中经常会使用到一些外部的状态管理库,这里以mobx举例,实现mobx的同构。我们只需要在
mobx实例初始化的时候传入脱水的数据,并在全局维护一个hydrate的变量来标志数据是否已经被客户端初始化,通过该变量控制useEffect是否执行获取数据的逻辑。示例代码如下:
Store的constructor方法中接受初始化的数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 export class APIDocStore { loading = false ; detail : GetAPIDocumentResponse | null = null ; constructor (detail ?: GetAPIDocumentResponse ) { if (detail) { this .detail = detail; } makeAutoObservable (this , { detail : observable.ref }, { autoBind : true }); } getDocDetail = flow (function * (this : APIDocStore , fileUri : string , revisionId ?: string ) { try { this .loading = true ; const data : GetAPIDocumentResponse | undefined = yield getDocDetail (fileUri, revisionId); if (data) { this .detail = data; } } catch (e) { console .error ('Get doc detail error' , e); } finally { this .loading = false ; } }); }
在组件初次渲染时传入组件所需的首屏数据初始化mobx实例(服务端和客户端分别都会执行一次);通过hydrate变量控制useEffect是否发起请求,客户端首屏渲染后需要将hydrate置为false,后续客户端切换路由由客户端发起请求获取数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 import React , { useEffect, useMemo } from 'react' ;import { useDocument } from '@/pages/document/provider' ;import { useHome } from '../../provider' ;import { observer } from 'mobx-react-lite' ;import { APIDocStore } from './store' ;import { RpcApiDocRender , RpcApiDocRenderType } from '@lark-meta/components' ;import { getFile } from '@/request/document' ;export const APIDoc = observer (() => { const { loaderData, store : documentStore } = useDocument (); const { selectedId } = documentStore; const { detail : loaderDetail } = loaderData; const { hydrate, setHydrate } = useHome (); const store = useMemo (() => new APIDocStore (loaderDetail), []); const { detail, loading, getDocDetail } = store; useEffect (() => { if (hydrate) { setHydrate (false ); return ; } getDocDetail (selectedId); }, [selectedId]); return ( <RpcApiDocRender loading ={loading} detail ={ detail ? { fileUri: detail.id , revisionId: detail.revision_id , name: detail.name , apiScopes: detail.api_scopes , fieldScopes: detail.field_scopes , updateTime: detail.update_time , projectName: detail.project_name , version: detail.project_version , content: detail.content , } : undefined } type ={RpcApiDocRenderType.DOC} getFile ={getFile} /> ); });
渲染同构 由于node环境下不能使用dom相关的api,有时候需要在组件渲染的代码中通过isNodeEnv区分不同环境执行的代码,以免服务端渲染报错。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 export function Layout ( ) { const { profile, path, req } = useGlobalData (); const defaultDomain = (function ( ) { if (isNodeEnv) { return req?.host ? `https://${req.host} ` : '' ; } else { return `https://${window .location.hostname} ` ; } })(); const [selectedKeys, setSelectedKeys] = useState (() => getSelectedKeys (path)); return ( <> </> ) }
样式闪烁 有些情况下会出现服务端渲染后样式闪烁的问题,一般导致这类问题主要有两个原因:
使用了 css-in-js 的方案处理样式 css-in-js 的方案是将 css 代码封装在js对象中,通常会通过操作 DOM 动态生成style标签填充样式,由于服务端渲染不能操作 DOM,可能会导致两端渲染的样式不一致。例如 styled-components 和 antd v5都是用了 css-in-js 的方案,针对SSR的场景也给出了相应的解决方案
styled-components 提供了ServerStyleSheetapi 创建样式表,收集所有 styled-components 生成的样式并生成内联 style 标签插入到 HTML 中来避免服务端渲染样式闪烁的问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import express from 'express' ;import React from 'react' ;import ReactDOMServer from 'react-dom/server' ;import { ServerStyleSheet , StyleSheetManager } from 'styled-components' ;import MyStyledComponent from './MyStyledComponent' ;const app = express ();app.get ('/' , (req, res ) => { const sheet = new ServerStyleSheet (); try { const html = ReactDOMServer .renderToString ( <StyleSheetManager sheet ={sheet.instance} > <MyStyledComponent /> </StyleSheetManager > ); const styleTags = sheet.getStyleTags (); const responseBody = ` <!DOCTYPE html> <html> <head>${styleTags} </head> <body>${html} </body> </html> ` ; res.send (responseBody); } catch (error) { console .error (error); res.status (500 ).send ('Internal Server Error' ); } finally { sheet.seal (); } }); app.listen (3000 );
参考 https://ant-design.antgroup.com/docs/react/server-side-rendering-cn
解决思路同styled-components,将样式从js中剥离出来(内联样式或者独立的css文件),注入到服务端返回的HTML中,让首屏就能加载出样式而不是通过执行js动态插入样式。
在 useEffect 或者 useLayoutEffect 等hook中修改客户端的样式 有些时候我们会习惯使用 useLayoutEffect 或者 useEffect 修改样式,由于服务端渲染执行不到 commit 阶段的生命周期,可能会导致两端渲染的样式不一致。所以,非必要情况不建议在 useLayoutEffect 或者 useEffect 里修改样式。
SEO 优化 在服务端渲染中实现 seo 优化非常简单,我们直接使用react-helmet 即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 <Parent > <Helmet > <title > My Title</title > <meta name ="description" content ="Helmet application" /> </Helmet > <Child > <Helmet > <title > Nested Title</title > <meta name ="description" content ="Nested component" /> </Helmet > </Child > </Parent >
缓存管理 在流量大的时候,执行服务端渲染会占用较多的服务器资源,有可能导致服务挂掉,所以我们需要根据业务的实际情况执行相应的缓存策略来降低服务器的压力。
在飞书域间文档项目中,采用了redis来缓存服务端渲染生成的html内容,缓存策略如下:(可根据实际情况调整)
过期时间1h
revisionId:标识API文档的版本
scmVersion:标识scm构建产物的版本
path:标识请求的路径
redis的key为:ssr-${scmVersion}-${revisionId}-${path}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @Get ('/*' )async index (@Req () req : HTTPRequest , @Query ('revisionId' ) queryRevisionId ?: string ) { const { path } = req; const configs = await getConfigs (path); if (!canSSr (path)) { const html = await this .views .render ('index.handlebars' , { ...configs, rootContent : '' , }); return html; } const { cdnPathPrefix, scmVersion } = getAppInfo (); const revisionId = queryRevisionId || (await getRevisionId (path)); const cacheKey = `ssr-${scmVersion} -${path} -${revisionId} ` ; const cacheHtml = await this .redisClient .get (cacheKey); const rootContent = cacheHtml ?? (await renderApp (publicCDNDomain, cdnPathPrefix, req).rootContent ); const html = await this .views .render ('index.handlebars' , { ...configs, rootContent, }); return html; }
降级处理 即使我们通过了一定的缓存策略来降低服务器压力,但是在服务端获取数据失败、接口超时、服务端渲染代码执行失败等情况下也会导致服务端渲染失败。需要采取一定的降级措施来保证用户侧不会感知到服务端渲染失败。
需要使用 try catch 包裹 loader 方法,在捕获到接口错误时返回空对象,客户端没有拿到注水的数据需要在浏览器再次发起请求。
1 2 3 4 5 6 7 8 9 10 11 loader : async (args) => { try { const data : LoaderData = {}; return json (data); } catch (e) { console .error ('Route loader error' , e); return json ({}); } },
客户端需要判断返回的数据是否为空对象,决定是否在客户端再次发起请求
由于存在代码不规范导致renderToString报错(reconciliation阶段使用了window/document等浏览器api)、接口调用超时等都会导致服务端渲染失败。需要针对这种情况进行降级处理。
定义1000ms超时时间,超时放弃服务端渲染,降级为客户端渲染
增加try/catch捕获,renderToString报错时降级为客户端渲染
浏览器window对象上增加降级处理标识,客户端代码读取到降级标识,放弃ReactDOM.hydrate,走ReactDOM.render逻辑
代码改造如下:
Node BFF
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 @Get ('/document*' )async document (@Req () req : HTTPRequest , @Res () res : HTTPResponse ) { const { path } = req; if (path === '/document' || path === '/document/' ) { res.redirect (`/axe/document/${RPC_LIST_KEY} ` ); return ; } let { publicCDNDomain = '' } = this .configStore .bffConfig ; const { user : userInfo } = this .userStore ; const user = JSON .stringify (userInfo ?? {}); if (isEnvDevelopment) { publicCDNDomain = 'http://localhost:4000' ; } try { const rootContent = (await timeout (this .ssr (req, publicCDNDomain), 1000 )) ?? '' ; res.set ('x-ssr' , '1' ); const html = await this .views .render ('doc.handlebars' , { publicCDNDomain, user, rootContent, ssrError : false , }); return html; } catch (e) { res.set ('x-ssr' , '0' ); if (e instanceof TimeoutError ) { this .logger .error (`[view controller] ssr timeout. error=${e.message} , path=${path} ` ); } else { this .logger .error (`[view controller] ssr error. error=${e} , path=${path} ` ); } const html = await this .views .render ('index.handlebars' , { publicCDNDomain, user, rootContent : '' , ssrError : true , }); return html; } }
客户端
1 2 3 4 5 6 7 8 const root = document .getElementById ('root' );if (process.env .DEV_NODE === 'csr' || window .__SSR_ERROR__ ) { ReactDOM .render (<App /> , root); } else { ReactDOM .hydrate (<App /> , root); }
开箱即用的 SSR 框架 现在业界有很多成熟的服务端渲染框架,已经封装好了很多服务端渲染的实现逻辑,可以根据实际的业务需求选用。
Edenx https://edenx.bytedance.net/
Next https://nextjs.org/
Remix https://remix.run/