react受控/非受控组件

883 阅读11分钟

前言

笔者虽然用react做开发也有一段时间了, 但也并没有对这个受控/非受控组件有一个深入的了解, 平时开发过程中基本都是受控组件, 同时我理解的受控组件就是: 一个组件所需要的属性均来自于父组件, 同时改变属性的方法也来自于父组件, 那么这个组件就是受控组件, 也许会有些偏差, 那接下来, 我们就跟着react的官方文档一起来完善这部分知识吧

一个例子

在开始之前, 先来看一个例子, 这个例子是工作中另外一个同事所写的代码, 在我给他做code review的时候发现的, 这个代码是一个弹窗的代码: 页面上有一个按钮, 点击按钮打开弹窗, 然后点击弹窗中的取消按钮或者确定按钮之后关闭弹窗, 然后再次点击按钮依旧能打开弹窗, 也就是一个简单的模态框的逻辑, 代码如下:

demo.js:

import React, { Component, Fragment } from 'react';
import { Button } from 'antd';
import MyModal from './modal';

class Demo extends Component {

  state = {
    modalVisible: false
  }

  handleModalVisible = () => {
    this.setState({
      modalVisible: true
    });
  }

  render() {
    const { modalVisible } = this.state;

    return (
      <Fragment>
        <Button
          type="primary"
          onClick={this.handleModalVisible}
        >打开弹窗</Button>
        <MyModal
          modalVisible={modalVisible}
        />
      </Fragment>
    );
  }
}

export default Demo;

modal.js:

import React, { Component } from 'react';
import { Modal } from 'antd';

class MyModal extends Component {

  state = {
    visible: this.props.modalVisible
  }

  componentWillReceiveProps(nextProps) {
    this.setState({
      visible: nextProps.modalVisible
    });
  }

  handleCloseModal = () => {
    this.setState({
      visible: false
    });
  }

  render() {
    const { visible } = this.state;

    return (
      <Modal
        title="模态框"
        visible={visible}
        onOk={this.handleCloseModal}
        onCancel={this.handleCloseModal}
      >
        <div>我是一个模态框</div>
      </Modal>
    );
  }
}

export default MyModal;

这里的弹窗使用了antd_modal, 上面的代码是能够完成需求的, 但这个时候我们的浏览器控制台中报了一个警告:

componentWillReceiveProps has been renamed, and is not recommended for use. See reactjs.org/link/unsafe… for details.

componentWillReceiveProps 这个生命周期方法已经被重命名了, 而且它是不推荐使用的, 详情请查阅: reactjs.org/link/unsafe…

查阅后发现它的新名字是: UNSAFE_componentWillReceiveProps

同时这里为何我不建议使用这样的方式来完成呢, 因为这个需求做成受控组件就会非常的完美: 弹窗所需要的那个决定它渲染与否的属性一定是来自外部的, 那么既然如此, 弹窗内部就不需要自己去维护这个属性了, 毕竟是来自外部的, 自己维护就没有意义了, 显而易见, 修改这个属性的方法也就和这个属性一样的来自外部了, 那么改成如下形式就会是一个非常好的方案了:

demo.js:

import React, { Component, Fragment } from 'react';
import { Button } from 'antd';
import MyModal from './modal';

class Demo extends Component {

  state = {
    modalVisible: false
  }

  handleModalVisible = () => {
    this.setState({
      modalVisible: true
    });
  }

  handleCloseModal = () => {
    this.setState({
      modalVisible: false
    });
  }

  render() {
    const { modalVisible } = this.state;

    return (
      <Fragment>
        <Button
          type="primary"
          onClick={this.handleModalVisible}
        >打开弹窗</Button>
        <MyModal
          modalVisible={modalVisible}
          closeModal={this.handleCloseModal}
        />
      </Fragment>
    );
  }
}

export default Demo;

modal.js:

import React, { Component } from 'react';
import { Modal } from 'antd';

class MyModal extends Component {

  render() {
    const { modalVisible, closeModal } = this.props;

    return (
      <Modal
        title="模态框"
        visible={modalVisible}
        onOk={closeModal}
        onCancel={closeModal}
      >
        <div>我是一个模态框</div>
      </Modal>
    );
  }
}

export default MyModal;

这样就非常清晰: 外部的属性渲染, 使得弹窗打开, 处理完逻辑之后调用外部的方法关闭弹窗, 这个关闭的方法也来自外部因为需要操作同样处在外部的那个使弹窗打开的属性

其实代码修改之后就ok了, 当然实际也是如此, 同时该项目也已经上线了, 但如果仅止于此的话也就不会有这篇文章了, 起因就是我在react官网中搜索componentWillReceiveProps的时候它里面的一段描述:

If you used componentWillReceiveProps to  “reset” some state when a prop changes, consider either making a component fully controlled or fully uncontrolled with a key instead.

如果你使用componentWillReceiveProps是为了 当一个prop改变的时候重置一些state, 那么请考虑使用完全受控组件或者带key的完全不受控组件来替代

这段描述说的正是一开始的代码的情形, 那段代码中使用componentWillReceiveProps的目的就是当一个prop改变的时候重置一些state, 同时这也引起了我的好奇心, 因此也有了这篇文章

componentWillReceiveProps的执行时机

那么为何componentWillReceiveProps会有一个新的名字: UNSAFE_componentWillReceiveProps呢? 以及为何是以不安全开头呢? 要解答这两个问题, 那么我们就需要先了解它的一个执行时机, 这个问题官方文档中有解答: Anti-pattern: Unconditionally copying props to state, 里面的这段描述值得我们反复阅读:

A common misconception is that getDerivedStateFromProps and componentWillReceiveProps are only called when props “change”. These lifecycles are called any time a parent component rerenders, regardless of whether the props are “different” from before. Because of this, it has always been unsafe to unconditionally override state using either of these lifecycles. Doing so will cause state updates to be lost.

一个常见的误解是: getDerivedStateFromPropscomponentWillReceiveProps只在props改变的时候执行, 而实际上是只要父组件重新渲染了, 那么它们就会执行, 无论重新渲染的时候props和之前相比是否"不同", 因此, 使用它们中的任何一个来 无条件 地覆盖state都是不安全的, 这么做会导致状态更新丢失

简而言之, componentWillReceiveProps的执行时机是: 父组件重新渲染的时候

使用它可能造成什么问题

了解了它的执行时机, 那么就可以来探讨为何不推荐使用它的原因了, 也就是使用它可能会造成什么问题, 首先我们来看一个例子, 从一个例子入手就能清晰直观地看到这个状态更新丢失的问题了: reactjs-org-when-to-use-derived-state-example-1

阅读代码之后不难发现, 我们的EmailInput组件通过来自父组件Timer的一个特定的props初始化了一个state值, 并且当我们更新这个值的时候, 也就是在输入框中输入值的时候, 我们输入的值也能正常更新, 但这个例子中还伴随一个现象: 我们输入一个值, 这个值确实更新了, 但伴随着的是更新之后立刻就被覆盖, 也就是我们新输入的值只存在了一小会, 然后立刻就被初始化的值给覆盖了, 也就是说我们的更新丢失了!

这是为何呢? 仔细看代码我们发现, 是因为父组件更新了!

当我们更新子组件EmailInput的值的时候, Timer也更新了, 此时才导致了我们新输入的值被覆盖了, 也就是更新丢失, 当然了, 这并不是因为我们更新了子组件EmailInput的值导致父组件Timer的更新, 而是因为TimercomponentDidMount中有一个计时器每隔1秒就setState, 导致Timer每秒都在更新, 更新的时候就会给EmailInput设置初始值: <EmailInput email="example@google.com" />, 而父组件重新渲染了, 我们知道EmailInput中的componentWillReceiveProps也就会执行, 执行的操作就是将父组件传递来的props email初始化到我们的state中, 导致我们输入的任意值被这个email="example@google.com"覆盖, 从而使得我们的更新丢失, 同时我们还留意到, 父组件虽然重新渲染了, 但传给子组件的prop email并没有改变, 始终是example@google.com, 关键代码如下:

Timer:

//...
  componentDidMount() {
    this.interval = setInterval(
      () =>
        this.setState(prevState => ({
          count: prevState.count + 1
        })),
      1000
    );
  }
//...

EmailInput:

  state = {
    email: this.props.email
  };
  //...
  componentWillReceiveProps(nextProps) {
    this.setState({ email: nextProps.email });
  }
  //...

这个例子形象地诠释了上面提到的更新丢失的问题, 哪怕我们在重置state之前使用了nextProps.email !== this.state.email也一样,因此是不推荐这样来使用的, 那怎么使用呢? 那就要涉及到本文的主题了: 受控非受控组件

受控组件

首先我们来了解一下何为受控组件, 参考官方文档: Controlled Components里的描述:

In HTML, form elements such as <input>, <textarea>, and <select> typically maintain their own state and update it based on user input. In React, mutable state is typically kept in the state property of components, and only updated with setState().

在HTML中, 例如<input>, <textarea><select>等表单元素通常都会保持自己的state, 并且根据用户的输入来更新这个(些)state. 在React中, 易变的state(数据)通常保存在组件的state中, 并且只通过setState()来更新

We can combine the two by making the React state be the “single source of truth”. Then the React component that renders a form also controls what happens in that form on subsequent user input. An input form element whose value is controlled by React in this way is called a “controlled component”.

我们可以通过让React的state成为single source of truth来结合这两者. 然后渲染表单的React组件也会控制该表单在随后的用户输入中所发生的事. 一个输入表单元素的值被React通过这样的方式控制, 这就叫做"受控组件"

简而言之就是: 当父组件控制一个子组件的数据了, 那么这个子组件就被称为受控组件, 光看文字描述太过抽象, 我们通过一个示例来理解一下: reactjs-org-when-to-use-derived-state-alternative-pattern-1:

关键代码如下:

index.js:

class App extends Component {
  state = {
    draftEmail: this.props.user.email
  };
  
  handleEmailChange = event => {
    this.setState({ draftEmail: event.target.value });
  };

  render() {
    return (
      <ControlledEmailInput
        email={this.state.draftEmail}
        handleChange={this.handleEmailChange}
      />
    )
  }
}

ControlledEmailInput.js:

export default function ControlledEmailInput(props) {
  return (
    <label>
      Email: <input value={props.email} onChange={props.handleChange} />
    </label>
  );
}

示例中父组件通过email来告诉子组件应该显示哪一个值, 这个写法就是我一开始使用的那个写法, 就是上面弹窗例子中修改之后的写法

同时这里插一嘴, 这个受控组件的写法, 个人理解就是single source of truth, 同时这句话个人将它理解为单一数据来源, 也就是数据的来源是单一的, 比如这个受控组件的例子, 子组件的值来自于父组件, 父组件给了子组件一个初始值, 和一个修改父组件传给的子组件值的方法, 子组件修改值的时候调用该方法修改这个值, 父组件通过setState来修改这个值, 重新渲染之后又将新的值又传给子组件, 使得子组件UI得到更新

这样的写法使得数据流清晰可见, 单向流动, 始终由上到下, 便于管理, 而且最重要的是它不会导致更新丢失

非受控组件

这里还有一个方式, 就是非受控组件, 首先还是来看一下官方文档: Uncontrolled Components, 表单数据被DOM元素自己处理, 而不是被父组件所控制, 此时它就是非受控组件, 同时它还有一个特点, 就是没有更新数据的事件, 而是通过key创建一个新的组件实例来替换原有的组件, 而不是更新原有的组件

还是通过一个示例来学习我们的非受控组件: reactjs-org-when-to-use-derived-state-alternative-pattern-1

依旧来看一下关键代码:

index.js:

const fakeAccounts = [
  {
    id: 1,
    name: "One",
    email: "fake.email@example.com"
  },
  {
    id: 2,
    name: "Two",
    email: "fake.email@example.com"
  }
];

render(
  <AccountsList accounts={fakeAccounts} />,
  document.getElementById("root")
);

AccountsList.js:

export default class AccountsList extends Component {
  state = {
    selectedIndex: 0
  };

  render() {
    const { accounts } = this.props;
    const { selectedIndex } = this.state;
    const selectedAccount = accounts[selectedIndex];

    return (
      <Fragment>
        <UncontrolledEmailInput
          key={selectedAccount.id}
          defaultEmail={selectedAccount.email}
        />
        <p>
          Accounts:
          {this.props.accounts.map((account, index) => (
            <label key={account.id}>
              <input
                type="radio"
                name="account"
                checked={selectedIndex === index}
                onChange={() => this.setState({ selectedIndex: index })}
              />{" "}
              {account.name}
            </label>
          ))}
        </p>
      </Fragment>
    )
  }
}

UncontrolledEmailInput.js:

export default class UncontrolledEmailInput extends Component {
  state = {
    email: this.props.defaultEmail
  };

  handleChange = event => {
    this.setState({ email: event.target.value });
  };

  render() {
    return (
      <label>
        Email: <input onChange={this.handleChange} value={this.state.email} />
      </label>
    );
  }
}

AccountsList还给UncontrolledEmailInput传递了一个key的属性, 同时需要注意的是, 这个key属性我们是没法直接使用的, props.key始终会是undefined, 如果我们需要使用key的值, 那么应该传递一个新的props进来, 然后使用那个新的props

UncontrolledEmailInput内部自己维护自己的数据, value来自于自己的state, 同时onChange也是修改自己的state, 当然了, 初始的value是来自于父组件AccountsList

我们修改input中的值, 里面的值变更了, 我们输什么它显示的就是什么, 没问题, 因为input中自己维护了state, 也就是我们输入的值

输入完毕, 此时我们去点击输入框下面的单选按钮, 单选也变了, 但是奇怪的事发生了: 我们刚修改过的input的值不见了, 变成了一开始的初始值: fake.email@example.com, 这是为何?

这是因为我们切换单选按钮的时候, AccountsList发生了更新, 而我们的UncontrolledEmailInput非受控组件, 它有一个key值, key值也发生了更新, 此时key变了, 整个UncontrolledEmailInput就发生了替换, 因此UncontrolledEmailInput里面原来的state也就不见了, 因为UncontrolledEmailInput被替换了, 已经不是原来的UncontrolledEmailInput了, 这点可以通过在UncontrolledEmailInput中增加constructor方法看到:

UncontrolledEmailInput.js:

constructor(props) {
  super(props);
  console.log('constructor');
}

可以看到当我们切换单选按钮的时候, UncontrolledEmailInput中的constructor就会执行, 这是因为UncontrolledEmailInput被替换了, 卸载又再挂载, 它的一整个生命周期都被重新执行了, 也就是被替换了

好的, 这次受控组件非受控组件就和大家聊到这了, 有任何问题欢迎在评论区探讨, 最后, 如果你觉得这篇文章写得还不错, 别忘了给我点个赞, 如果你觉得对你有帮助, 可以点个收藏, 以备不时之需

参考文献:

  1. Anti-pattern: Unconditionally copying props to state
  2. reactjs-org-when-to-use-derived-state-example-1
  3. Controlled Components
  4. reactjs-org-when-to-use-derived-state-alternative-pattern-1
  5. Uncontrolled Components
  6. Keys
  7. reactjs-org-when-to-use-derived-state-alternative-pattern-1