React Hooks的逻辑抽象与封装

1,957 阅读8分钟
登录 注册 写文章 首页下载APP

React Hooks的逻辑抽象与封装

RichardBillion关注赞赏支持

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 codesandbox.io/s/cool-yalo…

/**
 * 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还能满足需求吗?🧐

筛选项+多tab

有了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;
};

评论0 赞1赞 赞赏