Vuex 的遗憾
Vuex 是基于 Vue2 的 option API 设计的,因为 optionAPI 的一些先天问题,所以导致 Vuex 不得不用各种方式来补救,于是就出现了 getter、mutations、action、module、mapXXX 这些绕圈圈的使用方式。想要使用 Vuex 就必须先把这些额外的函数给弄明白。
Vue3 发布之后,Vuex4 为了向下兼容只是支持了 Vue3 的写法,但是并没有发挥 composition API 的优势,依然采用原有的设计思路。这个有点浪费 compositionAPI 的感觉。
如果你也感觉 Vuex 太麻烦了,那么欢迎来看看我的实现方式。
轻量级状态(nf-state):
compositionAPI 提供了 reactive、readonly 等好用的响应性的方式,那么为啥不直接用,还要套上 computed?又不需要做计算。我们直接使用 reactive 岂不是很爽?
可能有同学会说,状态最关键的在于跟踪,要知道是谁改了状态,这样便于管理和维护。
这个没关系,我们可以用 proxy 来套个娃,即可以实现对 set 的拦截,这样可以在拦截函数里面实现 Vuex 的 mutations 实现的各种功能,包括且不限于:
- 记录状态变化日志:改变状态的函数、组件、代码位置(开发模式)、修改时间、状态、属性名(含路径)、原值、新值。
- 设置钩子函数:实现状态的持久化,拦截状态改变等操作。
- 状态的持久化:存入indexedDB,或者提交给后端,或者其他。
- 其他功能
也就是说,我们不需要专门写 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 的部分,修改的属性就变成了基础类型。
- 如何获知改变状态的函数的?
这个要感谢乎友(否子戈 www.zhihu.com/people/frus… )的帮忙,我试了各种方式也没有搞定,在一次抬杠的时候,发现否子戈介绍的 new Error() 方式,可以获得各级改变状态的函数名称、组件名称和位置。 这样我们记录下来之后就可以知道是谁改变了状态。
用 concole.log(stackstr)
打印出来,在F12里面就可以点击进入代码位置,开发环境会非常便捷,生产模式由于代码被压缩了,所以效果嘛。。。
const stack = new Error().stack const arr = stack.split('\n') const stackstr = arr.length > 1 ? arr[2]: '' // 记录调用的函数 复制代码
在 Vue3 的项目里的使用方式
我们可以模仿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) } }) 复制代码
这里设置了两个用户状态,一个是可以随便读写的,一个是只读的,用于演示。
状态名称不可以重复,因为都会放在一个容器里面。
- 初始化
在这里可以设置inti初始化的回调函数,state是状态的容器,read 就是只读状态的可以修改的对象,可以通过read来改变只读状态。
这里引入了用户的controller,把 read 传递过去,这样controller里面就可以改变只读状态了。
main.js
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
好了,到了核心部分,我们来看看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 的使用,大家都熟悉了吧。
- 通过controller使用状态
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 ,然后设置日志数组和钩子函数即可。
源码
在线演示
naturefw.gitee.io/vite2-vue3-…