前言

构建大型 SPA 应用时,代码分割和懒加载是比较常用的优化手段,在 Vue 生态下,使用 vue-router 很容易实现组件的懒加载。

但应用里除了组件,还有庞大的业务逻辑,这部分如何分割和懒加载比较合适呢?

使用 Vuex 管理状态的话,其提供了方法 registerModule 用于动态注册 Module。

因此某个页面独有的业务逻辑和状态管理,在初始化全局 store 的时候可以不用引入,之后在该页面路由组件中再引入和注册 Vuex 模块。

简单的示例

1
2
3
4
5
6
7
const PageA = () => import('./views/PageA.js')

const router = new VueRouter({
routes: [
{ path: '/page-a', component: PageA }
]
})

简单的 Vuex 模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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 因找不到对应函数而出错

1
2
3
4
5
6
7
8
9
10
11
// 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 注入数据:

1
2
// entry-client.js
store.replaceState(window.__INITIAL_STATE__)

此处的 __INITIAL_STATE__ 是 Vue SSR 提供的一个功能,使得浏览器端可以复用服务器端已经预取过的数据。

1
2
3
4
5
6
// 在所有预取钩子(preFetch hook) resolve 后,
// 我们的 store 现在已经填充入渲染应用程序所需的状态。
// 当我们将状态附加到上下文,
// 并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state

此处的 asyncDataVue SSR 文档中的例子类似,与 Nuxt.js 中的同名函数用法略有不同。

prepareVuex 为自定义的组件钩子函数,会先于 asyncData 调用,具体过程之后探讨。

1
2
3
4
5
6
7
8
9
10
11
12
 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: 客户端没有用上服务器端预取的数据

解决方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 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 方法重新注册,因此热更新之后,使用该模块也会报错。

解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
   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 过程,此时销毁模块会导致依赖其的所有组件异常。

vue-router 文档中关于导航解析流程的部分

  1. 导航被触发。
  2. 在失活的组件里调用离开守卫。
  3. 调用全局的 beforeEach 守卫。
  4. 在重用的组件里调用 beforeRouteUpdate 守卫 (2.2+)。
  5. 在路由配置里调用 beforeEnter。
  6. 解析异步路由组件。
  7. 在被激活的组件里调用 beforeRouteEnter。
  8. 调用全局的 beforeResolve 守卫 (2.5+)。
  9. 导航被确认。
  10. 调用全局的 afterEach 钩子。
  11. 触发 DOM 更新。
  12. 用创建好的实例调用 beforeRouteEnter 守卫中传给 next 的回调函数。

因此安全的模块销毁时机需要在 DOM 更新中或后,旧的页面组件实例销毁过程调用时。

相关代码

最后的 PageA.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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()
}
}

两端的入口文件中相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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(_ => _)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 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)
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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')
})