写在前面
上篇React SSR 之 API 篇细致介绍了 React SSR 相关 API 的作用,本篇将深入源码,围绕以下 3 个问题,弄清楚其实现原理:
- React 组件是怎么变成 HTML 字符串的?
- 这些字符串是如何边拼接边流式发送的?
- hydrate 究竟做了什么?
一.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>'
这中间发生了什么?
首先,创建组件实例,再执行render
及之前的生命周期,最后将 DOM 元素映射成 HTML 字符串
创建组件实例
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 的方案相比,这种拦截状态更新的方式更快:
(摘自What’s New With Server-Side Rendering in React 16)
替换 React 内置 updater 的部分位于 React.Component 基类的构造器中:
function Component(props, context, updater) {
this.props = props;
this.context = context; // If a component has string refs, we will assign a different object later.
this.refs = emptyObject; // We initialize the default updater but the real one gets injected by the
// renderer.
this.updater = updater || ReactNoopUpdateQueue;
}
渲染组件
拿到初始数据(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);
}
“渲染”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,所以闭标签也没办法拼上去),但开标签部分已经完全确定,可以输出给客户端了
二.这些字符串是如何边拼接边流式发送的?
如此这般,每趟只渲染一个节点,直到栈中没有待完成的渲染任务为止:
function read(bytes) {
try {
var out = [''];
while (out[0].length < bytes) {
if (this.stack.length === 0) {
break;
}
// 取栈顶的渲染任务
var frame = this.stack[this.stack.length - 1];
// 该节点下所有子节点都渲染完毕
if (frame.childIndex >= frame.children.length) {
var footer = frame.footer;
// 当前节点(的渲染任务)出栈
this.stack.pop();
// 拼上闭标签,当前节点打完收工
out[this.suspenseDepth] += footer;
continue;
}
// 每处理一个子节点,childIndex + 1
var child = frame.children[frame.childIndex++];
var outBuffer = '';
try {
// 渲染一个节点
outBuffer += this.render(child, frame.context, frame.domNamespace);
} catch (err) { /*...*/ }
out[this.suspenseDepth] += outBuffer;
}
return out[0];
} finally { /*...*/ }
}
这种细粒度的任务调度让流式边拼接边发送成为了可能,与React Fiber 调度机制异曲同工,同样是小段任务,Fiber 调度基于时间,SSR 调度基于工作量(while (out[0].length < bytes)
)
按给定的目标工作量(bytes
)一块一块地输出,这正是流的基本特性:
生产者的生产模式已经完全符合流的特性了,因此,只需要将其包装成 Readable Stream 即可:
function ReactMarkupReadableStream(element, makeStaticMarkup, options) {
var _this;
// 创建 Readable Stream
_this = _Readable.call(this, {}) || this;
// 直接使用 renderToString 的渲染逻辑
_this.partialRenderer = new ReactDOMServerRenderer(element, makeStaticMarkup, options);
return _this;
}
var _proto = ReactMarkupReadableStream.prototype;
// 重写 _read() 方法,每次读指定 size 的字符串
_proto._read = function _read(size) {
try {
this.push(this.partialRenderer.read(size));
} catch (err) {
this.destroy(err);
}
};
异常简单:
function renderToNodeStream(element, options) {
return new ReactMarkupReadableStream(element, false, options);
}
P.S.至于非流式 API,则是一次性读完(read(Infinity)
):
function renderToString(element, options) {
var renderer = new ReactDOMServerRenderer(element, false, options);
try {
var markup = renderer.read(Infinity);
return markup;
} finally {
renderer.destroy();
}
}
三.hydrate 究竟做了什么?
组件在服务端被灌入数据,并“渲染”成 HTML 后,在客户端能够直接呈现出有意义的内容,但并不具备交互行为,因为上面的服务端渲染过程并没有处理onClick
等属性(其实是故意忽略了这些属性):
function shouldIgnoreAttribute(name, propertyInfo, isCustomComponentTag) {
if (name.length > 2 && (name[0] === 'o' || name[0] === 'O') && (name[1] === 'n' || name[1] === 'N')) {
return true;
}
}
也没有执行render
之后的生命周期,组件没有被完整地“渲染”出来。因此,另一部分渲染工作仍然要在客户端完成,这个过程就是 hydrate
hydrate 与 render 的区别
hydrate()
与render()
拥有完全相同的函数签名,都能在指定容器节点上渲染组件:
ReactDOM.hydrate(element, container[, callback])
ReactDOM.render(element, container[, callback])
但不同于render()
从零开始,hydrate()
是发生在服务端渲染产物之上的,所以最大的区别是 hydrate 过程会复用服务端已经渲染好的 DOM 节点
节点复用策略
hydrate 模式下,组件渲染过程同样分为两个阶段:
- 第一阶段(render/reconciliation):找到可复用的现有节点,挂到
fiber
节点的stateNode
上 - 第二阶段(commit):
diffHydratedProperties
决定是否需要更新现有节点,规则是看 DOM 节点上的attributes
与props
是否一致
也就是说,在对应位置找到一个“可能被复用的”(hydratable)现有 DOM 节点,暂时作为渲染结果记下,接着在 commit 阶段尝试复用该节点
选择现有节点具体如下:
// renderRoot的时候取第一个(可能被复用的)子节点
function updateHostRoot(current, workInProgress, renderLanes) {
var root = workInProgress.stateNode;
// hydrate模式下,从container中找出第一个可用子节点
if (root.hydrate && enterHydrationState(workInProgress)) {
var child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
workInProgress.child = child;
}
}
function enterHydrationState(fiber) {
var parentInstance = fiber.stateNode.containerInfo;
// 取第一个(可能被复用的)子节点,记到模块级全局变量上
nextHydratableInstance = getFirstHydratableChild(parentInstance);
hydrationParentFiber = fiber;
isHydrating = true;
return true;
}
选择标准是节点类型为元素节点(nodeType
为1
)或文本节点(nodeType
为3
):
// 找出兄弟节点中第一个元素节点或文本节点
function getNextHydratable(node) {
for (; node != null; node = node.nextSibling) {
var nodeType = node.nodeType;
if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) {
break;
}
}
return node;
}
预选节点之后,渲染到原生组件(HostComponent)时,会将预选的节点挂到fiber
节点的stateNode
上:
// 遇到原生节点
function updateHostComponent(current, workInProgress, renderLanes) {
if (current === null) {
// 尝试复用预选的现有节点
tryToClaimNextHydratableInstance(workInProgress);
}
}
function tryToClaimNextHydratableInstance(fiber) {
// 取出预选的节点
var nextInstance = nextHydratableInstance;
// 尝试复用
tryHydrate(fiber, nextInstance);
}
以元素节点为例(文本节点与之类似):
function tryHydrate(fiber, nextInstance) {
var type = fiber.type;
// 判断预选节点是否匹配
var instance = canHydrateInstance(nextInstance, type);
// 如果预选的节点可复用,就挂到stateNode上,暂时作为渲染结果记下来
if (instance !== null) {
fiber.stateNode = instance;
return true;
}
}
注意,这里并不检查属性是否完全匹配,只要元素节点的标签名相同(如div
、h1
),就认为可复用:
function canHydrateInstance(instance, type, props) {
if (instance.nodeType !== ELEMENT_NODE || type.toLowerCase() !== instance.nodeName.toLowerCase()) {
return null;
}
return instance;
}
在第一阶段的收尾部分(completeWork
)进行属性的一致性检查,而属性值纠错实际发生在第二阶段:
function completeWork(current, workInProgress, renderLanes) {
var _wasHydrated = popHydrationState(workInProgress);
// 如果存在匹配成功的现有节点
if (_wasHydrated) {
// 检查是否需要更新属性
if (prepareToHydrateHostInstance(workInProgress, rootContainerInstance, currentHostContext)) {
// 纠错动作放到第二阶段进行
markUpdate(workInProgress);
}
}
// 否则document.createElement创建节点
else {
var instance = createInstance(type, newProps, rootContainerInstance, currentHostContext, workInProgress);
appendAllChildren(instance, workInProgress, false, false);
workInProgress.stateNode = instance;
if (finalizeInitialChildren(instance, type, newProps, rootContainerInstance)) {
markUpdate(workInProgress);
}
}
}
一致性检查就是看 DOM 节点上的attributes
与组件props
是否一致,主要做 3 件事情:
- 文本子节点值不同报警告并纠错(用客户端状态修正服务端渲染结果)
- 其它
style
、class
值等不同只警告,并不纠错 - DOM 节点上有多余的属性,也报警告
也就是说,只在文本子节点内容有差异时才会自动纠错,对于属性数量、值的差异只是抛出警告,并不纠正,因此,在开发阶段一定要重视渲染结果不匹配的警告
P.S.具体见diffHydratedProperties,代码量较多,这里不再展开
组件渲染流程
与render
一样,hydrate
也会执行完整的生命周期(包括在服务端执行过的前置生命周期):
// 创建组件实例
var instance = new ctor(props, context);
// 执行前置生命周期函数
// ...getDerivedStateFromProps
// ...componentWillMount
// ...UNSAFE_componentWillMount
// render
nextChildren = instance.render();
// componentDidMount
instance.componentDidMount();
所以,单从客户端渲染性能上来看,hydrate
与render
的实际工作量相当,只是省去了创建 DOM 节点、设置初始属性值等工作
至此,React SSR 的下层实现全都浮出水面了
参考资料
有所得、有所惑,真好
关注「前端向后」微信公众号,你将收获一系列「用心原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术
本文首发于 ayqy.net ,原文链接:http://www.ayqy.net/blog/reac...