react-router v4.x 源碼拾遺1

react-router是react官方推薦並參與維護的一個路由庫,支持瀏覽器端、app端、服務端等常見場景下的路由切換功能,react-router自己不具有切換和跳轉路由的功能,這些功能所有由react-router依賴的history庫完成,history庫經過對url的監聽來觸發 Router 組件註冊的回調,回調函數中會獲取最新的url地址和其餘參數而後經過setState更新,從而使整個應用進行rerender。因此react-router自己只是封裝了業務上的衆多功能性組件,好比Route、Link、Redirect 等等,這些組件經過context api能夠獲取到Router傳遞history api,好比push、replace等,從而完成頁面的跳轉。
仍是先來一段react-router官方的基礎使用案例,熟悉一下總體的代碼流程html

import React from "react";
import { BrowserRouter as Router, Route, Link } from "react-router-dom";

function BasicExample() {
  return (
    <Router>
      <div>
        <ul>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/about">About</Link>
          </li>
          <li>
            <Link to="/topics">Topics</Link>
          </li>
        </ul>

        <hr />

        <Route exact path="/" component={Home} />
        <Route path="/about" component={About} />
        <Route path="/topics" component={Topics} />
      </div>
    </Router>
  );
}

function Home() {
  return (
    <div>
      <h2>Home</h2>
    </div>
  );
}

function About() {
  return (
    <div>
      <h2>About</h2>
    </div>
  );
}

function Topics({ match }) {
  return (
    <div>
      <h2>Topics</h2>
      <ul>
        <li>
          <Link to={`${match.url}/rendering`}>Rendering with React</Link>
        </li>
        <li>
          <Link to={`${match.url}/components`}>Components</Link>
        </li>
        <li>
          <Link to={`${match.url}/props-v-state`}>Props v. State</Link>
        </li>
      </ul>

      <Route path={`${match.path}/:topicId`} component={Topic} />
      <Route
        exact
        path={match.path}
        render={() => <h3>Please select a topic.</h3>}
      />
    </div>
  );
}

function Topic({ match }) {
  return (
    <div>
      <h3>{match.params.topicId}</h3>
    </div>
  );
}

export default BasicExample;

Demo中使用了web端經常使用到的BrowserRouter、Route、Link等一些經常使用組件,Router做爲react-router的頂層組件來獲取 history 的api 和 設置回調函數來更新state。這裏引用的組件都是來自react-router-dom 這個庫,那麼react-router 和 react-router-dom 是什麼關係呢。
說的簡單一點,react-router-dom 是對react-router全部組件或方法的一個二次導出,而且在react-router組件的基礎上添加了新的組件,更加方便開發者處理複雜的應用業務。html5

1.react-router 導出的全部內容node

clipboard.png

統計一下,總共10個方法
1.MemoryRouter.js、2.Prompt.js、3.Redirect.js、4.Route.js、5.Router.js、6.StaticRouter.js、7.Switch.js、8.generatePath.js、9.matchPath.js、10.withRouter.jsreact

2.react-router-dom 導出的全部內容android

clipboard.png

統計一下,總共14個方法
1.BrowserRouter.js、2.HashRouter.js、3.Link.js、4.MemoryRouter.js、5.NavLink.js、6.Prompt.js、7.Redirect.js、8.Route.js、9.Router.js、10.StaticRouter.js、11.Switch.js、12.generatePath.js、13.matchPath.js、14.withRouter.js
react-router-dom在react-router的10個方法上,又添加了4個方法,分別是BrowserRouter、HashRouter、Link、以及NavLink。
因此,react-router-dom將react-router的10個方法引入後,又加入了4個方法,再從新導出,在開發中咱們只須要引入react-router-dom這個依賴便可。git

下面進入react-router-dom的源碼分析階段,首先來看一下react-router-dom的依賴庫github

clipboard.png

  1. React, 要求版本大於等於15.x
  2. history, react-router的核心依賴庫,注入組件操做路由的api
  3. invariant, 用來拋出異常的工具庫
  4. loose-envify, 使用browserify工具進行打包的時候,會將項目當中的node全局變量替換爲對應的字符串
  5. prop-types, react的props類型校驗工具庫
  6. react-router, 依賴同版本的react-router
  7. warning, 控制檯打印警告信息的工具庫

①.BrowserRouter.js, 提供了HTML5的history api 如pushState、replaceState等來切換地址,源碼以下web

import warning from "warning";
import React from "react";
import PropTypes from "prop-types";
import { createBrowserHistory as createHistory } from "history";
import Router from "./Router";

/**
 * The public API for a <Router> that uses HTML5 history.
 */
class BrowserRouter extends React.Component {
  static propTypes = {
    basename: PropTypes.string, // 當應用爲某個子應用時,添加的地址欄前綴
    forceRefresh: PropTypes.bool, // 切換路由時,是否強制刷新
    getUserConfirmation: PropTypes.func, // 使用Prompt組件時 提示用戶的confirm確認方法,默認使用window.confirm
    keyLength: PropTypes.number, // 爲了實現block功能,react-router維護建立了一個訪問過的路由表,每一個key表明一個曾經訪問過的路由地址
    children: PropTypes.node // 子節點
  };
  // 核心api, 提供了push replace go等路由跳轉方法
  history = createHistory(this.props); 
  // 提示用戶 BrowserRouter不接受用戶自定義的history方法,
  // 若是傳遞了history會被忽略,若是用戶使用自定義的history api,
  // 須要使用 Router 組件進行替代
  componentWillMount() {
    warning(
      !this.props.history,
      "<BrowserRouter> ignores the history prop. To use a custom history, " +
        "use `import { Router }` instead of `import { BrowserRouter as Router }`."
    );
  }
  // 將history和children做爲props傳遞給Router組件 並返回
  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

export default BrowserRouter;

**總結:BrowserRouter組件很是簡單,它自己其實就是對Router組件的一個包裝,將HTML5的history api封裝好再賦予 Router 組件。BrowserRouter就比如一個容器組件,由它來決定Router的最終api,這樣一個Router組件就能夠完成多種api的實現,好比HashRouter、StaticRouter 等,減小了代碼的耦合度
②. Router.js, 若是說BrowserRouter是Router的容器組件,爲Router提供了html5的history api的數據源,那麼Router.js 亦能夠看做是子節點的容器組件,它除了接收BrowserRouter提供的history api,最主要的功能就是組件自己會響應地址欄的變化進行setState進而完成react自己的rerender,使應用進行相應的UI切換,源碼以下**api

import warning from "warning";
import invariant from "invariant";
import React from "react";
import PropTypes from "prop-types";

/**
 * The public API for putting history on context.
 */
class Router extends React.Component {
    // react-router 4.x依然使用的使react舊版的context API
    // react-router 5.x將會做出升級
  static propTypes = {
    history: PropTypes.object.isRequired,
    children: PropTypes.node
  };
  // 此處是爲了可以接收父級容器傳遞的context router,不過父級不多有傳遞router的
  // 存在的目的是爲了方便用戶使用這種潛在的方式,來傳遞自定義的router對象
  static contextTypes = {
    router: PropTypes.object
  };
  // 傳遞給子組件的context api router, 能夠經過context上下文來得到
  static childContextTypes = {
    router: PropTypes.object.isRequired
  };
  // router 對象的具體值
  getChildContext() {
    return {
      router: {
        ...this.context.router,
        history: this.props.history, // 路由api等,會在history庫進行講解
        route: {
          location: this.props.history.location, // 也是history庫中的內容
          match: this.state.match // 對當前地址進行匹配的結果
        }
      }
    };
  }
  // Router組件的state,做爲一個頂層容器組件維護的state,存在兩個目的
  // 1.主要目的爲了實現自上而下的rerender,url改變的時候match對象會被更新
  // 2.Router組件是始終會被渲染的組件,match對象會隨時獲得更新,並通過context api
  // 傳遞給下游子組件route等
  state = {
    match: this.computeMatch(this.props.history.location.pathname)
  };
  // match 的4個參數
  // 1.path: 是要進行匹配的路徑能夠是 '/user/:id' 這種動態路由的模式
  // 2.url: 地址欄實際的匹配結果
  // 3.parmas: 動態路由所匹配到的參數,若是path是 '/user/:id'匹配到了,那麼
  // params的內容就是 {id: 某個值}
  // 4.isExact: 精準匹配即 地址欄的pathname 和 正則匹配到url是否徹底相等
  computeMatch(pathname) {
    return {
      path: "/",
      url: "/",
      params: {},
      isExact: pathname === "/"
    };
  }

  componentWillMount() {
    const { children, history } = this.props;
    // 當 子節點並不是由一個根節點包裹時 拋出錯誤提示開發者
    invariant(
      children == null || React.Children.count(children) === 1,
      "A <Router> may have only one child element"
    );

    // Do this here so we can setState when a <Redirect> changes the
    // location in componentWillMount. This happens e.g. when doing
    // server rendering using a <StaticRouter>.
    // 使用history.listen方法,在Router被實例化時註冊一個回調事件,
    // 即location地址發生改變的時候,會從新setState,進而rerender
    // 這裏使用willMount而不使用didMount的緣由時是由於,服務端渲染時不存在dom,
    // 故不會調用didMount的鉤子,react將在17版本移除此鉤子,那麼到時候router應該如何實現此功能?
    this.unlisten = history.listen(() => {
      this.setState({
        match: this.computeMatch(history.location.pathname)
      });
    });
  }
   // history參數不容許被更改
  componentWillReceiveProps(nextProps) {
    warning(
      this.props.history === nextProps.history,
      "You cannot change <Router history>"
    );
  }
  // 組件銷燬時 解綁history對象中的監聽事件
  componentWillUnmount() {
    this.unlisten();
  }
  // render的時候使用React.Children.only方法再驗證一次
  // children 必須是一個由根節點包裹的組件或dom
  render() {
    const { children } = this.props;
    return children ? React.Children.only(children) : null;
  }
}

export default Router;

總結:Router組件職責很清晰就是做爲容器組件,將上層組件的api進行向下的傳遞,同時組件自己註冊了回調方法,來知足瀏覽器環境下或者服務端環境下location發生變化時,從新setState,達到組件的rerender。那麼history對象究竟是怎麼實現對地址欄進行監聽的,又是如何對location進行push 或者 replace的,這就要看history這個庫作了啥。數組

clipboard.png

  1. createBrowserHistory.js 使用html5 history api封裝的路由控制器
  2. createHashHistory.js 使用hash方法封裝的路由控制器
  3. createMemoryHistory.js 針對native app這種原生應用封裝的路由控制器,即在內存中維護一份路由表
  4. createTransitionManager.js 針對路由切換時的相同操做抽離的一個公共方法,路由切換的操做器,攔截器和訂閱者都存在於此
  5. DOMUtils.js 針對web端dom操做或判斷兼容性的一個工具方法集合
  6. LocationUtils.js 針對location url處理等抽離的一個工具方法的集合
  7. PathUtils.js 用來處理url路徑的工具方法集合

這裏主要分析createBrowserHistory.js文件

import warning from 'warning'
import invariant from 'invariant'
import { createLocation } from './LocationUtils'
import {
  addLeadingSlash,
  stripTrailingSlash,
  hasBasename,
  stripBasename,
  createPath
} from './PathUtils'
import createTransitionManager from './createTransitionManager'
import {
  canUseDOM,
  addEventListener,
  removeEventListener,
  getConfirmation,
  supportsHistory,
  supportsPopStateOnHashChange,
  isExtraneousPopstateEvent
} from './DOMUtils'

const PopStateEvent = 'popstate'
const HashChangeEvent = 'hashchange'

const getHistoryState = () => {
  // ...
}

/**
 * Creates a history object that uses the HTML5 history API including
 * pushState, replaceState, and the popstate event.
 */
const createBrowserHistory = (props = {}) => {
  invariant(
    canUseDOM,
    'Browser history needs a DOM'
  )

  const globalHistory = window.history
  const canUseHistory = supportsHistory()
  const needsHashChangeListener = !supportsPopStateOnHashChange()

  const {
    forceRefresh = false,
    getUserConfirmation = getConfirmation,
    keyLength = 6
  } = props
  const basename = props.basename ? stripTrailingSlash(addLeadingSlash(props.basename)) : ''

  const getDOMLocation = (historyState) => {
     // ...
  }

  const createKey = () =>
    Math.random().toString(36).substr(2, keyLength)

  const transitionManager = createTransitionManager()

  const setState = (nextState) => {
     // ...
  }

  const handlePopState = (event) => {
    // ...
  }

  const handleHashChange = () => {
    // ...
  }

  let forceNextPop = false

  const handlePop = (location) => {
     // ...
  }

  const revertPop = (fromLocation) => {
    // ...
  }

  const initialLocation = getDOMLocation(getHistoryState())
  let allKeys = [ initialLocation.key ]

  // Public interface

  const createHref = (location) =>
    basename + createPath(location)

  const push = (path, state) => {
    // ...
  }

  const replace = (path, state) => {
    // ...
  }

  const go = (n) => {
    globalHistory.go(n)
  }

  const goBack = () =>
    go(-1)

  const goForward = () =>
    go(1)

  let listenerCount = 0

  const checkDOMListeners = (delta) => {
    // ...
  }

  let isBlocked = false

  const block = (prompt = false) => {
    // ...
  }

  const listen = (listener) => {
    // ...
  }

  const history = {
    length: globalHistory.length,
    action: 'POP',
    location: initialLocation,
    createHref,
    push,
    replace,
    go,
    goBack,
    goForward,
    block,
    listen
  }

  return history
}

export default createBrowserHistory

createBrowserHistory.js 總共300+行代碼,其原理就是封裝了原生的html5 的history api,如pushState,replaceState,當這些事件被觸發時會激活subscribe的回調來進行響應。同時也會對地址欄進行監聽,當history.go等事件觸發history popstate事件時,也會激活subscribe的回調。

因爲代碼量較多,並且依賴的方法較多,這裏將方法分紅幾個小節來進行梳理,對於依賴的方法先進行簡短闡述,當實際調用時在深刻源碼內部去探究實現細節

1. 依賴的工具方法

import warning from 'warning'  // 控制檯的console.warn警告
import invariant from 'invariant' // 用來拋出異常錯誤信息
// 對地址參數處理,最終返回一個對象包含 pathname,search,hash,state,key 等參數
import { createLocation } from './LocationUtils' 
import { 
  addLeadingSlash,  // 對傳遞的pathname添加首部`/`,即 'home' 處理爲 '/home',存在首部`/`的不作處理
  stripTrailingSlash,  // 對傳遞的pathname去掉尾部的 `/`
  hasBasename, // 判斷是否傳遞了basename參數
  stripBasename, // 若是傳遞了basename參數,那麼每次須要將pathname中的basename統一去除
  createPath // 將location對象的參數生成最終的地址欄路徑
} from './PathUtils'
import createTransitionManager from './createTransitionManager' // 抽離的路由切換的公共方法
import {
  canUseDOM,  // 當前是否可以使用dom, 即window對象是否存在,是不是瀏覽器環境下
  addEventListener, // 兼容ie 監聽事件
  removeEventListener, // 解綁事件
  getConfirmation,   // 路由跳轉的comfirm 回調,默認使用window.confirm
  supportsHistory, // 當前環境是否支持history的pushState方法
  supportsPopStateOnHashChange, // hashChange是否會觸發h5的popState方法,ie十、11並不會
  isExtraneousPopstateEvent // 判斷popState是否時真正有效的
} from './DOMUtils'

const PopStateEvent = 'popstate'  // 針對popstate事件的監聽
const HashChangeEvent = 'hashchange' // 針對不支持history api的瀏覽器 啓動hashchange監聽事件

// 返回history的state
const getHistoryState = () => {
  try {
    return window.history.state || {}
  } catch (e) {
    // IE 11 sometimes throws when accessing window.history.state
    // See https://github.com/ReactTraining/history/pull/289
    // IE11 下有時會拋出異常,此處保證state必定返回一個對象
    return {} 
  }
}

creareBrowserHistory的具體實現

const createBrowserHistory = (props = {}) => {
  // 當不在瀏覽器環境下直接拋出錯誤
  invariant(
    canUseDOM,
    'Browser history needs a DOM'
  )

  const globalHistory = window.history          // 使用window的history
  // 此處注意android 2. 和 4.0的版本而且ua的信息是 mobile safari 的history api是有bug且沒法解決的
  const canUseHistory = supportsHistory()      
  // hashChange的時候是否會進行popState操做,ie十、11不會進行popState操做 
  const needsHashChangeListener = !supportsPopStateOnHashChange()

  const {
    forceRefresh = false,                     // 默認切換路由不刷新
    getUserConfirmation = getConfirmation,    // 使用window.confirm
    keyLength = 6                             // 默認6位長度隨機key
  } = props
  // addLeadingSlash 添加basename頭部的斜槓
  // stripTrailingSlash 去掉 basename 尾部的斜槓
  // 若是basename存在的話,保證其格式爲 ‘/xxx’
  const basename = props.basename ? stripTrailingSlash(addLeadingSlash(props.basename)) : ''

  const getDOMLocation = (historyState) => {
       // 獲取history對象的key和state
    const { key, state } = (historyState || {})
     // 獲取當前路徑下的pathname,search,hash等參數
    const { pathname, search, hash } = window.location 
      // 拼接一個完整的路徑
    let path = pathname + search + hash               

    // 當傳遞了basename後,全部的pathname必須包含這個basename
    warning(
      (!basename || hasBasename(path, basename)),
      'You are attempting to use a basename on a page whose URL path does not begin ' +
      'with the basename. Expected path "' + path + '" to begin with "' + basename + '".'
    )
    
    // 去掉path當中的basename
    if (basename)
      path = stripBasename(path, basename)
    
    // 生成一個自定義的location對象
    return createLocation(path, state, key)
  }

  // 使用6位長度的隨機key
  const createKey = () =>
    Math.random().toString(36).substr(2, keyLength)

  // transitionManager是history中最複雜的部分,複雜的緣由是由於
  // 爲了實現block方法,作了對路由攔截的hack,雖然能實現對路由切時的攔截功能
  // 好比Prompt組件,但同時也帶來了不可解決的bug,後面在討論
  // 這裏返回一個對象包含 setPrompt、confirmTransitionTo、appendListener
  // notifyListeners 等四個方法
  const transitionManager = createTransitionManager()
  
  const setState = (nextState) => {
    // nextState包含最新的 action 和 location
    // 並將其更新到導出的 history 對象中,這樣Router組件相應的也會獲得更新
    // 能夠理解爲同react內部所作的setState時相同的功能
    Object.assign(history, nextState)
    // 更新history的length, 實實保持和window.history.length 同步
    history.length = globalHistory.length
    // 通知subscribe進行回調
    transitionManager.notifyListeners(
      history.location,
      history.action
    )
  }
  // 當監聽到popState事件時進行的處理
  const handlePopState = (event) => {
    // Ignore extraneous popstate events in WebKit.
    if (isExtraneousPopstateEvent(event))
      return 
    // 獲取當前地址欄的history state並傳遞給getDOMLocation
    // 返回一個新的location對象
    handlePop(getDOMLocation(event.state))
  }

  const handleHashChange = () => {
      // 監聽到hashchange時進行的處理,因爲hashchange不會更改state
      // 故此處不須要更新location的state
    handlePop(getDOMLocation(getHistoryState()))
  }
   // 用來判斷路由是否須要強制
  let forceNextPop = false
   // handlePop是對使用go方法來回退或者前進時,對頁面進行的更新,正常狀況下來講沒有問題
   // 可是若是頁面使用Prompt,即路由攔截器。當點擊回退或者前進就會觸發histrory的api,改變了地址欄的路徑
   // 而後彈出須要用戶進行確認的提示框,若是用戶點擊肯定,那麼沒問題由於地址欄改變的地址就是將要跳轉到地址
   // 可是若是用戶選擇了取消,那麼地址欄的路徑已經變成了新的地址,可是頁面實際還停留再以前,這就產生了bug
   // 這也就是 revertPop 這個hack的由來。由於頁面的跳轉能夠由程序控制,可是若是操做的自己是瀏覽器的前進後退
   // 按鈕,那麼是沒法作到真正攔截的。
  const handlePop = (location) => {
    if (forceNextPop) {
      forceNextPop = false
      setState()
    } else {
      const action = 'POP'

      transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
        if (ok) {
          setState({ action, location })
        } else {
            // 當攔截器返回了false的時候,須要把地址欄的路徑重置爲當前頁面顯示的地址
          revertPop(location)
        }
      })
    }
  }
   // 這裏是react-router的做者最頭疼的一個地方,由於雖然用hack實現了表面上的路由攔截
   // ,但也會引發一些特殊狀況下的bug。這裏先說一下如何作到的僞裝攔截,由於自己html5 history
   // api的特性,pushState 這些操做不會引發頁面的reload,全部作到攔截只須要不手懂調用setState頁面不進行render便可
   // 當用戶選擇了取消後,再將地址欄中的路徑變爲當前頁面的顯示路徑便可,這也是revertPop實現的方式
   // 這裏貼出一下對這個bug的討論:https://github.com/ReactTraining/history/issues/690
  const revertPop = (fromLocation) => {
      // fromLocation 當前地址欄真正的路徑,並且這個路徑必定是存在於history歷史
      // 記錄當中某個被訪問過的路徑,由於咱們須要將地址欄的這個路徑重置爲頁面正在顯示的路徑地址
      // 頁面顯示的這個路徑地址必定是還再history.location中的那個地址
      // fromLoaction 用戶本來想去可是後來又不去的那個地址,須要把他換位history.location當中的那個地址      
    const toLocation = history.location

    // TODO: We could probably make this more reliable by
    // keeping a list of keys we've seen in sessionStorage.
    // Instead, we just default to 0 for keys we don't know.
     // 取出toLocation地址再allKeys中的下標位置
    let toIndex = allKeys.indexOf(toLocation.key)

    if (toIndex === -1)
      toIndex = 0
     // 取出formLoaction地址在allKeys中的下標位置
    let fromIndex = allKeys.indexOf(fromLocation.key)

    if (fromIndex === -1)
      fromIndex = 0
     // 二者進行相減的值就是go操做須要回退或者前進的次數
    const delta = toIndex - fromIndex
     // 若是delta不爲0,則進行地址欄的變動 將歷史記錄重定向到當前頁面的路徑   
    if (delta) {
      forceNextPop = true // 將forceNextPop設置爲true
      // 更改地址欄的路徑,又會觸發handlePop 方法,此時因爲forceNextPop已經爲true則會執行後面的
      // setState方法,對當前頁面進行rerender,注意setState是沒有傳遞參數的,這樣history當中的
      // location對象依然是以前頁面存在的那個loaction,不會改變history的location數據
      go(delta) 
    }
  }

  // 返回一個location初始對象包含
  // pathname,search,hash,state,key key有多是undefined
  const initialLocation = getDOMLocation(getHistoryState())
  let allKeys = [ initialLocation.key ]

  // Public interface

  // 拼接上basename
  const createHref = (location) =>
    basename + createPath(location)

  const push = (path, state) => {
    warning(
      !(typeof path === 'object' && path.state !== undefined && state !== undefined),
      'You should avoid providing a 2nd state argument to push when the 1st ' +
      'argument is a location-like object that already has state; it is ignored'
    )

    const action = 'PUSH'
    const location = createLocation(path, state, createKey(), history.location)

    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
      if (!ok)
        return

      const href = createHref(location)  // 拼接basename
      const { key, state } = location

      if (canUseHistory) {
        globalHistory.pushState({ key, state }, null, href) // 只是改變地址欄路徑 此時頁面不會改變

        if (forceRefresh) {
          window.location.href = href // 強制刷新
        } else {
          const prevIndex = allKeys.indexOf(history.location.key) // 上次訪問的路徑的key
          const nextKeys = allKeys.slice(0, prevIndex === -1 ? 0 : prevIndex + 1)

          nextKeys.push(location.key) // 維護一個訪問過的路徑的key的列表
          allKeys = nextKeys

          setState({ action, location }) // render頁面
        }
      } else {
        warning(
          state === undefined,
          'Browser history cannot push state in browsers that do not support HTML5 history'
        )

        window.location.href = href
      }
    })
  }

  const replace = (path, state) => {
    warning(
      !(typeof path === 'object' && path.state !== undefined && state !== undefined),
      'You should avoid providing a 2nd state argument to replace when the 1st ' +
      'argument is a location-like object that already has state; it is ignored'
    )

    const action = 'REPLACE'
    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) {
        globalHistory.replaceState({ key, state }, null, href)

        if (forceRefresh) {
          window.location.replace(href)
        } else {
          const prevIndex = allKeys.indexOf(history.location.key)

          if (prevIndex !== -1)
            allKeys[prevIndex] = location.key

          setState({ action, location })
        }
      } else {
        warning(
          state === undefined,
          'Browser history cannot replace state in browsers that do not support HTML5 history'
        )

        window.location.replace(href)
      }
    })
  }

  const go = (n) => {
    globalHistory.go(n)
  }

  const goBack = () =>
    go(-1)

  const goForward = () =>
    go(1)

  let listenerCount = 0
   // 防止重複註冊監聽,只有listenerCount == 1的時候纔會進行監聽事件
  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)
    }
  }
  // 默認狀況下不會阻止路由的跳轉
  let isBlocked = false
  // 這裏的block方法專門爲Prompt組件設計,開發者能夠模擬對路由的攔截
  const block = (prompt = false) => {
      // prompt 默認爲false, prompt能夠爲string或者func
      // 將攔截器的開關打開,並返回可關閉攔截器的方法
    const unblock = transitionManager.setPrompt(prompt)
      // 監聽事件只會當攔截器開啓時被註冊,同時設置isBlock爲true,防止屢次註冊
    if (!isBlocked) {
      checkDOMListeners(1)
      isBlocked = true
    }
     // 返回關閉攔截器的方法
    return () => {
      if (isBlocked) {
        isBlocked = false
        checkDOMListeners(-1)
      }

      return unblock()
    }
  }

  const listen = (listener) => {
    const unlisten = transitionManager.appendListener(listener) // 添加訂閱者
    checkDOMListeners(1) // 監聽popState pushState 等事件

    return () => {
      checkDOMListeners(-1)
      unlisten()
    }
  }

  const history = {
    length: globalHistory.length,
    action: 'POP',
    location: initialLocation,
    createHref,
    push,
    replace,
    go,
    goBack,
    goForward,
    block,
    listen
  }

  return history
}

因爲篇幅過長,因此這裏抽取push方法來梳理整套流程

const push = (path, state) => {
      // push可接收兩個參數,第一個參數path能夠是字符串,或者對象,第二個參數是state對象
      // 裏面是能夠被瀏覽器緩存的數據,當path是一個對象而且path中的state存在,同時也傳遞了
      // 第二個參數state,那麼這裏就會給出警告,表示path中的state參數將會被忽略
      
    warning(
      !(typeof path === 'object' && path.state !== undefined && state !== undefined),
      'You should avoid providing a 2nd state argument to push when the 1st ' +
      'argument is a location-like object that already has state; it is ignored'
    )

     const action = 'PUSH' // 動做爲push操做
     //將即將訪問的路徑path, 被緩存的state,將要訪問的路徑的隨機生成的6位隨機字符串,
     // 上次訪問過的location對象也能夠理解爲當前地址欄里路徑對象,  
     // 返回一個對象包含 pathname,search,hash,state,key
    const location = createLocation(path, state, createKey(), history.location)
     // 路由的切換,最後一個參數爲回調函數,只有返回true的時候纔會進行路由的切換
    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
      if (!ok)
        return
      
      const href = createHref(location)  // 拼接basename
      const { key, state } = location  // 獲取新的key和state

      if (canUseHistory) {
          // 當可使用history api時候,調用原生的pushState方法更改地址欄路徑
          // 此時只是改變地址欄路徑 頁面並不會發生變化 須要手動setState從而rerender
        // pushState的三個參數分別爲,1.能夠被緩存的state對象,即刷新瀏覽器依然會保留
        // 2.頁面的title,可直接忽略 3.href即新的地址欄路徑,這是一個完整的路徑地址
        globalHistory.pushState({ key, state }, null, href) 
        
        if (forceRefresh) { 
          window.location.href = href // 強制刷新
        } else {
          // 獲取上次訪問的路徑的key在記錄列表裏的下標
          const prevIndex = allKeys.indexOf(history.location.key)
          // 當下標存在時,返回截取到當前下標的數組key列表的一個新引用,不存在則返回一個新的空數組
          // 這樣作的緣由是什麼?爲何不每次訪問直接向allKeys列表中直接push要訪問的key
          // 好比這樣的一種場景, 1-2-3-4 的頁面訪問順序,這時候使用go(-2) 回退到2的頁面,假如在2
          // 的頁面咱們選擇了push進行跳轉到4頁面,若是隻是簡單的對allKeys進行push操做那麼順序就變成了
          // 1-2-3-4-4,這時候就會產生一悖論,從4頁面跳轉4頁面,這種邏輯是不通的,因此每當push或者replace
          // 發生的時候,必定是用當前地址欄中path的key去截取allKeys中對應的訪問記錄,來保證不會push
          // 連續相同的頁面
          const nextKeys = allKeys.slice(0, prevIndex === -1 ? 0 : prevIndex + 1)

          nextKeys.push(location.key) // 將新的key添加到allKeys中
          allKeys = nextKeys // 替換

          setState({ action, location }) // render頁面
        }
      } else {
        warning(
          state === undefined,
          'Browser history cannot push state in browsers that do not support HTML5 history'
        )

        window.location.href = href
      }
    })
  }

createLocation的源碼

export const createLocation = (path, state, key, currentLocation) => {
  let location
  if (typeof path === 'string') {
    // Two-arg form: push(path, state)
    // 分解pathname,path,hash,search等,parsePath返回一個對象
    location = parsePath(path)
    location.state = state 
  } else {
    // One-arg form: push(location)
    location = { ...path }

    if (location.pathname === undefined)
      location.pathname = ''

    if (location.search) {
      if (location.search.charAt(0) !== '?')
        location.search = '?' + location.search
    } else {
      location.search = ''
    }

    if (location.hash) {
      if (location.hash.charAt(0) !== '#')
        location.hash = '#' + location.hash
    } else {
      location.hash = ''
    }

    if (state !== undefined && location.state === undefined)
      location.state = state
  }

  // 嘗試對pathname進行decodeURI解碼操做,失敗時進行提示
  try {
    location.pathname = decodeURI(location.pathname)
  } catch (e) {
    if (e instanceof URIError) {
      throw new URIError(
        'Pathname "' + location.pathname + '" could not be decoded. ' +
        'This is likely caused by an invalid percent-encoding.'
      )
    } else {
      throw e
    }
  }

  if (key)
    location.key = key

  if (currentLocation) {
    // Resolve incomplete/relative pathname relative to current location.
    if (!location.pathname) {
      location.pathname = currentLocation.pathname
    } else if (location.pathname.charAt(0) !== '/') {
      location.pathname = resolvePathname(location.pathname, currentLocation.pathname)
    }
  } else {
    // When there is no prior location and pathname is empty, set it to /
    // pathname 不存在的時候返回當前路徑的根節點
    if (!location.pathname) {
      location.pathname = '/'
    }
  }

  // 返回一個location對象包含
  // pathname,search,hash,state,key
  return location
}

createTransitionManager.js的源碼

import warning from 'warning'

const createTransitionManager = () => {
  // 這裏使一個閉包環境,每次進行路由切換的時候,都會先進行對prompt的判斷
  // 當prompt != null 的時候,表示路由的上次切換被阻止了,那麼當用戶confirm返回true
  // 的時候會直接進行地址欄的更新和subscribe的回調
  let prompt = null // 提示符
  
  const setPrompt = (nextPrompt) => {
      // 提示prompt只能存在一個
    warning(
      prompt == null,
      'A history supports only one prompt at a time'
    )

    prompt = nextPrompt
     // 同時將解除block的方法返回
    return () => {
      if (prompt === nextPrompt)
        prompt = null
    }
  }
  // 
  const confirmTransitionTo = (location, action, getUserConfirmation, callback) => {
    // TODO: If another transition starts while we're still confirming
    // the previous one, we may end up in a weird state. Figure out the
    // best way to handle this.
    if (prompt != null) {
      // prompt 能夠是一個函數,若是是一個函數返回執行的結果
      const result = typeof prompt === 'function' ? prompt(location, action) : prompt
       // 當prompt爲string類型時 基本上就是爲了提示用戶即將要跳轉路由了,prompt就是提示信息
      if (typeof result === 'string') {
          // 調用window.confirm來顯示提示信息
        if (typeof getUserConfirmation === 'function') {
            // callback接收用戶 選擇了true或者false
          getUserConfirmation(result, callback)
        } else {
            // 提示開發者 getUserConfirmatio應該是一個function來展現阻止路由跳轉的提示
          warning(
            false,
            'A history needs a getUserConfirmation function in order to use a prompt message'
          )
          // 至關於用戶選擇true 不進行攔截
          callback(true)
        }
      } else {
        // Return false from a transition hook to cancel the transition.
        callback(result !== false)
      }
    } else {
        // 當不存在prompt時,直接執行回調函數,進行路由的切換和rerender
      callback(true)
    }
  }
   // 被subscribe的列表,即在Router組件添加的setState方法,每次push replace 或者 go等操做都會觸發
  let listeners = []
  // 將回調函數添加到listeners,一個發佈訂閱模式
  const appendListener = (fn) => {
    let isActive = true
     // 這裏有個奇怪的地方既然訂閱事件能夠被解綁就直接被從數組中刪除掉了,爲何這裏還須要這個isActive
     // 再加一次判斷呢,實際上是爲了不一種狀況,好比註冊了多個listeners: a,b,c 可是在a函數中註銷了b函數
     // 理論上來講b函數應該不能在執行了,可是註銷方法裏使用的是數組的filter,每次返回的是一個新的listeners引用,
     // 故每次解綁若是不添加isActive這個開關,那麼當前循環仍是會執行b的事件。加上isActive後,原始的liteners中
     // 的閉包b函數的isActive會變爲false,從而阻止事件的執行,當循環結束後,原始的listeners也會被gc回收
    const listener = (...args) => {
      if (isActive)
        fn(...args)
    }

    listeners.push(listener)
     
    return () => {
      isActive = false
      listeners = listeners.filter(item => item !== listener)
    }
  }
  // 通知被訂閱的事件開始執行
  const notifyListeners = (...args) => {
    listeners.forEach(listener => listener(...args))
  }

  return {
    setPrompt,
    confirmTransitionTo,
    appendListener,
    notifyListeners
  }
}

export default createTransitionManager

因爲篇幅太長,本身都看的蒙圈了,如今就簡單作一下總結,描述router工做的原理。
1.首先BrowserRouter經過history庫使用createBrowserHistory方法建立了一個history對象,並將此對象做爲props傳遞給了Router組件
2.Router組件使用history對的的listen方法,註冊了組件自身的setState事件,這樣同樣來,只要觸發了html5的popstate事件,組件就會執行setState事件,完成整個應用的rerender
3.history是一個對象,裏面包含了操做頁面跳轉的方法,以及當前地址欄對象的location的信息。首先當建立一個history對象時候,會使用props當中的四個參數信息,forceRefresh、basename、getUserComfirmation、keyLength 來生成一個初始化的history對象,四個參數均不是必傳項。首先會使用window.location對象獲取當前路徑下的pathname、search、hash等參數,同時若是頁面是通過rerolad刷新過的頁面,那麼也會保存以前向state添加過數據,這裏除了咱們本身添加的state,還有history這個庫本身每次作push或者repalce操做的時候隨機生成的六位長度的字符串key
拿到這個初始化的location對象後,history開始封裝push、replace、go等這些api。
以push爲例,能夠接收兩個參數push(path, state)----咱們經常使用的寫法是push('/user/list'),只須要傳遞一個路徑不帶參數,或者push({pathname: '/user', state: {id: 'xxx'}, search: '?name=xxx', hash: '#list'})傳遞一個對象。任何對地址欄的更新都會通過confirmTransitionTo 這個方法進行驗證,這個方法是爲了支持prompt攔截器的功能。正常在攔截器關閉的狀況下,每次調用push或者replace都會隨機生成一個key,表明這個路徑的惟一hash值,並將用戶傳遞的state和key做爲state,注意這部分state會被保存到 瀏覽器 中是一個長效的緩存,將拼接好的path做爲傳遞給history的第三個參數,調用history.pushState(state, null, path),這樣地址欄的地址就獲得了更新。
地址欄地址獲得更新後,頁面在不使用foreceRefrsh的狀況下是不會自動更新的。此時須要循環執行在建立history對象時,在內存中的一個listeners監聽隊列,即在步驟2中在Router組件內部註冊的回調,來手動完成頁面的setState,至此一個完整的更新流程就算走完了。
在history裏有一個block的方法,這個方法的初衷是爲了實現對路由跳轉的攔截。咱們知道瀏覽器的回退和前進操做按鈕是沒法進行攔截的,只能作hack,這也是history庫的作法。抽離出了一個路徑控制器,方法名稱叫作createTransitionManager,能夠理解爲路由操做器。這個方法在內部維護了一個prompt的攔截器開關,每當這個開關打開的時候,全部的路由在跳轉前都會被window.confirm所攔截。注意此攔截並不是真正的攔截,雖然頁面沒有改變,可是地址欄的路徑已經改變了。若是用戶沒有取消攔截,那麼頁面依然會停留在當前頁面,這樣和地址欄的路徑就產生了悖論,因此須要將地址欄的路徑再重置爲當前頁面真正渲染的頁面。爲了實現這一功能,不得不建立了一個用隨機key值的來表示的訪問過的路徑表allKeys。每次頁面被攔截後,都須要在allKeys的列表中找到當前路徑下的key的下標,以及實際頁面顯示的location的key的下標,後者減前者的值就是頁面要被回退或者前進的次數,調用go方法後會再次觸發popstate事件,形成頁面的rerender。
正式由於有了Prompt組件纔會使history不得不增長了key列表,prompt開關,致使代碼的複雜度成倍增長,同時不少開發者在開發中對此組件的濫用也致使了一些特殊的bug,而且這些bug都是沒法解決的,這也是做者爲何想要在下個版本中移除此api的原因。討論地址在連接描述

。下篇將會進行對Route Switch Link等其餘組件的講解

相關文章
相關標籤/搜索