Vue實現一個頁面快取、左滑返回的navigator

lqt0223發表於2019-03-03

前言

本文將介紹如何在不使用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的例子
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外部,包裹了類名為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的建立等知識的理解,也算是一大收穫吧。

相關文章