本文將介紹如何在不使用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
這種佈局通常須要header, content, tabbar具備如下的渲染邏輯java
在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
上一小節提到,咱們須要在不依賴vue-router官方提供的router-view組件的狀況下,實現咱們本身的navigator。分析router-view的功能和特色咱們能夠得出:vue-router
通過一段時間的摸索,可知: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被調用)時,記錄每次的當前路由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爲副視圖。
經過上面的整理,咱們能夠將整個app的視圖管理抽象成以下的模式(僅展現部分邏輯):
上一小節咱們整理了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 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
}複製代碼
根據前文,某個vue組件的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組件具備豐富的接口:
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的建立等知識的理解,也算是一大收穫吧。