为了多端统一的初衷,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,是整个路由系统的关键;web
而基于history
的单页应用通常面临着下面的问题:vue-router
处理状态
而且如何更新页面
;解决上述两个问题,taro-router
内部实现一套页面管理机制,在内部管理一套页面状态,而且根据状态变动,决定页面的新增、替换、删除
;在状态变动
的同时,根据页面的url路径,决定须要更新的页面组件;更新的页面由页面栈负责管理,页面栈管理页面的层级关系;小程序
在taro-router
中,调用API进行页面跳转时,能够观察到Dom
节点有以下的变化:微信小程序
能够看到每一个页面由<div class="taro_page"></div>
节点包裹,而全部的页面节点<div class="taro_router"></div>
节点包裹;在这里:api
<div class="taro_page"></div>
节点能够理解为页面栈,在taro-router
中,对应着Router
组件;<div class="taro_router"></div>
节点能够理解为页面,在taro-router
中,对应着Route
组件,它的实际做用是包裹真正的页面组件;Router
会在taro-build
的ENTRY文件
解析阶段,经过AST解析,将组件插入到render
函数中,插入口的代码相似(能够在.temp
文件中查看):
// 入口文件 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
的原理已经分析完,虽然里面依然有很多细节没有说起,可是主要的思路和逻辑,已经梳理得差很少,所以篇幅较长;但愿你们读完后,能有所收获,同时也但愿你们如发现其中疏漏的地方能批评指正,谢谢!