前言
在上篇中主要叙述了 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 实例,以及路由守卫是怎么根据文档中的执行顺序一步步执行的
个人觉得,关于源码分析的文章并不是那么好理解,如果点开文章的你觉得有什么不理解的,希望在评论区留言我会第一时间回答,这会帮助我改善文章质量,非常感谢~