Skip to content

组件实践(2):组件的内部实现

作者:Long Mo
字数统计:3.4k 字
阅读时长:11 分钟

上一小节中,通过设计一个“秒表”应用,我们学习了组件的接口定义的原则和方法,但那只是搭建了一个骨架,在这一小节中,我们就给这个骨架填充血肉,制造出能够运转的“秒表”。

我们不大可能一次就写出完美的代码,软件开发本来就是一个逐渐精进的过程,但是我们应该努力让代码达到这样的要求:

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

初始化应用框架

我们使用 Facebook 提供的 create-react-app 来创建我们的 React 应用。严 格说来,如果要开发一款真正大型的应用,用 create-react-app 并不合适,或者至少 create-react-app 的默认配置并不合适, 真正的应用需要对 webpack 等做更精细的配置,但是,create-react-app 足够简单易用,从学习 React 的角度来看,真的是非常合适了。

如果你的机器上还没有安装 create-react-app,那么就使用下面的命令来全局安装:

shell
npm install -g create-react-app

然后,找一个合适的目录,使用下面的命令来创建我们的应用框架,在这里,我们的应用名字叫 basic_stop_watch。

shell
create-react-app basic_stop_watch --use-npm

create-react-app 会优先使用 yarn 来安装依赖的 npm 包,但是不知道读者机器上有没有安装 yarn, 所以用 --use-npm 参数强制 create-react-app 使用传统的 npm 工具来安装依赖的包,这样能够保持一致。如果你更喜欢 yarn,不使用 --use-npm 这个参数就可以了。

创建应用框架需要花费一些时间,因为现在依赖的包实在太多太细碎了,在完成之后,会创建一个 basic_stop_watch 目录,进入这个目录,运行下面给的命令,就可以启动给一个基本的 React 应用。

shell
npm start

我们不会花费太多时间介绍 create-react-app 的其他功能和代码结构,因为我们有更重要的事情要做,那就是根据上一小节的组件设计来制造“秒表”相关组件。

构建 StopWatch

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

我们在写代码之前要做的第一个决定就是,要把这些组件放在哪里?是放在不同的文件中?还是放在一个文件中就好?

表面上看,把所有组件放在一个文件中也行得通,但是,将来维护代码的朋友可能会很抓狂,想要修改 ControlButtons 这个组件,但是从文件目录里找不到对应文件,这样很不方便。

所以,从达到“代码整洁”的目的来说,应该每个组件都有一个独立的文件,然后这个文件用 export default 的方式导出单个组件。

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

jsx
import React from 'react';

const ControlButtons = () => {
	//TODO: 实现ControlButtons
};

export default ControlButtons;

第一行导入 React,虽然目前没有派上什么用场,但是任何 JSX 都需要 React,很快我们在实现 ControlButtons 这个控件的内容时,就要写 JSX,所以导入 React 是必需的。

最后一行用 export default 的方式导出 ControlButtons,这样,在其他组件中就可以用下面的方式导入:

jsx
import ControlButtons from './ControlButtons';

在上一节中我们已经设计好了 ControlButtons 可以接受的 props,我们重试 ControlButtons 的实现代码,如下:

jsx
const ControlButtons = (props) => {
	const { activated, onStart, onPause, onReset, onSplit } = props;
	if (activated) {
		return (
			<div>
				<button onClick={onSplit}>计次</button>
				<button onClick={onPause}>停止</button>
			</div>
		);
	} else {
		return (
			<div>
				<button onClick={onReset}>复位</button>
				<button onClick={onStart}>启动</button>
			</div>
		);
	}
};

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

如果没有解构赋值,就只能用下面的代码,很明显,与使用了解构赋值的代码相比,真是啰嗦得让人难以忍受。

jsx
const activated = props.activated;
const onStart = props.onStart;
const onPause = props.onPause;
const onReset = props.onReset;
const onSplit = props.onSplit;

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

jsx
const ControlButtons = ({ activated, onStart, onPause, onReset, onSplit }) => {

}

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

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

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

jsx
const MajorClock = ({ milliseconds = 0 }) => {
	return <h1>{ms2Time(milliseconds)}</h1>
};

在这里,我们不光直接解构了参数,而且使用了默认值。 如果使用 MajorClock 时没有传入 milliseconds 这个 props,那么 milliseconds 的值就是 0。

因为把毫秒数转为 HH:mm:ss:mmm 这样的格式和 JSX 没什么关系,所以,我们不在组件中直接编写,而是放在 ms2Time 函数中,ms2Time 就是 ms-to-Time,代码如下:

jsx
import padStart from 'lodash/padStart';

const ms2Time = (milliseconds) => {
	let time = milliseconds;
	const ms = milliseconds % 1000;
	time = (milliseconds - ms) / 1000;
	const seconds = time % 60;
	time = (time - seconds) / 60;
	const minutes = time % 60;
	const hours = (time - minutes) / 60;

	const result = padStart(hours, 2, '0') + ":" + padStart(minutes, 2, '0') + ":" + padStart(seconds, 2, '0') + "." + padStart(ms, 3, '0');
	return result;
}

通过逐步从 milliseconds 中抽取毫秒、秒、分、时的信息,最终拼出人类容易理解的时间。

不过,为了和数字时钟显示一致,需要补齐,比如 2 秒 23 毫秒,显示成 2:23 可不好看,不够的位数要补上 0,显示成 00:00:02:023 。

这个补齐的工作和 React 无关,我们也不深究,直接使用 lodash 中的 padStart 实现。

为了在项目中使用 lodash,请先用 npm 完成对应的库安装。

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

jsx
import MajorClock from './MajorClock';

const SplitTimes = ({ value = [] }) => {
	return value.map((v, k) => (
		<MajorClock key={k} milliseconds={v} />
	));
};

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

值得一提的是,利用循环或者数组 map 而产生的动态数量的 JSX 元件,必须要有 key 属性。

这个 key 属性帮助 React 搞清楚组件的顺序,如果不用 key,那 React 会在开发模式下在 console 上输出红色警告。

一般来说,key 不应该取数组的序号,因为 key 要唯一而且稳定,也即是每一次渲染过程中,key 都能唯一标识一个内容。

对于 StopWatch 这个例子,倒是可以直接使用数组序号,因为计次时间的数组顺序不会改变,使用数组序号足够唯一标识内容。

StopWatch 状态管理

在实现了 MajorClock、ControlButtons 和 SplitTimes 之后,我们需要把这些子组件串起来,这就是 StopWatch。

StopWatch 是一个有状态的组件,所以,不能只用一个函数实现,而是做成一个继承自 React.Component 的类,如下:

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

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

传入什么 props 呢?当然是 StopWatch 记录的 state。

StopWatch 的 state 需要有这些信息:

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

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

text
constructor(){
	super(...arguments);

	this.state = {
		isStarted: false,
		startTime: null,
		currentTime: null,
		splits: [],
	};
}

如果定义构造函数 constructor,一定要记得通过 super 调用父类 React.Component 的构造函数,不然,功能会不正常。

React官方网站上的代码示例是这样调用super函数:

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

在早期版本中,React.Component 的构造函数参数有两个,第一个是 props,第二个是 context,如果忽略掉 context 参数,那么这个组件的 context 功能就不能正常工作,

不过,现在React的行为已经变了,第二个参数传递不传递都能让context正常工作,看起来React.Component 的构造函数只有第一个参数被用到,

但是,没准未来还会增加新的参数呢,所以,以不变应万变的方法,就是使用扩展操作符(spread operator)来展开 arguments,这样不管 React 将来怎么变,这样的代码都正确。

text
  constructor() {
    super(...arguments); //永远正确!
  }

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

属性初始化方法

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

不用 constructor,可以这样初始化 state,效果是完全一样的:

jsx
class StopWatch extends React.Component {
	state = {
		isStarted: false,
		startTime: null,
		currentTime: null,
		splits: [],
	}
}

接下来,我们要考虑如何实现传递给 ControlButtons 的一系列函数。

首先,明确一点,尽量不要在 JSX 中写内联函数(inline function),比如这样写,是很不恰当的:

jsx
  <ControlButtons
	activated={this.state.isStarted}
	onStart={() => { /* TODO */
	}}
	onPause={() => { /* TODO */
	}}
	onReset={() => { /* TODO */
	}}
	onSplit={() => { /* TODO */
	}}
/>

当然,按照上面那种写法,也可以完成程序的功能,但是,会带来性能的代价。

首先,每一次渲染这段 JSX,都会产生全新的函数对象,这是一种浪费;

其次,因为每一次传给 ControlButtons 的都是新的 props,这样 ControlButtons 也无法通过 shouldComponentUpdate 对 props 的检查来避免重复渲染。

在本小册中后续章节中,为了代码简洁会使用内联函数,但只是为了示例方便,在实际工作中,在 JSX 中应用的函数 props 应该尽量使用类成员函数,不要用内联函数。

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

  onSplit(){
	this.setState({
		splits: [...this.state.splits, this.state.currentTime - this.state.startTime]
	});
}

在 onSplit 中,利用 this.setState 来修改组件的状态。不过问题来了,这个函数执行时,this 是什么呢?

很可惜,对于 ES6 的类成员函数,并不自动绑定 this,也就是说,onSplit 中的 this,可不保证就是当前组件对象。

至于 render 这些生命周期函数,里面访问的 this 就是当前组件本身,完全是因为这些函数是 React 调用的, React 对它们进行了特殊处理,对于其他普通的成员函数,特殊处理就要靠我们自己了。

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

  constructor() {
    super(...arguments);

    this.onSplit = this.onSplit.bind(this);
  }

如果可以使用 bind operator,也可以这样写:

jsx
this.onSplit = ::this.onSplit;

只可惜 bind operator 并不是稳定的标准语法,而 create-react-app 又不想依赖不稳定的语法,所以在我们的应用中还不能这么写。

我们的 StopWatch 需要给 ControlButtons 传递四个函数类型的 props,分别是 onStart、onPause、onReset 和 onSplit, 对每一个函数都在构造函数里加一个 bind,也是够累的,还容易出错,所以,我们肯定会寻求更好的方法。

更好的方法依然是使用属性初始化,就和初始化 state 一样,利用等号直接初始化 onSplit,代码如下:

jsx
  onSplit = () => {
	this.setState({
		splits: [...this.state.splits, this.state.currentTime - this.state.startTime]
	});
}

像上面这样写,就不需要 constructor,函数体内的 this 绝对就是当前组件对象。

用同样的方法,我们一起实现其他函数成员。

text
  onStart = () => {
    this.setState({
      isStarted: true,
      startTime: new Date(),
      currentTime: new Date(),
    });

    this.intervalHandle = setInterval(() => {
      this.setState({currentTime: new Date()});
    }, 1000 / 60);
  }

  onPause = () => {
    clearInterval(this.intervalHandle);
    this.setState({
      isStarted: false,
    });
  }

  onReset = () => {
    this.setState({
      startTime: null,
      currentTime: null,
      splits: [],
    });
  }

至此,一个“秒表”的功能就完成了,在 App.js 文件中导入 StopWatch,在浏览器中我们可以看到这样的界面。

点击“启动”按钮,可以看见数字时钟开始运转;点击“计次”按钮,在按钮下方可以看到点击瞬间的时间;点击“停止”,时钟停止运转。

当然,现在这个“秒表”的界面还非常粗糙,和 iPhone 上的秒表应用差远了。但是,它该有的功能一个都不缺,只有功能完整而且正确,样式才有意义。

在后面的章节中,我们会用 React 的方式来来美化界面。

小结

在这一小节中,我们完成 StopWatch 秒表组件的实现,在这个过程中,读者应该学习到这些技巧:

  • 尽量每个组件都有自己专属的源代码文件;
  • 用解构赋值(destructuring assignment)的方法获取参数 props 的每个属性值;
  • 利用属性初始化(property initializer)来定义 state 和成员函数。

Contributors

Long Mo
文章作者:Long Mo
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Longmo Docs