从“传事件”到“只传值”:React + TypeScript 组件 Props 设计的两次进化

从"传事件"到"只传值":React + TypeScript 组件 Props 设计的两次进化

摘要:本文通过一个简单的姓名编辑组件,展示 React + TypeScript 中 Props 类型定义的三次迭代------从暴露事件对象到封装内部状态,再到状态提升与职责分离,揭示组件设计如何逐步走向清晰与可维护。

📑 目录

  • 类型化的第一步:React.FC 与 Props 接口
  • interface 与 type:为什么选 interface?
  • 版本一:把整个事件对象传给父组件
  • 版本二:子组件内部管理私有状态
  • 版本三:状态提升 + 职责分离
  • 演进背后的设计原则
  • 互动讨论

类型化的第一步:React.FC 与 Props 接口

在 React + TypeScript 项目中,组件不仅仅是"返回 JSX 的函数",它还需要满足类型约束。React.FC(Function Component 的缩写)就是用来约束组件函数必须符合 React 函数组件规范的:

typescript

typescript 复制代码
import React from "react";

interface Props {
    username: string
}

const Hello: React.FC<Props> = (props) => {
    return <h2>Hello {props.username}</h2>;
};

React.FC 做了两件事:第一,它约束了组件的返回值必须是 ReactNode(即 JSX);第二,它通过泛型 <Props> 约束了 props 的类型。如果父组件没有传递 username,或者传递的值不是字符串,TypeScript 都会报错。

这里隐含了一个重要机制:React.FC<P = {}> = FunctionComponent<P> 是 React 源码中的类型定义。FunctionComponent 是函数组件类的声明,返回值必须是 ReactElementtype 是 TypeScript 的类型别名,把 FC 作为 FunctionComponent 的简写。P = {} 是默认值------如果没有传泛型参数,就表示没有 props 约束。

interface 与 type:为什么选 interface?

在 TypeScript 中,声明 Props 类型有两种方式:interfacetype。它们在功能上几乎等价,但 React 社区更倾向于使用 interface

typescript

go 复制代码
// ✅ 使用 interface
interface Props {
    username: string
}

// 也可以使用 type
type Props = {
    username: string
}

为什么选 interface?原因有二:

第一,语义匹配。 组件需要"满足"props 中的属性和方法,这恰好对应着接口(Interface)的概念------父组件必须传递这些属性,且数据类型必须符合声明。

第二,扩展性更好。 interface 支持同名声明自动合并扩展,而 type 不支持:

typescript

typescript 复制代码
// interface 可以同名扩展
interface Props {
    username: string
}
interface Props {
    age?: number
}
// 最终 Props = { username: string; age?: number }

// type 不支持同名声明
type Props = { username: string }
type Props = { age?: number }  // ❌ 报错:重复声明

type 更常被用来给基础类型取别名,比如 type UserId = string,目的是为类型创造语义,而非描述一个数据结构。因此在 Props 声明中,interface 是更自然的选择。

版本一:把整个事件对象传给父组件

第一个版本的 NameEditComponent 展示了"过度暴露"的设计:

typescript

typescript 复制代码
interface Props {
    username: string;
    onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

const NameEditComponent: React.FC<Props> = (props) => {
    return (
        <>
            <label>Update name</label>
            <input value={props.username} onChange={props.onChange} />
        </>
    );
};

父组件中使用时,需要处理 React.ChangeEvent<HTMLInputElement>

typescript

ini 复制代码
// 父组件中
const setUsernameState = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUsername(e.target.value);
};

<NameEditComponent username={username} onChange={setUsernameState} />

这种设计的问题:

  • 父组件需要处理 React 合成事件,必须理解 React.ChangeEvent<HTMLInputElement> 的用法
  • 子组件把自己的内部细节(事件对象)暴露给了父组件
  • 父组件被迫"太懂"子组件的实现方式,违反了封装原则
  • 两处代码都要做同样的类型约束,代码冗余

这种写法虽然符合单向数据流------父组件持有状态,通过 props 传递给子组件------但父组件的可读性和简洁性受到了影响。父组件的本来使命是"持有和修改数据状态,给子组件们共享",如果它还要处理 input 的事件对象,职责就不纯粹了。

版本二:子组件内部管理私有状态

第二次迭代把复杂度收回到子组件内部:

typescript

typescript 复制代码
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 (
        <div>
            <label>Update name:</label>
            <input value={editingName} onChange={onChange} />
            <button onClick={onNameSubmit}>Change</button>
        </div>
    );
};

父组件变得非常简洁:

typescript

ini 复制代码
<NameEditComponent 
    initialUserName={username} 
    OnNameUpdated={setUsername} 
/>

这次改进的核心变化:

  • 子组件用 useState 创建了自己的私有状态 editingName
  • React.ChangeEvent 被完全封装在子组件内部,父组件不再需要知道
  • 父组件的 props 从"传事件对象"变成了"传初始值 + 传回调"
  • 父组件只需要处理字符串,不需要处理事件对象

这种写法让父组件的逻辑处理变得非常简洁。子组件自己打理输入框的 onChange,只在用户点击"Change"按钮时才把最终值提交给父组件。组件的私有状态只服务于组件内部,不需要与父组件共享。

版本三:状态提升 + 职责分离

第三次迭代进一步优化,把状态"提升"回父组件,但保留了事件处理的封装:

typescript

typescript 复制代码
interface Props {
    editingName: string;
    onEditingNameUpdated: (newEditingName: string) => void;
    onNameUpdated: () => void;
    disabled: boolean;
}

const NameEditComponent2: React.FC<Props> = (props) => {
    const { editingName, onEditingNameUpdated, onNameUpdated, disabled } = props;

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

    const onNameSubmit = () => {
        onNameUpdated();
    };

    return (
        <div>
            <label>Update name:</label>
            <input value={editingName} onChange={onChange} />
            <button onClick={onNameSubmit} disabled={disabled}>Change</button>
        </div>
    );
};

父组件中:

typescript

ini 复制代码
const [name, setName] = useState<string>('defaultName');
const [editingName, setEditingName] = useState<string>('defaultName');

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

<NameEditComponent2 
    editingName={editingName}
    onEditingNameUpdated={setEditingName}
    onNameUpdated={setUsernameState}
    disabled={editingName === "" || editingName === name}
/>

这次迭代的关键变化:

  • 子组件不再持有私有状态 editingName,所有数据都来自父组件
  • 子组件仍然封装了 React.ChangeEvent 的处理
  • 子组件职责更单一:只负责展示和交互,不管理任何数据状态
  • 父组件管理了 name(已确认的名称)和 editingName(正在编辑的名称)两个状态
  • disabled 逻辑由父组件计算后传入,子组件完全不需要知道"什么时候禁用按钮"

这个版本体现了 React 的一个核心公式:UI = fn(props) 。子组件职责非常单一,只负责显示,数据状态根本不管,只需要处理渲染相关的逻辑。无论从性能层面(子组件不持有状态,不会触发自己的重新渲染)还是代码规范性层面,都是最优解。

演进背后的设计原则

三个版本的递进,体现了几个重要的设计思想:

1. 封装变化,隐藏细节

版本一的 React.ChangeEvent 是"实现细节",本不该暴露给父组件。版本二和版本三将其封装在子组件内部,父组件只需要处理字符串。好的组件设计,应该让调用者"少操心、少写代码"。

2. 状态管理的职责归属

版本 状态归属 特点
版本一 父组件管理,但子组件暴露事件对象 父组件承担了过多细节
版本二 子组件管理私有状态 父组件简洁,但状态分散
版本三 父组件统一管理状态 状态单一来源,子组件纯净

版本三的"状态提升"是 React 官方推荐的高级模式------当多个组件需要共享同一份数据时,把状态提升到它们的共同父组件中。虽然这增加了父组件的代码量,但换来了数据流的清晰可控。

3. 单一职责原则

版本三的子组件真正做到了"只负责一件事":它接收数据(props),展示界面,响应用户操作后通过回调通知父组件。它不管数据从哪里来、怎么变化、什么时候该禁用------这些都由父组件决定。子组件越"笨",越容易被复用和维护。

4. 类型约束的合理位置

版本一的类型约束分散在父子两端;版本二和版本三把复杂的泛型约束(React.ChangeEvent<HTMLInputElement>)收敛到子组件内部。类型约束应该放在"需要约束的地方",而不是"所有地方"。

互动讨论

💬 为什么版本三要把 editingName 提升到父组件,而不是留在子组件?

因为父组件需要根据 editingNamename 的对比来决定按钮是否禁用(disabled={editingName === "" || editingName === name})。如果 editingName 留在子组件,父组件就无法获取这个信息来计算禁用状态。状态提升是为了"让需要这份数据的组件都能访问到它"。

💬 React.FC 中的泛型 <Props> 是什么?

泛型是对函数内部的数据类型进行约束。React.FC<Props> 中的 Props 传给 React 的类型定义,要求父组件传入的 props 必须符合 Props 接口的约束。如果不传泛型参数(React.FC),就表示没有 props 约束。

💬 typeinterface 到底怎么选?

Props 声明用 interface,因为它语义匹配(组件"满足"接口),扩展性更好(同名声明合并)。type 适合给基础类型起别名(如 type UserId = string)或定义联合类型(如 type Status = 'pending' | 'success')。两者各司其职。

💬 版本二的"私有状态"和版本三的"父组件状态"哪个性能更好?

版本三的性能更好。版本二的子组件持有私有状态,每次输入都会触发子组件自身的重新渲染;版本三的状态在父组件中,输入触发父组件重新渲染,父组件重新渲染时会同时更新所有子组件。虽然两者都是响应式更新,但版本三的数据流更可控、状态单一来源,调试更容易。

💬 如果子组件很复杂,所有状态都放父组件会不会让父组件变得臃肿?

如果父组件过于臃肿,说明组件拆分的粒度不够细。可以考虑使用 useReducer 或状态管理库(如 Zustand、Redux)来管理复杂状态。关键原则是:数据流要清晰,状态要可预测,而不是单纯追求"状态放哪里"的正确性。

相关推荐
Seven_Ting11 小时前
React-Hooks笔记
前端·笔记·react.js
Revolution611 天前
React 组件重新渲染时,到底重新执行了什么
前端·react.js·面试
林焱_RPAAI1 天前
影刀RPA技术深度:CSS选择器高级实战指南——伪类属性选择器与性能对比完全解析
vue.js·react.js
名字还没想好☜1 天前
React 受控输入框光标跳到末尾:格式化输入时的 selection 丢失 bug 与修复
前端·javascript·react.js·bug·react·next.js
10share1 天前
React 新一代样式隔离方案 —— 编译时、零运行时、原生写法
前端·react.js
光影少年1 天前
RN 的EventEmitter 双向通信
前端·react native·react.js
禅思院1 天前
流式 Markdown 渲染完全指南【引子】
前端·架构·前端框架
GuWenyue2 天前
90%前端写React+TS都踩坑!从组件类型、单向数据流到本地存储完整实战
前端·react.js
Dr_哈哈2 天前
从一个 Vue 项目到一套工程体系:Bun、Monorepo、Turbo 与 Docker 到底在解决什么?
前端工程化