Skip to main content
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 诞生之前的操作 DOM 就像用纸质账本管理仓库——每来一批货(新数据),你得手动翻账本(查询 DOM)、核对数量、涂改记录(修改 DOM),账本越厚越容易出错。React 就像引入了一套 ERP 系统——你只需要录入数据(声明状态),系统自动生成最新的库存报表(渲染 UI),再也不用手工对账了。
React 诞生解决的核心问题:
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 号货架上的东西”,系统自动定位、取货、更新库存记录。
回答时先说”为什么”(Facebook 的 UI 维护危机),再说”怎么解决”(声明式 UI + 虚拟 DOM)。想听的是你对技术诞生驱动力的理解,而不是背诵年份。

Q2: React 15 的问题是什么?怎么解决?

Stack Reconciler 的致命问题

React 15 的协调器(Stack Reconciler)采用同步递归算法遍历整棵组件树。当 setState 触发更新时,React 从根节点开始递归对比每个组件的虚拟 DOM,这个过程一旦开始就无法中断。
核心问题有三个:
  1. 同步递归无法中断:JavaScript 的调用栈是后进先出结构,递归过程中无法在中间状态暂停并恢复。大型应用一次更新可能占用主线程 100ms+,直接导致掉帧。
  2. 没有优先级概念:所有更新一视同仁——用户输入和数据加载的更新享有相同优先级,无法优先响应用户交互。
  3. 大型组件树性能悬崖:组件数量越多,递归深度越深,性能呈线性下降,且没有优雅的降级方案。

React 15 的优化尝试

面对这些问题,React 15 时期社区和官方提供了三种优化手段: 1. shouldComponentUpdate(手动中断递归)
shouldComponentUpdate 返回 false 时,React 会跳过该组件及其整个子树的 diff。但问题是:开发者必须手动判断每个 props 是否变化,遗漏任何一个就会导致不必要的渲染,写错了还会导致 UI 不更新。 2. PureComponent(浅比较自动化)
React.PureComponent 自动对 props 和 state 做浅比较(shallow compare),省去了手写 shouldComponentUpdate 的麻烦。但浅比较有天然缺陷——如果父组件传入新的数组或对象引用(即使内容相同),浅比较会认为”变了”,导致无效渲染。 3. Batching(批处理合并更新)
React 15 的批处理仅限于 React 自己管理的事件回调。在 setTimeout、Promise 等原生异步上下文中,每次 setState 都会立即触发一轮完整渲染。

为什么这些优化不够?

React 15 的 Stack Reconciler 就像一个必须一口气跑完全程的马拉松选手——shouldComponentUpdate 相当于给选手”抄近路”的机会,PureComponent 相当于”自动识别近路”,Batching 相当于”把多次出发合并成一次”。但无论怎么优化,选手依然不能中途停下来休息。React 16 的 Fiber 就像换了新规则——每跑 100 米就可以在路边歇一歇(时间切片),休息够了再接着跑,从此不再怕大型应用的”马拉松”。
React 15 的 Stack Reconciler 用同步递归做 diff,shouldComponentUpdate、PureComponent 和 Batching 只能”减少工作量”,无法改变”必须一口气跑完”的根本限制——React 16 的 Fiber 架构才是真正的解法。

Q3: React 18 的并发模式是如何工作的?

React 18 最核心的突破是将并发特性(Concurrent Features)从实验阶段正式落地。React 官方博客对并发能力的描述是:不必在”快速响应用户输入”和”渲染大量数据”之间二选一[3]。它的本质是赋予 React 中断并优先处理更重要渲染任务的能力。
React 18 默认启用并发渲染能力,但不会把所有更新都当成低优先级。只有显式调用 startTransition 或使用 useDeferredValue,某个更新才会被标记为”非紧急任务”;普通 setState 走 DefaultLane,仍按普通优先级调度。
并发模式就像医院急诊室的分诊机制。你正在给一个脚踝扭伤的病人(低优的列表渲染)做包扎,突然推车送来一个心脏病发的重症患者(高优的用户输入)。医生(React 调度器)会立即暂停包扎,优先抢救心脏病患者,等抢救结束脱离危险后,再回去把扭伤病人的绷带打完。

Q4: React 16 为什么是一次架构革命?

React 16 的架构革命就像工厂流水线的升级。React 15 的流水线一旦开工就不能停——哪怕工人(JS 引擎)已经累得干不动了,也得一口气把所有工序做完(同步递归),产品(UI)才能下线。React 16 把流水线改成了”可暂停”的模式——每做完一道工序(一个 Fiber 节点),就检查一下”下班时间到了没”(shouldYield),到了就暂停,把半成品存好(保存 nextUnitOfWork),明天接着干。同时 Hooks 的引入就像把分散在不同车间的工具(状态、副作用)集中到了一个工具箱里(函数组件),取用更方便。
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 解决的核心问题:
React 16 用 Fiber 链表替代递归调用栈,把”一口气跑完”变成了”跑一段歇一下”,彻底解决了大型应用的主线程阻塞问题。

React 15 的痛点

同步递归对比虚拟 DOM,阻塞主线程无法中断;逻辑复用靠高阶组件层层嵌套。就像一个必须一口气跑完全程的马拉松,中途不能停下调整呼吸。

React 16 的破局

Fiber 时间切片实现可中断渲染,Hooks 实现函数式逻辑复用,Error Boundaries 实现优雅降级。就像修了服务区的接力赛跑,可以停顿、可以重试、可以交接。
回答时先说”为什么”(同步递归阻塞主线程),再说”怎么解决”(Fiber 时间切片)。想听的是你对架构演进背后驱动力的理解,而不是流水账背版本号。

Q5: React 16 vs 18 vs 19 有哪些关键差异?

React 16 就像第一代智能手机——终于能中断任务了(Fiber 时间切片),但还不知道该优先处理什么。React 18 就像第二代——有了智能分屏(并发模式),可以一边看视频一边回消息(高优任务打断低优任务),而且所有应用的通知都统一管理了(Automatic Batching)。React 19 就像第三代——有了 AI 助手(React Compiler),它能自动帮你整理相册(自动 memoization),你再也不用手动分类了。

版本演进的底层逻辑

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 16 的事件委托就像在一栋大楼门口装了一个大喇叭——所有楼层的所有事件都通过这一个喇叭广播(代理到 document),如果大楼里同时有两个公司(微前端多版本共存),两家的广播会互相干扰。React 17+ 改成了每家公司配一个对讲机(代理到各自 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 版本在同一页面共存。
关键区别:
微前端项目中,如果父子应用使用不同 React 版本,要注意事件冒泡顺序的变化。React 18 子应用的 onClick 会在 Root Node 层触发,之后才冒泡到 document 触发父应用 React 16 的监听器。

Q7: React 生命周期经历了哪些变化?

React 15 的生命周期就像一部严格按剧本演出的舞台剧——每个演员(组件)在固定的时间点说固定的台词(componentWillMount、componentDidMount 等),顺序绝对不能乱。React 16 的 Fiber 打破了这个规则——导演(调度器)可能会喊”停!这段重来!“(Render 阶段中断重试),所以演员不能在排练阶段(Render)做不可逆的事(副作用)。Hooks 时代更像即兴表演——没有固定剧本,演员根据当前场景(依赖数组)灵活决定什么时候行动(useEffect),同一个表演可以同时涵盖”开场、中场互动、谢幕”(一个 useEffect 覆盖多个生命周期)。

生命周期演进的底层逻辑

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 覆盖了所有生命周期场景:
useEffect vs useLayoutEffect 执行时机:
  • useEffect 在浏览器完成绘制后异步执行,不会阻塞用户看到画面。适合大多数副作用(数据请求、日志、订阅)。
  • useLayoutEffect 在 DOM 更新后、浏览器绘制前同步执行。适合需要读取 DOM 布局并同步修改的场景(如定位 tooltip)。如果误用 useLayoutEffect 做耗时操作,会阻塞渲染导致页面卡顿。
  • 测试环境(Jest + jsdom)中 useLayoutEffect 会报 warning,因为测试环境没有真正的浏览器绘制。
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 被淘汰?
Function 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(经典方案)
方案 2:箭头函数(在 render 中)
每次 render 都会创建一个新的匿名函数,导致 React.memo 和 shouldComponentUpdate 的浅比较失效(引用不同)。在列表渲染中尤其明显——100 个列表项 = 100 个新函数 = 子组件全部重新渲染。
方案 3:类属性 + 箭头函数(推荐方案)
错误与正确写法对比:
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 的作用:
props 是什么?
为什么必须调用 super(props)?
不调用 super(props) 会怎样?
constructor 是 Class 的构造方法,用于初始化 state 和绑定 this;props 是父组件传入的只读数据;super(props) 是调用父类构造函数并让 this.props 在 constructor 中可用——不调用 super() 会直接报错,不传 props 会导致 constructor 中 this.props 为 undefined。

Q8: setState 什么时候是同步,什么时候是异步?

setState 的批量更新就像餐厅的下单系统。在 React 事件回调内(餐厅正常营业时),你连点三道菜(三次 setState),服务员不会每点一道就跑去厨房下单,而是等你点完后统一把菜单送到后厨(批量更新)。但在 setTimeout 等异步上下文中(React 17 的”非营业时间”),你每点一道菜,服务员就立刻跑去厨房下单(同步更新)。React 18 的 Automatic Batching 就像餐厅升级为 24 小时营业——不管什么时候点菜,都是统一送厨房。

批量更新的本质

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 以宏任务形式调度):
如果需要在批量更新中立即获取最新 state,可以使用 setState 的函数式写法:
函数式写法 vs 对象式写法:
setState 在 React 事件和生命周期中是批量更新(看起来像异步),在 setTimeout/Promise/原生事件中 React 17 是同步、React 18 也是批量更新;多个 setState 依赖上一次 state 时必须用函数式写法,否则会被覆盖。

Q9: 底层源码——Automatic Batching 是如何实现的?

Automatic Batching 的实现就像快递分拣中心的运作模式。不管快递是从哪个渠道来的——网购(onClick)、电话下单(setTimeout)、朋友寄的(Promise)——到了分拣中心(React 的调度系统)后,都会被归类到同一个批次(Lane 集合)。分拣中心用 MessageChannel 安排一辆卡车(宏任务),在固定时间统一把这批快递送出去(批量渲染)。Lanes 优先级模型就像快递上的标签——“加急件”(SyncLane)优先配送,“普通件”(DefaultLane)排队等候。

Automatic Batching 的调度机制

在 React 17 及以前,只有 React 自己管理的事件回调(如 onClick)内部的 setState 才会合并更新。而在 Promise、setTimeout 等原生异步任务中,每次 setState 都会立即触发一轮完整渲染。React 18 彻底解决了这个问题,详见 React 源码 packages/react-reconciler/src/ReactFiberWorkLoop.js。
这段代码做了 2 件事:第一,所有更新不论来源都通过 markRootUpdated 合并到根节点的 Lane 集合中;第二,ensureRootIsScheduled 判断本次任务优先级,低优先级任务通过 MessageChannel 调度一个宏任务来统一消费所有待处理的更新,实现自动批处理(同步优先级的更新仍会同步执行)。

Lanes 优先级模型

详见 React 源码 packages/react-reconciler/src/ReactFiberLane.js:
Lanes 模型的优势在于:通过位运算(&、|),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 的样板代码。

常见追问

答题要点: 核心在于区分 Render 阶段和 Commit 阶段的中断特性。高分回答: “对 React 自己维护的 state 和 props 来说,不会。并发中断仅仅发生在内存中的 Render 阶段(计算对比阶段)。当真正要把差异更新到真实 DOM 的 Commit 阶段时,操作是绝对同步且不可中断的。因此用户看到的 UI 永远是原子化的一致状态——要么是完全的旧状态,要么是完全的新状态。但要注意:如果组件直接读取 React 之外的可变数据源(如全局变量、外部 store),并发渲染期间该数据源可能被修改,从而出现撕裂。这类外部数据应通过 useSyncExternalStore 订阅,保证每次渲染读到一致快照。另外不在 Render 阶段写副作用代码,也是并发安全的前提。”
答题要点: 从数据结构的表达能力角度解释设计决策。高分回答: “ExpirationTime 是一个数字,只能表达线性的优先级高低,无法表达’一组任务’的交叉概念。Lanes 采用 32 位二进制位图(Bitmask),每个 bit 代表一个优先级通道。通过位运算(按位与 &、按位或 |),React 可以在 O(1) 时间内判断哪些任务属于同一批次、哪些高优任务插了队。例如 SyncLane 是第 0 位(0b1,值为 1),TransitionLane1 是第 6 位(0b1000000,值为 64)。在复杂动画和大数据表格同屏时,Lanes 能精准地把动画帧的渲染剥离出来优先投递给浏览器。”
答题要点: 必须结合 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 的目标确实是让开发者不再需要手写 useMemo/useCallback。它通过静态分析组件代码,自动识别哪些值需要缓存、哪些函数引用需要稳定。但它有明确的边界:第一,编译器只能优化符合 Rules of React 的纯组件,包含外部副作用或可变数据的代码无法自动优化;第二,编译器不理解业务语义——比如一个昂贵的 API 请求,它不知道这个结果应该在什么粒度上复用;第三,编译器已发布稳定版本,但仍需通过 eslint-plugin-react-compiler 排查不兼容代码,存量项目能覆盖多少取决于代码规范程度。短期内 useMemo/useCallback 仍是必要的手动工具,长期来看编译器会接管大部分通用场景。“

延伸阅读与引用参考