惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

J
Java Code Geeks
Stack Overflow Blog
Stack Overflow Blog
P
Proofpoint News Feed
L
LangChain Blog
C
Check Point Blog
F
Fortinet All Blogs
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Blog — PlanetScale
Blog — PlanetScale
腾讯CDC
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Jina AI
Jina AI
Recent Announcements
Recent Announcements
雷峰网
雷峰网
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
N
Netflix TechBlog - Medium
博客园 - 【当耐特】
Hugging Face - Blog
Hugging Face - Blog
Microsoft Azure Blog
Microsoft Azure Blog
U
Unit 42
The Cloudflare Blog
月光博客
月光博客
有赞技术团队
有赞技术团队
G
Google Developers Blog
Vercel News
Vercel News

博客园 - 梦烬

收到一个网页链接,先看清再决定要不要打开:MeowToolkit 的链接提取 不改 iPhone 系统语言,也能单独切换扫描工具:MeowScanner 的中英界面入口 待办写下后还是会忘:妙啊清单把截止事项带进时间线 想养成习惯却总忘记开始:Steady 把它变成今天的一段计时 重要日期散在备忘录里:妙啊时刻把“还有多久”放回时间线 Uber 与 Anthropic 关于 AI Skills 的实践与洞察 Cursor公布的这张图,价值千金 我用AI做了第一个付费App,现已上架AppStore 我用AI做了第一个付费App,现已上架AppStore 图解React SSR中的hydrate 精通 React SSR 之 API 篇 SSR 与当年的 JSP、PHP 有什么区别? 2020 SSR落地开花的三大机遇 SSR 它到底香不香?细数 SSR 的利与弊 假如 Web 当初不支持动态化 如何提升前端基建的效能价值? 如何衡量前端基建的效能价值? 从面向对象角度看前端工程体系 「前端工程化」该怎么理解? 跨端方案的三大困境 React 17 要来了,非常特别的一版 low-code 与 20 年前的 Dreamweaver 有什么区别? 什么才是定制化IDE的核心价值? 移动端跨平台技术之下的变与不变
React SSR源码剖析
梦烬 · 2020-11-27 · via 博客园 - 梦烬

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

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

输入一个 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);
}

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

组件在服务端被灌入数据,并“渲染”成 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()拥有完全相同的函数签名,都能在指定容器节点上渲染组件:

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