【第1859期】React Hooks的体系设计之一 - 分层
前言
今日早读文章由@张立理授权分享。
正文从这开始~~
React Hooks是React框架内的逻辑复用形式,因其轻量、易编写的形态,必然会逐渐成为一种主流。但在实际的开发中,我依然觉得大部分的开发者对hook的使用过于粗暴,缺乏设计感和复用性。
万物始于分层
软件工程中的经典论述:
We can solve any problem by introducing an extra level of indirection.
没有什么问题是加一个层解决不了的。
这个论述自软件工程诞生起,时至今日依然是成立的。但要使之成立就必须有一个大前提:我们有分层。
React内置的hook提供了基础的能力,虽然本质上它也有一些分层,比如:
useState是基于useReducer的简化版。
useMemo和useCallback事实上可以基于useRef实现。
但在实际应用时,我们可以将这些统一视为一层,即最基础的底层。
因此,如果我们在实际的应用开发中,单纯地在组件里组合使用内置的hook,无疑是一种不分层的粗暴使用形式,这仅仅在表象上使用了hook,而无法基于hook达到逻辑复用的目标。
状态的分层设计
分层的形式固然千千万万五花八门,我选择了一种更为贴进传统、更能表达程序的本质的方法,以此将hook在纵向分为了6个层,自底向上依次是:
最底层的内置hook,不需要自己实现,官方直接提供。
简化状态更新方式的hook,比较经典的是引入immer来达到更方便地进行不可变更新的目的。
引入“状态 + 行为”的概念,通过声明状态结构与相应行为快速创建一个完整上下文。
对常见数据结构的操作进行封装,如数组的操作。
针对通用业务场景进行封装,如分页的列表、滚动加载的列表、多选等。
实际面向业务的实现。
需要注意的是,这边仅提到了对状态的分层设计。事实上有大量的hook是游离于状态之外的,如基于useEffect的useDocumentTitle、useElementSize,或基于useRef的usePreviousValue、useStableMemo等,这些hook是更加零散、独立的形态。
使用immer更新状态
在第二层中,我们需要解决的问题是React要求的不可变数据更新有一定的操作复杂性,比如当我们需要更新对象的一个属性时,就需要:
const newValue = {
...oldValue,
foo: newFoo,
};
这仅限于一个属性的更新,如果属性的层级较深时,代码就不得不变成这样子:
const newValue = {
...oldValue,
foo: {
...oldValue?.foo,
bar: {
...oldValue?.foo?.bar,
alice: newAlice
},
},
};
数组同样也不怎么容易,比如想删除一个元素,你就得这么来:
const newArray = [
...oldArray.slice(0, index),
...oldArray.slice(index + 1)
];
要解决这一系列的问题,我们可以使用immer进行更新,利用Proxy的特性将可变的数据更新映射为不可变的操作。
状态管理的基础hook是useState和useReducer,因此我们能封装成:
const [state, setState] = useImmerState({foo: {bar: 1}});
setState(s => s.foo.bar++); // 直接进行可变更新
setState({foo: {bar: 2}}); // 保留直接更新值的功能
以及:
const [state, dispatch] = useImmerReducer(
(state, action) => {
case 'ADD':
state.foo.bar += action.payload;
case 'SUBTRACT':
state.foo.bar -= action.payload;
default:
return;
},
{foo: {bar: 1}}
);
dispatch('ADD', {payload: 2});
这一部分并没有太多的工作(immer的TS类型是真的难写),但提供了非常方便的状态更新能力,也便于在它之上的所有层的实现。
状态与行为的封装
组件的开发,或者说绝大部分的业务的开发,逃不出“一个状态加一系列行为”这个模式,且行为与状态的结构是强相关的。这个模式在面向对象里我们称之为类:
class User {
name = '';
age = 0;
birthday() {
this.age++;
}
}
而在hook中,我们会这么来:
const [name, setName] = useState('');
const [age, SetAge] = useState(0);
const birthday = useCallback(
() => {
setAge(age => age + 1);
},
[age]
);
会出现一些问题:
太多的useState和useCallback调用,重复的编码工作。
如果不仔细阅读代码,很难找到状态与行为的对应关系。
因此,我们需要一个hook能帮我们把“一个状态”和“针对这个状态的行为”合并在一起:
const userMethods = {
birthday(user) {
user.age++; // 利用了immer的能力
},
};
const [user, methods, setUser] = useMethods(
userMethods,
{name: '', age: 0}
);
methods.birthday();
可以看到,这样的声明非常接近面向对象的形态。有部分React的开发者在粗浅地了解函数式编程后,成了激进的“反面向对象党”,这显然是不可取的,面向对象依然是一种很好的封装和职责边界划分的形态,不一定要以其表面形态去实现,却也万万不可丢弃了其内在理念。
数据结构的抽象
有了useMethods之后,我们已经可以快速地使任何类型和结构的状态与hook整合。我们一定会意识到,有一部分状态类型是业务无关的,是全天下开发者公用的,比如最基础的数据类型number、string、Array等。
在数据类型的封装上,我们依然会面对几个核心问题:
部分数据类型的不可变操作相当复杂,比如不可变地实现Array#splice,好在有immer合理地解决了问题。
部分操作的语义会发生变化,setState最典型的是没有返回值,因此Array#pop只能产生“移除最后一个元素”的行为,而无法将移除的元素返回。
部分类型是天生可变的,如Set和Map,将之映射到不可变需要额外的工作。
针对常用数据结构的抽象,在试图解决这些问题(第2个问题还真解决不了)的同时,也能扩展一些行为,比如:
const [list, methods, setList] = useArray([]);
interface ArrayMethods<T> {
push(item: T): void;
unshift(item: T): void;
pop(): void;
shift(): void;
slice(start?: number, end?: number): void;
splice(index: number, count: number, ...items: T[]): void;
remove(item: T): void;
removeAt(index: number): void;
insertAt(index: number, item: T): void;
concat(item: T | T[]): void;
replace(from: T, to: T): void;
replaceAll(from: T, to: T): void;
replaceAt(index: number, item: T): void;
filter(predicate: (item: T, index: number) => boolean): void;
union(array: T[]): void;
intersect(array: T[]): void;
difference(array: T[]): void;
reverse(): void;
sort(compare?: (x: T, y: T) => number): void;
clear(): void;
}
而诸如useSet和useMap则会在每次更新时做一次对象复制的操作,强制实现状态的不可变。
我在社区的hook库中,很少看到有单独一个层实现数据结构的封装,实为一种遗憾。截止到今日,大致useNumber、useArray、useSet、useMap、useBoolean是已然实现的,其中还衍生出useToggle这样场景更狭窄的实现。而useString、useFunction和useObject能够提供什么能力还有待观察。
通用场景封装
在有了基本的数据结构后,可以对场景进行封装,这一点在阿里的@umijs/hooks体现的比较多,如useVirtualList就是一个价值非常大的场景的封装。
需要注意的是,场景的封装不应与组件库耦合,它应当是业务与组件之间的桥梁,不同的组件库使用相同的hook实现不同的界面,这才是一个理想的模式:
useTransfer实现左右双列表选择的能力。
useSelection实现列表上单选、多选、范围选择的能力
useScrollToLoad实现滚动加载的能力。
通用场景的封装非常的多,它的灵感可以来源于某一个组件库,也可以由团队的业务沉淀。一个充分的场景封装hook集合会是未来React业务开发的效率的关键之一。
总结
总而言之,在业务中暴力地直接使用useState等hook并不是一个值得提倡的方式,而针对状态这一块,精细地做一下分层,并在每个层提供相应的能力,是有助于组织hook库并赋能于业务研发效率的。
关于本文 作者:@张立理 原文:https://zhuanlan.zhihu.com/p/106665408
为你推荐
【第1836期】Remax - 使用 React 开发小程序
【第1795期】SWR:最具潜力的 React Hooks 数据请求库