[译] 从头为 Vue.js 3 实现 Vuex
source link: http://mp.weixin.qq.com/s?__biz=MzI0MDYzOTEyOA%3D%3D&%3Bmid=2247484352&%3Bidx=1&%3Bsn=2004fefa92ea13332bc906412f41cf60
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
原文:https://medium.com/@lachlanmiller_52885/vue-3s-alpha-has-been-out-for-a-while-now-but-no-vue-3-vuex-yet-c73b26389978
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 [email protected] 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
- 其实我们还什么都没返回呢。我们可以像下面这样检查返回值是否为一个 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 社区中的风靡之物)都能正常工作很自信
--End--
查看更多前端好文
请搜索 fewelife 关注公众号
转载请注明出处
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK