React16性能改善的原理(一)
问题背景
React16 更新了底层架构,新架构主要解决更新节点过多时,页码卡顿的问题。譬如如下代码,根据用户输入的文字生成10000行数据,用户输入框会出现卡顿现象。
class App extends React.Component {
constructor( props ) {
super( props );
this.state = {
rowData: []
}
}
handleUserInput = (e)=>{
let userInput = e.target.value;
let newRowData = [];
for( let i = 0; i < 10000; i++) {
newRowData.push( userInput );
}
this.setState( {
rowData: newRowData
} )
}
renderRows() {
return this.rowData.map( (s,index)=>{
return (
<tr key={index}>
<td>{s}</td>
</tr>
)
} )
}
render() {
return (
<div>
<div>
<input type="text" onChange={ this.handleUserInput }/>
</div>
<table>
<tbody>
{ this.renderRows() }
</tbody>
</table>
</div>
);
}
}
卡顿的原因
FPS
为了引出浏览器卡顿真正的原因,我们先简单介绍一个概念:FPS(Frames Per Second) - 每秒传输帧数。举个例子,一般来说动画片是如何动起来的呢?是以极快的速度连续播放静态的图片,利用视网膜图像残留效应,让人产生动起来的错觉。那么这个播放要多块呢?每秒最少要展示24张图片,观众才勉强不会感受到画面延时(即 FPS 达到24,不会让人觉得卡顿)。
页面绘制过程
浏览器其实也是类似的原理,每间隔一定的时间重新绘制一下当前页面。一般来说这个频率是每秒60次。也就是说每16毫秒( 1 / 60 ≈ 0.0167 )浏览器会有一个周期性地重绘行为,这每16毫秒我们称为一帧。这一帧的时间里面浏览器做些什么事情呢:
- 执行JS。
- 计算Style。
- 构建布局模型(Layout)。
- 绘制图层样式(Paint)。
- 组合计算渲染呈现结果(Composite)。
这个过程是顺序的,如果 JS 执行的时间过长,那么后续的步骤也就会被相应的延后,导致的后果就是一帧的时间变长,FPS 变低。人直观的感受就是页面变卡顿。回到上面的例子,一下子更新10000条数据导致 React 执行了相当长的时间,让浏览器这段时间内无法做其他事情,下一帧被延迟了。
有人会想到说,诶,一次执行时间太长会卡我能理解,但是为啥我以前用定时器做 JS 动画有时也会卡呢?下面我们就分析下原因。
setTimeout/setInterval
我们把 setTimeout 和浏览器帧流两条时间线放在一起看一下( 绿色是 paint,紫色是 render,黄色是执行 JS ):
-
第一种完美的情况,就是 setTimeout 执行的频率和浏览器的帧率相同。
image.png
-
太频繁,导致每一帧的元素变化过大(不是每次改变元素的效果都被显示出来),表现为动画不顺滑。譬如,你期望元素每次移动10像素,但是按之前的原理,用户看到的是元素每次移动了40像素。
image.png
-
setTimeout 的频率低于浏览器默认帧率,导致跳帧,表现也是不顺滑。这个就不用说了,元素可能几帧才动一次。
image.png
-
setTimeout 某次或者每次执行的函数时间过长,导致浏览器的 FPS 降低,表现为动画卡顿。这种别说动画卡,页面也卡了。
image.png
想象一下,当你不知道浏览器页面绘制原理的时候是不是全凭感觉来设置 setTimeout 的间隔?当然你也可以把 setTimeout 的间隔设置成16毫秒。不过如果对 event loop 机制了解的话,你会知道这个只能大致保证按这个时间间隔执行,并不会严格保证。setInterval 也是类似,但是比 setTimeout 更不可控。
解决方案
回过头来我们仔细分解下每一帧浏览器要做些什么(见下图),先是响应各种事件,然后执行 event loop 中的任务,然后是一段 raf 时间,最后是计算排版(layout)和重新绘制(paint)。大致你可以认为是先执行程序,然后再根据 JS 执行的结果重绘页面,当然如果 dom 元素没有任何变化,那么重绘这个步骤就省了。
image.png
如果我们能保证 JS 动画的每次执行都在重绘前,那么我们就能做到动画的顺滑,setTimeout 无法保证,但是浏览器提供了新的 API 来帮助我们了。
浏览器新API
requestAnimationFrame
这个函数的作用就是告诉浏览器你希望执行一段 JS,并且要求浏览器在下次重绘之前调用这段 JS 所在的回调函数。
requestAnimationFrame( function(){
document.body.style.width = '100px';
} )
上述代码执行后,在浏览器绘制页面的下一帧重绘前,会执行回调函数,那么就能保证修改的 dom 的效果能在下一帧被显示出来。回看上面的帧的生命周期,raf 时间就是留给 requestAnimationFrame 所注册的回调函数执行用的。这样我们把以前的 setTimeout 动画就可以用 requestAnimationFrame 来改造。
// 旧版:让元素右移500像素
function moveToRight( div ) {
let left = parseInt( div.style.left );
if ( left < 500 ) {
div.style.left = (left+10+'px');
setTimeout( function(){
moveToRight( div );
}, 16 )
} else {
return;
}
}
moveToRight( div );
// 新版:让元素右移500像素
function moveToRight( div ) {
let left = parseInt( div.style.left );
if ( left < 500 ) {
div.style.left = (left+10+'px');
requestAnimationFrame( function(){
moveToRight( div );
} )
} else {
return;
}
}
requestAnimationFrame( function(){
moveToRight( div );
} )
特别注意:不是用了 requestAnimationFrame 后动画就流畅了。如果你传入 requestAnimationFrame 的回调函数执行的 JS 耗时过长,一样会导致后续步骤的延时,引起浏览器 FPS 的下降。所以这点在写代码的时候要注意。
现在有一个问题,传入 requestAnimationFrame 的回调函数一定是会被被安排在下一次重绘前所调用的,但是如果 raf 时间之前就已经执行了长时间的 JS,那么我再执行这个回调岂不是雪上加霜?我能不能要求这种情况说,我的代码也不是很紧急,判断下如果当前帧不“忙”,我就执行,如果帧“忙”,我可以等下一帧之类的呢?好!下一个 API 来了。
requestIdleCallback
这个函数告诉浏览器,在空闲时期依次执行注册的回调函数。什么意思呢?上面我们说过浏览器在一帧的时间里面要做这个事,那个事,但是并不是每时每刻这些事情都耗时的。譬如你打开页面后什么都不做,那么一帧16毫秒之内又没有啥 JS 需要执行又没有大量的重绘工作,产生了有很多空余时间。看下图,黄色部分就是一帧内的空余时间,当浏览器发现一帧有空余时间就会看下有没有调用 requestIdleCallback 注册的回调函数,有的话就执行下。如果执行某个回调前看到帧结束了,那么就等下一次有空闲时间接着执行剩余的回调函数。
image.png有了 requestAnimationFrame 和 requestIdleCallback 我们就能比以前更细粒度的控制 JS 执行的时间了。接下来我们看下基于这个原理 React 如何优化它的更新 dom 的机制。
React调度算法
React 代码中如果某处 setState 被调用引起了一系列更新,React 大致要做的是生成新的虚拟 dom 树,然后和老的虚拟 dom 树做比较,生成更新列表,最后根据这个列表更新真实的 dom。当然更新 dom 耗时在 JS 层面现阶段是没法优化了,而生成虚拟 dom,做新老虚拟 dom 比较过程的耗时,是可能随着应用的复杂程度而增加的。React16 之前绝大多数情况是一次完成虚拟 dom 到真实 dom 更新的整个过程的。那么这个过程如果在一帧里面耗时过长,页面就卡顿了。React16 的思路就是想利用 requestAnimationFrame 和 requestIdleCallback 两个新 API,把一次耗时较长的更新任务分解到多个帧去执行。这样给浏览器留出时间去响应页面上的其他事件,解决卡顿的问题。接下来看下伪代码:
调度算法伪代码
原来这段写的匆忙且不好,重新更新了一篇讲调度算法的大概实现React16性能改善的原理(二)。
原更新步骤大致为
// 原更新步骤大致为:
setState( partialState ) {
var inst = this._instance;
var nextState = Object.assign( {}, inst.state, partialState );
// 根据新的 state 生成新的虚拟 dom
inst.state = nextState;
var nextRenderedElement = inst.render();
// 获取上一次的虚拟 dom
var prevComponentInstance = this._renderedComponent; // render 中的根节点的渲染对象
var prevRenderedElement = prevComponentInstance._currentElement;
if( shouldUpdateReactComponent(prevRenderedElement, nextRenderedElement) ) {
// 更新 dom node
prevComponentInstance.receiveComponent( nextRenderedElement )
}
}
根据新的优化思路,React16新的更新过长大致为:
setState( partialState ) {
updateQueue.push( {
instance: this,
partialState: partialState
} );
requestIdleCallback( doDiff )
}
function doDiff( deadline ) {
let nextUpdate = updateQueue.shift();
let pendingCommit = [];
// 如果更新队列里面有更新,且时间富裕,则逐步计算出需要更新的内容
while( nextUpdate && deadline.timeRemaining()>ENOUGH_TIME ) {
// 生成 fiber 节点,对比新老节点,生成更新dom的任务
pendingCommit.push( calculateDomModification(nextUpdate) ); // 把更新 dom 的任务加入待更新队列
nextUpdate = updateQueue.shift();
}
// 一次把当前时间片所有的 diff 出的更新任务都更新到 dom 上
if ( pendingCommit.lengt>0 ) {
commitAllWork( pendingCommit );
}
// 如果更新队列还有更新,但是时间片耗尽了,那么在下次空闲时间再更新
if ( nextUnitOfWork || updateQueue.length > 0 ) {
requestIdleCallback( doDiff );
}
}
实际代码当然要比这个复杂的多,React 对上述调度的实现基于现实的考虑进行了优化:考虑到 1.有的更新是比较紧急的不能等空闲去完成要用 requestAnimationFrame、2.有的是可以放到空闲时间去执行的、3.对于两个新 API 的浏览器支持不是很好、4.浏览器默认刷新频率的的时间片太短。React 团队实现了一个自己的调度函数 requestAnimationFrameWithTimeout。
其他关注点
后续还打算更新其他细节的内容,等研究好了再更新,譬如:
1. 更新任务不是同步完成的,如果同一个节点在还没有把更新真正反应到 dom 上的时候,有来了一次 setState 怎么办?
2. React fiber 为什么是链式结构?