手寫React-Router源碼,深刻理解其原理

上一篇文章咱們講了React-Router的基本用法,並實現了常見的前端路由鑑權。本文會繼續深刻React-Router講講他的源碼,套路仍是同樣的,咱們先用官方的API實現一個簡單的例子,而後本身手寫這些API來替換官方的而且保持功能不變。javascript

本文所有代碼已經上傳GitHub,你們能夠拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-router-code前端

簡單示例

本文用的例子是上篇文章開始那個不帶鑑權的簡單路由跳轉例子,跑起來是這樣子的:vue

Jul-10-2020 17-35-36

咱們再來回顧下代碼,在app.js裏面咱們用Route組件渲染了幾個路由:java

import React from 'react';
import {
  BrowserRouter as Router,
  Switch,
  Route,
} from "react-router-dom";
import Home from './pages/Home';
import Login from './pages/Login';
import Backend from './pages/Backend';
import Admin from './pages/Admin';


function App() {
  return (
    <Router>
      <Switch>
        <Route path="/login" component={Login}/>
        <Route path="/backend" component={Backend}/>
        <Route path="/admin" component={Admin}/>
        <Route path="/" component={Home}/>
      </Switch>
    </Router>
  );
}

export default App;

每一個頁面的代碼都很簡單,只有一個標題和回首頁的連接,好比登陸頁長這樣,其餘幾個頁面相似:react

import React from 'react';
import { Link } from 'react-router-dom';

function Login() {
  return (
    <>
      <h1>登陸頁</h1>
      <Link to="/">回首頁</Link>
    </>
  );
}

export default Login;

這樣咱們就完成了一個最簡單的React-Router的應用示例,咱們來分析下咱們用到了他的哪些API,這些API就是咱們今天要手寫的目標,仔細一看,咱們好像只用到了幾個組件,這幾個組件都是從react-router-dom導出來的:android

BrowserRouter: 被咱們重命名爲了 Router,他包裹了整個 React-Router應用,感受跟 之前寫過的react-reduxProvider相似,我猜是用來注入 context之類的。

Route: 這個組件是用來定義具體的路由的,接收路由地址path和對應渲染的組件做爲參數。ios

Switch:這個組件是用來設置匹配模式的,不加這個的話,若是瀏覽器地址匹配到了多個路由,這幾個路由都會渲染出來,加了這個只會渲染匹配的第一個路由組件。git

Link:這個是用來添加跳轉連接的,功能相似於原生的a標籤,我猜他裏面也是封裝了一個a標籤。github

BrowserRouter源碼

咱們代碼裏面最外層的就是BrowserRouter,咱們先去看看他的源碼幹了啥,地址傳送門:https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/BrowserRouter.jsweb

看了他的源碼,咱們發現BrowserRouter代碼很簡單,只是一個殼:

import React from "react";
import { Router } from "react-router";
import { createBrowserHistory as createHistory } from "history";

class BrowserRouter extends React.Component {
  history = createHistory(this.props);

  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

在這個殼裏面還引用了兩個庫react-routerhistoryBrowserRouter僅僅是調用historycreateHistory獲得一個history對象,而後用這個對象渲染了react-routerRouter組件。看起來咱們要搞懂react-router-dom的源碼還必須得去看react-routerhistory的源碼,如今咱們手上有好幾個須要搞懂的庫了,爲了看懂他們的源碼,咱們得先理清楚他們的結構關係。

React-Router的項目結構

React-Router的結構是一個典型的monorepomonorepo這兩年開始流行了,是一種比較新的多項目管理方式,與之相對的是傳統的multi-repo。好比React-Router的項目結構是這樣的:

image-20200727172427353

注意這裏的packages文件夾下面有四個文件夾,這四個文件夾每一個均可以做爲一個單獨的項目發佈。之因此把他們放在一塊兒,是由於他們以前有很強的依賴關係:

react-router:是 React-Router的核心庫,處理一些共用的邏輯

react-router-config:是React-Router的配置處理,咱們通常不須要使用

react-router-dom:瀏覽器上使用的庫,會引用react-router核心庫

react-router-native:支持React-Native的路由庫,也會引用react-router核心庫

像這樣多個倉庫,發佈多個包的狀況,傳統模式是給每一個庫都建一個git repo,這種方式被稱爲multi-repo。像React-Router這樣將多個庫放在同一個git repo裏面的就是monorepo。這樣作的好處是若是出了一個BUG或者加一個新功能,須要同時改react-routerreact-router-dommonorepo只須要一個commit一次性就改好了,發佈也能夠一塊兒發佈。若是是multi-repo則須要修改兩個repo,而後分別發佈兩個repo,發佈的時候還要協調兩個repo之間的依賴關係。因此如今不少開源庫都使用monorepo來將依賴很強的模塊放在一個repo裏面,好比React源碼也是一個典型的monorepo

image-20200727174904352

yarn有一個workspaces能夠支持monorepo,使用這個功能須要在package.json裏面配置workspaces,好比這樣:

"workspaces": {
    "packages": [
      "packages/*"
    ]
  }

扯遠了,monorepo能夠後面單獨開一篇文章來說,這裏講這個主要是爲了說明React-Router分拆成了多個包,這些包之間是有比較強的依賴的。

前面咱們還用了一個庫是history,這個庫沒在React-Routermonorepo裏面,而是單獨的一個庫,由於官方把他寫的功能很獨立了,不必定非要結合React-Router使用,在其餘地方也可使用。

React-Router架構思路

我以前另外一篇文章講Vue-Router的原理提到過,前端路由實現無非這幾個關鍵點:

  1. 監聽URL的改變
  2. 改變vue-router裏面的current變量
  3. 監視current變量
  4. 獲取對應的組件
  5. render新組件

其實React-Router的思路也是相似的,只是React-Router將這些功能拆分得更散,監聽URL變化獨立成了history庫,vue-router裏面的current變量在React裏面是用Context API實現的,並且放到了核心庫react-router裏面,一些跟平臺相關的組件則放到了對應的平臺庫react-router-dom或者react-router-native裏面。按照這個思路,咱們本身寫的React-Router文件夾下面也建幾個對應的文件夾:

image-20200728155030839

手寫本身的React-Router

而後咱們順着這個思路一步一步的將咱們代碼裏面用到的API替換成本身的。

BrowserRouter組件

BrowserRouter這個代碼前面看過,直接抄過來就行:

import React from "react";
import { Router } from "react-router";
import { createBrowserHistory as createHistory } from "history";

class BrowserRouter extends React.Component {
  history = createHistory(this.props);

  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

export default BrowserRouter;

react-router的Router組件

上面的BrowserRouter用到了react-routerRouter組件,這個組件在瀏覽器和React-Native端都有使用,主要獲取當前路由並經過Context API將它傳遞下去:

import React from "react";

import HistoryContext from "./HistoryContext.js";
import RouterContext from "./RouterContext.js";

/**
 * The public API for putting history on context.
 */
class Router extends React.Component {
  // 靜態方法,檢測當前路由是否匹配
  static computeRootMatch(pathname) {
    return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
  }

  constructor(props) {
    super(props);

    this.state = {
      location: props.history.location     // 將history的location掛載到state上
    };

    // 下面兩個變量是防護性代碼,防止根組件還沒渲染location就變了
    // 若是location變化時,當前根組件還沒渲染出來,就先記下他,等當前組件mount了再設置到state上
    this._isMounted = false;
    this._pendingLocation = null;

    // 經過history監聽路由變化,變化的時候,改變state上的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();
      this._isMounted = false;
      this._pendingLocation = null;
    }
  }

  render() {
    // render的內容很簡單,就是兩個context
    // 一個是路由的相關屬性,包括history和location等
    // 一個只包含history信息,同時將子組件經過children渲染出來
    return (
      <RouterContext.Provider
        value={{
          history: this.props.history,
          location: this.state.location,
          match: Router.computeRootMatch(this.state.location.pathname),
        }}
      >
        <HistoryContext.Provider
          children={this.props.children || null}
          value={this.props.history}
        />
      </RouterContext.Provider>
    );
  }
}

export default Router;

上述代碼是我精簡過的代碼,原版代碼能夠看這裏。這段代碼主要是建立了兩個context,將路由信息和history信息放到了這兩個context上,其餘也沒幹啥了。關於React的Context API我在另一篇文章詳細講過,這裏再也不贅述了。

history

前面咱們其實用到了history的三個API:

createBrowserHistory: 這個是用在BrowserRouter裏面的,用來建立一個history對象,後面的listen和unlisten都是掛載在這個API的返回對象上面的。

history.listen:這個是用在Router組件裏面的,用來監聽路由變化。

history.unlisten:這個也是在Router組件裏面用的,是listen方法的返回值,用來在清理的時候取消監聽的。

下面咱們來實現這個history:

// 建立和管理listeners的方法
function createEvents() {
  let handlers = [];

  return {
    push(fn) {
      handlers.push(fn);
      return function () {
        handlers = handlers.filter(handler => handler !== fn);
      };
    },
    call(arg) {
      handlers.forEach(fn => fn && fn(arg));
    }
  }
}

function createBrowserHistory() {
  const listeners = createEvents();
  let location = {
    pathname: '/',
  };

  // 路由變化時的回調
  const handlePop = function () {
    const currentLocation = {
      pathname: window.location.pathname
    }
    listeners.call(currentLocation);     // 路由變化時執行回調
  }

  // 監聽popstate事件
  // 注意pushState和replaceState並不會觸發popstate
  // 可是瀏覽器的前進後退會觸發popstate
  // 咱們這裏監聽這個事件是爲了處理瀏覽器的前進後退
  window.addEventListener('popstate', handlePop);

  // 返回的history上有個listen方法
  const history = {
    listen(listener) {
      return listeners.push(listener);
    },
    location
  }

  return history;
}

export default createBrowserHistory;

上述history代碼是超級精簡版的代碼,官方源碼不少,還支持其餘功能,咱們這裏只拎出來核心功能,對官方源碼感興趣的看這裏:https://github.com/ReactTraining/history/blob/28c89f4091ae9e1b0001341ea60c629674e83627/packages/history/index.ts#L397

Route組件

咱們前面的應用裏面還有個很重要的組件是Route組件,這個組件是用來匹配路由和具體的組件的。這個組件看似是從react-router-dom裏面導出來的,其實他只是至關於作了一個轉發,原封不動的返回了react-routerRoute組件:

image-20200728173934453

這個組件其實只有一個做用,就是將參數上的path拿來跟當前的location作對比,若是匹配上了就渲染參數上的component就行。爲了匹配pathlocation,還須要一個輔助方法matchPath我直接從源碼抄這個方法了。大體思路是將咱們傳入的參數path轉成一個正則,而後用這個正則去匹配當前的pathname

import pathToRegexp from "path-to-regexp";

const cache = {};
const cacheLimit = 10000;
let cacheCount = 0;

function compilePath(path, options) {
  const cacheKey = `${options.end}${options.strict}${options.sensitive}`;
  const pathCache = cache[cacheKey] || (cache[cacheKey] = {});

  if (pathCache[path]) return pathCache[path];

  const keys = [];
  const regexp = pathToRegexp(path, keys, options);
  const result = { regexp, keys };

  if (cacheCount < cacheLimit) {
    pathCache[path] = result;
    cacheCount++;
  }

  return result;
}

/**
 * Public API for matching a URL pathname to a path.
 */
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);

    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);
}

export default matchPath;

而後是Route組件,調用下matchPath來看下當前路由是否匹配就好了,當前路由記得從RouterContext裏面拿:

import React from "react";

import RouterContext from "./RouterContext.js";
import matchPath from "./matchPath.js";

/**
 * The public API for matching a single path and rendering.
 */
class Route extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          // 從RouterContext獲取location
          const location = context.location;
          const match = matchPath(location.pathname, this.props);  // 調用matchPath檢測當前路由是否匹配

          const props = { ...context, location, match };

          let { component } = this.props;

          // render對應的component以前先用最新的參數match更新下RouterContext
          // 這樣下層嵌套的Route能夠拿到對的值
          return (
            <RouterContext.Provider value={props}>
              {props.match
                ? React.createElement(component, props)
                : null}
            </RouterContext.Provider>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

export default Route;

上述代碼也是精簡過的,官方源碼還支持函數組件和render方法等,具體代碼能夠看這裏:https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Route.js

其實到這裏,React-Router的核心功能已經實現了,可是咱們開始的例子中還用到了SwitchLink組件,咱們也一塊兒來把它實現了吧。

Switch組件

咱們上面的Route組件的功能是隻要path匹配上當前路由就渲染組件,也就意味着若是多個Routepath都匹配上了當前路由,這幾個組件都會渲染。因此Switch組件的功能只有一個,就是即便多個Routepath都匹配上了當前路由,也只渲染第一個匹配上的組件。要實現這個功能其實也不難,把Switchchildren拿出來循環,找出第一個匹配的child,給它添加一個標記屬性computedMatch,順便把其餘的child所有幹掉,而後修改下Route的渲染邏輯,先檢測computedMatch,若是沒有這個再使用matchPath本身去匹配:

import React from "react";

import RouterContext from "./RouterContext.js";
import matchPath from "./matchPath.js";

class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          const location = context.location;     // 從RouterContext獲取location

          let element, match;     // 兩個變量記錄第一次匹配上的子元素和match屬性

          // 使用React.Children.forEach來遍歷子元素,而不能使用React.Children.toArray().find()
          // 由於toArray會給每一個子元素添加一個key,這會致使兩個有一樣component,可是不一樣URL的<Route>重複渲染
          React.Children.forEach(this.props.children, child => {
            // 先檢測下match是否已經匹配到了
            // 若是已經匹配過了,直接跳過
            if (!match && React.isValidElement(child)) {
              element = child;

              const path = child.props.path;

              match = matchPath(location.pathname, { ...child.props, path });
            }
          });

          // 最終<Switch>組件的返回值只是匹配子元素的一個拷貝,其餘子元素被忽略了
          // match屬性會被塞給拷貝元素的computedMatch
          // 若是一個都沒匹配上,返回null
          return match
            ? React.cloneElement(element, { location, computedMatch: match })   
            : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

export default Switch;

而後修改下Route組件,讓他先檢查computedMatch

// ... 省略其餘代碼 ...
const match = this.props.computedMatch
              ? this.props.computedMatch
              : matchPath(location.pathname, this.props);  // 調用matchPath檢測當前路由是否匹配

Switch組件其實也是在react-router裏面,源碼跟咱們上面寫的差很少:https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Switch.js

Link組件

Link組件功能也很簡單,就是一個跳轉,瀏覽器上要實現一個跳轉,能夠用a標籤,可是若是直接使用a標籤可能會致使頁面刷新,因此不能直接使用它,而應該使用history APIhistory API具體文檔能夠看這裏。咱們這裏要跳轉URL能夠直接使用history.pushState。使用history.pushState須要注意一下幾點:

  1. history.pushState只會改變history狀態,不會刷新頁面。換句話說就是你用了這個API,你會看到瀏覽器地址欄的地址變化了,可是頁面並無變化。
  2. 當你使用history.pushState或者history.replaceState改變history狀態的時候,popstate事件並不會觸發,因此history裏面的回調不會自動調用,當用戶使用history.push的時候咱們須要手動調用回調函數。
  3. history.pushState(state, title[, url])接收三個參數,第一個參數state是往新路由傳遞的信息,能夠爲空,官方React-Router會往裏面加一個隨機的key和其餘信息,咱們這裏直接爲空吧,第二個參數title目前大多數瀏覽器都不支持,能夠直接給個空字符串,第三個參數url是可選的,是咱們這裏的關鍵,這個參數是要跳往的目標地址。
  4. 因爲history已經成爲了一個獨立的庫,因此咱們應該將history.pushState相關處理加到history庫裏面。

咱們先在history裏面新加一個APIpush,這個API會調用history.pushState並手動執行回調:

// ... 省略其餘代碼 ...
push(url) {
  const history = window.history;
  // 這裏pushState並不會觸發popstate
  // 可是咱們仍然要這樣作,是爲了保持state棧的一致性
  history.pushState(null, '', url);

  // 因爲push並不觸發popstate,咱們須要手動調用回調函數
  location = { pathname: url };
  listeners.call(location);
}

上面說了咱們直接使用a標籤會致使頁面刷新,可是若是不使用a標籤,Link組件應該渲染個什麼標籤在頁面上呢?能夠隨便渲染個spandiv什麼的都行,可是可能會跟你們平時的習慣不同,還可能致使一些樣式失效,因此官方仍是選擇了渲染一個a標籤在這裏,只是使用event.preventDefault禁止了默認行爲,而後用history api本身實現了跳轉,固然你能夠本身傳component參數進去改變默認的a標籤。由於是a標籤,不能兼容native,因此Link組件實際上是在react-router-dom這個包裏面:

import React from "react";
import RouterContext from "../react-router/RouterContext";

// LinkAnchor只是渲染了一個沒有默認行爲的a標籤
// 跳轉行爲由傳進來的navigate實現
function LinkAnchor({navigate, ...rest}) {
  let props = {
    ...rest,
    onClick: event => {
      event.preventDefault();
      navigate();
    }
  }

  return <a {...props} />;
}

function Link({
  component = LinkAnchor,  // component默認是LinkAnchor
  to,
  ...rest
}) {
  return (
    <RouterContext.Consumer>
      {context => {
        const { history } = context;     // 從RouterContext獲取history對象

        const props = {
          ...rest,
          href: to,
          navigate() {
            history.push(to);
          }
        };

        return React.createElement(component, props);
      }}
    </RouterContext.Consumer>
  );
}

export default Link;

上述代碼是精簡版的Link,基本邏輯跟官方源碼同樣:https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/Link.js

到這裏開頭示例用到的所有API都換成了咱們本身的,其實也實現了React-Router的核心功能。可是咱們只實現了H5 history模式,hash模式並無實現,其實有了這個架子,添加hash模式也比較簡單了,基本架子不變,在react-router-dom裏面添加一個HashRouter,他的基本結構跟BrowserRouter是同樣的,只是他會調用historycreateHashHistorycreateHashHistory裏面不只僅會去監聽popstate,某些瀏覽器在hash變化的時候不會觸發popstate,因此還須要監聽hashchange事件。對應的源碼以下,你們能夠自行閱讀:

HashRouter: https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/HashRouter.js

createHashHistory: https://github.com/ReactTraining/history/blob/28c89f4091ae9e1b0001341ea60c629674e83627/packages/history/index.ts#L616

總結

React-Router的核心源碼咱們已經讀完了,下面咱們來總結下:

  1. React-Router由於有跨平臺的需求,因此分拆了好幾個包,這幾個包採用monorepo的方式管理:

    1. react-router是核心包,包含了大部分邏輯和組件,處理context和路由匹配都在這裏。
    2. react-router-dom是瀏覽器使用的包,像Link這樣須要渲染具體的a標籤的組件就在這裏。
    3. react-router-nativereact-native使用的包,裏面包含了androidios具體的項目。
  2. 瀏覽器事件監聽也單獨獨立成了一個包history,跟history相關的處理都放在了這裏,好比pushreplace什麼的。
  3. React-Router實現時核心邏輯以下:

    1. 使用不刷新的路由API,好比history或者hash
    2. 提供一個事件處理機制,讓React組件能夠監聽路由變化。
    3. 提供操做路由的接口,當路由變化時,經過事件回調通知React
    4. 當路由事件觸發時,將變化的路由寫入到React的響應式數據上,也就是將這個值寫到根routerstate上,而後經過context傳給子組件。
    5. 具體渲染時將路由配置的path和當前瀏覽器地址作一個對比,匹配上就渲染對應的組件。
  4. 在使用popstate時須要注意:

    1. 原生history.pushStatehistory.replaceState並不會觸發popstate,要通知React須要咱們手動調用回調函數。
    2. 瀏覽器的前進後退按鈕會觸發popstate事件,因此咱們仍是要監聽popstate,目的是兼容前進後退按鈕。

本文所有代碼已經上傳GitHub,你們能夠拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-router-code

參考資料

官方文檔:https://reactrouter.com/web/guides/quick-start

GitHub源碼地址:https://github.com/ReactTraining/react-router/tree/master/packages

文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。

做者博文GitHub項目地址: https://github.com/dennis-jiang/Front-End-Knowledges

相關文章
相關標籤/搜索