简介
如果我们想要拥有开箱即用的 SSR 的能力,通常需要和某些前端框架强绑定,由框架来提供一个大而全的方案,常见的如 Next.js / Umi 等。这些前端框架将 SSR 作为一个可配置的扩展能力提供给用户,用户通过遵循框架的规范进行开发来完成 SSR 应用。
然而,接入前端框架对于历史的项目迁移成本是很大的,也一定程度上限制了项目的自由度。正好最近有部分存量项目需要优化 SSR 的体验与性能,因此我们考虑到为存量项目提供一套低成本的迁移方案,同时又能为新项目提供一套 SSR 的最佳实践,我们设计实现了一个渐进式的 SSR 框架。
SSR 的核心是基于 React 提供的 renderToString 方法来实现在服务端渲染页面的能力,那我们不妨换个思路,把 SSR 所需要的必要能力抽象出来,不与具体的框架耦合,同时为业务提供可选的最佳实践和规范。这样就能灵活的为各种类型的前端服务提供扩展的 SSR 能力了。
为什么需要一个 SSR 框架?
React 本身就提供了 renderToNodeStream/renderToString 方法来实现在服务端渲染页面的能力,我们可以直接基于 renderToString 方法裸写一个 node.js 应用来实现服务端渲染的能力,但是在实际的业务场景中,这样做会遇到一些问题:
-
在业务 Node 服务中会存在大量实现 SSR 的框架代码,且实现规范不一,由于缺乏约束,前后端之间存在比较大的耦合性,也可能隐含一些前后端交互的规范约定,很容易导致业务架构混乱。
-
缺少一个 SSR 同构的最佳实践,经常无法考虑到 SSR 降级场景下的兼容性问题
我们可以通过一个渐进式的 SSR 框架提供以下可插拔的能力/规范来使得开发同构的 SSR 应用可以更加聚焦在业务上,同时又保证服务的灵活性:
-
标准化 SSR 渲染流程,封装服务端渲染和客户端渲染的逻辑
- SSR 场景下,组件渲染可能发生在前端或者服务端
-
提供标准化的本地开发/构建/部署的最佳实践(规范)
- SSR 场景下,本地开发依赖 SSR 服务端,需要正确配置代理以及产物的生成和更新
-
提供一套同构的数据获取/注入的机制(规范)
- SSR 场景下,数据获取的操作可能在前端或者服务端触发
-
提供一套同构的路由管理的机制(规范)
- SSR 场景下,前端和服务端都需要感知路由的配置
SSR 原理简介
相比于 客户端渲染 (CSR) ,服务端渲染(SSR) 的优势包括:
-
更快的首屏渲染速度
-
更友好的 SEO 能力
同时也会引入一些额外的复杂度:
-
编程中需要额外考虑服务端场景执行代码的限制
-
额外的 Node 服务运维成本
一个常规的 SSR 渲染流程如下:
简单地说, CSR 场景下部分在前端执行的逻辑可能会在 Node.js 运行环境中被执行,因此
-
从业务视角来看,我们需要保证业务代码能正常在两个环境中运行。
-
从框架视角来看,我们需要尽量抹平两个环境的差异,提供一致的开发体验。同时针对不同的地方,提供标准的规范和最佳实践,如前后端路由的管理方案等。
从 CSR 到 SSR
作为一个渐进式框架,显然需要能够支持将现有的项目进行渐进式的迁移到 SSR。
这一节我们简单的来描述如何手动将 CSR 服务转变成 SSR 服务。
生成 Server 端构建产物
Node.js 无法在没有 webpack 的情况下直接加载前端的组件,因为他无法处理 css 等其他资源的应用。在 CSR 应用中,我们通常会通过 webpack 进行构建产物的打包,最终生成 html,js,css 等其他资源文件。
要实现服务端渲染,我们需要额外的一个 ssr 的 webpack 的配置(或者其他构建工具)来构建出 commonjs 格式的 js bundle 供 Node.js 应用进行 react 组件的加载。
开发 Node 应用
Node.js 侧主要做的就是加载前端 App 组件,然后执行 renderToString 即可
定义数据获取和注入的规范
在 Node.js 侧执行 renderToString 的时候 useEffect 或者 componentDidMount 等钩子都不会被执行,因此我们需要在已有的 react 生命周期之外,额外定义一个数据获取规范。
考虑到通用性,参考业界的常用方案,我们可以做一下约定:
-
SSR 渲染的数据统一通过 App 的 props 注入,后续的处理由业务负责,如统一将 App 的 props 通过 useContext 进行维护等。
-
参考 Next.js,额外提供 GetInitialProps() 方法用于在 react 生命周期之前执行异步的数据请求等操作。
框架实现
这一节我们会具体介绍渐进式 SSR 框架的实现
Server Renderer
单次渲染的生命周期
-
Init
-
Load
-
Fetch
-
Render
-
Response
渲染上下文 SSRContext
在 SSR 渲染的生命周期中,通过 SSRContext 共享上下文信息,各个阶段之间的数据状态通过 SSRContext 进行共享
此外,这个上下文对象在 Server 端执行 GetInitialProps 的时候也会传入
TypeScript
export interface SSRContext {
ctx?: HttpContext;
pathname?: string;
mode?: 'CSR' | 'SSR';
logger?: LoggerType;
query?: Record<string, string | string[] | undefined>;
dev?: boolean;
initialProps?: InitialProps;
isSSR?: boolean;
pageName?: string;
pageComponentPath?: string;
pageComponent?: SSRPage;
templatePath?: string;
helmet?: boolean;
hooks?: HooksType;
rootNode?: string;
routeMap?: Map<string, ResourceItem>;
error?: Error;
startTimeStamp?: number;
startFetchTimeStamp?: number;
endFetchTimeStamp?: number;
startLoadTimeStamp?: number;
endLoadTimeStamp?: number;
startRenderTimeStamp?: number;
endRenderTimeStamp?: number;
endTimestamp?: number;
}
自定义 hooks
支持用户在渲染生命周期的各个节点注入自定义的逻辑
TypeScript
export type HooksType = { // 支持用户自定义在渲染流程中注入逻辑
preLoad?(ssrContext: SSRContext): any;
postLoad?(ssrContext: SSRContext): any;
preFetch?(ssrContext: SSRContext): any;
postFetch?(ssrContext: SSRContext): any;
preRender?(ssrContext: SSRContext): any;
postRender?(ssrContext: SSRContext): any;
preResponse?(ssrContext: SSRContext): any;
postResponse?(ssrContext: SSRContext): any;
}
日志打点
日志默认使用业务项目中的 ctx.logger 打印
打点通过 @byted-service/metrics 上报到 metrics 平台
渲染耗时打点
记录以下耗时,打印日志并上报 metrics 打点
-
SSR全阶段耗时
-
Load阶段耗时
-
Fetch阶段耗时
-
Render阶段耗时
eg. woody.ssr.anote.socrates.home.total.latency
渲染 Counter 打点
eg. woody.ssr.anote.socrates.home.render.success.throughput
Client Renderer
提供 client 侧的 renderer
本地开发
高效支持本地 SSR 应用的前后端开发
引入 SSR 后,在本地开发时我们需要打通 Node 服务和前端服务之间的资源交互,核心目标是不生成任何构建产物的中间代码,当 server 发生变更后能快速热重启 server 服务,当 client 侧产生变更后,需要同时更新 client bundle 和 server bundle 并通知到 SSR server 进行 bundle 的更新。
静态资源代理
前端会通过 webpack serve 提供静态资源,我们在本地开发时,gulu 插件会默认将 webpack serve 的静态资源代理到 ssr 的端口下,避免多个端口访问资源异常的问题
构建产物代理
SSR 渲染同时需要依赖 csr 构建产物中的 html 模板和 ssr 构建产物中的 js bundle,这两部分产物我们也会通过 webpack serve 提供 http 资源访问链接。我么通过 gulu 插件提供了自定义 bundle 加载机制,在 本地开发阶段可以通过 http 请求进行加载,示例代码如下:
TypeScript
// config/ssr.default.ts
import { SSRConfig } from '@byted-woody/gulu-react-ssr';
import HttpApplication from '@gulu/application-http';
export default (app: HttpApplication): Partial<SSRConfig> => ({
devProxy: {
target: 'http://localhost:3000', // 前端 csr 产物的代理地址
},
loadModule: async (_, pageName) => {
const res = await app.fetch(`http://127.0.0.1:3003/${pageName}.js`, {
timeout: 100000,
});
const requireString = await res.text();
// eslint-disable-next-line @typescript-eslint/no-var-requires
const requireFromString = require('require-from-string');
const data = requireFromString(requireString);
return data;
},
loadTemplate: async (_, pageName) => {
return (
await app.fetch(`http://127.0.0.1:3000/${pageName}.html`, {
timeout: 100000,
})
).text();
},
});
相比于生成中间文件的方式,本地开发时单页面的渲染耗时可以减少 1s 以上,SSR 阶段总耗时 <100ms 左右,Load 阶段耗时 1500ms -> 60ms
数据请求
考虑到兼容性,框架可以同时支持两种数据获取的模式
-
通过定义应用/页面级组件的静态方法获取数据 GetInitialProps(SSRContext)
-
支持在执行 server render 的时候手动注入数据到组件的 props 中
状态管理
由于前后端数据的交互只通过基础的 Props 进行传递,因此框架不绑定任何状态管理组件,业务方可以自由实现,包括 Redux,Context 等都可以。
路由管理
由于前后端数据的交互只通过基础的 Props 进行传递,框架默认不绑定任何路由管理组件,对历史项目的迁移很友好。
此外,为了提升开发的体验,我们提供了两种模式 MPA 和 SPA 应用的最佳实践和开发范式:
降级
在业务中,当插件执行 SSR 渲染流程中出错的时候,默认会降级为 CSR 渲染。
此外,当 CPU 过载时 Node 进程内的降级是不可靠的。为了应对流量突增/DDoS,我们提供了更加可靠的 Sidecar 降级能力
一体化应用
一体化形态的 SSR 应用可以为使用者提供更加统一的开发体验,但是也会导致前后端存在一定程度的耦合。作为一个渐进式 SSR 框架,支持一体化应用并不需要额外的成本,只需要定义业务上的开发规范即可。
优点
前后端在同一个项目中,TS 类型可以天然复用
项目的 dev/build 可以收敛到统一的方案,不需要维护两套脚本,两个 SCM 仓库
缺点
前后端存在一定耦合,会一定程度上限制前后端的技术选型
前后端依赖耦合,容易把一些前端不应该引用的包引入导致项目性能劣化
目录结构
尽量做到前后端分离,降低耦合
/server: Node.js 代码
/client: 前端代码
路由模式
采用 MPA,前端不控制路由,完全交由后端控制路由
写在最后
实现 React 应用服务端渲染的核心能力是相对比较简单的,但是由于引入了服务端,涉及到前后端之间逻辑的交互,如果我们没有合理的去进行系统架构的设计,拆分模块功能,那么很容易导致项目可维护性快速下降。渐进式 SSR 框架就是为了解决业务中的开发痛点诞生的,简单的说它是:
- 精简的 SSR 渲染流程的封装 + 一批 SSR 场景下最佳实践的规范 + 前端框架无关
从而能够帮助业务可以渐进式的去优化现有的 SSR 基础设施,在业务迭代和技术迭代之间找到一个较好的平衡点。