以 Vuex 為引,一窺狀態管理全貌

楊成功發表於2021-11-24

眾所周知,Vuex 是 Vue 官方的狀態管理方案。

Vuex 的用法和 API 不難,官網介紹也簡潔明瞭。得益於此,將 Vuex 快速整合到專案裡非常容易。然而正因為用法靈活,很多同學在 Vuex 的設計和使用上反而有些混亂。

其實在使用前,我們不妨暫停一下,思考幾個問題:

  • 什麼是狀態管理?
  • 我為什麼要用 Vuex?
  • 元件內部狀態和 Vuex 狀態如何分配?
  • 使用 Vuex 會有哪些潛在問題?

如果你對這些問題模稜兩可,那麼恭喜你,這篇文章可能是你需要的。

下面請和我一起,從起源開始,以 Vuex 為例,共同揭開狀態管理的神祕面紗。

大綱預覽

本文介紹的內容包括以下方面:

  • 狀態與元件的誕生
  • 需要狀態管理嗎?
  • 單一資料來源
  • 狀態更新方式
  • 非同步更新?
  • 狀態模組化
  • 模組化的槽點
  • 下一步

狀態與元件的誕生

自三大框架誕生起,它們共有的兩個能力徹底暴擊了 Jquery。這兩個能力分別是:

  1. 資料驅動檢視
  2. 元件化

資料驅動檢視,使我們告別了只能依靠操作 DOM 更新頁面的時代。我們不再需要每次更新頁面時,通過層層 find 找到 DOM 然後修改它的屬性和內容,可以通過運算元據來實現這些事情。

當然了在我們前端的眼裡,資料基本可以理解為儲存各種資料型別的 變數。在 資料驅動 這個概念出現之後,一部分變數也被賦予了特殊的含義。

首先是普通變數,和 JQ 時代沒差,僅用來儲存資料。除此之外還有一類變數,它們有響應式的作用,這些變數與檢視繫結,當變數改變時,繫結了這些變數的檢視也會觸發對應的更新,這類變數我稱之為狀態變數

所謂資料驅動檢視,嚴格說就是狀態變數在驅動檢視。隨著 Vue,React 的大力普及之下,前端開發們的工作重心逐漸從操作 DOM 轉移到了運算元據,狀態變數成為了核心。

狀態變數,現在大家似乎更願意稱之為狀態。我們經常詞不離口的狀態,狀態管理,其實這個狀態就是指狀態變數。下文提到的狀態同樣也是指狀態變數。

有了狀態之後,元件也來了。

JQ 時代的前端一個頁面就是一個 html,沒有“元件”的概念,對於頁面中的公共部分,想要優雅的實現複用簡直不要太難。所幸三大框架帶來了非常成熟的元件設計,可以很容易的抽取一個 DOM 片段作為元件,而且元件內部可以維護自己的狀態,獨立性更高。

元件的一個重要特性,就是內部的這些狀態是對外隔離的。父元件無法訪問到子元件內部的狀態,但是子元件可以訪問父元件顯示傳過來的狀態(Props),並且根據變化自動響應。

這個特性可以理解為狀態被模組化了。這樣的好處是,不需要考慮當前設定的狀態會影響到其他元件。當然了元件狀態徹底隔離也是不現實的,必然會有多個元件共享狀態的需求,這種情況的方案就是將狀態提取到離這些元件最近的父元件,通過 Props 向下傳遞。

上述共享狀態的方案,在通常情況下是沒有問題的,也是一種官方建議的最佳實踐。

但是如果你的頁面複雜,你會發現還是有力不從心的地方。比如:

  • 元件層級太深,需要共享狀態,此時狀態要層層傳遞。
  • 子元件更新一個狀態,可能有多個父元件,兄弟元件共用,實現困難。

這種情況下繼續使用 “提取狀態到父元件” 的方法你會發現很複雜。而且隨著元件增多,巢狀層級加深,這個複雜度也越來越高。因為關聯的狀態多,傳遞複雜,很容易出現像某個元件莫名其妙的更新,某個元件死活不更新這樣的問題,異常排查也會困難重重。

鑑於此,我們需要一個更優雅到方案,專門去處理這種複雜狀況下的狀態。

需要狀態管理嗎?

上一節我們說到,隨著頁面的複雜,我們在跨元件共享狀態的實現上遇到了棘手的問題。

那麼有沒有解決方案呢?當然有的,得益於社群大佬們的努力,方案還不止一個。但是這些方案都有一個共同的名字,就是我們在兩年前討論非常激烈的 ——— 狀態管理

狀態管理,其實可以理解為全域性狀態管理,這裡的狀態不同於元件內部的狀態,它是獨立於元件單獨維護的,然後再通過某種方式與需要該狀態的元件關聯起來。

狀態管理各有各的實現方案。Vue 有 Vuex,React 有 Redux,Mobx,當然還有其他方案。但是它們解決的都是一個問題,就是跨元件狀態共享的問題

我記得前兩年因為 “狀態管理” 這個概念的火熱,好像成了應用開發不可或缺的一部分。以 Vue 為例,建立一個專案必然會引入 Vuex 做狀態管理。但是很多人不知道為什麼用,什麼時候用,怎麼用狀態管理,只是盲目跟風,於是後來出現了非常多濫用狀態管理的例子。

看到這裡,你應該知道狀態管理不是必須的。它為什麼出現,以及它要解決什麼問題,上面基本都說明白了。如果你還沒明白,請暫停,從開頭再讀一遍。不要覺得一個技術方案誕生的背景不重要,如果你不明白它的出現是為了解決什麼問題,那麼你就無法真正發揮它的作用。

Redux 作者有一句名言:如果你不知道是否需要 Redux(狀態管理),那就是不需要它

好了,如果你在用狀態管理,或需要使用狀態管理幫你解決問題,那我們繼續往下看。

Vuex

Vue 在國內的應用非常廣泛,尤其是中小團隊,因此大多人接觸到的第一個狀態管理方案應該就是 Vuex。

那麼 Vuex 是如何解決跨元件狀態共享的問題的呢?我們一起來探索一下。

建立 store

我們上面說到,對於一般的元件共享狀態,官方建議“提取狀態到最近的父元件”。Vuex 則是更高一步,將所有狀態提取到了根元件,這樣任何元件都能訪問到。

也許你會問:這樣做不是把狀態暴露到全域性了嗎?不就徹底消除模組化的優勢了嗎?

其實不然。Vuex 這麼做的主要目的是為了讓所有元件都可以訪問到這些狀態,徹底避免子元件狀態訪問不了的情況。Vuex 把所有狀態資料都放在一個物件上,遵循單一資料來源的原則。但是這並不代表狀態是堆砌的,Vuex 在這顆單一狀態樹上實現了自己的模組化方案。

別急,我們一步步來,先看看如何使用 Vuex。

Vuex 是作為 Vue 的外掛存在的,首先 npm 安裝:

$ npm install --save vuex

安裝之後,我們新建 src/store 資料夾,在這裡放所有 Vuex 相關的程式碼。

新建 index.js 並寫入如下程式碼。這段程式碼主要的作用就是用 Vue.use 方法載入 Vuex 這個外掛,然後將配置好的 Vuex.Store 例項匯出。

import Vue from 'vue'
import Vuex from 'vuex'
// 安裝外掛
Vue.use(Vuex)

export default new Vuex.Store({
  state: {},
  mutations: {},
  actions: {},
  modules: {}
})

上面匯出的例項我們通常稱之為 store。一個 store 中包含了儲存的狀態(state)和修改狀態的函式(mutation)等,所有狀態和相關操作都在這裡定義。

最後一步,在入口檔案將上面匯出的 store 例項掛載到 Vue 上:

import store from './store'

new Vue({
  el: '#app',
  store: store
})

注意:掛載這一步不是必須的。掛載這一步的作用只是為了方便在 .vue 元件中通過 this.$store 訪問我們匯出的 store 例項。如果不掛載,直接匯入使用也是一樣的。

單一資料來源(state)

上一步我們用建構函式 Vuex.Store 建立了 store 例項,大家至少知道該怎麼用 Vuex 了。這一步我們來看看 Vuex.Store 建構函式的具體配置。

首先是 state 配置,他的值是一個物件,用來儲存狀態。Vuex 使用 單一狀態樹 原則,將所有的狀態都放在這個物件上,便於後續的狀態定位和除錯。

比如說我們有一個初始狀態 app_version 表示版本,如下:

new Vuex.Store({
  state: {
    app_version: '0.1.1'
  }
}

現在要在元件中獲取,可以這樣:

this.$store.state.app_version

但這並不是唯一的獲取方式,也可以這樣:

import store from '@/store' // @ 表示 src 目錄
store.state.app_version

為什麼要強調這一點呢?因為很多小夥伴以為 Vuex 只能通過 this.$store 操作。到了非元件內,比如在請求函式中要設定某一個 Vuex 的狀態,就不知道該怎麼辦了。

事實上元件中獲取狀態還有更優雅的方法,比如 mapState 函式,它讓獲取多狀態變得更簡單。

import { mapState } from 'vuex'

export default {
  computed: {
    ... // 其他計算屬性
    ...mapState({
      version: state => state.app_version
    })
  }
}

狀態更新方式(mutation)

Vuex 中的狀態與元件中的狀態不同,不能直接用 state.app_version='xx' 這種方式修改。Vuex 規定修改狀態的唯一方法是提交 mutation

Mutation 是一個函式,第一個引數為 state,它的作用就是更改 state 的狀態。

下面定義一個名叫 increment 的 mutation,在函式內更新 count 這個狀態:

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

然後在 .vue 元件中觸發 increment

this.$store.commit('increment', 2)

這樣繫結了 count 的檢視就會自動更新。

同步更新

雖然 mutation 是更新狀態的唯一方式,但實際上它還有一個限制:必須是同步更新

為什麼必須是同步更新?因為在開發過程中,我們常常會追蹤狀態的變化。常用的手段就是在瀏覽器控制檯中除錯。而在 mutation 中使用非同步更新狀態,雖然也會使狀態正常更新,但是會導致開發者工具有時無法追蹤到狀態的變化,除錯起來就會很困難。

再有 Vuex 給 mutation 的定位就是更改狀態,只是更改狀態,別的不要參與。所謂專人幹專事兒,這樣也幫助我們避免把更改狀態和自己的業務邏輯混起來,同時也規範了函式功能。

那如果確實需要非同步更新,該怎麼辦呢?

非同步更新

非同步更新狀態是一個非常常見的場景,比如介面請求回來的資料要儲存,那就是非同步更新。

Vuex 提供了 action 用於非同步更新狀態。與 mutation 不同的是,action 不直接更新狀態,而是通過觸發 mutation 間接更新狀態。因此即便使用 action 也不違背 “修改狀態的唯一方法是提交 mutation” 的原則。

Action 允許在實際更新狀態前做一些副作用的操作,比如上面說的非同步,還有資料處理,按條件提交不同的 mutation 等等。看一個例子:

new Vuex.Store({
  state: {
    count: 1
  },
  mutations: {
    add(state) {
      state.count++
    },
    reduce(state) {
      state.count--
    }
  },
  actions: {
    increment(context, data) {
      axios.get('**').then(res => {
        if (data.iscan) {
          context.commit('add')
        } else {
          context.commit('reduce')
        }
      })
    }
  }
})

在元件中觸發 action:

this.$store.dispatch('increment', { iscan: true })

這些就是 action 的使用方法。其實 action 最主要的作用就是請求介面,拿到需要的資料,然後觸發 mutation 修改狀態。

其實這一步在元件中也可以實現。我看過一些方案,常見的是在元件內寫一個請求方法,當請求成功,直接通過 this.$store.commit 方法觸發 mutation 來更新狀態,完全用不到 action。

難道 action 可有可無嗎?

也不是,在特定場景下確實需要 action 的,這個會在下一篇說。

狀態模組化(module)

前面講過,Vuex 是單一狀態樹,所有狀態存放在一個物件上。同時 Vuex 有自己的模組化方案
,可以避免狀態堆砌到一起,變的臃腫。

Vuex 允許我們將 store 分割成模組(module),每個模組擁有自己的 state、mutation、action。雖然狀態註冊在根元件,但是支援模組分割,相當於做到了與頁面元件平級的“狀態元件”。

為了區分,我們將被分割的模組稱為子模組,暴露在全域性的稱為全域性模組

我們來看基礎用法:

new Vuex.Store({
  modules: {
    user: {
      state: {
        uname: 'ruims'
      },
      mutation: {
        setName(state, name) {
          state.name = name
        }
      }
    }
  }
})

上面定義了 user 模組,包含了一個 state 和一個 mutation。在元件中使用方法如下:

// 訪問狀態
this.$store.state.user.uname
// 更新狀態
this.$store.commit('setName')

大家發現了,訪問子模組的 state 要通過 this.$store.state.[模組名稱] 這種方式去訪問,觸發 mutation 則與全域性模組一樣,沒有區別。

action 與 mutation 原理一致,不細說。

名稱空間

上面說到,子模組觸發 mutation 和 action 與全域性模組一致,那麼假設全域性模組和子模組中都有一個名為 setName 的 mutation。在元件中觸發,哪個 mutation 會執行呢?

經過試驗,都會執行。官方的說法是:為了多個模組能夠對同一 mutation 或 action 作出響應。

其實官方做的這個相容,我一直沒遇到實際的應用場景,反而因為同名 mutation 導致誤觸發帶來了不少的麻煩。可能官方也意識到了這個問題,索引後來也為 mutation 和 action 做了模組處理方案。

這個方案,就是名稱空間。

名稱空間也很簡單,在子模組中加一個 namespaced: true 的配置即可開啟,如:

new Vuex.Store({
  modules: {
    user: {
      namespaced: true,
      state: {}
    }
  }
})

開啟名稱空間後,觸發 mutation 就變成了:

this.$store.commit('user/setName')

可見提交引數由 '[mutation]' 變成了 '[模組名稱]/[mutation]'

模組化的槽點

上面我們介紹了 Vuex 的模組化方案,將單一狀態樹 store 分割成多個 module,各自負責本模組狀態的儲存和更新。

模組化是必要的,但是這個模組的方案,用起來總覺得有點彆扭

比如,總體的設計是將 store 先分模組,模組下在包含 state,mutation,action。

那麼按照正常理解,訪問 user 模組下 state 應該是這樣的:

this.$store.user.state.uname

但是實際 API 卻是這樣的:

this.$store.state.user.uname

這個 API 彷彿是在 state 中又各自分了模組。我沒看過原始碼,但從使用體驗上來說,這是彆扭一。

除 state 外,mutation,action 預設註冊在全域性的設計,也很彆扭

首先,官方說的多個模組對同一 mutation 或 action 作出響應,這個功能暫無找到應用場景。並且未配 namespace 時還要保證命名唯一,否則會導致誤觸發。

其次,用 namespace 後,觸發 mutation 是這樣的:

this.$store.commit('user/setName')

這個明顯是將引數單獨處理了,為什麼不是這樣:

this.$store.user.commit('setName')

總體感受就是 Vuex 模組化做的還不夠徹底。

為什麼吐槽

上面說的槽點,並不是為了吐槽而吐槽。主要是感覺還有優化空間。

比如 this.$store.commit 函式可以觸發任何 mutation 來更改狀態。如果一個元件複雜,需要操作多個子模組的狀態,那麼就很難快速的找出當前元件操作了哪些子模組,當然也不好做許可權規定。

我希望的是,比如在 A 元件要用到 b, c 兩個子模組的狀態,不允許操作其他子模組,那麼就可以先將要用到模組匯入,比如這樣寫:

import { a, b } from this.$store
export default {
  methods: {
    test() {
      alert(a.state.uname) // 訪問狀態
      a.commit('setName')// 修改狀態
    }
  }
}

這樣按照模組匯入,查詢和使用都比較清晰。

下一步

前面我們詳細介紹了狀態管理的背景以及 Vuex 的使用,分享了關於官方 API 的思考。相信看到這裡,你已經對狀態管理和 Vuex 有了更深刻的認識和理解。

然而本篇我們只介紹了 Vuex 這一個方案,狀態管理的其他方案,以及上面我們的吐槽點,能不能找到更優的實現方法,這些都等著我們去嘗試。

下一篇文章我們繼續深挖狀態管理,對比 Vuex 和 React,Fluter 在狀態管理實現上的差異,然後在 Vue 上整合 Mobx,打造我們優雅的應用。

往期精彩

專欄會長期輸出前端工程與架構方向的文章,已釋出如下:

如果喜歡我的文章,請點贊支援我吧!也歡迎關注我的專欄。

宣告: 本文原創,如有轉載需求,請加微信 ruidoc 聯絡授權。

相關文章