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,是整個路由系統的關鍵;

而基於history的單頁應用通常面臨着下面的問題:web

  • 單頁應用內頁面切換,怎麼處理狀態而且如何更新頁面
  • 頁面刷新後,如何恢復當前頁面,而不是回到最開始的狀態;

解決上述兩個問題,taro-router內部實現一套頁面管理機制,在內部管理一套頁面狀態,而且根據狀態變動,決定頁面的新增、替換、刪除;在狀態變動的同時,根據頁面的url路徑,決定須要更新的頁面組件;更新的頁面由頁面棧負責管理,頁面棧管理頁面的層級關係; vue-router

taro-router中,調用API進行頁面跳轉時,能夠觀察到Dom節點有以下的變化:小程序

taro-router-stack-and-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-buildENTRY文件解析階段,經過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.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的原理已經分析完,雖然裏面依然有很多細節沒有說起,可是主要的思路和邏輯,已經梳理得差很少,所以篇幅較長;但願你們讀完後,能有所收穫,同時也但願你們如發現其中疏漏的地方能批評指正,謝謝!

相關文章
相關標籤/搜索