为什么会有这篇文章
前言
本人是 HCI 背景而非 CS 背景,虽然和前端有一些关联,但严格来说并不是一条天然连续的路径。最初学习前端技术,其实是出于三个很朴素的目的:
- 做一个能够交互的 Web 端作品集。反正迟早要有作品集,不如直接搭一个 Web 端的,顺便炫个技,证明自己设计开发双修。
- 了解 UI/UX 与前端实现之间的关系,做到更好的协作。否则 idea 一旦落不了地,岂不是要被开发提着键盘转着圈地抽。
- 通过技术实现自己的一些创意。毕竟一些低代码工具虽然方便,但在交互和视觉表达上,往往满足不了我的审美需求。
于是,为了完成这些目标,在 2022 年的某一天,我选修了一门课:《现代前端开发》。那是我第一次系统地接触 HTML、CSS、JavaScript,也第一次真正开始学习 React。更重要的是因此得到了几位前辈的指引,于是,我转行了!
转行本身当然不是这篇文章的重点。之所以想写这一组文章,是最近在跳槽面试中,我忽然意识到,自己对 React 的理解,已经不再停留在“会写 JSX、会用 Hooks、会处理业务状态”中了。很多过去只会机械记忆的概念,比如 Fiber、调度、协调、副作用、提交,如今已经慢慢在脑子里连成了一条完整的链路。
所以我想借这个机会,把这些理解认真整理出来。一方面是帮助自己把零散认识收束成体系,另一方面也希望它能够帮助一些还愿意去学习 React 的小伙伴。再从一些私心出发,我也想在这个 AI 内容快速漫灌的时代,留下几篇真正属于自己的技术笔记。
为什么前言篇不直接讲 Fiber
如果这组文章一上来就进入 Fiber、lanes、Reconciler、flags、commit 这些关键词,虽然会显得很“硬核”,但也很容易让人失去坐标。
很多时候,我们不是看不懂某个源码细节,而是不知道这个细节在 React 整体运行模型中究竟扮演什么角色。比如:
ReactElement和FiberNode到底有什么区别?render、reconcile、commit到底是不是一回事?- 为什么一次状态更新不会立刻改 DOM,而是会先进入一轮调度和计算?
- React 明明强调声明式,为什么内部又必须有优先级、工作单元和副作用系统?
所以前言篇不会追求源码深度,而是先做一件更重要的事:建立一个概念坐标。后面的文章会不断讨论 React 的内部机制,而这一篇的目标,就是先把这些机制放到一个坐标系里做个比对。
React 到底在做什么
从表面上看,React 是一个用于构建用户界面的库。我们写组件、写 JSX、管理状态,最后让界面随着数据变化而变化。
但如果说得更本质一点,React 真正做的事情是:
给定某一时刻的输入,计算出界面应该长什么样,并把这份计算结果稳定地提交到宿主环境中。
这里的“输入”包括:
propsstate- 上下文
- 一次次触发的更新
这里的“宿主环境”通常是浏览器 DOM,但也可以是 React Native、Canvas,甚至是服务端渲染输出。
不难看出,React 的核心并不只是“把 JSX 变成 DOM”,而是维护一套从输入到输出的持续更新机制。也正是因为它要处理的不是一次性渲染,而是不断变化的更新流,所以 React 才必须关心组件边界、状态归属、更新优先级、节点复用、副作用收集以及最终渲染的一致性问题。
React 的几个核心理念
从当下的视角出发,我觉得有四个理念是后面所有内容的前提。
1. 声明式 UI
React 鼓励我们描述“某个状态下界面应该是什么样”,而不是手动编排当前的页面,即:“先创建这个节点,再修改那个节点,最后把它插进去”,也就是说具体的编排工作从开发者手里,交到了 React 来实现。
但这意味着 React 要接管一个更难的问题:当输入变化时,它该如何保障“新界面描述”转化为“新真实界面”的最小变更与一致性。
2. 组件化
React 并不是直接围绕 DOM 节点组织应用,而是围绕组件组织应用。组件是 UI 的组织单位,是我们拆分界面、抽象逻辑、管理边界的方式。
但是,组件只是开发者视角下的抽象,不等于 React 内部运行时的最小单位。后面我们会看到,React 在运行时真正依赖的是 FiberNode。
3. 状态驱动视图
React 的基本假设是:界面是状态的映射。
我们不会直接命令界面“去变成这样”,而是先改变状态数据,再让 React 根据新的输入重新计算出界面应该长成什么样。也正因为如此,状态更新本质上不是“立刻改界面”,而是“触发一次新的计算与更新流程”。
4. render 与 commit 分离
React 内部并不是一边计算一边直接修改界面,而是把“计算下一步应该怎么变”与“把变更结果展示出来”分成了两个阶段。
这个分离非常重要。因为计算阶段和提交阶段被拆分,所以 React 就可以在计算过程中做更多调度上的优化,例如中断、恢复、重试、按优先级推进;而真正涉及宿主环境副作用的提交阶段,则必须尽量保持短小和原子。
后面的 Fiber 架构、调度系统和副作用系统,本质上都和这个分离有关。
先了解几个最重要的概念
组件是什么
组件是 React 世界里描述 UI 的基本组织单位。一个组件接收输入,返回一段 UI 描述,并且可以通过组合形成更大的界面结构。
从开发者视角看,组件是我们最熟悉的抽象;但从 React 内部看,组件本身并不是直接参与调度和更新的那个对象。组件更像是一种“声明 UI 的方式”。
ReactElement 是什么
ReactElement 可以理解为 React 对某个 UI 节点的对象描述符。我们写下的 JSX,在编译之后,本质上会变成 React.createReactElement 的调用。
简而言之:
- 它是一个UI描述符,表达的是“某个位置上希望渲染出什么”。
- 它是轻量对象,不持有运行时状态。
所以我们可以简单地把 ReactElement 理解为 React 渲染过程中的输入之一。它描述了目标界面的形状,但它并不是 React 的运行时模型。
FiberNode 是什么
FiberNode 是 React 运行时的最小工作单元,也是后面这组文章真正要展开探讨的基础数据结构。
如果说 ReactElement 描述的是“想要什么 UI”,那么 FiberNode 解决的就是“React 该如何把这份描述变成可以被落地到宿主环境的过程”。
它通常会对应某个组件实例或某个宿主节点,并承载这些信息:
- 基础信息:当前节点的基础信息
- 父子指针:树结构关系
- 状态:当前和待处理的状态
- 优先级标记
- 副作用标记
也就是说,FiberNode 是一个数据结构,承载了 React 在更新时所需要数据,它通过上面提到的五部分数据,支撑了 React 的种种行为。
Hooks 是什么
Hooks 是函数组件复用状态逻辑与副作用逻辑的一套机制。
从使用层面上看,它让函数组件拥有了状态、生命周期能力以及逻辑复用能力;从实现层面上看,它依附在 FiberNode 上,帮助 React 管理函数组件在多次渲染之间的状态与副作用信息。
所以后面当我们讨论 FiberNode 时,也会一起看到:为什么 Hooks 最终会和链表、更新队列这些实现细节联系在一起。
render、reconcile、commit 分别是什么
render
render 可以先粗略理解为“根据最新输入计算下一棵 UI 树的过程”。
在理想模型里,它应该尽量保持为纯计算:输入给定,输出可预测,不直接产生宿主环境副作用。也正因为如此,React 才有可能让这个阶段具备可中断、可恢复和可重做的能力。
reconcile
reconcile 是 render 阶段中的协调过程。它不只是狭义上的 diff过程,而是 React 在构造下一棵树时,判断哪些节点可以复用、哪些节点需要更新、哪些节点应该删除、哪些子树还需要继续向下计算的过程。
commit
commit 是 React 把 render 阶段的计算结果真正提交到宿主环境中的过程。
到了这个阶段,React 才会去做真正有副作用的事情,比如:
- 插入、更新、删除 DOM
- 处理 ref
- 执行 layout effects
- 安排 passive effects
和 render 不同,commit 必须尽量保持原子性。因为一旦进入真实提交,就不能让界面停留在一种“只做了一半”的不一致状态里。
一次 React 更新的大致工作流
如果把后面整组文章压缩成一句最核心的话,那就是:
一次 React 更新,并不是“状态一变,DOM 立刻就变”,而是会经历一条完整的内部链路。
这个链路可以先粗略记成:
触发更新 -> 进入调度 -> render/reconcile -> 收集副作用 -> commit
前言篇先不展开每个环节的源码细节,我们只需要先建立一个整体印象:
- 更新产生之后,React 不一定会立刻执行,它会先进入调度体系。
- render 阶段会围绕 Fiber 树展开计算,尝试构造出一棵新的树。
- 在这个过程中,React 会判断节点复用关系,并收集后续需要执行的副作用。
- 最终再进入 commit,把这次更新一次性提交出去。
后面的每一篇文章,本质上都是在回答这条链路中的一个局部问题。
这组文章接下来会怎么展开
在后面的文章里,我会沿着这条主线继续写下去:
- 为什么 React 必须从旧的递归更新模型走向 Fiber。
- FiberNode 到底是什么,为什么它能成为 React 的运行时核心。
- Hooks 是什么,React 为什么需要 hooks
- React 如何通过 Scheduler 和 lanes 组织优先级。
- Reconciler 如何构造下一棵树,并尽量避免无意义遍历。
- children diff 如何处理复用、插入、删除和移动。
- React 如何用副作用系统描述一次界面变更。
- 为什么最终的 commit 阶段不能中断。
- 番外:介绍一些 React 第三方库的实现