React Hooks工程实践总结

3,982 阅读9分钟

    最近在项目中基本上全部使用了React Hooks,历史项目也用React Hooks重写了一遍,相比于Class组件,React Hooks的优点可以一句话来概括:就是简单,在React hooks中没有复杂的生命周期,没有类组件中复杂的this指向,没有类似于HOC,render props等复杂的组件复用模式等。本篇文章主要总结一下在React hooks工程实践中的经验。

  • React hooks中的渲染行为
  • React hooks中的性能优化
  • React hooks中的状态管理和通信

原文首发至我的博客: github.com/fortheallli…


一、React hooks中的渲染行为

1.React hooks组件是如何渲染的

    理解React hooks的关键,就是要明白,hooks组件的每一次渲染都是独立,每一次的render都是一个独立的作用域,拥有自己的props和states、事件处理函数等。概括来讲:

每一次的render都是一个互不相关的函数,拥有完全独立的函数作用域,执行该渲染函数,返回相应的渲染结果

而类组件则不同,类组件中的props和states在整个生命周期中都是指向最新的那次渲染.

React hooks组件和类组件的在渲染行为中的区别,看起来很绕,我们可以用图来区别,

未命名文件 (6)的副本

    上图表示在React hooks组件的渲染过程,从图中可以看出,react hooks组件的每一次渲染都是一个独立的函数,会生成渲染区专属的props和state. 接着来看类组件中的渲染行为:

未命名文件 (6)

    类组件中在渲染开始的时候会在类组件的构造函数中生成一个props和state,所有的渲染过程都是在一个渲染函数中进行的并且,每一次的渲染中都不会去生成新的state和props,而是将值赋值给最开始被初始化的this.props和this.state。

2.工程中注意React hooks的渲染行为

    理解了React hooks的渲染行为,就指示了我们如何在工程中使用。首先因为React hooks组件在每一次渲染的过程中都会生成独立的所用域,因此,在组件内部的子函数和变量等在每次生命的时候都会重新生成,因此我们应该减少在React hooks组件内部声明函数。

写法一:

function App() {
  const [counter, setCounter] = useState(0);
  function formatCounter(counterVal) {
    return `The counter value is ${counterVal}`;
  }
  return (
    <div className="App">
      <div>{formatCounter(counter)}</div>
      <button onClick={() => setCounter(prevState => ++prevState)}>
        Increment
      </button>
    </div>
  );
}

写法二:

function formatCounter(counterVal) {
  return `The counter value is ${counterVal}`;
}
function App() {
 const [counter, setCounter] = useState(0);
 return (
   <div className="App">
     <div>{formatCounter(counter)}</div>
     <button onClick={()=>onClick(setCounter)}>
       Increment
     </button>
   </div>
 );
}

    App组件是一个hooks组件,我们知道了React hooks的渲染行为,那么写法1在每次render的时候都会去重新声明函数formatCounter,因此是不可取的。我们推荐写法二,如果函数与组件内的state和props无相关性,那么可以声明在组件的外部。如果函数与组件内的state和props强相关性,那么我们下节会介绍useCallback和useMemo的方法。

    React hooks中的state和props,在每次渲染的过程中都是重新生成和独立的,那么我们如果需要一个对象,从开始到一次次的render1 , render2, ...中都是不变的应该怎么做呢。(这里的不变是不会重新生成,是引用的地址不变的意思,其值可以改变)

我们可以使用useRef,创建一个“常量”,该常量在组件的渲染期内始终指向同一个引用地址。

通过useRef,可以实现很多功能,比如在某次渲染的时候,拿到前一次渲染中的state。

function App(){
   const [count,setCount] = useState(0)
   const prevCount = usePrevious(count);
   return (
    <div>
      <h1>Now: {count}, before: {prevCount}</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
   );
}
function usePrevious(value) {
   const ref = useRef();
   useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}

上述的例子中,我们通过useRef()创建的ref对象,在整个usePrevious组件的周期内都是同一个对象,我们可以通过更新ref.current的值,来在App组件的渲染过程中,记录App组件渲染中前一次渲染的state.

这里其实还有一个不容易理解的地方,我们来看usePrevious:

function usePrevious(value) {
   const ref = useRef();
   useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}

这里的疑问是:为什么当value改变的时候,返回的ref.current指向的是value改变之前的值?

也就是说:

为什么useEffect在return ref.current之后才执行?

为了解释这个问题,我们来聊聊神奇的useEffect.

3.神奇的useEffect

    hooks组件的每一次渲染都可以看成一个个独立的函数 render1,render2 ... rendern,那么这些render函数之间是怎么关联的呢,还有上小节的问题,为什么在usePrevious中,useEffect在return ref.current之后才执行。带着这两个疑问我们来看看在hooks组件中,最为神奇的useEffect。

    用一句话概括就是:

每一渲染都会生成不同的render函数,并且每一次渲染通过useEffect会生成一个不同的Effects,Effects在每次渲染后声效。

每次渲染除了生成不同的作用域外,如果该hooks组件中使用了useEffect,通过useEffect还会生成一个独有的effects,该effects在渲染完成后生效。

举例来说:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

上述的例子中,完成的逻辑是:

  • 渲染初始的内容:<p>You clicked 0 times</p>
  • 渲染完成之后调用这个effect:{ document.title = 'You clicked 0 times' }。
  • 点击Click me
  • 渲染新的内容渲染的内容: <p>You clicked 1 times</p>
  • 渲染完成之后调用这个effect:() => { document.title = 'You clicked 1 times' }。

也就是说每次渲染render中,effect位于同步执行队列的最后面,在dom更新或者函数返回后在执行。

我们在来看usePrevious的例子:

function usePrevious(value) {
   const ref = useRef();
   useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}

因为useEffect的机制,在新的渲染过程中,先返回ref.current再执行deps依赖更新ref.current,因此usePrevios总是返回上一次的值。

现在我们知道,在一次渲染render中,有自己独立的state,props,还有独立的函数作用域,函数定义,effects等,实际上,在每次render渲染中,几乎所有都是独立的。我们最后来看两个例子:

(1)

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

(2)

function Counter() {
  const [count, setCount] = useState(0);
  
  setTimeout(() => {
      console.log(`You clicked ${count} times`);
  }, 3000);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

这两个例子中,我们在3内点击5次Click me按钮,那么输出的结果都是一样的。

You clicked 0 times You clicked 1 times You clicked 2 times You clicked 3 times You clicked 4 times You clicked 5 times

    总而言之,每一次渲染的render,几乎都是独立和独有的,除了useRef创建的对象外,其他对象和函数都没有相关性.

二、React hooks中的性能优化

    前面我们讲了React hooks中的渲染行为,也初步 提到了说将与state和props无关的函数,声明在hooks组件外面可以提高组件的性能,减少每次在渲染中重新声明该无关函数. 除此之外,React hooks还提供了useMemo和useCallback来优化组件的性能.

(1).useCallback

有些时候我们必须要在hooks组件内定义函数或者方法,那么推荐用useCallback缓存这个方法,当useCallback的依赖项不发生变化的时候,该函数在每次渲染的过程中不需要重新声明

useCallback接受两个参数,第一个参数是要缓存的函数,第二个参数是一个数组,表示依赖项,当依赖项改变的时候会去重新声明一个新的函数,否则就返回这个被缓存的函数.

function formatCounter(counterVal) {
  return `The counter value is ${counterVal}`;
}
function App(props) {
 const [counter, setCounter] = useState(0);
 const onClick = useCallback(()=>{
   setCounter(props.count)
 },[props.count]);
 return (
   <div className="App">
     <div>{formatCounter(counter)}</div>
     <button onClick={onClick}>
       Increment
     </button>
   </div>
 );
}

上述例子我们在第一章的例子基础上增加了onClick方法,并缓存了这个方法,只有props中的count改变的时候才需要重新生成这个方法。

(2).useMemo

    useMemo与useCallback大同小异,区别就是useMemo缓存的不是函数,缓存的是对象(可以是jsx虚拟dom对象),同样的当依赖项不变的时候就返回这个被缓存的对象,否则就重新生成一个新的对象。

为了实现组件的性能优化,我们推荐:

在react hooks组件中声明的任何方法,或者任何对象都必须要包裹在useCallback或者useMemo中。

(3)useCallback,useMemo依赖项的比较方法

    我们来看看useCallback,useMemo的依赖项,在更新前后是怎么比较的

import is from 'shared/objectIs';
function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
) {
  if (prevDeps === null) {
    return false;
  }

  
 if (nextDeps.length !== prevDeps.length) {
   return false
 }
 
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++)   {
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

其中is方法的定义为:

function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) 
  );
}

export default (typeof Object.is === 'function' ? Object.is : is);

这个is方法就是es6的Object.is的兼容性写法,也就是说在useCallback和useMemo中的依赖项前后是通过Object.is来比较是否相同的,因此是浅比较。

三、React hooks中的状态管理和通信

  react hooks中的局部状态管理相比于类组件而言更加简介,那么如果我们组件采用react hooks,那么如何解决组件间的通信问题。

(1) UseContext

  最基础的想法可能就是通过useContext来解决组件间的通信问题。

比如:

function useCounter() {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment }
}

let Counter = createContext(null)

function CounterDisplay() {
  let counter = useContext(Counter)
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  )
}

function App() {
  let counter = useCounter()
  return (
    <Counter.Provider value={counter}>
      <CounterDisplay />
      <CounterDisplay />
    </Counter.Provider>
  )
}

  在这个例子中通过createContext和useContext,可以在App的子组件CounterDisplay中使用context,从而实现一定意义上的组件通信。

此外,在useContext的基础上,为了其整体性,业界也有几个比较简单的封装:

github.com/jamiebuilds… github.com/diegohaz/co…

但是其本质都没有解决一个问题:

如果context太多,那么如何维护这些context

  也就是说在大量组件通信的场景下,用context进行组件通信代码的可读性很差。这个类组件的场景一致,context不是一个新的东西,虽然用了useContext减少了context的使用复杂度。

(2) Redux结合hooks来实现组件间的通信

  hooks组件间的通信,同样可以使用redux来实现。也就是说:

在React hooks中,redux也有其存在的意义

  在hooks中存在一个问题,因为不存在类似于react-redux中connect这个高阶组件,来传递mapState和mapDispatch, 解决的方式是通过redux-react-hook或者react-redux的7.1 hooks版本来使用。

  • redux-react-hook

  在redux-react-hook中提供了StoreContext、useDispatch和useMappedState来操作redux中的store,比如定义mapState和mapDispatch的方式为:

import {StoreContext} from 'redux-react-hook';

ReactDOM.render(
  <StoreContext.Provider value={store}>
    <App />
  </StoreContext.Provider>,
  document.getElementById('root'),
);

import {useDispatch, useMappedState} from 'redux-react-hook';

export function DeleteButton({index}) {
  // Declare your memoized mapState function
  const mapState = useCallback(
    state => ({
      canDelete: state.todos[index].canDelete,
      name: state.todos[index].name,
    }),
    [index],
  );

  // Get data from and subscribe to the store
  const {canDelete, name} = useMappedState(mapState);

  // Create actions
  const dispatch = useDispatch();
  const deleteTodo = useCallback(
    () =>
      dispatch({
        type: 'delete todo',
        index,
      }),
    [index],
  );

  return (
    <button disabled={!canDelete} onClick={deleteTodo}>
      Delete {name}
    </button>
  );
}
  • react-redux 7.1的hooks版

   这也是官方较为推荐的,react-redux 的hooks版本提供了useSelector()、useDispatch()、useStore()这3个主要方法,分别对应与mapState、mapDispatch以及直接拿到redux中store的实例.

简单介绍一下useSelector,在useSelector中除了能从store中拿到state以外,还支持深度比较的功能,如果相应的state前后没有改变,就不会去重新的计算.

举例来说,最基础的用法:

import React from 'react'
import { useSelector } from 'react-redux'

export const TodoListItem = props => {
  const todo = useSelector(state => state.todos[props.id])
  return <div>{todo.text}</div>
}

实现缓存功能的用法:

import React from 'react'
import { useSelector } from 'react-redux'
import { createSelector } from 'reselect'

const selectNumOfDoneTodos = createSelector(
  state => state.todos,
  todos => todos.filter(todo => todo.isDone).length
)

export const DoneTodosCounter = () => {
  const NumOfDoneTodos = useSelector(selectNumOfDoneTodos)
  return <div>{NumOfDoneTodos}</div>
}

export const App = () => {
  return (
    <>
      <span>Number of done todos:</span>
      <DoneTodosCounter />
    </>
  )
}

在上述的缓存用法中,只要todos.filter(todo => todo.isDone).length不改变,就不会去重新计算.