Vuex 模块动态注册的一些实践经验
前言
构建大型 SPA 应用时,代码分割和懒加载是比较常用的优化手段,在 Vue 生态下,使用 vue-router 很容易实现组件的懒加载。
但应用里除了组件,还有庞大的业务逻辑,这部分如何分割和懒加载比较合适呢?
使用 Vuex 管理状态的话,其提供了方法 registerModule
用于动态注册 Module。
因此某个页面独有的业务逻辑和状态管理,在初始化全局 store 的时候可以不用引入,之后在该页面路由组件中再引入和注册 Vuex 模块。
简单的示例
const PageA = () => import('./views/PageA.js')
const router = new VueRouter({
routes: [
{ path: '/page-a', component: PageA }
]
})
简单的 Vuex 模块:
// store/modules/page-a.js
export const VUEX_NS = 'page-a'
export default {
namespaced: true,
state() {
return {
inventory: {
list: []
}
}
},
getters: {
inventoryList(state) {
return state.inventory.list
}
}
}
实践时遭遇了几个问题:
问题 1:服务器/客户端 在尚未注册 Module 时,调用其下的 action/mutation ,Vuex 因找不到对应函数而出错
// views/PageA.js
import PAGE_A_MODULE, { VUEX_NS } from 'store/modules/page-a'
export default {
name: 'PageA',
beforeCreate() {
this.$store.registerModule(VUEX_NS, PAGE_A_MODULE)
return this.$store.dispatch(`${VUEX_NS}/fetchInventory`)
},
}
考虑服务器端预取数据注入给客户端的时候
客户(浏览器)端初始化代码,在初始化 router 之前,给 Vuex 全局 store 注入数据:
// entry-client.js
store.replaceState(window.__INITIAL_STATE__)
此处的 __INITIAL_STATE__
是 Vue SSR 提供的一个功能,使得浏览器端可以复用服务器端已经预取过的数据。
// 在所有预取钩子(preFetch hook) resolve 后,
// 我们的 store 现在已经填充入渲染应用程序所需的状态。
// 当我们将状态附加到上下文,
// 并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
此处的 asyncData
与 Vue SSR 文档中的例子类似,与 Nuxt.js 中的同名函数用法略有不同。
prepareVuex
为自定义的组件钩子函数,会先于 asyncData 调用,具体过程之后探讨。
export default {
name: 'PageA',
- beforeCreate() {
- this.$store.registerModule(VUEX_NS, PAGE_A_MODULE)
- return this.$store.dispatch(`${VUEX_NS}/fetchInventory`)
+ prepareVuex({ store }) {
+ store.registerModule(VUEX_NS, PAGE_A_MODULE)
+ },
+ asyncData({ store }) {
+ return store.dispatch(`${VUEX_NS}/fetchInventory`)
},
}
此时会遇见
问题2: 客户端没有用上服务器端预取的数据
解决方式:
export default {
name: 'PageA',
- prepareVuex({ store }) {
- store.registerModule(VUEX_NS, PAGE_A_MODULE)
+ prepareVuex({ store, isClientInitialRoute }) {
+ store.registerModule(VUEX_NS, PAGE_A_MODULE, { preserveState: isClientInitialRoute })
},
asyncData({ store }) {
return store.dispatch(`${VUEX_NS}/fetchInventory`)
},
+ beforeDestroy() {
+ // 销毁该模块
+ this.$store.unregisterModule(VUEX_NS)
+ }
}
注册 Vuex 模块的时候使用了 preserveState
,若启用此选项,注册 Module 时若 store.state[namespace]
下已存在数据,便不会使用声明 vuex 模块时的初始 state 覆盖已有数据。但需要注意,若 state 中没有 namespace 相应数据却开启了此选项,Vuex 还是会报错。因此此处添加了一个输入参数 isClientInitialRoute
, 只有在客户端初次进入页面(可以使用服务器预取数据)时才开启 preserveState
选项。
问题3: 组件热更新时,Vuex 模块被销毁
开发期间使用 HotModuleReplacementPlugin 和 vue-loader,若改变了 PageA.js 中的代码,会触发热更新。在 vue-hot-reload-api 中,当使用 vue-hot-reload-api 的 reload
方法处理组件实例时,该实例会被销毁而后重新创建。beforeDestroy
中销毁了 Vuex 的 page-a
模块,却没有调用 prepareVuex
方法重新注册,因此热更新之后,使用该模块也会报错。
解决方案:
asyncData({ store }) {
return store.dispatch(`${VUEX_NS}/fetchInventory`)
},
- beforeDestroy() {
- // 销毁该模块
- this.$store.unregisterModule(VUEX_NS)
+ beforeRouteLeave(to, from, next) {
+ this.$once('hook:beforeDestroy', () => {
+ // 销毁该模块
+ this.$store.unregisterModule(VUEX_NS)
+ })
+ next()
}
}
仔细想想,注册模块的时机是与路由相关的(进入页面之前),那么销毁的时机也可以与路由相关。不过并不适合在 beforeRouteLeave
钩子中立刻销毁模块。因为根据以下 vue-router 文档内容,在此钩子被调用完成时,整个页面还是在正常工作的(第2步到第11步中间),仍未进入组件的 destroy 过程,此时销毁模块会导致依赖其的所有组件异常。
- 导航被触发。
- 在失活的组件里调用离开守卫。
- 调用全局的 beforeEach 守卫。
- 在重用的组件里调用 beforeRouteUpdate 守卫 (2.2+)。
- 在路由配置里调用 beforeEnter。
- 解析异步路由组件。
- 在被激活的组件里调用 beforeRouteEnter。
- 调用全局的 beforeResolve 守卫 (2.5+)。
- 导航被确认。
- 调用全局的 afterEach 钩子。
- 触发 DOM 更新。
- 用创建好的实例调用 beforeRouteEnter 守卫中传给 next 的回调函数。
因此安全的模块销毁时机需要在 DOM 更新中或后,旧的页面组件实例销毁过程调用时。
相关代码
最后的 PageA.js:
import PAGE_A_MODULE, { VUEX_NS } from 'store/modules/page-a'
export default {
name: 'PageA',
prepareVuex({ store, isClientInitialRoute }) {
store.registerModule(VUEX_NS, PAGE_A_MODULE, { preserveState: isClientInitialRoute })
},
asyncData({ store }) {
return store.dispatch(`${VUEX_NS}/fetchInventory`)
},
beforeRouteLeave(to, from, next) {
this.$once('hook:beforeDestroy', () => {
// 销毁该模块
this.$store.unregisterModule(VUEX_NS)
})
next()
}
}
两端的入口文件中相关代码如下:
// router-util.ts
import Vue, { VueConstructor } from 'vue'
type VueCtor = VueConstructor<any>
export function getHookFromComponent(compo: any, name: string) {
return compo[name] || (compo.options && compo.options[name])
}
export function callComponentsHookWith(compoList: VueCtor[], hookName: string, context: any) {
return compoList.map((component) => {
const hook = getHookFromComponent(component, hookName)
if (hook) {
return hook(context)
}
}).filter(_ => _)
}
// entry-server.js
export default context => {
return new Promise((resolve, reject) => {
// set router's location
router.push(context.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
try {
// 加上 try/catch 避免此 block 内抛出的错误造成 promise unhandledRejection
callComponentsHookWith(matchedComponents, 'prepareVuex', { store })
const asyncDataResults = callComponentsHookWith(matchedComponents, 'asyncData',
{
store,
route: router.currentRoute,
}
)
Promise.all(asyncDataResults).then(() => {
context.state = store.state
resolve(app)
}).catch(reject)
} catch(err) {
reject(err)
}
}, reject)
})
}
// entry-client.js
router.onReady((initialRoute) => {
const initialMatched = router.getMatchedComponents(initialRoute)
callComponentsHookWith(initialMatched, 'prepareVuex', { store, isClientInitialRoute: true })
router.beforeResolve((to, from, next) => {
const matched = router.getMatchedComponents(to)
callComponentsHookWith(matched, 'prepareVuex', { store })
Promise.all(callComponentsHookWith(activated, 'asyncData', { store, route: to }))
.then(next)
.catch(next)
})
// actually mount to DOM
app.$mount('#app')
})