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

前言

本文将介绍如何在不使用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">
    
  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外部,包裹了类名为navigatornavigator-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外围增加一个

主视图到副视图

这种情况下,由于导航到副视图时,副视图总是一个新的实例,所以对于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">
    
  div>
  <div class="navigator-page">
    
  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组件具有丰富的接口:

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

navigator组件的使用例如下:

// template
"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的创建等知识的理解,也算是一大收获吧。

转载于:https://juejin.im/post/5a1cd04b5188252ae93aade4

你可能感兴趣的:(javascript,移动开发)