[譯] 從頭爲 Vue.js 3 實現 Vuex

原文:medium.com/@lachlanmil…前端

Vuex icon by Ergenekon Yiğit

Vue 3 的 alpha 版本已經放出有些日子了,可是大多數核心庫都還沒遇上趟 -- 說得就是 Vuex 和 Vue Router 了。讓咱們來使用 Vue 3 新的反應式 API 實現本身的罷。vue

爲了讓事情變得簡單,咱們將只實現頂級的 stateactionsgettersmutations - 暫時沒有 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.$storegithub

因爲 Vue 3 從其組件和模版系統中單獨暴露出了反應式 API,因此咱們就能夠用諸如 reactivecomputed 等函數來構建一個 Vuex store,而且單元測試也甚至徹底無需加載一個組件。這對於咱們足夠好了,由於 Vue Test Utils 還不支持 Vue 3。web

準備開始

咱們將採用 TDD 的方式完成本次開發。須要安裝的只有兩樣:vuejest 。 經過 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 做爲替代。

實現 mutations 和 commit

像上面同樣,仍是先來編寫測試:

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')
})
複製代碼

分發一個 action

dispatch 很相似於 commit - 二者的首個參數都是一個函數調用名的字符串,以及一個 payload 做爲第二個參數。

但與某個 mutation 函數接受 state 做爲首參不一樣,一個 action 的第一個參數是個 context 對象,該對象暴露了 statecommitgettersdispatch

同時,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
      })
    }
  }
}
複製代碼

如今一切正常了。

結合 module 的嵌套 state

爲了徹底兼容真實的 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

actionsmutationsgetters 稍微複雜一點。我會在以後的文章中實現它們。

升級 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 })

    // ..
  }
}
複製代碼

改進

一些特性還沒有實現 -- 好比:

  • 針對 module 的 namespaced actions/mutations/getters
  • plugin 系統
  • 對 mutations/actions 的 subscribe 能力 (主要用於 plugins)

我會在以後的文章中覆蓋它們,但實現起來也不太困難。對於有興趣的讀者也是很好的實踐機會。

總結

  • 經過 Vue 3 的反應式系統爲 Vue 構建反應式插件很簡單
  • 徹底有可能構建一個和 Vue 解耦的反應式系統 — 咱們一次都沒有渲染組件或打開瀏覽器,卻對插件能夠在 web 和 非 web 環境中(如 Weex、NativeScript 或其它什麼 Vue 社區中的風靡之物)都能正常工做很自信


--End--

查看更多前端好文
請搜索 fewelife 關注公衆號

轉載請註明出處

相關文章
相關標籤/搜索