因为最近在项目当中使用了较多的 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
|
这个 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
|