关于react.js:React-SSR源码剖析

2次阅读

共计 10302 个字符,预计需要花费 26 分钟才能阅读完成。

写在后面

上篇 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 的上层实现全都浮出水面了

参考资料

  • react-dom@17.0.1

有所得、有所惑,真好

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

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

正文完
 0