React受控组件与非受控组件

在React开发中经常会遇到controlled和uncontrolled组件的问题处理,最近仔细阅读了官方文档以及相关的介绍,整理一份基础笔记。

非受控组件

当用户将数据输入到表单字段(例如 input,dropdown 等)时,React 不需要做任何事情就可以映射更新后的信息

非受控组件就像是我们常见的DOM元素,Input节点元素内部保存输入的内容,然后在需要的时候可以通过 ref 获取它们的值。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React, { useCallback, useRef } from 'react';

interface UncontrolledComponentProps {}

const UncontrolledComponent: React.FC<UncontrolledComponentProps> = () => {
const nameInputRef = useRef<HTMLInputElement | null>(null);
const onSubmitForm = useCallback(() => {
// 主动提取表单中的数据
console.log(nameInputRef.current?.value);
}, [])
return (
<div>
<input type="text" ref={ nameInputRef } />
<button onClick={ onSubmitForm }>获取表单数据</button>
</div>
);
};

export default UncontrolledComponent;

非受控组建的特点就是,不会实时的向父组件发送数据的变化,只有在父组件需要的时候,才会使用ref主动的从该组件中「提取」最终结果数据。

非受控组件的应用场景比如一个应用场景比较单一的Form表单,它的特点如下:

  • 组件自己保存全部数据变化
  • 组件完善地处理错误提醒、正则数据验证、联动内容(disable/展示隐藏)等功能
  • 父组件只需要最终的结果,不会实时改动太多表单的内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import React, { useRef } from "react";
import { useImperativeHandle } from "react";

interface UncontrolledFormProps {}

// 组件Ref暴露的接口
export interface UFRefProps {
getFormValues: () => { [key: string]: string };
}

const UncontrolledForm = React.forwardRef<UFRefProps, UncontrolledFormProps>(
(props, fowardedRef) => {
const nameInputRef = useRef<HTMLInputElement | null>(null);
const passwordInputRef = useRef<HTMLInputElement | null>(null);

// 自定义暴露给父组件的实例值
useImperativeHandle(fowardedRef, () => ({
/** 获取表单的全部值 */
getFormValues() {
return {
username: nameInputRef.current?.value ?? "",
password: passwordInputRef.current?.value ?? "",
};
},
}));
return (
<div>
<form>
<input placeholder='用户名' type='text' ref={nameInputRef} />
<input autoComplete='on' placeholder='密码' type='password' ref={passwordInputRef} />
</form>
</div>
);
}
);

export default UncontrolledForm;

以上便是一个非常简单的非受控组件的例子,它通过forwardRef和useImperativeHandle 暴露出获取全部Form数据的接口,父组件在使用时,可以直接通过该接口「拉取」数据即可。

1
2
3
4
5
6
7
8
// index.tsx
const formRef = useRef<UFRefProps | null>(null);
return (
<div>
<UncontrolledForm ref={formRef} />
<button onClick={() => console.log(formRef.current?.getFormValues())}>获取表单数据</button>
<div>
)

在开发上,该组件的优点便是快速,完全不用考虑可能传递进来的参数的问题,快速完成功能。

受控组件

如果一个 input 表单元素的值是由 React 控制,就其称为受控组件

与非受控组件对应的,便是受控组件,特点也是与之对应,受控组件有以下特点:

  • 有一个函数实时的与父组件更新数据,
  • 有一个参数接受它的当前值。

参考「Controlled and uncontrolled form inputs in React don’t have to be complicated」文章介绍,它的流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
import React from "react";

interface ControlledInputProps {
value: string;
onChange: (value: string) => void;
}

const ControlledInput: React.FC<ControlledInputProps> = ({ value, onChange }) => {
return <input value={value} onChange={({ target: { value } }) => onChange(value)} />;
};

export default ControlledInput;

受控组件的当前值(value)与更新事件(onChange)一般是成对出现的,否则会导致组件变为readOnly组件,无法更新数据。在React开发模式可能会报warning:

Warning: Failed prop type: You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue.

使用对比

受控组件是React官方推荐的开发风格,大部分的组件都应该遵循该模式来实现。在实际使用中,因为父组件可以实时获取、控制数据,因此对处理表单的错误提醒、其他子组件的disabled等状态变化都有非常好的体验。同时,对于异步初始化更新子组件数据也会变得非常方便。

1
2
3
4
5
6
const [name, setName] = useState<string>("");
return (
<ControlledInput value={name} onChange={setName} />
<button onClick={ ()=> setName('helloworld') }>主动更新组件数据</button>
<Button disabled={!name}>提交数据</Button>
)

应用场景对比

大部分的组件都应该使用受控组件。但是对于一些指令式的操作,使用非受控组件可以更好实现,比如:

  • 管理焦点,文本选择,媒体播放。
  • 触发命令动画。
  • 与第三方 DOM 库集成。

之前有遇到过这样的业务:同一个页面是由多个Form组成,它们初始化数据是由多个接口请求到的数据来初始化,但是修改后使用同一个提交按钮同一个接口进行提交。

这样使用非受控组件的方案可以是每个组件单独去请求该数据,然后决定是否可以编辑,父组件点击提交时,从子组件中拉取全部数据,然后统一提交。

一般非受控组件,也会有一个defaultValue可以用来初始化组件的值,但是之后该值的改变,便不会实时更新。实现方法如下。

1
2
3
4
const UncontrolledInput: React.FC<UncontrolledInputProps> = ({ defaultValue: initialValue }) => {
const [value, setValue] = useState<string>(initialValue);
return <input value={value} onChange={({ target: { value } }) => setValue(value)} />;
};

利用了useState函数的参数值,只会在第一次创建的时候使用,之后再修改该props,并不会再对state的值引起改变的特点。

协调(reconciliation)时的问题

当对比两个相同类型的 React 元素时,React 会保留 DOM 节点,仅比对及更新有改变的属性。

1
2
3
<div className="before" title="stuff" />

<div className="after" title="stuff" />

通过对比这两个元素,React 知道只需要修改 DOM 元素上的 className 属性。

这时,非受控组件的问题就凸显出来了,再次更新的defaultValue值并不会更新组件的值,在一些特殊场景,比如切换Tab时,会出现将第一个Tab组件里的值,带入到第二个组件中去的问题。

1
2
3
4
5
6
7
8
<div>
<Button type='primary' onClick={() => setShowFirst(!showFirst)}>
切换展示 {showFirst + ""}
</Button>;
{
showFirst ? <UncontrolledInput defaultValue='hi1' /> : <UncontrolledInput defaultValue='hi2' />;
}
</div>

在该案例中,我们期待的是切换后,Input组件会切换两个默认值hi1和hi2的,但是实际效果并不如意,不仅如此,我们修改其中一个输入内容之后,点击切换,输入内容会带入到第二个中去。

我们通过对非受控组件中模拟mount和unmount事件,来查看切换时的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface UncontrolledInputProps {
defaultValue: string;
}

const UncontrolledInput: React.FC<UncontrolledInputProps> = ({ defaultValue: initialValue }) => {

useEffect(() => {
console.log("mount");
return () => {
console.log("clean Up");
};
}, []);

const [value, setValue] = useState<string>(initialValue);
return <input value={value} onChange={({ target: { value } }) => setValue(value)} />;
};

观察结果发现,组件并没有被销毁,而是被重用了,这样就导致出现了状态被重用的问题。参考官方文档介绍,我们可以通过两种方案修复:

  • 为其声明不同的父级元素
  • 使用不同的key

当根节点为不同类型的元素时,React 会拆卸原有的树并且建立起新的树。因此使用不同的父节点元素可以解决该问题。

1
2
3
4
5
6
7
8
9
10
11
{
showFirst ? (
<span>
<UncontrolledInput defaultValue='hi1' />
</span>
) : (
<b>
<UncontrolledInput defaultValue='hi2' />
</b>
);
}
正常卸载组件

通过观察可以发现,每次切换,都会卸载上一个组件,重新创建一个新的组件。这样就解决了之前的问题。但是,使用不同的父组件会让代码变得很难理解,因此,更推荐使用官方提供的添加key的方式。

1
2
3
4
5
6
7
{
showFirst ? (
<UncontrolledInput key='hi1' defaultValue='hi1' />
) : (
<UncontrolledInput key='hi2' defaultValue='hi2' />
);
}

当子元素拥有 key 时,React 使用 key 来匹配原有树上的子元素以及最新树上的子元素,这样key不相同,便不会复用原来的元素,这样就会保证了正常的切换。

受控组件,在表现上不会存在该问题,因为其value值的变化,都会实时的同步到元素状态中。但是,受控组件一样会复用该组件,就会导致组件内的状态(比如:定时器)会被复用,同样有一些隐患。因此,在切换受控组件时,也同样推荐增加key属性。

参考