写在后面
上篇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 的计划相比,这种拦挡状态更新的形式更快:
In React 16, though, the core team rewrote the server renderer from scratch, and it doesn’t do any vDOM work at all. This means it can be much, much faster.
(摘自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
)后,顺次执行组件生命周期函数:
// getDerivedStateFromPropsvar partialState = Component.getDerivedStateFromProps.call(null, element.props, inst.state);inst.state = _assign({}, inst.state, partialState);// componentWillMountif (typeof Component.getDerivedStateFromProps !== 'function') { inst.componentWillMount();}// UNSAFE_componentWillMountif (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
进行预处理:
// inputprops = _assign({ type: undefined}, props, { defaultChecked: undefined, defaultValue: undefined, value: props.value != null ? props.value : props.defaultValue, checked: props.checked != null ? props.checked : props.defaultChecked});// textareaprops = _assign({}, props, { value: undefined, children: '' + initialValue});// selectprops = _assign({}, props, { value: undefined});// optionprops = _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
)一块一块地输入,这正是流的根本个性:
stream 是数据汇合,与数组、字符串差不多。但 stream 不一次性拜访全副数据,而是一部分一部分发送/接管(chunk 式的)
生产者的生产模式曾经完全符合流的个性了,因而,只须要将其包装成 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// rendernextChildren = instance.render();// componentDidMountinstance.componentDidMount();
所以,单从客户端渲染性能上来看,hydrate
与render
的理论工作量相当,只是省去了创立 DOM 节点、设置初始属性值等工作
至此,React SSR 的上层实现全都浮出水面了
参考资料
- react-dom@17.0.1
有所得、有所惑,真好
关注「前端向后」微信公众号,你将播种一系列「用心原创」的高质量技术文章,主题包含但不限于前端、Node.js以及服务端技术
本文首发于 ayqy.net ,原文链接:http://www.ayqy.net/blog/reac...