rx-immer
基于不可变数据(immutable)与响应式数据流(observable)的JavaScript应用状态轻量级管理框架。
背景
不可变数据(immutable)
使用immer.js库维护应用状态对象的不可变性以及跟踪状态修改的增量信息。 immer能够保持JavaScript可序列化对象的不可变性,当嵌套的对象树的深层属性值发生改变时,能够自动新建对象来记录修改,而不影响到旧有的对象。对于树状结构中未修改的对象则复用旧对象的引用来优化性能(区别于深拷贝,很大程度上避免内存浪费)。
响应式数据流(observable)
使用rxjs库构建用于通知订阅模式的可观察对象observable,用于分发能够应用状态改变的消息。rxjs是主流的响应式编程框架ReactiveX在JavaScript平台的版本,融合了通知订阅模式、迭代器模式、异步调度、函数式编程等多种范式,其特有的操作符能够让开发者方便地利用函数式编程范式解决复杂的业务逻辑,为程序带来良好的扩展性、低耦合性、易调试性等。
快速开始
rx-immer具有非常简易实用的基础功能API,使用者不需要关注任何额外的细节或者编程模式,创建、读取、修改、销毁,均可以一个接口一行代码轻松搞定,灵活便捷。
在项目中引入
npm install rx-immer --save
在React项目中
注意:在v0.1.2之后,rx-immer与react搭配使用的代码被单独封装为rx-immer-react包,对react的依赖也从rx-immer中移除
npm install rx-immer-react --save
创建实例
快速创建
import { create } from 'rx-immer';
const store = create({});
工厂模式
import { factory } from 'rx-immer';
const store = new (factory())({});
rx-immer可使用工厂模式创建实例,factory
函数为类工厂,可接受配置项参数,返回被配置的类,即可使用new
关键字构建实例。构造函数接受一个可序列化的对象(例如Object、Array、Map、Set,可多层嵌套)参数,作为实例的初始状态值。
在TypeScript中,factory
作为泛型函数,能够显式地指定类型,指示状态值的类型信息:
import { factory } from 'rx-immer';
interface IState {
id: number;
name: string;
status: boolean;
}
const CustomStore = factory<IState>(); // 不传入配置项参数,使用默认配置
const store = new CustomStore({ id: 0, name: 'abc', status: true });
v0.1.2之后的版本提供了快捷使用的工厂函数create
直接创建实例:
import { create } from 'rx-immer';
const store = create({ id: 0, name: 'abc', status: true }, { /* 配置项 */ });
// create
可显式指定state类型,也可让编译器根据初始状态值推断
// 第二个参数为可选的配置项
在React项目中
rx-immer-react提供了自定义hooks更简单地创建实例。具体请参阅rx-immer-react项目文档。
监听状态改变
监听与解除监听
const subscription = store.observe().subscribe((state) => {
console.log(state);
}); // 监听state改变
subscription.unsubscribe(); // 解除监听
observe
实例方法返回一个Observable对象,关于Observable对象的细节可查阅rxjs文档。
通过路径指定监听的目标
observe
实例方法接受一个listenPath参数,类型为Path(string | string[]),代表需要监听的目标在整个状态对象中的路径,用于指示监听的范围;例如store.observe('a[0].b.c')
,即代表监听state.a[0].b.c
的改变;如不传入参数,则监听整个state的变化。 Path既可以是string,也可以是string数组;当path为string时,即代表以.
与[]
的语义书写路径;当path为string数组时,代表以path components的语义书写路径,两者等效:
'a[0].b.c'
= ['a', 0, 'b', 'c']
const subscription = store.observe('a[0].b.c').subscribe((c) => {
console.log(c);
}); // 监听state.a[0].b.c改变
const subscription = store.observe(['a', 0, 'b', 'c']).subscribe((c) => {
console.log(c);
}); // 等效写法
同样,在TypeScript中,observe
作为泛型方法,可以接受一个类型参数,指示监听值的类型信息。因为利用路径获得值具有动态性,所以TypeScript编译器无法自动推断监听值的准确类型,需要手动指定。
const subscription = store.observe<number>('a[0].b.c').subscribe((c) => {
console.log(c); // TypeScript编译器此时能知道c的类型为number
});
**注意:**在监听使用结束后,需要执行subscription.unsubscribe()
解除监听以释放资源。
v0.4.0新增:新增了query
实例方法,接受一个查询字符串参数,监听在状态对象中使用JSONPath查询字符串检索出的结果。
const subscription = store.query<Book>('$..book[?(@.price<10)]').subscribe((result) => {
console.log(result.length, result); // 类型为Book[],所有price小于10的Book对象
});
JSONPath具体语法与示例见相关文档。
在React项目中
rx-immer-react提供了自定义hooks更简单地绑定状态到组件的state。并且,在React组件卸载时,绑定的监听也会自动解除。具体请参阅rx-immer-react项目文档。
直接获取状态值
const state = store.value();
const c = store.value<number>('a[0].b.c'); // version >= 0.3.1
v0.4.0新增:新增了find
实例方法,接受一个查询字符串参数,返回在状态对象中使用JSONPath查询字符串检索出的结果集合。
const result = store.find<Book>('$..book[?(@.price<10)]'); // 类型为Book[],所有price小于10的Book对象
JSONPath具体语法与示例见相关文档。
修改状态
store.commit((state) => {
state.name = 'new name';
});
rx-immer实例修改状态是通过提交事务的方式进行的,commit
方法接受一个自定义的recipe函数,函数将被传入一份状态值的代理;在函数内所有对于state的直接操作(对象增加、删除、修改字段,数组增加、删除、修改元素等等)会被自动记录在事务当中,并在函数返回之后提交修改,更新实例的状态值,并通知相关的监听。
commit
方法可以接受第二个参数targetPath,即需要修改的目标在整个状态对象中的路径,用于指示修改的范围:
store.commit((b) => {
b.c = 1;
}, 'a[0].b');
注意:targetPath所指向的值必须是引用类型(如Object、Array)(已更新了新特性,详见下文),并且所有赋值操作前不能对代理对象解引用:
store.commit((c) => {
c = 1; // 错误!c为值类型,无法捕获修改!
}, 'a[0].b.c');
store.commit((b) => {
let { c } = b;
c = 1; // 错误!解引用,无法捕获修改!
}, 'a[0].b');
但是,只要变量指向的是引用类型,依然可以捕获修改:
store.commit((a0) => {
const { b } = a0;
b.c = 1; // 正确!因为变量b指向一个引用对象
}, 'a[0]');
一般规律是,在编写recipe函数体时,如果需要对代理对象内部属性解引用,请使用const
关键字以保证变量的不可变性。并且,如果项目使用eslint作为代码校验工具,可开启no-param-reassign规则禁止对函数参数再赋值,消除不恰当的代码可能造成的问题。
同样,在TypeScript中,commit
作为泛型方法,可以接受一个类型参数,指示修改目标的类型信息。
v0.2.0新增:提供commitValue方法,可对非引用类型的值类型数据进行修改,commitValue的recipe闭包传入一个包裹类型,通过直接访问包裹类型的value属性读写路径指向的值类型数据。 已移除
v0.3.0新增:commit方法接受一个返回值,代表直接修改路径指向的数据。注意保证上层路径指向一个引用类型,否则会抛出错误。
store.commit((c) => {
return c + 1;
}, 'a[0].b.c');
在不指定路径时,即对顶层state进行修改时,也可以返回值,此时将直接修改整个state。注意,对顶层state的一次commit中,要么对传入的state代理进行修改,要么返回一个新的state值,不能先进行修改,然后返回一个新的state值(使用返回值给出一个新的状态意味着之前所有的修改均无效,immer框架为避免歧义,在这种场景下会抛出错误)。
store.commit((state) => {
state.a[0].b.c = 1; // 允许,只对代理进行修改
});
store.commit(() => {
return { a: [{ b: { c: 1 } }] }; // 允许,只返回了新的状态值
});
store.commit((state) => {
state.a[0].b.c = 1;
return { a: [{ b: { c: 1 } }] }; // 报错!既进行了修改,又返回了新的状态值
});
如果想要将该路径的值设为undefined,该如何操作?
store.commit(() => {
return undefined; // 这是无效的,因为无法判断函数是显式返回undefined或者隐式结束
});
为此,immer框架专门提供了一个特征的实例nothing
,以显式的表示返回undefined值:
import { nothing } from 'rx-immer';
store.commit(() => {
return nothing; // 代表将state设为undefined(如在TypeScript中使用,state类型必须为可空类型T | undefined,否则会提示类型错误)
});
高级:请保证recipe函数同步返回时便完成本次提交的所有修改,传入recipe函数的代理state不应该被闭包捕获,因为在recipe函数返回之后,对于state的任何修改都不再会生效。例如state被闭包捕获传入某个异步回调中,在异步调用发生时,recipe函数已经执行完毕,修改事务已经提交,此时对于state的修改将不再有效果。
api().then((res) => {
store.commit((state) => {
state.data = res.data;
});
}); // 正确
store.commit((state) => {
api().then((res) => {
state.data = res.data;
});
}); // 错误!state被传入异步回调中,修改无法生效!
v0.3.0新增:提供了专门的commitAsync方法以完成异步的修改。commitAsync接受的recipe
是一个async function:
store.commitAsync(async (state) => {
const res = await api();
state.data = res.data;
});
ps:未来版本会考虑将commit方法与commitAsync方法合并统一处理同步与异步recipe。
创建子实例
v0.2.0新增:可以通过sub
方法创建实例的子实例:
const subStore = store.sub('a[0].b');
子实例接受一个路径值,为该子实例相对于父实例的相对路径:
store.observe('a[0].b.c');
subStore.observe('c'); // 两者等效
store.commit((b) => { b.c = 1; }, 'a[0].b');
subStore.commit((b) => { b.c = 1; }); // 两者等效
同时,子实例也能创建下一层的子实例:
const subSubStore = subStore.sub('path');
子实例可以通过sup
方法获得上一层的父实例:
const store = subStore.sup();
也可以通过root
方法获得最上层的根实例:
const store = subSubStore.root();
通过isSub与path字段查询实例是否为子实例以及相对于父级的路径:
store.isSub // boolean
store.path // eg: ['a', 0, 'b']
使用TypeScript时,实例与子实例之间有递归嵌套的泛型系统,可帮助编译器智能判定sup与root方法返回的上层实例的具体类型,与之对应的,需要在调用sub
方法产生子实例时显式指定子实例的状态类型T。
销毁实例
store.destroy();
清理实例内部监听事件链,如果手动创建实例,请保证在实例生命周期结束时显式调用destroy
方法。
在React项目中使用
rx-immer-react包含一系列扩展方便在React项目中使用rx-immer,具体请参阅rx-immer-react项目文档。
扩展功能
rx-immer使用高阶类(HOC)实现继承派生模式以扩展功能,并且使用工厂模式在创建实例时动态地派生子类以加载扩展的功能。采用这种模式能够提升项目的扩展性,也方便使用者开发自己的高阶类自定义扩展功能。
项目内置了一些扩展功能,通过配置项可以加载这些功能并使用。
操作记录栈
rx-immer默认配置开启操作记录栈,能够实现状态修改的撤销/重做等时间漫游功能:
const [undos, redos] = store.getRoamStatus?.() ?? [0, 0];
// 获取操作记录栈信息: [当前可撤销步骤数, 当前可重做步骤数]
store.revert?.(); // 撤销
store.recover?.(); // 重做
- Q:为什么要使用
?.
操作符?- A:扩展功能是根据配置项动态加载的,当相关配置项指定为关闭时,扩展功能不会加载,此时实例相关功能的方法是空值。对于TypeScript编译器来说,因为配置项具有动态性,是无法准确推断实例的具体派生类型,此时将扩展功能的方法指定为可空类型是一个安全的实践。
历史归档及场景还原
rx-immer内置了一个实验性的扩展功能,能够记录下应用状态的详细变更历史并进行归档。这不同于操作记录栈功能,操作记录栈功能可以方便使用者在变更提交中时间漫游,撤销或重做操作;而开启详细变更历史记录功能后,实例将会存储每一次变更细节,即使是操作记录栈的撤销或重做,也会作为一次变更历史进行归档。 历史归档功能可以应用在一些交互页面,如表单页,利用历史归档功能详细记录用户对于表单的操作轨迹。
场景还原功能作为历史归档功能的配属,能够接收历史归档数据,就像放映机插入光碟一样,还原播放应用状态历史记录。
历史归档及场景还原功能默认配置不开启。
历史归档
const store = create({}, { diachrony: true }); // 配置开启历史归档功能
const size = store.size?.(); // 查询当前历史记录的长度
const size = store.useDiachronySize?.(); // 在React中,可将历史记录长度状态绑定到组件状态中
const diachrony = store.archive?.(); // 将当前历史记录归档返回,并重置内部历史记录表,开启下一段记录
历史归档信息格式:
interface Diachrony<T extends Objectish> {
anchor: Immutable<T>; // 初始状态
anchorTimeStamp: number; // 初始状态时间戳
flows: { // 变更历史
uid: number; // 序列id
timeStamp: number; // 时间戳
patchesTuple: PatchesTuple; // 变更增量数据
}[];
destination: Immutable<T>; // 结束状态
archiveTimeStamp: number; // 结束状态时间戳
}
历史归档信息是以增量而非全量的方式来记录变更历史的,不会因为状态对象庞大而记录大量的冗余信息。
场景还原
需要在实例创建时指定开启场景还原模式,当开启场景还原模式后,内置的其他扩展功能将自动关闭。 并且,实例的commit
方法也将不生效(实例切换为只读模式)。
const store = create({}, { replay: true }); // 配置开启场景还原模式
store.timeRange$ // 流数据,当前播放的历史归档的起始结束时间戳,可以调用subscribe以监听变化
store.setDiachrony?.(diachrony); // 设置重播的历史归档数据
const keyFrames = store.getKeyframes?.(); // 获取重播历史的关键帧(发生变更历史的时间戳)
store.replay?.(timeStamp); // 将播放指针移动到某个时间戳
如果应用使用rx-immer框架管理状态,在绝大多数情况下,不需要对项目的业务代码进行任何修改,只需要将创建的实例切换到场景还原模式,便可以插入某个历史归档信息进行观看。
在演示项目中有详细的使用演示,并且演示了如何使用rx-immer管理antd的表单组件状态:只需轻松添加两行代码即可将现有的antd表单组件纳入rx-immer框架管理,给表单添加用户操作撤销、重做,记录与重播用户表单操作轨迹的功能。
添加事务
向rx-immer实例上注册事务,通常用于在实例初始化时添加常驻的监听事件。事务按照自定义的key管理,可手动停止,或者在实例destroy时自动停止。
// 开启事务
const affairKey = store.startAffair(function() { // 如果在此处不使用箭头函数,则可以在函数体内通过this取到实例
const subscription = this.observe().subscribe(() => {
// ...监听逻辑
});
// 返回用于清理监听的闭包
return () => {
subscription.unsubscribe();
}
}, 'custom affair key'); // 可提供一个自定义的事务唯一key,用于关闭或重启事务(再次添加相同key的事务时会自动停止上一次);
// 如不指定key,则会自动产生一个递增的key;key会作为startAffair方法的返回值。
store.stopAffair(affairKey); // 按照事务key停止事务,返回一个布尔值,是否成功停止
if (store.hasAffair(affairKey)) { /* ... */ } // 是否存在指定key的事务
const affairKeys = store.showAffairs(); // 获取所有运行中的事务key
配置
// 默认配置及配置详解
interface Config {
history: // 操作栈功能配置,设置为false时关闭操作栈功能
| {
capacity: number; // 操作栈最大容量
bufferDebounce: number; // 合并短时间多个状态变更操作的时间阈值
}
| false;
diachrony: boolean; // 是否开启历史记录归档功能
replay: boolean; // 是否开启场景还原模式
}
const defaultConfig: Config = {
history: { // 默认开启操作栈功能
capacity: Number.POSITIVE_INFINITY, // 默认操作栈容量无限制
bufferDebounce: 0, // 默认时间阈值为0,即不合并短时间多个操作
},
diachrony: false, // 默认关闭历史记录归档功能
replay: false, // 默认关闭场景还原模式
};
工具函数
rx-immer封装了多个工具函数,帮助完成一些通用的功能:
// 处理路径格式
trimPath('a[0].b.c') === ['a', 0, 'b', 'c']
assemblePath(['a', 0, 'b', 'c']) === 'a[0].b.c'
// 对象的深层更新
updateDeep(target, source) // 根据source对象递归地更新target,一般用在commit闭包中
store.commit((draft) => {
updateDeep(draft, source); // 根据source中地实际结构与值更新state数据,自动依据各层的对象或数组结构对原数据增删改,并且只对增量进行修改
})
F&Q
我该如何书写commit的recipe函数以完成对状态的更新?
immer框架提供了与mobx等框架相同的直观式更新状态对象的模式,在例如写入对象的属性值等场景下通俗而易于理解,但依旧会有不熟悉的使用者会在删除属性、修改数组等场景感到疑惑。 为了以最佳实践的使用相关功能,immer框架官方给出了Update Patterns(更新模式)范例。
只能在状态对象中包含原生Object,Array,Map与Set吗?是否能使用自定义的类?
答案是可以的。通过为自定义类添加symbol属性[immerable]
,可将自定义类纳入自动代理中:
import { immerable } from "rx-immer";
class Foo {
[immerable] = true; // 方式 1
constructor() {
this[immerable] = true; // 方式 2
}
}
Foo[immerable] = true; // 方式 3
对象的具体行为模式参见文档。