告別Vuex,發揮compositionAPI的優勢,打造Vue3專用的輕量級狀態

Vuex 的遺憾

Vuex 是基於 Vue2 的 option API 設計的,因為 optionAPI 的一些先天問題,所以導致 Vuex 不得不用各種方式來補救,於是就出現了 getter、mutations、action、module、mapXXX 這些繞圈圈的使用方式。想要使用 Vuex 就必須先把這些額外的函數給弄明白。

Vue3 發布之後,Vuex4 為了向下兼容只是支援了 Vue3 的寫法,但是並沒有發揮 composition API 的優勢,依然採用原有的設計思路。這個有點浪費 compositionAPI 的感覺。

如果你也感覺 Vuex 太麻煩了,那麼歡迎來看看我的實現方式。

輕量級狀態(nf-state):

輕量級狀態.png

compositionAPI 提供了 reactive、readonly 等好用的響應性的方式,那麼為啥不直接用,還要套上 computed?又不需要做計算。我們直接使用 reactive 豈不是很爽?

可能有同學會說,狀態最關鍵的在於跟蹤,要知道是誰改了狀態,這樣便於管理和維護。

這個沒關係,我們可以用 proxy 來套個娃,即可以實現對 set 的攔截,這樣可以在攔截函數裡面實現 Vuex 的 mutations 實現的各種功能,包括且不限於:

  • 記錄狀態變化日誌:改變狀態的函數、組件、程式碼位置(開發模式)、修改時間、狀態、屬性名(含路徑)、原值、新值。
  • 設置鉤子函數:實現狀態的持久化,攔截狀態改變等操作。
  • 狀態的持久化:存入indexedDB,或者提交給後端,或者其他。
  • 其他功能

也就是說,我們不需要專門寫 mutations 來改變狀態了,直接給狀態賦值即可。

以前是把全局狀態和局部狀態放在一起,用了一段時間之後發現,沒有必要合在一起。

全局狀態,需要一個統一的設置,避免命名衝突,避免重複設置,但是局部狀態只是在局部有效,並不會影響其他,那麼也就沒有必要統一設置了。

於是新的設計裡面,把局部狀態分離出去,單獨管理。

因為 proxy 只支援對象類型,不支援基礎類型,所以這裡的狀態也必須設計成對象的形式,不接受基礎類型的狀態。也不支援ref。

輕量級狀態的整體結構設計

狀態整體設計.png

整體採用 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/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 ,然後設置日誌數組和鉤子函數即可。

 點擊位置可以定位程式碼

源碼

//gitee.com/naturefw/vue-data-state

在線演示

//naturefw.gitee.io/vite2-vue3-demo/

Tags: