认识
一、认识
React SSR
是一个结合SPA
的SSR
。通过 React SSR
渲染的页面, 需要再客户端激活才能实现交互。因此, React SSR
包含两部分: 服务端渲染的首屏、包含交互的SPA
。也就是说: React SSR
首次渲染页面是服务端直出,后续的访问(路由切换、事件交互)都是SPA
。这样一来,既能解决SEO
问题,也能保持页面切换的效率,服务器的压力要比传统的SSR
也相对小。
1.1 传统 React 渲染流程
-
浏览器发送请求
-
服务器返回
HTML
-
浏览器发送
bundle.js
请求 -
服务器返回
bundle.js
-
浏览器执行
bundle.js
中的React
代码,将页面渲染出来
1.2 SSR React 渲染流程
-
浏览器发送请求
-
服务器运 行
React
代码生成页面 -
服务器返回页面
二、模型
ReactSSR
的三体人模型:
- 服务端渲染:在服务端注入数据,构建出组件树
- 序列化成 HTML:脱水成人干
- 客户端渲染:到达客户端后泡水,激活水流,变回活人
2.1 喝水(render)
首先要有水可脱,所以先要拉取数据(水),在服务端完成组件首次渲染。也就是根据外部数据构建出初始组件树,过程中仅执行render及之前的几个生命周期,是为了尽可能缩短保命招数的前摇,尽快脱水
2.2 脱水(dehydrate)
简单理解: 接着对组件树进行脱水,使其在恶劣的环境同样能够以一种更简单的形态“生存”下来,比如禁用了JavaScript
的客户端环境。比组件树更简单的形态是HTML
片段,脱去生命的水气(动态数据),成为风干标本一样。内存里的组件树被序列化成了静态的HTML
片段,还能看出来人样(初始视图),不过已经无法与之交互了,但这种便携的形态尤其适合运输,能够通过网络传输到地球上的某个客户端
实践理解: 在服务器端渲染时,首先服务端请求接口拿到数据,并处理准备好数据状态(如果使用 Redux,就是进行 store 的更新),为了减少客户端的请求,我们需要保留住这个状态。一般做法是在服务器端返回 HTML 字符串的时候,将数据 JSON.stringify 一并返回,这个过程,叫做脱水(dehydrate)
2.3 注水(hydrate)
简单理解: 抵达客户端后,如果环境适宜(没有禁用 JavaScript),就立即开始“浸泡”(hydrate
),组件随之复苏。客户端“浸泡”的过程实际上是重新创建了组件树,将新生的水(state、props、context等)注入其中,并将鲜活的组件树塞进服务端渲染的干瘪躯壳里,使之复活:
浸泡也需要一定时间,所以在SSR
模式下,客户端有一段时间是无法正常交互的,注水完成之后才能彻底复活(单向数据流和交互行为都恢复正常)
实践理解: 在客户端,就不再需要进行数据的请求了,可以直接使用服务端下发下来的数据,这个过程叫注水(hydrate)
三、工作流
SSR
会在服务端(这里主要指 Node.js
端)提前渲染出完整的 HTML
内容,那这是如何做到的呢?
首先需要保证前端的代码经过编译后放到服务端中能够正常执行,其次在服务端渲染前端组件,生成并组装应用的 HTML
。这就涉及到 SSR
应用的两大生命周期: 构建时和运行时
3.1 构建时
- 解决模块加载问题: 在原有的构建过程之外,需要加入
SSR
构建的过程 ,具体来说,我们需要另外生成一份CommonJS
格式的产物,使之能在Node.js
正常加载。当然,随着Node.js
本身对ESM
的支持越来越成熟,我们也可以复用前端ESM
格式的代码,Vite
在开发阶段进行SSR
构建也是这样的思路。
- 移除样式代码的引入: 直接引入一行
css
在服务端其实是无法执行的,因为Node.js
并不能解析CSS
的内容。但CSS Modules
的情况除外,如下所示:
import styles from './index.module.css'
// 这里的 styles 是一个对象,如{ "container": "xxx" },而不是 CSS 代码
console.log(styles)
- 依赖外部化(
external
): 对于某些第三方依赖我们并不需要使用构建后的版本,而是直接从node_modules
中读取,比如react-dom
,这样在SSR
构建的过程中将不会构建这些依赖,从而极大程度上加速SSR
的构建。
3.2 运行时
对于 SSR
的运行时,一般可以拆分为比较固定的生命周期阶段,简单来讲可以整理为以下几个核心的阶段:
-
加载
SSR
入口模块: 在这个阶段,我们需要确定SSR
构建产物的入口,即组件的入口在哪里,并加载对应的模块 -
进行数据预取: 这时候
Node
侧会通过查询数据库或者网络请求来获取应用所需的数据 -
渲染组件: 这个阶段为
SSR
的核心,主要将第1
步中加载的组件渲染成HTML
字符串或者Stream
流 -
HTML
拼接: 在组件渲染完成之后,我们需要拼接完整的HTML
字符串,并将其作为响应返回给浏览器
从上面的分析中你可以发现,SSR
其实是构建和运行时互相配合才能实现的,也就是说,仅靠构建工具是不够的
四、SSR API
React
提供的SSR API
分为两部分,一部分面向服务端(react-dom/server)
,另一部分仍在客户端执行(react-dom)
4.1 ReactDOMServer
ReactDOMServer 相关 API 能够在服务端将 React 组件渲染成静态的 HTML 标签
。 把组件渲染成 HTML 标签的工作在浏览器环境中也能完成,因此面向服务端的ReactDOMServer
API 分为两类:
- 能跨Node.js、浏览器环境运行的
String API
:renderToString()
、renderToStaticMarkup()
- 只能在Node.js 环境运行的
Stream API
:renderToNodeStream()
、renderToStaticNodeStream()
4.2 ReactDOMServer.renderToString
ReactDOMServer.renderToStaticMarkup(element)
最基础的SSR API
, 输入React
组件,输出HTML
字符串。之后由客户端hydrate API
对服务端返回的视图结构加上交互行为,完成页面渲染。
版本对比:
- React16之前:
renderToString()
基于字符串校验和
的HTML节点复用方式,字对字的严格校验一致性,一旦发现不匹配就完全丢弃服务端渲染结果,在客户端渲染。
renderToString()
生成了大量的额外属性:
// renderToString
<div data-reactroot="" data-reactid="1"
data-react-checksum="122239856">
<!-- react-text: 2 -->This is some <!-- /react-text -->
<span data-reactid="3">server-generated</span>
<!-- react-text: 4--> <!-- /react-text -->
<span data-reactid="5">HTML.</span>
</div>
- React16之后:
renderToString()
采用单节点校验来复用服务端返回的HTML节点,不再生成data-reactid
、data-react-checksum
等体积占用大户。
renderToString()
渲染结果:
// renderToString
<div data-reactroot="">
<!-- react-text: 2 -->This is some <!-- /react-text -->
<span>server-generated</span>
<!-- react-text: 4--> <!-- /react-text -->
<span>HTML.</span>
</div>
4.3 ReactDOMServer.renderToStaticMarkup
ReactDOMServer.renderToStaticMarkup(element)
同renderToString
类似,区别在于API
设计上,renderToStaticMarkup
只用于纯展示(没有事件交互,不需要hydrate
)的场景。因此renderToStaticMarkup
只生成干净的HTML
,不带额外的DOM
属性(如 data-reactroot),响应体积上有些略微的优势。
renderToStaticMarkup()
渲染结果:
// renderToStaticMarkup
<div>
<span>server-generated</span>
<span>HTML.</span>
</div>
也就是说,目前React17
中,renderToStaticMarkup
与renderToString
的实际差异主要在于:
renderToStaticMarkup
不生成data-reactroot
renderToStaticMarkup
不在相邻文本节点之间生成<!-- -->
。相当于合并了文本节点,不考虑节点复用,算是针对静态渲染的额外优化措施。
4.4 ReactDOMServer.renderToNodeStream
ReactDOMServer.renderToNodeStream(element)
对应于renderToString()
的Stream API
,将renderToString()
生成的HTML字符串以Node.js Readable stream
形式返回。默认返回utf-8
编码的字节流,其他编码格式需自行转换。另外,该 API 的实现依赖Node.js 的 Stream 特性
,所以不能在浏览器环境使用。
4.5 ReactDOMServer.renderToStaticNodeStream
ReactDOMServer.renderToStaticNodeStream(element)
对应于renderToStaticMarkup
的Stream
API,将renderToStaticMarkup
生成的干净HTML字符串以Node.js Readable stream
形式返回。同样按utf-8
编码,并且不能在浏览器环境使用。
4.6 ReactDOM.hydrate()
ReactDOM.hydrate(element, container[, callback])
hydrate
配合SSR
使用,与render()
的区别在于渲染过程中能够复用服务端返回的现有HTML节点
,只为其附加交互行为(事件监听等),并不重新创建DOM
节点。
如果服务端返回的HTML与客户端渲染结果不一致时,出于性能考虑,hydrate()
并不纠正除文本节点外的SSR渲染结果,而是将错就错。
五、组件转换细节
服务端 React
组件是怎么变成HTML字符串的?
输入一个React
组件:
class MyComponent extends React.Component {
constructor() {
super();
this.state = {
title: 'Welcome to React SSR!',
};
}
handleClick() {
alert('clicked');
}
render() {
return (
<div>
<h1 className="site-title" onClick={this.handleClick}>{this.state.title} Hello There!</h1>
</div>
);
}
}
经过ReactDOMServer.renderToString()
处理后输出HTML
字符串:
'<div data-reactroot=""><h1 class="site-title">Welcome to React SSR!<!-- --> Hello There!</h1></div>'
这中间发生了什么?
过程为创建组件示例、渲染组件、渲染DOM元素
5.1 创建组件
inst = new Component(element.props, publicContext, updater);
通过第三个参数updater
注入了外部updater
,用来拦截setState
等操作:
var updater = {
isMounted: function (publicInstance) {
return false;
},
enqueueForceUpdate: function (publicInstance) {
if (queue === null) {
warnNoop(publicInstance, 'forceUpdate');
return null;
}
},
enqueueReplaceState: function (publicInstance, completeState) {
replace = true;
queue = [completeState];
},
enqueueSetState: function (publicInstance, currentPartialState) {
if (queue === null) {
warnNoop(publicInstance, 'setState');
return null;
}
queue.push(currentPartialState);
}
};
与先前维护虚拟 DOM 的方案相比,这种拦截状态更新的方式更快。
5.2 渲染组件
拿到初始数据(inst.state
)后,依次执行组件生命周期函数:
// getDerivedStateFromProps
var partialState = Component.getDerivedStateFromProps.call(null, element.props, inst.state);
inst.state = _assign({}, inst.state, partialState);
// componentWillMount
if (typeof Component.getDerivedStateFromProps !== 'function') {
inst.componentWillMount();
}
// UNSAFE_componentWillMount
if (typeof inst.UNSAFE_componentWillMount === 'function' && typeof Component.getDerivedStateFromProps !== 'function') {
// In order to support react-lifecycles-compat polyfilled components,
// Unsafe lifecycles should not be invoked for any component with the new gDSFP.
inst.UNSAFE_componentWillMount();
}
注意新旧生命周期的互斥关系,优先getDerivedStateFromProps
,若不存在才会执行componentWillMount/UNSAFE_componentWillMount
,特殊的,如果这两个旧生命周期函数同时存在,会按以上顺序把两个函数都执行一遍.
接下来准备render
了,但在此之前,先要检查updater
队列,因为componentWillMount/UNSAFE_componentWillMount
可能会引发状态更新:
if (queue.length) {
var nextState = oldReplace ? oldQueue[0] : inst.state;
for (var i = oldReplace ? 1 : 0; i < oldQueue.length; i++) {
var partial = oldQueue[i];
var _partialState = typeof partial === 'function' ? partial.call(inst, nextState, element.props, publicContext) : partial;
nextState = _assign({}, nextState, _partialState);
}
inst.state = nextState;
}
接着进入render
:
child = inst.render();
并递归向下对子组件进行同样的处理(processChild)
:
while (React.isValidElement(child)) {
// Safe because we just checked it's an element.
var element = child;
var Component = element.type;
if (typeof Component !== 'function') {
break;
}
processChild(element, Component);
}
直至遇到原生 DOM 元素(组件类型不为function
),将 DOM 元素“渲染”成字符串并输出:
if (typeof elementType === 'string') {
return this.renderDOM(nextElement, context, parentNamespace);
}
5.3 渲染DOM元素
特殊的,先对受控组件的props
进行预处理:
// input
props = _assign({
type: undefined
}, props, {
defaultChecked: undefined,
defaultValue: undefined,
value: props.value != null ? props.value : props.defaultValue,
checked: props.checked != null ? props.checked : props.defaultChecked
});
// textarea
props = _assign({}, props, {
value: undefined,
children: '' + initialValue
});
// select
props = _assign({}, props, {
value: undefined
});
// option
props = _assign({
selected: undefined,
children: undefined
}, props, {
selected: selected,
children: optionChildren
});
接着正式开始拼接字符串,先创建开标签:
// 创建开标签
var out = createOpenTagMarkup(element.type, tag, props, namespace, this.makeStaticMarkup, this.stack.length === 1);
function createOpenTagMarkup(tagVerbatim, tagLowercase, props, namespace, makeStaticMarkup, isRootElement) {
var ret = '<' + tagVerbatim;
for (var propKey in props) {
var propValue = props[propKey];
// 序列化style值
if (propKey === STYLE) {
propValue = createMarkupForStyles(propValue);
}
// 创建标签属性
var markup = null;
markup = createMarkupForProperty(propKey, propValue);
// 拼上到开标签上
if (markup) {
ret += ' ' + markup;
}
}
// renderToStaticMarkup() 直接返回干净的HTML标签
if (makeStaticMarkup) {
return ret;
}
// renderToString() 给根元素添上额外的react属性 data-reactroot=""
if (isRootElement) {
ret += ' ' + createMarkupForRoot();
}
return ret;
}
再创建闭标签:
// 创建闭标签
var footer = '';
if (omittedCloseTags.hasOwnProperty(tag)) {
out += '/>';
} else {
out += '>';
footer = '</' + element.type + '>';
}
并处理子节点:
// 文本子节点,直接拼到开标签上
var innerMarkup = getNonChildrenInnerMarkup(props);
if (innerMarkup != null) {
out += innerMarkup;
} else {
children = toArray(props.children);
}
// 非文本子节点,开标签输出(返回),闭标签入栈
var frame = {
domNamespace: getChildNamespace(parentNamespace, element.type),
type: tag,
children: children,
childIndex: 0,
context: context,
footer: footer
};
this.stack.push(frame);
return out;
注意: 此时完整的 HTML 片段虽然尚未渲染完成(子节点并未转出 HTML,所以闭标签也没办法拼上去),但开标签部分已经完全确定,可以输出给客户端了
六、生命周期细节
SSR
模式下,服务端只执行3
个生命周期函数:
-
constructor
-
getDerivedStateFromProps/componentWillMount
-
render
过程冲只执行render
及之前的生命周期,其余任何生命周期函数在服务端都不执行。