寫在前面
上篇 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)後,依次執行組件生命週期函數:
// 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 元素
特殊的,先對 [受控組件](/articles/從 componentwillreceiveprops 說起/#articleHeader5) 的 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)一塊一塊地輸出,這正是 流 的基本特性:
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
// render
nextChildren = instance.render();
// componentDidMount
instance.componentDidMount();
所以,單從客戶端渲染性能上來看,hydrate 與 render 的實際工作量相當,只是省去了創建 DOM 節點、設置初始屬性值等工作
至此,React SSR 的下層實現全都浮出水面了
暫無評論,快來發表你的看法吧