关于javascript:手写ReactRouter源码深入理解其原理

8次阅读

共计 13937 个字符,预计需要花费 35 分钟才能阅读完成。

上一篇文章咱们讲了 React-Router 的根本用法,并实现了常见的前端路由鉴权。本文会持续深刻 React-Router 讲讲他的源码,套路还是一样的,咱们先用官网的 API 实现一个简略的例子,而后本人手写这些 API 来替换官网的并且放弃性能不变。

本文全副代码曾经上传 GitHub,大家能够拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-router-code

简略示例

本文用的例子是上篇文章开始那个不带鉴权的简略路由跳转例子,跑起来是这样子的:

咱们再来回顾下代码,在 app.js 外面咱们用 Route 组件渲染了几个路由:

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;

每个页面的代码都很简略,只有一个题目和回首页的链接,比方登录页长这样,其余几个页面相似:

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 导出来的:

BrowserRouter: 被咱们重命名为了 Router,他包裹了整个React-Router 利用,感觉跟以前写过的 react-reduxProvider相似,我猜是用来注入 context 之类的。

Route: 这个组件是用来定义具体的路由的,接管路由地址 path 和对应渲染的组件作为参数。

Switch:这个组件是用来设置匹配模式的,不加这个的话,如果浏览器地址匹配到了多个路由,这几个路由都会渲染进去,加了这个只会渲染匹配的第一个路由组件。

Link:这个是用来增加跳转链接的,性能相似于原生的 a 标签,我猜他外面也是封装了一个 a 标签。

BrowserRouter 源码

咱们代码外面最外层的就是BrowserRouter,咱们先去看看他的源码干了啥,地址传送门:https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/BrowserRouter.js

看了他的源码,咱们发现 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 的我的项目构造是这样的:

留神这里的 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

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 文件夹上面也建几个对应的文件夹:

手写本人的 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组件:

这个组件其实只有一个作用,就是将参数上的 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

正文完
 0