从 setState promise 化的探讨 体会 React 团队设计思想
从 setState 那个众所周知的小秘密说起...在 React 组件中,调用 this.setState() 是最基本的场景。这个方法描述了 state 的变化、触发了组件 re-rendering。但是,也许看似平常的 this.setState() 里面却也许蕴含了很多鲜为人知的设计和讨论。 相信很多开发者已经意识到,setState 方法“或许”是异步的。也许你觉得,看上去更新 state 是如此轻而易举的操作,这并没有什么可异步处理的。但是要意识到,因为 state 的更新会触发 re-rendering,而 re-rendering 代价昂贵,短时间内反复进行渲染在性能上肯定是不可取的。所以,React 采用 batching 思想,它会 batches 一系列连续的 state 更新,而只触发一次 re-render。 关于这些内容,如果你还不清楚,推荐参考@程墨的系列文章:setState:这个API设计到底怎么样;英语好的话,可以直接关注长发飘飘的 Eric Elliott 著名的引起系列口水战的吐槽文:setState() Gate。 或者,直接看下面的一个小例子。 function incrementMultiple() { this.setState({count: this.state.count + 1}); this.setState({count: this.state.count + 1}); this.setState({count: this.state.count + 1}); } 直观上来看,当上面的 incrementMultiple 函数被调用时,组件状态的 让 setState 连续更新的几个 hack如果想让 count 一次性加3,应该如何优雅地处理潜在的异步操作,规避上述问题呢? 以下提供几种解决方案:
一个引起广泛讨论的 Issue这些内容貌似已经不再新鲜,很多 React 资深开发者其实都是了解的,或能很快理解。 可是,你想过这个问题吗: 说具体一些,就是调用 setState 方法之后,返回一个 promise,状态更新完毕后我们在调用 promise.then 进行下一步处理。 答案是肯定的,但是却被官方否决了。 我是如何得出“答案是肯定的,但是是不被官方建议的。”这个结论,喜欢刨根问底的读者请继续往下阅读,相信你一定会有所启发,也能更充分理解 React 团队的设计思想。 第 2642 Issue 解读和深入分析我是一步一步在 Facebook 开源 React 的官方 Github仓库上,找到了线索。 整个过程跟下来,相信在各路大神的 comments 之间,你会对 React 的设计理念以及 javascript 解决问题的思路有一个更清晰的认识。 一切的探究始于 React 第 #2642 号 issue: Make setState return a promise,上面关于 count 连续 +3 大家已经有所了解。接下来我举一个真正在生产开发中的例子,方便大家理解讨论。 我们现在开发一个可编辑的 table,需求是:当用户敲下“回车”,光标将会进入下一行(调用 setState 进行光标移动);如果用户当前已经在最后一行,那么敲下回车时,第一步将先创建一个新行(调用 setState 创建新的最后一行),在新行创建之后,再去新的最后一行进行光标聚焦(调用 setState 进行光标移动)。 常见且错误的处理在于: this.setState({ selected: input // 创建新行 }.bind(this)); this.props.didSelect(this.state.selected); 因为第一个 this.setState 是异步进行的话,下一处 didSelect 方法执行 this.setState 时,所处理的参数 this.state.selected 可能还不是预期的下一行。很明显,这就是 this.setState 的异步性带来的问题。 为了解决这个完成这样的逻辑,想到了 setState 第二个参数解决方案,用代码简单表述就是: this.setState({ selected: input // 创建新行 },function() { this.props.didSelect(this.state.selected); }).bind(this)); 这种解决方案是使用嵌套的 setState 方法。但这无疑潜在地会带来嵌套地狱的问题。 Promise 化方案登场这一切是不是像极了传统 Javascript 处理异步老套路?解决回调地狱,你是不是应激性地想到了 promise? 如果 setState 方法返回的是一个 promises,自然会更加优雅:
This results in a callback hell for a very stateful component. Having it return a promise would make it much more managable. 如果用 promise 风格解决问题的话,无非就是: this.setState({ selected: input }).then(function() { this.props.didSelect(this.state.selected); }.bind(this)); 看上去没什么问题,一个很时髦的设计。但是,我们进一步想:如果想让 React 支持这样的特性,采用提出 pull request 的方式,我们该如何去改源代码呢? 探索 React 源码,完成 setState promise 化的改造首先找到源码中关于 setState 定义的地方,它在 react/src/isomorphic/modern/class/ReactBaseClasses.js 这个目录下: ReactComponent.prototype.setState = function(partialState,callback) { invariant( typeof partialState === 'object' || typeof partialState === 'function' || partialState == null,'setState(...): takes an object of state variables to update or a ' + 'function which returns an object of state variables.',); this.updater.enqueueSetState(this,partialState,callback,'setState'); }; 我们首先看到一句注释:
这是采用 setState 第二个参数传入处理回调的基础。 另外,从注释中我们还找到:
这是给 setState 方法直接传入一个函数的基础。 言归正传,如何改动源码,使得 setState promise 化呢? ReactComponent.prototype.setState = function(partialState,callback) { invariant( typeof partialState === 'object' || typeof partialState === 'function' || partialState == null,'setState(...): takes an object of state variables to update or a ' + 'function which returns an object of state variables.',); + let callbackPromise; + if (!callback) { + class Deferred { + constructor() { + this.promise = new Promise((resolve,reject) => { + this.reject = reject; + this.resolve = resolve; + }); + } + } + callbackPromise = new Deferred(); + callback = () => { + callbackPromise.resolve(); + }; + } this.updater.enqueueSetState(this,'setState'); + + if (callbackPromise) { + return callbackPromise.promise; + } }; 我用 “+” 标注了对源码所做的更改。如果开发者调用 setState 方法时,传入的是一个 javascript 对象的话,那么会返回一个 promise,这个 promise 将会在 state 更新完毕后 resolve。 解决方案有了,可是 React 官方会接受这个 PR 吗?很遗憾,答案是否定的。我们来从 React 设计思想上,和 React 官方团队的回应上,了解一下否决理由。 sebmarkbage(Facebook 工程师,React 核心开发者)认为:解决异步带来的困扰方案其实很多。比如,我们可以在合适的生命周期 hook 函数中完成相关逻辑。在这个场景里,就是在行组件的 componentDidMount 里调用 focus,自然就完成了自动聚焦。 此外,还有一个方法:新的 refs 接口设计支持接收一个回调函数,当其子组件挂载时,这个回调函数就会相应触发。 所有上述模式都可以完全取代之前的问题方案,即使不能也不意味着要接受 promises 化这个PR。 为此,sebmarkbage 说了一段很扎心的话:
问题的根源在于现有的 batching 策略,实话实说,这个策略带来了一系列问题。也许这个在后期后有调整,在 batching 策略是否调整之前,盲目的扩充 setState 接口只会是一个短视的行为。 对此,Redux 原作者 Dan Abramov 也发表了自己的看法。他认为,以他的经验来看,任何需要使用 setState 第二个参数 callback 的场景,都可以使用生命周期函数 componentDidUpdate (and/or componentDidMount) 来复写。
另外,在一些极端场景下,如果开发者确实需要同步的处理方式,比如如果我想在某 DOM 元素挂载到屏幕之前做一些操作,promises 这种方案便不可行。因为 Promises 总是异步的。反过来,如果 setState 支持这两种不同的方式,那么似乎也是完全没有必要而多余的。 在社区,确实很多第三方库渐渐地接受使用 promises 风格,但是这些库解决的问题往往都是强异步性的,比如文件读取、网络操作等等。 React 似乎没有必要增加这么一个 confusing 的特性。 另外,如果每个 setState 都返回一个 promises,也会带来性能影响:对于 React 来说,setState 将必然产生一个 callback,这些 callbacks 需要合理储存,以便在合适时间来触发。 总结一下,解决 setState 异步带来的问题,有很多方式能够完美优雅地解决。在这种情况下,直接让 setState 返回 promise 是画蛇添足的。另外,这样也会引起性能问题等等。 我个人认为,这样的思路很好,但是难免有些 Overengineering。 这一次为自己疯狂,我和我的倔强怎么样,是否说服你了呢?如果没有,在不能更改 React 源码情况下,你就是想用 promise 化的 setState,怎么办呢? 这里提供一个“反模式”的方案:我们不改变源码,自己也可以进行改造,原理上就是直接对 this.setState 进行拦截,进而进行 promise 化,再封装一个新的接口出来。 import Promise from "bluebird"; export default { componentWillMount() { this.setStateAsync = Promise.promisify(this.setState); },}; 之后,便可以异步地: this.setStateAsync({ loading: true,}).then(this.loadSomething).then((result) => { return this.setStateAsync({result,loading: false}); }); 当然,也可以使用原声的 promises: function setStatePromise(that,newState) { return new Promise((resolve) => { that.setState(newState,() => { resolve(); }); }); } 甚至...我们还可以脑洞大开使用 async/await。 最后,所有这种做法非常的 dirty,我是不建议这么使用的。 总结其实研究一下 React Issue,深入源码学习,收获确实很多。总结也没有更多想说的了,无耻滴做个广告吧: 我的其他关于 React 文章:
Happy Coding! PS: (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |