说实话,最开始学 React + TypeScript 的时候,我是拒绝的。
不就是写个组件吗,JavaScript 写得好好的,加个
.tsx后缀,整个世界都变了。一个简单的 input 框,我得给 event 写类型;一个 props 传值,我得先定义个 interface;连个组件函数本身,都得套个React.FC。最崩溃的是那天,我就想写一个"编辑名字"的小功能------上面显示名字,下面一个输入框加个按钮,点提交更新名字。就这么点事儿,我写了三版才写明白,中间 TS 报错报得我头皮发麻。
后来回头看,这三版代码刚好把 React 里最核心的几个概念串起来了:类型约束、单向数据流、状态提升、副作用......今天就顺着这个路子,把我当时怎么一步步踩坑、怎么想明白的,原原本本讲给你听。
第一版:把 event 直接甩给父组件,爽是爽,就是类型满天飞
最开始我是这么想的:父组件管状态,子组件就是个输入框,用户输入啥,我就把 event 传给父组件,父组件自己去拿 event.target.value。
听起来没毛病对吧?我当时也觉得挺合理的。
代码大概长这样:
tsx
// 父组件
import * as React from 'react';
import Hello from './components/Hello';
import NameEditComponent from './components/NameEditComponent';
const APP: React.FC = () => {
const [username, setUserName] = React.useState('initialName');
const setUserNameState = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserName(event.target.value);
}
return (
<div>
<Hello userName={username}/>
<NameEditComponent
userName={username}
onChange={setUserNameState}
/>
</div>
)
}
子组件更简单,就是个纯展示的 input:
tsx
// 子组件
import * as React from 'react';
interface Props {
userName: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
const NameEditComponent: React.FC<Props> = (props) => {
return (
<div>
<label>Update Name:</label>
<input value={props.userName} onChange={props.onChange}></input>
</div>
)
}
跑起来没问题,功能也实现了。但我越看越别扭 ------
父组件凭啥要知道 React.ChangeEvent<HTMLInputElement> 这种东西?父组件的职责不就是 "持有状态、修改状态" 吗?现在倒好,父组件里出现了 DOM 事件的类型,子组件是什么 input 还是 textarea,父组件都得知道。
这就好比你去餐厅吃饭,服务员把后厨的菜刀直接递给你,说 "来,你自己切"。菜是能吃上,但总觉得哪里不对。
而且你发现没,React.ChangeEvent<HTMLInputElement> 这个类型,父子组件两边都得写一遍。以后子组件要是换成 textarea,两边都得改。这耦合度,想想就头疼。
我当时踩的坑最开始我连
React.ChangeEvent都不知道写,直接写了个Event,TS 直接给我报红,说target上没有value属性。我懵了半天,后来才反应过来 ------React 里的事件是 "合成事件",不是浏览器原生的那个 Event,得用 React 自己提供的类型。
等等,先插一句:React.FC 到底是个啥?
说到类型,我得先聊聊 React.FC 这个东西。最开始我就是照着教程抄,写组件就加个 React.FC,也不知道为啥要加。
后来我去翻了一眼 React 的类型定义,才发现它其实就是个泛型类型别名:
typescript
type FC<P = {}> = FunctionComponent<P>;
再往里看,FunctionComponent 本质上就是个函数,接收 props,返回一个 ReactElement。说白了,它就是给 "函数组件" 这个东西上了个类型约束。
泛型这个东西,你可以理解成 "类型的参数"。就像函数的参数是传值的,泛型是传类型的。React.FC<Props> 意思就是:这是个函数组件,它的 props 类型是 Props。
那 Props 又是啥?一般用 interface 来定义:
tsx
interface Props {
userName: string;
}
interface 你就把它想成一张 "清单"------ 这个组件的 props 里必须有啥、每个东西是什么类型,全列在上面。组件要是没按清单来,TS 直接给你报红。
说到 interface 和 type 的区别,我也纠结过一阵。后来我给自己定了个简单的规矩:描述对象的形状(比如 props、state)就用 interface,其他情况用 type。组件的 props 天然就是个对象,所以用 interface 就对了。
好了,类型的事儿先聊到这,咱们回到那个名字编辑组件。
第二版:子组件自己管自己的状态,父组件终于清净了
既然把 event 传给父组件那么别扭,那我换个思路 ------ 输入框那点事儿,子组件自己搞定不就行了?
子组件内部维护一个 "正在编辑的名字",用户输入的时候子组件自己改自己的状态,点提交的时候,只把最终的字符串值传给父组件。
这样父组件就只需要知道 "哦,新名字是个字符串",根本不用管你是 input 还是 textarea,更不用管什么 event 类型。
来看看代码:
tsx
// 子组件 - 自己管理编辑状态
import * as React from 'react';
interface Props {
initialName: string;
onNameUpdate: (newName: string) => void;
}
const NameEditComponent: React.FC<Props> = (props) => {
// 表单状态自己打理,不麻烦父组件
const [editingName, setEditingName] = React.useState(props.initialName);
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEditingName(event.target.value);
}
const onNameSubmit = () => {
// 提交的时候只传值,父组件根本不知道 event 是什么
props.onNameUpdate(editingName);
}
return (
<>
<label>Update Name:</label>
<input value={editingName} onChange={onChange}></input>
<button onClick={onNameSubmit}>Submit</button>
</>
)
}
父组件这边就清爽多了:
tsx
// 父组件
const APP: React.FC = () => {
const [username, setUserName] = React.useState('initialName');
return (
<div>
<Hello userName={username}/>
<NameEditComponent
initialName={username}
onNameUpdate={setUserName}
/>
</div>
)
}
你看,父组件的 onNameUpdate 就是个 (newName: string) => void,干干净净。DOM 事件的复杂性全被子组件封装在内部了。
当时我觉得这版就挺完美的了。父组件管 "真正的状态",子组件管 "编辑过程中的临时状态",各司其职。
但后来我又遇到了新问题 ------
如果有两个子组件都需要用到这个 "正在编辑的名字" 呢?比如左边一个实时预览,右边一个输入框,两边都要显示 editingName。那 editingName 放在子组件里就不行了,因为另一个子组件拿不到啊。
这时候就轮到 "状态提升" 出场了。
第三版:状态提升到父组件,子组件变成纯展示
状态提升这个词,听起来挺玄乎的,其实道理特别简单 ------哪个状态需要被多个组件共享,就把它放到它们共同的父组件里去。
就像家里的零食,你一个人吃,放你自己抽屉里就行;但如果兄弟姐妹都要吃,就得放到客厅的公共柜子里。
好,那我们把 editingName 也提到父组件去:
tsx
// 父组件 - 管理所有状态
import * as React from 'react';
import HelloComponent from './components/Hello';
import NameEditingComponent from './components/NameEditingComponent';
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}/>
<NameEditingComponent
editingName={editingName}
onNameUpdated={setUserNameState}
onEditingNameUpdated={setEditingName}
disabled={editingName === "" || editingName === name}
/>
</>
)
}
子组件这边就更简单了,完全没有自己的状态,纯靠 props 吃饭:
tsx
// 子组件 - 纯展示,没有内部状态
import * as React from 'react';
interface Props {
editingName: string;
onNameUpdated: () => void;
onEditingNameUpdated: (newEditingName: string) => void;
disabled: boolean;
}
const NameEditComponent: React.FC<Props> = (props) => {
const {
editingName,
onEditingNameUpdated,
onNameUpdated,
disabled,
} = props;
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onEditingNameUpdated(event.target.value);
}
const onNameSubmit = () => {
onNameUpdated();
}
return (
<>
<label>Update Name:</label>
<input
value={editingName}
onChange={onChange}
></input>
<button
disabled={disabled}
onClick={onNameSubmit}
>Submit</button>
</>
)
}
你看,子组件现在就干一件事:给我啥我显示啥,你点按钮我就通知你。它自己不存任何状态。
这种组件有个名字,叫 "受控组件"------ 它的一切都被父组件控制着,父组件传什么值,它就显示什么值。
对应的,第二版那种自己管状态的叫 "非受控组件"。
那到底用哪个?我后来总结了个简单的判断方法:
- 这个状态只有它自己用?→ 放子组件里,非受控,简单
- 这个状态别的组件也要用?→ 提升到父组件,受控,共享方便
没有绝对的好坏,看场景。
顺着这个组件,我搞懂了单向数据流
这三版代码写下来,我突然就理解了 React 一直说的 "单向数据流" 到底是啥意思。
以前看官方文档,"单向数据流" 这五个字,每个字都认识,放一起就懵。现在回头看,其实就是一句话:
数据从上往下流,事件从下往上传。
父组件把数据通过 props 传给子组件(往下流),子组件要改数据,就触发一个事件通知父组件(往上传),父组件自己改自己的 state,改完了再通过 props 传下来。
就这么一个循环,永远是单向的。子组件不能直接改父组件的 state,必须通过事件通知父组件来改。
你可以把它想象成一个公司的组织结构:
- 老板(父组件)掌握核心数据(state)
- 老板把任务和资料下发给员工(props 向下传)
- 员工做完了,打个报告给老板(事件向上传)
- 老板看了报告,决定要不要改数据(setState)
- 改完了再把新资料发下去
员工永远不能直接改老板的数据库,必须走汇报流程。这就是单向数据流。
为什么要这么设计?因为简单、可预测。如果谁都能改数据,改来改去你都不知道是谁改的,出了 bug 排查起来想死。单向数据流就一条路,数据从哪来、怎么变的,清清楚楚。
聊完数据流,再说说 useEffect 这个让人又爱又恨的东西
刚才第三版代码里有这么一段:
tsx
React.useEffect(() => {
loadUserName();
}, [])
这个 useEffect,我最开始学的时候也是一头雾水。啥叫 "副作用"?好好的函数组件,怎么就有副作用了?
后来我想了个类比,觉得挺贴切的:
函数组件的本职工作是 "根据 state 和 props 渲染 UI",这是 "主作用"。除此之外的所有事情,都是 "副作用"。
比如:
- 发请求拿数据 → 副作用
- 操作 DOM → 副作用
- 设个定时器 → 副作用
- 往 localStorage 存东西 → 副作用
这些事儿都不是 "渲染 UI" 本身,但又跟 UI 有关联,所以叫 "副作用"------ 附带产生的作用。
useEffect 的四种打开方式
我当时写了个 Todo List 的 demo,把 useEffect 的几种写法都试了一遍,终于搞明白了。
tsx
const App = () => {
const [count, setCount] = useState(0);
const [todos, setTodos] = useState([]);
// 写法一:不传第二个参数
useEffect(() => {
console.log("每次渲染都执行");
});
// 写法二:传空数组
useEffect(() => {
console.log("只在挂载后执行一次");
}, []);
// 写法三:传具体依赖
useEffect(() => {
console.log("count 变了才执行");
}, [count]);
// 写法四:带清理函数
useEffect(() => {
const interval = setInterval(() => {
console.log('Interval is here');
}, 1000);
return () => {
console.log('组件卸载前执行,清理工作');
clearInterval(interval);
}
}, [])
// ...
}
这四种写法,其实对应了类组件里的三个生命周期:
- 空数组
[]→componentDidMount(挂载后) - 不传第二个参数 →
componentDidMount+componentDidUpdate(每次更新都跑) - 传具体依赖 → 依赖变了才跑,相当于 "监听" 某个状态
- return 一个函数 →
componentWillUnmount(卸载前)
我当时觉得最神奇的是,就这么一个 hook,把类组件三个生命周期全给替代了。而且还更灵活 ------ 你可以写多个 useEffect,每个管一件事,不用全堆在一个生命周期函数里。
我踩过的坑:依赖数组
useEffect 最容易踩坑的地方就是那个依赖数组。
我刚开始写的时候,经常漏写依赖。比如我想在 todos 变化的时候存到 localStorage:
tsx
// 我最开始的写法 - 错的
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, []); // 我以为这样就只存一次
结果呢?todos 都更新了,localStorage 里还是旧数据。我调试了半天才反应过来 ------ 你依赖数组里不写 todos,它怎么知道 todos 变了?
正确写法:
tsx
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]); // todos 变了就重新存
一个简单的原则useEffect 里用到了哪些 state 或 props,依赖数组里就得写上。别偷懒,不然肯定出 bug。
当然也有例外,比如你就想在挂载时执行一次,里面用到的 state 就是初始值,那空数组也没问题。但这种场景其实不多,大部分时候你都得老老实实写依赖。
另一个坑:内存泄漏
还有一个坑,就是清理函数。
我写过一个 Demo 组件,里面设了个定时器,然后在父组件里根据 count 的奇偶来决定显不显示它:
tsx
{count % 2 === 0 && <Demo />}
Demo 组件里有个 setInterval,每秒打印一次。最开始我没写清理函数:
scss
// 错误示范 - 没有清理
useEffect(() => {
setInterval(() => {
console.log('Interval is here');
}, 1000);
}, [])
结果你猜怎么着?组件卸载了,定时器还在跑。count 变成奇数,Demo 组件没了,但控制台还在一秒一秒地打印。
这就是内存泄漏 ------ 组件都没了,但它占的资源(定时器、订阅之类的)还没释放,内存永远收不回来。
正确写法是 return 一个清理函数:
tsx
useEffect(() => {
const interval = setInterval(() => {
console.log('Interval is here');
}, 1000);
return () => {
// 组件卸载前执行,打扫卫生
clearInterval(interval);
}
}, [])
React 会在组件卸载前调用这个 return 的函数,帮你把烂摊子收拾干净。
说真的,这个坑我掉进去过不止一次。每次写定时器、写订阅、写事件监听,我都得提醒自己:别忘了清理。
顺手聊聊 localStorage
说到存数据,就聊聊 localStorage 吧。
前端本地存储这玩意儿,我最早接触的时候觉得挺神奇的 ------ 浏览器居然能存数据,关了页面再打开还在。
它的用法特别简单,就两个核心方法:
javascript
// 存
localStorage.setItem('key', 'value');
// 取
localStorage.getItem('key');
但有个要注意的地方 ------它只能存字符串。
你要是想存个对象、数组啥的,得先用 JSON.stringify 转成字符串:
tsx
// 存的时候转字符串
localStorage.setItem('todos', JSON.stringify(todos));
// 取的时候转回来
const savedTodos = JSON.parse(localStorage.getItem('todos') || '[]');
我初始化 todos 的时候就是这么写的:
tsx
const [todos, setTodos] = useState(() => {
return JSON.parse(localStorage.getItem('todos')) || [];
});
useState 里传个函数,这叫 "惰性初始化"------ 只有第一次渲染的时候才会执行这个函数,后面重新渲染不会再跑。性能更好一点。
注意容量localStorage 大概只有 5M 左右的空间,别啥都往里塞。存点配置、用户偏好啥的还行,真要存大量数据,得用 IndexDB 那种浏览器数据库。
回头看,这一路我搞懂了啥
从一个名字编辑组件开始,写到第三版,再到 Todo List 里折腾 useEffect 和 localStorage,这一圈下来,我觉得有几个点是最关键的:
第一,类型约束不是负担,是保护。
最开始写 TS,我觉得全是废话 ------ 我自己传的 props 我还不知道是什么类型吗?但写多了就发现,TS 真的能帮你挡掉很多低级错误。拼写错了 prop 名、传错了类型、漏了必填字段,TS 直接在你写代码的时候就给你报红,比跑起来再 debug 快多了。
特别是大项目、多人协作的时候,类型就是最好的文档。你看一个组件的 interface,就知道它要接收什么、返回什么,不用去读实现代码。
第二,单向数据流是 React 的灵魂。
数据往下传,事件往上传,这个规矩看起来笨,但真的能让你的代码逻辑清晰很多。出了问题,顺着数据流往上找,一定能找到根源。比起 "谁都能改数据" 的混乱模式,单向的代价是多写几行代码,但换来的是可维护性的巨大提升。
第三,useEffect 的本质是 "同步"。
我后来想明白了,useEffect 不是 "生命周期",而是 "同步副作用到状态"。状态变了,副作用也要跟着变,保持同步。依赖数组就是在说 "哪些状态变了的时候,我需要重新跑这个副作用"。
这么理解的话,依赖数组就不是什么 "需要记住的规则",而是自然而然的事情 ------ 你用到了什么状态,就得在它变化的时候重新同步。
最后说两句
当然了,这些东西也不是万能的。
比如状态提升,也不是提得越高越好。什么状态都往最顶层扔,最后顶层组件变成了 "上帝组件",啥状态都管,啥逻辑都有,一样难维护。状态提升到 "刚好能被需要共享的组件们访问到" 的层级就行,别过度设计。
再比如 useEffect,也不是啥都得往里塞。有些逻辑放在事件处理函数里更合适,没必要为了用 hook 而用 hook。
学习这事儿吧,我觉得就是这样 ------ 先跑起来,再慢慢优化。别一开始就追求 "最佳实践",写着写着你自然就知道哪里别扭、哪里需要改了。我那个名字编辑组件,也是写了三版才找到舒服的写法。
如果你也在学 React + TS,看到这儿有啥想法,或者你有别的理解,欢迎评论区聊聊。搞懂了记得回来留个言,我也想看看你的思路是不是跟我一样。