前言
我参照 React 官网将 React Hook 分为了两种:
- 基础 Hook:
useState
、useEffect
、useContext
; - 额外的 Hook:
useMemo
、useCallback
、useRef
、useImperativeHandle
、useReducer
、useLayoutEffect
;
本篇主要介绍的是“额外的 Hook”,关于基础 Hook 可以阅读我的上一篇文章:
《React Hook之useState、useEffect和useContext》
useCallback 和 useMemo
熟悉 React 的小伙伴们对“性能优化”这个词一定很熟悉。当我们在类组件中进行父子组件传值的时候,可以通过shouldComponentUpdate
和PureComponen
进行性能优化,函数组件也可以通过memo
实现同样的效果。(详细介绍:传送门)
这里我把 useCallback
和 useMemo
放在同一个小节里,是因为这两个 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())
}, [])
...
这里的依赖项数组设置为 []
表示只执行一次 useCallback
,Child
组件就不会重新渲染了。当然我们也可以通过设置依赖项控制方法的更新。
useMemo
当我们理解了 useCallback
后, useMemo
就非常简单了。
useMemo
返回一个 memoized 值,把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。
使用场景:
- 如果组件中使用的某个值需要经过高开销的函数计算,我们可以「记住」 它,避免每次组件重新渲染都要去重新计算。
- 由于值的引用发生变化,导致组件重新渲染,我们也需要「记住」这个值。
场景一:高开销的函数计算。
这个场景还是很好理解的,就直接看一下官网的例子吧。假设 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
;如果仅仅是保持值的引用的时候,需要考虑 useMemo
和 useRef
(或者其他方式)谁到底更合适。
小结
useCallback
和 useMemo
都接收两个参数:函数和依赖项数组。区别是 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
进行绑定,指定该子组件对外暴露的方法或属性(submit
和 params
)。
此时,在父组件中就可以调用子组件的方法了。
useReducer
useReducer
的用法和 Redux
很像,可以用来创建一个局部的状态管理。
const [state, dispatch] = useReducer(reducer, initialArg, init)
useReducer
是 useState
的替代方案,它接收一个形如 (state, action) => newState
的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。
useReducer
的第三个参数可以省略,当我们需要惰性初始化的时候可以设置第三个参数(详细介绍)。
使用场景:
state
结构复杂,数据之间关联性强,一个操作需要修改多个state
;- 在深层子组件里面去修改父组件的状态;
复杂state的使用
Counter
组件中 useReducer
接收了两个参数:countReducer
和 initialState
(其中 initialState
的值包含 count
、times
、otherState
; countReducer
是一个纯函数,用来计算并返回最新的 state)。
useReducer
返回初始化的 state
和 dispatch
方法。点击按钮,通过 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 在比较 oldState
和 newState
的时候是使用 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 内部的更新计划将被同步刷新。
这里引用了官网的一段描述,那么 useLayoutEffect
和 useEffect
的区别到底是什么呢?我们可以通过下面这个例子体验一下。
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, 再设置为当前时间的时间戳。
快速连续点击的时候,数字会出现抖动的情况。
接下来我们修改 useEffect
为 useLayoutEffect
。
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