前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >React SSR 源码剖析

React SSR 源码剖析

作者头像
ayqy贾杰
发布2020-11-19 17:39:28
2.7K0
发布2020-11-19 17:39:28
举报
文章被收录于专栏:黯羽轻扬

写在前面

上篇React SSR 之 API 篇细致介绍了 React SSR 相关 API 的作用,本篇将深入源码,围绕以下 3 个问题,弄清楚其实现原理:

  • React 组件是怎么变成 HTML 字符串的?
  • 这些字符串是如何边拼接边流式发送的?
  • hydrate 究竟做了什么?

一.React 组件是怎么变成 HTML 字符串的?

输入一个 React 组件:

代码语言:javascript
复制
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 字符串:

代码语言:javascript
复制
'<div data-reactroot=""><h1 class="site-title">Welcome to React SSR!<!-- --> Hello There!</h1></div>'

这中间发生了什么?

首先,创建组件实例,再执行render及之前的生命周期,最后将 DOM 元素映射成 HTML 字符串

创建组件实例

代码语言:javascript
复制
inst = new Component(element.props, publicContext, updater);

通过第三个参数updater注入了外部updater,用来拦截setState等操作:

代码语言:javascript
复制
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 基类的构造器中:

代码语言:javascript
复制
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)后,依次执行组件生命周期函数:

代码语言:javascript
复制
// 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可能会引发状态更新:

代码语言:javascript
复制
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

代码语言:javascript
复制
child = inst.render();

并递归向下对子组件进行同样的处理(processChild):

代码语言:javascript
复制
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 元素“渲染”成字符串并输出:

代码语言:javascript
复制
if (typeof elementType === 'string') {
  return this.renderDOM(nextElement, context, parentNamespace);
}

“渲染”DOM 元素

特殊的,先对受控组件props进行预处理:

代码语言:javascript
复制
// 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
});

接着正式开始拼接字符串,先创建开标签:

代码语言:javascript
复制
// 创建开标签
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;
}

再创建闭标签:

代码语言:javascript
复制
// 创建闭标签
var footer = '';
if (omittedCloseTags.hasOwnProperty(tag)) {
  out += '/>';
} else {
  out += '>';
  footer = '</' + element.type + '>';
}

并处理子节点:

代码语言:javascript
复制
// 文本子节点,直接拼到开标签上
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,所以闭标签也没办法拼上去),但开标签部分已经完全确定,可以输出给客户端了

二.这些字符串是如何边拼接边流式发送的?

如此这般,每趟只渲染一个节点,直到栈中没有待完成的渲染任务为止

代码语言:javascript
复制
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 即可:

代码语言:javascript
复制
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);
  }
};

异常简单:

代码语言:javascript
复制
function renderToNodeStream(element, options) {
  return new ReactMarkupReadableStream(element, false, options);
}

P.S.至于非流式 API,则是一次性读完(read(Infinity)):

代码语言:javascript
复制
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等属性(其实是故意忽略了这些属性):

代码语言:javascript
复制
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()拥有完全相同的函数签名,都能在指定容器节点上渲染组件:

代码语言:javascript
复制
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 阶段尝试复用该节点

选择现有节点具体如下:

代码语言:javascript
复制
// 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):

代码语言:javascript
复制
// 找出兄弟节点中第一个元素节点或文本节点
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上:

代码语言:javascript
复制
// 遇到原生节点
function updateHostComponent(current, workInProgress, renderLanes) {
  if (current === null) {
    // 尝试复用预选的现有节点
    tryToClaimNextHydratableInstance(workInProgress);
  }
}

function tryToClaimNextHydratableInstance(fiber) {
  // 取出预选的节点
  var nextInstance = nextHydratableInstance;
  // 尝试复用
  tryHydrate(fiber, nextInstance);
}

以元素节点为例(文本节点与之类似):

代码语言:javascript
复制
function tryHydrate(fiber, nextInstance) {
  var type = fiber.type;
  // 判断预选节点是否匹配
  var instance = canHydrateInstance(nextInstance, type);

  // 如果预选的节点可复用,就挂到stateNode上,暂时作为渲染结果记下来
  if (instance !== null) {
    fiber.stateNode = instance;
    return true;
  }
}

注意,这里并不检查属性是否完全匹配,只要元素节点的标签名相同(如divh1),就认为可复用

代码语言:javascript
复制
function canHydrateInstance(instance, type, props) {
  if (instance.nodeType !== ELEMENT_NODE || type.toLowerCase() !== instance.nodeName.toLowerCase()) {
    return null;
  }
  return instance;
}

在第一阶段的收尾部分(completeWork)进行属性的一致性检查,而属性值纠错实际发生在第二阶段:

代码语言:javascript
复制
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也会执行完整的生命周期(包括在服务端执行过的前置生命周期):

代码语言:javascript
复制
// 创建组件实例
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

支持原创

点赞? + 在看?,将有价值的知识传递更远~(对文中内容感兴趣,可直接微信联系作者 ayqywx )

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-11-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端向后 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 写在前面
  • 一.React 组件是怎么变成 HTML 字符串的?
    • 创建组件实例
      • 渲染组件
        • “渲染”DOM 元素
        • 二.这些字符串是如何边拼接边流式发送的?
        • 三.hydrate 究竟做了什么?
          • hydrate 与 render 的区别
            • 节点复用策略
              • 组件渲染流程
                • 参考资料
                相关产品与服务
                容器服务
                腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档