【vue3】给 reactive 打个补丁,顺便做个状态管理

378 阅读6分钟

关于状态管理已经写过好几次了,每次都有不同的想法,一开始是模仿,接下来是反思:自己真的需要那么多功能和方法吗?于是不断地做减法,最后发现只剩下了 reactive 的补丁。

以前写的:

  1. 基类:实现辅助功能
  2. 继承:充血实体类
  3. 继承:OptionApi 风格的状态
  4. Model:正确的打开方式
  5. 组合:setup 风格,更灵活
  6. 注册状态的方法、以及局部状态和全局状态
  7. 实践:当前登录用户的状态
  8. 实践:列表页面需要的状态
  9. 新的列表状态的定义方式

给 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.  $stateProxy // 统一加的补丁
        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等信息;
  • 直接打印出来。

如何定位?

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

状态之代码定位.png

全局状态

本来想封装的,但是发现自己不会推导状态,那么封装就没啥意思了。
不考虑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环境下,可以有类型提示:

状态之类型提示.png

局部状态

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

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

定义一个状态

以列表为例简单写一下:

// 定义一个标记,以便于区分
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 ,创建自己的状态,便于递归列表的调用。