最近在复盘 React 18 的中级知识点,顺手把 code with mosh 的 React: Intermediate Topics 第二部分完整过了一遍。这门课我早些年看过 JS 版,这次带着 TypeScript 重新梳理,发现同样的主题在不同的类型系统约束下,理解深度完全不一样。所以这篇既是课程核心内容的拆解,也是一份结合 TS 实践的操练笔记,适合已经能用 React 写页面、但对 hooks 原理、组件复用、状态管理还处于“能跑但说不清为什么”阶段的人参考。
1. 中级主题到底在讲什么——先把学习边界搞清楚
1.1 初级到中级的门槛在哪里
很多人的 React 学习路径是:看官方文档 → 写几个组件 → 会用 useState/useEffect 调接口 → 觉得自己会了。但真正进入中级之后,你会发现初级阶段的“会”其实只是记住了 API 的用法,而没有理解 React 的运行机制。
我理解的“中级”分水岭主要有三个:
- 从“会用 hook”到“理解 hook 的依赖关系”。useEffect 为什么需要依赖数组?依赖写错会发生什么?这些在初级项目里可能只是警告,但在复杂业务里就是 bug 温床。
- 从“组件能复用”到“逻辑能复用”。初级阶段的复用是复制粘贴组件,中级是提炼自定义 hook,高级是设计泛型组件和组合模式。
- 从“状态能改”到“状态可预测”。多个 useState 散落各处,组件多了状态互相牵制,这时候就需要统一的状态管理方案。
Mosh 这门 Intermediate Topics 的 Part 2,刚好就是围绕着这几个分水岭展开的。它不是那种“进阶高深技巧”的哗众取宠,而是把日常工程里真正高频的核心场景,用系统性的方式串了一遍。
1.2 Part 2 实际覆盖的核心主题
我整理了一下 Part 2 的知识点图谱,大致可以分成五块:
- Hooks 深度使用:useRef、useMemo、useCallback 的适用场景,以及如何用自定义 Hook 抽取重复逻辑。
- 组件设计模式:比如如何用 children 属性组合组件,如何设计可复用的通用组件。
- 状态管理方案选型:Context API 搭配 useReducer 的实践,以及和第三方库(比如 Redux / Zustand)的对比。
- TypeScript 与 React 的深度结合:类型标注、泛型组件、类型收窄,这些都是 JS 教程不会专门讲但在工程里躲不掉的部分。
- 前后端交互的规范写法:处理 loading、error、数据缓存,以及如何组织 API 调用层。
这五块内容单独看似乎都能在网上找到资料,但难的是它们彼此之间是有依赖关系的。Mosh 的课程编排好就好在,它是用“逐步重构一个项目”的方式来讲的,你看到的是一个组件从状态混乱到逻辑清晰的过程。
1.3 为什么强调 React18 + TypeScript 这个组合
这年头如果在招聘 JD 里看到 “熟悉 React”,潜台词基本就是 “熟悉 React + TypeScript”。React 18 是全面拥抱并发渲染的版本,新增的自动批处理、startTransition、useId 这些特性,本身并不难学,但配合 TypeScript 之后,对类型的精准度要求就上来了。
举个最简单的例子:useState 初始化时的类型推导。JS 里写 useState([]) 完全没问题,但 TS 里你就会想,这个数组到底是什么类型?是 never[] 吗?在实际开发中这种问题一天能遇到十几次。Intermediate Topics 这门课把 TS 融入每个 demo,而不是单独开一章讲 TS 语法,这一点我觉得是很关键的,也是我这次用 TS 重看课程收获最大的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心知识点拆解与实操笔记
2.1 useState / useReducer 的类型标注:别偷懒写 any
先说一个最常见的反面教材。很多人为了省事,在 useState 的类型不确定时直接写:
typescript复制const [user, setUser] = useState<any>(null);
表面上问题解决了,但当你需要修改这段代码的时候,user 身上所有字段都是 any,编辑器不报错,等于放弃了 TypeScript 的核心价值——编译期检查。
在实际项目中,我更推荐的做法是明确接口类型,然后用联合类型标注状态可能为空的场景:
typescript复制interface User {
id: number;
name: string;
email: string;
}
const [user, setUser] = useState<User | null>(null);
这样当你访问 user.name 时,TS 会提醒你先做空值判断。这种“被迫处理边界情况”的体验,恰恰是写 React 项目想要的安全感。
当状态逻辑变复杂时,useReducer 是更好的选择。它适合那种“多个状态字段互相影响”的场景,比如一个表单的多个字段校验。Mosh 在课程里用一个购物车示例展示了 useReducer 的核心价值:把状态更新的逻辑集中到 reducer 函数里,组件只负责 dispatch action。配合 TS,action 类型可以用可辨识联合来实现,非常优雅:
typescript复制type CartAction =
| { type: 'ADD_ITEM'; payload: Product }
| { type: 'REMOVE_ITEM'; id: number }
| { type: 'CLEAR' };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.payload] };
case 'REMOVE_ITEM':
return { ...state, items: state.items.filter(item => item.id !== action.id) };
case 'CLEAR':
return { items: [] };
default:
return state;
}
}
这种写法带来的好处是:当你在组件里 dispatch 时,TS 会自动根据 type 字段推断出后面该有的参数。写错 action 类型或者漏了参数,编辑器直接红色波浪线提示,而不是运行到页面白屏才抓瞎。
2.2 自定义 Hook 的正确姿势:封装逻辑与类型推导
Mosh 在课程里反复强调一个理念:自定义 Hook 不是必须,但它是逻辑复用的核心工具。
我见过很多人封装自定义 Hook 时,习惯把所有东西都 return 出去,组件里想怎么用就怎么用。这其实是反面教材。一个好的自定义 Hook,应该做到两点:
- 只暴露必要的状态和方法,内部实现细节不泄露。
- 返回值有清晰的类型,最好是能自动推导的,不需要使用者手动标注。
举个例子,课程里的点击外部关闭弹窗这个场景,通过 useRef 加 useEffect 封装成一个 hook:
typescript复制function useClickOutside<T extends HTMLElement>(onClose: () => void) {
const ref = useRef<T | null>(null);
useEffect(() => {
function handleClick(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [onClose]);
return ref;
}
这个 hook 用到了泛型 T extends HTMLElement,调用方不需要关心 ref 的具体类型,ref.current 的类型会自动推断为对应的元素类型。这就是 TS + 自定义 Hook 组合的魅力:封装逻辑的同时,把类型也封装进去。
经验之谈:封装 Hook 时,如果返回值是一组状态操作,可以考虑返回元组,类似 const [count, increment, reset] = useCounter(0),用起来跟 useState 一样顺手。如果返回值是一堆相关状态,返回对象更合适。没有绝对标准,核心是“调用方用着顺手”。
2.3 useRef 的高级用法:从 DOM 操作到数据存储
很多初学者把 useRef 当 document.querySelector 用,只用来拿 DOM 节点。但中级开发者必须掌握一个更重要的用法:useRef 是存储跨渲染可变数据的容器。
它的核心特性是:修改 .current 不会触发重新渲染,但值可以在组件的多次渲染之间保留。这个特性在几个场景中非常有用:
- 保存定时器 ID,在组件卸载时清理。
- 记录上一次的状态值。
- 缓存不需要展示给 UI 的临时数据。
课程里有个很经典的例子:用 useRef 防止用户在请求未完成时重复提交。
typescript复制const isSubmittingRef = useRef(false);
async function handleSubmit() {
if (isSubmittingRef.current) return;
isSubmittingRef.current = true;
try {
await api.submit(data);
} finally {
isSubmittingRef.current = false;
}
}
如果把 isSubmitting 放进 useState,每次修改都会触发渲染,虽然也能实现防重复,但会引入不必要的渲染开销。用 useRef 存储这类“不需要展示”的状态,性能开销几乎为零。
这里有个注意点:不要在渲染过程中读取或修改 useRef 的值,因为它不会触发渲染,可能导致 UI 与数据不一致。它只适合在事件回调、异步操作、effect 清理函数中使用。
2.4 泛型组件:真正可复用的组件是怎么设计的
Part 2 里有一个话题让我印象特别深:如何用 TypeScript 写一个泛型组件。这也是很多 JS 出身的人转向 TS 后最大的坎。
举个场景:一个列表组件,希望它同时能渲染用户列表、订单列表、商品列表。具体每一项的渲染逻辑不同,但整体的列表布局、loading、空状态逻辑是一样的。
typescript复制interface ListProps<T> {
data: T[];
renderItem: (item: T) => React.ReactNode;
loading?: boolean;
emptyText?: string;
}
function List<T>({ data, renderItem, loading, emptyText = '暂无数据' }: ListProps<T>) {
if (loading) return <div>加载中...</div>;
if (data.length === 0) return <div>{emptyText}</div>;
return <div>{data.map(renderItem)}</div>;
}
这里关键点是函数的泛型语法。在 .tsx 文件里,function List<T> 这种写法没问题,但如果你用箭头函数:
typescript复制const List = <T,>({ data, renderItem }: ListProps<T>) => { ... };
注意这个泛型后面的逗号 <T,>,不加的话 JSX 解析器会把它当成 JSX 标签,这是 TS + React 里一个非常经典的坑。Mosh 课程里也特意提了这一点,我当时学的时候就在这吃过亏。
泛型组件的价值在于:类型安全在库的边界处被守住。使用方传入 User[] 时,renderItem 的参数类型自动推导为 User,写错了编辑器会立即报错。在写通用组件库、UI 库、表格组件、表单组件时,泛型几乎是必备技能。
3. 状态管理进阶:Context 组合 useReducer 的落地实践
3.1 什么时候值得引入 Context + useReducer
“我需要用 Redux 吗?” 这个问题几乎每个 React 开发者都纠结过。Mosh 在 Intermediate Topics 里给出的判断标准很实用:如果你的状态需要被多个层级的组件共享,并且更新逻辑复杂,才值得引入全局状态管理。
React 内置的 Context + useReducer 组合,在很多中小型项目中完全够用,而且比引入 Redux 更轻量、心智负担更小。我在实际项目里的经验是:
- 状态只在父子组件间传递 → 用 props 就行。
- 状态需要在兄弟组件或深层组件间共享 → 用 Context。
- 状态更新逻辑复杂、涉及多个字段联动 → useReducer + Context。
课程里的购物车例子特别典型:商品列表页面需要往购物车添加商品,导航栏显示购物车数量,购物车页面需要展示明细并支持删除。这三个组件层级并不相同,用 props 一层层传的话,中间不知道要经过多少个无关组件,代码会非常痛苦。
3.2 类型安全的 Context 写法:一张完整的 TS 使用模板
先给出一个我实际项目里一直在用的模板,这个模板经过了多次迭代,自认为比较稳妥:
typescript复制import { createContext, useContext, useReducer, ReactNode, Dispatch } from 'react';
interface CartState {
items: Product[];
}
type CartAction =
| { type: 'ADD'; product: Product }
| { type: 'REMOVE'; productId: number };
const CartContext = createContext<{
state: CartState;
dispatch: Dispatch<CartAction>;
} | undefined>(undefined);
const cartReducer = (state: CartState, action: CartAction): CartState => {
switch (action.type) {
case 'ADD':
return { items: [...state.items, action.product] };
case 'REMOVE':
return { items: state.items.filter(item => item.id !== action.productId) };
default:
return state;
}
};
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return <CartContext.Provider value={{ state, dispatch }}>{children}</CartContext.Provider>;
}
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart 必须在 CartProvider 内使用');
}
return context;
}
这个模板有几点值得注意:
- context 默认值是
undefined,而不是空对象或 null,这样能强制要求业务代码包在 Provider 中。 useCart里做了空值判断并抛出错误,这是很好的防御性编程,如果业务代码不小心在 Provider 外层用了这个 hook,错误信息会非常明确。Dispatch<CartAction>这个类型是 useReducer 的内置类型,这样使用方在 dispatch 时,能自动获得 action 参数的完整提示。
3.3 拆分 Context 避免无效渲染
随着项目变大,很多人会发现 Context 导致组件渲染次数变多。这个问题的根源往往不是 Context 本身,而是把太多状态放在同一个 Context 里。
比如你的 context 里有用户信息、购物车、主题配置,购物车一变化,所有消费了 context 的组件都会重新渲染,即使它们只关心用户信息。
解决办法是拆分 context。把“低频更新但全局共享”的状态(如用户信息、主题)和“高频更新”的状态(如购物车、消息列表)分开。这是 Context 使用中比较进阶但性价比极高的优化。
Mosh 在课程中给出了另一个偏操作层面的验证方法:用 React DevTools 里的 Profiler 看每次状态更新后,哪些组件发生了 re-render。如果发现一个小组件因为消费了大型 context 而被频繁渲染,先考虑拆分 context,而不是急着引第三方库。
4. React18 + TypeScript 实操中的常见问题排查实录
4.1 baseUrl 弃用警告:tsconfig 里的历史遗留
最近圈里一个比较热的搜索词是 “option 'baseurl' is deprecated and will stop functioning in typescript 7.0”。这是 TypeScript 5.x 版本的一个弃用警告,很多老项目升级后控制台会蹦出来。
在 React 项目里,很多人之前习惯在 tsconfig 里配:
json复制{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@/*": ["*"]
}
}
}
新版 TypeScript 对这个方案不推荐了,因为模块解析已经支持直接基于 paths 的相对路径解析,baseUrl 不再是必须项。我处理得好之后的做法是把它移除,只保留 paths,并让路径映射相对于 tsconfig 所在目录:
json复制{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
测试下来没有影响现有代码,而且警告彻底消失了。老项目升级 TS 遇到这个警告,不用慌,按这个思路处理就行。
4.2 API 返回数据的类型处理:断言还是收窄
这是新手问得最多的问题之一。调用接口拿数据时,经常会看到这样的代码:
typescript复制const data = (await api.getUser()) as User;
用 as 断言处理 API 返回数据,短期内很省事,但隐患在于:如果接口的返回结构变了(比如后端把 name 字段改成了 nickname),as 断言不会在编译期报错,运行时会悄悄变成 undefined,bug 排查成本极高。
更稳妥的做法是:先用泛型标注接口返回类型,再对必要字段做运行时校验。
typescript复制interface ApiResponse<T> {
data: T;
code: number;
message: string;
}
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/user/${id}`);
const json: ApiResponse<User> = await res.json();
return json.data;
}
对于关键业务数据,可以在运行时做一次简单校验,比如:
typescript复制function isUser(data: unknown): data is User {
return typeof data === 'object' && data !== null && 'id' in data && 'name' in data;
}
这种类型谓词(type predicate)写法,能提醒你处理数据格式异常的问题,是工程化项目里一个比较重要的习惯。
4.3 闭包陷阱与 useEffect 依赖数组:经典翻车现场
React 的闭包陷阱,我用一句话概括就是:effect 或回调函数捕获的是它创建时的那次渲染的 props 和 state。
最典型的翻车代码是:
typescript复制const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖数组是空的
这段代码里的 count 永远停在 0。因为 effect 只在挂载时执行一次,闭包捕获的 count 是初始值 0。修复方式是依赖数组加上 count,或者用函数式更新:
typescript复制const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(timer);
}, [count]);
依赖数组里的依赖越少越好,但如果必须依赖某个状态,与其用数组里塞满依赖的丑写法,不如想想能不能通过规范数据流来避免。比如把需要读取的最新值放进 ref 里,或者把逻辑收敛到 reducer / 自定义 hook 里。
Mosh 在课程里也强调了:useEffect 的依赖数组不是装饰,它是 effect 与渲染周期同步的协议,这个理解能帮你避掉大半与 effect 相关的 bug。
4.4 useMemo / useCallback 的过度优化:性能负担反而更大
Intermediate Topics 里专门讲到了 useMemo 和 useCallback,但 Mosh 的立场和很多社区大佬一致:不要过度使用。
useMemo 和 useCallback 本质上是缓存。缓存是有代价的——React 需要额外分配内存来保存缓存的结果,并且每次渲染时还需要对比依赖项是否变化。如果计算本身很快,这个代价可能比重新计算还高。
我见过不少代码,所有函数都包了一层 useCallback,所有计算都包 useMemo,看着很专业,实际是把 JavaScript 引擎的 JIT 优化空间给堵了,有时性能反而更差。
我的个人经验是两个判断标准:
- 只有当 useMemo 依赖的变量是“渲染成本较高的对象/计算”时,才值得用。
- useCallback 主要用于配合 React.memo 做子组件渲染优化,或者作为自定义 hook 返回值给调用方时,保证引用稳定。如果你的子组件不是 React.memo,useCallback 的意义就很小。
更重要的是:这些优化都应该在项目性能分析之后进行。先用 Profiler 定位到性能瓶颈,再针对性地加优化,而不是眉毛胡子一把抓。
5. 学完中级之后还能怎么练——一个趁手的前端落地清单
课程看完之后最怕的就是“眼睛会了,手不会”。这里给你一份我学完之后亲手整理的项目练手清单,每一个都是把课程知识内化的好场景:
- 任务管理面板:用自定义 Hook 封装 localStorage 读写,用 useReducer 管理任务状态,涉及多个层级组件共享数据。
- 购物车完整流程:商品列表 → 购物车详情 → 全局购物车数量角标,用 Context + useReducer 落地,注意拆分 Context。
- 通用表格组件:用泛型写一个 Table 组件,接收任意类型的行数据,通过 render prop 或函数式列配置完成自定义渲染。
- 滚动加载列表:用 useRef 做无限滚动的容器探测,配合自定义 Hook 封装数据请求、loading 状态、错误重试逻辑。
练手的过程里,时刻问自己这几个问题:哪些状态是真正需要全局共享的?哪些逻辑值得抽成自定义 Hook?数据流是一目了然的还是有多个组件在偷偷修改状态?这些问题想清楚了,React 的水平就上了一个档次。
我个人在实际操作中的体会是,React 的中级内容最值钱的部分并不是某个具体的写法或者 API,而是一套“什么时候用什么方案”的判断力。这套判断力的建立,光靠看一门课不够,还需要自己在项目里踩几次坑、调几个性能问题,才能真正长到身上。如果你也在学这门课,建议别开倍速,把每个 demo 自己在编辑器里敲一遍,改一改、破坏一下再看报错,你会发现收获比单纯刷视频多得多。
