react 服务端渲染原理不复杂,其中最核心的内容就是同构。
node server 接收客户端请求,得到当前的req url path,然后在已有的路由表内查找到对应的组件,拿到需要请求的数据,将数据作为 props 、context或者store 形式传入组件,然后基于 react 内置的服务端渲染api renderToString() or renderToNodeStream() 把组件转换为 html字符串或者 stream 流(脱水后输出覆盖到html), 在把最终的 html 进行输出前需要将数据注入到客户端(注水,将脱水后的数据重新转换格式变为客户端可用),浏览器开始进行渲染和节点对比,然后执行组件的componentDidMount 完成组件内事件绑定和一些交互,浏览器重用了服务端输出的 html 节点(因为初始的props值是服务端注水后传入的,与服务端使用的是同一份数据),整个流程结束。
react ssr 的核心就是同构,没有同构的 ssr 是没有意义的。
所谓同构就是采用一套代码,构建双端(server 和 client)逻辑,最大限度的重用代码,不用维护两套代码。而传统的服务端渲染是无法做到的,react 的出现打破了这个瓶颈,并且现在已经得到了比较广泛的应用。
路由同构
双端使用同一套路由规则,node server 通过req url path 进行组件的查找,得到需要渲染的组件。
//组件和路由配置 ,供双端使用 routes-config.js
数据同构(预取同构)
这里开始解决我们最开始发现的第二个问题 - 【获取数据的方法和逻辑写在哪里?】
数据预取同构,解决双端如何使用同一套数据请求方法来进行数据请求。
先说下流程,在查找到要渲染的组件后,需要预先得到此组件所需要的数据,然后将数据传递给组件后,再进行组件的渲染。
我们可以通过给组件定义静态方法来处理,组件内定义异步数据请求的方法也合情合理,同时声明为静态(static),在 server 端和组件内都也可以直接通过组件(function) 来进行访问。
比如 Index.getInitialProps
渲染同构
假设我们现在基于上面已经实现的代码,同时我们也使用 webpack 进行了配置,对代码进行了转换和打包,整个服务可以跑起来。
路由能够正确匹配,数据预取正常,服务端可以直出组件的 html ,浏览器加载 js 代码正常,查看网页源代码能看到 html 内容,好像我们的整个流程已经走完。
但是当浏览器端的 js 执行完成后,发现数据重新请求了,组件的重新渲染导致页面看上去有些闪烁。
这是因为在浏览器端,双端节点对比失败,导致组件重新渲染,也就是只有当服务端和浏览器端渲染的组件具有相同的props 和 DOM 结构的时候,组件才能只渲染一次。
刚刚我们实现了双端的数据预取同构,但是数据也仅仅是服务端有,浏览器端是没有这个数据,当客户端进行首次组件渲染的时候没有初始化的数据,渲染出的节点肯定和服务端直出的节点不同,导致组件重新渲染。
喝水(render)
首先要有水可脱,所以先要拉取数据(水),在服务端完成组件首次渲染(mount)的过程:
也就是根据外部数据构建出初始组件树,过程中仅执行
render
及之前的几个生命周期,是为了尽可能缩短保命招数的前摇,尽快脱水脱水(dehydrate)
接着对组件树进行脱水,使其在恶劣的环境同样能够以一种更简单的形态“生存”下来,比如禁用了 JavaScript 的客户端环境。
比组件树更简单的形态是 HTML 片段,脱去生命的水气(动态数据),成为风干标本一样的静态快照:内存里的组件树被序列化成了静态的 HTML 片段,还能看出来人样(初始视图),不过已经无法与之交互了,但这种便携的形态尤其适合运输,能够通过网络传输到地球上的某个客户端
注水(hydrate)
抵达客户端后,如果环境适宜(没有禁用 JavaScript),就立即开始“浸泡”(hydrate),组件随之复苏
客户端“浸泡”的过程实际上是重新创建了组件树,将新生的水(
state
、props
、context
等)注入其中,并将鲜活的组件树塞进服务端渲染的干瘪躯壳里,使之复活。
CSR & SSR
CSR:Client Side Rendering 客户端渲染,流程如下:
SSR:Server Side Rendering 服务端渲染,流程如下:
三、SSR 的优缺点及使用场景
3.1 优点
- 更快的首屏加载速度:无需等待 JS 完成下载且执行才显示内容,更快地看到完整渲染的页面,有更好的用户体验。
- 更友好的 SEO:
- 爬虫可以直接抓取渲染之后的页面,CSR 首次返回的 HTML 文件中,root 节点为空,不包含内容;而 SSR 返回渲染之后的 HTML 片段,内容完整,能更好地被爬虫分析与索引
3.2 缺点
- 对服务器性能消耗较高
- 项目复杂度变高,多了一个 node 中间层
- 需要考虑 SSR 及其的运维、申请、扩容,增加了运维成本
3.3 UmiJS 预渲染
服务端渲染,首先得有后端服务器(一般是 Node.js)才可以使用,而没有后端服务器的情况下,可以使用预渲染。
预渲染与服务端渲染唯一的不同点在于 渲染时机,服务端渲染的时机是在用户访问时执行渲染(即实时渲染,数据一般是最新的),预渲染的时机是在项目构建时,当用户访问时,数据不一定是最新的(如果数据没有实时性,可以直接考虑预渲染)。
预渲染在构建时执行渲染,将渲染后的 HTML 片段生成静态 HTML 文件。无需使用 web 服务器实时动态编译 HTML,适用于 静态站点生成。
四、Umi 服务端渲染
Umi3 在 SSR 上做了大量优化及开发体验的提升,具有以下特性:
- 开箱即用:内置 SSR,一键开启,可在
umi dev
中预览,方便调试开发。 - 服务端框架无关:Umi 不耦合服务端框架(如 Egg.js、Express、Koa),无论是哪种框架或者 Serverless 模式,都可以非常简单的进行集成。
- 支持应用和页面级数据预获取
- 支持按需加载:开启
dynamicImport
(按需加载)后,Umi 3 会根据不同路由加载对应的资源文件(css/js)。 - 内置预渲染功能:Umi 3 中内置了预渲染功能,不再通过安装额外插件使用,同时开启
ssr
和exportStatic
,在umi build
构建时会编译出渲染后的 HTML。 - 支持渲染降级:优先使用 SSR,如果服务端渲染失败,自动降级为客户端渲染(CSR),不影响正常业务流程。
- 支持流式渲染:
ssr: { mode: 'stream' }
即可开启流式渲染,流式 SSR 较正常 SSR 有更少的 TTFB(发送页面请求到接收到应用数据第一个字节所花费的毫秒数) 时间。 - 兼容客户端动态加载:可同时使用 SSR 和 dynamicImport。
- SSR 功能插件化:可通过提供的 API 来自定义 SSR 功能。
4.1 启用服务端渲染
默认情况下,服务端渲染时关闭的,可通过配置开启:
export default {
ssr: {
// 开发模式下的服务端渲染,默认为 true
devServerRender: false,
},
};
4.2 数据预获取
服务端渲染的数据获取方式与 SPA(单页应用) 有所不同,为了让客户端和服务端都能获取到同一份数据,Umi 提供了页面级数据的预获取。
页面级数据获取 - 使用
每个页面可能有单独的数据预获取逻辑,这里我们会获取页面组件上的 getInitialProps
静态方法,执行后将结果注入到该页面组件的 props
中,如:
// pages/index.tsx 函数组件
import { IGetInitialProps } from "umi";
import React from "react";
const Home = (props) => {
const { data } = props;
return <div>{data.title}</div>;
};
Home.getInitialProps = (async (ctx) => {
return Promise.resolve({
data: {
title: "Hello World!",
},
});
}) as IGetInitialProps;
export default Home;
// pages/index.tsx 类组件
import { IGetInitialProps } from "umi";
import React from "react";
class Home extends React.Component {
static getInitialProps = (async (ctx) => {
return Promise.resolve({
data: {
title: "Hello World",
},
});
}) as IGetInitialProps;
render() {
const { data } = props;
return <div>{data.title}</div>;
}
}
export default Home;
getInitialProps
有几个固定参数:
match
:与客户端页面 props 中的match
保持一致,保存当前路由的相关数据isServer
:是否为服务端在执行该方法route
:当前路由对象history
:history 对象
扩展 ctx 参数
为了结合数据流框架,我们提供了 modifyGetInitialPropsCtx
方法,由插件或应用来扩展 ctx
参数,以 dva
为例:
// plugin-dva/runtime.ts
export const ssr = {
modifyGetInitialPropsCtx: async (ctx) => {
ctx.store = getApp()._store;
},
};
然后在页面中,可以获取到 store
:
// pages/index.tsx
const Home = () => <div />;
Home.getInitialProps = async (ctx) => {
const state = ctx.store.getState();
return state;
};
export default Home;
同时也可以在自身应用中进行扩展:
// app.ts
export const ssr = {
modifyGetInitialPropsCtx: async (ctx) => {
ctx.title = "params";
return ctx;
},
};
同时可以使用 getInitialPropsCtx
将服务端参数扩展到 ctx
中,例如:
app.use(async (req, res) => {
// 或者从 CDN 上下载到 server 端
const render = require("./dist/umi.server");
res.setHeader("Content-Type", "text/html");
const context = {};
const { html, error, rootContainer } = await render({
path: req.url,
query: {},
context,
getInitialPropsCtx: {
req,
},
});
});
在使用的时候,就有 req
对象,不过需要注意的是,只在服务端执行时才有此参数:
Page.getInitialProps = async (ctx) => {
if (ctx.isServer) {
// console.log(ctx.req);
}
return {};
};
则在执行 getInitialProps
方法时,除了以上两个固定参数外,还会获取到 title
和 store
参数。
关于 getInitialProps
执行逻辑和时机,需要注意:
- 开启 ssr,且执行成功
- 未开启
forceInitial
,首屏不触发getInitialProps
,切换页面时会执行请求,和客户端渲染逻辑保持一致。 - 开启
forceInitial
,无论是首屏还是页面切换,都会触发getInitialProps
,目的是始终以客户端请求的数据为准。(有用在静态页面站点的实时数据请求上)
- 未开启
- 未开启 ssr 时,只要页面中有
getInitialProps
静态方法,则会执行该方法。
参考文章: