React SSR源码剖析

974 阅读7分钟

写在前面

上篇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 元素

特殊的,先对受控组件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 节点上的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;
}

在第一阶段的收尾部分(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 件事情:

  • 文本子节点值不同报警告并纠错(用客户端状态修正服务端渲染结果)
  • 其它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 的下层实现全都浮出水面了

参考资料

有所得、有所惑,真好

关注「前端向后」微信公众号,你将收获一系列「用原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

本文首发于 ayqy.net ,原文链接:www.ayqy.net/blog/react-…