React + TypeScript 组件开发实战:从类型约束到状态管理

0 阅读5分钟

为什么大厂项目都在用 React + TypeScript?本文通过一个 NameEdit 组件实战,带你掌握 TS 在 React 中的核心用法。


一、为什么 React + TypeScript 是绝配?

React 本身就是用 TypeScript 写的,它内置了大量类型声明,比如 React.FCReactNodeReact.ChangeEvent 等。TypeScript 为 React 提供了:

  • 编译期类型检查 — 拼写错误、参数类型不匹配在写代码时就暴露
  • 智能提示 — IDE 自动补全 props,不用来回翻文档
  • 大型项目可维护性 — 类型即文档,新人接手代码成本大幅降低

二、核心概念:React.FCProps 类型约束

React.FC 是什么?

看 React 源码中的定义:

type FC<P = {}> = FunctionComponent<P>;
  • FCFunctionComponent 的别名,就是一个类型缩写
  • <P = {}> 是泛型,P 代表 props 的类型,默认值为空对象 {}
  • 如果你传入具体的 Props 类型,就会按你传的类型来约束组件

interface 还是 type

两者都可以声明类型,但在组件 Props 场景下,推荐用 interface

// 推荐:interface 更适合描述对象形状
interface Props {
  userName: string;
}

// 也可以:type 别名
type Props = {
  userName: string;
};

区别interface 支持声明合并(同名自动合并),type 不行。对于组件 Props 这种描述"对象需要满足哪些属性"的场景,interface 更语义化。

Hello 组件实战

// Hello.tsx
import * as React from 'react';

type Props = {
  userName: string;
};

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

export default HelloComponent;

这样,当父组件传 props 时,TS 会强制检查:

// ✅ 正确
<HelloComponent userName="张三" />

// ❌ 编译报错:缺少 userName
<HelloComponent />

// ❌ 编译报错:userName 应该是 string,不是 number
<HelloComponent userName={123} />

三、组件通信:单向数据流

React 的数据流是单向的:父组件持有状态,通过 props 传给子组件

三种常见模式

模式数据流向适用场景
直接传 props父 → 子只读展示
传 props + 回调父 ⇄ 子子组件通知父组件修改
子组件私有状态内部不需要共享的表单状态

模式一:父组件直接传 props

// App.tsx
const App = () => {
  const [name, setName] = React.useState("defaultUserName");

  return (
    <>
      <HelloComponent userName={name} />
    </>
  );
};

子组件只负责展示,不修改数据。这是最简单的单向数据流。

模式二:传回调函数实现双向通信

当子组件需要修改父组件的状态时,父组件把修改方法作为回调传给子组件:

// App2.tsx — 父组件
const App2: React.FC = () => {
  const [username, setUserName] = React.useState("initialName");

  return (
    <div>
      <HelloComponent userName={username} />
      <NameEditComponent
        initialUserName={username}
        onNameUpdated={setUserName}
      />
    </div>
  );
};
// NameEditComponent.tsx — 子组件
interface Props {
  initialUserName: string;
  onNameUpdated: (newName: string) => any;
}

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>
    </>
  );
};

关键设计思路

  1. 子组件用 editingName 管理自己的输入状态,不直接修改父组件的数据
  2. 只有点击按钮时,才通过 onNameUpdated 回调通知父组件
  3. 父组件收到新值后,用 setUserName 更新状态,再通过 props 传回给 HelloComponent

为什么子组件要有自己的状态,而不是直接 onChange 更新父组件?

如果每次按键都更新父组件,会导致:

  • 父组件频繁重渲染,影响性能
  • 子组件失去独立性,耦合度高
  • 用户可能想取消编辑,直接改父组件数据不可逆

模式三:复杂的自定义事件类型

如果子组件不需要自己管理状态,也可以直接把事件抛给父组件处理:

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>
  );
};

这种模式适用于简单表单,状态完全由父组件管理。


四、React.ChangeEvent — 最常用的事件类型

React 使用合成事件(SyntheticEvent),看起来像原生事件,但实际上是 React 封装的跨浏览器事件对象。

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setEditingName(e.target.value);
};
  • React.ChangeEvent — 泛型,指定事件发生在哪个元素上
  • <HTMLInputElement> — 告诉 TS 这是 input 元素的 change 事件
  • e.target.value — TS 知道 target 是 input,所以有 value 属性

常见事件类型速查

类型适用元素
React.ChangeEvent<HTMLInputElement>input、textarea 的 onChange
React.MouseEvent<HTMLButtonElement>button 的 onClick
React.KeyboardEvent<HTMLInputElement>input 的 onKeyDown
React.FormEvent<HTMLFormElement>form 的 onSubmit

五、useEffect — 组件挂载后的异步操作

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

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

  React.useEffect(() => {
    loadUsername();
  }, []); // 空依赖数组 = 只在组件挂载时执行一次

  return (
    <>
      <HelloComponent userName={name} />
    </>
  );
};

useEffect 的执行逻辑

  1. 组件先快速渲染,显示初始值 "defaultUserName"
  2. 挂载完成后,useEffect 触发 loadUsername
  3. 2 秒后,setNamesetEditingName 更新状态
  4. 组件重新渲染,显示 "name from async call"

为什么要把初始值设置好,而不是等数据返回再渲染?

这是 React 的设计哲学:组件第一要素是赶快显示出来,让用户觉得快。先展示占位内容,数据回来后再更新。如果等接口返回再渲染,用户看到的就是白屏。


六、完整数据流总结

                      ┌─────────────────┐
                      │    App2 (父组件)  │
                      │                 │
                      │  useState        │
                      │  username ──────►│──── props ────► HelloComponent
                      │  setUserName ◄───│── callback ─── NameEditComponent
                      └─────────────────┘
                              │
                    ┌─────────┴─────────┐
                    ▼                   ▼
           ┌──────────────┐   ┌────────────────────┐
           │ HelloComponent│   │ NameEditComponent   │
           │              │   │                    │
           │ 展示 username │   │ editingName (私有)  │
           │ 只读 props   │   │ onChange → setState │
           └──────────────┘   │ 点击按钮 → onNameUpdated│
                              └────────────────────┘

七、总结

概念要点
React.FC<Props>用泛型约束 props 类型
interface Props定义组件需要哪些属性
单向数据流父组件持有状态,通过 props 向下传递
回调通信子组件通过回调通知父组件修改状态
React.ChangeEvent<T>合成事件类型,指定事件来源元素
useEffect(fn, [])组件挂载后执行副作用(如请求数据)

核心原则:React + TypeScript 不只是"加类型",而是通过类型系统把组件之间的数据契约显式化。当一个新人接手你的代码时,他不需要看实现,只需要看一眼 interface Props,就知道这个组件需要什么、返回什么。


类型不是负担,是给未来的自己和你同事的说明书。