用 400 行代码构建自己的 React.js Mini-React 演示介绍许可证 JSX 和 createElement 渲染光纤架构和并发模式触发更新结论

2025-06-10

用 400 行代码构建你自己的 React.js

迷你反应

演示

介绍

执照

JSX 和 createElement

使成为

Fiber 架构与并发模式

触发更新

结论

最初发表在 我的时事通讯中。

React v19 测试版现已发布。与 React 18 相比,它提供了许多用户友好的 API,但其核心原则基本保持不变。你可能已经使用 React 一段时间了,但你知道它的内部工作原理吗?

本文将帮助你用大约 400 行代码构建一个支持异步更新和可中断的 React 版本——这是 React 的核心特性,许多高级 API 都依赖于它。最终效果图如下:

图片

我使用了React 官方网站提供的井字游戏教程示例,可以看到它运行良好。

它目前托管在我的GitHub上,您也可以访问在线版本亲自尝试一下。

GitHub 徽标 ZacharyL2 / mini-react

用 400 行代码实现 Mini-React,一个具有异步可中断更新的最小模型。

推介会

迷你反应

用 400 行代码实现 Mini-React,一个具有异步可中断更新的最小模型。

演示

在线演示

简单的运行截图:

演示

介绍

我使用了React 网站上提供的井字游戏教程示例,效果很好。

此外,它同时支持函数式和类式组件。其整体逻辑和函数命名与 React 的基本原理基本一致。如果您对 React 的内部工作原理感兴趣,那么本教程非常适合您!

看看如何构建它。

执照

麻省理工学院




JSX 和 createElement

在深入研究mini-react.ts的原理之前,理解 JSX 的含义非常重要。我们可以使用 JSX 来描述 DOM 并轻松应用 JavaScript 逻辑。然而,浏览器本身并不理解 JSX,因此我们编写的 JSX 会被编译成浏览器可以理解的 JavaScript。

图片

您可以看到它调用了React.createElement,它提供了以下选项:

  1. type:表示当前节点的类型,例如div
  2. config:表示当前元素节点的属性,例如{id: "test"}
  3. children:子元素,可以是多个元素、简单文本,也可以是通过 React.createElement 创建的多个节点。

如果你是一位经验丰富的 React 用户,你可能还记得在 React 18 之前,你需要import React from 'react';正确编写 JSX。从 React 18 开始,这不再是必需的,这增强了开发者的体验,但React.createElement底层仍然调用它。

图片

对于我们简化的 React 实现,我们需要配置Vitereact({ jsxRuntime: 'classic' })将 JSX 直接编译到React.createElement实现中。

然后我们可以实现我们自己的

// Text elements require special handling.
const createTextElement = (text: string): VirtualElement => ({
  type: 'TEXT',
  props: {
    nodeValue: text,
  },
});

// Create custom JavaScript data structures.
const createElement = (
  type: VirtualElementType,
  props: Record<string, unknown> = {},
  ...child: (unknown | VirtualElement)[]
): VirtualElement => {
  const children = child.map((c) =>
    isVirtualElement(c) ? c : createTextElement(String(c)),
  );

  return {
    type,
    props: {
      ...props,
      children,
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

使成为

接下来我们根据前面创建的数据结构实现一个简化版本的render函数,将JSX渲染到真实的DOM上。

// Text elements require special handling.
const createTextElement = (text) => ({
  type: 'TEXT',
  props: {
    nodeValue: text,
  },
});

// Create custom JavaScript data structures.
const createElement = (type, props = {}, ...child) => {
  const isVirtualElement = (e) => typeof e === 'object';

  const children = child.map((c) =>
    isVirtualElement(c) ? c : createTextElement(String(c))
  );

  return {
    type,
    props: { ...props, children },
  };
};

// Update DOM properties.
// For simplicity, we remove all the previous properties and add next properties.
const updateDOM = (DOM, prevProps, nextProps) => {
  const defaultPropKeys = 'children';

  for (const [removePropKey, removePropValue] of Object.entries(prevProps)) {
    if (removePropKey.startsWith('on')) {
      DOM.removeEventListener(
        removePropKey.substr(2).toLowerCase(),
        removePropValue
      );
    } else if (removePropKey !== defaultPropKeys) {
      DOM[removePropKey] = '';
    }
  }

  for (const [addPropKey, addPropValue] of Object.entries(nextProps)) {
    if (addPropKey.startsWith('on')) {
      DOM.addEventListener(addPropKey.substr(2).toLowerCase(), addPropValue);
    } else if (addPropKey !== defaultPropKeys) {
      DOM[addPropKey] = addPropValue;
    }
  }
};

// Create DOM based on node type.
const createDOM = (fiberNode) => {
  const { type, props } = fiberNode;
  let DOM = null;

  if (type === 'TEXT') {
    DOM = document.createTextNode('');
  } else if (typeof type === 'string') {
    DOM = document.createElement(type);
  }

  // Update properties based on props after creation.
  if (DOM !== null) {
    updateDOM(DOM, {}, props);
  }

  return DOM;
};

const render = (element, container) => {
  const DOM = createDOM(element);
  if (Array.isArray(element.props.children)) {
    for (const child of element.props.children) {
      render(child, DOM);
    }
  }

  container.appendChild(DOM);
};
Enter fullscreen mode Exit fullscreen mode

这是在线实现链接。它目前只渲染一次 JSX,因此不处理状态更新。

Fiber 架构与并发模式

Fiber 架构和并发模式主要是为了解决元素树一旦递归完成就无法中断,从而可能长时间阻塞主线程的问题。高优先级的任务(例如用户输入或动画)可能无法及时处理。

图片

在其源代码中,工作被分解成多个小单元。每当浏览器空闲时,它就会处理这些小的工作单元,从而放弃主线程的控制权,以便浏览器能够及时响应高优先级的任务。一旦作业的所有小单元都完成,结果就会映射到真实的 DOM 中。

React Conf 2024

而在实际的 React 中,我们可以使用其提供的 API 比如useTransitionuseDeferredValue来明确降低更新的优先级。

所以,总而言之,这里的两个关键点是如何放弃主线程以及如何将工作分解为可管理的单元。

requestIdleCallbackAPI

requestIdleCallback是一个实验性的 API,会在浏览器空闲时执行回调。目前并非所有浏览器都支持它。在 React 中,它被用在Scheduler 包中,该包的调度逻辑比 requestIdleCallback 更复杂,包括更新任务优先级。

但这里我们只考虑异步可中断性,因此这是模仿 React 的基本实现:

// Enhanced requestIdleCallback.
((global: Window) => {
  const id = 1;
  const fps = 1e3 / 60;
  let frameDeadline: number;
  let pendingCallback: IdleRequestCallback;
  const channel = new MessageChannel();
  const timeRemaining = () => frameDeadline - window.performance.now();

  const deadline = {
    didTimeout: false,
    timeRemaining,
  };

  channel.port2.onmessage = () => {
    if (typeof pendingCallback === 'function') {
      pendingCallback(deadline);
    }
  };

  global.requestIdleCallback = (callback: IdleRequestCallback) => {
    global.requestAnimationFrame((frameTime) => {
      frameDeadline = frameTime + fps;
      pendingCallback = callback;
      channel.port1.postMessage(null);
    });
    return id;
  };
})(window);
Enter fullscreen mode Exit fullscreen mode

下面对一些关键点进行简单解释:

为什么要使用MessageChannel?

它主要使用宏任务来处理每一轮单元任务。但为什么要使用宏任务呢?

这是因为我们需要使用宏任务来放弃对主线程的控制,让浏览器在空闲期间更新 DOM 或接收事件。由于浏览器将更新 DOM 作为单独的任务,因此此时不会执行 JavaScript。

主线程一次只能运行一个任务 - 执行 JavaScript 或处理 DOM 计算、样式计算、输入事件等。Promise.then然而,微任务(例如)不会放弃对主线程的控制。

为什么不使用 setTimeout?

这是因为现代浏览器认为嵌套 setTimeout 调用超过五次会阻塞,并将其最小延迟设置为 4ms,因此不够精确。

算法

请注意,React 不断发展,我描述的算法可能不是最新的,但足以理解其基本原理。

这是 React 包如此庞大的一个主要原因

下图显示了工作单元之间的连接:

图片

在 React 中,每个工作单元被称为一个 Fiber 节点。它们通过类似链表的结构链接在一起:

  1. child:从父节点指向第一个子元素的指针。
  2. return/parent:所有子元素都有一个指向父元素的指针。
  3. brothers:从第一个子元素指向下一个兄弟元素。

有了这个数据结构,我们来看看具体的实现

我们只是扩展了渲染逻辑,将调用序列重构为workLoop-> performUnitOfWork-> reconcileChildren-> commitRoot

  1. workLoop:通过连续调用获取空闲时间requestIdleCallback。如果当前空闲,且有单元任务需要执行,则执行每个单元任务。
  2. performUnitOfWork:执行的具体单元任务。这是链表思想的体现。具体来说,每次只处理一个 Fiber 节点,并返回下一个待处理的节点。
  3. reconcileChildren:协调当前 Fiber 节点,这实际上是对虚拟 DOM 的比较,并记录要进行的修改。可以看到,我们直接在每个 Fiber 节点上进行了修改并保存,因为现在只是对 JavaScript 对象的修改,并没有触及真实的 DOM。
  4. commitRoot:如果当前需要更新(根据wipRoot),并且没有下一个单元任务需要处理(根据!nextUnitOfWork),则意味着需要将虚拟更改映射到真实 DOM。commitRoot是根据纤程节点的变化来修改真实 DOM。

有了这些,我们就可以真正使用光纤架构进行可中断的 DOM 更新,但我们仍然缺少一个触发器。

触发更新

在 React 中,最常见的触发器是useState,也就是最基本的更新机制。让我们来实现它来触发我们的 Fiber 引擎。

下面是具体的实现,简化成一个函数:

// Associate the hook with the fiber node.
function useState<S>(initState: S): [S, (value: S) => void] {
  const fiberNode: FiberNode<S> = wipFiber;
  const hook: {
    state: S;
    queue: S[];
  } = fiberNode?.alternate?.hooks
    ? fiberNode.alternate.hooks[hookIndex]
    : {
        state: initState,
        queue: [],
      };

  while (hook.queue.length) {
    let newState = hook.queue.shift();
    if (isPlainObject(hook.state) && isPlainObject(newState)) {
      newState = { ...hook.state, ...newState };
    }
    if (isDef(newState)) {
      hook.state = newState;
    }
  }

  if (typeof fiberNode.hooks === 'undefined') {
    fiberNode.hooks = [];
  }

  fiberNode.hooks.push(hook);
  hookIndex += 1;

  const setState = (value: S) => {
    hook.queue.push(value);
    if (currentRoot) {
      wipRoot = {
        type: currentRoot.type,
        dom: currentRoot.dom,
        props: currentRoot.props,
        alternate: currentRoot,
      };
      nextUnitOfWork = wipRoot;
      deletions = [];
      currentRoot = null;
    }
  };

  return [hook.state, setState];
}
Enter fullscreen mode Exit fullscreen mode

它巧妙地将钩子的状态保存在 Fiber 节点上,并通过队列修改状态。从这里,你也能理解为什么 React 钩子的调用顺序不能改变。

结论

我们实现了一个 React 的极简模型,它支持异步和可中断更新,没有任何依赖,并且不包括注释和类型,代码量可能不到 400 行。希望它能帮到你。

如果您觉得这篇文章对您有帮助,请考虑订阅我的新闻通讯,以获取更多关于 Web 开发的见解。感谢您的阅读!

鏂囩珷鏉ユ簮锛�https://dev.to/zacharylee/build-your-own-reactjs-in-400-lines-of-code-3l1e
PREV
面向完全初学者的 CSS 网格课程
NEXT
Git Rebase 简介:教程