消除React的副作用函数,解决React的心智负担问题
前言
本文需要用到两个hook:useSyncState
和useSyncMemo
,目前升级到第三个版本了,本次升级带来了以下内容:
- 沿用第一版的格式;
- 采用不可变数据代理;
- 支持无依赖计算属性 memo。
它们可以用来获取最新的状态值,消除react的副作用函数,起到一定的性能优化作用,并在某种程度上解决react的心智负担问题,极大改善开发体验。本文将详细介绍其用法以及是如何消除react副作用函数的,可能会与你以往的react开发思维有所不同,但是这两个hook均符合react的开发规则,相信对你还是能起到一定帮助作用的。本文需要用到的两个hook地址:react-sync-state-hook。
- 通过npm下载:
npm i react-sync-state-hook
- 引入:
import { useSyncState, useSyncMemo, _getImmutableCopy_ } from 'react-sync-state-hook'
hook介绍
- useSyncState(value)
useSyncState
的原理其实很简单,就是用一个变量保存state的最新值,再生成一个不可变数据代理返回,以此在重新渲染前获取到新状态的副本,注意是重新渲染前,用法如下:
const [A, setA, curA] = useSyncState(0)
setA(1)
console.log(A) // 0
console.log(curA.current) // 1
其中A
和setA
在用法上与平时使用的state和setState并无二致,而curA
则是保存最新状态值的副本,后文没有举例的地方我们会称其为currentState
,是一个不可变数据的代理,类似immer
的draft
,因此对其进行的任何修改都不会对状态有任何影响,本着状态不可变的原则,我们可以这样修改状态:
const [A, setA, curA] = useSyncState([{a: 0}])
curA.current[0].a = 1
setA(curA.current)
这里无需担心传进去的curA.current
是一个代理对象,因为在setA
的内部会自动将其解包,状态值将被赋予解包后的数据。setA
也支持函数式更新,其所带参数是一个不可变数据代理,并且无需通过.current
调用,类似useImmer
的用法,如下所示:
const [A, setA, curA] = useSyncState([{a: 0}])
setA(prev => {
prev.push({b: 1})
return prev
})
- useSyncMemo(fn[, deps])
useSyncMemo
的原理,则是通过订阅currentState
的getter
和setter
,来感知依赖项的变化,从而计算得到重新渲染前的memo值,因此需要显式传递依赖项的话,需要用currentState
作为依赖项,用法如下:
const [A, setA, curA] = useSyncState(0)
const [ M, curM ] = useSyncMemo(() => {
return curA.current + 1
}, [curA])
setA(100)
console.log(M) // 1
console.log(curM.current) // 101
注意:计算函数里只有使用curA
去计算才能得到实时的curM
,并且在没有显式传依赖项的情况下,useSyncMemo
内部是通过curA
的getter
去获取依赖项的,如下:
const [A, setA, curA] = useSyncState(0)
const [ M, curM ] = useSyncMemo(() => {
return curA.current + 1
})
setA(100)
console.log(M) // 1
console.log(curM.current) // 101
- _getImmutableCopy_(value)
不可变数据代理的解包函数,在极少数情况下,我们可能需要手动解包,保险起见还是预留了该函数,用法如下:
import { _getImmutableCopy_ } from 'react-sync-state-hook'
const [ state, setState, curState ] = useSyncState({a: 0})
const copy = _getImmutableCopy_(curState)
react的心智负担
在解释如何消除副作用函数之前,我们先来聊聊react的心智负担。这里我列举了三个react最具代表性的心智负担(参考文章:每天都在用,也没整明白的 React Hook):
- 保证状态的不可变性;
- 渲染之后如何正确执行副作用;
- 如何规避不必要的渲染;
当这三者相互影响的时候,尤其是副作用函数开始执行的时候,不确定性因素将呈指数型增长,导致我们在后续开发过程中需要步步为营,小心谨慎地开发,否则的话就很容易出现一些奇奇怪怪、很难排查的问题。你如果仔细分析的话,就会发现造成react心智负担的最大作俑者,就是副作用,它会引发其他各种奇奇怪怪的负担,所以我们往往在写副作用的时候就会感觉不好下手。
举个例子,为了规避一些副作用问题,有些人可能会选择将多个状态合并为一个对象,这样很容易在不知不觉中就打破状态的不可变性原则,尤其是对一些新手来说,很容易就把状态当成普通对象直接修改了,我自己就这么干过哈。我们无论是用useState
来声明状态也好,亦或者用useReduce
也好,都需要遵循状态的不可变性原则,这时候合不合并状态其实已经没什么区别了。
再比方说,当有多个状态相互依赖时,我们可能会在副作用函数中修改其他状态,这无疑会引起二次渲染,并且每次依赖状态变更时,都会触发相应的副作用,尽管我们可以用一个ref来控制副作用的执行与否,但这无疑又会是另一种负担。聪明的你肯定也会想到用useMemo
可以减少渲染次数,但是useMemo
是一个计算属性,我们一般只用来缓存计算结果减少计算次数,而且useMemo
也不像vue的计算属性一样可以自由更改,它没有自己的setState
方法,我们会因此失去对状态的变更自由。那么我们有什么既可以减少渲染,又能保留状态setState
能力的方法,让状态变更更加可控吗?答案是有的,那就是消除副作用。
如何消除副作用函数
从上面的hook介绍章节,我们已经了解到useSyncState
和useSyncMemo
可以提前获取到状态值和计算值了,因此我们也具备了消除副作用函数的条件,先看下我们平时的副作用写法:
const [A, setA] = useState(0)
useEffect(() => {
setA(1)
}, [])
useEffect(() => {
todos(A) // 副作用
}, [A])
这里我们需要确保依赖项填写正确,否则很容易出现问题,我们再用useSyncState
来改写看看:
const [A, setA, curA] = useSyncState(0)
useEffect(() => {
setA(1)
todos(curA.current) // 副作用
}, [])
此时你应该已经发现了,我们将副作用给提前了,放到了第一次渲染切片中执行,这样有什么好处呢,一个是比较符合我们的直觉思维,什么意思呢,我们看以下代码:
const [ A, setA ] = useState(0)
const [ B, setB ] = useState(0)
let request1 = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
setA(1)
resolve()
}, 500)
})
}
let request2 = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
setB(1)
resolve()
}, 1000)
})
}
useEffect(() =>{
request1()
request2()
}, [])
useEffect(() =>{
todos(A, B) // A和B变更时都会执行副作用,产生冗余计算
}, [A, B])
这段代码的含义是我们有两个异步请求去获取状态A
和B
的值,然后根据A
和B
的值做一些计算,此时我们就需要将思维放到下一次渲染切片的副作用中,这时候需要考虑的范围将会是组件的整个生命周期,很显然这并不符合我们的直觉思维。并且你会发现,A
和B
变更时都会去执行副作用todos()
,此时便产生了冗余计算,以此类推,当有更多依赖状态时,将会产生更多的冗余计算,我们用useSyncState
改写看看:
const [ A, setA, curA ] = useSyncState(0)
const [ B, setB, curB ] = useSyncState(0)
let request1 = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
setA(1)
resolve()
}, 500)
})
}
let request2 = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
setB(1)
resolve()
}, 1000)
})
}
useEffect(() =>{
Promise.all([request1(), request2()]).then(res => {
todos(curA.current, curB.current) // 只会执行一次副作用
})
}, [])
这种写法就比较符合我们的直觉思维了,我们想要的便是在两个异步请求之后根据A
和B
的值去做计算,并且你会发现,此时计算次数减少到1次,不论后面我们新增多少个状态,都只会计算一次,再没有冗余的计算了,而且有时候我们会希望状态变更的时候执行不同的副作用,如果用副作用函数去写的话,逻辑将会非常的复杂;但是用useSyncState
写的话,逻辑就比较简单了,想在哪里执行副作用就在哪里执行副作用,想执行什么副作用就执行什么副作用,不需要考虑依赖问题,我们把setState
看成是刷新视图的工具函数即可,无需再关心复杂的数据流动和副作用问题。
如果你还抱有怀疑态度的话,那我们再来看个副作用函数错误使用导致的问题:
const [A, setA] = useState(0)
useEffect(() => {
setA(1)
}, [])
useEffect(() => {
if(tag){
todos(A)
}else{
setA(0)
}
}, [A])
假设某个萌新写了这段代码,此时在开发环境和测试环境中,tag
的值为true
,这段代码正常运行,但是发布到线上后,由于数据不同,tag
值可能为false
,此时将陷入死循环,导致线上应用崩溃,测试人员在测试环境一测,发现又是正常运行的,这可能需要排查半天,才能知道问题所在,我们再用useSyncState
改写看看:
const [A, setA, curA] = useSyncState(0)
useEffect(() => {
setA(1)
if(tag){
todos(curA.current)
}else{
setA(0)
}
}, [])
此时无论tag
是什么值,都不会导致死循环,有人可能会说,我就是希望每次A变化的时候执行一下副作用,那也有办法:
const [A, setA, curA] = useSyncState(0)
const setAWithEffect = (value) => {
setA(value)
if(tag){
todos(curA.current)
}else{
setA(0)
}
}
setAWithEffect(1)
因为状态的不可变性,我们必然会通过setA
来更新状态,所以此时我们将setA
改用setAWithEffect
,就能实现状态A
每次更新都执行副作用了。
不过有一点要注意的是,采用同步思维开发的时候,有时候会阻塞视图更新,但我们也有方法解决,如下所示:
const [ A, setA, curA ] = useSyncState(0)
const [ B, setB, curB ] = useSyncState(0)
setA(1)
todos() // 副作用
setB(100)
由于react的批量处理机制(batch update),setA
之后视图不会立刻刷新,需要等到执行完setB
的时候才会发起重新渲染,在副作用todos()
执行速度比较快的情况下,这样写是可以直接减少渲染,提升性能;但在todos()
执行速度比较慢的情况下,状态A的视图就需要等很久才刷新了,那么我们只要绕过react的批量处理即可,如下所示:
const [ A, setA, curA ] = useSyncState(0)
const [ B, setB, curB ] = useSyncState(0)
setA(1)
setTimeout(() => {
todos() // 副作用
setB(100)
})
总结
useSyncState
和useSyncMemo
能够在重新渲染前就拿到新状态值,这给我们消除副作用函数提供了条件,我们无需再关心复杂的数据流动和副作用问题,大大降低了开发react时的心智负担。同时由于状态副本currentState
是一个不可变数据代理,可以消除掉一些维持状态不可变性的负担,并且在一些场景下也能够让我们减少一些不必要的渲染和计算,提高性能。如果看到这里你还有疑问的话,可以试着先用来写写看,再回头对比以前的写法,就能大概感受到思维上的区别了,而且引用这两个hook也不影响我们继续使用以前的开发思维,互不冲突。目前用回第一版的格式是因为在项目中改起来确实会比较简单,只需在开头状态定义处修改补充currentState
即可,其他地方甚至都不需要进行任何改动,很是方便。
创作不易,如果觉得对你有帮助的话,麻烦帮我点点赞,点点关注,github上点点star(地址:react-sync-state-hook),这对我真的很重要,感激不尽!!!
转载自:https://juejin.cn/post/7226207194701971514