阅读 639

[Vue.js进阶]从源码角度剖析vue-router(三)

前言

上篇中主要叙述了 vue-router 中生成 $route 对象的时机,路由懒加载的原理,以及异步路由之前执行的一系列路由守卫

在本篇中会讲述:

  • 异步路由解析成功后执行的一系列路由守卫
  • vue-router 是如何通过路由来实现页面之间的切换
  • 为什么 beforeRouteEnter 守卫需要通过回调的形式获取组件实例

同时本文会按照 vue-router 官网完整的导航解析流程的 7-12 步,逐个解析每一步的背后的原理

图1:

文中的源码截图只保留核心逻辑 完整源码地址

有兴趣的朋友也可以看我学习源码时的详细注释源码地址

vue-router 版本:3.0.2

生成 beforeRouteEnter 守卫

上文说到,当异步路由(组件)全部解析完毕后,会执行 next 方法遍历 queue 数组中的下个元素,但此时 queue 数组中的元素已经全部遍历完毕,所以会直接执行 runQueue 的第三个参数,即成功的回调函数

图2:

紧接着会执行 extractEnterGuards 这个函数,而上文中介绍到 extract 开头的函数会根据传入的路由记录这个参数,从中获取组件配置项中的指定的路由守卫,这里 vue-router 会根据 activated 数组,也就是跳转前后新增的路由记录数组,从中获取 beforeRouteEnter 守卫

和之前的那些路由守卫不同的是,它会额外传入一个 postEnterCbs 参数来存储 beforeRouteEnter 守卫中,通过 next 方法传入的回调参数

图3:

如果在组件中 beforeRouteEnter 守卫里的 next 函数里,传入了一个回调函数,就会往 postEnterCbs 数组中添加这个回调,同时回调会被包裹一层 poll 函数用来指定参数,即组件实例 vm

图4:

通过 instance[key] 从路由记录的 instance 属性获取到组件实例,但是在注册回调时,这个时候组件实例为空对象

图5:

这是为什么呢?我们同时再来思考一个问题,为什么 vue-router 的其他守卫可以直接在内部通过 this 访问组件实例,而 beforeRouteEnter 必须通过在 next 函数中传入回调的形式来获取组件实例?这2个问题我们放到后面来讨论,继续往下走主线的流程

调用 beforeResolve 守卫

之后包含 beforeRouteEnter 守卫的数组会和 beforeResolve 守卫合并,并且再一次的执行 runQueue,即开始第二轮的遍历

遍历逻辑在上文中也详细叙述过,主要就是每次遍历 queue 的一个路由守卫,并且当路由守卫调用 next 方法后才会继续遍历下个守卫,也就是说 beforeRouteEnter 和 beforeResolve 会依次执行,对应图1官网流程的 7,8 两步

确认导航

当第二轮 queue 遍历完毕后,再一次执行 runQueue 方法成功的回调,在 runQueue 成功回调中会又执行到 onComplete 这个函数,它是 confirmTransition 的成功回调,执行确认导航的逻辑

因为 queue 数组是在 confirmTransition 这个方法内被遍历的的,而onComplete 也是在执行 confirmTransition 被传入的

图6:

其中的第二个参数即为 onComplete 函数,这个函数的第一行中会执行 history 实例的 updateRoute 方法

图7:

这个时候 vue-router 会更新 current 属性,也就是说此时的 current 已经不在是跳转前的 $route 对象了,更新成跳转后的 $route 对象,接着会执行 cb 方法

cb 方法定义在 vue-router 类中

图8:

当 vue-router 初始化的时候会执行 history.listen 并传入一个回调,而这个回调最终会成为 history 实例的 cb 方法,当执行这个回调时,就可以实现页面之间的切换

注册页面更新的回调

图9:

接下来我们来分析这个能改变视图的函数, this.apps 我们第一章分析过,是一个保存根 Vue 实例的数组,最终会将根实例的 _route 属性更新为当前的 $route 对象,就是这样短短一行代码就可以实现整个页面的切换,这是为什么呢?

在第一章混入全局钩子那节,我留了一个悬念

图10:

观察图中第 8 行可以发现,vue-router 会调用 Vue 核心库中的 defineReactive 将根实例的 _route 属性变成响应式, 另外还通过 Object.defineProperty 定义了 $route 属性指向 _route,结合 Vue 的响应式原理,也就是说当 $route 被修改后,通过 defineReactive 会通知所有依赖 $route 的 watcher

而只有 render watcher 才有改变视图的功能,所以可以推测出在某个组件的 render 函数中依赖到了 $route,而这个组件就是 vue-router 内置的全局视图组件 router-view

图11 router-view 组件:

router-view 内部会通过 render 函数根据 $route 中的 components 属性也就是组件配置项,生成 vnode 最后交给 Vue 渲染出视图,所以就会依赖到 $route

异步更新视图

回到图7,在确认导航的 updateRoute 方法中,执行 cb 就会触发视图的改变,但是这个行为不会立即被触发,即

视图并不会立即被改变

视图并不会立即被改变

视图并不会立即被改变

重要的事情说三遍,这里就简单提一下 Vue 的视图更新原理

Vue 会维护一个队列,保存所有 watcher,当 cb 执行后为了更新视图,会将 router-view 的 render watcher 推入这个队列,在推入的过程中会进行唯一值的判断,使得同一个 watcher 在队列中只存在一个,并在 nextTick 后再执行所有的 watcher 回调,这个时候才会改变视图

Vue 之所以这么做是防止不必要的多次渲染,例如你在 methods 中写了个 10000 次的循环的方法,每个循环都会改变一次视图,导致队列中有 10000 个 render watcher,最终触发了 10000 次渲染,这就非常的不合理

而优化后只在第一次循环时将 render watcher 推入队列,之后的 9999 次则只是数据的更新不会把相同的 render watcher 推入队列,最终队列中只有 1 个 render watcher

另外之所以数据更新是一般是同步的,而视图是在 nextTick 后异步更新的,原因在于只有这样所有的 watcher 才能获取到最终的数据,在同一个事件循环轮次中,异步任务永远是晚于同步任务的

执行 afterEach 守卫

所以视图的更新就被 Vue 延迟到 nextTick 后执行,先会在 updateRoute 中遍历 afterHooks 执行 afterEach 守卫

监听浏览器的前进后退事件

在执行完 afterEach 后,文档的下一步是触发 DOM 更新也就是视图的更新,但其实 vue-router 还会做一些别的逻辑,例如给 hash 模式下的路由设置监听事件,监听浏览器的前进后退,以及一些滚动事件

updateRoute 方法执行后会执行 transitionTo 方法的成功回调,hash 模式最终会执行 setupListeners 设置监听事件

图12:

当浏览器点击前进后退时,会再次执行 transitionTo 方法,即路由的跳转逻辑,达到视图的跳转

history 模式同样也会监听这2个事件,只是监听的时机不同,它是在实例化时进行监听

图13:

随后会执行 ensureURL 方法,使用 pushState 或者 location.hash 的形式设置 url

执行 beforeRouteEnter 守卫中的回调

前面介绍 beforeRouterEnter 时提到,vue-router 会将 next 方法中的回调推入 postEnterCbs 数组中,当 confirmTransition 的成功回调执行完毕后,会把 postEnterCbs 数组放到 nextTick 后执行

图14:

前面还提到,当在更新视图的时候,Vue 会将视图更新的 render watcher 也放在 nextTick 后执行,也就是说当 postEnterCbs 数组被执行前,会先执行视图更新的逻辑

这就是为什么只有 beforeRouteEnter 守卫获得组件实例时,需要定义一个回调并传入 next 函数中的原因,因为守卫执行的时候是同步的,但是只有在 nextTick 后才能获得组件实例, vue-router 通过回调的形式,将回调的触发时机放到视图更新之后,这样就能保证能够获得组件实例

回调的参数

之前还留下一个问题是,在注册回调时,会给回调传入组件实例,也就是路由记录中 instance[key], 而在注册时它却是一个空对象

答案显而易见,还是因为这个时候组件并没有生成,所以不会有组件实例,但是当组件生成后我们需要将 instance[key] 赋值为当前组件

回到最初安装 vue-router 的时候,vue-router 会全局混入 beforeCreate 和 destroyed 2个钩子,之前我省略了 registerInstance 这个函数,完整的代码是这样的

图15:

而这个 registerInstance 的作用正是当组件被生成时,给路由记录的 instance 属性添加当前视图的组件实例( registerInstance 一定会在 next 的回调执行前执行,因为组件更新顺序在 next 的回调之前,而 beforeCreate 是组件更新时执行的逻辑)

图16:

最终在 router-view 组件中调用 matched.instances[name] = val 进行赋值,这样在执行 next 的回调中就可以获取到组件实例

总结

  • 当异步组件解析成功后,会执行 beforeRouteEnter 守卫
  • 通过 Vue 核心库的 defineReactive 方法,当 $route 被赋值时就会触发 router-view 组件的重新渲染,达到更新视图的功能
  • Vue 会异步更新视图,所以 beforeRouteEnter 中需要使用回调的形式访问到组件实例
  • vue-router 通过监听浏览器的 popState 或者 hashChange 使得点击前进后退也能更新视图

一些感悟

个人认为 vue-router 的源码并不是那么容易理解,多层的回调非常跳跃(个人认为如果 vue-router 使用 async/await 语法会容易理解的多),并且伴随着很多边缘情况的处理,在阅读源码时,建议新建一个工程,找到源码文件,多通过 debugger 的形式执行文中所说的关键函数,观察参数以及调用栈的依赖关系

或许源码的阅读并不能像某些文章一样直接对日常开发有所帮助,它的影响是长远的,在源码中往往用到了很多 JavaScript 技巧,例如闭包,柯里化,回调,异步编程,事件循环,原型继承。而这些都是需要有足够扎实的 JavaScript 基础才能够理解的,同时在阅读的过程中可以进一步提升你的 JavaScript 基础

不仅如此,通过阅读源码能够对这个框架有着更深层的理解,而不是死记硬背框架某些的行为,就比如为什么 beforeRouteEnter 中必须要通过 next 方法的回调形式才能获得 Vue 实例,以及路由守卫是怎么根据文档中的执行顺序一步步执行的

个人觉得,关于源码分析的文章并不是那么好理解,如果点开文章的你觉得有什么不理解的,希望在评论区留言我会第一时间回答,这会帮助我改善文章质量,非常感谢~

参考资料

Vue.js 技术揭秘

关注下面的标签,发现更多相似文章
评论