最近在跟 Mosh 的 React 18 + TypeScript 中级课程,正好是 Intermediate Topics 的第二个部分。之前我用 JS 写 React 的时间不短,组件、状态、Hook 这些基础都有概念,但一直没把 TypeScript 认真接进项目里。这次趁着一个真实项目切换到 React 18 + TS,把课程里的思路和踩坑一起过了一遍,有几个感受特别明显:类型声明不是“给代码上枷锁”,而是把组件之间的约定写出来;React 18 的几个新特性不是炫技,是真能解决列表搜索、异步请求这类场景里的更新调度问题。这篇不打算复述课程视频,而是把我实际操作中的工程配置、组件设计、常见报错和排查过程整理出来,给同样卡在“基础都会、一上项目就乱”阶段的朋友一个参考。
1. 为什么说 React 基础到中级的那个坎,是 TypeScript 的入场时机
1.1 基础阶段你可能遇到的问题
入门 React 时,JavaScript 完全够用。props 是对象,state 是任意值,事件回调想怎么写就怎么写,组件跑得起来就行。但项目一旦超过十几个组件,我实际遇到的情况是:一个页面从接口拿数据,经过三个组件往下传,最底层的组件里把 props 名字拼错了,浏览器控制台只在运行时报一个 undefined,得从最外层组件一层层往上翻。更麻烦的是状态更新,在一个异步回调里连写两个 setState,JS 下可能触发多次渲染,性能问题在开发环境根本看不出来。
这时候需要的不只是“再细心一点”,而是一种能让约定自动生效的工具。TypeScript 在这里的定位不是语言层面的颠覆,而是给 React 组件的输入输出做显式建模:props 该有哪些字段、回调的参数是什么类型、state 的变更函数要接收什么值,全都写进代码里。换句话说,它把很多“运行到一半才报错”的问题,提前到了“保存文件的那一刻”。
1.2 TypeScript 和 JS 的区别,不在于“能不能写”,而在于“什么时候发现错误”
这里可能要说清楚一个常见的困惑。TypeScript 是 JavaScript 的超集,浏览器最终运行的还是 JS,TS 只是给开发流程加了一层静态检查。用生活化一点的说法:JS 像手写的购物清单,写错字也没人管,直到结账时才发现买错;TS 像带格式的表格,填错格式当场就标红。这也是我在项目中实际感受到最大的效率来源,不是“代码少写了”,而是“试错周期变短了”。
下面这个表格是我常用来给团队讲的对比:
| 对比维度 | JavaScript | TypeScript |
|---|---|---|
| 语法层面 | 灵活但缺少类型标注 | 支持完整类型标注,且能自动推断局部类型 |
| 报错时机 | 多数组件错误在运行时报错 | 在写代码或编译时报错,并定位到具体行列 |
| 重构体验 | 改一个 props 名要全局搜索 | 有类型引用,改名后所有使用位置同步标红 |
| 协作约定 | 靠注释和口头约定 | 靠 interface / type 直接约束契约 |
注意:TS 不是银弹。如果团队里每个人都不看类型、滥用 any,那 TS 也会退化成“带注释的 JS”。后面在组件实战里我会讲怎么把类型用在刀刃上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工程搭建:Vite + React 18 + TS,先解决 baseurl 弃用这个坑
2.1 为什么我选了 Vite 而不是 Create React App
很多跟着课程走的人还在用 CRA 初始化项目。但我推荐 Vite,理由很直接:CRA 的维护已经进入低速期,React 18 的模板虽然能用,但开发服务器和构建速度明显不如 Vite;Vite 基于原生 ESM,冷启动是真快,热更新也是毫秒级。对于中级课程这种要反复改组件调试的场景,等待时间越短,学习节奏越连贯。
创建项目只需要一条命令:
bash复制npm create vite@latest react-ts-intermediate -- --template react-ts
之后 npm install && npm run dev。装完后去看 package.json,确认 react 和 react-dom 是 ^18.x,typescript 是 5.x。这条很重要,后面要聊的 baseurl 弃用警告就跟 TS 版本直接相关。
2.2 tsconfig 里的关键配置,别直接默认到底
Vite 的 react-ts 模板会生成一份基础 tsconfig,但有几项我建议手动确认:
"target":"ES2020"或更高。React 18 的很多特性依赖现代语法,比如可选链、空值合并,ES2020 是底线。"jsx":"react-jsx"。React 17 之后不需要在每个文件里 import React,这个模式能避免大量无意义的导入。"moduleResolution":"Bundler"。这是 Vite 等打包器场景下的推荐配置,比 Node 的解析方式更贴合实际。- 另外要打开
"strict": true。新手可能觉得严格模式报错多,但中级阶段必须习惯它,否则类型检查形同虚设。
这些配置并不是改完就一劳永逸,它们会直接决定你在写组件时能不能拿到准确的类型提示。比如 strict 没开,很多 null 和 undefined 的隐患就不会被标记出来,等你上线跑起来才在某个深层组件里炸掉,那才是真的难受。
2.3 baseurl 弃用警告是怎么来的,怎么改
如果你在项目里看到这样一条警告:Option 'baseurl' is deprecated and will stop functioning in typescript 7.0.,别慌,这是 TS 5.x 给出的迁移提示。以前为了用 @/ 这样的路径别名,我们通常会在 tsconfig 里写:
json复制{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
TS 团队发现 baseUrl 在模块解析上容易造成边界混乱,而且 paths 在大部分场景下不需要 baseUrl 也能正常工作,所以计划在 TS 7.0 里移掉这个选项。正确做法是直接把 baseUrl 删掉,只保留 paths:
json复制{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
注意 paths 里写的 "./src/*" 是相对 tsconfig.json 所在目录的路径,删掉 baseUrl 后必须要写清楚。改完以后跑一次 npx tsc --noEmit,确认原来用 @/ 导入的地方都能正常解析。
这里容易忽略的是,Vite 本身并不知道 tsconfig 里的 paths,运行时解析 @/ 还需要在 vite.config.ts 里配置 resolve.alias:
ts复制import path from "path";
export default {
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
}
}
}
tsconfig 管的是编辑器提示和编译检查,vite.config 管的才是打包时的路径解析,两边必须保持一致。这是中级课程里特别容易踩的实际问题,我第一次在项目里配置时,以为改完 tsconfig 就万事大吉,结果 npm run dev 直接报找不到模块,折腾了快半小时才发现是 vite 那边没配上。
3. React 18 的几个新特性,到底哪些值得在业务里用
3.1 StrictMode 双调用:不是 bug,是 React 18 在帮你体检
很多人在 React 18 里第一次看到 console.log 输出两次,会以为代码写错了。其实 StrictMode 的开发环境行为是故意设计的:组件挂载时 React 会执行 mount → unmount → mount 的流程,让副作用暴露出“没有正确清理”的问题。对应到实际场景就是 useEffect 里的 addEventListener 没有 removeEventListener、定时器没有 clearInterval、请求没有 cancel。如果你在 StrictMode 下发现某个 effect 异常执行两次,优先检查的应该是清理函数,而不是去关掉 StrictMode。
这一条放到课程里是“新特性”,放到工程项目里其实是质量门槛。我的建议:项目里保留 <React.StrictMode>,不要因为开发时看到双输出就把它删掉。很多教程为了演示效果会关掉 StrictMode,但真实业务项目里,这种“体检”能帮你提前暴露大量内存泄漏和重复订阅问题。
3.2 startTransition 和 useTransition:给紧急更新让路
React 18 最核心的底层变化是并发渲染。这里的“并发”不是同时执行两个任务,而是渲染过程可以被更高优先级的更新打断。典型场景是搜索框:用户每敲一个字符,输入框本身需要立刻响应(高优先级),而根据输入内容过滤出一个大列表是低优先级工作。如果每次按键都同步渲染整个列表,用户会感觉到卡顿。
useTransition 的用法是把低优先级的更新包进 transition 里:
tsx复制const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
startTransition(() => {
setFilteredList(filterList(data, e.target.value));
});
};
这里 setInputValue 会立即执行,而 setFilteredList 会进入可中断的调度队列。isPending 可以用来在列表更新前显示一个轻量加载态。课程里讲这个概念时会用一个大列表做演示,实际项目里如果数据量不大,没必要强行用,它解决的是“渲染开销明显影响交互响应”的问题。
3.3 自动批处理:异步代码里的 setState 不再各管各的
React 18 之前,React 事件回调里的多个 setState 会被合并成一次渲染,但 Promise 回调、setTimeout 这些场景不会自动合并。React 18 把自动批处理覆盖到了所有场景。比如在 fetch 回调里连续执行:
ts复制setLoading(false);
setData(result);
setError(null);
React 18 会合并成一次渲染,减少无意义的 DOM diff。这个新特性不需要额外配置,升级到 18 就生效,但它意味着你在调试时不能根据 console.log 的执行顺序来推断渲染次数,要习惯用 React DevTools 的 Profiler 去观察真实渲染次数。我在实际项目里见过有人为了“避免多次渲染”在异步回调里手动包一层 batch 或者 flushSync,在 React 18 里大部分情况下都是多余的。
3.4 useId、Suspense 这些,按需引入就行
除了上面三个,React 18 还带来了 useId 用于生成稳定的唯一 ID,解决表单 label 和 input 关联在 SSR/hydration 时的 ID 不匹配问题;Suspense 在数据请求场景下可以让组件“等待”而不渲染空白。这些属于锦上添花,初学者不需要一上来全用,只要知道存在,等遇到对应场景再回来看文档就能快速上手。
拿 useId 举个例子,我之前做的一个无障碍表单组件,需要把 label 的 htmlFor 和 input 的 id 关联起来。手动写死一个 id 在组件复用时会冲突,在 SSR 时还可能和客户端不一致。useId 就是解决这个问题的:
tsx复制const id = useId();
<label htmlFor={id}>用户名</label>
<input id={id} type="text" />
这种细节在课程里可能只是一笔带过,但实际项目里做表单、做弹窗、做手风琴时经常用到,提前知道能省不少查文档的时间。
4. 组件和 Hook 的进阶设计:把类型安全变成约束
4.1 先给 props 立规矩:interface 还是 type
课程里某个组件反复强调:props 类型定义是 TypeScript 在 React 里最重要的应用场景。我的推荐是团队风格统一。一般来说,组件的 props 用 interface,因为它支持声明合并,适合描述对象结构;type 更适合联合类型、交叉类型这种没法用 interface 表达的形态。比如:
tsx复制export interface ButtonProps {
label: string;
variant?: "primary" | "secondary" | "danger";
onClick?: () => void;
disabled?: boolean;
}
export function Button({ label, variant = "primary", onClick }: ButtonProps) {
return (
<button className={`btn btn-${variant}`} onClick={onClick}>
{label}
</button>
);
}
这样在其他地方使用 <Button label={123} /> 时,TS 会立刻提示类型错误。一个小技巧:props 里的事件回调不要直接写 Function,尽量写明确的函数签名,比如 (id: number) => void,这样调用方在传函数时就知道参数类型到底该是什么。我自己见过很多代码里回调都写成 Function 或 () => any,虽然编译能过,但类型检查基本失效了。
4.2 泛型组件:让复用不再靠 any
中级项目里最常见的场景是列表类组件。一个 DataList 可能要渲染用户列表、订单列表、日志列表……如果每个列表都单独写一个组件,代码会膨胀;如果都用 any 数组,类型安全就丢了。泛型组件是比较优雅的解法:
tsx复制interface DataListProps<T> {
data: T[];
renderItem: (item: T) => React.ReactNode;
}
export function DataList<T>({ data, renderItem }: DataListProps<T>) {
return <div>{data.map(renderItem)}</div>;
}
使用的时候:
tsx复制const UserList = () => (
<DataList
data={users}
renderItem={(user) => <div>{user.name}</div>}
/>
);
TS 会从 data={users} 推断出 T 是 User,然后 renderItem 的 user 参数自动带出 User 类型,不需要额外标注。这样既保证了复用,也不用放弃类型检查。要注意的是,泛型组件在 .tsx 文件里写 <T> 有时需要写成 <T,>,以避免和 JSX 语法冲突,这是新手比较容易懵的地方。
4.3 自定义 Hook 的类型设计:拿 useLocalStorage 练手
自定义 Hook 是把逻辑抽出来的常用方式。这里的关键是返回值类型要准确。以 useLocalStorage 为例:
tsx复制function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = window.localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initialValue;
});
const updateValue = (next: T | ((prev: T) => T)) => {
setValue((prev) => {
const resolved =
typeof next === "function" ? (next as (p: T) => T)(prev) : next;
window.localStorage.setItem(key, JSON.stringify(resolved));
return resolved;
});
};
return [value, updateValue] as const;
}
这里用 as const 让返回的数组推断成 readonly 元组 [T, (next: T | ((prev: T) => T)) => void],而不是 (T | function)[]。否则拿到外部使用时类型会变宽,调用第二个元素时还要做类型断言,就很烦。这个 Demo 里的 as 断言稍微有点丑,但作为教学示例足够清楚,实际项目里可以再封装得优雅些。
4.4 事件对象的类型,老生常谈但也最容易错
很多新人第一次写 onSubmit、onChange 时不知道类型从哪来。经验是直接使用 React.FormEvent、React.ChangeEvent、React.MouseEvent 等内置类型,并显式标注:
tsx复制const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// ...
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
这些类型都从 @types/react 中来,不需要自己定义。如果你在使用表单库(比如 react-hook-form),它的类型体系会有点不一样,但底层仍然离不开这些原生事件类型。这里有个容易混淆的点:FormEvent 和 ChangeEvent 的泛型参数填的是触发事件的 DOM 元素类型,而不是事件本身的类型。我第一次写的时候填错了,导致 e.target.value 取不到,报错看了半天才反应过来。
4.5 别在 useEffect 里无脑 setState
课程里有一个让我印象很深的点:不要在 effect 里同步更新与 props 直接相关的 state。用课程的一个常见错误示范来解释,有人会把 props 同步到 state:
tsx复制useEffect(() => {
setUser(props.user);
}, [props.user]);
这会导致组件挂载后额外渲染一次,还可能造成状态不一致。更靠谱的做法是“渲染期间派生状态”:如果 user 只是 props.user 的一个临时展示形态,直接在 render 阶段计算,而不是塞进 state。只有当数据是异步获取的(比如 fetch 返回后),才适合在 effect 里 setState,前提是记得处理竞态和清理。这个问题的本质是:useEffect 是“副作用”的执行时机,而不是“状态转换”的工具,搞混了就会产生大量无意义的渲染。
5. 这套课程/实践里最常见的类型问题与排查干货
5.1 事件处理器类型报错,先确认用的是 React 内置类型还是 DOM 原生类型
有时候在 input 的 onChange 里写 (e) => setValue(e.target.value),编辑器会报 e 是隐式 any。原因通常是 tsconfig 开了 noImplicitAny,而事件参数没有被推断出来,常见于你把 handler 单独抽到组件外面时没有参数类型。解决办法就是显式标注 e: React.ChangeEvent<HTMLInputElement>。注意不要直接写 e: any,那就失去了类型检查的意义。
我在课程实践里就犯过这个错:把表单的 onChange 抽成一个共享函数,结果参数没有类型,Vite 开发服务器不会报错,直到跑了 tsc --noEmit 才看到一堆红色波浪线。后来养成习惯,只要 handler 被单独声明,第一件事就是先标类型。
5.2 第三方库没有类型,或者类型过时
这是实践中大概率会遇到的问题。对于一些没有官方类型的库,优先去 DefinitelyTyped 找现成的包:npm i -D @types/xxx。如果找不到版本匹配的,就自己写一个声明文件,在 src/types 下放一个 xxx.d.ts:
ts复制declare module "my-untyped-lib" {
export function doThing(input: string): void;
}
然后把项目里的 import 路径接上。这种补救措施能让你至少不卡在编译阶段,等库作者更新官方类型后再移除声明文件。要注意声明文件里的类型一定要和库的真实行为保持一致,不然会引入新的坑。我见过有人图省事把整个模块声明成 any,结果用的时候根本不知道函数返回了什么,等于白搭。
5.3 路径别名 @/ 在编辑器里提示正常,但运行时找不到模块
这个问题的检查顺序是固定的:先看 tsconfig.json 里有没有删干净 baseUrl,再看 paths 里的相对路径是否相对于 tsconfig 所在文件夹,最后确认 vite.config.ts(或 next.config 等)里有没有配对应的 resolve.alias。通常是因为只改了一边。我记得有一次就是 tsconfig 里配了 paths,但忘了 vite.config.ts 的 alias,结果 npm run dev 直接报模块解析不到,编辑器里却一切正常——因为编辑器用的是 tsconfig 的解析规则。这两个配置必须成对出现。
把这条单独拿出来写,是因为它在“React 18 + TypeScript”新项目里太常见了。尤其是从 cg 或某些脚手架迁移到 Vite 时,新旧配置混在一起,最容易出这种“编辑器正常、构建报错”的诡异现象。
5.4 StrictMode 下 useEffect 执行两次,怎么判断是不是 bug
先区分场景:如果你的 effect 本身只是读取、没有副作用,执行两次无所谓;如果里面有事件绑定、请求、定时器这类带副作用的操作,就要看清理函数有没有写全。事件绑定没清理的表现是 console.log 输出越来越多,或者同一个操作触发多次回调。正确姿势:
tsx复制useEffect(() => {
const onClick = () => setCount((c) => c + 1);
window.addEventListener("click", onClick);
return () => window.removeEventListener("click", onClick);
}, []);
这样在 StrictMode 的两次挂载过程中,第一次挂载的事件会在卸载时被清理,不会出现重复绑定。如果你看到请求发了两遍,也是同一个原因:请求本身没有在清理时 abort。可以在 AbortController 配合下做取消,或至少在 cleanup 里标记 isMounted = false,避免在组件卸载后继续 setState。这个排查思路同样适用于事件监听,双调用机制的本质是“让你发现副作用没有清理干净”。
5.5 TS 版本升级后,旧配置和弃用警告怎么处理
升级 TypeScript 大版本时,最实用的步骤是:升级前先看 release notes 里的 Breaking Changes;升级后跑一次 npx tsc --noEmit,把报错一条条过掉。像这次的 baseurl 弃用警告,就是在 TS 5.0 之后出现的。虽然它现在只是 warning,不影响编译,但迟早要处理。我的习惯是每次新建项目时直接不留 baseUrl,只写 paths,省得以后迁移。
另外,如果你用的是 Vite,它的 esbuild 不会做类型检查,所以类型检查要靠单独的 tsc --noEmit。建议在 package.json 的 build 脚本里加一句 "typecheck": "tsc --noEmit",每次提交前跑一遍,能避免很多把类型错误带到 CI 的尴尬。这个习惯养成以后,你的项目类型健康度会比大多数只靠编辑器提示的项目高出不少。
如果你正好也在跟着 Mosh 的这套中级课程,或者正准备把项目从 JS 迁到 TS,我最想分享的一句话是:别急着把代码全部改完,先拿一个组件试水。当你第一次看到一个传错的 props 在编辑器里标红,而不是在浏览器控制台里报 undefined 时,那个体验会说服你继续走下去。React 18 的并发特性和 TS 的静态约束,本质上都是让你在规模变大的时候,依然能把复杂度控制在手里。
