likes
comments
collection
share

React hooks优化指北

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

写在开头

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

从一个简单的useToggle开始

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

function App() {
  const [on, toggle] = useToggle();
  //on是状态,toggle切换状态
  ...
}

简单实现

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来实现它。

例如:

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()来解决这个问题。

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了

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

我们浅浅的测试一下。

在线测试

//测试代码,可跳过
//默认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的时候,都是一个独立的快照,会有独属于它自己的”作用域”。

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里传入正确的依赖项?

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];
}

这样的话,和我们一开始的写法本质上是没有区别的,当on改变时,也会返回新的toggle。

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

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

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的方式来达到目的。

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来实现。

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来减少某一个具体组件的重复渲染之前,可以先思考一下是否有使用它的必要。

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

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)下放到子组件中。

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的状态来改变样式。

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

该怎么做呢?

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为浅比较.

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

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

参考

www.developerway.com/posts/how-t…