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

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

Hook 的使用准则

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

React hooks:not magic,just arrays

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 会直接给你报错。

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

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

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

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

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

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

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

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

// 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

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

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 的引用都不同。

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。

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 可以做到上游组件重渲染,下游组件重渲染,而自己不重渲染的....

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 renderChild 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 使用,但除此之外,还有很多地方需要注意,否则很可能加了也没用。如果性能不敏感,全部使用 useMemoReact.memo 收益很小。

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

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

Dan 关于 PureComponent 的评论

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

除了这个,其实我们还可以使用 useMemo 来实现 useCallbackuseRef

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.forwardRefuseImperativeHandle 上的。

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

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

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

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

一个经典的例子。

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 组件也能做到函数组件一样的行为。

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. 永远获取到最新值

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

  • 如果是一个纯函数,直接提取到组件外面,虽然这个函数每次渲染都会不断的创建,销毁。但是这点性能开销几乎可以忽略不计。
const filterOdd = arr => arr.filter(num => num % 2 === 0)
function App() {
  return <List filter={filterOdd} />
}
  • 利用 useRef 的引用不变封装一个 useCallback
function useCallback(cb) {
  const ref = useRef(cb)
  ref.current = cb
  return useRef((...args) => {
    ref.current.apply(this, args)
  }).current
}

补充阅读:

useEffect

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

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

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

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

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

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

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

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

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

你可以通过这个 demo 来验证

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

useLayoutEffect

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

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

useRef

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

useImperativeHandle

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

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

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 组件

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 的状态管理。constateunstated-next 等。

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

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

自定义 Hooks

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

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

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

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

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 的心智模型,下面是优化后的版本

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> 能够帮助你更容易发现这一点,参见