Two History Bug
• • ☕️ 4 min read你可以从本文了解到
背景
有一个项目,在做 UT 改造的时候。由于组件经常会对 history 的进行操作(push、replace 等),为了方便测试,决定对其功能收口。
也参考了一些开源项目,尤其是mattermost-app。其中utils/browser_history.jsx
模块就是一个典型的例子。
他有一个browser_history 模块
可以看到它主要做了两件事
import {createBrowserHistory} from 'history';
// 实例化一个history
const b = createBrowserHistory({basename: window.basename});
// 劫持其push方法,返回一个新的history对象(内容是shallow copy了b)
export const browserHistory = {
...b,
push: (path, ...args) => {
if (isDesktop) {
// ...
} else {
b.push(path, ...args);
}
}
};
我们也模仿着在项目中新建一个utils/browserHistory.ts
模块,将从此模块导出 app 全局共享的 history(想法很美好哇)。
然后在 app 主入口注入这个 history 给 ReactRouter
import React, {Suspense} from 'react';
import {Router, Redirect, Route, Switch} from 'react-router-dom';
import NotFound from 'pages/error/NotFound';
import {browserHistory} from 'utils/browserHistory';
import {routes} from './routes';
/* Use components to define routes */
const App = () => (
<Router history={browserHistory}> <Suspense fallback={null}>
<Switch>
{routes.map((route) => {
return (
<Route
key={route.path}
path={route.path}
component={route.component}
exact={route.exact}
></Route>
);
})}
{/* 找不到,不要跳走去默认页面,否则缓存无法及时更新(用户可以停留在出错页面,刷新来获取新页面) */}
<Route path={'*'} component={NotFound}></Route>
</Switch>
</Suspense>
</Router>
);
export default App;
bug 被发现
然后比如我们在另一个组件里面去用这个 location 值。从其他页面跳转到新页面。这个页面如下。
const PrivateComp: React.FC<Props> = ({children}) => {
const {pathname} = useHistory().location; // 来自useHistory的location值
const location = useLocation(); // 来自useLocation的location值
const currentUrl = location.pathname;
console.log('ddt::pathname,currentUrl', pathname, currentUrl); // ddt::pathname,currentUrl /create2021 /create2021/works if (!children) throw new Error('children must exist');
// onClickCapture 捕获
return <div onClickCapture={handleClick}>{children}</div>;
function handleClick(e: React.MouseEvent) {
// ...
}
};
export default PrivateComp;
可以看到打印结果的两个 location 不一样。这是为什么呢?
原因定位和解决
首先我们的 browserHistory 是基本上 copy 的 mattermost 的,人家没遇到 bug 么。
那么顺着这个思路我们去捋一下这个 bug。
我们放慢一点
// 我们的app中utils/browserHistory.tsx
import {createBrowserHistory} from 'history';
import pathConfig from 'config/pathConfig';
const b = createBrowserHistory<any>({basename: pathConfig.BASENAME});
// bug case
export const c = {
...b,
push: (path, ...args) => {
console.log('browserHistory::push', path);
//@ts-ignore
return b.push(path, ...args);
}
};
export default c;
可以看到 c 浅拷贝了 b,并且重写了 push 方法,在 c 的 push 方法中调用 b 的 push 方法。
最后我们把 c 传给 Router。
我们先看看 history 这个包的 push 方法做了什么。
function push(path, state) {
process.env.NODE_ENV !== 'production'
? warning(
!(
typeof path === 'object' &&
path.state !== undefined &&
state !== undefined
),
'You should avoid providing a 2nd state argument to push when the 1st ' +
'argument is a location-like object that already has state; it is ignored'
)
: void 0;
var action = 'PUSH';
var location = createLocation(path, state, createKey(), history.location);
transitionManager.confirmTransitionTo(
location,
action,
getUserConfirmation,
function (ok) {
if (!ok) return;
var href = createHref(location);
var key = location.key,
state = location.state;
if (canUseHistory) {
// 更新window.history对象(全局的)
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({ action: action,
location: location
}); // 更新history对象
}
} else {
process.env.NODE_ENV !== 'production'
? warning(
state === undefined,
'Browser history cannot push state in browsers that do not support HTML5 history'
)
: void 0;
window.location.href = href;
}
}
);
}
可以看到会调用 setState 方法实现一个 Pub/Sub 模式。从而实现内部和外部数据的同步。
setState 主要有 2 个操作:
1、在原 history 对象上修改,赋予新值。
2、 通知以 listen 方法注册的观察者【callback(location,action)】。
function setState(nextState) {
_extends(history, nextState); // 1
history.length = globalHistory.length;
transitionManager.notifyListeners(history.location, history.action); // 2}
回到我们的问题,我们传给 Router 的是 c,c 中又调用了 b 的 push,因此实际上 b 的 location 发生了更新,c 的 location 并未更新。
所以这解释了为什么 useHistory 的 location 没变。(useHistory 返回的是 history 对象,我们传给 Router 的是 c,c 没变,b 变了)。
至此问题解开了一半。
那么为什么 useLocation 的 pathname 变了?(明明传给 Router 的是 c,理应也没变才对嘛)
我们去看 ReactRouter 的 Router 的构造函数
// packages/react-router/modules/Router.js
class Router extends React.Component {
constructor(props) {
super(props);
this.state = {
location: props.history.location // 内部维护了一份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) => { if (this._isMounted) {
this.setState({location});
} else {
this._pendingLocation = location;
}
});
}
}
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>
);
}
}
listen方法如下,主要是往transitionManager里面添加了一个listener。
function listen(listener) {
var unlisten = transitionManager.appendListener(listener);
checkDOMListeners(1);
return function () {
checkDOMListeners(-1);
unlisten();
};
}
可以看到 Router 调用了 history 的 listen 方法,将自己注册为观察者。因此 push 方法的 setState 的 callback 会通知到 Router,从而引起 Router 的 setState, 更新自己内部维护的 location。所以即使绑定错了 history,location 依然会更新。(另一半问题得到了解释)
这里的细节是,我们的 c 的 listen 是 b 的 listen 是浅拷贝。所以实质上这个 listen 是 listen 了 b 的 setState。我们可以看下面这个 demo验证一下这个问题。
var b = {a: function () {}};
// undefined;
var c = {...b};
// undefined;
c.a === b.a;
// true;
所以Router用了c的history,listen了b的setState,push了b的push。一切完美解释。
接着我们看那两个 hooks
// packages/react-router/modules/hooks.js
import React from 'react';
import invariant from 'tiny-invariant';
import Context from './RouterContext.js';
import HistoryContext from './HistoryContext.js';
import matchPath from './matchPath.js';
const useContext = React.useContext;
export function useHistory() {
return useContext(HistoryContext); // 返回注入的history}
export function useLocation() {
return useContext(Context).location; // 返回Router的state.location}
可见 ReactRouter 用了两个 Context,一个 HistoryContext,一个 RouterContext。 useHistory 返回注入的 history,我们注入错了。 useLocation 返回 Router 自己维护的 location,与 history 无关,因此一直是对的。
这个问题的解决办法有多种,核心就是就是不要复制 b,而是修改 b 或者通过 proxy 拦截和转发。 不用 proxy 的改法如下。
const b = createBrowserHistory<any>({basename: pathConfig.BASENAME});
const _push = b.push; // 保存一份
const browserHistory = b;
browserHistory.push = (path, ...args) => {
// 可以打些log啥的
console.log('browserHistory::push', path);
_push(path, ...args);
};
export {browserHistory};
反思
通过这个 bug,我们将 history 和 ReactRouter 串联了起来。
mattermost之所以没遇到这个bug是因为他们拿pathname永远都是从this.props.location或者useLocation拿,一旦有人从history拿就会出问题。
有机会还是应该把这个 bug report 给他们的。
教训:copy paste 要小心,别把 bug copy 过来!