Skip to content

使用 useReducer 管理互相依赖的 State #1

@kyon45

Description

@kyon45

TL;DR

使用 useReducer,用 reducer 组织 State 之间的依赖关系,可以更加友好地管理与维护,一个例子:

✏️ Codepen Demo 1

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

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

考虑这样一个常见的列表页面,有筛选器(Filter)、分页器(Pager)和表格本体(Table),其中表格的数据(data)是依赖筛选器数据(filter)和分页器数据(pager)向 API 请求得到的。那么一般就这样组织 State:

const ListTable = () => {
  const [filter, setFilter] = useState({});
  const [pager, setPager] = useState({
    page: 0,
    pageSize: 10,
  });
  // 假设有这么一个 TableData Hook 专门处理数据请求
  const data = useTableData('/api/list', { filter, pager });

  // render
  return (
    <seciton>
      <Filter filter={filter} onChange={setFilter} />
      <Table />
      <Pager pager={pager} onChange={setPager} />
    </seciton>
  );
}

现在有这么一个需求,当筛选项变更的时候,自动跳转到第一页,于是现在 pager 这个 State 不仅响应用户操作,还需要响应 filter 的变化。一般我们就用 useEffect 来实现:

  // ...
  useEffect(() => {
    setPager(p => ({
      ...p,
      page: 0,
    }));
  }, [filter]);

  // ...

这么写有两个问题

  • 根据 React 圣经 You Might Not Need an Effect,这个 Effect 里的逻辑并不是为了实现以下的目的:

    "synchronize your components with some external system like a non-React widget, network, or the browser DOM",即“与外部系统,如非 React 窗体、网络(理解为 API)或浏览器 DOM,进行同步”

    因此这里使用 useEffect 来达到“监听”的效果其实是一个下策

  • 不利于扩展。这个很好理解。假设现在多了一个开关模糊搜索的交互,并且切换开关后 filterkeyword 字段需要清空,那么就需要和 pager 依赖于 filter 那样,再写一个 fuzzy State + 一个 Effect

      // ...
      const [fuzzy, setFuzzy] = useState(false);
      useEffect(() => {
        setFilter(f => ({
          ...f,
          keyword: '',
        }));
      }, [fuzzy]);
      // 先不考虑 fuzzy 怎么影响数据请求
      // ...

    那么代码就会变得不好理解,如果没有特别注释的话,一堆 State 和 Effect 堆叠在一起,需要花费一定的时间才能厘清这里 "pager -> filter -> fuzzy" 的依赖关系,徒增理解和维护成本。并且,当切换开关时,筛选项会清空,最终分页器会回到第一页,但实际上经历了两次 render 才做成这件事,显然是低效的。

使用 useReducer

useReducer 的用法就不展开了,说说如何利用它更好地管理上述复杂依赖的情况。

我们可以将 filterpager 这些分离而互相依赖的 State 聚合到同一个 State 中,由 useReducer 导出,然后我们需要做的就是编写 reducer。

所谓 reducer,就是一个基于旧状态 state 和动作 action 的纯函数,在这里“动作”一般就是一些外部触发的事件(比如用户交互触发、订阅一个 Web API 触发),至于内部的子状态们如何“牵一发而动全身”则不认为是“动作”,而是需要在 reducer 中完成编排的。

然后我们把 reducer “喂给” useReducer,得到的就是高内聚的 State,然后把这些状态分发给需要的组件即可。需要响应 action 以更新 State 的时候,以调用 dispatch({ type: 'xxx', payload: yyy }) 的方式来告诉 reducer 进行处理。

下面用 useReducer 改造上面的例子。

首先去掉所有 State 和 Effect,现在我们只需要一个 State

const ListTable = () => {
  // reducer 待实现
  const [params, dispatch] = useReducer(reducer, {
    // 初始化状态
    filter: {},
    pager: { page: 0, pageSize: 10 },
    fuzzy: false,
  });

  // render 待实现
};

接下来定义 state 和 action 的类型签名。用户交互可能触发的动作有“筛选项变化”、“分页器变化”和“模糊搜索开关”,据此定义 action 的类型签名如下:

interface State {
  filter: Record<string, any>;
  pager: Record<string, any>;
  fuzzy: boolean;
}

interface UpdateFilterAction {
  type: 'update_filter';
  filter: State['filter'];
}
interface UpdatePagerAction {
  type: 'update_pager';
  pager: State['pager'];
}
interface UpdateFuzzyAction {
  type: 'update_fuzzy';
  fuzzy: State['fuzzy'];
}
type Action =
  | UpdateFilterAction
  | UpdatePagerAction
  | UpdateFuzzyAction;

然后是 reducer 的主体。在 reducer 中主要完成的事情就是根据 action.type,采取不同的策略更新状态并返回它。根据上文,我们有以下几个需求:

  • filter 更新的时候,pager.page 应该置为 0;
  • fuzzy 更新的时候,filter.keyword 应该清零
/**
 * @param {State} state
 * @param {Action} action
 */
function reducer(state, action) {
  switch (action.type) {
    case 'update_filter':
      return {
        ...state,
        filter: action.filter,
        pager: {
          ...state.pager,
          page: 0,
        },
      };
    case 'update_pager':
      return {
        ...state,
        pager: action.pager,
      };
    case 'update_fuzzy':
      return {
        ...state,
        fuzzy: action.fuzzy,
        filter: {
          ...state.filter,
          keyword: '',
        },
        // 🤔
        pager: {
          ...state.pager,
          page: 0,
        },
      };
    default:
      return {
        ...state,
      };
  }
}

接着,需要处理事件,触发对应的 action:

const ListTable = () => {
  // const [params, dispatch] = useReducer(reducer, {
  // ...

  // handler
  const handleFilterChange = filter => {
    dispatch({ type: 'update_filter', filter });
  };
  const handlePagerChange = pager => {
    dispatch({ type: 'update_pager', pager });
  };
  const handleFuzzyChange = fuzzy => {
    dispatch({ type: 'update_fuzzy', fuzzy });
  };

  // render 待实现
};

最后,渲染视图,并传入对应的 State 和 Handler

const ListTable = () => {
  // ...

  // render
  return (
    <seciton>
      <Filter filter={params.filter} onChange={handleFilterChange} />
      <Switch
        on={params.fuzzy}
        onChange={handleFuzzyChange}
        label={`Fuzzy ${params.fuzzy ? 'on' : 'off'}`}
      />
      <Table />
      <Pager pager={params.pager} onChange={handlePagerChange} />
    </seciton>
  );
};

完整代码在这里 ✏️ Codepen Demo 2

此外,useReducer 和几个 handler 的声明还可以封装到一个 Hook 中,这样 ListTable 组件就只要关注视图的渲染。

优化

这里有一个问题待思考,在 Demo 2 的 reducer 中注释了一处“🤔”的地方。由于我们是在 reducer 中编排状态之间的依赖与更新,因此在所有影响了 filter 的地方都需要写一遍对 pager 的赋值,这可能又需要额外的注释或文档来说明这个问题(否则后来的开发者很可能因为不知道这段逻辑而漏写了)。那么是否可以链式地调用 dispatch 呢?也就是说我们把编排的逻辑放到 handler 里面去做?

答案是可以的。实测在 React@18 和 React@17 中,连续地多次调用都能够自动地合成为同一次更新 👉 文档;因此我们可以这样修改 reducer 和 handler 的写法

function reducer(state, action) {
  switch (action.type) {
    case "update_filter":
      return {
        ...state,
        filter: action.filter,
      };
    case "update_pager":
      return {
        ...state,
        pager: action.pager,
      };
    case "update_fuzzy":
      return {
        ...state,
        fuzzy: action.fuzzy,
      };
    default:
      return {
        ...state
      };
  }
}

const ListTable = () => {
  // ...

  // handler
  const setPager = pager => {
    dispatch({ type: 'update_pager', pager });
  };
  const setFilter = filter => {
    dispatch({ type: 'update_filter', filter });
    setPager({ ...params.pager, page: 1 });
  };
  const setFuzzy = fuzzy => {
    dispatch({ type: 'update_fuzzy', fuzzy });
    setFilter({ ...params.filter, keyword: '' });
  };

  // ...
}

这样串行地调用 disaptch,React 就能够贴心地合成为一次更新,比如调用 setFuzzy 就能在一次更新中同时更新 fuzzy filterpager。那么如何验证确实是只有一次更新呢?先戳完整可运行 Demo ✏️ Codepen Demo 3

主要针对这个例子进行验证,首先在 keyword 输入框输入些文字,然后设置 pager 为 3,这时候停下。

有两种方式验证只有一次更新

  1. 在 ListTable 中添加 console.log
const ListTable = () => {
  // const [params, dispatch] = useReducer(reducer, {
  // ...

  console.log('re-render:', JSON.stringify(params));

  // handler
  // ...
};

此时清空控制台输出,然后点击 Fuzzy 按钮,可以观察到:

  • 页面上 Fuzzy 状态反转,keyword 输入框清空,pager 重置为 1
  • 控制台输出只有一行
  1. 使用 React Devtools/Profiler

这种方式需要自行在本地起一个最小 Demo,然后按照如下方式操作和观察:

  • 输入 keyword
  • 修改 pager
  • 打开 Devtools > ⚛️ Profiler 面板
    • 设置 > Profiler > 勾选 Record why each component rendered while profiling
  • 🔵 Start profiling
  • 按下 Fuzzy 按钮
  • 🔴 Stop profiling
  • 查看 Flamegraph 中 ListTable 组件的 "Why did this render?",可见“Hooks 1 changed”

上述两种方式在 React@18 和 React@17 都能观察到相同结果,验证了确实只有一次更新。

最后说个需要注意的点,如果在 setPager 又意外地调用了 setFuzzy,就会造成死循环,所以开发的时候要格外小心。

总结

通过用 useReducer 替换 useState + useEffect 的写法组织和管理状态,可以写出具有如下特点的代码

  • 互相依赖的子状态在 reducer 内完成组织
  • 不需要额外的注释/文档来解释数据流向
  • 关注点分离:reducer 专注子状态的更新,handler 专注依赖的编排,组件专注渲染 UI

参考

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions