React hooks 发布于 React V16.8.0,想了解 react-hooks 基本概念的同学可以参考 官方文档,想深入了解 useEffect 的同学可以看一下 Dan Abramov 写的 A complete guide to useEffect,对于 react-hooks 的 rules 有疑惑的同学可以参考 React hooks:not magic, just arrays 这一篇文章。
这篇文章的目的在于记录我在学习和应用 react-hooks 过程中的一些感悟,也希望能对你学习和应用 react-hooks 提供帮助。
State Management
之前 React 对于状态管理没有提供很好的支持,所以我们会依赖 redux 和 mobx 这些第三方的状态管理库。redux 很好地实践了 immutable 和 pure function;mobx 则是 mutable 和 reactive 的代表。现在使用 React 内置 useReducer 和 useContext 这两个 hook 可以让我们实现 redux 风格的状态管理;结合 useMemo 和 useEffect 可以模拟 mobx 的 computed 和 reaction。(hooks 没有使用 Proxy,所以跟 mobx 的代理响应是不一样的)
下面我们使用 hooks 实现一个原生的状态管理库。
Global Store
跟 redux 一样,我们使用 React Context 实现 global store:
// store.ts
import { useContext, createContext, useReducer } from 'react'
// User 和 Env 是 reducer 文件
import { default as User } from './User'
import { default as Env } from './Env'
// 创建一个全局 context
// 这里使用了 ReturnType 作为 Context 的范型声明
export const GlobalContext = createContext<ReturnType<typeof useGlobalStore>>(
null
)
// custom hook:封装所有全局数据,用于 GlobalContext.Provider 的 value 属性赋值
export function useGlobalStore() {
const currentUser = useReducer(User.reducer, User.init())
const env = useReducer(Env.reducer, Env.init())
return {
currentUser,
env,
}
}
// custom hook:实现了 GlobalContext.Consumer 的功能
export function useGlobal() {
return useContext(GlobalContext)
}
上面的代码定义了 store 模块作为项目的全局状态库,导出了三个实体:
- GlobalContext:全局 context,用于链接 store 和相关组件;
- useGlobalStore:自定义 hook,将多个数据模块封装在一起,用于 GlobalContext.Provider 的 value 属性;
- useGlobal:自定义 hook,用于子组件引用全局 store;
Provider
下面是使用 store 模块封装的 Provider 组件:
// GlobalProvider.tsx
import React from 'react'
import { GlobalContext, useGlobalStore } from './store'
export function GlobalProvider({ children }) {
const store = useGlobalStore()
return (
<GlobalContext.Provider value={store}>{children}</GlobalContext.Provider>
)
}
将 Provider 模块引用到项目根组件:
// App.tsx
import React from 'react'
import { GlobalProvider } from './components'
import { Home } from './Home'
export function App() {
return <GlobalProvider><Home /></GlobalProvider>
}
Consumer
现在可以在 Home 组件里面消费 store:
// Home.tsx
import React from 'react'
import { useGlobal } from './store'
export default function Home() {
const { currentUser } = useGlobal()
const [user, dispatch] = currentUser
// 使用 dispatch 修改用户姓名
function changeName(event) {
dispatch({
type: 'update_name',
payload: event.target.value
})
}
return <div>
<h2>{user.name}</h2>
<input onChange={changeName} />
</div>
}
Reducer
下面是定义 reducer 的 User.ts 代码:
// User.ts
function init() {
return {
name: ''
}
}
const reducer = (state, { type, payload }) => {
switch(type) {
case 'UPDATE_NAME': {
return {
...state,
name: payload
}
}
case 'UPDATE': {
return {
...state,
...payload
}
}
case 'INIT': {
return init()
}
default: {
return state
}
}
}
export default {
init,
reducer
}
Reducer Typing
上面的 store 已经能够正常运作了,但是还有优化空间,我们再次聚焦到 Home.tsx 上,有些同学应该已经发现了问题:
// Home.tsx
import React from 'react'
import { useGlobal } from './store'
export default function Home() {
const { currentUser } = useGlobal()
const [user, dispatch] = currentUser
function changeName(event) {
dispatch({
// 这里的 update_name 与 User.ts 中定义的 UPDATE_NAME 不一致
// 这是个 bug,但是只有在运行时会报错
type: 'update_name',
payload: event.target.value
})
}
return <div>
<h2>{user.name}</h2>
<input onChange={changeName} />
</div>
}
我们需要加一些类型提示🤔
Have a try
我们先确定下期望,我们希望 reducer typing 能带给我们的提示有:
- 在 Consumer 中调用 dispatch,输入 type 以后会显示该 reducer 相关的所有 action type 名称,输入不存在的 type 会有 错误提示;
- 在第一步输入正确的 type 以后,再输入 payload,会有对应 type 的 payload 的类型提示,输入不一致的 payload 会有 错误提示;
上面的粗体部分是我们期望 typing 提供的 4 个功能,让我们尝试解决一下:
// User.ts
// IUser 作为用户数据结构类型
type IUser = {
name: string
}
function init() {
return {
name: '',
}
}
// 使用 Union Types 枚举所有的 action
type ActionType =
| {
type: 'INIT'
payload: void
}
| {
type: 'UPDATE_NAME'
payload: string
}
| {
type: 'UPDATE'
payload: IUser
}
const reducer = (state, { type, payload }: ActionType) => {
switch (type) {
case 'UPDATE_NAME': {
return {
...state,
name: payload
}
}
case 'UPDATE': {
return {
...state,
...payload
}
}
case 'INIT': {
return init()
}
default: {
return state
}
}
}
export default {
init,
reducer,
}
看起来好像很不错,但是。。。
typescript 把 type 和 payload 两个字段分别做了 union,导致对象展开符报错了。
我们可以把 payload 定义成 any 解决这个问题,但是就会失去上面期望的对于 paylod 的类型提示。
一种更健壮的方案是使用 Type Guard,代码看起来会像下面这样:
// User.ts
type IUser = {
name: string
}
function init() {
return {
name: '',
}
}
type InitAction = {
type: 'INIT'
payload: void
}
type UpdateNameAction = {
type: 'UPDATE_NAME'
payload: string
}
type UpdateAction = {
type: 'UPDATE'
payload: IUser
}
type ActionType = InitAction | UpdateNameAction | UpdateAction
function isInitAction(action): action is InitAction {
return action.type === 'INIT'
}
function isUpdateNameAction(action): action is UpdateNameAction {
return action.type === 'UPDATE_NAME'
}
function isUpdateAction(action): action is UpdateAction {
return action.type === 'UPDATE'
}
const reducer = (state, action: ActionType) => {
if (isUpdateNameAction(action)) {
return {
...state,
name: action.payload,
}
}
if (isUpdateAction(action)) {
return {
...state,
...action.payload,
}
}
if (isInitAction(action)) {
return init()
}
return state
}
export default {
init,
reducer,
}
我们得到了我们想要的:
-
输入 type 以后会显示该 reducer 相关的所有 action type 名称
- 输入不存在的 type 会有 错误提示
-
payload 的类型提示
- 输入不一致的 payload 会有 错误提示
但是每写一个 action 都要加一个 guard,太浪费宝贵的时间了。让我们用 deox 这个库优化一下我们的代码:
// User.ts
import { createReducer, createActionCreator } from 'deox'
type IUser = {
name: string
}
function init() {
return {
name: '',
}
}
const reducer = createReducer(init(), action => [
action(createActionCreator('INIT'), () => init()),
action(
createActionCreator('UPDATE', resolve => (payload: IUser) =>
resolve(payload)
),
(state, { payload }) => ({
...state,
...payload,
})
),
action(
createActionCreator('UPDATE_NAME', resolve => (payload: string) =>
resolve(payload)
),
(state, { payload }) => ({
...state,
name: payload,
})
),
])
export default {
init,
reducer,
}
让我们再做一些小小的优化,把繁重的 createActionCreator 函数简化一下:
// User.ts
import { createReducer, createActionCreator } from 'deox'
type IUser = {
name: string
}
function init() {
return {
name: '',
}
}
// 简化以后的 createAction 需要调用两次
// 第一次调用使用类型推断出 action type
// 第二次调用使用范型声明 payload type
function createAction<K extends string>(name: K) {
return function _createAction<T = void, M = void>() {
return createActionCreator(name, resolve => (payload: T, meta?: M) =>
resolve(payload, meta)
)
}
}
const reducer = createReducer(init(), action => [
action(createAction('INIT')(), () => init()),
action(createAction('UPDATE')<IUser>(), (state, { payload }) => ({
...state,
...payload,
})),
action(createAction('UPDATE_NAME')<string>(), (state, { payload }) => ({
...state,
name: payload,
})),
])
export default {
init,
reducer,
}
大功告成,可以开始愉快地写代码了😊
useResize
开发前端页面的时候会遇到很多页面自适应的需求,这个时候子组件就需要根据父组件的宽高来调整自己的尺寸,我们可以开发一个获取父容器 boundingClientRect 的 hook,获取 boundingClientRect 在 React 官网有介绍:
function useClientRect() {
const [rect, setRect] = useState(null);
const ref = useCallback(node => {
if (node !== null) {
setRect(node.getBoundingClientRect());
}
}, []);
return [rect, ref];
}
我们扩展一下让它支持监听页面自适应:
// useLayoutRect.ts
import { useState, useEffect, useRef } from 'react'
export function useLayoutRect(): [
ClientRect,
React.MutableRefObject<any>
] {
const [rect, setRect] = useState({
width: 0,
height: 0,
left: 0,
top: 0,
bottom: 0,
right: 0,
})
const ref = useRef(null)
const getClientRect = () => {
if (ref.current) {
setRect(ref.current.getBoundingClientRect())
}
}
// 监听 window resize 更新 rect
useEffect(() => {
getClientRect()
window.addEventListener('resize', getClientRect)
return () => {
window.removeEventListener('resize', getClientRect)
}
}, [])
return [rect, ref]
}
你可以这么使用:
export function ResizeDiv() {
const [rect, ref] = useLayoutRect()
return <div ref={ref}>The width of div is {rect.width}px</div>
}
如果你的需求只是监听 window resize 的话,这个 hook 写到这里就可以了,但是如果你需要监听其他会引起界面尺寸变更的事件(比如菜单的伸缩)时要怎么办?让我们改造一下 useLayoutRect 让它更加灵活:
// useLayoutRect.ts
import { useState, useEffect, useRef } from 'react'
export function useLayoutRect(): [
ClientRect,
React.MutableRefObject<any>,
() => void
] {
...
const getClientRect = () => {
if (ref.current) {
setRect(ref.current.getBoundingClientRect())
}
}
...
// 额外导出 getClientRect 方法
return [rect, ref, getClientRect]
}
上面的代码我们多导出了 getClientRect 方法,然后我们可以组合成新的 useResize hook:
// useResize.ts
import { useLayoutRect } from '@/utils'
import { useGlobal } from './store'
import { useEffect } from 'react'
export function useResize(): [ClientRect, React.MutableRefObject<any>] {
// menuExpanded 存在 global store 中,用于获取菜单的伸缩状态
const { env } = useGlobal()
const [{ menuExpanded }] = env
const [rect, ref, resize] = useLayoutRect()
// 因为菜单伸缩有 300ms 的动画,我们需要加个延时
useEffect(() => {
setTimeout(resize, 300)
}, [menuExpanded])
return [rect, ref]
}
你可以在 useResize 中添加其他的业务逻辑,hooks 具有很好的组合性和灵活性👍
useReactRouter
Charles Stover 在 git 上发布了一个很好用的 react-router hook,可以在 functional component 中实现 withRouter 的功能。实现的原理可以参考他的这篇博客 How to convert withRouter to a react hook。使用方法如下:
import useReactRouter from 'use-react-router';
const MyPath = () => {
const { history, location, match } = useReactRouter();
return (
<div>
My location is {location.pathname}!
</div>
)
}
useDidUpdate
useEffect 的第二个参数传递空数组可以让 effect 只执行第一次,但是 React 没有提供让 useEffect 跳过第一次执行的机制(类似 componentDidUpdate),我们可以自己实现一个 useDidUpdate hook:
// useDidUpdate.ts
import { useRef, useEffect } from 'react'
export function useDidUpdate(fn, deps?: any[]) {
const didMountRef = useRef(false)
useEffect(() => {
if (didMountRef.current) {
fn()
} else {
didMountRef.current = true
}
}, deps)
}
usePromise
在使用 Promise 的时候我们需要追踪 Promise 的进行中(loading)、结果(result)、异常(error)状态,为了避免重复的 then catch 或者 try catch,我们可以提供一个对这些功能开箱即用的 custom hook:
// usePromise.ts
import { useState, useEffect } from 'react'
export function usePromise<T>(
fn: () => Promise<T>,
deps?: any[]
): [boolean, T, Error] {
const [state, setState] = useState({
loading: false,
result: null,
error: null,
})
useEffect(() => {
setState({
loading: true,
result: null,
error: null,
})
// fix race condition
let didCancel = false
const trigger = async () => {
let result
let error
try {
result = await fn()
} catch (err) {
error = err
} finally {
if (!didCancel) {
setState({
loading: false,
result,
error,
})
}
}
}
trigger()
return () => {
didCancel = true
}
}, deps || [])
return [state.loading, state.result, state.error]
}
结语
学习 react hooks 的过程中愈发觉得 hooks 的强大,也更加能理解为什么说 react hooks 是 future。相信随着时间的发展,社区能够创造出越来越的 custom hook,也会涌现出越来越多像 hooks 这样开创性的设计。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。