1. 定义组件接口

React 世界由组件构成,所以,如何设计组件的接口就成了组件设计最重要的事情。

(1)设计原则

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

在设计 React 组件时,要注意以下原则:

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

下面来看实现一个秒表(下图为iPhone上的秒表应用):
React 组件实践 - 图1
对于这个秒表,按下右侧“启动”按钮,这个按钮就会变成“停止”,同时上面的数字时钟开始计时;按下“停止”按钮,数字时钟停止计时。左侧还有一个按钮,初始状态显示“复位”,点击该铵钮会清空时钟;开始计时之后,这个左侧按钮会变成“计次”,按一下“计次”,秒表底部就会增加一列时间,记录下按下“计次”这一瞬间的时刻。

下面来规划一下秒表的 React 组件接口如何设计。

(2)组件的划分

下面来实现一个 React 组件渲染整个秒表,这个组件叫 StopWatch,目前看来这个组件不需要从外部获得什么输入,本着“props 数量要少”的原则,也不需要管未来会用上什么 props,目前就当 StopWatch 不支持 props。

此外,这个组件需要记录当前计时,还要记录每一次按下“计次”的时间,所以需要维持状态(state),所以 StopWatch 是一个有状态的组件,不能只是一个纯函数,而是一个继承自 Component 的类。

  1. class StopWatch extends React.Component {
  2. render() {
  3. //TODO: 返回所有JSX
  4. }
  5. }

在React组件中,随着逻辑的复杂,JSX 代码越来越多,就需要拆分函数中的内容。在 React 中有一个误区,就是把 render 中的代码分拆到多个 renderXXXX 函数中去,比如下面这样:

  1. class StopWatch extends React.Component {
  2. render() {
  3. const majorClock = this.renderMajorClock();
  4. const controlButtons = this.renderControlButtons();
  5. const splitTimes = this.renderSplitTimes();
  6. return (
  7. <div>
  8. {majorClock}
  9. {controlButtons}
  10. {splitTimes}
  11. </div>
  12. );
  13. }
  14. renderMajorClock() {
  15. //TODO: 返回数字时钟的JSX
  16. }
  17. renderControlButtons() {
  18. //TODO: 返回两个按钮的JSX
  19. }
  20. renderSplitTimes() {
  21. //TODO: 返回所有计次时间的JSX
  22. }
  23. }

用上面的方法组织代码,当然比写一个庞大的 render 函数要强,但是这些 renderXXXX 函数访问的是同样的 props 和 state,这样代码依然耦合在了一起。更好的方法是把这些 renderXXXX 重构成各自独立的 React 组件:

  1. class StopWatch extends React.Component {
  2. render() {
  3. return (
  4. <div>
  5. <MajorClock>
  6. <ControlButtons>
  7. <SplitTimes>
  8. </div>
  9. );
  10. }
  11. }
  12. const MajorClock = (props) => {
  13. //TODO: 返回数字时钟的JSX
  14. };
  15. const ControlButtons = (props) => {
  16. //TODO: 返回两个按钮的JSX
  17. };
  18. const SplitTimes = (props) => {
  19. //TODO: 返回所有计次时间的JSX
  20. }

初始化 MajorClockControlButtonsSplitTimes 这三个组件,目前,并不知道它们是否应该有自己的 state,但是从简单开始,首先假设它们没有自己的 state,定义为函数形式的无状态组件。

(3)按照数据边界来分割组件

现在,来看一下这样的组件划分是否符合“按照数据边界划分”的原则:

  • 渲染 MajorClock,需要的是当前展示的时间,在点击“启动”按钮之后,这个时间是不断增长的。
  • 渲染 ControlButtons,两个按钮显示什么内容,完全由当前是否是“启动”的激活状态决定。此外,Buttons 是秒表中唯一有用户输入的组件,对于按钮的按键会改变秒表的状态。
  • 计次时间 SplitTimes,需要渲染多个时间,可以想象,需要有一个数组来记录所有计次时间。

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

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

从表格可以看出,每个数据影响的组件都不多,唯一影响两个组件的数据是 activated,这个 activated 基本上就是一个布尔值,数据量很小,影响两个组件问题也不大。这样的组件划分是符合以数据为边界原则的。

(4)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 只单纯做渲染就足够。

(5)组件 props 的设计

当确定了组件结构和 state 之后,最后要做的就是设计 props。

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

  1. const MajorClock = ({milliseconds}) => {
  2. //TODO: 返回数字时钟的JSX
  3. };
  4. MajorClock.propTypes = {
  5. milliseconds: PropTypes.number.isRequired
  6. };

和函数参数的命名一样,props的命名一定力求简洁而且清晰。对于MajorClock,如果把这个props命名为 time,很容易引起歧义,这个 time 的单位是什么?是毫秒?还是秒?还是一个 Date 对象?

所以,明确传入的 props 是一个代表毫秒的数字,所以命名为 milliseconds

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

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

  • 启动(start)
  • 停止(pause)
  • 计次(split)
  • 复位(reset)

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

  1. const ControlButtons = (props) => {
  2. //TODO: 返回两个按钮的JSX
  3. };
  4. ControlButtons.propTypes = {
  5. activated: PropTypes.bool,
  6. onStart: PropTypes.func.isRquired,
  7. onPause: PropTypes.func.isRquired,
  8. onSplit: PropTypes.func.isRquired,
  9. onReset: PropTypes.func.isRquired,
  10. };

最后是 SplitTimes,它需要接收一个数组类型的 props。PropTypes 也可以支持数组类型的定义:

  1. const SplitTimes = (props) => {
  2. //TODO: 返回所有计次时间的JSX
  3. }
  4. SplitTimes.propTypes = {
  5. splits: PropTypes.arrayOf(PropTypes.number)
  6. };

至此,就完成了秒表的组件接口设计。

2. 组件内部实现

下面来具体实现一下秒表应用,目标如下:

  • 功能正常;
  • 代码整洁;
  • 高性能。

    (1)初始化应用框架

    使用React提供的 create-react-app 来创建秒表应用。可以使用下面的命令来全局安装:
    1. npm install -g create-react-app
    使用下面的命令来创建应用框架,在这里应用名字叫 basic_stop_watch
    1. create-react-app basic_stop_watch --use-npm
    在完成之后,会创建一个 basic_stop_watch 目录,进入这个目录,运行下面给的命令,就可以启动应用:
    1. npm start

    (2)构建 StopWatch

    上面已经确定了要用四个组件组合来实现“秒表”,这四个组件分别是 StopWatch、MajorClock、ControlButtons 和 SplitTimes。

为了达到“代码整洁”的目的,我们让每个组件都有一个独立的文件,然后这个文件用 export default 的方式导出单个组件。

比如,在 src 目录下为 ControlButtons 创建一个 ControlButtons.js 文件,最初内容像下面这样:

  1. import React from 'react';
  2. const ControlButtons = () => {
  3. //TODO: 实现ControlButtons
  4. };
  5. export default ControlButtons;

第一行导入 React,因为任何 JSX 都需要 React。最后一行用 export default 的方式导出 ControlButtons,这样,在其他组件中就可以用下面的方式导入:

  1. import ControlButtons from './ControlButtons';

我们知道,ControlButtons 可以接受的 props,其实现代码如下:

  1. const ControlButtons = (props) => {
  2. const {activated, onStart, onPause, onReset, onSplit} = props;
  3. return (
  4. {activated ? (
  5. <div>
  6. <button onClick={onSplit}>计次</button>
  7. <button onClick={onPause}>停止</button>
  8. </div>
  9. ) : (
  10. <div>
  11. <button onClick={onReset}>复位</button>
  12. <button onClick={onStart}>启动</button>
  13. </div>
  14. )}
  15. )
  16. };

这里用到了 ES6 中的解构赋值(Destructuring Assignment)。因为 ControlButtons 是一个函数类型的组件,所以 props 以参数形式传递进来,props 中的属性包含 activated 这样的值,利用大括号,就可以完成对 props 的“解构”,把 props.activated 赋值给同名的变量 activated

可以更进一步地把解构赋值提到参数中,这样连 props 的对象都看不见:

  1. const ControlButtons = ({activated, onStart, onPause, onReset, onSplit}) => {
  2. }

在 ControlButtons 的实现部分,根据 activated 的值返回不同的 JSX,当 activated 为 true 时,返回的是“计次”和“停止”;当 activated 为 false 时,返回的是“复位”和“启动”,对应分别使用了传入的 on 开头的函数类型 props。

可以看到,ControlButtons 除了显示内容和分配 props,没有做什么实质的工作。

接下来实现 MajorClock 组件,根据传入 props 的 milliseconds 来显示数字时钟一样的时分秒。在 MajorClock.js 文件中,我们这样定义 MajorClock:

  1. const MajorClock = ({milliseconds=0}) => {
  2. return <h1>{ms2Time(milliseconds)}</h1>
  3. };

在这里还使用了默认值。如果使用 MajorClock 时没有传入 milliseconds 这个 props,那么 milliseconds 的值就是 0
因为把毫秒数转为 HH:mm:ss:mmm 这样的格式和 JSX 没什么关系,所以,不在组件中直接编写,而是放在 ms2Time 函数中,ms2Time 就是 ms-to-Time,代码如下:

  1. import padStart from 'lodash/padStart';
  2. const ms2Time = (milliseconds) => {
  3. let time = milliseconds;
  4. const ms = milliseconds % 1000;
  5. time = (milliseconds - ms) / 1000;
  6. const seconds = time % 60;
  7. time = (time - seconds) / 60;
  8. const minutes = time % 60;
  9. const hours = (time - minutes) / 60;
  10. const result = padStart(hours, 2, '0') + ":" + padStart(minutes, 2, '0') + ":" + padStart(seconds, 2, '0') + "." + padStart(ms, 3, '0');
  11. return result;
  12. }

通过逐步从 milliseconds 中抽取毫秒、秒、分、时的信息,最终拼出正确的时间。不过,为了和数字时钟显示一致,需要补齐,比如 2 秒 23 毫秒,显示成 2:23 可不好看,不够的位数要补上 0,显示成 00:00:02:023 。这个补齐可以使用 lodash 中的 padStart 实现。为了在项目中使用 lodash,需要用 npm 完成对应的库安装。

最后是 SplitTimes 这个组件,在 SplitTimes.js 这个文件中,需要这样定义 SplitTimes:

  1. import MajorClock from './MajorClock';
  2. const SplitTimes = ({value=[]}) => {
  3. return value.map((v, k) => (
  4. <MajorClock key={k} milliseconds={v} />
  5. ));
  6. };

因为根据毫秒数显示数字时钟的功能在 MajorClock 中已经做到了,所以直接导入 MajorClock 使用就好,这符合“重用代码”的原则。

利用循环或者数组 map 而产生的动态数量的 JSX 元件,必须要有 key 属性。这个 key 属性帮助 React 搞清楚组件的顺序,如果不用 key,那 React 会在开发模式下在 console 上输出红色警告。

注意:key 不应该取数组的序号,因为 key 要唯一而且稳定,也即是每一次渲染过程中,key 都能唯一标识一个内容。但是,对于 StopWatch 这个例子,可以直接使用数组序号,因为计次时间的数组顺序不会改变,使用数组序号足够唯一标识内容。

(3)StopWatch 状态管理

在实现了 MajorClock、ControlButtons 和 SplitTimes 之后,我们需要把这些子组件串起来,这就是 StopWatch。
StopWatch 是一个有状态的组件,所以,不能只用一个函数实现,而是做成一个继承自 React.Component 的类:

  1. class StopWatch extends React.Component {
  2. render() {
  3. return (
  4. <Fragment>
  5. <MajorClock />
  6. <ControlButtons />
  7. <SplitTimes />
  8. </Fragment>
  9. );
  10. }
  11. }

对于一个 React 组件类,最少要有一个 render 函数实现,不过,上面的 render 只是一个大概的代码框架,引用了相关子组件,但是没有传入 props。

传入的 props 就是 StopWatch 记录的 state。StopWatch 的 state 需要有这些信息:

  • isStarted,是否开始计时;
  • startTime,计时开始时间,Date 对象;
  • currentTime,当前时间,也是 Date 对象;
  • splits,所有计次时间的数组,每个元素是一个毫秒数。

React 组件的 state 需要初始化,一般来说,初始化 state 是在构造函数中:

  1. constructor() {
  2. super(...arguments);
  3. this.state = {
  4. isStarted: false,
  5. startTime: null,
  6. currentTime: null,
  7. splits: [],
  8. };
  9. }

如果定义构造函数 constructor,需要通过 super 调用父类 React.Component 的构造函数,不然,功能会不正常。React官方网站上的代码示例是这样调用super函数:

  1. constructor(props) {
  2. super(props); //目前可行,但有更好的方法
  3. }

在早期版本中,React.Component 的构造函数参数有两个,第一个是 props,第二个是 context,如果忽略掉 context 参数,那么这个组件的 context 功能就不能正常工作,不过,现在React的行为已经变了,第二个参数传递不传递都能让context正常工作,看起来React.Component 的构造函数只有第一个参数被用到,但是,没准未来还会增加新的参数呢,所以,以不变应万变的方法,就是使用扩展操作符(spread operator)来展开 arguments,这样不管 React 将来怎么变,这样的代码都正确。

  1. constructor() {
  2. super(...arguments); //永远正确!
  3. }

扩展操作符的作用,在 React 开发中会经常用到,在 JSX 中展开 props 的时候会用到。

(4)属性初始化方法

其实我可以不写 constructor 函数,而直接使用属性初始化(Property Initializer),也就是在 class 定义中直接初始化类的成员变量。效果是完全一样的:

  1. class StopWatch extends React.Component {
  2. state = {
  3. isStarted: false,
  4. startTime: null,
  5. currentTime: null,
  6. splits: [],
  7. }
  8. }

接下来实现传递给 ControlButtons 的一系列函数。需要注意,在实际工作中,在 JSX 中应用的函数 props 应该尽量使用类成员函数,不要用内联函数。

以最 onSplit 为例,这个函数响应用户点击“计次”按钮的事件,代码如下:

  1. onSplit() {
  2. this.setState({
  3. splits: [...this.state.splits, this.state.currentTime - this.state.startTime]
  4. });
  5. }

在 onSplit 中,利用 this.setState 来修改组件的状态。那这个函数执行时,this 是什么呢?对于 ES6 的类成员函数,并不自动绑定 this,也就是说,onSplit 中的 this,可不保证就是当前组件对象。至于 render 这些生命周期函数,里面访问的 this 就是当前组件本身,完全是因为这些函数是 React 调用的,React 对它们进行了特殊处理,对于其他普通的成员函数,特殊处理就要靠我们自己了。

通常的处理方法,就是在构造函数中对函数进行绑定,然后把新产生的函数覆盖原有的函数,就像这样:

  1. constructor() {
  2. super(...arguments);
  3. this.onSplit = this.onSplit.bind(this);
  4. }

StopWatch 需要给 ControlButtons 传递四个函数类型的 props,分别是 onStartonPauseonResetonSplit,对每一个函数都在构造函数里加一个 bind很容易出错。更好的方法依然是使用属性初始化,就和初始化 state 一样,利用等号直接初始化

StopWatch 需要给 ControlButtons 传递四个函数类型的 props,分别是 onStartonPauseonResetonSplit,对每一个函数都在构造函数里加一个 bind 很容易出错,更好的方法依然是使用属性初始化,就和初始化 state 一样,利用等号直接初始化 onSplit,代码如下:

  1. onSplit = () => {
  2. this.setState({
  3. splits: [...this.state.splits, this.state.currentTime - this.state.startTime]
  4. });
  5. }

这样写,就不需要 constructor,函数体内的 this 绝对就是当前组件对象。用同样的方法来实现其他函数成员:

  1. onStart = () => {
  2. this.setState({
  3. isStarted: true,
  4. startTime: new Date(),
  5. currentTime: new Date(),
  6. });
  7. this.intervalHandle = setInterval(() => {
  8. this.setState({currentTime: new Date()});
  9. }, 1000 / 60);
  10. }
  11. onPause = () => {
  12. clearInterval(this.intervalHandle);
  13. this.setState({
  14. isStarted: false,
  15. });
  16. }
  17. onReset = () => {
  18. this.setState({
  19. startTime: null,
  20. currentTime: null,
  21. splits: [],
  22. });
  23. }

至此,秒表的功能就完成了,在 App.js 文件中导入 StopWatch,在浏览器中就可以看到这样的界面。
React 组件实践 - 图2
点击“启动”按钮,可以看见数字时钟开始运转;点击“计次”按钮,在按钮下方可以看到点击瞬间的时间;点击“停止”,时钟停止运转。
React 组件实践 - 图3

3. 组件化样式

上面只实现了秒表的功能,而没有关注样式。下面就来给 React 组件增加样式,让“秒表”这个应用看起来更美观。

(1)React 带来的对样式管理革命

在 React 诞生之前,为了修改一个功能,需要牵扯到 HTML、CSS 和 JavaScript 的修改,但是这三部分分别属于不同的文件,一个功能却要去修改至少三个文件。

在软件开发中,同一个功能相关的代码最好放在一个地方,这就是高内聚性(High Cohesiveness)。把网页功能分在 HTML、CSS 和 JavaScript 中,明显背离了高内聚性的原则,直到 React 出现。

在实现“秒表”的时候,“内容”和“动态功能”已经混合在一起,换句话说,JSX 负责产生“内容”,和各种响应用户输入的 JavaScript 代码共同存在于 React 组件之中。在 React 中,当修改一个功能的内容和行为时,在一个文件中就能完成,这样就达到了高内聚的要求。

那么,在 React 中又是如何处理样式的呢?下面先从组件的 style 属性开始,最后过渡到组件式的样式。

(2)style 属性

上面实现的“秒表”中,虽然功能齐备,但是展示上有一个大问题,就是当时钟开始运转之后,因为各个数字的宽度不同,比如1 就没有 0 宽,导致时间宽度忽大忽小,产生闪烁效果,这样看起来很不专业。为了解决这个问题,就需要定制 MajorClock 的样式。

最简单也是最直接的方法,就是给对应的 React 元素增加 style 属性,属性值为一个普通的 JavaScript 对象:

  1. const MajorClock = ({milliseconds=0}) => {
  2. const style = {
  3. 'font-family': 'monospace'
  4. };
  5. return <h1 style={style}>{ms2Time(milliseconds)}</h1>
  6. }

在上面的例子中,把 MajorClock 中的 h1 元素的 font-family 设为 monospace,monospace 是等宽字体,这样所有数字所占宽度相同,数字变化起来的时候宽度也就不会发生变化了,效果图如下:

React 组件实践 - 图4

如果 style 对象每次都是一样的,最好把它提取到组件之外,这样就可以重用一个对象:

  1. const clockStyle = {
  2. 'font-family': 'monospace'
  3. };
  4. const MajorClock = ({milliseconds=0}) => {
  5. return <h1 style={clockStyle}>{ms2Time(milliseconds)}</h1>
  6. }

(3)导入 CSS 文件

React 也支持使用 CSS 来定制样式。下面以 ControlButtons 为例,改进控制按钮的样式。

为了配合 CSS,需要在 ControlButtons 的 JSX 中让渲染出来的 DOM 元素包含 class。

  1. const ControlButtons = ({activated, onStart, onPause, onReset, onSplit}) => {
  2. return (
  3. {activated ? (
  4. <div>
  5. <button className="left-btn" onClick={onSplit}>计次</button>
  6. <button className="right-btn" onClick={onPause}>停止</button>
  7. </div>
  8. ) : (
  9. <div>
  10. <button className="left-btn" onClick={onReset}>复位</button>
  11. <button className="right-btn" onClick={onStart}>启动</button>
  12. </div>
  13. )}
  14. )
  15. };

ControlButtons.js 中增加下面这样,导入一个同目录下的 ControlButtons.css 文件:

  1. import "./ControlButtons.css";

create-react-app 会用 webpack 完成打包过程,只要 JavaScript 文件中应用的资源,都会被打包进最终的文件,所以,ControlButtons.css 中的样式规则就会被应用。ControlButtons.css 中的内容如下:

  1. .left-btn, .right-btn {
  2. border-radius: 50%;
  3. width: 70px;
  4. height: 70px;
  5. }
  6. .left-btn {
  7. margin: 0 35px 0 0;
  8. }
  9. .right-btn {
  10. margin: 0 0 0 35px;
  11. }

最终的效果图如下:
React 组件实践 - 图5

(4)组件式的样式

对比使用 style 属性和导入 CSS 两种方法,可以看出各有优缺点。

使用 style 属性,好处是可以将样式应用到每个元素,互相不干扰;缺点就是非常不简洁,如果想要定制一个元素的样式,必须给这个元素加 style 属性。

比如,我们想让 MajorClock 中的 h1 元素字体为 monospace,使用 style 属性来实现,就要给 h1 加上 style,如果只有一个 h1 元素还好应付,如果很多 h1 元素,就非常麻烦:

  1. const style={
  2. 'font-family': 'monospace'
  3. };
  4. <h1 style={style}>...</h1>

相反,用 CSS 表达复杂的样式规则很容易,比如,上一段提到的样式,用 CSS 轻松可以实现,而且不用给每个 h1 加什么 style 属性。

  1. h1 {
  2. font-family: monospace;
  3. }

不过,CSS 也有它的缺点,CSS 定义的样式是全局的,这样很容易失控,比如上面的 CSS 规则,一旦导入,那么所有的 h1 都具备这样的样式,即使不在 MajorClock 中的h1 元素,一样被 MajorClock 导入的 CSS 文件影响。

对于不同模块之间 CSS 互相干扰的问题,有很多种解决方法,基本原则就是给 CSS 规则增加更加特定的限制。比如,要限定上面的 CSS 规则只作用于 MajorClock 中的 h1 元素,就要这样来写一个 MajorClock.css

  1. .clock h1 {
  2. font-family: monospace;
  3. }

但是,也需要修改 MajorClock 的 JSX,让 h1 包含在一个类名为 clock 的元素中。

  1. import "./MajorClock.css";
  2. const MajorClock = ({milliseconds=0}) => {
  3. return (
  4. <div class="clock">
  5. <h1 style={clockStyle}>{ms2Time(milliseconds)}</h1>
  6. </div>
  7. );
  8. }

这样当然可行,但是这样不好处理 JSX 和 CSS 之间的关系,而且违背高内聚的原则。当需要修改一个组件时,要被迫去分别修改 JavaScript 文件 和 CSS 文件,明显不是最优的方法。

在 React 的世界中,一切都是组件,所以很自然诞生了组件化的样式(Component Style)。组件化样式的实现方式很多,下面来看一下最容易理解的库:styled-jsx

1)添加 styled-jsx 支持
要使用 styled-jsx,必须要修改 webpack 配置,一般来说,对于用 create-react-app 创建的应用,需要用 eject 方法来“弹射”出配置文件,只是,eject 指令是不可逆的,不到万不得已,我们还是不要轻易“弹射”。一个更简单的方式,是使用 react-app-rewired,不需要 eject,就能够修改 create-react-app 产生应用的配置方法。

首先,在项目中安装 react-app-rewired 和 styled-jsx:

  1. npm install react-app-rewired styled-jsx

打开 package.json 文件,找到 scripts 这个部分,应该是下面这样:

  1. "scripts": {
  2. "start": "react-scripts start",
  3. "build": "react-scripts build",
  4. "test": "react-scripts test --env=jsdom",
  5. "eject": "react-scripts eject"
  6. }

当执行 npm start 时,执行的就是 scripts 部分定义的指令,可以看到都是执行 react-scripts。在这里还可以看到 eject 指令的定义,做这个修改就是为避免使用 eject

修改 scripts 部分的代码如下:

  1. "scripts": {
  2. "start": "react-app-rewired start",
  3. "build": "react-app-rewired build",
  4. "test": "react-app-rewired test --env=jsdom",
  5. "eject": "react-scripts eject"

修改的方法就是把 startbuildtest 对应脚本中的 react-scripts 替换为 react-app-rewired,之后,当用 npm 执行这些指令的时候,就会使用 react-app-rewired。

react-app-rewired 扩展了 react-scripts 的功能,可以从当前目录的 config-overrides.js 文件中读取配置,扩充 react-scripts 的功能。

需要让 react-scripts 支持 styled-jsx,对应只需要在项目根目录增加一个 config-overrides.js 文件,内容如下:

  1. const { injectBabelPlugin } = require('react-app-rewired');
  2. module.exports = function override(config, env) {
  3. config = injectBabelPlugin(['styled-jsx/babel'], config);
  4. return config;
  5. };

上面 config-overrides.js 文件就是把 styled-jsx/babel 注入到 react-scripts 的基本配置中去,然后应用就支持 styled-jsx 了。

2)使用 styled-jsx 定制样式
有了 styled-jsx 中,就可以在 JSX 中用 style jsx 标签直接添加 CSS 规则。比如,给 MajorClock 中的 h1 增加 CSS 规则,可以这样使用:

  1. const MajorClock = ({milliseconds=0}) => {
  2. return (
  3. <React.Fragment>
  4. <style jsx>{`
  5. h1 {
  6. font-family: monospace;
  7. }
  8. `}</style>
  9. <h1>
  10. {ms2Time(milliseconds)}
  11. </h1>
  12. </React.Fragment>
  13. );
  14. };

注意紧贴 style jsx 内部的是一对大括号,大括号代表里面是一段 JavaScript 的表达式,再往里,是一对符号,代表中间是一段多行的字符串,也就是说,style jsx 包裹的是一个字符串表达式,而这个字符串就是 CSS 规则。

在 MajorClock 中用 style jsx 添加的 CSS 规则,只作用于 MajorClock 的 JSX 中出现的元素,不会影响其他的组件。

在 StopWatch 中添加一个 h1 元素,内容就是“秒表”,然后用 style jsxh1 的颜色设为绿色:

  1. render() {
  2. return (
  3. <Fragment>
  4. <style jsx>{`
  5. h1 {
  6. color: green;
  7. }
  8. `}</style>
  9. <h1>秒表</h1>
  10. <MajorClock
  11. milliseconds={this.state.currentTime - this.state.startTime}
  12. activated={this.state.isStarted}
  13. />
  14. ...

可以看到,StopWatch 中的 h1 字体不是 monospace,MajorClock 中的 color 也不是绿色:
React 组件实践 - 图6
可见,styled jsx 中虽然使用了 CSS,但是这些 CSS 规则只作用于所在组件中的样式,甚至不会影响子组件的样式。这样既可以使用 CSS 的语法,又可以把 CSS 的作用域限定在一个组件之内,达到了高内聚的要求。

3)动态 styled jsx**
我们还可以动态修改 styled jsx 中的值,因为 styled jsx 的内容就是字符串,只要修改其中的字符串,就修改了样式效果。

比如,让 MajorClock 在开始计时状态显示红色,否则显示黑色,修改代码如下:

  1. const MajorClock = ({milliseconds=0, activated=false}) => {
  2. return (
  3. <React.Fragment>
  4. <style jsx>{`
  5. h1 {
  6. color: ${activated? 'red' : 'black'};
  7. font-family: monospace;
  8. }
  9. `}</style>
  10. <h1>
  11. {ms2Time(milliseconds)}
  12. </h1>
  13. </React.Fragment>
  14. );
  15. };

在 style jsx 中,color 后面的值不是固定的,利用 ES6 的字符串模板功能,可以根据 activated 的值动态决定是 red 还是 black:
React 组件实践 - 图7