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