如何優雅使用 vuex

請叫我大蘇發表於2023-11-16

大綱

本文內容更多的是講講使用 vuex 的一些心得想法,所以大概會講述下面這些點:

Q1:我為什麼會想使用 vuex 來管理資料狀態互動?

Q2:使用 vuex 框架有哪些缺點或者說副作用?

Q3:我是如何在專案裡使用 vuex 的?

初識 vuex

對於 vuex,有人喜歡,有人反感

喜歡的人覺得它可以很好的解決複雜的資料互動場景

反感的人覺得它有各種缺點:繁瑣冗餘的程式碼編寫、維護性差的字串形式變數注入、過於依賴 vue 框架導致非同步擴充套件場景差

這其中,有一個很模糊的點,複雜的資料互動場景並沒有一個衡量標準,每個人都有自己的見解

再加上不同人有著不同的專案經歷,這就造成了經常會出現有趣的現象:你體會不到我為什麼非要使用 vuex,他理解不了這種場景何須使用 vuex,我也講不明白選擇 vuex 的緣由

借用官網文件一句話:

您自會知道什麼時候需要它

很玄乎,更通俗來講就是,多踩點坑,多遭遇些痛點,當你的最後一根稻草被壓垮時,自然就會去尋找更好的方案解決

我一直都不喜歡 vuex,因為我覺得它的 mapMutations 或者 mapState 注入到 vue 裡的變數和方法都是字串,極大的破壞了程式碼的可讀性和維護性,沒辦法透過 idea 快速的跳轉到變數定義的地方

當然,你也可以定義一些靜態變數來替換這些字串就可以解決跳轉問題,但代價就是程式碼更繁瑣了,本來使用 vuex 時就需要寫一堆繁瑣的程式碼,這下更麻煩

還有一個不想使用 vuex 的原因是因為我的專案業務邏輯挺複雜,除了 vue 單檔案外,專案裡還劃分了來負責業務邏輯或非同步任務的 js 層程式碼,而 vuex 是為 vue 框架而設計的,存放在 vuex 資料中心的變數可以透過它的一些工具方法來注入到 vue 元件的 computed 計算屬性裡方便直接使用,比如

import { mapState } from 'vuex'

export default {
  // 對映 this.count 為 store.state.count
  computed: mapState({
    count: state => state.count
  })
}

但如果想在 js 檔案裡使用 vuex 裡的資料,就會比較繁瑣:

import store from 'store'

console.log(store.state.count);

基於以上種種原因,我遲遲未在專案裡使用 vuex

那麼,我最後為什麼又選擇使用了 vuex 呢?

一,專案的一些資料互動場景使用 vue 原生的輸入輸出方案已經忍不下去了

二,我想辦法解決了我沒法忍受的 vuex 的幾個缺點了

三,這是個新專案,並沒有複雜的業務場景,專案基本由 vue 單檔案來書寫即可

簡單來說,就是有個新專案符合適用 vuex 的場景,而且一些元件互動場景使用原生方案過於繁瑣,vuex 剛好能夠解決這個問題,雖然 vuex 有一定的使用成本,但這些缺點恰好又被我想了一些法子解決或簡化掉

這樣一來,引入 vuex 既能解決我的訴求,又不會引入太多我無法接受的缺點,那自然可以來玩一玩

背景

vue 框架是基於元件機制來組裝成頁面,所以頁面資料是分散到各個元件間,而元件間的資料互動使用的是 vue 自帶的輸入(props)輸出($emit)機制

這種資料互動方案有個特點,資料物件都儲存在元件內部,互動需要透過輸入和輸出

而輸入輸出機制有個缺點:頁面複雜時,經常需要層層傳遞資料,因為非父子元件間的互動,只能尋找最近的祖先元件來做中轉

同時,輸入輸出機制還有一個侷限性:當不同頁面的元件需要進行資料互動時,它就無能為力了

平常開發,這種輸入輸出的方案也基本滿足了

但如果有需要跨頁面的資料互動或者說,有需要將資料做持久化處理的場景時;以及如果頁面元件層次複雜,存在props 和 $emit 層層傳遞時,那這時候如果忍不了輸入輸出方案的用法,那麼就可以研究新方案了

解決這種場景的方案很多,但從本質上來講,都可以統歸為:資料中心方案

這種方案思路就是將資料物件從元件內部移出到外部儲存維護,需要使用哪個資料變數的元件自行去資料中心取

vue 其實也有機制可以達到這種效果,如:依賴注入,但慎用,太破壞資料流的走向了

我們也可以自己用 js 來實現一個資料中心,專門建個 js 檔案來儲存維護資料模型,需要資料變數的元件引入 js 檔案來讀取

但每個資料中心都必須解決兩個問題:資料複用和資料汙染,通俗來講就是資料初始化和重置,也就是資料的生命週期

資料複用是為了確保不同元件間從資料中心裡讀取時,是同一份資料副本,這才能達到資料互動目的

而資料汙染是指不同模組間使用同個資料中心時,資料模型是否可以達到相互獨立,互不影響的效果,這通常是某個功能在不同模組間被複用時會出現的場景;如果這種場景不好理解,那麼也可以想想這種場景:再次載入該頁面,元件再次被建立後,從資料中心裡讀取的資料副本是否是相互獨立的

如果資料儲存在 vue 元件內部,那資料的生命週期就是跟隨著元件的建立和銷燬,這也是為什麼 data 是一個返回物件的函式,因為這樣可以藉助 js 的函式作用域機制來解決資料的複用和汙染問題

但資料從 vue 元件內部移出,儲存到資料中心,那麼這些處理就需要自己來實現

所以,資料中心並不是簡單建個 js 類,裡面宣告下資料物件就完事了的

基於此,我選擇使用 vuex

vuex 副作用

先看個使用 vuex 的簡單例子:

// 宣告
const store = new Vuex.Store({
  state: {
    count: 1
  },
  mutations: {
    increment (state) {
      // 變更狀態
      state.count++
    }
  }
})

// vue 裡使用
import { mapMutations } from 'vuex'
import { mapState } from 'vuex'

export default {
  // ...
  computed: {
     ...mapState({
         // 將 `this.count` 對映為 `this.$store.state.count`
         count: state => state.count
     })   
  },
  methods: {
    ...mapMutations([
      'increment', // 將 `this.increment()` 對映為 `this.$store.commit('increment')`
    ])
  }
}

僅僅簡單定義個資料物件,就需要宣告物件模型 state,宣告物件操作方法 mutation,然後在相應的 vue 元件內先透過 mapState 注入變數的讀方法,再透過 mapMutations 注入變數的寫方法

而以上這麼多繁瑣的程式碼,在原本的 vue 機制裡,就是簡單的在 data 裡宣告下變數就完事,這一對比,vuex 的使用上,複雜度和繁瑣度很大,有一定的使用成本

所以很多人不喜歡用它,官方也說簡單的頁面也沒有必要去使用它

這是我覺得 vuex 的第一個缺點,或者說副作用:繁瑣冗餘的程式碼編寫

第二個我覺得 vuex 的缺點就是,mapState 或 mapMutation 注入的變數,都是字串的

字串就意味著,你在 vue 單檔案內其他地方透過 this.xxx 使用這些變數時,當你想檢視變數的宣告時,idea 無法識別!

這是我特別無法接受的一點,降低我的維護、開發效率

不過這點因人而異,有人覺得它不是個問題,或者使用個靜態變數來替換字串也可以解決,但這些我個人是沒辦法接受

然而 vue 原生輸入輸出的資料互動又不足夠支撐我的一些需求場景,自己用 js 實現個資料中心吧,又擔心沒強制規範,沒處理好,後期跑偏掉更難維護,那就想想辦法搞定 vuex 的這兩個缺點吧

如何更簡易的使用 vuex

先說下,我雖然用了些方法,讓我使用 vuex 可以達到我的預期,既滿足我的需求場景,又不至於引入太多副作用

但實際上,這種方式也許就偏離了 vuex 官方的推薦方式了,別人不一定能接受我的這種用法

所以,這篇更多的是分享我的一些思路和想法,有一說一,並不通用,歡迎拍磚

就我個人對於 vuex 的缺點,我所不能接受的就兩點:

  • 繁瑣冗餘的程式碼編寫
  • 維護性差、可讀性差的字串變數注入

那麼,就是得想辦法解決這兩個問題,先來說第一個

封裝自動生成程式碼解決 vuex 使用繁瑣問題

用 vuex 需要編寫很多繁瑣的程式碼,這些程式碼是少不了的,既然少不了,那換個思路,不用我來編寫不就好了

想辦法提取共性,封裝個工具方法,讓它來生成每次使用 vuex 的那些繁瑣程式碼,這樣一來,使用就方便了

state 裡宣告的資料物件模型,這些程式碼是沒辦法自動生成的,畢竟資料模型都不大一樣

而修改資料變數的 mutation 程式碼就可以想辦法來自動生成了

/**
 * 根據 state 物件屬性自動生成 mutations 更新屬性的方法,如:
 * state: {
 *  projectId: '',
 *  searchParams: {
 *      batchId: ''
 *  }
 * }
 *
 * ===>
 *
 * {
 *  updateProjectId: (state, payload) => { state.projectId = payLoad }
 *  updateSearchParams: (state, payload) => { state.searchParams = {...state.searchParams, ...payload} }
 *  updateBatchId: (state, payload) => { state.searchParams.batchId = payload }
 * }
 *
 * 非對像型別的屬性直接生成賦值操作,物件型別屬性會透過擴充套件運算子重新生成物件
 * 且會遞迴處理內部物件的屬性,扁平化的生成 updateXXX 方法掛載到 mutations 物件上
 * @param {Object} stateTemplate
 */
export function generateMutationsByState(stateTemplate) {
  let handleInnerObjState = (parentKeyPath, innerState, obj) => {
    Object.keys(innerState).forEach(key => {
      let value = innerState[key];
      let updateKey = `update${key[0].toUpperCase()}${key.substr(1)}`;
      if (typeof value === 'object' && value != null && !Array.isArray(value)) {
        obj[updateKey] = (state, payload) => {
          let target = state;
          for (let i = 0; i < parentKeyPath.length; i++) {
            target = target[parentKeyPath[i]];
          }
          target[key] = { ...target[key], ...payload };
        };
        handleInnerObjState([...parentKeyPath, key], value, obj);
      } else {
        obj[updateKey] = (state, payload) => {
          let target = state;
          for (let i = 0; i < parentKeyPath.length; i++) {
            target = target[parentKeyPath[i]];
          }
          target[key] = payload;
        };
      }
    });
  };
  let mutations = {};
  Object.keys(stateTemplate).forEach(key => {
    let obj = {};
    let value = stateTemplate[key];
    let updateKey = `update${key[0].toUpperCase()}${key.substr(1)}`;
    if (typeof value === 'object' && value != null && !Array.isArray(value)) {
      obj[updateKey] = (state, payload) => {
        state[key] = { ...state[key], ...payload };
      };
      handleInnerObjState([key], value, obj);
    } else {
      obj[updateKey] = (state, payload) => {
        state[key] = payload;
      };
    }
    Object.assign(mutations, obj);
  });
  return mutations;
}

然後是 mapState 和 mapMutation 注入到 vue 元件的這些程式碼也可以透過 computed 計算屬性的特性來自動生成,這樣使用上更加方便,畢竟使用 computed 計算屬性的方式就跟使用 data 裡宣告的變數一樣,沒有什麼區別

import store from './index';
/**
 * 將 store 裡指定的 state 轉成計算屬性 computed 的 set() get()
 * vue 裡就可以直接類似操作 data 屬性一樣使用 state
 *
 * @param {String} moduleName state 所屬的 store 的 module 名
 * @param {Array} states 待處理的 states e.g: ['project', 'searchParams.projectName'] 其中,
 * 掛載在 computed 上的屬性名,預設等於 state,當 state 結構多層時,取最後一層的屬性名
 *
 * ps: state 對應的 mutation 必須以 updateXXX 方式命名
 */
export function storeToComputed(moduleName, states) {
  if (!store) {
    throw new TypeError('store is null');
  }
  if (!moduleName) {
    throw new TypeError("state's module name is null");
  }
  if (!states || !Array.isArray(states) || states.length === 0) {
    throw new TypeError('states is null or not array');
  }
  let computed = {};
  states.forEach(state => {
    if (state.indexOf('.') !== -1) {
      let _states = state.split('.');
      let _key = _states[_states.length - 1];
      computed[_key] = {
        get() {
          let res = store.state[moduleName];
          for (let i = 0; i < _states.length; i++) {
            res = res[_states[i]];
          }
          return res;
        },
        set(value) {
          store.commit(
            `${moduleName}/update${_key[0].toUpperCase()}${_key.substr(1)}`,
            value
          );
        },
      };
    } else {
      computed[state] = {
        get() {
          return store.state[moduleName][state];
        },
        set(value) {
          store.commit(
            `${moduleName}/update${state[0].toUpperCase()}${state.substr(1)}`,
            value
          );
        },
      };
    }
  });

  return computed;
}

那麼最終可以達到的效果就是:

  • 只需在 store 檔案裡宣告 state 資料變數
  • 然後再需要注入的 vue 元件裡注入即可
// 宣告
import { generateMutationsByState } from './helper';

const global = {
    state: {
        count: 0
    }
}
global.mutations = generateMutationsByState(global.state);

const store = new Vuex.Store({
    modules: {
        global
    }
})
// vue裡使用
import { storeToComputed } from '@/store/storeToComputed';

export default {
  // ...
  computed: {
      // 將 this.$store.state.global.count 對映成 this.count
     ...storeToComputed('global', ['count'])
  },
}

我的這種用法,其實就只是單純將 vuex 拿來作為資料中心使用而已,在 store 檔案裡不編寫邏輯程式碼,也不使用 action

這種用法的好處,我是覺得,會跟原本在 vue 的 data 裡宣告變數後的用法比較類似。因為就是將原本定義在 data 裡的變數換成定義在專門的 store 檔案裡,然後再多一步將變數透過工具方法注入到 vue 的 computed 裡,接下去的使用變數的任何場景,在哪賦值,在哪取值,哪裡處理非同步請求等等的程式碼,原本怎麼寫,現在還是怎麼寫,完全不影響

這就意味著,這種方案後續如果有缺陷,或者用不習慣,那麼想切換到 vue 原生的輸入輸出方案非常方便,影響點、改動點都會比較少,就是將 storeToComputed 注入到 computed 的變數換到 data 就完事了

甚至說,後續想換掉 vuex 也會比較方便,畢竟只是單純用它當做資料中心而已

然後再配合上 vuex 的動態掛載和解除安裝的用法,這個資料中心就可以像 angular 框架那樣做到精確控制資料物件的作用域和生命週期,全域性共享、模組間共享、頁面內共享、元件內共享等都可以很方便做到,這樣一來,資料互動就不怕複雜場景了

這是我之所以會這麼使用 vuex 的考慮

自定義 vscode 外掛解決字串變數的跳轉問題

繁瑣的程式碼編寫問題搞定了,接下去就是看看怎麼解決字串變數注入的跳轉問題了

先來說說,我為什麼會在意變數支不支援利用 idea 直接跳轉到宣告的地方

這是因為,有些頁面比較複雜,資料變數比較多,或者時間久了,很容易忘記一些變數的命名、含義

而我們通常都只會在宣告的地方加上一些註釋

所以利用 idea 直接快速跳轉到宣告的地方,第一,有註釋可以快速幫助回憶、理清變數含義;第二,忘記變數命名全稱可以快速複製使用;第三,方便我檢視其它資料變數

那麼,怎麼解決這個問題呢?

自然就是自己擴充套件開發個 vscode 外掛來支援了,面向百度的話,vscode 外掛開發並不困難,看幾篇教程,清楚外掛的生命週期和一些 API 就可以上手了

關鍵是,如何識別 vuex 注入的這些變數?如何跳轉到 store 檔案裡宣告資料變數的 state 位置?

如果想做成通用的外掛,那可能需要多花點精力

但如果只是基於自己當前的專案來解決這個問題,那就簡單多了,因為專案有一定的規範,比如 vuex 的 store 檔案存放的目錄地址,比如注入到 vue 元件裡的使用方式,這些都是有規範和規律的

簡單說下我的思路:

  1. 先掃描專案 store 目錄下檔案,識別出有資料模型 (state) 的檔案,解析並儲存資料模型各個變數名和位置
  2. 註冊 vscode 的變數操作響應,當按住 ctrl 並將滑鼠移到變數上時,響應我們的外掛程式
  3. 判斷當前聚焦操作的變數是否是透過在 computed 裡注入的變數,是則繼續下一步尋找變數宣告的檔案位置
  4. 透過變數名和模組名到 store 裡匹配變數,匹配到後,記錄變數的宣告資訊和檔案位置,當點選左鍵時,響應跳轉行為

github 地址:vuex-peek

總結

最後簡單總結下,專案裡並不是必須要使用 vuex,vuex 所解決的場景,用 vue 原生的輸入輸出機制想想辦法也能解決,區別可能就是程式碼的可讀性、維護性上的區別,資料流走向是否清晰等

vuex 作為三方庫,自然就是一個可選的服務,用不用,怎麼用,都因人而異;考慮好自己的訴求,對比好引入前後的影響點,權衡好自己能接受的點就好

比如我,使用 vuex 的方式上說得難聽點,也有點不倫不類,畢竟並沒有按照官方示例來使用,反而是自己搞了套使用規範,這也增加了別人的上手成本

所以寫這篇,不在於強推使用 vuex,只是從自己的一些經歷分享自己使用一些三方庫的心路歷程,所思所想

很多時候,當你開始吐槽某某方案、當你開始無法接受某某用法時,這其實意味著,這是一次絕佳的探索機會

吐槽完就想辦法去最佳化、去尋找新方案;接受不了時,就想辦法去研究看能否解決這些痛點

人嘛,總是在一次次的踩進坑裡,再爬出來

相關文章