最近在维护项目时发现一段代码: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);
}
}