首发于FE FAME
为什么 withRouter 高阶组件应该 处于最外层?

为什么 withRouter 高阶组件应该 处于最外层?

这篇文章的主要内容是说 React 16.3 以前的 getChildContext 这个老 Context API 存在会被 PureComponent 截断的问题,React-Router 4.3.1 及之前版本是用这种 API 实现的,所以存在这个问题,在新版本的 React-Router 中已经没有这个问题了。

之前在 CR 中看到立理大佬的评论,说 withRouter 高阶组件应该放在最外面,不然可能会造成 url 变化了但是组件没有渲染的情况,当时并不理解,然后照着 ReactRouter 的文档仔细研究了一下原因。

复现不能正常渲染的情况

React 中有两种常见提升渲染性能的方式:

  1. 通过 shouldComponentUpdate 生命周期控制组件是否重新渲染
  2. 使用 PureComponent

下面是一个简单的例子:

class UpdateBlocker extends PureComponent {
    render() {
        return this.props.children;
    }
}
  
const App = () => (
    <Router>
        <UpdateBlocker>
            <NavLink to='/about'>About</NavLink>
            <NavLink to='/faq'>F.A.Q.</NavLink>
        </UpdateBlocker>
    </Router>
);

这个 NavLink 也是 react-router-dom 里面的一个组件,它也是渲染了一个 a 标签,比较特殊的是,当他能匹配当前 url 的时候,会默认给 a 标签再添加一个 active 类。

所以,我们再添加这样一段 CSS,然后看效果:

a {
  display: block;
}

a.active {
  color: red;
}

按照设想中的效果,应该是点击 About 之后,About 变红,点击 F.A.Q. 后,F.A.Q. 变红。但是现实却是点击之后并没有什么效果。

原因就是 UpdateBlocker 是一个 PureComponent,想要它重新渲染,除非它的 state 或者 props 有什么大的变动才行(浅比较结果为 false),然而在上面的例子中,UpdateBlocker 并没有发生这种变化,所以就理所当然的不会变化了。

shouldComponentUpdate 原理类似。所以在实现这个生命周期的时候,也要考虑 url 变动的情况。官方文档中说可以通过 context.router 来确定 url 是否变动,但由于用户不该直接使用 context 所以不建议这么做,而是推荐通过使用传入 location 属性的形式。

解决方案

所以,当你的 Component 是 Pure 的,应该如何处理这种情况呢?

我简单看了一下 react-router 的实现,当 <Link> 点击之后,会通过 context 触发 <Router>或者 <Route> 里面实现的相应的函数,然后在 <Router><Route> 中 setState 触发渲染。所以不管是 <Link> 还是 withRouter 这些东西,一定要是 <Router> 或者 <Route>的后代才行。(没理解错吧)

所以,如果希望 UpdateBlocker 也能正常渲染的话,只要给它传入一个能够触发渲染的属性就好了,比如 location 对象。只要想办法在父组件拿到 location 对象,然后通过属性给那个 Pure 的组件传过去。当 URL 变化时,location 也会相应改变,所以也就不怕 Pure 的组件不渲染了:

<UpdateBlocker location={location}>
  <NavLink to='/about'>About</NavLink>
  <NavLink to='/faq'>F.A.Q.</NavLink>
</UpdateBlocker>

那么如何让父组件拿到 location 对象呢?

直接通过 <Route> 渲染的组件

如果你的组件是直接通过 <Route> 渲染的话:

1. 一个直接通过 <Route> 渲染的组件,不需要担心上面的问题,因为 <Route> 会自动为其包裹的组件插入 location 属性。

// 当 url 变化时,<Blocker> 的 location 属性一定会变化
<Route path='/:place' component={Blocker}/>

2. 一个直接通过 <Route> 渲染的组件,既然可以拿到 location 属性,所以自然也可以把 location 传给由它创建的子组件。

<Route path='/parent' component={Parent} />

const Parent = (props) => {
  // 既然 <Parent> 能拿到 location 属性
  // 自然也可以把 location 传给由它创建的子组件
  return (
    <SomeComponent>
      <Blocker location={props.location} />
    </SomeComponent>
  );
}

不是直接通过 <Route> 渲染的组件

如果一个组件不是由 <Route> 直接渲染的怎么办呢?也有两种办法:

1. 可以使用不传 path 属性的 <Route> 组件。<Route> 组件中的 path 属性也不是必须的,当不传入 path 属性时,表示它包裹的组件总会渲染:

// <Blocker> 组件总会渲染
const MyComponent= () => (
  <SomeComponent>
    <Route component={Blocker} />
  </SomeComponent>
);

2. 使用 withRouter 高阶组件。这个高阶组件就会给它包裹的组件传三个属性,分别是 locationmatchhistory

const BlockAvoider = withRouter(Blocker)

const MyComponent = () => (
  <SomeComponent>
    <BlockAvoider />
  </SomeComponent>
);

其他情况

有时候即便你没有使用 PureComponent 也有可能出现上面的问题,因为你有可能使用了一些实现了 shouldComponentUpdate 的高阶组件,比如:

// react-redux
const MyConnectedComponent = connect(mapStateToProps)(MyComponent)

// mobx-react(这个我没用过)
const MyObservedComponent = observer(MyComponent)

这个 connectobserver 都实现了自己的 shouldComponentUpdate,它们也是对当前的 props 和 nextProps 浅比较,所以也会导致 即使 url 变化,也无法重新渲染 的情况。

通过上面的分析我们也很容易找到相应的解决方案,比如:

const MyConnectedComponent = withRouter(connect(mapStateToProps)(MyComponent))

const MyConnectedComponent = withRouter(observer(MyComponent))

其实当我看到这里的时候就已经理解为什么 withRouter 要放在最外层了。很好理解,因为如果 withRouterconnect 里面,即便能够给 MyComponent 传入 location 对象,可是渲染早在 connect 那一层就被拦截住了...

withRouter 这个高阶组件很好用,但是它并不是所有情景的最佳解决方案,还是应该视情况而定。

因为 withRouter 本身的作用是为了给那些需要操作 route 的组件提供 location 等属性。如果一个组件本身就已经能拿到这些属性了,那再使用 withRouter 就是一种浪费了。

原文中还举了一个常见的错误操作,即通过 <Route> 包裹的组件,就实在没必要包裹一层 withRouter 了:

// 这里的 withRouter 是完全没必要的
const MyComponent = withRouter(connect(...)(AComponent));

<Route path='/somewhere' component={MyComponent} />
/*
 * <Route path='/somewhere>
 *   <withRouter()>
 *     <Route>
 *       <connect()>
 *         <AComponent>
 */

context 与 shouldComponentUpdate 一起使用

通过上面对于 react-router 的讨论,也可以推广至其他使用 context 的场景。

在 React 16.3 以前,context 是一个实验性的 API,应该是尽量避免使用的,起码要尽量避免直接使用,虽然使用 context 实现跨级组件通信很方便。

如果使用 context 实现了跨级组件通信,就会面临这样的问题:shouldComponentUpdate 或者 PureComponent 阻止了 context 的 “捕获”。

import React, {PureComponent, Component} from 'react';
import ReactDOM from 'react-dom';
import {bind} from 'lodash-decorators';
import PropTypes from 'prop-types';

class ColorProvider extends Component {
    static childContextTypes = {
        color: PropTypes.string
    };

    getChildContext() {
        return {color: this.props.color};
    }

    render() {
        return this.props.children;
    }
}

class ColorText extends Component {
    static contextTypes = {
        color: PropTypes.string
    };

    render() {
        const {color} = this.context;
        const {children} = this.props;

        return (
            <div style={{color}}>{children}</div>
        );
    }
}

class TextBox extends PureComponent {
    render() {
        return <ColorText>TextBox</ColorText>
    }
}

class App extends Component {
    state = {
        color: 'red'
    };

    @bind()
    handleClick() {
        this.setState({color: 'blue'});
    }

    render() {
        const {color} = this.state;

        return (
            <ColorProvider color={color}>
                <button onClick={this.handleClick}>
                    <ColorText>Click Me</ColorText>
                </button>
                <TextBox />
            </ColorProvider>
        );
    }
}

ReactDOM.render(<App />, document.getElementById("app"));

上面这份代码的效果就是显示一个按钮和一行文字,点击按钮后,按钮颜色变蓝,但是下面那行文字却没动静。因为 TextBox 组件是 Pure 的,点击按钮后,它的 state 和 props 都没有变化,所以自然没有重新渲染。也就是说,这个 Pure 的组件把 context 的传递给拦截住了。

如何让 conext 和 shouldComponent 一起工作呢?我查了相关资料之后,得到了以下两种办法:

1. shouldComponentUpdate 是支持第三个参数的...如果 contextTypes 在组件中定义,下列的生命周期方法将接受一个额外的参数, 就是 context 对象:

constructor(props, context)
componentWillReceiveProps(nextProps, nextContext)
shouldComponentUpdate(nextProps, nextState, nextContext)
componentWillUpdate(nextProps, nextState, nextContext)
componentDidUpdate(prevProps, prevState, prevContext)

2. 使用一种类似 EventEmitter 这样的东西实现:

import React, {PureComponent, Component} from 'react';
import ReactDOM from 'react-dom';
import {bind} from 'lodash-decorators';
import PropTypes from 'prop-types';

class Color {
    constructor(value) {
        this.value = value;
        this.depends = [];
    }

    depend(f) {
        this.depends.push(f);
    }

    setValue(value) {
        this.value = value;
        this.depends.forEach(f => f());
    }
}

class ColorProvider extends Component {
    static childContextTypes = {
        color: PropTypes.object
    };

    getChildContext() {
        return {color: this.props.color};
    }

    render() {
        return this.props.children;
    }
}

class ColorText extends Component {
    static contextTypes = {
        color: PropTypes.object
    };

    componentDidMount() {
        this.context.color.depend(() => this.forceUpdate());
    }

    render() {
        const {color} = this.context;
        const {children} = this.props;
        return (
            <div style={{color: color.value}}>{children}</div>
        );
    }
}

class TextBox extends PureComponent {
    render() {
        return <ColorText>TextBox</ColorText>
    }
}

class App extends Component {
    color = new Color('red');

    @bind()
    handleClick() {
        this.color.setValue('blue');
    }

    render() {
        return (
            <ColorProvider color={this.color}>
                <button onClick={this.handleClick}>
                    <ColorText>Click Me</ColorText>
                </button>
                <TextBox />
            </ColorProvider>
        );
    }
}

ReactDOM.render(<App />, document.getElementById("app"));

更好的做法是,不要把 context 当做 state 用,而是把它作为一个 dependency:

Context should be used as if it is received only once by each component.

在 React 16.3 之后,context 有了正经的新 API,在新的 context API 中,使用 React.createContext(defaultValue) 这个 API 来创建 context 对象,使用 context 对象中的 <Provider /><Consumer /> 来操作 context。并且当 Provider 的值改变时,所有的 Consumer 也都会重新渲染,所以说,在新的 API 中,已经轻松避免了上述问题...

编辑于 2019-04-29

文章被以下专栏收录