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
相关推荐
拖孩5 分钟前
微信群太多,管理麻烦?那试试接入AI助手吧~
前端·后端·微信
乌兰麦朵22 分钟前
Vue吹的颅内高潮,全靠选择性失明和 .value 的PUA!
前端·vue.js
Code季风22 分钟前
Gin Web 层集成 Viper 配置文件和 Zap 日志文件指南(下)
前端·微服务·架构·go·gin
蓝倾22 分钟前
如何使用API接口实现淘宝商品上下架监控?
前端·后端·api
舂春儿24 分钟前
如何快速统计项目代码行数
前端·后端
毛茸茸24 分钟前
⚡ 从浏览器到编辑器只需1秒,这个React定位工具改变了我的开发方式
前端
Pedantic25 分钟前
我们什么时候应该使用协议继承?——Swift 协议继承的应用与思
前端·后端
Software攻城狮26 分钟前
vite打包的简单配置
前端
Codebee26 分钟前
如何利用OneCode注解驱动,快速训练一个私有的AI代码助手
前端·后端·面试
流星稍逝26 分钟前
用vue3的写法结合uniapp在微信小程序中实现图片压缩、调整分辨率、做缩略图功能
前端·vue.js