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

金色海洋(jyk)發表於2021-10-17

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 的部分,修改的屬性就變成了基礎型別。

  • 如何獲知改變狀態的函式的?
    這個要感謝乎友(否子戈 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

線上演示

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

相關文章