React + TypeScript 入门到进阶:从 Props 类型约束到状态管理与副作用最佳实践

React 搭配 TypeScript 已是现代前端企业级开发的标准方案。TS 带来的静态类型检查、编译期错误拦截、代码自文档化能力,能极大提升大型项目的可维护性与协作效率。本文将从最基础的组件 Props 类型约束出发,一步步拆解组件通信的三种演进形态,再深入 useEffect 副作用的核心概念与生命周期模拟,带你系统建立 React + TS 的开发思维。

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

1.1 React.FC:函数组件的内置类型

React 本身由 TypeScript 编写,提供了丰富的内置类型声明,React.FC 就是最常用的函数组件类型。它是 React.FunctionComponent 的类型别名,本质上约定了「这是一个 React 函数组件,返回值为 ReactElement」。

React.FC 支持泛型语法 FC<P>,我们可以将 Props 的类型作为泛型参数传入,从而约束组件接收的属性:

tsx

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

interface Props {
  userName: string;
}

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

export default HelloComponent;

这段代码的核心意义是:用接口 Props 定义一份「契约」,约定 HelloComponent 必须接收一个字符串类型的 userName 属性。调用组件时如果漏传、传错类型,TS 会在编码阶段直接红线报错,而不是等到运行时才出现 bug。

1.2 为什么优先用 interface 约束 Props

TS 中 type 类型别名和 interface 接口都可以定义对象结构,但社区约定俗成优先用 interface 定义组件 Props,核心原因有三点:

  1. 语义更贴合interface 本义就是「接口、契约」,用来定义组件对外暴露的入参规范,语义上完全匹配;type 更偏向通用类型别名,可定义联合类型、元组等复杂类型,语义更宽泛。
  2. 支持声明合并同名 interface 会自动合并属性,在封装基础组件、扩展第三方组件属性时非常实用;而 type 不允许重复声明。

tsx

kotlin 复制代码
interface BaseProps { size: 'small' | 'large' }
interface BaseProps { color?: string }
// 最终 BaseProps 同时拥有 size 和 color
  1. 继承扩展更直观 多层组件封装时,extends 语法比交叉类型 & 可读性更强:

tsx

php 复制代码
interface CardProps extends BaseProps {
  title: string;
}

补充:当 Props 内部需要大量联合类型、条件类型等复杂运算时,再选择 type 即可。

二、组件通信与单向数据流:三种实现版本的演进

React 的核心原则是单向数据流:状态由父组件持有,通过 props 向下传递给子组件;子组件通过回调函数通知父组件修改状态。围绕「编辑用户名」这个常见场景,我们可以看到三种典型的实现演进,每一步都对应着不同的封装粒度与职责划分。

版本 1:透传事件对象,父组件处理全部逻辑

最朴素的写法是子组件完全不做封装,直接把 input 的事件对象透传给父组件:

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

缺点非常明显

  • 事件类型 React.ChangeEvent<HTMLInputElement> 泄漏到父组件,父组件也要维护复杂的事件类型
  • 子组件没有任何封装性,只是一个单纯的壳
  • 父组件职责过重:既要持有状态,又要处理 DOM 事件细节

这是初学者最容易写出的代码,它违背了「封装变化」的原则。

版本 2:子组件持有私有状态,内部消化事件复杂度

优化思路:把输入框的临时状态和事件处理全部收敛到子组件内部,父组件只需要在提交时拿到最终值。

tsx

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

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

优势

  • 事件复杂度全部封装在子组件,父组件无需关心 DOM 事件类型
  • 父组件接口非常简洁:只需要传初始值和提交回调
  • 草稿状态与父组件的正式状态天然隔离

局限性

  • 编辑状态在子组件内部,其他兄弟组件无法实时共享草稿值(比如实时预览)
  • 父组件更新 initialUserName 时,子组件的 useState 初始值不会自动同步(只在首次挂载生效)

版本 3:状态提升至父组件,子组件变为纯展示

当多个子组件需要共享同一份状态时,标准解法是状态提升:把编辑状态上移到父组件统一管理,子组件变为纯展示的「无状态组件」。

tsx

typescript 复制代码
// 子组件:只负责渲染,没有自身状态
interface Props {
  editingName: string;
  onNameUpdated: () => 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();
  };

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

对应的父组件统一管理所有状态:

tsx

ini 复制代码
const App = () => {
  // 正式生效的名字
  const [name, setName] = React.useState<string>("defaultUserName");
  // 编辑中的草稿
  const [editingName, setEditingName] = React.useState("defaultUserName");

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

  return (
    <>
      名字: {name}
      {/* 实时预览:共享 editingName */}
      <HelloComponent userName={editingName} />
      <NameEditComponent
        editingName={editingName}
        onNameUpdated={setUserNameState}
        onEditingNameUpdated={setEditingName}
        disabled={editingName === "" || editingName === name}
      />
    </>
  );
};

核心收益

  1. 单一数据源:所有状态集中在父组件,避免数据不一致
  2. 组件职责单一 :子组件满足 UI = fn(props),只负责根据属性渲染,逻辑更简单、复用性更强
  3. 多组件共享:HelloComponent 可以实时拿到编辑中的草稿值,实现实时预览

这也是企业级项目最常用的模式:页面级组件持有状态,业务子组件保持无状态、只负责渲染与回调触发

三、useEffect 副作用:概念、生命周期与最佳实践

3.1 到底什么是「副作用」

理解副作用的前提是先理解「纯函数」:

  • 纯函数:只依赖入参计算返回值,不修改外部数据、不与外界交互,相同输入永远得到相同输出
  • 副作用:函数执行过程中,对函数外部世界产生的影响

React 组件的本职工作是「根据 state 和 props 渲染 UI」,除此之外所有和外部交互的操作都是副作用,常见包括:

  • 发起后端接口请求
  • 定时器 setTimeout / setInterval
  • 操作 DOM 元素
  • 读写 localStorage、订阅事件

组件渲染函数中不能直接写副作用,否则每次重渲染都会重复执行,造成定时器泄漏、重复请求等严重 bug。useEffect 的设计目的就是把副作用和渲染逻辑分离,精准控制执行时机。

3.2 useEffect 的三种写法,模拟三类生命周期

很多人会把 useEffect 和类组件的生命周期对应,准确来说:useEffect 不是生命周期,但可以通过依赖数组模拟出挂载、更新、卸载三种行为。

表格

写法 模拟生命周期 执行时机
useEffect(fn, []) componentDidMount + componentWillUnmount 仅组件首次挂载执行一次;组件销毁时执行清理函数
useEffect(fn, [a, b]) componentDidMount + componentDidUpdate + componentWillUnmount 首次挂载执行;依赖项变化时重新执行;每次重新执行前先运行上一次的清理函数
useEffect(fn) componentDidMount + 每次 componentDidUpdate 组件每一次渲染完成后都会执行,极易造成死循环,非特殊场景不推荐

3.3 实战:异步加载数据的完整写法

回到我们的用户名加载场景,页面打开后异步拉取数据:

tsx

scss 复制代码
const loadUsername = () => {
  setTimeout(() => {
    setName("name from async call");
    setEditingName("name from async call");
  }, 2000);
};

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

设计思路

  1. 组件先快速渲染出默认值,让用户立刻看到页面,提升感知速度
  2. 挂载完成后再发起异步请求,数据回来后更新状态触发重渲染

常见坑与修复:上面的代码存在内存泄漏风险:如果组件在 2 秒内被销毁,定时器依然会执行 setState。正确写法必须加上清理函数:

tsx

scss 复制代码
React.useEffect(() => {
  const timer = setTimeout(() => {
    setName("name from async call");
    setEditingName("name from async call");
  }, 2000);
  // 组件卸载时清除定时器
  return () => clearTimeout(timer);
}, []);

四、React + TS 开发高频避坑指南

  1. Props 属性名大小写严格一致 userNameusername 在 TS 中是完全不同的两个属性,拼写不一致会直接导致类型报错,这是初学者最高频的错误。
  2. useState 初始值只执行一次 useState(props.initialUserName) 只会在组件首次挂载时读取 props;后续 props 更新不会自动同步本地 state,需要配合 useEffect 监听依赖来同步。
  3. 合成事件类型要写全 输入框的 change 事件完整类型是 React.ChangeEvent<HTMLInputElement>,省略泛型会导致 e.target 类型丢失,无法正常读取 value。
  4. 状态分离原则正式数据与编辑草稿分开存储(name + editingName),既可以实现实时预览,又能支持取消编辑回滚,是表单类场景的标准实践。

五、总结

React + TypeScript 的学习不是单纯的语法记忆,核心是建立「类型契约 + 单向数据流 + 副作用管控」的完整思维体系。

从 Props 接口定义组件契约,到三种组件通信模式的演进,再到 useEffect 对副作用的精准管控,本质上都是在解决同一个问题:如何在项目复杂度提升时,依然保持代码的可维护性、可预测性与可复用性。理解了这些底层逻辑,你就能从「能写出来」进阶到「写得好」,真正掌握企业级 React 开发的核心能力。

相关推荐
码哥DFS2 小时前
算法练习day1-备战2027届秋招
前端·javascript·数据结构·算法
张元清2 小时前
React useInfiniteScroll Hook:无限滚动轻松实现(2026)
javascript·react.js
别怪我很水2 小时前
Vue element admin 浏览器本地存储 localStorage、useStorage
前端·javascript·vue.js
晓得迷路了3 小时前
栗子前端技术周刊第 140 期 - pnpm、Node 新 API 文档网站、Oxlint...
前端·javascript·node.js
烬羽3 小时前
一个队列,怎么让滑动窗口从 O(nk) 变 O(n)?单调队列彻底搞懂
javascript·数据结构·算法
猫猫不是喵喵.13 小时前
Vue3 Props 属性
前端·javascript·vue.js
jarvisuni17 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
破z晓19 小时前
javascript 导出excel表
开发语言·javascript·excel
小罗水20 小时前
第17章 后台管理端与演示支持
javascript·vue.js·ecmascript