React Hooks 最佳实践:更换 Vue 3.0 (大雾)

最近在使用 React Hooks 的过程中,有一些经验和踩的一些坑,在此做个总结。

Hook 的使用准则

只在最顶层使用 Hook,千万不能在条件判断和循环里使用

React hooks:not magic,just arrays

TSX
let firstRender = true
function RenderFunctionComponent() {
  let initName
  if (firstRender) {
    ;[initName] = useState("Rudi")
    firstRender = false
  }
  const [firstName, setFirstName] = useState(initName)
  const [lastName, setLastName] = useState("Yardley")
  return <Button onClick={() => setFirstName("Fred")}>Fred</Button>
}

React 内部记录 Hook 的状态取决于调用的顺序,当你在条件判断里调用,会打乱 Hook 的调用顺序,记录下来的状态和调用的 Hook 就匹配不上了,可能就会出现异常。循环或者遍历的话,由于不能保证每次渲染的时候 hook 调用次数是一致的,因此也会造成顺序不一致。如果你这样使用了,React 会直接给你报错。

下面这种情况不注意可能就出错了

TSX
function App(props) {
  const [posts] = usePosts(props.tag)

  if (!posts.length) {
    return <div>No Posts!</div>
  }

  const hotPosts = useMemo(() => {
    return posts.filter(p => p.read_num > 10000)
  }, [posts])

  return <Hot posts={hotPosts} />
}

useState

useState 是 useReducer 的语法糖。可以通过 useReducer 来实现 useState

TSX
function useState(initialState) {
  return useReducer(
    (state, action) => {
      if (typeof action === "function") {
        return action(state)
      }
      return action
    },
    typeof initialState === "function" ? initialState() : initialState
  )
}

实际上,useState 源码上也是使用 useReducer 实现的。

相比较于 class 组件的 setState,useState 返回的 dispatch 有些不同:

  1. dispatch 不会替你做对象的合并操作,你传进去什么,state 就是什么。
  2. dispatch 不像 this.setState(state, callback) 那样有第二个参数回调来给你执行一些更新后的副作用

除此之外,class 组件我们所有状态都是声明在 this.state 这个对象里,useState 我们更建议将不相关的状态单独声明。

TSX
// good
const [count, setCount] = useState(0)
const [step, setStep] = useState(1)

// bad
const [state, setState] = useState({ count: 0, step: 1 })

// good
const person = useState({ name: "xxx", age: 18 })

// bad
const [name, setName] = useState("xxx")
const [age, setAge] = useState(18)

上文提到,useState 不会做对象的合并操作。所以你传入的如果是一个对象,你要修改状态的话会有些麻烦。你可能需要通过 setState ({...state}) 这样的方式来更新,如果对象层级比较深就更麻烦了。所以还是建议以 immutable 的方式封装一个自定义 Hook 来替代 useState。例如 immer

TSX
import produce from "immer"

function useImmer(initialState) {
  const [state, setState] = React.useState(initialState)

  const update = React.useCallback(updater => {
    setState(produce(updater))
  }, [])

  return [state, update]
}

useReducer

useReducer 来源于 Redux 的 reducer,可以认为是一个组件粒度的 Redux。对于组件内的业务逻辑,我们应该尽量使用 useReducer 将相关状态组织到一起,而不是分散使用 useState。

TSX
type State = {
  loading: boolean;
  error: boolean;
  data: any;
};
type ActionTypes = 'Success' || 'Error' || 'Pending'
type Action = {
  payload: any;
  type: ActionTypes
}

function reducer(state: State, action: Action):State {
  switch (action.type) {
    case 'Pending':
      return {...state, loading: true, error: false }
    case 'Error':
      return {...state, error: true, loading: false }
    case 'Success': {
      return {error: false, loading: false, data: action.payload}
    }
  }
}

function useModel(initialState: State) {
  const [state, dispatch] = useReducer(reducer, initialState);

  const fetchArticle = (tag: 'vue' | 'react' | 'angular') => {
    // 可以在这里面做更多的业务逻辑处理, 比如竞态, debounce 等等
    dispatch({type: 'Pending'})
    fetch(`/api/article?tag=${tag}`).then(res => {
      dispatch({type: 'Success', payload: res})
    }).catch(() => {
      dispatch({type: 'Error'})
    })
  }

  const fetchVue = () => fetchArticle('vue')
  const fetchReact = () => fetchArticle('react')
  const fetchAngular = () => fetchArticle('angular')

  return {
    state,
    fetchVue,
    fetchReact,
    fetchAngular
  }
}

我们利用 useReducer 封装了一个自定义 Hook useModel,而我们的组件,只需要负责调用 useModel 返回的方法,组件就可以重点关注交互逻辑,而不是具体的业务逻辑。

除此以外,关于 reducer 的写法,推荐一篇文章用状态机来替代 isLoading

React.memo

父组件传进来的 props,只要是对象,就要注意两点:

  1. immutable,当对象更新时,一定要确保引用更新,否则子组件不会更新,
  2. 对象没有更新时,要确保引用不变,否则性能优化就全白费了。

比如下面的 React.memo 是无效的,因为每次渲染时 b 的引用都不同。

TSX
const Child = React.memo(props => {
  return <div></div>
})

const a = {}
export default function App() {
  const forceUpdate = useForceUpdate()
  const b = {}

  return (
    <>
      <button onClick={forceUpdate}>force update</button>
      <Child a={a} b={b} />
    </>
  )
}

如果使用了 useContext,当 Provider 的值更新了,那么 React.memo 也是无效并且重渲染的。所以我发现了一个有趣的 Demo。

TSX
const CounterContext = React.createContext()

const Counter = React.memo(
  props => {
    const value = React.useContext(CounterContext)
    return (
      <div>
        <div>Prop: {props.value}</div>
        <div>Provider: {value}</div>
      </div>
    )
  },
  () => true
)

export default function App() {
  const [value, setValue] = React.useState(1)
  const handleClick = () => {
    setValue(v => v + 1)
  }

  return (
    <div>
      <CounterContext.Provider value={value}>
        <Counter value={value} />
      </CounterContext.Provider>
      <button onClick={handleClick}>Add</button>
    </div>
  )
}

点击按钮,<Counter value={value}/> 组件会重新渲染,但是,props.value 是不会更新的,来自 Provider 的 value 是会更新的。所以在没有看过 React 源码的情况下,我只能猜测:当 React.memo 的第二个参数 areEqual 函数返回 true 时,并不是简简单单的直接返回上次渲染的 vdom。

除此之外,还发现 React 可以做到上游组件重渲染,下游组件重渲染,而自己不重渲染的....

TSX
const CounterContext = React.createContext()

const Child = () => {
  console.log("Child render")
  const value = React.useContext(CounterContext)
  return <div>Child: {value}</div>
}

const Counter = React.memo(
  props => {
    console.log("Counter render")
    return (
      <div>
        <div>Prop: {props.value}</div>
        <Child />
      </div>
    )
  },
  () => true
)

export default function App() {
  const [value, setValue] = React.useState(1)
  const handleClick = () => {
    setValue(v => v + 1)
  }
  console.log("App render")
  return (
    <div>
      <CounterContext.Provider value={value}>
        <Counter value={value} />
      </CounterContext.Provider>
      <button onClick={handleClick}>Add</button>
    </div>
  )
}

这个 demo,点击按钮,控制台输出 App render 和 Child render,不会输出 Counter render。

可以在这里试试

useMemo

我们常常把 useMemo 当作性能优化的手段。实际上,有些情况下,效果可能会适得其反。

写过 Vue 的人应该都能想到 useMemo 和 Vue 的 computed 很像,两者都是对一个值做缓存,当依赖不变时直接使用缓存的结果,当依赖变化时才会自动的重新计算。但由于框架机制的不同,Vue 依赖侦测的机制,可以在依赖变动时自动通知 computed 更新重新计算。而 React 是在组件重渲染时,虽然 useMemo 不一定每次都会重新计算,但是 React 去浅比较 (shallowEqual) 依赖数组里的每一项是否变化这一步是必不可少的。这个比较显然导致了一部分的开销,另外一部分的开销是调用 hook 时本身的开销。从这两点我们可以得出一个结论,如果计算的开销 < React 比较依赖的开销 + hook 本身的开销,那么使用 useMemo 其实是负优化。在简单的计算场景下,计算开销往往都非常小,使用 useMemo 是得不偿失的。因为当你使用了之后,就需要维护依赖 (我不认为 eslint-plugin-react-hooks 是个完美的解决方案),如果哪天需求变更,忘记把依赖加到数组里,Bug 就产生了。

useMemo 带来的问题:

  • 要缓存数据,势必会带来更大的内存开销。
  • 除非计算逻辑复杂,否则 useMemo 的调用开销更大。
  • useMemo 主要是防止重渲染,需要配合 React.memo 使用,但除此之外,还有很多地方需要注意,否则很可能加了也没用。如果性能不敏感,全部使用 useMemo 和 React.memo 收益很小。

因此,当你使用 useMemo 时,请明确是否真的需要 useMemo。如果一个状态,它的依赖每次渲染都会变动,或者本身没有开销大的计算,也不是作为一个对象当作 prop 传入子组件,那么直接声明成变量就好了。 总结了下,使用 useMemo 时至少需要满足的以下任一条件:

  1. 这个计算的操作确实比较昂贵,比如数组遍历,循环等等。
  2. 这个计算操作的返回值是个对象,并且会作为其他 hook (包括子组件 hook) 或 React.memo 组件的依赖。
  3. 这是在自定义 hook 中使用。

Dan 关于 PureComponent 的评论

总的来说,在明确会有性能问题的场景下,我们才需要使用 useMemo。

除了这个,其实我们还可以使用 useMemo 来实现 useCallback 和 useRef。

TSX
function useCallback(cb, deps) {
  return useMemo(() => (...args) => cb(...args), deps)
}

const useRef = value => {
  return useMemo(() => ({ current: value }), [])
}

可以看到,useCallback 其实就相当于 useMemo 返回一个函数。useRef 则是通过传一个空的依赖数组,那么 useMemo 返回值就不会更新,这样返回的就是一个引用不变的对象了,并且你可以直接修改这个对象上的 current 属性,这个 useRef 也是可以用在 DOM,React.forwardRef 和 useImperativeHandle 上的。

我们也可以用 useRef 实现 useMemo (禁止套娃)

TSX
function useMemo(getter, deps = []) {
  const depsRef = useRef(deps)
  const valueRef = useRef(getter())

  if (deps && shallowEqualDeps(depsRef.current, deps)) {
    return valueRef.current
  }
  depsRef.current = deps
  return (valueRef.current = getter())
}

能实现 useMemo,肯定也就能实现 useCallBack。下文有另一个版本 useCallback,也是通过 useRef 实现,那个版本不需要管理依赖也能达到 useCallback 的效果。

useCallback

useCallback 是 useMemo 的语法糖,看上面那个代码应该就能明白。

但是 useCallback 问题更严重一些。React Hooks 是基于闭包的。

一个经典的例子。

TSX
class TickC extends React.PureComponent {
  handleClick = () => {
    setInterval(() => {
      const count = this.props.count
      console.log("class:", count)
    }, 500)
  }

  render() {
    return <button onClick={this.handleClick}>Class</button>
  }
}

function TickF(props) {
  const handleClick = useCallback(() => {
    setInterval(() => {
      console.log("function:", props.count)
    }, 500)
  }, [])

  return <button onClick={handleClick}>Function</button>
}

export default function Demo() {
  const [count, setCount] = React.useState(0)
  const add = () => setCount(c => ++c)

  return (
    <div>
      <TickF count={count} />
      <TickC count={count} />
      <button onClick={add}>Add</button>
    </div>
  )
}

点击两次按钮,在 class 版本中,定时器会输出当前最新的 count 的值,而 Hooks 版本的定时器中,只会输出 0。原因是 useEffect 只会在我们的组件中调用一次,也就是首次渲染完之后。而我们传给 useEffect 的函数,引用了 count 变量,形成了一个闭包。这就把此时 (首次渲染) 的 count 的给记住了。

利用闭包,class 组件也能做到函数组件一样的行为。

TSX
class TickC extends React.PureComponent {
  handleClick = () => {
    const count = this.props.count
    setInterval(() => {
      console.log("class:", count)
    }, 500)
  }
}

实际上,如果这个函数不会作为其他 hook 的依赖,就不需要 useCallback,否则反而影响性能。尤其注意 <div onClick={handleClick}></div> 这种用法,根本就没有任何效果。

思考一下,我们希望的 useCallback 是怎样的?

  1. 引用不变
  2. 永远获取到最新值

我们有什么办法可以解决这两个问题?

  • 如果是一个纯函数,直接提取到组件外面,虽然这个函数每次渲染都会不断的创建,销毁。但是这点性能开销几乎可以忽略不计。
TSX
const filterOdd = arr => arr.filter(num => num % 2 === 0)

function App() {
  return <List filter={filterOdd} />
}
  • 利用 useRef 的引用不变封装一个 useCallback
TSX
function useCallback(cb) {
  const ref = useRef(cb)
  ref.current = cb
  return useRef((...args) => {
    ref.current.apply(this, args)
  }).current
}

补充阅读:

useEffect

TSX
const doSomethingA = () => {
  /* ... */
}
const doSomethingB = () => {
  /* ... */
}

useEffect(() => {
  doSomethingA()
  doSomethingB()
}, [...deps, doSomethingA, doSomethingB])

如果一个函数只在 useEffect 里使用,可以直接在 useEffect 内部声明,这样你就不用处理他的依赖。

TSX
useEffect(() => {
  const doSomethingA = () => {
    /* ... */
  }
  const doSomethingB = () => {
    /* ... */
  }

  doSomethingA()
  doSomethingB()
}, deps)

日后你如果需要添加一个 doSomethingC 的逻辑,就不用漏掉依赖了。

我觉得最好的使用方式是,一个 useEffect 做一件事

TSX
useEffect(() => {
  const doSomethingA = () => {
    /* ... */
  }
  doSomethingA()
}, [...deps])

useEffect(() => {
  const doSomethingB = () => {
    /* ... */
  }
  doSomethingB()
}, [...deps])

内部分享的时候有同事问:

Q:如果 doSomethingA 依赖了某个变量 a,需要把这个 a 添加到 deps 里头吗?

A:实际上,这里没有使用 useCallback,每次渲染传给 useEffect 的都是一个新的匿名箭头函数。当 useEffect 被调用,声明的 doSomethingA 也是新的,因此使用的 a 变量也是最新的。只要你的 useEffect 调用时机不依赖变量 a,就可以不添加。否则就会造成,虽然 doSomethingA 能访问到的 a 是最新的,但是 useEffect 没有被调用。

useEffect 会在组件渲染完成之后调用。因此我们常常会封装一个 useMount。

TSX
function useMount(effect) {
  useEffect(effect, [])
}

useMount 里的 hook 只会在组件挂载完后调用一次。然而这里的 mount,和 class 组件的 componentDidMount 生命周期的调用时机其实是不一样的。

与 componentDidMount、componentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会延迟调用

你可以通过这个 demo 来验证

这在一些场景下会造成行为不一致 useMount Demo1useMount Demo2

useLayoutEffect

虽然上面说的那种场景不太常见,假如真遇到了的话,那可以使用 useLayoutEffect,useLayoutEffect 是在修改 DOM 后立即调用,行为和 componentDidMount 相似。

class 和 hook 的心智模型不同,不要把 useEffect 当作生命周期。我们更关注的是,当某个依赖变化了,我应该做些什么。而不是在某个 timing,我应该做些什么。hook 让你更关注于状态随依赖变化的关系,而不是组件的生命周期。

useRef

在 class 组件中,我们有时需要一些无关渲染的状态,比如存储一些定时器之类的,我们往往都是挂载在 this 上面。useRef 类似于 class 组件上的实例属性,它是一个 mutable 的对象。改变 ref.current 值不会触发重渲染。由于对象的引用传递,即使是在闭包里,我们也可以通过 ref.current 拿到最新值。还记得上面 class 组件的 Counter 组件的例子吗?这就像你通过 this.state 拿到最新值一样。

useImperativeHandle

useImperativeHandle 需要配合 useRef 和 forwardRef 一起食用。 之前,给 class 组件添加 ref 时拿到的是整个组件的实例,这样很容易被滥用。而你是不能给一个函数组件添加 ref 拿到组件状态的 (因为函数组件没有实例),你只能通过 React.forwardRef 将其转发到对应的 DOM 节点上。

现在,useImperativeHandle 可以让函数组件暴露一些接口给父组件。

TSX
class Count extends React.Component {
  state = {
    count: 0,
  }

  add = () => {
    this.setState(state => ({ count: state.count + 1 }))
  }

  render() {
    return (
      <div>
        count: {this.state.count}
        <button onClick={this.add}>Add</button>
      </div>
    )
  }
}

export default function App() {
  const ref = React.useRef(null)
  const handleClick = () => {
    ref.current.add()
  }
  return (
    <div className="App" onClick={handleClick}>
      <Counter ref={ref} />
    </div>
  )
}

App 组件里是可以访问到整个子组件 Counter 的实例的,所以你可以调用 Counter 实例上的 add 方法。实际上,作为子组件,我们可能不希望把 add 方法暴露出去。

将 Counter 改写成 function 组件

TSX
const Counter = React.forwardRef((props, ref) => {
  const [count, setCount] = React.useState(0)
  const add = () => {
    setCount(c => c + 1)
  }

  React.useImperativeHandle(ref, () => ({
    count,
  }))

  return (
    <div>
      count: {count}
      <button onClick={add}>Add</button>
    </div>
  )
})

现在,App 组件可以访问到 Counter 组件的 count 这个值,却没办法调用 add 方法。这比 class 组件下的行为要好。因为 useImperativeHandle 可以既可以让父组件访问函数子组件暴露出来的一些接口,又可以让子组件严格控制应该暴露出哪些东西。

useContext

尽量不要使用,使用第三方基于 Hook 的状态管理。constate 或 unstated-next 等。

分享时有同事对不要使用 useContext 的疑议。可能是因为我用词不当造成了一些误解。

其实我本意是想强调,比较会用到 Context 的场景,一些状态管理都已经替我们解决了,我们应该优先考虑状态管理而不是 Context。当然,Context 和 Redux 之类的状态管理库还是有区别,Redux 强调单一数据源 (the single source of truth),而 Context 是可以在树上的不同节点间多次独立使用的,只不过我们这种场景比较少,不过我认为即使遇到这种场景,也应该抽象一层出来,不要裸用 useContext。

自定义 Hooks

这其实是 React Hooks 最大的亮点,它比 HOC 和 render props 更好的解决了逻辑复用的问题,并且不会有层级嵌套深,props 命名冲突等问题,通过一个函数来封装逻辑也比他们简单的多。

有一些激进的团队,他们认为组件里不应该裸用 useState,useMemo 等 hook,而是从这些基础 hook 上按层次封装出一系列功能独立的自定义 hook,通过组合这些自定义来实现业务逻辑.....当然我认为这样写 hook 可能就太累了…(用最基础的 hook 心智负担就已经比较重了,更何况包装了三四层的 hook?)

不过,如果不做那么彻底的话,我们简单的包装一两层,也还是比较容易做到的。一个好的 hook,你可以通过名字知道这个 hook 干嘛用的,很多时候,当他人接手代码的时候,实际上是可以跳过阅读这段代码的,这样可以专注在组件的逻辑上,而不是一些复杂的数据处理流程等等。

下面是一个在业务代码里看到的验证码倒计时组件:

TSX
const MAX = 60 * 1000

function CountDown() {
  const [isBlock, setBlock] = useState(false)
  const [count, setCount] = useState(MAX)

  const handleClick = () => {
    getAuthCode().then(() => {
      setBlock(true)
    })
  }

  useEffect(() => {
    let timer
    if (isBlock) {
      timer = countdownBlock()
    }
    return () => {
      clearInterval(timer)
    }
  }, [isBlock])

  const countdownBlock = () => {
    const timer = window.setInterval(() => {
      setCount(c => {
        const _c = c - 1
        if (_c < 0) {
          clearInterval(timer)
        }
        return _c
      })
    }, 1000)
    return timer
  }

  useEffect(() => {
    if (count < 0) {
      setBlock(false)
      setCount(MAX)
    }
  }, [count])

  return (
    <div>
       <div>
        验证码: <input />
       </div>
      <button disabled={isBlock} onClick={handleClick} disabled={isBlock}>
        {isBlock ? `${count}秒后重新发送` : "发送验证码"}
      </button>
    </div>
  )
}

其实这个写的太复杂了,可能是不熟悉 hook 的心智模型,下面是优化后的版本

TSX
function useCountDown(max) {
  const [count, setCount] = useState(-1)
  const [run, cancel] = useInterval(() => setCount(c => c - 1), 1000)

  useEffect(() => {
    if (count < 0) {
      cancel()
    }
  }, [count])

  const countdown = () => {
    setCount(max)
    run()
  }

  return [count, countdown]
}

function CountDown() {
  const [count, countdown] = useCountDown(60)
  const isBlock = count >= 0

  const handleClick = () => {
    getAuthCode().then(() => {
      countdown()
    })
  }

  return (
    <div>
      <div>
        验证码: <input />
      </div>
      <button disabled={isBlock} onClick={handleClick} disabled={isBlock}>
        {isBlock ? `${count}秒后重新发送` : "发送验证码"}
      </button>
    </div>
  )
}

从调用顺序来看,逻辑清晰了许多,代码量也减少了。

遇到的一个坑

开发环境下,根组件外面包裹上 <React.StrictMode>,会导致组件渲染两次。 原因是 React 团队认为组件渲染时不应该含有副作用。包裹上 <React.StrictMode> 能够帮助你更容易发现这一点,参见