React以对象操作state导致不一致
写React代码的时候遇到一个state状态不一致的问题:如果state是对象(数组),用扩展运算符多次更新,一些状态被冲掉了。
class组件中,通常设置state的方法的步骤是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class Clock extends React.Component { constructor(props) { super(props); this.state = { foo: 1, bar: 'a' }; } componentDidMount() { this.setState({ foo: 2 }); this.setState({ bar: 'b' }); } render () { return <div>{this.state.foo} {this.state.bar}</div> } } |
此时会正常输出2 b
,因为大概知道setState的状态是会merge到一起的。
但是放到函数组件里,如果直接写
1 2 3 4 5 6 7 8 9 |
const [data, setData] = useState({foo: 1, bar: 'a'}); useEffect(() => { setData({ foo: 2 }); setData({ bar: 'b' }); }, []); |
foo: 2
会被第二次的setData
冲掉。
那么,如果想以对象(或数组)的形式来设置state,需要怎么操作呢?
1) 拆分为两个状态,foo
和bar
,以不同的方式来更新(显然跑题了)
1 2 3 4 5 6 |
const [foo, setFoo] = useState(1); const [bar, setBar] = useState('a'); useEffect(() => { setFoo(2); setBar('b'); }, []); |
2) 使用扩展运算符...
1 2 3 4 5 6 7 8 9 10 11 |
const [data, setData] = useState({foo: 1, bar: 'a'}); useEffect(() => { setData({ ...data, foo: 2 }); setData({ ...data, bar: 'b' }); }, []); |
然后就遇到问题了,输出始终是1 b
。然后自然猜测,setData
是异步的,当操作到高亮部分的时候,上面的state还未实际发生作用,所以下面的state取到的并非最新的值。
真的是这样吗?如果查看官方文档,发现useState
的设置,可以传递函数,即
1 2 3 4 5 6 7 8 9 10 11 |
const [data, setData] = useState({foo: 1, bar: 'a'}); useEffect(() => { setData((prev) => ({ ...prev, foo: 2 })); setData((prev) => ({ ...data, bar: 'b' })); }, []); |
这时,又能正常输出2 b
了。为啥呢?
官方解释是,不像setState
,useState
这个hook并不会自动的合并更新(merge update),所以可用用函数的方式来达到同样效果。
解决是解决了,但是感觉怪怪的。
set方法的执行流程
之前梳理过set流程,useState实际通过dispatchAction
绑定dispatch回调的方式做了绑定,所以从这里开始看。
1) 直接使用对象setData({...})
一次更新的内容,会以循环链表的形式(queue)存储起来,最后统一遍历执行
queue,是在创建的时候(mountState
,是useState
的入口函数)就一并绑定的,存储在hook上
1 2 3 4 5 6 7 8 |
var queue = hook.queue = { last: null, dispatch: null, lastRenderedReducer: basicStateReducer, lastRenderedState: initialState }; var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber, queue) return [hook.memoizedState, dispatch] // dispatch即我们调用的setData |
update queue循环链表结构
这个循环链表在每次调用setData,都会在上次操作节点的后面插入新的节点。遍历的时候从head的下个节点开始即可。
1 2 3 4 5 6 7 8 9 10 11 12 |
dispatchAction _update.eagerState = _eagerState renderWithHooks -dispatcher.useState(initialState) -updateState(initialState) -updateReducer(basicStateReducer, initialState) _newState = reducer(_newState, action) =basicStateReducer(state, action) return typeof action === 'function' ? action(state) : action; |
第一次调用时,基础state是{foo: 1, bar: ‘a’},最后把newState设置为action即{foo: 2, bar: ‘a’};
第二次调用时,基础state是{foo: 2, bar: ‘a’},最后把newState设置为action即{foo: 1, bar: ‘b’}。
2) 使用functionsetData((prev) => ({...prev, {...}}))
1 2 3 4 5 6 7 8 9 10 11 |
dispatchAction _update.eagerState = _eagerState // {a: 1, b: 'b'} renderWithHooks -dispatcher.useState(initialState) -updateState(initialState) -updateReducer(basicStateReducer, initialState) // ... 触发update链表更新 _newState = reducer(_newState, action) =basicStateReducer(state, action) return typeof action === 'function' ? action(state) : action; |
第一次调用时,基础state是{foo: 1, bar: 'a'}
,最后把newState设置为action(state)
,即函数执行结果{foo: 2, bar: 'a'}
;
第二次调用时,基础state是{foo: 2, bar: 'a'}
,最后把newState设置为action(state)
,即函数执行结果{foo: 2, bar: 'b'}
。
action在这里,调用的是action,即我们传入的函数,而参数是{a: 1, b: 'b'}
,所以返回
1 2 3 4 5 6 7 8 9 |
{ ...{foo: 2, bar: 'a'}, bar: 'b' } // 即 { foo: 2, bar: 'b' } |
可以看出,二者的区别仅仅在于最后对于basicStateReducer
的使用。
为什么这么设计?
为什么以对象调用时,能取到上次的state,却直接抛弃不用,可以改吗?
在最后调用basicStateReducer(state, action)
的时候,是传入了上次计算所得值state的,但是action是对象时,选择了直接抛弃,而使用新传入的值。
设想1. 把最后的basicStateReducer
改成根据state来合并新对象
1 2 |
basicStateReducer(state, action) return typeof action === 'function' ? action(state) : assign({}, state, action); |
首次调用时,基础state是{foo: 1, bar: 'a'}
,action是{foo:2, bar: 'a'}
。二者合并为{foo: 2, bar: 'a'}
;
第二次调用时,基础state是{foo: 2, bar: 'a'}
,action是{foo: 1, bar: 'b'}
。二者合并,还是{foo: 1, bar: 'b'}
。
即使调换顺序,使用assign({}, action, state)
,效果也是一样。
那么,像setState那样,每次setData
只更新一个属性,然后合并可以吗?不行,因为这里的newState
是基于上个链表节点的计算结果来的,只更新一个属性,会造成state丢失。
所以,主要原因在于函数是先绑定方法,setData的时候调用,而作为对象是直接参与计算的。
设想2. 把最后的basicStateReducer
统一改为函数调用
不行,必须有一个和传递函数一样的函数,它满足两个条件:既能保留要更新的属性,又能把之前的state传递进来。
1 2 |
basicStateReducer(state, action) return typeof action === 'function' ? action(state) : ((state) => ({...state, ...action})()); |
和设想1一样,不能一次更新一个属性(丢失state)。或者就利用对象属性对比,但是开销太大了。
class组件的state属性合并逻辑不一样吗
有个误解,class组件的state的状态合并和函数组件不一样。
因为class组件的state本身,就是作为一个对象存储的
1 2 3 4 |
this.state = { foo: 1, bar: 'a' }; |
所以当更新的时候,setState({foo: 2})
没问题,其实相当于是分成了两个state。如果要对应上面的例子,应该是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
this.state = { a: { foo: 1, bar: 'a' } } componentDidMount() { this.setState({ foo: { ...this.state.a, foo: '2' } }); this.setState({ foo: { ...this.state.a, bar: 'b' } }); } |
输出依然是1 b
,也没有"正确"合并。解决方法也是变为函数来设置。所以两个版本关于state更新这块,逻辑基本是一致的。
1 2 3 4 5 6 |
// react 15.6.0 _processPendingState: function (props, context) { // ... _assign(nextState, typeof partial === 'function' ? partial.call(inst, nextState, props, context) : partial); // ... } |
居然0评论,最近大家都。。。 催更
向博主学习
都是代码,学习了
兄弟,你还活着吗?
@郑永 活着,最近工作太忙了
@axiu 那我就放心了,现在多灾多难,有的人悄无声息的走了。
@郑永
活……活着呢,谢永哥关心 