基于Vue3做一套适合自己的状态管理(一)基类:实现辅助功能
计划章节
- 基类:实现辅助功能
- 继承:充血实体类
- Model:正确的打开方式
- 组合:setup 风格,更灵活
- 列表页面需要的状态
- 当前登录用户的状态
适合自己的才是最好的
Pinia 不是很好用吗,那么为啥还要重复制造轮子?其实我只是想做一把趁手的锤子。
我需要几个小功能:
- 支持局部状态
- 可以更灵活一些
- 不需要其他功能
所以,自己动手丰衣足食了。
定义基类实现辅助功能
reactive 有一个小问题,如果直接赋值的话,会失去响应性,原因就是:搬新家了没有通知好友,而好友还在监听旧地址。所以我们需要做个辅助工具避免这些麻烦。
先定义个接口
参考Pinia 的$state、$patch 等做几个小功能,为了便于统一,我们先定义一个接口:
/**
* 对象和数组的基类,要实现的函数
* * 私有成员
* * * get $id —— 获取ID、状态标识,string | symbol
* * * get $value —— 获取原值,可以是对象、数组,也可以是 function
* * * get $isLog —— 获取是否记录日志。true :记日志;false: 不记日志(默认值)
* * * get $isState —— 验证是否状态
* * * get $isObject —— 验证是否用了对象基类
* * * get $isArray —— 验证是否用了数组基类
* * 内置方法
* * * $reset() —— 重置
* * * async $patch() —— 修改部分属性
* * * set $state —— 整体赋值,会去掉原属性
* * * $toRaw() —— 取原型,不包含内部方法
* * * get $log —— 获取日志
* * * $clearLog() —— 清空日志
* * *
*/
export interface IState {
/**
* 状态标识,string | symbol,用于全局状态,或者记录日志
*/
get $id(): IStateKey;
/**
* 记录原值,可以是对象(数组),也可以是 function
*/
get $value(): IObjectOrFunction;
/**
* 获取是否记录日志。true :记日志;false: 不记日志(默认值)
*/
get $isLog(): boolean;
/**
* 验证是不是充血实体类的状态
*/
get $isState(): boolean;
/**
* 验证是不是有辅助工具,区分普通的对象
*/
get $isObject(): boolean;
/**
* 验证是不是有辅助工具,区分普通的数组
*/
get $isArray(): boolean;
/**
* 重置,恢复初始值。函数的情况支持多层
*/
$reset(): void;
/**
* 修改部分属性
*/
$patch(_val: IObjectOrFunction): void;
/**
* 整体赋值,不会增加新属性
*/
set $state(value: IAnyObject);
/**
* 取原型,去掉内部方法
*/
$toRaw<T extends IAnyObject>(): T | T[];
/**
* 获取日志
*/
get $logs(): Array<IStateLogInfo>;
/**
* 清空日志
*/
$clearLog(): void;
/**
* 可以有扩展属性
*/
[key: IStateKey] : any;
}
对象的辅助工具
我们用class定义一个基础类(对象),实现接口定义的辅助功能:
/**
* 给对象加上辅助功能:$state、$patch、$reset
* @param objOrFunction 初始值,可以是对象,也可以是函数
* @param id 标识,记录日志用
* @param isLog 是否记录日志
*/
export default class BaseObject implements IState {
#id: IStateKey
#isLog: boolean
#_value: IObjectOrFunction // 初始值或者初始函数
// 初始化
constructor (
objOrFunction: IObjectOrFunction,
id: IStateKey = Symbol('_object'),
isLog = false
) {
this.#id = id
this.#isLog = isLog
switch (typeof objOrFunction) {
case 'function':
// 记录初始函数
this.#_value = objOrFunction
// 执行函数获得对象,设置具体的属性,浅层拷贝
Object.assign(this, objOrFunction())
break
case 'object':
// 记录初始值的副本,浅层拷贝,对象形式的初始值,只支持单层属性
this.#_value = Object.assign(objOrFunction)
// 设置具体的属性,浅层拷贝
Object.assign(this, objOrFunction)
break
default:
// 不支持
this.#_value = {}
break
}
}
// 操作状态的方法
...
// 各种标识
...
// 日志
...
}
初始化
初始化只做了两件事情,一个是保存初始值(包括ID和是否记录日志),另一个是设置属性。这里采用 Object.assign
实现浅层拷贝。
这里使用了一种不太正规的方法,初始化的时候传入一个对象(函数),把对象的属性设置给类的属性,这种做法的优缺点都挺明显的。
- 优点:
- 简单方便,和 reactive 的使用有点像,符合 js 的一贯风格
- 不用一个一个的定义class
- 缺点
- 因为是运行时定义的属性,所以TS无法推断类型
- 不规范(会不会被喷)
Pinia 的 state 必须使用函数的方式设置,这个大概是为了实现 reset() 的时候能方便点吧。因为对象可以是多层的,有个地址引用的问题,对于多层的对象,只做浅拷会出现一些问题,而使用函数的方式,就没有深考的麻烦了。
不过我觉得对于单层的对象,还是使用函数的形式做初始值,使用的使用有点繁琐,所以还是支持了直接使用对象的方式。
理想的使用方式:
- 单层的对象,可以直接使用对象作为初始值,
- 多层的对象,需要使用函数的形式作为初始值,否则reset()会出现点小问题。
好吧,这样好像有点复杂,可能增加了心智负担。
get $value()
先写一个 get 访问器实现获取初始值的功能:
/**
* 获取初始值,如果是函数的话,会调用函数返回结果
*/
get $value() {
const val = toRaw(this).#_value
const re = typeof val === 'function' ? val() : val
return re
}
首先 #_value 是私有成员,外部不能直接访问,其次获取初始值的时候,不应该关心其是不是函数,直接获得一个对象即可,所以有了这个访问器。
为什么使用 toRaw 呢?因为 class 的实例套上 reactive 之后,this的指向会发生变化,这一变化就无法访问内部成员了,所以只好用 toRaw 获取原型的this。
set $state()
体验了一下 Pinia 的 set state(),理论上必须和状态的成员一致,否则 TS 会出现提示信息,但是可以强行运行。运行后,不会增加状态没有的属性,这样实际功能和 patch 基本一致了。
TS 的掌握还不够,不会 给 set 加上验证的方式,所以我们先实现功能,然后再完善细节。
/**
* 设置新值
*/
set $state(value: IAnyObject) {
// 要不要判断 value 的属性是否完整?
copy(this, value)
// Object.assign(this, value)
}
拷贝的小问题
如果是单层的对象的话,我们直接使用 Object.assign(this, value)
即可实现赋值的操作。
但是这样做可能增加新的属性,另外如果是多层的对象的话,那么深层的对象依然可能失去响应性,这样就不好了,所以这里我们写了一个函数来处理这些问题:
/**
* 以 target 的属性为准,进行赋值。支持部分深层copy
* * 如果属性是数组的话,可以保持响应性,但是不支持深层 copy
* * 如果属性是对象的话,可以支持深考
* * 如果 有 $state,会调用。
* @param target 目标
* @param source 源
*/
export const copy = (target: myObject, source: myObject) => {
const _this = target
const _source = toRaw(source)
// 以 原定状态的属性为准遍历,不增加、减少属性
Object.keys(_this).forEach((key: string) => {
const _val = unref(_source[key]) // 应对 ref 取值
const _target = _this[key]
if (_val) { // 如果有值
if (_target.$state) { // 对象、数组可以有 $state。
_target.$state = _val
} else {
if (Array.isArray(_target)) { // 数组的话,需要保持响应性
_target.length = 0
if (Array.isArray(_val)) // 来源是数组,拆开push
_target.push(..._val)
else
_target.push(_val) // 不是数组直接push
} else if (typeof _target === 'object') { // 对象,浅拷
copy(_this[key], _val)
} else {
if (isRef(_this[key])) { // 还得考虑 ref
_this[key].value = _val
} else {
_this[key] = _val // 其他,赋值
}
}
}
} else {
// 0,'',false,null,undefined,的情况
if (typeof _val === 'undefined') {
// 不处理
} else {
_this[key] = _val
}
}
})
}
对象可以深考,但是数组只支持第一层。因为我觉得一般不会关心数组内部成员的响应性问题。
对象要不要做深考,纠结了一下,最后感觉似乎问题不大,于是就实现了。
$patch()
Pinia 的 patch 有两个功能,一个是方便做状态变更,另一个是可以实现时间线。所以支持对象和函数两种类型的参数。
对象的话,就是直接赋值;而函数的话,则是回调的方式,目的是实现时间线的记录。
所以我们也模仿实现一下:
/**
* 替换部分属性,只支持单层
*/
async $patch(obj: IObjectOrFunction) {
writeLog(this, obj, '$patch', 3, async () => {
if (typeof obj === 'function') {
// 回调,不接收返回值
await obj(this)
} else {
// 赋值
copy(this, obj)
}
})
}
因为 $state 没能实现类型判断,所以其功能和 $patch() 是一样的了。不过还是先按照 Pinia 的方式实现,以后再说。
$reset()
$reset() 大概是用在表单的重置功能上面,想想自己的表单似乎也是需要这种功能,所以我们也实现一下:
/**
* 恢复初始值,值支持单层
*/
$reset() {
// 模板里面触发的事件,没有 this
if (this) {
writeLog(this, this.$value, '$reset', 3, () => {
copy(this, this.$value)
})
}
}
获取初始值,然后用 copy 函数赋给基类的属性。
为什么要判断 this?因为直接在 template 上面调用 reset 的时候,如果不写() ,比如:
@click="foo.$reset"
那么 this 就变成 undefined 。所以不得不判断一下。
toRaw()
使用了基类,就不是原本的对象了,加上了一些函数,一般情况下不会有什么问题,但是如果想提交给后端,或者存入前端容器(比如 indexedDB),可能会出现问题,所以这里准备了一个 toRaw函数,可以得到一个单纯的对象。
/**
* 取原型,不包含内部方法
*/
$toRaw<T extends IAnyObject>(): T {
const obj: IAnyObject = {} as IAnyObject
const tmp: IAnyObject = toRaw(this)
Object.keys(tmp).forEach((key: IStateKey) => {
if (typeof key === 'symbol') {
obj[key] = (tmp[key].$toRaw) ? tmp[key].$toRaw() : toRaw(tmp[key])
} else {
if ((key as string).substring(0,1) !== '#') {
obj[key] = (tmp[key].$toRaw) ? tmp[key].$toRaw() : toRaw(tmp[key])
}
}
})
return obj as T
}
得到一个新的对象,不包括私有成员和辅助函数。 不知道不支持私有成员的浏览器会如何处理私有成员,所以这里做了一个过滤条件。
设置各种标识
一些标识使用了私有成员,想要访问就需要使用访问器:
set $value(val) {
toRaw(this).#_value = val
}
get $id() {
return toRaw(this).#id
}
get $isLog() {
return toRaw(this).#isLog
}
set $isLog(val: boolean) {
toRaw(this).#isLog = val
}
get $isState() {
return false
}
get $isObject() {
return true
}
get $isArray() {
return false
}
记录状态的变更日志
其实不想加这个日志的,感觉作用不大,不过想想还是加上吧,万一有用呢,这是获取日志和清空日志的函数:
/**
* 获取日志
*/
get $logs() {
if (stateLog[this.$id]) {
return stateLog[this.$id].log
} else {
return []
}
}
/**
* 清空日志
*/
$clearLog() {
if (stateLog[this.$id]) {
stateLog[this.$id].log.length = 0
}
}
这里只是定义了一个基类,并没有实现响应性功能!
数组的辅助工具
数组的基类和对象的基本一致。
/**
* 继承 Array 实现 IState 接口,实现辅助功能
*/
export default class BaseArray extends Array implements IState {
#id: IStateKey
#_value : IArrayOrFunction
/**
* 数组的辅助工具
* @param arrayOrFunction 初始值,数组或者函数
* @param id 标识
*/
constructor (arrayOrFunction: IArrayOrFunction, id: IStateKey = '_array') {
// 调用父类的 constructor()
super()
this.#id = id
this.#_value = arrayOrFunction
let arr = arrayOrFunction
if (typeof arrayOrFunction === 'function') {
arr = arrayOrFunction()
}
// 设置初始值
if (Array.isArray(arr)) {
if (arr.length > 0) this.push(...arr)
} else {
if (arr) this.push(arr)
}
}
/**
* 整体替换,会清空原数组,
*/
set $state(value: Array<any> | any) {
// 删除原有数据
this.length = 0
if (Array.isArray(value)) {
this.push(...value)
} else {
this.push(value)
}
}
/**
* 取原型,不包含内部方法,不维持响应性
*/
$toRaw<T>(): Array<T> {
const arr: Array<T> = []
const tmp = toRaw(this)
tmp.forEach(item => {
const _item = toRaw(item)
arr.push( (_item.$toRaw) ? _item.$toRaw() : _item )
})
return arr
}
// 其他函数略
}
首先要继承 js 原生的 Array,然后实现接口。
- 感觉数组不需要 reset 功能,所以不实现了;
- patch 也不知道如何“部分修改”,所以也不实现了。
- state 只实现浅层
- toRaw 建立一个新数组,浅拷就好。
记录变更日志,定位代码位置
我们做项目的时候,最郁闷的就是不知道bug发生在哪里,比如状态变化了,但是不知道是哪个组件、或者函数里面触发的变更,这就给找bug带来了难度。
如果可以做个日志,记录触发变更的代码位置,是不是可以方便修改 bug 呢?因为现在js代码都启用了“严格模式”,所以以前那些方法都不好用了。死磕了好久终于遇到一位高手提供了一种方法:const stack = new Error().stack
。
/**
* 添加一个新记录
* @param key 状态的key
* @param kind 操作类型
* @param oldVal 原值
* @param newVal 新值
* @param subVal 参数,引发变更的值(对象)
* @param _stackstr stack 拆分为数组后,记录哪个元素
*/
function addLog(
key: IStateKey,
kind: string,
oldVal: IAnyObject,
newVal: IAnyObject,
subVal: IAnyObject = {},
_stackstr: string
): void {
if (!stateLog[key]) {
stateLog[key] = {log: []}
}
if (kind === 'init') return
const _oldVal = oldVal // 变更之前就要做副本
const _newVal = deepClone({}, newVal)
const _subVal = deepClone({}, subVal)
stateLog[key].log.push({
time: new Date().valueOf(), // 触发的时间
kind: kind, // 触发类型
oldValue: _oldVal, // 原来的值
newValue: _newVal, // 变更后的值
subValue: _subVal, // 导致变更的值
callFun: stackstr // 调用的函数名和位置
})
}
因为都是对象,如果直接存放的话,那么只是记录个地址,所以要做个深层拷贝,保留副本才行。
写日志的语法糖
/**
* 写日志的语法糖
* @param me 状态,this
* @param submitVal 触发改变的值
* @param kind 操作类型
* @param index 日志的位置
* @param callback 回调函数
*/
const writeLog = async (
me: IAnyObject,
submitVal: IAnyObject,
kind: string,
index: number,
callback: () => void
) => {
if (!me.$isLog) {
// 不记录日志,执行回调,退出
await callback()
return
}
// 开始记录
// 记录调用堆栈
const stack = new Error().stack ?? ''
const arr = stack.split('\n')
// 记录原值的副本
const val1 = (me.$isObject || me.$isArray) ? me.$toRaw() : me
const oldVal = deepClone({}, val1)
//执行回调,变更状态
await callback()
// 记录变化
const newVal = (me.$isObject || me.$isArray) ? me.$toRaw() : me
addLog(me.$id, kind, oldVal, newVal, submitVal, arr[index])
}
writeLog 就是前面 $state 等函数内部使用的记录日志的函数。这样当调用的时候,我们就可以记录下来代码位置,然后配合F12 ,点击即可自动跳转到代码的位置。
- 变更日志
- 代码定位
源码
在线演示
转载自:https://juejin.cn/post/7236196670278549565