要彻底理解 React 的实现原理,我们需要先忘掉那些复杂的框架概念,回到原生前端开发的最基本面:HTML 负责结构,CSS 负责样式,JavaScript 负责交互。
在没有 React 的时代,当数据发生变化时,我们需要用 JavaScript 手动去查找 DOM 节点(比如 document.getElementById),然后手动修改它们(比如 element.innerText = newData)。当页面非常复杂、数据变化频繁时,手动操作 DOM 不仅代码难以维护,而且性能很差,因为浏览器的 DOM 对象非常庞大。
React 的诞生就是为了解决这个问题。它的核心思想是:UI 应该是数据的映射。你只需要关心数据是什么样子,React 负责帮你把数据高效地变成页面上的 DOM。
我们将通过四个逐步递进的阶段,用纯 JavaScript 从头实现一个“迷你版 React”。
第一阶段:用 JavaScript 对象描述 UI(Virtual DOM)
真实的 DOM 节点非常沉重。你可以尝试在控制台打印一个简单的 div,展开后会看到成百上千个属性。如果频繁创建和销毁真实 DOM,浏览器会不堪重负。
因此,React 引入了虚拟 DOM(Virtual DOM)。它其实就是一个普通的、轻量级的 JavaScript 对象,用来描述真实的 DOM 应该长什么样。
假设我们有这样一段 HTML:
<div id="app" class="container">
<h1>Hello</h1>
<p>React</p>
</div>
用 JavaScript 对象(虚拟 DOM)来描述它,就是这样的:
const vdom = {
type: 'div',
props: {
id: 'app',
className: 'container',
children: [
{ type: 'h1', props: { children: ['Hello'] } },
{ type: 'p', props: { children: ['React'] } }
]
}
};
为了方便生成这种对象,我们可以写一个辅助函数 createElement:
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
// 为了统一处理,我们将文本也包装成一个特殊的虚拟节点
children: children.map(child =>
typeof child === 'object' ? child : createTextElement(child)
)
}
};
}
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: []
}
};
}
注:我们在日常开发中写的 JSX(比如 <div id="app">...</div>),本质上只是一个语法糖。在代码运行前,打包工具(如 Babel)会把你写的 JSX 编译成上面这种 createElement 函数的调用。
第二阶段:将虚拟 DOM 渲染到页面(Render)
现在我们有了描述 UI 的 JavaScript 对象,下一步是把它变成浏览器里真正的 DOM。这就是 render 函数要做的事情:遍历这个 JS 对象,调用浏览器的 API 来创建真实的 DOM 树。
function render(vdom, container) {
// 1. 创建真实的 DOM 节点
const dom =
vdom.type === 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(vdom.type);
// 2. 将虚拟 DOM 上的属性(props)赋值给真实 DOM
const isProperty = key => key !== 'children';
Object.keys(vdom.props)
.filter(isProperty)
.forEach(name => {
dom[name] = vdom.props[name];
});
// 3. 递归渲染子节点
vdom.props.children.forEach(child => {
render(child, dom);
});
// 4. 将生成的真实 DOM 挂载到父容器上
container.appendChild(dom);
}
到这一步,我们就完成了一个静态页面的渲染。但前端的复杂之处在于交互和更新。
第三阶段:当数据改变时的高效更新(Diff 算法)
如果数据变化了怎么办?最粗暴的做法是:清空页面(container.innerHTML = ''),用新的数据生成一份全新的虚拟 DOM,然后重新跑一遍 render 函数。
这显然不行,不仅性能差,还会丢失页面状态(比如输入框里正在输入的文字会消失)。
React 的做法是:比较新旧两棵虚拟 DOM 树,找出它们之间的差异(Diff),然后只把变化的部分应用到真实的 DOM 上(这个过程叫做 Patch 或 Reconciliation)。
我们来看看 Diff 算法的核心逻辑思路:
-
类型不同(Type changed): 如果旧节点是
<h1>,新节点变成了<p>,说明结构完全变了。React 不会继续对比子节点,而是直接销毁旧的真实 DOM,创建新的真实 DOM 替换上去。 -
类型相同,属性不同(Props changed): 如果新旧节点都是
<div>,只是class从red变成了blue。React 会保留现有的真实 DOM 节点,仅仅调用dom.setAttribute修改它的 class 属性。 -
对比子节点(Children Diff): 递归地对比它们的子节点。
为了在对比子节点时能精准识别出“哪个节点是哪个”(特别是面对列表项打乱顺序、插入、删除的情况),React 引入了 key 属性。有了 key,React 就能知道一个节点到底是新建的,还是只是从别的位置移动过来的,从而最大化复用 DOM。
第四阶段:从递归到可中断渲染(Fiber 架构)
刚才提到的 render 和 Diff 过程有一个致命的问题:它们是递归的。
JavaScript 是单线程执行的,且与浏览器的绘制引擎互斥。如果页面非常庞大,虚拟 DOM 树很深,一旦开始递归遍历,JavaScript 就会一直占用主线程,可能需要几十甚至上百毫秒。在这个过程中,浏览器无法响应用户的点击,也无法绘制动画,页面就会显得“卡顿”。
为了解决这个问题,React 16 彻底重写了核心架构,引入了 Fiber。
Fiber 的核心思路是:将一个庞大的渲染任务,拆分成无数个小任务。执行完一个小任务后,看看浏览器有没有更紧急的事情(比如用户点击、动画绘制),如果有,就暂停渲染,把控制权交还给浏览器;等浏览器空闲了,再接着渲染。 这被称为“时间切片”(Time Slicing)。
要实现“暂停和恢复”,递归就行不通了(因为函数的调用栈无法中途暂停并保存状态)。React 把树形结构的虚拟 DOM 改造成为了一种单链表结构,也就是 Fiber 树。
每个 Fiber 节点代表一个工作单元,并且保存了三个指针,将所有节点连接起来:
-
child:指向第一个子节点。 -
sibling:指向右边的兄弟节点。 -
return:指向父节点。
Fiber 的工作循环(Work Loop)伪代码:
let nextUnitOfWork = null; // 下一个要执行的 Fiber 节点
// 浏览器的 API,在浏览器空闲时调用该函数
function workLoop(deadline) {
let shouldYield = false;
// 如果有任务,且还有空闲时间,就一直执行
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // 执行当前任务,并返回下一个任务
// 检查浏览器是否快要没有时间了(比如接近 16.6ms 的渲染周期)
shouldYield = deadline.timeRemaining() < 1;
}
// 如果任务没做完,等浏览器下次空闲继续
if (nextUnitOfWork) {
requestIdleCallback(workLoop);
} else {
// 所有任务都计算完毕,一次性将变化提交(Commit)到真实的 DOM 上
commitRoot();
}
}
// 开启循环
requestIdleCallback(workLoop);
在 performUnitOfWork 中做了什么?
-
处理当前节点: 为当前 Fiber 节点创建真实 DOM(但不挂载到页面上)。
-
构建 Fiber 树和 Diff: 找出当前节点的子节点的变化,为它们创建子 Fiber 节点,并建立
child、sibling、return链接。记录下当前节点需要做什么具体的 DOM 操作(比如更新属性,或者删除节点)。 -
返回下一个任务: 按照“先找子节点 -> 再找兄弟节点 -> 最后回溯到父节点”的顺序,返回下一个需要处理的 Fiber 节点。
在这个阶段,真实的页面是没有任何变化的,一切都在内存中进行。
当所有的 Fiber 节点都遍历计算完毕后(nextUnitOfWork 变成 null),React 就会进入 Commit 阶段。在这个阶段,React 会根据刚才记录下来的差异,一口气、同步地把所有的改变应用到真实的 DOM 上,从而避免用户看到渲染了一半的页面。
整体脉络回顾
-
编写代码: 我们写类似 HTML 的 JSX 代码。
-
构建 VDOM: JSX 被转译成
createElement函数调用,生成轻量级的 JavaScript 对象(虚拟 DOM)。 -
调度与协调(Render Phase / Reconciliation): React 将虚拟 DOM 转化为 Fiber 树。利用浏览器的空闲时间,分段计算新旧节点的差异,并在内存中构建需要更新的 DOM 树。这个阶段是可中断的。
-
提交更新(Commit Phase): 当所有差异计算完毕,React 一次性将这些变化(增删改)应用到浏览器的真实 DOM 上。这个阶段是同步且不可中断的。
通过将 UI 转化为数据结构(虚拟 DOM),再通过高效的对比(Diff),并利用链表结构和调度器(Fiber)解决性能瓶颈,这就是 React 能够高效渲染复杂交互界面的底层逻辑。
在上一部分的推导中,我们明确了 Fiber 节点是 React 内部的工作单元,它以对象的形式存在于内存中,记录了组件的结构、属性和 DOM 节点。
但是在引入 Hooks 之前,函数组件(Function Component)存在一个致命的物理限制:函数每次执行完毕后,内部的局部变量就会被销毁(垃圾回收)。 如果页面重新渲染,函数再次被调用,所有的变量都会重新初始化。函数组件本身是没有“记忆”的。
Hooks 的本质,就是提供一种机制,让无状态的函数组件能够将自己的内部状态和副作用“挂载”到它对应的那个 Fiber 节点上。 只要 Fiber 节点不被销毁,这份“记忆”就一直存在。
为了把这个过程拆解清楚,我们将重点剖析 useState 和 useEffect 的核心算法思路。
第五阶段:赋予函数组件记忆(Hooks 架构)
当 React 准备渲染一个函数组件时,它会做两件事:
-
把当前正在处理的 Fiber 节点保存到一个全局变量中(我们暂且叫它
wipFiber,Work In Progress Fiber)。 -
在这个 Fiber 节点上初始化一个数组(或链表),用来存放这个组件里调用的所有 Hooks。同时准备一个游标变量(比如
hookIndex),初始值为 0。
1. useState 的核心逻辑
当你在函数组件内部调用 useState 时,React 并没有施展魔法,它只是在执行一次普通的函数调用,并进行如下操作:
JavaScript
// 全局变量,记录当前正在渲染的 Fiber 节点和 Hook 索引
let wipFiber = null;
let hookIndex = null;
function useState(initialValue) {
// 1. 尝试获取上一次渲染时留下的旧 Hook 状态
// wipFiber.alternate 指向上一次渲染完毕的旧 Fiber 树中的对应节点
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
// 2. 构造当前这一次渲染的全新 Hook 对象
const hook = {
// 如果有旧状态就继承,没有就使用初始值
state: oldHook ? oldHook.state : initialValue,
// 用于存放那些还未执行的更新操作(比如多次调用 setState)
queue: [],
};
// 3. 计算最新状态
// 如果在两次渲染之间,用户调用了 setState,这些操作会被暂存在旧 Hook 的 queue 中
const actions = oldHook ? oldHook.queue : [];
actions.forEach(action => {
// 假设 action 是一个直接的值,或者是类似 prev => prev + 1 的函数
hook.state = typeof action === 'function' ? action(hook.state) : action;
});
// 4. 定义更新状态的函数 setState
const setState = action => {
// 将更新动作推入队列
hook.queue.push(action);
// 【核心触发点】:告诉 React 需要重新渲染了。
// 这会将当前树标记为需要更新,并重新启动我们在第四阶段讲过的 Work Loop(工作循环)
triggerRender();
};
// 5. 将这个构造好的 hook 对象保存到当前 Fiber 节点的 hooks 数组中
wipFiber.hooks.push(hook);
// 6. 游标向后移动一位,为下一个可能出现的 Hook 做准备
hookIndex++;
// 7. 返回当前状态和修改状态的方法
return [hook.state, setState];
}
2. 解构“不能在条件语句中使用 Hooks”的约束边界
现在我们可以从底层逻辑来审视 React 官方提出的一条铁律:“绝对不要在 if 或循环中调用 Hooks”。
仔细观察上面的代码,useState 的内部完全没有标识符(ID)或变量名来区分它是哪个状态。它唯一依赖的,是 hookIndex 这个递增的数字索引。
假设我们有这样的组件:
JavaScript
function App() {
const [name, setName] = useState('Alice'); // hookIndex: 0
if (Math.random() > 0.5) {
const [age, setAge] = useState(25); // hookIndex: 1
}
const [city, setCity] = useState('Beijing'); // hookIndex: 2
}
如果在某次渲染中,Math.random() 导致 if 语句没有执行:
-
useState('Alice')执行,读取旧的hooks[0]。hookIndex变为 1。 -
if被跳过。 -
useState('Beijing')执行,此时它会去读取旧的hooks[1]。结果,原本属于
age的状态被错误地赋值给了city。组件的数据会瞬间彻底错乱。
结论:Hooks 的状态能够正确映射,建立在一个不可动摇的条件上——每次渲染时,组件内部 Hooks 的调用顺序和数量必须绝对保持一致。
代码段
3. useEffect 的底层执行链路
如果说 useState 是为了解决数据的存储问题,那么 useEffect 就是为了解决副作用的调度问题。副作用指的是那些与 UI 渲染计算无关的操作,比如发起网络请求、操作非 React 控制的真实 DOM、设置定时器等。
useEffect 的内部结构也是一个挂载在 Fiber 节点上的对象,它的核心逻辑围绕着依赖项(Dependencies)比对展开。
JavaScript
function useEffect(callback, dependencies) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
// 检查依赖项是否发生了变化
const hasChanged = oldHook
? !dependencies.every((dep, i) => dep === oldHook.dependencies[i])
: true;
const hook = {
dependencies,
// 如果依赖变了(或者没有提供依赖项),记录下需要执行的 callback
// 否则直接复用之前的 callback 占位
effect: hasChanged ? callback : null,
// 记录可能存在的清理函数(比如 clearTimeout)
cleanup: oldHook ? oldHook.cleanup : null,
};
wipFiber.hooks.push(hook);
hookIndex++;
}
这里需要理清一个非常重要的时间线。副作用函数(callback)绝对不能在组件的渲染阶段(Render Phase)执行。 在之前的 Fiber 讲解中我们提到,Render 阶段是可以被浏览器中断的。如果在这个阶段发起网络请求,一旦渲染被中断并重新开始,请求就会被触发多次。
因此,React 对副作用的处理流程是分离的:
-
Render 阶段: 执行组件函数,遇到
useEffect时,只计算依赖项是否改变,将需要执行的副作用函数收集起来,暂存在 Fiber 节点上。 -
Commit 阶段: 计算完所有节点的差异后,同步地把变化应用到真实 DOM 上(页面更新)。
-
Commit 阶段之后: 此时真实的 DOM 已经更新完毕。React 会遍历所有的 Fiber 节点,找出那些标记了需要执行的 effect。先执行旧 hook 中记录的
cleanup函数(清理上一次的遗留物),然后再执行新的effect函数。
全景视图:React 的闭环运行机制
将第一步的 Virtual DOM 到现在的 Hooks 结合起来,我们可以清晰地勾勒出 React 运转的完整链路:
-
初始化:
createRoot(container).render(<App />)触发首次渲染,进入 Work Loop 建立 Fiber 树。 -
函数执行与挂载状态: 调度器执行
App()函数。内部调用的useState和useEffect依次在当前的 Fiber 节点上创建并挂载状态对象。 -
构建 VDOM 与对比: 函数返回一段 JSX(也就是
createElement嵌套调用生成的虚拟 DOM)。React 将这份虚拟 DOM 与当前的 Fiber 节点进行比对(Diff)。 -
提交到屏幕: 浏览器空闲时间被榨干或者计算完毕后,统一 Commit,操作真实 DOM。
-
执行副作用: 真实 DOM 更新后,依次触发依赖项发生变化的
useEffect回调。 -
状态变更触发重启: 用户点击按钮触发
setState。React 将更新动作塞入对应 Hook 的队列中,重新将App所在的 Fiber 节点标记为起点,重新开启 Work Loop,进入下一轮循环。
这种将状态依附于 Fiber 链表、通过不可变数据(Virtual DOM)描述视图、依靠可中断调度循环(Work Loop)分配计算资源、并在最终统一提交(Commit)的架构,构成了现代 React 运行的全部基石。