18

[译] 从头为 Vue.js 3 实现 Vuex

 4 years ago
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

y6byIr7.png!web

Vue 3 的 alpha 版本已经放出有些日子了,但是大多数核心库都还没赶上趟 -- 说得就是 Vuex 和 Vue Router 了。让我们来使用 Vue 3 新的反应式 API 实现自己的罢。

为了让事情变得简单,我们将只实现顶级的 stateactionsgetters    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   对象,该对象暴露了   statecommitgetters    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

actionsmutations    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--

U7zyuuM.jpg!web

查看更多前端好文

请搜索 fewelife 关注公众号

转载请注明出处


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK