使用 High-Order Components 替代 Mixins 解决 React 组件的代码复用

当使用 React 框架编写 UI 的时候,经常会发现若干组件有着相似的行为。举个例子, 我会有几个组件用来呈现一个 Promise 的最终结果,或展示一个 Rx 事件流的值变化,或是拖放操作的源/目标等等。我想去定义这些共同的行为一次,然后在需要时复用它们到我的组件类中。这,概括地讲,就是一个所谓 High-Order Components(高阶组件)做的事。

使用高阶组件的一个场景例子

假设我们在写一个国际电子商务网站。

当用户使用这个网站的时候,他们所看到的信息应是根据其居住国家而本地化的。这个网站使用用户的国家去决定展示价格的货币,计算运输费用等等。这个网站在每个页面的顶部导航条中显示顾客的国家。如果用户正在旅行,他们可以从该网站支持的国家列表中选择一个倾向的国家。

与用户会话关联的当前国家以及网站应用支持的国家列表都是通过 HTTP 从服务器获取到 JSON 格式数据并由 React 组件展示的。

例如,用户的国家数据如下:

{"iso": "gb", "name": "United Kingdom"}

而网站支持的国家列表数据如下:

[
  {"iso": "fr", "name": "France"},
  {"iso": "gb", "name": "United Kingdom"},
  ...
]

这个下面的Country组件展示了用户倾向的国家。因为国家数据是异步获取的,这个Country组件必须给予一个国家信息的 Promise。 当这个 Promise处于pending状态, 这个组件显示一个加载指示器。当这个 Promise 成功切换至resolved 状态时, 这个组件以一个国旗和名称的方式显示国家信息。如果,这个 Promise 状态是rejected,那么这个组件则显示错误信息。

class Country extends React.Component {  
    constructor(props) {
        super(props);
        this.state = {loading: true, error: null, country: null};
    }

    componentDidMount() {
        this.props.promise.then(
            value => this.setState({loading: false, country: value}),
            error => this.setState({loading: false, error: error}));
    }

    render() {
        if (this.state.loading) {
            return <span>Loading...</span>;
        }
        else if (this.state.error !== null) {
            return <span>Error: {this.state.error.message}</span>;
        }
        else {
            var iso = this.state.country.iso;
            var name = this.state.country.name;

            return (
                <span className="country">
                    <span className={"flag-icon flag-icon-"+iso}/>
                    <span className="country-name">{name}</span>
                </span>
            );
        }
    }
}

它可以像这样使用(假定fetchJson开始从一个 url 加载数据并返回一个包含了JSON数据的 Promise:

<Country promise={fetchJson('/api/country.json')}/>  

这个下面的CountryChooser 组件则显示可用国家列表,列表也是通过 Promise方式 传递给组件的:

class CountryChooser extends React.Component {  
    constructor(props) {
        super(props);
        this.state = {loading: true, error: null, countries: null};
    }

    componentDidMount() {
        this.props.promise.then(
            value => this.setState({loading: false, countries: value}),
            error => this.setState({loading: false, error: error}));
    }

    render() {
        if (this.state.loading) {
            return <span>Loading...</span>;
        }
        else if (this.state.error !== null) {
            return <span>Error: {this.state.error.message}</span>;
        }
        else {
            return (
                <ul className="country-chooser">
                    {this.state.countries.map(c => 
                        <li key={c.iso} onClick={() => this.props.onSelect(c.iso)}>
                            <span className="country">
                                <span className={"flag-icon flag-icon-"+c.iso}/>
                                <span className="country-name">{c.name}</span>
                            </span>
                        </li>)
                    }
                </ul>
            );
        }
    }
}

它可以像这样使用(假定使用这个相同的fetchJson函数以及发送国家变更信息至服务器的changeUsersPreferredCountry函数):

<CountryChooser promise={fetchJson('/api/countries.json')}  
                onSelect={changeUsersPreferredCountry}/>

这两个组件之间有很多重复的地方。

它们重复这个必要的状态机去接收和渲染通过 Promise 异步获取的数据。而且它们还只是该应用中需要异步从服务器抓取显示数据的组件一部分,因此处理好这个重复问题将明显改善网站的代码。

CountryChooser组件不能使用Country组件去展示列表中的国家,因为事件处理和数据呈现混合到一块去了。因此它重复了渲染国家至 HTML 的代码。我们不想要这些 HTML 代码片段分散,因为这将导致在 CSS 样式表中更多的重复。

我们该如何做?

通过组件的父级/子级关系,一个父级组件处理 Promise 事件而子组件渲染 Promise 返回值,不能满足我们的需求。 子组件的 props由创建组件的该层代码指定,但是在那个时候我们不知道这个 prop 值。我们想要在 Promise状态变更至 resolved 时动态计算 props。

我们可以剥离这个 Promise 事件处理至一个基类中。但是 JavaScript 只支持单重继承,因此如果我们的组件继承 Promises 的事件处理,它们就不能从基类中继承其他事件处理,例如 UI(注释1)。而且尽管它将 Promise 事件处理从渲染中解耦,它并没有将渲染从 Promise 事件处理中解耦,因此我们仍不能在CountryChooser组件中使用Country组件。

它听起来像 mixins 所做的工作,但是 React mixins 在 ES6类中不能正常工作,而且未来也将从 API 中移除该特性。

这个问题的最佳解决方案就是高阶组件。

高阶组件

一个高阶组件仅仅是一个从一个组件类至另一个组件类的函数A。这个函数将一个组件类作为参数并返回一个新的封装了有用的功能的组件类(注释2)。如果你熟悉“Gang of Four”设计模式并且在思考“装饰器模式”,你就很容易明白它。

为了速记,在文章接下来的部分,将会避免提及 ES7的装饰器特性,我将传入高阶组件中的组件类参数称之为封装类。我将使用封装组件来称呼这个封装类的实例,使用被封装组件来称呼这个被封装的类的实例。

一个封装组件通常代表被封装组件处理事件。它维持一些状态以及通过被封装组件的props来传递状态值和回调来与被封装组件交流。

假定我们有一个名为 Promised 的高阶组件,它转化 promise 的值至被封装组件的 props 中。这个封装组件对使用 promise 所需的状态进行管理。这意味着被封装组件可以是无状态的,只需要考虑内容呈现。

这个Country 组件现在只需要展示国家信息:

var Country = ({name, iso}) =>  
    <span className="country">
        <span className={"flag-icon flag-icon-"+iso}/>
        <span className="country-name">{name}</span>
    </span>;

为了定义一个通过 Promise 异步获取国家信息的组件,我们用 Promised 这个高阶组件来装饰它:

var AsyncCountry = Promised(Country);  

这个CountryChooser也可以写成一个无状态的组件,而且现在可以使用Country组件来展示每个国家:

var CountryChooser = ({countries, onSelect}) =>  
    <ul className="country-chooser">
    { 
        countries.map(c => 
            <li key={c.iso} onClick={() => onSelect(c.iso)}>
                <Country {...c}/>
            </li>)
    }
    </ul>;

且也可以用 Promised 来封装它去使用 promise 获取和接收国家列表数据:

var AsyncCountryChooser = Promised(CountryChooser);  

通过将状态管理移动至类级高阶组件,我们可以简化应用的组件,并可以使它们更多的复用。

高阶组件实例代码

这是一个 Promised 函数的实例:

var React = require('react');  
var R = require('ramda');

var Promised = Wrapped => class Promised extends React.Component {        // (1)  
    constructor(props) {
        super(props);
        this.state = {loading: true, error: null, value: null};           
    }

    componentDidMount() {
        this.props.promise.then(                                          // (2)
            value => this.setState({loading: false, value: value}),
            error => this.setState({loading: false, error: error}));
    }

    render() {
        if (this.state.loading) {
            return <span>Loading...</span>;
        }
        else if (this.state.error !== null) {
            return <span>Error: {this.state.error.message}</span>;
        }
        else {
            var propsWithoutThePromise = R.dissoc('promise', this.props); // (3)
            return <Wrapped {...propsWithoutThePromise}
                            {...this.state.value}/>;
        }
    }
};
  1. Promised是一个从一个组件类,在本例中是Wrapped组件,生成另一个类的函数。就像一个函数,在其作用域类给被封装组件类添加特性并返回,因此这个类可以引用这个函数的参数和本地变量。作为高阶组件的参数之一,被封装组件的名字必须以一个大写字母开头,这样能让 JSX 编译器正确将它识别为一个 React 组件,而不是一个 HTML DOM 元素。

  2. 客户端代码包含被封装组件props的 promise 以名为 promise 的 prop 传递至封装组件中。

  3. 封装组件传递所有其他 props 至未变化的被封装组件。这允许你用与配置未封装组件相同的 props 来配置一个Promised(X)组件。例如,你可以用传递给被封装组件的事件回调在被封装组件渲染时初始化封装组件。
    当封装组件渲染被封装组件时,它为被封装组件创建props,该 props 由 promise 得到的结果以及其自身的除了 promise 相关的属性合并而来。上面的代码使用了一个来自 Ramda 库的实用工具函数来移除封装组件中的与 promise 相关属性,而且使用 ES6“spread”语法移除和合并自身 props 和 promise 返回值。

避免属性冲突

敏锐的读者可能已经注意到AsyncCountryChooser的 API 与上边的CountryChooser组件有少许不同之处。原来的接受一个国家对象数组的 promise。但是Promised封装组件使用promised 值字段作为被封装组件的 props,因此 promised 值必须是一个包含了 key 为“countries”,而 value 为国家对象数组的对象。

我们可以通过在创建 promise 时映射这个数组至一个对象来达到上述要求:

<AsyncCountryChooser  
    promise={fetchJson('/api/countries.json').then(list => {countries: list})} 
    onSelect={changeCountry}/>,

另一个问题是当前的实例保留了 prop 名"promise"。这意味着我们不能传递一个名为 promise 的 prop 至被封装组件。这可能会在未来我们升级该系统时引发麻烦。

如果要它与任何组件兼容,高阶组件必须提供控制封装与被封装组件接口的方法,以避免命名冲突和正确从封装组件传递 props 的方式向被封装组件传递必要的数据。最灵活的方法,也是 NPM 中发布库最常用的方法,就是用一个函数,该函数映射封装组件的state和props至传递给被封装组件的props,来参数化高阶组件。这种方法,客户端代码完全控制封装和被封装组件的接口,且能够编程方式解决命名冲突。然而,在这个例子中,让调用者命名 promise属性,然后使用 promise 的 then 方法来映射 promised 返回值至被封装组件的 props 就足够了。

因为一个 JavaScript 类就是一个我们可以传递 promise 属性名至 Promised 函数以及要封装类的闭包。

var Promised = (promiseProp, Wrapped) => class extends React.Component {  
    constructor(props) {
        super(props);
        this.state = {loading: true, error: null, value: null};
    }

    componentDidMount() {
        this.props[promiseProp].then(
            value => this.setState({loading: false, value: value}),
            error => this.setState({loading: false, error: error}));
    }

    render() {
        if (this.state.loading) {
            return <span>Loading...</span>;
        }
        else if (this.state.error !== null) {
            return <span>Error: {this.state.error.message}</span>;
        }
        else {
            var propsWithoutThePromise = R.dissoc(promiseProp, this.props);
            return <Wrapped {...propsWithoutThePromise} {...this.state.value}/>;
        }
    }

};

现在我们需要在使用高阶组件去定义新的类时给这些 promises 命名。但这使得我们在代码中引入更好的名字,以我看来这是件好事。

var AsyncCountry = Promised("country", Country);  
var AsyncCountryChooser = Promised("countries", CountryChooser);

...
<AsyncCountry country={fetchJson('/api/country.json')}/>

<AsyncCountryChooser countries={fetchJson('/api/countries.json').then(list => {countries: list})}  
                     onSelect={changeCountry}/>

扩展阅读

你可以在 Github获得 本例的全部代码.

其他使用了 HOC 的 React 库包含:

原文链接: Higher Order React Components


注1. 例如,在目前的项目中我们需要添加实时更新、拖放等行为至无状态渲染组件中↩

注2. 实际上,一个高阶组件可以以多个组件为参数,但本例中我们只使用了一个↩