Skip to content

组件实践(1):如何定义清晰可维护的接口

在第一节,我们已经知道,React 世界由组件构成,所以,如何设计组件的接口就成了组件设计最重要的事情。

设计原则

React 的组件其实就是软件设计中的模块,所以其设计原则也遵从通用的组件设计原则, 简单说来,就是要减少组件之间的耦合性(Coupling),让组件的界面简单,这样才能让整体系统易于理解、易于维护。

更具体一点,在设计 React 组件时,要注意以下原则:

  • 保持接口小,props 数量要少;
  • 根据数据边界来划分组件,充分利用组合(composition);
  • 把 state 往上层组件提取,让下层组件只需要实现为纯函数。

说太多理论没意思,让我们用一个实际例子来解读这些原则,我们选择“秒表”这个程序来讲。

秒表曾经是一个体育专业人士才配备的工具,不过,现在大家的手机上肯定都有这样的应用,比如,在 iPhone 上的“时钟”里就如下图所示的秒表。

按下右侧“启动”按钮,这个按钮就会变成“停止”,同时上面的数字时钟开始计时;按下“停止”按钮,数字时钟停止计时。

请注意左侧还有一个按钮,初始状态显示“复位”,点击该铵钮会清空时钟;开始计时之后,这个左侧按钮会变成“计次”,按一下“计次”,秒表底部就会增加一列时间,记录下按下“计次”这一瞬间的时刻。

秒表可以用来测量运动员的训练时间,比如运动员起跑的时候按“启动”,每跑一圈回到起点,按一下“计次”。这样跑十圈下来,可以知道每一圈都分别花了多少时间,运动员和教练可以做对应调整。

秒表是一个很实用的应用,复杂度也适当,这本小册中会以秒表为例展示 React 应用的开发,不过,我们无需急着写代码,首先来规划一下秒表的React 组件接口如何设计。

组件的划分

我们会做一个 React 组件来渲染整个秒表,这个组件可以叫做 StopWatch,

目前看来这个组件不需要从外部获得什么输入,本着“props 数量要少”的原则,我们也不需要费心未来会用上什么 props,目前就当 StopWatch 不支持 props 好了。

此外,这个组件需要记录当前计时,还要记录每一次按下“计次”的时间,所以需要维持状态(state),

可以肯定,StopWatch 是一个有状态的组件,不能只是一个纯函数,而是一个继承自 Component 的类。

js
class StopWatch extends React.Component {
  render() {
    // TODO: 返回所有JSX
  }
}

任何一个复杂组件都是从简单组件开始的,一开始我们在 render 函数里写的代码不多,但是随着逻辑的复杂,JSX 代码越来越多,于是,就需要拆分函数中的内容。

在 React 中,有一个误区,就是把 render 中的代码分拆到多个 renderXXXX 函数中去,比如下面这样:

jsx
class StopWatch extends React.Component {
    render() {
        const majorClock = this.renderMajorClock();
        const controlButtons = this.renderControlButtons();
        const splitTimes = this.renderSplitTimes();

        return (
            <div>
                {majorClock}
                {controlButtons}
                {splitTimes}
            </div>
        );
    }

    renderMajorClock() {
        //TODO: 返回数字时钟的JSX
    }

    renderControlButtons() {
        //TODO: 返回两个按钮的JSX
    }

    renderSplitTimes() {
        //TODO: 返回所有计次时间的JSX
    }
}

用上面的方法组织代码,当然比写一个巨大的 render 函数要强,但是,实现这么多 renderXXXX 函数并不是一个明智之举, 因为这些 renderXXXX 函数访问的是同样的 props 和 state,这样代码依然耦合在了一起。

更好的方法,是把这些 renderXXXX 重构成各自独立的 React 组件,像下面这样:

jsx
class StopWatch extends React.Component {
    render() {
        return (
            <div>
                <MajorClock>
                    <ControlButtons>
                        <SplitTimes>
            </div>
    );
    }
    }

    const MajorClock = (props) => {
        //TODO: 返回数字时钟的JSX
    };

    const ControlButtons = (props) => {
        //TODO: 返回两个按钮的JSX
    };

    const SplitTimes = (props) => {
        //TODO: 返回所有计次时间的JSX
    }

我们创造了 MajorClock、ControlButtons 和 SplitTimes 这三个组件, 目前,我们并不知道它们是否应该有自己的 state,但是从简单开始,首先假设它们没有自己的 state,定义为函数形式的无状态组件

按照数据边界来分割组件

现在,我们来检视一下,这样的组件划分,是否符合“按照数据边界划分”的原则。

渲染 MajorClock,需要的是当前展示的时间,在点击“启动”按钮之后,这个时间是不断增长的。

渲染 ControlButtons,两个按钮显示什么内容,完全由当前是否是“启动”的激活状态决定。 此外,Buttons 是秒表中唯一有用户输入的组件,对于按钮的按键会改变秒表的状态。

最后,计次时间 SplitTimes,需要渲染多个时间,可以想象,需要有一个数组来记录所有计次时间。

总结一下所有需要的数据和对应标识符,以及影响的组件:

| 数据 | 标识符 | 影响的组件| | 当前时间 | timeElapsed | MajorClock |是否启动 | activated | MajorClock, ControlButtons |计次时间 | splits| SplitTimes

从上面的表格可以看出,每个数据影响的组件都不多,唯一影响两个组件的数据是 activated,这个 activated 基本上就是一个布尔值,数据量很小,影响两个组件问题也不大。

这样的组件划分是符合以数据为边界原则的,很好。

state 的位置

接下来,我们要确定 state 的存储位置。

当秒表处于启动状态,MajorClock 会不断更新时间,似乎让 MajorClock 来存储时间相关的 state 很合理,但是仔细考虑一下,就会发现这样并不合适。

设想一下,MajorClock 包含一个 state 记录时间,因为 state 是组件的内部状态,只能通过组件自己来更新,所以要 MajorClock 用一个 setTimeout 或者 setInterval 来持续更新这个 state,可是,另一个组件 ControlButtons 将会决定什么时候暂停 MajorClock 的 state 更新,而且,当用户按下“计次“按钮的时候,MajorClock 还需要一个方法把当前的时间通知给 SplitTimes 组件。

这样一个数据传递过程,想一想都觉得很麻烦,明显不合适。

这时候就需要考虑这样的原则,尽量把数据状态往上层组件提取。 在秒表这个应用中,上层组件就是 StopWatch,如果我们让 StopWatch 来存储时间状态,那一切就会简单很多。

StopWatch 中利用 setTimeout 或者 setInterval 来更新 state,每一次更新会引发一次重新渲染,在重新渲染的时候,直接把当前时间值传递给 MajorClock 就完事了。

至于 ControlButtons 对状态的控制,让 StopWatch 传递函数类型 props 给 ControlButtons,当特定按钮时间点击的时候回调这些函数, StopWatch 就知道何时停止更新或者启动 setTimeout 或者 setInterval,因为这一切逻辑都封装在 StopWatch 中,非常直观自然。

对了,还有 SplitTimes,它需要一个数组记录所有计次时间,这些数据也很自然应该放在 StopWatch 中维护, 然后通过 props 传递给 SplitTimes,这样 SplitTimes 只单纯做渲染就足够

组件 props 的设计

当我们确定了组件结构和 state 之后,剩下来要做的就是 props 的设计了。

提示

根据各个组件结构,分别设计各个组件的 props。

先来看 MajorClock,因为它依赖的数据只有当前时间,所以只需要一个 props。

jsx
const MajorClock = ({milliseconds}) => {
    //TODO: 返回数字时钟的JSX
};

MajorClock.propTypes = {
    milliseconds: PropTypes.number.isRequired
};

和函数参数的命名一样,props的命名一定力求简洁而且清晰。

对于MajorClock,如果把这个props命名为 time,很容易引起歧义,这个 time 的单位是什么?是毫秒?还是秒?还是一个 Date 对象?

所以,我们明确传入的 props 是一个代表毫秒的数字,所以命名为 milliseconds,如果你的开发团队可以接受,也可以简写为 `ms。

然后是 ControlButtons,这个组件需要根据当前是否是“启动”状态显示不同的按钮,所以需要一个 props 来表示是否“启动”,我们把它命名为 activated。

此外,StopWatch 还需要传递回调函数给 ControlButtons,所以还需要支持函数类型的 props,分别代表 ControlButtons 可以做的几个动作:

  • 启动(start)

  • 停止(pause)

  • 计次(split)

  • 复位(reset)

一般来说,为了让开发者能够一眼认出回调函数类型的 props,这类 props 最好有一个统一的前缀,比如 on 或者 handle,我个人倾向于用 on,所以,ControlButtons 的接口就是下面这样:

jsx
const ControlButtons = (props) => {
    //TODO: 返回两个按钮的JSX
};

ControlButtons.propTypes = {
    activated: PropTypes.bool,
    onStart: PropTypes.func.isRquired,
    onPause: PropTypes.func.isRquired,
    onSplit: PropTypes.func.isRquired,
    onReset: PropTypes.func.isRquired,
};

最后是 SplitTimes,很简单,它需要接受一个数组类型的 props。

你知道吗?PropTypes 也可以支持数组类型的定义哦。

jsx
const SplitTimes = (props) => {
    //TODO: 返回所有计次时间的JSX
}

SplitTimes.propTypes = {
    splits: PropTypes.arrayOf(PropTypes.number)
};

至此,完成了秒表的组件接口设计,我们还完全没有涉及组件内部的具体代码编写,这在后面的小节中会详细讲解, 不过,一个好的设计就是要在写代码之前就应用被证明最佳的原则,这样写代码的过程就会少走弯路

小结

在这一节中,我们通过设计秒表,展示了组件接口设计的三个原则:

  • 保持接口小,props 数量要少
  • 根据数据边界来划分组件,利用组合(composition)
  • 把 state 尽量往上层组件提取

同时,我们也接触了这样一些最佳实践:

  • 避免 renderXXXX 函数
  • 给回调函数类型的 props 加统一前缀
  • 使用 propTypes 来定义组件的 props

Contributors

作者:Long Mo
字数统计:2.5k 字
阅读时长:8 分钟
Long Mo
文章作者:Long Mo
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Longmo Docs