Vue 3 的 alpha 版本已經放出有些日子了,可是大多數核心庫都還沒遇上趟 -- 說得就是 Vuex 和 Vue Router 了。讓咱們來使用 Vue 3 新的反應式 API 實現本身的罷。vue
爲了讓事情變得簡單,咱們將只實現頂級的 state
、actions
、getters
和 mutations
- 暫時沒有 namespaced modules
,儘管我也會留一條如何實現的線索。react
本文中的源碼和測試能夠在 這裏 找到。在線的 demo 能夠在 這裏 看到。ios
簡單起見,咱們的實現將不支持整個 API — 只是一個子集。咱們將編碼以使下面的代碼可用:git
const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
INCREMENT(state, payload) {
state.count += payload
}
},
actions: {
increment(context, payload) {
context.commit('INCREMENT', payload)
}
},
getters: {
triple(state) {
return state.count * 3
}
}
})
複製代碼
惟一的問題是在本文撰寫之時,尚無辦法將 $store
附加到 Vue.prototype
,因此咱們將用 window.store
代替 this.$store
。github
因爲 Vue 3 從其組件和模版系統中單獨暴露出了反應式 API,因此咱們就能夠用諸如 reactive
和 computed
等函數來構建一個 Vuex store,而且單元測試也甚至徹底無需加載一個組件。這對於咱們足夠好了,由於 Vue Test Utils 還不支持 Vue 3。web
咱們將採用 TDD 的方式完成本次開發。須要安裝的只有兩樣:vue
和 jest
。 經過 yarn add vue@3.0.0-alpha.1 babel-jest @babel/core @babel/preset-env
安裝它們。須要作一些基礎的配置 - 按其文檔配置便可。vuex
第一個測試是關於 state
的:axios
test('reactive state', () => {
const store = new Vuex.Store({
state: {
count: 0
}
})
expect(store.state).toEqual({ count: 0 })
store.state.count++
expect(store.state).toEqual({ count: 1 })
})
複製代碼
毫無懸念地失敗了 — Vuex 爲 undefined。讓咱們定義它:瀏覽器
class Store {
}
const Vuex = {
Store
}
複製代碼
如今咱們獲得的是 Expected: {"count": 0}, Received: undefined
。讓咱們從 vue
中提取 reactive
並讓測試經過吧!
import { reactive } from 'vue'
class Store {
constructor(options) {
this.state = reactive(options.state)
}
}
複製代碼
Vue 的 reactive
函數真是 so easy。咱們在測試中直接修改了 store.state
- 這不太理想,因此來添加一個 mutation 做爲替代。
像上面同樣,仍是先來編寫測試:
test('commits a mutation', () => {
const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
INCREMENT(state, payload) {
state.count += payload
}
}
})
store.commit('INCREMENT', 1)
expect(store.state).toEqual({ count: 1 })
})
複製代碼
測試失敗,理所固然。報錯信息爲 TypeError: store.commit is not a function
。讓咱們來實現 commit
方法,同時也要把 options.mutations
賦值給 this.mutations
,這樣才能在 commit
中訪問到:
class Store {
constructor(options) {
this.state = reactive(options.state)
this.mutations = options.mutations
}
commit(handle, payload) {
const mutation = this.mutations[handle]
if (!mutation) {
throw Error(`[Hackex]: ${handle} is not defined`)
}
mutation(this.state, payload)
}
}
複製代碼
由於 mutations
只是一個將函數映射爲其屬性的對象,因此咱們用 handle
參數就能取出對應的函數,並傳入 this.state
調用它。咱們也能爲 mutation 未被定義的狀況編寫一個測試:
test('throws an error for a missing mutation', () => {
const store = new Vuex.Store({ state: {}, mutations: {} })
expect(() => store.commit('INCREMENT', 1))
.toThrow('[Hackex]: INCREMENT is not defined')
})
複製代碼
dispatch
很相似於 commit
- 二者的首個參數都是一個函數調用名的字符串,以及一個 payload 做爲第二個參數。
但與某個 mutation 函數接受 state
做爲首參不一樣,一個 action 的第一個參數是個 context
對象,該對象暴露了 state
、commit
、getters
和 dispatch
。
同時,dispatch
將老是返回一個 Promise
- 因此 dispatch(...).then
應該是合法的。這意味着若是用戶的 action 沒返回一個 Promise,或調用了某些相似 axios.get
的東西,咱們也須要爲用戶返回一個 Promise。
咱們能夠編寫以下測試。你可能會注意到測試重複了一大段剛纔 mutation 用例中的東西 — 咱們稍後會用一些工廠函數來清理它。
test('dispatches an action', async () => {
const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
INCREMENT(state, payload) {
state.count += payload
}
},
actions: {
increment(context, payload) {
context.commit('INCREMENT', payload)
}
}
})
store.dispatch('increment', 1).then(() => {
expect(store.state).toEqual({ count: 1 })
})
})
複製代碼
運行這段將獲得報錯 TypeError: store.dispatch is not a function
。從前面的經驗中咱們得知須要在構建函數中也給 actions 賦值,因此讓咱們完成這兩件事,並以早先調用 mutation
的相同方式調用 action
:
class Store constructor(options) {
// ...
this.actions = options.actions
}
// ...
dispatch(handle, payload) {
const action = this.actions[handle]
const actionCall = action(this, payload)
}
}
複製代碼
如今運行後獲得了 TypeError: Cannot read property 'then' of undefined
報錯。這固然是由於,沒有返回一個 Promise
- 其實咱們還什麼都沒返回呢。咱們能夠像下面這樣檢查返回值是否爲一個 Promise
,若是不是的話,那就硬返回一個:
class Store {
// ...
dispatch(handle, payload) {
const action = this.actions[handle]
const actionCall = action(this, payload)
if (!actionCall || !typeof actionCall.then) {
return Promise.resolve(actionCall)
}
return actionCall
}
複製代碼
這和 Vuex 的真實實現並沒有太大區別,在 這裏 查看其源碼。如今測試經過了!
computed
實現 getters實現 getters
會更有意思一點。咱們一樣會使用 Vue 暴露出的新 computed
方法。開始編寫測試:
test('getters', () => {
const store = new Vuex.Store({
state: {
count: 5
},
mutations: {},
actions: {},
getters: {
triple(state) {
return state.count * 3
}
}
})
expect(store.getters['triple']).toBe(15)
store.state.count += 5
expect(store.getters['triple']).toBe(30)
})
複製代碼
照例,咱們獲得了 TypeError: Cannot read property 'triple' of undefined
報錯。不過此次咱們不能只在 constructor
中寫上 this.getters = getters
就草草了事啦 - 須要遍歷 options.getters 並確保它們都用上搭配了響應式狀態值的 computed
。一種簡單卻不正確的方式會是這這樣的:
class Store {
constructor(options) {
// ...
if (!options.getters) {
return
}
for (const [handle, fn] of Object.entries(options.getters)) {
this.getters[handle] = computed(() => fn(this.state)).value
}
}
}
複製代碼
Object.entries(options.getters)
返回函數名 handle
(本例中爲 triple
) 和回調函數 fn
(getter 函數自己) 。當咱們運行測試時,第一條斷言 expect(store.getters['triple']).toBe(15)
經過了,由於返回了 .value
;但同時也丟失了反應性 -- store.getters['triple']
被永遠賦值爲一個數字了。咱們本想返回調用 computed
後的值的。能夠經過 Object.defineProperty
實現這一點,在對象上定義一個動態的 get
方法。這也是真實的 Vuex 所作的 - 參考 這裏 。
更新後可工做的實現以下:
class Store {
constructor(options) {
// ...
for (const [handle, fn] of Object.entries(options.getters)) {
Object.defineProperty(this.getters, handle, {
get: () => computed(() => fn(this.state)).value,
enumerable: true
})
}
}
}
複製代碼
如今一切正常了。
爲了徹底兼容真實的 Vuex,須要實現 module。鑑於文章的長度,我不會在這裏完整的實現它。基本上,你只須要爲每一個 module 遞歸地實現以上的過程並適當建立命名空間便可。就來看看 module 中嵌套的 state
如何實現這點吧。測試看起來是這樣的:
test('nested state', () => {
const store = new Vuex.Store({
state: {
count: 5
},
mutations: {},
actions: {},
modules: {
levelOne: {
state: {},
modules: {
levelTwo: {
state: { name: 'level two' }
}
}
}
}
})
expect(store.state.levelOne.levelTwo.name).toBe('level two')
})
複製代碼
咱們能夠用一些巧妙的遞歸來實現:
const registerState = (modules, state = {}) => {
for (const [name, module] of Object.entries(modules)) {
state[name] = module.state
if (module.modules) {
registerState(module.modules, state[name])
}
}
return state
}
複製代碼
Object.entries
再次發揮了做用 - 咱們取得了模塊名稱以及內容。若是該模塊又包含 modules
,則再次調用 registerState
,並傳入上一層模塊的 state。這讓咱們能夠任意嵌套多層。到達底層模塊時,就直接返回 state
。
actions
、mutations
和 getters
稍微複雜一點。我會在以後的文章中實現它們。
升級 constructor
以使用 registerState
方法,全部測試再次經過了。這一步在 actions/mutations/getters 以前完成,這樣它們就能訪問傳遞到 reactive
中的整個狀態了。
class Store {
constructor(options) {
let nestedState = {}
if (options.modules) {
nestedState = registerState(options.modules, options.state)
}
this.state = reactive({ ...options.state, ...nestedState })
// ..
}
}
複製代碼
一些特性還沒有實現 -- 好比:
subscribe
能力 (主要用於 plugins)我會在以後的文章中覆蓋它們,但實現起來也不太困難。對於有興趣的讀者也是很好的實踐機會。
查看更多前端好文
請搜索 fewelife 關注公衆號
轉載請註明出處