React受控&非受控组件——我想都要!

1,342 阅读12分钟

受控组件与非受控组件像是朝着两个不同方向发展的产物。一个完全依赖外部状态而拥有极少的内部状态;一个依赖极少的外部状态而自身状态确丰富得多。本文总结了笔者在开发过程中关于受控和非受控组件的一点思考,并给出典型的表单场景下的demo(基于Antd Form)。

组件

什么是组件?
是继承了React.componentClass,亦或是return JSXFunction
如果这样这样认为那么对组件的认识就太狭隘了。用一句经典的话来说就是:在React还没诞生的时候,组件这个概念就已经存在了。
在传统的三剑客时代,JS、CSS、HTML分别负责前端页面的行为、展现和结构。这样的分工到现在也没有改变。区别就是开发者不再是单独地写 JS,CSS,HTML,而是把它们有机地组合在一起,构成页面里的最小逻辑单元———组件。没错,组件就是三者的完美融合。

js,css,html.png

React中的组件也遵循着 Web 组件的实现模式,只不过JSX解析成原生JS和HTML的部分React帮你做了。

组件,从概念上类似于 JavaScript 函数。它接受任意的入参(即 “props”),并返回用于描述页面展示内容的 React 元素。 ———— React官网

All in JS的思想指导下,React开发社区里更是推出了styled-components这样的css in js类库。也正如此才有了“未来能用JavaScript实现的功能最终将会由JavaScript实现”这样的论断。这一趋势的本质其实是JavaScript承担了HTML和CSS两者部分的角色、作用。

受控组件

在 HTML 中,表单元素(如<input>、 <textarea> 和 <select>)之类的表单元素通常自己维护 state,并根据用户输入进行更新。而在 React 中,可变状态(mutable state)通常保存在组件的 state 属性中,并且只能通过使用 setState()来更新。

我们可以把两者结合起来,使 React 的 state 成为“唯一数据源”。渲染表单的 React 组件还控制着用户输入过程中表单发生的操作。被 React 以这种方式控制取值的表单输入元素就叫做“受控组件”。

上面是React官网上对受控组件的描述。所以说,受控组件是内部状态可以被它的使用者“控制”的组件。比如下面这个例子:

import React, { useState } from 'react';

/**
 * 受控组件
 * @returns
 */
function ControlledComp() {
  const [value, setValue] = useState('');

  const changeHandle = (event: React.ChangeEvent<HTMLInputElement>) => {
    setValue(event.target.value);
  };
  const handleSubmit = (event: React.ChangeEvent<HTMLFormElement>) => {
    alert('提交的名字: ' + value);
    event.preventDefault();
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:&nbsp;
        <input type="text" value={value} onChange={changeHandle} />
      </label>
      <input type="submit" value="Submit" />
    </form>
  );
}

export default ControlledComp;

效果:

受控组件.png

上面的demo中,form中的input标签的value值由 ControlledCompState => value赋值,为了监听用户的输入,需要在设置input标签的onChange事件处理,在onChangesetValue更新最新值。在这一过程中,input值的改变自始至终受到ControlledComp的“控制”,所以input元素对于ControlledComp来说是受控的。

非受控组件

与受控组件相反,非受控组件不再由依赖外部进行状态管理,而是完全由组件自身维护和更新状态。也就是说组件100%掌握这自身状态的控制权。

在一个受控组件中,表单数据是由 React 组件来管理的。另一种替代方案是使用非受控组件,这时表单数据将交由 DOM 节点来处理。

上面是React官网关于非受控组件的描述,可以看到React官方把受控组件的概念限定在了表单应用场景中。其实,对于开发者而言,受控非受控组件的概念的应用范围扩展到整个React生态中也是可以的。
来看一个非受控组件的demo:

import React, { useRef } from 'react';

/**
 * 非受控组件
 * @returns
 */
function UnControlledComp() {
  const input = useRef<HTMLInputElement>(null);

  const handleSubmit = (event: React.ChangeEvent<HTMLFormElement>) => {
    alert('提交的名字: ' + input.current?.value);
    event.preventDefault();
  };

  const clickHandle = () => {
    console.log(input.current);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:&nbsp;
        <input type="text" ref={input} />
      </label>
      <input type="submit" value="Submit" />
      <input type="button" value="Print Ref" onClick={clickHandle} />
    </form>
  );
}

export default UnControlledComp;

效果:

非受控组件.png

同样是一个form表单中拥有一个input标签,可以输入任意内容,程序获取并打印用户的输入。在上面代码中,UnControlledComp没有用自身的状态去影响input的状态,直观来看就是没有设置inputvalueonChange。想要获取input自身的“状态”(其实就是输入value),只能通过input自身的引用获取。于是,可以看到代码中使用了 Refs 来获取inputDOM实例,拿到inputDOM实例后就可以任意获取和控制input了(感觉回到了jQuery那个时代)。
打印Ref的值,可以发现打印的结果确实是input这个标签:

原生input.png

对比

对比.png 原图链接,这是国外开发者总结的受控和非受控组件的特性对比,可以发现受控组件支持更多的操作特征,这也是React官方推荐使用受控组件的原因。
同时,上述总结的文章作者也补充道:

Both the controlled and uncontrolled form fields have their merit. Evaluate your specific situation and pick the approach — what works for you is good enough.

If your form is incredibly simple in terms of UI feedback, uncontrolled with refs is entirely fine. You don't have to listen to what the various articles are saying is "bad."

受控制和不受控制的表单字段都有其优点。评估你的具体情况,选择适合你的方法就足够了。如果你的表单在UI反馈方面非常简单,那么选择非受控组件就是很好的。你不必去听那些文章说什么是“不好的”。

这里,大家可能会有困惑:我哪知道UI反馈是简单还是复杂呢?
老实说,没有人能给出一个准确的答案。因此推荐的步骤是不妨先把状态控制全部交给组件自身,把组件当做一个非受控组件来开发,这样也能让相似的逻辑代码在同一“物理空间”(其实都是在同一个文件)中。等到发现组件完全依赖自身的状态已无法完成既定功能,或自身状态需要被传递到外部时,可以考虑采用受控组件的思路,将一部分状态的控制权“转移”到父组件中。
有没有点状态提升的感觉?不用怀疑,就是状态提升哈。React是MVVM架构模式的实现,遵循数据驱动开发范式,数据既是状态,状态决定行为。
受控组件/非受控组件控制的是什么?不就是状态吗?通过控制组件状态达到进一步控制组件行为的目的也是理所当然哈。

部分受控组件

通过上面的内容大家应该知道我接下来要讲什么了吧?是的,不管你是受控还是非受控组件,特性我全都要!
我要的是“部分受控组件”(自己取的名字,哈哈哈),我相信大家在日常中使用组件库的组件也都是“部分受控组件”。这些组件的典型特征就是脱离父组件也能独立使用,可以使用钩子获取变化,也可以通过ref拿到实例来获取内部特性。典型的例子就是Antd Form:

import React, { useRef } from 'react';
import { Form, Input, Button } from 'antd';

/**
 * 部分受控组件
 * @returns
 */
function AntdForm() {
  const [form] = Form.useForm();
  const formRef = useRef<any>();
  return (
    <div style={{ textAlign: 'left' }}>
      <Form form={form} ref={formRef}>
        <Form.Item>
          <Input />
        </Form.Item>
      </Form>
      <Button
        onClick={() => {
          console.log('form = ', form);
          console.log('formRef = ', formRef.current);
          console.log(form === formRef.current);
        }}
      >
        Print form formRef
      </Button>
    </div>
  );
}
export default AntdForm;

AntdForm.png

上面demo中使用了useForm钩子获取Form组件的实例,也用了ref获取实例,通过打印结果可知 form === formRef:

form-formRef.png

所以,无论是form还是formRef其实都具备操作Form表单的能力。从这个意义讲,基于form实例操作Form组件时,Form组件相对于调用者是非受控的。
同时Form组件也提供了initialValues,validateTrigger等属性,onFieldsChange,onFinish,onFinishFailed,onValuesChange等回调函数,提供不直接控制form实例也能处理表单校验、提交等操作。这些操作可以在让父组件通过属性传值控制Form表单的行为,此时Form是受控的。

奇技淫巧

通过上面内容已经知道,自定义的组件应该兼有受控性和非受控性。受控性主要用过propsAPI来体现,非受控性则是内部状态自我管理,父组件想要获取组件的内部状态时可以通过 React.createRef()/React.useRef() + ref属性获取组件实例进行操作。根据官网文档,ref 属性具有如下特性:

  • 当 ref 属性用于 HTML 元素时,构造函数中使用 React.createRef() 创建的 ref 接收底层 DOM 元素作为其 current 属性。
  • 当 ref 属性用于自定义 class 组件时,ref 对象接收组件的挂载实例作为其 current 属性。
  • 你不能在函数组件上使用 ref 属性,因为他们没有实例。

最重要的一点:

如果要在函数组件中使用 ref,你可以使用 forwardRef(可与 useImperativeHandle 结合使用),或者可以将该组件转化为 class 组件。

接下来要讲的就是 forwardRef + useImperativeHandle的妙用,先看一个demo:

import React, { useRef, useState, useImperativeHandle, forwardRef } from 'react';
import { Form, Input, Button } from 'antd';

function AntdForm(props: any) {
  const { formRef } = props ?? {};
  const [form] = Form.useForm();
  const [loading, setLoading] = useState(false);
  const initialValues = {
    name: '张三',
    age: 22,
  };
  useImperativeHandle(formRef, () => ({
    ...form,
    getInitialValues: () => initialValues,
    getLoading: () => loading,
  }));

  return (
    <div style={{ textAlign: 'left' }}>
      <Form form={form} initialValues={initialValues}>
        <Form.Item label="Name:" name="name" rules={[{ required: true, message: 'please enter name' }]}>
          <Input />
        </Form.Item>
        <Form.Item label="Age:" name="age" rules={[{ required: true, message: 'please enter age' }]}>
          <Input />
        </Form.Item>
        <Button
          loading={loading}
          onClick={() => {
            setLoading(true);
            setTimeout(() => {
              setLoading(false);
            }, 2000);
          }}
        >
          提交
        </Button>
      </Form>
    </div>
  );
}

const AntdFormRef = forwardRef<any, any>((props, ref) => <AntdForm {...props} formRef={ref} />);

function FormRefPage() {
  const antdFormRef = useRef<any>();

  return (
    <div className="form-ref-page">
      <h3>Antd Form Ref</h3>
      <AntdFormRef ref={antdFormRef} />
      <button
        onClick={() => {
          console.log(antdFormRef.current);
          console.log('loading = ', antdFormRef.current?.getLoading());
          console.log('initialValues = ', antdFormRef.current?.getInitialValues());
        }}
      >
        print AntdFormRef
      </button>
    </div>
  );
}

export default FormRefPage;

效果:

image.png

上面的代码的关键部分如下:

...
useImperativeHandle(formRef, () => ({
    ...form,
    getInitialValues: () => initialValues,
    getLoading: () => loading,
  }));
...

useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值。如果我们把实例值的范围扩大,将组件内部的State及其SetStateAction暴露给父组件,那么父组件就可以直接操作组件内部的State,从而达到完全“控制”子组件状态跟行为的目的。
在上面的例子中,我将子组件内部变量loadinginitialValues通过getLoadinggetInitialValues方法返回,父组件调用这两个方法即可拿到子组件的状态。

image.png

打印 antdFormRef.current 可以发现两个方法已经被挂载到了ref实例上,在父组件中调用这两个方法就可以获取子组件的这两个内部状态:

内部状态.png

一点思考

时间线先稍微往前拨一点,互联网诞生之初便确立了模拟再造现实世界的终极课题,怎么实现?基于模仿和二次创造,找到现实世界的运作规律然后以计算思维重塑可以帮助人们更好地完善互联网世界。
在这一目的下,更复杂的应用系统、更复杂的连接模型逐渐诞生,随之而来的是更高的开发挑战。复杂的应用场景呼唤新的开发范式,于是,Object-oriented programming(OOP)横空出世。
在抽象、继承、多态三大原则的加持下,OOP要求开发人员关注想要操作的对象,而不是操作这些对象所需的逻辑。这种编程方法非常适合大型、复杂和主动更新或维护的程序。这包括制造和设计程序,以及移动应用程序。
图形用户界面GUI)日渐崛起的情况下,OOP很好地适应了潮流,并在上世纪80年代成为了一种主导思想,这种思想至今影响着我们。
在React中万物皆对象,我们所说的组件本质也是对象。组件拥有外部状态props、内部状态state以及内部function(行为)。

image.png

所以,OOP中的一些编程理念其实在组件开发中也可以遵循:

  • 组件开发应该遵循“高内聚、低耦合”;
  • 组件应该是抽象的,具备良好的业务拓展性,特定场景下的业务组件不应该作为公共组件来看待,存放目录也不应该在一级目录下;
  • 组件应该尽可能自己维护内部状态,不应该直接暴露内部状态给父组件,而是提供setter作为修改内部状态的入口;
  • 组件的应该能够接受外部输入,外部输入或者作为初始状态或者直接在组件内部被消费,根据外部状态而派生新的内部状态不可取,因为这样会增加父子组件的耦合。

总结

受控组件/非受控组件是一个相对的概念,是子组件相对父组件来说才有受控/非受控一说。具体到组件开发中应该遵循“开发时非受控,使用时受控”的原则。“开发时非受控”意味着组件的对外依赖要少,内部状态自己维护,不轻易暴露自身状态给外部;“使用时受控”则表明组件需提供外部API,需主动暴露一些接口以驱动内部状态改变,从而实现自身行为的多态。提供API需要注意的点有:

  1. 接受props,但不要以props派生出state再使用,而是尽量直接使用props
  2. 当组件内部state需要供外部读写时可以进行“变量提升”,其实就是state => propsstate转变为props);
  3. props之外,需考虑到父组件使用refs获取组件实例来操作自身;对于Class Component refs可以拿到组件实例,即组件内部所有状态和方法都可调用;对于Function Component因为不存在实例则需要借助forwardRef+ useImperativeHandle暴露出任意状态和方法;

参考

controlled-components
uncontrolled-components
Controlled and uncontrolled form inputs in React don't have to be complicated
you-probably-dont-need-derived-state
Object-oriented programming
object-oriented-programming-OOP