서두에
이전 글 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 를 실행.特殊的으로, 이 2 개의 구 라이프사이클 함수가 동시에 존재하는 경우, 위의 순서로 2 개의 함수를 모두 실행
다음으로 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 로 변환되지 않았으므로 종료 태그도 연결할 수 없음). 하지만 시작 태그 부분은 완전히 확정되어 클라이언트에 출력할 수 있습니다
이.이 문자열들은 어떻게 연결하면서 스트리밍 전송되는가?
이와 같이, 1 라운드마다 1 개의 노드만 렌더링하고, 스택에 완료되지 않은 렌더링 태스크가 없을 때까지:
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;
}
// 자식 노드를 1 개 처리할 때마다, childIndex + 1
var child = frame.children[frame.childIndex++];
var outBuffer = '';
try {
// 1 개의 노드를 렌더링
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 모드에서는, 컴포넌트 렌더링 프로세스도 마찬가지로 2 개의 단계 로 나뉩니다:
-
제 1 단계 (render/reconciliation): 재사용 가능한 기존 노드를 찾아,
fiber노드의stateNode에挂载 -
제 2 단계 (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;
}
제 1 단계의收尾 부분 (completeWork) 에서 속성의 일치성 체크를 수행하고, 속성 값의 정정은 실제로 제 2 단계에서 발생:
function completeWork(current, workInProgress, renderLanes) {
var _wasHydrated = popHydrationState(workInProgress);
// 매치 성공한 기존 노드가 존재하는 경우
if (_wasHydrated) {
// 속성 업데이트가 필요한지 체크
if (prepareToHydrateHostInstance(workInProgress, rootContainerInstance, currentHostContext)) {
// 정정 동작을 제 2 단계에 배치
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 의 하층 구현은 모두 수면에 떠올랐습니다
아직 댓글이 없습니다