我是靠谱客的博主 虚幻水杯,这篇文章主要介绍React hooks 优化指北,现在分享给大家,希望可以做个参考。

写在开头

  • 阅读之前:希望您知道基础hooks的使用,如useCallback,useReducer等,我并不会过多的介绍文章中出现的hooks。
  • 注释的问题:为了表达的更清楚,在有的React代码片段中,我会添加注释来解释一些东西,为了方便书写,在jsx中我使用的是//而不是{ /*  */ },请忽略这个错误。

从一个简单的useToggle开始

相信大家都使用过复选框或者开关组件,我们实现一个useToggle()让事情变的更简单

复制代码
1
2
3
4
5
6
function App() { const [on, toggle] = useToggle(); //on是状态,toggle切换状态 ... }

简单实现

复制代码
1
2
3
4
5
6
import React from 'react'; export function useToggle(on: boolean): [boolean, () => void] { const [_on, setOn] = React.useState(on); return [_on, () => {setOn(!_on)}] }

现在这个useToggle已经可以投入使用了,有一个小问题不知道各位观察到没有?每次on状态的改变,都会导致我们重新返回新的toggle方法,当然简单使用的话其实并没有什么影响。

尝试优化useToggle

现在我们有了新的需求,尝试优化一下useToggle来实现它。

例如:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
function App() { const [on, toggle] = useToggle(); return( <div> //NeedOn仅需要使用on状态 <NeedOn on ={on}></NeedOn> //Button组件仅负责修改on的状态 <Button toggle = {toggle}></Button> </div> ) }

在上面的例子中,我们有两个组件,NeedOn仅需要使用on状态,Button仅负责修改on的状态。

而现在每一次on的改变都会引起App的重新渲染,进而导致NeedOnButton的重新渲染。现在我们需要在功能不变的情况下使on改变时,Button不再重新渲染。

如何解决呢?首先我们要解决toggle改变的问题,因为一个组件不在重新渲染的基础条件之一就是它的props不再改变。我们可以使用useCallback()来解决这个问题。

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react'; export function useToggle(on: boolean): [boolean, () => void] { const [_on, setOn] = React.useState(on); const _toggle = React.useCallback(() => { setOn(!_on); },[]) return [_on,_toggle]; } //现在我们使用了useCallback来缓存_toggle。 //由于传入数组为空_toggle再也不会被更新了,现在我们再也不用担心Button组件进行多余的渲染了。 // -> -> -> 如果您觉得这段代码没问题,那您可能需要重新学习一下hooks了

上述代码确实没有渲染的问题了,但是出现了一个更加严重的问题:代码逻辑错误

我们浅浅的测试一下。

在线测试

复制代码
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
//测试代码,可跳过 //默认on为true,调用4次toggle,期望结果为:[true, false, true, false, true] function useToggle(on = true){ const [_on, setOn] = React.useState(on); const _toggle = React.useCallback(() => { setOn(!_on); }, []) return [_on, _toggle]; } const values = []; const App = () => { const [on, toggle] = useToggle(true) const renderCountRef = React.useRef(1) React.useEffect(() => { if (renderCountRef.current < 5) { renderCountRef.current += 1 toggle() } }, [on]) values.push(on) return null } setTimeout(() => {console.log(values)},1000); ReactDOM.render(<App/>,document.getElementById('root'))

我们期望的结果是[true, false, true, false, true],实际结果是: [true,false,false]。我们先来关注逻辑错误,暂时忽略结果的长度异常。

capture value引起的逻辑错误

这个逻辑错误是因为hooks的capture value特性,什么是capture value,有点类似于js的闭包。你可以认为每次组件render的时候,都是一个独立的快照,会有独属于它自己的”作用域”。

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Count(){ //count为一个常量,每次render的count都是独立的 //第一次点击,count:0 //第二次点击,count:1 //第三次点击,count:2 const [count,setCount] = React.useState(0); setTimeout(() => { console.log(count); //这里始终输出对应的值而不是输出最新的值 //假如说你回调触发之前,2秒内点击三次button,之后3次回调函数依次触发:依然输出0,1,2而不是2,2,2 },2000) return ( <div> <span>{count}</span> <button onClick= {() => {setCount(count + 1)}}>增加?</button> </div> ) }

现在,我们已经知道错误的原因了:由于_toggle方法不会被更新,该方法引用外部的常量on一直为默认值即true,后续_toggle所有的调用都是重复把true变为false。

那该怎么解决呢?在useCallback里传入正确的依赖项?

复制代码
1
2
3
4
5
6
7
8
9
10
import React from 'react'; export function useToggle(on: boolean): [boolean, () => void] { const [_on, setOn] = React.useState(on); const _toggle = React.useCallback(() => { setOn(!_on); },[_on]) //在数组中传入_on,这样每次_on改变的时候,_toggle也会改变。 return [_on,_toggle]; }

这样的话,和我们一开始的写法本质上是没有区别的,也会有重复渲染的问题。

使用useCallback及通过函数来更新state

其实只需要在useState中使用函数来更新:

复制代码
1
2
3
4
5
6
7
8
9
10
11
import React from 'react'; export function useToggle(on: boolean): [boolean, () => void] { const [_on, setOn] = React.useState(on); const _toggle = React.useCallback(() => { setOn(_on => !_on); //现在我们在setOn内传入函数,函数内的_on每次都是最新的。 //同时,依赖数组是空的,这也意味着_toggle是不会更新的。 },[]) return [_on,_toggle]; }

现在,我们已经写出了一个不错的hooks,我们使用useCallback来缓存toggle,这样当on改变的时候,toggle并不会改变,即Button组件的props不会改变,那么Button也不会再重新渲染了,是这样吗?

使用useReducer

我们都知道useReducer的dispatch是不会改变的,那我们可以在useToggle内部使用useReducer来通过返回dispatch的方式来达到目的。

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
import React from 'react'; export function useToggle(on = true) { function reducer(state,action){ switch(action.type){ case 'toggle': return !state; default: throw new Error(); } } const [_on, dispatch] = React.useReducer(reducer,on); return [_on,dispatch]; }

优化结束了吗?

很不幸,如果只优化useToggle并没有什么用。在线代码:优化useToggle

因为当父组件渲染时,子组件一定会重新渲染,无论子组件的props是否改变。

因此除了对useToggle进行优化外,我们还要对Button进行缓存,使用useCallback的好兄弟useMemo来实现。

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function App() { const [on, toggle] = useToggle(); const MyButton = useMemo(() => { return <Button toggle = {toggle}></Button> },[]) return( <div> //NeedOn仅需要使用on状态 <NeedOn on ={on}></NeedOn> //由于没有在useMemo中传入依赖,MyButton不会改变 {MyButton} </div> ) }

现在,我们才算完成了最初的需求,回顾一下步骤:

  • 我们优化了useToggle,用useCallback缓存内部的toggle函数,使on改变时,toggle不会改变。
  • 基于优化后的useToggle,我们又使用useMemo对Button进行了缓存,这样当on改变时,虽然会导致App重新渲染,但不会再引起Button的重新渲染。

在线代码:优化完成

useMemo之前

其实大多数情况下,并不需要做上面这样优化,因为对性能提示并没有太大的帮助,而且频繁的使用useMemo和useCallback还会加重心智负担。所以当你要使用useMemo来减少某一个具体组件的重复渲染之前,可以先思考一下是否有使用它的必要。

下面这个例子可能会出现在各位的代码中。

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function ExpensiveTree() { let now = performance.now(); while (performance.now() - now < 100) { // Artificial delay -- do nothing for 100ms } return <p>I am a very slow component tree.</p>; } function App() { const [on, toggle] = useToggle(); return( <div> //NeedOn仅需要使用on状态 <NeedOn on ={on}></NeedOn> //Button组件仅负责修改on的状态 <Button toggle = {toggle}></Button> <ExpensiveTree/> </div> ) }

还是最开始的例子,不同的是我们现在的目标是阻止ExpensiveTree的重新渲染。简单的使用useMemo就能达到目的。但除此之外呢?

下沉state

ExpensiveTree重新渲染是因为App重新渲染,那我们试着来直接避免App的重新渲染。

App重新渲染是因为on(state)的改变。因此我们把NeedOnButton抽离就可以了,或者说把useToggle(useState)下放到子组件中。

复制代码
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
function ExpensiveTree() { let now = performance.now(); while (performance.now() - now < 100) { // Artificial delay -- do nothing for 100ms } return <p>I am a very slow component tree.</p>; } function Toggle(){ const [on, toggle] = useToggle(); return( //NeedOn仅需要使用on状态 <NeedOn on ={on}></NeedOn> //Button组件仅负责修改on的状态 <Button toggle = {toggle}></Button> ) } function App() { return( <div> <Toggle/> <ExpensiveTree/> </div> ) }

现在Toggle会重新渲染,而App和ExpensiveTree则不会。

提升内容

但向下面这种情况,我们好像并不能将useToggle下沉。因为我们要基于on的状态来改变样式。

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function ExpensiveTree() { let now = performance.now(); while (performance.now() - now < 100) { // Artificial delay -- do nothing for 100ms } return <p>I am a very slow component tree.</p>; } function App() { const [on, toggle] = useToggle(); return( //现在我们需要根据on的状态来改变样式 <div style={on ? {color: 'red'} : {color: 'black'}> <NeedOn on ={on}></NeedOn> <Button toggle = {toggle}></Button> <ExpensiveTree/> </div> ) }

该怎么做呢?

复制代码
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
function ExpensiveTree() { let now = performance.now(); while (performance.now() - now < 100) { // Artificial delay -- do nothing for 100ms } return <p>I am a very slow component tree.</p>; } function Toggle({children}){ const [on, toggle] = useToggle(); return ( <div className={on ? 'white' : 'black'}> <NeedOn on ={on}></NeedOn> <Button toggle = {toggle}></Button> {children} </div> ) } function App() { return( <Toggle> <ExpensiveTree/> </Toggle> ) }

我们抽离出一个Toggle组件,然后通过传入ExpensiveTree的方式来达到目的。

在线代码:提升内容

因为当on改变,Toggle重新渲染的时候,我们通过App传入的ExpensiveTree是不会变化的。现在我们既可以通过on来修改样式,也避免了ExpensiveTree的重新渲染。

以上,当我们使用React的时候,可以小小的关注一下某些不必要的“昂贵”的组件的重新渲染,是否可以通过一些简单的处理来避免掉。

bail out导致的长度异常

在上面的一个例子中由于capture value的特性,导致逻辑出现异常,但除此之外结果[true,false,false]的长度也与实际情况有些出入。

bailing out of a state update,该特性在官网上被提到过。

即如果你更新的state和当前的state是”相同”的话,就会导致bail out,该组件的子孙组件不会重新渲染且该组件useEffect不会被触发。

“相同”指,Object.is(nextState,curState)返回true。Object.is为浅比较.

重新回顾一下上面的测试代码。

复制代码
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
26
function useToggle(on = true){ const [_on, setOn] = React.useState(on); const _toggle = React.useCallback(() => { setOn(!_on); }, []) return [_on, _toggle]; } const values = []; const App = () => { const [on, toggle] = useToggle(true) const renderCountRef = React.useRef(1) React.useEffect(() => { if (renderCountRef.current < 5) { renderCountRef.current += 1 toggle() } }, [on]) //初始化时,推入true //初始化后useEffect执行 -> 第一次触发toggle,修改on值为false //第一次toggle触发后useEffect执行 -> 第二次触发toggle,经react检测,Object(false,false)为true,bail out. values.push(on) return null } setTimeout(() => {console.log(values)},1000); ReactDOM.render(<App/>,document.getElementById('root'))

为什么useEffect执行两次,而values.push推入三次?

不知道大家有这个疑问没有,首先这个问题其实上面提到过:

如果你更新的state和当前的state是”相同”的话,就会导致bail out,该组件的子孙组件不会重新渲染且该组件useEffect不会被触发。

具体来解释的话就要再深入一点点,我们应该知道,目前react采用fiber架构,而fiber的更新是分为两个阶段的,即render和commit阶段。对于类组件来说大部分生命周期在commit阶段触发(带will的生命周期在render中触发),对于hooks来说,useEffect(包括useLayoutEffect)也在commit阶段中触发。在render阶段我们对比jsx对象与旧fiber,并将变化记录到effectList链表中.而为了确保是否真的应该bail out(我们知道react有批处理,即多个同步的setState会被合并,所以只看单个setState的话是无法确保这次更新是否应该bail out),而在React reRedener的时候,这多个setState被链式的储存在fiber节点的updateQueue属性上。react会在render阶段通过updateQueue链式的计算最后的state并将结果储存到fiber的memoizedState属性上。在此时进行对比,才能决定是否应该bail out。

而在我们的测试代码中,values.push在render阶段触发,所以它会被触发3次,而useEffect不在render阶段,不会触发第3次。

这两个链接可以帮助你理解这个问题。

Why React needs another render to bail out state updates?

useState not bailing out when state does not change #14994

参考

https://www.developerway.com/posts/how-to-write-performant-react-code

最后

以上就是虚幻水杯最近收集整理的关于React hooks 优化指北的全部内容,更多相关React内容请搜索靠谱客的其他文章。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(85)

评论列表共有 0 条评论

立即
投稿
返回
顶部