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 的部分,修改的屬性就變成了基礎型別。 -
如何獲知改變狀態的函式的?
這個要感謝乎友(否子戈 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]: '' // 記錄呼叫的函式
在 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 ,然後設定日誌陣列和鉤子函式即可。
原始碼
https://gitee.com/naturefw/vue-data-state