前言
本文将介绍如何在不使用vue-router提供的router-view的情况下,实现一个渲染路由对应组件的navigator控件,并逐步增加主副舞台区分、页面缓存、页面切换动画、左滑返回支持等功能。
本组件的源码位于我的github: github.com/lqt0223/nav…
本组件的demo: navigator-demo.herokuapp.com/#/view1 (建议在移动设备上打开(在iOS设备上使用Safari等浏览器打开时可能遇到左滑返回冲突的问题),或使用chrome dev tool,在手机模式下打开以支持触摸事件)
需求
笔者所在公司所开发的webapp为单页面应用,原来使用的框架为Backbone。
此app使用了现在流行的上部header,中部content,下部tabbar的布局形式。
这种布局一般需要header, content, tabbar具有以下的渲染逻辑
- tabbar在app运行期间只实例化一次
- header与content为一一对应关系,不同的视图对应不同的标题
- 点击tabbar的按钮后,所呈现的视图为app中的主视图
- 例如下图中tabbar上有五个按钮,那么app中就有5个主视图
- 主视图一般分配给app中最主要的、最先给用户展示的功能的呈现,例如“我的信息”、“商品列表”、“首页推荐”等
- 主视图在app运行期间只应该被实例化一次。例如用户第一次打开首页时,可以通过API调用来渲染首页上的动态内容;第二次打开首页时,则只渲染之间缓存的页面,此页面的created, mounted等生命周期函数都不应被调用
- 在app的主视图之间切换时,不需要动画效果
- 点击content或header中的按钮后,所呈现的视图为app中的副视图
- 副视图是除主视图以外,其他的功能页面所使用的视图
- 副视图一般分配给app中次要的、设计具体数据展示的、或者流程较长的功能的呈现,例如“某一商品的详情介绍”、“注册表单中的某一步”等。
- 涉及到副视图的页面切换,都需要动画效果
- 每次跳转到一个副视图时,根据情况,副视图需要是一个新的实例。
- 从副视图可以左滑返回到上一个视图
- (具体跳转规则请参照下面的小节)
在Backbone时代,一条路由规则仅仅是由路径的匹配模式和对应的处理函数组成的。当url中的hash部分发生变化,变化后的值符合某一条路由规则时,就调用此路由规则所指定的处理函数。在处理函数中,我们需要实现页面内容更新、渲染的全部逻辑。
在Vue时代,从页面的每个小的组成部分,到整个页面本身,都是一个Vue component。Vue中的一条路由规则是由路径的匹配模式和对应的component组成的。当url中的hash部分发生变化,变化后的值符合某一条路由规则时,Vue会将此规则对应的component实例化,并渲染到app中的router-view组件中。我们不需要自己实现页面内容更新、渲染的逻辑。
经过以上的对比我们可以发现,Backbone需要自己实现对应路由的渲染逻辑,因此我们可以自己实现以上的页面缓存、动画过渡等功能。但基于vue-router的router-view,则无法阻止一些框架的默认行为(例如每次路由切换时,对应的component都是新的实例)。
虽然通过定义component属性为空的路由规则,并利用vue-router的beforeEach钩子函数,也可以达到一定的hack目的。但在笔者着手实现此需求时,同事已经将带component属性的路由规则全部写好。为了减少代码的修改,以及通过自定义控件的实现达到一定的复用性,最终笔者还是决定抛开vue-router提供的router-view,写一个自己的路由视图组件。
最简单的router-view
上一小节提到,我们需要在不依赖vue-router官方提供的router-view组件的情况下,实现我们自己的navigator。分析router-view的功能和特点我们可以得出:
- router-view作为一个组件,没有自己的固定模版。这意味着我们只能使用render函数来实现这个组件
- 这个组件的render方法中,需要返回当前路由所对应组件的vnode
- 这个组件的render方法,会在组件的data属性或组件被注入的对象状态发生变化时被调用,调用时状态的值已更新。
经过一段时间的摸索,可知:render函数被调用时,当前路由所对应的组件可以在render函数的作用域中,通过如下属性访问到:this.$route.matched[0].components.default
上面的代码的语义是:当前路由匹配到的第一条路由规则所指定的组件中的默认组件
又知:render函数的第一个参数(一般名为h),是vue内部一个用于创建vnode的函数。它既可以使用h(tag, attributes, children)
的形式,返回任意属性和结构的vnode,也可以使用h(Component)
的形式,返回指定组件的vnode。
因此,我们只需要如此实现render方法,就可以实现一个基本的router-view了:
render(h) {
return h(this.$route.matched[0].components.default)
}复制代码
在render函数以外的组件的作用域中,无法访问到h函数的情况下,可以使用this.$createElement代替
举一反三
上面的最简单的router-view的例子说明了:路由变化时,我们的自定义组件的render方法就会被调用。我们只需要在render方法中返回希望呈现的vnode即可。
如果仅仅是返回对应组件的vnode,离我们需要的页面缓存以及视图栈功能还相差很远。navigator的render方法逻辑如下:
- 在组件内创建一个
this.cache
对象,在路由跳转(即render被调用)时,如果此页面还未被缓存过,则向其中添加vnode的缓存,代码近似于this.cache[routeName] = h(this.$route.matched[0].components.default)
- 在组件内创建一个
this.history
数组,在路由跳转(即render被调用)时,记录每次的当前路由 - 在render函数中,根据
this.history
中的路由历史记录,从this.cache中
依次取出对应的缓存好的vnode,形成一个每个历史页面并排的vnode。只要保证当前路由对应页面的vnode位于这些并排vnode的最后,通过为每个页面设定适当的css样式,即可正确呈现页面。
这里以一个例子说明一下以上的逻辑:
app启动,首先需要呈现#home页的内容,此时:
this.cache = { home: 组件Home.vue的vnode实例 } this.history = ['home'] // render函数所返回的vnode,最终会被渲染成如下DOM结构 <div class="navigator"> <div class="navigator-page"> div> div>复制代码
app启动后,用户点击了注册按钮,需要呈现#register页的内容,此时:
this.cache = { home: 组件Home.vue的vnode实例, register: 组件Register.vue的vnode实例 } this.history = ['home', 'register'] // render函数所返回的vnode,最终会被渲染成如下DOM结构 <div class="navigator"> <div class="navigator-page"> div> <div class="navigator-page"> div> div>复制代码
注意这里在我们呈现所需要的vnode外部,包裹了类名为navigator
和navigator-page
的父node,这是为了向每个页面DOM指定相同的全屏渲染需要的样式,例如position: absolute
等
跳转行为整理
前一小节中提到了在不同的视图之间跳转时,根据跳转发生的起点视图和终点视图的不同,产生的渲染行为也不同。这里整理如下:
原视图 | 新视图 | 新视图是否被访问过 | 行为 |
---|---|---|---|
主视图 | 主视图 | 是/否 | 直接替换app视图区域的内容 |
主视图 | 副视图 | 是/否 | 新视图从右至左进入视图区域,旧视图从右至左退出视图区域 |
副视图 | 主视图 | 是/否 | 将位于当前副视图下方的视图替换为目标主视图,并使新视图从左至右进入视图区域,旧视图从左至右退出视图区域 |
副视图 | 副视图 | 否 | 新视图从右至左进入视图区域,旧视图从右至左退出视图区域 |
副视图 | 副视图 | 是 | 将位于当前副视图下方的视图替换为目标副视图,并使新视图从左至右进入视图区域,旧视图从左至右退出视图区域 |
上面的整理内容比较抽象,下面链接中的demo是一个体现上述逻辑的例子。其中view1和view3为主视图,view2和view4为副视图。
navigator-demo.herokuapp.com/#/view1
通过上面的整理,我们可以将整个app的视图管理抽象成如下的模式(仅展示部分逻辑):
page_stack_draft处理跳转行为
上一小节我们整理了5种不同情况下的跳转行为,这里摘要分析其中的几种,并说明其中的实现难点。具体的全部逻辑大家可以参考navigator的源码。
主视图到主视图
这应该是最简单的一种情况,任何情况下,从主视图到主视图的一次路由跳转,我们只需要“替换”app视图区域中的页面内容即可。实际的代码实现是这样的:
// fromRoute是前一个路由的key,toRoute是当前路由的key
// 从主视图
if (this.isMain(this.cache[this.fromRoute].$route)) {
// 到主视图
if (this.isMain(this.cache[this.toRoute].$route)) {
// 以下4行,如果history中有当前路由的key,则将此记录调换至最后;如果没有则新增一条
if (this.history.indexOf(this.toRoute) > -1) {
this.history.splice(this.history.indexOf(this.toRoute), 1)
}
this.history.push(this.toRoute)
// 在mainToMain方法中做一些vnode本身的修改操作,或者需要在nextTick中执行的DOM操作
this.mainToMain(this.toRoute)
}
}
// 执行至此,this.history中的历史记录已经按我们需要的层叠顺序排列
// 只需要根据历史记录取出缓存的vnode节点,并排返回即可
const children = []
for (let i = 0; i < this.history.length; i++) {
const cached = this.cache[this.history[i]]
const node = this.wrap(cached) // wrap方法为页面的vnode外围增加一个转载于:https://juejin.im/post/5a1cd04b5188252ae93aade4