Skip to main content

Hook 的实现原理及使用

Hook 是 React 16.8 新增的钩子 API,它可以增加代码的可复用性和逻辑性,弥补函数组件没有生命周期及数据管理状态 state 的缺陷。

一、为什么要引入 Hook

  • 原有的函数组件也被称为无状态组件,只负责渲染工作,引入 react-hooks 后,函数组件也可以是有状态的组件,可以维护自身的状态和做一些逻辑处理;
  • react-hooks 可以增强代码的逻辑性,抽离公共方法和公共组件;
  • react-hooks 趋近于函数式编程的思想,不用像 class 组件一样去声明周期,写 render 函数等,提高开发效率;
  • react-hooks 可以把庞大的 class 组件单元化,化整为很多小组件,使用 useMemo 等方法可以让组件或变量制定一个独立的渲染空间,减少渲染次数,提高性能。

二、Hook 的使用规则

使用 Hook 的时候必须遵守 2 条规则:

  • 只能在代码的第一层调用 Hook,不能在循环、条件分支或者嵌套函数中调用 Hook;
  • 只能在函数组件自定义 Hook 中调用 Hook,不能在普通的 JS 函数中调用。

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 派发更新函数的执行,会让整个函数组件从头到尾执行一次,可以和 useMemousecallback 等 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 中去,避免导致闪烁。

点击查看二者的源码分析

注意:使用 useLayoutEffectSSR 服务端渲染可能会出现以下警告:

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 中,避免出现闪烁问题;
  • useLayoutEffectcomponentDidMount 是等价的,会同步调用,阻塞渲染;
  • 在服务端渲染时使用 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>
}

点击查看 ref 使用详情

3-2、缓存数据

useRef 还有个很重要的作用就是缓存数据,usestateuseReducer 可以保存当前的数据源,但它们更新数据源的函数执行会带来整个组件的重新渲染,如果在函数组件内部声明变量,则下一次更新也会重置,因此,如果想要悄悄的保存数据,而不触发函数的更新,就需要用到 useRef

const currenRef = useRef(InitialData)

useRef 的第一个参数可以用来初始化保存数据,这些数据可以在 current 属性上获取到,当然也可以对 current 赋值新的数据源:

currenRef.current = newValue

下面通过 react-redux 源码来看看 useRef 的巧妙运用(react-redux 在 react-hooks 发布后,用 react-hooks 重写了其中的 ProvideconnectAdvanced)核心模块,可以看到 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

点击查看更多 Context 使用详情

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

效果如下:

上面 useCallbacksetCount 作为依赖,只要 setCount 不改变,increment 函数就不变,相应的子组件接受到的 props 没改变,就不会 rerender。

7、useMemo

useMemouseCallback 类似,都是通过缓存来提升性能,区别在于 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 实现只暴露 valuefocus 给父级:

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 文件模拟实现了 useStateuseEffect 接口,其基本原理和 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 节点添加到链表中。

下面以 useStateuseEffect 两个最常用的 hook 为例,来分析 Hooks 如何与 Fiber 共同工作。

在每个状态 Hook(如 useState)节点中,会通过 queue 属性上的循环链表记住所有的更新操作,并在 update 阶段依次执行循环链表中的所有更新操作,最终拿到最新的 state 返回。

状态 Hooks 组成的链表的具体结构如下图所示:

在每个副作用 Hook(如 useEffect)节点中,创建 effect 挂载到 Hook 的 memoizedState 中,并添加到环形链表的末尾,该链表会保存到 Fiber 节点的 updateQueue 中,在 commit 阶段执行。

副作用 Hooks 组成的链表的具体结构如下图所示:

点击查看 Hook 实现原理详情

五、总结

什么是 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 中计算出最终输出的状态和执行相关的副作用。