結合 Vuex 和 Pinia 做一個適合自己的狀態管理 nf-state

金色海洋(jyk)發表於2022-05-11

一開始學習了一下 Vuex,感覺比較冗餘,就自己做了一個輕量級的狀態管理。
後來又學習了 Pinia,於是參考 Pinia 改進了一下自己的狀態管理。

結合 Vuex 和 Pinia, 保留需要的功能,去掉不需要的功能,修改一下看著不習慣的使用方法,最後得到了一個滿足自己需要的輕量級狀態管理 —— nf - state

設計思路

還是喜歡 MVC設計模式,狀態可以看做 M,元件是V,可以用 controller 做排程,需要訪問後端的話,可以做一個 services。這樣整體結構比較清晰明瞭。

當然簡單的狀態不需要 controller,直接使用 getters、actions 即可。整體結構如下:

狀態管理 nf-state

原始碼

https://gitee.com/naturefw-code/nf-rollup-state

線上演示

https://naturefw-code.gitee.io/nf-rollup-state/

線上文件

https://nfpress.gitee.io/doc-nf-state

優點

  • 支援全域性狀態區域性狀態
  • 可以像 Vuex 那樣,用 createStore 統一註冊全域性狀態 ;
  • 也可以像 Pinia 那樣,用 defineStore 分散定義全域性狀態和區域性狀態;
  • 根據不同的場景需求,選擇適合的狀態變更方式(安全等級);
  • 可以和 Vuex、Pinia 共存;
  • 資料部分和操作部分“分級”存放,便於遍歷;
  • 狀態採用 reactive 形式,可以直接使用 watch、toRefs 等;
  • 更輕、更小、更簡潔;
  • 可以記錄變化日誌,也可以不記錄;
  • 封裝了物件、陣列的一些方法,使用 reactive 的時候可以“直接”賦值。

缺點

  • 不支援 option API、vue2;
  • 暫時不支援 TypeScript;
  • 暫時不支援 vue-devtool;
  • 不支援SSR;
  • 只有一個簡單的狀態變化記錄(預設不記錄)。

nf-state 的結構

  • state:支援物件、函式的形式。
  • getters:會變成 computed,不支援非同步(其實也可以用非同步)。
  • actions:變更狀態,支援非同步。
  • 內建函式:
    • $state:整體賦值。
    • $patch:修改部分屬性,支援深層。
    • $reset:重置。

本來想只保留 state 即可,但是看看 Pinia,感覺加上 getter、action 也不是不行,另外也參考 Pinia 設定了幾個內建函式。

內建函式

reactive 哪都好,就是不能直接賦值,否則就會失去響應性,雖然有辦法解決,但是需要多寫幾行程式碼,所以我們可以封裝一下。好吧,是看到 Pinia 的 $state、$patch 後想到的。

$state

可以直接整體賦值,支援 object 和 陣列。直接賦值即可,這樣用起來就方便多了。

this.dataList.$state = {xxx}

$patch

修改部分屬性。我們可以直接改狀態的屬性值,但是如果一次改多個的話,就有一點點麻煩,用$patch可以整潔一點。

// 依次設定屬性值:
this.pagerInfo.count = list.allCount === 0 ? 1 : list.allCount
this.pagerInfo.pagerIndex = 1

// 使用 $patch 設定屬性值:
this.pagerInfo.$patch({
  count: list.allCount === 0 ? 1 : list.allCount,
  pagerIndex: 1
})

支援深層屬性。

全域性狀態的使用方式

全域性狀態有兩種定義方式:

  • 像 Vuex 那樣,在 main.js 裡面統一註冊;
  • 像 Pinia 那樣,在元件裡面定義。

在 main.js 裡面統一註冊全域性狀態

nf-state 的全域性狀態的使用方法和 Vuex 差不多,先建立一個 js檔案,定義一個或者多個狀態,然後在main.js裡面掛載。

優點:可以統一註冊、便於管理,一個專案裡有哪些全域性狀態,可以一目瞭然。

  • /store/index.js
// 定義全域性狀態
import { createStore } from '@naturefw/nf-state'

/* 模擬非同步操作 */
const testPromie = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      const re = {
        name: '非同步的方式設定name'
      }
      resolve(re)
    }, 500)
  })
}

/**
 * 統一註冊全域性狀態。key 相當於  defineStore 的第一個引數(id)
 */
export default createStore({
  // 定義狀態,會變成 reactive 的形式。store 裡面是各種狀態
  store: {
    // 如果只有 state,那麼可以簡化為一個物件的方式。
    user: {
      isLogin: false,
      name: 'jyk', //
      age: 19,
      roles: []
    },
    // 有 getters、actions
    userCenter: {
      state: {
        name: '',
        age: 12,
        list: []
      },
      getters: {
        userName () {
          return this.name + '---- 測試 getter'
        }
      },
      actions: {
        async loadData(val, state) {
          const foo = await testPromie()
          state.name = foo.name
          this.name = foo.name
          this.$state = foo
          this.$patch(foo)
        }
      },
      options: {
        isLocal: false, // true:區域性狀態;false:全域性狀態(預設屬性);
        isLog: true, // true:做記錄;false:不用做記錄(預設屬性);
        /**
         * 1:寬鬆,可以各種方式改變屬性,適合彈窗、抽屜、多tab切換等。
         * 2:一般,不能通過屬性直接改狀態,只能通過內建函式、action 改變狀態
         * 3:嚴格,不能通過屬性、內建函式改狀態,只能通過 action 改變狀態
         * 4:超嚴,只能在指定元件內改變狀態,比如當前使用者的狀態,只能在登入元件改,其他元件完全只讀!
        */
        level: 1
      }
    },
    // 陣列的情況
    dataList: [123] 
  },
  // 狀態初始化,可以給全域性狀態設定初始狀態,支援非同步。
  init (store) {
      // 可以從後端API、indexedDB、webSQL等,設定狀態的初始值。
  }
})

  • main.js
import { createApp } from 'vue'
import App from './App.vue'

import store from './store'

createApp(App)
  .use(store)
  .mount('#app')

在元件裡獲取統一註冊的全域性狀態

使用方法和 Vuex 類似,直接獲取全域性狀態:

  import { store } from '@naturefw/nf-state'

  const { user, userCenter } = store

在元件裡註冊全域性狀態

這種方式,借鑑了Pinia的方式,我們可以建立一個 js 檔案,然後定義一個狀態,可以用Symbol 作為標誌,這樣可以更方便的避免重名。(當然也可以用 string)

import { defineStore } from '@naturefw/nf-state'

const flag = Symbol('UserInfo')
// const flag = 'UserInfo'

const getUserInfo = () => defineStore(flag, {
  state: {
    name: '客戶管理',
    info: {}
  },
  getters: {
  },
  actions: {
    updateName(val) {
      this.name = val
    }
  }
})

export {
  flag,
  getUserInfo
}

雖然使用 Symbol 可以方便的避免重名,但是獲取狀態的時候有點小麻煩。
ID(狀態標識)支援 string 和 Symbol ,大家可以根據自己的情況選擇適合的方式。

在元件裡面引入 這個js檔案,然後可以通過 getUserInfo 函式獲取狀態,可以用統一註冊的全域性狀態的方式獲取。

使用區域性狀態

基於 provide/inject 設定了區域性狀態。

有時候,一個狀態並不是整個專案都需要訪問,這時候可以採用區域性狀態,比如列表頁面裡的狀態。

定義一個區域性狀態

我們可以建立一個js檔案,定義狀態:

  • state-list.js

import { watch } from 'vue'

import { defineStore, useStore, store } from '@naturefw/nf-state'

const flag = Symbol('pager001')
// const flag = 'pager001'

/**
 * 註冊區域性狀態,父元件使用 provide 
 * * 資料列表用
 * @returns
 */
const regListState = () => {
  // 定義 列表用的狀態
  const state = defineStore(flag, {
    state: () => {
      return {
        moduleId: 0, // 模組ID
        dataList: [], // 資料列表
        findValue: {}, // 查詢條件的精簡形式
        findArray: [], // 查詢條件的物件形式
        pagerInfo: { // 分頁資訊
          pagerSize: 5,
          count: 20, // 總數
          pagerIndex: 1 // 當前頁號
        },
        selection: { // 列表裡選擇的記錄
          dataId: '', // 單選ID number 、string
          row: {}, // 單選的資料物件 {}
          dataIds: [], // 多選ID []
          rows: [] // 多選的資料物件 []
        },
        query: {} // 查詢條件
      }
    },
    actions: {
      /**
       * 載入資料,
       * @param {*} isReset true:需要設定總數,頁號設定為1;false:僅翻頁
       */
      async loadData (isReset = false) {
        // 獲取列表資料
        const list = await xxx
        // 使用 $state 直接賦值
        this.dataList.$state = list.dataList
        if (isReset) {
          this.pagerInfo.$patch({
            count: list.allCount === 0 ? 1 : list.allCount,
            pagerIndex: 1
          })
        }
      }
    }
  },
  { isLocal: true } // 設定為區域性狀態,沒有設定的話,就是全域性狀態了。
  )

  // 初始化
  state.loadData(true)

  // 監聽頁號,實現翻頁功能
  watch(() => state.pagerInfo.pagerIndex, (index) => {
    state.loadData()
  })

  // 監聽查詢條件,實現查詢功能。
  watch(state.findValue, () => {
    state.loadData(true)
  })

  return state
}

/**
 * 子元件用 inject 獲取狀態
 * @returns
 */
const getListState = () => {
  return useStore(flag)
}

export {
  getListState,
  regListState
}

是不是應該把 watch 也內建了?

在父元件引入區域性狀態

建立父元件,使用 getListState 引入區域性狀態:

  • data-list.vue
  // 引入
  import { regListState } from './controller/state-list.js'

  // 註冊狀態
  const state = regListState()

呼叫 getListState() 會用 provide 設定一個狀態。

在子元件裡獲取區域性狀態

建立子元件,獲取區域性狀態:

  • pager.vue
  // 區域性狀態
  import { getListState } from '../controller/state-list.js'

  // 獲取父元件提供的區域性狀態
  const state = getListState()

呼叫 getListState(), 內部會用 inject (注入)獲取父元件的區域性狀態。這樣使用起來就比較明確,也比較簡單。

子元件也可以呼叫 regListState ,這樣可以註冊一個子元件的狀態,子子元件只能獲取子元件的狀態。
子子元件如果想獲取父元件的狀態,那麼需要設定不同的ID。

安全等級

變更狀態可以有四個安全級別:寬鬆、一般、嚴格、超嚴。

安全級別 state型別 直接改屬性 內建函式 action 範圍 舉例
寬鬆 reactive 所有元件 彈窗、抽屜的狀態
一般 readonly 所有元件
嚴格 readonly 所有元件
超嚴 readonly 特定元件才可更改 當前使用者狀態
  • 寬鬆:任何元件裡都可以通過屬性、內建函式和 action 來更改狀態。
    比如彈窗狀態(是否開啟)、抽屜狀態(是否開啟)、tab標籤的切換等。
    這些場景裡,如果可以直接修改屬性的話,那麼可以讓程式碼更簡潔。

  • 一般和嚴格:二者主要區別是,內建函式是否可以使用的問題,其實一開始不想區分的,但是想想還是先分開的話,畢竟多提供了一個選擇。

  • 超嚴:只能在特定的元件裡改變狀態,其他元件只能讀取狀態。
    比如當前訪問者的狀態,只有在登入元件、退出元件裡改變,其他元件不能更改。

這樣可以更好的適應不同的場景需求。

和 Pinia 的區別

nf-state 看起來和 Pnina 挺像的,那麼有哪些區別呢?

區域性狀態

Pinia 都是 全域性狀態,沒有區域性狀態,或者說,區域性狀態比較簡單,似乎不用特殊處理,只是,既然都封裝了,那麼就做全套吧,統一封裝,統一使用風格。

狀態的結構

雖然都是 reactive 的形式,但是內部結構的層次不一樣。

pinia 的狀態,資料部分和操作部分都在一個層級裡面,感覺有點分佈清楚,所以 pinia 提供了 來實現 toRefs 的功能。

pinia的狀態結構.png

我還是喜歡那種層次分明的形式,比如這樣:

class+reactive的方式

這樣設計層次很清晰,可以直接使用 toRefs 實現解構,而不會解構出來“不需要”的方法。

支援的功能

官方提供的狀態管理需要滿足各種需求,所以要支援 option API、vue2、TypeScript等。

而我自己做的狀態管理,滿足自己的需求即可,所以可以更簡潔,當然可能無法滿足你的需求。

可以不重複製造輪子,但是要擁有製造輪子的能力。做一個狀態管理,可以培養這種能力。