🚩 React + TypeScript 的 Props 通信,我从"把事件对象传给父组件"进化到了"只传值"

18 阅读8分钟

写在前面:今天学了一个非常"实用"但又有点抽象的知识点——React + TypeScript 中父子组件如何优雅地通信。老师拿了一个"修改用户名"的小功能,前后写了三个版本。从最原始的"把整个事件对象传给父组件",到"子组件自己管理输入状态",再到"状态提升到父组件但子组件无状态"——三次迭代,我一开始觉得"不都一样吗?"看完之后才发现,差距太大了。TypeScript 的强类型约束 + React 的单向数据流,写出来的代码可读性和健壮性完全不是一个量级。


一、TypeScript 给 React 带来了什么?

1.1 为什么要用 TypeScript?

老师说:

"React + TS 非常适合企业级开发。TS 提供了类型约束、静态编译、大型语言的丰富功能。"

纯 JS 写 React:

const Hello = (props) => {
    return <h2>Hello {props.userName}</h2>
};

用 TS 写 React:

import * as React from 'react';

interface Props {
    userName: string;
}

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

区别在哪?

对比纯 JSTypeScript
props 传错了运行时才能发现编译时直接报错
少传了属性显示 undefined❌ 编译不过
类型约束interface Props 强制约束
代码提示没有编辑器自动提示 props 字段

React.FC<Props> 的含义:

老师说:

"React.FC——React 函数组件类型。() => ReactNode。React 本身就是用 TS 写的,ReactNodeReact.FC 都是内置的类型声明。"

  • FC = FunctionComponent(函数组件)。
  • <Props> = 泛型参数,告诉 TypeScript "这个组件的 props 必须满足 Props 接口的定义"。

二、React.FC 和泛型

2.1 源码层面理解

老师说:

"type FC<P = {}> = FunctionComponent<P>——React 源码。FunctionComponent 函数组件类的声明,返回一定是 ReactElementtype 类型别名,FC 简短一些。type FC<P = {}>——默认值为 {},如果你传呢?用传递的类型参数来约束。"

// 不传泛型:props 默认是空对象
const Hello: React.FC = (props) => { };

// 传泛型:props 必须满足 Props 接口
interface Props {
    userName: string;
}
const Hello: React.FC<Props> = (props) => {
    return <h2>Hello {props.userName}</h2>;
};

如果父组件这么调用:

// ❌ 错误:Props 要求 userName 是 string,但没传
<Hello />

// ✅ 正确:
<Hello userName="张三" />

// ❌ 错误:Props 没有 age 属性
<Hello userName="张三" age={18} />

TypeScript 在编译时就会拦截这些错误——不会等到运行时才发现。


三、版本一:把事件对象传给父组件(不优雅)

3.1 代码

NameEditComponent.tsx 中注释的部分:

interface Props {
    userName: string;
    onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

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

父组件 App 中:

const App = () => {
    const [username, setUsername] = React.useState('initialName');

    const setUsernameState = (event: React.ChangeEvent<HTMLInputElement>) => {
        setUsername(event.target.value);
    };

    return (
        <NameEditComponent
            userName={username}
            onChange={setUsernameState}
        />
    );
};

3.2 问题出在哪?

老师说:

"把子组件的 event 对象传给父组件,导致两边都要 React.ChangeEvent<HTMLInputElement>——单向数据流、父子组件通信的 state 交给父组件,props 传给子组件们,应用状态正确的前提(法律?)。"

这个版本有两个问题:

问题说明
父组件被事件对象污染父组件需要知道 ChangeEvent<HTMLInputElement> 这个类型
耦合性高如果输入框换成别的组件(比如下拉框),父组件也要改

父组件本应只关心"值",但现在它不得不关心"事件对象"。


四、版本二:子组件自己管状态(好一些)

4.1 代码

NameEditComponent2.tsx

interface Props {
    initialUserName: string;
    onNameUpdated: (newName: string) => void;
}

const NameEditComponent: React.FC<Props> = (props) => {
    // 自有状态——子组件自己管输入
    const [editingName, setEditingName] = React.useState(
        props.initialUserName
    );

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

    const onNameSubmit = () => {
        props.onNameUpdated(editingName);  // 只传值,不传事件对象
    };

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

这个版本的变化:

变化之前现在
子组件无状态,只接收 props有自己的 editingName 状态
通信方式传事件对象点按钮时才传值 onNameUpdated(editingName)
父组件接口<input> 的事件类型只接收 string 值

父组件现在只需要:

<NameEditComponent
    initialUserName={username}
    onNameUpdated={setUsername}
/>

父组件不需要知道 ChangeEvent 是什么,它只接受一个 string 子组件内部的输入逻辑完全私有化。

老师说:

"子组件中添加了私有状态 editingName,onChange 自己修改。提交父组件时只需要给值就好。"


五、版本三:状态提升 + 无状态子组件(性能最优)

5.1 为什么还要升级?

老师说:

"将私有状态提升到父组件,通过 props 传过来,onChange 修改 editingName。子组件没有状态,性能会更好,就负责展示。UI = fn(props)——子组件职责非常单一,就是负责显示。"

版本二的问题:子组件自己有状态,但父组件有时也需要知道"当前输入了什么"——比如想禁用"提交"按钮(当输入为空或和原来一样时)。

5.2 最终版代码

NameEditComponent.tsx(非注释版本):

interface Props {
    editingName: string;
    onNameUpdated: () => void;
    onEditingNameUpdated: (editingName: string) => void;
    disabled: boolean;
}

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

    const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        onEditingNameUpdated(e.target.value); // 报告父组件
    };

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

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

父组件 App.tsx

const App = () => {
    const [name, setName] = React.useState<string>('defaultUserName');
    const [editingName, setEditingName] = React.useState('defaultUserName');

    const loadUsername = () => {
        setTimeout(() => {
            setName('name from async call');
            setEditingName('name from async call');
        }, 2000);
    };

    React.useEffect(() => {
        loadUsername(); // 挂载后异步加载用户名
    }, []);

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

    return (
        <>
            名字:{name}
            <HelloComponent userName={editingName} />
            <NameEditComponent
                editingName={editingName}
                onNameUpdated={setUserNameState}
                onEditingNameUpdated={setEditingName}
                disabled={editingName === "" || editingName === name}
            />
        </>
    );
};

5.3 这个版本做了什么?

功能谁管的怎么实现的
显示当前名字App(父组件)HelloComponent 展示 name
输入框的值App 通过 Props 传的editingName 是父组件的状态
输入框变化时子组件通过事件上报onEditingNameUpdated(e.target.value)
提交时子组件通知父组件onNameUpdated()
按钮禁用父组件算好传下来disabled={editingName === "" || editingName === name}

子组件完全没有自己的状态——它所有的数据都是从 Props 来的,所有的操作都是通过事件上报的。

老师说:

"UI = fn(props)——子组件没有状态,性能会更好,就负责展示。子组件职责非常单一,就是负责显示。"

这个模式的好处:

  • 子组件可以复用——同样的输入框组件可以用在任何需要编辑文本的地方。
  • 性能更好——没有自己的状态,不会触发不必要的重新渲染。
  • 数据流清晰——所有状态都在父组件,修改路径单一。

六、useEffect:异步加载初始数据

6.1 为什么需要 useEffect?

看 App.tsx 中的代码:

React.useEffect(() => {
    loadUsername(); // 组件挂载后,异步加载用户名
}, []);

老师说:

"useEffect——副作用。在组件挂载(mounted)后,再去请求接口,拿到数据,响应式更新。满足组件即刻挂载,快(第一步),更新状态(第二步)。"

useEffect 让组件先快速显示出来(第一步),再去后台加载数据(第二步)。 这是 React 性能优化的核心思想——用户不用等数据加载完才看到页面。

[] 作为依赖数组,表示"只在组件挂载时执行一次",不会在每次更新时重复执行。


七、三个版本的进化对比

版本子组件状态通信方式父组件复杂度适用场景
V1无状态传事件对象高(要处理 ChangeEvent)快速实现,不推荐
V2有私有状态提交时传值低(只接收 string)简单表单输入
V3无状态所有事件上报中(状态在父组件)需要父组件控制的复杂表单

老师说:

"版本的变迁:① 把子组件的 event 对象传给父组件 → 影响父组件的可读性。② 子组件中添加私有状态 editingName,提交父组件时只需要给值。③ 将私有状态提升到父组件,子组件没有状态,性能会更好,就负责展示。UI = fn(props)。"


八、总结:React + TS 的最佳实践

概念说明
React.FC函数组件类型,泛型参数约束 props
interface Props定义组件需要的属性和方法
单向数据流父组件持有状态,通过 Props 传给子组件
自定义事件子组件通过调用父组件传的函数来"上报"
状态提升多个子组件共享的状态放在共同父组件
无状态子组件UI = fn(props),性能更好,更易复用
React.ChangeEventReact 合成事件的类型,泛型指定元素类型
useEffect副作用,挂载后异步加载数据

React + TypeScript 的核心原则:用 interface 约束数据,用单向数据流保证可预测性,用状态提升实现共享。Version 3 是最佳实践——子组件无状态,父组件统一管理,数据流清晰透明。


写在最后

今天这个知识点有点抽象——三个版本的代码看起来都能跑,但可维护性天差地别。以前我写 React 组件,event 对象到处传,从来没考虑过"父组件要不要知道 ChangeEvent 是什么"。现在知道了:父组件应该只关心"值",不应该关心"事件"。

下次面试官问你:"React 父子组件怎么传事件?"

你可以淡定地说:

"最优雅的方式是遵循'子组件无状态,所有操作通过自定义事件上报'的原则。子组件通过 Props 接收父组件传下来的数据和事件处理函数,内部不管理状态(纯展示组件)。输入变化时子组件调用 onEditingNameUpdated(value) 把值上报,提交时调用 onNameUpdated()。父组件统一管理状态,通过 disabled 等属性控制子组件的行为。这样父组件不需要关心 ChangeEvent 的类型,子组件完全可复用。加上 TypeScript 的 interface Props 约束,数据流清晰、类型安全。如果需要在组件挂载后异步加载数据,用 useEffect 实现,让组件先快速显示再更新数据。"

然后看着面试官满意的表情,心里默念:这波,又稳了。


本文所有代码示例均来自课堂学习资料,真实可运行。