消除React的副作用函数,解决React的心智负担问题

您所在的位置:网站首页 choice函数的作用 消除React的副作用函数,解决React的心智负担问题

消除React的副作用函数,解决React的心智负担问题

2023-04-28 19:44| 来源: 网络整理| 查看: 265

前言

本文需要用到两个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心智负担的最大搅屎棍,就是副作用,它会引发其他各种奇奇怪怪的负担,所以我们往往在写副作用的时候就会感觉不好下手。

比方说,为了规避一些副作用问题,有些人可能会选择将多个状态合并为一个对象,我不知道大家会不会和我一样觉得这样会有点别扭,因为对象本身就很容易导致状态发生改变,尤其是对一些新手来说,很容易就把状态当成普通对象直接修改了,我自己就这么干过哈。我们无论是用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看成是刷新视图的工具函数即可,无需再关心复杂的数据流动和副作用问题。

如果硬要说像哪个框架的思维的话,我会跟你说像微信小程序的写法。在这里我可以很确切地告诉大家,我以前用第一版写项目的时候没有写过任何一个有依赖的副作用函数,简单的副作用用useState函数式去写就好了,复杂的都是用useSyncState写的,这样写起业务来真的很丝滑。

如果你还抱有怀疑态度的话,那我们再来看个副作用函数错误使用导致的问题:

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),这对我真的很重要,感恩不尽!!!



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3