1. 定义组件接口
React 世界由组件构成,所以,如何设计组件的接口就成了组件设计最重要的事情。
(1)设计原则
React 的组件其实就就是软件设计中的模块,所以其设计原则也遵从通用的组件设计原则,简单说来,就是要减少组件之间的耦合性(Coupling),让组件的界面简单,这样才能让整体系统易于理解、易于维护。
在设计 React 组件时,要注意以下原则:
1. 保持接口小,props 数量要少;
2. 根据数据边界来划分组件,充分利用组合(composition);
3. 把 state 往上层组件提取,让下层组件只需要实现为纯函数。
下面来看实现一个秒表(下图为 iPhone 上的秒表应用):
对于这个秒表,按下右侧“启动”按钮,这个按钮就会变成“停止”,同时上面的数字时钟开始计时;按下“停止”按钮,数字时钟停止计时。左侧还有一个按钮,初始状态显示“复位”,点击该铵钮会清空时钟;开始计时之后,这个左侧按钮会变成“计次”,按一下“计次”,秒表底部就会增加一列时间,记录下按下“计次”这一瞬间的时刻。
(2)组件的划分
下面来实现一个 React 组件渲染整个秒表,这个组件叫 StopWatch
,目前看来这个组件不需要从外部获得什么输入,本着“props 数量要少”的原则,也不需要管未来会用上什么 props
,目前就当 StopWatch
不支持 props
。
此外,这个组件需要记录当前计时,还要记录每一次按下“计次”的时间,所以需要维持状态(state
),所以 StopWatch
是一个有状态的组件,不能只是一个纯函数,而是一个继承自 Component
的类。
1 | class StopWatch extends React.Component { |
在 React 组件中,随着逻辑的复杂,JSX 代码越来越多,就需要拆分函数中的内容。在 React 中有一个误区,就是把 render
中的代码分拆到多个 renderXXXX
函数中去,比如下面这样:
1 | class StopWatch extends React.Component { |
用上面的方法组织代码,当然比写一个庞大的 render
函数要强,但是这些 renderXXXX
函数访问的是同样的 props
和 state
,这样代码依然耦合在了一起。更好的方法是把这些 renderXXXX
重构成各自独立的 React 组件:
1 | class StopWatch extends React.Component { |
初始化 MajorClock
、ControlButtons
和 SplitTimes
这三个组件,目前,并不知道它们是否应该有自己的 state
,但是从简单开始,首先假设它们没有自己的 state
,定义为函数形式的无状态组件。
(3)按照数据边界来分割组件
现在,来看一下这样的组件划分是否符合“按照数据边界划分”的原则:
● 渲染 MajorClock
,需要的是当前展示的时间,在点击“启动”按钮之后,这个时间是不断增长的。
● 渲染 ControlButtons
,两个按钮显示什么内容,完全由当前是否是“启动”的激活状态决定。此外,Buttons 是秒表中唯一有用户输入的组件,对于按钮的按键会改变秒表的状态。
● 计次时间 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}) => { |
和函数参数的命名一样,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) => { |
最后是 SplitTimes
,它需要接收一个数组类型的 props
。PropTypes
也可以支持数组类型的定义:
至此,就完成了秒表的组件接口设计。
1 | const SplitTimes = (props) => { |
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'; |
第一行导入 React,因为任何 JSX 都需要 React。最后一行用 export default
的方式导出 ControlButtons
,这样,在其他组件中就可以用下面的方式导入:
1 | import ControlButtons from './ControlButtons'; |
我们知道,ControlButtons
可以接受的 props
,其实现代码如下:
1 | const ControlButtons = (props) => { |
这里用到了 ES6
中的解构赋值(Destructuring Assignment)。因为 ControlButtons
是一个函数类型的组件,所以 props
以参数形式传递进来,props
中的属性包含 activated
这样的值,利用大括号,就可以完成对 props
的“解构”,把 props.activated
赋值给同名的变量 activated
。
可以更进一步地把解构赋值提到参数中,这样连 props
的对象都看不见:
1 | const ControlButtons = ({activated, onStart, onPause, onReset, onSplit}) => { |
在 ControlButtons
的实现部分,根据 activated
的值返回不同的 JSX,当 activated
为 true
时,返回的是“计次”和“停止”;当 activated
为 false
时,返回的是“复位”和“启动”,对应分别使用了传入的 on
开头的函数类型 props
。
可以看到,ControlButtons
除了显示内容和分配 props
,没有做什么实质的工作。
接下来实现 MajorClock
组件,根据传入 props
的 milliseconds
来显示数字时钟一样的时分秒。在 MajorClock.js
文件中,我们这样定义 MajorClock
:
1 | const MajorClock = ({milliseconds=0}) => { |
在这里还使用了默认值。如果使用 MajorClock
时没有传入 milliseconds
这个 props
,那么 milliseconds
的值就是 0。
因为把毫秒数转为 HH:mm:ss:mmm
这样的格式和 JSX 没什么关系,所以,不在组件中直接编写,而是放在 ms2Time
函数中,ms2Time
就是 ms-to-Time
,代码如下:
1 | import padStart from 'lodash/padStart'; |
通过逐步从 milliseconds
中抽取毫秒、秒、分、时的信息,最终拼出正确的时间。不过,为了和数字时钟显示一致,需要补齐,比如 2 秒 23 毫秒,显示成 2:23 可不好看,不够的位数要补上 0,显示成 00:00:02:023 。这个补齐可以使用 lodash 中的 padStart
实现。为了在项目中使用 lodash,需要用 npm 完成对应的库安装。
最后是 SplitTimes
这个组件,在 SplitTimes.js
这个文件中,需要这样定义 SplitTimes
:
1 | import MajorClock from './MajorClock'; |
因为根据毫秒数显示数字时钟的功能在 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 { |
对于一个 React 组件类,最少要有一个 render
函数实现,不过,上面的 render
只是一个大概的代码框架,引用了相关子组件,但是没有传入 props
。
传入的 props
就是 StopWatch
记录的 state
。StopWatch
的 state
需要有这些信息:
● isStarted
,是否开始计时;
● startTime
,计时开始时间,Date 对象;
● currentTime
,当前时间,也是 Date 对象;
● splits
,所有计次时间的数组,每个元素是一个毫秒数。
React 组件的 state 需要初始化,一般来说,初始化 state 是在构造函数中:
1 | constructor() { |
如果定义构造函数 constructor
,需要通过 super
调用父类 React.Component
的构造函数,不然,功能会不正常。React 官方网站上的代码示例是这样调用 super 函数:
1 | constructor(props) { |
在早期版本中,React.Component
的构造函数参数有两个,第一个是 props
,第二个是 context
,如果忽略掉 context
参数,那么这个组件的 context
功能就不能正常工作,不过,现在 React 的行为已经变了,第二个参数传递不传递都能让context
正常工作,看起来React.Component
的构造函数只有第一个参数被用到,但是,没准未来还会增加新的参数呢,所以,以不变应万变的方法,就是使用扩展操作符(spread operator)来展开 arguments
,这样不管 React 将来怎么变,这样的代码都正确。
1 | constructor() { |
扩展操作符的作用,在 React 开发中会经常用到,在 JSX 中展开 props 的时候会用到。
(4)属性初始化方法
其实我可以不写 constructor
函数,而直接使用属性初始化(Property Initializer),也就是在 class
定义中直接初始化类的成员变量。效果是完全一样的:
1 | class StopWatch extends React.Component { |
接下来实现传递给 ControlButtons
的一系列函数。需要注意,在实际工作中,在 JSX 中应用的函数 props 应该尽量使用类成员函数,不要用内联函数。
以最 onSplit
为例,这个函数响应用户点击“计次”按钮的事件,代码如下:
1 | onSplit() { |
在 onSplit
中,利用 this.setState
来修改组件的状态。那这个函数执行时,this
是什么呢?对于 ES6
的类成员函数,并不自动绑定 this
,也就是说,onSplit
中的 this
,可不保证就是当前组件对象。至于 render
这些生命周期函数,里面访问的 this
就是当前组件本身,完全是因为这些函数是 React 调用的,React 对它们进行了特殊处理,对于其他普通的成员函数,特殊处理就要靠我们自己了。
通常的处理方法,就是在构造函数中对函数进行绑定,然后把新产生的函数覆盖原有的函数,就像这样:
1 | constructor() { |
StopWatch
需要给 ControlButtons
传递四个函数类型的 props
,分别是 onStart
、onPause
、onReset
和 onSplit
,对每一个函数都在构造函数里加一个 bind
很容易出错。更好的方法依然是使用属性初始化,就和初始化 state
一样,利用等号直接初始化
StopWatch
需要给 ControlButtons
传递四个函数类型的 props
,分别是 onStart
、onPause
、onReset
和 onSplit
,对每一个函数都在构造函数里加一个 bind
很容易出错,更好的方法依然是使用属性初始化,就和初始化 state
一样,利用等号直接初始化 onSplit
,代码如下:
1 | onSplit = () => { |
这样写,就不需要 constructor
,函数体内的 this
绝对就是当前组件对象。用同样的方法来实现其他函数成员:
1 | onStart = () => { |
至此,秒表的功能就完成了,在 App.js
文件中导入 StopWatch
,在浏览器中就可以看到这样的界面。
点击“启动”按钮,可以看见数字时钟开始运转;点击“计次”按钮,在按钮下方可以看到点击瞬间的时间;点击“停止”,时钟停止运转。
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}) => { |
在上面的例子中,把 MajorClock
中的 h1
元素的 font-family
设为 monospace
,monospace
是等宽字体,这样所有数字所占宽度相同,数字变化起来的时候宽度也就不会发生变化了,效果图如下:
如果 style
对象每次都是一样的,最好把它提取到组件之外,这样就可以重用一个对象:
1 | const clockStyle = { |
(3)导入 CSS 文件
React 也支持使用 CSS 来定制样式。下面以 ControlButtons
为例,改进控制按钮的样式。
为了配合 CSS,需要在 ControlButtons
的 JSX 中让渲染出来的 DOM 元素包含 class
。
1 | const ControlButtons = ({activated, onStart, onPause, onReset, onSplit}) => { |
在 ControlButtons.js
中增加下面这样,导入一个同目录下的 ControlButtons.css
文件:
1 | import "./ControlButtons.css"; |
create-react-app
会用 webpack
完成打包过程,只要 JavaScript 文件中应用的资源,都会被打包进最终的文件,所以,ControlButtons.css
中的样式规则就会被应用。ControlButtons.css
中的内容如下:
1 | .left-btn, .right-btn { |
最终的效果图如下:
(4)组件式的样式
对比使用 style
属性和导入 CSS 两种方法,可以看出各有优缺点。
使用 style 属性,好处是可以将样式应用到每个元素,互相不干扰;缺点就是非常不简洁,如果想要定制一个元素的样式,必须给这个元素加 style 属性。
比如,我们想让 MajorClock
中的 h1
元素字体为 monospace
,使用 style
属性来实现,就要给 h1
加上 style
,如果只有一个 h1
元素还好应付,如果很多 h1
元素,就非常麻烦:
1 | const style={ |
相反,用 CSS 表达复杂的样式规则很容易,比如,上一段提到的样式,用 CSS 轻松可以实现,而且不用给每个 h1 加什么 style 属性。
1 | h1 { |
不过,CSS 也有它的缺点,CSS 定义的样式是全局的,这样很容易失控,比如上面的 CSS 规则,一旦导入,那么所有的 h1 都具备这样的样式,即使不在 MajorClock 中的 h1 元素,一样被 MajorClock 导入的 CSS 文件影响。
对于不同模块之间 CSS 互相干扰的问题,有很多种解决方法,基本原则就是给 CSS 规则增加更加特定的限制。比如,要限定上面的 CSS 规则只作用于 MajorClock 中的 h1 元素,就要这样来写一个 MajorClock.css:
1 | .clock h1 { |
但是,也需要修改 MajorClock 的 JSX,让 h1 包含在一个类名为 clock 的元素中。
1 | import "./MajorClock.css"; |
这样当然可行,但是这样不好处理 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": { |
当执行 npm start
时,执行的就是 scripts
部分定义的指令,可以看到都是执行 react-scripts
。在这里还可以看到 eject
指令的定义,做这个修改就是为避免使用 eject
。
修改 scripts
部分的代码如下:
1 | "scripts": { |
修改的方法就是把 start
、build
和 test
对应脚本中的 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'); |
上面 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}) => { |
注意紧贴 style jsx
内部的是一对大括号,大括号代表里面是一段 JavaScript 的表达式,再往里,是一对符号,代表中间是一段多行的字符串,也就是说,style jsx
包裹的是一个字符串表达式,而这个字符串就是 CSS 规则。
在 MajorClock
中用 style jsx
添加的 CSS 规则,只作用于 MajorClock 的 JSX 中出现的元素,不会影响其他的组件。
在 StopWatch
中添加一个 h1
元素,内容就是“秒表”,然后用 style jsx
把 h1
的颜色设为绿色:
1 | render() { |
可以看到,StopWatch
中的 h1
字体不是 monospace
,MajorClock
中的 color
也不是绿色:
可见,styled jsx
中虽然使用了 CSS,但是这些 CSS 规则只作用于所在组件中的样式,甚至不会影响子组件的样式。这样既可以使用 CSS 的语法,又可以把 CSS 的作用域限定在一个组件之内,达到了高内聚的要求。
3)动态 styled jsx
我们还可以动态修改 styled jsx
中的值,因为 styled jsx
的内容就是字符串,只要修改其中的字符串,就修改了样式效果。
比如,让 MajorClock
在开始计时状态显示红色,否则显示黑色,修改代码如下:
1 | const MajorClock = ({milliseconds=0, activated=false}) => { |
在 style jsx
中,color
后面的值不是固定的,利用 ES6 的字符串模板功能,可以根据 activated
的值动态决定是 red
还是 black
: