likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 81

前言

本文需要用到两个hook:useSyncStateuseSyncMemo,目前升级到第三个版本了,本次升级带来了以下内容:

  1. 沿用第一版的格式;
  2. 采用不可变数据代理;
  3. 支持无依赖计算属性 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

其中AsetA在用法上与平时使用的state和setState并无二致,而curA则是保存最新状态值的副本,后文没有举例的地方我们会称其为currentState,是一个不可变数据的代理,类似immerdraft,因此对其进行的任何修改都不会对状态有任何影响,本着状态不可变的原则,我们可以这样修改状态:

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的原理,则是通过订阅currentStategettersetter,来感知依赖项的变化,从而计算得到重新渲染前的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内部是通过curAgetter去获取依赖项的,如下:

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):

  1. 保证状态的不可变性;
  2. 渲染之后如何正确执行副作用;
  3. 如何规避不必要的渲染;

当这三者相互影响的时候,尤其是副作用函数开始执行的时候,不确定性因素将呈指数型增长,导致我们在后续开发过程中需要步步为营,小心谨慎地开发,否则的话就很容易出现一些奇奇怪怪、很难排查的问题。你如果仔细分析的话,就会发现造成react心智负担的最大作俑者,就是副作用,它会引发其他各种奇奇怪怪的负担,所以我们往往在写副作用的时候就会感觉不好下手。

举个例子,为了规避一些副作用问题,有些人可能会选择将多个状态合并为一个对象,这样很容易在不知不觉中就打破状态的不可变性原则,尤其是对一些新手来说,很容易就把状态当成普通对象直接修改了,我自己就这么干过哈。我们无论是用useState来声明状态也好,亦或者用useReduce也好,都需要遵循状态的不可变性原则,这时候合不合并状态其实已经没什么区别了。

再比方说,当有多个状态相互依赖时,我们可能会在副作用函数中修改其他状态,这无疑会引起二次渲染,并且每次依赖状态变更时,都会触发相应的副作用,尽管我们可以用一个ref来控制副作用的执行与否,但这无疑又会是另一种负担。聪明的你肯定也会想到用useMemo可以减少渲染次数,但是useMemo是一个计算属性,我们一般只用来缓存计算结果减少计算次数,而且useMemo也不像vue的计算属性一样可以自由更改,它没有自己的setState方法,我们会因此失去对状态的变更自由。那么我们有什么既可以减少渲染,又能保留状态setState能力的方法,让状态变更更加可控吗?答案是有的,那就是消除副作用。

如何消除副作用函数

从上面的hook介绍章节,我们已经了解到useSyncStateuseSyncMemo可以提前获取到状态值和计算值了,因此我们也具备了消除副作用函数的条件,先看下我们平时的副作用写法:

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])

这段代码的含义是我们有两个异步请求去获取状态AB的值,然后根据AB的值做一些计算,此时我们就需要将思维放到下一次渲染切片的副作用中,这时候需要考虑的范围将会是组件的整个生命周期,很显然这并不符合我们的直觉思维。并且你会发现,AB变更时都会去执行副作用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)  // 只会执行一次副作用
    })
}, [])

这种写法就比较符合我们的直觉思维了,我们想要的便是在两个异步请求之后根据AB的值去做计算,并且你会发现,此时计算次数减少到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)
})

总结

useSyncStateuseSyncMemo能够在重新渲染前就拿到新状态值,这给我们消除副作用函数提供了条件,我们无需再关心复杂的数据流动和副作用问题,大大降低了开发react时的心智负担。同时由于状态副本currentState是一个不可变数据代理,可以消除掉一些维持状态不可变性的负担,并且在一些场景下也能够让我们减少一些不必要的渲染和计算,提高性能。如果看到这里你还有疑问的话,可以试着先用来写写看,再回头对比以前的写法,就能大概感受到思维上的区别了,而且引用这两个hook也不影响我们继续使用以前的开发思维,互不冲突。目前用回第一版的格式是因为在项目中改起来确实会比较简单,只需在开头状态定义处修改补充currentState即可,其他地方甚至都不需要进行任何改动,很是方便。

创作不易,如果觉得对你有帮助的话,麻烦帮我点点赞,点点关注,github上点点star(地址:react-sync-state-hook),这对我真的很重要,感激不尽!!!

转载自:https://juejin.cn/post/7226207194701971514
评论
请登录