一次 Webpack 下的 Vuex HMR 尝试

- hikerpig
#Engineering#Webpack#Vuex

使用 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 情况,实际使用中这个似乎也不常见。