React16 更新了底层架构,新架构主要解决更新节点过多时,页码卡顿的问题。譬如以下代码,根据用户输入的文字生成10000行数据,用户输入框会出现卡顿现象。javascript
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(Frames Per Second) - 每秒传输帧数。举个例子,通常来讲动画片是如何动起来的呢?是以极快的速度连续播放静态的图片,利用视网膜图像残留效应,让人产生动起来的错觉。那么这个播放要多块呢?每秒最少要展现24张图片,观众才勉强不会感觉到画面延时(即 FPS 达到24,不会让人以为卡顿)。java
浏览器其实也是相似的原理,每间隔必定的时间从新绘制一下当前页面。通常来讲这个频率是每秒60次。也就是说每16毫秒( 1 / 60 ≈ 0.0167 )浏览器会有一个周期性地重绘行为,这每16毫秒咱们称为一帧。这一帧的时间里面浏览器作些什么事情呢:node
inter-frame idle period.jpg算法
这个过程是顺序的,若是 JS 执行的时间过长,那么后续的步骤也就会被相应的延后,致使的后果就是一帧的时间变长,FPS 变低。人直观的感觉就是页面变卡顿。回到上面的例子,一会儿更新10000条数据致使 React 执行了至关长的时间,让浏览器这段时间内没法作其余事情,下一帧被延迟了。segmentfault
有人会想到说,诶,一次执行时间太长会卡我能理解,可是为啥我之前用定时器作 JS 动画有时也会卡呢?下面咱们就分析下缘由。浏览器
咱们把 setTimeout 和浏览器帧流两条时间线放在一块儿看一下( 绿色是 paint,紫色是 render,黄色是执行 JS ):架构
想象一下,当你不知道浏览器页面绘制原理的时候是否是全凭感受来设置 setTimeout 的间隔?固然你也能够把 setTimeout 的间隔设置成16毫秒。不过若是对 event loop 机制了解的话,你会知道这个只能大体保证按这个时间间隔执行,并不会严格保证。setInterval 也是相似,可是比 setTimeout 更不可控。dom
回过头来咱们仔细分解下每一帧浏览器要作些什么(见下图),先是响应各类事件,而后执行 event loop 中的任务,而后是一段 raf 时间,最后是计算排版(layout)和从新绘制(paint)。大体你能够认为是先执行程序,而后再根据 JS 执行的结果重绘页面,固然若是 dom 元素没有任何变化,那么重绘这个步骤就省了。
life of a frame.png函数
若是咱们能保证 JS 动画的每次执行都在重绘前,那么咱们就能作到动画的顺滑,setTimeout 没法保证,可是浏览器提供了新的 API 来帮助咱们了。oop
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 注册的回调函数,有的话就执行下。若是执行某个回调前看到帧结束了,那么就等下一次有空闲时间接着执行剩余的回调函数。
inter-frame idle period.jpg
有了 requestAnimationFrame 和 requestIdleCallback 咱们就能比之前更细粒度的控制 JS 执行的时间了。接下来咱们看下基于这个原理 React 如何优化它的更新 dom 的机制。
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 为何是链式结构?