写在前面

上篇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 的方案相比,这种拦截状态更新的方式更快

(摘自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)一块一块地输出,这正是的基本特性:

生产者的生产模式已经完全符合流的特性了,因此,只需要将其包装成 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 ,原文链接:http://www.ayqy.net/blog/reac...

03-05 15:26