Vuex 是 state management pattern + library 工具,集中儲存所有 components,加上特定改變狀態的規則。
State Management Pattern
- state: 目前 app 的狀態
- view: 根據 state 產生的畫面
- actions: 從 view 取得 user input,修改 state
如果有多個 components 共享 common state 會遇到的問題
- multiple views 會由 the same piece of state 決定
- 由不同的 views 產生的 actions,可改變 the same piece of state
Vuex 提出的方法是將 shared state 由 components 取出來,並用 global singleton 管理。
Vuex 可協助處理 shared state management,如果 app 很簡單,不是大型 SPA,就不需要 Vuex,只需要用 store pattern 即可
Store Pattern
如果有兩個 component 需要共享一個 state 時,可能會這樣寫
<div id="app-a">App A: {{ message }}</div>
<div id="app-b">App B: {{ message }}</div>
<script>
const { createApp, reactive } = Vue
const sourceOfTruth = reactive({
message: 'Hello'
})
const appA = createApp({
data() {
return sourceOfTruth
}
}).mount('#app-a')
const appB = createApp({
data() {
return sourceOfTruth
},
mounted() {
sourceOfTruth.message = 'Goodbye' // both apps will render 'Goodbye' message now
}
}).mount('#app-b')
</script>
畫面上兩個文字部分,都會變成 Goodbye
。
因為 sourceOfTruth 可以在程式中任意一個地方,被修改資料,當程式變多,會造成 debug 的難度。
這個問題就用 store pattern 處理。
store 類似 java 的 data object,透過 set method 修改資料內容,資料以 reactive
通知 Vue 處理異動。
<div id="app-a">{{sharedState.message}}</div>
<div id="app-b">{{sharedState.message}}</div>
<script>
const { createApp, reactive } = Vue
const store = {
debug: true,
state: reactive({
message: 'Hello!'
}),
setMessageAction(newValue) {
if (this.debug) {
console.log('setMessageAction triggered with', newValue)
}
this.state.message = newValue
},
clearMessageAction() {
if (this.debug) {
console.log('clearMessageAction triggered')
}
this.state.message = ''
}
}
const appA = createApp({
data() {
return {
privateState: {},
sharedState: store.state
}
},
mounted() {
store.setMessageAction('Goodbye!')
}
}).mount('#app-a')
const appB = createApp({
data() {
return {
privateState: {},
sharedState: store.state
}
}
}).mount('#app-b')
Simplest Store
Vuex app 的核心就是 store,用來儲存 app 的狀態,以下兩點,是 Vuex store 跟 global object 的差異
- Vuex stores 是 reactive,如果 Vue component 使用了 state,將會在 state 異動時,自動更新 component
- 無法直接修改 store 的 state,修改的方式是透過 committing mutations,可確保 state change 可被追蹤
透過 mutations methods 異動 state
<div id="app-a">
{{sharedState.count}}
<button @click="increment">increment</button>
</div>
<script>
// import { createApp } from 'vue'
// import { createStore } from 'vuex'
const { createApp, reactive } = Vue
const { createStore } = Vuex
// Create a new store instance.
const store = createStore({
state () {
return {
count: 0
}
},
mutations: {
increment (state) {
state.count++
}
}
})
const app = createApp({
data() {
return {
privateState: {},
sharedState: store.state
}
},
methods: {
increment() {
this.$store.commit('increment')
console.log(this.$store.state.count)
}
}
})
app.mount('#app-a')
app.use(store)
</script>
State
Single State Tree
single state tree 就是包含 application 所有 state 的單一物件,也就是 "single sure of truth",每一個 application 都只有一個 store。單一物件容易使用部分 state 資料,也很容易 snapshot 目前的狀態值。
single state 並不會跟 modularity 概念衝突,後面會說明如何將 state 與 mutations 分割到 sub modules
store 儲存的 data 遵循 Vue instance 裡面的 data 的規則
Getting Vuex State into Vue Components
因為 Vuex store 是 reactive,最簡單的方法就是透過 computed property 取出部分 store state
以下產生一個 component,並將 store inject 到 component 中,透過 this.$store
存取
<div id="app">
<counter></counter>
</div>
<script>
// import { createApp } from 'vue'
// import { createStore } from 'vuex'
const { createApp, reactive } = Vue
const { createStore } = Vuex
// Create a new store instance.
const store = createStore({
state () {
return {
count: 0
}
},
mutations: {
increment (state) {
state.count++
}
}
})
const app = createApp({
data() {
return {
privateState: {},
sharedState: store.state
}
},
})
const Counter = {
template: `<div>{{ count }}</div> <button @click="increment">increment</button>`,
computed: {
count () {
return this.$store.state.count
}
},
methods: {
increment() {
this.$store.commit('increment')
console.log(this.$store.state.count)
}
}
}
app.use(store)
app.component('counter', Counter)
app.mount('#app')
</script>
mapState
當 component 需要使用多個 store state properties or getters,宣告多個 computed property 會很麻煩,Vuex 用 mapState 產生 computed getter functions
<div id="app">
<counter></counter>
</div>
<script>
// import { createApp } from 'vue'
// import { createStore } from 'vuex'
const { createApp, reactive } = Vue
const { createStore, mapState } = Vuex
// Create a new store instance.
const store = createStore({
state () {
return {
count: 0
}
},
mutations: {
increment (state) {
state.count++
}
}
})
const app = createApp({
data() {
return {
privateState: {},
sharedState: store.state
}
},
})
const Counter = {
template: `<div>{{ count }}</div>
<div>{{ countAlias }}</div>
<div>{{ countPlusLocalState }}</div>
<button @click="increment">increment</button>`,
data() {
return {
localCount: 2,
};
},
computed: mapState({
// arrow functions can make the code very succinct!
count: state => state.count,
// passing the string value 'count' is same as `state => state.count`
countAlias: 'count',
// to access local state with `this`, a normal function must be used
countPlusLocalState (state) {
return state.count + this.localCount
}
}),
methods: {
increment() {
this.$store.commit('increment')
console.log(this.$store.state.count)
}
}
}
app.use(store)
app.component('counter', Counter)
app.mount('#app')
</script>
也可以直接傳入 string array 給 mapState,mapped computed property 的名稱要跟原本 state sub tree name 一樣
computed: mapState([
'count'
]),
Object Spread Operator
mapState 會回傳一個物件,如果要組合使用 local computed property,通常要用 utility 將多個物件 merge 在一起,再將該整合物件傳給 computed
利用 object spread operator 可簡化語法
<div id="app">
<counter></counter>
</div>
<script>
// import { createApp } from 'vue'
// import { createStore } from 'vuex'
const { createApp, reactive } = Vue
const { createStore, mapState, mapGetters } = Vuex
// Create a new store instance.
const store = createStore({
state () {
return {
count: 0,
todos: [{
id: 1,
text: '...',
done: true
},
{
id: 2,
text: '...',
done: false
}
]
}
},
getters: {
doneTodos: state => {
return state.todos.filter(todo => todo.done)
},
doneTodosCount: (state,getters) => {
return getters.doneTodos.length
},
getTodoById: (state) => (id) => {
return state.todos.find(todo => todo.id === id)
}
},
mutations: {
increment (state) {
state.count++
}
}
})
const app = createApp({
data() {
return {
privateState: {},
sharedState: store.state
}
},
})
const Counter = {
template: `<div>
<div>{{count}}</div>
<div>{{countAlias}}</div>
<div>{{countPlusLocalState}}</div>
<div>{{doneTodos}}</div>
<div>{{doneTodosAlias}}</div>
<div>{{doneTodosCount}}</div>
<div>{{getTodoById}}</div>
</div>
<button @click="increment">increment</button>`,
data() {
return {
localCount: 2,
};
},
computed: {
// 本地 computed
getTodoById() {
return this.$store.getters.getTodoById(2);
},
// 使用展開運算符將 mapState 混合到外部物件中
...mapState([
'count',
]),
...mapState({
countAlias: 'count',
countPlusLocalState(state) {
return state.count + this.localCount;
},
}),
// 使用展開運算符將 mapGetters 混合到外部物件中
...mapGetters([
'doneTodos',
'doneTodosCount',
]),
...mapGetters({
doneTodosAlias: 'doneTodos',
}),
},
methods: {
increment() {
this.$store.commit('increment')
console.log(this.$store.state.count)
}
}
}
app.use(store)
app.component('counter', Counter)
app.mount('#app')
</script>
Getters
有時候需要根據儲存的 state 計算出衍生的 state
ex:
computed: {
doneTodosCount () {
return this.$store.state.todos.filter(todo => todo.done).length
}
}
如果有多個 component 需要這個 function,可以在 store 裡面定義 getters,第一個參數固定為 state
const store = createStore({
state: {
todos: [
{ id: 1, text: '...', done: true },
{ id: 2, text: '...', done: false }
]
},
getters: {
doneTodos (state) {
return state.todos.filter(todo => todo.done)
}
}
})
Property-Style Access
getters 是透過 store.getters
物件使用
store.getters.doneTodos // -> [{ id: 1, text: '...', done: true }]
可接受其他 getters 為第二個參數
getters: {
// ...
doneTodosCount (state, getters) {
return getters.doneTodos.length
}
}
store.getters.doneTodosCount // -> 1
在 component 可這樣呼叫
computed: {
doneTodosCount () {
return this.$store.getters.doneTodosCount
}
}
Method-Style Access
可利用 return a function 傳給 getters 參數,這對於查詢 store 裡面的 array 很有用
getters: {
// ...
getTodoById: (state) => (id) => {
return state.todos.find(todo => todo.id === id)
}
}
store.getters.getTodoById(2) // -> { id: 2, text: '...', done: false }
mapGetters
map store getters 為 local computed properties
import { mapGetters } from 'vuex'
export default {
// ...
computed: {
// mix the getters into computed with object spread operator
...mapGetters([
'doneTodosCount',
'anotherGetter',
// ...
])
}
}
可 mapping 為不同名稱
...mapGetters({
// map `this.doneCount` to `this.$store.getters.doneTodosCount`
doneCount: 'doneTodosCount'
})
Mutations
修改 state 的方式是透過 committing a mutation
Vuex mutations 類似 events,每個 mutation 都有 string type 及 a handler
const store = createStore({
state: {
count: 1
},
mutations: {
increment (state) {
// mutate state
state.count++
}
}
})
不能直接呼叫 mutation handler,必須這樣呼叫
store.commit('increment')
Commit with Payload
傳送新增的參數給 store.commit
稱為 mutation 的 payload
// ...
mutations: {
increment (state, n) {
state.count += n
}
}
呼叫
store.commit('increment', 10)
通常 payload 會是一個 object,裡面有多個欄位
// ...
mutations: {
increment (state, payload) {
state.count += payload.amount
}
}
呼叫
store.commit('increment', {
amount: 10
})
Object-Style Commit
commit a mutation 的另一個方式
store.commit({
type: 'increment',
amount: 10
})
這時候,整個物件會成為 payload,故 handler 不變
mutations: {
increment (state, payload) {
state.count += payload.amount
}
}
Using Constants for Mutation Types
常見到在 Flux 會使用 constants 為 mutation types,優點是可將所有 constants 集中放在一個檔案裡面,可快速知道整個 applicaiton 的 mutations
// mutation-types.js
export const SOME_MUTATION = 'SOME_MUTATION'
// store.js
import { createStore } from 'vuex'
import { SOME_MUTATION } from './mutation-types'
const store = createStore({
state: { ... },
mutations: {
// we can use the ES2015 computed property name feature
// to use a constant as the function name
[SOME_MUTATION] (state) {
// mutate state
}
}
})
Mutations Must Be Synchronous
mutation handler functions must be synchronous
如果這樣寫,當 commit mutation 時 callback 無法被呼叫。devtool 無法得知什麼時候被呼叫了 callback
mutations: {
someMutation (state) {
api.callAsyncMethod(() => {
state.count++
})
}
}
Committing Mutations in Components
可用 this.$store.commit('xxx')
或是 mapMutations
helper
import { mapMutations } from 'vuex'
export default {
// ...
methods: {
...mapMutations([
'increment', // map `this.increment()` to `this.$store.commit('increment')`
// `mapMutations` also supports payloads:
'incrementBy' // map `this.incrementBy(amount)` to `this.$store.commit('incrementBy', amount)`
]),
...mapMutations({
add: 'increment' // map `this.add()` to `this.$store.commit('increment')`
})
}
}
Vuex 的 mutations 是 synchronous transactions
store.commit('increment')
// any state change that the "increment" mutation may cause
// should be done at this moment.
如果需要用到 asynchronous opertions,要使用 Actions
Actions
類似 mutations,差別:
- actions commit mutations,而不是 mutating the state
- actions 可封裝任意非同步 operations
這是簡單的 actions 例子
const store = createStore({
state: {
count: 0
},
mutations: {
increment (state) {
state.count++
}
},
actions: {
increment (context) {
context.commit('increment')
}
}
})
action handler 以 context 為參數,裡面是 store instance 的 methods/properties,故能呼叫 context.commit
commit a mutation,context.state
與 context.getters
也能用 context.dispatch
呼叫其他 actions
只使用 commit 的時候,可這樣簡化寫法
actions: {
increment ({ commit }) {
commit('increment')
}
}
Dispatching Actions
store.dispatch
會驅動 actions
store.dispatch('increment')
因為 mutations 必須要為 synchronous,故如要處理 asynchronous operations,而不是直接呼叫 store.commit('increment')
actions: {
incrementAsync ({ commit }) {
setTimeout(() => {
commit('increment')
}, 1000)
}
}
actions 支援 payload format & object-style dispatch
// dispatch with a payload
store.dispatch('incrementAsync', {
amount: 10
})
// dispatch with an object
store.dispatch({
type: 'incrementAsync',
amount: 10
})
這是更真實的例子:checkout a shopping cart
actions: {
checkout ({ commit, state }, products) {
// save the items currently in the cart
const savedCartItems = [...state.cart.added]
// send out checkout request, and optimistically
// clear the cart
commit(types.CHECKOUT_REQUEST)
// the shop API accepts a success callback and a failure callback
shop.buyProducts(
products,
// handle success
() => commit(types.CHECKOUT_SUCCESS),
// handle failure
() => commit(types.CHECKOUT_FAILURE, savedCartItems)
)
}
}
Dispatching Actions in Components
可使用 this.$store.dispatch('xxx')
或 mapActions
helper 在 component 中 dispatch actions
import { mapActions } from 'vuex'
export default {
// ...
methods: {
...mapActions([
'increment', // map `this.increment()` to `this.$store.dispatch('increment')`
// `mapActions` also supports payloads:
'incrementBy' // map `this.incrementBy(amount)` to `this.$store.dispatch('incrementBy', amount)`
]),
...mapActions({
add: 'increment' // map `this.add()` to `this.$store.dispatch('increment')`
})
}
}
Composing Actions
因 action 是非同步的,可利用 Promise 得知 action 已完成
actions: {
actionA ({ commit }) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('someMutation')
resolve()
}, 1000)
})
}
}
現在就能這樣呼叫
store.dispatch('actionA').then(() => {
// ...
})
////// 在另一個 action 可這樣呼叫
actions: {
// ...
actionB ({ dispatch, commit }) {
return dispatch('actionA').then(() => {
commit('someOtherMutation')
})
}
}
可利用 async/await 撰寫 actions
// assuming `getData()` and `getOtherData()` return Promises
actions: {
async actionA ({ commit }) {
commit('gotData', await getData())
},
async actionB ({ dispatch, commit }) {
await dispatch('actionA') // wait for `actionA` to finish
commit('gotOtherData', await getOtherData())
}
}
Modules
因使用 single state tree,application 的所有 states 集中在一個物件中,如果 application 很大,store 也會很大
Vuex 可將 store 切割為 modules,每個 module 有各自的 state, mutations, actions, getters, nested modules
const moduleA = {
state: () => ({ ... }),
mutations: { ... },
actions: { ... },
getters: { ... }
}
const moduleB = {
state: () => ({ ... }),
mutations: { ... },
actions: { ... }
}
const store = createStore({
modules: {
a: moduleA,
b: moduleB
}
})
store.state.a // -> `moduleA`'s state
store.state.b // -> `moduleB`'s state
Module Local State
在 module 的 mutations 與 getters,第一個參數為 module 的 local state
const moduleA = {
state: () => ({
count: 0
}),
mutations: {
increment (state) {
// `state` is the local module state
state.count++
}
},
getters: {
doubleCount (state) {
return state.count * 2
}
}
}
在 module action,透過 context.state
存取 local state,透過 context.rootState
存取 root state
const moduleA = {
// ...
actions: {
incrementIfOddOnRootSum ({ state, commit, rootState }) {
if ((state.count + rootState.count) % 2 === 1) {
commit('increment')
}
}
}
}
在 module getter,rootState 是第三個參數
const moduleA = {
// ...
getters: {
sumWithRootCount (state, getters, rootState) {
return state.count + rootState.count
}
}
}
Namespacing
actions, mutations, getters 預設註冊為 global namespace
可用 namespaces:true
,自動加上 module name
const store = createStore({
modules: {
account: {
namespaced: true,
// module assets
state: () => ({ ... }), // module state is already nested and not affected by namespace option
getters: {
isAdmin () { ... } // -> getters['account/isAdmin']
},
actions: {
login () { ... } // -> dispatch('account/login')
},
mutations: {
login () { ... } // -> commit('account/login')
},
// nested modules
modules: {
// inherits the namespace from parent module
myPage: {
state: () => ({ ... }),
getters: {
profile () { ... } // -> getters['account/profile']
}
},
// further nest the namespace
posts: {
namespaced: true,
state: () => ({ ... }),
getters: {
popular () { ... } // -> getters['account/posts/popular']
}
}
}
}
}
})
- Accessing Global Assets in Namespaced Modules
rootState
及 rootGetters
有傳入 getter function 作為第三、四個參數,且可透過 context
物件使用 properties
如果要使用 global namespace 的 actions, mutations,要在 dispatch, commit 傳入 {root:true}
modules: {
foo: {
namespaced: true,
getters: {
// `getters` is localized to this module's getters
// you can use rootGetters via 4th argument of getters
someGetter (state, getters, rootState, rootGetters) {
getters.someOtherGetter // -> 'foo/someOtherGetter'
rootGetters.someOtherGetter // -> 'someOtherGetter'
rootGetters['bar/someOtherGetter'] // -> 'bar/someOtherGetter'
},
someOtherGetter: state => { ... }
},
actions: {
// dispatch and commit are also localized for this module
// they will accept `root` option for the root dispatch/commit
someAction ({ dispatch, commit, getters, rootGetters }) {
getters.someGetter // -> 'foo/someGetter'
rootGetters.someGetter // -> 'someGetter'
rootGetters['bar/someGetter'] // -> 'bar/someGetter'
dispatch('someOtherAction') // -> 'foo/someOtherAction'
dispatch('someOtherAction', null, { root: true }) // -> 'someOtherAction'
commit('someMutation') // -> 'foo/someMutation'
commit('someMutation', null, { root: true }) // -> 'someMutation'
},
someOtherAction (ctx, payload) { ... }
}
}
}
- register global actions in namespaces modules
{
actions: {
someOtherAction ({dispatch}) {
dispatch('someAction')
}
},
modules: {
foo: {
namespaced: true,
actions: {
someAction: {
root: true,
handler (namespacedContext, payload) { ... } // -> 'someAction'
}
}
}
}
}
- binding helpers with namespace
如果要呼叫 nested module 的 getters, action 會比較麻煩
computed: {
...mapState({
a: state => state.some.nested.module.a,
b: state => state.some.nested.module.b
}),
...mapGetters([
'some/nested/module/someGetter', // -> this['some/nested/module/someGetter']
'some/nested/module/someOtherGetter', // -> this['some/nested/module/someOtherGetter']
])
},
methods: {
...mapActions([
'some/nested/module/foo', // -> this['some/nested/module/foo']()
'some/nested/module/bar' // -> this['some/nested/module/bar']()
])
}
可用 module namespace string 作為第一個參數鎚入 helpers
computed: {
...mapState('some/nested/module', {
a: state => state.a,
b: state => state.b
}),
...mapGetters('some/nested/module', [
'someGetter', // -> this.someGetter
'someOtherGetter', // -> this.someOtherGetter
])
},
methods: {
...mapActions('some/nested/module', [
'foo', // -> this.foo()
'bar' // -> this.bar()
])
}
也可以用 createNamespacedHelpers
import { createNamespacedHelpers } from 'vuex'
const { mapState, mapActions } = createNamespacedHelpers('some/nested/module')
export default {
computed: {
// look up in `some/nested/module`
...mapState({
a: state => state.a,
b: state => state.b
})
},
methods: {
// look up in `some/nested/module`
...mapActions([
'foo',
'bar'
])
}
}
- caveat for plugin developers
如果有 plugin 提供 module,並讓使用者加入 vuex store,如果 plugin user 把 module 加入某個 namespaced module,會讓使用者的 module 也被 namespaced
可透過 plugin option 的 namedspace 參數解決此問題
// get namespace value via plugin option
// and returns Vuex plugin function
export function createPlugin (options = {}) {
return function (store) {
// add namespace to plugin module's types
const namespace = options.namespace || ''
store.dispatch(namespace + 'pluginAction')
}
}
Dynamic Module Registration
可在 store 產生後,再透過 store.registerModule
註冊 module
import { createStore } from 'vuex'
const store = createStore({ /* options */ })
// register a module `myModule`
store.registerModule('myModule', {
// ...
})
// register a nested module `nested/myModule`
store.registerModule(['nested', 'myModule'], {
// ...
})
module 的 state 為 store.state.myModule
and store.state.nested.myModule
動態註冊的 module,可用 store.unregisterModule(moduleName)
移除
可用 store.hasModule(moduleName)
檢查是否有被註冊
註冊新的 module 時,可用 preserveState
option: store.registerModule('a', module, { preserveState: true })
保留 state
Module Reuse
有時候需要產生 module 的多個 instance,ex:
- 用一個 module 產生多個 store
- 在一個 store 重複註冊某個 module
如果用 plain object 宣告 state of the module,state object 會以 reference 方式被分享,如果 mutated 時,會造成 cross store/module state pollution
解決方法:use a function for declaring module state
const MyReusableModule = {
state: () => ({
foo: 'bar'
}),
// mutations, actions, getters...
}
References
Vuex