10

以列表页为例,谈React Hooks的逻辑抽象与封装

 3 years ago
source link: https://zhuanlan.zhihu.com/p/100683538
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

以列表页为例,谈React Hooks的逻辑抽象与封装

神经网络调过参,现做前端来搬砖

本文不是hooks的最佳实践指南,也不是类似hox的state manager,而仅仅是探讨,当我们使用react hooks开发业务时,如何对组件逻辑进行抽象与封装。

react官方在推出Hooks之初,就提到“hooks解决了之前开发中遇到的非常多的问题

Hooks solve a wide variety of seemingly unconnected problems in React that we’ve encountered over five years of writing and maintaining tens of thousands of components. hard to reuse stateful logic between components complex components become hard to understand * class confuse both people and machines

Hooks通过“函数式”的写法,成功避免了之前class component状态逻辑难以复用的问题。但由于函数式的写法太过灵活,也给我们的业务逻辑抽象与组装带来了一些挑战。接下来,本文将围绕几个主要问题讨论react hooks在业务开发中的一些理论与实践。

hooks能否返回UI组件

我的回答:要尽量避免。

Hooks就完全是一个function, 可以返回任意元素,state, component or something else. 但倘若custom hooks返回一个function Component,当其内部的state切换后,会导致该function Component的引用发生改变,从而引发外层组件的rerender(因为useCostomHooks的引用值发生了变化)。这带来两个问题:

  1. 性能问题(虽然多数情况下可能感知并不明显)
  2. 状态切换有过渡动画效果时,由于defaultValue与props.value不同,能明显看到“闪烁”。比如下面例子中的MyModal。

Example: useModal https://codesandbox.io/s/cool-yalow-5nm1e

/**
 * const { MyButton, MyModal } = useModal();
 *
 * <div>
 * <MyButton>编辑</MyButton >
 * <MyModal  onOk={() => {}}>弹窗内容</MyModal>
 * </div>
 */

const useModal = () => {
  const [on, setOn] = useState(false);
  const toggle = () => setOn(!on);
  const MyBtn = props => <Button {...props} onClick={toggle} />;
  const MyModal = ({ onOk, ...props }) => (
    <Modal
      {...props}
      visible={on}
      onOk={async () => {
        onOk && (await onOk());
        toggle();
      }}
      onCancel={toggle}
    />
  );
  return { MyBtn, MyModal };
};

useModal方法内部封装了控制Modal显隐的状态,并return了 MyModal与MyButton两个UI组件。

触发Modal onOk回调时,会将visible state切换,useModal的rerender会返回一个新的MyModal引用,并会导致外层组件的rerender。虽然性能略有影响,但UI上并不会看出什么问题。可如果useModal内部再添加上一个状态,比如confirmLoading的话,“闪烁”问题就很明显了:

onOk={async () => {
        if (onOk) {
          setConfirmLoading(true);
          await onOk();
          setConfirmLoading(false);
        }
        toggle();
      }}

调用onOk前后分别加上confirmLoading状态的切换,这会导致useModal(以及外层组件)多1次rerender:而在这rerender过程中,Modal会重新mount,default visible是false,但传入的visible是true, 所以会导致Modal出现从关闭到显示的动画。这就是前面提到的“闪烁问题”:在弹窗真正关闭之前,Modal出现了关闭又打开的过程。

对添加confirmLoading后,导致useModal多rerender 1次的解释: okOk异步操作时, ​setConfirmLoading(true)​会执行,导致rerender1次 (confirmLoading多带来的一次rerender) onOk执行完后,​setConfirmLoading(false)​与​toggle()​的state更新会合并执行,导致rerender 1次(本来就有)

综上,使用hooks的原则: 只抽象数据逻辑,不包含UI组件。组件的封装还是交给“组件化”。

useEffect: 如何比较object type deps的变化

在function component中,常常会定义一个类型为obejct的variable(比如queryObj),然后在其变化时,执行一些副作用。而useEffect 检测deps变化是通过浅比较实现的,这回导致每次rerender时发现queryObj !== queryObj,从而多次执行effect。那么对于类型为object的依赖,如何正确判断其是否改变呢?

  1. 可以通过json.stringify将object变为string类型作为依赖项;
  2. 实现一个 deep comparable ​useEffect​: 将useEffect的deps返回为memoized value.
import { useEffect, useRef } from 'react';
import { isEqual, cloneDeep } from 'lodash';

const useDeepCompare = value => {
  const ref = useRef();
  if (!isEqual(value, ref.current)) {
    ref.current = cloneDeep(value);
  }

  return ref.current;
};

const useDeepEffect = (callback, deps) => {
  useEffect(callback, useDeepCompare(deps)); 
};

export const useDeepUpdate = (callback, deps) => {
  const didMountRef = useRef(null);
  useDeepEffect(() => {
    if (!didMountRef.current) {
      didMountRef.current = true;
      return;
    }
    callback();
  }, deps);
};

export default useDeepEffect;

以列表页为例,谈谈数据的组合与抽象

列表页通常分为2部分: 筛选项Filter和列表Table(展示列表+翻页)。

如果按照较细的粒度定义state,组件状态可能定义成这样:

const [filter, setFilter] = useState({});
  const [pagination, setPagination] = useState({
    current: 1,
    pageSize: PAGE_SIZE,
    total: 1,
  });
  const [data, setData] = useState([]);

  const { current, pageSize } = pagination;

然后对于副作用,filter和current变化时,行为略有不同: 筛选部分点击“查询”按钮时,需要重置current page. 所以针对fetchData方法传个参数,标识是否reset currentPage就好?

// fetchData 参数为resetPage
  useDeepEffect(() => {
    fetchData(true);
  }, [filter]); //filter type为对象

  useEffect(() => {
    fetchData(false);
  }, [current]);

然后fetchData也很快写好:

const fetchData = async resetPage => {
    // 筛选框点击“查询”按钮时需要重置current page
    let page = current;
    if (resetPage) {
      page = 1;
    }
    const res = axios.post('/xxx', { ...filter, page, pageSize });
    const { data = [], total = 0 } = res.data;
    const newPagination = { ...pagination, total };
    if (resetPage) {
      newPagination.current = 1;
    }
    setData(data);
    setPagination(newPagination);
  };

在fetchData内根据resetPage的值也做了行为的区分,虽然代码看起来恶心,但逻辑好像也不复杂。然而,好像哪里不太对劲:如果resetPage为true, 那么setPagination就会改变current的值,然后根据前面写的useEffect...又会调用一遍fetchData?

问题出在useEffect的两个deps,current与filter,有依赖:filter变化时会导致current的变化。或者说,对网络请求而言,filter与page、pageSize的组合才是导致effect的依赖项。

即从数据流角度来看: Filter+pagination是一类,都是广义上的filter; 剩下的就是展示型的列表。重新设置state:

const { filters, setFilters } = useState({ current: 1 }); // 将网络请求相关的参数,都放到filters中
  const [data, setData] = useState([]);
  const [total, setTotal] = useState(0);

  const { current } = filters;

  const fetchData = () => {
    const res = axios.post("/xxx", { ...filters, pageSize: PAGE_SIZE });
    const { data, total } = res.data;

    setData(data);
    setTotal(total);
  };

  useDeepEffect(() => {
    fetchData();
  }, [filters]);

  onFilterChange = params => {
    setFilters({ ...filters, ...params, current: 1 });
  }; //记得重置current Page

  onPagination = page => {
    setFilters({ ...filters, current: page });
  };

此时,数据流可以正常工作,稍加完善之后,就可以将这些封装成为custom hook: useListData,作为列表页的统一数据流管理方法。

如上,其实我们讨论的是:“面向UI”的状态定义 vs “面向请求”的状态定义。在hooks操作中,复杂点通常是来自于对副作用的管理,面向请求进行状态定义,可以将相关依赖直接封装进一个“笼子”里。至少对列表页这个场景来说,如此定义状态,数据处理逻辑简单清晰了很多。

useListData在多Tab场景下的使用

前面对useListData方法export出了两种类型的变量

  • state: loading、dataSource和pagination
  • function: setFilter, setPagination, updateListData, refreshListData(updateListData可用于行内编辑,refreshListData适用于新增记录之后刷新数据)

显然这个方法封装能够满足通常的筛选面板+列表页需求。如果改为筛选面板+多Tab下的列表呢,useListData还能满足需求吗?

有了useEffect这种“auto run”方法,嵌套几层都不是问题。因为useListData内部就是靠filters的变化触发的数据请求,所以如上UI的变化并不会影响useListData内部逻辑,我们只需要: 将顶部筛选项的queryObj作为props传入TabPane内即可,TabPane内再定义一个“副作用”即可。

const { setFilter } = useListData('/api/xxx', params);

    useDeepUpdate(() => {
        setFilters(queryObj);
    }, [queryObj])

至此,可以看到通过hooks将逻辑抽象,可以轻易实现相似场景下的逻辑复用。并且,也都有效降低了每个文件的代码量,便于后续维护。这个场景也呼应了最初提出的原则:hooks最好只封装数据逻辑,而不返回UI组件。这样,在UI发生变化后,我们的useListData仍能使用。

Hooks && Hoc: 鱼与熊掌兼得

Hoc(High Order Component)是在hooks出现之前,复用class component逻辑的常见操作。Hooks的出现,使得我们可以以很低的成本实现逻辑复用,并且还避免了组件的层层嵌套。可以说,在hooks时代,Hoc的使用场景会越来越少。而在使用hooks时,我发现有一个场景特别适合搭配Hoc使用。

Hooks中有这样一条规则: Only call hooks at the top level. 因为react需要依赖hooks调用的顺序来确定该state对应哪个useState. 所以要求在每次render时,hooks的调用顺序都要保持一致。

而在一些场景下:我们需要先检查一些数据是否已满足条件,若未满足就return null 或者 Empty之类。此时,将检查逻辑封装进Hoc再合适不过了,因为严格地说,这检查方法是不属于该function componen内的业务逻辑的。并且,将检查逻辑封装进Hoc还可以避免eslint总是报错“run conditionally”的问题(感觉是eslint太严格了,在文件顶层做如此判断,并不会导致hooks run conditionally,因为如果条件不满足,根本不会执行到hooks)。

所以可以抽象一个通用的Hoc,比如checkRequiredDataHoc

const checkRequirdDataHoc = (
  checkFunc,
  placeholderElement,
) => WrappedFunction => props => {
  if (typeof checkFunc === 'function' && checkFunc(props)) {
    return <WrappedFunction {...props} />;
  }

  return placeholderElement || null;
};

Hooks 给一些通用场景下的业务逻辑提供了复用的可能,并且如果以只抽象数据逻辑为原则,其使用的灵活性还是很高的,可以适应多种相似场景的需求。随着hooks的流行,team内也需要沉淀各种custom hooks。它该归为组件库还是函数工具库,or something else?


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK