从零手写 React:深度解析 Fiber 架构与 Hooks 实现

从零手写 React:深度解析 Fiber 架构与 Hooks 实现

在前端开发中,React 无疑是最受欢迎的框架之一。然而,仅仅停留在使用 API 的层面是不够的。为了真正理解 React 的底层运行机制,最好的方式就是抛开现有的框架,从零开始(Vanilla JS)手写一个简易版的 React。

本文将带领大家一步步构建一个包含 JSX 编译、Fiber 架构、并发渲染(Concurrent Mode)、函数组件以及 useStateuseEffect 等核心 Hooks 的迷你 React。


一、 从原生 DOM 操作到数据结构抽象

在纯原生(Vanilla)开发中,我们通常这样创建和挂载 DOM:

javascript 复制代码
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)

javascript 复制代码
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 语法:

jsx 复制代码
const App = <div id="app">Hello</div>

本质上,打包工具(如 Vite/Babel)会将这段 JSX 编译成普通的 JS 函数调用(经典模式):

javascript 复制代码
const App = React.createElement("div", { id: "app" }, "Hello");

因此,我们需要在项目(如 vite.config.js)中配置 JSX 工厂函数指向我们手写的 React.createElement

javascript 复制代码
// vite.config.js
import { defineConfig } from 'vite'

export default defineConfig({
  esbuild: {
    jsx: 'transform',
    jsxFactory: 'React.createElement',
    jsxFragment: 'React.Fragment',
  }
})

2. 封装 createElement

接下来,我们实现 createElement,它负责将 JSX 编译后的参数转化为我们上面定义的虚拟 DOM 结构:

javascript 复制代码
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 建立一个工作循环:

javascript 复制代码
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 返回值。

javascript 复制代码
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 数组。
javascript 复制代码
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 的祖先节点。

javascript 复制代码
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 核心原理

  1. 通过 wipFiber.alternate 获取上一次渲染时的旧 Hook。
  2. 继承旧状态,并执行所有在队列中的更新操作(setState 传入的 action)。
  3. 声明一个 setState 闭包函数,它负责将新的 action 存入队列,并调用 update 触发整棵树的重新渲染。
javascript 复制代码
// 在处理函数组件时初始化 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 数组,将副作用回调和依赖项存入:

javascript 复制代码
function useEffect(callback, depends) {
    const effect = {
        callback,
        depends,
        clear: null // 用于保存清理函数
    };
    wipFiber.effects.push(effect);
}

2. 执行与清理 Effect

在所有的 DOM 操作(commitWork)结束后,我们遍历 Fiber 树,对比依赖项。如果依赖发生变化,则先执行上一次的清理函数(clear),再执行新的副作用回调

javascript 复制代码
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)。在这个过程中,我们深刻理解了:

  1. JSX 本质上是 React.createElement 的语法糖,它返回的是描述界面的虚拟 DOM
  2. Fiber 架构 是一套基于链表结构和 requestIdleCallback 的时间切片机制,解决了大型组件树同步渲染卡顿的问题。
  3. Diff 算法 使得我们可以对比新旧 Fiber 树,高效复用真实 DOM。
  4. Hooks 依托于 Fiber 节点上挂载的数组与索引运行,因此必须保证其调用顺序的绝对稳定。

掌握这些底层机制,不仅能帮助我们在日常开发中写出性能更优的 React 代码,更能提升我们在复杂业务场景下排查与解决 Bug 的能力!

相关推荐
敲代码的约德尔人2 小时前
React Hooks 最佳实践指南
react.js
敲代码的约德尔人2 小时前
React useEffect 完全指南:我在 3 个项目中踩坑后总结的血泪经验
前端·react.js
小凡同志2 小时前
React 组件设计模式:从 HOC 到 Render Props 再到 Hooks
前端·react.js
栀秋6662 小时前
深入浅出:手写一个迷你版 Zustand
前端·react.js·前端框架
SuperEugene2 小时前
Vue3 + Vue Router + Pinia 路由守卫规范:beforeEach 应做 / 不应做,避死循环、防重复请求|状态管理与路由规范篇
开发语言·前端·javascript·vue.js·前端框架
GISer_Jing2 小时前
React核心语法:组件化与声明式编程
前端·react.js·前端框架
Alan Lu Pop2 小时前
React 表单提交关键词意外触发刷新
前端·javascript·react.js
我命由我123453 小时前
React - 创建 React 项目、React 项目结构、React 简单案例、TodoList 案例
前端·javascript·react.js·前端框架·ecmascript·html5·js
SuperEugene3 小时前
Vue3 Pinia 状态管理规范:何时用 Pinia 何时用本地状态|状态管理与路由规范篇
开发语言·前端·javascript·vue.js·前端框架