关于 Hook 的一些总结

关于 Hook 的一些总结

因为手头上了项目暂时告一段落,也终于有时间可以回过头来总结总结,因为在之前关于 React 的使用也仅仅只局限在小型应用当中,而目前完结的这个项目算是整体基于 React 全家桶搭建而成的项目

当然在开发当中也遇到了不少棘手的问题,也踩了不少的坑,尤其是关于 Hook 的一些使用方面,所以也就打算抽些时间,重新温故一下文档以及参考一些前辈们整理的文章,简单的梳理一下在使用 Hook 过程当中遇到的一些问题和总结

单个 state 与多个 state

useState 的出现,让我们可以使用多个 state 变量来保存 state,比如

1
2
3
4
const [width, setWidth] = useState(100)
const [height, setHeight] = useState(100)
const [left, setLeft] = useState(0)
const [top, setTop] = useState(0)

但同时我们也可以像 Class 组件的 this.state 一样,将所有的 state 放到一个对象中,这样我们就只需一个 state 变量即可

1
2
3
4
5
6
const [state, setState] = useState({
width: 100,
height: 100,
left: 0,
top: 0,
})

那么问题来了,我们到底应该使用单个的 state 变量还是多个的 state 变量呢?

如果使用单个 state 变量,每次更新 state 时需要合并之前的 state,因为 useState 返回的 setState 会替换原来的值,这一点和 Class 组件的 this.setState 有所不同,this.setState 会把更新的字段自动合并到 this.state 对象中

1
2
3
4
5
6
7
const handleMouseMove = e => {
setState(prevState => ({
...prevState,
left: e.pageX,
top: e.pageY,
}))
}

如果使用多个 state 变量可以让 state 的粒度更细,更易于逻辑的拆分和组合,比如们可以将关联的逻辑提取到自定义 Hook 中

1
2
3
4
5
6
7
8
9
10
const usePosition = () => {
const [left, setLeft] = useState(0)
const [top, setTop] = useState(0)

useEffect(() => {
// ...
}, [])

return [left, top, setLeft, setTop]
}

这样一来,我们每次在更新 left 的时候 top 也会随之更新,因此把 top 和 left 拆分为两个 state 变量显得有点多余

在使用 state 之前,我们通常需要考虑状态拆分的『粒度』问题,如果粒度过细,代码就会变得比较冗余,如果粒度过粗,代码的可复用性就会降低,那么到底哪些 state 应该合并,哪些 state 又应该拆分呢?

简单来说我们遵循的规律主要有以下两点

  • 将完全不相关的 state 拆分为多组 state,比如 size 和 position
  • 如果某些 state 是相互关联的,或者需要一起发生改变,就可以把它们合并为一组 state,比如 left 和 top
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Box = () => {
const [position, setPosition] = usePosition()
const [size, setSize] = useState({ width: 100, height: 100 })
// ...
}

const usePosition = () => {
const [position, setPosition] = useState({ left: 0, top: 0 })

useEffect(() => {
// ...
}, [])

return [position, setPosition]
}

依赖过多的问题

我们在使用 useEffect Hook 时,为了避免每次 render 都去执行它的 callback,通常会传入第二个参数 dependency array,也就是我们常说的『依赖数组』,这样一来只有当依赖数组发生变化时,才会执行 useEffect 的回调函数

1
2
3
4
5
const Example = ({ id, name }) => {
useEffect(() => {
console.log(id, name)
}, [id, name])
}

在上面的例子中,只有当 id 或 name 发生变化时,才会打印日志,依赖数组中必须包含在 callback 内部用到的所有参与 React 数据流的值,比如 state、props 以及它们的衍生物,如果有遗漏,可能会埋下隐患

1
2
3
4
5
6
const Example = ({ id, name }) => {
useEffect(() => {
// 由于依赖数组中不包含 name,所以当 name 发生变化时,无法打印日志
console.log(id, name)
}, [id])
}

在 React 中,除了 useEffect 外,接收依赖数组作为参数的 Hook 还有 useMemo、useCallback 和 useImperativeHandle,我们在上面也提到过,依赖数组中千万不要遗漏回调函数内部依赖的值,但是如果依赖数组依赖了过多东西,可能导致代码难以维护,比如下面这段代码

1
2
3
const refresh = useCallback(() => {
// ...
}, [name, searchState, address, status, personA, personB, progress, page, size])

光是看到这一堆依赖就令人头大,如果项目中到处都是这样的代码,可想而知维护起来多么痛苦,所以针对于这样的情况,我们就需要重新的思考一下这些 deps 是否真的都需要?看下面这个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Example = ({ id }) => {
const requestParams = useRef({})

useEffect(() => {
requestParams.current = { page: 1, size: 20, id }
})

const refresh = useCallback(() => {
doRefresh(requestParams.current)
}, [])

useEffect(() => {
id && refresh()
}, [id, refresh])
}

虽然 useEffect 的回调函数依赖了 id 和 refresh 方法,但是我们观察 refresh 方法可以发现,它在首次 render 被创建之后,就永远不会发生改变了,因此把它作为 useEffect 的 deps 是多余的

其次,如果这些依赖真的都是需要的,那么这些逻辑是否应该放到同一个 Hook 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Example = ({ id, name, address, status, personA, personB, progress }) => {
const [page, setPage] = useState()
const [size, setSize] = useState()

const doSearch = useCallback(() => {
// ...
}, [])

const doRefresh = useCallback(() => {
// ...
}, [])

useEffect(() => {
id && doSearch({ name, address, status, personA, personB, progress })
page && doRefresh({ name, page, size })
}, [id, name, address, status, personA, personB, progress, page, size])
}

可以看出,在 useEffect 中有两段逻辑,而且这两段逻辑是相互独立的,因此我们可以将这两段逻辑放到不同 useEffect 中

1
2
3
4
5
6
7
useEffect(() => {
id && doSearch({ name, address, status, personA, personB, progress })
}, [id, name, address, status, personA, personB, progress])

useEffect(() => {
page && doRefresh({ name, page, size })
}, [name, page, size])

但是如果逻辑无法继续拆分,但是依赖数组还是依赖了过多东西,该怎么办呢?就比如我们上面的代码

1
2
3
useEffect(() => {
id && doSearch({ name, address, status, personA, personB, progress })
}, [id, name, address, status, personA, personB, progress])

这段代码中的 useEffect 依赖了七个值,但是我们仔细观察上面的代码,可以发现这些值都是『过滤条件』的一部分,通过这些条件可以过滤页面上的数据,因此我们可以将它们看做一个整体,也就是我们前面讲过的合并 state

1
2
3
4
5
6
7
8
9
10
11
12
const [filters, setFilters] = useState({
name: '',
address: '',
status: '',
personA: '',
personB: '',
progress: '',
})

useEffect(() => {
id && doSearch(filters)
}, [id, filters])

但是如果 state 不能合并,并且在 callback 内部又使用了 setState 方法的话,在这种情况下,我们就可以考虑使用 setState callback 来减少一些依赖,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const useValues = () => {
const [values, setValues] = useState({
data: {},
count: 0,
})

const [updateData] = useCallback(
nextData => {
setValues({
data: nextData,
// 因为 callback 内部依赖了外部的 values 变量,所以必须在依赖数组中指定它
count: values.count + 1,
})
},
[values]
)

return [values, updateData]
}

上面的代码中,我们必须在 useCallback 的依赖数组中指定 values,否则我们无法在 callback 中获取到最新的 values 状态,但是通过 setState 回调函数,我们就不用再依赖外部的 values 变量,因此也无需在依赖数组中指定它,就像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const useValues = () => {
const [values, setValues] = useState({})

const [updateData] = useCallback(nextData => {
setValues(prevValues => ({
data: nextData,
// 通过 setState 回调函数获取最新的 values 状态,这时 callback 不再依赖于外部的 values 变量了
// 因此依赖数组中不需要指定任何值
count: prevValues.count + 1,
}))
// 这个 callback 永远不会重新创建
}, [])

return [values, updateData]
}

最后我们还可以通过 ref 来保存可变变量,以前我们只是把 ref 用作保持 DOM 节点引用的工具,可是 useRef Hook 能做的事情远不止如此,我们可以用它来保存一些值的引用,并对它进行读写,举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const useValues = () => {
const [values, setValues] = useState({})
const latestValues = useRef(values)

useEffect(() => {
latestValues.current = values
})

const [updateData] = useCallback(nextData => {
setValues({
data: nextData,
count: latestValues.current.count + 1,
})
}, [])

return [values, updateData]
}

但是需要注意的是,我们在使用 ref 的过程当中要特别小心,因为它可以随意赋值,所以一定要控制好修改它的方法,特别是一些底层模块,在封装的时候千万不要直接暴露 ref,而是提供一些修改它的方法

说了这么多,归根到底都是为了写出更加清晰、易于维护的代码,如果发现依赖数组依赖过多,我们就需要重新审视自己的代码

  • 依赖数组依赖的值最好不要超过三个,否则会导致代码会难以维护
  • 如果发现依赖数组依赖的值过多,我们应该采取一些方法来减少它
    • 去掉不必要的依赖
    • 将 Hook 拆分为更小的单元,每个 Hook 依赖于各自的依赖数组
    • 通过合并相关的 state,将多个依赖值聚合为一个
    • 通过 setState 回调函数获取最新的 state,以减少外部依赖
    • 通过 ref 来读取可变变量的值,不过需要注意控制修改它的途径

该不该使用 useMemo

对于这个问题,有的人从来没有思考过,有的人甚至不觉得这是个问题,因为我们在网上经常见到的做法是,那就是不管什么情况,只要用 useMemo 或者 useCallback 简单的『包裹一下』,似乎就能使应用远离性能的问题,但真的是这样吗?有的时候 useMemo 没有任何作用,甚至还会影响应用的性能

为什么这么说呢?首先我们需要知道,useMemo 本身也有开销,useMemo 会『记住』一些值,同时在后续 render 时,将依赖数组中的值取出来和上一次记录的值进行比较,如果不相等才会重新执行回调函数,否则直接返回『记住』的值,这个过程本身就会消耗一定的内存和计算资源,因此过度使用 useMemo 可能会影响程序的性能

所以说要想合理使用 useMemo,我们需要搞清楚 useMemo 适用的场景

  • 有些计算开销很大,我们就需要『记住』它的返回值,避免每次 render 都去重新计算
  • 由于值的引用发生变化,导致下游组件重新渲染,我们也需要『记住』这个值

让我们来看个例子

1
2
3
4
5
6
7
const Example = ({ page, type }) => {
const resolvedValue = useMemo(() => {
return getResolvedValue(page, type)
}, [page, type])

return <ExpensiveComponent resolvedValue={resolvedValue} />
}

在上面的例子中,渲染 ExpensiveComponent 的开销很大,所以当 resolvedValue 的引用发生变化时,我们不想重新渲染这个组件,因此使用 useMemo 来避免每次 render 的时候重新计算 resolvedValue,导致它的引用发生改变,从而使下游组件 re-render

这个担忧是正确的,但是使用 useMemo 之前,我们应该先思考两个问题

  1. 传递给 useMemo 的函数开销大不大?

在上面的例子中,就是考虑 getResolvedValue 函数的开销大不大,JavaScript 中大多数方法都是优化过的,比如 Array.map、Array.forEach 等,如果你执行的操作开销不大,那么就不需要记住返回值,否则使用 useMemo 本身的开销就可能超过重新计算这个值的开销,因此对于一些简单的 JavaScript 运算来说,我们不需要使用 useMemo 来记住它的返回值

  1. 当输入相同时,记忆的值的引用是否会发生改变?

在上面的例子中,就是当 page 和 type 相同时,resolvedValue 的引用是否会发生改变?这里我们就需要考虑 resolvedValue 的类型了,如果 resolvedValue 是一个对象,由于我们项目上使用『函数式编程』,每次函数调用都会产生一个新的引用,但是如果 resolvedValue 是一个原始值(string,boolean,null,undefined,number,symbol),也就不存在『引用』的概念了,每次计算出来的这个值一定是相等的,也就是说 ExpensiveComponent 组件不会被重新渲染

因此,如果 getResolvedValue 的开销不大,并且 resolvedValue 返回一个字符串之类的原始值,那我们完全可以去掉 useMemo,就像下面这样

1
2
3
4
const Example = ({ page, type }) => {
const resolvedValue = getResolvedValue(page, type)
return <ExpensiveComponent resolvedValue={resolvedValue} />
}

还有一个误区就是对创建函数开销的评估,有的人觉得在 render 中创建函数可能会开销比较大,为了避免函数多次创建,使用了 useMemo 或者 useCallback,但是对于现代浏览器来说,创建函数的成本微乎其微

因此我们没有必要使用 useMemo 或者 useCallback 去节省这部分性能开销,当然如果是为了保证每次 render 时回调的引用相等,你可以放心使用 useMemo 或者 useCallback

1
2
3
4
5
6
7
8
const Example = () => {
// 考虑这里的 useCallback 是否必要
const onSubmit = useCallback(() => {
doSomething()
}, [])

return <form onSubmit={onSubmit}></form>
}

其实通常来说在这里可能会存在一些争议,那就是如果只是想在重新渲染时保持值的引用不变,除了可以使用 useMemo 外,还可以使用 useRef,那么它们两者之间有什么区别呢?

1
2
3
4
5
6
7
8
9
10
11
// 使用 useMemo
const Example = () => {
const users = useMemo(() => [1, 2, 3], [])
return <ExpensiveComponent users={users} />
}

// 使用 useRef
const Example = () => {
const { current: users } = useRef([1, 2, 3])
return <ExpensiveComponent users={users} />
}

在上面的例子中,我们用 useMemo 来『记住』users 数组,不是因为数组本身的开销大,而是因为 users 的引用在每次 render 时都会发生改变,从而导致子组件 ExpensiveComponent 重新渲染(可能会带来较大开销)

虽然在 React 中 useRef 和 useMemo 的实现有一点差别,但是当 useMemo 的依赖数组为空数组时,它和 useRef 的开销可以说相差无几,useRef 甚至可以直接用 useMemo 来实现,就像下面这样

1
2
3
const useRef = v => {
return useMemo(() => ({ current: v }), [])
}

因此,使用 useMemo 或者是 useRef 都可以用来保持值的引用一致

我们在编写自定义 Hook 时,返回值一定要保持引用的一致性,因为你无法确定外部要如何使用它的返回值,如果返回值被用做其他 Hook 的依赖,并且每次 re-render 时引用不一致(当值相等的情况),就可能就会产生问题,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const useData = () => {
// 获取异步数据
const resp = getAsyncData([])

// 处理获取到的异步数据,这里使用了 Array.map
// 因此即使 data 相同,每次调用得到的引用也是不同的
const mapper = data => data.map(item => ({ ...item, selected: false }))

return resp ? mapper(resp) : resp
}

const Example = () => {
const data = useData()
const [dataChanged, setDataChanged] = useState(false)

useEffect(() => {
// 当 data 发生变化时,调用 setState
// 如果 data 值相同而引用不同,就可能会产生非预期的结果
setDataChanged(prevDataChanged => !prevDataChanged)
}, [data])

console.log(dataChanged)

return <ExpensiveComponent data={data} />
}

在上面的例子中,我们通过 useData Hook 获取了 data,每次 render 时 data 的值没有发生变化,但是引用却不一致,如果把 data 用到 useEffect 的依赖数组中,就可能产生非预期的结果,另外由于引用的不同,也会导致 ExpensiveComponent 组件 re-render,产生性能问题

如果因为 prop 的值相同而引用不同,从而导致子组件发生 re-render,不一定会造成性能问题,因为 Virtual DOM re-render 并不等同于 DOM re-render,但是当子组件特别大时,Virtual DOM 的 Diff 开销也很大,因此还是应该尽量避免子组件 re-render

所以在使用 useMemo 之前,我们不妨先问自己几个问题

  • 要记住的函数开销很大吗?
  • 返回的值是原始值吗?
  • 记忆的值会被其他 Hook 或者子组件用到吗?

下面我们就来看看 useMemo 的具体使用场景

应该使用 useMemo 的场景

  1. 保持引用相等
    • 对于组件内部用到的 object、array、函数等,如果用在了其他 Hook 的依赖数组中,或者作为 props 传递给了下游组件,应该使用 useMemo
    • 自定义 Hook 中暴露出来的 object、array、函数等,都应该使用 useMemo,以确保当值相同时,引用不发生变化
    • 使用 Context 时,如果 Provider 的 value 中定义的值(第一层)发生了变化,即便用了 Pure Component 或者 React.memo,仍然会导致子组件 re-render,这种情况下,仍然建议使用 useMemo 保持引用的一致性
  2. 成本很高的计算
    • 比如 cloneDeep 一个很大并且层级很深的数据

无需使用 useMemo 的场景

  1. 如果返回的值是原始值 string、boolean、null、undefined、number、symbol(不包括动态声明的 Symbol),一般不需要使用 useMemo
  2. 仅在组件内部用到的 object、array、函数等(没有作为 props 传递给子组件),且没有用到其他 Hook 的依赖数组中,一般不需要使用 useMemo

一些好的实践方式

  1. 若 Hook 类型相同,且依赖数组一致时,应该合并成一个 Hook,否则会产生更多开销
1
2
3
4
5
6
7
8
9
10
11
12
13
const dataA = useMemo(() => {
return getDataA()
}, [A, B])

const dataB = useMemo(() => {
return getDataB()
}, [A, B])

// 应该合并为 ==>

const [dataA, dataB] = useMemo(() => {
return [getDataA(), getDataB()]
}, [A, B])
  1. 参考原生 Hooks 的设计,自定义 Hooks 的返回值可以使用 Tuple 类型,更易于在外部重命名,但如果返回值的数量超过三个,还是建议返回一个对象
1
2
3
4
5
6
7
8
9
10
export const useToggle = (defaultVisible: boolean = false) => {
const [visible, setVisible] = useState(defaultVisible)
const show = () => setVisible(true)
const hide = () => setVisible(false)
return [visible, show, hide] as [typeof visible, typeof show, typeof hide]
}

// 在外部可以更方便地修改名字
const [isOpen, open, close] = useToggle()
const [visible, show, hide] = useToggle()
  1. ref 不要直接暴露给外部使用,而是提供一个修改值的方法

  2. 在使用 useMemo 或者 useCallback 时,确保返回的函数只创建一次,也就是说,函数不会根据依赖数组的变化而二次创建,举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const useCount = () => {
const [count, setCount] = useState(0)

const [increase, decrease] = useMemo(() => {
const increase = () => {
setCount(count + 1)
}
const decrease = () => {
setCount(count - 1)
}
return [increase, decrease]
}, [count])

return [count, increase, decrease]
}

在 useCount Hook 中,count 状态的改变会让 useMemo 中的 increase 和 decrease 函数被重新创建,由于闭包特性,如果这两个函数被其他 Hook 用到了,我们应该将这两个函数也添加到相应 Hook 的依赖数组中,否则就会产生问题,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const Counter = () => {
const [count, increase] = useCount()

useEffect(() => {
const handleClick = () => {
// 执行后 count 的值永远都是 1
increase()
}
document.body.addEventListener('click', handleClick)
return () => {
document.body.removeEventListener('click', handleClick)
}
}, [])

return <h1>{count}</h1>
}

在 useCount 中,increase 会随着 count 的变化而被重新创建,但是 increase 被重新创建之后,useEffect 并不会再次执行,所以 useEffect 中取到的 increase 永远都是首次创建时的 increase,而首次创建时 count 的值为 0,因此无论点击多少次,count 的值永远都是 1

既然这样的话,那把 increase 函数放到 useEffect 的依赖数组中不就好了吗?事实上,这会带来更多问题

  • 首先 increase 的变化会导致频繁地绑定事件监听,以及解除事件监听
  • 其次需求是只在组件 mount 时执行一次 useEffect,但是 increase 的变化会导致 useEffect 多次执行,不能满足需求

那么我们如何解决这些问题呢?有下面两种方式

  1. 通过 setState 回调,让函数不依赖外部变量,例如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export const useCount = () => {
const [count, setCount] = useState(0)

const [increase, decrease] = useMemo(() => {
const increase = () => {
setCount(latestCount => latestCount + 1)
}

const decrease = () => {
setCount(latestCount => latestCount - 1)
}
return [increase, decrease]
// 保持依赖数组为空,这样 increase 和 decrease 方法都只会被创建一次
}, [])

return [count, increase, decrease]
}
  1. 通过 ref 来保存可变变量,例如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const useCount = () => {
const [count, setCount] = useState(0)
const countRef = useRef(count)

useEffect(() => {
countRef.current = count
})

const [increase, decrease] = useMemo(() => {
const increase = () => {
setCount(countRef.current + 1)
}

const decrease = () => {
setCount(countRef.current - 1)
}
return [increase, decrease]
// 保持依赖数组为空,这样 increase 和 decrease 方法都只会被创建一次
}, [])

return [count, increase, decrease]
}

总结

最后的最后,我们再来将上面涉及到的内容整体的回顾一下

  • 将完全不相关的 state 拆分为多组 state
  • 如果某些 state 是相互关联的,或者需要一起发生改变,就可以把它们合并为一组 state
  • 依赖数组的值最好不要过多,如果发现依赖数组依赖的值过多,我们应该采取一些方法来减少它
    • 去掉不必要的依赖
    • 将 Hook 拆分为更小的单元,每个 Hook 依赖于各自的依赖数组
    • 通过合并相关的 state,将多个依赖值聚合为一个
    • 通过 setState 回调函数获取最新的 state,以减少外部依赖
    • 通过 ref 来读取可变变量的值,不过需要注意控制修改它的途径
  • 应该使用 useMemo 的场景
    • 保持引用相等
    • 成本很高的计算
  • 无需使用 useMemo 的场景
    • 如果返回的值是原始值,一般不需要使用 useMemo
    • 仅在组件内部用到的 object、array、函数等(没有作为 props 传递给子组件),且没有用到其他 Hook 的依赖数组中,一般不需要使用 useMemo
  • 若 Hook 类型相同,且依赖数组一致时,应该合并成一个 Hook
  • 自定义 Hook 的返回值可以使用 Tuple 类型,更易于在外部重命名,如果返回的值过多,则不建议使用
  • ref 不要直接暴露给外部使用,而是提供一个修改值的方法
  • 在使用 useMemo 或者 useCallback 时,可以借助 ref 或者 setState callback,确保返回的函数只创建一次,也就是说,函数不会根据依赖数组的变化而二次创建
# React

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×