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

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

本文将帮助您构建一个大约 400 行代码的 React 版本,该版本支持异步更新并且可以中断——这是许多高级 API 所依赖的 React 核心功能。 最终效果Gif如下:

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

在深入探讨原理之前 迷你反应.ts,了解 JSX 代表什么很重要。 我们可以使用 JSX 来描述 DOM,并轻松应用 JavaScript 逻辑。 然而,浏览器本身并不理解JSX,所以我们编写的JSX被编译成浏览器可以理解的JavaScript。

我在这里使用了 babel,但是当然你可以使用其他构建工具,它们生成的内容将是类似的。

所以你可以看到它调用了 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 实现,我们需要配置 迅速地react({ 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,
    },
  };
};

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

// 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);
};

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

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

在真正的 React 中,我们可以使用它提供的 API,例如 useTransition 或者 useDeferredValue 显式降低更新的优先级。

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

请求空闲回调

requestIdleCallback 是一个实验性 API,在浏览器空闲时执行回调。 尚不支持 所有浏览器。 在 React 中,它被用在 调度程序包,它的调度逻辑比 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);

以下是一些关键点的简要说明:

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

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

主线程一次只能运行一项任务——要么执行 JavaScript,要么处理 DOM 计算、样式计算、输入事件等。 Promise.then),但是,不要放弃对主线程的控制。

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

算法

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

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

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

  1. 孩子:从父节点到第一个子元素的指针。

  2. 返回/父级:所有子元素都有一个返回父元素的指针。

  3. 兄弟:从第一个子元素指向下一个同级元素。

我们只是简单地扩展 使成为 逻辑,将调用序列重构为 workLoop -> performUnitOfWork -> reconcileChildren -> commitRoot

  1. 工作循环 :通过调用获取空闲时间 requestIdleCallback 不断地。 如果当前空闲并且有单元任务需要执行,则执行每个单元任务。

  2. 执行工作单元:执行的具体单元任务。 这就是链表思想的体现。 具体来说,一次只处理一个Fiber节点,并返回下一个要处理的节点。

  3. 和解孩子:协调当前的 Fiber 节点,其实就是虚拟 DOM 的对比,记录下要做的改变。 可以看到我们直接在每个 Fiber 节点上修改并保存,因为现在只是对 JavaScript 对象的修改,并没有触及真正的 DOM。

  4. 提交根:如果当前需要更新(根据 wipRoot)并且没有下一个单元任务要处理(根据 !nextUnitOfWork),这意味着虚拟的变化需要映射到真实的DOM。 这 commitRoot 就是根据 Fiber 节点的变化来修改真实的 DOM。

有了这些,我们就可以真正使用 Fiber 架构来进行可中断的 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];
}

它巧妙地将钩子的状态保留在 Fiber 节点上,并通过队列修改状态。 从这里,你也可以明白为什么React hook调用的顺序一定不能改变。

我们已经实现了React的最小模型,它支持异步和可中断更新,没有依赖关系,并且排除注释和类型,它可能少于400行代码。 我希望它对你有帮助。

如果您觉得我的内容有帮助,请点个赞 考虑订阅。 我发送一个 每周日通讯 具有最新的网络开发更新。 感谢您的支持!

Leave a Reply

Your email address will not be published. Required fields are marked *

近期新闻​

编辑精选​