Shape of My React: 从零开始实现自己的React

1,082 阅读21分钟

前言

本文翻译自著名的博文Build your own React, 从 React 的最基本原理和设计入手, 帮助读者从零开始, 实现基本的 React 框架功能. 深入浅出地帮助读者理解 React 框架中最基本且核心的原理.

实现自己的 React

我们将从头开始, 一步一步地重写 React. 遵从真实 React 框架的代码设计构架, 但是并不会包含所有的优化和非核心功能实现.

如果你已经阅读过较早的一篇 制作你自己的 React, 两篇文章唯一的区别是, 本文是基于 React 16.8, 因此使用了 hooks 并且去除了所有有关 classes 的代码.

(本文相关的代码存放在Didact repo)

从零开始, 以下是所有我们将要添加到我们自己版本 React 中的步骤

  • Step 1: createElement 函数
  • Step2: render 函数
  • Step3: 同步模式
  • Step4: Fibers
  • Step5: 渲染和提交阶段
  • Step6: 调和
  • Step7: 函数组件
  • Step8: Hooks

Step0: 基本概念回顾

const element = <h1 title="foo">Hello</h1>;
const container = document.getElementById("root");
ReactDOM.render(element, container);

在一切开始之前, 让我们先回顾一些基础的概念. 如果你已经熟悉了 React, JSX 以及 DOM 元素, 那么你可以跳过这一部分.

我们来使用这个 React app, 只有三行代码. 第一行定义了一个 React 的元素, 第二行获取了一个 DOM 节点, 最后一行渲染了 React 的元素到 container 元素里.

让我们移除掉所有的 React 相关的代码, 并将其转变为最普通的原生 Javascript.

const element = <h1 title="foo">Hello</h1>;

在第一行中, 我们使用 JSX 语法定义了一个元素, 但他其实并不是一个有效的 Javascript 语句. 因此,我们应当先将其转变为有效的 JS.

JSX 语法可以使用一些构建工具, 例如 Babel, 转化为有效的 JS 语句. 这种转译过程其实十分简单: 将标签内的 code 转变为名为 createElement 的函数调用, 并解析标签的名称, 标签的属性和子元素等作为函数的参数传递.

const element = React.createElement("h1", { title: "foo" }, "Hello");

React.creatElement 方法会基于所接收的参数创建一个对象. 当然还有一些额外的验证参数的内容, 此处我们不必关心. 这就是所有它所做的事情了. 所以我们可以将其直接替换为调用结果

const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
};

这就是元素本体啦! 一个拥有两个属性(type 和 props)的对象(实际上会有更多的属性, 不过在这里,我们仅关注上述两个属性)

type 属性是一个字符串, 用来指定我们要创建的 DOM 节点的类型, 这与你通常使用的 document.createElement 方法的第一个参数 tagName 完全一样. 该属性也可以是一个方法, 不过我们将会在 Step6 才接触到.

props 属性是一个对象, 它包含所有的 JSX 所定义的键值对. 它还具有一个特定的属性 children.

在这个例子中, children是一个字符串, 但在实际场景下, 它经常是一个多元素的数组. 这也是为什么元素节点的总体是一颗树的结构.

ReactDOM.render(element, container);

我们需要替换掉这一段 React 的代码.

render方法是 React 框架用来变更 DOM 的地方, 我们可以自行处理.

/*
	const element = {
	type : 'h1',
	props : {
		title : "foo",
		children : "Hello"
	}
}
*/
// code 0-1
const node = document.createElement(element.type);
node["title"] = element.props.title;

// code 0-2
const text = document.createTextNode("");
text["nodeValue"] = element.props.children;

// code 0-3
node.appendChild(text);
container.appendChild(node);

首先, 我们使用 element 对象的 type 属性, 创建一个 DOM 节点. 在这个例子中, 即为 h1 code 0-1

然后, 我们指定这个 DOM 节点的所有属性, 在这个例子中, 也就是 title 属性.

然后, 我们创建子节点. 在这个例子中, 子节点仅仅是个字符串, 因此我们创建一个文本类型的 DOM 节点. code 0-2

我们使用textNode而不是innerText, 这样子可以让我们把所有内容都当作 DOM 的 node 节点来处理, 做到统一.

最后, 我们将textNode添加到 h1 节点, 将 h1 节点添加到 container 上 code 0-3

经过了上述的改造, 现在我们获得了与一开始的 React 代码完全一致的原生 JS 代码

const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
};

const container = document.getElementById("root");

const node = document.createElement(element.type);
node["title"] = element.props.title;

const text = document.createTextNode("");
text["nodeValue"] = element.props.children;

node.appendChild(text);
container.appendChild(node);

Step1: createElement 函数

让我们重新从另外一个例子开始. 这次我们将会使用我们自己所创建的 React 代码来替换真实的 React 例子.

const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
);

const container = document.getElementById("root");
ReactDOM.render(element, container);

我们将从创建我们自己的 createElement函数开始.

让我们首先将 JSX 转译为 JS 代码, 这样子我们就会看到createElement的调用情况.

const element = React.createElement(
  "div",
  {
    id: "foo",
  },
  React.createElement("a", null, "bar"),
  React.createElement("b")
);

正如我们在 Step0 中所描述的那样, 一个元素就是一个拥有 type 和 props 属性的对象. 我们的createElement函数唯一要做的事情就是创建这样一个对象.

我们使用扩展运算来操作 props 属性, 并且用剩余参数来传递 children, 这样的话, children 属性就会始终是数组.

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children,
    },
  };
}

举个例子, createElement('div')将会返回

{
	type : 'div',
	props : {
		children : []
	}
}

createElement('div',null,a) 将会返回

{
	type : "div",
	props : {
		children : [a]
	}
}

createElement('div', null , a , b)将会返回

{
	type : 'div',
	props : {
		children : [a, b]
	}
}

children数组内也可以包含一些基本类型的值, 例如字符串或者数字. 因此,当元素并不是对象的时候, 我们定义一种特殊的类型:TEXT_ELEMENT 来包装非对象元素.

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) =>
        typeof child === "object" ? child : createTextElement(child)
      ),
    },
  };
}

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

需要注意的是, React 内部并不会包装基本类型或者刻意在没有 children 的时候创建空数组来占位. 但是在本文中,我们做了上述的一些处理, 因为这样可以简化代码. 在这里,我们更关注代码的简洁性而非性能

const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b")
);
const container = document.getElementById("root");
ReactDOM.render(element, container);

我们还在使用 React 所提供的 createElement 方法.

为了更良好地替换掉 React, 我们来命名一下我们的库.我们需要一个听起来很像 React, 同时暗示它的演示指导目的的名称.

我们将它命名为Didact

但是我们仍然想在代码中使用 JSX. 我们如何告知 babel 编译器来使用我们所定义的 Didact 的 createElement,而不是默认的 React 的方法呢?

const Didact = {
  createElement,
};

/** @jsx Didact.createElement */
const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
);

我们在语句前加上特定的注释, 当 babel 转义器遇到 JSX 的时候, 它就会使用我们所定义的方法了.

Step2 : render 函数

RenderDOM.render(element, container);

接下来, 我们需要实现自己的 render 函数.

目前, 我们只关注向 DOM 上添加节点. 关于更新和删除的部分, 稍后处理.

function render(element, container) {
  // code 2-1
  // const dom = document.createElement(element.type);

  // code 2-3
  const dom =
    element.type === "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type);

  // code 2-4
  const isProperty = (key) => key !== "children";
  Object.keys(element.props)
    .filter(isProperty)
    .forEach((name) => {
      dom[name] = element.props[name];
    });

  // code 2-2
  element.props.children.forEach((child) => {
    render(child, dom);
  });

  container.appendChild(dom);
}

const Didact = {
  createElement,
  render,
};

我们首先使用元素的类型创建一个 DOM 节点, 然后把这个新的节点添加到 conatiner 上.code 2-1

我们递归地针对每个 child 都进行这样的操作.code 2-2

我们还需要处理文本类型的元素, 当一个元素的类型是 TEXT_ELEMENT的时候, 我们创建一个文本节点,而非一个常规的节点code 2-3

最后一件事,就是将元素上的属性一一赋值给节点code 2-4

到此, 我们完成一个render函数, 可以将渲染 JSX 语言渲染为真实的 DOM 元素

截止此步骤的代码, 请见此处Didact-render

Step3: 同步模式

但是, 在 Step2 中, 我们添加子节点的方式需要重构

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

递归地调用render函数存在问题.

一旦我们开始渲染过程, 这一调用过程就无法被停止,直到所有的元素树都被完全渲染. 如果整个元素树非常大, 调用过程将会阻塞浏览器的主进程很长时间. 如果浏览器在此时需要取处理一些高优先级的事务, 例如处理用户输入或者保持动画流畅等, 浏览器必须等待整个render调用完毕才行.

所以我们需要将整个过程拆分为较小的工作单元, 每完成一个单元的功能, 我们就可以让浏览器尝试中断渲染过程,如果在此期间, 有高优先级任务需要被处理, 浏览器可以自如地处理这些任务, 而不是被阻塞.

let nextUnitOfWork = null;

function workLoop(deadline) {
  let shouldYield = false;
  while (nextUnitOfWork && !shouldYield) {
    // code 3-3
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    // code 3-2
    shouldYield = deadline.timeRemaining() < 1;
  }
  requestIdleCallback(workLoop);
}

// code 3-1
requestIdleCallback(workLoop);

function performUnitOfWork(nextUnitOfWork) {
  // TODO
}

我们使用requestIdleCallback来创建一个循环. 你可以将requestIdleCallback视为setTimeout, 区别在于, requestIdleCallback的回调函数将被浏览器在主线程空闲时调用code 3-1

requestIdleCallback 的 MDN 文档

实际上, React 已经不再使用requestIdleCallback, 而是使用自实现的一种调度器. 但是在基本概念上,可以认为是一致的.

requestIdleCallback会赋予回调函数一个dealline参数. 我们可以使用这一参数来检查浏览器将再次拿回控制权之前还剩下多少时间.code 3-2

要开始进入循环, 我们需要设置第一个工作单元, 然后我们设置一个perfromUnitOfWork函数, 在此函数中执行工作单元, 并且返回下一个需要被执行的工作单元code 3-3

step4: Fibers

为了组织工作单元, 我们需要引入一种新的数据结构: Fiber Tree

我们给每一个元素关联一个 Fiber, 然后每一个 Fiber 代表一个工作单元.

我们来看这样一个例子

build-your-own-react-2.png

假设我们需要渲染这样的一个元素树:

Didact.render(
  <div>
    <h1>
      <p />
      <a></a>
    </h1>
    <h2 />
  </div>,
  container
);

render函数中, 我们将会创建一个 root fiber 并且将它设置为原始的 nextUnitOfWork. 剩余的工作将会发生在performUnitOfWork函数中, 在该函数中,我们需要对每个 Fiber 做三件事:

  1. 添加元素到 DOM 中
  2. 为元素的子元素创建对应的 Fiber
  3. 选出下一个工作单元

build-your-own-react-1.png

设计 Fiber 这一数据结构的其中一个目的, 就是可以方便地选出下一个工作单元. 这也是为什么 Fiber 的设计中, 每一个 fiber 都有一个指向它第一个子元素(child)的链接, 一个指向它第一个兄弟元素(sibling)的链接, 还有一个指向它的父元素(parent)的链接.

当完成一个 fiber 的运行后, 如果它有子 fiber 那么子 fiber 将被选为下一个工作单元.

在我们的例子中, 当对应于 div 元素的 fiber 执行完毕后, 下一个 fiber 就应当是 h1 所对应的 fiber.

如果 fiber 没有子 fiber 了, 那么将会选择兄弟 fiber(sibling)为下一个工作单元.

在我们的例子中, 当对应于 p 元素的 fiber 执行完毕后, 下一个 fiber 就应当是 a 元素对应的 fiber.

如果一个 fiber 既没有子 fiber 也没有兄弟 fiber 了, 那么将会将父 fiber 作为下一个 fiber.

在我们的例子中, 当对应于 a 元素的 fiber 执行完毕后, 下一个 fiber 就应当回归到检查 h1 元素的 fiber.

当然, 如果发现父 fiber 没有兄弟 fiber(sibling), 则继续沿着父 fiber 的路径向上, 直到发现新的没有被处理过的兄弟 fiber, 或者, 回到整个 fiber 树的 root. 如果我们回到了 root, 则说明我们遍历了整个 fiber 树, 完成了渲染全过程所需要执行的所有工作单元.

现在, 让我们来完成代码.

// code 4-1
function createDom(fiber) {
  const dom =
    fiber.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type);

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

  return dom;
}

// code 4-2
function render(element, container) {
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],
    },
  };
}

let nextUnitOfWork = null;

function workLoop(dealline) {
  let shouldYield = false;
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    shouldYield = dealline.timeRemaining() < 1;
  }
  requestIdleCallback(workLoop);
}
// code 4-3
requestIdleCallback(workLoop);

function performUnitOfWork(fiber) {
  // add dom node
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom);
  }

  // create new fibers for children
  const elements = fiber.props.children;
  let index = 0;
  while (index < elements.length) {
    const element = elements[index];

    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    };
    if (index === 0) {
      fiber.child = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }

    let prevSibling = newFiber;
    index++;
  }

  // return next unit of work
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}

首先, 我们需要去除render函数中的一些代码.

我们将创建 DOM 节点的部分单独提取出来, 将其命名为新的createDom函数, 我们后面会用到它.code 4-1

render函数中, 我们设置nextUnitOfWork为 fiber 的 root.code 4-2

然后, 让浏览器准备好, 它将会调用我们的workLoop函数以从根节点开始整个的流程.code 4-3

首先, 我们创建一个新的节点, 并将其添加到 DOMadd dom node

我们使用 fiber 的 dom 属性来跟踪真实的 DOM 节点.

然后, 给每一个子元素创建对应的 fiber.create new fibers for children

然后, 我们将新创建的子 fiber 添加到 Fiber tree 中, 按照他们是否是第一个子 fiber, 来决定它们是 child 还是 sibling.create new fibers for children

最后, 我们按照之前所定义的搜索策略, 来搜索下一个工作单元.return next unit of work

这就是整个的performUnitOfWork函数

截止此步骤的代码, 请见此处Didact-fiber

Step5: 渲染和提交阶段

现在我们有另外一个问题.

我们在每次的工作单元中, 都将新的节点添加到 DOM 上. 但是请记住, 浏览器可以在完成整个元素树的过程中, 中断我们的工作过程. 在这种情况下, 用户将会看到不完整的 UI 界面. 这是我们不希望看到的.

// need be removed
if (fiber.parent) {
  // 此处的每次运行只给DOM添加了一部分UI
  // debugger
  fiber.parent.dom.appendChild(fiber.dom);
}

所以, 我们需要移除这部分使得 DOM 突变的代码

let nextUnitOfWork = null;
let wipRoot = null;
function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
  };
  nextUnitOfWork = wipRoot;
}

作为替代的是, 我们将会跟踪 fiber 树的 root. 我们将其称之为仍在工作中的 root(wipRoot).

let nextUnitOfWork = null;
let wipRoot = null;

// code 5-2
function commitRoot() {
  // add whole nodes to DOM
  commitWork(wipRoot.child);
  wipRoot = null;
}

function commitWork(fiber) {
  if (!fiber) {
    return;
  }
  const domParent = fiber.parent.dom;
  domParent.appendChild(fiber.dom);
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

function workLoop(dealline) {
  let shouldYield = false;
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    shouldYield = dealline.timeRemaining() < 1;
  }
  // code 5-1
  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }

  requestIdleCallback(workLoop);
}

一旦我们完成了所有的工作(我们会知道这一点, 因为我们会根据返回, 知道没有下一个工作单元了), 我们将提交整个 fiber 树给 DOM, 以完成渲染.code 5-1

commitRoot函数中, 我们递归地将所有的节点添加到 DOM 上.code 5-2

截止此步骤的代码, 请见此处Didact-commit

Step6: 调和

到目前为止, 我们只是向 DOM 添加节点, 但如何更新或者删除节点呢?

这就是我们将要处理的问题, 我们需要比较在render函数中传递的元素树和我们最后提交给 DOM 的 Fiber tree.

let nextUnitOfWork = null;
let wipRoot = null;
let currentRoot = null;

function commitRoot() {
  commitWork(wipRoot.child);
  currentRoot = wipRoot;
  wipRoot = null;
}

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  };
  nextUnitOfWork = wipRoot;
}

所以我们需要在完成提交到 DOM 的步骤后, 保存“最后的 Fiber tree”的引用. 我们将其定义为currentRoot

我们还需要给 Fiber 的结构中添加 alternate这一属性. 这个属性是一个旧的 Fiber 的引用, 也就是那个我们在上一次提交阶段的 Fiber tree.

function performUnitOfWork(fiber) {
  // add dom node
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }

  // create new fibers for children
  const elements = fiber.props.children;
  // code 6-1
  reconcileChildren(fiber, elements);

  // return next unit of work
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}

function reconcileChildren(wipFiber, elements) {
  let index = 0;
  let prevSibling = null;
  while (index < elements.length) {
    const element = elements[index];

    const newFiber = {
      type: element.type,
      props: element.props,
      parent: wipFiber,
      dom: null,
    };
    if (index === 0) {
      wipFiber.child = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }
}

现在, 让我们从performUnitOfWork中提取出有关创建新 fibers 的内容. 将这部分内容重新命名为reconcileChildren函数.code 6-1

function reconcileChildren(wipFiber, elements) {
  let index = 0;
  // code 6-2
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child;

  let prevSibling = null;
  while (index < elements.length || oldFiber != null) {
    let element = elements[index];
    let newFiber = null;
    const sameType = oldFiber && element && element.type == oldFiber.type;
    // code 6-3
    if (sameType) {
      // code 6-3 update the node
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      };
    }
    // code 6-4
    if (element && !sameType) {
      // code 6-4 add this node
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      };
    }
    // code 6-5
    if (oldFiber && !sameType) {
      // code 6-5 delete olderfiber
      oldFiber.effectTag = "DELETION";
      // code 6-5 add oldfiber to record
      deletions.push(oldFiber);
    }
    // code 6-6
    // 在迭代子fiber的过程中,也要迭代olderFiber
    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }

    if (index === 0) {
      wipFiber.child = newFiber;
    } else if (element) {
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }
}

reconcileChildren函数中, 我们将新的元素和原来的 Fiber tree 进行调和过程.

我们同时迭代旧的 Fiber tree(wipFiber.alternate)的 children 和 我们希望调和的新的元素数组.code 6-6

让我们现在忽略一些迭代过程中不重要的样板, 而聚焦于最重要的部分: 旧的 fiber 和 当前的 element. 当前的 element 就是我们想要在这次渲染到 DOM 上的内容, 而旧的 fiber 则是我们上一次已经完成渲染的内容.

我们需要去比较二者, 以明确是否有一些变化, 需要被应用到 DOM 上.

我们需要用到类型来比较它们code 6-2:

  • 如果旧的 fiber 和新的 element 拥有相同的类型, 则我们可以保留当前的 DOM 节点, 而仅更新它的属性code 6-3
  • 如果类型不同,且有一个新的 element, 则说明我们需要新建一个新的 DOM 节点code 6-4
  • 如果类型不同, 且有一个旧的 fiber, 则说明我们应当移除对应的旧节点code 6-5

在这一部分, React 还会使用 keys 以实现更好的调和效果. 例如, 当子元素在元素列表中变化了位置.

当旧的 fiber 和 element 拥有相同的类型时, 我们创建一个新的 fiber, 将 DOM 节点与旧的 fiber 绑定, 并将节点属性和 element 绑定.code 6-3 update the node

我们还给 fiber 添加一个新的属性, 叫做effectTag. 我们一会将会在提交阶段使用到它.

当 element 需要被创建一个新的 DOM 节点的时候, 我们将 fiber 的effectTag设置为PLACEMENT.code 6-4 add this node

当我们需要删除节点的时候, 没有新的 fiber, 但是我们需要将旧 fiber 的effectTag属性设置为DELETION.code 6-5 delete olderfiber

但是当我们向 DOM 提交 fiber 的时候, 我们是使用wipRoot的, 但是这个 fiber 中没有保存到旧的 fibers 信息.

所以我们需要一个数组来跟踪需要被删除的节点.

然后, 当我们向 DOM 提交变更的时候, 我们需要使用到这个保存了被删除节点的数组.code 6-5 add oldfiber to record

function commitRoot() {
  deletions.forEach(commitWork);
  commitWork(wipRoot.child);
  currentRoot = wipRoot;
  wipRoot = null;
}

function commitWork(fiber) {
  if (!fiber) {
    return;
  }

  const domParent = fiber.parent.dom;
  // code 6-7
  if (fiber.effectTag === "PLACEMENT" && fiber.dom !== null) {
    domParent.appendChild(fiber.dom);
  } else if (fiber.effectTag === "UPDATE" && fiber.dom !== null) {
    // code 6-9
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  } else if (fiber.effectTag === "DELETION") {
    // code 6-8
    domParent.removeChild(fiber.dom);
  }

  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

现在, 让我们重写commitWork函数, 来处理不同的effectTag

如果是标记了PLACEMENTtag 的 fiber, 我们保持根之前一样的处理, 即向父 fiber 对应的节点添加新的节点.code 6-7

如果是标记了DELETION, 我们将执行相反操作, 即删除子 fiber 对应的节点.code 6-8

如果是标记了UPDATE, 我们需要更新已经存在的 DOM 节点的那些变更了的属性. 关于更新属性的操作, 我们在updateDom函数中进行.code 6-9

const isEvent = (key) => key.startsWith("on");

const isProperty = (key) => key !== "children" && !isEvent(key);

const isNew = (prev, next) => (key) => prev[key] !== next[key];

const isGone = (prev, next) => (key) => !(key in next);

function updateDom(dom, prevProps, nextProps) {
  // Remove old or changed event listeners
  Object.keys(prevProps)
    .filter(isEvent)
    .filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.removeEventListener(eventType, prevProps[name]);
    });

  // Remove old properties
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = "";
    });

  // Set new or changed properties
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = nextProps[name];
    });

  // Add event listeners
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[name]);
    });
}

我们将旧的 fiber 中的属性和新的 fiber 中属性进行比较, 删除已经不存在的属性, 设置变更或者新增的属性.

这里有一个特殊的属性需要被特化处理 —— 事件处理器, 所以如果属性的名称是以on前缀开头, 我们需要特化处它.

如果事件处理器变化了, 我们就将其从节点上删除.

然后我们添加新的事件处理器.

截止此步骤的代码, 请见此处Didact-reconcile

Step7: 函数组件

下一个我们需要支持的特性是函数组件.

/** @jsx Didact.createElement */
function App(props) {
  return <h1>Hi {props.name}</h1>;
}

const element = <App name="foo" />;

const container = document.getElementById("root");
Didact.render(element, container);

首先,我们修改一下需要渲染的例子.

如果我们将这个这个 JSX 的例子转变为 js, 那么将会是这样:

function App(props) {
  return Didact.createElement("h1", null, "Hi", props.name);
}

const element = Didact.create(App, {
  name: "foo",
});

函数组件的特性在于以下两点:

  • 函数组建所对应的 fiber 没有 DOM 节点
  • children 来自函数的返回, 而不是在属性(props)中
function performUnitOfWork(fiber) {
  // code 7-1
  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;
  }
}

// code 7-3 handle function component
function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)];
  reconcileChildren(fiber, children);
}

// code 7-2 handle host component
function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  const elements = fiber.props.children;
  reconcileChildren(fiber, elements);
}

我们检查一下 fiber 的类型是否是函数, 根据结果, 做不同的处理code 7-1

updateHostComponent函数中, 我们的处理根之前一致code 7-2 handle host component

updateFunctionComponent函数中, 我们调用 fiber 的函数, 以获得子元素code 7-3 handle function component

举个例子, 这里的fiber.type就是App函数, 当调用它的时候, 将返回h1元素.

然后, 一点我们获得了子元素, 调和过程的工作是完全一致的. 我们不需要修改.

function commitWork(fiber) {
  if (!fiber) {
    return;
  }

  // code 7-4
  let domParentFiber = fiber.parent;
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent;
  }
  const domParent = domParent.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") {
    // code 7-5
    commitDeletion(fiber, domParent);
  }

  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

// code 7-5
function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    commitDeletion(fiber.child, domParent);
  }
}

我们还需要修改commitWork函数.

在加入了函数组建后, 我们有了一种没有 DOM 的 fiber, 我们需要在此处做两件事.

首先, 我们需要向上查找 Fiber tree, 以找到最近的拥有 DOM 节点的 fiber.code 7-4

还有, 当删除节点的时候, 也是需要沿着 child 指向, 去向下寻找第一个拥有 DOM 节点的 fiber.code 7-5

截止此步骤的代码, 请见此处Didact-function-component

Step8: Hooks

我们已经有了函数组件, 最后一步, 我们给它添加状态.

const Didact = {
  createElement,
  render,
  useState,
};

function Counter() {
  const [state, setState] = Didact.useState(1);

  return <h1 onClick={() => setState((c) => c + 1)}>Count : {state}</h1>;
}

const element = <Counter />;

让我们把例子修改为一个经典的计数器组件. 每次点击它, 它将状态计数增加一.

const Didact = {
  createElement,
  render,
  useState,
};

我们使用Didact.useState来获取和修改状态数值.

// code 8-1
let wipFiber = null;
let hookIndex = null;

function updateFunctionComponent(fiber) {
  wipFiber = fiber;
  hookIndex = 0;
  wipFiber.hooks = [];
  const children = [fiber.type(fiber.props)];
  reconcileChildren(fiber, children);
}

如上代码所示, 我们在updateFunctionComponent中调用Counter组件. 在组件内, 我们调用useState

我们需要在调用函数组件之前, 初始化一些全局变量, 以便于我们在useState函数中使用它们.code 8-1

首先, 我们保存一下正在工作中的 fiber(wipFiber).

我们还需要向 fiber 添加一个hooks数组, 以支持在函数组件中多次调用useState. 然后我们跟踪 hook 的索引.

function useState(initial) {
  // code 8-2
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex];

  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [],
  };

  // code 8-5
  const actions = oldHook ? oldHook.queue : [];
  actions.forEach((action) => {
    hook.state = action(hook.state);
  });

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

  wipFiber.hooks.push(hook);
  hookIndex++;
  return [hook.state, setState];
}

当函数组件调用useState时, 我们检查一下是否已经存在旧的 hook. 我们在 fiber 的alternate属性中, 使用索引去检查这件事.code 8-2

如果存在旧的 hook, 我们从旧的 hook 赋值状态到新的 hook, 如果没有旧的 hook, 我们使用初始值来创建一个新的状态.

然后我们将新的 hook 添加到 fiber 中, 增加 hook 的索引, 然后返回状态.

useState还应该返回一个可以更新状态的方法, 所以我们定义一个setState方法来接收action.(在这个计数器的例子中, action 就是给状态加一).code 8-3

我们将action存入一个 hook 上的queue数组.

然后我们需要做一些很类似于render中的事, 设置一个新的wipRoot并作为新的下一个工作单元, 这样子workLoop就可以开始新一轮的渲染过程.code 8-4

但是我们还没有调用改变状态的action.

我们会在下一次渲染组件的时候调用它, 我们从 hook 中的 queue 数组中一次性取出所有的action, 然后把他们应用到新的 hook 的状态上, 当我们返回状态的时候,它就会更新.code 8-5

终于, 全部完成了. 这就是全部了. 我们实现了属于自己版本的 React.

截止此步骤的代码, 请见此处Didact-hooks

后记

本文的目的不只是在于帮助你理解 React 的基本工作原理, 还在于帮助你更轻松地深入研究 React 的代码. 这也是为什么, 我们使用了与真实 React 代码中的变量与函数一致的命名.

举个例子, 如果你给一个真实的 React app 打一个断点, 你会在调用栈里看到:

  • workLoop
  • performUnitOfWork
  • updateFunctionComponent

我们并没有包含其他的非常多的 React 的特性和优化. 比如, 下面这些 React 所做的不同的事:

  • Didact中, 我们在渲染阶段遍历了整个树. React 其实会采用一些启发式方法来跳过完全没有变化的子树.
  • 在提交阶段,我们也遍历了整个树. React 维护了一个链表, 其中保存着那些具有副作用的 fiber, 在提交阶段仅会访问这些内容.
  • 每次我们构建一个新的工作树时, 我们都会为每个 fiber 创建新对象. React 其实会从旧的树中回收 fiber.
  • Didact在渲染阶段收到更新时, 会丢弃正在进行的工作树并从根重新开始. React 其实会使用过期时间戳标记每个更新, 并使用它来决定哪个更新具有更高的优先级.
  • 以及更多...

还有不少我们可以轻松添加进来的特性:

如果你为Didact添加了任何特性, 你可以给Didact 的 Github 仓库提起pull request, 这样大家都可以看到.

非常感谢你的阅读!

原作者 Rodrigo Pombo 的 twitter