尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

手写React:函数组件与Hooks状态管理底层原理实现

手写React:函数组件与Hooks状态管理底层原理实现 从零手搓 React 系列写到第 8 篇我终于开始处理 function component。这个组件形态我们每天都在写默认它是 React 里最自然的表达。但真到要自己实现它时你会发现它并不是“把类组件去掉 class 那么简单”。我一开始以为render 阶段多判断一下typeof fiber.type function然后直接调用函数拿返回结果就可以了。结果跑通单次渲染只花了几分钟后面补状态、补更新、补 DOM 挂载问题时才意识到函数组件真正改变的是“组件实例”这个模型。它没有 this没有实例每次调用都是一张白纸。真正能跨越多次渲染记住状态的只能是外层那棵 fiber 树。这篇就把我实现 function component 过程中的关键卡点、最小流程和最容易踩的坑拆开讲一遍。如果你也在自己写一个极简 React或者想真正理解函数组件与 hooks 的底层关系这篇会比较对路。1. 先弄清楚 function component 到底改变了什么1.1 从类组件到函数组件不是删掉 class 那么简单在 React 还是以 class 组件为主的时代组件背后是一个实例对象。React 需要new出这个实例调用它的constructor初始化状态在合适的时候调用render()得到子节点再通过componentDidMount、componentDidUpdate这些生命周期方法通知它“你该做点善后工作了”。这个模型里组件实例是稳定存在的它天然成了状态和生命周期方法的宿主。但 function component 没有这些。它只是一个普通函数function Greeting({ name }) { return createElement(div, null, hello ${name}); }没有this没有state没有生命周期方法。React 能做的最直接操作就是调用这个函数拿到它返回的虚拟 DOM然后继续往下调和。很多人第一次理解 function component 时会把它当作“简化版的类组件”。这种理解在写业务时没有大问题但当你手写实现时会发现它完全是另一套模型维度类组件函数组件实例化方式new ComponentType(props)直接调用ComponentType(props)状态存储位置实例对象this.statefiber 节点上的 hooks 链表生命周期来源框架按阶段回调实例方法通过 useEffect 等副作用机制模拟重新渲染触发调用实例的render()方法重新执行整个组件函数是否依赖this是否函数组件不是“没有生命周期”而是把生命周期这件事从组件实例上抽走了交给了 fiber 节点和 hooks 系统。这个转移才是它真正改变的东西。1.2 两者在渲染流程上的真正差异类组件的渲染流程在极简实现里大概是这样的根据当前 fiber 上的组件类型创建实例。调用实例的render()方法得到虚拟 DOM。把虚拟 DOM 作为这个 fiber 的 children继续走 reconcile。函数组件就不一样了它没有实例需要创建直接调用函数本身即可if (typeof fiber.type function) { const children fiber.type(fiber.props); }看起来更简单但它丢失了一个很重要的东西调用完之后函数就退场了局部变量全部销毁。下一次更新时如果 React 只是再从零调用一次函数所有状态都会丢。这就是函数组件最反直觉的地方它看起来很轻但为了让状态在多次函数调用之间保留框架必须在外层做大量额外工作。我们平时写函数组件觉得顺畅恰恰说明 React 在背后把“状态归属”处理得很好。所以我把这一篇的主判断放在这里实现 function component 的核心难点不是让它渲染出结果而是让一个没有实例的函数能在多次调用之间复用状态。2. 从零实现 function component 的最小闭环2.1 在 render 阶段识别 function 类型绝大多数极简 React 实现都会用同样的虚拟 DOM 结构function createElement(type, props, ...children) { return { type, props: { ...props, children } }; }当type是一个普通字符串时它对应原生 DOM 标签当type是一个函数时它就是一个函数组件。在performUnitOfWork里第一步就是判断当前 fiber 的typefunction performUnitOfWork(fiber) { if (typeof fiber.type function) { // 函数组件或类组件 } else { // 原生元素 } }这时候先别急着展开函数组件我们要小心处理这个判断。因为类组件也是函数形态的constructor如果只靠typeof fiber.type function判断一定会把类组件也当成普通函数调用。实际实现里通常靠fiber.type.prototype.isReactComponent来区分类组件但既然是手写我们完全可以用一个内部标记。核心思路是if (fiber.type.prototype fiber.type.prototype.isReactComponent) { // 类组件 const instance new fiber.type(fiber.props); const children instance.render(); } else { // 函数组件 const children fiber.type(fiber.props); }这是教学代码不是源码但它已经能说明问题函数组件和类组件在渲染阶段的差异本质上就是“要不要 new 一个实例”。2.2 调用函数组件后还要继续调和 children很多第一次写的人会卡在这里函数组件调用完拿到返回的虚拟 DOM 之后下一步该怎么处理最直接的想法是把返回的虚拟 DOM 直接当成当前 fiber 的 children继续走原有的 reconcileChildren 逻辑。这个方向是对的但执行时要避免一个误区不要因为函数组件返回了一个 div就创建一个新的 div fiber然后把原来的函数组件 fiber 替换掉。正确做法是保持当前 fiber 不动让函数组件返回的虚拟 DOM 进入当前 fiber 的 children 调和流程if (typeof fiber.type function) { const vdomChildren fiber.type(fiber.props); reconcileChildren(fiber, vdomChildren); }这样函数组件在 fiber 树中仍然占据一个节点。它虽然没有真实 DOM但它的子节点下面有真实 DOM。这个层级关系对后续更新和复用至关重要。如果不保留函数组件 fiber 这一层更新时 React 就不知道这个函数组件上一次的 state 存在哪里也无法在 props 变化时精准触发组件重新执行。2.3 commit 阶段怎么处理真实 DOM函数组件本身不产生真实 DOM。它返回的 children 里可能是一层又一层函数组件一直到某个原生节点才会创建真实 DOM。所以在 commit 阶段我们不能简单写parent.stateNode.appendChild(fiber.stateNode);因为函数组件的fiber.stateNode通常是null。正确做法是向上找到最近一个有真实 DOM 节点的祖先 fiber把新的 DOM 节点挂到它下面function getParentDom(fiber) { let parent fiber.parent; while (parent !parent.stateNode) { parent parent.parent; } return parent ? parent.stateNode : null; }然后在 commit 时const parentDom getParentDom(fiber); if (parentDom fiber.stateNode) { parentDom.appendChild(fiber.stateNode); }这看起来是一个很小的细节但如果你只测单个函数组件很容易忽略。一旦嵌套多层函数组件比如function App() { return createElement(Container, null, createElement(p, null, text)); }如果获取父 DOM 的逻辑不对最终结果就是内容渲染出来了但 DOM 挂到错误的位置甚至整个树都串掉。2.4 最小验证样例把上面的逻辑拼起来后可以写一个最小用例function Welcome({ message }) { return createElement(div, null, message); } const root createRoot(document.getElementById(root)); root.render(createElement(Welcome, { message: hello }));预期页面显示hello。验证时可以按三步检查控制台没有报错。页面出现hello文本。把message改成world后页面内容能更新为world。如果前两步没问题但第三步卡住说明你已经进入了函数组件更新复用的深层问题。注意先别急着把函数组件和类组件都塞进同一个实现里。更稳妥的方式是先用一个最小分支支持函数组件跑通单次渲染和简单更新再回头统一类组件的流程。3. 为什么函数组件的状态管理会牵出整套 hooks 机制3.1 函数组件没有 this状态存在哪里我之前一直用类组件的思路理解状态状态放在实例上this.state一取就有。函数组件没有实例每次函数执行结束局部变量全部销毁。如果想要跨渲染保留状态唯一的办法就是把状态放到函数外部。这个“外部”在 React 里就是 fiber 节点。所以在极简实现里我直接在 fiber 对象上挂了一个hooks数组const fiber { type: Greeting, props: { name: zhang }, stateNode: null, parent: null, child: null, sibling: null, hooks: [], // 函数组件专属 alternate: null };hooks数组用来保存函数组件里每一次useState调用的状态。第一次渲染时这个数组是空的每次执行到useState就往数组里 push 一个 hook 对象。更新时新的函数组件执行前需要从旧 fiber 的hooks数组里把上一次的状态读出来作为新的一次渲染的初始值。这套机制就是 hooks 的雏形。3.2 一个朴素的 useState 设计按顺序存储我先写一个最朴素的useState用来理解它为什么能工作let currentFiber null; let hookIndex 0; function useState(initialValue) { const oldHook currentFiber.alternate ? currentFiber.alternate.hooks[hookIndex] : null; const hook { state: oldHook ? oldHook.state : initialValue }; currentFiber.hooks.push(hook); hookIndex; const setState (action) { const nextState typeof action function ? action(hook.state) : action; hook.state nextState; scheduleWork(currentFiber); }; return [hook.state, setState]; }这里的currentFiber是当前正在渲染的函数组件对应的 fiber。每次渲染一个函数组件前需要把currentFiber指向它并把hookIndex重置为 0function renderFunctionComponent(fiber) { currentFiber fiber; hookIndex 0; currentFiber.hooks []; const children fiber.type(fiber.props); reconcileChildren(fiber, children); }更新时currentFiber.alternate指向旧 fiber通过alternate.hooks[hookIndex]拿到上一次的 hook 状态。这正是 hooks 规则“必须在组件顶层调用”的底层原因框架并没有给每个 hook 起名字它只认调用顺序。第一次渲染时useState的调用顺序就是未来每次渲染都必须保持的顺序。3.3 为什么不能在条件语句里调用 hooks如果我在函数组件里写function BadComponent({ show }) { if (show) { const [a] useState(0); } const [b] useState(1); }第一次渲染时show为 truehooks 数组里保存了[a, b]两个状态。第二次渲染时show变成 falseuseState(0)没执行useState(1)会去读alternate.hooks[0]拿到的却是上一次a的状态。于是b的状态错乱了。这个例子在 React 文档里被反复强调但如果你不亲手实现一次很难真正理解“按顺序存储”到底意味着什么。手写一遍之后你会非常自然地接受这条规则因为你会看到状态数组在错位时有多脆弱。不要为了减少 hook 调用次数而用条件包裹 hook。状态复用靠的是稳定顺序不是靠名字查找。4. 从单次渲染到更新复用function component 真正的坑4.1 更新时不能重复调用组件函数就完事初次渲染时调用函数组件拿到 children逻辑很简单。但更新时如果只是再调用一次fiber.type(fiber.props)会遇到两个问题第一个问题是props 可能没变。React 的更新不一定都来自 props很多是组件内部 setState 触发的。此时组件函数还是要重跑一遍但 props 本身可能还是旧值不能把“父组件重新渲染”和“子组件 props 变化”混为一谈。第二个问题是函数组件内部 useState 需要借助旧 fiber 上的 hooks 数组来初始化。如果更新时创建了一个全新 fiber替代了旧的函数组件 fiber那么旧状态就丢了。所以在极简实现里更新阶段需要判断当前 fiber 能否复用。通常的做法是function reconcileChildren(workInProgress, children) { // 如果 workInProgress.alternate 存在且 type 相同则复用 alternate if (workInProgress.alternate workInProgress.alternate.type workInProgress.type) { // 复用 fiber 节点 } else { // 创建新 fiber } }当新旧 fiber 的type相同说明它们对应同一个组件状态可以继续沿用。如果type不同就只能重建。这个逻辑放到函数组件里就是每一次渲染React 都要靠同一根 fiber 上的alternate找到上一次的 hooks 状态。4.2 fiber 树复用与 hooks 状态迁移在极简 React 里我维护两棵树一棵 current tree表示上一次已经渲染到页面上的树一棵 workInProgress tree表示这次更新正在构建的新树。函数组件的 fiber 节点更新流程大致是从 current 树上找到对应的旧 fiber。判断type是否相同。如果相同新 fiber 的alternate指向旧 fiber。执行函数组件时通过alternate.hooks读取旧状态。新状态写入新 fiber 自己的hooks数组。commit 阶段把新 fiber 树替换成 current tree。这个过程最典型的工程坑是“在渲染期间定义新组件”function App() { function Child() {} return createElement(Child); }每次 App 函数执行时都会生成一个全新的Child函数。虽然它们长得一模一样但Child ! Child所以 fiber 的type不相等状态无法复用。这种写法在实际 React 里也会导致组件卸载重挂但如果在小实现里跑你还会看到状态被清空、input 失焦等一系列奇怪问题。4.3 更新异常的排查链路如果你的手写实现遇到函数组件更新问题我建议按这个顺序排查先看setState是否触发了重新 render。如果没有首要问题在调度入口。再看更新时新 fiber 的alternate是否指向旧 fiber。如果为 null说明状态无从继承。再看函数组件执行前hookIndex是否重置为 0。如果没重置后一个 hook 会拿前一个 hook 的状态。再看hooks数组是写在 current fiber 上还是 workInProgress fiber 上。如果混用了更新后状态会被旧值覆盖。最后看 commit 阶段如果 DOM 内容更新了但挂载父级错误就回到getParentDom的逻辑。这几个点按顺序检查完基本能覆盖手写 React 函数组件更新时 90% 的问题。5. 手写实现和真实 React 对照边界在哪里5.1 真实 React 的 function component 远不止“多一层判断”当我把最小实现跑通后第一反应是“原来函数组件也不难”。但把真实 React 打开看会发现它在这个模型上叠加了太多东西。真实 React 里函数组件在 beginWork 阶段会被识别为FunctionComponent随后走到updateFunctionComponent。它不仅要执行组件函数还要处理优先级、批量更新、context、ref、memo、abort 等情况。hooks 也不是简单数组而是一个单链表结构节点之间相互关联并且支持更新阶段跳过某些 effect。我们手写的最小实现本质上是把这些机制全部压缩到“数组 alternate”这两个概念里。它能讲清楚原理但不能覆盖生产环境里的复杂行为。5.2 小型实现能做什么不能做什么能力手写最小实现生产 React函数组件渲染直接调用组件函数beginWork 流程中统一处理状态保存用 hooks 数组用单链表 并发机制更新复用type 相同则复用依赖 fiber 树、优先级和 bailout并发渲染无有 lanes、可中断渲染副作用未实现useEffect/useLayoutEffect 完整机制批量更新一般不做自动批处理 手动批量处理手写实现最大的价值不是“再造一个 React”而是把虚拟 DOM、fiber 树、函数组件、hooks 这些抽象概念变成你可以直接调试的代码。5.3 继续深入的建议如果你已经完成了 function component 的最小闭环下一步不建议急着看更多源码而是先把自己的实现补齐让useState支持函数式更新比如setCount(c c 1)。实现useReducer你会发现它只是useState的通用版本。再实现useEffect你会接触到 commit 阶段如何派发副作用。然后把类组件和函数组件统一到同一个调度流程里这时你对 React 整体工作方式的理解会更完整。等到手写实现已经能跑常见的计数器、todo、表单再回头去看真实源码很多曾经看不进去的细节会变得顺理成章。从零手搓 React 到第 8 篇函数组件这一关过去后最难的已经不在渲染而在“状态如何留在不同层级”。当你真正在代码里把alternate、hooks、fiber这三个概念串在一起时你会发现自己对 React 的理解已经变了你不再是背 hooks 规则而是看到规则背后的机制。下一次我会继续让useEffect走进这个手写世界。到那时这个极简 React 才真正开始像我们在业务里用的那个老朋友。
返回列表