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

手写React

在前端开发中,为了真正理解 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 的能力,更好的应对面试中遇到的React源码问题。

相关推荐
arvin_xiaoting1 小时前
OpenClaw学习总结_I_核心架构_9:Multi-Agent详解
网络·学习·架构·系统架构·ai agent·multi-agent·openclaw
Nice__J2 小时前
Mcu架构以及原理——3.存储器架构
单片机·嵌入式硬件·架构
殷紫川2 小时前
吃透 Spring Boot 3 + Spring Cloud 云原生新特性
spring boot·spring cloud·架构
heimeiyingwang2 小时前
【架构实战】Spring Cloud微服务实战入门
spring cloud·微服务·架构
不甜情歌2 小时前
JS 异步:Event-Loop+async/await
前端
程序员库里2 小时前
AI协同写作应用-TipTap基础功能
前端·javascript·面试
程序员阿峰2 小时前
【JavaScript面试题-算法与数据结构】手写一个 LRU(最近最少使用)缓存类,支持 `get` 和 `put` 操作,要求时间复杂度 O(1)
前端·javascript·面试
im_AMBER2 小时前
AJAX vs Fetch API:Promise 与异步 JavaScript 怎么用?
前端·javascript·面试
用户9751470751362 小时前
关于通过react使用hooks进行数据状态处理
前端