news 2026/10/21 5:39:39

从零手写迷你版React:深入Fiber架构与状态管理核心

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手写迷你版React:深入Fiber架构与状态管理核心

1. 项目概述:一个小而完整的迷你版 React 核心实现

rea这个项目名,我第一眼看到时就想到了 React 的缩写。手头正好做完了一个类似的自学项目,核心目标只有一个:不依赖任何框架源码,从零手写一个能跑通JSX 渲染、组件更新、状态管理三个核心流程的迷你版 React 核心实现。我把这个项目命名为rea,取的是 React Elementary Architecture 的意思——用最精简的代码,把 React 最核心的架构原理拆开揉碎,重新组装一遍。

这个项目能做什么?简单说,它能让你写<Counter />这样的组件,组件内部有useState和useEffect,点击按钮会走完整的状态更新、DOM 差异对比、局部刷新流程。它不生产级框架,但你用它能直观理解 React 源码里最晦涩的几个概念:Fiber 架构、协调过程、hook 链式调用原理、更新优先级调度。

适合谁来参考?一种是正在学 React 但总感觉隔着一层纱的开发者,看了无数篇文章还是不明白为什么setState是异步的;另一种是自己读过源码但很多细节记不清,想通过动手写一个简化版来捋顺思路的进阶玩家。我个人属于后者,写完之后对 React 调度机制的理解确实上了一个台阶。整个项目核心代码量在 600 行左右,拆解成了六个模块,下面我从头到尾讲讲设计思路和实现细节。

2. 核心思路拆解:为什么手写框架是理解源码最有效的路径

2.1 手写前必须想清楚的四件事

动手之前,我花了整整一个周末梳理 React 的核心链路,画了无数张草图,最终把实现范围限定在四条主链路上。

第一条链路是JSX 如何处理。项目不需要自己搞一套编译器,而是用官方推荐的@babel/plugin-transform-react-jsx将 JSX 语法转换为jsx(type, props, ...children)函数调用的形式。以<div className="wrap">Hello</div>为例,经过 babel 转换后等价于:

import { jsx } from "./rea"; jsx("div", { className: "wrap" }, "Hello");

第二条链路是渲染器如何工作。需要一个createRoot(container).render(element)入口,在首次渲染时把整棵 vdom 树挂载为真实 DOM,同时建立一棵内部 Fiber 树,记录每个节点的父子关系、兄弟关系、类型、属性和对应的 DOM 节点。

第三条链路是组件如何更新。这一步最难,要处理状态更新时组件重新执行的流程。具体包括:触发更新后如何从当前 Fiber 节点向上回溯到根节点,如何构建出新的 vdom 树并与旧 Fiber 树做差异对比,如何把差异以最小代价应用到真实 DOM 上。

第四条链路是hook 机制如何串联。useState和useEffect的实现难点在于:一个组件内多次调用 hook,React 怎么知道每次渲染时该取哪份状态?我把 hook 设计为组件 Fiber 节点上挂载的一个队列,每次组件渲染时按调用顺序依次取出对应 hook 的状态。这条链路理解透了,就会明白为什么 React 官方文档建议不要在循环、条件语句中调用 hook。

2.2 架构选型:为什么选用 Fiber 剥离渲染

我在写之前对比了两种常见方案。第一种是递归渲染方案,用递归函数同步遍历整棵 vdom,直接在过程中创建/更新 DOM。优点是代码极其简洁,大概一百行能写完,缺点是一旦组件树深了,递归会阻塞主线程,用户操作会卡顿。第二种就是 Fiber 方案,将递归过程拆分为可中断的“工作单元”。

我选的方案是借鉴 Fiber 的思路,但做了大幅简化。核心设计是每个 vdom 节点都对应一个 Fiber 节点,Fiber 节点保存三类数据:

  • 类型与key:用于判断节点是否可复用(diff 的第一道关卡)
  • props:包括属性、事件处理函数、children 等
  • 连接信息:指向父节点、子节点、兄弟节点的引用,以及它对应的真实 DOM 节点

这种设计让“渲染”和“协调”解耦。渲染器只负责把 Fiber 树上的属性翻译成真实 DOM 操作;协调器则专注于新旧两棵树的差异计算。我亲自实现过后才理解 React 源码中beginWork和completeWork这两个阶段的含义:一个向下递,一个向上归。

2.3 项目结构设计

我把整个项目拆成了六个模块,相互独立,通过统一的内部类型接口协作:

模块职责核心对外API
react.js暴露 jsx 创建函数与 render 入口jsx,createRoot
fiber.jsFiber 节点的创建与连接操作createFiber,setChild
render.js将 Fiber 树递归渲染到真实 DOMrenderElement,updateElement
reconciler.js调度更新,协调新旧两棵树scheduleUpdate,reconcileChildren
hooks.jshook 队列与状态管理useState,useEffect
commit.js将 diff 结果应用到真实 DOMcommitWork

这种设计在后续排错时帮了大忙。每个模块的边界非常清晰,出问题时能很快定位到具体函数,而不是在一大坨逻辑里翻来翻去。

3. 核心细节解析:Fiber 节点、diff 算法与 hook 链

3.1 Fiber 节点的数据结构设计

每个 Fiber 节点的结构在实现中长这样:

const fiber = { type: "div", // 节点类型:标签名字符串、函数(组件)、文本节点 key: null, // key 属性,用于列表 diff props: {}, // 属性对象,包括 className、onClick、children 数组等 dom: null, // 对真实 DOM 节点的引用,首次渲染后赋值 parent: null, // 指向父 Fiber child: null, // 指向第一个子 Fiber sibling: null, // 指向下一个兄弟 Fiber alternate: null, // 指向旧 Fiber(同一节点上一次渲染的版本) effectTag: null, // 标记:UPDATE / PLACEMENT / DELETION hooks: [] // 组件 Fiber 独有的 hook 链表 };

每个字段都有存在的意义。比如alternate是 diff 算法的关键枢纽:新渲染出的 Fiber 节点通过它找到上一次渲染的对应节点,才能进行比较;effectTag是“待处理标记”的集合,所有 Fiber 树构建完成后再统一遍历处理。这个设计让我理解了一个常被忽略的点——React 并不是每渲染一个新节点就立刻操作 DOM,而是先把需要的操作全部标记在 Fiber 树上,最后一次性提交变更。这样能最大化利用浏览器空闲时间,避免频繁操作真实 DOM。

3.2 diff 算法:三刀切开的节点比较

写 diff 算法前我认真读了 React 团队那篇知名博客。React 官方给出了三个决策条件,按顺序逐条比较:

  • 如果新旧节点type不同,直接判定为不同节点,整棵子树一起重建
  • 如果key不同,也直接判定为不复用
  • 如果前两条都相同,则认为节点“基本一致”,进入属性更新流程

我按这三个条件实现了reconcileChildren函数。这里有个细节值得单独拿出来说说:type 相同时怎么做属性 diff。

我的做法是:合并新旧 props 取唯一属性集合,再逐一判断是否要删除或更新:

function reconcileProps(fiber) { const oldProps = fiber.alternate ? fiber.alternate.props : {}; const newProps = fiber.props; const dom = fiber.dom; // 第一步:找出需要删除的属性 for (const key in oldProps) { if (!(key in newProps)) { if (key.startsWith("on")) { // 移除事件监听:从 id 中提取真实事件名,例如 onClick -> click const eventType = key.slice(2).toLowerCase(); dom.removeEventListener(eventType, oldProps[key]); } else if (key !== "children") { dom.removeAttribute(key); } } } // 第二步:找出需要新增或更新的属性 for (const key in newProps) { if (key === "children") continue; if (key.startsWith("on")) { const eventType = key.slice(2).toLowerCase(); // React 合成事件简化:真实 DOM 只保留最新的监听函数 dom.removeEventListener(eventType, oldProps[key]); dom.addEventListener(eventType, newProps[key]); } else if (key === "className" || key === "style") { // className 与 style 需要特殊处理 if (key === "className") { dom.setAttribute("class", newProps[key]); } else { Object.assign(dom.style, newProps[key]); } } else { dom.setAttribute(key, newProps[key]); } } }

很多教程里会一笔带过的三个坑,我在这一步全踩了:

坑一:事件监听的更新方式。如果先 addEventListener 再 removeEventListener,新旧函数都会响应事件。必须先把旧监听移除,再挂新监听。否则在闭包场景下,组件会捕获旧的状态值,产生典型的“陈旧闭包”问题。

坑二:style属性的处理。如果直接dom.setAttribute("style", newProps[key]),当传入的是对象时,会隐式调用对象的toString(),结果为[object Object]。需要单独使用Object.assign(dom.style, newProps[key])逐次拷贝样式键值。

坑三:属性变化时,部分 DOM 节点在比较上很特殊。比如value和checked两个属性在 React 中属于受控组件范畴,但普通元素不会在真实 DOM 中体现出来,需要额外判断元素类型,避免把这些属性误设到普通元素上。

3.3 hook 链式调用背后的队列机制

这个部分我花的时间最久。useState的实现如果不借助闭包很难做到跨 render 共享状态。我参考 React 源码中的“hook 链表 + dispatcher”设计,简化出了自己的实现方案。

核心设计:每个组件 Fiber 节点上挂着一个hooks数组,数组下标表示第几个 hook。首次渲染时,组件函数执行完会按调用顺序依次创建 hook 对象,存入数组。后续 rerender(重新渲染)时,同一组件函数再次执行,通过一个全局的“当前正在工作的 hook 下标”逐位取出对应旧状态,并复用 hook 对象本身。

具体实现里最巧妙的一点是状态更新的来源不是直接改写当前 hook 的值,而是把它推入一个updateQueue(更新队列),然后触发一次新的调度:

function useState(initialValue) { const fiber = getCurrentFiber(); const hookIndex = getCurrentHookIndex(); // 首次渲染时初始化,非首次渲染时复用旧 hook 对象 const hook = fiber.hooks[hookIndex] ? fiber.hooks[hookIndex] : { state: initialValue, queue: [] }; // 处理队列中等待应用的所有更新动作 hook.queue.forEach((action) => { hook.state = typeof action === "function" ? action(hook.state) : action; }); hook.queue = []; // 更新函数:不是直接修改 state,而是入队后触发调度 const setState = (action) => { hook.queue.push(action); scheduleUpdate(fiber); }; fiber.hooks[hookIndex] = hook; setCurrentHookIndex(hookIndex + 1); return [hook.state, setState]; }

这里面有个优化细节值得说:为什么 setState 支持传函数而不是只支持传值?因为 React 处理连续点击多个setState((count) => count + 1)时,如果每个 setState 都只存一个最终值,那第二次、第三次更新都会拿到同一个基准值,结果就是只加了 1 而不是加 3。队列中依次执行函数式的 action 就能天然解决这个“多次连续更新依赖前一次结果”的问题,所以我保留了action(hook.state)这种形式。

useEffect的实现则依赖对等条件判断:

function useEffect(callback, deps) { const fiber = getCurrentFiber(); const hookIndex = getCurrentHookIndex(); const prevDeps = fiber.hooks[hookIndex] ? fiber.hooks[hookIndex].deps : null; if (!prevDeps || !deps || prevDeps.some((dep, i) => dep !== deps[i])) { // 将副作用加入提交阶段执行列表 scheduleEffect(callback); } fiber.hooks[hookIndex] = { deps }; setCurrentHookIndex(hookIndex + 1); }

这个实现没有做到真正的异步执行,而是在 commit 阶段统一执行,但对理解“依赖数组决定是否执行副作用”这一核心逻辑已经非常直观。

4. 实操过程全记录:从零跑通一次完整更新

4.1 环境准备与 Babel 配置

我用 Vite 作为开发服务器,同时手动配置了 Babel 插件来处理 JSX 转换。安装的依赖如下:

pnpm add react rea pnpm add -D @babel/core @babel/plugin-transform-react-jsx vite

在.babelrc中配置 JSX 插件指向自定义的 jsx 运行时:

{ "plugins": [ [ "@babel/plugin-transform-react-jsx", { "runtime": "automatic", "importSource": "rea" } ] ] }

设置runtime: automatic后,Babel 会自动在文件顶部注入import { jsx, jsxs } from "rea",无需手动在每个组件里导入。这里遇到一个细节:JSXautomatic模式默认导入的是jsx和jsxs两个函数,我的react.js模块里必须同时导出这两个函数。刚开始我只导出了jsx,结果页面渲染空白,控制台警告引用不存在。所以这里务必记得export { jsx, jsxs }。

4.2 入口文件与渲染管线

入口文件长这样:

import { createRoot } from "rea"; import App from "./App"; const container = document.getElementById("root"); const root = createRoot(container); root.render(<App />);

createRoot返回的对象内部保存了根容器和根 Fiber 节点。render方法做的事情表面上是“渲染一个组件”,实际上是把<App />这个 vdom 对象作为nextUnitOfWork(下一个工作单元),交给 reconcile 调度器。

首次渲染流程如下:

  1. 创建根 Fiber 节点,alternate为空
  2. 进入performUnitOfWork函数:取出<App />节点,调用组件函数获得其返回的子 vdom 树
  3. 对子 vdom 树的每个节点一一创建 Fiber,用 child / sibling 指针串成链表结构
  4. 全部 Fiber 节点标记为PLACEMENT(需要插入)
  5. 从叶子节点开始,逐级向上 commit:创建真实 DOM 元素,填充属性,挂载事件,插入到父容器
  6. 遍历完整棵 Fiber 树后,将整棵树挂到根容器下

4.3 更新流程:setState 之后到底发生了什么

组件内点击按钮触发setState后,我设计的调度器执行以下步骤:

  1. 收集更新:将当前触发更新的 Fiber 节点标记为“待更新根”,存入全局变量
  2. 提取新状态:在调度循环中再次调用组件函数,获得一个新生成的值和返回的新 vdom 树
  3. 构建新 Fiber 树:为新的 vdom 树创建 Fiber 节点,并让每个新节点的alternate指向旧树对应节点
  4. 执行 diff:从根节点开始,对比新旧 vdom 树,生成 effectTag 列表
  5. 提交变更:遍历含 effectTag 的 Fiber 节点,根据PLACEMENT/UPDATE/DELETION分别执行插入、更新属性和删除节点

这里有一个非常容易踩的坑:render函数本身是同步递归的,但真实 React 中渲染和提交是分离的两阶段。在这个简化版里,我为了保证流程简单,把 schedule 阶段做成同步执行,但刻意保留了 commit 阶段的独立遍历。这样做的意义在于:渲染阶段可以随意中断、丢弃、重来,不会对真实 DOM 造成任何不可逆的破坏;而提交阶段一旦开始,就必须完整执行,因为此时所有修改都已经是相对真实 DOM 的最终决定了。

4.4 实际效果测试

为了验证项目能跑通最常见的场景,我写了一个计数器组件:

function Counter() { const [count, setCount] = useState(0); useEffect(() => { console.log(`count changed to ${count}`); }, [count]); return ( <div> <h1>{count}</h1> <button onClick={() => setCount((c) => c + 1)}>+1</button> <button onClick={() => setCount(0)}>reset</button> </div> ); }

点击三次加号按钮后,页面上显示 3,控制台依次打印count changed to 1、count changed to 2、count changed to 3。这个结果验证了两件事:功能上,状态更新、重新渲染、副作用触发都正常运行;原理上,每次 setState 都会重新执行 Counter 函数来获取新 vdom,这正是 React 的渲染模型核心。

我还测试了列表渲染中 key 的作用,这里有一段印象非常深的实验:

const [items, setItems] = useState(["A", "B", "C"]); function shuffle() { setItems((prev) => [prev[2], prev[0], prev[1]]); }

第一次我故意不给列表项加 key,Babel 编译时会自动将下标作为 key。结果 shuffle 之后整个 DOM 序列乱了——用户看到的顺序和 items 数组的顺序不一致。这是因为以数组下标作 key 时,diff 算法认为每个位置的节点类型没变,不需要移动 DOM 节点,可数据本身已经换到别的下标上去了。而正确做法应该是给每项指定一个稳定性好的唯一 key,diff 时才能识别出这是一个“移动”操作,而非“原地修改”。这个实验让我彻底明白了 React 文档中key部分的警告绝不是空话。

5. 常见问题与排查技巧实录

5.1 页面空白且控制台无报错

这类问题通常是初始化流程没有执行到 commit 阶段。我用 chrome devtools 断点排查,发现最典型的原因是入口模块没有正确导入jsx函数。因为 Babel 自动注入的 import 语句可能和我手写的 import 语句产生命名冲突,IDE 不会报错,但运行时 import 失败后,浏览器静默跳过了渲染流程。排查技巧:在createRoot().render()前后各打印一行日志,判断入口是否被执行;再在performUnitOfWork开端打印节点类型,判断渲染流程是否被正确启动。

5.2 组件状态更新后 DOM 未刷新

这种问题几乎都是因为新旧 Fiber 树的alternate关系未正确维护。常见的原因是我在更新时重新创建了根 Fiber,却让新旧根节点的alternate为空,导致 diff 阶段找不到旧节点,后续 props 更新直接被跳过。解决办法是确认每次调度更新时,alternate指向的还是上一次渲染完成时保存的那棵完整 Fiber 树。可以在 commit 完成时打一个快照字段,专门用来保存“上一次提交后 Fiber 树的引用”。

5.3 连续点击 setState 时 count 只加了 1

这是我最早遇到的一个困惑。原因是我在实现setState时直接用新值覆盖了 hook 的 state,而不是入队。导致点击第二次时,组件重新渲染,useState返回的已经是第一次更新后的结果,但队列丢弃了那一次的旧状态信息,后续操作没法依赖它继续累加。

解决方法是把 state 的更新改为入队 + 批量处理模式,在组件执行时依次取出队列里所有 action,逐个应用到hook.state上。这个设计直接从根源上解决了 React 的“状态批处理”问题。

5.4 useEffect 的依赖比较失效

useEffect中比较依赖数组时,直接prevDeps === deps是永远 false 的,因为每次 render 都会创建新的数组引用。需要改成逐个元素比较:

function areDepsEqual(prev, next) { if (!prev || !next) return false; if (prev.length !== next.length) return false; for (let i = 0; i < prev.length; i++) { if (prev[i] !== next[i]) return false; } return true; }

这里还有一个细节:如果依赖数组中包含对象类型,直接比较引用依然会出现“依赖变了但 useEffect 没触发”或者反过来“依赖没变但 useEffect 无限制触发”的情况。React 官方文档同样提醒过这一点,深层方案是做值比较而非引用比较,这超出精简版范围,但了解这个瓶颈对理解框架边界非常有用。

5.5 常见问题速查表

问题现象可能原因排查方向
页面空白无报错jsx/jsxs 导入失败检查 Babel importSource 配置与模块导出
组件更新后 DOM 未刷新alternate 链路断裂检查调度中 Fiber 树的引用关系
多次点击累加无效state 直接覆盖而非入队改用 updateQueue 批量应用
列表渲染顺序错乱key 缺失或使用数组下标指定稳定且唯一的 key
useEffect 重复执行依赖数组比较用了引用相等改为逐元素比较
事件函数总是取旧值监听器更新顺序不对先 removeEventListener 再 addEventListener

6. 个人经验小结:完成这个项目后我想说的几件事

手写这个精简版 React 的过程,让我在三个方面的收获远大于阅读任何源码分析文章:

第一,理解边界远比记忆 API 更重要。比如useState的“异步更新”特性并不神秘,它是因为 setState 入队后需要触发一次完整的协调过程,而协调过程无法在事件处理函数内同步完成并立刻反映到当前执行栈中。写一次调度器代码,对这种机制的理解比背十遍文档深刻得多。

第二,diff 算法的取舍直接影响应用体验。我最初实现 diff 时没有移动操作的标记,列表项调换顺序时只能删除重建,效果反响极差。后来才加上effectTag: "MOVE"来处理移动场景。这让我体会到 React 团队在权衡算法复杂度和实际 UI 操作之间找到平衡点的艰难。

第三,一个小细节能影响全部。比如jsxs和jsx的区别,Babel 在 children 为单个元素时调用jsx,多个元素时调用jsxs。如果漏导出哪个,某个场景下就会静默失败。而这些坑,几乎只有真正把一个项目跑起来才会遇见。

如果你也想做类似的项目,我的建议是:不要一开始就追求复刻 React 的全部特性。先从一次全量渲染跑通开始,再逐步加入 diff 更新、key 复用、effect 副作用、批量调度。每加一个功能,都会发现前一个功能里藏着设计缺陷,这种重构过程本身就是最宝贵的训练。有基础的同学习惯直接啃源码,但源码的世界是有太多优化策略叠加的迷宫,自己写一套极简版本,反而更容易在全局上建立正确的图像。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 5:38:59

OpenClaw是什么?给AI装上爪子的开源智能体实战指南

昨天在茶水间&#xff0c;我撞见一场很典型的职场对话。老板对新来的实习生说&#xff1a;“现在做事要会用AI&#xff0c;不然以后简历都不好意思写。”实习生低着头&#xff0c;小声问&#xff1a;“那我用那个……OpenClaw&#xff1f;”老板眼睛一亮&#xff1a;“对&#…

作者头像 李华
网站建设 2026/10/21 5:38:51

MochiWeb服务测试实战:用TDD与EUnit构建可靠Erlang HTTP接口

不管你是从老项目堆里爬出来的运维&#xff0c;还是刚接触Erlang的初学者&#xff0c;只要被“HTTP服务怎么测才靠谱”这个问题卡过&#xff0c;那这篇内容就很适合你。C语言有libevent、Java有Netty、Erlang这边主打轻量并发的HTTP服务器其实是MochiWeb。很多老牌Erlang服务至…

作者头像 李华
网站建设 2026/10/21 5:38:51

NUMA架构详解:多核服务器性能调优的关键实战

前两年我拿到一台双路服务器&#xff0c;十六核起步、内存两百多G&#xff0c;本以为跑什么业务都绰绰余。结果把一个有状态服务部署上去&#xff0c;压测时线程处理耗时忽高忽低&#xff0c;整体吞吐远低于硬件配置应有的水平。排查到最后发现&#xff0c;问题根本不在代码逻辑…

作者头像 李华
网站建设 2026/10/21 5:38:31

基于Spring Boot的网络安全意识教育平台:钓鱼演练与行为闭环设计

1. 为什么这个平台值得做&#xff1a;安全意识教育的真实缺口之前我负责维护一个内部管理系统时&#xff0c;最头疼的事情之一就是每年反复组织的网络安全培训。培训邮件发下去&#xff0c;学习时长刷完&#xff0c;考试抄个答案&#xff0c;表格上填"已完成"&#x…

作者头像 李华
网站建设 2026/10/21 5:37:25

从零搭建分布式微服务架构:技术选型、服务拆分与治理实战

1. 为什么我要从零搭一套分布式微服务1.1 单体架构撑不住之后&#xff0c;我踩过的那些坑去年下半年&#xff0c;我负责维护的一个内部业务系统开始频繁出问题。这套系统最早是一个典型的单体应用&#xff0c;所有模块——用户管理、订单处理、库存扣减、消息通知——全部打包在…

作者头像 李华
网站建设 2026/10/21 5:37:22

STC8H1K28中功率三相无刷电机控制原理图与代码实现

简介&#xff1a;这份资源是面向电子工程师与嵌入式开发者的中功率三相无刷直流电机控制原理图&#xff0c;基于STC8H1K28微控制器设计&#xff0c;适合从事电机驱动、BLDC控制方案开发及调试的技术人员参考。压缩包内共1个PDF文件&#xff0c;约52KB&#xff0c;以电路原理图形…

作者头像 李华