自定义 Hook

自定义 Hook

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

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

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

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

Util

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

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,因为在项目当中不可避免的遇到与请求数据打交道的场景,其实简单来说,不管我们选择哪种方式来获取数据,Axios、Fetch 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 当中来进行执行,我们会传递 URL 和 options,而对于返回的 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

双向绑定

接着我们再来看一个平常在业务当中可能是会经常遇到的情况,那就是双向绑定,不过在这里我们会分别采用 HOC、Render Props 和 Hook 的三种实现方式,同时也可以对比一下它们几者之间的优缺点,下面我们就先从 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

×