React 服务端渲染缓慢原因浅析从属于笔者的Web 前端入门与工程实践。
前几日笔者在服务端渲染性能大乱斗:Vue, React, Preact, Rax, Marko 一文中比较了当前流行的数个前端框架服务端渲染的性能表现,下图数值越高越好:
笔者看完这个数据对比之后不由好奇,缘何 React 服务端渲染的性能会如此之差;从设计理念的角度来看 React 本身专注于跨平台的界面库,其保证较好抽象层次的同时势必会付出一定的代价,并且 Facebook 在生产环境中并未大规模应用服务端渲染,也就未花费过多的精力来优化服务端渲染的性能。笔者也对比了下 React 与 Preact 有关服务端渲染的实现代码,确实高度的抽象需要额外的代码逻辑与对象创建,React 本身并没有冗余的部分,只是单纯地大量的毫秒级别额外对象操作的耗时的累加导致了最后性能表现的巨大差异。我们首先看下 Preact 的renderToString
的函数实现,其紧耦合于 DOM 环境,以较低的抽象程度换取较少的代码实现:
/** The default export is an alias of `render()`. */
export default function renderToString(vnode, context, opts, inner, isSvgMode) {
// 获取节点属性
let { nodeName, attributes, children } = vnode || EMPTY,
isComponent = false;
context = context || {};
opts = opts || {};
let pretty = opts.pretty,
indentChar = typeof pretty==='string' ? pretty : '\t';
if (vnode==null) {
return '';
}
// 字符串类型则直接返回
if (!nodeName) {
return encodeEntities(vnode);
}
// 处理组件
if (typeof nodeName==='function') {
isComponent = true;
if (opts.shallow && (inner || opts.renderRootComponent===false)) {
nodeName = getComponentName(nodeName);
}
else {
...
if (!nodeName.prototype || typeof nodeName.prototype.render!=='function') {
// 处理无状态函数式组件
...
}
else {
// 处理类组件
...
}
//递归处理下一层元素
return renderToString(rendered, context, opts, opts.shallowHighOrder!==false);
}
}
// 将 JSX 渲染到 HTML
let s = '', html;
if (attributes) {
let attrs = objectKeys(attributes);
//处理所有元素属性
...
}
// 处理多行属性
...
if (html) {
// 处理多行缩进
...
}
else {
// 递归处理子元素
...
}
...
return s;
}
Preact 的实现还是比较简单明了的,我们继续来看下 React 中涉及到服务端渲染相关的代码,其主要涉及到 ReactDOMServer.js, ReactServerRendering.js, instantiateReactComponent.js, ReactCompositeComponent.js 以及 ReactReconciler.js 等几个文件,其中前两个文件算是专注于服务端渲染,而后三个文件则是用于定义 React 组件以及组件系统的组合与调和机制,其并不耦合于某个具体的平台,也是主要的以牺牲性能来换取较好地抽象层次的实现类。首先我们来从应用的角度考虑下两个可能影响服务端渲染性能的因素,一个是对于环境变量的设置。在 React 的源代码中我们可以发现很多如下的调试语句:
if (process.env.NODE_ENV !== 'production') {
...
}
显而易见如果我们没有将环境变量设置为production
,势必会在运行时调用更多的调试代码,拖慢整体性能。另一个有可能拖慢服务端渲染性能的因素是 React 在生成 HTML 后会对元素进行校验和计算并且附加到元素属性中:
...
上述代码中的data-react-checksum
就是计算而来的校验和,该计算过程是会占用部分时间,不过影响甚微。笔者对于renderToStringImpl
函数进行了断点性能分析,主要是利用console.time
记录函数执行时间并且进行对比:
...
return transaction.perform(function () {
var componentInstance = instantiateReactComponent(element, true);
var reactDOMContainerInfo = ReactDOMContainerInfo();
console.time('transaction');
console.log('transaction 开始:' + Date.now());
var markup = ReactReconciler.mountComponent(componentInstance, transaction, null, reactDOMContainerInfo, emptyObject, 0 /* parentDebugID */
);
console.log('transaction 结束:' + Date.now());
console.timeEnd('transaction');
...
if (!makeStaticMarkup) {
console.time('markup');
markup = ReactMarkupChecksum.addChecksumToMarkup(markup);
console.timeEnd('markup');
}
return markup;
...
// 运行结果为:
// transaction: 12.643ms
// markup: 0.249ms
从运行结果上可以看出,计算校验和并未占用过多的时间比重,因此这也不会是拖慢服务端渲染性能的主因。实际上当我们调用ReactDOMServer.renderToString
时,其会调用ReactServerRendering.renderToStringImpl
这个内部实现,该函数的第二个参数makeStaticMarkup
用来标识是否需要计算校验和。换言之,如果我们使用的是ReactDOMServer.renderToStaticMarkup
,其会将makeStaticMarkup
设置为true
并且不计算校验和。完整的一次服务端渲染的对象与函数调用流程如下:
整个流程同样是递归解析组件树到 HTML 标记的过程,笔者同样是以断点计时的方式进行追踪,有趣的一个细节是从 Transaction 开始到首次调用ReactReconciler 中mountComponent
函数之间间隔 2ms,换言之,有大量的时间花费在了具体的解析之外,可能这种类型的抽象带来的额外消耗会是 React 服务端渲染性能较差的原因之一吧。