react-fiber前瞻

2017-04-23 fishedee 前端

1 概述

React Fiber前瞻,下一代的React v16.0.0中有重大改变,增加Fiber特性,可以提升整个react的渲染效率。

整体来说,react fiber相当于实现了ui线程上的执行队列优先级而已。早在win32编程上就有类似的实现,例如派发队列时,会自动合并未执行的paint事件,和提升用户触发事件的优先级。只是react能在js上实现了执行队列的优先级,这个确实牛逼。

2 问题

众所周知,更新react的view时,我们都是使用setState,然后react通过对virtual dom执行diff算法后,更新到real dom上的。

React.createClass({
    componentDidMount(){
        this.setState({title:"123"});
        this.setState({name:"123"});
    },
    render(){
    }
});

在setState上,react默认已经做了一个小优化,如果在一个js线程执行栈上多次调用了setState,react也只会执行一次diff操作而已。例如,在以上的componentDidMount中调用了两次setState,其实react也只会一次diff操作而已。

React.createClass({
    componentDidMount(){
        this.setState({title:"123"});
        setTimeout(()=>{
            this.setState({name:"123"});
        },0);
    },
    render(){
    }
});

但是,如果在不同的js线程执行栈中调用了多次的setState,react就会执行多次的diff操作,造成了无效率的diff操作,因为这么短时间的两个setState,其实应该可以合并为一次的diff操作,这样才是高效的。

另外,setState的操作是一个阻塞ui线程上的同步操作,一旦diff的计算量太大,页面就会不能及时响应用户的输入事件,造成页面卡顿。

最后是,setState作为更新ui的办法,没有区分优先级,例如,在react中动画实现时修改属性用的是setState,填充数据到页面上也是用setState。由于两者没有区分优先级,就有可能变为以下这种

动画diff(10ms)
数据填充diff(500ms)
动画diff(10ms)
动画diff(10ms)
动画diff(10ms)

由于中途一次数据填充diff的时间过长造成动画展示卡顿。更好的办法应该是让数据填充diff在执行时,如果遇到了动画diff,就应该让出自己的时间片,让动画diff先执行,再回过头执行自己的diff,让操作流程变为

动画diff(10ms)
数据填充diff之一(200ms)
动画diff(10ms)
数据填充diff之二(200ms)
动画diff(10ms)
数据填充diff之三(100ms)
动画diff(10ms)

以上就是目前react遇到的三个问题了

  • 不能跨js执行栈合并setState,造成无效多次的diff
  • 不能暂停setState的diff操作,造成无法响应用户输入
  • 不能区分setState的优先级,造成低优先级的更新卡住了高优先级的更新

3 解决

解决以上办法的fiber,取自于操作系统中的迁程的概念,有点像go里面的轻量级线程,但又不太像。

众所周知,react的update ui操作分两部,一部分是diff(reconciler),第二部分是render。第一部分就是在js中进行virtual dom的操作,第二部分是将更新real dom的。

fiber的思想是将,第一部分的diff操作从一个递归算法,更改为一个平坦的分步算法。首先,将一次setState执行的操作,分为一个dom树的多次render的操作,而每次render操作后是将所有权返回给调度器,而不是像原来一样继续递归到子dom上进行render操作。

由于在一个setState中的每次render都返回到调度器,所以react fiber能将setState的操作从一次的同步操作,变为多次的分步操作。在每个分步的空隙,它能判断当下的setState是否应该继续往下操作了,是否应该执行更高优先级的setState,是否应该交还控制权给js了。例如:

  • 用户输入事件来临了
  • 更高优先级的时间片的setState来临了

更进一步地,如果它发现同一优先级中也是有一个setState放在执行队列中,它就直接将这个setState操作也合并过来一起diff就可以了。

最后是,将diff的结果render到real dom上。注意,react fiber仅对第一部分的diff操作有效,对第二部分操作是没有效的。也就是说,react fiber只会暂停恢复中断diff(reconciler)操作,而不会暂停恢复中断renderer操作的。

目前,react fiber仍在开发当中,相关的实现细节仍在不断变动,但从文档中,基本可以猜测到的实现技术大概就是:

  1. 建立钩子hook住每个react component的render操作
  2. 调度器取出执行队列中最高优先级的setState操作,如果这个setState操作是全新的,就直接执行根react component的render操作。否则接着上次的setState操作(这里可以进行合并同级的setState合并操作)。
  3. react component的render操作正常执行,完毕后被钩子将控制权拉回到调度器上
  4. 调度器先判断目前setState的时间片是否占用过长了,如果不是太长,就继续进行子react component的render操作,这时返回到第3步。否则,为了响应用户输入事件,调度器会先暂停当前的setState操作,然后将其重新丢入到执行队列去,最后调用一次setTimeout(()=>{},0),将控制权交给js的事件队别,等js的事件队列都清空了以后,js将控制权再次交给调度器,这时返回到第2步
var App = React.createClass({
    getInitialState(){
        return {
            id:0,
            result:0,
        }
    },
    componentDidMount(){
        setInterval(()=>{
            this.setState({id:this.state.id+1});
        },500)
    },
    singleSum(){
        var result = 0;
        for ( var i = 0 ; i != 100000 ; i++ ){
            result += i;
        }
        return result;
    },
    onClick(){
        var result = 0;
        for( var i = 0 ; i != 100000 ;i++){
            result += this.singleSum()
        }
        this.setState({result:result})
    },
    render(){
        return (
            <div>
                <div>目前的id是:{this.state.id}</div>
                <div>长cpu计算任务的执行结果为:{this.state.result}</div>
                <button onClick={this.onClick}>执行cpu任务</button>
            </div>
        )
    }
})

举个例子,上面这段代码中,id值每隔500ms会自动更新一次,当我们点击button按钮后,会执行一个占用cpu的计算任务,导致整个页面都卡住了,无法响应,定时器的运行有变得突然延迟了。react fiber要解决的就是这个问题,让cpu执行render任务时,也能响应用户输入事件

var App = React.createClass({
    getInitialState(){
        return {
            id:0,
            result:0,
            interval:0,
        }
    },
    componentDidMount(){
        setInterval(()=>{
            this.setState({id:this.state.id+1});
        },500)
    },
    async singleSum(){
        var result = 0;
        for ( var i = 0 ; i != 1000000 ; i++ ){
            result += i;
        }
        return result;
    },
    async onClick(){
        var beginTime = (new Date()).valueOf();
        var result = 0;
        for( var i = 0 ; i != 50 ;i++){
            result += await this.singleSum()
        }
        var endTime = (new Date()).valueOf();
        this.setState({
            result:result,
            interval:endTime-beginTime,
        })
    },
    render(){
        return (
            <div>
                <div>目前的id是:{this.state.id}</div>
                <div>长cpu计算任务的执行结果为:{this.state.result},执行时间为:{this.state.interval}ms</div>
                <button onClick={this.onClick}>执行cpu任务</button>
            </div>
        )
    }
})

这时候,我们即使加上async await的关键词也没有用,因为promise不能将自动将长cpu任务让出来,只能将长io任务让出来。

function scheduleOut(){
    return new Promise(function(resolve,reject){
        setTimeout(resolve,0);
    });
}

var App = React.createClass({
    getInitialState(){
        return {
            id:0,
            result:0,
            interval:0,
        }
    },
    componentDidMount(){
        setInterval(()=>{
            this.setState({id:this.state.id+1});
        },500)
    },
    async singleSum(){
        await scheduleOut()
        var result = 0;
        for ( var i = 0 ; i != 1000000 ; i++ ){
            result += i;
        }
        return result;
    },
    async onClick(){
        var beginTime = (new Date()).valueOf();
        var result = 0;
        for( var i = 0 ; i != 50 ;i++){
            result += await this.singleSum()
        }
        var endTime = (new Date()).valueOf();
        this.setState({
            result:result,
            interval:endTime-beginTime,
        })
    },
    render(){
        return (
            <div>
                <div>目前的id是:{this.state.id}</div>
                <div>长cpu计算任务的执行结果为:{this.state.result},执行时间为:{this.state.interval}ms</div>
                <button onClick={this.onClick}>执行cpu任务</button>
            </div>
        )
    }
})

但是,如果我们在singleSum加入简单的一句await scheduleOut(),整个问题就能解决了,页面就不会卡住了,定时器执行也不延误了,同时cpu任务也能正常运行。这就是react fiber的本质原理,在render函数的后面执行scheduleOut,将控制权交给调度器,调度器根据占用时间片的大小来确定是否将控制权交还给js运行时。而在下次调度回来时又能从原来的cpu任务地方重新执行。

Screen Shot 2017-04-25 at 7.32.35 P
Screen Shot 2017-04-25 at 7.32.05 P

要注意的是,我这里实现比较粗略,暂停堆栈的方法直接用promise,而每个singleSum在执行前都直接scheduleOut(实际的调度器是会判断时间片的占用情况来确定是否要scheduleOut)。同时从执行结果中也能看出,由于过度的scheduleOut,整个cpu任务的执行时间几乎翻倍。所以,react fiber在提高响应速度的同时,也在降低整体计算的吞吐量。

4 改变

基本上来说,这项性能优化对程序员是免费的午餐,更新一下react库,就能享受react的性能提升了。可是,要注意的是,react的生命周期会发生变化。

  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate
  • render
  • componentDidUpdate

例如,react中更新一个子dom的props后,上面的生命周期回调会不多不少地刚好顺序执行一次。

但是,在更新到react fiber以后,由于render函数执行以后可能会中断,然后被合并新的props,这导致生命周期为

  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate
  • render(第一次render,这里执行完毕后,发现可以合并执行队列中其他的setState)
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate
  • render(第二次render)
  • componentDidUpdate

你看,执行了两次render以后,才执行一次的componentDidUpdate

因此,更新react fiber以后,除了要留意生命周期回调时机变化了以外,基本和原来一样写代码。

5 局限

react fiber调度器本质上依赖的是hook在函数上的中断,而不是像cpu一样的硬件中断,所以如果你在一个render函数上死循环了,就不要指望react fiber会让出时间片来处理用户输入事件了。这是没有救的,整个页面都会卡死了的。

注意,在操作系统上,一个进程死循环了,另外一个进程还能好好地运行,不受卡死影响,这是为什么呢?

6 总结

react fiber可谓是一项充满了脑洞的优化方法,简直叹为观止。其实,对于简单的一次setState操作而言,其会拖慢单次的效率,但是由于优先级调度和合并调度的存在,会大幅提高对于频繁setState操作的吞吐量。

而这一切,都是建立在react的virtual dom的想法上,这在jQuery直接操作dom的时代,都是不可想象的。

就目前而言,建立在virtual dom上的react拥有了react fiber和react list以后,渲染性能上已经抛离了vue和angular不是一点半点的了。

相关文章