前言

构建大型 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

此处的 asyncDataVue 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 过程,此时销毁模块会导致依赖其的所有组件异常。

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:

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')
})