阅读 3823

React hooks 最佳实践【更新中】

导语: 随着目前需求更新的节奏越来越快,我们目前更多时候原因使用 function component 来代替类的写法,在 hooks 推出之后,我们也可以完全使用 function component 来代替类的写法;但是俗话说的好,没有什么东西是十全十美的,在本次整理总结 hooks 库的过程中,有体验到 hooks 带来的体验提升,同时也存在对比类生命周期写法中不足的地方。

React hooks 的思想

首先对于原先的类组件而言,最好的思想是封装,我们使用的constructorcomponentDidMount都是继承自React的方法,这样做相对于hooks来说的好处是,我们的每一个组件对于我们来说都是可预见的,这样我们在写每个组件的时候也都是在这个思路上进行开发的,很显然,这样一种方式带来的不便就是我们每个组件的开发成本太高,组件其中如果有涉及到某个生命周期的逻辑,我们也不便将它抽离出来复用

使用 hooks 彻底改变了上面这种模式 —— 将一个生命周期钩子的集合变成了一个从头到尾即插即用的模式,从某种意义上来说,我们的组件设计更加灵活了

基本原则

1、尽量设计简单的hooks

hooks 设计的初衷就是为了使开发更加快捷简便,因此在使用hooks 的时候,我们不应该吝啬使用较多的hooks,例如我们处理不同状态对应不同逻辑的时候,按照写class的逻辑,我们经常会在一个生命周期函数里写下多个逻辑,并用if区分;在写hooks的时候,因为没有shouldComponentUpdate这类的生命周期函数,我们应该将他们分离开,将他们写在不同的useEffect里或者用不同的useCallback包起来,所依赖的变量,也要尽可能的与逻辑相关联,这样可以尽可能的避免性能损耗和bug的产出。

2、注意hooks内部的逻辑 主要是官网提到的两个原则 reactjs.org/docs/hooks-… ,这里涉及到hooks的一个很重要的概念就是顺序(order),在每次我们定义钩子函数的时候,react都会按照顺序将他们存在一个“栈”中,类似 pic1  如果这时候,我们进行了某种操作,将其中一个钩子函数放到了if语句中,例如我们将firstName设置为仅在初次渲染,那么会造成这种情况:第一次渲染的时候正常,但是在第二次渲染的时候,执行到的第一个钩子函数是

const [lastName, setLastName] = useState('yeung');
复制代码

这时候,react会去执行顶层的栈中的方法,也就是我们后续的操作都往前挪了一位。 pic1

初始化

通常情况,我们使用 useState 来创建一个带有状态的变量,这个钩子函数返回一个状态变量和一个setter,当我们调用setter函数的时候,render函数会重新执行;这里有一个常见的问题,使用多个state或者合并成一个state

这个问题的产生来自于编写useSetState的时候所做的思考,按照之前写class的经验,显然将所有状态写在一起更加方便也更加好管理,但是,显然hooks并不是class,事实上,这里的setter函数的机制也和setState不一样,setState是把更新的字段合并到 this.state 中,而hooks中的setter则是直接替换,所以如果我们这里将所有的状态变量放在一个state中,显然违背了更方便维护的初衷。

但是也并不是将state分离的越细越好,看一下这个例子:

const [left, setLeft] = useState(0);

const [right, setRight] = useState(0);

el.addEventListener('mousemove', e => {

 setLeft(e.offsetX);

 setRight(e.offsetY);

})
复制代码

我们需要在鼠标操作的时候修改位置,这时候还将 left 和 right 分开处理显得很没必要,因为我们同时修改两个值的概率远大于只修改一个值的概率,那么这个时候我们就可以把他们写在一起:

const [position, setPosition] = useState({
 left: 0,
 right: 0,
})
复制代码

总结下来就是,如果我们两个或多个值是经常同时修改的,那么我们把它们写在一起是更加方便的,反之,则拆分开来。

清理操作

这里涉及到的钩子函数是useEffect,按照官方文档的介绍,useEffect可以看作componentDidMount, componentDidUpdate, and componentWillUnmount的集合,DidMount和DidUpdate很常用到,这里主要说一下作为componentWillUnmount的用法。 在useClickOut中,我们有为document添加事件,显然这个事件我们需要在组件卸载的时候将其同样卸载,这里的做法是在useEffect的return中执行卸载函数,关于这一部分的用法,官网有完整的介绍: React会在组件卸载和依赖状态变化重新执行callback之前的时候执行useEffect中callback返回的函数,为什么?因为effects会在每一次重新渲染的时候执行不止一次,所以,理所当然的也会清理掉之前的effects。这里需要注意的是,无论是卸载操作,还是callback操作都是在组件return之后才执行的。

减少重复渲染

React.memo

这个方法的作用类似于class中的shouldComponentUpdate,不同的是shouldComponentUpdate同样会比较state的不同,但是React.memo只会比较props,其比较的规则也很简单,它会比较前后两次的props,以判断是否重新渲染,但是这其中其实存在很大的隐患,有些博主并不建议使用React.memo,但我觉得,只要遵循一下几个原则,React.memo确实可以很大程度上节约渲染时间,特别是现在都使用redux,经常需要避免其他state的更新导致当前组件更新。 性能优化时,组件更新的条件需要比较详细的计算,一般需要添加的条件包括基本类型,对象类型适当进行深度比较,函数类型依情况看可能变更的部分,使整个函数,还仅仅是几个参数,如果无法确定,那么最好直接使用PureComponent或者React.memo

useMemo

useMemo通常用来记录一些值,首先了解一下useMemo的使用场景:

1.存储一些开销很大的变量,避免每次render都重新计算;

2.特定记录一些不想要变化的值;

关于2,直接用就可以了,关于1,我们就应该以情况来判定是否使用了,看一下下面这个场景:

const value = useMemo(() => {

 return massiveCompute(deps);

}, [deps]);
复制代码

是否使用useMemo取决于:

1、massiveComoute的操作是否真的大到影响性能;

2、deps的数据类型,如果是对象或者数组,那么使用useMemo是没有意义,甚至增加了一次比较还影响了性能;

UseEffect与ComponentDidMount 的对比

在官方文档中,有提到 useEffect 可以实现各种生命周期的mock,但事实上,hooks与各种生命周期函数存在机制上的差别,如果笼统的将其和生命周期画上等号,那么在后续的理解上可能会出现偏差。 具体的区别可以参考useEffect is not the new ComponentDidMount, 下面会结合开发过程中遇到的问题来简要说明。

运行时机

首先对于 componentDidMount 而言,在初次进入的时候,如果我们在 componentDidMount 中进行state操作,是会造成两次渲染的,分别是在 componentDidMount 之前和之后,在这之后,浏览器只会渲染最后一次 render 渲染以避免闪屏,也就是 componentDidMount 实际上是会在浏览器绘制之前执行;但是对于 useEffect,虽然同样会造成第二次渲染,但是第二次渲染是在浏览器绘制之后再次执行的,这样的影响也是会造成闪屏。注意 useEffect 是在每次组件 return 之后才会执行一次。

对状态时机捕获的区别

思考 componentDidMount 的一种应用场景,componentDidMount 中进行一个异步操作,在异步操作 resolve 之后,如果打印此时的 state,我们会得到什么样的结果?具体的代码可以查看 longResolve with ComponentDidMount

上面的例子中,我们在异步操作进行的过程中,如果改变 state 的值,最后在异步操作完成,打印对应 state 的时候,我们得到的结果其实就是改变后最新的结果。

同样的例子,如果用 useEffect 代替ComponentDidMount 会如何?查看 longResolve with useEffect。

我们可以发现,无论我们在异步操作的过程中如何改变 state 的值,最后打印的时候都是最初的值或者说异步操作开始定义的时候的 state 的值。

为什么会这样?

如果我们在 hooks 的例子中修改一下代码,在 useEffect 的 deps 中加入 count,我们可以更好的理解其中的原因

 useEffect(() => {

   longResolve().then(() => {

     console.log(count);

   });

 }, [count]);
复制代码

这时候,我们点击 n 次,这里的函数也会执行 n 次,因此,我们可以把 useEffect 的机制理解为,当 deps 中的数值改变时,我们 useEffect 都会把回调函数推到执行队列中,这样,函数中使用的值也很显然是保存时的值了。

setInterval

在编写 useInterval 的时候,就遇到了这个问题,如果像在 class 中的处理一样,那么我们做的就是直接在 useEffect 中写 interval 的逻辑:

useEffect(() => {

   const id = setInterval(() => {

     setCount(count + 1)

   }, 1000);

   return () => clearInterval(id)

 }, [])
复制代码

这样带来的结果是,count首先从0 -> 1,然后就一直不变了,原因跟上面说的一样,解决办法是在 deps 中添加对应的依赖变量 -> count,有可能我们会担心造成死循环,因为我们同时在改变依赖的变量,但考虑到 setInterval 本来就是一个无限循环的操作,所以这里并没有问题,同时,这里我们应该理解到的是,只要我们在useEffect中使用到了某个变量,那么就有必要添加它到 deps 中,如果代码出现了死循环,那么我们应该考虑是不是我们的内部逻辑出现了问题。 值得提出来的是,setter函数还有另一种写法,我们不需要在 deps 中添加变量

useEffect(() => {

 const id = setInterval(() => {

   // When we pass a function, React calls that function with the current

   // state and whatever we return becomes the new state.

   setCount(count => count + 1)

 }, 1000)

 return () => clearInterval(id)

}, [])
复制代码

React默认hooks原理分析

useMemo

首先说明一下 useMemo的作用,useMemo 可以用来保存一个存储值,这个值只会在 deps 改变的时候重新计算,在保存某些大计算量的值的时候经常会用到;接下来看一看React是如何实现这个功能的。

function useMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T {
  currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
  workInProgressHook = createWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  if (workInProgressHook !== null) {
    const prevState = workInProgressHook.memoizedState;
    if (prevState !== null) {
      if (nextDeps !== null) {
        const prevDeps = prevState[1];
        if (areHookInputsEqual(nextDeps, prevDeps)) {
          return prevState[0];
        }
      }
    }
  }

  if (__DEV__) {
    isInHookUserCodeInDev = true;
  }
  const nextValue = nextCreate();
  if (__DEV__) {
    isInHookUserCodeInDev = false;
  }
  workInProgressHook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}
复制代码

首先理解这里的一些全局变量的含义,workInProgressHook 表示当前正在运行的 hooks 是否是 re-render 的hooks,这里第一次的 if 判断就表示如果当前不是第一次渲染,那么 useMemo 会拿到两次 deps 去做比较,如果相等,就直接返回缓存中的状态;如果是第一次渲染,或者两次 deps 不想等,那么 useMemo 会重新执行一遍 callback,并将值赋给对应的缓存。

useReducer & useState

useReduceruseState 本质上是一个原理,虽然我们平时会使用 useState 更多,但事实上 useStateuseReducer 的封装;下面,对 useReducer 的实现原理做一下梳理;useReducer 可以分为初次渲染和re-render两种,首先看一下初次渲染的情况:

if (__DEV__) {
  isInHookUserCodeInDev = true;
}
let initialState;
if (reducer === basicStateReducer) {
  // Special case for `useState`.
  initialState =
    typeof initialArg === 'function'
      ? ((initialArg: any): () => S)()
      : ((initialArg: any): S);
} else {
  initialState =
    init !== undefined ? init(initialArg) : ((initialArg: any): S);
}
if (__DEV__) {
  isInHookUserCodeInDev = false;
}
workInProgressHook.memoizedState = initialState;
const queue: UpdateQueue<A> = (workInProgressHook.queue = {
  last: null,
  dispatch: null,
});
const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
  null,
  currentlyRenderingComponent,
  queue,
): any));
return [workInProgressHook.memoizedState, dispatch];
复制代码

从最熟悉的返回值看起,我们都很清楚,useState(useReducer)返回一个数组,0、1的index分别为 statedispatch,首先看一下 state,这里的 state 在初次渲染的时候直接是等于我们传入给 useReducer 的参数的(useReducer 可以多传一个 init 函数,用于接收初始 state 作为参数,并返回对应 state);重点是这里 dispatch 的处理,这里有一个 dispatchAction 方法,这个方法的作用是将更新方法存放到一个以 queue 作为 key 的 Map 中,关于 dispatchAction 的源代码和具体作用:

function dispatchAction<A>(
  componentIdentity: Object,
  queue: UpdateQueue<A>,
  action: A,
) {
  if (componentIdentity === currentlyRenderingComponent) {
    didScheduleRenderPhaseUpdate = true;
    const update: Update<A> = {
      action,
      next: null,
    };
    if (renderPhaseUpdates === null) {
      renderPhaseUpdates = new Map();
    }
    const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
    if (firstRenderPhaseUpdate === undefined) {
      renderPhaseUpdates.set(queue, update);
    } else {
      // 这里的处理是当我们连续调用 dispatch 的时候,我们将 update 追加到已有的队列后面,而不是另起一个
      // 队列,这里在下次执行的时候可以将同步执行的 dispatch 合并到一个队列中,到时候也可以统一更新
      let lastRenderPhaseUpdate = firstRenderPhaseUpdate;
      while (lastRenderPhaseUpdate.next !== null) {
        lastRenderPhaseUpdate = lastRenderPhaseUpdate.next;
      }
      lastRenderPhaseUpdate.next = update;
    }
  } else {
  }
}
复制代码

dispatchAction 是一个渲染阶段的更新,将其隐藏在一个惰性创建的 queue -> 更新链表 中(renderPhaseUpdates)。在此渲染结束后,我们将重新启动并将隐藏的更新应用到正在进行的工作钩子(work-in-process)上。 dispatchAction有接收三个参数,分别为componentIdentityqueueaction 这里使用了bind进行了绑定,所以action 参数就是在调用 dispatch 的时候传入的参数。至此,一次 useState 初始化完成,其实我们可以发现,我们在调用 dispatch 时,具体的操作其实并不是修改 state 的值,而是将对应的 action(或者说修改的值)追加到一个队列中,当重复渲染计算到 useState 时,再去从这个全局队列中执行对应的更新;下面看一下重复渲染时的情况,给出当重复渲染时 useReducer 中的逻辑:

// This is a re-render. Apply the new render phase updates to the previous
// current hook.
const queue: UpdateQueue<A> = (workInProgressHook.queue: any);
const dispatch: Dispatch<A> = (queue.dispatch: any);
if (renderPhaseUpdates !== null) {
  // Render phase updates are stored in a map of queue -> linked list
  const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
  if (firstRenderPhaseUpdate !== undefined) {
    renderPhaseUpdates.delete(queue);
    let newState = workInProgressHook.memoizedState;
    let update = firstRenderPhaseUpdate;
    do {
      // Process this render phase update. We don't have to check the
      // priority because it will always be the same as the current
      // render's.
      const action = update.action;
      if (__DEV__) {
        isInHookUserCodeInDev = true;
      }
      newState = reducer(newState, action);
      if (__DEV__) {
        isInHookUserCodeInDev = false;
      }
      update = update.next;
    } while (update !== null);

    workInProgressHook.memoizedState = newState;

    return [newState, dispatch];
  }
}
return [workInProgressHook.memoizedState, dispatch];
复制代码

首先,如果 renderPhaseUpdates 为 null,说明这次更新之前都没有过 dispatch 的调用,这时候直接按原值返回;如果 renderPhaseUpdates 不为 null,说明之前有过 dispatch 调用,但是这个更新是全局的,所以其实 hooks 也不知道具体是什么触发了更新,这时候根据queue 去之前存储的 renderPhaseUpdates 中取对应的更新方法,如果取到了,说明这次更新之前有调用过 dispatch,这时候更新的操作是一个 do-while 循环,这里的逻辑对应到 dispatchAction 的队列建立逻辑 - 会把多个 updater 合并到一个队列中,所以这里一个 do -while 循环一次性执行所有的 updater,注意这里的注释和逻辑,也就是说如果我们在一系列的 dispatch 中都直接对 state 的值修改,这里的修改实际上只保留了最后一次修改,但是如果传入的是回调函数例如 setState((state) => state + 1) 那么是可以拿到最新的 state 值的,因为 newState 每次都变了。