基于Vue3做一套适合自己的状态管理(一)基类:实现辅助功能

428 阅读4分钟

计划章节

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

适合自己的才是最好的

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 ,点击即可自动跳转到代码的位置。

  • 变更日志

302日志.jpg

  • 代码定位

300代码定位.jpg

源码

gitee.com/naturefw-co…

在线演示

naturefw-code.gitee.io/nf-rollup-s…