使用 Webpack 构建 Vue 项目时,借助 vue-loader 和 vue-hot-reload-api,我们在开发的时候可以获得很好的组件热加载(Hot Module Replacement)体验。然而 vue-loader 中却没有关于 vuex 的配置(当然这也的确不是它应该插手的地方)。

官方 vue-cli 的 vuex 插件也没有相应支持(在 cli serve 下如果更改 store 或是其依赖的模块,页面会自动刷新,这个行为是 hot reload 而不是 HMR)。

Nuxt.js 框架秉承着 convention over configuration 的思想,在一定的目录和文件结构约定下,通过目录分析和脚手架文件模板,很好地解决了 HMR 的问题,生成 store 入口模块的相关代码在这里

在不使用 Nuxt 的情况下,我们也可以通过在项目中保持一定的模块规范来简单实现 Vuex HMR 的配置。

Vuex 的 API

Vuex 自身是提供了 hotUpdate api 以及 一个 HMR 的代码示例 的。

if (module.hot) {
// accept actions and mutations as hot modules
module.hot.accept(['./mutations', './modules/a'], () => {
// require the updated modules
// have to add .default here due to babel 6 module output
const newMutations = require('./mutations').default
const newModuleA = require('./modules/a').default
// swap in the new modules and mutations
store.hotUpdate({
mutations: newMutations,
modules: {
a: newModuleA
}
})
})
}

这个例子稍显简单,需要手动指定每一个 submodule 的路径。

解决方案

假设项目中 vuex 相关文件的目录结构如下。

src/store
├── index.js
└── modules
├── complex
│   └── index.js
├── sub.js // 一个 vuex 模块定义文件
├── util.js // 一个随意的工具函数文件,不导出 vuex 模块定义,并不推荐这样与 module 并行的结构,但我们的方案不会误判,详情请继续往下看
└── ...

其中省略了上例中的 mutations 文件,将全局根模块的内容都写在 store/index.js 中。 modules 文件夹里存放模块的定义内容。

那我们就可以使用 require.context 来动态得出依赖的模块列表。

首先 sub.jscomplex/index.js 需要服从一些我们预设的规则:

  1. vuex module 定义文件都使用 export default 导出
  2. 如果是 namespaced 模块,需要通过 export const VUEX_NS 或者在 vuex module 定义中添加一个 namespace: string 字段来导出命名空间名。

例如 sub.js 文件内容:

import { greet } from './util'

export const VUEX_NS = 'sub'

export default {
namespaced: true,
actions: {
test_sub_action() {
console.log('sub v1')
greet()
}
}
}

在 store/index.js 中的例子如下:

import Vue from 'vue'
import Vuex from 'vuex'

import SUB, { VUEX_NS as SUB_VUEX_NS } from './modules/sub'
import COMPLEX, { VUEX_NS as COMPLEX_VUEX_NS } from './modules/complex'

Vue.use(Vuex)

const store = new Vuex.Store({
actions: {
test_action() {
console.log('root action v1')
}
},
modules: {
[SUB_VUEX_NS]: SUB,
[COMPLEX_VUEX_NS]: COMPLEX,
}
})

export default store

if (module.hot) {
// submodules hmr
const moduleFiles = require.context('./modules', true, /js$/)
const moduleFileKeys = moduleFiles.keys().map(k => moduleFiles.resolve(k))

module.hot.accept(moduleFileKeys, (deps) => {
console.log('module files update', deps)
const hotUpdatePayload = {
modules: {},
}
deps.forEach((moduleId) => {
const m = __webpack_require__(moduleId)
const moduleDef = m.default
if (moduleDef && (moduleDef.actions || moduleDef.mutations)) {
let namespace = ''
if (moduleDef.namespaced) {
// Guess namespace
namespace = moduleDef.namespace || m['VUEX_NS'] || moduleDef['namespace']
}
if (namespace) {
if (hotUpdatePayload.modules[namespace]) {
console.warn(`Already exists module with namespace ${namespace}`)
}
Object.assign(hotUpdatePayload.modules, { [namespace]: moduleDef })
} else {
Object.assign(hotUpdatePayload, moduleDef)
}
}
})

store.hotUpdate(hotUpdatePayload)
})
}

样例说明

在以上的目录结构下,moduleFileKeys 的结果为:

["./src/store/modules/complex/index.js", "./src/store/modules/sub.js", "./src/store/modules/util.js"]

这其中任一个文件发生变化,都会在 module 更新结束后,进入给 module.hot.accept 函数传入的回调函数 (deps) => {...} 中,执行我们自定义的更新逻辑。

__webpack_require__ 是一个 webpack module 作用域内特有的函数,文档在此。P.S. 这里使用它,而不是 require(moduleId),我们都知道源文件中的 require 语句会被 webpack 分析并在生成目标代码时改写,但若入参不是字符串,不能被静态分析出具体的模块,在生成的 bundle 里会被 webpack 转为 __webpack_require__("./src/store sync recursive")(moduleId),在我的测试中,__webpack_require__("./src/store sync recursive") 这句的结果是一个 webpackEmptyContext,调用它会抛出 MODULE_NOT_FOUND 的异常。

接下来关于 moduleDef 判断的代码建立在之前说的预设规则上,可以根据项目实际修改。

P.S. 目前这个方案没有解决两层及以上深度的 module 情况,实际使用中这个似乎也不常见。