Hook 简介
Hook 是 React 16.8 的新增特性
在此之前,组件一般划分为 无状态组件 (函数组件)
和 类组件
使用类组件的麻烦:
- this 指向问题
- 繁琐的生命周期
- 以及一些其他的问题
因而推出 Hook
,专门用于增强函数组件的功能,使之理论上可以成为类组件的替代品
Hook
均只能在函数组件中使用,均是一个函数,以 use
开头,一般最后一个参数都是可选的依赖项数组
原理:
jsx 表达式解析到函数组件节点,会通过 React.createElement
创建 React 元素对象,这个对象会持有这个函数的引用
-
若是初次运行这个函数,遇到
Hooks
函数调用,则按照书写Hooks
函数的顺序去创建该节点的Hooks链表
,并按顺序将Hooks
函数的参数设置为这个状态的值 (链表会挂载这个组件节点上,保证后续更新取到正确的值) -
若是调用更新自身状态的函数或父组件更新等导致重新渲染而重新运行这个函数时,会按照书写
Hooks
的顺序去状态链表中对应的索引位置获取已存在状态值或相关引用,从而忽略useState
函数传递的参数值,从而保证每个Hook
在组件更新时能拿到正确的值
State Hook (useState)
useState
用于在函数组件中使用状态,接受一个参数,作为状态的默认值
import React, {useState, PureComponent} from 'react'
function App() {
const [count, setCount] = useState(0)
// const [visible, setVisible] = useState(true)
return (
<div>
<button onClick={() => {
setCount(count - 1)
}}>-</button>
<span>{count}</span>
<button onClick={() => {
setCount(count + 1)
}}>+</button>
</div>
)
}
// 等效的 类组件
class ClassApp {
constructor(props) {
super(props)
this.state = {
count: 0
}
}
render() {
return (
<div>
<button onClick={() => {
this.setState({
count: this.state.count - 1
})
}}>-</button>
<span>{this.state.count}</span>
<button onClick={() => {
this.setState({
count: this.state.count + 1
})
}}>+</button>
</div>
)
}
}
相比较而言,使用 Hook 的函数组件比类组件要简洁得多,而且在函数组件中使用多个 Hook,这样就可以将状态分离,写出更优质且逻辑更清晰的代码
注意细节:
-
useState
声明状态,一般书写在函数的起始位置,方便阅读与统一管理 -
useState
语句严禁
出现在if
或for 循环
等不确定的代码块中 (可见上述原理部分,因为函数组件的状态值是按照调用useState
语句的顺序去创建状态链表的,不确定的语句会导致函数中声明的状态与链表存储的状态不能一一对应的问题) -
为了节约空间,每次运行函数时
useState
返回的更新状态的函数是同一个 (如上例:第一次运行得到的setCount
与函数第二次运行得到的setCount
函数时同一个 ) -
如果使用函数改变数据 (如上面的
setCount(count + 1)
),若数据和之前的数据相等
(用Object.is(newVal, oldVal)
比较),不会导致重新渲染 (与类组件this.setState({})
不同),以优化效率 -
使用函数改变数据时,传入的值是
直接覆盖
,而不是像类组件this.setState()
去混合状态 -
如果要实现强制刷新组件 (很少用):
-
类组件中使用
this.forceUpdate()
(直接绕过shouldComponentUpdate
) -
函数组件则可以使用变式的状态处理,
const [, forceUpdate] = useState({})
,后续需要强制刷新时,调用forceUpdate({})
传递空对象即可 (对象引用不同,可以刷新组件)
-
-
如果某些状态没有必要的联系,应该分割为不同的状态,而不要合成为一个对象
-
和类组件的状态一样,函数组件中改变状态可能是异步的 (DOM 事件中),多个状态变化会合并以提高效率,此时,需要使用函数的形式获取;因而,存在多次状态改变的情况时,尽量使用回调函数的方式更新状态
import React, {useState} from 'react' function App() { const [count, setCount] = useState(0) return ( <div> <span>{count}</span> <button onClick={() => { setCount(count + 1) setCount(count + 1) }}>Plus</button> </div> ) }
比如这个栗子,点击 Plus 按钮,按理来说应该会 +2,得到结果是 2 才对,但实际结果是两次 setCount 会进行合并,每次 count 都还未更新,实际两次均是
0 + 1
,得到最终结果为1
那么我们就需要使用回调函数的方式多次更新状态,
更新状态依旧会合并操作
,点击 Plus 只会运行一次 render
:import React, {useState} from 'react' function App() { const [count, setCount] = useState(0) return ( <div> <span>{count}</span> <button onClick={() => { setCount(prevCount => prevCount + 1) setCount(prevCount => prevCount + 1) }}>Plus</button> </div> ) }
Effect Hook (useEffect)
它用于在函数组件中执行副作用操作,useEffect
Hook 可以看做类组件的 componentDidMount
,componentDidUpdate
和 componentWillUnmount
这三个函数的组合
常见的副作用操作:
- 数据获取 (ajax 请求等)
- 计时器等设置订阅
- 其他的异步操作
- 操作真实 DOM
- 本地存储 (localStorage 等)
- 其他会对外部产生影响的操作 (如更改标签页标题等)
用法说明: useEffect
函数,接受一个函数作为参数,接受的函数就是需要进行副作用操作的函数
细节:
-
副作用函数的运行时间点,是在页面完成
真实的 UI 渲染之后
,它的执行是异步
的,不会阻塞浏览器 -
与
componentDidMount
和componentDidUpdate
的区别:这两个生命周期函数中,已经更改了真实 DOM,但还未渲染到页面上 (UI 尚未更新
);useEffect
中的副作用函数运行时,UI 已经更新
了 -
每个函数组件中可以多次使用
useEffect
,但不能放到不确定语句的代码块中 (if 语句
/for 循环
等) -
useEffect
的副作用函数,可以直接return
,也可以有返回值,返回值是一个函数
(清理函数:用于移除相关的副作用操作),这个清理函数会在每次 render 之后
,执行副作用函数之前
,首次加载不会运行
,组件被销毁时一定会运行
import React, {useState, useEffect} from 'react' let timer = null function stop() { clearInterval(timer) timer = null } // 比如每次渲染,需要在 10s 内操作 DOM 元素移动到某个位置 function Comp() { useEffect(() => { let times = 0 // stop() // 有 return 清理函数, 则此处不需要清除计时器了 timer = setInterval(() => { times++ /* ...... 操作 DOM 移动的副作用代码 */ // 执行 100 次,清除计时器 if (times >= 100) { stop() } }, 10) return stop }) } export default function App() { const [show, setShow] = useState(true) return ( <div> { show && <Comp /> } <button onClick={() => { setShow(!show) }}>Show / Hide</button> </div> ) }
如这个示例,若不在
Comp
组件的useEffect
的副作用函数内部返回清理函数,那么点击Show / Hide
按钮销毁掉Comp
组件时,计时器依旧还在运行,只有等到10s
后才会在满足其函数内部的条件时清理掉这个计时器 -
useEffect
函数的第二个参数为一个数组,用于记录该副作用的依赖数据,只有依赖的数据有变化时 (通过Object.is(oldVal, newVal)
对比),才会执行副作用函数;所以,第二个参数传递空数组
,可以保证副作用函数仅在挂载阶段运行一次,内部有清理函数的话,仅在卸载阶段运行一次 -
副作用函数中,如果使用了函数上下文中的变量,会由于闭包的影响,导致副作用函数中的变量不会变化
// 比如这个代码,想要在挂载阶段启动一个计时器, // 每秒 count--,count 为 0 或组件卸载则清除计时器 import React, {useState, useEffect} from 'react' function Comp() { const [count, setCount] = useState(10) useEffect(() => { const timer = setInterval(() => { setCount(count - 1) if (count === 0) { clearInterval(timer) } console.log(count) }, 1000) // const timer = setInterval(() => { // const newCount = count - 1 // setCount(newCount) // if (newCount === 0) { // clearInterval(timer) // } // console.log(newCount) // }, 1000) return () => { clearInterval(timer) } }, []) return <span>{count}</span> } /* 运行起来你会发现,控制台只会不断输出 10 且 setCount 函数仅会生效一次,后续 count 值不变,页面并不会重新渲染 */ // 正确的处理方案应该如下: let timer = null function Comp() { const [count, setCount] = useState(10) useEffect(() => { if (count === 0) { return } timer = setTimeout(() => { setCount(count - 1) console.log(count) }, 1000) return () => { clearTimeout(timer) } }, [count]) return <span>{count}</span> }
Context Hook (useContext)
用于获取上下文对象的数据,可以替换掉 Consumer
组件的使用,减少组件嵌套层次;目前 Provider
还是必须的结构
在新版的函数组件中,我们使用上下文需要使用 Consumer
组件,但也会在组件结构上进行过多的嵌套,如:
import React, {createContext} from 'react'
const ctx = createContext()
function Comp() {
return <ctx.Consumer>
{ value => <span>Context Value: {value}</span> }
</ctx.Consumer>
}
function App() {
return <ctx.Provider value="K">
<Comp />
</ctx.Provider>
}
// Comp 组件使用 Hook 的写法就会简洁很多:
import React, {useContext} from 'react'
function Comp() {
// 上下文对象作为参数传递,直接获取上下文的值
const value = useContext(ctx)
// const value1 = useContext(ctx1) // 多个上下文,多次使用即可
return <span>Context Value: {value}</span>
}
Reducer Hook (useReducer)
Flux
是 Facebook 出品的一个数据流方案,它有如下规定:
-
数据是单向流动的
-
数据存储在数据仓库中
-
action
是改变数据的唯一原因 (action
本质上就是一个对象,包含type
[string,操作的类型]
和payload
[any,动作发生的附加信息]
属性) -
具体改变数据的是一个纯函数 (
reducer
),它接受两个参数(state,action)
,state 表示当前仓库中的数据,action 表示需要执行的操作及附加信息;这个函数必须返回一个结果 (创建的新的对象),用于表示数据仓库变化后的数据 -
触发
reducer
必须通过辅助函数dispatch(action)
去分发 action,通过 dispatch 函数去间接调用 reducer 来改变数据
基于这个数据流方案,我们可以将数据处理部分的逻辑进行抽离实现:
import React, { memo, useState } from 'react'
const INCREMENT = 'INCREMENT'
const DECREMENT = 'DECREMENT'
// 数据处理函数
function reducer(state, action) {
switch (action.type) {
case INCREMENT:
return state + 1
case DECREMENT:
return state - 1
default:
throw new TypeError(`${action.type} isn't existed`)
}
}
function Task() {
const [n, setN] = useState(0)
function dispatch(action) {
try {
const state = reducer(n, action)
setN(state)
} catch (error) {
console.error(error.message)
}
}
return <>
<button onClick={() => {
dispatch({ type: DECREMENT })
}}> - </button>
<span>{n}</span>
<button onClick={() => {
dispatch({ type: INCREMENT })
}}> + </button>
</>
}
export default memo(Task)
那么,我们就可以将上面例子中的 useState(0) 与 dispatch 函数
抽离出去形成一个自定义的 Hook:
import React, { memo, useState, useCallback } from 'react'
function reducer(state, action) {
// change state
}
/**
* @params {function} reducer 标准格式的 reducer 函数
* @params {any} initialState 状态初始值
*/
function useReducer(reducer, initialState) {
const [state, setState] = useState(initialState)
const dispatch = useCallback(action => {
try {
const newState = reducer(state, action)
setState(newState)
} catch (error) {
console.error(error.message)
}
}, [state, reducer])
return [state, dispatch]
}
function Task() {
const [n, dispatch] = useReducer(reducer, 0) // 使用自定义 Hook
return <>
{/* UI */}
</>
}
export default memo(Task)
官方提供的 useReducer Hook
已经帮我们封装好了这个功能,它可以接受三个参数,分别是:reducer
、initialState
和 init 函数
;第三个参数很少会用到,useReducer
会将 init 函数
的返回值覆盖第二个参数传递的 initialState
而作为这个状态的初始值,initialState
则会作为参数传递给这个 init 函数
进行你需要的处理 (因为 initialState
可能是来自属性的值,需要通过计算获得最终的初始值)
官方的 useReducer Hook
会确保 dispatch
函数的引用是 稳定
的,所以可以在 useEffect
或 useCallback
的依赖列表中省略 dispatch
用法如下:
import { useReducer } from 'react'
const [state, dispatch] = useReducer(reducer, initialState, init)
Callback Hook (useCallback)
useCallback
函数有两个参数,用于得到一个固定引用的函数,第二个参数即为此函数的依赖项,若这个函数没有依赖项或依赖项不变时,这个函数的引用是不会改变的;通常可以进行性能优化 (如下例:改变 n
的值,Parent
组件刷新并不会导致 Child
组件的重新渲染)
import React, {memo, useCallback, useState} from 'react'
function Child(props) {
console.log('Child Render')
return <div>
<span>{props.txt}</span>
<button onClick={props.onClick}>Click</button>
</div>
}
const MemoChild = memo(Child) // 类似继承 PureComponent 的类组件
function Parent() {
console.log('Parent Render')
const [txt, setTxt] = useState("K")
const [n, setN] = useState(0)
const handleClick = useCallback(() => {
setTxt(txt + 1)
}, [txt])
return (<>
<MemoChild txt={txt} onClick={handleClick} />
<button onClick={() => {
setN(n + 1)
}}>n + 1</button>
</>)
}
Memo Hook (useMemo)
与其他 Hook 的使用方式一样,useMemo
函数传递两个参数,第一个参数传递一个函数,这个函数的 返回结果
将被固定下来,第二个参数传递依赖项,仅依赖项发生改变时,传递的第一个函数才会重新计算 (类似于 Vue 的 Computed 中的属性
);若不传递第二个参数,那么每次 render 还是会重新计算
因为它是固定传入函数执行的返回值,那么它就可以经过运算去 返回任何值
,当然也可以返回一个函数 (相当于替换 useCallback
Hook,但相对来说使用 useCallback
更简洁一点)
因而,可以使用这个 Hook 去进行优化,比如一些不常变动的高开销的计算:
import React, {useState, useMemo} from 'react'
function Item(props) {
console.log('Item render')
return <li>{props.value}</li>
}
// 比如这里有一堆 li 需要渲染
export default function List() {
console.log('List render')
const [range,] = useState({ min: 1, max: 10000 })
const [n, setN] = useState(0)
const lis = useMemo(() => {
console.log('useMemo func')
const {min, max} = range
const list = new Array(max - min + 1)
for (let i = min; i <= max; i++) {
list[i] = <Item key={i} value={i} />
}
// 此返回值会被固定保存下来,直到依赖项变化才会重新运行此函数,更新返回的结果
return list
}, [range.min, range.max])
return <div>
<ul>{lis}</ul>
<p>{n}</p>
<button onClick={() => {
setN(n + 1)
}}> n + 1 </button>
</div>
}
这个栗子,你会发现,点击 n+1 按钮
导致页面刷新,会不断打印 'List render'
;'Item render'
只会在初始阶段打印 10000 次,后续不会重新渲染 (因为 lis
数组中的对象引用没有发生改变,不存在创建新节点或删除旧节点的问题);'useMemo func'
只会在初始阶段打印一次
Ref Hook (useRef)
useRef
函数可以传递一个参数,即为设置 current
属性的默认值;返回一个 ref 对象,后续函数运行时,它总是返回 之前创建的对象
,与在类组件中使用 ref 的方式相类似,如下面这个栗子 (点击按钮 => input 聚焦
):
import React, {useCallback, useRef} from 'react'
function Comp() {
const inputEl = useRef(null) // 初始化 => inputEl.current = null
const handleClick = useCallback(() => {
// `current` 指向已挂载到 DOM 上的文本输入元素
inputEl.current.focus();
}, []);
return (<>
<input ref={inputEl} type="text" />
<button onClick={handleClick}>Focus the input</button>
</>);
}
由于 useRef
总是引用的同一个对象,所以我们可以用它保存任何一个可控的值,比如:
// 如上面在说明 useEffect Hook 时,想要在挂载阶段启动一个计时器的需求时
// 上面的组件使用 setInterval 暂不能实现想要的功能
// 而且若被多次使用,因为共用一个 timer 变量,则会出现相互影响的问题
// 1. 我们可以使用 useRef 使 timer 变量分隔开 (互不影响)
function Comp() {
const [count, setCount] = useState(10)
const timerRef = useRef(null)
useEffect(() => {
if (count === 0) return
timerRef.current = setTimeout(() => {
setCount(count - 1)
}, 1000)
return () => {
clearTimeout(timerRef.current)
}
}, [count]) // 依赖 count,则每次更改 count 值就运行一次
return <span>{count}</span>
}
// 2. 使用 useRef 结合 setInterval 实现
function Comp() {
const [count, setCount] = useState(10)
const countRef = useRef(count) // 使用 count 初始化 countRef 对象
useEffect(() => {
const timer = setInterval(() => {
setCount(--countRef.current)
}, 1000)
return () => {
clearInterval(timer)
}
}, []) // 无依赖,则仅挂载运行一次
return <span>{count}</span>
}
ImperativeHandle Hook (useImperativeHandle) 很少用
比如说有个需求是:在父组件中调用子组件的方法
,那么之前使用类组件,再结合 useRef
,我们可以这么实现:
class Child extends PureComponent {
method: () => {
console.log("Child method has been called by it's parent")
}
render() {
return <p>Child Component</p>
}
}
function Parent() {
const childRef = useRef(null)
return (<>
<Child ref={childRef} /> {/* 类组件可以直接使用 ref */}
<button onClick={() => {
childRef.current.method()
}}> Call Child Method </button>
</>)
}
// 点击父组件的按钮,控制台会输出子组件 method 方法的打印内容
这个需求,在之前我们却不能使用函数组件来实现 (因为函数组件不能使用 ref),函数组件只能通过 ref 转发 (React.forwardRef
) 去拿到函数组件内部的某个 DOM 对象或它内部的其他组件
useImperativeHandle
用法说明:
1. useImperativeHandle 与其他 Hook 一样,只能在函数组件中使用
2. useImperativeHandle 函数接受三个参数
- 第一个参数:通过 ref 转发获得的 ref 对象
- 第二个参数:一个函数,返回值会赋值给 ref 对象的 current 属性,根据第三个参数决定是否进行缓存
- 第三个参数:可选的依赖项,若不给,则每次组件更新时,第二个参数传递的函数均会运行 (即不缓存)
若给空数组,则仅组件挂载阶段运行一次并缓存赋值给 ref.current
若有依赖项,则依赖项变化时,才会重新运行第二个参数的函数,更新缓存
那么现在,我们就可以借助 useImperativeHandle Hook
来在 函数组件
中实现上述需求 (思路:在使用 ref 转发接受 ref 对象的子组件中,通过给 ref.current
赋值为一个对象,给这个对象赋予需要调用的相关方法即可)
function Child(props, ref) {
useImperativeHandle(ref, () => ({
method: () => {
console.log("Child method has been called by it's parent")
})
}, [])
return <p>Child Component</p>
}
const ForwardChild = React.forwardRef(Child)
function Parent() {
const childRef = useRef(null)
return (<>
<ForwardChild ref={childRef} /> {/* 函数组件使用 ref 转发 */}
<button onClick={() => {
childRef.current.method()
}}> Call Child Method </button>
</>)
}
LayoutEffect Hook (useLayoutEffect)
-
上面有说到
useEffect
中的副作用操作会在浏览器渲染完成,用户已经看到新的渲染效果后
运行。因而,如果我们在useEffect
中操作真实 DOM 更改其内容时,我们就会看到页面闪烁
的效果。比如,我们点击按钮更新 n 的值后,又需要更改 p 元素内的显示内容,那么我们可能这么实现:import React, { memo, useEffect, useState, useCallback, useRef } from 'react' function Task() { const [n, setN] = useState(0) const refer = useRef(null) useEffect(() => { refer.current.innerText = Math.random().toFixed(2) }) const handle = useCallback(() => { setN(n + 1) }, [n]) return <> <p ref={refer}>{n}</p> <button onClick={handle}>+</button> </> } export default memo(Task)
-
而
useLayoutEffect Hook
比较类似于类组件的componentDidMount
和componentDidUpdate
这两个生命周期钩子函数,完成了 DOM 更新,但浏览器尚未完成渲染,还未呈现给用户,它的用法与useEffect
一样。与useEffect
不同的是:useEffect
中的副作用操作是异步的,不会造成阻塞,而它会阻塞页面的渲染
,如果存在大量的计算或 DOM 改动,浏览器就会一直等待它的处理结果 (表现上就是浏览器几乎不响应的状态)
所以,我们在使用上,优先考虑使用 useEffect
,如果出现了渲染异常或闪烁等问题,再考虑使用 useLayoutEffect
DebugValue Hook (useDebugValue)
useDebugValue Hook
,我们只是用来在 React 开发者工具中显示自定义的 Hook 标签
// 比如,我们写了个自定义标签 useTest:
import React, { memo, useEffect, useState, useCallback, useRef } from 'react'
function useTest() {
const [list, ] = useState([])
return list
}
function Task() {
const [n, setN] = useState(0)
const refer = useRef(null)
useEffect(() => {
refer.current.innerText = Math.random().toFixed(2)
})
const handle = useCallback(() => {
setN(n + 1)
}, [n])
useTest() // 使用自定义 Hook
return <>
<p ref={refer}>{n}</p>
<button onClick={handle}>+</button>
</>
}
export default memo(Task)
我们可以在 React 开发者工具中看到 Hooks 的链表结构以及对应的描述或值:
我们使用 useDebugValue
添加 Hook 描述后:
function useTest() {
const [list, ] = useState([])
useDebugValue("測試使用的自定義 Hook")
return list
}
使用说明:
useDebugValue
函数可以接受两个参数,若只给予第一个参数,那么它就会将第一个参数的结果加入到开发者工具的 Hook 描述中,如果这个自定义 Hook 中书写了多个 useDebugValue
,开发者工具会将描述信息收集为一个数组进行展示;第二个参数可选,接受一个格式化函数,用于格式化第一个参数的显示 (如:第一个参数是一个日期对象 new Date()
,那么第二个参数就可以是 date => date.toDateString()
来格式化显示日期,最后映射到开发者工具中显示)
一般是创建的自定义 Hook 的通用性比较高,我们可以使用 useDebugValue
来方便调试;或者是作为一个共享库,也可以使用此 Hook 来添加描述,方便其他人使用
自定义 Hook
将一些常用的、跨越多个组件的 Hook 功能,以及组件逻辑提取到可重用的函数中,形成自定义 Hook
由于自定义 Hook 内部需要使用 Hook 功能,所以它本身也需要遵循 Hook 的规则
- 函数命名以
use
开头 - 调用自定义 Hook 函数时,应放置于调用函数的顶部,且不能置于循环或判断语句中
// 比如我们需要在多个组件中分页获取同一套数据
// 那么之前的写法就可能需要使用 HOC 封装或使用 render props 来实现
// 但总会将组件结构嵌套地更深
// 使用 Hook 抽离这部分逻辑时,你会发现仅仅是在封装函数而已
import {useState, useEffect} from 'react'
import {getDataListByPage} from '../utils/getData'
/*
current: 当前页
pageSize: 每页显示的条数
*/
function useDataList(current, pageSize) {
const [resp, setResp] = useState({}) // total - 总页数, list - 数据列表...
useEffect(() => {
// 比如获取数据
getDataListByPage({current, pageSize})
.then(res => {
setResp(res)
})
}, [current, pageSize])
return resp
}
// 其他组件通过调用此函数,拿到数据即可使用
function App() {
const [current, setCurrent] = useState(1)
const [pageSize, setPageSize] = useState(10)
// current / pageSize 更新自会返回新数据
const resp = useDataList(current, pageSize)
// do other things...
return <> ... </>
}
使用自定义 Hook,不会加深组件层级,也可以更细致地进行功能点的拆分