关于 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 也会随之更新,因此把 topleft 拆分为两个 state 变量显得有点多余

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

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

  • 将完全不相关的 state 拆分为多组 state,比如 sizeposition
  • 如果某些 state 是相互关联的,或者需要一起发生改变,就可以把它们合并为一组 state,比如 lefttop
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])
}

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

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

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

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 的回调函数依赖了 idrefresh 方法,但是我们观察 refresh 方法可以发现,它在首次 render 被创建之后,就永远不会发生改变了,因此把它作为 useEffectdeps 是多余的

其次,如果这些依赖真的都是需要的,那么这些逻辑是否应该放到同一个 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.mapArray.forEach 等,如果你执行的操作开销不大,那么就不需要记住返回值,否则使用 useMemo 本身的开销就可能超过重新计算这个值的开销,因此对于一些简单的 JavaScript 运算来说,我们不需要使用 useMemo 来记住它的返回值

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

在上面的例子中,就是当 pagetype 相同时,resolvedValue 的引用是否会发生改变?这里我们就需要考虑 resolvedValue 的类型了,如果 resolvedValue 是一个对象,由于我们项目上使用『函数式编程』,每次函数调用都会产生一个新的引用,但是如果 resolvedValue 是一个原始值(stringbooleannullundefinednumbersymbol),也就不存在『引用』的概念了,每次计算出来的这个值一定是相等的,也就是说 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 重新渲染(可能会带来较大开销)

虽然在 ReactuseRefuseMemo 的实现有一点差别,但是当 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,每次 renderdata 的值没有发生变化,但是引用却不一致,如果把 data 用到 useEffect 的依赖数组中,就可能产生非预期的结果,另外由于引用的不同,也会导致 ExpensiveComponent 组件 re-render,产生性能问题

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

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

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

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

应该使用 useMemo 的场景

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

无需使用 useMemo 的场景

  1. 如果返回的值是原始值 stringbooleannullundefinednumbersymbol(不包括动态声明的 Symbol),一般不需要使用 useMemo
  2. 仅在组件内部用到的 objectarray、函数等(没有作为 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 中的 increasedecrease 函数被重新创建,由于闭包特性,如果这两个函数被其他 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
    • 仅在组件内部用到的 objectarray、函数等(没有作为 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

×