把状态装进马克杯里,给你一个不烫手的状态库大家好,我是 OQ(Open Quoll),一个在前端开发摸爬了若干年的打工人
大家好,我是 OQ(Open Quoll),一个在前端开发摸爬了若干年的打工人。抱着改善一些基础问题的心愿,最近写了一个 React 状态库,名为 React Mug,请 jym 多多指教。
(友情提示:本文中部分 API 仅适用于 react-mug@0.3.x
及以下版本。)
为什么写 React Mug
(已经察觉到 React 现有状态库问题或者对为什么不感兴趣的 jym,可以直接跳至 “React Mug 基本用法”。)
自从 React 16 发布以来,我一直在用 React,React 很好,他专注渲染,能够灵活共生于不同的简单或复杂的软件结构。在 React 的理念里 “界面 = 渲染(状态)”,定义了渲染逻辑,给定了状态,界面也就确定了。但有专注就意味着有取舍,对于状态要怎么管理 React 是开放的,于是状态管理的实践成为了百家争鸣的话题。
百家争鸣本身倒不是什么坏事,只是用多了现有的状态库,内心总会不禁发问 “难道没有更好的实践吗?”。很长一段时间里,感觉自己就像知道身体难受却又不知道具体哪里难受的慢性病患者,最终走了许多弯路,之后逐渐形成了一些新的认识。
重新认识 Redux
Redux 的前身 Flux 是 React 的维护公司 Facebook 自己写 React 应用的状态管理实践,而 Redux 是 Flux 实践上最好的封装者,这让人很容易产生 “Redux 是 React 官方都在用的东西,拿来准没错” 的想法。这确实也是最不容易出错的做法,但是这会是最好的做法吗?
Redux 的核心是 “新状态 = reducer(老状态, action)”,这里 reducer 是纯函数,action 是参数包。由于纯函数是没有副作用的,换句话说结果只由入参决定而不受任何其他变量影响,使得新状态只由老状态和 action 决定,使得状态变化十分好预测,再加上纯函数很容易测试,能够大量减少状态变化的 bug。
这种说法尽管听上去很理想,但是弱化了在其他配套部分的繁琐。在 Redux 中每次状态变化前都需要准备一个 action 参数包,在推荐实践里这个过程封装在另一个函数中根据入参生成。然后手动派发这个准备好的参数包就会调用全部提前注册好的 reducer,reducer 再匹配参数包里 type 字段决定是否产生新状态。
结果是每一个状态变化都被拆分成了两个部分,“action 准备” 和 “reducer 处理”,当修改某个状态变化逻辑时,修改一个部分也要对应修改另一个部分,表现出强行拆分高耦合代码的典型症状。
然而 Flux 设计之初,在 action 和 reducer 之间其实还有个叫派发器(dispatcher)的东西,不同的 reducer 经常会处理同一个 type 的 action,通过派发器可以等待其他 reducer 读取并其最新状态来计算自己的派生状态,当 action 和 reducer 之间存在多对多关系时,上面这种拆分其实是合理的。
后来 Redux 发展 Flux 的时候通过引入 selector 单独解决了状态的派生问题,导致 reducer 之间等待的场景消失了,派发器也就被隐藏了,而 action 与 reducer 之间转变为了稳定的一对一关系,于是这种拆分就变得多余了起来,白白添加了复杂度,让学习和使用变得繁琐。
重新认识 MobX
MobX 是贴合编程语言自身的类和对象机制搭建的状态库,简洁而不简单。当应用整体是基于面向对象的思想开发时,能够自然而然的从 React 组件衔接到 MobX 组件再衔接到应用中其他纯粹的面相对象组件,可以支撑复杂结构。美中不足的是因为发布时间早,Hook 集成度不太高,在 React 组件订阅状态变化时只有 observer HOC 这一个选项。
但是面向对象编程是不是足够好的编程范式呢?问题的答案也是函数式编程流行起来的原因。面向对象编程的软肋主要是,每一个对象的方法都有可能因为对象内部状态的不同而行为不同,每次调用还会有可能更进一步改变这些内部状态,这就导致只通过阅读代码来推导对象的准确行为是比较困难的,同时测试上由于需要正确准备内部状态也会时常变得挑战。难推导、难测试就意味着容易出 bug。在没有高频率的内存操作影响性能的情况下,函数式编程在这些方面会好很多。
重新认知 Zustand 和 Jotai
Zustand 和 Jotai 都是直接基于 Hook 搭建的状态库。Zustand 原创度更高一些,Jotai 更多受到 Facebook 的 Recoil 的启发,但是两者都十分简洁,对于以渲染为主的简单应用都是完全够用的。
然而对于把 React 渲染只当作多层结构的其中一层的复杂应用来说,Zustand 和 Jotai 都缺少自己独立的组件化机制,不能帮助 React 组件有效衔接到到应用中其他纯粹的组件,不能支撑复杂结构。尽管有人尝试基于 Hook 的复杂结构,但是 Hook 也是有内部状态的,效果无法好于面向对象编程,而且这不但闲置了许多现成的软件设计方法,还把整个软件的命运与 React 的一小支机制紧密相连,会导致更高的维护风险。
发现问题
在重新认识了上面的状态库后,我逐渐总结发现一个 React 状态库的特性按照重要性可以排序为:
特性 1:简洁。
特性 2:函数式。
特性 3:贴合编程语言自身的组件化机制。
特性 4:完善的 Hook 集成。
而现有的状态库拥有的特性为:
特性 1 | 特性 2 | 特性 3 | 特性 4 | |
---|---|---|---|---|
Redux | 🔴 | 🟢 | 🟡[1] | 🟢 |
MobX | 🟢 | 🔴 | 🟢 | 🟡[2] |
Zustand | 🟢 | 🔴 | 🔴 | 🟢 |
Jotai | 🟢 | 🔴 | 🔴 | 🟢 |
🟢 完全拥有 🟡 部分拥有 🔴 没有
所以抱着点亮以上全部特性的心愿,我写了 React Mug:
特性 1 | 特性 2 | 特性 3 | 特性 4 | |
---|---|---|---|---|
React Mug | 🟢 | 🟢 | 🟢 | 🟢 |
React Mug 基本用法
React Mug 的包名为 react-mug,当前版本 0.1.0
,用喜欢的包管理器安装就可以使用了,比如我喜欢直接用 npm
:
npm i react-mug
下面我用一个 “加鱼” 的简单例子带大家快速感受一下。
首先声明一个盛装 “鱼的数量” 状态的 Mug。所谓 Mug,就是马克杯的那个马克,而状态就是马克杯里的咖啡,声明一个 Mug 就拥有了一个盛装某个状态的容器,这里的状态便是 “鱼的数量”,[construction]
字段既表示了当前对象是一个 Mug 也设置了当前 Mug 里状态的初始值:
import { Mug, construction } from 'react-mug';
const fishesMug: Mug<number> = {
[construction]: 10,
};
然后创建一个 “加鱼” 的写操作器。加鱼顾名思义就是增加一条鱼 🐟:
import { w } from 'react-mug';
const increment = w((n: number) => n + 1);
之后分别创建 “展示鱼的数量” 和 “操控鱼的数量” 的两个 React 组件:
import { check, useOperator } from 'react-mug';
function Fishes() {
const fishes = useOperator(check, fishesMug);
return <div>鱼的数量:{fishes}</div>;
}
function Controls() {
return <button onClick={() => increment(fishesMug)}>加鱼</button>;
}
最后渲染这两个组件就可以在点击 “加鱼” 按钮的时候让 “鱼的数量” 加 1 了,除了以上代码不需要进行任何额外的配置。我觉得还挺简洁的,jym 觉得怎么样?
React Mug 概念深入
下面我来一点点展开 React Mug 全貌并解释一下上面的代码中发生了什么。
Mug
如基本用法里提到的,Mug,马克杯,是 React Mug 承载状态的基本模块,状态就像咖啡一样盛装在马克杯里被管理着。任何一个顶层字段中包含 [construction]
的对象都是一个 Mug,而 [construction]
字段的值就是这个 Mug 所盛装状态的初始值。比如这是一个 Mug:
import { construction } from 'react-mug';
const sizeMug = {
[construction]: 'XL',
};
这也是一个 Mug:
import { construction } from 'react-mug';
const deskMug = {
[construction]: {
l: 80,
w: 80,
h: 80,
},
};
Mug 对盛装状态的类型是开放的,一个在任何一层字段中都不包含 [construction]
的值便可以是状态,比如原始类型值、数组、元组、普通对象、类的实例等等。为了方便定义类型,可以借助帮助类型 Mug
,比如刚刚的两个 Mug 可以分别优化为:
import { Mug, construction } from 'react-mug';
const sizeMug: Mug<string> = {
[construction]: 'XL',
};
import { Mug, construction } from 'react-mug';
interface DeskState {
l: number;
w: number;
h: number;
}
const deskMug: Mug<DeskState> = {
[construction]: {
l: 80,
w: 80,
h: 80,
},
};
这也是基本用法中声明 fishesMug
的方式。
如果需要动态生成 Mug,可以通过类或创建函数实现,比如 sizeMug
对应的类和创建函数可以分别这么定义:
import { Mug, construction } from 'react-mug';
class SizeMug implements Mug<string> {
[construction] = 'XL';
}
import { Mug, construction } from 'react-mug';
function createSizeMug(): Mug<string> {
return {
[construction]: 'XL',
};
}
读操作
有了 Mug,就可以对其中的状态进行读写操作了。操作(Operation)是通过操作器(Operator)完成的。React Mug 中的操作器只有两种,读操作器(Read operator)和写操作器(Write operator),分别对状态进行读操作(Read operation)和写操作(Write operation)。
读操作器能够接收 Mug 以及其他一些参数,然后返回一个值。读操作器不会改变入参 Mug 里的状态。最简单的读操作器是 React Mug 自带的 check
,只会直接返回入参 Mug 里的状态。比如读取 sizeMug
:
import { check } from 'react-mug';
const size = check(sizeMug); // 结果:'XL'
或者在 React 组件中结合 useOperator
持续读取 sizeMug
:
import { check, useOperator } from 'react-mug';
function SomeReactComponent() {
const size = useOperator(check, sizeMug);
return <div>Size: {size}</div>
}
这也是基本用法中读取 fishesMug
的方式。
自定义读操作器是通过帮助函数 r
实现的,能够根据给到的读函数(Read function)生成对应结构的读操作器。当调用读操作器时,会把入参 Mug 里的状态以及其他一些入参传入到读函数中进行调用,然后直接透传返回读函数的返回值。比如这样可以自定义一个对 sizeMug
的读操作器:
import { r } from 'react-mug';
const bigger = r((size: string) => {
return 'X' + size;
});
然后就可以像 check
那样使用了:
const biggerSize = bigger(sizeMug); // 结果:'XXL'
import { useOperator } from 'react-mug';
function SomeReactComponent() {
const biggerSize = useOperator(bigger, sizeMug);
return <div>Bigger size: {biggerSize}</div>
}
写操作
另一种操作器就是写操作器了。写操作器能够接收 Mug 以及其他一些参数,然后返回入参 Mug。写操作器通常会改变入参 Mug 里的状态。React Mug 自带的写操作器 swirl
能够以合并逻辑改变 Mug 里的状态。比如写入 deskMug
:
import { check, swirl } from 'react-mug';
swirl(deskMug, { h: 120 });
const deskState = check(deskMug); // 结果:{ l: 80, h: 80, h: 120 }
自定义写操作器是通过帮助函数 w
实现的,能够根据给到的写函数(Write function)生成对应结构的写操作器。当调用写操作器时,会把入参 Mug 里的状态以及其他一些入参传入到写函数中进行调用,然后写函数的返回值会作为新状态写入入参 Mug,之后返回入参 Mug。比如这样可以自定义一个对 deskMug
的写操作器:
import { w } from 'react-mug';
const adjustHeight = w((deskState: DeskState, h: number) => {
return { ...deskState, h };
});
然后就可以像 swirl
那样使用了:
import { check } from 'react-mug';
adjustHeight(deskMug, 120);
const deskState = check(deskMug); // 结果:{ l: 80, h: 80, h: 120 }
这也是基本用法中定义和使用 increment
的方式。
Mug 连续统
既然在顶层字段中包含 [construction]
的对象是 Mug,在任何一层字段中都不包含 [construction]
的值是状态,那么介于两者之间的是什么呢?比如:
import { construction } from 'react-mug';
const stuff = {
l: 80,
w: 80,
h: {
[construction]: 80
}
};
这便是 MugLike 了,即类马克杯。任何一个在任何一层字段中包含或不包含 [construction]
的值都是 MugLike。类马克杯就像是一包马克杯和咖啡的混合物,可以观察一下 stuff
中的 h
字段其实是一个 Mug。
现在重新看一下 Mug、MugLike 和 状态 的关系。最像 Mug 的,或者说最 MugLike 的,就是 Mug 本身,而最不像 Mug 的,或者说最不 MugLike 的,就是状态。(一个 MugLike 可能是 Mug,也可能是状态,也可能是 Mug 和 状态之间的什么。而一个 Mug 一定是 MugLike,同理一个状态也一定是 MugLike。)
于是 Mug、MugLike 和 状态 构成了 Mug 连续统(一个有相关特征的实体构成的连续而统一的整体)。
然后再看一下读写操作,其实操作器的第一个参数不仅可以是 Mug,也可以是 MugLike,只要层层解析 [construction]
字段后得到的最终状态匹配操作器内部函数对应参数的结构即可。操作器实际上就是层层解析入参 MugLike 后把得到的状态传入到其内部函数中的。比如 adjustHeight
和 check
都可以操作刚刚的 MugLike stuff
:
import { check } from 'react-mug';
adjustHeight(stuff, 120);
const result = check(stuff); // 结果:{ l: 80, w: 80, h: 120 }
但是 MugLike 的用途不是仅仅解决形式问题,更重要的是让一个操作器同时操作多个 Mug 变得可能。比如可以这样扩展一下基本用法的例子,先创建一个盛装 “熊掌的数量” 状态的 Mug:
import { Mug, construction } from 'react-mug';
const bearPawsMug: Mug<number> = {
[construction]: 10,
};
然后定义一个用鱼换熊掌的写操作器就可以实现 “舍鱼而取熊掌” 的逻辑了:
import { tuple, w } from 'react-mug';
const swapFishesForBearPaws = w(([fishes, bearPaws]: [number, number]) => {
const rate = 3; // 三条鱼换一个熊掌
return tuple(fishes % rate, bearPaws + Math.floor(fishes / rate));
});
function Controls() {
return <>
...
<button onClick={() => swapFishesForBearPaws([fishesMug, bearPawsMug])}>
舍鱼而取熊掌
</button>
</>;
}
这里鱼和熊掌的元组是一个 MugLike。由于 TypeScript 中没有元组的字面量,所以 React Mug 提供了 tuple
函数帮助。
嵌套 Mug
如同 MugLike 对于读写操作器那样,Mug 的 [construction]
字段也不仅可以是状态,也可以是 MugLike。也就是说,Mug 是可以嵌套 Mug 的,或者说,Mug 是可以引用 Mug 的。比如可以这样声明一个 heightMug
然后嵌套为 deskMug
中的 h
字段:
import { Mug, construction } from 'react-mug';
const heightMug: Mug<number> = {
[construction]: 80,
};
const deskMug: Mug<DeskState, { h: Mug<number> }> = {
[construction]: {
l: 80,
w: 80,
h: heightMug,
},
};
而对于读写操作器只要入参 Mug 在层层解析后得到的状态依旧匹配其内部函数对应参数的结构就可以继续正常运行了。当写操作器改变 deskMug
中的 h
字段的值时,heightMug
里的状态也会随之改变。反之亦然。比如用 adjustHeight
写入 deskMug
时 heightMug
的状态就会随之改变:
import { check } from 'react-mug';
adjustHeight(deskMug, 120);
const height = check(heightMug); // 结果:120
通过嵌套 Mug 就可以编织任意复杂度的 Mug 网络了。值得注意的是,当 Mug 之间存在循环引用时,调用操作器会抛出异常。
函数式编程
既然操作器的第一个参数可以是 MugLike,那么这个参数自然也可以是最不 MugLike 的 状态(任何一层字段中都不包含 [construction]
)。以状态为入参调用操作器与直接调用操作器内部的函数是完全等价的,此时操作器就回归为了纯函数。比如 bigger
和 adjustHeight
都可以作为纯函数调用:
const biggerSize = bigger('L'); // 结果:'XL'
const deskState = adjustHeight({ l: 80, w: 80, h: 80 }, 120); // 结果:{ l: 80, w: 80, h: 120 }
由于操作器内部函数的第一个参数本身就是状态,所以操作器内部可以把参数匹配的其他操作器当作纯函数直接调用。也就是说,操作器是可以嵌套操作器的。比如可以这样扩展一下基本用法的例子实现 “养鱼” 的功能,先是定义一个 “繁殖” 的写操作器:
import { w } from 'react-mug';
const reproduce = w((n: number) => Math.floor(1.2 * n));
然后定义和使用 “养鱼” 的写操作器实现先加一条鱼再繁殖的逻辑:
import { w } from 'react-mug';
const farmFishes = w((fishes: number) => {
return reproduce(increment(fishes));
});
function Controls() {
return <>
...
<button onClick={() => farmFishes(fishesMug)}>养鱼</button>
</>;
}
通过嵌套操作器就可以用函数式编程的思想编织任意复杂度的逻辑了。而且测试操作器的时候也可以直接传入状态进行调用而不用特别准备 Mug。
结语
以上就是 React Mug 的理念和用法了,应该算是同时点亮了前面提到的 4 个特性。目前项目已经写了大量测试保障了功能的正确性,请 jym 多多指教,欢迎试用、评论、私信、提 issue(或者 点星 🌟、点赞 👍、收藏 ❤️),我会吸收意见积极跟进,谢谢!
转载自:https://juejin.cn/post/7410601890793521193