React Hook之“额外的 Hook”

2,218 阅读12分钟

前言

我参照 React 官网将 React Hook 分为了两种:

  1. 基础 Hook:useStateuseEffectuseContext
  2. 额外的 Hook:useMemouseCallbackuseRefuseImperativeHandleuseReduceruseLayoutEffect

本篇主要介绍的是“额外的 Hook”,关于基础 Hook 可以阅读我的上一篇文章:

《React Hook之useState、useEffect和useContext》

useCallback 和 useMemo

熟悉 React 的小伙伴们对“性能优化”这个词一定很熟悉。当我们在类组件中进行父子组件传值的时候,可以通过shouldComponentUpdatePureComponen进行性能优化,函数组件也可以通过memo实现同样的效果。(详细介绍:传送门

这里我把 useCallbackuseMemo 放在同一个小节里,是因为这两个 Hook 函数都可以用来进行性能优化的,且他们有着相似的用法。

useCallback

useCallback 方法接收两个参数:内联回调函数、依赖项数组。它将返回该回调函数的 memoized 版本(使子组件对它的引用是不变的),且仅在某个依赖项改变时才会更新。

先看一个例子吧。

例子很简单,子组件接收父组件传递的 state 和 方法。

新建 Parent 组件:

import React, {useState, useEffect} from 'react'
import Child from './Child'

function Parent() {
  const [parentTxt, setParentTXT] = useState('parent 初始化')
  const [childTxt, setChildTxt] = useState('child 初始化')

  useEffect(() => {
    console.log('Parent')
  })

  const updateParentTxt = () => {
    setParentTXT('parentText-' + new Date().getTime())
  }

  const updateChildTxt = () => {
    setChildTxt('childTxt-' + new Date().getTime())
  }

  return (
    <div>
      <p>Parent组件:{parentTxt}</p>
      <button onClick={updateParentTxt}>修改 parentTxt</button>
      <br/>
      <Child childTxt={childTxt} updateChildTxt={updateChildTxt}></Child>
    </div>
  )
}

export default Parent

新建 Child 组件:

import React, {useEffect, memo} from 'react'

function Child(props) {
  const { childTxt, updateChildTxt } = props

  useEffect(() => {
    console.log('Child')
  })

  return (
    <div>
      <p>Child组件:{childTxt}</p>
      <button onClick={updateChildTxt}>修改 childTxt</button>
    </div>
  )
}

export default memo(Child)

运行例子:

当点击按钮 “修改 parentTxt” 的时候,控制台会再次输出一遍 “Child” 和 “Parent”,父组件 Parent 重新渲染,而子组件 Child 也进行了重新渲染。

那么这是什么原因导致的呢,这个时候 memo 为什么没有起到作用呢?

原因是当 Parent 组件向 Child 组件传递 updateChildTxt 方法时,Child 组件接收的是这个方法的引用。当 Parent 组件重新渲染后,产生了另一个独立的 updateChildTxt(每一次渲染都有其独立的 state、 props、事件处理函数和effects),而 Child 组件接收的引用地址发生了改变,所以 React 认为 Child 组件也需要重新渲染。

此时我们明确知道,updateChildTxt 方法本身的作用其实没有变化,所以在上面的那种场景下,不希望 Child 也进行重新渲染。本质上就是让 Child 组件对 updateChildTxt 方法的引用保持不变就可以了,这个时候就需要使用 useCallback 方法(需要结合 memo 一起使用)。

使用 useCallback 包裹 updateChildTxt 方法:

import React, {useState, useEffect, useCallback} from 'react'
...
const updateChildTxt = useCallback(() => {
  setChildTxt('childTxt-' + new Date().getTime())
}, [])
...

这里的依赖项数组设置为 [] 表示只执行一次 useCallbackChild 组件就不会重新渲染了。当然我们也可以通过设置依赖项控制方法的更新。

useMemo

当我们理解了 useCallback 后, useMemo 就非常简单了。

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

使用场景:

  1. 如果组件中使用的某个值需要经过高开销的函数计算,我们可以「记住」 它,避免每次组件重新渲染都要去重新计算。
  2. 由于值的引用发生变化,导致组件重新渲染,我们也需要「记住」这个值。

场景一:高开销的函数计算。

这个场景还是很好理解的,就直接看一下官网的例子吧。假设 computeExpensiveValue 方法是一个高开销的函数,我们可以通过 useMemo 对它的返回值进行 记忆,避免每次组件渲染的时候都会重新计算。

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b])

在 a 和 b 不变的情况下,computeExpensiveValue 方法只执行一次,即:memoizedValue 的值不变。从而达到节省性能的目的。

场景二:引用相等。

const listValue = useMemo(() => {
  getList(page, page_size);
}, [page, page_size])

在这个例子中,我们需要思考,如果 page 和 page_size 相同,listValue 的引用会发生改变吗?即:我们需要考虑 listValue 的类型。如果 listValue 是一个对象,由于我们在项目中使用的是 「函数式编程」,函数的每次调用都会产生一个新的引用,所以此时我们需要使用 useMemo「记住」它。如果 listValue 是基本引用类型数据,则引用不会发生变化,也就不需要使用 useMemo

补充:关于场景二的扩展,我们看一下另外一个例子:

const arr = useMemo(() => [1, 2, 3], [])

这行代码的目的很简单,就是保持 arr 值的引用不变,而且在第二个参数中传入的是空数组,它的开销并不大,所以这里使用 useMemo 并不合适。

而最终我们希望看到的仅仅是在组件重新渲染时保持值的引用不变,而不是「记住」一个值。此时我们应该使用 useRef 来实现我们的目的。

const {current: arr} = useRef([1, 2, 3])

所以我的理解是:当在包含高开销的计算的时候,首先考虑 useMemo;如果仅仅是保持值的引用的时候,需要考虑 useMemouseRef(或者其他方式)谁到底更合适。

小结

useCallbackuseMemo 都接收两个参数:函数和依赖项数组。区别是 useCallback 返回一个 memoized 回调函数,useMemo 返回 memoized 值。我们应该在合理的地方进行使用,而不是一味的追求 “性能优化”, 盲目地使用,有时候反而会适得其反。

useRef 和 useImperativeHandle

上面一小节中提到了 useRef,那我们就先来详细看一下 useRef 到底可以用来做哪些事情吧。

useRef

const refContainer = useRef(initialValue)

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。

在函数组件中,我们都使用过 ref 这一种访问 DOM 的主要方式。如果你将 ref 对象以 <div ref={myRef} /> 形式传入组件,则无论该节点如何改变,React 都会将 ref 对象的 .current 属性设置为相应的 DOM 节点。

useRef 可以很方便地保存任何可变值,且它会在每次渲染时返回同一个 ref 对象。 这也就是在上面一小节中,我们可以通过 const {current: arr} = useRef([1, 2, 3]) 来保持 arr 的值引用不变的原因。

我们再看另一个应用场景:

优化依赖。我们在使用 Hooks 的时候经常会遇到依赖方面的问题,例如下面这个例子:

function Example ({ fn, propA, propB, propC }) {
  useEffect(() => {
    fn(propA, propB, propC)
  }, [])

  return (
    <div></div>
  )
}

这个例子可能还不能完全符合实际应用中的场景,但我们可以清楚的知道它的目的:只在 Example 组件第一次渲染的时候,执行 fn(propA, propB, propC)

如果就直接这样写,我们会收到来自官方的 ESLint Hooks 插件的警告:缺少依赖项。当然我们也可以通过使用 eslint-disable 注释这种简单粗暴的方式避免这个警告。

useEffect(() => {
    fn(propA, propB, propC)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

但显然上面这个方法不是最合适的。

这个时候我们就可以通过 useRef 保持对初始的 props 值的引用,以此来避免例子中的 useEffect 缺少依赖项而发出警告。

function Example ({ fn, propA, propB, propC }) {
  const initProps = useRef({
    fn,
    propA,
    propB,
    propC,
  })

  useEffect(() => {
    const { fn, propA, propB, propC } = initProps.current
    fn(propA, propB, propC)
  }, [])

  return (
    <div></div>
  )
}

使用 useRef 后,ESLint 就明白引用的值不会改变,也不会发出警告了。

当 ref 对象内容发生变化时,useRef 并不会通知你。变更 .current 属性不会引发组件重新渲染。如果想要在 React 绑定或解绑 DOM 节点的 ref 时运行某些代码,则需要使用回调 ref 来实现。

useImperativeHandle

useImperativeHandle(ref, createHandle, [deps])

第一个参数,接收一个通过 forwardRef 引用父组件的ref实例,第二个参数一个回调函数,返回一个对象,对象里面存储需要暴露给父组件的属性或方法

useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值。在大多数情况下,应当避免使用 ref 这样的命令式代码。useImperativeHandle 应当与 forwardRef 一起使用。

来看一个很常见的例子(父组件调用子组件的方法和值):有一个弹窗组件和一个表单组件,我们需要在弹窗组件中提交表单。

新建弹窗组件 Popup

import React, {useRef} from 'react'
import Form from './Form'

function Popup() {
  const formRef = useRef(null)
  const submitForm = () => {
    console.log(formRef)
    formRef.current.submit()
  }

  return (
    <div>
      <Form ref={formRef}></Form>
      <button onClick={submitForm}>确定</button>
    </div>
  )
}

export default Popup

新建表单组件 Form

import React, {memo, useImperativeHandle, forwardRef} from 'react'

function Form (props, ref) {
  useImperativeHandle(ref, () => ({
    submit: () => {
      console.log('提交表单')
    },
    params: { a: 1 }
  }))

  return (
    <div>
      <input type="text" placeholder="请输入姓名"/>
      <input type="password" placeholder="请输入密码"/>
    </div>
  )
}

export default memo(forwardRef(Form))

分析:

在父组件 Popup 中,使用 useRef 创建一个 ref 对象,并将其传递给子组件 Form

在子组件 Form 中 ,forwardRef 接收组件(Form)作为参数,并创建一个React组件。这个组件能够将其接收的 ref 属性转发到其组件树下的另一个组件中。再将传入的 ref 通过 useImperativeHandle 进行绑定,指定该子组件对外暴露的方法或属性(submitparams)。

此时,在父组件中就可以调用子组件的方法了。

useReducer

useReducer 的用法和 Redux 很像,可以用来创建一个局部的状态管理。

const [state, dispatch] = useReducer(reducer, initialArg, init)

useReduceruseState 的替代方案,它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。

useReducer 的第三个参数可以省略,当我们需要惰性初始化的时候可以设置第三个参数(详细介绍)。

使用场景:

  1. state 结构复杂,数据之间关联性强,一个操作需要修改多个 state
  2. 在深层子组件里面去修改父组件的状态;

复杂state的使用

Counter 组件中 useReducer 接收了两个参数:countReducerinitialState(其中 initialState 的值包含 counttimesotherStatecountReducer 是一个纯函数,用来计算并返回最新的 state)。

useReducer 返回初始化的 statedispatch 方法。点击按钮,通过 dispatch 发起一个 action,执行 countReducer 方法。

import React, {useReducer} from 'react'

const initialState = {
  count: 0,
  times: 0,
  otherState: ''
}

function countReducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {...state, count: state.count + 1, times: state.times + 1}
    case 'decrement':
      return {...state, count: state.count - 1, times: state.times + 1}
    default:
      return state
  }
}

function Counter() {
  const [state, dispatch] = useReducer(countReducer, initialState)

  return (
    <>
      Times: {state.times}
      <br/>
      Count: {state.count}
      <br/>
      <button onClick={() => dispatch({type: 'decrement'})}>减</button>
      <button onClick={() => dispatch({type: 'increment'})}>加</button>
    </>
  )
}

export default Counter

需要注意的是:React 在比较 oldStatenewState 的时候是使用 Object.is 函数,如果是同一个对象则组件不会重新渲染。所以我们在使用 reducer 时不能直接修改参数中 state 对象,而是需要创建一个新的对象 newState,在 newState 上修改数据并返回。

例子中我们是通过 ES6 的解构赋值创建的新的对象,但是实际应用中我们的数据结构可能会更为复杂,state 可能是多层嵌套的,对于这种复杂的 state 的应用场景,我们可以通过 immer 等库来解决。

修改后的例子:

import produce from 'immer'
...
const countReducer = produce((draft, action) => {
  switch (action.type) {
    case 'increment':
      draft.count++
      draft.times++
      break
    case 'decrement':
      draft.count--
      draft.times++
      break
    default:
      return draft
  }
})
...

深层组件中的使用

在上一章中我们讲过了 useContext 可以在组件之间共享值和方法,而不必显式地通过组件树来逐层传递 props。

当我们在深层组件中,需要修改父组件中的 state 状态时,就可以通过 useContext + useReducer 的方式来实现。避免了逐层传递 props。

使用方法如下。

新建 Parent 组件:

import React, {useReducer, createContext} from 'react'
import produce from 'immer'
import Child from './Child'

export const ParentContext = createContext({})

const initState = {
  form: {
    name: '小红'
  }
}
const reducer = produce((draft, action) => {
  switch (action.type) {
    case 'updateName':
      draft.form.name = action.payload.name
      break
    default:
      return draft
  }
})

function Parent() {
  const [state, dispatch] = useReducer(reducer, initState)

  return (
    <>
      <ParentContext.Provider value={dispatch}>
        姓名:{state.form.name}
        <Child></Child>
      </ParentContext.Provider>
    </>
  )
}

export default Parent

新建子组件 Child

import React, {useContext, memo} from 'react'
import {ParentContext} from './Parent'

function Child() {
  const dispatch = useContext(ParentContext)

  return (
    <button onClick={() => dispatch({type: 'updateName', payload: {name: '测试'}})}>
    修改姓名</button>
  )
}

export default memo(Child)

例子只包含了一层嵌套,当组件树中包含多层嵌套的时候,这种使用方式还是很方便的。

useLayoutEffect

其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。

这里引用了官网的一段描述,那么 useLayoutEffectuseEffect 的区别到底是什么呢?我们可以通过下面这个例子体验一下。

import React, {useState, useEffect} from 'react'

function App() {
  const [count, setCount] = useState(-1)
  useEffect(() => {
    if (count === 0) {
      setCount(new Date().getTime())
    }
  }, [count])

  return (
    <div>
      time: {count}
      <br/>
      <button onClick={() => setCount(0)}>修改 count</button>
    </div>
  )
}

export default App

点击按钮修改 count 的值,先设置 count 的值为 0, 再设置为当前时间的时间戳。

快速连续点击的时候,数字会出现抖动的情况。

接下来我们修改 useEffectuseLayoutEffect

import React, {useState, useLayoutEffect} from 'react'

function App() {
  const [count, setCount] = useState(-1)
  useLayoutEffect(() => {
    if (count === 0) {
      setCount(new Date().getTime())
    }
  }, [count])

  return (
    <div>
      time: {count}
      <br/>
      <button onClick={() => setCount(0)}>修改 count</button>
    </div>
  )
}

export default App

此时进行同样的操作,抖动消失了。相比使用 useEffect,当我们点击按钮时,count 更新为 0,但此时的页面并不会渲染,而是会等待 useLayoutEffect 内部状态修改后,才会去更新页面,所以数字不会抖动。

通过对比我们可以知道,它们的主要区别在于 useLayoutEffect 会阻塞渲染,而 useEffect 不会。所以我们在使用 useLayoutEffect 时需要慎重考虑。

总结

至此 Hooks 的基本使用方法已经总结完毕了,想要用好 Hooks,就需要清楚地知道每个 Hook 具有什么样的作用,应该在什么样的场景下使用。合理地使用每个 Hook 将有助于提高我们的代码质量。

谢谢你这么好看还给我点赞~

相关文章

React Hook之useState、useEffect和useContext
React性能优化之shouldComponentUpdate、PureComponent和React.memo