前言
构建大型 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') }) 复制代码