一步一步解析前端路由

CoyPan

写在前面

现在的前端应用很多都是单页应用。路由对于单页应用来说,是一个重要的组成部分。本系列文章将讲解前端路由的实现原理。这是系列文章的第三篇:React-Router源码解析。

前两篇文章在这里:

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

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

本文不会再介绍路由的基本原理,而是会结合React-Router的源码,探索一下路由和React是如何结合的。

示例

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

我们用官方最简单的代码示例来分析一下React-Router的源码。

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进行解析。

BrowserRouter

BrowserRouter很简单:

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,返回了一个对象:

// 请记住这个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对象等信息。

// 这里的 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),值得继续研究。

阅读 2.1k

符合预期的FE
努力成为一名符合预期的FE,成为一名出色的工程师。 欢迎关注我的微信公众号:符合预期的CoyPan

FE

3.8k 声望
3.7k 粉丝
0 条评论

FE

3.8k 声望
3.7k 粉丝
文章目录
宣传栏