最近在维护项目时发现一段代码:window.location.href="#/xxx",乍一看为啥不用 react 提供的路由跳转方式呢?看了一眼文件路径,在 utils 文件内,想了下在这里 history 对象去哪找呢?

Router

由于项目为 Hash 路由,连夜百度了下,确实有相关属性供我们使用 createHashHistory

由之前的

import { HashRouter } from 'react-router-dom';

function App() {
  return <HashRouter>xxx</HashRouter>;
}

改为

import { Router } from 'react-router-dom';
// history 为 react-router-dom 依赖,直接引入即可
import { createHashHistory, createBrowserHistory } from 'history';

// 若你是 browser 路由则使用 createBrowserHistory
const history = createHashHistory();

function App() {
  return <Router history={history}>xxx</Router>;
}
  1. const history = createHashHistory(); history 可在 utils 定义再导出 App 内在引用赋值,方便其它地方引用,当然这因人而异。
  2. 另外 HashRouter 是不接 history 属性虽说是基于 Router 具体可继续往下看
  3. history 直接使用不赋值给 HashRouter 也可以对路由产生作用(测试仅限 Hash,应该内部监听了 Hash change),也由于未给 Router 赋值,所以会出现 history 表现不一致等未知情况,例如history的action等

createHashHistory 与 createBrowserHistory

看 V5 中 HashRouterBrowserRouter的源码,虽然是基于 Router 但是并没有透传多余参数给他,所以上面我们直接使用 Router 即可,内部参数相同

HashRouter

import React from "react";
import { Router } from "react-router";
import { createHashHistory as createHistory } from "history";
import PropTypes from "prop-types";
import warning from "tiny-warning";

/**
 * The public API for a <Router> that uses window.location.hash.
 */
class HashRouter extends React.Component {
  history = createHistory(this.props);

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

if (__DEV__) {
  HashRouter.propTypes = {
    basename: PropTypes.string,
    children: PropTypes.node,
    getUserConfirmation: PropTypes.func,
    hashType: PropTypes.oneOf(["hashbang", "noslash", "slash"])
  };

  HashRouter.prototype.componentDidMount = function() {
    warning(
      !this.props.history,
      "<HashRouter> ignores the history prop. To use a custom history, " +
        "use `import { Router }` instead of `import { HashRouter as Router }`."
    );
  };
}

export default HashRouter;

BrowserRouter

import React from "react";
import { Router } from "react-router";
import { createBrowserHistory as createHistory } from "history";
import PropTypes from "prop-types";
import warning from "tiny-warning";

/**
 * The public API for a <Router> that uses HTML5 history.
 */
class BrowserRouter extends React.Component {
  history = createHistory(this.props);

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

if (__DEV__) {
  BrowserRouter.propTypes = {
    basename: PropTypes.string,
    children: PropTypes.node,
    forceRefresh: PropTypes.bool,
    getUserConfirmation: PropTypes.func,
    keyLength: PropTypes.number
  };

  BrowserRouter.prototype.componentDidMount = function() {
    warning(
      !this.props.history,
      "<BrowserRouter> ignores the history prop. To use a custom history, " +
        "use `import { Router }` instead of `import { BrowserRouter as Router }`."
    );
  };
}

export default BrowserRouter;

Route 和 Router(V5)

插句嘴~ 区分下 Route 和 Router 一字之差到底有啥区别

Router

如他首行注释所描述就是 Router 做的事情,RouterContext 串联起 Route 为其提供了 路由相关方法(props history)
通过 history 提供的 listener 去修改 state.location 来触发 render

/**
 * 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
    };

    // This is a bit of a hack. We have to start listening for location
    // changes here in the constructor in case there are any <Redirect>s
    // on the initial render. If there are, they will replace/push when
    // they mount and since cDM fires in children before parents, we may
    // get a new location before the <Router> is mounted.
    this._isMounted = false;
    this._pendingLocation = null;

    if (!props.staticContext) {
      this.unlisten = props.history.listen(location => {
        this._pendingLocation = location;
      });
    }
  }

  componentDidMount() {
    this._isMounted = true;

    if (this.unlisten) {
      // Any pre-mount location changes have been captured at
      // this point, so unregister the listener.
      this.unlisten();
    }
    if (!this.props.staticContext) {
      this.unlisten = this.props.history.listen(location => {
        if (this._isMounted) {
          this.setState({ location });
        }
      });
    }
    if (this._pendingLocation) {
      this.setState({ location: this._pendingLocation });
    }
  }

  componentWillUnmount() {
    if (this.unlisten) {
      this.unlisten();
      this._isMounted = false;
      this._pendingLocation = null;
    }
  }

  render() {
    return (
      <RouterContext.Provider
        value={{
          history: this.props.history,
          location: this.state.location,
          match: Router.computeRootMatch(this.state.location.pathname),
          staticContext: this.props.staticContext
        }}
      >
        <HistoryContext.Provider
          children={this.props.children || null}
          value={this.props.history}
        />
      </RouterContext.Provider>
    );
  }
}

Route

Route 主要是根据传入的 path 和当前的 pathname 是否 match,来决定是否 rendering


/**
 * The public API for matching a single path and rendering.
 */
class Route extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          invariant(context, "You should not use <Route> outside a <Router>");

          const location = this.props.location || context.location;
          const match = this.props.computedMatch
            ? this.props.computedMatch // <Switch> already computed the match for us
            : this.props.path
            ? matchPath(location.pathname, this.props)
            : context.match;

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

          let { children, component, render } = this.props;

          // Preact uses an empty array as children by
          // default, so use null if that's the case.
          if (Array.isArray(children) && isEmptyChildren(children)) {
            children = null;
          }

          return (
            <RouterContext.Provider value={props}>
              {props.match
                ? children
                  ? typeof children === "function"
                    ? __DEV__
                      ? evalChildrenDev(children, props, this.props.path)
                      : children(props)
                    : children
                  : component
                  ? React.createElement(component, props)
                  : render
                  ? render(props)
                  : null
                : typeof children === "function"
                ? __DEV__
                  ? evalChildrenDev(children, props, this.props.path)
                  : children(props)
                : null}
            </RouterContext.Provider>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

有兴趣的可以看下 matchPath 的实现,里面使用 path-to-regexp的库可以帮你处理 pathname 与 route path 的关系,当你在 layout 组件内时 match 是无法正确返回当前组件的,可以通过此库来实现 match

history - 5.x

ok,现在看下 history 内部是如何处理 Hash 和 Browser 的(有点跳啊,本身项目是 5.x 的 router-dom 对应的是 4.x 的 history,但github上是新版的 history 就代入了。。。这里直接就 5.x 吧)

Hash 和 Browser 的区别是什么?

Hash Browser
Url url 中存留 hash 标识#,通过 hash 值来 match route,兼容老旧浏览器,方便部署服务端不需要额外配置
URL 即为资源地址,浏览器请求资源不会携带 Hash 值,所以对 Hash 来说始终为 index.html,对 Browser 而言 /home 就需要 home.html 存在不然会 404,或服务端将请求指向 index.html
location.state 对于 5.x 的话此处无区别,而 4.x 的 Hash 路由则是通过模仿 history 来实现,不会在浏览器持久化保存 使用 web api history 来实现路由跳转,5.x 中 hash 与 Browser 无区别

createHashHistory

4.x 跳转(push)

path 为 history.push 的参数,传的 state 在 path 内,因为跳转使用的是 window.location 相关 api ,而 state 也只是透传 (setState({ action, location });)所以在 4.x 中你刷新就会丢失 state 状态

// /history/modules/createHashHistory.js
function push(path, state) {
    warning(
      state === undefined,
      'Hash history cannot push state; it is ignored'
    );

    const action = 'PUSH';
    const location = createLocation(
      path,
      undefined,
      undefined,
      history.location
    );

    transitionManager.confirmTransitionTo(
      location,
      action,
      getUserConfirmation,
      ok => {
        if (!ok) return;

        const path = createPath(location);
        const encodedPath = encodePath(basename + path);
        const hashChanged = getHashPath() !== encodedPath;

        if (hashChanged) {
          // We cannot tell if a hashchange was caused by a PUSH, so we'd
          // rather setState here and ignore the hashchange. The caveat here
          // is that other hash histories in the page will consider it a POP.
          ignorePath = path;
          pushHashPath(encodedPath);

          const prevIndex = allPaths.lastIndexOf(createPath(history.location));
          const nextPaths = allPaths.slice(0, prevIndex + 1);

          nextPaths.push(path);
          allPaths = nextPaths;

          setState({ action, location });
        } else {
          warning(
            false,
            'Hash history cannot PUSH the same path; a new entry will not be added to the history stack'
          );

          setState();
        }
      }
    );
  }
function pushHashPath(path) {
  window.location.hash = path;
}

5.x

globalHistory.pushState(historyState, "", url); 这里就很清楚的看到 5.x 的 hash 是使用 history api 来操作的,所以 state 将和 Browser 一致会保存,也可 window.history log 出来

function push(to: To, state?: any) {
    let nextAction = Action.Push;
    let nextLocation = getNextLocation(to, state);
    function retry() {
      push(to, state);
    }

    warning(
      nextLocation.pathname.charAt(0) === "/",
      `Relative pathnames are not supported in hash history.push(${JSON.stringify(
        to
      )})`
    );

    if (allowTx(nextAction, nextLocation, retry)) {
      let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);

      // TODO: Support forced reloading
      // try...catch because iOS limits us to 100 pushState calls :/
      try {
        globalHistory.pushState(historyState, "", url);
      } catch (error) {
        // They are going to lose state here, but there is no real
        // way to warn them about it since the page will refresh...
        window.location.assign(url);
      }

      applyTx(nextAction);
    }
  }