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 !