Jansiel Notes

【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等信息;
  • 直接打印出来。

如何定位?

把那一行打印出来,然后点一下就可以定位了,是不是很方便。

Jansiel_Essay_1727580121956

全局状态

本来想封装的,但是发现自己不会推导状态,那么封装就没啥意思了。

不考虑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环境下,可以有类型提示:

Jansiel_Essay_1727580253295

局部状态

局部状态,就是只在某个模块(父子组件、子孙组件)里面有效的状态,兄弟组件无效,实现起来也很简单,使用依赖注入就好。

本来想封装来着,但是封装之后才发现,根本没法使用,所以干脆不封装了。

定义一个状态

以列表为例简单写一下:

 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 ,创建自己的状态,便于递归列表的调用。