composition vue?告别Vuex,发挥compositionAPI的优势,打造Vue3专用的轻量级状态
告别Vuex,发挥compositionAPI的优势,打造Vue3专用的轻量级状态2021-10-17 11:10 金色海洋(jyk) 阅读(0) 评论(0) 编辑 收藏 举报Vuex 的
顺晟科技
2022-09-23 11:03:53
189
Vuex 是基于 Vue2 的 option API 设计的,因为 optionAPI 的一些先天问题,所以导致 Vuex 不得不用各种方式来补救,于是就出现了 getter、mutations、action、module、mapXXX 这些绕圈圈的使用方式。想要使用 Vuex 就必须先把这些额外的函数给弄明白。
Vue3 发布之后,Vuex4 为了向下兼容只是支持了 Vue3 的写法,但是并没有发挥 composition API 的优势,依然采用原有的设计思路。这个有点浪费 compositionAPI 的感觉。
如果你也感觉 Vuex 太麻烦了,那么欢迎来看看我的实现方式。
compositionAPI 提供了 reactive、readonly 等好用的响应性的方式,那么为啥不直接用,还要套上 computed?又不需要做计算。我们直接使用 reactive 岂不是很爽?
可能有同学会说,状态最关键的在于跟踪,要知道是谁改了状态,这样便于管理和维护。
这个没关系,我们可以用 proxy 来套个娃,即可以实现对 set 的拦截,这样可以在拦截函数里面实现 Vuex 的 mutations 实现的各种功能,包括且不限于:
也就是说,我们不需要专门写 mutations 来改变状态了,直接给状态赋值即可。
以前是把全局状态和局部状态放在一起,用了一段时间之后发现,没有必要合在一起。
全局状态,需要一个统一的设置,避免命名冲突,避免重复设置,但是局部状态只是在局部有效,并不会影响其他,那么也就没有必要统一设置了。
于是新的设计里面,把局部状态分离出去,单独管理。
因为 proxy 只支持对象类型,不支持基础类型,所以这里的状态也必须设计成对象的形式,不接受基础类型的状态。也不支持ref。
整体采用 MVC设计模式,状态( reactive 和 proxy套娃)作为 model,然后我们可以在单独的 js文件里面写 controller 函数,这样就非常灵活,而且便于复用。
再复杂一点的话,可以加一个 service,负责和后端API、前端存储(比如 indexedDB等)交换数据。
在组件里面直接调用 controller 即可,当然也可以直接获取状态。
好了开始上干货,看看如何实现上面的设计。
我们先定义一个结构,用于状态的说明:
const info = { // 状态名称不能重复
// 全局状态,不支持跟踪、钩子、日志
state: {
user1: { // 每个状态都必须是对象,不支持基础类型
name: 'jyk' //
}
},
// 只读状态,不支持跟踪、钩子、日志,只能用初始化回调函数的参数修改
readonly: {
user2: { // 每个常量都必须是对象,不支持基础类型
name: 'jyk' //
}
},
// 可跟踪状态,支持跟踪、钩子、日志
track: {
user3: { // 每个状态都必须是对象,不支持基础类型
name: 'jyk' //
}
},
// 初始化函数,可以从后端、前端等获取数据设置状态
// 设置好状态的容器后调用,可以获得只读状态的可写参数
init(state, _readonly) {}
这里把状态分成了三类:全局状态、只读状态和跟踪状态。
全局状态:直接使用 reactive, 简洁快速,适用于不关心状态是怎么变的,可以变化、可以响应即可的环境。
只读状态:可以分为两种,一个是全局常量,初始设置之后,其他的地方都是只读的;一个是只能在某个位置改变状态,其他地方都是只读,比如当前登录用户的状态,只有登录和退出的地方可以改变状态,其他地方只能只读。
可以跟踪的状态:使用 proxy 套娃reactive 实现,因为又套了一层,还要加钩子、记录日志等操作,所以性能稍微差了一点点,好吧其实也应该差不了多少。
把状态分为可以跟踪和不可以跟踪两种情况,是考虑到各种需求,有时候我们会关心状态是如何变化的,或者要设置钩子函数,有时候我们又不关心这些。两种需求在实现上有点区别,所以干脆设置成两类状态,这样可以灵活选择。
import { reactive, readonly } from 'vue'
import trackReactive from './trackReactive.js'
/**
* 做一个轻量级的状态
*/
export default {
// 状态的容器,reactive 的形式
state: {},
// 全局状态的跟踪日志
changeLog: [],
// 内部钩子,key:数组
_watch: {},
// 外部函数,设置钩子,key:回调函数
watch: {},
// 状态的初始化回调函数,async
init: () => {},
createStore (info) {
// 把 state 存入 state
for (const key in info.state) {
const s = info.state[key]
// 外部设置空钩子
this.watch[key] = (e) => {}
this.state[key] = reactive(s)
}
// 把 readonly 存入 state
const _readonly = {} // 可以修改的状态
for (const key in info.readonly) {
const s = info.readonly[key]
_readonly[key] = reactive(s) // 设置一个可以修改状态的 reactive
this.state[key] = readonly(_readonly[key]) // 对外返回一个只读的状态
}
// 把 track 存入 state
for (const key in info.track) {
const s = reactive(info.track[key])
// 指定的状态,添加监听的钩子,数组形式
this._watch[key] = []
// 外部设置钩子
this.watch[key] = (e) => {
// 把钩子加进去
this._watch[key].push(e)
}
this.state[key] = trackReactive(s, key, this.changeLog, this._watch[key])
}
// 调用初始化函数
if (typeof info.init === 'function') {
info.init(this.state, _readonly)
}
const _store = this
return {
// 安装插件
install (app, options) {
// 设置模板可以直接使用状态
app.config.globalProperties.$state = _store.state
}
}
}
}
代码非常简单,算上注释也不超过100行,主要就是套上 reactive 或者 proxy套娃。
最后 return 一个 vue 的插件,便于设置模板里面直接访问全局状态。
全局状态并没有使用 provide/inject,而是采用“静态对象”的方式。这样任何位置都可以直接访问,更方便一些。
import { isReactive, toRaw } from 'vue'
// 修改深层属性时,记录属性路径
let _getPath = []
/**
* 带跟踪的reactive。使用 proxy 套娃
* @param {reactive} _target 要拦截的目标 reactive
* @param {string} flag 状态名称
* @param {array} log 存放跟踪日志的数组
* @param {array} watch 监听函数
* @param {object} base 根对象
* @param {array} _path 嵌套属性的各级属性名称的路径
*/
export default function trackReactive (_target, flag, log = [], watch = null, base = null, _path = []) {
// 记录根对象
const _base = toRaw(_target)
// 修改嵌套属性的时候,记录属性的路径
const getPath = () => {
if (!base) return []
else return _path
}
const proxy = new Proxy(_target, {
// get 不记录日志,没有钩子,不拦截
get: function (target, key, receiver) {
const __path = getPath(key)
_getPath = __path
// 调用原型方法
const res = Reflect.get(target, key, receiver)
// 记录
if (typeof key !== 'symbol') {
// console.log(`getting ${key}!`, target[key])
switch (key) {
case '__v_isRef':
case '__v_isReactive':
case '__v_isReadonly':
case '__v_raw':
case 'toString':
case 'toJSON':
// 不记录
break
default:
// 嵌套属性的话,记录属性名的路径
__path.push(key)
break
}
}
if (isReactive(res)) {
// 嵌套的属性
return trackReactive(res, flag, log, watch, _base, __path)
}
return res
},
set: function (target, key, value, receiver) {
const stack = new Error().stack
const arr = stack.split('\n')
const stackstr = arr.length > 1 ? arr[2]: '' // 记录调用的函数
const _log = {
stateKey: flag, // 状态名
keyPath: base === null ? '' : _getPath.join(','), //属性路径
key: key, // 要修改的属性
value: value, // 新值
oldValue: target[key], // 原值
stack: stackstr, // 修改状态的函数和组件
time: new Date().valueOf(), // 修改时间
// targetBase: base, // 根
target: target // 上级属性/对象
}
// 记录日志
log.push(_log)
if (log.length > 100) {
log.splice(0, 30) // 去掉前30个,避免数组过大
}
// 设置钩子,依据回调函数决定是否修改
let reValue = null
if (typeof watch === 'function') {
const re = watch(_log) // 执行钩子函数,获取返回值
if (typeof re !== 'undefined')
reValue = re
} else if (typeof watch.length !== 'undefined') {
watch.forEach(fun => { // 支持多个钩子
const re = fun(_log) // 执行钩子函数,获取返回值
if (typeof re !== 'undefined')
reValue = re
})
}
// 记录钩子返回的值
_log.callbackValue = reValue
// null:可以修改,使用 value;其他:强制修改,使用钩子返回值
const _value = (reValue === null) ? value : reValue
_log._value = _value
// 调用原型方法
const res = Reflect.set(target, key, _value, target)
return res
}
})
// 返回实例
return proxy
}
使用 proxy 给 reactive 套个娃,这样可以“继承” reactive 的响应性,然后拦截 set 操作,实现记录日志、改变状态的函数、组件、位置等功能。
为啥还要拦截 get 呢?
主要是为了支持嵌套属性。
当我们修改嵌套属性的时候,其实是先把第一级的属性(对象)get 出来,然后读取其属性,然后才会触发 set 操作。如果是多级的嵌套属性,需要递归多次,而最后 set 的部分,修改的属性就变成了基础类型。
如何获知改变状态的函数的?
这个要感谢乎友(否子戈 https://www.zhihu.com/people/frustigor )的帮忙,我试了各种方式也没有搞定,在一次抬杠的时候,发现否子戈介绍的 new Error() 方式,可以获得各级改变状态的函数名称、组件名称和位置。
这样我们记录下来之后就可以知道是谁改变了状态。
用 concole.log(stackstr)
打印出来,在F12里面就可以点击进入代码位置,开发环境会非常便捷,生产模式由于代码被压缩了,所以效果嘛。。。
const stack = new Error().stack
const arr = stack.split('\n')
const stackstr = arr.length > 1 ? arr[2]: '' // 记录调用的函数
我们可以模仿Vuex的方式,先设计一个 定义的js函数,然后在main.js挂载到实例。
然后设置controller,最后就可以在组件里面使用了。
store-nf/index.js
// 加载状态的类库
import { createStore } from 'nf-state'
import userController from '../views/state/controller/userController.js'
export default createStore({
// 读写状态,直接使用 reactive
state: {
// 用户是否登录以及登录状态
user: {
isLogin: false,
name: 'jyk', //
age: 19
}
},
// 全局常量,使用 readonly
readonly:{
// 访问indexedDB 和 webSQL 的标识,用于区分不同的库
dbFlag: {
project_db_meta: 'plat-meta-db' // 平台 运行时需要的 meta。
},
// 用户是否登录以及登录状态
user1: {
isLogin: false,
info:{
name: '测试第二层属性'
},
name: 'jyk', //
age: 19
}
},
// 跟踪状态,用 proxy 给 reactive 套娃
track: {
trackTest: {
name: '跟踪测试',
age: 18,
children1: {
name1: '子属性测试',
children2: {
name2: '再嵌一套'
}
}
},
test2: {
name: ''
}
},
// 可以给全局状态设置初始状态,同步数据可以直接在上面设置,如果是异步数据,可以在这里设置。
init (state, read) {
userController().setWriteUse(read.user1)
setTimeout(() => {
read.dbFlag.project_db_meta = '加载后修改'
}, 2000)
}
})
这里设置了两个用户状态,一个是可以随便读写的,一个是只读的,用于演示。
状态名称不可以重复,因为都会放在一个容器里面。
这里引入了用户的controller,把 read 传递过去,这样controller里面就可以改变只读状态了。
import { createApp } from 'vue'
import App from './App.vue'
import store from './store' // vuex
import router from './router' // 路由
import nfStore from './store-nf' // 轻量级状态
createApp(App)
.use(nfStore)
.use(store)
.use(router)
.mount('#app')
main.js 的使用方式和 Vuex 基本一致,另外和 Vuex 不冲突,可以在一个项目里同时使用。
好了,到了核心部分,我们来看看controller的编写方式,这里模拟一下当前登录用户。
// 用户的管理类
import { state } from 'nf-state'
let _user = null
const userController = () => {
// 获取可以修改的状态
const setWriteUse = (u) => {
_user = u
}
const login = (code, psw) => {
// 假装访问后端
setTimeout(() => {
// 获得用户信息
const newUser = {
name: '后端传的用户名:' + code
}
Object.assign(_user, newUser)
_user.isLogin = true
}, 100)
}
const logout = () => {
_user.isLogin = false
_user.name = '已经退出'
}
const getUser = () => {
// 返回只读状态的用户信息
return state.user1
}
return {
setWriteUse,
getUser,
login,
logout
}
}
export default userController
这样是不是很清晰。
准备工作都做好了,那么在组件里面如何使用呢?
<template>
全局状态-user:{{$state.user1}}<br>
</template>
import { state, watchState } from 'nf-state'
// 可以直接操作状态
console.log(state)
const testTract2 = () => {
state.trackTest.children1.name1 = new Date().valueOf()
}
const testTract3 = () => {
state.trackTest.children1.children2.name2 = new Date().valueOf()
state.test2.name = new Date().valueOf()
}
这样就变成了 reactive 的使用,大家都熟悉了吧。
import userController from './controller/userController.js'
const { login, logout, getUser } = userController()
// 获取用户状态,只读
const user = getUser()
// 模拟登录
const ulogin = () => {
login('jyk', '123')
}
// 模拟退出登录
const ulogout = () => {
logout()
}
import { state, watchState } from 'nf-state'
// 设置监听和钩子
watchState.trackTest(({keyPath, key, value, oldValue}) => {
if (keyPath === '') {
console.log(`\nstateKey.${key}=`)
} else {
console.log(`\nstateKey.${keyPath.replace(',','.')}.${key}=` )
}
console.log('oldValue:', oldValue)
console.log('value:', value )
// return null
})
watchState 是一个容器,后面可以跟一个状态同名的钩子函数,也就是说状态名不用写字符串了。
我们可以直接指定要监听的状态,不会影响其他状态,在钩子里面可以获取当前 set产生的日志,从而获得各种信息。
还可以通过返回值的方式来影响状态的改变:
局部状态不需要进行统一定义,直接写 controller 即可。
controller 可以使用对象的形式,也可以使用函数的形式,当然也可以使用class。
import { reactive, provide, inject } from 'vue'
import { trackReactive } from 'nf-state'
const flag = 'test2'
/**
* 注入局部状态
*/
const reg = () => {
// 需要在函数内部定义,否则就变成“全局”的了。
const _test = reactive({
name: '局部状态的对象形式的controller'
})
// 注入
provide(flag, _test)
// 其他操作,比如设置 watch
return _test
}
/**
* 获取注入的状态
*/
const get = () => {
// 获取
const re = inject(flag)
return re
}
const regTrack = () => {
const ret = reactive({
name: '局部状态的可跟踪状态'
})
// 定义记录跟踪日志的容器
const logTrack = reactive([])
// 设置监听和钩子
const watchSet = (res) => {
console.log(res)
console.log(res.stack)
console.log(logTrack)
}
const loaclTrack = trackReactive(ret, 'loaclTrack', logTrack, watchSet)
return {
loaclTrack,
logTrack,
watchSet
}
}
// 其他操作
export {
regTrack,
reg,
get,
}
如果不需要跟踪的话,其实就是 provide/inject + reactive 的形式,这个没啥特别的。
如果要实现跟踪的话,需要引入 trackReactive ,然后设置日志数组和钩子函数即可。
https://gitee.com/naturefw/vue-data-state
https://naturefw.gitee.io/vite2-vue3-demo/
23
2022-09
23
2022-09