React 的版本演进可以按”每次重写解决了什么”来记:栈调和卡在哪、Fiber 补上了什么、Hooks 又换掉了哪种写法。
Q1: React 的历史?为什么要有它?
1
2010 年 - Facebook 的 UI 危机
Facebook 当时用 PHP + XHP(PHP 的 XML 扩展)拼接 HTML DOM。通知系统是重灾区——用户收到一条新消息,需要手动查询 DOM 节点、判断是否已存在、再插入或更新。随着消息类型(点赞、评论、@提及)不断膨胀,代码变成一团意大利面条,状态与 DOM 严重耦合,修一个 bug 冒出三个新 bug。
2
2011 年 - Jordan Walke 的原型 FaxJS
Facebook 工程师 Jordan Walke 受 XHP(Facebook 内部的 PHP XML 扩展框架)的启发——XHP 让开发者在 PHP 中用 XML-like 语法构建 UI 组件。Walke 将这个思想带入 JavaScript,构建了 FaxJS 原型。核心思想:UI = f(state)——给定一个状态,函数返回一棵描述 UI 结构的轻量对象树(即虚拟 DOM 的雏形)。状态变化时重新调用函数生成新树,框架自动 diff 两棵树的差异并更新真实 DOM。开发者从此不再手动操作 DOM。Walke 在 2019 年的演讲中回顾了这段经历,称 React 的设计灵感来自”函数式编程对 UI 的重新定义”[1]。
3
2012 年 - Instagram 的验证
Facebook 内部孵化出 Instagram 后,团队面临”从零构建一个完整 SPA”的需求。他们选择用 FaxJS 的思想来驱动 Instagram 的前端。这次内部验证证明了声明式 UI 的可行性——代码量大幅减少,bug 率显著降低,团队开始认真打磨这套方案。
4
2013 年 - React 开源(JSConf US)
2013 年 5 月,JSConf US 上 Pete Hunt 做了”Rethinking Web App Development at Facebook”演讲,React 正式开源[2](Jordan Walke 是 React 的主要作者)。虚拟 DOM + JSX 的组合震惊了业界——“在 JavaScript 里写 HTML?“引发了巨大争议。Pete Hunt 在演讲中强调:“React 不是在教你怎么做 UI,而是在教你如何思考 UI——把 UI 看成数据的函数输出。“Facebook 花了很长时间说服社区接受”关注点分离不是文件分离,而是组件分离”的理念。早期生态极不完善,没有路由、没有状态管理,但核心思想逐渐被验证。
5
2015 年 - React 15(Stack Reconciler)
React 15 采用 Stack Reconciler 架构:用同步递归遍历虚拟 DOM 树做 diff。这个架构简单直接,在中小型应用中表现良好。但它的致命问题是同步且不可中断——一旦开始 diff,就必须一口气跑完整棵组件树,大型应用可能阻塞主线程 100ms+,导致掉帧和输入延迟。
React 的诞生源于 Facebook 的 UI 维护危机,Jordan Walke 用”UI = f(state)“的函数式思想创造了虚拟 DOM,让开发者从手动操作 DOM 的泥潭中解放出来。
没有 React(2010 年之前)
前端用 jQuery 手动操作 DOM,状态散落在各种全局变量和 DOM 属性中。通知系统每来一条消息就要
document.querySelector + if/else 判断 + appendChild,就像一个仓库管理员没有任何清单系统,每次取货都要满仓库翻找。引入 React(2013 年之后)
声明式 UI 让开发者只需描述”UI 应该长什么样”,框架负责 diff 和更新。就像仓库引入了智能库存系统——管理员只需要说”我要 5 号货架上的东西”,系统自动定位、取货、更新库存记录。
Q2: React 15 的问题是什么?怎么解决?
Stack Reconciler 的致命问题
React 15 的协调器(Stack Reconciler)采用同步递归算法遍历整棵组件树。当setState 触发更新时,React 从根节点开始递归对比每个组件的虚拟 DOM,这个过程一旦开始就无法中断。
- 同步递归无法中断:JavaScript 的调用栈是后进先出结构,递归过程中无法在中间状态暂停并恢复。大型应用一次更新可能占用主线程 100ms+,直接导致掉帧。
- 没有优先级概念:所有更新一视同仁——用户输入和数据加载的更新享有相同优先级,无法优先响应用户交互。
- 大型组件树性能悬崖:组件数量越多,递归深度越深,性能呈线性下降,且没有优雅的降级方案。
React 15 的优化尝试
面对这些问题,React 15 时期社区和官方提供了三种优化手段: 1. shouldComponentUpdate(手动中断递归)shouldComponentUpdate 返回 false 时,React 会跳过该组件及其整个子树的 diff。但问题是:开发者必须手动判断每个 props 是否变化,遗漏任何一个就会导致不必要的渲染,写错了还会导致 UI 不更新。
2. PureComponent(浅比较自动化)
React.PureComponent 自动对 props 和 state 做浅比较(shallow compare),省去了手写 shouldComponentUpdate 的麻烦。但浅比较有天然缺陷——如果父组件传入新的数组或对象引用(即使内容相同),浅比较会认为”变了”,导致无效渲染。
3. Batching(批处理合并更新)
setTimeout、Promise 等原生异步上下文中,每次 setState 都会立即触发一轮完整渲染。
为什么这些优化不够?
React 15 的 Stack Reconciler 用同步递归做 diff,
shouldComponentUpdate、PureComponent 和 Batching 只能”减少工作量”,无法改变”必须一口气跑完”的根本限制——React 16 的 Fiber 架构才是真正的解法。Q3: React 18 的并发模式是如何工作的?
React 18 最核心的突破是将并发特性(Concurrent Features)从实验阶段正式落地。React 官方博客对并发能力的描述是:不必在”快速响应用户输入”和”渲染大量数据”之间二选一[3]。它的本质是赋予 React 中断并优先处理更重要渲染任务的能力。Q4: React 16 为什么是一次架构革命?
1
16.6ms 的帧预算与同步阻塞
现代浏览器为了保持 60fps,要求每帧执行时间在 16.6ms 以内。React 15 的 Stack Reconciler 用同步递归对比虚拟 DOM,一旦开始就无法中断。大型应用一次
setState 可能占用主线程 100ms+,直接导致掉帧卡顿。2
Stack Reconciler 的致命局限
JavaScript 原生调用栈(Call Stack)是后进先出的结构,无法在中间状态暂停并事后恢复。这意味着 React 15 没有任何办法把一个巨大的更新任务拆成小块,只能一口气跑完。
3
Fiber 架构的引入
React 16 彻底抛弃递归算法,引入了基于单链表的 Fiber 架构。每个组件对应一个 Fiber 节点,通过
child、sibling、return 三个指针构建虚拟调用栈,用 while 循环替代递归,实现了时间切片(Time Slicing)。4
生命周期的废弃与重塑
由于 Fiber 允许 Render 阶段的中断和重新执行,旧版带有副作用的生命周期(
componentWillMount、componentWillReceiveProps、componentWillUpdate)可能被多次触发。React 从 16.3 起标记废弃,并在 React 17 移除,引入 getDerivedStateFromProps 等更纯粹的替代方案。5
Hooks 的范式转移 (16.8)
Hooks 让函数组件拥有了状态管理和副作用处理的能力,逻辑复用从高阶组件(HOC)和 Render Props 的地狱中解放出来。
useState、useEffect、useMemo 等 API 极大降低了代码耦合度。React 16 用 Fiber 链表替代递归调用栈,把”一口气跑完”变成了”跑一段歇一下”,彻底解决了大型应用的主线程阻塞问题。
React 15 的痛点
同步递归对比虚拟 DOM,阻塞主线程无法中断;逻辑复用靠高阶组件层层嵌套。就像一个必须一口气跑完全程的马拉松,中途不能停下调整呼吸。
React 16 的破局
Fiber 时间切片实现可中断渲染,Hooks 实现函数式逻辑复用,Error Boundaries 实现优雅降级。就像修了服务区的接力赛跑,可以停顿、可以重试、可以交接。
Q5: React 16 vs 18 vs 19 有哪些关键差异?
版本演进的底层逻辑
React 每次大版本升级都不是孤立的 API 增加,而是在解决前一版本遗留的架构瓶颈。理解这个演进逻辑,比记住版本号更重要。基础梳理
专家视角深度剖析
很多人只能背诵上面的表格,但实际更在意的是:React 每次大版本升级背后的技术驱动力是什么? React 16 的核心驱动力是解决同步阻塞。Fiber 架构把递归变成迭代,为时间切片奠定了基础。但 Fiber 只是”能中断”,并没有告诉 React “该中断什么、优先处理什么”。 React 18 补上了这块拼图。Andrew Clark 在 Lanes 设计 PR 中说明了从 ExpirationTime 到 Bitmask 的演进动机:ExpirationTime 用一个数字表示优先级,只能做线性比较;Lanes 用位图表示多个优先级通道,可以通过位运算快速判断任务批次和插队关系[4]。这让调度器可以根据任务类型(用户输入 > 过渡动画 > 数据获取)决定中断策略。同时,自动批处理(Automatic Batching)解决了 React 17 中setTimeout 和 Promise 内 setState 不批处理的历史包袱。
React 19 的方向是减少手写优化与异步样板。Sebastian Markbage 在 React Summit 2020 的演讲中阐述了 React Server Components 的设计动机:传统 React 应用把所有代码发给客户端;Server Components 只发送客户端需要的代码,同时直接访问后端资源[5]。需要区分的是,React Compiler(原 React Forget)是独立构建插件,不是 React 19 运行时内置特性;Actions 和 use() 才是 React 19 正式提供的能力。use() 统一了 Promise、Context 的读取方式,Actions 简化了异步状态与表单处理。
Q6: React 事件系统为什么从 document 改到了 Root Node?
合成事件与事件委托
React 的合成事件系统是其跨平台能力的基石——通过在顶层统一代理所有 DOM 事件,React 可以在不同渲染器(DOM、React Native)中提供一致的事件模型。 React 16 的事件委托: React 16 将所有事件统一代理到全局document 上。这在单应用时代没问题,但微前端场景下,多个 React 版本共存时,事件冒泡顺序会变得不可预测。
React 17 的改变:
从 React 17 开始,事件不再代理到 document,而是代理到该 React 应用渲染的根节点(Root Node,通常是 div#root)。这支持了多个 React 版本在同一页面共存。
Q7: React 生命周期经历了哪些变化?
生命周期演进的底层逻辑
React 生命周期的每一次变化都不是孤立的 API 调整,而是底层架构变革的必然结果。理解这个演进逻辑,比记住每个生命周期方法的调用时机更重要。React 15 生命周期
React 15 的生命周期围绕类组件的挂载、更新和卸载三个阶段设计,整体流程是同步且线性的:React 16 生命周期
React 16 引入 Fiber 架构后,Render 阶段可以被中断和重新执行。如果生命周期方法有副作用(如修改 state、操作 DOM),就会在中断恢复时被重复触发,导致不可预测的 bug。因此 React 16.3 废弃了三个带副作用的生命周期,引入了两个更”纯粹”的替代方案。React 15 vs 16 生命周期对比表
为什么 React 16 废弃了旧生命周期?
核心原因在于 Fiber 架构将渲染分为两个阶段:componentWillMount、componentWillReceiveProps、componentWillUpdate 都处于 Render 阶段。如果在这些方法中有副作用(发起网络请求、修改全局变量、手动修改 DOM),中断恢复时副作用会被重复执行——发两次请求、改两次 DOM、导致数据错乱。
React 16 的策略是:Render 阶段只允许纯计算(getDerivedStateFromProps 返回新 state、getSnapshotBeforeUpdate 返回快照),所有副作用全部放到 Commit 阶段(componentDidMount、componentDidUpdate)。
Hooks 生命周期
Hooks 时代没有显式的生命周期方法,但useEffect 和 useLayoutEffect 覆盖了所有生命周期场景:
React 生命周期从 15 的同步线性模型演进到 16 的可中断双阶段模型,本质原因是 Fiber 允许 Render 阶段中断重试,副作用必须全部移到 Commit 阶段;Hooks 用
useEffect 统一了生命周期概念,用依赖数组控制执行时机。Function Component vs Class Component
范式转移的本质
React 组件经历了从 Class Component 到 Function Component 的范式转移。Dan Abramov 在 Hooks RFC 中指出:“Classes are confusing for both people and machines —this binding can be confusing, and there’s no reliable way to extract reusable behavior between components”[1]。理解两者的本质区别,是理解 React 为什么最终拥抱 Hooks 的前提。
两种组件的本质区别:
为什么 Class Component 被淘汰?
Class Component 用 Class 实例承载状态和生命周期,但 this 绑定、逻辑复用和生命周期分散是三大痛点;Function Component 用纯函数 + Hooks 实现相同能力,代码更简洁、逻辑更内聚、复用更扁平——这就是 React 16.8 引入 Hooks 后 Class Component 逐渐被淘汰的根本原因。
为什么要写 this.handleClick = this.handleClick.bind(this)
JavaScript this 绑定与 React 的碰撞
Class Component 中最经典的”坑”之一:事件处理函数中的this 丢失问题。这道题考察的是你对 JavaScript this 绑定规则的理解深度。
Class Component 的 this 丢失问题:
onClick={this.handleClick} 会丢失 this?
this 的指向在调用时才确定,而不是定义时。
3 种解决方案:
方案 1:constructor 中 bind(经典方案)
Class Component 中
onClick={this.handleClick} 丢失 this 的本质是 JavaScript 的”隐式绑定”在直接调用时失效;三种解决方案中,constructor bind 是经典方案、render 中箭头函数有性能隐患、类属性箭头函数最简洁且性能最优——而在 Function Component 中这个问题根本不存在,因为没有 this。constructor / props / super 的作用
ES6 Class 继承与 React 的交互
Class Component 的constructor、props 和 super 是面试中被问烂的基础题,但很多人只能背”要调用 super(props)“,却说不清”为什么”。这道题考察的是你对 JavaScript Class 继承机制的理解。
constructor 的作用:
constructor 是 Class 的构造方法,用于初始化 state 和绑定 this;props 是父组件传入的只读数据;super(props) 是调用父类构造函数并让 this.props 在 constructor 中可用——不调用 super() 会直接报错,不传 props 会导致 constructor 中 this.props 为 undefined。Q8: setState 什么时候是同步,什么时候是异步?
批量更新的本质
setState 的”异步”本质是批量更新(Batching)。React 将多次 setState 合并到一个队列中,在当前执行栈结束后的微任务中统一处理,而不是每次 setState 都立即更新 DOM。
setState 的异步场景
在 React 事件回调和生命周期方法中,setState 是”异步”的(更准确地说是批量更新)。React 会把多次 setState 合并成一次渲染,而不是每次 setState 都立即更新 DOM。
count 是 1 而不是 2?因为 React 18 之前,setState({ count: this.state.count + 1 }) 中的 this.state.count 在同一个事件回调内始终是更新前的值(0)。两次 setState 都是 { count: 0 + 1 },最后一次覆盖前一次,结果是 1。
setState 的同步场景
在setTimeout、Promise、原生 DOM 事件等非 React 管理的上下文中,React 17 及以前的 setState 会立即触发渲染,表现为”同步”:
React 18 之后的变化:Automatic Batching
React 18 引入了 Automatic Batching(自动批处理),将批处理范围从 React 事件扩展到了所有上下文:setState 的批量更新机制
React 的批量更新本质上是将多个setState 调用收集到更新队列中,等当前执行栈结束后,由调度器统一安排一次渲染(React 18 的并发渲染通常通过 MessageChannel 以宏任务形式调度):
setState 的函数式写法:
setState 在 React 事件和生命周期中是批量更新(看起来像异步),在 setTimeout/Promise/原生事件中 React 17 是同步、React 18 也是批量更新;多个 setState 依赖上一次 state 时必须用函数式写法,否则会被覆盖。Q9: 底层源码——Automatic Batching 是如何实现的?
Automatic Batching 的调度机制
React 18 的自动批处理是怎么做到跨越宏任务的?
在 React 17 及以前,只有 React 自己管理的事件回调(如 onClick)内部的 setState 才会合并更新。而在 Promise、setTimeout 等原生异步任务中,每次 setState 都会立即触发一轮完整渲染。React 18 彻底解决了这个问题,详见 React 源码 packages/react-reconciler/src/ReactFiberWorkLoop.js。
markRootUpdated 合并到根节点的 Lane 集合中;第二,ensureRootIsScheduled 判断本次任务优先级,低优先级任务通过 MessageChannel 调度一个宏任务来统一消费所有待处理的更新,实现自动批处理(同步优先级的更新仍会同步执行)。
Lanes 优先级模型
Lanes 优先级模型在源码中长什么样?
详见 React 源码 packages/react-reconciler/src/ReactFiberLane.js:
&、|),React 可以在 O(1) 时间内判断哪些任务属于同一批次、哪些高优任务插了队,比 ExpirationTime 的数字比较更高效。
Q10: 及格回答和满分回答差在哪?
面试评分标准
考察 React 历史和架构演进时,区分”知道版本号”和”理解设计决策”的关键在于:你是否能说出每次架构变革背后的技术瓶颈和设计权衡。问题:“说说 React 的历史和架构演进”
及格回答(60-70分):“React 是 Facebook 在 2013 年开源的,React 16 引入了 Fiber 架构,React 18 引入了并发模式,React 19 引入了编译器优化。”问题出在:知道版本号但不理解驱动力。 满分回答(90-100分):
React 的诞生源于 Facebook 2010 年的 UI 维护危机——PHP + XHP 拼接 DOM 导致通知系统代码混乱不堪。Jordan Walke 受函数式编程启发,构建了 FaxJS 原型,核心思想是”UI = f(state)“:状态变化时重新生成虚拟 DOM 树,框架自动 diff 更新。2013 年开源后,虚拟 DOM + JSX 的组合逐渐被业界接受。
React 的每一次大版本升级都是在解决一个具体的技术瓶颈。React 15 的 Stack Reconciler 用同步递归对比虚拟 DOM,大型应用一次 setState 可能阻塞主线程 100ms 导致掉帧。React 16 的核心突破是用 Fiber 链表替代递归调用栈,实现了时间切片——每执行完一个 Fiber 节点就检查剩余时间,没时间就让出主线程。
但 Fiber 只解决了”能中断”的问题,没有解决”该中断什么”。React 18 引入了 Lanes 优先级模型(32 位二进制位图),让调度器可以区分同步任务、连续交互任务和过渡任务的优先级。同时 Automatic Batching 将批处理从 React 事件扩展到所有异步上下文(setTimeout、Promise),彻底解决了 setState 散弹式渲染的问题。
React 19 的方向是”让框架帮你优化”。React Compiler 通过静态分析自动添加 memoization,开发者不再需要手写 useMemo/useCallback。use() hook 统一了资源消费方式,可以在条件语句中使用。Actions 允许把异步函数直接交给表单或 useActionState,由 React 内置维护 pending 与错误状态,减少了手写 loading/error 的样板代码。
常见追问
React 18 的并发更新会不会出现 UI 撕裂 (Tearing) ?
React 18 的并发更新会不会出现 UI 撕裂 (Tearing) ?
答题要点:
核心在于区分 Render 阶段和 Commit 阶段的中断特性。高分回答:
“对 React 自己维护的 state 和 props 来说,不会。并发中断仅仅发生在内存中的 Render 阶段(计算对比阶段)。当真正要把差异更新到真实 DOM 的 Commit 阶段时,操作是绝对同步且不可中断的。因此用户看到的 UI 永远是原子化的一致状态——要么是完全的旧状态,要么是完全的新状态。但要注意:如果组件直接读取 React 之外的可变数据源(如全局变量、外部 store),并发渲染期间该数据源可能被修改,从而出现撕裂。这类外部数据应通过
useSyncExternalStore 订阅,保证每次渲染读到一致快照。另外不在 Render 阶段写副作用代码,也是并发安全的前提。”React 为什么要用 Lanes 替代 ExpirationTime?
React 为什么要用 Lanes 替代 ExpirationTime?
答题要点:
从数据结构的表达能力角度解释设计决策。高分回答:
“ExpirationTime 是一个数字,只能表达线性的优先级高低,无法表达’一组任务’的交叉概念。Lanes 采用 32 位二进制位图(Bitmask),每个 bit 代表一个优先级通道。通过位运算(按位与
&、按位或 |),React 可以在 O(1) 时间内判断哪些任务属于同一批次、哪些高优任务插了队。例如 SyncLane 是第 0 位(0b1,值为 1),TransitionLane1 是第 6 位(0b1000000,值为 64)。在复杂动画和大数据表格同屏时,Lanes 能精准地把动画帧的渲染剥离出来优先投递给浏览器。”微前端场景下 React 16 和 React 18 共存, 事件冒泡顺序是什么?
微前端场景下 React 16 和 React 18 共存, 事件冒泡顺序是什么?
答题要点:
必须结合 React 17 事件委托节点的变化来分析。高分回答:
“React 16 将事件代理到
document,React 17+ 代理到 Root Node。点击子应用(React 18)时,事件先冒泡到子应用的 Root Node,触发 React 18 的合成事件逻辑;随后继续冒泡到 document,触发父应用 React 16 的监听器。如果在 React 18 的 onClick 中调用了原生事件的 e.stopPropagation(),事件在原生 DOM 树上停止冒泡,父应用的监听器根本不会被触发。这种行为差异是微前端升级时最常见的坑。”React Compiler 自动 memoization 会不会取代 useMemo/useCallback?
React Compiler 自动 memoization 会不会取代 useMemo/useCallback?
答题要点:
对比手动优化和编译器优化的适用范围与局限性。高分回答:
“React Compiler 的目标确实是让开发者不再需要手写 useMemo/useCallback。它通过静态分析组件代码,自动识别哪些值需要缓存、哪些函数引用需要稳定。但它有明确的边界:第一,编译器只能优化符合 Rules of React 的纯组件,包含外部副作用或可变数据的代码无法自动优化;第二,编译器不理解业务语义——比如一个昂贵的 API 请求,它不知道这个结果应该在什么粒度上复用;第三,编译器已发布稳定版本,但仍需通过 eslint-plugin-react-compiler 排查不兼容代码,存量项目能覆盖多少取决于代码规范程度。短期内 useMemo/useCallback 仍是必要的手动工具,长期来看编译器会接管大部分通用场景。“
延伸阅读与引用参考
- React: The Birth of a New Language:Jordan Walke 在 2019 年的演讲,回顾 React 从 FaxJS 到开源的完整历程。[1]
- Pete Hunt: React - Rethinking Best Practices (JSConf 2013):React 开源时的首次公开演讲,首次提出”UI = f(state)“的声明式范式。[2]
- React v18.0 Release Notes:Dan Abramov 撰写的官方发布说明,全面阐述 Concurrent Mode、Automatic Batching 和 Suspense for Data Fetching。[3]
- React 源码中的 Lanes 模型 PR:Andrew Clark 提交的 Lanes 设计思路,解释了从 ExpirationTime 到 Bitmask 的演进。[4]
- Sebastian Markbage: Designing Server Rendering’s Future (React Summit 2020):RSC 的设计动机——零 bundle 组件、直接访问后端资源、自动代码分割。[5]
- React 17 事件委托变更:官方解释微前端多版本共存的事件系统变更。[6]
- React Compiler 文档:官方对 React Compiler 自动优化机制的权威解释。[7]
- 崔健(卡颂):React 技术揭秘:像素级剖析 React Fiber 源码,国内 React 底层原理第一参考。[8]
