React 组件设计的三个层次:从类型约束到状态归属,再到纯展示

React 组件设计的三个层次:从类型约束到状态归属,再到纯展示

用好 TypeScript,不只是写类型,更是设计组件边界。读这一篇就够了。


写在前面:一个组件,两种写法,天壤之别

假设你要实现一个用户名编辑功能:页面上显示当前用户名,有一个输入框可以编辑,点"Update"按钮提交。

功能完全相同,但代码有两种写法。

V1:父组件掌管一切

tsx 复制代码
// App.tsx
const App = () => {
  const [name, setName] = useState('defaultUserName');
  const [editingName, setEditingName] = useState('defaultUserName');

  const setUsernameState = () => {
    setName(editingName);
  };

  return (
    <>
      <HelloComponent userName={editingName} />
      <NameEditingComponent
        editingName={editingName}
        onNameUpdated={setUsernameState}
        onEditingNameUpdated={setEditingName}
        disabled={editingName === '' || editingName === name}
      />
    </>
  );
};
tsx 复制代码
// NameEditingComponent.tsx
interface Props {
  editingName: string;
  onNameUpdated: () => void;
  onEditingNameUpdated: (newName: string) => void;
  disabled: boolean;
}

const NameEditingComponent: React.FC<Props> = (props) => {
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    props.onEditingNameUpdated(e.target.value);
  };
  const onNameSubmit = () => {
    props.onNameUpdated();
  };
  return (
    <>
      <input value={props.editingName} onChange={onChange} />
      <button disabled={props.disabled} onClick={onNameSubmit}>Update</button>
    </>
  );
};

V2:子组件自己管自己

tsx 复制代码
// App2.tsx
const App = () => {
  const [username, setUserName] = useState('initialName');
  return (
    <>
      <Hello userName={username} />
      <NameEditComponent
        initialUsername={username}
        onNameUpdated={setUserName}
      />
    </>
  );
};
tsx 复制代码
// NameEditComponent.tsx
interface Props {
  initialUsername: string;
  onNameUpdated: (newName: string) => void;
}

const NameEditComponent: React.FC<Props> = (props) => {
  const [editingName, setEditingName] = useState(props.initialUsername);

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEditingName(e.target.value);
  };

  const onNameSubmit = () => {
    props.onNameUpdated(editingName);
  };

  return (
    <>
      <input value={editingName} onChange={onChange} />
      <button onClick={onNameSubmit}>Update</button>
    </>
  );
};

功能一模一样,代码天差地别。

V1 的 NameEditingComponent 有 4 个 props,0 个自有 state,是一个纯粹的"传声筒";V2 只有 2 个 props,1 个自有 state,是一个能独立工作的单元。

问题的核心是:状态该归谁管?

这不仅仅是一个关于 React 的问题,更是一个关于 TypeScript 组件设计的问题。因为 TypeScript 的类型约束,在组件设计之初就划定了边界。

在深入探讨状态归属之前,我们需要先理解 TypeScript 在 React 组件中到底约束了什么。为此,我们得从组件最基础的单元------React.FC------说起。


第一层:类型约束 ------ React.FC 与泛型

React.FC 的本质

React.FCFunctionComponent 的简写,它的类型定义简化后如下:

tsx 复制代码
type FC<P = {}> = FunctionComponent<P>;

interface FunctionComponent<P = {}> {
    (props: P): ReactElement<any, any> | null;
}

它揭示了 React 组件的本质:一个函数,输入是 props,输出是 ReactNode

React.FC 有两个实实在在的作用:

  1. 调用时自动检查:在 JSX 中使用组件时,传入的 props 不符合类型定义,TS 会立即报错。
  2. 编码时智能提示 :在组件内部使用 props 时,编辑器会给出自动补全。

注意 :React 18 移除了 React.FC 中隐式的 children 属性。如果你需要 children,必须显式声明。

泛型:给类型传参

React.FC 本身是不完整的。它通过泛型 <Props> 来接收具体的 props 类型。这就像函数的参数一样:

  • 函数 f(x) 给值传参。
  • 泛型 T<X> 给类型传参。

React.FC<Props> 里的 Props 必须是一个对象类型,因为 React 的 props 永远是一个对象。这个泛型参数同时做了两件事:

  • 对外约束:调用者必须传入正确的 props。
  • 对内约束 :组件内部 props 对象的类型是确定的。
Props 的四种定义方式
  1. interface:最常用,适合需要扩展、导出的复杂类型。
  2. type 别名 :效果与 interface 几乎等同,但不能重复定义。
  3. 对象字面量:适用于只有一两个 props 的简单场景。
  4. 直接标注在参数上 :社区主流写法,不依赖 React.FC

第二层:状态归属 ------ State 该放哪儿?

回到开头的例子。为什么 V1 和 V2 的差异如此巨大?关键在于状态归属(State Ownership) 的原则。

状态归属原则:state 应该放在离使用它最近的组件里。如果一个状态只影响一个组件的行为,它就应该属于那个组件。

V1 的设计缺陷
  1. 父组件知道太多 :父组件需要计算 disabled 逻辑,而这个逻辑本该是子组件自己的事。这让父组件变得臃肿,并和子组件的内部逻辑耦合。
  2. 子组件没有自主权onNameUpdated 是一个无参函数,意味着子组件无法决定提交什么值。它被父组件的接口绑死了,难以复用。
  3. 接口冗余editingNameonEditingNameUpdated 暴露了子组件的内部实现细节,即"编辑过程是逐字变化的"。
V2 的改进

V2 的设计遵循了状态归属原则,将 editingName 下沉到 NameEditComponent 内部。这样:

  • 父组件更干净 :它只需要管理最终的用户名 username
  • 子组件更独立:它是一个完整的、可复用的单元,拥有自己的状态和逻辑。
  • 接口更精简Props 只包含"初始值"和"结果回调",清晰明了。
何时提升 State,何时下沉?
场景 State 放哪 例子
State 只被一个组件使用 下沉到那个组件内部 editingNameNameEditComponent
State 被多个兄弟组件共享 提升到最近的公共祖先 usernameApp(被 HelloNameEditComponent 共享)
State 需要全局持久化 提升到 Context / Store 用户登录态 → AuthContext

第三层:演进之路 ------ 从事件透传到 UI = fn(props)

开头的 V1 和 V2 代表了两种典型的组件设计模式。但在一个更完整的实战场景中,组件的演进通常有三个阶段。理解这个演进过程,能帮你在不同的场景下做出最合适的设计选择。

阶段一:事件对象透传(原始版)

最直接的写法,子组件充当一个"透明管道",将 DOM 事件(如 React.ChangeEvent)原封不动地抛给父组件。

tsx 复制代码
// 子组件
interface Props {
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
// 父组件
const setUsernameState = (e: React.ChangeEvent<HTMLInputElement>) => {
  setUserName(e.target.value);
};

问题 :父组件被迫处理 DOM 细节(event.target.value),React.ChangeEvent 这个复杂类型在父子组件间耦合,导致两者都变得不纯粹。

阶段二:私有状态封装(V2 模式)

子组件拥有自己的 editingName 状态,将 React.ChangeEvent 锁在内部,对外只暴露干净的 string 类型。

tsx 复制代码
// 子组件
interface Props {
  initialUsername: string;
  onNameUpdated: (newName: string) => void;
}

优点 :父组件无需关心编辑过程,子组件封装了内部逻辑,成为一个独立的单元。 适用场景:输入过程不需要被外部感知,例如一个"标签编辑器"或"备注输入框"。

阶段三:受控组件(UI = fn(props))

最激进的设计:子组件完全没有任何 useState,所有状态全部提升到父组件。子组件成为一个"纯傀儡",完全由父组件控制。

tsx 复制代码
// 子组件
interface Props {
  editingName: string;
  onNameUpdated: () => void;
  onEditingNameUpdated: (newName: string) => void;
  disabled: boolean;
}
// 父组件
const [editingName, setEditingName] = useState('');

优点 :父组件拥有绝对控制权,可以随时修改、校验、响应子组件的输入。子组件职责单一,只负责展示。 适用场景:需要对输入做复杂逻辑处理,或需要多个组件共享同一份编辑状态时。

版本对照总表
版本 子组件状态 数据流方向 子组件职责
V1: 事件透传 子 -> 父(透传事件对象) 透明管道
V2: 私有状态 有 (useState) 子 -> 父(提交最终值) 独立单元
V3: 受控组件 父 <-> 子(双向受控) 纯展示 (UI = fn(props))

TypeScript 与组件设计的"化学反应"

在 TypeScript 的世界里,状态归属和组件演进从来不是孤立的。TS 的类型约束,是驱动你做出正确设计决策的"催化剂"。

1. 类型即边界

当你为一个组件编写 interface Props 时,你实际上是在定义它和外部世界的"合同"。这份合同让你在设计之初就思考:"这个组件需要什么?它要对外暴露什么?"

  • 如果你发现 Props 里充满了 onXxxChangeisXxxDisabled 这样的细节,这通常意味着你在让父组件过度参与子组件的内部事务,触犯了状态归属原则。
  • 一个干净、自洽的 Props 接口,应当只包含"数据输入"和"事件输出",就像 V2 的 initialUsernameonNameUpdated 那样。
2. 类型即约束

React.FC<P> 的泛型约束,不仅约束了 props 的类型,也约束了组件的形态。它强制组件必须是一个接受对象参数、返回 ReactElement 的函数。

当你尝试在 V2 的 NameEditComponent 里管理自己的 editingName 时,TS 不会阻止你。但当你写出 V1 那种 4 个 props 的接口时,TS 也不会报错。它给你的是"工具"而非"规则" 。但这恰恰是它的力量所在:它让你在编写代码时,每敲下一个类型,都在进行一次设计上的思考。正如第二篇文章所说:"TypeScript 的接口约束让你'设计先行'"。

3. 类型与重构

在 V3 的受控组件模式中,状态的提升和数据的双向流动,对类型的准确性要求更高。父组件管理着 editingNamedisabled,子组件完全受控。这种模式下,Props 的定义就是父组件和子组件之间的"数据协议"。任何一方的更改,都会导致另一端出现类型错误,从而保证了两端的同步。


总结

React.FC 的类型约束,到状态归属的设计原则,再到 UI = fn(props) 的终极形态,这是一个渐进式的设计演化过程。

  1. 理解类型 :用好 React.FC 和泛型,让 TypeScript 帮你划清组件边界。
  2. 合理归属:遵循"状态归属原则",把 state 放在离使用者最近的地方。
  3. 演进架构:根据场景选择设计模式------从事件透传,到私有状态封装,再到完全受控的纯展示组件。

下次你写一个新组件时,不妨先停下来想一想:这个 state 属于谁?这个 Props 会不会太多?我是在设计一个独立的"单元",还是一个纯粹的"展示层"?

你的选择,将决定你的代码是变得清晰、可维护,还是变得臃肿、难以理解。


一个行为改变 :下次你准备 useState 的时候,先问自己 3 秒钟------"这个 state,真的需要放在当前组件吗?还是放在子组件更合适?"

一个开放问题:你遇到过最臃肿的组件里有多少个 props?是怎么拆的?欢迎在评论区分享你的重构经验。

相关推荐
万敏2 小时前
Vue3 全栈实战第四周:watch、nextTick、性能优化与 keep-alive 实战记录
vue.js·node.js·全栈
A24207349305 小时前
Vue3 + TypeScript:后端数据在表格内渲染后进行增删改的完整实现步骤
前端·javascript·typescript
水獭比特5 小时前
AI 视频生成不是一次 HTTP 请求:先把长任务状态机补齐
人工智能·typescript
kyriewen20 小时前
别再这样写TypeScript了——Code Review中最常见的8个反模式
前端·javascript·typescript
A24207349301 天前
Vue 3 + TypeScript:核心优势与开发注意事项详解
vue.js·ubuntu·typescript
用户0934077735141 天前
HarmonyOS WPS Open SDK 实践:enableEdit 怎么映射预览与编辑
typescript
鱼饼Y1 天前
AI时代,使用大模型学习LangChain (4)——LangGraph
typescript·langchain
带娃的IT创业者1 天前
PostHog 的 TypeScript 原生移植:一场开源产品工程化的自我革命
javascript·typescript·开源·开源软件·posthog·技术重构
听风3472 天前
Pi Agent Harness 源码解析:TypeScript 实现的模块化 Coding Agent
typescript·pi agent