自定义 Hook

自定义 Hook

因为最近在项目当中使用了较多的 Hook 来进行开发,用到了不少的自定义 Hook,期间也踩了不少的坑,所以打算在这里简单的总结梳理一下

因为我们在之前的 React 中的 Hook 章节当中已经梳理过了关于 Hook 的一些基本使用内容,所以这里也就不再多做提及,我们主要来深入的了解一些自定义 Hook 的封装和使用方式

其实简单来说,就像之前我们介绍过的 HOCMixin 一样,我们之所以使用自定义 Hook,其实目的还是将组件中类似的状态逻辑抽取出来,自定义 Hook 的实现比较简单,我们只需要定义一个函数,并且把相应需要的状态和 effect 封装进去,同时 Hook 之间也是可以相互引用的,并且约定成俗的使用 use 开头来命名自定义 Hook,这样也可以方便我们使用 eslint 来进行检查

下面我们就来分别看看几类不同的 Hook 封装

Util

顾名思义也就是我们常用的工具类,一些比较常见的有 useFetchuseDebounceuseIntervaluseWindowSize 等等,下面我们就来看看它们具体如何实现

useWindowSize

这个不用介绍太多,定义十分简单,而使用方式也正如其名,我们可以使用它来获取元素 resize 后的长宽

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useEffect, useState } from 'react'

export default function useWindowSize(el) {
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
})
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
})
}
window.addEventListener('resize', handleResize)
handleResize()
return () => window.removeEventListener('resize', handleResize)
}, [el])
return windowSize
}

useFetch

这个也是我们平常接触较多的一个自定义 Hook,因为在项目当中不可避免的遇到与请求数据打交道的场景,其实简单来说,不管我们选择哪种方式来获取数据,AxiosFetch API,还是其它,我们很有可能在 React 组件当中一次又一次地编写相同的代码,因此我们来看看如何构建一个简单但有用的自定义 Hook,以便在需要在应用程序内部获取数据时调用该 Hook

这里我们就姑且称其为 useFetch,因为我们这里就使用最为基本的 Fetch API 来进行介绍,这个 Hook 可以接受两个参数,一个是获取数据所需查询的 URL,另一个是表示要应用于请求的选项的对象,它的大致轮廓如下

1
2
3
4
5
import { useState, useEffect } from 'react'

const useFetch = (url = '', options = null) => {}

export default useFetch

由于获取数据是一个副作用,所以我们应该在 useEffect 当中来进行执行,我们会传递 URLoptions,而对于返回的 Promise 则使用 json() 方法处理后将它存储在一个 state 变量中即可

1
2
3
4
5
6
7
8
9
10
11
12
import { useState, useEffect } from 'react'

const useFetch = (url = '', options = null) => {
const [data, setData] = useState(null)
useEffect(() => {
fetch(url, options)
.then(res => res.json())
.then(data => setData(data))
}, [url, options])
}

export default useFetch

现在一个最基本的轮廓已经有了,但是我们还需要处理网络错误,以防我们的请求出错,所以我们要用另一个 state 变量来存储错误,这样我们就能从 Hook 中返回它并能够判断是否发生了错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { useState, useEffect } from 'react'

const useFetch = (url = '', options = null) => {
const [data, setData] = useState(null)
const [error, setError] = useState(null)

useEffect(() => {
fetch(url, options)
.then(res => res.json())
.then(data => {
setData(data)
setError(null)
})
.catch(error => {
setError(error)
setData(null)
})
}, [url, options])
}

export default useFetch

处理完了数据和错误之后,下面我们再来看看返回值,我们的 useFetch 应该返回一个对象,其中包含从 URL 中获取的数据,并且如果发生了任何错误,则应该返回错误

另外为了表明异步请求的状态,比如在呈现结果之前显示 loading,所以我们还需要添加第三个 state 变量来跟踪请求的状态,在请求之前将 loading 设置为 true,并在请求之后完成后设置为 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const useFetch = (url = '', options = null) => {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)

useEffect(() => {
setLoading(true)
fetch(url, options)
.then(res => res.json())
.then(data => {
setData(data)
setError(null)
})
.catch(error => {
setError(error)
setData(null)
})
.finally(() => setLoading(false))
}, [url, options])

return { loading, error, data }
}

最后,在使用 userFetch 之前,我们还有一件事情需要处理,那就是我们需要检查使用我们 Hook 的组件是否仍然被挂载,以更新我们的状态变量,否则会有内存泄漏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { useState, useEffect } from 'react'

const useFetch = (url = '', options = null) => {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)

useEffect(() => {
let isMounted = true

setLoading(true)

fetch(url, options)
.then(res => res.json())
.then(data => {
if (isMounted) {
setData(data)
setError(null)
}
})
.catch(error => {
if (isMounted) {
setError(error)
setData(null)
}
})
.finally(() => isMounted && setLoading(false))

return () => (isMounted = false)
}, [url, options])

return { loading, error, data }
}

export default useFetch

这样一来我们就完成了一个比较通用的 userFetch 方法,下面我们再来看看如何进行使用,其实我们只需要传递我们想要检索的资源的 URL 即可,然后我们可以得到一个对象,这样我们就可以使用得到的数据来渲染我们的应用程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import useFetch from './useFetch'

const App = () => {
const { loading, error, data = [] } = useFetch('url')

if (error) return <p>Error!</p>
if (loading) return <p>Loading...</p>

return (
<div>
<ul>
{data?.map(item => (
<li key={item.id}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
</div>
)
}

export default App

useEventListener

这个 Hook 也是我们平常使用较多的一种,它主要负责在组件内部设置和清理事件监听器,它接收如下几个参数

  • eventType 事件类型
  • listener 监听函数
  • target 监听对象
  • options 可选参数

基本轮廓如下

1
2
3
4
5
6
7
8
9
10
import { useEffect, useRef } from 'react'

const useEventListener = (
eventType = '',
listener = () => null,
target = null,
options = null
) => {}

export default useEventListener

与上面的 useFetch 一样,我们使用 useEffect 来添加一个事件监听器,首先我们需要确保 target 是否支持 addEventListener 方法,否则我们什么也不做

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useEffect, useRef } from 'react'

const useEventListener = (
eventType = '',
listener = () => null,
target = null,
options = null
) => {
useEffect(() => {
if (!target?.addEventListener) return
}, [target])
}

export default useEventListener

然后我们可以添加实际的事件监听器并在卸载函数中删除它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useEffect, useRef } from 'react'

const useEventListener = (
eventType = '',
listener = () => null,
target = null,
options = null
) => {
useEffect(() => {
if (!target?.addEventListener) return

target.addEventListener(eventType, listener, options)

return () => {
target.removeEventListener(eventType, listener, options)
}
}, [eventType, target, options, listener])
}

export default useEventListener

但是通常来说,更为常见的做法是使用一个引用对象来存储和持久化监听器函数,只有当监听器函数发生变化并在事件监听器方法中使用该引用时,我们才会更新该引用,也就像下面这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useEffect, useRef } from 'react'

const useEventListener = (
eventType = '',
listener = () => null,
target = null,
options = null
) => {
const savedListener = useRef()

useEffect(() => {
savedListener.current = listener
}, [listener])

useEffect(() => {
if (!target?.addEventListener) return

const eventListener = event => savedListener.current(event)

target.addEventListener(eventType, eventListener, options)

return () => {
target.removeEventListener(eventType, eventListener, options)
}
}, [eventType, target, options])
}

export default useEventListener

这样一来我们就不需要从此 Hook 返回任何内容,因为我们只是侦听事件并运行处理程序函数传入作为参数

以下面这个组件为例,我们来看看如何使用,该组件的作用是检测 DOM 元素外部的点击,如果用户单击对话框组件,则在此处关闭对话框组件,这里关于样式部分的处理已经剔除掉了,只保留了逻辑的部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { useRef } from 'react'
import ReactDOM from 'react-dom'
import { useEventListener } from './hooks'

const Dialog = ({ show = false, onClose = () => null }) => {
const dialogRef = useRef()

useEventListener(
'mousedown',
event => {
if (event.defaultPrevented) {
return
}
if (dialogRef.current && !dialogRef.current.contains(event.target)) {
onClose()
}
},
window
)

return show
? ReactDOM.createPortal(<div ref={dialogRef}>dialog</div>, document.body)
: null
}

export default Dialog

useLocalStorage

这个 Hook 主要有两个参数,一个是 key,一个是 value,轮廓如下

1
2
3
4
5
import { useState } from 'react'

const useLocalStorage = (key = '', initialValue = '') => {}

export default useLocalStorage

这个 Hook 会返回一个数组,类似于使用 useState 获得的数组,因此此数组将包含有状态值和在将其持久存储在 localStorage 中时对其进行更新的函数,下面我们先来创建将与 localStorage 同步的 React 状态变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useState } from 'react'

const useLocalStorage = (key = '', initialValue = '') => {
const [state, setState] = useState(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.log(error)
return initialValue
}
})
}

export default useLocalStorage

这里我们使用惰性初始化来读取 localStorage 以获取键的值,如果找到该值,则解析该值,否则返回传入的 initialValue,如果在读取 localStorage 时出现错误,我们只记录一个错误并返回初始值

最后我们需要创建 update 函数来返回它将在 localStorage 中存储任何状态的更新,而不是使用 useState 返回的默认更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useState } from 'react'

const useLocalStorage = (key = '', initialValue = '') => {
const [state, setState] = useState(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
return initialValue
}
})

const setLocalStorageState = newState => {
try {
const newStateValue = typeof newState === 'function' ? newState(state) : newState
setState(newStateValue)
window.localStorage.setItem(key, JSON.stringify(newStateValue))
} catch (error) {
console.error(`Unable to store new value for ${key} in localStorage.`)
}
}
return [state, setLocalStorageState]
}

export default useLocalStorage

更新函数可以同时更新 React 状态和 localStorage 中的相应键值,另外还可以支持函数更新,例如常规的 useState,最后我们返回状态值和我们的自定义更新函数,最后我们再来看看如何进行使用,比如将组件中的数据持久化到 localStorage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { useLocalStorage } from './hooks'

const defaultSettings = {
notifications: 'weekly',
}

const App = () => {
const [appSettings, setAppSettings] = useLocalStorage(
'app-settings',
defaultSettings
)

return (
<div>
<div>
<p>Your application's settings:</p>
<select
value={appSettings.notifications}
onChange={e =>
setAppSettings(settings => ({
...settings,
notifications: e.target.value,
}))
}
>
<option value="daily">daily</option>
<option value="weekly">weekly</option>
<option value="monthly">monthly</option>
</select>
</div>
<button onClick={() => setAppSettings(defaultSettings)}>
Reset settings
</button>
</div>
)
}

export default App

useMediaQuery

这个 Hook 可以帮助我们在组件中监控媒体查询,例如当我们需要渲染不同的 UI 取决于设备的类型或特定的特征,它接受三个参数

  • 首先,对应媒体查询的字符串数组
  • 然后,以与前一个数组相同的顺序匹配这些媒体查询的值数组
  • 最后,如果没有匹配的媒体查询,则使用默认值
1
2
3
4
5
import { useState, useCallback, useEffect } from 'react'

const useMediaQuery = (queries = [], values = [], defaultValue) => {}

export default useMediaQuery

我们在这个 Hook 中做的第一件事是为每个匹配的媒体查询构建一个媒体查询列表,使用这个数组通过匹配媒体查询来获得相应的值

1
2
3
4
5
6
7
import { useState, useCallback, useEffect } from 'react'

const useMediaQuery = (queries = [], values = [], defaultValue) => {
const mediaQueryList = queries.map(q => window.matchMedia(q))
}

export default useMediaQuery

为此我们创建了一个包装在 useCallback 中的回调函数,检索列表中第一个匹配的媒体查询的值,如果没有匹配则返回默认值

1
2
3
4
5
6
7
8
9
10
11
12
import { useState, useCallback, useEffect } from 'react'

const useMediaQuery = (queries = [], values = [], defaultValue) => {
const mediaQueryList = queries.map(q => window.matchMedia(q))

const getValue = useCallback(() => {
const index = mediaQueryList.findIndex(mql => mql.matches)
return typeof values[index] !== 'undefined' ? values[index] : defaultValue
}, [mediaQueryList, values, defaultValue])
}

export default useMediaQuery

然后我们创建一个 React 状态来存储匹配的值,并使用上面定义的函数来初始化它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useState, useCallback, useEffect } from 'react'

const useMediaQuery = (queries = [], values = [], defaultValue) => {
const mediaQueryList = queries.map(q => window.matchMedia(q))

const getValue = useCallback(() => {
const index = mediaQueryList.findIndex(mql => mql.matches)
return typeof values[index] !== 'undefined' ? values[index] : defaultValue
}, [mediaQueryList, values, defaultValue])

const [value, setValue] = useState(getValue)
}

export default useMediaQuery

最后我们在 useEffect 中添加一个事件监听器来监听每个媒体查询的更改,当发生变化时我们运行更新函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useState, useCallback, useEffect } from 'react'

const useMediaQuery = (queries = [], values = [], defaultValue) => {
const mediaQueryList = queries.map(q => window.matchMedia(q))

const getValue = useCallback(() => {
const index = mediaQueryList.findIndex(mql => mql.matches)
return typeof values[index] !== 'undefined' ? values[index] : defaultValue
}, [mediaQueryList, values, defaultValue])

const [value, setValue] = useState(getValue)

useEffect(() => {
const handler = () => setValue(getValue)
mediaQueryList.forEach(mql => mql.addEventListener('change', handler))

return () =>
mediaQueryList.forEach(mql => mql.removeEventListener('change', handler))
}, [getValue, mediaQueryList])

return value
}

export default useMediaQuery

下面我们再来测试一下,比如添加一个媒体查询来检查设备是否允许用户悬停在元素上,如果用户可以悬停或应用基本样式,我就可以添加特定的不透明样式

1
2
3
4
5
6
7
8
9
10
11
12
import { useMediaQuery } from './hooks'

function App() {
const canHover = useMediaQuery(['(hover: hover)'], [true], false)
const canHoverClass = 'opacity-0 hover:opacity-100 transition-opacity'
const defaultClass = 'opacity-100'
return (
<div className={canHover ? canHoverClass : defaultClass}>Hover me!</div>
)
}

export default App

useDarkMode

简单来说,这个 Hook 的主要作用就是按需启用和禁用 dark 模式,其实也就是一个在网页当中比较常见的效果,那就是主题切换

我们将当前状态存储在 localStorage 中,在 localStorage 中初始化,存储和保留当前状态(暗或亮模式),这里我们就可以借用上面介绍到的 useLocalStorage 来进行实现

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useEffect } from 'react'
import useMediaQuery from './useMediaQuery'
import useLocalStorage from './useLocalStorage'

const useDarkMode = () => {
const preferDarkMode = useMediaQuery(
['(prefers-color-scheme: dark)'],
[true],
false
)
}

export default useDarkMode

最后就是触发 useEffect 以向 document.body 元素添加或删除 dark 类,这样我们就可以简单地将 dark 样式应用于我们的应用程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useEffect } from 'react'
import useMediaQuery from './useMediaQuery'
import useLocalStorage from './useLocalStorage'

const useDarkMode = () => {
const preferDarkMode = useMediaQuery(
['(prefers-color-scheme: dark)'],
[true],
false
)

const [enabled, setEnabled] = useLocalStorage('dark-mode', preferDarkMode)

useEffect(() => {
if (enabled) {
document.body.classList.add('dark')
} else {
document.body.classList.remove('dark')
}
}, [enabled])

return [enabled, setEnabled]
}

export default useDarkMode

API

比如我们有一个公用的城市列表接口,在用 Redux 的时候可以放在全局公用,有了 Hook 以后我们只需要 use 一下就可以在其他地方复用了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useState, useEffect } from 'react'
import { getCityList } from '@/services/static'

const useCityList = (params) => {
const [cityList, setList] = useState([])
const [loading, setLoading] = useState(true)
const getList = async () => {
const { success, data } = await getCityList(params)
if (success) setList(data)
setLoading(false)
}
useEffect(() => { getList() }, [])
return {
cityList,
loading
}
}

// 使用
const { cityList, loading } = useCityList('beijing')

UI

我们在平常开发过程当中也会遇到一些和 UI 绑定在一起的 Hook,最为常见的就是模态框,比如下面这个模态框示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import React, { useState } from 'react'
import { Modal } from 'antd'

export default function useModal(key = 'open') {
const [opens, setOpen] = useState({
[key]: false
})
const onCancel = () => {
setOpen({ [key]: false })
}
const showModal = (type = key) => {
setOpen({ [type]: true })
}
const MyModal = (props) => {
return <Modal key={key} visible={opens[key]} onCancel={onCancel} {...props} />
}
return {
showModal,
MyModal,
}
}

// 使用
const { showModal, MyModal } = useModal()
return (
<>
<button onClick={showModal}>展开</button>
<MyModal onOk={console.log} />
</>
)

Logic

逻辑类,比如我们有一个点击用户头像关注用户或者取消关注的逻辑,可能在评论列表、用户列表都会用到,我们可以这样做

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useState, useEffect } from 'react'
import { followUser } from '@/services/user'

const useFollow = ({ accountId, isFollowing }) => {
const [isFollow, setFollow] = useState(false)
const [operationLoading, setLoading] = useState(false)
const toggleSection = async () => {
setLoading(true)
const { success } = await followUser({ accountId })
if (success) {
setFollow(!isFollow)
}
setLoading(false)
}
useEffect(() => {
setFollow(isFollowing)
}, [isFollowing])
return {
isFollow,
toggleSection,
operationLoading
}
}

export default useFollow

双向绑定

接着我们再来看一个平常在业务当中可能是会经常遇到的情况,那就是双向绑定,不过在这里我们会分别采用 HOCRender PropsHook 的三种实现方式,同时也可以对比一下它们几者之间的优缺点,下面我们就先从 HOC 的实现方式开始看起

HOC

首先我们定义了一个高阶组件 HocInput 和一个普通组件 Input,在返回的时候我们使用高阶组件 HocInput()Input 包裹以后在进行返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const HocInput = WrapperComponent =>
class extends React.Component {
state = {
value: this.props.initialValue
}
onChange = e => {
this.setState({ value: e.target.value })
if (this.props.onChange) {
this.props.onChange(e.target.value)
}
}
render() {
const newProps = {
value: this.state.value,
onChange: this.onChange
}
return <WrapperComponent {...newProps} />
}
}

const Input = props => (
<>
<p>{props.value}</p>
<input placeholder="input" {...props} />
</>
)

export default HocInput(Input)

然后像下面这样使用既可

1
<HocInput initialValue="init" onChange={val => console.log(`HocInput`, val) } />

Render Props

HOC 不同的是,我们这次在 render() 的时候返回的是 this.props.children()props 接受两个参数,初始值 initialValue 以及 onChange 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class HocBind extends React.Component {
constructor(props) {
super(props)
this.state = {
value: props.initialValue
}
}
onChange = e => {
this.setState({ value: e.target.value })
if (this.props.onChange) {
this.props.onChange(e.target.value)
}
}
render() {
return (
<>
{this.props.children({
value: this.state.value,
onChange: this.onChange
})}
</>
)
}
}

export default HocBind

使用

1
2
3
4
5
6
<HocBind initialValue="init" onChange={val => console.log(`HocBind`, val) } >
{props => (<>
<p>{props.value}</p>
<input placeholder="input" {...props} />
</>)}
</HocBind>

Hook

最后我们再来看看 Hook 的实现方式,我们定义了一个 useBind 方法,接受一个 initialValue 参数作为默认输入,使用的时候我们使用 inputProps 来接收调用 useBind() 以后的返回值,然后在使用的时候我们就可以直接用 inputProps.value,然后将剩余的 props(也就是 onChange)传递给 input

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function useBind(initialValue) {
const [value, setValue] = useState(initialValue || '')
const onChange = e => {
setValue(e.target.value)
}
return { value, onChange }
}

function InputBind() {
const inputProps = useBind('init')
return (
<div>
<p>{inputProps.value}</p>
<input {...inputProps} />
</div>
)
}

class App extends Component {
render() {
return <InputBind />
}
}

export default App
# React

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×