Hook 的实现原理及使用
Hook 是 React 16.8 新增的钩子 API,它可以增加代码的可复用性和逻辑性,弥补函数组件没有生命周期及数据管理状态 state 的缺陷。
一、为什么要引入 Hook
- 原有的函数组件也被称为无状态组件,只负责渲染工作,引入 react-hooks 后,函数组件也可以是有状态的组件,可以维护自身的状态和做一些逻辑处理;
- react-hooks 可以增强代码的逻辑性,抽离公共方法和公共组件;
- react-hooks 趋近于函数式编程的思想,不用像 class 组件一样去声明周期,写
render
函数等,提高开发效率; - react-hooks 可以把庞大的 class 组件单元化,化整为很多小组件,使用
useMemo
等方法可以让组件或变量制定一个独立的渲染空间,减少渲染次数,提高性能。
二、Hook 的使用规则
使用 Hook 的时候必须遵守 2 条规则:
Hook 的设计极度依赖其定义时候的顺序,如果在后序的 render 中 Hook 的调用顺序发生变化,会出现不可预知的问题。上面 2 条规则都是为了保证 Hook 调用顺序的稳定性。为了贯彻这 2 条规则,React 提供一个 ESLint plugin 来做静态代码检测:eslint-plugin-react-hooks
三、常见的 Hook
1、useState
useState
用于在函数组件中引入状态,实现数据存储和派发更新。
useState
返回一个数组,第一项为当前的 state ,第二项为更新 state 的函数。举个例子:
import React, { useState } from 'react'
const App = () => {
const [count, setCount] = useState(0)
return (
<div>
{count}
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
)
}
export default App
上面实现点击按钮,count 的值加 1,等价于下面的 Class 组件:
class App extends React.Component {
constructor(props) {
super(props)
this.state = {
count: 0,
}
}
render() {
return (
<div>
{this.state.count}
<button onClick={() => this.setState({ count: this.state.count + 1 })}>+1</button>
</div>
)
}
}
需要注意的是:useState
派发更新函数的执行,会让整个函数组件从头到尾执行一次,可以和 useMemo
、usecallback
等 API 配合使用来优化,这也是为什么滥用 hooks 会带来负作用的原因之一。
2、useEffect
副作用:函数式编程将与数据计算无关的操作,统称为副作用(side effect)
useEffect
用于在函数组件中执行带有副作用的操作。常见用途有:
- 获取数据(data fetching)
- 事件监听或订阅(setting up a subscription)
- 改变 DOM(changing the DOM)
- 输出日志(logging)
2-1、基本用法
如果想在组件初次加载完成时执行指定的操作,就可以用 useEffect
来充当 Class 组件中的 componentDidMount 生命周期函数;如果想在组件更新时执行指定的操作,也可以用 useEffect
来充当 Class 组件中的 componentDidUpdate 生命周期函数。
import React, { useEffect, useRef } from 'react'
const App = () => {
const theDiv = useRef(null)
console.log(theDiv.current) // null
useEffect(() => {
console.log(theDiv.current) // <div>Hello World</div>
})
return <div ref={theDiv}>Hello World</div>
}
export default App
2-2、useEffect 的第二个参数
useEffect
的第二个参数可以使用一个数组指定副作用函数的依赖项,只有依赖项发生变化,才会重新渲染。例如:
useEffect(() => {
document.title = `You clicked ${count} times`
}, [count]) // 仅在 count 更改时更新
如果第二个参数是一个空数组,则表明副作用参数没有任何依赖项。因此,副作用函数只在组件加载进入 DOM 后执行一次,后面组件重新渲染则不再执行。
2-3、useEffect 的返回值
副作用是随着组件加载而发生的,那么组件卸载时可能需要清理这些副作用。
useEffect
允许返回一个函数,在组件卸载时执行该函数,以清理副作用。如果不需要清理副作用,useEffect
就不用返回任何值。
useEffect(() => {
const subscription = props.source.subscribe()
return () => {
subscription.unsubscribe()
}
}, [props.source])
上面 useEffect
在组件加载时订阅了一个事件,并且返回一个清理函数,在组件卸载时取消订阅。
实际使用中,由于副作用函数默认每次渲染都执行,所以清理函数不仅会在组件卸载时执行一次,每次副作用函数重新执行前也会执行一次,用来清理上一次渲染的副作用。
举个例子 ▼
使用 useEffect
前:
componentDidMount() {
window.addEventListener('message', handleXX)
}
componentWillUnmount() {
window.removeEventListener('message', handleXX)
}
使用 useEffect
后:
useEffect(() => {
window.addEventListener('message', handleXX)
return () => window.removeEventListener('message', handleXX)
})
2-4、异步 async effect 用法
useEffect
是不能直接用 async & await 语法糖的,下面是错误用法:
useEffect(async () => {
const res = await getUserInfo(payload)
}, [a, number])
如果想用 async effect 可以对 useEffect
进行一层包装:
const XXComponent = (props) => {
useEffect(() => {
async function getUserInfo() {
await loadContent()
}
getUserInfo()
}, [])
return <div>xxx</div>
}
// 也可以使用 IIFE
const XXComponent = (props) => {
useEffect(() => {
;(async function getUserInfo() {
await loadContent()
})()
}, [])
return <div>xxx</div>
}
2-5、useLayoutEffect 用法
useLayoutEffect 会在所有 DOM 变更后同步调用 effect,可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。
二者的执行顺序如下:
- useEffect 执行顺序: 组件更新挂载完成 -> 浏览器 DOM 绘制完成 -> 执行
useEffect
回调 - useLayoutEffect 执行顺序: 组件更新挂载完成 -> 执行 useLayoutEffect 回调 -> 浏览器 DOM 绘制完成
二者的区别在于:
useEffect
是异步执行的,而 useLayoutEffect 是同步执行的;useEffect
是在浏览器完成渲染之后执行,而 useLayoutEffect 是在浏览器把内容真正渲染到界面(浏览器 DOM 绘制完成)前执行,和componentDidMount
等价。
举个例子:
import React, { useState, useEffect, useLayoutEffect } from 'react'
function App() {
const [state, setState] = useState('hello world')
useEffect(() => {
let i = 0
while (i <= 100000000) {
i++
}
setState('world hello')
}, [])
// useLayoutEffect(() => {
// let i = 0
// while (i <= 100000000) {
// i++
// }
// setState('world hello')
// }, [])
return <div>{state}</div>
}
export default App
使用 useEffect
的效果:
使用 useLayoutEffect 的效果:
可以看到换成 useLayoutEffect 之后闪烁现象就消失了,因为 useEffect
是渲染完之后异步执行的,所以会导致 hello world 先被渲染到了屏幕上,再变成 world hello,就会出现闪烁现象。而 useLayoutEffect 是渲染之前同步执行的,所以会等它执行完再渲染上去,就避免了闪烁现象。也就是说我们最好把操作 DOM 的相关操作放到 useLayoutEffect 中去,避免导致闪烁。
注意:使用 useLayoutEffect 在 SSR 服务端渲染可能会出现以下警告:
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://fb.me/react-uselayouteffect-ssr for common fixes.
这是因为 useLayoutEffect 不会在服务端执行,所以可能导致 SSR 渲染出来的内容和实际的首屏内容并不一致。可以通过以下方式解决:
- 放弃使用 useLayoutEffect,使用
useEffect
代替; - 如果明知 useLayouteffect 对于首屏渲染没有影响,但后续会需要,可以这样写:
import { useEffect, useLayoutEffect } from 'react'
export const useCustomLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
当使用 useLayoutEffect 的时候就用 useCustomLayoutEffect 代替。这样在服务端就会用 useEffect
,不会出现警告了。
小结:
- 优先使用
useEffect
,因为它是异步执行的,不会阻塞渲染; - 会影响到渲染的操作尽量放到 useLayoutEffect 中,避免出现闪烁问题;
- useLayoutEffect 和
componentDidMount
是等价的,会同步调用,阻塞渲染; - 在服务端渲染时使用 useLayoutEffect 会有一个警告,可能导致首屏实际内容和服务端渲染出来的内容不一致。
3、useRef
useRef
返回一个可变的 ref 对象,在组件的整个生命周期内持续存在,可用于访问 DOM 节点或缓存数据。
3-1、访问 DOM 节点
import React, { useRef, useEffect } from 'react'
const App = () => {
const theDiv = useRef(null)
useEffect(() => {
console.log(theDiv.current) // <div>Hello World</div>
})
return <div ref={theDiv}>Hello World</div>
}
3-2、缓存数据
useRef
还有个很重要的作用就是缓存数据,usestate、useReducer 可以保存当前的数据源,但它们更新数据源的函数执行会带来整个组件的重新渲染,如果在函数组件内部声明变量,则下一次更新也会重置,因此,如果想要悄悄的保存数据,而不触发函数的更新,就需要用到 useRef
:
const currenRef = useRef(InitialData)
useRef
的第一个参数可以用来初始化保存数据,这些数据可以在 current 属性上获取到,当然也可以对 current 赋值新的数据源:
currenRef.current = newValue
下面通过 react-redux 源码来看看 useRef
的巧妙运用(react-redux 在 react-hooks 发布后,用 react-hooks 重写了其中的 Provide、connectAdvanced)核心模块,可以看到 react-hooks 在限制数据更新,高阶组件上有一定的优势,其源码大量运用 useMemo 来做数据判定:
/* 这里用到的 useRef 没有一个是绑定在 dom 元素上的,都是做数据缓存用的 */
/* react-redux 用 userRef 来缓存 merge之后的 props */
const lastChildProps = useRef()
// lastWrapperProps 用 useRef 来存放组件真正的 props 信息
const lastWrapperProps = useRef(wrapperProps)
// 是否储存 props 是否处于正在更新状态
const renderIsScheduled = useRef(false)
上面是 react-redux 中用 useRef
对数据做的缓存,下面来看是怎么做更新的:
// 获取包装的 props
function captureWrapperProps(lastWrapperProps, lastChildProps, renderIsScheduled, wrapperProps, actualChildProps, childPropsFromStoreUpdate, notifyNestedSubs) {
// 捕获包装 props 和子 props,以便稍后进行比较
lastWrapperProps.current = wrapperProps // 子 props
lastChildProps.current = actualChildProps // 经过 merge props 后形成的 prop
renderIsScheduled.current = false
}
可以看到,react-redux 用重新赋值的方法,改变了缓存的数据源,避免不必要的数据更新,如果选用 useState 储存数据,会导致组件重新渲染,采用 useRef
可以解决该问题。
4、useContext
useContext
钩子函数接收一个 Context 对象并返回该 Context 的当前值。当前的 Context 值由上层组件中距离当前组件最近的 <MyContext.Provider>
的 value prop 决定。
当组件上层最近的 <MyContext.Provider>
更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext provider 的 context value 值。
import React, { useContext } from 'react'
const ThemeContext = React.createContext('light')
const Father = () => {
return (
<ThemeContext.Provider value="dark">
<Child />
</ThemeContext.Provider>
)
}
const Child = () => {
return <GrandChild />
}
const GrandChild = () => {
// 使用 useContext 引入提供的 ThemeContext
const value = useContext(ThemeContext)
return <div>{value}</div>
}
export default Father
5、useReducer
useReducer
用于返回当前 Redux 的 state 以及与其对应的 dispatch 方法。
const initialState = { count: 0 }
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 }
case 'decrement':
return { count: state.count - 1 }
default:
throw new Error()
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</>
)
}
6、useCallback
useCallback
传入回调函数及其依赖项,当该依赖项改变时才更新传入的回调函数,用于避免不必要的更新。
// 当依赖项 b 变化时才更新被包裹的函数
// 如果 b 始终未发生变化则该函数不会重新生成
const a = useCallback(() => {
console.log(b)
}, [b])
使用场景:
- 用于父组件向子组件传递函数时, 阻止传递的函数在每次
render
时重新创建,从而造成子组件重新渲染。需配合 React.memo() 使用。 - 或在 useEffect() 中使用外部创建的函数, 但不希望这个函数一直变化, 导致 useEffect() 被重复触发。
举个例子:
下面在父组件中传递函数给子组件,每次父组件 render 时, 传递的函数 () => setCount(count + 1)
都会重新创建一次,导致子组件重新渲染。
import React, { useState } from 'react'
const Button = (props) => {
const { increment } = props
console.log('render')
return <button onClick={increment}>Add Count</button>
}
const App = () => {
const [count, setCount] = useState(0)
// 每次 render 时传递的 handleIncrement 函数都会重新生成
const handleIncrement = () => setCount(count + 1)
return (
<>
<Button increment={handleIncrement} />
<div>count: {count}</div>
</>
)
}
export default App
效果如下:
使用 useCallback
优化如下:
import React, { useState } from 'react'
import React, { useState, useCallback } from 'react'
const Button = (props) => {
const Button = React.memo((props) => {
const { increment } = props
console.log('render')
return <button onClick={increment}>Add Count</button>
}
})
const App = () => {
const [count, setCount] = useState(0)
const handleIncrement = () => setCount(count + 1)
const handleIncrement = useCallback(() => {
setCount((prevCount) => prevCount + 1)
}, [setCount])
return (
<>
<Button increment={handleIncrement} />
<div>count: {count}</div>
</>
)
}
export default App
效果如下:
上面 useCallback
将 setCount 作为依赖,只要 setCount 不改变,increment 函数就不变,相应的子组件接受到的 props 没改变,就不会 rerender。
7、useMemo
useMemo
与 useCallback 类似,都是通过缓存来提升性能,区别在于 useCallback 缓存的是函数的引用,返回一个未执行的函数;而 useMemo
缓存的是函数的返回值,返回执行完函数后的结果。
const foo = () => 'bar'
const testCallback = useCallback(foo, [])
const testMemo = useMemo(foo, [])
console.log(testCallback) // foo() {}
console.log(testMemo) // bar
使用场景:
- 减少不必要的子组件渲染:
function Parent({ a, b }) {
// 当 a 改变时才会重新渲染
const child1 = useMemo(() => <Child1 a={a} />, [a])
// 当 b 改变时才会重新渲染
const child2 = useMemo(() => <Child2 b={b} />, [b])
return (
<>
{child1}
{child2}
</>
)
}
如果想实现类组件的 shouldComponentUpdate 方法,可以使用 React.memo,区别是它只能比较 props,不会比较 state:
const Parent = React.memo(({ a, b }) => {
// 当 a 改变时才会重新渲染
const child1 = useMemo(() => <Child1 a={a} />, [a])
// 当 b 改变时才会重新渲染
const child2 = useMemo(() => <Child2 b={b} />, [b])
return (
<>
{child1}
{child2}
</>
)
})
8、useImperativeHandle
useImperativeHandle
可以在使用 ref 时自定义暴露给父组件的实例值,需要与 forwardRef 一起使用:
useImperativeHandle(ref, createHandle, [deps])
举个例子,点击 Focus the input 按钮,聚焦输入框并输出 <input>
:
import React, { useRef, forwardRef } from 'react'
// 暴露整个 input 节点给父级
const Child = forwardRef((props, ref) => {
return <input ref={ref} {...props} />
})
function Father() {
const curInput = useRef(null)
function onButtonClick() {
console.log(curInput.current) // <input>
curInput.current.focus()
}
return (
<>
<Child ref={curInput} />
<button onClick={onButtonClick}>Click To Focus</button>
</>
)
}
export default Father
上面点击 Click To Focus 按钮输出 <input>
节点并聚焦输入框,接下来使用 useImperativeHandle
实现只暴露 value 和 focus 给父级:
import React, { useRef, forwardRef, useImperativeHandle } from 'react'
// 只暴露 value、focus 给父级
const Child = forwardRef((props, ref) => {
const theInput = useRef()
useImperativeHandle(ref, () => ({
value: theInput.current.value,
focus: () => theInput.current.focus(),
}))
return <input ref={theInput} {...props} />
})
function Father() {
const curInput = useRef(null)
function onButtonClick() {
console.log(curInput.current)
curInput.current.focus()
}
return (
<>
<Child ref={curInput} />
<button onClick={onButtonClick}>Click To Focus</button>
</>
)
}
export default Father
点击 Click To Focus 按钮输出以下结果并聚焦输入框:
四、Hook 的实现原理
1、Hooks 的模拟实现
下面是一个 React Hooks 接口的简化模拟实现,可以实际运行观察。其中 react.js 文件模拟实现了 useState
和 useEffect
接口,其基本原理和 react 实际实现类似。
2、模拟与实际代码对比分析
2-1、状态 Hook 分析
模拟的 useState
实现中,通过闭包将 state 保存在 memoizedState[cursor]
。memoizedState 是一个数组,可以按顺序保存 hook 多次调用产生的状态。
let memoizedState = []
let cursor = 0
function useState(initialValue) {
// 初次调用时,传入的初始值作为 state,后续使用闭包中保存的 state
let state = memoizedState[cursor] ?? initialValue
// 对游标进行闭包缓存,使得 setState 调用时,操作正确的对应状态
const _cursor = cursor
const setState = (newValue) => (memoizedState[_cursor] = newValue)
// 游标自增,为接下来调用的 hook 使用时,引用 memoizedState 中的新位置
cursor += 1
return [state, setState]
}
实际的 useState
实现经过多方面的综合考虑,React 最终选择将 Hooks 设计为顺序结构,这也是 Hooks 不能条件调用的原因。
function mountState<S>(
initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
// 创建 Hook,并将当前 Hook 添加到 Hooks 链表中
const hook = mountWorkInProgressHook()
// 如果初始值是函数,则调用函数取得初始值
if (typeof initialState === 'function') {
initialState = initialState()
}
hook.memoizedState = hook.baseState = initialState
// 创建一个链表来存放更新对象
const queue = (hook.queue = {
pending: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState
})
// dispatch 用于修改状态,并将此次更新添加到更新对象链表中
const dispatch: Dispatch<BasicStateAction<S>> = (queue.dispatch =
(dispatchAction.bind(null, currentlyRenderingFiber, queue): any))
return [hook.memoizedState, dispatch]
}
2-2、副作用 Hook 分析
模拟的 useEffect
实现,同样利用了 memoizedState 闭包来存储依赖数组。依赖数组进行浅比较,默认的比较算法是 Object.is
:
function useEffect(cb, depArray) {
const oldDeps = memoizedState[cursor]
let hasChange = true
if (oldDeps) {
// 对比传入的依赖数组与闭包中保存的旧依赖数组,采用浅比较算法
hasChange = depArray.some((dep, i) => !Object.is(dep, oldDeps[i]))
}
if (hasChange) cb()
memoizedState[cursor] = depArray
cursor++
}
实际的 useEffect
实现:
function mountEffect(create: () => (() => void) | void, deps: Array<mixed> | void | null): void {
return mountEffectImpl(
UpdateEffect | PassiveEffect, // fiberFlags
HookPassive, // hookFlags
create,
deps
)
}
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
// 创建 hook
const hook = mountWorkInProgressHook()
const nextDeps = deps === undefined ? null : deps
// 设置 workInProgress 的副作用标记
currentlyRenderingFiber.flags |= fiberFlags // fiberFlags 被标记到 workInProgress
// 创建 Effect, 挂载到 hook.memoizedState 上
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags, // hookFlags 用于创建 effect
create,
undefined,
nextDeps
)
}
3、Hooks 如何与 Fiber 共同工作
Hook 与 Fiber 的部分结构定义如下:
export type Hook = {
memoizedState: any // 最新的状态值
baseState: any // 初始状态值
baseQueue: Update<any, any> | null
queue: UpdateQueue<any, any> | null // 环形链表,存储的是该 hook 多次调用产生的更新对象
next: Hook | null // next 指针,之下链表中的下一个 Hook
}
export type Fiber = {
updateQueue: mixed // 存储 Fiber 节点相关的副作用链表
memoizedState: any // 存储 Fiber 节点相关的状态值
flags: Flags // 标识当前 Fiber 节点是否有副作用
}
与模拟实现不同,真实的 Hooks 是一个单链表的结构,React 按 Hooks 的执行顺序依次将 Hook 节点添加到链表中。
下面以 useState
和 useEffect
两个最常用的 hook 为例,来分析 Hooks 如何与 Fiber 共同工作。
在每个状态 Hook(如 useState)节点中,会通过 queue 属性上的循环链表记住所有的更新操作,并在 update 阶段依次执行循环链表中的所有更新操作,最终拿到最新的 state 返回。
状态 Hooks 组成的链表的具体结构如下图所示:
在每个副作用 Hook(如 useEffect)节点中,创建 effect 挂载到 Hook 的 memoizedState 中,并添加到环形链表的末尾,该链表会保存到 Fiber 节点的 updateQueue 中,在 commit 阶段执行。
副作用 Hooks 组成的链表的具体结构如下图所示:
五、总结
什么是 Hook?
Hook 是 React 16.8 新增的钩子 API,它可以增加代码的可复用性和逻辑性,弥补函数组件没有生命周期及数据管理状态 state 的缺陷。
使用 Hook 的注意事项?
- 只能在函数组件或自定义 Hook 中调用 Hook;
- 不能在普通 JS 函数、循环、条件或嵌套函数中调用 Hook。
常用的 Hook 有哪些?
useState
:用于在函数组件中引入状态,实现数据存储和派发更新;useEffect
:用于在函数组件中执行带有副作用的操作;useRef
:返回一个可变的 ref 对象,在组件的整个生命周期内持续存在,可用于访问 DOM 节点或缓存数据;useContext
:接收一个 Context 对象并返回该 Context 的当前值,可用于跨级传参;useCallback
:传入回调函数及其依赖项,当该依赖项改变时才更新传入的回调函数,用于避免不必要的更新;useMemo
:与 useCallback 类似,都是通过缓存来提升性能,区别在于 useCallback 缓存的是函数的引用;而 useMemo 缓存的是函数的返回值。
Hook 的实现原理?
Hooks 的实现原理是利用闭包来保存状态,使用链表保存一系列的 Hooks,将链表中的第一个 Hook 与 Fiber 关联。在 Fiber 树更新时,就能从 Hooks 中计算出最终输出的状态和执行相关的副作用。