用金字塔原理拆解一个 React + TS 项目的三次重构,看清组件设计演进的底层逻辑。
一、核心结论(金字塔塔尖)
React + TypeScript 是企业级前端开发的黄金组合。用好它的关键不在于背 API,而在于理解三条铁律:类型约束保障编译安全、单向数据流保障状态正确、组件职责单一保障可维护性。
这三条铁律的具体落地路径是:
- 类型约束 :用
React.FC<Props>+interface为组件通信建立"合同",编译期拦截错误 - 单向数据流:状态提升到父组件,通过 props 向下传递,通过回调向上通知------这是 React 应用的"法律"
- 组件职责单一 :追求
UI = fn(props),子组件只负责展示,不持有业务状态
下面用 SCQA 框架展开整个推导过程。
二、序言:为什么要上 TypeScript 这辆车
2.1 背景(Situation)
React 已经是前端主流框架。Hooks 的普及让函数组件成为标配,而 TypeScript 在国内企业级项目中的渗透率已超过 70%。
写 React 项目,你大概率会碰到这些场景:
- 父组件传 props 给子组件,字段名拼写错误到运行时才发现
- 回调函数签名不一致,
onChange有时传 event 对象,有时传 string - 多人协作时,组件接口全靠口口相传或 README 维护
2.2 冲突(Conflict)
JavaScript 的自由是一把双刃剑。项目小时灵活,项目大时危险:
typescript
// JS 中这样写完全合法,运行时才会炸
<HelloComponent userName={123} /> // 本该传 string,实际传了 number
<NameEditComponent onNameUpdated="not a function" /> // 回调传成了字符串
没有类型约束的代码,就像没有红绿灯的十字路口------小车(小项目)能凭默契通过,大车流(大团队)必然撞车。
2.3 疑问(Question)
如何在享受 React 灵活性的同时,获得企业级项目必需的稳定性和可维护性?
2.4 回答(Answer)
React + TypeScript,配合三条核心实践:类型约束保安全、单向数据流保正确、组件职责单一保可维护。
下面自顶向下,逐层展开。
三、关键句第一层:类型约束------给组件通信上"合同"
3.1 React.FC 是什么
打开 React 源码,FC 的定义只有一行:
typescript
type FC<P = {}> = FunctionComponent<P>;
FC是FunctionComponent的类型别名,起个短名方便用<P = {}>是泛型参数,默认值{}------你不传就用空对象,传了就用你的类型FunctionComponent保证返回值一定是ReactNode
这意味着什么?只要你写了 React.FC<Props>,TypeScript 编译器就自动帮你检查:
- 你传给组件的 props 是否满足
Props定义 - 组件返回的是否是合法的 React 节点
3.2 Props 接口:父子之间的"合同"
实战中用一个 Hello 组件举例------功能很简单,向某人打招呼:
typescript
// Hello.tsx
import * as React from 'react';
interface Props {
userName: string;
}
const Hello: React.FC<Props> = (props) => {
return <h1>Hello {props.userName}!</h1>;
};
别小看这 5 行代码。interface Props 在这里扮演的角色是父子组件之间的"技术合同":
| 角色 | 合同的含义 |
|---|---|
| 父组件 | "我会给你一个叫 userName 的 string" |
| 子组件 | "我保证接收一个 userName,并且永远只展示它,不改它" |
| 编译器 | "你们说的我都记下了,谁违约我报红" |
如果你在父组件里写成 <Hello userName={123} />,还没跑代码,编辑器先红了。这就是类型约束的核心价值:把运行时错误提前到编译期。
3.3 自定义事件:不仅约束数据,还约束行为
组件不只是展示数据,还要响应用户操作。在 React 里,子组件通知父组件靠的是回调函数------而回调函数的签名同样需要类型约束。
以 NameEditComponent 为例,它有一个"提交新名字"的动作:
typescript
// NameEditComponent2.tsx
interface Props {
initialUserName: string;
onNameUpdated: (newName: string) => void; // 回调签名的合同
}
这里 onNameUpdated: (newName: string) => void 告诉父组件:
- 我会传给你一个
string参数(新名字) - 你不用返回任何东西(
void)
父组件使用时:
typescript
<NameEditComponent
initialUserName={username}
onNameUpdated={setUserName} // setUserName 恰好是 (val: string) => void,类型匹配!
/>
useState 返回的 setUserName 签名恰好是 (value: string) => void,和 onNameUpdated 完全对得上------这不是巧合,是类型系统帮你做了参数级别的接口校验。
3.4 React 合成事件:穿着原生外衣的类型
表单控件里有 onChange,看代码像原生 DOM 事件,实际是 React 的合成事件:
typescript
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditingName(e.target.value);
};
React.ChangeEvent<HTMLInputElement> 这个范型:
ChangeEvent告诉 TS 这是一个 change 事件<HTMLInputElement>告诉 TS,e.target一定是<input>元素,所以.value合法
事件的复杂性(类型推断、target 类型)被锁在子组件内部 ,父组件只需要知道"子组件会给我一个 string",不需要关心这个 string 是从 e.target.value 来的还是从别的地方来的。封装的意义就在于此。
小结 :类型约束的本质不是"多写几个类型注解",而是把组件间的暗契约变成明合同。编译器是 24 小时不睡觉的 Code Reviewer。
四、关键句第二层:单向数据流------React 应用的"宪法"
4.1 什么是单向数据流
React 有一条铁律:数据只能从父组件流向子组件,不能反过来。
css
父组件(持有 state + 修改 state 的方法)
│
│ props(数据下行)
▼
子组件 A 子组件 B 子组件 C
(只读 props)(只读 props)(只读 props)
│ │ │
└───────────┴───────────┘
回调(通知上行)
4.2 状态该放哪里
这是 React 新手最纠结的问题。判断标准只有一条:
这个状态需要被多个子组件共享吗?
- 需要 → 提升到它们的最近共同父组件
- 不需要 → 留在子组件内部当私有状态
在我们的例子中:
typescript
// App.tsx(父组件)
const App = () => {
const [name, setName] = React.useState<string>("defaultUserName"); // 共享状态
const [editingName, setEditingName] = React.useState("defaultUserName"); // 共享状态
// ✅ 父组件持有修改状态的方法
const setUserNameState = () => {
setName(editingName);
};
return (
<>
名字: {name}
<HelloComponent userName={name} />
<NameEditComponent
initialUserName={name}
editingName={editingName}
onNameChange={setUserNameState}
onEditingNameUpdated={setEditingName}
/>
</>
);
};
name 被两个子组件(展示组件 HelloComponent 和编辑组件 NameEditComponent)同时使用 → 必须提升到父组件。这是应用状态正确的前提,没有妥协余地。
4.3 为什么它是"法律"而不是"建议"
如果你违反单向数据流------比如子组件直接修改父组件传下来的 props,或者多个子组件各自维护同一份数据的副本------稍复杂一点的交互就会出 bug:
- 用户编辑了名字点"确认",展示区没更新
- A 组件改了数据,B 组件看到的还是旧数据
- 排查问题时,你不知道数据到底在谁手里被改了
单向数据流的本质:数据的"真相来源"永远只有一个(父组件的 state)。任何人想改,必须通过父组件提供的"合法渠道"(回调)。这就是为什么把它称为 React 的"宪法"------不是不能绕开,而是一绕就乱。
五、关键句第三层:组件设计的三次演进------从"能用"到"优雅"
这是本文最有价值的实操部分。我们来看同一个 NameEditComponent 经历了三次重构,每一次都在解决一个特定的设计问题。
5.1 第一版:把 Event 对象传给父组件(❌ 职责泄漏)
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>
);
};
// 父组件被迫写这种代码:
const setUsernameState = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserName(event.target.value);
};
问题在哪?
子组件把 React.ChangeEvent<HTMLInputElement> 这个复杂的类型"泄漏"给了父组件。父组件被迫:
- 引入
React.ChangeEvent类型 - 知道"我是从
<input>拿的值" - 写
event.target.value这种实现细节
而且父组件和子组件两边都写了 React.ChangeEvent<HTMLInputElement>------一份实现细节,两次声明,零复用。
核心问题 :子组件暴露了"我是怎么实现交互的"(用 <input> + onChange),而不是对父组件说"我能提供什么价值"(我能让用户编辑名字,最终给你一个 string)。
5.2 第二版:子组件自己消化事件(✅ 封装复杂度)
typescript
// NameEditComponent2.tsx(第二版)
interface Props {
initialUserName: string;
onNameUpdated: (newName: string) => void; // 注意:这里改成传递 string!
}
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}>Update</button>
</>
);
};
改了什么?
| 维度 | 第一版 | 第二版 |
|---|---|---|
| 父组件回调签名 | (e: ChangeEvent<HTMLInputElement>) => void |
(newName: string) => void |
| 事件处理位置 | 父组件手动 e.target.value |
子组件内部消化 |
| 父组件需要 import 的类型 | React.ChangeEvent |
无 |
| 提交按钮 | 无(每次按键都通知父组件) | 有(用户确认后才提交) |
| 编辑中间态 | 无(直接改 props) | 有(子组件私有 state editingName) |
核心改进:
- 封装复杂度 :
React.ChangeEvent<HTMLInputElement>留在子组件内部,父组件只需要知道"我收到一个 string" - 增加确认按钮:用户编辑后点"确认"才提交,不再是每次按键都触发父组件更新
- 编辑中间态 :子组件用私有 state
editingName管理编辑过程中的值,不直接修改 props
这一版的关键思想:让父组件回归它本来的使命------持有状态和修改状态的方法,让子组件共享。不要让父组件去关心子组件"怎么实现交互"。
5.3 第三版(终极形态):无状态子组件------UI = fn(props)
typescript
// 终极版:状态全部提升到父组件
// 父组件
const App = () => {
const [name, setName] = React.useState<string>("defaultUserName");
const [editingName, setEditingName] = React.useState("defaultUserName");
return (
<>
名字: {name}
<HelloComponent userName={name} />
<NameEditComponent
initialUserName={name}
editingName={editingName}
onNameChange={setUserNameState}
onEditingNameUpdated={setEditingName}
/>
</>
);
};
// 子组件(纯展示+纯回调)
interface Props {
initialUserName: string;
editingName: string;
onNameChange: () => void;
onEditingNameUpdated: (newName: string) => void;
disabled: boolean;
}
这一版的理念是:把所有状态都提升到父组件,子组件变成"纯函数"------给它 props,它返回 UI,仅此而已。
typescript
UI = fn(props)
为什么这是"更优"的设计?
- 性能更好:无状态子组件可以被 React 更激进地 memo 优化
- 测试更容易:纯函数组件,输入确定,输出确定,不依赖任何内部隐藏状态
- 职责绝对单一:子组件只做一件事------渲染。数据从哪来、往哪去,它完全不用管
- 调试友好:所有状态都在父组件,React DevTools 里一眼看到全部数据流
5.4 三次演进的底层逻辑
ini
第一版:能用,但职责泄漏
↓ 问题:父组件被迫知道子组件的实现细节(ChangeEvent)
第二版:封装复杂度,子组件自己消化事件
↓ 问题:状态分散在父子组件之间,不够纯粹
第三版:状态收敛到父组件,子组件 = 纯展示
↓ 目标:UI = fn(props)
这个演进路径不是事先设计的,而是被问题一步步推出来的。理解了这三步,你就理解了大厂 React 代码为什么长那样。
六、关键句第四层:useEffect------在"副作用"中管理生命周期
6.1 什么是副作用
React 的核心是"把 state 渲染成 UI"。一切不在这个渲染流程之内的事情,都是副作用。包括:
- 请求 API 拿数据
- 操作 localStorage
- 设置定时器
- 手动操作 DOM
- 订阅/取消订阅外部事件
6.2 三种执行时机
useEffect 第二个参数(依赖数组)控制它什么时候执行:
typescript
// 👇 只在挂载后执行一次(模拟 componentDidMount)
React.useEffect(() => {
loaderUsername(); // 异步加载数据
}, []);
// 👇 挂载后 + todos 每次变化后都执行
React.useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
// 👇 挂载后 + 每次重渲染后都执行(⚠️ 慎用)
React.useEffect(() => {
console.log('组件更新了');
}); // 不传第二个参数
| 依赖数组 | 执行时机 | 适用场景 |
|---|---|---|
[] |
仅挂载后执行一次 | 初始化请求、挂载事件监听 |
[todos] |
挂载后 + todos 变化后 |
同步 state 到 localStorage、根据 props 变化重新请求 |
| 不传 | 每次渲染后都执行 | 极少使用,容易死循环 |
6.3 核心原则:先渲染,再干活
typescript
React.useEffect(() => {
loaderUsername(); // ← 第二优先:挂载完成后才执行
}, []);
// 组件 return JSX ← 第一优先:赶快渲染出来
React 的哲学 :组件第一要务是赶快把界面展示出来,让用户觉得快。至于请求数据、操作存储这些"慢活",放到 useEffect 里异步做。对用户来说顺序是:
- 界面先出来(可能显示 loading 或初始值)
- 数据到了,界面再更新
这就是 React "快"的秘密------不让副作用阻塞首次渲染。
6.4 清理副作用:不要留下内存垃圾
typescript
React.useEffect(() => {
const timer = setInterval(() => {
console.log('轮询中...');
}, 1000);
return () => { // ← 卸载前执行
clearInterval(timer); // 清除定时器
};
}, []);
return 的函数在组件卸载前执行。如果不清除定时器、事件监听、订阅,这个组件即使从 DOM 移除了,定时器还跑着,占用的内存永远无法回收------这就是前端内存泄漏的典型案例。
6.5 实战:用 useEffect 实现一键三连
typescript
const App = () => {
const [name, setName] = React.useState<string>("defaultUserName");
const [editingName, setEditingName] = React.useState("defaultUserName");
// 挂载后异步加载数据
const loaderUsername = () => {
setTimeout(() => {
setName("name from async call");
setEditingName("name from async call");
}, 2000);
};
React.useEffect(() => {
loaderUsername(); // 组件先渲染,2 秒后数据才到
}, []);
// 名字变化时同步到 localStorage
React.useEffect(() => {
localStorage.setItem('username', name);
}, [name]);
return (/* ... */);
};
两个 useEffect 各司其职:
- 第一个管"数据从服务端来"(拉)
- 第二个管"数据到本地存"(存)
每个 useEffect 只做一件事 ,这是 Hook 设计的最佳实践------比类组件里把互不相关的逻辑堆在 componentDidMount 里清晰太多。
七、总结:回到塔尖
我们用金字塔结构拆解了 React + TypeScript 企业级开发的核心知识体系。回顾整个推导链条:
kotlin
React + TypeScript 企业级开发 = 类型约束 + 单向数据流 + 组件设计 + 副作用管理
│
┌───────────────────────┼───────────────────────┐
│ │ │
类型约束(合同) 单向数据流(宪法) 组件设计(演进)
├─ React.FC<Props> ├─ 状态提升 ├─ V1: 职责泄漏
├─ interface Props ├─ props 下行 ├─ V2: 封装复杂度
├─ 回调签名约束 ├─ 回调上行 └─ V3: UI = fn(props)
└─ 合成事件封装 └─ 真相唯一源
副作用管理
├─ 挂载后:异步加载
├─ 更新后:同步存储
└─ 卸载前:清理资源
三条铁律再强调一次
| 铁律 | 一句话解释 |
|---|---|
| 类型约束 | 把暗契约变成明合同,编译器替你 Code Review |
| 单向数据流 | 数据永远从上往下流,真相只有一个 |
| 组件职责单一 | 子组件干得越少越好,终极目标是 UI = fn(props) |
你可以带走的三句话
- 写 props 前先写 interface:这不是麻烦,是给自己和同事留退路
- 犹豫状态放哪里?往上提:放在共同父组件从来没错过
- useEffect 里的事情永远可以等一等:先渲染,再干活,用户感知的才是真性能