表单

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

受控组件

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

文本框

文本框包含类型为 textinput 元素和 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>
        );
    }
}

复选框和单选框

复选框是类型为 checkboxinput 元素,单选框是类型为 radioinput 元素,它们的受控方式不同于类型为 textinput 元素。通常,复选框和单选框的值是不变的,需要改变的是它们的 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>
        );
    }
}

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

下面为 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 属性调用父组件 PostListhandleSave 方法,将更新后的 Post(标题和时间)保存到 PostListstate 中。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 对组件状态管理的一致性,往往容易出现不容易排查的问题,因此非特殊情况下,不建议大家使用。