乐趣区

从setState-forceUpdate-unstablebatchedUpdates看React的批量更新

setState同步异步问题,React批量更新一直是一个比较模糊的问题,本文希望从框架设计的角度说明一下这个问题。

React有个 UI = f(data) 公式:UI 是由data 推导出来的,所以在写应用的时候,我们只需要关心数据的改变,只需 data ---> data',那么UI ---> UI',在这个过程中,我们其实并不关心 UI 是怎么变化到 UI‘的(即DOM 的变化),这部分工作是 React 替我们处理了。

那么 React 是如何知道当数据变化的时候,需要修改哪些 DOM 的呢?最简单暴力的是,每次都重新构建整个 DOM 树。实际上,React 使用的是一种叫 virtual-dom 的技术:用 JS 对象来表示 DOM 结构,通过比较前后 JS 对象的差异,来获得 DOM 树的增量修改。virtual-dom通过暴力的 js 计算,大大减少了 DOM 操作,让 UI = f(data) 这种模型性能不是那么的慢,当然你用原生 JS/jquery 直接操作 DOM 永远是最快的。

setState 批量更新

除了 virtual-dom 的优化,减少数据更新的频率是另外一种手段,也就是 React 的批量更新。比如:

g() {
   this.setState({age: 18})
    this.setState({color: 'black‘})
}

f() {
    this.setState({name: 'yank'})
    this.g()}

会被 React 合成为一次 setState 调用

f() {
    this.setState({
        name: 'yank',
        age: 18, 
        color: 'black'
    })
}

我们通过伪码大概看一下 setState 是如何合并的。

setState实现

setState(newState) {if (this.canMerge) {this.updateQueue.push(newState)
        return 
    }
    
    // 下面是真正的更新: dom-diff, lifeCycle...
    ...
}

然后 f 方法调用


g() {
   this.setState({age: 18})
    this.setState({color: 'black‘})
}

f() {
    this.canMerge = true
    
    this.setState({name: 'yank'})
    this.g()
    
    this.canMerge = false
    // 通过 this.updateQueue 合并出 finalState
    const finalState = ...  
    // 此时 canMerge 已经为 false 故而走入时机更新逻辑
    this.setState(finaleState) 
}

可以看出 setState首先会判断是否可以合并,如果可以合并,就直接返回了。

不过有同学会问:在使用 React 的时候,我并没有设置 this.canMerge 呀?我们的确没有,是 React 隐式的帮我们设置了!事件处理函数,声明周期,这些函数的执行是发生在 React 内部的,React对它们有完全的控制权。

class A extends React.Component {componentDidMount() {console.log('...')
    }

    render() {return (<div onClick={() => {console.log('hi')
        }}></div>
    }
}

在执行 componentDidMount 前后,React 会执行 canMerge 逻辑,事件处理函数也是一样,React 委托代理了所有的事件,在执行你的处理函数函数之前,会执行 React 逻辑,这样 React 也是有时机执行 canMerge 逻辑的。

批量更新是极好滴!我们当然希望任何 setState 都可以被批量,关键点在于 React 是否有时机执行 canMerge 逻辑,也就是 React 对目标函数有没有控制权。如果没有控制权,一旦 setState 提前返回了,就再也没有机会应用这次更新了。


class A extends React.Component {handleClick = () => {this.setState({x: 1})
        this.setState({x: 2})
        this.setState({x: 3})
        
        setTimeout(() => {this.setState({x: 4})
            this.setState({x: 5})
            this.setState({x: 6})
        }, 0)
    }    
    
    render() {return (<div onClick={this.handleClick}></div>
    }
}

handleClick 是事件回调,React有时机执行 canMerge 逻辑,所以 x 为 1,2,3 是合并的,handleClick结束之后 canMerge 被重新设置为 false。注意这里有一个 setTimeout(fn, 0)。这个 fn 会在handleClick 之后调用,而 React 对 setTimeout 并没有控制权,React 无法在 setTimeout 前后执行 canMerge 逻辑,所以 x 为 4,5,6 是无法合并的,所以 fn 这里会存在 3 次 dom-diff。React 没有控制权的情况有很多:Promise.then(fn), fetch 回调,xhr网络回调等等。

unstable_batchedUpdates 手动合并

那 x 为 4,5,6 有办法合并吗?是可以的,需要用 unstable_batchedUpdates 这个 API,如下:

class A extends React.Component {handleClick = () => {this.setState({x: 1})
        this.setState({x: 2})
        this.setState({x: 3})
        
        setTimeout(() => {ReactDOM.unstable_batchedUpdates(() => {this.setState({x: 4})
                this.setState({x: 5})
                this.setState({x: 6})
            })
        }, 0)
    }    
    
    render() {return (<div onClick={this.handleClick}></div>
    }
}

这个 API,不用解释太多,我们看一下它的伪码就很清楚了

function unstable_batchedUpdates(fn) {
    this.canMerge = true
    
    fn()
    
    this.canMerge = false
    const finalState = ...  // 通过 this.updateQueue 合并出 finalState
    this.setState(finaleState)
}

so, unstable_batchedUpdates 里面的 setState 也是会合并的。

forceUpdate 的说明

forceUpdate 从函数名上理解:“强制更新”。既然是“强制更新”有两个问题容易引起误解:

  1. forceUpdate 是同步的吗?“强制”会保证调用然后直接 dom-diff 吗?
  2. “强制”更新整个组件树吗?包括自己,子孙后代组件吗?

这两个问题官方文档都没有明确说明。

class A extends React.Component{handleClick = () => {this.forceUpdate()
        this.forceUpdate()
        this.forceUpdate()
        this.forceUpdate()}
    
    shouldComponentUpdate() {return false}
    
    render() {
        return (<div onClick={this.handleClick}>
                <Son/> // 一个组件
            </div>
        )
    }
}

对于第一个问题:forceUpdate 在批量与否的表现上,和 setState 是一样的。在 React 有控制权的函数里,是批量的。

对于第二个问题:forceUpdate 只会强制本身组件的更新,即不调用“shouldComponentUpdate”直接更新,对于子孙后代组件还是要调用自己的“shouldComponentUpdate”来决定的。

所以 forceUpdate 可以简单的理解为 this.setState({}),只不过这个setState 是不调用自己的“shouldComponentUpdate”声明周期的。

Fiber 的想象

显示的让开发者调用 unstable_batchedUpdates 是不优雅的,开发者不应该被框架的实现细节影响。但是正如前文所说,React没有控制权的函数,unstable_batchedUpdates好像是不可避免的。不过 React16.xfiber 架构,可能有所改变。我们看下 fiber 下的更新

setState(newState){this.updateQueue.push(newState)
    requestIdleCallback(performWork)
}

requestIdleCallback 会在浏览器空闲时期调用函数,是一个低优先级的函数。

现在我们再考虑一下:

handleClick = () => {this.setState({x: 1})
        this.setState({x: 2})
        this.setState({x: 3})
        
        setTimeout(() => {this.setState({x: 4})
            this.setState({x: 5})
            this.setState({x: 6})
        }, 0)
    }    

当 x 为 1,2,3,4,5,6 时 都会进入更新队列,而当浏览器空闲的时候 requestIdleCallback 会负责来执行统一的更新。

由于 fiber 的调度比较复杂,这里只是简单的说明,具体能不能合并,跟优先级还有其他都有关系。不过 fiber 的架构的确可以更加优雅的实现批量更新,而且不需要开发者显示的调用unstable_batchedUpdates

广告时间

最后,广告一下我们开源的 RN 转小程序引擎 alita,alita 区别于现有的社区编译时方案,采用的是运行时处理 JSX 的方式,详见这篇文章。

所以 alita 内置了一个 mini-react, 这个mini-react 同样提供了合成 setState/forceUpdate 更新的功能,并对外提供了 unstable_batchedUpdates 接口。如果你读 react 源码无从下手,可以看一下 alita minil-react 的实现,这是一个适配小程序的 react 实现,且小,代码在 https://github.com/areslabs/alita/tree/master/packages/wx-react。

alita 地址:https://github.com/areslabs/alita。欢迎 star & pr & issue

退出移动版