2.2 组件
2.2.1 组件定义
组件是React的核心概念,是React应用程序的基石。组件将应用的UI拆分成独立的、可复用的模块,React应用程序正是由一个一个组件搭建而成的。
定义一个组件有两种方式,使用ES 6 class(类组件)和使用函数(函数组件)。我们先介绍使用class定义组件的方式,使用函数定义组件的方式稍后介绍。
使用class定义组件需要满足两个条件:
(1)class继承自React.Component。
(2)class内部必须定义render方法,render方法返回代表该组件UI的React元素。
使用create-react-app新建一个简易BBS项目,在这个项目中定义一个组件PostList,用于展示BBS的帖子列表。
PostList的定义如下:
注意,在定义组件之后,使用ES 6 export将PostList作为默认模块导出,从而可以在其他JS文件中导入PostList使用。现在页面上还无法显示出PostList组件,因为我们还没有将PostList挂载到页面的DOM节点上。需要使用ReactDOM.render()完成这一个工作:
图2-1
注意,使用ReactDOM.render()需要先导入react-dom库,这个库会完成组件所代表的虚拟DOM节点到浏览器的DOM节点的转换。此时,页面展现在浏览器中,如图2-1所示。因为我们并没有为组件添加任何CSS样式,所以当前的页面效果还非常简陋,后续会逐步进行优化。本节项目源代码的目录为/chapter-02/bbs-components。
2.2.2 组件的props
在2.2.1小节中,PostList中的每一个帖子都使用一个标签直接包裹,但一个帖子不仅包含帖子的标题,还会包含帖子的创建人、帖子创建时间等信息,这时候标签下的结构就会变得复杂,而且每一个帖子都需要重写一次这个复杂的结构,PostList的结构将会变成类似这样的形式:
这样的结构显然很冗余,我们完全可以封装一个PostItem组件负责每一个帖子的展示,然后在PostList中直接使用PostItem组件,这样在PostList中就不需要为每一个帖子重复写一堆JSX标签。但是,帖子列表的数据依然存在于PostList中,如何将数据传递给每一个PostItem组件呢?这时候就需要用到组件的props属性。组件的props用于把父组件中的数据或方法传递给子组件,供子组件使用。在2.1节中,我们介绍了JSX标签的属性。props是一个简单结构的对象,它包含的属性正是由组件作为JSX标签使用时的属性组成。例如下面是一个使用User组件作为JSX标签的声明:
此时User组件的props结构如下:
现在我们利用props定义PostItem组件:
然后在PostList中使用PostItem:
此时,页面截图如图2-2所示。本节项目源代码的目录为/chapter-02/bbs-components-props。
图2-2
2.2.3 组件的state
组件的state是组件内部的状态,state的变化最终将反映到组件UI的变化上。我们在组件的构造方法constructor中通过this.state定义组件的初始状态,并通过调用this.setState方法改变组件状态(也是改变组件状态的唯一方式),进而组件UI也会随之重新渲染。
下面来改造一下BBS项目。我们为每一个帖子增加一个“点赞”按钮,每点击一次,该帖子的点赞数增加1。点赞数是会发生变化的,它的变化也会影响到组件UI,因此我们将点赞数vote作为PostItem的一个状态定义到它的state内。
这里有三个需要注意的地方:
(1)在组件的构造方法constructor内,首先要调用super(props),这一步实际上是调用了React.Component这个class的constructor方法,用来完成React组件的初始化工作。
(2)在constructor中,通过this.state定义了组件的状态。
(3)在render方法中,我们为标签定义了处理点击事件的响应函数,在响应函数内部会调用this.setState更新组件的点赞数。
新页面的截图如图2-3所示。本节项目源代码的目录为/chapter-02/bbs-components-state。
通过2.2.2和2.2.3两个小节的介绍可以发现,组件的props和state都会直接影响组件的UI。事实上,React组件可以看作一个函数,函数的输入是props和state,函数的输出是组件的UI。
图2-3
React组件正是由props和state两种类型的数据驱动渲染出组件UI。props是组件对外的接口,组件通过props接收外部传入的数据(包括方法);state是组件对内的接口,组件内部状态的变化通过state来反映。另外,props是只读的,你不能在组件内部修改props;state是可变的,组件状态的变化通过修改state来实现。在第4章中,我们还会对props和state进行详细比较。
2.2.4 有状态组件和无状态组件
是不是每个组件内部都需要定义state呢?当然不是。state用来反映组件内部状态的变化,如果一个组件的内部状态是不变的,当然就用不到state,这样的组件称之为无状态组件,例如PostList。反之,一个组件的内部状态会发生变化,就需要使用state来保存变化,这样的组件称之为有状态组件,例如PostItem。
定义无状态组件除了使用ES 6 class的方式外,还可以使用函数定义,也就是我们在本节开始时所说的函数组件。一个函数组件接收props作为参数,返回代表这个组件UI的React元素结构。例如,下面是一个简单的函数组件:
可以看出,函数组件的写法比类组件的写法要简洁很多,在使用无状态组件时,应该尽量将其定义成函数组件。
在开发React应用时,一定要先认真思考哪些组件应该设计成有状态组件,哪些组件应该设计成无状态组件。并且,应该尽可能多地使用无状态组件,无状态组件不用关心状态的变化,只聚焦于UI的展示,因而更容易被复用。React应用组件设计的一般思路是,通过定义少数的有状态组件管理整个应用的状态变化,并且将状态通过props传递给其余的无状态组件,由无状态组件完成页面绝大部分UI的渲染工作。总之,有状态组件主要关注处理状态变化的业务逻辑,无状态组件主要关注组件UI的渲染。
下面让我们回过头来看一下BBS项目的组件设计。当前的组件设计并不合适,主要体现在:
(1)帖子列表通过一个常量data保存在组件之外,但帖子列表的数据是会改变的,新帖子的增加或原有帖子的删除都会导致帖子列表数据的变化。
(2)每一个PostItem都维持一个vote状态,但除了vote以外,帖子其他的信息(如标题、创建人等)都保存在PostList中,这显然也是不合理的。
我们对这两个组件进行重新设计,将PostList设计为有状态组件,负责帖子列表数据的获取以及点赞行为的处理,将PostItem设计为无状态组件,只负责每一个帖子的展示。此时,PostList和PostItem重构如下:
这里主要的修改有:
(1)帖子列表数据定义为PostList组件的一个状态。
(2)在componentDidMount生命周期方法中(关于组件的生命周期将在2.3节详细介绍)通过setTimeout设置一个延时,模拟从服务器端获取数据,然后调用setState更新组件状态。
(3)将帖子的多个属性(ID、标题、创建人、创建时间、点赞数)合并成一个post对象,通过props传递给PostItem。
(4)在PostList内定义handleVote方法,处理点赞逻辑,并将该方法通过props传递给PostItem。
(5)PostItem定义为一个函数组件,根据PostList传递的post属性渲染UI。当发生点赞行为时,调用props.onVote方法将点赞逻辑交给PostList中的handleVote方法处理。
这样修改后,PostItem只关注如何展示帖子,至于帖子的数据从何而来以及点赞逻辑如何处理,统统交给有状态组件PostList处理。组件之间解耦更加彻底,PostItem组件更容易被复用。本节项目源代码的目录为/chapter-02/bbs-components-stateless。
2.2.5 属性校验和默认属性
我们已经知道,props是一个组件对外暴露的接口,但到目前为止,组件内部并没有明显地声明它暴露出哪些接口,以及这些接口的类型是什么,这不利于组件的复用。幸运的是,React提供了PropTypes这个对象,用于校验组件属性的类型。PropTypes包含组件属性所有可能的类型,我们通过定义一个对象(对象的key是组件的属性名,value是对应属性的类型)实现组件属性类型的校验。例如:
PropTypes可以校验的组件属性类型见表2-1。
表2-1 组件属性类型和PropTypes属性的对应关系
当使用PropTypes.object或PropTypes.array校验属性类型时,我们只知道这个属性是一个对象或一个数组,至于对象的结构或数组元素的类型是什么样的,依然无从得知。这种情况下,更好的做法是使用PropTypes.shape或PropTypes.arrayOf。例如:
表示style是一个对象,对象有color和fontSize两个属性,color是字符串类型,fontSize是数字类型;sequence是一个数组,数组的元素是数字。
如果属性是组件的必需属性,也就是当使用某个组件时,必须传入的属性,就需要在PropTypes的类型属性上调用isRequired。在BBS项目中,对于PostItem组件,post和onVote都是必需属性,PostItem的propTypes定义如下:
本节项目源代码的目录为/chapter-02/bbs-components-propTypes。
React还提供了为组件属性指定默认值的特性,这个特性通过组件的defaultProps实现。当组件属性未被赋值时,组件会使用defaultProps定义的默认属性。例如:
2.2.6 组件样式
到目前为止,我们还未对组件添加任何样式。本节将介绍如何为组件添加样式。
为组件添加样式的方法主要有两种:外部CSS样式表和内联样式。
1.外部CSS样式表
这种方式和我们平时开发Web应用时使用外部CSS文件相同,CSS样式表中根据HTML标签类型、ID、class等选择器定义元素的样式。唯一的区别是,React元素要使用className来代替class作为选择器。例如,为Welcome组件的根节点设置一个className='foo'的属性:
然后在CSS样式表中通过class选择器定义Welcome组件的样式:
样式表的引入方式有两种,一种是在使用组件的HTML页面中通过标签引入:
另一种是把样式表文件当作一个模块,在使用该样式表的组件中,像导入其他组件一样导入样式表文件:
第一种引入样式表的方式常用于该样式表文件作用于整个应用的所有组件(一般是基础样式表);第二种引入样式表的方式常用于该样式表作用于某个组件(相当于组件的私有样式),全局的基础样式表也可以使用第二种方式引入,一般在应用的入口JS文件中引入。
补充说明:使用CSS样式表经常遇到的一个问题是class名称冲突。业内解决这个问题的一个常用方案是使用CSS Modules,CSS Modules会对样式文件中的class名称进行重命名从而保证其唯一性,但CSS Modules并不是必需的,create-react-app创建的项目,默认配置也是不支持这一特性的。CSS Modules的使用并不复杂,感兴趣的读者可自行了解(参考地址:https://github.com/css-modules/css-modules)。
2.内联样式
内联样式实际上是一种CSS in JS的写法:将CSS样式写到JS文件中,用JS对象表示CSS样式,然后通过DOM类型节点的style属性引用相应样式对象。依然使用Welcome组件举例:
style使用了两个大括号,这可能会让你感到迷惑。其实,第一个大括号表示style的值是一个JavaScript表达式,第二个大括号表示这个JavaScript表达式是一个对象。换一种写法就容易理解了:
当使用内联样式时,还有一点需要格外注意:样式的属性名必须使用驼峰格式的命名。所以,在Welcome组件中,background-color写成backgroundColor,font-size写成fontSize。
下面为BBS项目增加一些样式。创建style.css、PostList.css和PostItem.css三个样式文件,三个样式表分别在index.html、PostList.js、PostItem.js中引入。样式文件如下:
这里需要提醒一下,style.css放置在public文件夹下,PostList.css和PostItem.css放置在src文件夹下。create-react-app将public下的文件配置成可以在HTML页面中直接引用,因此我们将style.css放置在public文件夹下。而PostList.css和PostItem.css是以模块的方式在JS文件中被导入的,因此放置在src文件夹下。
我们还将PostItem中的点赞按钮换成了图标,图标也可以作为一个模块被JS文件导入,如PostItem.js所示:
增加样式后的页面截图如图2-4所示。本节项目源代码的目录为/chapter-02/bbs-components-style。
图2-4
2.2.7 组件和元素
在2.1节介绍过React元素的概念。React组件和元素这两个概念非常容易混淆。React元素是一个普通的JavaScript对象,这个对象通过DOM节点或React组件描述界面是什么样子的。JSX语法就是用来创建React元素的(不要忘了,JSX语法实际上是调用了React.createElement方法)。例如:
上面的JSX代码会创建下面的React元素:
React组件是一个class或函数,它接收一些属性作为输入,返回一个React元素。React组件是由若干React元素组建而成的。通过下面的例子,可以解释React组件与React元素间的关系。