OverRainbow

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 过来!