关于状态管理已经写过好几次了,每次都有不同的想法,一开始是模仿,接下来是反思:自己真的需要那么多功能和方法吗?于是不断地做减法,最后发现只剩下了 reactive 的补丁。
以前写的:
- 基类:实现辅助功能
- 继承:充血实体类
- 继承:OptionApi 风格的状态
- Model:正确的打开方式
- 组合:setup 风格,更灵活
- 注册状态的方法、以及局部状态和全局状态
- 实践:当前登录用户的状态
- 实践:列表页面需要的状态
- 新的列表状态的定义方式
给 reactive 打个补丁
大家都知道 reactive 直接整体赋值会失去响应性,也知道可以用 Object.assign 保持响应性,但是为啥不封装一下呢?(话说,官方为啥不给打个补丁呢,不就没那些麻烦事了。)
使用 Object.defineProperty 打补丁
使用 Object.defineProperty 可以直接给实例加方法,而不会影响原型,所以我们先试试这种方法。
定义一个函数,内部弄一个 reactive,然后加上 $state 即可,需要判断是不是数组:
function myReactive(obj) {
// 获得一个 reactive
const re = reactive(obj)
// 给实例加 $state
Object.defineProperty(re, '$state', {
// get: () => { return re }, 可以不设置 get
set: (_obj) => {
// 浅层拷贝
if (Array.isArray(re)) {
// 如果是数组,清空后添加新数组
// re.splice(0, re.length,..._obj)
re.length = 0
re.push(..._obj)
} else {
// 如果是对象,先实现浅拷贝
Object.assign(re, _obj)
}
}
})
return re
}
按照 pinia 的风格可以使用 $state,如果按照 ref 的风格,可以使用 value。
- 如果是数组,那么可以用
re.splice(0, re.length,..._obj)或者re.length = 0; re.push(..._obj)保持响应性; - 如果是对象,那么可以用
Object.assign(re, _obj)保持响应性; - 以上都是浅层拷贝,不涉及深层。(深考有点麻烦,暂时不考虑)
使用方式:
封装完毕我们看看如何使用:
const foo = myReactive({name:'jyk'})
console.log(' defineProperty 的 foo:',foo)
console.log(' foo.keys:', Object.keys(foo))
console.log(' foo 的 toRaw:', toRaw(foo))
const myChange = () => {
foo.$state = {
name: '$state 的方式赋值'
}
}
使用方面和 reactive 基本一致,只是取原型的时候,不是原本的对象,而是会带上 $state。
结构
我们来看看结构:
1. Proxy {name: "jyk"}
1. 1. [[Handler]]: MutableReactiveHandler
1. [[Target]]: Object
1. 1. name: "jyk" // 自己定义的。
1. $state: Proxy // 统一加的补丁
1. get $state: () => { return re; }
1. set $state: (_obj) => {…}
1. __proto__: Object
1. [[IsRevoked]]: false
这结构当然是和 reactive 一样的,只是增加了 $state 部分。
keys
使用 Object.keys(foo) 只会得到自己定义的属性,不会得到 $state 。
也就是说,用 v-for 遍历的时候,不会出现 $state。
取原型
使用 toRaw 取原型的话,会带上 $state,这个不太好,所以需要我们再写一个 $toRaw, 去掉不需要的部分。
用 class 的方法打补丁
如果代码少的话,使用 Object.defineProperty 比较方便,但是如果代码多了,就不是那么便于阅读和维护,所以我们还可以尝试一下使用 class。
对象的补丁
对象和数组,我们分开处理,先给对象的 reactive 打个补丁:
/**
* 给对象加上辅助功能:$state、$toRaw
* @param obj 初始值, 对象
*/
export default class BaseObject<T extends object> implements IState<T> {
/**
* 创建一个基础的对象,实现辅助工具的功能
* @param obj 初始值,可以是对象,也可以是函数
*/
constructor ( obj: T ) {
// 设置具体的属性,浅层拷贝
Object.assign(this, obj)
}
/**
* 整体赋值。
* 定义一个名为 $state 的 setter 方法,用于设置当前状态对象的值。
*/
set $state(value: T) {
Object.assign(this, value)
}
/**
* 取原型,去掉内部方法
*/
$toRaw<T extends object>(): T {
const obj = {} as TAnyObject
const tmp: TAnyObject = toRaw(this)
Object.keys(tmp).forEach((key: TStateKey) => {
obj[key] = (tmp[key].$toRaw) ? tmp[key].$toRaw() : toRaw(tmp[key])
})
return obj as T
}
}
个人感觉使用 class 更便于阅读和维护,比如我们需要增加一个 $toRaw 方法的时候,直接加上就好。
对象的工厂
为了便于使用,我们给对象做一个工厂:
/**
* 给 baseObject 套上 reactive,算是一个工厂吧
* @param obj 对象
*/
export default function useState<T extends object> (
obj: T
): T & IState<T> {
const _obj = new BaseObject(obj)
return reactive(_obj) as T & IState<T>
}
为啥不考虑数组?我觉得,数组就不应该算作是状态。
数组的补丁
数组和对象还是有一些区别的,所以还是分开处理的好。
/**
* 继承 Array 实现 IState 接口,实现辅助功能
*/
export default class BaseArray<T> extends Array implements IState<T> {
/**
* 数组的辅助工具
* @param arrayOrFunction 初始值,数组或者函数
*/
constructor (arr: Array<T>, id: TStateKey = Symbol('_array')) {
// 调用父类的 constructor()
super()
// 设置初始值
if (Array.isArray(arr)) {
if (arr.length > 0) this.push(...arr)
} else {
if (arr) this.push(arr)
}
}
/**
* 整体赋值,会清空原数组,
*/
set $state(value: Array<T>) {
// 删除原有数据
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
}
}
数组的工厂
还是给数组做一个工厂:
/**
* 给 BaseArray 套个壳,加上 reactive 实现响应性 & IState
* @param val 数组或者函数
*/
export default function useList<T>(
val: Array<T>)
) {
const re = new BaseArray<T>(val)
const ret = reactive(re)
return ret as BaseArray<T> & T
}
精简的状态管理
其实打完补丁之后,状态管理基本也就封装完毕了,以前参考 pinia 各种封装,现在看来有啥用?
状态的分类
我喜欢细粒度,所以分类也比较细,状态可以分为以下几类:
- reactive:基础状态,不需要整体变更状态
- useState:简单状态,可以整体赋值
- useModel:适用于表单,可以 reset
- useList:适用于数组
- shallowRef:只关注整体赋值的数组
简单状态
基础状态就是 useState。
话说状态需要整体赋值吗?感觉好像只有表单才需要,同理 reset 也是表单才需要的。所以一直不太明白 pinia 为啥要支持这两种操作,难道是为了给 reactive 打补丁?
关于数组比较头疼。基于 reactive 做吧,是深层响应的,而有时候可能只需要关注整体更新即可,并不需要关注深层的,那么使用 shallowRef 就很适合,但是这个东东需要使用 .value,这就导致风格不统一,而想要统一那么就要用 ref,这又和 reactive 的风格不一致。
好吧官网赢了!
表单
表单需要增加 $reset,我们可以使用面向对象的继承来实现:
/**
* 表单的 model,加上 $reset 功能
* @param fun 初始值,函数,方便实现重置的功能
*/
export default class BaseModel<T extends TAnyObject> extends BaseObject<T> implements IModel<T> {
#_value: () => T // 初始函数
constructor (
fun: () => T,
) {
if (typeof fun === 'function') {
// 调用父类初始化,然后才会有this
super(fun(), id)
// 记录初始函数
this.#_value = fun
}
}
/**
* 恢复初始值,值支持浅层
*/
$reset() {
// 模板里面触发的事件,没有 this
if (this) {
const tmp = toRaw(this).#_value()
this.$state = tmp
} else {
console.warn('在 template 里面使用的时候,请加上(),比如: foo.$reset()。')
}
}
}
为了实现 reset 做了两个小改动:
- 参数改为 函数 的形式。
- 增加内部成员 #_value 保存初始函数。
代码定位
pinia 有 timeline,这个看了一下,里面记录的非常详细,只是没发现记录调用代码的位置。
出了问题,是不是想知道是哪行代码出的事?所以我觉得,代码定位比较重要。
那么如何定位呢?可以使用 watch 的 第三个参数的 onTrigger ,外加 new Error 来实现。
/**
* 代码定位,获得修改属性的代码位置。
* 仅支持开发模式。
* @param ret 要监听的对象,必须是 reactive。
*/
export default function lineCode(ret: any) {
watch(ret, (newValue, oldValue) => {}, {
onTrigger: (event: DebuggerEvent) => {
const err = new Error()
if (err.stack){
const arrayCode = err.stack.split(' at ')
let codes = []
// 寻找定位代码
for(let i = 2; i < arrayCode.length; i++){
if (!arrayCode[i].includes('/node_modules/')){
codes.push(arrayCode[i])
}
}
// 记录新旧值等
const msg = {
id:event.target?.$id.toString(), // 状态的id
time: new Date().valueOf(), // 时间戳
key: event.key, // 属性名
oldValue: event.oldValue, // 旧值
newValue: event.newValue, // 新值
type: event.type, // 类型
target: event.target, // 目标对象
oldTarget: event.oldTarget, // 旧目标对象
codeLine: codes[0], // 触发变更的代码行
}
// 打印事件、新旧值等
console.log(`\ntimeline,【${msg.id}】:`, msg)
// 打印代码定位
console.log(`\ntimeline,state 【${msg.id}】 mutations at:`, codes[0])
// 打印error
// console.log(`\ntimeline__${msg.id}__code:`, code)
}
}
})
}
思路:
- 使用 watch 获得变更事件;
- 使用 Error 记录调用代码的集合;
- 去掉vue内部的调用,剩下的就是代码定位;
- 记录修改时间、新旧值、状态ID等信息;
- 直接打印出来。
如何定位?
把那一行打印出来,然后点一下就可以定位了,是不是很方便。
全局状态
本来想封装的,但是发现自己不会推导状态,那么封装就没啥意思了。
不考虑SSR的话,完全可以使用全局变量,填里面就可以了。
定义一个状态
export default function regUserState() {
// 内部使用的用户状态
const user = reactive({
name: '没有登录',
isLogin: false,
role: { }
})
// 登录用的函数,仅示意,不涉及细节
const login = () => {
// 模拟登录
user.isLogin = true
...
}
// 模拟退出,仅示意
const logout = () => {
// 模拟退出
user.name = '已经退出'
user.isLogin = false
...
}
// 返回 数据和状态
return {
// 如果不想直接修改状态,可以套上 readonly 变成只读形式。
// 如果可以直接改,那么就不用套 readonly。
user: readonly(user),
login,
logout,
}
}
填入全局变量
// 导入 全局状态
import regUserState from './state-user'
// 创建状态
const userState = regUserState()
// 记入全局状态
const store = {
userState
// 其他状态
...
}
// 变成全局状态,导出
export default store
这样全局状态就搞定了。
使用
import store from '@/store-nf'
// 解构出来需要的状态
const { userState } = store
类型提示
在ts环境下,可以有类型提示:
局部状态
局部状态,就是只在某个模块(父子组件、子孙组件)里面有效的状态,兄弟组件无效,实现起来也很简单,使用依赖注入就好。
本来想封装来着,但是封装之后才发现,根本没法使用,所以干脆不封装了。
定义一个状态
以列表为例简单写一下:
// 定义一个标记,以便于区分
const flag = Symbol('pager') as InjectionKey<string>
/**
* 创建数据列表的状态,局部有效
* @param service 获取数据的回调函数。
* * service: (
* * * query: TQueryState,
* * * pagerInfo: TPagerState
* * ) => Promise<TResource<T>>
* @returns 总记录数和列表数据, Promise<TResource<T>>
*/
export function createListState<T extends object>(
service: (
query: TQueryState,
pagerInfo: TPagerState
) => Promise<TResource<T>>
) {
// 记录列表数据
const dataList = shallowRef<Array<T>>([])
// 查询状态
const query: TQueryState = reactive({
...
})
// 分页状态
const pagerState: TPagerState = reactive({
...
pagerIndex: 1 // 当前页号
})
/**
* 内部用的更新数据的函数,
*/
async function updateData () {
// 获取数据
const { allCount, list } = await service(query, pagerState)
// 设置
pagerState.count = allCount
dataList.value = list
}
// 监听查询条件,更新数据
watch(query, async () => {
// 设置到第一页
pagerState.pagerIndex = 1
await updateData() // 更新数据
})
// 监听页号,翻页后更新数据
watch(() => pagerState.pagerIndex, () => {
updateData()
}, {
immediate: true // 立即执行
})
// 整合需要暴露出去的数据和方法
const state: TListState<T> = {
dataList, // 列表数据的容器
pagerState, // 翻页的状态
query // 查询条件
}
// 依赖注入,共享给子组件
provide<TListState<T>>(flag, state)
// 返回给父组件
return state
}
/**
* 子组件用 inject 获取状态
* @returns TListState<T>
*/
export function getListState<T extends object>(): TListState<T> {
const re = inject<TListState<T>>(flag)
return re as TListState<T>
}
- 内部统一使用 watch,避免滥用 watch;
- createListState:在父组件里面调用,创建一个局部状态 ;
- getListState:在子组件里面调用,获取父组件创建的状态;
- 子组件也可以调用 createListState ,创建自己的状态,便于递归列表的调用。