All articles

Micro React Demo

Dec 28, 2022

本文内容是阅读 Build Your Own React 时的一个记录(大部分是原文内容的直译,少部分自己的想法),体验建立一个自己的 micro-react,通过 The createElement Function、The render Function、Concurrent Mode、Fibers、Render and Commit Phases、Reconciliation、Function Components、Hooks 这八个步骤实现 react 的一些核心内容。

实验地址:

The createElement Function

现在,将从 createElement 开始,react element 是一个带有 typeprops 的对象。createElement 函数需要创建出一个这样的对象。

const createElement = (
  type,
  props,
  ...children
) => {
  return {
    type,
    props: {
      ...props,
      // child 有可能是 string 或者 number
      children: children.map((child) =>
        typeof child === "object"
          ? child
          : createTextElement(child)
      ),
    },
  };
};

children 数组中可能也有 stringnumber 这样的基础值,因此需要对不是对象的值创建出一种特殊的类型 TEXT_ELEMENT

const createTextElement = (text) => {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  };
};

React 对于一个基本值的子元素,不会创建空数组也不会包一层 TEXT_ELEMENT,但是为了简化代码,这里的实现和 React 有差异,毕竟 micro-react 只需要实现核心的功能。

对于 createElement 的参数,需要通过 BabelJSX 转换成 JS,会将下面的代码生成 react element

const element = React.createElement(
  "div",
  { style: "background: salmon" },
  React.createElement("h1", null, "HelloWorld"),
  React.createElement(
    "h2",
    { style: "text-align:right" },
    "from tauysi"
  )
);

The render Function

当创建出 react element 之后,就需要将 element 渲染出来,因此需要用到 ReactDOM.render 函数,在这里只需要关注如何在 DOM 上添加东西,之后再考虑更新和删除的问题。

const render = (element, container) => {
  const dom = document.createElement(
    element.type
  );
  container.appendChild(dom);
};

根据 elementtype 的属性创建出 DOM 节点,再将新节点添加到 container 中。除此之外,还需要对每一个子节点进行递归做相同处理,最后把 react element 的属性值赋值给 DOM 节点。

const render = (element, container) => {
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type);

  Object.keys(element.props)
    .filter((key) => key !== "children")
    .forEach((name) => {
      dom[name] = element.props[name];
    });

  element.props.children.forEach((child) =>
    render(child, dom)
  );

  container.appendChild(dom);
};

这样既可渲染出来元素。

Concurrent Mode

通过上文,可以发现目前递归调用 render 渲染 element 树一旦开始,就无法暂停。当这棵树很大时,会对主线程进行阻塞,因此意味着浏览器的一些高优先级任务会一直等待渲染,如:用户输入。

因此需要将整个任务分成一些小块,每当我们完成其中一块之后就把主线程控制权交给浏览器,让浏览器看看是否有更高优先级的任务需要完成。

这里使用 requestIdleCallback 作为一个循环。可以把 requestIdleCallback 类比成 setTimeout,只不过这次是浏览器来决定什么时候运行回调函数,而不是 setTimeout 里通过我们指定的一个时间。浏览器会在主线程有空闲的时候运行回调函数。

// 下一个需要执行的单元
let nextUnitOfWork = null;

const workLoop = (deadline) => {
  // shouldYield 表示线程繁忙,应该中断渲染
  let shouldYield = false;
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    );
    // timeRemaining 返回当前空闲期的估计剩余毫秒数 检测线程是否繁忙
    shouldYield = deadline.timeRemaining() < 1;
  }
  // 重复请求
  requestIdleCallback(workLoop);
};

// 空闲时执行渲染
requestIdleCallback(workLoop);

/**
 * currentUnitOfWork 当前执行的任务单元
 * 还需要返回下一个执行的任务单元
 */
const performUnitOfWork = (
  currentUnitOfWork
) => {};

Fibers

React Fiber 简介 —— React 背后的算法

对于 Fiber,算是第一次接触,它是把所有任务单元组织在一起的数据结构。每一个 element 都是一个 fiber,每一个 fiber 都是一个任务单元。在 render 中创建了一个根 fiber,并且将其设置为 nextUnitOfWork 作为第一个任务单元,剩下的任务会通过 performUnitOfWork 返回,每个 fiber 节点完成了下述三件事情:

  1. element 添加到 DOM 上。
  2. 为该 fiber 节点的子节点新建 fiber
  3. 选出下一个任务单元。

fiber指向它第一个子节点、下一个兄弟节点和父节点,为了更加方便的找到下一个任务单元。处理完当前的 fiber 节点之后,会处理它的 child,如果没有 child,就处理它的 sibling;若既没有 child 也没有 sibling,它的 uncle 将作为下一个任务单元。类似于 DFS 进行处理 fibers

render 函数中,将 nextUnitOfWork 设置为 fiber 的根节点。此时的 children 就是我们通过 render 传进来的 element

const render = (element, container) => {
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],
    },
  };
};

做好这些内容之后,等待浏览器空闲时,workLoop 会开始遍历这棵树。遍历时通过 performUnitOfWork 去获取下一个工作单元。在 performUnitOfWork 中,需要做三步内容:

  1. 添加 dom 节点。
  2. 为当前 fiberchildren 创建出新的 fiber
  3. 返回下一个需要执行的 fiber
const elements = currentUnitOfWork.props.children;
let prevSibling = null;
elements.forEach((element, index) => {
  const nextFiber = {
    type: element.type,
    props: element.props,
    parent: currentUnitOfWork,
    dom: null,
  };
  if (index === 0) {
    currentUnitOfWork.child = nextFiber;
  } else {
    prevSibling.sibling = nextFiber;
  }
  prevSibling = nextFiber;
});

这里的 element 是通过 React.createElement 创建出来的,dom 是对应最终的 dom 节点,fiber 是从 elementdom 的中间产物,主要方便用于时间切片处理。

Render and Commit Phases

performUnitOfWork 中,一边遍历 element,一边生成新的 DOM 节点并且添加到父节点上,在完成整棵树的渲染之前,浏览器中途可能会中断这个过程,那么用户可能会看到渲染未完成的 ui

因此把修改 DOM 的内容记录在 fiber tree 上,通过追踪这棵树来收集所有 DOM 节点的修改,这棵树叫 wipRoot(work in progress Root)

一旦完成了 wipRoot 这棵树的所有任务(nextUnitOfWorkundefined)就把这棵树的变更提交(commit)到实际的 DOM 上。

// 将 fiber 提交到实际的 dom 上
const commitRoot = () => {
  // add nodes to dom
  commitWork(wipRoot.child);
  wipRoot = null;
};

// 递归添加 dom
const commitWork = (fiber) => {
  if (!fiber) return;
  const domParent = fiber.parent.dom;
  domParent.appendChild(fiber.dom);
  commitWork(fiber.child);
  commitWork(fiber.sibling);
};

Reconciliation

到此为止,已经可以将新的节点添加DOM 上,但是对于更新删除节点暂时还不可以。并且还需要比较 render 中新接收的 element 生成的 fiber 树和上次提交到 DOMfiber 树。

我们的 micro-react 已经可以进行相关事件的绑定。如下图所示。

上次提交到 DOM 节点的 fiber 树进行保存到额外参数上 currentRoot 在每一个 fiber 节点上添加 alertnate 属性用于记录旧 fiber(上次 commit 阶段使用的 fiber)的引用。

注意区分这里定义的 currentRootwipRootwipRoot 是指正在渲染的 fiber 树,currentRoot 是上次渲染的 fiber 树,在 commit 阶段需要对 wipRoot 进行提交,提交后将 wipRoot 保存到 currentRoot 再对 wipRoot 进行清空。

对原来的 performUnitOfWork 中创建新 fiber 节点抽离出来放在新的 reconcileChildren 函数中

const reconcileChildren = (
  wipFiber,
  elements
) => {
  // 如果有alternate,就返回它的child,没有,就返回undefined
  let oldFiber =
    wipFiber.alternate &&
    wipFiber.alternate.child;
  let prevSibling = null;
  let index = 0;
  while (index < elements.length || oldFiber) {
    const element = elements[index];
    let newFiber = null;
    // 如果新旧节点上类型相同可以直接复用旧 DOM 修改上面的属性。
    const sameType =
      oldFiber &&
      element &&
      element.type === oldFiber.type;
    /** 这里需要补充对 sameType 判断来进行 oldFiber 与 element 的比较 */
    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }
    if (index === 0) {
      wipFiber.child = newFiber;
    } else if (element) {
      prevSibling.sibling = newFiber;
    }
    prevSibling = newFiber;
    index++;
  }
};

通过对 sameType 判断可以知道当前的 wipRootreact element 区别,从而判断需要对 dom 节点的操作,并且在新的 fiber 节点上新增 effectTag 属性(用于在 commit 阶段,提交到真实 dom 节点上时的操作)。

如果是 update 可以复用 oldFiber.dom,如果是 addoldFiber 无关,如果是 delete 则不需要去遍历 oldFiber,只需要将需要删除的节点放在一个数组中,在 commit 阶段直接将数组中需要删除的 dom 移除。

const commitWork = (fiber) => {
  if (!fiber) return;
  const domParent = fiber.parent.dom;
  if (
    fiber.effectTag === "PLACEMENT" &&
    fiber.dom != null
  ) {
    domParent.appendChild(fiber.dom);
  } else if (
    fiber.effectTag === "UPDATE" &&
    fiber.dom !== null
  ) {
    updateDOM(
      fiber.dom,
      fiber.alternate.props,
      fiber.props
    );
  } else if (fiber.effectTag === "DELETION") {
    domParent.removeChild(fiber.dom);
  }
  commitWork(fiber.child);
  commitWork(fiber.sibling);
};

对于上面 updateDOM 函数,内部主要是比较新老 fiber 节点的属性。这部分内容没什么需要细说的没有的属性就删除,如果是事件就 removeEventListener

Function Components

对于 micro-react 当然也要支持函数组件,以最简单的 HelloWorld 为例,进行实验。

const App = (props) => {
  return React.createElement(
    "div",
    { className: "App" },
    React.createElement(
      "header",
      { className: "App-header" },
      React.createElement(
        "h2",
        null,
        "Hello " + props.name
      )
    )
  );
};

const element = React.createElement(App, {
  name: "World",
});

ReactDOM.render(element, container);

对于函数组件的 fiber 而言,没有 DOM 节点,并且子节点是由函数运行而来的不是直接从 props 属性中获取。在 performUnitOfWork 函数中需要对 fiber 类型进行判断,看看是否是函数组件。

const updateFunctionComponent = (fiber) => {
  const children = [fiber.type(fiber.props)];
  reconcileChildren(fiber, children);
};

updateFunctionComponent 用于从函数组件中生成子组件,运行 fiber.typeApp 函数),返回子数组,剩下的调和(reconciliation)工作和之前一致。

原来在 commit 中,所有 fiber 都由真实存在的 DOM

const domParent = fiber.parent.dom;

但是在函数组件没有 DOM 节点,因此函数组件里面返回的子组件需要挂载在函数组件(App)的父节点,所以当我们的 fiber 没有 DOM 的时候,需要修改两个地方。首先找 DOM 节点的时候需要向上遍历 fiber 节点,直到找到有 DOM 节点的 fiber 节点。

let domParentFiber = fiber.parent;
// 向上找到存在的 DOM
while (!domParentFiber.dom) {
  domParentFiber = domParentFiber.parent;
}
const domParent = domParentFiber.dom;

其次在移除节点的同时也需要找到该 fiber 下第一个有 DOM 节点的 fiber 节点。

const commitDeletion = (fiber, domParent) => {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    commitDeletion(fiber.child, domParent);
  }
};

通过递归来找到存在的 DOM 节点,并且 remove 掉。

Hooks

这小结主要为函数组件添加状态,以最简单的计数器为例子。首先在函数组件中调用 useState,创建出状态。

const Counter = () => {
  const [count, setCount] = useState(0);
  const [value, setValue] = useState(7);
  return React.createElement(
    "div",
    { className: "App" },
    React.createElement(
      "header",
      { className: "App-header" },
      React.createElement(
        "h1",
        null,
        "Counter: ",
        count
      ),
      React.createElement("input", {
        value: value,
        oninput: (e) =>
          setValue(Number(e.target.value)),
        className: "mt",
      }),
      React.createElement(
        "div",
        { className: "operator mt" },
        React.createElement(
          "button",
          {
            onclick: () =>
              setCount((pre) => pre + value),
          },
          "ADD Input (+)"
        ),
        React.createElement(
          "button",
          {
            onclick: () =>
              setCount(count - value),
          },
          "SUB Input (-)"
        )
      )
    )
  );
};

为此,需要在调用函数组件时初始化一些全局变量,我们需要在 useState 中使用到这些全局变量。当函数组件调用 useState 时,校验 fiber 对应的 alternate 字段下的旧 fiber 是否存在旧 hook。如果存在旧 hook,将旧 hook 值拷贝到新 hook;如果不存在,将 state 初始化,然后在 fiber 上添加新的 hook,返回状态。

useState 还需要一个可以更新状态的函数,定义为 setState,它接收一个 action 参数(可能为函数,也有可能直接是状态值)。将 action 推入到队列中,之后和之前在 render 函数中一样,将 wipRoot 设置为当前 fiber,之后调度器会帮我们进行新一轮的渲染。

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

然后在 useState 中进行调度 hook 队列中的 action,用来更新 state

actions.forEach((action) => {
  if (action instanceof Function) {
    hook.state = action(hook.state);
  } else {
    hook.state = action;
  }
});

最终完成了 micro-react,可以实现如下效果。

Summary

阅读完这篇文章跟着做完实验下来,理解了 React 工作原理,为之后读懂 React 源码建立基础。但是这只实现了一些简单的功能,没有涉及到 React 复杂功能和相关优化,并且和真正的 React 有一定区别,比如:micro-reactrender 阶段遍历了整棵 fiber 树,React 通过一些算法跳过没有变更发生的子树;micro-reactcommit 阶段遍历了整棵 fiber 树,React 维护了一个列表用于记录变化的 fiber 并且只访问这些 fibermicro-react 新建一个 wipTree,都对每个 fiber 新建了一个对象,React 会尽可能复用之前 fiber 树的对象,等等…

后续可以尝试为 micro-react 添加上这些功能:

  • 用一个对象记录 style prop
  • 展平 children 数组
  • 实现 useEffect hook
  • 使用 key 来调和(reconciliation

antcao.me © 2022-PRESENT

: 0x9aB9C...7ee7d