4

        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 对于状态管理没有提供很好的支持,所以我们会依赖 reduxmobx 这些第三方的状态管理库。redux 很好地实践了 immutable 和 pure function;mobx 则是 mutable 和 reactive 的代表。现在使用 React 内置 useReduceruseContext 这两个 hook 可以让我们实现 redux 风格的状态管理;结合 useMemouseEffect 可以模拟 mobx 的 computedreaction。(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 模块作为项目的全局状态库,导出了三个实体:

  1. GlobalContext:全局 context,用于链接 store 和相关组件;
  2. useGlobalStore:自定义 hook,将多个数据模块封装在一起,用于 GlobalContext.Provider 的 value 属性;
  3. 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 能带给我们的提示有:

  1. 在 Consumer 中调用 dispatch,输入 type 以后会显示该 reducer 相关的所有 action type 名称,输入不存在的 type 会有 错误提示
  2. 在第一步输入正确的 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,
}

        看起来好像很不错,但是。。。

屏幕快照 2020-01-06 下午6.02.01.png

        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,
}

        我们得到了我们想要的:

  1. 输入 type 以后会显示该 reducer 相关的所有 action type 名称
    屏幕快照 2020-01-06 下午6.27.52.png
  2. 输入不存在的 type 会有 错误提示
    屏幕快照 2020-01-06 下午6.28.26.png
  3. payload 的类型提示
    屏幕快照 2020-01-06 下午7.14.00.png
  4. 输入不一致的 payload 会有 错误提示
    屏幕快照 2020-01-06 下午7.14.51.png

        但是每写一个 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 这样开创性的设计。


xh4722
240 声望11 粉丝