表单

在有交互的 Web 应用中,表单是必不可少的。但是,和其他元素相比,表单元素在 React 中的工作方式存在一些不同。像 div、p、span 等非表单元素只需根据组件的属性或状态进行渲染即可,但表单元素自身维护一些状态,而这些状态默认情况下是不受 React 控制的。例如,input 元素会根据用户的输入自动改变显示的内容,而不是从组件的状态中获取显示的内容。我们称这类状态不受 React 控制的表单元素为非受控组件。在 React 中,状态的修改必须通过组件的 state,非受控组件的行为显然有悖于这一原则。为了让表单元素状态的变更也能通过组件的 state 管理,React 采用受控组件的技术达到这一目的。

受控组件

如果一个表单元素的值是由 React 来管理的,那么它就是一个受控组件。React 组件渲染表单元素,并在用户和表单元素发生交互时控制表单元素的行为,从而保证组件的 state 成为界面上所有元素状态的唯一来源。对于不同的表单元素,React 的控制方式略有不同,下面我们就来看一下三类常用表单元素的控制方式。

文本框

文本框包含类型为 text 的 input 元素和 textarea 元素。它们受控的主要原理是,通过表单元素的 value 属性设置表单元素的值,通过表单元素的 onChange 事件监听值的变化,并将变化同步到 React 组件的 state 中。下面是一个例子。

image 2024 04 23 10 40 35 186

用户名和密码两个表单元素的值是从组件的 state 中获取的,当用户更改表单元素的值时,onChange 事件会被触发,对应的 handleChange 处理函数会把变化同步到组件的 state,新的 state 又会触发表单元素重新渲染,从而实现对表单元素状态的控制。

这个例子还包含一个处理多个表单元素的技巧:通过为两个 input 元素分别指定 name 属性,使用同一个函数 handleChange 处理元素值的变化,在处理函数中根据元素的 name 属性区分事件的来源。这样的写法显然比为每一个 input 元素指定一个处理函数简洁得多。

textarea 的使用方式和 input 几乎一致,这里不再赘述。

列表

列表 select 元素是最复杂的表单元素,它可以用来创建一个下拉列表:

<select>
    <option value="react">React</option>
    <option value="redux">Redux</option>
    <option selected value="mobx">MobX</option>
</select>

通过指定 selected 属性可以定义哪一个选项(option)处于选中状态,所以上面的例子中,Mobx 这一选项是列表的初始值,处于选中状态。在 React 中,对 select 的处理方式有所不同,它通过在 select 上定义 value 属性来决定哪一个 option 元素处于选中状态。这样,对 select 的控制只需要在 select 这一个元素上修改即可,而不需要关注 option 元素。下面是一个例子:

class ReactStackForm extends React.Component {
    constructor(props) {
        super(props);
        this.state = {value: 'mobx'};
        this.handleChange = this.handleChange.bind(this);
        this.handleSubmit = this.handleChange.bind(this);
    }

    // 监听下拉列表的变化
    handleChange(event) {
        this.setState({value: event.target.value});
    }

    // 表单提交的响应函数
    handleSubmit(event) {
        alert('You picked ' + this.state.value);
        event.preventDefault();
    }

    render() {
        return (
          <form onSubmit={this.handleSubmit}>
            <label>
                Pick one library:
                {/* select 的 value 属性定义当前哪个 option 元素处于被选中状态 */}
                <select>
                    <option value="react">React</option>
                    <option value="redux">Redux</option>
                    <option value="mobx">MobX</option>
                </select>
            </label>
            <input type="submit" value="Submit" />
          </form>
        );
    }
}

复选框和单选框

复选框是类型为 checkbox 的 input 元素,单选框是类型为 radio 的 input 元素,它们的受控方式不同于类型为 text 的 input 元素。通常,复选框和单选框的值是不变的,需要改变的是它们的 checked 状态,因此 React 控制的属性不再是 value 属性,而是 checked 属性。例如:

class ReactStackForm extends React.Component {
    constructor(props) {
        super(props);
        this.state = { react: false, redux: false, mobx: false };
        this.handleChange = this.handleChange.bind(this);
        this.handleSubmit = this.handleChange.bind(this);
    }

    // 监听复选框变化,设置复选框的 checked 状态
    handleChange(event) {
        this.setState({ [event.target.name]: event.target.checked });
    }

    // 表单提交的响应函数
    handleSubmit(event) {
        event.preventDefault();
    }

    render() {
        return (
            <form onSubmit={this.handleSubmit}>
                {/* 设置3个复选框 */}
                <label>React
                    <input
                        type="checkbox"
                        name="react"
                        value="react"
                        checked={this.state.react}
                        onChange={this.handleChange}
                    />
                </label>
                <label>Redux
                    <input
                        type="checkbox"
                        name="redux"
                        value="redux"
                        checked={this.state.redux}
                        onChange={this.handleChange}
                    />
                </label>
                <label>MobX
                    <input
                        type="checkbox"
                        name="mobx"
                        value="mobx"
                        checked={this.state.mobx}
                        onChange={this.handleChange}
                    />
                </label>
                <input type="submit" value="Submit" />
            </form>
        );
    }
}

上面的例子中,input 的 value 是不变的,onChange 事件改变的是 input 的 checked 属性。单选框的用法和复选框相似,读者可自行尝试使用。

下面为 BBS 项目添加表单元素,让每一个帖子的标题支持编辑功能。本节项目源代码的目录为 /chapter-02/bbs-components-form。修改后的 PostItem 如下:

import React, { Component } from "react";
import "./PostItem.css";
import like from "./images/like-default.png";

class PostItem extends Component {
  constructor(props) {
    super(props);
    this.state = {
      editing: false,    // 帖子是否处于编辑态
      post: props.post
    };
    this.handleVote = this.handleVote.bind(this);
    this.handleEditPost = this.handleEditPost.bind(this);
    this.handleTitleChange = this.handleTitleChange.bind(this);
  }

  componentWillReceiveProps(nextProps) {
    // 父组件更新post后,更新PostItem的state
    if (this.props.post !== nextProps.post) {
      this.setState({
        post: nextProps.post
      });
    }
  }

  // 处理点赞事件
  handleVote() {
    this.props.onVote(this.props.post.id);
  }

  // 保存/编辑按钮点击后的逻辑
  handleEditPost() {
    const editing = this.state.editing;
    // 当前处于编辑态,调用父组件传递的onSave方法保存帖子
    if (editing) {
      this.props.onSave({
        ...this.state.post,
        date: this.getFormatDate()
      });
    }
    this.setState({
      editing: !editing
    });
  }

  // 处理标题textarea值的变化
  handleTitleChange(event) {
    const newPost = { ...this.state.post, title: event.target.value };
    this.setState({
      post: newPost
    });
  }

  // 显示日期格式化
  getFormatDate() {
    const date = new Date();
    const year = date.getFullYear();
    let month = date.getMonth() + 1 + "";
    month = month.length === 1 ? "0" + month : month;
    let day = date.getDate() + "";
    day = day.length === 1 ? "0" + day : day;
    let hour = date.getHours() + "";
    hour = hour.length === 1 ? "0" + hour : hour;
    let minute = date.getMinutes() + "";
    minute = minute.length === 1 ? "0" + minute : minute;
    return `${year}-${month}-${day} ${hour}:${minute}`;
  }

  render() {
    const { post } = this.state;
    return (
      <li className="item">
        <div className="title">
          {this.state.editing
            ? <form>
                <textarea
                  value={post.title}
                  onChange={this.handleTitleChange}
                />
              </form>
            : 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={this.handleVote} />
          </span>
          <span>
            {post.vote}
          </span>
        </div>
        <div>
          <button onClick={this.handleEditPost}>
            {this.state.editing ? "保存" : "编辑"}
          </button>
        </div>
      </li>
    );
  }
}

export default PostItem;

当点击编辑状态的 button 时,帖子的标题会使用 textarea 展示,此时标题处于可编辑状态,当再次点击 button 时,会执行保存操作,PostItem 通过 onSave 属性调用父组件 PostList 的 handleSave 方法,将更新后的 Post(标题和时间)保存到 PostList 的 state 中。PostList 中的修改如下:

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

class PostList extends Component {
  constructor(props) {
    super(props);
    this.state = {
      posts: []
    };
    this.timer = null;
    this.handleVote = this.handleVote.bind(this);
    this.handleSave = this.handleSave.bind(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) {
    const posts = this.state.posts.map(item => {
      const newItem = item.id === id ? {...item, vote: ++item.vote} : item;
      return newItem;
    })
    this.setState({
      posts
    })
  }

  // 保存帖子
  handleSave(post) {
    // 根据post的id,过滤出当前要更新的post
    const posts = this.state.posts.map(item => {
      const newItem = item.id === post.id ? post : item;
      return newItem;
    })
    this.setState({
      posts
    })
  }

  render() {
    return (
      <div className='container'>
        <h2>帖子列表</h2>
        <ul>
          {this.state.posts.map(item =>
            <PostItem
              key = {item.id}
              post = {item}
              onVote = {this.handleVote}
              onSave = {this.handleSave}
            />
          )}
        </ul>
      </div>
    );
  }
}

export default PostList;

非受控组件

使用受控组件虽然保证了表单元素的状态也由 React 统一管理,但需要为每个表单元素定义 onChange 事件的处理函数,然后把表单状态的更改同步到 React 组件的 state,这一过程是比较烦琐的,一种可替代的解决方案是使用非受控组件。非受控组件指表单元素的状态依然由表单元素自己管理,而不是交给 React 组件管理。使用非受控组件需要有一种方式可以获取到表单元素的值,React 中提供了一个特殊的属性 ref,用来引用 React 组件或 DOM 元素的实例,因此我们可以通过为表单元素定义 ref 属性获取元素的值。例如:

class SimpleForm extends Component {
    constructor(props) {
        super(props);
        this.handleSubmit = this.handleSubmit.bind(this);
    }

    handleSubmit(event) {
        // 通过 this.input 获取到 input 元素的值
        alert('The title you submitted was ' + this.input.value);
        event.preventDefault();
    }

    render() {
        return (
            <form >
                <label>
                    title:
                    { /* this.input 指向当前 input 元素 */ }
                    <input type="text" ref={(input) => this.input = input } />
                </label>
                <input type="submit" value="Submit" />
            </form>
        );
    }
}

ref 的值是一个函数,这个函数会接收当前元素作为参数,即例子中的 input 参数指向的是当前元素。在函数中,我们把 input 赋值给了 this.input,进而可以在组件的其他地方通过 this.input 获取这个元素。

在使用非受控组件时,我们常常需要为相应的表单元素设置默认值,但是无法通过表单元素的 value 属性设置,因为非受控组件中,React 无法控制表单元素的 value 属性,这也就意味着一旦在非受控组件中定义了 value 属性的值,就很难保证后续表单元素的值的正确性。这种情况下,我们可以使用 defaultValue 属性指定默认值:

render() {
    return (
        <form onSubmit={this.handleSubmit}>
            <label>
                title:
                <input defaultValue="something" type="text" ref={(input) => this.input = input } />
            </label>
            <input type="submit" value="Submit" />
        </form>
    );
}

上面的例子,defaultValue 设置的默认值为 something,而后续值的更改则由自己控制。类似地,select 元素和 textarea 元素也支持通过 defaultValue 设置默认值,<input type="checkbox"> 和 <input type="radio"> 则支持通过 defaultChecked 属性设置默认值。

非受控组件看似简化了操作表单元素的过程,但这种方式破坏了 React 对组件状态管理的一致性,往往容易出现不容易排查的问题,因此非特殊情况下,不建议大家使用。