Vuex icon by Ergenekon Yiğit
Vue 3 的 alpha 版本已经放出有些日子了,但是大多数核心库都还没赶上趟 -- 说得就是 Vuex 和 Vue Router 了。让我们来使用 Vue 3 新的反应式 API 实现自己的罢。
为了让事情变得简单,我们将只实现顶级的 state
、actions
、getters
和 mutations
- 暂时没有 namespaced modules
,尽管我也会留一条如何实现的线索。
本文中的源码和测试可以在 这里 找到。在线的 demo 可以在 这里 看到。
规范
简单起见,我们的实现将不支持整个 API — 只是一个子集。我们将编码以使下面的代码可用:
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
。
由于 Vue 3 从其组件和模版系统中单独暴露出了反应式 API,所以我们就可以用诸如 reactive
和 computed
等函数来构建一个 Vuex store,并且单元测试也甚至完全无需加载一个组件。这对于我们足够好了,因为 Vue Test Utils 还不支持 Vue 3。
准备开始
我们将采用 TDD 的方式完成本次开发。需要安装的只有两样:vue
和 jest
。 通过 yarn add vue@3.0.0-alpha.1 babel-jest @babel/core @babel/preset-env
安装它们。需要做一些基础的配置 - 按其文档配置即可。
反应式状态
第一个测试是关于 state
的:
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
对象,该对象暴露了 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
- 其实我们还什么都没返回呢。
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
。
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 }) // .. } }
改进
一些特性尚未实现 -- 比如:
- 针对 module 的 namespaced actions/mutations/getters
- plugin 系统
- 对 mutations/actions 的
subscribe
能力 (主要用于 plugins)
我会在之后的文章中覆盖它们,但实现起来也不太困难。对于有兴趣的读者也是很好的实践机会。
总结
- 通过 Vue 3 的反应式系统为 Vue 构建反应式插件很简单
- 完全有可能构建一个和 Vue 解耦的反应式系统 — 我们一次都没有渲染组件或打开浏览器,却对插件可以在 web 和 非 web 环境中(如 Weex、NativeScript 或其它什么 Vue 社区中的风靡之物)都能正常工作很自信