2

前言

这篇文章旨在总结 React Hooks 的使用技巧以及在使用过程中需要注意的问题,其中会附加一些问题产生的原因以及解决方式。但是请注意,文章中所给出的解决方式并不一定完全适用,解决问题的方案有很多种,也许你所在的团队针对这些问题已经给出了对应的规范,亦或是你已经对这些问题的解决方式形成了更好的认知。所以你的着重点应该放在 你是否在使用 React hooks 过程中意识到了这些问题 以及 你对这些问题的思考

useState hook

初始化状态

如果组件的某个状态需要依靠大量计算得到初始值,一般我们会定义一个函数来初始化状态

  • 在 class 组件中

    state = {
     state1: calcInitialState()
    }

    没什么问题,在组件不被重新挂载的情况下,即使组件多次重新渲染,calcInitialState 也只会被执行一次

  • 而在函数组件中, 有两种方式

    const state1 = useState(calcInitialState) // 组件多次渲染时,calcInitialState 仅会被执行一次
    const state1 = useState(calcInitialState()) // 组件每次渲染时,calcInitialState 都会被执行

    函数组件每次重新渲染,都会执行函数组件本身,在第一次渲染时,useState 会读取初始值,如果是初始值函数,则会被执行,并且函数的返回值被作为初始状态,此时,这两种写法表现相同。但是在后续重新渲染过程,useState 虽然不会读取默认状态值,也不会对默认状态值做任何处理,但是第二种写法中的 calcInitialState 仍然会被执行,且是毫无意义的。内部运行流程见源码 mountStateupdateState

状态的捕获方式 (this & 闭包)

前段时间,我在团队内部分享了常用的 React Hooks 原理以及源码,当时我提到了,不论是 class 组件还是函数组件, 他们的状态都存储在组件对应的 fiber 上。函数组件 和 class 组件状态更新的流程如下图所示:

file

详细可见源码 updateClassInstanceupdateReducer

对于渲染的那一部分(JSX)来说,可以一直拿到最新的的状态。只是获取状态的方式不同,class 组件是通过 this.state 指向 fiber 节点上存储的状态,函数组件则是通过 useState 这个函数的返回值获取,那么问题在于函数组件拿到的状态是存储在闭包中的,这个闭包由 useState 执行产生。

换个角度来说,对于函数组件,我们需要特别重视“渲染”这个概念。函数组件每次渲染,其内部声明的函数或者是返回的 UI(JSX) 都只能捕获到当前这次渲染的 props 和 state,这对于 UI(JSX) 来说完全没有问题。 但是对于函数组件内部的函数,特别是延迟回调的函数,需要特别注意等到回调函数执行时,回调函数中捕获的 state 和 props 是否是你所期望的。

想要避免函数组件中闭包问题带来的困扰,需要理解并记住下面两句话

  • 函数组件每一次渲染都有它自己的 props 和 state
  • 函数组件每一次渲染都有它自己的事件处理函数

状态粒度

状态粒度过细

在编写 class 组件时,几乎不用考虑状态粒度的问题,因为开发者总是可以一次性声明所有状态或者一次性更新所有状态,就像这样

handleClick = () => {
    this.setState({
    currentPage: 2,
    pageSize: 20,
    total: 100
  })
}

大多数开发者并不会蠢到使用三次 setState 去更新这三个状态,但是在函数组件中只能这样

const handleClick = () => {
  setCurrent(2)
  setPageSize(20)
  setTotal(100)
}

看到这段代码,嗯,可能会让人感觉到有点不舒服,问题就出在更新粒度过细,事实上一个分页组件的 currentPage,pageSize,total 经常会需要同时被更新,但是多次触发 setXXX 还是会让人感到隐隐的不安,即使多次触发更新可能会被 React 的 batchUpdate 机制合并为一次,但是当 setXXX 方法执行脱离了 React 的上下文时会触发多次更新,例如异步结束时的回调中。

此时,我们可以在 useState 中存储一个对象,将相关联的状态放在一起。也可以使用 useReducer 来管理多个状态。

状态粒度过粗

当一个 state 有一定的复杂度的时候,我并不推荐暴力的将 class 组件声明 state 的方式硬生生塞到 useState 中,因为这也许会将 class 中 state 的粒度过粗的缺陷引入进来

问题一:难以发现可以被复用的状态逻辑

当一个组件的状态越来越多,组件的可读性和可维护性就会越来越差,不少人应该都深有体会, 就像这样:

ps :截取自真实的业务代码

class XXX extends React.Component{
  constructor(props: any) {
    super(props);
    this.state = {
      tableListMap: {},
      showPreview: false,
      showRegModal: false,
      dataSource: [],
      columns: [],
      tablePartitionList: [],
      incrementColumns: [],
      loading: false,
      isChecked: {},
      isShowImpala: false,
      tableListSearch: {},
      schemaList: [],
      fetching: false,
      tableListLoading: false,
      bucketList: [],
      showPreviewPath: false,
      previewPath: '',
      currentObject: { object: [''], index: 0, bucket: '' },
      isCompressed: false,
      matchType: null
    };
  }
}

试想一下,在函数组件中,一个 useState 里面被塞进如此多的状态,且不谈能否发现其中可复用的状态和逻辑,即便你慧眼如炬,发现了它跟其他组件之间有可以复用的状态和逻辑。大概率,也很难在保证在当前组件(历史代码)不会出问题的情况下将可以复用的状态逻辑提取出来。

问题二:将无关的状态放在同一个 useState 中可能让状态更新变得不好控制

举个例子,假如页面上有个按钮,当点击这个按钮时,需要同时从不同的接口中拿到两份数据并渲染到页面上,此时如果这两份数据被存放在同一个 useState 中

function DataViewer (props) {
    const [dataMap, setDataMap] = useState({ data1: undefined, data2: undefined })
  
  const loadData1 = async () => {
    if (visible) {
      const data1 = await fetchData1()
      setDataMap({ ...dataMap, data1 })
    }
  }

  const loadData2 = async () => {
    if (visible) {
      const data2 = await fetchData2()
        setDataMap({ ...dataMap, data2 })
    }
  }
  
  const handleClick = () => {
    loadData1()
    loadData2()
  }
 // ...
}

问题很明显,只要两个请求都完成,无论成功还是失败,dataMap 中都不会有先完成的那个请求返回的数据。

在 class 组件中,基本上是不会出现这种问题的,因为总是可以通过 this 拿到当前的最新的状态,不会出现多次更新中状态覆盖的问题。当然在函数组件中,也可以使用 useRef 暂存接口数据,然后一起更新状态,但需要额外写一些逻辑,这里就不介绍这种黑科技了。

此时第一个解决办法是,将第一个接口返回的数据用变量暂存起来等到第二个接口完成再去更新 dataMap,就像这样

const loadDataMap = async () => {
  if (visible) {
     const data1 = await fetchData1()
     const data2 = await fetchData2()
     setDataMap({ data1, data2 })
  }
}

这样做带来的问题是,一定要等第一个请求完成,才能去发起第二个请求,对于用户体验来说并不友好。

好吧,想要两个接口并行,还可以使用 Promise.allSettled  并行的处理两个请求

const loadDataMap = () => {
  if (visible) {
     Promise.allSettled([ fetchData1(), fetchData2() ])
        .then(results => {
        //...
     })
  }
}

看起来好像没什么问题了。但是,如果对接口的状态、返回值等有额外的处理逻辑时,你就需要将所有的接口的处理逻辑都塞到 .then 的回调中,并且这种方法一定要两个接口都完成才能更新状态然后在页面中展示数据,也无法单独的检测到其中的某一个接口是否处于 pending 状态,这种方式似乎也不是那么友好。

这样看来比较完美的解决方式只有两种

  1. 避开闭包, 你只需要在更新状态时传入一个函数就可以了, 就像这样

    setDataMap(dataMap => ({ ...dataMap, data2 }))
  2. 状态切分

    const [ data1, setData1 ]  = useState()
    const [ data2, setData2 ]  = useState()

解决问题的方式往往不止一种,你需要根据实际的业务情况自行去选择你认为更加合适的方式。

如何设计状态粒度

官方文档的 QA 中如是说道

把所有 state 都放在同一个 useState 调用中,或是每一个字段都对应一个 useState 调用,这两方式都能跑通。当你在这两个极端之间找到平衡,然后把相关 state 组合到几个独立的 state 变量时,组件就会更加的可读。如果 state 的逻辑开始变得复杂,我们推荐 用 reducer 来管理它,或使用自定义 Hook。

个人认为,聚合相关的状态,拆分无关的状态,是一种比较好的实践方式。比如将分页器组件的  currentPage、pageSize、total  三个状态放在同一个 useState 中,将不同请求的返回的数据拆分到不同的 useState 中。另外还有一些情况是,状态的逻辑比较复杂,这个时候也可以使用 useReducer 来管理状态,这样就可以将一些复杂的逻辑抽离到 reducer 中。

状态更新的两种方式

不论是函数组件还是 class 组件,更新状态的方式都有两种: setState(newState)   和  setState(oldState => newState),它们之间的差异在于,一个注重结果,一个注重目的。 setState(newState)  用于描述新的状态,而 setState(oldState => newState) 用于描述新的状态与旧的状态相比应当做出什么样的改变。

这样说可能有点抽象,简单来说 setState(newState)  是用新的状态替换掉旧的状态,  setState(oldState => newState) 是用来通过旧的状态计算出新的状态

useEffect hook

为什么需要 useEffect

从理论上讲函数组件就是单纯的用来渲染的,也就是所谓的纯函数,事实上没有 React Hooks 之前也确实是这样的。而其他的操作如数据获取,设置定时器,修改 DOM 等都被称作副作用。

为什么不能直接在函数组件内直接执行副作用?

  • 有一些副作用操作可能会影响到渲染,如修改 DOM
  • 有一些副作用操作是需要清除的,如定时器
  • 如果直接在函数组件内部直接进行副作用操作,那么函数组件每次重新渲染时都会执行这些操作,没法控制这些操作何时执行何时不执行

useEffect 怎样解决这些问题?

  • useEffect 包裹的函数会在浏览器渲染完成之后执行,保证不会影响到组件的渲染。另外被 useEffect 包裹的函数执行脱离了函数组件本身的执行上下文,所以不会对函数组件本身的执行造成影响
  • useEffect 包裹的函数可以 return 一个函数,用于清除副作用
  • useEffect 可以传入依赖项数组,当依赖项变化时才去执行副作用操作

useEffect 的这些特性有点像事件回调,只不过事件回调函数的触发依靠 dom 事件如点击、输入等,而 useEffect 包裹的函数出发依靠依赖项的变化。很多时候,将一些副作用操作放到事件回调函数中去执行是更好的选择,这样就可以不用考虑 useEffect 依赖项的问题了。

useEffect 是如何捕获 props 和 state 的

useEffect 包裹的函数中,捕获 props 和 state 的方式跟普通函数没两样,依赖于函数组件本身的执行上下文。useEffect 内部并没有做什么如数据绑定、依赖 fiber 等特别的事情。因此,函数组件每一次渲染都有它自己的 effects。在函数组件渲染完成后,产生的 effects 会被存储到组件对应的 fiber 上,等待特定的时机执行这些 effects (副作用)。 即便是 effects 中某些异步回调执行时,页面已经重新渲染了很多次了,这些异步的回调函数中捕获的 props 和 state 还是产生这些 effects 的那次渲染中组件的 state 和 props。

useEffect 的依赖项

哪些应该被放在 useEffect 的依赖项中

理论上来说。useEffect 的心智模型更接近于 effect 在某些值变化时去执行,但是有的时候为了保证 effect 中捕获的 props 和 state 是你所期望的,你不得不将 effect 中用到的所有的组件内的变量都放到依赖项中。如果你在项目中设置了对应的 lint 规则,lint 工具也会告诉你应该这样做,但是这好像与 useEffect 的心智模型产生了一些冲突。

这种冲突带来的后果是,effect 可能会频繁的执行,如下例是一个每秒递增的计数器

function Counter () {
  const [count, setCount] = useState(1)
  useEffect(() => {
    const timerId = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(timerId);
  }, [count]);
  
  return (
      <>{count}</>
  )
}

如果移除了这个 useEffect 的依赖项中的 count,那么定时器中的回调函数就会一直执行 setCount(1 + 1),这不是我们所期望的。好吧,老实一点,将 count 放到依赖项中,但是此时定时器会被频繁的清除和创建,这可能会影响定时器回调的触发频率,这也不是我们所期望的。

到现在为止,问题还是没有得到解决,我还是倾向于 useEffect 的依赖项是用来触发 effect 的,而不是用来解决闭包问题的,那么只能想办法移除掉 useEffect 对 count 的依赖。

如何减少 useEffect 的依赖项

  • 消除 useEffect 中不必要的捕获
    如上例中的 useEffect 可以写成

    useEffect(() => {
      const timerId = setInterval(() => {
     setCount(count => count + 1);
      }, 1000);
      return () => clearInterval(timerId);
    }, []);
  • 将依赖从 effect 中解耦
    还是这个定时计数器的例子,如果我们想要通过 props 传递一个 step 属性给这个组件,用来告诉这个组件每秒递增的值的大小
<Counter step={2}/>

于是 Counter 组件就变成了这样

function Counter ({step}) {
  const [count, setCount] = useState(1)
  useEffect(() => {
    const timerId = setInterval(() => {
      setCount(count => count + step);
    }, 1000);
    return () => clearInterval(timerId);
  }, [step]);
  
  return (
      <>{count}</>
  )
}

现在的问题是,当 step 的值变化时,仍然会重启定时器。现在该 useReducer 上场了。

function Counter ({step}) {
  const [count, dispatch] = useReducer(reducer, 0);

  function reducer(state, action) {
    if (action.type === 'tick') {
      return state + step;
    } else {
      throw new Error('type in action is not true');
    }
  }

  useEffect(() => {
    const timerId = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(timerId);
  }, []);
  
  
  return (
      <>{count}</>
  )
}

那么现在问题来了:
Q:_为什么我们可以不在依赖项中加入 _dispatch_ ?_
A:因为 React 向我们保证了 _dispatch_ (来源于useReducer)setState (来源于useState)以及ref.current (来源于useRef)即使组件多次渲染,它们的引用地址也不会改变
Q:_为什么 reducer 中捕获到的 step 值是最新的?_
A:对于 useReducer 来说,React 只会记住它的 action,并不会记住它的 reducer。也就是说每次组件重新渲染执行 useReducer 时,它都会重新读取 reducer
上文中说过 useStateuseReducer 在源码中是同一个东西。在实际使用过程中,相比于 useState ,useReducer 可以让我们把 更新逻辑(reducer)描述发生了什么(action)分开 ,而这一点正好可以用来移除不必要的依赖。

当然实际业务中,很少会碰到上述例子中的场景,绝大部分情况下,都只用将想要用来触发执行 effect 的值放到 useEffect 的依赖项中。对于无法用上面讲的两种方法解决的场景,也可以通过 useRef 来绕过烦人的闭包问题。

函数应当作为 useEffect 的依赖项吗

个人观点是:绝大多数情况下,不应该将函数作为 useEffect 的依赖项。至于能否安心的将函数从依赖项中移除主要看

  • 函数是否参与 React 的数据流
    简单来说,就是看这个函数中是否用到了函数组件内部的变量(useRef 除外)。如果一个函数并没有参与 React 数据流,但是在 useEffect 中用到了,此时你应该将这个函数提取到组件外部,这样你就可以在 useEffect 的依赖项中无脑移除掉这个函数。
  • 函数是否被异步延迟调用
    函数被延迟调用的情况下很容易产生闭包问题,这时即使将函数作为 useEffect 的依赖项,也无法解决闭包问题,反而可能增加 effect 的触发频率,下文中的 使用可变数据替代不可变数据 一节会介绍一种方法能够保证在函数引用地址不变的情况下,使函数自动捕组件内变量最新的值的方法。

大多数情况下都可以不用将函数放在 useEffect 的依赖项中。也许有一些极端特殊的业务场景,这时只能将函数用 useCallback 包裹,然后放到 useEffect 的 依赖项中 (我目前没碰到过这种情况)

useRef hook

按照我个人的理解,useRef 更像是 class 组件的实例属性,即 this.xxx。在函数组件中,useRef  可以看做是一个容器,你可以任意操作这个容器中的数据,并且这个容器中的引用地址不会因为组件多次重新渲染而改变。在我看来它就是函数组件的作弊器同时也是解决函数组件中闭包问题的绝世利器。

useRef 的特征

  1. 当 useRef  存储的值变化时,并不会引起组件重新渲染
  2. 可以用来存放可变数据,在组件多次渲染时,能保持 ref (useRef 返回值)本身的引用地址不变

ps: useRef 能保证返回值引用地址不变的原因是,即使组件多次渲染,useRef 返回的 ref 还是第一次执行时返回的那个 ref。在函数组件第一次渲染时, React 内部会将 useRef 的返回值(ref)存储在组件对应的 fiber 节点上,后续组件重新渲染时,React 内部不会对 useRef 做任何处理,直接返回  fiber 节点上存储的 ref。详情可见源码 mountRef  和 updateRef

基于 useRef 的特征可以做什么

  • 实现一个自定义 hook 用来统计组件的渲染次数

    const useRenderTimes = () => {
      const ref = useRef(0)
      ref.current += 1
      return ref.current
    }
  • 记录上一次组件渲染时的某个值

    const usePreValue = (value) => {
      const ref = useRef(undefined)
      const preValue = ref.current
      ref.current = value
      return preValue
    }
  • 避开不必要的重新渲染
    如果某一个状态与渲染无关,那么你可以使用 useRef 代替 useState 。还记得上述 useEffect 那一节中的 Counter 组件吗,如果将 count 作为 useEffect 的依赖项,那么定时器会不停的创建/销毁,上面给出了两种解决方法,现在我们来说说另一种方式。思路是,只要在 count 变化时,不重新渲染组件就好了, 那么可以使用 useRef 存储 count 值,当然,这种方式的仅限于 count 不参与渲染的情况,或者也可以在 useRef 中存储值改变的同时去触发组件重新渲染
    这样的场景其实很常见,比如有一个表单,当用户在表单中填写完成后,点击 submit 按钮,将数据通过接口发送给后端。如果这个表单不是一个受控组件,那么相比于 useState 用 useRef 存储表单数据是个更好的选择,因为它不会导致不必要的重新渲染

    function Counter () {
      const count = useRef(1)
      useEffect(() => {
     const timerId = setInterval(() => {
       count.current += 1;
     }, 1000);
     return () => {
       clearInterval(timerId)
       console.log(count.current)
     };
      }, []);
    }

有时在函数组件中使用 useState 是为了在组件重新渲染之后仍然能拿到某个值,但我们希望让这个值变化时不要触发组件更新,亦或是想避免 useState 的不可变数据导致的闭包问题,那么这个时候就是使用 useRef 的时机。

如何看待 useRef

在我看来在 React Hooks 中 useRef 最起码与 useState是同等重要的,知乎有篇文章中的一句话这样说,

每一个希望深入 hook 实践的开发者都必须记住这个结论,无法自如地使用 useRef 会让你失去 hook 将近一半的能力。

表示认同。

useCallback Hook

关于 useCallback,官网上的介绍是

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

又看到了熟悉的词汇-“依赖项”,要想保证在 useCallback 中包裹的函数捕获到当前渲染时函数组件内部的值,必须将 useCallback 包裹的函数中所有引用到的函数组件内部的值都放到依赖项中。另外,请注意官网介绍的 useCallback 的作用是- “性能优化”。

你真的需要为函数组件中的每一个函数都包裹上 useCallback 吗? 就拿官方文档中的 shouldComponentUpdate 举例,我们在函数组件中定义了一个函数 handleClick 并用 useCallback 包裹,然后通过 props 传递给子组件,子组件中通过shouldComponentUpdate 对比 handleClick ,决定是否需要更新。

function Parent () {
  const handleClick = useCallback(()=>{
    //...
  },[...])
  
  return (<Child handleClick={handleClick}/>)
}

class Child extends React.Component {
  shouldComponentUpdate(nextProps) {
    return this.props.handleClick !== nextProps.handleClick
  }
  // ...
}

那么此时应该有一个疑问,性能提升到底有多大,如果你感兴趣的话,不妨动起手来,写个示例,对比一下 performance 性能面板,你应该看到 useCallback 对性能提升到底有多大,同时根据测试结果,可以大概得到什么时候应该用 useCallback 来提升性能。useCallback 的另一个作用是可以维持函数引用地址不变。但是它仍然会在依赖项变化时重新生成函数,想要维持函数引用地址一直不变还要是要使用 useRef

我抵触 useCallback 的原因是,在我看来它本身的作用比较鸡肋,而且使用 useCallback,必须注意依赖项,这又还会带来额外的心智负担。

useMemo Hook

使用 useMemo 时需要注意的点不多,官方文档也写的非常明白了

useMemo 的作用是

把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。

请把着重点放在 “高开销的计算” 上,有的时候,可能也并不需要 useMemo

使用 useMemo  时需要注意的是

你可以把 **useMemo** 作为性能优化的手段,但不要把它当成语义上的保证。将来,React 可能会选择“遗忘”以前的一些 memoized 值,并在下次渲染时重新计算它们,比如为离屏组件释放内存。先编写在没有 useMemo 的情况下也可以执行的代码 —— 之后再在你的代码中添加 useMemo,以达到优化性能的目的。

函数组件中的闭包问题

结合上文,可以总结出在函数组件中,闭包问题主要是因为函数的延迟调用,不论是 useEffect 包裹的函数还是定时器回调函数亦或者是异步请求的回调函数,它们内部捕获到的变量都是存在外部函数组件执行时产生的闭包中,那么想要规避闭包带来的困扰,思路有两个

减少函数内部对外部变量的依赖

比如上述定时计数器例子中

setCount(count + 1)
// 替换为
setCount(count => count + 1)

使用可变数据替代不可变数据

在 class 组件中很少遇到闭包的困扰是因为在 class 组件中访问组件的 state 和 props 都是通过 this,虽然 this.state 和 this.props 指向的是是不可变数据,但是 this 内部存储的数据是可变的并且 this 的引用地址不会发生改变。那么函数组件中有没有类似 this 的东西呢?有, useRef

  • 对于非函数类型,可以使用 useRef 替代 useState
    这样即使是延迟调用的函数,也可以通过 ref.current 取到最新的值,因为延迟调用的函数里面取的是 useRef 返回值的引用地址。上文中的例子中也这样用过了。需要注意的是,如果 useRef 中存储的值参与了渲染,比如

    function demo () {
     const text = useRef("")
      return <>{text.current}</>
    }

这时,更新 useRef 中存储的值,并不会引起视图重新渲染。但是我们可以通过更新另一个状态 (useState) 来使视图同步。如果在组件中实在找不到一个可以在 useRef 内部的值变化时去触发更新的状态,那么也可以写一个自定义 hook 去强制触发更新

function useForceUpdate () {
  const [, forceUpdate] = useReducer(x => x + 1, 0)
  return forceUpdate
}

封装一下,就可以得到一个存储可变数据的 useState

function useMutableState (init) {
      const stateRef = useRef(init)
    const [, updateState] = useReducer((preState, action) => {
          stateRef.current = typeof action === 'function'
            ? action(preState)
            : action
          return stateRef.current
    }, init)

    return [stateRef, updateState]
}
  • 对于函数类型,也可以通过 useRef 保持函数引用地址不变,函数内部自动捕获最新的值

    function useStableFn(fn, deps) {
      const fnRef = useRef();
      fnRef.current = fn;
      return useCallback(() => {
     return fnRef.current();
      }, []);
    }

结语

在 React Hooks 中很难总结出真正完美的最佳实践,就连官方文档和博客上也只是描述了 React Hooks 的心智模型。上文中的有些观点或者示例违背了官方给出的心智模型,不得不承认我是 useRef 的爱好者。但是对于 React Hooks 的实践来说,没有银弹。重要的是,你是否理解 hooks 是如何工作的,以及你有没有自己的避坑指南。

参考链接


袋鼠云数栈UED
277 声望33 粉丝

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。