Vue3使用class + reactive打造一套超轻状态管理
作者 | 自然框架 出品 | 脚本之家(ID:jb51net)
Pinia 的状态管理非常优秀,只是我喜欢“充血实体类”风格的状态管理,于是使用 ES6 的 class 设计了一个适合自己需求的状态模式。
设计一个简单的状态。
我们先用 ES6 的 class 设计一个简单的状态,比如当前访问用户。(采用 TypeScript 的方式)
/**
* 登录用户的状态
*/
export default class UserState {
// 可以增加其他属性
name: string
department: number | string
rules: {
modules: Array<number | string>
}
// 初始化,设置默认属性值
constructor() {
this.name = '没有登录'
this.department = 0
this.rules = {
modules: []
}
}
// 操作状态的函数
/**
* 登录,设置状态
*/
async login() {
// 实现登录的代码,这里仅模拟
this.rules.modules.push(100)
this.name = '登录成功!'
}
/**
* 退出登录,更改状态
*/
async signOut() {
// 实现退出的代码,仅示例
this.rules.modules.length = 0
this.name = '没有登录'
}
/**
* 验证权限
*/
verifyPermissions(modulesId: number | string) {
return this.rules.modules.includes(modulesId)
}
// 可以设置其他操作的函数
}
状态的结构
我们来获取一个实例,然后打印出来看看内部结构:
department: 0
name: "登录成功!"
rules: {modules: Array(1)}
[[Prototype]]: Object
constructor: class UserState2
login: ƒ login()
signOut: ƒ signOut()
verifyPermissions: ƒ verifyPermissions(modulesId)
属性和函数分为两个层次,第一层的属性用来表示状态,第二层的函数用来获取状态或者变更状态,属性和函数都可以灵活扩展。
优点
状态和变更方式分离
状态在“第一层”,操作方式在“第二层”,看着不乱,可以直接使用原生 js 的方法,比如 Object.keys、Object.assign 等。直接遍历状态
使用Object.keys 获取key 的时候,只有属性部分(不含私有成员),没有函数部分,方便遍历等操作,无需自己写辅助函数处理。直接使用 Vue 提供的各种API
可以直接使用 reactive、readonly、toRefs等,函数不会出来“捣乱”。
状态的响应性问题
class 本身是没有响应性的,不过不用担心,Vue3 的 composition API 提供了多种响应方式,比如 reactive、ref、computed 等。
reactive
使用 reactive 是很简单的,我们只需要把 class 的实例放入 reactive 即可实现响应性,因为 “实例”本身也是对象。
// 创建实例
const user = new UserState()
console.log('user', user)
// 实现响应性
const userState = reactive(user)
console.log('personState', userState)
看看打印效果(userState —— Proxy):
[[Handler]]: Object
[[Target]]: UserState
department: 0
name: "没有登录"
rules: {modules: Array(0)}
[[Prototype]]: Object
constructor: class UserState2
login: ƒ login()
signOut: ƒ signOut()
verifyPermissions: ƒ verifyPermissions(modulesId)
[[Prototype]]: Object
ref、computed
我们可以在初始化的时候,把属性设置为 ref 或者 computed 的形式,当然如果有需要,我们也可以使用 reactive、readonly 等。
get 访问器也可以 return computed,只不过每次调用的时候,都会返回一个 computed。
实现只读状态
上面的代码虽然可以实现基础的状态功能,但是有个小问题:状态的属性可以随意变更!
我们来设想一下现实中的需求:
宽松模式:简单需求可以直接变更状态; 严格模式:不能随意变更状态。
那么能否限制状态的随意变更呢?当然可以,我们可以使用 class 的私有成员,或者使用 vue3 的 readonly 来实现。
使用私有成员禁止随意变更状态
ES6 的 class 提供了私有标记 #(ES2022),我们可以把不希望随意变更的状态(属性)设置为私有成员,这样可以禁止外部直接变更状态,安全感满满。
/**
* 私有成员的状态
*/
export class UserState {
#name: Ref<string> // 有响应性
#department: number | string // 没有响应性,外面加上 reactive 也不行
#rules: {
modules: Array<number | string>
}
constructor () {
this.#name = ref('测试私有成员的响应性')
this.#department = 18
this.#rules = {
modules: []
}
}
/**
* 获取name
*/
get name(): string {
// 需要返回 value,否则 name 可以被修改
return toRaw(this).#name.value
}
/**
* 获取部门,验证 原生对象 无法实现响应性
*/
get department() {
// 取原型,失去响应性;不取原型,this 指向被改变,无法访问私有成员。
return toRaw(this).#department
}
// 操作状态的函数
// 同上 略,
}
用 # 标记私有成员
在属性前面加上 # 号,这样使用的时候就无法直接改变值,必须使用特定函数才能变更。使用 get 访问器获取私有成员
想要获取私有成员的话,需要设置 get 访问器 。为什么使用 toRaw?因为实现响应性的时候会套上 reactive,而 reactive 的本质是 Proxy,Proxy 在拦截 get 操作后,会改变 this 指向,导致 class 内部的 this 无法获取私有成员,所以需要使用 toRaw 获取原来的 this。
私有成员为什么要使用 ref?解决私有成员的响应性的一种方法。
私有成员与 reactive 的冲突
class 的实例加上 reactive 即可实现响应性,但是如果其中有私有成员就会出现问题。
reactive 的本质是 Proxy,拦截 get 后会改变 this 指向,导致无法访问私有成员。
如果使用 toRaw(this) 取原型,那么会导致失去响应性。
目前的解决方法是使用 ref 来实现私有成员的响应性,但是这样的结构整体看起来就有点臃肿。(不太喜欢Pinia的状态的属性都是 ObjectRefImpl。)
私有成员的结构
在打印的时候可以看到私有成员,但是在 Object.keys () 、for in 里不会出现私有成员和 get 方法。
在 js 代码里可以通过 get 方式获取状态。
#department: 18
#name: RefImpl
#rules: Object
name: (...)
department: (...)
[[Prototype]]: Object
name: (...)
constructor: class UserState
department: (...)
login: ƒ async login()
signOut: ƒ async signOut()
verifyPermissions: ƒ verifyPermissions(modulesId)
get name: ƒ name()
get department: ƒ department()
[[Prototype]]: Object
使用 readonly 禁止随意变更状态
如果担心浏览器不支持 class 的私有成员的话,可以使用 Vue3 的 readonly 来实现只读的状态。
我们把状态放入 readonly 即可。
const user = new UserState()
const userState = reactive(user)
const userRead = readonly(userState)
对比
私有成员
可以精确设置某个状态只读,但是只读属性不会出现在Object.keys () 里 ; 和 reactive 有点小冲突,需要使用 ref 实现响应性。 readonly
只能把全部状态(属性)都设为只读。 可以阻止随意增减状态的属性。
使用 js 的风格封装一下
后端的语言习惯使用 class,使用的时候 new 一下很自然,但是在 js 里面可能不太习惯使用 new,那么我们可以再封装一下。
// 可以写个函数封装一下
function mystate () {
const re = new UserState()
return reactive(re)
}
ref、computed 等就是使用 class 实现的,然后又封装了一下 new 。
共享问题
状态做好了,那么如何让其他组件共享呢?我们可以使用全局变量,或者使用 provide / inject 。
全局变量
如果不需要考虑 SSR 的话,使用全局变量是一种很简单的方式。我们可以建立一个 js 文件(或者 ts 文件),然后导出一个全局变量即可。
我们写一段简单的代码测试一下这个想法:
// 在main、组件里面引入这个文件。
/**
* 单例模式创建状态
*/
// 容器
export const mystore:{[key: string]: any} = {}
// 模拟状态类
class Foo {
name: string
constructor () {
this.name = '测试静态状态'
}
}
// 初始化
const run = () => {
const foo = new Foo ()
mystore['s'] = foo // 存入容器
}
// 判断是否已经初始化
if ( Object.keys(mystore).length === 0 ) {
run()
}
上面只是一个简单的例子,验证一下想法是否确实可行。
测试结果和预想的一样,这种方式可以看做是一个单列模式。
注入
Vue 提供 provide / inject 实现注入,这是一种很方便的共享方式,既可以实现全局状态,也可以实现局部状态。
全局状态,我们可以在 main 里面注入; 局部状态,我们需要在对应的父组件里面注入。
设计几个辅助功能
Pinia 提供了 patch 等几个内置函数,解决了 reactive 赋值麻烦的问题,那么我们是否可以借鉴一下呢?
如果要加上内置函数,那么使用组合还是使用继承?Vue3 采用的是组合,其实对于简单的需求,我们也可以尝试一下继承的方式。
我们可以设计一个基类,在基类里面统一实现内置函数,然后定义其他状态时继承这个基类即可。
实现 $reset
看到这个功能时想到了表单的重置功能,Pinia 实现这个功能,不会就是为表单而做的吧。(话说表单的 model 属于状态吗?)
好了不纠结这个问题,我们来看看如何实现。
想要实现重置,首先需要知道初始值,初始值有两种情况,一个是对象,一个是函数。
如果是函数的话比较方便,再调用一次函数就可以得到初始值。如果是对象的话,由于“引用”问题,所以对象的“初始值”会发生变化。
可能是因为想要简化操作,Pinia 规定 state 需要使用函数的方式。
那么如果 state 是对象的方式呢?其实我们只需要在初始化的时候深拷贝留一个备份即可。不过说起来简单实现起来麻烦,js 的深拷贝可不是随便写写就行的。
所以我们也可以参照 Pinia 的设计综合一下,单层属性的状态可以直接使用对象,多层的或者复杂的状态,需要使用函数的方式。
/**
* 使用基类实现状态的共用函数
*/
export class NFState {
#_value: IObjectOrFunction
constructor (obj: IObjectOrFunction) {
if (typeof obj === 'function'){
// 记录初始函数
this.#_value = obj
// 执行函数,浅层拷贝,设置属性
Object.assign(this, obj())
}
else {
// 记录初始值的副本,浅层拷贝,只支持单层属性
this.#_value = Object.assign(obj)
// 浅层拷贝,设置属性
Object.assign(this, obj)
}
}
// 操作状态的函数
/**
* 获取初始值,如果是函数的话,会调用函数返回结果
*/
get $value() {
const val = toRaw(this).#_value
const re = typeof val === 'function' ? val() : val
return re
}
/**
* 恢复初始值
*/
$reset() {
// 模板里面触发的事件,没有 this
if (this) {
copy(toRaw(this), this.$value)
}
}
}
在初始化的时候保存初始值,然后设置一个访问器获取初始值,最后在 state 赋值即可。
实现 $state
实现这个功能的时候纠结了好久,按照 js 的风格,对象定义之后可以增减属性,但是按照 ts 风格和状态的设定,对象(状态)定义之后不应该增减属性。
于是还是先看看 Pinia 的 $state 的实现方式。
Pinia 的 state 测试结果:
状态定义之后不能通过 $state 增减属性。 按照 ts 规则,必须使用完整的属性才可以赋值,如果不完整会出现提示(Typescript)。但是可以“强行”运行,运行时可以修改部分属性值(效果等同 $patch)。
所以我们做一个简单的 $state。
以定义的状态的属性为准,不能增加属性,也不需要减少属性。 不支持私有成员,因为私有成员遍历不出来,另外私有成员也不能随意变更状态。 不支持 readonly,set 访问器会被拦截。 只支持“两层”的拷贝,更深的直接覆盖地址。我觉得复杂的状态应该拆分成多个小的状态。 取原型遍历,set 访问器会触发响应。
/**
* 设置新值
*/
set $state(value: IAnyObject) {
// 要不要判断 value 的属性是否完整?
copy(toRaw(this), value)
}
这里并没有判断 value 的属性是否和定义的状态是否一致,因为 Pinia 似乎也没有判断(运行时),我们也就先不实现了。
实现 $patch
patch就是函数的形式,这是为了直观感觉吗?
测试了一下 Pinia 的 state 基本一样。好吧,官网说,patch。
$patch 的参数可以是函数也可以是一个对象,如果是函数的话,则把状态作为参数调用函数;如果是对象的话吗,那么调用内部的 copy 函数。
Pinia 提供 $patch 的目的,应该是方便实现“时间线”,那么我们是否要实现呢?暂时先不实现了因为还没弄懂怎么和 devTool 通讯。
/**
* 替换部分属性,只支持单层
*/
async $patch(obj: IObjectOrFunction) {
if (typeof obj === 'function') {
// 回调,不接收返回值
await obj(this)
} else {
// 赋值
copy(this, obj)
}
}
实现拷贝的函数
好了我们来看看拷贝函数要如何来实现。常见的情况是浅层拷贝和深层拷贝,但是 Vue 提供了响应性,而对象的直接赋值会破坏这个响应性,所以我们还要考虑 reactive 和 ref 的情况。
所以我做了这个“半深考”的拷贝方法。
/**
* 以 target 的属性为准,进行赋值。支持部分深层copy
* * 如果属性是数组的话,可以保持响应性,但是不支持深层copy
* * 如果 有 $state,会调用。
* @param target 目标
* @param source 源
*/
function copy(target: IAnyObject, source: IAnyObject) {
const _this = target
const _source = toRaw(source)
// 以 原定状态的属性为准遍历,不增加、减少属性
for (const key in _this) {
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') { // 对象,浅拷
Object.assign(_this[key], _val)
} else {
if (isRef(_this[key])) { // 还得考虑 ref
_this[key].value = _val
} else {
_this[key] = _val // 其他,赋值
}
}
}
}
}
}
实现子类
基类设计好之后,我们只需要继承一下即可:(仅示例)
class Foo extends NFState {
sonName: string
constructor(obj: IObjectOrFunction) {
super(obj) // 调用父类的constructor()
this.sonName = '子类的属性' // 设置之类属性
}
}
const a = new Foo({})
源码
https://gitee.com/naturefw-code/nf-rollup-state
本文作者:自然框架
个人网址:jyk.cnblogs.com
声明:本文为 脚本之家专栏作者 投稿,未经允许请勿转载。
写的不错?赞赏一下
长按扫码赞赏我
推荐阅读:原创推荐:
【人人都可低代码】Vue3 把 el-form 变成LowCode风格的表单控件
结合 Vuex 和 Pinia 做一个适合自己的状态管理nf-state
一篇文章说清 webpack、vite、vue-cli、create-vue 的区别