Taro 1.0系列:taro-router原理分析

Taro如何处理页面路由

为了多端统一的初衷,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-router-stack

在小程序端,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.pushStatehistory.replaceStatehistory.go还有popstate事件这几个关键API,是整个路由系统的关键;web

而基于history的单页应用通常面临着下面的问题:vue-router

  • 单页应用内页面切换,怎么处理状态而且如何更新页面
  • 页面刷新后,如何恢复当前页面,而不是回到最开始的状态;

解决上述两个问题,taro-router内部实现一套页面管理机制,在内部管理一套页面状态,而且根据状态变动,决定页面的新增、替换、删除;在状态变动的同时,根据页面的url路径,决定须要更新的页面组件;更新的页面由页面栈负责管理,页面栈管理页面的层级关系;小程序

taro-router中,调用API进行页面跳转时,能够观察到Dom节点有以下的变化:微信小程序

taro-router-stack-and-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-buildENTRY文件解析阶段,经过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.getCurrentPagesAPI;

Router组件当收到TransitionManager发布的事件后,根据其回调函数中的三个参数fromLocation, toLocation, action做进一步处理:

  • fromLocation 表示从哪一个路径跳转;
  • toLocation 表示跳转到哪一个路径;
  • action 表示跳转的动做,包含PUSH、POP、REPLACE

根据返回的PUSH、POP、REPLACE动做类型,对页面栈routeStack进行页面的入栈、出栈、替换处理;

PUSH动做

当监听到的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 })
}
复制代码

POP动做

当监听到的action为POP时:

  • 首先,根据fromLocationtoLocationkey值之差,决定在要页面栈中回退多少个页面;
  • 计算出的差值为delta,再经过splice进行删除;
  • 删除操做完成后,检查页面栈的长度是否为0,若为0,则将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 })
}
复制代码

REPLACE动做

当监听到的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,这样就能够对应上updateComponentprops.componentLoader()的调用了,它的then回调中,表示这个dynamic import对应的模块已经成功加载,能够获取该模块导出的component了;获取导出的component后,通过包装再触发强制更新,进行渲染;

页面状态的管理

taro-router其内部维护一套页面状态,配合浏览器的historyAPI进行状态管理;内部实例化TransitionManager,用于当页面状态变化后,通知订阅者更新页面;

初始化流程

taro-buildENTRY文件解析阶段,会在app.jsx文件中插入taro-router的初始化代码:

const _taroHistory = createHistory({
  mode: "hash",
  basename: "/",
  customRoutes: {},
  firstPagePath: "/pages/demo/index"
});

mountApis({
  "basename": "/",
  "customRoutes": {}
}, _taroHistory);
复制代码

在初始化代码中,会首先调用createHistory方法,而后调用mountApi将路由API(如:navagateToredirectTo)挂载到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-layer

状态变化过程

taro-router维护的页面状态,保存内部的stateKey变量中,而且用于history对象的state中;

  • 在首次进入单页应用时,stateKey会被赋予初始值0
  • 当每次进行pushState时,会触发stateKey自增1
  • 进行replaceState时,stateKey保持不变;
  • popstate触发时,回调函数会返回最新的stateKey,根据先后两次stateKey的比较,决定页面的action;

状态变化流程以下图:

taro-router-state-change

注意:当业务代码中使用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-routerhandlePopState方法:

// 此处只保留关键代码
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
    })
  }
复制代码

在比较nextKeycurrentKey时,就出现了1mock的比较,从而致使不可预计的action值产生;

路由拦截的实现

路由拦截,是指在路由进行变化时,可以拦截路由变化前的动做,保持页面不变,并交由业务逻辑做进一步的判断,再决定是否进行页面的切换;

Vue里面,咱们比较熟悉的路由拦截API就有vue-routerbeforeRouteLeavebeforeRouteEnter;在React当中,就有react-router-domPrompt组件;

文中一开始的时候,就提到Taro在路由跳转的交互体验上,保持了小程序端和h5端的统一,所以小程序中没有实现的路由拦截,H5端也没有实现;

那么,在taro-router中是否就真的不能作到路由拦截呢?

答案是否认的,做者本人从vue-routerreact-router-dom以及history中获得灵感,在taro-router是实现了路由拦截的APIbeforeRouteLeave,你们能够查看相关commit

只有在页面中声明该拦截函数,页面才具备路由拦截功能,不然页面不具备拦截功能,该函数有三个参数分别为fromtonext

  • from:表示从哪一个Location离开
  • to:表示要跳转到哪一个Location
  • next: 函数,其入参为boolean;next(true),表示继续跳转下一个页面,next(false)表示路由跳转终止

它的使用方式是:

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的原理已经分析完,虽然里面依然有很多细节没有说起,可是主要的思路和逻辑,已经梳理得差很少,所以篇幅较长;但愿你们读完后,能有所收获,同时也但愿你们如发现其中疏漏的地方能批评指正,谢谢!

相关文章
相关标签/搜索