路由懒加载所引发的问题

1,508 阅读6分钟

背景

在开发移动端项目时候,项目中用到了vant-ui这个组件库,在使用van-popup(弹出层组件)时,进行页面切换时发生了一些奇怪的现象。如下: 图片 预期:点击跳转按钮时,页面关闭并跳转到about页面,从about页面切换到home页面,弹窗不会被打开

实际: 可以从中看出,第一次从home 切换到 about 页面,再从 about 切换到 home 页面时,弹窗没有被打开。但是从第二次开始,从home切换到about,再从about切换到home页面时,弹窗会被一直打开。

项目所使用框架、组件库vue@2.6.14 vue-router@^3.1.5 vant@^2.9.4

具体代码

以下代码是从项目中精简后的demo

App.vue 入口文件

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link>
      <router-link to="/about">About</router-link>
    </div>
    <keep-alive>
      <router-view/>
    </keep-alive>
  </div>
</template>

router.js 路由配置文件

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
// import About from '../views/About.vue'
Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // component: About
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  }
]

const router = new VueRouter({
  routes
})

export default router

Home.vue home页面

<template>
  <div class="home">
    <button is-link @click="show = !show">toggleShow</button>
    <van-popup position="bottom" v-model="show" :style="{ height: '50vh'}" :close-on-popstate="true">
      <button @click="toAbout">跳转about页面</button>
    </van-popup>
  </div>
</template>

<script>

export default {
  name: 'Home',
  data () {
    return {
      show: false
    }
  },
  watch: {
    show: (newVal) => {
      console.log('parent value', newVal)
    }
  },
  methods: {
    toAbout () {
      this.show = false
      // 引发问题的代码
      this.$router.push({
        name: 'About'
      })
    }
  },
  deactivated () {
    console.log('parent deactivated', this.show)
  },
  activated () {
    console.log('activated home')
  }
}
</script>

About.vue about页面

<template>
  <div class="about">
    <h1>This is an about page111111</h1>
  </div>
</template>

代码很简单,过一下即可。

定位问题

  1. 我尝试将代码中的keep-alive缓存组件删除,App.vue
<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </div>
    <!-- <keep-alive> -->
      <router-view/>
    <!-- </keep-alive> -->
  </div>
</template>

发现问题得到了解决,但是因为业务的原因,弹窗所在的页面需要设置缓存,所以这种方案pass掉。

  1. 因为跟keep-alive 有关,所以我判定van-popup组件内部是在相关的钩子函数activateddeactivated中进行了一些逻辑执行。 接着我便找到对应的组件代码,跟弹窗相关的展示消失的逻辑在这里
// popup/index.js
// 以下是截取的是跟问题相关的代码
watch: {
  value(val) {
    console.log('child value', val)
    const type = val ? 'open' : 'close';
    this.inited = this.inited || this.value;
    this[type]();

    if (!options.skipToggleEvent) {
      this.$emit(type);
    }
  },

  overlay: 'renderOverlay',
},
activated() {
  if (this.shouldReopen) {
    this.$emit('input', true); // 打开弹窗
    this.shouldReopen = false;
  }
},
deactivated() {
  console.log('child deactivated')
  if (this.value) {
    this.close(); // 关闭弹窗
    this.shouldReopen = true;
  }
}

可以从代码中看到,van-popup组件中的确定义了跟keep-alive相关的钩子函数

代码中的this.value 就是在使用组件时 v-model 绑定的值,用于控制弹窗的显示和隐藏

在切换页面的时候,我在Home.vuevan-popup组件的watch监听器deactivated 钩子中进行了打印:(执行顺序如下) 控制台输出 从图中就可以看出问题所在,如果当popup组件执行deactivated 钩子时,this.value为true时,会执行this.close关闭弹窗,但同时会将this.shouldRepoen赋值为true

if (this.value) {
  this.close(); // 关闭弹窗
  this.shouldReopen = true;
}

当重新切换到Home页面时,执行activated钩子,导致重新打开了弹窗

if (this.shouldReopen) {
  this.$emit('input', true); // 打开弹窗
  this.shouldReopen = false;
}

这个现象的原因,我们知道是由于watchdeactivated代码的执行顺序出现了前后不一致导致的,到此,我们的问题就转化为为什么切换页面的时候前后两次的执行顺序是不一样的?

  1. 因为两个文件的代码本质上没什么区别,此时我猜测是由于异步路由所引起的问题,接着我将异步路由改成同步的
// router.js
const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
+   component: About, // 同步
-   component: () => import(/* webpackChunkName: "about" */ '../views/About.vue') // 异步
  }
]

发现控制台输出的内容都是第二次从Home切换到About的顺序(deactivated执行的顺序是优先于watch),也就是说在使用同步路由的情况,每次都会出现从About 切换回 Home时,弹窗都会自动打开。

4 在回顾下切换页面的代码,理解整个过程

this.show = false
// 引发问题的代码
this.$router.push({
  name: 'About'
})

先对show变量值进行改变,然后执行路由的切换。

熟悉vue的同学都知道,改变show变量时候,对应的watch监听器的回调是不会马上执行的,而是会推入一个队列中,在Promise.then的回调中执行(微队列)。

当代码执行到$router.push时,其实会调用histroy.transitionTo的方法,最后的结果会改变当前路由对象,从而导致router-view的重新渲染(重新执行render方法),渲染About组件,接着就会走到patch的流程,又因为oldVnode(home的vnode)newVnode(about的vnode)不相同并且都是保留节点,最后会移除掉oldVnode

// src/core/vdom/patch.js
removeVnodes([oldVnode], 0, 0)

移除掉vnode的同时会调用组件vnodedestroy钩子,又因为是被keep-alive 组件所包裹

destroy (vnode: MountedComponentVNode) {
    const { componentInstance } = vnode
    if (!componentInstance._isDestroyed) {
      if (!vnode.data.keepAlive) {
        componentInstance.$destroy()
      } else {
      // 进入这里的逻辑
        deactivateChildComponent(componentInstance, true /* direct */)
      }
    }
  }

最后递归执行deactivateChildComponent函数

function deactivateChildComponent (vm, direct) {
  if (direct) {
    vm._directInactive = true;
    if (isInInactiveTree(vm)) {
      return
    }
  }
  if (!vm._inactive) {
    vm._inactive = true;
    for (var i = 0; i < vm.$children.length; i++) {
      // 深度递归
      deactivateChildComponent(vm.$children[i]);
    }
    callHook(vm, 'deactivated');
  }
}

以上router-view渲染的过程是一个同步过程,所以当渲染过程执行完了才会去微队列执行任务。这就为什么deactivated会早于watch 执行的原因。

所以我们只要保证deactivated的执行慢于watch就可以,很容易就得出这样的代码

this.show = false
1. this.$nextTick(() => .../// 切换路由) 
2. setTimeout(() => .../// 切换路由)

实际上这也解决了我的问题,把路由切换会异步路由也能够符合预期地执行。但是这也没解释为什么使用异步路由第一次切换的执行顺序,会跟加了延迟器一样?

5.继续对异步路由进行分析

这次我分析的重点就到了vue-router的源码,在切换路由的时候会执行相关的方法,大体如下: transitionTo --> confirmTransition --> 改变当前路由对象

其中处理异步路由的代码在confirmTransition方法中执行

// src/util/resolve-components.js
function resolveAsyncComponents (matched: Array<RouteRecord>): Function {
  return (to, from, next) => {
    let hasAsync = false
    let pending = 0
    let error = null

    flatMapComponents(matched, (def, _, match, key) => {

      if (typeof def === 'function' && def.cid === undefined) {
        // 异步组件第一次加载才会进入判断,后续不会进入
        hasAsync = true
        pending++

        const resolve = once(resolvedDef => {
          // 组件加载完的逻辑
          if (isESModule(resolvedDef)) {
            resolvedDef = resolvedDef.default
          }
          def.resolved = typeof resolvedDef === 'function'
            ? resolvedDef
            : _Vue.extend(resolvedDef)
          // 加载完一次之后,设置component的值为组件对象
          match.components[key] = resolvedDef
          next()
        })

        let res
        try {
          // 加载组件资源
          res = def(resolve, reject)
        } catch (e) {
          reject(e)
        }
        if (res) {
          if (typeof res.then === 'function') {
            // 返回一个promise对象
            res.then(resolve, reject)
          } else {
            ...
          }
        }
      }
    })

    if (!hasAsync) next()
  }
}

上面的代码其中有几个变量的含义需要知道

  • def: 这里指的其实就是router.js中,异步component对应的值() => import(/* webpackChunkName: "about" */ '../views/About.vue')
  • next: 其实就是执行下一个任务,对于我们的例子而言,这里可以简单理解为改变当前的路由对象(触发router-view的重新渲染)
  • resolve: 这里就是异步组件代码加载完之后执行的函数,其中match.components[key] = resolvedDef这行代码的含义可以简单理解为把路由对应的component设置为组件对象,这样就不会重新再走异步路由加载的逻辑了
// resolve
- component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
+ component: About

看到这里其实就比较清楚异步路由切换的整个流程了,执行def会放回一个promise,resolve会在promise.then这个微任务队列中执行,resolve执行的时候会改变当前的路由对象,从而触发router-view的重新渲染,这里就是移除Home组件渲染About组件,所以异步路由切换的代码其实可以写成这样

this.show = false
Promise.then(... watch的回调函数)
// $nextTick原理其实就是把函数执行放在Promise.then回调中
this.$router.push({ name: 'about' })
Promise.then(... 改变路由对象,重新渲染router-view)

所以路由渲染会晚于watch的执行,一开始的现象也就解释的通了。

def函数的实现

到这里,可能会有同学对def这个函数感到很疑惑,不明白为什么就返回一个promise,我们在def函数执行的地方打断点,我们继续往下调试。 为了方便大家理解,下面贴的代码都是精简过后的代码,不影响主流程分析

这是我打包后的js文件的代码,用于对比分析 def函数执行进入到fn.e函数中,其实就是webpack打包后的文件中了,

fn.e = function(chunkId) {
  // 这里的chunkId就是about文件的名字
  return __webpack_require__.e(chunkId).then(finishChunkLoading, function(err) {
 				finishChunkLoading();
 				throw err;
 			});
}
  1. fn.e函数中又调用了__webpack_require__.e函数,具体实现如下
__webpack_require__.e = function requireEnsure(chunkId) {
  var promises = [];
  // installedChunks 加载chunk的列表
  var installedChunkData = installedChunks[chunkId];
  // 如果 installedChunkData等于0,代表加载完成
  if(installedChunkData !== 0) { 
    if(installedChunkData) { 
      // 如果有值,代表chunk正在加载中
      promises.push(installedChunkData[2]);
    } else {
      var promise = new Promise(function(resolve, reject) {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
      });
      promises.push(installedChunkData[2] = promise);
      // 最后installedChunkData会变成[resolve, reject, promise]
      // 通过jsonp请求去加载chunk文件
      var script = document.createElement('script');
      var onScriptComplete;
      // 拿到资源文件的地址
      script.src = jsonpScriptSrc(chunkId);
      onScriptComplete = function (event) {
        // ..
      };
      script.onerror = script.onload = onScriptComplete;
      document.head.appendChild(script);
    }
  }
  return Promise.all(promises);
}

整个函数的逻辑比较清晰,最后就是通过jsonp请求去加载对应的资源文件,在我们这里就是about.js文件,最后返回一个promise对象,细心的同学已经发现了函数里边没有对chunkId对应的promise进行resolve操作,那这个resolve操作到底在哪里被执行,我们接下去看。

  1. 当我们jsonp加载完about.js之后,就会执行about.js中的代码
((typeof self !== 'undefined' ? self : this)["webpackJsonp"] = (typeof self !== 'undefined' ? self : this)["webpackJsonp"] || []).push([['about'], { ... }])

而这里执行xxx.push代码后,其实会执行jsonpArray.push函数,定义如下:

var jsonpArray = (typeof self !== 'undefined' ? self : this)["webpackJsonp"] = (typeof self !== 'undefined' ? self : this)["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;

所以最后会调用webpackJsonpCallback函数,定义如下

function webpackJsonpCallback(data) {
  var chunkIds = data[0]; // about
  var resolves = [];
  for(;i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    // installedChunks[chunkId] = [resolve, reject, promise]
    if(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
      resolves.push(installedChunks[chunkId][0]);
      // 相当于执行resolves.push(resolve)
    }
    installedChunks[chunkId] = 0
  }
  while(resolves.length) {
    // 拿到所有promise的resolve列表,依次执行resolve()函数,让chunkd对应的promise状态变为fulfilled
    resolves.shift()();
  }
}

从代码中可以看到,会把每个chunkId对应的promise的resolve函数收集到resolves数组中,在最后依次取出并执行,当我们执行了resolve后,在我们例子中就是异步组件加载完成了,就会执行到vue-router中的代码逻辑

try {
  // 加载组件资源
  res = def(resolve, reject)
} catch (e) {
  reject(e)
}
if (res) {
  if (typeof res.then === 'function') {
    // 执行then方法中的回调函数,执行resolve方法
    res.then(resolve, reject)
  } else {
    ...
  }
}
const resolve = once(resolvedDef => {
  // 组件加载完的逻辑
  if (isESModule(resolvedDef)) {
    resolvedDef = resolvedDef.default
  }
  def.resolved = typeof resolvedDef === 'function'
    ? resolvedDef
    : _Vue.extend(resolvedDef)
  // 加载完一次之后,设置component的值为组件对象
  match.components[key] = resolvedDef
  next()
})

至此,整个路由懒加载的逻辑我们也分析完了,整个组件渲染跟路由切换的流程也比较清晰了。

如果文中有不对的地方,希望大家能够指出,谢谢!