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的引用值发生了变化)。这带来两个问题:
-
性能问题(虽然多数情况下可能感知并不明显)
-
状态切换有过渡动画效果时,由于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的依赖,如何正确判断其是否改变呢?
-
可以通过json.stringify将object变为string类型作为依赖项;
-
实现一个 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赞 赞赏