Vue实现一个页面缓存、左滑返回的navigator

2017-11-29 15:56:36来源:https://juejin.im/post/5a1cd04b5188252ae93aade4作者:稀土掘金人点击

分享
第七城市th7cn

本文将介绍如何在不使用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的布局形式。





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">
<!-- home页的内容 -->
</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">
<!-- home页的内容 -->
</div>
<div class="navigator-page">
<!-- register页的内容 -->
</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外围增加一个<div class="navigator-page">的父节点,方便后续的样式控制
children.push(node)
}
const composedVNode = h('div', {
class: 'navigator'
}, children)
return composedVNode
主视图到副视图

这种情况下,由于导航到副视图时,副视图总是一个新的实例,所以对于 this.history ,我们只需要增加一条新的历史记录即可。


从主视图到副视图需要过渡效果。为了提高组件的可定制性,这里我们通过onBeforeEnter, onBeforeLeave, onEnter, onLeave这几个props将过渡动画的实现接口提供给组件的使用者。这几个接口的使用和vue中的transition JavaScript hooks使用非常相似,可以参照 vuejs.org/v2/guide/tr…


// onBeforeEnter回调为即将进入的元素在进入前的状态
// el为即将进入的元素,done为动画执行完毕后需要执行的回调
onBeforeEnter(el, done) {
const h = () => {
done()
el.removeEventListener('transitionend', h)
}
el.addEventListener('transitionend', h)
el.style.transform = 'translateX(100%)'
el.style.transition = 'all 0.3s'
},
// onEnter回调为即将进入的元素在进入后的状态
// el为进入的元素,done为动画执行完毕后需要执行的回调
onEnter(el, done) {
const h = () => {
done()
el.removeEventListener('transitionend', h)
}
el.addEventListener('transitionend', h)
el.style.transform = 'translateX(0%)'
el.style.transition = 'all 0.3s'
},

这几个接口在组件中的实现方法如下:


// 由于需要将生成的DOM暴露出去,这里的查找元素的方法需要在nextTick中执行,否则无法找到节点
setTimeout(() => {
// 我们在wrap方法中已经实现了为页面vnode包裹一个我们需要的父节点
// wrap也可以为页面vnode的父节点添加类似于id: 'navigator-page-path-name'这样的属性
// 方便了我们在这里直接获取对应的DOM
const leaveEl = document.querySelector('#' + this.getNavigatorPageId(fromRoute))
const enterEl = document.querySelector('#' + this.getNavigatorPageId(toRoute))
// 先调用onBefore系列方法
this.onBeforeLeave(leaveEl, this.transitionEndCallback)
// 稍作间隔后,调用on系列方法
setTimeout(() => {
this.onLeave(leaveEl, this.transitionEndCallback)
}, 50);
this.onBeforeEnter(enterEl, this.transitionEndCallback)
setTimeout(() => {
this.onEnter(enterEl, this.transitionEndCallback)
}, 50);
}, 0)

关于这里的this.transitionEndCallback是什么,请见下一小节。


副视图到主视图

这种情况与上面的两种情况相比,多了一个“清理”的步骤。


所谓“清理”,是因为从副视图到主视图路由结束后,已经退出的副视图需要被完全销毁。因此,在过渡动画播放完毕时,我们需要从以下几个方面进行“清理”:


this.history 中副视图的条目
this.cache 中副视图的vnode缓存
组件中已经被渲染的副视图的DOM

其中,在实现最后的DOM清理的时候,我并没有直接使用DOM API,而是选择了比较vue的方式:再调用一次render方法,返回清理后的vnode来实现。


上一小节中提到的 this.transitionEndCallback 方法会在我们需要DOM清理的时候被调用,它的实现很简单,如下:


transitionEndCallback() {
this.clear = true
}

仅仅是修改了组件的 this.$data.clear ,便会再次触发render方法。我们便可以针对 clear=true 的情况实现DOM清理的逻辑:


// this.clear是预先在this.data中设定的一个响应的属性
if (this.clear) {
this.clear = false
// 清理this.history的内容,并相应地清理this.cache的内容
const toClear = this.history.splice(this.history.indexOf(this.toRoute) + 1)
for (let i = 0; i < toClear.length; i++) {
delete this.cache[toClear[i]]
}
// 组合出最后的vnode树
const children = []
for (let i = 0; i < this.history.length; i++) {
const cached = this.cache[this.history[i]]
const node = this.wrap(cached)
children.push(node)
}
const composedVNode = h('div', {
class: 'navigator',
on: {
touchmove: this.handleTouchMove,
touchstart: this.handleTouchStart,
touchend: this.handleTouchEnd
}
}, children)
return composedVNode
}
再谈render方法被调用的时机

根据前文,某个vue组件的render方法被调用的时机有以下几种:


当组建本身渲染所依赖的数据源被修改时,render会被调用。例如 this.$data 中被声明的属性被修改时
vm.$route 被修改(也就是使用了vue-router插件,路由变化)时

后来,笔者在开发过程中发现,由于我们的项目已经导入了vuex,当 vm.$store 中的任意一个state发生变化时,也会触发render方法。这时我们并不需要渲染新的内容,因此可以通过下面的代码忽略:


// 因为render方法是由其他全局状态的改变引起的,这时路由不会变化
if (this.toRoute === this.fromRoute) {
// vue组件的旧vnode保存在_vnode这个属性上,返回它即可
return this._vnode
}

我们也可以利用 this._vnode 作错误处理,如果app不小心跳转到了一个没有路由规则的路由地址上,则返回 this._vnode ,让页面保持原状即可。


左滑返回的实现

加入这个功能,意味着我们需要在某个容器元素上监听touchstart, touchmove, touchend事件。


由前文可知,假设app启动时加载主视图home,之后用户点击注册按钮,app加载副视图register。这时我们的组件内部的vnode结构如下


<div class="navigator"> <!-- 应该在这个节点上绑定触摸事件 -->
<div class="navigator-page">
<!-- home页的内容 -->
</div>
<div class="navigator-page">
<!-- register页的内容 -->
</div>
</div>

应该在最外层的组件根节点上绑定此触摸事件,因为这里在每次渲染时都是固定的。


在使用h方法创建vnode时,用于绑定事件的v-on指令变成了on属性,例如:


render(h) {
return h('div', {
class: 'navigator',
on: {
touchmove: this.handleTouchMove,
touchstart: this.handleTouchStart,
touchend: this.handleTouchEnd
}
}, children)
}

使用h方法创建vnode时,如果需要指定节点的各种属性,可以参考vue中的VNode类定义。见 github.com/vuejs/vue/b…


然后,我们再相应地实现handleTouchMove, handleTouchStart, handleTouchEnd的逻辑即可。


这里,为了提高组件的可定制性,我们使用名为onTouch的prop,让组件使用者自定义触摸并拖动时页面产生的变动。下面是一个使用的例子:


// enterEl表示即将进入的元素(对于左滑返回来说即是位于下方的页面)
// leaveEl表示即将离开的元素(对于左滑返回来说即是位于上方的页面)
onTouch(enterEl, leaveEl, x, y) {
const screenWidth = window.document.documentElement.clientWidth
const touchXRatio = x / screenWidth
// 由于在之前的onBeforeLeave等回调中,此元素可能被设定了transition样式的值,这里改回none
enterEl.style.transition = 'none'
leaveEl.style.transition = 'none'
enterEl.style.transform = `translate(${touchXRatio * 100}%)`
leaveEl.style.transform = `translate(${touchXRatio * 50 - 50}%)`
}

这个接口的实现也很简单:


handleTouchMove(e) {
if (this.touching) {
// 由于touchmove事件被触发时,组件的DOM已经被渲染,因此可以用this.$el直接访问需要的DOM
const childrenEl = this.$el.children
const enterEl = Array.prototype.slice.call(childrenEl, -1)[0]
const leaveEl = Array.prototype.slice.call(childrenEl, -2, -1)[0]
this.onTouch(enterEl, leaveEl, e.touches[0].pageX, e.touches[0].pageY)
}
}

略为复杂的是handleTouchEnd的实现,当touchend事件发生时,如果触摸的水平位置大于阈值,则我们需要继续播放返回的转场动画效果,并调用 this.$router.go(-1) 完成后退。但麻烦的地方在于,$router的变化会导致render方法再次被调用。


这里,我们使用一个控制变量 backInvokedByGesture 来表示此次render是左滑操作完成,路由变化后引起的。此时,我们需要手动清理掉 this.history 中的最后一个元素(也就是左滑返回时离开的视图所对应的历史记录),并清理相应的 this.cache 缓存,再返回最终的vnode树即可。代码如下:


handleTouchEnd(e) {
if (this.touching) {
const childrenEl = this.$el.children
const el = Array.prototype.slice.call(childrenEl, -1)[0]
const leaveEl = Array.prototype.slice.call(childrenEl, -2, -1)[0]
const x = e.changedTouches[0].pageX
const y = e.changedTouches[0].pageY
// 当触摸结束时的水平位置大于阈值
if (x / window.document.documentElement.clientWidth > this.swipeBackReleaseThreshold) {
// 手动控制路由回退
this.onBeforeLeave(leaveEl, () => {
this.backInvokedByGesture = true
this.transitionEndCallback()
this.$router.go(-1)
})
this.onBeforeEnter(el, () => {})
} else {
// 停留在原页面
this.onLeave(leaveEl, () => {})
this.onEnter(el, () => {})
}
}
this.touching = false
}
// render方法中针对backInvokedByGesture的逻辑
if (this.backInvokedByGesture) {
this.backInvokedByGesture = false
// 删除this.history中的最后一条,并清除this.cache中相应的缓存
const toDelete = this.history.pop()
delete this.cache[toDelete]
// 组合出最后的vnode树
const children = []
for (let i = 0; i < this.history.length; i++) {
const cached = this.cache[this.history[i]]
const node = this.wrap(cached)
children.push(node)
}
const composedVNode = h('div', {
class: 'navigator',
on: {
touchmove: this.handleTouchMove,
touchstart: this.handleTouchStart,
touchend: this.handleTouchEnd
}
}, children)
return composedVNode
}
大功告成

完成后的navigator组件具有丰富的接口:


可使用isMain判定哪些页面需要放在主视图,哪些页面需要放在副视图
可使用onBeforeEnter, onEnter, onBeforeLeave, onLeave等一系列transition hook,实现转场效果
可使用onTouch方法,实现触摸时的移动效果
可使用swipeBackEdgeThreshold规定左滑触摸动作被触发,所需要的手指到左边缘的距离
可使用swipeBackReleaseThreshold规定左滑释放时被判定为一次后退操作的范围

navigator组件的使用例如下:


// template
<navigator
:on-before-enter="transitionBeforeEnter"
:on-before-leave="transitionBeforeLeave"
:on-enter="transitionEnter"
:on-leave="transitionLeave"
:is-main="isMain"
:on-touch="onTouch"
:swipe-back-edge-threshold="0.05"
:swipe-back-release-threshold="0.5"
>
</navigator>
// script
transitionBeforeEnter(el, done) {
el.style.transition = 'all ' + this.transitionDuration + 'ms'
const h = () => {
done()
el.removeEventListener('transitionend', h)
}
el.addEventListener('transitionend', h)
this.setElementTranslateX(el, '100%')
},
transitionBeforeLeave(el, done) {
el.style.transition = 'all ' + this.transitionDuration + 'ms'
const h = () => {
done()
el.removeEventListener('transitionend', h)
}
el.addEventListener('transitionend', h)
this.setElementTranslateX(el, '0%')
},
transitionEnter(el, done) {
el.style.transition = 'all ' + this.transitionDuration + 'ms'
const h = () => {
done()
el.removeEventListener('transitionend', h)
}
el.addEventListener('transitionend', h)
this.setElementTranslateX(el, '0%')
},
transitionLeave(el, done) {
el.style.transition = 'all ' + this.transitionDuration + 'ms'
const h = () => {
done()
el.removeEventListener('transitionend', h)
}
el.addEventListener('transitionend', h)
this.setElementTranslateX(el, '-50%')
},
// route相当于vm.$route,即当前的路由
// 这里将几个特定名字的路由设定为主视图
isMain(route) {
const list = ['Card', 'Rewards', 'Profile', 'Home', 'Coupons']
return list.indexOf(route.name) > -1
},
onTouch(enterEl, leaveEl, x, y) {
const screenWidth = window.document.documentElement.clientWidth
const touchXRatio = x / screenWidth
enterEl.style.transition = 'none'
leaveEl.style.transition = 'none'
enterEl.style.transform = `translate(${touchXRatio * 100}%)`
leaveEl.style.transform = `translate(${touchXRatio * 50 - 50}%)`
}
后记

原本vue和vue-router中提供了router-view, keep-alive, transition这几大内置组件,分别对应路由视图、页面缓存、进出场效果这三大功能,然而我将它们嵌套使用时却一直无法达到预期效果,也难以通过阅读源码进行hack。无奈之下选择了自己实现控件,完全控制这些逻辑。在一步步加入各种功能时,代码也在不断复杂,并经历了一两次大重写。


这次实现的navigator还有许多不足的地方,例如渲染组件的方法实现得过于简单,无法对应nested routes的情况等。但在实现的过程中,我加深了对于render function的作用、触发时机,以及vnode的创建等知识的理解,也算是一大收获吧。


第七城市th7cn

微信扫一扫

第七城市微信公众平台