Skip to content

You-Might-Not-Need-an-Effect 系列之「Adjusting some state when a prop changes」 #2

@kyon45

Description

@kyon45

开始前请先阅读 👉 Adjusting some state when a prop changes · React Docs Beta

本文默认你熟悉 React Hooks,如果不了解可以查看 React Hooks 文档

======== 以下是正文 ========

考虑这样一个场景,有一个列表,你可以通过接口得到筛选后的数量 filteredCount 和未加筛选的数量 allCount;有一个导出按钮,点击后打开一个弹窗,提示你可以选择导出的方式,点击即发起导出的请求,就像这样:


  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ┃  导出为 Excel                    × 
  ┠───────────────────────────────────
  ┃
  ┃  ⦿ 筛选结果(共 567 条数据)
  ┃  ◯ 全部结果(共 765 条数据)
  ┃
  ┠───────────────────────────────────
  ┃                       取消 | 导出
  ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

模版代码
const ExportType = {
  Filtered = 1,
  All = 2,
};

const MyModal = props => {
  // #props
  const { visible, filteredCount, allCount } = props;

  // #state
  const [exportType, setExportType] = useState(ExportType.Filtered);

  // #handler
  const handleSubmit = () => {
    request('/api/export', { exportType });
  };

  const handleRadioChange = e => {
    setExportType(e.target.value);
  };

  // #render
  return (
    <Modal
      visible={visible}
      onOk={handleSubmit}
    >
      <Radio.Group
        value={exportType}
        onChange={handleRadioChange}
      >
        <Radio value={ExportType.Filtered}>
          Export filtered result ({filteredCount} records)
        </Radio>
        <Radio value={ExportType.All}>
          Export all result ({allCount} records)
        </Radio>
      </Radio.Group>
    </Modal>
  );
};

现在分别有两个需求要实现:

  1. 如果筛选结果的数量为 0,则禁用掉“筛选结果” Radio,并且自动选上“全部结果” Radio;
  2. 重新呼出弹窗都需要默认选择“筛选结果” Radio,特殊情况如果打开时筛选结果的数量为 0,按照 1 的方式处理;

在这个例子中,可能变化的 props 有 visiblefilteredCount,内部的 state exportType 需要随之改变而做调整(adjust)。下面就用两种实现方式,作为例子来解释 You-Might-Not-Need-an-Effect 中关于「Adjusting some state when a prop changes」这个场景的建议。

Effect 实现

首先考虑第二个需求,我会很习惯性地想到,“关闭弹窗”就是 visible 变为 false 嘛,那就用 useEffect “监听”一下就完事了;同时考虑第一个需求,我可以在“componentDidMount / componentDidUpdate”那样的时机,判断一遍 filteredCount 的值,然后进行 setExportType 就可以了,于是乎代码如下:

  // #props
  // ...
  const isFilteredEmpty = filteredCount <= 0;

  // #state
  // ...

  // #effect
  // 🔴 Avoid: Adjusting state on prop change in an Effect
  useEffect(() => {
    if (visible) {
      setExportType(
        isFilteredEmpty ? ExportType.All : ExportType.Filtered
      );
    }
  }, [visible, isFilteredEmpty]);

  // #handler
  // ...

  // #render
  return (
    <Modal {/** ... */}>
      <Radio.Group {/** ... */}>
        <Radio value={ExportType.Filtered} disabled={isFilteredEmpty}>
          Export filtered result ({filteredCount} records)
        </Radio>
        <Radio value={ExportType.All}>
          Export all result ({allCount} records)
        </Radio>
      </Radio.Group>
    </Modal>
  );

这么写有几个问题:

  1. 很明显违背了 Effect 作为逃生舱的设计初衷,并没有实现“与外部系统,如非 React 窗体、网络(理解为 API)或浏览器 DOM,进行同步”的效果
  2. 简单地将 Effect 用作 componentDidMount / componentDidUpdate 这样的生命周期钩子,但显然二者是有区别的

从字面上来理解,由于 props 变化造成的组件内部 state 的调整,不能算是一种“副作用”,它完全是 React 自身可控的,所以这里其实完全没有必要请到 useEffect 这尊大佛。下面用文档推荐的方式来解决这个问题。

最佳实践 | Best Practice

首先考虑第一个问题。可以看到 state exportType 依赖于 prop filteredCount 的变化,那不就是 useMemo 包一下嘛,但是这个 state 还可以由用户通过 UI 来改变,也就是同时具备 setState 能力的一个新 Hook —— “useMemoState”。

现在我们抛开 Hook 想想,一定要是 “useMemo” 吗?由 prop filteredCount 的 state exportType 的计算并不是昂贵的(expensive),我们直接在 render 阶段将这个值计算出来就可以了:

  // #state
  // ✅ Best: Calculate everything during rendering
  const [exportType, setExportType] = useState(ExportType.Filtered);
  const _exportType = isFilteredEmpty ? ExportType.All : exportType;

然后,最关键的一点,现在我们认为这个计算得到的值就是一个 state,我们只要把它改个名,就和原来的 exportType 一样的用法,它既作为 Radio.Group 的 value 传参,也作为 handleSubmit 中 API 的参数

  // #state
  // ✅ Best: Calculate everything during rendering
  const [_exportType, setExportType] = useState(ExportType.Filtered);
  const exportType = isFilteredEmpty ? ExportType.All : exportType;

这样我们避免了用 Effect 来实现第一个需求。接下来看第二个:“重新呼出弹窗”的时候默认选中“筛选结果”。

在 Effect 实现中,我们企图在“重新呼出弹窗”的时候,即 visible=true 的情况赋值,现在转变一下思路,在“关闭弹窗”的时候做这件事是不是一样的效果?反正用户关闭弹窗的时候也大概率看不到瞬时的变化,相当于关闭弹窗就进行了一次“复位”。

说到“复位”,可能又想到在 useEffect 中返回一个“用于清除订阅器的回调”这个例子,但是这里我们并没有“订阅”什么东西,所以这么实现也是不妥的,恰好相反,它是一个用户交互事件(Event),也就是说“复位”这件事我们需要在“关闭弹窗”这个事件的 handler 中去做,比如 AntD 的 Modal 的话 afterClose 就是这样一个回调,于是代码调整如下:

  // #handler
  // ...
  const handleModalClose = () => {
    setExportType(ExportType.Filtered);
  };

  // ...

  // #render
  return (
    <Modal afterClose={handleModalClose} {/** ... */}>
      {/** ... */}
    </Modal>
  );

验证

最后用这几个用例来测试一下逻辑的正确性

注:方便起见

  • prop 中 visble 简写为 vfilteredCount 简写为 f
  • state 中 _exportType 简写为 _eexportType 简写为 e
  • 1ExportType.Filtered 的值,2ExportType.All 的值
步骤 说明 Prop State 效果
1 初始状态,
筛选结果数量为 100
v: false
f: 100
_e: 1
e: 1
弹窗隐藏中
验证需求 2
2 呼出弹窗 v: true
f: 100
_e: 1
e: 1
弹窗显示
选中“筛选结果”
3 选中“全部结果” v: true
f: 100
_e: 2
e: 2
弹窗显示
选中“全部结果”
4 关闭弹窗,重新呼出 v: true
f: 100
_e: 1
e: 1
弹窗显示
选中“筛选结果”
验证需求 1
5 改变筛选条件,使得结果为空,
此时呼出弹窗
v: true
f: 0
_e: 1
e: 2
弹窗显示
选中“全部结果”
6 关闭弹窗,重新呼出 v: true
f: 0
_e: 1
e: 2
弹窗显示
选中“全部结果”
7 关闭弹窗
改变筛选条件,使得结果非空
此时呼出弹窗
v: true
f: 200
_e: 1
e: 1
弹窗显示
选中“筛选结果”

总结

对于「Adjusting some state when a prop changes」这种场景,用文档中的一句注释来描述就是:

Calculate everything during rendering,把能做的计算都放到 render 阶段来做

以此为基础,还要弄清楚是究竟是 prop 变化导致 state 要跟着变,还是“Event”导致的,如果是后者,那就在 handler 里 adjust。

这样,就可以不用 Effect 也能完美解决。

参考

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions