查看原文
其他

【第1859期】React Hooks的体系设计之一 - 分层

张立理 前端早读课 2020-03-02

前言

今日早读文章由@张立理授权分享。

正文从这开始~~

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要求的不可变数据更新有一定的操作复杂性,比如当我们需要更新对象的一个属性时,就需要:

  1. const newValue = {

  2. ...oldValue,

  3. foo: newFoo,

  4. };

这仅限于一个属性的更新,如果属性的层级较深时,代码就不得不变成这样子:

  1. const newValue = {

  2. ...oldValue,

  3. foo: {

  4. ...oldValue?.foo,

  5. bar: {

  6. ...oldValue?.foo?.bar,

  7. alice: newAlice

  8. },

  9. },

  10. };

数组同样也不怎么容易,比如想删除一个元素,你就得这么来:

  1. const newArray = [

  2. ...oldArray.slice(0, index),

  3. ...oldArray.slice(index + 1)

  4. ];

要解决这一系列的问题,我们可以使用immer进行更新,利用Proxy的特性将可变的数据更新映射为不可变的操作。

状态管理的基础hook是useState和useReducer,因此我们能封装成:

  1. const [state, setState] = useImmerState({foo: {bar: 1}});

  2. setState(s => s.foo.bar++); // 直接进行可变更新

  3. setState({foo: {bar: 2}}); // 保留直接更新值的功能

以及:

  1. const [state, dispatch] = useImmerReducer(

  2. (state, action) => {

  3. case 'ADD':

  4. state.foo.bar += action.payload;

  5. case 'SUBTRACT':

  6. state.foo.bar -= action.payload;

  7. default:

  8. return;

  9. },

  10. {foo: {bar: 1}}

  11. );


  12. dispatch('ADD', {payload: 2});

这一部分并没有太多的工作(immer的TS类型是真的难写),但提供了非常方便的状态更新能力,也便于在它之上的所有层的实现。

状态与行为的封装

组件的开发,或者说绝大部分的业务的开发,逃不出“一个状态加一系列行为”这个模式,且行为与状态的结构是强相关的。这个模式在面向对象里我们称之为类:

  1. class User {

  2. name = '';

  3. age = 0;

  4. birthday() {

  5. this.age++;

  6. }

  7. }

而在hook中,我们会这么来:

  1. const [name, setName] = useState('');

  2. const [age, SetAge] = useState(0);

  3. const birthday = useCallback(

  4. () => {

  5. setAge(age => age + 1);

  6. },

  7. [age]

  8. );

会出现一些问题:

  • 太多的useState和useCallback调用,重复的编码工作。

  • 如果不仔细阅读代码,很难找到状态与行为的对应关系。

因此,我们需要一个hook能帮我们把“一个状态”和“针对这个状态的行为”合并在一起:

  1. const userMethods = {

  2. birthday(user) {

  3. user.age++; // 利用了immer的能力

  4. },

  5. };


  6. const [user, methods, setUser] = useMethods(

  7. userMethods,

  8. {name: '', age: 0}

  9. );


  10. methods.birthday();

可以看到,这样的声明非常接近面向对象的形态。有部分React的开发者在粗浅地了解函数式编程后,成了激进的“反面向对象党”,这显然是不可取的,面向对象依然是一种很好的封装和职责边界划分的形态,不一定要以其表面形态去实现,却也万万不可丢弃了其内在理念。

数据结构的抽象

有了useMethods之后,我们已经可以快速地使任何类型和结构的状态与hook整合。我们一定会意识到,有一部分状态类型是业务无关的,是全天下开发者公用的,比如最基础的数据类型number、string、Array等。

在数据类型的封装上,我们依然会面对几个核心问题:

  • 部分数据类型的不可变操作相当复杂,比如不可变地实现Array#splice,好在有immer合理地解决了问题。

  • 部分操作的语义会发生变化,setState最典型的是没有返回值,因此Array#pop只能产生“移除最后一个元素”的行为,而无法将移除的元素返回。

  • 部分类型是天生可变的,如Set和Map,将之映射到不可变需要额外的工作。

针对常用数据结构的抽象,在试图解决这些问题(第2个问题还真解决不了)的同时,也能扩展一些行为,比如:

  1. const [list, methods, setList] = useArray([]);


  2. interface ArrayMethods<T> {

  3. push(item: T): void;

  4. unshift(item: T): void;

  5. pop(): void;

  6. shift(): void;

  7. slice(start?: number, end?: number): void;

  8. splice(index: number, count: number, ...items: T[]): void;

  9. remove(item: T): void;

  10. removeAt(index: number): void;

  11. insertAt(index: number, item: T): void;

  12. concat(item: T | T[]): void;

  13. replace(from: T, to: T): void;

  14. replaceAll(from: T, to: T): void;

  15. replaceAt(index: number, item: T): void;

  16. filter(predicate: (item: T, index: number) => boolean): void;

  17. union(array: T[]): void;

  18. intersect(array: T[]): void;

  19. difference(array: T[]): void;

  20. reverse(): void;

  21. sort(compare?: (x: T, y: T) => number): void;

  22. clear(): void;

  23. }

而诸如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 开发小程序


【第1831期】React团队的技术准则


【第1795期】SWR:最具潜力的 React Hooks 数据请求库


【PPT】@张克军:微前端架构体系

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存