Vuex 的遺憾
Vuex 是基于 Vue2 的 option API 設(shè)計的,因為 optionAPI 的一些先天問題,所以導致 Vuex 不得不用各種方式來補救,于是就出現(xiàn)了 getter、mutations、action、module、mapXXX 這些繞圈圈的使用方式。想要使用 Vuex 就必須先把這些額外的函數(shù)給弄明白。
Vue3 發(fā)布之后,Vuex4 為了向下兼容只是支持了 Vue3 的寫法,但是并沒有發(fā)揮 composition API 的優(yōu)勢,依然采用原有的設(shè)計思路。這個有點浪費 compositionAPI 的感覺。
如果你也感覺 Vuex 太麻煩了,那么歡迎來看看我的實現(xiàn)方式。
輕量級狀態(tài)(nf-state):

compositionAPI 提供了 reactive、readonly 等好用的響應性的方式,那么為啥不直接用,還要套上 computed?又不需要做計算。我們直接使用 reactive 豈不是很爽?
可能有同學會說,狀態(tài)最關(guān)鍵的在于跟蹤,要知道是誰改了狀態(tài),這樣便于管理和維護。
這個沒關(guān)系,我們可以用 proxy 來套個娃,即可以實現(xiàn)對 set 的攔截,這樣可以在攔截函數(shù)里面實現(xiàn) Vuex 的 mutations 實現(xiàn)的各種功能,包括且不限于:
- 記錄狀態(tài)變化日志:改變狀態(tài)的函數(shù)、組件、代碼位置(開發(fā)模式)、修改時間、狀態(tài)、屬性名(含路徑)、原值、新值。
- 設(shè)置鉤子函數(shù):實現(xiàn)狀態(tài)的持久化,攔截狀態(tài)改變等操作。
- 狀態(tài)的持久化:存入indexedDB,或者提交給后端,或者其他。
- 其他功能
也就是說,我們不需要專門寫 mutations 來改變狀態(tài)了,直接給狀態(tài)賦值即可。
以前是把全局狀態(tài)和局部狀態(tài)放在一起,用了一段時間之后發(fā)現(xiàn),沒有必要合在一起。
全局狀態(tài),需要一個統(tǒng)一的設(shè)置,避免命名沖突,避免重復設(shè)置,但是局部狀態(tài)只是在局部有效,并不會影響其他,那么也就沒有必要統(tǒng)一設(shè)置了。
于是新的設(shè)計里面,把局部狀態(tài)分離出去,單獨管理。
因為 proxy 只支持對象類型,不支持基礎(chǔ)類型,所以這里的狀態(tài)也必須設(shè)計成對象的形式,不接受基礎(chǔ)類型的狀態(tài)。也不支持ref。
輕量級狀態(tài)的整體結(jié)構(gòu)設(shè)計

整體采用 MVC設(shè)計模式,狀態(tài)( reactive 和 proxy套娃)作為 model,然后我們可以在單獨的 js文件里面寫 controller 函數(shù),這樣就非常靈活,而且便于復用。
再復雜一點的話,可以加一個 service,負責和后端API、前端存儲(比如 indexedDB等)交換數(shù)據(jù)。
在組件里面直接調(diào)用 controller 即可,當然也可以直接獲取狀態(tài)。
定義各種狀態(tài)
好了開始上干貨,看看如何實現(xiàn)上面的設(shè)計。
我們先定義一個結(jié)構(gòu),用于狀態(tài)的說明:
const info = { // 狀態(tài)名稱不能重復
// 全局狀態(tài),不支持跟蹤、鉤子、日志
state: {
user1: { // 每個狀態(tài)都必須是對象,不支持基礎(chǔ)類型
name: 'jyk' //
}
},
// 只讀狀態(tài),不支持跟蹤、鉤子、日志,只能用初始化回調(diào)函數(shù)的參數(shù)修改
readonly: {
user2: { // 每個常量都必須是對象,不支持基礎(chǔ)類型
name: 'jyk' //
}
},
// 可跟蹤狀態(tài),支持跟蹤、鉤子、日志
track: {
user3: { // 每個狀態(tài)都必須是對象,不支持基礎(chǔ)類型
name: 'jyk' //
}
},
// 初始化函數(shù),可以從后端、前端等獲取數(shù)據(jù)設(shè)置狀態(tài)
// 設(shè)置好狀態(tài)的容器后調(diào)用,可以獲得只讀狀態(tài)的可寫參數(shù)
init(state, _readonly) {}
這里把狀態(tài)分成了三類:全局狀態(tài)、只讀狀態(tài)和跟蹤狀態(tài)。
全局狀態(tài):直接使用 reactive, 簡潔快速,適用于不關(guān)心狀態(tài)是怎么變的,可以變化、可以響應即可的環(huán)境。
只讀狀態(tài):可以分為兩種,一個是全局常量,初始設(shè)置之后,其他的地方都是只讀的;一個是只能在某個位置改變狀態(tài),其他地方都是只讀,比如當前登錄用戶的狀態(tài),只有登錄和退出的地方可以改變狀態(tài),其他地方只能只讀。
可以跟蹤的狀態(tài):使用 proxy 套娃r(nóng)eactive 實現(xiàn),因為又套了一層,還要加鉤子、記錄日志等操作,所以性能稍微差了一點點,好吧其實也應該差不了多少。
把狀態(tài)分為可以跟蹤和不可以跟蹤兩種情況,是考慮到各種需求,有時候我們會關(guān)心狀態(tài)是如何變化的,或者要設(shè)置鉤子函數(shù),有時候我們又不關(guān)心這些。兩種需求在實現(xiàn)上有點區(qū)別,所以干脆設(shè)置成兩類狀態(tài),這樣可以靈活選擇。
實現(xiàn)各種狀態(tài)
import { reactive, readonly } from 'vue'
import trackReactive from './trackReactive.js'
/**
* 做一個輕量級的狀態(tài)
*/
export default {
// 狀態(tài)的容器,reactive 的形式
state: {},
// 全局狀態(tài)的跟蹤日志
changeLog: [],
// 內(nèi)部鉤子,key:數(shù)組
_watch: {},
// 外部函數(shù),設(shè)置鉤子,key:回調(diào)函數(shù)
watch: {},
// 狀態(tài)的初始化回調(diào)函數(shù),async
init: () => {},
createStore (info) {
// 把 state 存入 state
for (const key in info.state) {
const s = info.state[key]
// 外部設(shè)置空鉤子
this.watch[key] = (e) => {}
this.state[key] = reactive(s)
}
// 把 readonly 存入 state
const _readonly = {} // 可以修改的狀態(tài)
for (const key in info.readonly) {
const s = info.readonly[key]
_readonly[key] = reactive(s) // 設(shè)置一個可以修改狀態(tài)的 reactive
this.state[key] = readonly(_readonly[key]) // 對外返回一個只讀的狀態(tài)
}
// 把 track 存入 state
for (const key in info.track) {
const s = reactive(info.track[key])
// 指定的狀態(tài),添加監(jiān)聽的鉤子,數(shù)組形式
this._watch[key] = []
// 外部設(shè)置鉤子
this.watch[key] = (e) => {
// 把鉤子加進去
this._watch[key].push(e)
}
this.state[key] = trackReactive(s, key, this.changeLog, this._watch[key])
}
// 調(diào)用初始化函數(shù)
if (typeof info.init === 'function') {
info.init(this.state, _readonly)
}
const _store = this
return {
// 安裝插件
install (app, options) {
// 設(shè)置模板可以直接使用狀態(tài)
app.config.globalProperties.$state = _store.state
}
}
}
}
代碼非常簡單,算上注釋也不超過100行,主要就是套上 reactive 或者 proxy套娃。
最后 return 一個 vue 的插件,便于設(shè)置模板里面直接訪問全局狀態(tài)。
全局狀態(tài)并沒有使用 provide/inject,而是采用“靜態(tài)對象”的方式。這樣任何位置都可以直接訪問,更方便一些。
實現(xiàn)跟蹤狀態(tài)
import { isReactive, toRaw } from 'vue'
// 修改深層屬性時,記錄屬性路徑
let _getPath = []
/**
* 帶跟蹤的reactive。使用 proxy 套娃
* @param {reactive} _target 要攔截的目標 reactive
* @param {string} flag 狀態(tài)名稱
* @param {array} log 存放跟蹤日志的數(shù)組
* @param {array} watch 監(jiān)聽函數(shù)
* @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
// 調(diào)用原型方法
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]: '' // 記錄調(diào)用的函數(shù)
const _log = {
stateKey: flag, // 狀態(tài)名
keyPath: base === null ? '' : _getPath.join(','), //屬性路徑
key: key, // 要修改的屬性
value: value, // 新值
oldValue: target[key], // 原值
stack: stackstr, // 修改狀態(tài)的函數(shù)和組件
time: new Date().valueOf(), // 修改時間
// targetBase: base, // 根
target: target // 上級屬性/對象
}
// 記錄日志
log.push(_log)
if (log.length > 100) {
log.splice(0, 30) // 去掉前30個,避免數(shù)組過大
}
// 設(shè)置鉤子,依據(jù)回調(diào)函數(shù)決定是否修改
let reValue = null
if (typeof watch === 'function') {
const re = watch(_log) // 執(zhí)行鉤子函數(shù),獲取返回值
if (typeof re !== 'undefined')
reValue = re
} else if (typeof watch.length !== 'undefined') {
watch.forEach(fun => { // 支持多個鉤子
const re = fun(_log) // 執(zhí)行鉤子函數(shù),獲取返回值
if (typeof re !== 'undefined')
reValue = re
})
}
// 記錄鉤子返回的值
_log.callbackValue = reValue
// null:可以修改,使用 value;其他:強制修改,使用鉤子返回值
const _value = (reValue === null) ? value : reValue
_log._value = _value
// 調(diào)用原型方法
const res = Reflect.set(target, key, _value, target)
return res
}
})
// 返回實例
return proxy
}
使用 proxy 給 reactive 套個娃,這樣可以“繼承” reactive 的響應性,然后攔截 set 操作,實現(xiàn)記錄日志、改變狀態(tài)的函數(shù)、組件、位置等功能。
為啥還要攔截 get 呢?
主要是為了支持嵌套屬性。
當我們修改嵌套屬性的時候,其實是先把第一級的屬性(對象)get 出來,然后讀取其屬性,然后才會觸發(fā) set 操作。如果是多級的嵌套屬性,需要遞歸多次,而最后 set 的部分,修改的屬性就變成了基礎(chǔ)類型。如何獲知改變狀態(tài)的函數(shù)的?
這個要感謝乎友(否子戈 https://www.zhihu.com/people/frustigor )的幫忙,我試了各種方式也沒有搞定,在一次抬杠的時候,發(fā)現(xiàn)否子戈介紹的 new Error() 方式,可以獲得各級改變狀態(tài)的函數(shù)名稱、組件名稱和位置。
這樣我們記錄下來之后就可以知道是誰改變了狀態(tài)。
用 concole.log(stackstr)打印出來,在F12里面就可以點擊進入代碼位置,開發(fā)環(huán)境會非常便捷,生產(chǎn)模式由于代碼被壓縮了,所以效果嘛。。。
const stack = new Error().stack
const arr = stack.split('\n')
const stackstr = arr.length > 1 ? arr[2]: '' // 記錄調(diào)用的函數(shù)
在 Vue3 的項目里的使用方式
我們可以模仿Vuex的方式,先設(shè)計一個 定義的js函數(shù),然后在main.js掛載到實例。
然后設(shè)置controller,最后就可以在組件里面使用了。
定義
store-nf/index.js
// 加載狀態(tài)的類庫
import { createStore } from 'nf-state'
import userController from '../views/state/controller/userController.js'
export default createStore({
// 讀寫狀態(tài),直接使用 reactive
state: {
// 用戶是否登錄以及登錄狀態(tài)
user: {
isLogin: false,
name: 'jyk', //
age: 19
}
},
// 全局常量,使用 readonly
readonly:{
// 訪問indexedDB 和 webSQL 的標識,用于區(qū)分不同的庫
dbFlag: {
project_db_meta: 'plat-meta-db' // 平臺 運行時需要的 meta。
},
// 用戶是否登錄以及登錄狀態(tài)
user1: {
isLogin: false,
info:{
name: '測試第二層屬性'
},
name: 'jyk', //
age: 19
}
},
// 跟蹤狀態(tài),用 proxy 給 reactive 套娃
track: {
trackTest: {
name: '跟蹤測試',
age: 18,
children1: {
name1: '子屬性測試',
children2: {
name2: '再嵌一套'
}
}
},
test2: {
name: ''
}
},
// 可以給全局狀態(tài)設(shè)置初始狀態(tài),同步數(shù)據(jù)可以直接在上面設(shè)置,如果是異步數(shù)據(jù),可以在這里設(shè)置。
init (state, read) {
userController().setWriteUse(read.user1)
setTimeout(() => {
read.dbFlag.project_db_meta = '加載后修改'
}, 2000)
}
})
這里設(shè)置了兩個用戶狀態(tài),一個是可以隨便讀寫的,一個是只讀的,用于演示。
狀態(tài)名稱不可以重復,因為都會放在一個容器里面。
- 初始化
在這里可以設(shè)置inti初始化的回調(diào)函數(shù),state是狀態(tài)的容器,read 就是只讀狀態(tài)的可以修改的對象,可以通過read來改變只讀狀態(tài)。
這里引入了用戶的controller,把 read 傳遞過去,這樣controller里面就可以改變只讀狀態(tài)了。
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' // 輕量級狀態(tài)
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 = () => {
// 獲取可以修改的狀態(tài)
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 = '已經(jīng)退出'
}
const getUser = () => {
// 返回只讀狀態(tài)的用戶信息
return state.user1
}
return {
setWriteUse,
getUser,
login,
logout
}
}
export default userController
這樣是不是很清晰。
組件
準備工作都做好了,那么在組件里面如何使用呢?
- 模板里直接使用
<template>
全局狀態(tài)-user:{{$state.user1}}<br>
</template>
- 直接使用狀態(tài)
import { state, watchState } from 'nf-state'
// 可以直接操作狀態(tài)
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使用狀態(tài)
import userController from './controller/userController.js'
const { login, logout, getUser } = userController()
// 獲取用戶狀態(tài),只讀
const user = getUser()
// 模擬登錄
const ulogin = () => {
login('jyk', '123')
}
// 模擬退出登錄
const ulogout = () => {
logout()
}
設(shè)置監(jiān)聽和鉤子
import { state, watchState } from 'nf-state'
// 設(shè)置監(jiān)聽和鉤子
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 是一個容器,后面可以跟一個狀態(tài)同名的鉤子函數(shù),也就是說狀態(tài)名不用寫字符串了。
我們可以直接指定要監(jiān)聽的狀態(tài),不會影響其他狀態(tài),在鉤子里面可以獲取當前 set產(chǎn)生的日志,從而獲得各種信息。
還可以通過返回值的方式來影響狀態(tài)的改變:
- 沒有返回值:允許狀態(tài)的改變。
- 返回原值:不允許狀態(tài)的改變,維持原值。
- 返回其他值:表示把返回值設(shè)置為狀態(tài)改變后的值。
局部狀態(tài)
局部狀態(tài)不需要進行統(tǒng)一定義,直接寫 controller 即可。
controller 可以使用對象的形式,也可以使用函數(shù)的形式,當然也可以使用class。
import { reactive, provide, inject } from 'vue'
import { trackReactive } from 'nf-state'
const flag = 'test2'
/**
* 注入局部狀態(tài)
*/
const reg = () => {
// 需要在函數(shù)內(nèi)部定義,否則就變成“全局”的了。
const _test = reactive({
name: '局部狀態(tài)的對象形式的controller'
})
// 注入
provide(flag, _test)
// 其他操作,比如設(shè)置 watch
return _test
}
/**
* 獲取注入的狀態(tài)
*/
const get = () => {
// 獲取
const re = inject(flag)
return re
}
const regTrack = () => {
const ret = reactive({
name: '局部狀態(tài)的可跟蹤狀態(tài)'
})
// 定義記錄跟蹤日志的容器
const logTrack = reactive([])
// 設(shè)置監(jiān)聽和鉤子
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 的形式,這個沒啥特別的。
如果要實現(xiàn)跟蹤的話,需要引入 trackReactive ,然后設(shè)置日志數(shù)組和鉤子函數(shù)即可。

源碼
https://gitee.com/naturefw/vue-data-state