【React的作弊模式】理解useReducer的优势和高级用法

或许你已经知道,“当多个state需要一起更新时,就应该考虑使用useReducer”;或许你也已经听说过,“使用useReducer能够提高应用的性能”。但是篇文章希望帮助你理解:为什么useReducer能提高代码的可读性和性能,以及如何在reducer中读取props的值。

由于useReducer造就的解耦模式以及高级用法,React团队的Dan Abramov将useReducer描述为"React的作弊模式"

useReducer的优势

举一个例子:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step); // 依赖其他state来更新
    }, 1000);
    return () => clearInterval(id);
    // 为了保证setCount中的step是最新的,
    // 我们还需要在deps数组中指定step
  }, [step]);

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

这段代码能够正常工作,但是随着相互依赖的状态变多,setState中的逻辑会变得很复杂useEffect的deps数组也会变得更复杂,降低可读性的同时,useEffect重新执行时机变得更加难以预料

使用useReducer替代useState以后:

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
      }, 1000);
    return () => clearInterval(id);
  }, []); // deps数组不需要包含step

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  )
}

现在组件只需要发出action,而无需知道如何更新状态。也就是将What to doHow to do解耦。彻底解耦的标志就是:useReducer总是返回相同的dispatch函数(发出action的渠道),不管reducer(状态更新的逻辑)如何变化。

这是useReducer的逆天之处之一,下面会详述

另一方面,step的更新不会造成useEffect的失效、重执行。因为现在useEffect依赖于dispatch,而不依赖于状态值(得益于上面的解耦模式)。这是一个重要的模式,能用来避免useEffect、useMemo、useCallback需要频繁重执行的问题

以下是state的定义,其中reducer封装了“如何更新状态”的逻辑:

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}

总结:

  • 当状态更新逻辑比较复杂的时候,就应该考虑使用useReducer。因为:

    • reducer比setState更加擅长描述“如何更新状态”。比如,reducer能够读取相关的状态、同时更新多个状态。
    • 【组件负责发出action,reducer负责更新状态】的解耦模式,使得代码逻辑变得更加清晰,代码行为更加可预测(比如useEffect的更新时机更加稳定)。
    • 简单来记,就是每当编写setState(prevState => newState)的时候,就应该考虑是否值得将它换成useReducer。
  • 通过传递useReducer的dispatch,可以减少状态值的传递

    • useReducer总是返回相同的dispatch函数,这是彻底解耦的标志:状态更新逻辑可以任意变化,而发起actions的渠道始终不变
    • 得益于前面的解耦模式,useEffect函数体、callback function只需要使用dispatch来发出action,而无需直接依赖状态值。因此在useEffect、useCallback、useMemo的deps数组中无需包含状态值,也减少了它们更新的需要。不但能提高可读性,而且能提升性能(useCallback、useMemo的更新往往会造成子组件的刷新)。

高级用法:内联reducer

你可以将reducer声明在组件内部,从而能够通过闭包访问props、以及前面的hooks结果:


const initialState = {
  count: 0
};

function Counter() {
  const [step, setStep] = useState(1);

  console.log("before useReducer");
  const [state, dispatch] = useReducer(reducer, initialState);
  console.log("after useReducer", state);

  function reducer(prevState, action) {
    // reducer will read the value from the latest render
    console.log("reducer", step);
    const { count: prevCount } = prevState;
    if (action.type === "add") {
      return { count: prevCount + step };
    } else if (action.type === "add-current-step") {
      return { count: prevCount + action.step };
    } else {
      throw new Error();
    }
  }

  return (
    <div>
      <h1>{state.count}</h1>
      <div>
        <button
          onClick={() => {
            // this two state updates will be batched
            console.log("before dispatch");
            dispatch({
              type: "add"
            });
            console.log("after dispatch");
            setStep((v) => v + 1);
          }}
        >
          add latest step
        </button>

        <button
          onClick={() => {
            // this two state updates will be batched
            console.log("before dispatch");
            dispatch({
              type: "add-current-step",
              step
            });
            console.log("after dispatch");
            setStep((v) => v + 1);
          }}
        >
          add current step
        </button>
      </div>
    </div>
  );
}
在线查看demo。在demo中可以对比“使用dispatch时的props”与“使用下次渲染的props”的区别。

这个能力可能会出乎很多人的意料。因为大部分人对reducer的触发时机的理解是错误的(包括以前的我)。我以前理解的触发时机是这样:

  1. 某个button被用户点击,它的onClick被调用,其中执行了dispatch({type:'add'}),React框架安排一次更新
  2. React框架处理刚才安排的更新,调用reducer(prevState, {type:'add'}),得到新的状态 (注意此时还没有发生重新渲染)
  3. React框架用新的状态来重新渲染组件树,执行到Counter组件的useReducer时,返回上一步得到的新状态即可

但是实际上,React会在下次渲染的时候,会同步地调用reducer来处理队列中的action

  1. 某个button被用户点击,它的onClick被调用,其中执行了dispatch({type:'add'}),React框架安排一次更新
  2. React框架处理刚才安排的更新,开始重渲染组件树 (注意此时还不知道最新的reducer状态)
  3. React框架重新渲染组件树,执行到Counter组件的useReducer时,调用reducer(prevState, {type:'add'}) ,得到新的状态

重要的区别在于,reducer是在重新渲染的时候被调用的,它的闭包捕获到了下次渲染的闭包(包括props以及前面的hooks结果)。

如果按照上面的错误理解,reducer是在重新渲染之前被调用的,它的闭包捕获到上次渲染的props,那么点击“add latest step”按钮的结果应该是数字变成1。

事实上,上面的例子使用了console.log来打印执行顺序,会发现reducer会在新渲染执行useReducer的时候被同步执行的

  console.log("before useReducer");
  const [state, dispatch] = useReducer(reducer, initialState);
  console.log("after useReducer", state);

调用点击按钮以后的输出包括:

before useReducer
reducer 2
after useReducer {count: 2}

证明reducer确实被useReducer同步地调用来获取新的state。
并且,如果按照上面所说的错误理解,在reducer中打印的step值应该是1。但实际打印的是2(本次渲染的step值),而不是1(上一次渲染的step值),说明它拿到了最新的闭包值。

坑点

内联reducer的用法要小心地使用,否则很容易踩坑。比如,dispatch的action会一直在队列中等待,直到下次渲染的时候将它们全部释放,这可能会出乎你的意料:
https://github.com/facebook/r...

参考资料


csRyan的学习专栏
分享对于计算机科学的学习和思考,只发布有价值的文章: 对于那些网上已经有完整资料,且相关资料已经整...

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart...

1.1k 声望
181 粉丝
0 条评论
推荐阅读
手写一个Parser - 代码简单而功能强大的Pratt Parsing
在编译的流程中,一个很重要的步骤是语法分析(又称解析,Parsing)。解析器(Parser)负责将Token流转化为抽象语法树(AST)。这篇文章介绍一种Parser的实现算法:Pratt Parsing,又称Top Down Operator Precede...

csRyan阅读 2.7k

手把手教你写一份优质的前端技术简历
不知不觉一年一度的秋招又来了,你收获了哪些大厂的面试邀约,又拿了多少offer呢?你身边是不是有挺多人技术比你差,但是却拿到了很多大厂的offer呢?其实,要想面试拿offer,首先要过得了简历那一关。如果一份简...

tonychen152阅读 17.7k评论 5

封面图
正则表达式实例
收集在业务中经常使用的正则表达式实例,方便以后进行查找,减少工作量。常用正则表达式实例1. 校验基本日期格式 {代码...} {代码...} 2. 校验密码强度密码的强度必须是包含大小写字母和数字的组合,不能使用特殊...

寒青56阅读 8.4k评论 11

JavaScript有用的代码片段和trick
平时工作过程中可以用到的实用代码集棉。判断对象否为空 {代码...} 浮点数取整 {代码...} 注意:前三种方法只适用于32个位整数,对于负数的处理上和Math.floor是不同的。 {代码...} 生成6位数字验证码 {代码...} ...

jenemy48阅读 6.8k评论 12

从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望
总结截止到本章 “从零搭建 Node.js 企业级 Web 服务器” 主题共计 16 章内容就更新完毕了,回顾第零章曾写道:搭建一个 Node.js 企业级 Web 服务器并非难事,只是必须做好几个关键事项这几件必须做好的关键事项就...

乌柏木75阅读 7k评论 16

再也不学AJAX了!(二)使用AJAX ① XMLHttpRequest
「再也不学 AJAX 了」是一个以 AJAX 为主题的系列文章,希望读者通过阅读本系列文章,能够对 AJAX 技术有更加深入的认识和理解,从此能够再也不用专门学习 AJAX。本篇文章为该系列的第二篇,最近更新于 2023 年 1...

libinfs42阅读 6.8k评论 12

封面图
从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
分层规范从本章起,正式进入企业级 Web 服务器核心内容。通常,一块完整的业务逻辑是由视图层、控制层、服务层、模型层共同定义与实现的,如下图:从上至下,抽象层次逐渐加深。从下至上,业务细节逐渐清晰。视图...

乌柏木45阅读 8.4k评论 6

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart...

1.1k 声望
181 粉丝
宣传栏