前言
本文將介紹如何在不使用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的佈局形式。
這種佈局一般需要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為副檢視。
通過上面的整理,我們可以將整個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清理的時候,我並沒有直接使用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的建立等知識的理解,也算是一大收穫吧。