一步一步解析前端路由

寫在前面

如今的前端應用不少都是單頁應用。路由對於單頁應用來講,是一個重要的組成部分。本系列文章將講解前端路由的實現原理。這是系列文章的第三篇:React-Router源碼解析。javascript

前兩篇文章在這裏:

前端路由解析(一)—— hash路由前端

前端路由解析(二)—— history路由java

本文不會再介紹路由的基本原理,而是會結合React-Router的源碼,探索一下路由和React是如何結合的。

示例

本文所用的React-Router的版本爲:5.1.2,react版本爲:16.12.0react

咱們用官方最簡單的代碼示例來分析一下React-Router的源碼。web

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

export default function App() {
    return (
        <Router>
            <div>
                <nav>
                    <ul>
                        <li>
                            <Link to="/">Home</Link>
                        </li>
                        <li>
                            <Link to="/about">About</Link>
                        </li>
                        <li>
                            <Link to="/users">Users</Link>
                        </li>
                    </ul>
                </nav>

                {/* A <Switch> looks through its children <Route>s and
            renders the first one that matches the current URL. */}
                <Switch>
                    <Route path="/about">
                        <About />
                    </Route>
                    <Route path="/users">
                        <Users />
                    </Route>
                    <Route path="/">
                        <Home />
                    </Route>
                </Switch>
            </div>
        </Router>
    );
}

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

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

function Users() {
    return <h2>Users</h2>;
}

初次渲染

下面依次對 BrowserRouter、Switch、Route、Link進行解析。api

BrowserRouter

BrowserRouter很簡單:react-router

import { Router } from 'react-router';

...
_this.history = createBrowserHistory(_this.props);

...

_proto.render = function render() {
  return React.createElement(Router, {
    history: this.history,
    children: this.props.children
  });
};

其中,Router來自react-router這個庫,是react路由的核心組件,其內部維護了一個state。當頁面的路由發生變化時,會更新state的location值,從而觸發react的re-render。框架

/*
interface Location<S = LocationState> {
    pathname: Pathname;
    search: Search;
    state: S;
    hash: Hash;
    key?: LocationKey;
} 
*/

_this.state = {
         location: props.history.location
 }

props.history,是createBrowserHistory這個函數生成的,createBrowserHistory是來自history這個package,返回了一個對象:dom

// 請記住這個history
var history = {
    length: globalHistory.length,
    action: 'POP',
    location: initialLocation,
    createHref: createHref,
    push: push,
    replace: replace,
    go: go,
    goBack: goBack,
    goForward: goForward,
    block: block,
    listen: listen
};
return history

Router組件渲染時,會將上述的方法、對象,傳遞給須要的childern組件。傳遞是經過 context 完成的。Router會在childern外包一層 Router.Provider,來提供history對象等信息。ide

// 這裏的 context.Provider 是一個組件。使用的是 mini-create-react-context 這個 package.
// 參考 React.createContext

_proto.render = function render() {
    return React.createElement(context.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
      }
    });
};
Switch
...

_proto.render = function render() {
    var _this = this;

    return React.createElement(context.Consumer, null, function (context) {
      !context ? process.env.NODE_ENV !== "production" ? invariant(false, "You should not use <Switch> outside a <Router>") : invariant(false) : void 0;
      var location = _this.props.location || context.location;
      var element, match; // We use React.Children.forEach instead of React.Children.toArray().find()
 
      React.Children.forEach(_this.props.children, function (child) {
        if (match == null && React.isValidElement(child)) {
          element = child;
          var path = child.props.path || child.props.from;
          match = path ? matchPath(location.pathname, _extends({}, child.props, {
            path: path
          })) : context.match;
        }
      });
      return match ? React.cloneElement(element, {
        location: location,
        computedMatch: match
      }) : null;
    });
  };

Switch 會找到第一個匹配當前路由的Route,來進行渲染。Switch會在childern外面包一層Router.Comsumer。這個是爲了經過context,拿到外層Router組件傳遞的history對象相關信息傳遞給Route組件。

Route

Route組件的邏輯也很好理解,首先是經過Router.Consumer拿到history對象等相關信息,通過一些處理後,在children外面包一層Router.Provider, 而後渲染children。之因此要包一層,我理解是爲了供children中的Link組件等消費。來看代碼:

...
proto.render = function render() {
    var _this = this;

    return React.createElement(context.Consumer, null, function (context$1) {
      !context$1 ? process.env.NODE_ENV !== "production" ? invariant(false, "You should not use <Route> outside a <Router>") : invariant(false) : void 0;
      var location = _this.props.location || context$1.location;
      var match = _this.props.computedMatch ? _this.props.computedMatch // <Switch> already computed the match for us
      : _this.props.path ? matchPath(location.pathname, _this.props) : context$1.match;

      var props = _extends({}, context$1, {
        location: location,
        match: match
      });

      var _this$props = _this.props,
          children = _this$props.children,
          component = _this$props.component,
          render = _this$props.render; // Preact uses an empty array as children by
      // default, so use null if that's the case.

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

      return React.createElement(context.Provider, {
        value: props
      }, props.match ? children ? typeof children === "function" ? process.env.NODE_ENV !== "production" ? evalChildrenDev(children, props, _this.props.path) : children(props) : children : component ? React.createElement(component, props) : render ? render(props) : null : typeof children === "function" ? process.env.NODE_ENV !== "production" ? evalChildrenDev(children, props, _this.props.path) : children(props) : null);
    });
...
Link

Link組件最終會render一個包裹着Router.Consumer的LinkAnchor組件。Router.Consumer是爲了獲取外層Router組件的history對象等信息,LinkAnchor綁定了特殊的點擊跳轉邏輯的標籤。這裏先不展開。

小結

初次渲染後,示例代碼會造成下面這樣的組件樹:

分析了這麼久,能夠發現 :react-router-dom 主要的邏輯是處理history對象在整個react應用中的傳遞以及children的渲染等。history對象的傳遞是經過context完成的。history對象是由history 這個package中的createBrowserHistory函數生成的。而真正處理路由的核心邏輯, 是在 history 這個package中。

下面,咱們來看看點擊Link後,會發生什麼?

點擊Link

點擊link後,最終會調用到history對象中的方法來進行路由的切換。

function navigate() {
   var location = resolveToLocation(to, context.location);
   // 這裏的history,就是 createBrowserHistory 方法生成,而且在react組件樹中傳遞的對象
   var method = replace ? history.replace : history.push;
   method(location);
}

下面,進入history中的邏輯:

var href = createHref(location);
var key = location.key,
        state = location.state;

if (canUseHistory) {
  // 調用window.history.pushState
  globalHistory.pushState({
    key: key,
    state: state
  }, null, href);

  if (forceRefresh) {
    window.location.href = href;
  } else {
    var prevIndex = allKeys.indexOf(history.location.key);
    var nextKeys = allKeys.slice(0, prevIndex + 1);
    nextKeys.push(location.key);
    allKeys = nextKeys;
    // 更新狀態。注意,這裏的setState可不是react中的setState
    setState({
      action: action,
      location: location
    });
  }
}

咱們來看下history中的setState幹了什麼:

function setState(nextState) {
  // 更新history對象
  _extends(history, nextState);

  history.length = globalHistory.length;
  // 通知訂閱者,history已更新。控制react組件從新渲染的關鍵就在這裏
  transitionManager.notifyListeners(history.location, history.action);
}

其實,在Router組件初始化的時候,就監聽了history的更新,下面是Router組件的代碼:

...
if (!props.staticContext) {
  // 這裏的history.listen是history對象提供的方法,用於監聽頁面history的更新。
  _this.unlisten = props.history.listen(function (location) {
    if (_this._isMounted) {
      _this.setState({
        location: location
      });
    } else {
      _this._pendingLocation = location;
    }
  });
}
...

能夠看到,Router組件監聽了history的更新。當頁面的history更新時,會調用Router組件的setState,從而完成頁面的re-render。

總結

hashHistory的邏輯與BrowserHistory的邏輯相似,本文就再也不繼續展開了,

到這裏,能夠簡單總結一下,整個react-router的實現思路是:

使用一個第三方的、框架無關的history對象來控制頁面的路由變化邏輯。在react側,使用context來傳遞history對象,保證路由組件中能夠訪問到history對象、方便控制路由,而且將history對象與業務組件隔離。使用發佈訂閱模式,解耦了頁面路由的更新與react的更新。

在咱們本身的業務組件中,沒法直接訪問到history對象。若是想直接訪問到history對象,可使用withRouter這個HOC。

寫在後面

前端路由系列文章算是告一段落了。本系列文章從最基本的路由原理講起,到框架的路由實現結束,還算符合預期。不過路由中還涉及到很多的知識點 以及一些高級的功能(好比 keep-alive),值得繼續研究。