组件

组件定义

组件是 React 的核心概念,是 React 应用程序的基石。组件将应用的 UI 拆分成独立的、可复用的模块,React 应用程序正是由一个一个组件搭建而成的。

定义一个组件有两种方式,使用 ES6 class(类组件)和使用函数(函数组件)。我们先介绍使用 class 定义组件的方式,使用函数定义组件的方式稍后介绍。

使用 class 定义组件需要满足两个条件:

  1. class 继承自 React.Component。

  2. class 内部必须定义 render 方法,render 方法返回代表该组件 UI 的 React 元素。

使用 create-react-app 新建一个简易 BBS 项目,在这个项目中定义一个组件 PostList,用于展示 BBS 的帖子列表。

PostList 的定义如下:

PostList.js
import React, { Component } from "react";

class PostList extends Component {
  render() {
    return (
      <div>
        帖子列表:
        <ul>
          <li>大家一起来讨论React吧</li>
          <li>前端框架,你最爱哪一个</li>
          <li>Web App的时代已经到来</li>
        </ul>
      </div>
    );
  }
}

export default PostList;

注意,在定义组件之后,使用 ES6 export 将 PostList 作为默认模块导出,从而可以在其他 JS 文件中导入 PostList 使用。现在页面上还无法显示出 PostList 组件,因为我们还没有将 PostList 挂载到页面的 DOM 节点上。需要使用 ReactDOM.render() 完成这一个工作:

index.js
import React from "react";
import ReactDOM from "react-dom";
import PostList from "./PostList";

ReactDOM.render(<PostList />, document.getElementById("root"));
image 2024 04 22 20 54 12 273
Figure 1. 图2-1

注意,使用 ReactDOM.render() 需要先导入 react-dom 库,这个库会完成组件所代表的虚拟 DOM 节点到浏览器的 DOM 节点的转换。此时,页面展现在浏览器中,如图 2-1 所示。因为我们并没有为组件添加任何 CSS 样式,所以当前的页面效果还非常简陋,后续会逐步进行优化。本节项目源代码的目录为 /chapter-02/bbs-components。

组件的props

在 2.2.1 小节中,PostList 中的每一个帖子都使用一个标签直接包裹,但一个帖子不仅包含帖子的标题,还会包含帖子的创建人、帖子创建时间等信息,这时候标签下的结构就会变得复杂,而且每一个帖子都需要重写一次这个复杂的结构,PostList 的结构将会变成类似这样的形式:

class PostList extends Component {
  render() {
    return (
      <div>
        帖子列表:
        <ul>
          <li>
                <div>大家一起来讨论 React 吧</div>
                <div>创建人:<span>张三</span></div>
                <div>创建时间:<span>2017-09-01 10:00</span></div>
          </li>
          <li>
                <div>前端框架,你最爱哪一个</div>
                <div>创建人:<span>李四</span></div>
                <div>创建时间:<span>2017-09-01 12:00</span></div>
          </li>
          <li>
                <div>Web App的时代已经到来</div>
                <div>创建人:<span>王五</span></div>
                <div>创建时间:<span>2017-09-01 14:00</span></div>
          </li>
        </ul>
      </div>
    );
  }
}

这样的结构显然很冗余,我们完全可以封装一个 PostItem 组件负责每一个帖子的展示,然后在 PostList 中直接使用 PostItem 组件,这样在 PostList 中就不需要为每一个帖子重复写一堆 JSX 标签。但是,帖子列表的数据依然存在于 PostList 中,如何将数据传递给每一个 PostItem 组件呢?这时候就需要用到组件的 props 属性。组件的 props 用于把父组件中的数据或方法传递给子组件,供子组件使用。在 2.1 节中,我们介绍了 JSX 标签的属性。props 是一个简单结构的对象,它包含的属性正是由组件作为 JSX 标签使用时的属性组成。例如下面是一个使用 User 组件作为 JSX 标签的声明:

<User name='React' age='4' address='America' >

此时 User 组件的 props 结构如下:

props = {
    name: 'React',
    age: '4',
    address: 'America'
}

现在我们利用 props 定义 PostItem 组件:

PostItem.js
import React, { Component } from "react";

class PostItem extends Component {
  render() {
    const { title, author, date } = this.props;
    return (
      <li>
        <div>
          {title}
        </div>
        <div>
          创建人:<span>{author}</span>
        </div>
        <div>
          创建时间:<span>{date}</span>
        </div>
      </li>
    );
  }
}

export default PostItem;

然后在 PostList 中使用 PostItem:

PostList.js
import React, { Component } from "react";
import PostItem from "./PostItem";

const data = [
  { title: "大家一起来讨论React吧", author: "张三", date: "2017-09-01 10:00" },
  { title: "前端框架,你最爱哪一个", author: "李四", date: "2017-09-01 12:00" },
  { title: "Web App的时代已经到来", author: "王五", date: "2017-09-01 14:00" }
];
class PostList extends Component {
  render() {
    return (
      <div>
        帖子列表:
        <ul>
          {data.map(item =>
            <PostItem
              title={item.title}
              author={item.author}
              date={item.date}
            />
          )}
        </ul>
      </div>
    );
  }
}

export default PostList;

此时,页面截图如图2-2所示。本节项目源代码的目录为 /chapter-02/bbs-components-props。

image 2024 04 22 21 07 32 932
Figure 2. 图2-2

组件的state

组件的 state 是组件内部的状态,state 的变化最终将反映到组件 UI 的变化上。我们在组件的构造方法 constructor 中通过 this.state 定义组件的初始状态,并通过调用 this.setState 方法改变组件状态(也是改变组件状态的唯一方式),进而组件 UI 也会随之重新渲染。

下面来改造一下 BBS 项目。我们为每一个帖子增加一个 “点赞” 按钮,每点击一次,该帖子的点赞数增加 1。点赞数是会发生变化的,它的变化也会影响到组件 UI,因此我们将点赞数 vote 作为 PostItem 的一个状态定义到它的 state 内。

import React, { Component } from "react";

class PostItem extends Component {
  constructor(props) {
    super(props);
    this.state = {
      vote: 0
    };
  }

  // 处理点赞逻辑
  handleClick() {
    let vote = this.state.vote;
    vote++;
    this.setState({
      vote: vote
    });
  }

  render() {
    const { title, author, date } = this.props;
    return (
      <li>
        <div>
          {title}
        </div>
        <div>
          创建人:<span>{author}</span>
        </div>
        <div>
          创建时间:<span>{date}</span>
        </div>
        <div>
          <button
            onClick={() => {
              this.handleClick();
            }}
          >
            点赞
          </button>
          &nbsp;
          <span>
            {this.state.vote}
          </span>
        </div>
      </li>
    );
  }
}

export default PostItem;

这里有三个需要注意的地方:

  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。

UI = Component(props, state)
image 2024 04 22 22 42 08 264
Figure 3. 图2-3

React 组件正是由 props 和 state 两种类型的数据驱动渲染出组件 UI。props 是组件对外的接口,组件通过 props 接收外部传入的数据(包括方法);state 是组件对内的接口,组件内部状态的变化通过 state 来反映。另外,props 是只读的,你不能在组件内部修改 props;state 是可变的,组件状态的变化通过修改 state 来实现。在第 4 章中,我们还会对 props 和 state 进行详细比较。

有状态组件和无状态组件

是不是每个组件内部都需要定义 state 呢?当然不是。state 用来反映组件内部状态的变化,如果一个组件的内部状态是不变的,当然就用不到 state,这样的组件称之为 无状态组件,例如 PostList。反之,一个组件的内部状态会发生变化,就需要使用 state 来保存变化,这样的组件称之为有状态组件,例如 PostItem。

定义无状态组件除了使用 ES6 class 的方式外,还可以使用函数定义,也就是我们在本节开始时所说的函数组件。一个函数组件接收 props 作为参数,返回代表这个组件 UI 的 React 元素结构。例如,下面是一个简单的函数组件:

function Welcome(props) {
    return <h1>Hello, {props.name}</h1>;
}

可以看出,函数组件的写法比类组件的写法要简洁很多,在使用无状态组件时,应该尽量将其定义成函数组件。

在开发 React 应用时,一定要先认真思考哪些组件应该设计成有状态组件,哪些组件应该设计成无状态组件。并且,应该尽可能多地使用无状态组件,无状态组件不用关心状态的变化,只聚焦于 UI 的展示,因而更容易被复用。React 应用组件设计的一般思路是,通过定义少数的有状态组件管理整个应用的状态变化,并且将状态通过 props 传递给其余的无状态组件,由无状态组件完成页面绝大部分 UI 的渲染工作。总之,有状态组件主要关注处理状态变化的业务逻辑,无状态组件主要关注组件 UI 的渲染。

下面让我们回过头来看一下 BBS 项目的组件设计。当前的组件设计并不合适,主要体现在:

  1. 帖子列表通过一个常量 data 保存在组件之外,但帖子列表的数据是会改变的,新帖子的增加或原有帖子的删除都会导致帖子列表数据的变化。

  2. 每一个 PostItem 都维持一个 vote 状态,但除了 vote 以外,帖子其他的信息(如标题、创建人等)都保存在 PostList 中,这显然也是不合理的。

我们对这两个组件进行重新设计,将 PostList 设计为有状态组件,负责帖子列表数据的获取以及点赞行为的处理,将 PostItem 设计为无状态组件,只负责每一个帖子的展示。此时,PostList 和 PostItem 重构如下:

PostList.js
import React, { Component } from "react";
import PostItem from "./PostItem";

class PostList extends Component {
  constructor(props) {
    super(props);
    this.state = {
      posts: []
    };
    this.timer = null; // 定时器
    this.handleVote = this.handleVote.bind(this); // ES 6 class中,必须手动绑定方法this的指向
  }

  componentDidMount() {
    // 用setTimeout模拟异步从服务器端获取数据
    this.timer = setTimeout(() => {
      this.setState({
        posts: [
          { id: 1, title: "大家一起来讨论React吧", author: "张三", date: "2017-09-01 10:00", vote: 0 },
          { id: 2, title: "前端框架,你最爱哪一个", author: "李四", date: "2017-09-01 12:00", vote: 0 },
          { id: 3, title: "Web App的时代已经到来", author: "王五", date: "2017-09-01 14:00", vote: 0 }
        ]
      });
    }, 1000);
  }

  componentWillUnmount() {
    if(this.timer) {
      clearTimeout(this.timer); // 清除定时器
    }
  }

  handleVote(id) {
    //根据帖子id进行过滤,找到待修改vote属性的帖子,返回新的posts对象
    const posts = this.state.posts.map(item => {
      const newItem = item.id === id ? {...item, vote: ++item.vote} : item;
      return newItem;
    })
    // 使用新的posts对象设置state
    this.setState({
      posts
    })
  }

  render() {
    return (
      <div>
        帖子列表:
        <ul>
          {this.state.posts.map(item =>
            <PostItem
              post = {item}
              onVote = {this.handleVote}
            />
          )}
        </ul>
      </div>
    );
  }
}

export default PostList;
PostItem.js
import React from "react";

function PostItem(props) {
  const handleClick = () => {
    props.onVote(props.post.id);
  };
  const { post } = props;
  return (
    <li>
      <div>
        {post.title}
      </div>
      <div>
        创建人:<span>{post.author}</span>
      </div>
      <div>
        创建时间:<span>{post.date}</span>
      </div>
      <div>
        <button onClick={handleClick}>点赞</button>
        &nbsp;
        <span>{post.vote}</span>
      </div>
    </li>
  );
}

export default 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。

属性校验和默认属性

我们已经知道,props 是一个组件对外暴露的接口,但到目前为止,组件内部并没有明显地声明它暴露出哪些接口,以及这些接口的类型是什么,这不利于组件的复用。幸运的是,React 提供了 PropTypes 这个对象,用于校验组件属性的类型。PropTypes 包含组件属性所有可能的类型,我们通过定义一个对象(对象的 key 是组件的属性名,value 是对应属性的类型)实现组件属性类型的校验。例如:

import PropTypes from 'prop-types';

function PostItem(props) {
    //.....
}

PostItem.propTypes = {
    post: PropTypes.object,
    onVote: PropTypes.func
};

PropTypes 可以校验的组件属性类型见表2-1。

Table 1. 表2-1 组件属性类型和PropTypes属性的对应关系
类型 PropTypes对应属性

String

PropTypes.string

Number

PropTypes.number

Boolean

PropTypes.bool

Function

PropTypes.func

Object

PropTypes.object

Array

PropTypes.array

Symbol

PropTypes.symbol

Element(React元素)

PropTypes.element

Node(可被渲染的节点:数字、字符串、React元素或由这些类型的数据组成的数组)

PropTypes.node

当使用 PropTypes.object 或 PropTypes.array 校验属性类型时,我们只知道这个属性是一个对象或一个数组,至于对象的结构或数组元素的类型是什么样的,依然无从得知。这种情况下,更好的做法是使用 PropTypes.shape 或 PropTypes.arrayOf。例如:

style: PropTypes.shape({
    color: PropTypes.string,
    fontSize: PropTypes.number
}),
sequence: PropTypes.arrayOf(PropTypes.number)

表示 style 是一个对象,对象有 color 和 fontSize 两个属性,color 是字符串类型,fontSize 是数字类型;sequence 是一个数组,数组的元素是数字。

如果属性是组件的必需属性,也就是当使用某个组件时,必须传入的属性,就需要在 PropTypes 的类型属性上调用 isRequired。在 BBS 项目中,对于 PostItem 组件,post 和 onVote 都是必需属性,PostItem 的 propTypes 定义如下:

PostItem.propTypes = {
  post: PropTypes.shape({
    id: PropTypes.number,
    title: PropTypes.string,
    author: PropTypes.string,
    date: PropTypes.string,
    vote: PropTypes.number
  }).isRequired,
  onVote: PropTypes.func.isRequired
}

本节项目源代码的目录为 /chapter-02/bbs-components-propTypes。

React 还提供了为组件属性指定默认值的特性,这个特性通过组件的 defaultProps 实现。当组件属性未被赋值时,组件会使用 defaultProps 定义的默认属性。例如:

function Welcome(props) {
    return <h1 className='foo'>Hello, {props.name}</h1>
}

Welcome.defaultProps = {
    name: 'Stranger'
};

组件样式

到目前为止,我们还未对组件添加任何样式。本节将介绍如何为组件添加样式。为组件添加样式的方法主要有两种:外部 CSS 样式表和内联样式。

外部CSS样式表

这种方式和我们平时开发 Web 应用时使用外部 CSS 文件相同,CSS 样式表中根据 HTML 标签类型、ID、class 等选择器定义元素的样式。唯一的区别是,React 元素要使用 className 来代替 class 作为选择器。例如,为 Welcome 组件的根节点设置一个 className='foo' 的属性:

function Welcome(props) {
    return <h1 className="foo">Hello, {props.name}</h1>
}

然后在 CSS 样式表中通过 class 选择器定义 Welcome 组件的样式:

// style.css
.foo {
    width: 100%;
    height: 50px;
    background-color: blue;
    font-size: 20px;
}

样式表的引入方式有两种,一种是在使用组件的 HTML 页面中通过标签引入:

<link rel="stylesheet" type="text/css" href="style.css">

另一种是把样式表文件当作一个模块,在使用该样式表的组件中,像导入其他组件一样导入样式表文件:

import './style.css'; // 要保证相对路径设置正确

function Welcome(props) {
    return <h1 className="foo">Hello, {props.name}</h1>
}

第一种引入样式表的方式常用于该样式表文件作用于整个应用的所有组件(一般是基础样式表);第二种引入样式表的方式常用于该样式表作用于某个组件(相当于组件的私有样式),全局的基础样式表也可以使用第二种方式引入,一般在应用的入口 JS 文件中引入。

使用 CSS 样式表经常遇到的一个问题是 class 名称冲突。业内解决这个问题的一个常用方案是使用 CSS Modules,CSS Modules 会对样式文件中的 class 名称进行重命名从而保证其唯一性,但 CSS Modules 并不是必需的,create-react-app 创建的项目,默认配置也是不支持这一特性的。CSS Modules 的使用并不复杂,感兴趣的读者可自行了解(参考地址: https://github.com/css-modules/css-modules )。

内联样式

内联样式实际上是一种 CSS in JS 的写法:将 CSS 样式写到 JS 文件中,用 JS 对象表示 CSS 样式,然后通过 DOM 类型节点的 style 属性引用相应样式对象。依然使用 Welcome 组件举例:

function Welcome(props) {
    return (
        <h1
            style={{
                width: "100%",
                height: "50px",
                backgroundColor: "blue",
                fontSize: "20px"
            }}
        >
            Hello, {props.name}
        </h1>
    );
}

style 使用了两个大括号,这可能会让你感到迷惑。其实,第一个大括号表示 style 的值是一个 JavaScript 表达式,第二个大括号表示这个 JavaScript 表达式是一个对象。换一种写法就容易理解了:

function Welcome(props) {
    const style = {
        width: "100%",
        height: "50px",
        backgroundColor: "blue",
        fontSize: "20px"
    };
    return <h1 style={style}>Hello, {props.name}</h1>
}

当使用内联样式时,还有一点需要格外注意:样式的属性名必须使用驼峰格式的命名。所以,在 Welcome 组件中,background-color 写成 backgroundColor,font-size 写成 fontSize。

下面为 BBS 项目增加一些样式。创建 style.css、PostList.css 和 PostItem.css 三个样式文件,三个样式表分别在 index.html、PostList.js、PostItem.js 中引入。样式文件如下:

style.css
body {
  margin: 0;
  padding: 0;
  font-family: sans-serif;
}

ul {
  list-style: none;
}

h2 {
  text-align: center;
}
PostList.css
.container {
  width: 900px;
  margin: 20px auto;
}
PostItem.css
.item {
  border-top: 1px solid grey;
  padding: 15px;
  font-size: 14px;
  color: grey;
  line-height: 21px;
}

.title {
  font-size: 16px;
  font-weight: bold;
  line-height: 24px;
  color: #009a61;
}

.like {
  width: 100%;
  height: 20px;
}

.like img{
  width: 20px;
  height: 20px;
}

.like span{
  width: 20px;
  height: 20px;
  vertical-align: middle;
  display: table-cell;
}

这里需要提醒一下,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 所示:

import React from "react";
import "./PostItem.css";
import like from "./images/like-default.png"; // 图标作为模块被导入

function PostItem(props) {
  const handleClick = () => {
    props.onVote(props.post.id);
  };
  const { post } = props;
  return (
    <li className='item'>
      <div className='title'>
        {post.title}
      </div>
      <div>
        创建人:<span>{post.author}</span>
      </div>
      <div>
        创建时间:<span>{post.date}</span>
      </div>
      <div className='like'>
         <span><img alt='vote' src={like} onClick={handleClick} /></span>
         <span>{post.vote}</span>
      </div>
    </li>
  );
}

export default PostItem;

增加样式后的页面截图如图 2-4 所示。本节项目源代码的目录为 /chapter-02/bbs-components-style 。

image 2024 04 23 00 07 15 010
Figure 4. 图2-4

组件和元素

在2.1节介绍过 React 元素的概念。React 组件和元素这两个概念非常容易混淆。React 元素是一个普通的 JavaScript 对象,这个对象通过 DOM 节点或 React 组件描述界面是什么样子的。JSX 语法就是用来创建 React 元素的(不要忘了,JSX 语法实际上是调用了 React.createElement 方法)。例如:

// Button是一个自定义的React组件
<div className='foo'>
    <Button color='blue'>OK</Button>
</div>

上面的 JSX 代码会创建下面的 React 元素:

image 2024 04 23 00 13 41 936

React 组件是一个 class 或函数,它接收一些属性作为输入,返回一个 React 元素。React 组件是由若干 React 元素组建而成的。通过下面的例子,可以解释 React 组件与 React 元素间的关系。

image 2024 04 23 00 14 03 183