react-router源碼解析

前言

上篇文章介紹了前端路由的兩種實現原理,今天我想從react-router源碼分析下他們是如何管理前端路由的。由於以前一直都是使用V4的版本,因此接下來分析的也是基於react-router v4.4.0版本的(如下簡稱 V4),歡迎你們提出評論交流。Let's get started。前端

學前知識

在分析源碼前先回顧如下相關知識,有利於更好的理解源碼設計。react

  1. react中如何實現不一樣的路由渲染不一樣的組件?

react 做爲一個前端視圖框架,自己是不具備除了 view(數據與界面之間的抽象)以外的任何功能的;上篇文章中咱們是經過觸發的回調函數來操做 DOM ,而在 react 中咱們不直接操做 DOM,而是管理抽象出來的 VDOM 或者說 JSX,對 react 的來講路由須要管理組件的生命週期,對不一樣的路由渲染不一樣的組件。git

  1. history(第三方庫)的使用

由於React-Router 是基於history這個庫來實現對路由變化的監聽,因此咱們下面會先對這個庫進行簡單的分析。固然咱們主要分析它的監聽模式listen是如何實現的,這對實現路由是相當重要的,想了解更多其餘的API,請移步history學習更多。github

history

history基本用法是這樣的:react-native

import { createBrowserHistory } from 'history';

const history = createBrowserHistory();

// 獲取當前location。
const location = history.location;

// 監聽當前location的更改。
const unlisten = history.listen((location, action) => {
  // location是一個相似window.location的對象
  console.log(action, location.pathname, location.state);
});

// 使用push、replace和go來導航。
history.push('/home', { some: 'state' });

// 若要中止監聽,請調用listen()返回的函數.
unlisten();
複製代碼

咱們查看源碼modules下面的index.js,能夠看出history 暴露出了七個方法:數組

export { default as createBrowserHistory } from './createBrowserHistory';
export { default as createHashHistory } from './createHashHistory';
export { default as createMemoryHistory } from './createMemoryHistory';
export { createLocation, locationsAreEqual } from './LocationUtils';
export { parsePath, createPath } from './PathUtils';
複製代碼

經過上面的例子咱們來簡單比較createBrowserHistorycreateHashHistory:瀏覽器

  • createHashHistory使用 URL 中的 hash(#)部分去建立形如 example.com/#/some/path的路由。

    createHashHistory源碼簡析:react-router

    function getHashPath() {
        // 咱們不能使用window.location.hash,由於它不是跨瀏覽器一致- Firefox將預解碼它!
        const href = window.location.href;
        const hashIndex = href.indexOf('#');
        return hashIndex === -1 ? '' : href.substring(hashIndex + 1);//函數返回hush值
      }
    複製代碼
  • createBrowserHistory使用瀏覽器中的 History API 用於處理 URL,建立一個像example.com/some/path這樣真實的 URL 。

    createBrowserHistory.js源碼簡析:app

    //createBrowserHistory.js
    const PopStateEvent = 'popstate';//變量,下面window監聽事件popstate用到
    const HashChangeEvent = 'hashchange';//變量,下面window監聽事件hashchange用到
    
    function createBrowserHistory(props = {}) {
        invariant(canUseDOM, 'Browser history needs a DOM');
    
        const globalHistory = window.history; // 建立一個使用HTML5 history API(包括)的history對象
        const canUseHistory = supportsHistory();
        const needsHashChangeListener = !supportsPopStateOnHashChange();
        //...
        //push方法
        function push(path, state) {
            if (canUseHistory) {
                globalHistory.pushState({ key, state }, null, href);//在push方法內使用pushState
              ...
        }
        //replace方法
        function replace(path, state) {
            if (canUseHistory) {
                globalHistory.replaceState({ key, state }, null, href);//在replaceState方法內使用replaceState
            }
        }
        let listenerCount = 0;
        //註冊路由監聽事件
        function checkDOMListeners(delta) {
            listenerCount += delta;
    
            if (listenerCount === 1 && delta === 1) {
                window.addEventListener(PopStateEvent, handlePopState);//popstate監聽前進/後退事件
    
                if (needsHashChangeListener)
                window.addEventListener(HashChangeEvent, handleHashChange);//hashchange監聽 URL 的變化
            } else if (listenerCount === 0) {
                window.removeEventListener(PopStateEvent, handlePopState);
    
                if (needsHashChangeListener)
                window.removeEventListener(HashChangeEvent, handleHashChange);
            }
        }
        //路由監聽
        function listen(listener) {
            const unlisten = transitionManager.appendListener(listener);
            checkDOMListeners(1);
        
            return () => {
                checkDOMListeners(-1);
                unlisten();
            };
         }
    複製代碼

listen如何觸發監聽:框架

上面createBrowserHistory.js中也有介紹如何註冊路由監聽,咱們再看下如何觸發路由監聽者。 分析事件監聽的回調函數handlePopState ,其最終是經過setState 來觸發路由監聽者,其中notifyListeners 會調用全部的listen 的回調函數,從而達到通知監聽路由變化的監聽者。

//createBrowserHistory.js
const transitionManager = createTransitionManager();

  function setState(nextState) {
    Object.assign(history, nextState);
    history.length = globalHistory.length;
   // 調用全部的listen 的回調函數,從而達到通知監聽路由變化的監聽者
    transitionManager.notifyListeners(history.location, history.action);
  }
  //事件監聽的回調函數handlePopState
  function handlePopState(event) {
    // 忽略WebKit中無關的popstate事件。
    if (isExtraneousPopstateEvent(event)) return;
    handlePop(getDOMLocation(event.state));
  }

  let forceNextPop = false;
  //回調執行函數
  function handlePop(location) {
    if (forceNextPop) {
      forceNextPop = false;
      setState();
    } else {
      const action = 'POP';

      transitionManager.confirmTransitionTo(
        location,
        action,
        getUserConfirmation,
        ok => {
          if (ok) {
            setState({ action, location });
          } else {
            revertPop(location);
          }
        }
      );
    }
  }
複製代碼
// createTransitionManager.js
    function notifyListeners(...args) {
        //調用全部的listen 的回調函數
        listeners.forEach(listener => listener(...args));
    }
複製代碼

react-routerRouter 組件的componentWillMount 生命週期中就調用了history.listen調用,從而達到當路由變化, 會去調用setState 方法, 從而去Render 對應的路由組件。

// react-router/Router.js
constructor(props) {
    super(props);

    this.state = {
      location: props.history.location
    };
    //調用history.lesten方法監聽,setState渲染組件
    this.unlisten = props.history.listen(location => {
      this.setState({ location });
    });
  }

  componentWillUnmount() {
    //路由監聽
    this.unlisten();
  }
複製代碼

小結:以上分析了react-router如何使用第三方庫history監聽路由變化的過程,下面將介紹react-router是如何結合history作到SPA路由變化達到渲染不一樣組件的效果的,咱們先看下react-router的基本使用,梳理解析思路。

react-router基本使用

//引用官方例子
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;
複製代碼

V4 將路由拆成了如下幾個包:

  • react-router 負責通用的路由邏輯
  • react-router-dom 負責瀏覽器的路由管理
  • react-router-native 負責 react-native 的路由管理

用戶只需引入 react-router-domreact-router-native 便可,react-router 做爲依賴存在再也不須要單獨引入。

React Router中有三種類型的組件:

  • 路由器組件(<BrowserRouter>、<HashRouter>)
  • 路由匹配組件(<Route>、<Switch>)
  • 導航組件(<Link>、<NavLink>、<Redirect>)。

上面Demo中咱們也正使用了這三類組件,爲了方便分析源碼,咱們能夠梳理一個基本流程出來:

  • 使用<BrowserRouter>建立一個專門的history對象,並註冊監聽事件。
  • 使用<Route>匹配的path,並渲染匹配的組件。
  • 使用<Link>建立一個連接跳轉到你想要渲染的組件。

下面咱們就根據上述流程步驟,一步一步解析react-router 代碼實現。

BrowserRouter

/* react-router-dom/BrowserRouter.js */
//從history引入createBrowserHistory
import { createBrowserHistory as createHistory } from "history";
import warning from "warning";
//導入react-router 的 Router 組件
import Router from "./Router";

class BrowserRouter extends React.Component {
  //建立全局的history對象,這裏用的是HTML5 history
  history = createHistory(this.props);

  render() {
  //將 history 做爲 props 傳遞給 react-router 的 Router 組件
    return <Router history={this.history} children={this.props.children} />; } } 複製代碼

BrowserRouter 的源碼在 react-router-dom 中,它是一個高階組件,在內部建立一個全局的 history 對象(能夠監聽整個路由的變化),並將 history 做爲 props 傳遞給 react-routerRouter 組件(Router 組件再會將這個 history 的屬性做爲 context 傳遞給子組件)。以下,藉助 context 向 Route 傳遞組件,這也解釋了爲何 Router 要在全部 Route 的外面。

//react-router/Router.js
import React from "react";

import RouterContext from "./RouterContext";

//獲取history、location、match...
function getContext(props, state) {
  return {
    history: props.history,
    location: state.location,
    match: Router.computeRootMatch(state.location.pathname),
    staticContext: props.staticContext
  };
}

class Router extends React.Component {
  //定義Router組件的match屬性字段
  static computeRootMatch(pathname) {
    return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
    /* * path: "/", // 用來匹配的 path * url: "/", // 當前的 URL * params: {}, // 路徑中的參數 * isExact: pathname === "/" // 是否爲嚴格匹配 */
  }
  
  constructor(props) {
    super(props);

    this.state = {
      location: props.history.location
    };
    //監聽路由的變化並執行回調事件,回調內setState
    this.unlisten = props.history.listen(location => {
      this.setState({ location });
        /* *hash: "" // hash *key: "nyi4ea" // 一個 uuid *pathname: "/explore" // URL 中路徑部分 *search: "" // URL 參數 *state: undefined // 路由跳轉時傳遞的 state */
    });
  }

  componentWillUnmount() {
    //組件卸載時中止監聽
    this.unlisten();
  }

  render() {
    const context = getContext(this.props, this.state);

    return (
      <RouterContext.Provider children={this.props.children || null} value={context}<!--藉助 contextRoute 傳遞組件--> /> ); } } export default Router; 複製代碼

相比於在監聽的回調裏setState 作的操做,setState 自己的意義更大 —— 每次路由變化 -> 觸發頂層 Router 的回調事件 -> Router 進行 setState -> 向下傳遞 nextContextcontext 中含有最新的 location)-> 下面的 Route 獲取新的 nextContext 判斷是否進行渲染。

Route

源碼中有這樣介紹:"用於匹配單個路徑和呈現的公共API"。簡單理解爲找到location<router>path匹配的組件並渲染。

//react-router/Route.js
//判斷Route的子組件是否爲空
function isEmptyChildren(children) {
  return React.Children.count(children) === 0;
}
//獲取history、location、match...
//父組件沒傳的話使用context中的
function getContext(props, context) {
  const location = props.location || context.location;
  const match = props.computedMatch
    ? props.computedMatch // <Switch> already computed the match for us
    : props.path
      ? matchPath(location.pathname, props)//在./matchPath.js中匹配location.pathname與path
      : context.match;

  return { ...context, location, match };
}
class Route extends React.Component {

  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          invariant(context, "You should not use <Route> outside a <Router>");

          const props = getContext(this.props, context);
          //context 更新 props 和 nextContext會從新匹配
          let { children, component, render } = this.props;
          // 提早使用一個空數組做爲children默認值,若是是這樣,就使用null。
          if (Array.isArray(children) && children.length === 0) {
            children = null;
          }

          if (typeof children === "function") {
            children = children(props);

            if (children === undefined) {

              children = null;
            }
          }

          return (
            <RouterContext.Provider value={props}>
              {children && !isEmptyChildren(children)
                ? children    
                : props.match//對應三種渲染方式children、component、render,只能使用一種
                  ? component
                    ? React.createElement(component, props)
                    : render
                      ? render(props)
                      : null
                  : null}
            </RouterContext.Provider>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}
複製代碼

Route 接受上層的 Router 傳入的 contextRouter 中的 history 監聽着整個頁面的路由變化,當頁面發生跳轉時,history 觸發監聽事件,Router 向下傳遞 nextContext,就會更新 Routepropscontext 來判斷當前 Routepath 是否匹配 location,若是匹配則渲染,不然不渲染。

是否匹配的依據就是 matchPath 這個函數,在下文會有分析,這裏只須要知道匹配失敗則 matchnull,若是匹配成功則將 match 的結果做爲 props 的一部分,在 render 中傳遞給傳進來的要渲染的組件。

從render 方法能夠知道有三種渲染組件的方法(childrencomponentrender)渲染的優先級也是依次按照順序,若是前面的已經渲染後了,將會直接 return。

  • children (若是children 是一個方法, 則執行這個方法, 若是隻是一個子元素,則直接render 這個元素)
  • component (直接傳遞一個組件, 而後去render 組件)
  • render(render 是一個方法, 經過方法去render 這個組件)

接下來咱們看下 matchPath 是如何判斷 location 是否符合 path 的。

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

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

  const { regexp, keys } = compilePath(path, { end: exact, strict, sensitive });
  const match = regexp.exec(pathname);

  if (!match) return null;

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

  if (exact && !isExact) return null;

  return {
    path, // 用來進行匹配的路徑,實際上是直接導出的傳入 matchPath 的 options 中的 path
    url: path === "/" && url === "" ? "/" : url, // URL的匹配部分
    isExact, // url 與 path 是不是 exact 的匹配
    // 返回的是一個鍵值對的映射
    // 好比你的 path 是 /users/:id,而後匹配的 pathname 是 /user/123
    // 那麼 params 的返回值就是 {id: '123'}
    params: keys.reduce((memo, key, index) => {
      memo[key.name] = values[index];
      return memo;
    }, {})
  };
}
複製代碼

Link

class Link extends React.Component {
  static defaultProps = {
    replace: false
  };

  handleClick(event, context) {
    if (this.props.onClick) this.props.onClick(event);

    if (
      !event.defaultPrevented && // 阻止默認事件
      event.button === 0 && // 忽略除左擊以外的全部內容
      !this.props.target && // 讓瀏覽器處理「target=_blank」等。
      !isModifiedEvent(event) // 忽略帶有修飾符鍵的單擊
    ) {
      event.preventDefault();

      const method = this.props.replace
        ? context.history.replace
        : context.history.push;

      method(this.props.to);
    }
  }

  render() {
    const { innerRef, replace, to, ...props } = this.props;
    // eslint-disable-line no-unused-vars

    return (
      <RouterContext.Consumer>
        {context => {
          invariant(context, "You should not use <Link> outside a <Router>");

          const location =
            typeof to === "string"
              ? createLocation(to, null, null, context.location)
              : to;
          const href = location ? context.history.createHref(location) : "";

          return (
            <a
              {...props}
              onClick={event => this.handleClick(event, context)}
              href={href}
              ref={innerRef}
            />
          );
        }}
      </RouterContext.Consumer>
    );
  }
}
複製代碼

從render來看,Link其實就是一個<a>標籤,在 handleClick中,對沒有被 preventDefault的 && 鼠標左鍵點擊的 && 非 _blank 跳轉 的&& 沒有按住其餘功能鍵的單擊進行 preventDefault,而後 pushhistory 中,這也是前面講過的 —— 路由的變化 與 頁面的跳轉 是不互相關聯的,V4Link 中經過 history 庫的 push 調用了 HTML5 historypushState,可是這僅僅會讓路由變化,其餘什麼都沒有改變。還記不記得 Router 中的 listen,它會監聽路由的變化,而後經過 context 更新 propsnextContext 讓下層的 Route 去從新匹配,完成須要渲染部分的更新。

總結

讓咱們回想下咱們看完基礎用法梳理的流程:

  • 使用<BrowserRouter>建立一個專門的history對象,並註冊監聽事件。
  • 使用<Route>匹配的path,並渲染匹配的組件。
  • 使用<Link>建立一個連接跳轉到你想要渲染的組件。

結合源碼咱們再分析下具體實現

  1. 使用BrowserRouterrender一個Router時建立了一個全局的history對象,並經過props傳遞給了Router,而在Router中設置了一個監聽函數,使用的是history庫的listen,觸發的回調裏面進行了setState向下傳遞 nextContext。
  2. 當點擊頁面的Link是,實際上是點擊的a標籤,只不過使用了 preventDefault 阻止 a 標籤的頁面跳轉;經過給a標籤添加點擊事件去執行 hitsory.push(to)
  3. 路由改變是會觸發 RoutersetState 的,在 Router 那章有寫道:每次路由變化 -> 觸發頂層 Router 的監聽事件 -> Router 觸發 setState -> 向下傳遞新的 nextContextnextContext 中含有最新的 location)。
  4. Route 接受新的 nextContext 經過 matchPath 函數來判斷 path 是否與 location 匹配,若是匹配則渲染,不匹配則不渲染。
相關文章
相關標籤/搜索