Vue實現一個頁面緩存、左滑返回的navigator

前言

本文將介紹如何在不使用vue-router提供的router-view的狀況下,實現一個渲染路由對應組件的navigator控件,並逐步增長主副舞臺區分、頁面緩存、頁面切換動畫、左滑返回支持等功能。javascript

本組件的源碼位於個人github: github.com/lqt0223/nav…css

本組件的demo: navigator-demo.herokuapp.com/#/view1 (建議在移動設備上打開(在iOS設備上使用Safari等瀏覽器打開時可能遇到左滑返回衝突的問題),或使用chrome dev tool,在手機模式下打開以支持觸摸事件)html

需求

筆者所在公司所開發的webapp爲單頁面應用,原來使用的框架爲Backbone。
此app使用瞭如今流行的上部header,中部content,下部tabbar的佈局形式。vue

tabbar的例子
tabbar的例子

這種佈局通常須要header, content, tabbar具備如下的渲染邏輯java

  • tabbar在app運行期間只實例化一次
  • header與content爲一一對應關係,不一樣的視圖對應不一樣的標題
  • 點擊tabbar的按鈕後,所呈現的視圖爲app中的主視圖
    • 例以下圖中tabbar上有五個按鈕,那麼app中就有5個主視圖
    • 主視圖通常分配給app中最主要的、最早給用戶展現的功能的呈現,例如「個人信息」、「商品列表」、「首頁推薦」等
    • 主視圖在app運行期間只應該被實例化一次。例如用戶第一次打開首頁時,能夠經過API調用來渲染首頁上的動態內容;第二次打開首頁時,則只渲染之間緩存的頁面,此頁面的created, mounted等生命週期函數都不該被調用
    • 在app的主視圖之間切換時,不須要動畫效果
  • 點擊content或header中的按鈕後,所呈現的視圖爲app中的副視圖
    • 副視圖是除主視圖之外,其餘的功能頁面所使用的視圖
    • 副視圖通常分配給app中次要的、設計具體數據展現的、或者流程較長的功能的呈現,例如「某一商品的詳情介紹」、「註冊表單中的某一步」等。
    • 涉及到副視圖的頁面切換,都須要動畫效果
    • 每次跳轉到一個副視圖時,根據狀況,副視圖須要是一個新的實例。
    • 從副視圖能夠左滑返回到上一個視圖
    • (具體跳轉規則請參照下面的小節)

在Backbone時代,一條路由規則僅僅是由路徑的匹配模式和對應的處理函數組成的。當url中的hash部分發生變化,變化後的值符合某一條路由規則時,就調用此路由規則所指定的處理函數。在處理函數中,咱們須要實現頁面內容更新、渲染的所有邏輯。node

在Vue時代,從頁面的每一個小的組成部分,到整個頁面自己,都是一個Vue component。Vue中的一條路由規則是由路徑的匹配模式和對應的component組成的。當url中的hash部分發生變化,變化後的值符合某一條路由規則時,Vue會將此規則對應的component實例化,並渲染到app中的router-view組件中。咱們不須要本身實現頁面內容更新、渲染的邏輯。git

通過以上的對比咱們能夠發現,Backbone須要本身實現對應路由的渲染邏輯,所以咱們能夠本身實現以上的頁面緩存、動畫過渡等功能。但基於vue-router的router-view,則沒法阻止一些框架的默認行爲(例如每次路由切換時,對應的component都是新的實例)。github

雖然經過定義component屬性爲空的路由規則,並利用vue-router的beforeEach鉤子函數,也能夠達到必定的hack目的。但在筆者着手實現此需求時,同事已經將帶component屬性的路由規則所有寫好。爲了減小代碼的修改,以及經過自定義控件的實現達到必定的複用性,最終筆者仍是決定拋開vue-router提供的router-view,寫一個本身的路由視圖組件。web

最簡單的router-view

上一小節提到,咱們須要在不依賴vue-router官方提供的router-view組件的狀況下,實現咱們本身的navigator。分析router-view的功能和特色咱們能夠得出:vue-router

  • 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外部,包裹了類名爲navigatornavigator-page的父node,這是爲了向每一個頁面DOM指定相同的全屏渲染須要的樣式,例如position: absolute

跳轉行爲整理 

前一小節中提到了在不一樣的視圖之間跳轉時,根據跳轉發生的起點視圖和終點視圖的不一樣,產生的渲染行爲也不一樣。這裏整理以下:

原視圖 新視圖 新視圖是否被訪問過 行爲
主視圖 主視圖 是/否 直接替換app視圖區域的內容
主視圖 副視圖 是/否 新視圖從右至左進入視圖區域,舊視圖從右至左退出視圖區域
副視圖 主視圖 是/否 將位於當前副視圖下方的視圖替換爲目標主視圖,並使新視圖從左至右進入視圖區域,舊視圖從左至右退出視圖區域
副視圖 副視圖 新視圖從右至左進入視圖區域,舊視圖從右至左退出視圖區域
副視圖 副視圖 將位於當前副視圖下方的視圖替換爲目標副視圖,並使新視圖從左至右進入視圖區域,舊視圖從左至右退出視圖區域

上面的整理內容比較抽象,下面連接中的demo是一個體現上述邏輯的例子。其中view1和view3爲主視圖,view2和view4爲副視圖。

navigator-demo.herokuapp.com/#/view1

經過上面的整理,咱們能夠將整個app的視圖管理抽象成以下的模式(僅展現部分邏輯):

page_stack_draft
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組件具備豐富的接口:

  1. 可以使用isMain斷定哪些頁面須要放在主視圖,哪些頁面須要放在副視圖
  2. 可以使用onBeforeEnter, onEnter, onBeforeLeave, onLeave等一系列transition hook,實現轉場效果
  3. 可以使用onTouch方法,實現觸摸時的移動效果
  4. 可以使用swipeBackEdgeThreshold規定左滑觸摸動做被觸發,所須要的手指到左邊緣的距離
  5. 可以使用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的建立等知識的理解,也算是一大收穫吧。

相關文章
相關標籤/搜索