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:Client Side Rendering 客户端渲染,流程如下:
SSR:Server Side Rendering 服务端渲染,流程如下:
服务端渲染,首先得有后端服务器(一般是 Node.js)才可以使用,而没有后端服务器的情况下,可以使用预渲染。
预渲染与服务端渲染唯一的不同点在于 渲染时机,服务端渲染的时机是在用户访问时执行渲染(即实时渲染,数据一般是最新的),预渲染的时机是在项目构建时,当用户访问时,数据不一定是最新的(如果数据没有实时性,可以直接考虑预渲染)。
预渲染在构建时执行渲染,将渲染后的 HTML 片段生成静态 HTML 文件。无需使用 web 服务器实时动态编译 HTML,适用于 静态站点生成。
Umi3 在 SSR 上做了大量优化及开发体验的提升,具有以下特性:
umi dev
中预览,方便调试开发。dynamicImport
(按需加载)后,Umi 3 会根据不同路由加载对应的资源文件(css/js)。ssr
和 exportStatic
,在 umi build
构建时会编译出渲染后的 HTML。ssr: { mode: 'stream' }
即可开启流式渲染,流式 SSR 较正常 SSR 有更少的 TTFB(发送页面请求到接收到应用数据第一个字节所花费的毫秒数) 时间。默认情况下,服务端渲染时关闭的,可通过配置开启:
export default {
ssr: {
// 开发模式下的服务端渲染,默认为 true
devServerRender: false,
},
};
服务端渲染的数据获取方式与 SPA(单页应用) 有所不同,为了让客户端和服务端都能获取到同一份数据,Umi 提供了页面级数据的预获取。
页面级数据获取 - 使用
每个页面可能有单独的数据预获取逻辑,这里我们会获取页面组件上的 getInitialProps
静态方法,执行后将结果注入到该页面组件的 props
中,如:
// pages/index.tsx 函数组件
import { IGetInitialProps } from "umi";
import React from "react";
const Home = (props) => {
const { data } = props;
return {data.title};
};
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 {data.title};
}
}
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 = () => ;
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
执行逻辑和时机,需要注意:
forceInitial
,首屏不触发 getInitialProps
,切换页面时会执行请求,和客户端渲染逻辑保持一致。forceInitial
,无论是首屏还是页面切换,都会触发 getInitialProps
,目的是始终以客户端请求的数据为准。(有用在静态页面站点的实时数据请求上)getInitialProps
静态方法,则会执行该方法。
参考文章: