React的两种状态哲学:受控与非受控模式

原生html元素受控和非受控模式

在 React 中,表单组件(如 <input><textarea><select> 等)有两种处理用户输入的方式:受控组件(Controlled Components) 和 非受控组件(Uncontrolled Components)。

受控组件 (Controlled Components)

定义:表单元素的值完全由React组件状态驱动,形成单向数据流

核心特征

  • 数据源唯一性:value属性绑定组件state
  • 严格同步机制:必须通过onChange事件更新state
  • 完全状态控制:每次输入都触发重新渲染
js 复制代码
。
function ControlledInput() {
  const [inputValue, setInputValue] = useState("");

  return (
    <div>
      <input
        type="text"
        value={inputValue} // 受控
        onChange={(e) => setInputValue(e.target.value)} // 更新 state
      />
      <p>输入值: {inputValue}</p>
    </div>
  );
}

export default ControlledInput;

非受控组件 (Uncontrolled Components)

定义:非受控组件是指表单元素的值由 DOM 自己管理,而不是由 React state 控制。通常使用 ref 直接访问和操作 DOM 元素。

特点:

  • 数据存储在 DOM 中,而不是 React 组件 state 中。
  • 文件上传等不需要即时验证的输入
  • 适用于与非 React 代码(如第三方库)集成的场景。
js 复制代码
import { useRef } from "react";

function UncontrolledInput() {
  const inputRef = useRef(null);

  const handleSubmit = () => {
    alert(`输入值: ${inputRef.current.value}`);
  };

  return (
    <div>
      <input type="text" ref={inputRef} /> {/* 非受控 */}
      <button onClick={handleSubmit}>提交</button>
    </div>
  );
}

export default UncontrolledInput;

在实际开发中,很多第三方组件库(如 antd)的表单组件都支持受控和非受控两种模式

  • <Input value={value} onChange={setValue} />(受控模式)
  • <Input defaultValue="初始值" ref={inputRef} /> 或者 <Input defaultValue="初始值" onChange={读取数据}> (非受控模式)

组件级别的受控和非受控模式

当从原生html元素的受控/非受控模式切换到组件级别的受控/非受控模式时,具体的控制逻辑也是相似的。

受控组件模式

父组件通过props完全控制子组件状态

特征

  • 父组件传入valueonChange
  • 子组件成为无状态(pure)组件
  • 数据流完全可预测

非受控组件模式

子组件自主管理状态,父组件通过defaultValue初始化

特征

  • 父组件传入defaultValue
  • 子组件内部维护状态
  • 通过onChange通知父组件状态变更

实现一个同时支持受控模式和非受控模式的子组件Calendar

tsx 复制代码
function Calendar(props: CalendarProps) {
  // 受控模式:父组件传入value和onChange
  // 非受控模式:父组件传入defaultValue和onChange
  const { value: propsValue, defaultValue, onChange, dates } = props;

  // 初始化内部状态(非受控模式使用)
  const [internalValue, setInternalValue] = useState<Date | undefined>(propsValue ?? defaultValue);

  // 处理日期选择
  const changeValue = (date: Date) => {
    if (propsValue === undefined) {
      setInternalValue(date); // 仅非受控模式更新内部状态
    }
    onChange?.(date); 
  };

  // 最终显示值
  const mergedValue = propsValue ?? internalValue;

  return (
    <div>
      <div>当前选中:{mergedValue?.toLocaleDateString() || "未选择"}</div>
      {dates.map((date) => (
        <div key={date.toISOString()} onClick={() => changeValue(date)} style={{ cursor: "pointer", margin: "8px 0" }}>
          {date.toLocaleDateString()}
        </div>
      ))}
    </div>
  );
}
tsx 复制代码
// 受控模式调用子组件
export default function App() {
  const dates = [new Date(2025, 4, 1), new Date(2025, 4, 2), new Date(2025, 4, 3)];
  const [date, setDate] = useState(new Date(2025, 4, 1));
  return (
    <Calendar dates={dates} value={date} onChange={(date) => {setDate(date); }}
    />
  );
}

// 非受控模式调用子组件
export default function App() {
  const dates = [new Date(2025, 4, 1), new Date(2025, 4, 2), new Date(2025, 4, 3)];
  return (
    <Calendar dates={dates} onChange={(date) => {console.log(date);}}
      defaultValue={new Date(2025, 4, 1)}
    />
  );
}

考虑edge case:

  1. 非受控转受控:如果组件开始时是非受控的(没有传入value),后来父组件传入了value,此时组件会切换为受控模式。此时internalValue会被保留,但由于mergedValue优先使用propsValue,所以UI会正确显示父组件传入的value。
  2. 受控转非受控 :当组件从受控模式切换到非受控模式时,比如父组件之前传了value,后来不传了,这时候internalValue却不会更新,因为此时 propsValue变为undefined,mergedValue 会取 internalValue,而 internalValue 可能还是之前的value,这可能导致错误展示。

所以需要增加useEffect监听propsValue的变化,同步受控模式的外部值变化。

tsx 复制代码
function Calendar(props: CalendarProps) {

  const { value: propsValue, defaultValue, onChange, dates } = props;

  // 初始化内部状态(非受控模式使用)
  const [internalValue, setInternalValue] = useState<Date | undefined>(propsValue ?? defaultValue);

  // ===同步受控模式的外部值变化===
  useEffect(() => {
    if (propsValue !== undefined) {
      setInternalValue(propsValue);
    }
  }, [propsValue]);
  // =============================
  
  // 处理日期选择
  const changeValue = (date: Date) => {
    if (propsValue === undefined) {
      setInternalValue(date); // 仅非受控模式更新内部状态
    }
    onChange?.(date); 
  };

  // 最终显示值
  const mergedValue = propsValue ?? internalValue;

  return (
   ......
  );
}

但是这可能导致性能问题。

propsValue 变化时,父组件触发子组件渲染 → useEffectsetInternalValue 再次触发子组件渲染,导致 ​单次外部变化引发两次渲染 。所以将useEffect改成只有新旧值不同时才更新

tsx 复制代码
import { isEqual } from "lodash";

useEffect(() => {
  if (propsValue === undefined) return;
  setInternalValue((prev) => { // 值未变化时返回 prev 以跳过渲染
    return isEqual(prev, propsValue) ? prev : propsValue;
  });
}, [propsValue]);

知乎大佬提出用 Ref + forceUpdate() 来解决这个问题,可以参考:zhuanlan.zhihu.com/p/536322574

封装hook

但简单起见,直接用useEffect这种好理解的方案进行hook封装。

tsx 复制代码
import { isEqual } from "lodash";

type Options<T> = {
  value?: T;
  defaultValue?: T;
  onChange?: (data: T) => void;
};
export function useMergeState<T>(options: Options<T>) {
  const { defaultValue, value: propValue, onChange } = options || {};

  const [internalValue, setInternalValue] = useState<T | undefined>(propValue ?? defaultValue);

  // 同步受控模式的外部值变化
  useEffect(() => {
    if (propValue === undefined) return;
    setInternalValue((prev) => {
      // 值未变化时返回 prev 以跳过渲染
      return isEqual(prev, propValue) ? prev : propValue;
    });
  }, [propValue]);

  const updateValue = (newValue: T) => {
    if (propValue === undefined) {
      setInternalValue(newValue);
    }
    onChange?.(newValue);
  };
  const mergedValue = propValue ?? internalValue;
  return [mergedValue, updateValue] as const;
}

仅当值变化时触发onChange

但是现在的封装还存在一个问题,只要用户触发了点击,无论值是否存在变化,都会调用onChange。

需要做一下判断,只有新旧值不同时,才触发 onChange 和状态更新。在 updateValue 中,通过 mergedValueRef.current 获取最新值,使用 isEqual 进行深比较。

diff 复制代码
import { isEqual } from "lodash";

type Options<T> = {
  value?: T;
  defaultValue?: T;
  onChange?: (data: T) => void;
};

export function useMergeState<T>(options: Options<T>) {
  const { defaultValue, value: propValue, onChange } = options || {};

  const [internalValue, setInternalValue] = useState<T | undefined>(propValue ?? defaultValue);
  const mergedValue = propValue ?? internalValue;
+  const mergedValueRef = useRef<T | undefined>(mergedValue);
+  mergedValueRef.current = mergedValue; 

  // 同步受控模式的外部值变化
  useEffect(() => {
    if (propValue === undefined) return;
    setInternalValue((prev) => {
      // 值未变化时返回 prev 以跳过渲染
      return isEqual(prev, propValue) ? prev : propValue;
    });
  }, [propValue]);

  const updateValue = (newValue: T) => {
+   if (isEqual(mergedValueRef.current, newValue)) return;

    if (propValue === undefined) {
      setInternalValue(newValue);
    }
    onChange?.(newValue);
  };
  return [mergedValue, updateValue] as const;
}

最后在子组件调用useMergeState

tsx 复制代码
function Calendar(props: CalendarProps) {
  const { value, defaultValue, onChange, dates } = props;
  const [date, updateValue] = useMergeState({ value, defaultValue, onChange });
  return (
    <div>
      <div>当前选中:{date?.toLocaleDateString() || "未选择"}</div>
      {dates.map((d) => (
        <div key={d.toISOString()} onClick={() => updateValue(d)} >
          {d.toLocaleDateString()}
        </div>
      ))}
    </div>
  );
}

总结

在 React 开发中,​受控与非受控模式 是管理组件状态的核心设计思想,其本质区别在于数据控制权的归属

  • 受控模式 通过 valueonChange 将数据流完全交给父组件,适合需要精准控制状态(如实时表单验证、动态交互)的场景,确保数据流的可预测性。
  • 非受控模式 通过 defaultValueref 让组件自主管理状态,适用于性能敏感场景(如表单初始化后无需频繁更新)。

参考:

  1. juejin.cn/book/729408...
  2. zhuanlan.zhihu.com/p/536322574
相关推荐
巴巴_羊24 分钟前
yarn npm pnpm
前端·npm·node.js
chéng ௹2 小时前
vue2 上传pdf,拖拽盖章,下载图片
前端·css·pdf
嗯.~2 小时前
【无标题】如何在sheel中运行Spark
前端·javascript·c#
A_aspectJ4 小时前
【Bootstrap V4系列】学习入门教程之 组件-输入组(Input group)
前端·css·学习·bootstrap·html
兆。5 小时前
电子商城后台管理平台-Flask Vue项目开发
前端·vue.js·后端·python·flask
互联网搬砖老肖5 小时前
Web 架构之负载均衡全解析
前端·架构·负载均衡
sunbyte6 小时前
Tailwind CSS v4 主题化实践入门(自定义 Theme + 主题模式切换)✨
前端·javascript·css·tailwindcss
湛海不过深蓝7 小时前
【css】css统一设置变量
前端·css
程序员的世界你不懂7 小时前
tomcat6性能优化
前端·性能优化·firefox
爱吃巧克力的程序媛7 小时前
QML ProgressBar控件详解
前端