React + TypeScript 从入门到实战:组件通信、状态管理与副作用全解析

0 阅读10分钟

前言

如果你刚学会 React,开始用 TypeScript 写项目时可能会一脸懵:为什么多了这么多 <>interface:?这篇文章基于真实的学习路径,从最简单的组件开始,一步步走到组件通信、状态管理和副作用处理,把 React + TypeScript 的核心知识点讲清楚。


一、为什么 React + TypeScript?

纯 JavaScript 写 React 的问题

场景纯 JS加 TS
传 props写错了运行才报错写的时候就提示"你少传了属性"
用 props不知道 props. 后面有什么props. 自动提示所有可用属性
团队协作靠文档和口头约定类型本身就是文档
重构改了组件,不知道哪里受影响TS 编译器逐个排查所有调用处

一句话:TypeScript 就像给代码装了护栏,防止你在高速行驶时掉进坑里。

TypeScript 提供了什么

  • 类型约束:变量、函数参数、返回值都有明确的类型
  • 静态编译:代码还没跑就能发现错误
  • 泛型:像函数参数一样传递类型,灵活又安全

二、React 的类型约束:React.FC

它是什么?

const App: React.FC = () => {
  return <div>Hello</div>
}

React.FC 是 React 内置的类型声明,它的全称是 FunctionComponent。它的作用是告诉 TypeScript:"这是一个 React 函数组件"

它做了什么?

  1. 约束返回值:函数必须返回 ReactElement(JSX 元素)或 null
  2. 自带 children 类型(React 18 之前)
  3. 为 props 提供类型检查入口

源码长什么样

type FC<P = {}> = FunctionComponent<P>;
//     ^^^^^^    ^^^^^^^^^^^^^^^^^^^^
//     泛型参数P  真实的类型定义
//     默认值为{}  如果你不传,P就是空对象

P 是一个泛型参数,就像函数的形参一样,但它是用来传类型的:

// 不传泛型 → P 默认是 {},props 里什么都不能有
const A: React.FC = () => ...

// 传了 Props → P 变成 Props,props 必须满足 Props 的约束
const B: React.FC<Props> = (props) => ...

之前遇到的坑:为什么空函数体报错?

// ❌ 报错
const App: React.FC = () => {
  // 什么都没 return
}

箭头函数用花括号 {} 时,没有 return 语句,返回类型被推断为 void。但 React.FC 要求返回 ReactElement | null,类型不匹配就报错了。

// ✅ 正确:有 return
const App: React.FC = () => {
  return <div></div>
}

三、Props 接口:父子组件之间的"合同"

什么叫接口?

interface Props {
  username: string;              // 合同第一条:必须传一个字符串
  onNameUpdated: (newName: string) => void;  // 合同第二条:必须传一个函数
}

子组件说:"想用我,就必须遵守这份合同。缺一条都不行。"

谁定义 Props?

定义 interface Props 的文件就是子组件。 因为 Props 是别人传给你的东西 → 你被动接收 → 你是子组件。

type vs interface

// 方式一:interface(推荐用于 props)
interface Props {
  username: string;
}

// 方式二:type(也可以,更通用)
type Props = {
  username: string;
}
interfacetype
用途专门描述对象的形状可以描述任何类型
扩展extends& 交叉类型
设计意图"合同/接口""别名"

90% 的场景可以互换。React 社区更常用 interface 定义 props。

函数类型的声明

onNameUpdated: (newName: string) => void
//             ^^^^^^^^^^^^^^^^    ^^^^
//             参数:接收一个string  返回值:不返回任何东西(void)

TS 里看到 () => 某类型 这种箭头结构,就知道这是一个函数类型。注意它和 JS 箭头函数长得像但含义不同——这是类型层面的声明

关于 void

void 表示函数"不返回任何东西"。但参数照样可以往里传

onNameUpdated: (newName: string) => void
//             ^^^^^^^^^^^^^^^^ 进   ^^^^ 出
//             名字从参数进去           不需要返回值出来

参数是进门,返回值是出门。void 说的是出门不拿东西,不影响进门。


四、React 事件类型:React.ChangeEvent<>

为什么需要事件类型?

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setEditingName(e.target.value)
  //            ^^^^^^^ TS 知道 target 是 input,有 value 属性
}

泛型里填的是"事件发生在哪个元素上"

泛型可用属性
HTMLInputElemente.target.value, e.target.checked
HTMLSelectElemente.target.value
HTMLDivElemente.target.textContent(没有 value

不填会怎样?

(e: React.ChangeEvent) => {
  e.target.value  // ❌ TS 不知道 target 是谁,不敢让你用
}

类比

你去快递站拿快递。不写泛型 = "我来拿个快递",老板不知道该给你哪个。写了 <HTMLInputElement> = "我来拿顺丰的快递",老板立刻找到。

事件最重要的就是事件发生的元素,不同元素有不同属性,写清楚了 TS 才能正确提示。


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

React 的核心法则

状态只属于一个组件。 数据永远单向流动,改变状态的唯一方式是该组件自己调用 setState

父子通信模式

父组件(拥有状态)              子组件(接收 + 展示)
┌──────────────────┐           ┌──────────────────────┐
│ username = "峰哥"  │  props   │                      │
│ setUserName()    │ ───────→ │ props.username       │
│                  │  username│ props.onNameUpdated() │
│                  │          │                      │
│                  │ ←─────── │ 调用回调函数          │
│ setUserName(新值) │  回调    │ (子组件只是喊一声,  │
└──────────────────┘           │  不知道函数里干了什么)│
                               └──────────────────────┘

为什么不是双向?

子组件调用 props.onNameUpdated("新名字") 的时候:

  • 子组件不知道父组件会拿这个名字干什么
  • 子组件没有碰父组件的任何变量
  • 子组件只是喊了一嗓子,通知父组件

就像你按门铃——你不知道门铃后面是开门、报警还是播放音乐。你只是按了,仅此而已。真正改数据的人永远是父组件自己。

类比

角色类比
父组件老板,钱(状态)在她手里
子组件收银员,收了钱只能汇报,不能自己揣兜里
props账本,老板给收银员看的
回调函数对讲机,收银员喊"老板,有人交钱了"
setState老板自己亲手记账

六、组件架构的三次进化

这是理解 React 组件设计最精华的部分。我们从一个简单的"改名字"功能出发,看它怎么一步步优化。

版本一:事件对象直接透传(新人写法)

// 父组件
const App = () => {
  const [username, setUserName] = useState("initialName");
  
  const setUsernameState = (event: React.ChangeEvent<HTMLInputElement>) => {
    setUserName(event.target.value)
  }
  
  return <NameEditComponent username={username} onChange={setUsernameState} />
}

// 子组件
interface Props {
  username: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

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

问题:

  • 父组件需要知道 React.ChangeEvent<HTMLInputElement> 的存在
  • 复杂度泄漏到了父组件,父组件本该只关心数据和业务逻辑
  • 子组件只是一个"传声筒",没有承担应有的封装职责

版本二:子组件内部消化复杂度(推荐起步写法)

// 子组件
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 (
    <>
      <label>Update name:</label>
      <input value={editingName} onChange={onChange} />
      <button onClick={onNameSubmit}>Change</button>
    </>
  )
}

改进点:

  • 父组件只需要收一个干净的 string,不再接触 React.ChangeEvent
  • React.ChangeEvent<HTMLInputElement> 的复杂性被封装在子组件内部
  • 子组件有了自己的状态(editingName),可以"自己打理"表单

类比:洗衣机把复杂的内部机械结构封装起来,你只需要扔衣服进去、按按钮、拿干净衣服。

版本三:无状态子组件(追求极致性能)

// 父组件同时持有 name 和 editingName
const App = () => {
  const [name, setName] = useState("defaultUserName");
  const [editingName, setEditingName] = useState("defaultUserName");
  
  const setUserNameState = () => {
    setName(editingName);
  }
  
  return (
    <>
      名字: {name}
      <NameEditComponent
        editingName={editingName}
        onNameUpdated={setUserNameState}
        onEditingNameUpdated={setEditingName}
        disabled={editingName === "" || editingName === name}
      />
    </>
  )
}

// 子组件:纯展示,没有任何自己的状态
const NameEditComponent: React.FC<Props> = (props) => {
  return (
    <>
      <label>Update name:</label>
      <input 
        value={props.editingName} 
        onChange={(e) => props.onEditingNameUpdated(e.target.value)} 
      />
      <button 
        onClick={props.onNameUpdated} 
        disabled={props.disabled}
      >
        Change
      </button>
    </>
  )
}

改进点:

  • 子组件完全无状态,只负责展示
  • UI = fn(props)——给同样的 props,永远渲染同样的界面
  • 性能更好,职责非常单一
  • 所有状态集中在父组件管理,更容易追踪

三个版本的对比

版本一版本二版本三
子组件有状态吗有(editingName
父组件接触 Event 对象吗接触不接触不接触
封装性最好
子组件职责传声筒自己管表单纯展示
适用场景不推荐表单组件追求极致简洁

七、useEffect:副作用与生命周期

什么是副作用?

React 组件的主业是:根据 props 和 state,返回 JSX(渲染 UI)。除此之外做的事都是副作用

  • 请求接口拿数据
  • 操作 localStorage
  • 设置定时器
  • 操作 DOM

组件的首要任务是赶快显示出来,让用户觉得快。 耗时的副作用放后面做。

useEffect 的四种模式

// 模式一:每次渲染都执行
useEffect(() => {
  console.log('每次更新都执行');
});

// 模式二:只在挂载后执行一次
useEffect(() => {
  console.log('只会在挂载后执行');
}, []);  // ← 空数组 = 没有依赖 = 只执行一次

// 模式三:依赖项变化时执行
useEffect(() => {
  console.log('count 改变时执行');
}, [count]);  // ← count 变了就触发

// 模式四:依赖项变化 + 卸载前清理
useEffect(() => {
  console.log('todos 更新后执行');
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

一张表搞懂依赖数组

第二个参数执行时机常见用途
不传每次渲染后都执行不推荐(性能浪费)
[]只在挂载后执行一次请求初始数据、设置监听
[a, b]挂载后 + a 或 b 变化时响应特定状态变化
不存在

卸载前的清理工作

const Demo = () => {
  useEffect(() => {
    const interval = setInterval(() => {
      console.log('interval is here');
    }, 1000);
    
    // 返回的函数会在组件卸载前执行
    return () => {
      console.log('组件卸载前,清理定时器');
      clearInterval(interval);
    }
  }, []);
  
  return <>Demo</>
}

为什么要清理?

如果不清理定时器,即使组件已经被移除了,定时器还在后台跑。这个定时器引用的变量永远无法被垃圾回收——这就是内存泄漏

清理工作清单:

  • clearInterval / clearTimeout
  • 取消网络请求
  • 移除事件监听器

实际应用:同步 localStorage

const [todos, setTodos] = useState(() => {
  // 初始化:从 localStorage 恢复数据
  return JSON.parse(localStorage.getItem('todos')) || [];
});

// 每次 todos 变化,自动存到 localStorage
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

这样数据就持久化了——刷新页面也不会丢。


八、前端本地存储小知识

存储方式容量用途
浏览器缓存静态资源(JS/CSS/图片)
localStorage约 5MBkey-value 配置、关键数据持久化
IndexedDB较大类数据库,存更多结构化数据

localStorage 只能存字符串,存对象需要用 JSON.stringify

// 存
localStorage.setItem('todos', JSON.stringify(todos));

// 取
JSON.parse(localStorage.getItem('todos')) || [];

九、完整实战:一个带本地持久化的用户名编辑器

项目结构

src/
├── App.tsx                  ← 父组件,持有所有状态
├── components/
│   ├── Hello.tsx            ← 展示组件
│   └── NameEditComponent.tsx ← 编辑组件

App.tsx

import * as React from 'react';
import HelloComponent from './components/Hello';
import NameEditComponent from './components/NameEditComponent';

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

export default App;

Hello.tsx

import * as React from 'react';

interface Props {
  userName: string;
}

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

export default Hello;

NameEditComponent.tsx

import * as React from 'react';

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

const NameEditComponent: React.FC<Props> = (props) => {
  return (
    <>
      <label>Update name:</label>
      <input
        value={props.editingName}
        onChange={(e) => props.onEditingNameUpdated(e.target.value)}
      />
      <button
        onClick={props.onNameUpdated}
        disabled={props.disabled}
      >
        Change
      </button>
    </>
  )
}

export default NameEditComponent;

十、核心要点回顾

知识点一句话总结
React.FC给组件贴上"我是函数组件"的标签,约束返回值
泛型 <T>像函数参数一样传类型React.FC<Props> 意思是用 Props 约束 props
interface Props子组件写给父组件的"合同",声明我需要什么数据
事件类型React.ChangeEvent<HTMLInputElement>,告诉 TS 事件发生在哪个元素上
单向数据流状态只归一个组件管,子组件通过回调通知,不是传数据回去
版本进化复杂度内聚 → 子组件封装 → 纯展示组件
useEffect副作用的总管:请求数据、存储、定时器、清理
依赖数组 []只挂载后执行一次;[x]:x 变了就执行
清理函数 return () => {}组件卸载前执行,防止内存泄漏

写在最后

React + TypeScript 的学习曲线确实比纯 JS 要陡一些,但一旦跨过那道坎,你会发现写代码变得更安心——改一个组件,TS 会逐个排查所有调用处;接别人的代码,看 interface Props 就知道这个组件怎么用。

本文从最基础的 React.FC 类型讲起,到 props 接口声明、事件类型、组件通信模式、架构进化,再到 useEffect 副作用处理,覆盖了 React + TypeScript 日常开发的绝大部分场景。希望对你有所帮助!