「源碼解析 」這一次完全弄懂react-router路由原理

寫在前面:爲何要學習react-router底層源碼? 爲何要弄明白整個路由流程? 筆者我的感受學習react-router,有助於咱們學習單頁面應用(spa)路由跳轉原理,讓咱們理解從history.push,到組件頁面切換的全套流程,使咱們在面試的時候再也不爲路由相關的問題發怵,廢話不說,讓咱們開啓深刻react-router源碼之旅吧。css

一 正確理解react-router

1 理解單頁面應用

什麼是單頁面應用?html

我的理解,單頁面應用是使用一個html下,一次性加載js, css等資源,全部頁面都在一個容器頁面下,頁面切換實質是組件的切換。前端

2 react-router初探,揭露路由原理面紗

react-router-domreact-routerhistory庫三者什麼關係

history 能夠理解爲react-router的核心,也是整個路由原理的核心,裏面集成了popState,history.pushState等底層路由實現的原理方法,接下來咱們會一一解釋。react

react-router能夠理解爲是react-router-dom的核心,裏面封裝了Router,Route,Switch等核心組件,實現了從路由的改變到組件的更新的核心功能,在咱們的項目中只要一次性引入react-router-dom就能夠了。面試

react-router-dom,在react-router的核心基礎上,添加了用於跳轉的Link組件,和histoy模式下的BrowserRouter和hash模式下的HashRouter組件等。所謂BrowserRouter和HashRouter,也只不過用了history庫中createBrowserHistory和createHashHistory方法api

react-router-dom 咱們很少說了,這裏咱們重點看一下react-router瀏覽器

②來個小demo嚐嚐鮮?

import { BrowserRouter as Router, Switch, Route, Redirect,Link } from 'react-router-dom'

import Detail from '../src/page/detail'
import List from '../src/page/list'
import Index from '../src/page/home/index'

const menusList = [
  {
    name: '首頁',
    path: '/index'
  },
  {
    name: '列表',
    path: '/list'
  },
  {
    name: '詳情',
    path: '/detail'
  },
]
const index = () => {
  return <div > <div > <Router > <div>{ /* link 路由跳轉 */ menusList.map(router=><Link key={router.path} to={ router.path } > <span className="routerLink" >{router.name}</span> </Link>) }</div> <Switch> <Route path={'/index'} component={Index} ></Route> <Route path={'/list'} component={List} ></Route> <Route path={'/detail'} component={Detail} ></Route> {/* 路由不匹配,重定向到/index */} <Redirect from='/*' to='/index' /> </Switch> </Router> </div> </div>
}
複製代碼

效果以下markdown

二 單頁面實現核心原理

單頁面應用路由實現原理是,切換url,監聽url變化,從而渲染不一樣的頁面組件。react-router

主要的方式有history模式和hash模式。app

1 history模式原理

①改變路由

history.pushState

history.pushState(state,title,path)
複製代碼

1 state:一個與指定網址相關的狀態對象, popstate 事件觸發時,該對象會傳入回調函數。若是不須要可填 null。

2 title:新頁面的標題,可是全部瀏覽器目前都忽略這個值,可填 null。

3 path:新的網址,必須與當前頁面處在同一個域。瀏覽器的地址欄將顯示這個地址。

history.replaceState

history.replaceState(state,title,path)
複製代碼

參數和pushState同樣,這個方法會修改當前的 history 對象記錄, history.length 的長度不會改變。

②監聽路由

popstate事件

window.addEventListener('popstate',function(e){
    /* 監聽改變 */
})
複製代碼

同一個文檔的 history 對象出現變化時,就會觸發 popstate 事件  history.pushState 可使瀏覽器地址改變,可是無需刷新頁面。注意⚠️的是:用 history.pushState() 或者 history.replaceState() 不會觸發 popstate 事件popstate 事件只會在瀏覽器某些行爲下觸發, 好比點擊後退、前進按鈕或者調用 history.back()、history.forward()、history.go()方法。

2 hash模式原理

①改變路由

window.location.hash

經過window.location.hash 屬性獲取和設置 hash 值。

②監聽路由

onhashchange

window.addEventListener('hashchange',function(e){
    /* 監聽改變 */
})
複製代碼

三 理解history庫

react-router路由離不開history庫,history專一於記錄路由history狀態,以及path改變了,咱們應該作寫什麼, 在history模式下用popstate監聽路由變化,在hash模式下用hashchange監聽路由的變化。

接下來咱們看 Browser模式下的createBrowserHistoryHash模式下的 createHashHistory方法。

1 createBrowserHistory

Browser模式下路由的運行 ,一切都從createBrowserHistory開始。這裏咱們參考的history-4.7.2版本,最新版本中api可能有些出入,可是原理都是同樣的,在解析history過程當中,咱們重點關注setState ,push ,handlePopState,listen方法

const PopStateEvent = 'popstate'
const HashChangeEvent = 'hashchange'
/* 這裏簡化了createBrowserHistory,列出了幾個核心api及其做用 */
function createBrowserHistory(){
    /* 全局history */
    const globalHistory = window.history
    /* 處理路由轉換,記錄了listens信息。 */
    const transitionManager = createTransitionManager()
    /* 改變location對象,通知組件更新 */
    const setState = () => { /* ... */ }
    
    /* 處理當path改變後,處理popstate變化的回調函數 */
    const handlePopState = () => { /* ... */ }
   
    /* history.push方法,改變路由,經過全局對象history.pushState改變url, 通知router觸發更新,替換組件 */
    const push=() => { /*...*/ }
    
    /* 底層應用事件監聽器,監聽popstate事件 */
    const listen=()=>{ /*...*/ } 
    return {
       push,
       listen,
       /* .... */ 
    }
}
複製代碼

下面逐一分析各個api,和他們以前的相互做用

const PopStateEvent = 'popstate'
const HashChangeEvent = 'hashchange'
複製代碼

popstatehashchange是監聽路由變化底層方法。

①setState

const setState = (nextState) => {
    /* 合併信息 */
    Object.assign(history, nextState)
    history.length = globalHistory.length
    /* 通知每個listens 路由已經發生變化 */
    transitionManager.notifyListeners(
      history.location,
      history.action
    )
  }
複製代碼

代碼很簡單:統一每一個transitionManager管理的listener路由狀態已經更新。

何時綁定litener, 咱們在接下來的React-Router代碼中會介紹。

②listen

const listen = (listener) => {
    /* 添加listen */
    const unlisten = transitionManager.appendListener(listener)
    checkDOMListeners(1)

    return () => {
      checkDOMListeners(-1)
      unlisten()
    }
}
複製代碼

checkDOMListeners

const checkDOMListeners = (delta) => {
    listenerCount += delta
    if (listenerCount === 1) {
      addEventListener(window, PopStateEvent, handlePopState)
      if (needsHashChangeListener)
        addEventListener(window, HashChangeEvent, handleHashChange)
    } else if (listenerCount === 0) {
      removeEventListener(window, PopStateEvent, handlePopState)
      if (needsHashChangeListener)
        removeEventListener(window, HashChangeEvent, handleHashChange)
    }
  }
複製代碼

listen本質經過checkDOMListeners的參數 1-1 來綁定/解綁 popstate 事件,當路由發生改變的時候,調用處理函數handlePopState

接下來咱們看看push方法。

③push

const push = (path, state) => {
    const action = 'PUSH'
    /* 1 建立location對象 */
    const location = createLocation(path, state, createKey(), history.location)
    /* 肯定是否能進行路由轉換,還在確認的時候又開始了另外一個轉變 ,可能會形成異常 */
    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
      if (!ok)
        return
      const href = createHref(location)
      const { key, state } = location
      if (canUseHistory) {
        /* 改變 url */
        globalHistory.pushState({ key, state }, null, href)
        if (forceRefresh) {
          window.location.href = href
        } else {
          /* 改變 react-router location對象, 建立更新環境 */
          setState({ action, location })
        }
      } else {
        window.location.href = href
      }
    })
  }
複製代碼

push ( history.push ) 流程大體是 首先生成一個最新的location對象,而後經過window.history.pushState方法改變瀏覽器當前路由(即當前的path),最後經過setState方法通知React-Router更新,並傳遞當前的location對象,因爲此次url變化的,是history.pushState產生的,並不會觸發popState方法,因此須要手動setState,觸發組件更新

④handlePopState

最後咱們來看看當popState監聽的函數,當path改變的時候會發生什麼,

/* 咱們簡化一下handlePopState */
const handlePopState = (event)=>{
     /* 獲取當前location對象 */
    const location = getDOMLocation(event.state)
    const action = 'POP'

    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
        if (ok) {
          setState({ action, location })
        } else {
          revertPop(location)
        }
    })
}
複製代碼

handlePopState 代碼很簡單 ,判斷一下action類型爲pop,而後 setState ,重新加載組件。

2 createHashHistory

hash 模式和 history API相似,咱們重點講一下 hash模式下,怎麼監聽路由,和push , replace方法是怎麼改變改變路徑的。

監聽哈希路由變化

const HashChangeEvent = 'hashchange'
  const checkDOMListeners = (delta) => {
    listenerCount += delta
    if (listenerCount === 1) {
      addEventListener(window, HashChangeEvent, handleHashChange)
    } else if (listenerCount === 0) {
      removeEventListener(window, HashChangeEvent, handleHashChange)
    }
  }
複製代碼

和以前所說的同樣,就是用hashchange來監聽hash路由的變化。

改變哈希路由

/* 對應 push 方法 */
const pushHashPath = (path) =>
  window.location.hash = path

/* 對應replace方法 */
const replaceHashPath = (path) => {
  const hashIndex = window.location.href.indexOf('#')

  window.location.replace(
    window.location.href.slice(0, hashIndex >= 0 ? hashIndex : 0) + '#' + path
  )
}

複製代碼

hash模式下 ,history.push 底層是調用了window.location.href來改變路由。history.replace底層是掉用 window.location.replace改變路由。

總結

咱們用一幅圖來描述了一下history庫總體流程。

四 核心api

1 Router-接收location變化,派發更新流

Router 做用是把 history location 等路由信息 傳遞下去

Router

/* Router 做用是把 history location 等路由信息 傳遞下去 */
class Router extends React.Component {
  static computeRootMatch(pathname) {
    return { path: '/', url: '/', params: {}, isExact: pathname === '/' };
  }
  constructor(props) {
    super(props);
    this.state = {
      location: props.history.location
    };
    //記錄pending位置
    //若是存在任何<Redirect>,則在構造函數中進行更改
    //在初始渲染時。若是有,它們將在
    //在子組件身上激活,咱們可能會
    //在安裝<Router>以前獲取一個新位置。
    this._isMounted = false;
    this._pendingLocation = null;
    /* 此時的history,是history建立的history對象 */
    if (!props.staticContext) {
      /* 這裏判斷 componentDidMount 和 history.listen 執行順序 而後把 location複製 ,防止組件從新渲染 */
      this.unlisten = props.history.listen(location => {
        /* 建立監聽者 */
        if (this._isMounted) {

          this.setState({ location });
        } else {
          this._pendingLocation = location;
        }
      });
    }
  }
  componentDidMount() {
    this._isMounted = true;
    if (this._pendingLocation) {
      this.setState({ location: this._pendingLocation });
    }
  }
  componentWillUnmount() {
    /* 解除監聽 */
    if (this.unlisten) this.unlisten();
  }
  render() {
    return (
      /* 這裏能夠理解 react.createContext 建立一個 context上下文 ,保存router基本信息。children */
      <RouterContext.Provider children={this.props.children || null} value={{ history: this.props.history, location: this.state.location, match: Router.computeRootMatch(this.state.location.pathname), staticContext: this.props.staticContext }} />
    );
  }
}
複製代碼

總結:

初始化綁定listen, 路由變化,通知改變location,改變組件。 react的history路由狀態是保存在React.Content上下文之間, 狀態更新。

一個項目應該有一個根Router , 來產生切換路由組件以前的更新做用。 若是存在多個Router會形成,會形成切換路由,頁面不更新的狀況。

2 Switch-匹配正確的惟一的路由

根據router更新流,來渲染當前組件。

/* switch組件 */
class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {/* 含有 history location 對象的 context */}
        {context => {
          invariant(context, 'You should not use <Switch> outside a <Router>');
          const location = this.props.location || context.location;
          let element, match;
          //咱們使用React.Children.forEach而不是React.Children.toArray().find()
          //這裏是由於toArray向全部子元素添加了鍵,咱們不但願
          //爲呈現相同的兩個<Route>s觸發卸載/從新裝載
          //組件位於不一樣的URL。
          //這裏只需然第一個 含有 match === null 的組件
          React.Children.forEach(this.props.children, child => {
            if (match == null && React.isValidElement(child)) {
              element = child;
              // 子組件 也就是 獲取 Route中的 path 或者 rediect 的 from
              const path = child.props.path || child.props.from;
              match = path
                ? matchPath(location.pathname, { ...child.props, path })
                : context.match;
            }
          });
          return match
            ? React.cloneElement(element, { location, computedMatch: match })
            : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

複製代碼

找到與當前path,匹配的組件進行渲染。 經過pathname和組件的path進行匹配。找到符合path的router組件。

matchPath

function matchPath(pathname, options = {}) {
  if (typeof options === "string" || Array.isArray(options)) {
    options = { path: options };
  }

  const { path, exact = false, strict = false, sensitive = false } = options;

  const paths = [].concat(path);

  return paths.reduce((matched, path) => {
    if (!path && path !== "") return null;
    if (matched) return matched;

    const { regexp, keys } = compilePath(path, {
      end: exact,
      strict,
      sensitive
    });
    const match = regexp.exec(pathname);
    /* 匹配不成功,返回null */
    if (!match) return null;

    const [url, ...values] = match;
    const isExact = pathname === url;

    if (exact && !isExact) return null;

    return {
      path, // the path used to match
      url: path === "/" && url === "" ? "/" : url, // the matched portion of the URL
      isExact, // whether or not we matched exactly
      params: keys.reduce((memo, key, index) => {
        memo[key.name] = values[index];
        return memo;
      }, {})
    };
  }, null);
}
複製代碼

匹配符合的路由。

3 Route-組件頁面承載容器

/** * The public API for matching a single path and rendering. */
class Route extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          /* router / route 會給予警告警告 */
          invariant(context, "You should not use <Route> outside a <Router>");
          // computedMatch 爲 通過 swich處理後的 path
          const location = this.props.location || context.location;
          const match = this.props.computedMatch 
            ? this.props.computedMatch // <Switch> already computed the match for us
            : this.props.path
            ? matchPath(location.pathname, this.props)
            : context.match;
          const props = { ...context, location, match };
          let { children, component, render } = this.props;

          if (Array.isArray(children) && children.length === 0) {
            children = null;
          }

          return (
            <RouterContext.Provider value={props}>
              {props.match
                ? children
                  ? typeof children === "function"
                    ? __DEV__
                      ? evalChildrenDev(children, props, this.props.path)
                      : children(props)
                    : children
                  : component
                  ? React.createElement(component, props)
                  : render
                  ? render(props)
                  : null
                : typeof children === "function"
                ? __DEV__
                  ? evalChildrenDev(children, props, this.props.path)
                  : children(props)
                : null}
            </RouterContext.Provider>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}
複製代碼

匹配path,渲染組件。做爲路由組件的容器,能夠根據將實際的組件渲染出來。經過RouterContext.Consume 取出當前上一級的location,match等信息。做爲prop傳遞給頁面組件。使得咱們能夠在頁面組件中的props中獲取location ,match等信息。

4 Redirect-沒有符合的路由,那麼重定向

重定向組件, 若是來路由匹配上,會重定向對應的路由。

function Redirect({ computedMatch, to, push = false }) {
  return (
    <RouterContext.Consumer> {context => { const { history, staticContext } = context; /* method就是路由跳轉方法。 */ const method = push ? history.push : history.replace; /* 找到符合match的location ,格式化location */ const location = createLocation( computedMatch ? typeof to === 'string' ? generatePath(to, computedMatch.params) : { ...to, pathname: generatePath(to.pathname, computedMatch.params) } : to ) /* 初始化的時候進行路由跳轉,當初始化的時候,mounted執行push方法,當組件更新的時候,若是location不相等。一樣會執行history方法重定向 */ return ( <Lifecycle onMount={() => { method(location); }} onUpdate={(self, prevProps) => { const prevLocation = createLocation(prevProps.to); if ( !locationsAreEqual(prevLocation, { ...location, key: prevLocation.key }) ) { method(location); } }} to={to} /> ); }} </RouterContext.Consumer>
  );
}

複製代碼

初始化的時候進行路由跳轉,當初始化的時候,mounted執行push方法,當組件更新的時候,若是location不相等。一樣會執行history方法重定向。

五 總結 + 流程分析

總結

history提供了核心api,如監聽路由,更改路由的方法,已經保存路由狀態state。

react-router提供路由渲染組件,路由惟一性匹配組件,重定向組件等功能組件。

流程分析

當地址欄改變url,組件的更新渲染都經歷了什麼?😊😊😊 拿history模式作參考。當url改變,首先觸發histoy,調用事件監聽popstate事件, 觸發回調函數handlePopState,觸發history下面的setstate方法,產生新的location對象,而後通知Router組件更新location並經過context上下文傳遞,switch經過傳遞的更新流,匹配出符合的Route組件渲染,最後有Route組件取出context內容,傳遞給渲染頁面,渲染更新。

當咱們調用history.push方法,切換路由,組件的更新渲染又都經歷了什麼呢?

咱們仍是拿history模式做爲參考,當咱們調用history.push方法,首先調用history的push方法,經過history.pushState來改變當前url,接下來觸發history下面的setState方法,接下來的步驟就和上面如出一轍了,這裏就不一一說了。

咱們用一幅圖來表示各個路由組件之間的關係。

但願讀過此篇文章的朋友,可以明白react-router的整個流程,代碼邏輯不是很難理解。整個流程我給你們分析了一遍,但願同窗們能主動看一波源碼,把整個流程搞明白。紙上得來終覺淺,絕知此事要躬行。

寫在最後,謝謝你們鼓勵與支持🌹🌹🌹,喜歡的能夠給筆者點贊關注,公衆號:前端Sharing

相關文章
相關標籤/搜索