爲了多端統一的初衷,Taro
在路由跳轉的交互體驗上,保持了小程序端和h5端的統一,即同一套代碼,在h5和小程序端的跳轉體驗是一致的;如何理解Taro處理頁面路由
的方式,咱們能夠經過一個頁面棧來表示路由的狀態變化,Taro
封裝了多個路由API,每次調用路由API,都是對頁面棧的一次進棧出棧操做:vue
Taro.navigateTo
:保留當前頁面,並跳轉到應用內某個頁面,至關於把新頁面push
進頁面棧;Taro.redirectTo
:關閉當前頁面,並跳轉到應用內某個頁面,至關於用新的頁面替換掉舊的頁面;Taro.switchTab
:跳轉到tabBar
頁面,目前h5不支持;Taro.reLaunch
:關閉全部頁面,打開到應用內的某個頁面,至關於清空頁面棧,而且將新頁面push
進棧底;Taro.navigateBack
:關閉當前頁面,返回上一頁面或多級頁面,至關於將頁面pop
出頁面棧;能夠經過下圖更加直觀表示上述API和頁面棧的關係react
在小程序端,Taro路由API將直接轉換成調用原生路由API,也就是說,在微信小程序中,源代碼中調用Taro.navigateTo
,最終調用的是wx.navigateTo
;而在H5端,Taro路由API將轉換成調用window.history
對象的API; webpack
那在H5端如何管理頁面棧和頁面狀態
,以及頁面切換後,如何加載和卸載頁面(頁面更新)
等的問題,將由本文的主角taro-router
進行處理;git
衆所周知,Taro H5端
是一個單頁應用,其路由系統基於瀏覽器的history
路由(更多關於單頁應用的路由原理,推薦看看這篇文章);github
這裏咱們要記住history API
中的history.pushState
、history.replaceState
、history.go
還有popstate事件
這幾個關鍵API,是整個路由系統的關鍵;
而基於history
的單頁應用通常面臨着下面的問題:web
處理狀態
而且如何更新頁面
;解決上述兩個問題,taro-router
內部實現一套頁面管理機制,在內部管理一套頁面狀態,而且根據狀態變動,決定頁面的新增、替換、刪除
;在狀態變動
的同時,根據頁面的url路徑,決定須要更新的頁面組件;更新的頁面由頁面棧負責管理,頁面棧管理頁面的層級關係; vue-router
在taro-router
中,調用API進行頁面跳轉時,能夠觀察到Dom
節點有以下的變化:小程序
能夠看到每一個頁面由<div class="taro_page"></div>
節點包裹,而全部的頁面節點<div class="taro_router"></div>
節點包裹;在這裏:微信小程序
<div class="taro_page"></div>
節點能夠理解爲頁面棧,在taro-router
中,對應着Router
組件;<div class="taro_router"></div>
節點能夠理解爲頁面,在taro-router
中,對應着Route
組件,它的實際做用是包裹真正的頁面組件;Router
會在taro-build
的ENTRY文件
解析階段,經過AST解析,將組件插入到render
函數中,插入口的代碼相似(能夠在.temp
文件中查看):api
// 入口文件 class App extends Taro.Component { render() { return <Router mode={"hash"} history={_taroHistory} routes={[{ path: '/pages/demo/index', componentLoader: () => import( /* webpackChunkName: "demo_index" */'./pages/demo/index'), isIndex: true }, { path: '/pages/index/index', componentLoader: () => import( /* webpackChunkName: "index_index" */'./pages/index/index'), isIndex: false }]} customRoutes={{}} />; } }
在頁面狀態變化後,會經過taro-router
中的TransitionManager
通知Router
組件去操做頁面棧,TransitionManager
相似觀察者模式
,Router
組件屬於它的訂閱者,它的發佈者在後面頁面狀態的管理
會說起到;在Router
組件內部,經過routeStack
變量來管理頁面棧,它經過一個數組來實現;
另外currentPages
一樣也是頁面棧的另一個實現,它的變化發生在頁面棧中頁面實例初始化後,經過collectComponents
收集;這個變量是對外暴露的,使用方式相似小程序中的getCurrentPages
,在Taro中則能夠調用Taro.getCurrentPages
API;
Router
組件當收到TransitionManager
發佈的事件後,根據其回調函數中的三個參數fromLocation, toLocation, action
做進一步處理:
PUSH、POP、REPLACE
;根據返回的PUSH、POP、REPLACE
動做類型,對頁面棧routeStack
進行頁面的入棧、出棧、替換處理;
當監聽到的action爲PUSH
時:
toLocation
進行匹配,目的是爲了找到對應的route
對象,route
對象包含path, componentLoader, isIndex
等等的信息,其中componentLoader
指向了要加載的頁面組件;route
對象matchedRoute
加入到routeStack
中;setState
進行更新;push (toLocation) { const routeStack= [...this.state.routeStack] const matchedRoute = this.computeMatch(toLocation) routeStack.forEach(v => { v.isRedirect = false }) routeStack.push(assign({}, matchedRoute, { key: toLocation.state.key, isRedirect: false })) this.setState({ routeStack, location: toLocation }) }
當監聽到的action爲POP
時:
fromLocation
和toLocation
的key
值之差,決定在要頁面棧中回退多少個頁面;delta
,再經過splice
進行刪除;toLocation
對應的頁面推入頁面棧;setState
進行更新;pop (toLocation, fromLocation) { let routeStack = [...this.state.routeStack] const fromKey = Number(fromLocation.state.key) const toKey = Number(toLocation.state.key) const delta = toKey - fromKey routeStack.splice(delta) if (routeStack.length === 0) { // 不存在歷史棧, 須要從新構造 const matchedRoute = this.computeMatch(toLocation) routeStack = [assign({}, matchedRoute, { key: toLocation.state.key, isRedirect: false })] } this.setState({ routeStack, location: toLocation }) }
當監聽到的action爲RELPLACE
時:
toLocation
進行匹配,找到對應的route
對象matchedRoute
;route
對象,替換爲matchedRoute
;setState
進行更新;replace (toLocation) { const routeStack = [...this.state.routeStack] const matchedRoute = this.computeMatch(toLocation) // 替換 routeStack.splice(-1, 1, assign({}, matchedRoute, { key: toLocation.state.key, isRedirect: true })) this.setState({ routeStack, location: toLocation }) }
在獲知具體的頁面棧動做以後,routeStack
對象將會發生變化,routeStack
的更新,也會觸發Route
組件數量的變化;
上文說起到,Route組件是具體頁面的包裹
Router
組件的render
函數中,根據routeStack
的大小,渲染對應的Route
組件:
render () { const currentLocation = Taro._$router return ( <div className="taro_router" style={{ height: '100%' }}> {this.state.routeStack.map(({ path, componentLoader, isIndex, isTabBar, key, isRedirect }, k) => { return ( <Route path={path} currentLocation={currentLocation} componentLoader={componentLoader} isIndex={isIndex} key={key} k={k} isTabBar={isTabBar} isRedirect={isRedirect} collectComponent={this.collectComponent} /> ) })} </div> ) }
在Route
組件實例初始化後,將會調用組件內updateComponent
方法,進行具體頁面的拉取:
updateComponent (props = this.props) { props.componentLoader() .then(({ default: component }) => { if (!component) { throw Error(`Received a falsy component for route "${props.path}". Forget to export it?`) } const WrappedComponent = createWrappedComponent(component) this.wrappedComponent = WrappedComponent this.forceUpdate() }).catch((e) => { console.error(e) }) }
是否記得在入口文件中插入的代碼:
<Router mode={"hash"} history={_taroHistory} routes={[{ path: '/pages/demo/index', componentLoader: () => import( /* webpackChunkName: "demo_index" */'./pages/demo/index'), isIndex: true }, { path: '/pages/index/index', componentLoader: () => import( /* webpackChunkName: "index_index" */'./pages/index/index'), isIndex: false }]} customRoutes={{}} />;
componentLoader
字段傳入的是一個dynamic import
形式的函數,它的返回是一個Promise
,這樣就能夠對應上updateComponent
中props.componentLoader()
的調用了,它的then
回調中,表示這個dynamic import
對應的模塊已經成功加載,能夠獲取該模塊導出的component
了;獲取導出的component
後,通過包裝再觸發強制更新,進行渲染;
taro-router
其內部維護一套頁面狀態,配合瀏覽器的history
API進行狀態管理;內部實例化TransitionManager
,用於當頁面狀態變化後,通知訂閱者更新頁面;
在taro-build
的ENTRY文件
解析階段,會在app.jsx
文件中插入taro-router
的初始化代碼:
const _taroHistory = createHistory({ mode: "hash", basename: "/", customRoutes: {}, firstPagePath: "/pages/demo/index" }); mountApis({ "basename": "/", "customRoutes": {} }, _taroHistory);
在初始化代碼中,會首先調用createHistory
方法,而後調用mountApi
將路由API(如:navagateTo
、redirectTo
)掛載到Taro
實例下;下面就講一下createHistory
方法的流程:
若是有看過 history這個倉庫的同窗,應該會更容易理解taro-router
初始化流程,由於初始化流程跟history
的邏輯很像;
TransitionManager
,用於實現發佈者訂閱者模式,通知頁面進行更新;history state
,若是從window.history.state
中能獲取key
,則使用該key
,不然使用值爲'0'
的key
值;state
經過window.history.replaceState
進行歷史記錄的替換;popstate
事件,在回調函數中,對返回的state
對象中的key
值進行比較,經過比較得出須要進行的action
,並將這個action
經過TransitionManager
通知到Router
組件;結合頁面棧管理以及頁面更新的邏輯,能夠把整個taro-router
的結構描述以下:
taro-router
維護的頁面狀態,保存內部的stateKey
變量中,而且用於history對象的state中;
stateKey
會被賦予初始值0
;pushState
時,會觸發stateKey
自增1
;replaceState
時,stateKey
保持不變;popstate
觸發時,回調函數會返回最新的stateKey
,根據先後兩次stateKey
的比較,決定頁面的action;狀態變化流程以下圖:
注意:當業務代碼中使用history api進行pushState,這個狀態將不在taro-router內部維護的history狀態中,甚至會影響到taro-router的邏輯;
例如:在業務代碼中調用window.history.pushState
插入一個狀態:
class Index extends Taro.Component { componentDidMount() { window.history.pushState({ key: 'mock' }, null, window.location.href); } };
假設在插入該狀態前,history的state爲{ key: '1' }
;此時,用戶觸發返回操做,瀏覽器popstate
事件被觸發,這個時候,就會執行taro-router
的handlePopState
方法:
// 此處只保留關鍵代碼 const handlePopState = (e) => { const currentKey = Number(lastLocation.state.key) const nextKey = Number(state.key) let action: Action if (nextKey > currentKey) { action = 'PUSH' } else if (nextKey < currentKey) { action = 'POP' } else { action = 'REPLACE' } store.key = String(nextKey) setState({ action, location: nextLocation }) }
在比較nextKey
和currentKey
時,就出現了1
和mock
的比較,從而致使不可預計的action
值產生;
路由攔截,是指在路由進行變化時,可以攔截路由變化前的動做,保持頁面不變,並交由業務邏輯做進一步的判斷,再決定是否進行頁面的切換;
在Vue
裏面,咱們比較熟悉的路由攔截
API就有vue-router
的beforeRouteLeave
和beforeRouteEnter
;在React
當中,就有react-router-dom
的Prompt
組件;
文中一開始的時候,就提到Taro
在路由跳轉的交互體驗上,保持了小程序端和h5端的統一,所以小程序中沒有實現的路由攔截,H5端也沒有實現;
那麼,在
taro-router
中是否就真的不能作到路由攔截呢?
答案是否認的
,做者本人從vue-router
和react-router-dom
以及history
中獲得靈感,在taro-router
是實現了路由攔截
的APIbeforeRouteLeave
,你們能夠查看相關commit;
只有在頁面中聲明該攔截函數,頁面才具備路由攔截功能,不然頁面不具備攔截功能,該函數有三個參數分別爲from,to,next
它的使用方式是:
import Taro, { Component } from '@tarojs/taro' import { View, Button } from '@tarojs/components' export default class Index extends Component { beforeRouteLeave(from, to, next) { Taro.showModal({ title: '肯定離開嗎' }).then((res) => { if (res.confirm) { next(true); } if (res.cancel) { next(false); } }) } render () { return ( <View> <Button onClick={() => { Taro.navigateBack(); }}>返回</Button> </View> ) } }
它的實現原理是藉助TransitionManager
中的confirmTransitionTo
函數,在通知頁面棧更新前,進行攔截;
// 此處只保留關鍵代碼 const handlePopState = (e) => { const currentKey = Number(lastLocation.state.key) const nextKey = Number(state.key) const nextLocation = getDOMLocation(state) let action: Action if (nextKey > currentKey) { action = 'PUSH' } else if (nextKey < currentKey) { action = 'POP' } else { action = 'REPLACE' } store.key = String(nextKey) // 攔截確認 transitionManager.confirmTransitionTo( nextLocation, action, (result, callback) => { getUserConfirmation(callback, lastLocation, nextLocation) }, ok => { if (ok) { // 通知頁面更新 setState({ action, location: nextLocation }) } else { revertPop() } } ) }
攔截過程當中,調用getUserConfirmation
函數獲取頁面棧中棧頂
的頁面實例,而且從頁面實例中獲取beforeRouteLeave
函數,調用它以獲取是否繼續執行路由攔截
的結果;
function getUserConfirmation(next, fromLocation, toLocation) { // 獲取棧頂的Route對象 const currentRoute = getCurrentRoute() || {} const leaveHook = currentRoute.beforeRouteLeave if (typeof leaveHook === 'function') { tryToCall(leaveHook, currentRoute, fromLocation, toLocation, next) } else { next(true) } }
至此,taro-router
的原理已經分析完,雖然裏面依然有很多細節沒有說起,可是主要的思路和邏輯,已經梳理得差很少,所以篇幅較長;但願你們讀完後,能有所收穫,同時也但願你們如發現其中疏漏的地方能批評指正,謝謝!