【译】在 React Hooks 中使用 useReducer 的几种用例

22,261 阅读10分钟

原文:How to use useReducer in React Hooks for performance optimization

github 的地址 欢迎 star!
React Hook 出来已经有一段时间了,具体的一些用法以及它解决的痛点,可以查看 Dan 的两篇文章 useEffect 完整指南以及编写有弹性的组件进行详细了解。

本文主要是介绍了6种在 React Hooks 使用 useReducer 的不同的方法

前言

React Hooks API正式在 React V16.8 版本发布了。这篇博客,主要是介绍了其中 useReducer 的各种用法示例。在读之前,你确保你已经看过 React Hooks官方指南

useReducer hook 属于官方扩展的 hooks:

是 useState 的另一种替代。它接受(state, action) => newState,并且返回了一个与当前state成对的dispatch的方法。(如果你熟悉 Redux ,你也很快就能理解它是怎么工作的。)

尽管 useReducer 是扩展的 hook, 而 useState 是基本的 hook,但 useState 实际上执行的也是一个 useReducer。这意味着 useReducer 是更原生的,你能在任何使用 useState 的地方都替换成使用 useReducer。Reducer 如此给力,所以有各种各样的用例。

本文接下来就介绍了几种代表性的用例。每个例子都代表一种特定的用例,都有相关的代码。

用例1:最小(简单)的模式

可以看这个简单示例的代码。下文都是用这个计数的例子做延伸的。

const initialState = 0;
const reducer = (state, action) => {
  switch (action) {
    case 'increment': return state + 1;
    case 'decrement': return state - 1;
    case 'reset': return 0;
    default: throw new Error('Unexpected action');
  }
};

首先,我们定义了初始化的 initialState 以及 reducer。注意这里的 state 仅是一个数字,不是对象。熟悉 Redux 的开发者可能是困惑的,但在 hook 中是适宜的。此外,action 仅是一个普通的字符串。

下面是一个使用 useReducer 的组件。

const Example01 = () => {
  const [count, dispatch] = useReducer(reducer, initialState);
  return (
    <div>
      {count}
      <button onClick={() => dispatch('increment')}>+1</button>
      <button onClick={() => dispatch('decrement')}>-1</button>
      <button onClick={() => dispatch('reset')}>reset</button>
    </div>
  );
};

当用户点击一个按钮,它就会 dispatch 一个 action 来更新计数值 count,页面就会展示更新之后 count。你可以在 reducer中尽可能多定义 action,但这种模式有局限,它的 action是有限的。

下面是完整的代码:

import React, { useReducer } from 'react';

const initialState = 0;
const reducer = (state, action) => {
  switch (action) {
    case 'increment': return state + 1;
    case 'decrement': return state - 1;
    case 'reset': return 0;
    default: throw new Error('Unexpected action');
  }
};

const Example01 = () => {
  const [count, dispatch] = useReducer(reducer, initialState);
  return (
    <div>
      {count}
      <button onClick={() => dispatch('increment')}>+1</button>
      <button onClick={() => dispatch('decrement')}>-1</button>
      <button onClick={() => dispatch('reset')}>reset</button>
    </div>
  );
};

export default Example01;

用例2:action是一个对象

这个例子 Redux的使用者是熟悉的。我们使用了 state对象以及一个 action对象。

const initialState = {
  count1: 0,
  count2: 0,
};
const reducer = (state, action) => {
  switch (action.type) {
    case 'increment1':
      return { ...state, count1: state.count1 + 1 };
    case 'decrement1':
      return { ...state, count1: state.count1 - 1 };
    case 'set1':
      return { ...state, count1: action.count };
    case 'increment2':
      return { ...state, count2: state.count2 + 1 };
    case 'decrement2':
      return { ...state, count2: state.count2 - 1 };
    case 'set2':
      return { ...state, count2: action.count };
    default:
      throw new Error('Unexpected action');
  }
};

在 state中存放了两个数字。我们能使用复杂的对象表示 state,只要能把 reducer组织好(列如:react-redux中 combineReducers)。另外,因为 action是一个对象,除了 type值,你还可以给它添加其他属性像action.count。这个例子中 reducer 是有点杂乱的,但不妨碍我们下面这样使用它:

const Example02 = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      <div>
        {state.count1}
        <button onClick={() => dispatch({ type: 'increment1' })}>+1</button>
        <button onClick={() => dispatch({ type: 'decrement1' })}>-1</button>
        <button onClick={() => dispatch({ type: 'set1', count: 0 })}>reset</button>
      </div>
      <div>
        {state.count2}
        <button onClick={() => dispatch({ type: 'increment2' })}>+1</button>
        <button onClick={() => dispatch({ type: 'decrement2' })}>-1</button>
        <button onClick={() => dispatch({ type: 'set2', count: 0 })}>reset</button>
      </div>
    </>
  );
};

注意到 state 中有两个计数器,定义各自相应的 action 类型来更新它们。在线示例点击这里

用例3:使用多个useReducer

上面的单个 state 中出现两个计数器,这就是一种典型的全局 state 的方法。但我们仅仅需要使用本地(局部)的 state,故有另外一种方法,可以使用 useReducer 两次。

const initialState = 0;
const reducer = (state, action) => {
  switch (action.type) {
    case 'increment': return state + 1;
    case 'decrement': return state - 1;
    case 'set': return action.count;
    default: throw new Error('Unexpected action');
  }
};

这里的 state 是一个数字,而不是对象,它和用例1中是一致的。注意不同点这里的 action 是一个对象。

组件如何使用呢

const Example03 = () => {
  const [count1, dispatch1] = useReducer(reducer, initialState);
  const [count2, dispatch2] = useReducer(reducer, initialState);
  return (
    <>
      <div>
        {count1}
        <button onClick={() => dispatch1({ type: 'increment' })}>+1</button>
        <button onClick={() => dispatch1({ type: 'decrement' })}>-1</button>
        <button onClick={() => dispatch1({ type: 'set', count: 0 })}>reset</button>
      </div>
      <div>
        {count2}
        <button onClick={() => dispatch2({ type: 'increment' })}>+1</button>
        <button onClick={() => dispatch2({ type: 'decrement' })}>-1</button>
        <button onClick={() => dispatch2({ type: 'set', count: 0 })}>reset</button>
      </div>
    </>
  );
};

可以看到,每个计数器有各自的 dispatch 的方法,但共享了 reducer 方法。这个的功能和用例2是一致的。

用例4:文本输入(TextInput)

来看一个真实的例子,多个 useReducer 能各司其职。我们以 React 原生的 input 输入组件为例,在本地状态 state 中存储文本数据。通过调用 dispatch 函数更新文本状态值。

const initialState = '';
const reducer = (state, action) => action;

注意到每次调用 reducer,之前旧的 state 就会被丢掉。具体使用如下:

const Example04 = () => {
  const [firstName, changeFirstName] = useReducer(reducer, initialState);
  const [lastName, changeLastName] = useReducer(reducer, initialState);
  return (
    <>
      <div>
        First Name:
        <TextInput value={firstName} onChangeText={changeFirstName} />
      </div>
      <div>
        Last Name:
        <TextInput value={lastName} onChangeText={changeLastName} />
      </div>
    </>
  );
};

就是这么简单。当然你也可以添加一些校验的逻辑在里面。完整代码:

import React, { useReducer } from 'react';

const initialState = '';
const reducer = (state, action) => action;

const Example04 = () => {
  const [firstName, changeFirstName] = useReducer(reducer, initialState);
  const [lastName, changeLastName] = useReducer(reducer, initialState);
  return (
    <>
      <div>
        First Name:
        <TextInput value={firstName} onChangeText={changeFirstName} />
      </div>
      <div>
        Last Name:
        <TextInput value={lastName} onChangeText={changeLastName} />
      </div>
    </>
  );
};

// ref: https://facebook.github.io/react-native/docs/textinput
const TextInput = ({ value, onChangeText }) => (
  <input type="text" value={value} onChange={e => onChangeText(e.target.value)} />
);

export default Example04;

用例5:Context

有些时候,我希望在组件之间共享状态(理解为实现全局状态 state )。通常,全局状态会限制组件的复用,因此我们首先考虑使用本地 state,通过 props 进行传递( dispatch 来改变),但当它不是那么方便的时候(理解嵌套传递过多),就可以使用 Context。如果你熟悉 Context 的 API,请点击查看官方文档

这个例子,使用了和用例3一样的 reducer。接下来看怎么创建一个 context。

const CountContext = React.createContext();

const CountProvider = ({ children }) => {
  const contextValue = useReducer(reducer, initialState);
  return (
    <CountContext.Provider value={contextValue}>
      {children}
    </CountContext.Provider>
  );
};

const useCount = () => {
  const contextValue = useContext(CountContext);
  return contextValue;
};

useCount 就是自定义的 hook,它也和其他官方的 hook 一样使用。如下面一样:

const Counter = () => {
  const [count, dispatch] = useCount();
  return (
    <div>
      {count}
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
      <button onClick={() => dispatch({ type: 'set', count: 0 })}>reset</button>
    </div>
  );
};

contextValue就是 useReducer 返回的结果,我们也用 hook 重构了useCount。注意到这个点,使用了哪一个 context是不固定的。

最后,像这样使用 context:

const Example05 = () => (
  <>
    <CountProvider>
      <Counter />
      <Counter />
    </CountProvider>
    <CountProvider>
      <Counter />
      <Counter />
    </CountProvider>
  </>
);

如上所示,有两个 CountProvider 组件,意味着有两个计数器,即使我们只使用了一个 context。 在同一个 CountProvider 组件中计数器共享状态 state。你可以运行一下这个用例了解它是怎么工作的。点击这里查看

用例6:Subscription (订阅)

在hooks中实现组件共享状态 state 首选的应该就是 Context,但当在 React 组件的外部早已经有一个共享状态 state 时,该怎么(共享呢)?专业的做法订阅监听状态 state,当共享状态 state 更新时,更新组件。当然它还有一些局限性,不过React官方提供了一个公共功能 create-subscription,你可以用它来进行订阅。

不幸的,这个公共方法包还没有用 React Hooks 进行重写,现在只能靠我们自己用 hooks 尽力去实现。让我们不使用 Context 实现和用例 5 一样的功能。

首先,创建一个自定义的hook:

const useForceUpdate = () => useReducer(state => !state, false)[1];

这个 reducer 仅仅是对先前的 state 取反,忽略了 action。[1]仅仅返回了 dispatch 而没有 state。接下来,主函数实现共享状态 state 以及返回自定义 hook:

const createSharedState = (reducer, initialState) => {
  const subscribers = [];
  let state = initialState;
  const dispatch = (action) => {
    state = reducer(state, action);
    subscribers.forEach(callback => callback());
  };
  const useSharedState = () => {
    const forceUpdate = useForceUpdate();
    useEffect(() => {
      const callback = () => forceUpdate();
      subscribers.push(callback);
      callback(); // in case it's already updated
      const cleanup = () => {
        const index = subscribers.indexOf(callback);
        subscribers.splice(index, 1);
      };
      return cleanup;
    }, []);
    return [state, dispatch];
  };
  return useSharedState;
};

我们使用了 useEffect。它是非常重要的 hook,你需要仔细的看官方文档学习如何使用它。在 useEffect 中,我们订阅了一个回调函数来强制更新组件。在组件销毁的时候需要清除订阅。

接下来,我们可以创建两个共享状态 state。使用了和用例5,用例3一样的 reducer 和初始值 initialState:

const useCount1 = createSharedState(reducer, initialState);
const useCount2 = createSharedState(reducer, initialState);

这和用例 5 是不一样的,这两个 hooks 绑定了特定的共享状态 state。然后我们使用这两个 hooks。

const Counter = ({ count, dispatch }) => (
  <div>
    {count}
    <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
    <button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
    <button onClick={() => dispatch({ type: 'set', count: 0 })}>reset</button>
  </div>
);

const Counter1 = () => {
  const [count, dispatch] = useCount1();
  return <Counter count={count} dispatch={dispatch} />
};

const Counter2 = () => {
  const [count, dispatch] = useCount2();
  return <Counter count={count} dispatch={dispatch} />
};

注意到,Counter 组件是一个共同的无状态组件。这样子使用:

const Example06 = () => (
  <>
    <Counter1 />
    <Counter1 />
    <Counter2 />
    <Counter2 />
  </>
);

可以看到,我们没有用 Context,但是也实现了共享状态。大家应该都要具体看看 useReducer,对于性能优化很有帮助。

文中所有的代码都在这里,要看在线示例点击这里查看

自己的一些总结:

  1. hooks每次 Render 都有自己的 Props 与 State 可以认为每次 Render 的内容都会形成一个快照并保留下来(函数被销毁了,但变量被react保留下来了),因此当状态变更而 Rerender 时,就形成了 N 个 Render 状态,而每个 Render 状态都拥有自己固定不变的 Props 与 State。 这也是函数式的特性--数据不变性
  2. 性能注意事项 useState 函数的参数虽然是初始值,但由于整个函数都是 Render,因此每次初始化都会被调用,如果初始值计算非常消耗时间,建议使用函数传入,这样只会执行一次:
  3. 如果你熟悉 React 的 class 组件的生命周期,你可以认为useEffect Hook就是组合了componentDidMount, componentDidUpdate, 以及 componentWillUnmount(在useEffect的回调中),但是又有区别,useEffect不会阻止浏览器更新屏幕
  4. hooks 把相关的逻辑放在一起统一处理,不在按生命周期把逻辑拆分开
  5. useEffect 是在浏览器 render 之后触发的,想要实现 DOM 改变时同步触发的话,得用 useLayoutEffect,在浏览器重绘之前和 DOM 改变一起,同步触发的。不过尽量在布局的时候,其他的用标准的 useEffect,这样可以避免阻止视图更新。
```
function FunctionComponent(props) {
  const [rows, setRows] = useState(() => createRows(props.count));
}
useRef 不支持这种特性,需要写一些[冗余的函判定是否进行过初始化。](https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily)
```

如果有错误或者不严谨的地方,请务必给予指正,十分感谢!

参考

  1. 精读《useEffect 完全指南》
  2. react 官网