メインコンテンツへ移動

React SSR 原理編

無料2020-11-17#Front-End#JS#React SSR原理#React hydrate#hydration#React水化#React水合

React コンポーネントはどうやって HTML 文字列になるのか?これらの文字列はどうやって連結しながらストリーミング送信されるのか?hydrate は一体何をしているのか?ソースコードを見ればわかります

はじめに

前回の 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);

3 番目のパラメータ 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 ノード上の attributesprops が一致するかどうか

つまり、対応位置に「再利用可能な」(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;
}

選択基準はノードタイプが要素ノード(nodeType1)またはテキストノード(nodeType3):

// 兄弟ノード中の最初の要素ノードまたはテキストノードを找出す
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;
  }
}

注意、ここでは属性が完全にマッチするかチェックせず、要素ノードのタグ名が同じ(divh1 など)であれば、再利用可能とみなします

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 つのことを行います:

  • テキスト子ノード値が異なる場合警告し訂正(クライアント状態でサーバー側レンダリング結果を修正)
  • その他 styleclass 値などの差異は警告のみ、訂正せず
  • DOM ノードに余分な属性がある場合も警告

つまり、テキスト子ノード内容に差異がある場合のみ自動訂正し、属性数量、値の差異については警告を投げるのみで訂正しません。したがって、開発段階では必ずレンダリング結果不一致の警告を重視してください

P.S. 詳細は diffHydratedProperties を参照。コード量が多いため、ここでは展開しません

コンポーネントレンダリングフロー

render と同様に、hydrate も完全なライフサイクルを実行(サーバー側で実行済みの前置ライフサイクルを含む):

// コンポーネントインスタンスの作成
var instance = new ctor(props, context);
// 前置ライフサイクル関数の実行
// ...getDerivedStateFromProps
// ...componentWillMount
// ...UNSAFE_componentWillMount

// render
nextChildren = instance.render();

// componentDidMount
instance.componentDidMount();

したがって、単にクライアント側レンダリングパフォーマンスから見ると、hydraterender の実際の作業量は同等で、DOM ノードの作成、初期属性値の設定などの作業を省いただけです

至此、React SSR の下層実装はすべて水面に浮上しました

参考資料

コメント

コメントはまだありません

コメントを書く