吃透这 5 个核心点,你的 React+TS 代码直接上一个台阶

在现代前端企业级开发中,React + TypeScript 已经成为公认的主流技术栈。TypeScript 提供的静态类型约束、编译期错误检查、完整的类型系统,极大提升了大型项目的可维护性与团队协作效率,有效避免了大量运行时类型错误。

很多初学者刚上手 React + TS 时,容易在组件类型标注、事件类型、副作用处理等环节反复踩坑,甚至写出类型冗余、耦合度高的代码。本文将从基础组件定义出发,结合组件设计的演进过程,系统梳理 React + TS 开发的核心规范与最佳实践,所有内容均贴合实际开发场景。

一、函数组件的类型基石:React.FC 与 Props 定义

1.1 什么是 React.FC

React.FC 是 React 内置的函数组件类型,全称为 FunctionComponent,本质是一个泛型类型别名,约定了函数组件的入参结构与返回值类型。React 源码中的定义如下GitHub:

typescript

运行

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

interface FunctionComponent<P = {}> {
  (props: PropsWithChildren<P>, context?: any): ReactElement | null;
  propTypes?: WeakValidationMap<P>;
  defaultProps?: Partial<P>;
  displayName?: string;
}

通过泛型参数 <P>,我们可以将自定义的 Props 类型注入组件,让 TypeScript 完整校验 props 的属性名、类型与必填项。

1.2 Props 的两种定义方式

在 TypeScript 中,定义组件 Props 有两种主流方式,适用场景各有侧重:

  • interface(接口) :优先推荐,支持扩展、声明合并,最适合描述对象结构的 Props
  • type(类型别名) :更灵活,支持联合类型、交叉类型,适合复杂类型组合

两种写法示例:

typescript

运行

typescript 复制代码
// interface 写法
interface Props {
  username: string;
}

// type 写法
type Props = {
  username: string;
  age: number;
}

1.3 两种组件写法,禁止混用

很多初学者会犯重复标注的错误:既写 React.FC<Props>,又给 props 参数单独加类型注解。正确写法二选一即可:

**写法一:React.FC 泛型标注(推荐)**语义清晰,自动兼容 children、defaultProps 等属性:

tsx

typescript 复制代码
import * as React from 'react';

interface Props {
  username: string;
}

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

export default Hello;

写法二:直接给 props 参数标注类型更轻量,适合简单组件:

tsx

javascript 复制代码
const Hello = (props: Props) => {
  return <h2>Hello {props.username}</h2>
}

🚩 踩坑提醒:不要写成 const Hello: React.FC<Props> = (props: Props) => {...},属于双重类型标注,完全冗余。

二、事件系统:合成事件类型与组件回调的三层演进

React 基于 W3C 规范封装了一套跨浏览器兼容的合成事件(SyntheticEvent)系统,抹平了不同浏览器的 API 差异React。在 TypeScript 中,我们需要为事件对象标注准确的类型,而组件回调的设计也直接决定了代码的解耦程度。

2.1 事件类型命名口诀

React 合成事件的类型命名遵循统一规则:React.事件名Event<DOM元素类型>,可以总结为一句口诀:什么事件 + 什么标签

常见事件类型对应表:

表格

事件类型 适用场景 常用 DOM 元素
React.ChangeEvent<T> 表单内容变更 HTMLInputElement / HTMLSelectElement
React.MouseEvent<T> 鼠标点击、移动 HTMLButtonElement / HTMLDivElement
React.FormEvent<T> 表单提交 HTMLFormElement
React.KeyboardEvent<T> 键盘按键 HTMLInputElement

根据回调的用途,通常分为三类标注方式:

  1. 无参简单回调() => void,仅触发动作,不传递参数、不关心返回值
  2. 业务传值回调(value: 业务类型) => void,子组件向父组件传递业务数据(最佳实践)
  3. 原生事件回调(e: React.XXXEvent<HTMLXXXElement>) => void,需要访问事件对象本身时使用

2.2 组件回调的三层演进

我们以名称编辑组件为例,通过三个版本的迭代,理解组件解耦的核心思路。

版本 1:透传事件对象(不推荐,耦合度高)

最原始的写法是子组件直接把 input 的 change 事件透传给父组件,由父组件从事件对象中取值。

tsx

typescript 复制代码
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>
  )
}

问题:父组件必须感知 DOM 事件细节,依赖具体的元素类型。如果后续子组件将 input 替换为其他控件,父组件的类型定义必须同步修改,耦合度极高。

版本 2:子组件维护私有状态(推荐,内部自治)

优化思路:子组件自己维护编辑中的状态,只在提交时将最终业务值传递给父组件。父组件完全不用关心内部实现与事件类型。

tsx

typescript 复制代码
interface Props {
  initialUserName: string;
  onNameUpdated: (newName: string) => void;
}

const NameEditComponent: React.FC<Props> = (props) => {
  const [editing, setEditing] = React.useState(props.initialUserName);

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

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

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

这是经典的组件设计原则:事件处理留在子组件,对外只暴露业务数据。父组件只需要接收字符串类型的新名称,与具体 DOM 实现彻底解耦。

版本 3:状态提升,纯展示组件(适合多组件联动)

如果编辑状态需要被多个兄弟组件共享,就需要将状态提升到最近的公共父组件,子组件变为完全受控的纯展示组件。

tsx

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

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

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

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

  return (
    <>
      <label>Update name:</label>
      <input 
        type="text" 
        value={editingName}
        onChange={onChange}
      />
      <button onClick={onNameSubmit} disabled={disabled}>change</button>
    </>
  )
}

此时子组件没有自身状态,完全由父组件通过 props 驱动,逻辑更可控,适合复杂表单与多组件联动场景。

对应的父组件完整示例:

tsx

typescript 复制代码
import * as React from 'react';
import HelloComponent from './components/Hello'
import NameEditingComponent from './components/NameEditingComponent';

const App: React.FC = () => {
  const [name, setName] = React.useState<string>("default");
  const [editingName, setEditingName] = React.useState("default")

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

  React.useEffect(() => {
    loadUsername();
  }, [])

  return (
    <>
      名字:{name}
      <HelloComponent username={editingName} />
      <NameEditingComponent 
        editingName={editingName}
        onNameUpdated={setName}
        onEditingNameUpdated={setEditingName}
        disabled={editingName === "" || editingName === name}
      />
    </>
  )
}

export default App;

三、useEffect 副作用:生命周期、异步写法与内存清理

useEffect 是函数组件中处理副作用的核心 Hook,专门用于执行不能写在组件顶层的操作,比如接口请求、定时器、DOM 事件监听等,完整覆盖了类组件的生命周期能力。

3.1 四种执行时机

通过第二个参数「依赖数组」,可以精确控制副作用的执行时机:

  1. 仅挂载执行一次 :依赖数组为空 []对应类组件 componentDidMount,用于页面初始化、接口请求、全局事件绑定。

tsx

scss 复制代码
React.useEffect(() => {
  loadUsername();
}, []);
  1. 挂载 + 依赖项更新时执行 :依赖数组填写指定变量对应 componentDidMount + componentDidUpdate,依赖项变化时重新执行副作用。
  2. 挂载 + 任意状态更新都执行:不写第二个参数不推荐,极易引发死循环与性能问题,仅极少数特殊场景使用。
  3. 卸载前清理 :return 返回清理函数对应 componentWillUnmount,用于清除定时器、取消订阅、移除监听,避免内存泄漏。

3.2 清理函数与内存泄漏

持续运行的副作用如果不在组件卸载时清除,会造成内存泄漏。定时器是最典型的场景:

tsx

javascript 复制代码
React.useEffect(() => {
  const interval = setInterval(() => {
    console.log('interval is running');
  }, 2000);

  // 组件卸载前执行清理
  return () => {
    console.log('组件卸载,清理定时器');
    clearInterval(interval);
  }
}, []);

💡 核心原则:凡是会持续运行的副作用,都必须编写清理函数。定时器、防抖节流、全局事件监听、WebSocket 连接均在此列。

3.3 异步请求的正确写法

绝对不能直接给 useEffect 回调加 async。因为 async 函数永远返回 Promise,而 useEffect 只允许返回清理函数或 undefined,直接写 async 会导致清理机制失效,还会触发类型报错。

标准写法:在 useEffect 内部定义异步函数,再立即调用(IIFE 或具名函数均可):

tsx

scss 复制代码
React.useEffect(() => {
  // 内部定义异步函数
  const fetchData = async () => {
    const members = await getMembersCollection();
    setMemberCollection(members);
  }
  fetchData();
}, [])

也可以使用立即执行函数的简写形式:

tsx

scss 复制代码
React.useEffect(() => {
  (async () => {
    const members = await getMembersCollection();
    setMemberCollection(members);
  })()
}, [])

四、拓展:前端本地存储 localStorage

在前端开发中,本地存储是常用的轻量数据持久化方案,localStorage 是其中最常用的一种,核心特点:

  • 持久化存储,关闭浏览器后数据依然保留
  • 单域名存储容量约 5MB
  • 以键值对形式存储,值仅支持字符串

常用 API:

  • 存储localStorage.setItem(key, value),存储对象需用 JSON.stringify 转为字符串
  • 读取localStorage.getItem(key),返回值可能为 null,读取对象需用 JSON.parse 解析
  • 删除localStorage.removeItem(key)

示例:

typescript

运行

javascript 复制代码
// 存储对象
localStorage.setItem('userInfo', JSON.stringify({ name: '张三', id: 1 }));

// 读取对象,处理 null 边界
const raw = localStorage.getItem('userInfo');
const userInfo = raw ? JSON.parse(raw) : null;

五、核心设计思想:单向数据流

所有类型规范与组件设计,本质都遵循 React 的核心原则 ------单向数据流

  • 数据自上而下:父组件通过 props 将数据传递给子组件,子组件不能直接修改 props
  • 事件自下而上:子组件通过回调函数通知父组件修改状态,状态更新后再通过 props 下发

结合组件演进过程,可以总结出两条通用最佳实践:

  1. 优先让子组件自治,内部状态自行维护,对外只暴露业务回调,不传递原生事件对象
  2. 状态需要跨组件共享时,再提升到最近的公共父组件,避免过度状态提升

总结

React + TypeScript 的价值,从来不是为了写类型而写类型,而是通过静态类型约束,在编译期暴露问题、提升代码可维护性、降低大型项目的协作成本。

本文覆盖的核心内容,可以归纳为三条开发规范:

  1. 组件类型 :Props 优先使用 interface,React.FC<Props> 与 props 类型注解二选一,不重复标注
  2. 事件回调:优先传递业务数据,少透传原生事件对象,降低组件耦合度
  3. 副作用:明确 useEffect 执行时机,持续副作用必须编写清理函数,异步逻辑写在函数内部
相关推荐
小林ixn2 小时前
从混乱到清晰:项目架构与自定义 Hook 的双重实践
react.js·架构·前端框架
Lear2 小时前
Vite+Vue3 模块自动导入实战:彻底告别繁琐的 import 语句
前端
Csvn2 小时前
📌 position: sticky 失效的 6 个经典坑:明明写了 sticky,为什么就是不吸顶?
前端
恋猫de小郭2 小时前
Flutter 的另外一种形态?社区 DartNative 要来了。
android·前端·flutter
秋天的一阵风3 小时前
🔥 Network 里那坨 "data:" 我真看吐了,自制开源 Chrome 插件,AI 流式调试直接开挂
前端·人工智能·后端
看到我请叫我铁锤3 小时前
vue编写web端在线预览文档
前端·javascript·vue.js
Ratten3 小时前
【Vben 解决】---- 跨页复选获取选择数据
前端
ssshooter3 小时前
为什么明明只有一个 12px 的小元素,父容器却有 63px 高?
前端·javascript·面试
IT_陈寒3 小时前
Python的GIL让我深夜加班,这破锁到底怎么折腾的
前端·人工智能·后端