从零手写 React:深度解析 Fiber 架构与 Hooks 实现
在前端开发中,React 无疑是最受欢迎的框架之一。然而,仅仅停留在使用 API 的层面是不够的。为了真正理解 React 的底层运行机制,最好的方式就是抛开现有的框架,从零开始(Vanilla JS)手写一个简易版的 React。
本文将带领大家一步步构建一个包含 JSX 编译、Fiber 架构、并发渲染(Concurrent Mode)、函数组件以及 useState 和 useEffect 等核心 Hooks 的迷你 React。
一、 从原生 DOM 操作到数据结构抽象
在纯原生(Vanilla)开发中,我们通常这样创建和挂载 DOM:
const div = document.createElement('div');
div.id = 'app';
div.style.color = 'red';
const text = document.createTextNode('我是文本');
div.appendChild(text);
const root = document.querySelector('#root');
root.appendChild(div);
为了让代码具备更好的可复用性和抽象性,我们需要将 DOM 节点抽象为数据结构(虚拟 DOM):
const element = {
type: "div",
props: {
id: 'app',
style: 'color:red',
children: [
{
type: 'TEXT_ELEMENT',
props: {
nodeValue: '我是文本',
children: []
}
}
]
}
}
注:文本节点没有常规的标签名,我们统一使用 TEXT_ELEMENT 作为它的 type。
基于这种树形数据结构,我们可以编写一个基础的 render 函数,通过同步递归的方式创建并挂载真实 DOM。
二、 核心起点:createElement 与 JSX 的本质
1. JSX 是如何被编译的?
在 React 中,我们习惯使用 JSX 语法:
const App = <div id="app">Hello</div>
本质上,打包工具(如 Vite/Babel)会将这段 JSX 编译成普通的 JS 函数调用(经典模式):
const App = React.createElement("div", { id: "app" }, "Hello");
因此,我们需要在项目(如 vite.config.js)中配置 JSX 工厂函数指向我们手写的 React.createElement:
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
esbuild: {
jsx: 'transform',
jsxFactory: 'React.createElement',
jsxFragment: 'React.Fragment',
}
})
2. 封装 createElement
接下来,我们实现 createElement,它负责将 JSX 编译后的参数转化为我们上面定义的虚拟 DOM 结构:
function createElement(type, props, ...children) {
return {
type,
// JSX 无属性时编译结果是 null,需要兜底
props: {
...(props || {}),
// 规范化 children:将基本数据类型包装成 TEXT_ELEMENT
children: children.map(child =>
typeof child === 'object' ? child : createTextElement(child)
)
}
}
}
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: []
}
}
}
三、 引入 Fiber 架构与时间切片
1. 为什么需要 Fiber?
在早期(React 15 及之前),React 的渲染过程是同步递归的。如果组件树非常庞大,递归创建 DOM 会长时间占用浏览器主线程,导致浏览器无法处理用户的交互(点击、输入)和动画,从而引发页面卡顿。
为了解决这个问题,我们需要引入 Fiber 架构。其核心思想是将整个渲染任务切片,在浏览器每一帧的空闲时间里执行一小部分任务,执行完后将控制权交还给浏览器,避免阻塞主线程。
2. requestIdleCallback 与浏览器渲染机制
浏览器渲染通常是每秒 60 帧,即每 16.6ms 渲染一帧。在这一帧内,浏览器需要处理事件、执行 JS、计算样式、布局和绘制。如果这些任务做完后还有剩余时间,浏览器就会触发 requestIdleCallback 注册的回调。
注:W3C 规定空闲时间单次最多给 50ms,防止长期占用导致用户输入无响应。
3. Fiber 数据结构
为了能够随时暂停和恢复渲染任务,我们不能再使用传统的递归树,而是将树转化为链表。每个 Fiber 节点就是一个工作单元(Unit of Work),包含以下核心指针:
child: 指向第一个子节点sibling: 指向下一个兄弟节点parent: 指向父节点
4. 任务调度循环 (WorkLoop)
我们利用 requestIdleCallback 建立一个工作循环:
let nextUnitOfWork = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
// 执行一个工作单元,并返回下一个工作单元
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
// 如果当前帧剩余时间不足 1ms,则暂停任务
shouldYield = deadline.timeRemaining() < 1;
}
// 当所有任务完成,且存在根节点时,一次性将结果提交到真实 DOM
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
// 启动循环
requestIdleCallback(workLoop);
四、 构建 Fiber 树与支持函数组件
performUnitOfWork 是构建 Fiber 树的核心。针对普通 DOM 标签和函数组件,我们需要分别处理。
函数组件与普通组件最大的不同在于:函数组件本身没有对应的真实 DOM,它的子节点是通过执行该函数得到的虚拟 DOM 返回值。
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
// 寻找下一个工作单元:先找子节点,再找兄弟节点,最后找叔叔节点
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
return null;
}
// 处理普通标签组件
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}
// 处理函数组件
let wipFiber = null; // 全局变量,记录当前正在处理的函数组件 Fiber
function updateFunctionComponent(fiber) {
wipFiber = fiber;
// 函数组件执行后返回其 children
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
五、 Diff 算法与 Commit 阶段
当我们触发更新时,不能每次都推翻重建。我们需要使用 Diff 算法对比新旧 Fiber 树,尽量复用原有的 DOM 节点。
1. Diff 核心逻辑:reconcileChildren
在构建新的 Fiber 树时,我们给每个节点添加一个 alternate 属性,指向它在旧树中的对应节点。
对比时:
- 类型相同 (UPDATE):保留旧 DOM,仅更新属性。
- 类型不同且存在新节点 (PLACEMENT):创建新 DOM 节点。
- 类型不同且存在旧节点 (DELETION):将旧节点标记为删除,存入
deletions数组。
function reconcileChildren(fiber, children) {
let oldFiber = fiber.alternate && fiber.alternate.child;
let index = 0;
let prevSibling = null;
while (index < children.length || oldFiber) {
const element = children[index];
const sameType = oldFiber && element && element.type === oldFiber.type;
let newFiber = null;
if (sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: oldFiber.dom, // 复用旧 DOM
parent: fiber,
alternate: oldFiber,
effectTag: "UPDATE",
};
} else if (element) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: fiber,
alternate: null,
effectTag: "PLACEMENT",
};
}
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
// 构建链表指针
if (index === 0) {
fiber.child = newFiber;
} else if (element) {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
2. 渲染到页面:CommitWork
在 Fiber 树完全构建好之后,我们在 commitRoot 阶段统一处理 DOM 的增删改操作。
注意:因为函数组件没有真实的 DOM,所以在向父级追加节点时,需要通过 while 循环向上寻找到真正具备 dom 的祖先节点。
function commitWork(fiber) {
if (!fiber) return;
let parentFiber = fiber.parent;
while (!parentFiber.dom) {
parentFiber = parentFiber.parent;
}
const parentDom = parentFiber.dom;
if (fiber.effectTag === "PLACEMENT" && fiber.dom) {
parentDom.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, parentDom);
return; // 删除节点后无需继续遍历其子节点
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
六、 状态管理:useState 的实现
在 React 中,我们通过 useState 来管理组件状态并触发重渲染。其核心逻辑是利用 Fiber 节点(wipFiber)保存状态和更新队列。
1. useState 核心原理
- 通过
wipFiber.alternate获取上一次渲染时的旧 Hook。 - 继承旧状态,并执行所有在队列中的更新操作(setState 传入的 action)。
- 声明一个
setState闭包函数,它负责将新的 action 存入队列,并调用update触发整棵树的重新渲染。
// 在处理函数组件时初始化 hooks
function updateFunctionComponent(fiber) {
wipFiber = fiber;
wipFiber.hookIndex = 0;
wipFiber.hooks = [];
wipFiber.effects = [];
// ...
}
function useState(initValue) {
const oldHook = wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[wipFiber.hookIndex];
const hook = {
state: oldHook ? oldHook.state : initValue,
queue: []
};
// 批量处理状态更新
const actions = oldHook ? oldHook.queue : [];
actions.forEach(action => {
hook.state = typeof action === "function" ? action(hook.state) : action;
});
const setState = (action) => {
hook.queue.push(action);
update(); // 触发重新调度和渲染
};
if (!wipFiber.hooks) {
wipFiber.hooks = [];
}
wipFiber.hooks.push(hook);
wipFiber.hookIndex++;
return [hook.state, setState];
}
💡 思考:为什么 React 规定 Hook 不能写在条件判断(if)或循环中?
从上述源码可以看出,Hook 的状态提取完全依赖于 wipFiber.hookIndex 这个数组索引。如果某个 Hook 因条件判断未执行,后续所有 Hook 的索引都会错位,导致状态张冠李戴引发严重 Bug。
七、 副作用与清理机制:useEffect 的实现
useEffect 同样挂载在函数组件的 Fiber 节点上。它会在 DOM 渲染完成(commitRoot 之后)时统一执行。
1. 收集 Effect
我们在 wipFiber 上增加 effects 数组,将副作用回调和依赖项存入:
function useEffect(callback, depends) {
const effect = {
callback,
depends,
clear: null // 用于保存清理函数
};
wipFiber.effects.push(effect);
}
2. 执行与清理 Effect
在所有的 DOM 操作(commitWork)结束后,我们遍历 Fiber 树,对比依赖项。如果依赖发生变化,则先执行上一次的清理函数(clear),再执行新的副作用回调。
function commitEffect(fiber) {
if (!fiber) return;
fiber.effects?.forEach((effect, index) => {
if (!fiber.alternate) {
// 首次挂载
effect.clear = effect.callback();
} else {
const depends = effect.depends;
const oldDepends = fiber.alternate.effects[index]?.depends;
// 检查依赖项是否发生改变
const hasChanged = !depends || depends.some((item, i) => item !== oldDepends[i]);
if (hasChanged) {
// 先执行旧的清理函数
if (typeof fiber.alternate.effects[index]?.clear === "function") {
fiber.alternate.effects[index].clear();
}
// 执行新的副作用并保存清理函数
effect.clear = effect.callback();
}
}
});
// 递归处理整棵树
commitEffect(fiber.child);
commitEffect(fiber.sibling);
}
注:清理函数(Cleanup Function)非常重要,常用于清除定时器、取消网络请求或移除事件监听,防止内存泄漏和不必要的逻辑冲突。
八、 总结
通过以上步骤,我们从零实现了一个微型 React(Mini-React)。在这个过程中,我们深刻理解了:
- JSX 本质上是
React.createElement的语法糖,它返回的是描述界面的虚拟 DOM。 - Fiber 架构 是一套基于链表结构和
requestIdleCallback的时间切片机制,解决了大型组件树同步渲染卡顿的问题。 - Diff 算法 使得我们可以对比新旧 Fiber 树,高效复用真实 DOM。
- Hooks 依托于 Fiber 节点上挂载的数组与索引运行,因此必须保证其调用顺序的绝对稳定。
掌握这些底层机制,不仅能帮助我们在日常开发中写出性能更优的 React 代码,更能提升我们在复杂业务场景下排查与解决 Bug 的能力!