别背结论,把它推一遍
下面每一个知识点都用同一个节奏走:先给一句结论,再给一段能跑的例子, 然后解释"为什么会这样",最后一条面试可以直接说出口的答法。 带 ★ 的是几乎每轮都会问的。
JSX 到底编译成了什么
所以 <div /> 里的 div 是字符串,
而 <MyComp /> 里的 MyComp 是一个变量引用——
这解释了两件事:组件必须先定义再使用;以及 <mycomp /> 之所以被当成原生标签,是因为小写开头会被当作字符串。
- 属性名是驼峰的:
className、htmlFor、tabIndex,因为对象 key 不能有连字符(class又是 JS 保留字)。 - style 传对象:
style={{ fontSize: 14 }},不是字符串。 - 表达式用
{},里面只能是表达式,不能写if语句(要写就用三元或提前算好)。 - element 是"描述"不是"实例":它不可变,只是告诉 React "我想要什么",React 再去决定怎么改 DOM。
createElement / jsx() 的语法糖,编译产物是一个描述 UI 的普通对象,包含 type、props、key、ref。React 拿到这个对象树之后,通过 reconciler 对比新旧树,最终才产生 DOM 操作。所以 JSX 的性能开销主要是"创建对象"和"对比",不是"解析字符串模板"。组件的本质:一个被反复调用的函数
关键推论:函数内部声明的所有变量、对象、函数,每次渲染都是全新的。 它们不是"保存下来的状态",只是这次调用过程中的局部变量。 这一点直接导致了后面好几节的坑。
- props 只读:不能在子组件里
props.title = 'x'。数据只能单向往下流,要改就往上抛回调。 - 组件名必须大写开头:否则被当成字符串标签。
- 函数组件没有 this:所以不存在
bind、也不存在this.setState。
state 是一次渲染的快照★
useState 返回的值,是这一次渲染时的 state 快照。
它是个常量,不会因为你调用了 setState 就在当前函数里变了。
点击后界面变成 1
一秒后控制台打印 0 ← 不是 1
因为 handleClick 这个闭包捕获的是这次渲染的 count,
它的值在函数执行时就已经固定为 0。setCount(1) 触发了一次新的渲染,
新渲染里有一个新的 count = 1,但那是另一次函数调用,跟这个闭包无关。
✕ 直觉上以为
count 像 let count = 0,是个变量,改了就是新的。
✓ 实际是
每次渲染都 const count = 当前值,一次次全新生成,互不影响。
想在异步回调里拿到最新值,有三条路:用函数式更新
setCount(c => c + 1)(如果只是想"基于最新值算");
用 useRef 存一份最新值(如果要读);
或者把逻辑写进 useEffect,让它跟随 state 变化重新执行。
三次 setState 为什么只加一★
要注意的是:批处理不是为了省那点计算,而是为了"同一个事件里多次改状态,界面不应该闪中间态"。
React 18 把这个行为扩展到了所有场景——setTimeout、Promise、原生事件里也会自动批处理。
setCount(count+1) 时,三次传入的都是基于同一份旧快照算出的同一个值,后写的覆盖先写的,所以只加一。传函数 setCount(c => c+1) 时,React 会把函数按顺序依次作用在上一次的结果上,所以加三。这也是"新状态依赖旧状态时要用函数式更新"的原因。"重渲染"到底发生了什么
但 Child 对应的 DOM 一个字节都不会变——因为 diff 结果为空
这是 React 的默认策略:父组件重渲染,所有子组件都会跟着重新执行,
不论 props 有没有变。React 认为"重新执行函数的成本"远小于"漏掉一次更新导致界面错误"的成本。
真要拦下来,用 React.memo 做 props 浅比较,或者把不变的部分
用 children 传进去(元素对象没变,React 会跳过整个子树)。
key 是列表项的身份证★
key 来判断"这是不是原来那个"。
用 index 当 key,等于告诉 React"身份就是位置"——一旦顺序变了,身份就错乱。
第一行输入框里仍然是 原来 A 那一行的内容
→ 用户看到的内容和实际数据错位了
原因:key=0、1、2。删掉 A 之后,新的列表 key 变成 0、1。 React 一看"key 0 还在,key 1 还在",于是复用了原来的 DOM 节点, 只把文本内容从 A 改成 B、从 B 改成 C。但输入框里的值是用户在 DOM 里敲的, React 不去碰它,那个 3 个字符就留在了第一个节点上。
✕ key={index}
列表不增删、不排序时没问题。一旦插入、删除、排序,就会出现状态错位、动画错乱、输入框串位。
✓ key={item.id}
身份跟着数据走,节点会跟着移动、复用,输入框里的值和动画状态都跟着"那一条数据"。
- key 只在兄弟之间唯一,不需要全局唯一。
- key 不会作为 props 传给组件:子组件里
props.key永远是 undefined。 - 改变 key 可以强制重建:想让一个组件彻底重置(清空内部状态),换个 key 是最干净的做法。
- 列表 diff 只做同层比较:React 不会跨层级移动节点,所以别把列表包在频繁变化的父节点里。
类组件的生命周期★
constructor、render、
componentDidMount、componentWillUnmount。
剩下的那些——尤其是 getDerivedStateFromProps 和
getSnapshotBeforeUpdate——是 Fiber 引入"渲染可中断"之后打的补丁。
更新:getDerivedStateFromProps → shouldComponentUpdate → render → getSnapshotBeforeUpdate → componentDidUpdate
卸载:componentWillUnmount
子组件出错:getDerivedStateFromError → componentDidCatch
- constructor 里不要发请求:它属于 render 之前,React 可能因为并发渲染丢弃这次结果,请求就白发了。发请求是
componentDidMount的事。 - componentDidMount 在 DOM 就绪之后,可以读元素尺寸;但它在浏览器绘制之前同步执行,所以在这里改样式会阻塞首屏。
- getSnapshotBeforeUpdate 必须和 componentDidUpdate 一起用:它拿到的是"DOM 已经更新、但还没绘制"的瞬间,用来记录滚动位置这类东西。返回值会变成
componentDidUpdate的第三个参数。 - shouldComponentUpdate 只是性能优化开关,写错会直接导致界面不更新。现在更推荐
React.PureComponent或React.memo。
✕ 被废弃的三个 W
componentWillMountcomponentWillReceivePropscomponentWillUpdate
16.3 起标 UNSAFE_ 前缀,17 起移除。原因是 Fiber 的 render 阶段可中断、可丢弃——这三个钩子在 render 之前被调用,一旦这次渲染被高优先级任务打断重来,它们就会执行多次,里面写的副作用(请求、订阅、setState)自然就重复了。
✓ 各自的替代品
- willMount → 直接用
constructor或componentDidMount - willReceiveProps →
getDerivedStateFromProps - willUpdate →
getSnapshotBeforeUpdate
注意 getDerivedStateFromProps 是 static 的,拿不到 this,就是逼你别在里面做副作用——只能根据 props 算 state。
这其实是一条主线:凡是"可能在 render 期间被调用多次"的地方,都不许写副作用。 Fiber 之后 React 随时可能把一次渲染丢掉重来,所以能安全放副作用的位置只剩 commit 阶段(componentDidMount / componentDidUpdate / useEffect)。
Hooks 怎么写同样的逻辑
- 构造函数里的 state 初始化 →
useState(初始值)。注意初始值只在第一次渲染生效,不是每次算。 - componentDidMount →
useEffect(fn, [])。区别:effect 在绘制之后异步跑,didMount 在绘制之前同步跑。 - componentDidUpdate →
useEffect(fn)(每次)或useEffect(fn, [a, b])(a/b 变了才跑)。 - componentWillUnmount → useEffect 里
return的清理函数。它比 willUnmount 多做一件事:依赖变化时也会先跑。 - shouldComponentUpdate →
React.memo(组件级)或useMemo(值级)。 - getSnapshotBeforeUpdate →
useLayoutEffect。两者都在"DOM 已更新、未绘制"的窗口里同步执行,适合读布局。 - getDerivedStateFromProps → 没有直接对应。React 的立场是:能不用就不用,改成"受控组件 + 在渲染时直接算"。
<StrictMode> 会把挂载流程走两遍:
render → effect → 清理 → effect。类组件对应的是
componentDidMount → componentWillUnmount → componentDidMount。
这不是 bug,是故意让你发现"清理写漏了"。生产环境不会双调用。
useEffect 什么时候执行★
useEffect 在 DOM 更新完成、浏览器绘制之后才异步执行。
所以它不会阻塞画面,也不该在里面做需要"在用户看到之前"完成的布局操作。
- 不传第二个参数:每次渲染后都执行一次。
- 传
[]:只在挂载后执行一次(开发环境 StrictMode 下会执行两次,见下文)。 - 传
[a, b]:挂载后执行,之后只要 a 或 b 变了就再执行。 - 清理函数的时机:不是"卸载时才跑",而是"下一次 effect 执行前 + 卸载时"。所以它清理的永远是上一次那轮副作用。
✕ 把它当生命周期背
"相当于 componentDidMount"——这只对空依赖的情况成立,而且掩盖了真正的心智模型。
✓ 正确的心智模型
"让外部世界和当前 state 保持同步"。每次渲染都有一个对应的 effect,依赖变了就重新同步一次。
React 官方更推荐的描述是"同步"而不是"生命周期":你不是在某个时刻做某件事, 而是在描述"当这些值是这样时,外部世界应该长这样"。 这样写出来的清理逻辑天然是对的。
useEffect(默认选它)
绘制之后异步执行。不阻塞首屏,适合数据请求、订阅、日志、定时器。绝大多数场景用它。
useLayoutEffect
DOM 更新后、绘制之前同步执行。会阻塞绘制。适合"读布局再改样式"——比如测量元素宽高后立即调整,避免闪一下。
依赖数组的三个坑★
Object.is(引用相等)。
而组件里每次渲染都会新建对象和函数,于是"值没变但引用变了",依赖永远判定为变化。
这个坑很隐蔽:代码不报错、不循环,只是发出去的数据永远是旧的。
解决办法不是加 msg 进依赖(那会导致每次输入都重连),
而是用 useRef 保存最新值,或者用函数式更新。
useRef:不惊动渲染的盒子
useRef 返回一个跨渲染保持同一个的对象,改它的
.current 不会触发重渲染。它适合存"渲染不关心的东西"。
state 打印:0 0 0(每次都是那次渲染的快照)
界面显示:3
- 拿 DOM:
<input ref={inputRef} />,之后inputRef.current.focus()。 - 存定时器 id / 上一次的值:这些不需要触发渲染。
- 存"最新值"绕开闭包:
latest.current = count每次渲染同步一次,异步回调读到的就是最新的。 - 不参与 diff,也不出现在依赖里:ref 的 current 变化不会让 React 知道任何事。
memo / useMemo / useCallback★
React.memo(Component)
包组件。props 浅比较没变就跳过这个组件的渲染。
useMemo(fn, deps)
包一个计算。依赖没变就返回上次缓存的值,不再重算。
useCallback(fn, deps)
包一个函数。等价于 useMemo(() => fn, deps),返回同一个函数引用。
memo 完全没起作用——因为 props 的引用每次都变
- memo 是浅比较:props 里嵌套对象变了,它发现不了。要深比较得自己传第二个参数。
- 不要无脑加:每次渲染都跑一遍依赖比较、还要缓存结果,本身有成本。计算很便宜时(比如
a + b)用 useMemo 反而更慢。 - 正确性不能依赖 useMemo:官方明确说它可能被丢弃、可能在未来被重新计算。它只保证性能,不保证语义。
- children 优化比 memo 更便宜:不动的那部分直接作为 element 传进去,React 会整棵跳过。
Context 不是状态管理,它有代价
Provider 的 value 一变,
所有消费这个 Context 的组件都会重渲染,不管它实际只用了其中一小部分。
- value 一定要包 useMemo:否则每次渲染都是新对象,Provider 下所有消费者无条件重渲染。
- 拆 Context:按变化频率拆成多个,
UserContext、ThemeContext分开,互不牵连。 - Context 解决的是"透传",不是"状态管理"。它没有选择器、没有细粒度订阅,高频更新的数据放进去会拖垮整棵树。
- 高频场景换方案:外部 store(Zustand、Jotai)用订阅模型,只有真正用到那块数据的组件才更新。
自定义 Hook 与两条铁律★
为什么"按顺序"这件事这么关键?因为 React 没有名字可以对应,它只能记住 "这个组件的第 1 个 Hook 是 useState,第 2 个是 useEffect……"。
之后 isLogin=true:顺序变成 [useState, useState, useEffect]
→ React 把 useEffect 的槽位错当成 useState,报 "Rendered more hooks than during the previous render"
- 只能在顶层调用:不能放在 if、for、while、回调里。要条件执行,把条件写进 Hook 内部。
- 只能在 React 函数里调用:组件或自定义 Hook 内部,不能在普通函数里。
- 自定义 Hook 必须以 use 开头:这条不是审美要求,是让 ESLint 能检查上面两条规则的唯一依据。
受控 vs 非受控
✓ 受控适合
需要实时校验、联动(比如输入后即时过滤列表)、格式化输入、禁用提交按钮。代价:每次按键都触发一次重渲染。
✓ 非受控适合
大表单、文件上传(<input type="file"> 只能是非受控)、只关心提交时的一次取值。代价:无法在输入过程中干预。
- 只给 value 不给 onChange:输入框会变成只读,控制台还会警告。这是新手最常撞的墙。
- 受控组件重置:改 state 为
''就清空了;非受控得靠换 key 或者手动改 DOM。 - 性能不是主要矛盾:普通表单的输入重渲染成本很低,除非字段极多,否则优先受控——可预测性好得多。
React 18:批处理扩展与并发
如果某些场景确实需要"立刻渲染"(比如要马上读 DOM 布局),
用 flushSync 强制同步刷新。但这是逃生舱,别日常用。
- 没有并发时:一个大列表过滤会占住主线程,输入框卡住——因为 render 一旦开始就不能停。
- 有并发时:render 可以被中断,用户继续打字就丢弃上一轮没算完的结果,重新按最新输入算。
useDeferredValue是另一条路:不控制更新,而是"让某个值慢半拍",适合没法包在 startTransition 里的场景(比如值来自 props)。- Suspense + lazy:代码分割和异步依赖的声明式处理,配合并发可以做"避免加载态闪烁"的体验。
Fiber:React 为什么能"打断自己"
- Fiber 节点是"工作单元":除了对应一个组件实例,还存了 state、props、effect 副作用链表、优先级等信息。
- 双缓冲树:内存里同时有 current 树和 workInProgress 树,算完再整体切换,保证界面永远看到完整状态。
- 两阶段:render(可中断、可丢弃)→ commit(同步、不可中断,因为要保证 DOM 一致)。
- 这也是 useEffect 异步的原因:副作用不能放在可中断的阶段里执行,否则可能执行一半被丢弃。
Fiber 的 render 阶段在做什么★
workInProgress 让出主线程;
commit 阶段拿到算好的效果链,一口气同步刷到 DOM,中途绝不打盹。
- beginWork 负责"往下":根据新的 props / state 算出子节点,调用组件函数或类组件的 render,拿到的结果和上一次的 child 做 diff(reconcileChildren),能复用就复用,不能就标记插入、删除。
- completeWork 负责"往上":给宿主组件创建真实的 DOM 节点(注意:只是创建,还没插进文档),把子树的变更标记汇总到自己身上(
subtreeFlags),然后回到父节点。 - 为什么必须先下后上:父节点要知道"我这棵子树里有没有活要干",才能决定 commit 阶段要不要进来。标记是自下而上冒泡的。
- 时间切片只切 render 阶段:commit 阶段不能停,因为 DOM 已经改了一半,停下来用户会看到残缺界面。
commit 的三段,各自能干什么
- before mutation:DOM 还没动。这里跑
getSnapshotBeforeUpdate,用来记录"变化之前的滚动位置"。 - mutation:真正增删改 DOM。所有插入、删除、属性更新都在这一段完成。
- layout:DOM 已改完、浏览器还没绘制。这里跑
componentDidMount、componentDidUpdate、useLayoutEffect,可以同步读布局并立刻再改样式,用户看不到中间态。
✕ 常见误解
- "Fiber 让渲染变成异步的" —— 渲染本身还是同步的计算,被切成片的是整个 render 阶段的推进节奏。
- "commit 也能被打断" —— 不能,它是同步的。
- "双缓冲就是缓存了一份 DOM" —— 缓存的是 Fiber 节点树,不是 DOM。
✓ 准确的说法
- current 树是屏幕上正在显示的那棵,workInProgress 是内存里在算的那棵。
- 两棵树的节点通过
alternate互相指向,切换只是把root.current换个指针。 - 算到一半被丢弃时,workInProgress 直接扔掉重来——这正是副作用不能放在 render 阶段的原因。
tag(函数组件 / 类组件 / 宿主节点…)、
key、stateNode(对应的真实 DOM 或组件实例)、
三个指针 return / child / sibling、
alternate(另一棵树里的自己)、
memoizedState(Hooks 链表就挂在这)、
memoizedProps / pendingProps、
updateQueue(待处理的更新)、
flags / subtreeFlags(自己 / 子树的副作用标记)、
lanes(优先级)
记住一句就能应付大部分追问:render 阶段算的是"要变成什么样",commit 阶段做的是"真的变"。 beginWork 和 completeWork 都只在前者里,所有能摸到真实 DOM 和用户可见效果的事情都在后者。
useReducer:状态太多时的退路
useState 互相依赖时,会出现"改 A 必须同时改 B"的隐式约定。useReducer 把所有转移逻辑收进一个纯函数,让"哪些状态组合是合法的"变成代码里能写出来的东西。多个 useState
- loading、data、error 三个状态各自独立
- 可能出现 loading 和 error 同时为真
- 每加一种状态就要检查所有 setState 的地方
- 逻辑散在多个事件处理函数里
useReducer
- 一个 state 对象,状态组合由类型约束
- 非法组合根本构造不出来
- 所有转移集中在一个纯函数里,能单独测
- dispatch 引用稳定,传给子组件不用 useCallback
- 什么时候该换:状态的更新逻辑有多个分支、多个字段要一起改、或者下一个状态依赖上一个。这种情况用多个 useState 一定会漏。
- reducer 必须是纯函数:不能在里发请求、读 localStorage。副作用留在事件处理函数里做完,再 dispatch 结果。
- dispatch 的引用永远不变:React 保证这一点。所以把它传给深层子组件不用包
useCallback,这是相对回调函数的一个实际优势。 - 不该换的情况:一两个独立的状态、互相不依赖的 UI 开关。这时 useReducer 只是多写二十行样板代码。
错误边界与 Portals
- 为什么错误边界必须是 class:它依赖
componentDidCatch和getDerivedStateFromError这两个只有 class 才有的生命周期。到目前为止 React 还没提供函数式等价物,所以社区库(react-error-boundary)也是内部包一个 class。 - 要放几层:至少三层——根节点一层兜住整页白屏、路由级一层避免一个页面挂了影响导航、以及关键区块各一层(侧边栏、主内容互不影响)。只放根节点的话,一个小组件报错会让整页变成错误页。
- Portals 的事件:
createPortal出来的 DOM 在 body 下,但事件仍然按 React 树冒泡,不是按 DOM 树。所以弹窗里的点击能被它的 React 父组件捕获——这一点和直觉相反,但正是它有用的地方。 - Portals 的用途:弹窗、下拉菜单、Toast,都需要跳出父级的
overflow: hidden和z-index层叠上下文。用 Portal 挂到 body,DOM 层面解放,逻辑层面还在组件树里。
RSC:服务端组件不是 SSR★
use client是边界不是标记:写了它的文件,以及它 import 的所有模块,都会进入 client bundle。所以边界要画在叶子节点上,画在根节点等于放弃 RSC 的收益。- 服务端组件不能用 Hook:没有状态、没有生命周期、不能用
useState/useEffect。它是"每次请求执行一次的异步函数",不是"有生命周期的实例"。 - props 必须可序列化:服务端组件传给客户端组件的 props 要能被序列化,函数不行(除 Server Actions)。这是从 RSC 迁回普通组件的常见卡点。
- 面试怎么答才不混:说"SSR 是把组件在服务端渲染成 HTML 以加快首屏,组件代码仍然会打进客户端;RSC 是组件只在服务端执行,客户端只收到渲染结果,所以组件代码不进 bundle,而且可以直接访问数据库。两者不冲突,一个 RSC 页面里客户端组件照样走 SSR。"
速答清单
这些是高频的"一句话问答",每条都对应上面某一节。考前扫一遍,卡住的回去看那一节。