译:从零开始实现 React

原文:https://www.rob.directory/blog/react-from-scratch
作者:Robby Pruzan
译者:ChatGPT 4 Turbo

编者注:这篇太长没看,感兴趣的自己看吧。

完整代码:https://github.com/RobPruzan/react-scratch

我的目标是逐步讲解我从零开始构建 react 的过程,希望能让你对 react 中事物的行为方式有一个直观的理解。在很多情况下,react 在其接口中泄露了其抽象,因此了解内部可能的实现方式对于理解这些接口设计背后的动机非常有用。

但是,我并不打算遵循 react 团队的相同实现方式。我甚至在编码之前都不知道内部架构。只知道一些高层次的概念,如虚拟 DOM 和协调。

这也不应该是一个最优的实现。react 实现了几种非常令人印象深刻的优化,我不会尝试像并发/可取消渲染那样的操作。

我想在这里完成的是:

  • 核心渲染模型(组件树中的组件在实现之间应该重新渲染相同的次数)
  • 给定相同输入时,输出应该看起来相同
  • 实现了核心钩子(useState、useRef、useEffect、useContext、useMemo、useCallback)
  • 精确的 Dom 更新

渲染某些东西到屏幕上

让我们从第一个目标开始,使用 react 的 api 将某些东西渲染到屏幕上。传统上,react 是通过 jsx 编写的,jsx 是一种类似 html 的语法,用于实例化组件。但是,实际的库对这种表示方式一无所知。所有的 jsx 语法都会被转换成函数调用。

例如,以下片段:

<div id="parent">
  <span>hello</span>
</div>

将会被转换成:

React.createElement("div", { id:"parent" }, React.createElement("span", null, "hello"))

我不会讲解这种转换是如何发生的,因为这篇文章不是关于解析器的 :).

为了开始,让我们创建我们将要为之创建一个最小实现的第一个示例:

const App = () => {
	return React.createElement("div", { innerText: "hello world" }, React.createElement("span", { innerText: "child" }))
}

首先显而易见的是,我们有一个类树状 / 递归结构来构建我们的视图层次结构 – createElement 接受剩余参数,其中这些参数是 create element 的返回类型。

那么,让我们继续模拟这个视图层次结构和 createElement 的输入类型。

// createElement 将会接受的输入
export type ReactComponentExternalMetadata<T extends AnyProps> = {
  component: keyof HTMLElementTagNameMap | ReactComponentFunction<T>;
  props: T;
  children: Array<ReactComponentInternalMetadata>;
};

// 为了便于处理的组件元数据的内部表示
export type TagComponent = {
  kind: "tag";
  tagName: keyof HTMLElementTagNameMap;
};

export type FunctionalComponent = {
  kind: "function";
  name: string;
  function: (
    props: Record<string, unknown> | null
  ) => ReactComponentInternalMetadata;
};

export type ReactComponentInternalMetadata = {
  id: string;
  component: TagComponent | FunctionalComponent;
  props: AnyProps;
  children: Array<ReactComponentInternalMetadata>;
};

然后我们可以轻松地将 createElement 的输入转换为我们的内部表示:

export const createElement = <T extends AnyProps>(
  component: ReactComponentExternalMetadata<T>["component"],
  props: T,
  ...children: Array<ReactComponentInternalMetadata>
): ReactComponentInternalMetadata => ({
  component: mapComponentToTaggedUnion(externalMetadata.component), // 实现留给读者作为练习
  children: externalMetadata.children,
  props: externalMetadata.props,
  id: crypto.randomUUID(),
});

现在我们有了视图层次结构的表示,我们可以使用以下代码成功地将其应用于 dom:

const applyComponentsToDom = (metadata: ReactComponentInternalMetadata, parentElement: HTMLElement | null) => {
    if (metadata.component.kind === "tag") {
        const element = document.createElement(metadata.component.tagName);
        Object.assign(element, metadata.props);
        parentElement?.appendChild(element);
        metadata.childNodes.forEach(childNode => appendTagsToDOM(childNode, element));
    } else {
		throw Error("Not Implemented")
	}
}

目前,applyComponentsToDom 只遍历标签元素。这是因为标签元素的树是急切求值的,因为它们只是传递给 createElement 的字符串,所以不需要任何努力就能生成我们要遍历的树。它已经为我们构建好了。

但是,如果我们想开始渲染函数式组件,并将不同的函数式组件组合在一起,我们将不再拥有一个急切求值的树。它会看起来更像是:

其中,每个函数都需要手动执行以评估树的其余部分。

换句话说,组件元数据中的 “children” 属性不再代表一个我们可以轻松遍历的有效树状结构。我们将不得不使用 HTML 标记信息,连同与组件关联的函数,来生成一个我们可以遍历的完整树。

为了做到这一点,让我们来模型一下我们需要构建的树上的一个节点会是什么样子

type ReactViewTreeNode = {
  id: string;
  childNodes: Array<ReactViewTreeNode>;
  metadata: ReactComponentInternalMetadata;
};

有了这种树,我们将显式持有在 “childNodes” 属性内完全执行的节点

我们的下一个挑战将是实际生成给定根组件元数据的这个视图树。根组件将是传递给

ReactDom.render(<App />, document.getElementById("root")!);

在我们试图制作的这棵树中,父子层级关系由组件元数据上的 “children” 属性确定。

为了制作一个能将懒惰的内部元数据转变成完整视图树的函数,我们需要执行以下操作:

  • 为每个内部元数据创建一个新节点(这个节点将在函数结束时返回)
  • 如果元数据表示一个函数,运行该函数,递归遍历其输出,并将递归调用的结果追加到新节点的子节点中
  • 这为我们提供了在函数下完全执行的树(最多为 1 个子节点)
  • 如果元数据代表一个标签,请通过递归调用 generateViewTree 为所有子元数据的视图节点获取节点。将该输出设置为为标签元数据创建的新节点的子节点
    • 这为我们提供了在标签节点下完全执行的树(可能有多个子节点)

代码中的样子如下:

const generateViewTree = ({
  internalMetadata,
}: {
  internalMetadata: ReactComponentInternalMetadata;
}): ReactViewTreeNode => {
  const newNode: ReactViewTreeNode = {
    id: crypto.randomUUID(),
    metadata: internalMetadata,
    childNodes: [],
  };

  switch (internalMetadata.component.kind) {
    case "function": {
      const outputInternalMetadata = internalMetadata.component.function({
        ...internalMetadata.props,
        children: internalMetadata.children,
      });

      const subViewTree = generateViewTree({
        internalMetadata: nextNodeToProcess,
      });

      newNode.childNodes.push(subViewTree);
      break;
    }
    case "tag": {
      newNode.childNodes = renderNode.internalMetadata.children.map(
        generateViewTree
      );
      break;
    }
  }

  return newNode;
};

然后我们需要更新我们的 applyComponentsToDom 函数,以便它遍历这棵新树

const applyComponentsToDom = (
  viewNode: ReactViewNode,
  parentElement: HTMLElement | null
) => {
  switch (viewNode.internalMetadata.kind) {
    case "tag": {
      const element = document.createElement(
        viewNode.metadata.component.tagName
      );
      Object.assign(element, viewNode.metadata.props);
      parentElement?.appendChild(element);
      viewNode.metadata.childNodes.forEach((childNode) =>
        appendTagsToDOM(childNode, element)
      );
      break;
    }
    case "function": {
      applyComponentsToDom(viewNode.childNodes[0]); // 一个函数组件最多有 1 个子节点,因为每个元素在返回时必须有父元素
    }
  }
};

这让我们能够将生成的视图树应用于 DOM,使我们能够将功能组件组合在一起。我们的下一个目标将是为这些组件添加一些交互性。

useState

在 react 中,使用 useState 钩子将状态绑定到组件。

组件中首次调用 useState 时,它将状态绑定到被调用的组件,并将创建的状态与该特定的钩子调用关联起来。允许组件在后续的渲染中读取状态的最新值。

这个过程的困难部分是:

  1. useState 如何知道它是从哪个组件实例中被调用的?
  2. 如果一个组件中有多个 useState,它如何在渲染之间记住它与哪个状态相关联?
const Component = () => {
  const [a, setA] = useState(0); // 它如何知道是在 Component 里面被调用的?
  const [b, setB] = useState("b"); // 下次渲染时,它将如何知道返回 "b" 而不是 0?
};

为了解决第一个问题,这并不完全困难。我们只需要全局跟踪我们在遍历组件的内部元数据以生成视图树时调用的组件即可。在我们调用它之前,我们更新一些全局可用的对象以持有该组件的元数据。然后在 useState 定义内部,它可以读取那个全局变量并知道它是从哪个组件调用的。

现在是问题 #2。我们已经知道 react 如何在不查看代码的情况下实现这一点。react 的一个重要规则是不能有条件地调用钩子。但这是为什么呢?这是因为 react 使用钩子调用的顺序来确定渲染之间钩子调用的相等性。

如果 react 断言条件下绝不可以调用钩子,并假设有一个组件做出了以下钩子调用:

useState();
useState();
useEffect();
useState();

然后在下一次渲染时,它做出了以下钩子调用:

useState();
useState();
useEffect();
useState();

我们可以轻易地说,在每次渲染中,第 i 个钩子调用彼此关联,其中 i=钩子被调用的顺序索引。

我们可以通过在钩子定义内部每次调用时递增一个全局可访问的计数器,并在调用时读取计数器的值,来跟踪钩子调用的当前顺序。

在伪代码中看起来像这样

let currentHookOrder = 0;

const useState = () => {
  let useStateHookOrder = currentHookOrder;
  currentHookOrder += 1;
  // 做一些事情
};

我们可以使用这些信息的一种方式是,在我们组件视图节点中维护一个“钩子状态”的数组。其中第 i 位的项目属于在该组件中第 i 次调用的钩子。

因为当前渲染的组件在全局是可访问的,我们可以从 useState 函数定义中访问其钩子数组,然后使用我们的全局计数器进行索引。如果钩子是第一次运行,我们只需将初始状态推送到这个数组中。

然后,由 useState 返回的元组是:

  • 存储在钩子状态数组第 i 位的值
  • 一个闭包,它能够用提供的值改变该钩子数组,并触发定义状态的组件的重新渲染(通过在闭包中捕获当前渲染的组件)

这会看起来像这样

const useState = (initialState) => {
  const currentlyRenderingComponent =
    someGlobalObject.currentlyRenderingComponent;
  const useStateHookOrder = currentHookOrder;
  currentHookOrder += 1;
  if (!currentlyRenderingComponent.hasRendered) {
    currentlyRenderingComponent.hookState.push(initialState);
  }
  const state = currentlyRenderingComponent.hookState[currentHookOrder];
  return [
    state,
    (value) => {
      currentlyRenderingComponent.hookState[useStateHookOrder] = value;
      triggerReRender(currentlyRenderingComponent); // 待办
    },
  ];
};

这个钩子的最终代码几乎与这个示例完全相同,只是加上了一些错误检查、适当的类型定义,以及引用了真实的全局值。如果你想查看 这里是定义

现在我们定义了我们的第一个钩子,我们有了一个很好的方式来定义一个钩子是什么。它只是一个函数,但它是一个依赖于绑定到组件的信息的函数。

重新渲染一个组件

现在让我们继续我们的下一个目标:实际实现 triggerReRender()

它将包括 3 个步骤:

  1. setState 闭包中捕获的 currentlyRenderingComponent 开始,重新生成视图树
  2. 一旦子视图树生成,就将现有视图树(我们存储在全局对象中)打补丁,以使用新生成的子树,并将其状态转移到新的视图树中
  3. 给定新的、打了补丁的视图树,更新 DOM

第 1 步不会太有挑战性。我上面展示的 generateViewTree 函数是一个纯函数,并且如果传递的是树的根,或是较大树的一部分的子树,它的操作不会有所不同。所以我们可以直接传递在 useState 返回闭包中捕获的变量 —— currentlyRenderingComponent —— 并获取我们的新视图树,自动重新渲染所有子节点。因为我们在重新渲染子节点之前变异了钩子状态数组,他们将读取传给 set state 函数的最新值。

现在我们可以继续进行第 2 步,打补丁到现有视图树。这同样也很简单。我们只需要遍历现有视图树来找到当前渲染组件的父节点,然后只需用新生成的节点替换掉前一个节点。为了转移状态,我们可以简单地将组件状态复制到新的树节点上(这不是正确的做法,稍后会讨论正确的方式)。

然后使用这个打了补丁的视图树,我们将以一种极其低效的方式更新 DOM,然后回头实现一个更高效的实现。我们将从我们的 react 应用的根开始拆除整个 DOM,然后使用我们的新视图树重新构建它。我们已经有了一个可以给定根 DOM 元素应用整个视图树到 DOM 的函数,所以我们可以重用那个。

这 3 个步骤在函数中的样子如下:

const triggerReRender = (
  capturedCurrentlyRenderingRenderNode: ReactViewTreeNode
) => {
  const newViewTree = generateViewTree(capturedCurrentlyRenderingRenderNode);
  const parentNode = findParentNode(capturedCurrentlyRenderingRenderNode);
  replaceNode({
    parent: parentNode,
    oldNode: capturedCurrentlyRenderingRenderNode,
    newNode: newViewTree,
  });
  while (globalState.roomDomNode.firstChild) {
    removeDomTree(node.firstChild);
  }

  applyComponentsToDom(newViewTree, globalState.roomDomNode);
};

这就是我们需要的所有内容,用于一个极端低效的重新渲染策略。

现在我们可以重新渲染组件了,我想回顾一下我们最初的目标之一:

  • 组件树中的组件在实现之间应该重新渲染相同次数

如果我们的视图树正确构造,这应该是真的。当父节点在 react 中发生变化时,默认情况下,它会无条件地重新渲染其子节点。

但是,对于以下代码,我们期望父子关系在渲染方面是怎样的(这里为了简洁使用 JSX):

const ComponentA = ({ children }) => {
  const [count, setCount] = useState(0);
  return <div>{children}</div>;
};
const ComponentB = () => {
  return <div>我是组件 B</div>;
};
const App = () => {
  return (
    <ComponentA>
      <ComponentB />
    </ComponentA>
  );
};

另一种问这个问题的方式是,如果 ComponentA 重新渲染了,ComponentB 应该重新渲染吗?让我们看看我们当前视图树的样子会是什么:

这意味着 ComponentA 的重新渲染应该重新渲染 ComponentB。但它需要吗?ComponentB 从未接受 ComponentA 的任何 props,因为它是在 App 中创建的。

如果我们说一个组件接受另一个组件的 props 的可能性创建了两个组件之间的依赖关系,那么上述 react 应用的依赖树看起来会是这样的:

注意,在我实际的 GitHub 代码中,我将依赖树称之为 “RenderTree”

这个依赖树更加符合 react 决定重新渲染组件的方式。这意味着我们遇到了一个 bug,因为根据我们的实现,当一个同级(在依赖树中)发生变化时,ComponentB 会被重新渲染,因为它在视图树中是一个子元素。

我们现在需要引用 2 个树表示来正确地重新渲染一个组件。一个是决定 DOM 应该如何显示的,另一个是决定组件何时需要重新渲染的。

构建这个依赖树所需要的唯一信息是知道给定组件是在哪个组件中调用的。某个给定组件被调用的组件将被标记为父级。这是因为调用 createElement 后,没有办法更新元素的 props:

const element = createElement(SomeComponent, { someProp: 2 });

return <div>{ element }</div> // 没有办法给已经创建的元素传递 props

所以我们所要做的就是跟踪 createElement 调用发生在哪个组件中,以建立一个有效的依赖树。我们可以不费太大劲儿地做到这一点:

  • 在渲染组件之前,为其创建一个依赖树节点,并全局存储它,以便 createElement 可以访问它(本质上与前面讨论的当前渲染组件相同)
  • 对于每个 createElement 调用,推入一个新的渲染节点,表示为元素创建的组件,作为全局可访问渲染节点的子节点

注意:这个策略只是接近于我最终采取的做法。我最终实施了一种更迂回的方法,以便它能与我的现有代码很好地配合。我真正做的事情的一般思路是:

  • 拿一个组件的输出内部元数据(一个树状结构),然后将其展平成一个数组
  • 如果数组内的任何节点已经在依赖树中,它必须是之前调用过的,所以将其过滤掉。这个方法有效,因为元素首次返回的值必须是它被创建在其中的组件。

使用这个策略,我们可以构建我们正在寻找的依赖树。然而,由于我们没有将其用于任何事情,我们的组件仍会不正确地重新渲染。

我们需要在遍历组件返回的懒树时,使用这个依赖树:

我们可以使用依赖树的一个简单方法是,在生成视图树时,只有当组件依赖于触发重新渲染的组件(或者如果它之前从未被渲染过)时,才重新渲染一个组件。

如果一个组件既不依赖于触发渲染的节点,也不是第一次渲染,我们可以跳过渲染那个组件,并使用该组件之前的视图树输出,本质上缓存输出。

如果你想在代码中看到这是什么样子,这里有使用这个策略的真实实现

在渲染之间对视图节点进行协调

我们的渲染模型仍然是破损的。正如之前提到的,我们在渲染之间传递状态的方式是非常错误的!

以前,为了确保状态在重新渲染之间不丢失,我们只是为触发重新渲染的组件复制过来状态

但是其他所有组件怎么办?它们都将重新初始化状态,不记得上一次重新渲染的任何内容。

这是一个相当难的问题。我们只有运行时表示,了解树的“样貌”。我们没有一个编译器运行在用户的代码上,告诉我们哪些组件是相等的

我们必须做的是确定树的两个节点之间的相等性,而不仅仅是微不足道的根节点。

让我们看看作为一个人如何做到这一点。对于以下示例,如果你必须做出决定,你将如何确定节点之间的等式:

我认为答案在这里相当简单:只需匹配相同位置的节点:

那么我们如何以编程方式定义位置呢?我们可以简单地说是到达节点的路径

当一个节点在视图树之间的索引路径相同时,我们可以将状态从前一个树转移过来。如果有一个我们无法映射的新路径(意味着它是一个新节点),我们不会转移任何状态,让它自行初始化。

这就是为什么 React 在渲染列表时如此强调提供键的原因。如果节点重新排序,它将错误地确定节点之间的相等性,状态将被错误地分配给组件。

如果你想看看树之间的这个对账过程的代码长什么样,这里是链接(注意,在最终代码中,我们考虑了索引路径和组件名称):

条件元素

条件元素是 React 功能的核心部分。因为你只是编写一个返回 React 元素的 JavaScript 函数,你当然可以有条件地返回元素:

return <div> {condition ? <ComponentA /> : <ComponentB />} </div>;

或者你可以有条件地渲染一个元素

return <div> {condition && <ComponentA />} </div>;

其中当条件为 false | null | undefined 时,React 会在其位置上什么都不渲染。

我们的实现已经自动处理了第一种情况,因为名称不同,我们将正确决定初始化 ComponentB(如果组件都是 ComponentA,我们的实现会认为它们相同,但如果你不提供键,React 也会这样。)

关于第二种情况,目前我们的 createElement 函数不接受 null | false | undefined 值作为子元素。而这正是条件渲染编译为的内容:

因此,我们需要更新 createElement 以允许 null | false | undefined,并以某种方式处理它。

最简单的解决方案是过滤掉所有的 false | null | undefined 值。然后,每当返回其中一个值时,不会为该值创建节点,它似乎就像我们期望的那样被删除了?

但我们不能那么做。想象一下在两次渲染之间生成的以下树,其中路径 [0] 上的节点是有条件渲染的:

如果在第二次渲染中,[0] 被替换为 false | undefined | null,我们可以省略为它创建一个节点。但那么位于 [1] 的节点会滑回到位置 [0],使它看起来像是第 [0] 个节点,其子节点也是如此。

但那显然是错误的,这意味着每当用户想要有条件地渲染组件时,我们必须要求组件有键。React 并不要求用户这样做。

相反,我们可以将 false | undefined | null 表示为树中的一个空槽。其中 false | null | undefined 创建有效的 react 元素放在树上。它们只是没有元数据和没有子节点。这样,我们的树在渲染之间将是稳定的:

每当我们尝试渲染一个空槽时,我们只是跳过它。它没有子节点,也不能输出任何东西。我们所有的节点仍然存在于相同的位置,因此我们可以在渲染之间正确地传递状态。

高效的 dom 更新

目前,每当任何东西重新渲染时,我们都会拆除整个 dom。显然,react 不会这样做。它只会更新需要的 dom 节点。

我们已经讨论了如何确定树之间的相等性——节点的路径。这种策略对于更新 dom 将非常有用。

在组件重新渲染后,我们将比较新旧视图树。当我们找到具有匹配索引路径的标签节点时,我们将新视图节点的 props 直接传递给 HTML 标签。React 通过他们的 合成事件 系统对此有一个轻量级的抽象。但本质上做的是同样的事情。

如果一个新节点在之前的树中没有可以匹配的节点,那么它必须是一个新节点,我们为它创建一个新的 HTML 元素。

如果一个旧节点在之前的树中没有可以匹配的节点,那么它必定不再存在,因此我们删除这个现有的 HTML 元素。

这实际上就是进行高效 dom 更新所需要的全部。我为此编写的实际代码看起来比我描述的要复杂得多,因为由于条件元素(空插槽)的需要,需要进行一些记录。

如果视图树节点在渲染间变成了一个空插槽,我们必须删除关联的 dom 节点。如果空插槽变成了一个真实元素,我们必须将它插入到 dom 中正确的位置。

更多钩子

现在我们的核心渲染模型基本完成了,我们可以实现一些有趣的钩子

useRef

这是一个非常容易实现的钩子。它简单地将一个不变的引用绑定到你的组件上,允许你随意改变引用处的内容,不触发重渲染。

为了适应 useState 的示例代码,它可能看起来像是这样

const useRef = (initialState) => {
  const currentlyRenderingComponent =
    someGlobalObject.currentlyRenderingComponent;
  const useRefHookOrder = currentHookOrder;
  currentHookOrder += 1;
  if (!currentlyRenderingComponent.hasRendered) {
    currentlyRenderingComponent.hookState.push(
      Object.seal({ current: initialState })
    );
  }
  const ref = currentlyRenderingComponent.hookState[useRefHookOrder];
  return ref;
};

核心部分缺少的是没有触发重渲染。否则,它仍然绑定到组件的数据,并且与 useState 返回的状态非常相似。

useEffect

现在让我们来看看 useEffectuseEffect 有 3 个核心组成部分:

  • 效果回调
  • 效果依赖
  • 效果清理

每次依赖改变或组件首次挂载时,都会调用效果回调。如果效果回调返回一个函数,那么在下一个效果之前,这个函数将被调用,作为对在效果回调中进行的任何设置逻辑的清理函数。

useEffect 钩子也非常容易实现,特别是因为在一些代码库中它可能感觉非常复杂。

我们遵循与 useRefuseState 相似的过程:我们读取当前渲染的组件,索引到它的钩子中,并在组件尚未被渲染时初始化它。

但是我们有一个额外的步骤。如果依赖项相比于上一次渲染发生了变化(通过浅等式测量),那么我们更新钩子状态的效果回调和它的依赖项。如果依赖项改变,那么先前的回调有一个对过时值的闭包,所以我们需要新计算的闭包(记住,闭包只是一个特殊的对象,我们想要最新的一个)。

export const useEffect = (cb: () => void, deps: Array<unknown>) => {
  const useEffectHookOrder = currentHookOrder;
  currentHookOrder += 1;

  if (!currentlyRendering.hasRendered) {
    currentlyRendering.hooks.push({
      cb,
      deps,
      cleanup: null,
      kind: "effect"
    });
  }
  const effect = currentlyRendering.hooks[currentStateOrder];

  if (
    effect.deps.length !== deps.length ||
    !effect.deps.every((dep, index) => {
      const newDep = deps[index];
      return newDep === dep;
    })
  ) {
    effect.deps = deps;
    effect.cb = cb;
  }
};

我们完成了为组件设置效果的步骤,但这个函数定义没有处理调用效果或效果清理。这发生在渲染完成之后。

不出所料,我们将在调用组件的 render 函数之后的代码位置调用效果。

我们将必须遍历组件持有的所有效果,检查与上一次渲染相比依赖项是否发生了变化,如果确实如此:

  • 如果有清理函数,则调用效果清理
  • 调用效果
  • 如果效果返回一个回调,则将其设置为钩子状态中的新清理函数

这就是实现这一功能所需的确切代码:

const outputInternalMetadata = internalMetadata.component.function({
  ...renderNode.internalMetadata.props,
  children: internalMetadata.children,
});
const currentRenderEffects = outputInternalMetadata.hooks.filter(
  (hook) => hook.kind === "effect"
);

currentRenderEffects.forEach((effect, index) => {
  const didDepsChange = Utils.run(() => {
    if (!hasRendered) {
      return true;
    }
    const currentDeps = effect.deps;
    const previousDeps = previousRenderEffects[index];

    if (currentDeps.length !== previousDeps.length) {
      return true;
    }

    return !currentDeps.every((dep, index) => {
      const previousDep = previousDeps[index];
      return dep === previousDep;
    });
  });

  if (didDepsChange) {
    effect.cleanup?.();
    const cleanup = effect.cb();
    if (typeof cleanup === "function") {
      effect.cleanup = () => cleanup(); // typescript stuff
    }
  }
});

一点也不差

useMemo

关于 useMemo 有很多误解,人们常常因为它们产生的开销而避免使用。但让我们看看事实是否真的如此。

useMemo 接受一个输出值的函数,并通过调用该函数返回该值。useMemo 的特别之处在于,它还接受一个依赖项数组。如果这些依赖项在渲染之间没有变化,它将不会再次调用该函数;相反,它将重用函数的最后输出。如果它们发生了变化,函数将再次运行,并且那个值将被返回。

这是优化组件的一种方式,因为你可以避免不必要的重新计算值。但是,这只在记忆检查的开销小于调用函数的开销时才成立。但我们可以明确定义那个开销是多少,因为我们正在实现这个钩子:

它将与 useEffect 的实现非常相似。我们需要做所有初步的设置逻辑来获取钩子状态,或者如果是第一次渲染则初始化它。然后我们检查依赖项是否发生了变化,就像 useEffect。不同的是,如果 useMemo 中的依赖项发生变化,我们调用提供的函数。否则,我们返回之前的值:

export const useMemo = (cb: () => void, deps: Array<unknown>) => {
  const useMemoHookOrder = currentHookOrder;
  currentHookOrder += 1;

  if (!currentlyRendering.hasRendered) {
    currentlyRendering.hooks.push({
      cb,
      deps,
      cleanup: null,
    });
  }
  const memo = currentlyRendering.hooks[useMemoHookOrder];

  if (
    memo.deps.length !== deps.length ||
    !memo.deps.every((dep, index) => {
      const newDep = deps[index];
      return newDep === dep;
    })
  ) {
    const value = cb();
    memo.value = value;
    return value;
  }
  return memo.value;
};

在我们的实现中,调用 useMemo 的开销相当低!我们所做的只是一些廉价的 if 语句,对一个很可能是小数组来说。即便在 javascript 中,这也是相当快的。

我在事后看了一下 react 的实现。基本上是完全一样的(请在这里查看,基本上每个其他 hook 都是这样)

如果你不想点击链接,这里有代码:

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null
): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  // 假设这些都是定义好的。如果它们没有定义,areHookInputsEqual 会警告。
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
  const nextValue = nextCreate();
  if (shouldDoubleInvokeUserFnsInHooksDEV) {
    setIsStrictModeForDevtools(true);
    nextCreate();
    setIsStrictModeForDevtools(false);
  }
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

其中 areHookInputsEqual 的实现简单明了:

for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
  // $FlowFixMe[incompatible-use] 升级 Flow 时发现的问题
  if (is(nextDeps[i], prevDeps[i])) {
    continue;
  }
  return false;
}
return true;

这意味着,如果你的函数执行的计算量超过了执行 deps.length 浅比较所需的计算量,你最好使用 useMemo

这也在规模上得到了证明,通过 react 编译器——一个几乎对所有东西都进行记忆化的编译器。他们将其应用到 Instagram 代码库中,发现内存没有增加,并且互动性大大提高:

useCallback

既然我们实现了 useMemo,那么让我们来实现 useCallback

请打鼓。

export const useCallback = <T,>(
  fn: () => T,
  deps: Array<unknown>
): (() => T) => {
  return useMemo(() => fn, deps);
};

useCallback 实际上就是返回函数的 useMemo。与

useMemo(() => () => { }, [])

相比,它读起来稍微顺畅一些。

后来,出于好奇,我查看了 react 的实现:

大体上,它与 useMemo 的实现相同:

useContext

useContext 与大多数其他钩子相比有一点特别。这是因为调用 useContext 时不负责创建任何状态。它仅负责读取状态,该状态在更高层次的树中被共享。

共享这个状态所需的功能不是钩子的一部分。它来源于一个上下文提供者——一个特殊的组件。

以下是一个示例用法,确保我们理解是一致的:

const CountContext = createContext({ count: 0 });

const App = () => {
  const [count, setCount] = useState(0);

  return (
    <CountContext.Provider value={{ count }}> 这是一个特殊的组件,由 createContext 创建
      <Child />
    </CountContext.Provider>
  );
};

const Child = () => {
  const { count } = useContext(CountContext);
  return <div> {count} </div>;
};

createContext 充当一个工厂,以创建一个包含数据的特殊 Provider 组件。组件通过调用 useContext 并传递 createContext 的返回值来读取这些数据。告诉 useContext 沿视图树向上搜索,直到找到相同上下文创建的提供者。

注意,我说的是沿视图树向上搜索,而不是依赖树。从依赖层次结构来看,一个组件可能是另一个组件的兄弟,但从视图层次结构来看,它可能是子组件,并且仍然能够读取上下文。例如:

return (
<div>
  <SomeContext.Provider>
    <SomeComponent/>
  </SomeContext.Provider>
  <OtherComponent/>
</div>
)

如果 SomeContext.Provider 是一个组件,在依赖树中它是 SomeComponent 的兄弟。但是,我们期望 SomeComponent 在调用 useContext(SomeContext) 时可以获取到上下文状态。因此,我们可以说 useContext 搜索的树必须基于视图树。

你可能会认为这意味着 SomeComponent 现在依赖于 SomeContext.Provider,我们应该更新依赖树来表示这一点,但事实并非如此。

虽然 SomeComponet 确实读取了由 SomeContext.Provider 提供的数据,SomeContext.Provider 仅仅是广播一个值,它没有创建可更新 + 导致重渲染的 react 状态(与之相关的没有设置器)。

要更改提供者内的数据,必须由 SomeContext.Provider 的祖先触发状态更改(提供者持有的所有数据都必须来自于它依赖的组件,因为它是作为 props 传递的)。并且由于 SomeComponent + SomeContext.Provider 在依赖树中是兄弟节点,它们共享相同的祖先并将一起被重新渲染。允许 SomeComponent 读取 SomeComponent 广播的最新值。

如果 SomeContext.Provider 广播的是一个不是 react 状态的值;它将仅仅是静态的,没有理由依赖于一些永远不会改变的东西。

让我们从实现 createContext,创建特殊提供者组件的函数开始。

目前 createElement 仅返回有关其实例化的组件的元数据。这些元数据包括 props、函数或 html 标签及其子项。

对于提供者组件,我们可以通过在内部元数据上添加一个属性来分配我们想要分发给组件后代的数据。然后,我们在使用 createContext 创建元数据时设置它。

稍后,当我们调用 useContext(SomeContext) 时,我们向上读取视图树并寻找一个与 SomeContext.Provider 相等的提供者节点,如果确实如此,数据将可供读取。

以下是 createContext 实现的样子:

export const createContext = <T,>(initialValue: T) => {
  const contextId = crypto.randomUUID();

  currentTreeRef.defaultContextState.push({
    contextId,
    state: initialValue,
  }); // 解释稍后
  return {
    Provider: (data: {
      value: T;
      children: Array<
        ReactComponentInternalMetadata | null | false | undefined
      >;
    }) => {
      if (
        typeof data.value === "object" &&
        data.value &&
        "__internal-context" in data.value
      ) {
        // hack 来关联提供者的 id,允许我们确定 ProviderA === ProviderB。我们本可以使用函数引用,但这对于调试更容易
        return contextId as unknown as ReturnType<typeof createElement>;
      }
      const el = createElement("div", null, ...data.children); // 因为我已犯错,理想情况下应该使用 fragment
      if (!(el.kind === "real-element")) {
        throw new Error();
      }
      el.provider = {
        state: data.value, // 将由 useContext 读取的数据
        contextId,
      };
      return el;
    },
  };
};
  • 注意我传递的元素类型是 div。这显然是错误的。我们真正想做的是类似于 fragment 的东西。目前我不打算实现它,因为我认为这不是 react 的核心功能。
  • 注意,之前,我们的视图树节点只携带有关其子节点的信息。所以,我们需要进行可能代价高昂的遍历,向上移动树并检查祖先节点是否有我们正在寻找的提供者。为了避免这种情况,我进行了一个小优化并对视图树进行了非规范化处理,通过在子节点上存储父节点作为属性。

如果我们在树上找不到提供者,我们想要返回传递给 createContext 的默认值。这是上述 createContext 实现中未解释的部分。我们可以做的是将默认值存储在一个全局数组中。当找不到提供者时,useContext 函数可以回退到读取默认值。这种行为与编程语言在任何祖先作用域中找不到值时的行为非常相似——回退到查看全局作用域。

现在让我们看看 useContext 实现将会是什么样子,因为所有需要的部分都完成了:

export const useContext = <T,>(
  context: ReturnType<typeof createContext<T>>
) => {
  const providerId = context.Provider({
    value: {
      "__internal-context": true,
    },
  } as any) as unknown as string;
  const state = searchForContextStateUpwards(
    currentlyRenderingComponent,
    providerId
  );
  return state as T;
};

您可能已经注意到在我们使用 context 的实现中,我们从未通过一个增加了调用的 hooks 或改变了 render 节点的 hooks 数组。这是因为我们根据在 react 树外部创建的 ID(createContext)读取上下文数据,并且因为 useContext 创建的数据不需要跨渲染保持,它只读。所以我们不受它被调用的顺序的限制。

这意味着我们可以有条件地调用它,没有问题。这与 react 的新 use 钩子的行为相同,可以用来读取上下文。我想它可以有条件地被调用的原因非常相似。

虽然,我不知道为什么不能有条件地调用 useContext 的限制是什么。也许这是实现中的一个怪癖,使得 use 更接近它们理想中的实现。

最终结果

让我们最终看一个把所有这些结合在一起的例子!

下面的例子展示了一个示例应用,它包含了上下文、状态、记忆化、在 useEffect 中获取数据、条件渲染、列表渲染、深层属性和高效的 DOM 更新!

结论

从头开始重建库是一种非常棒的方式,可以直观地了解为什么库会做出某些决策。你也会构建出库的强大内部模型,这让你可以推理出你可能从未遇到的场景。这也是了解那些没有构建库就绝对想不到的隐藏行为的好方法。

如果你最终深入整个代码库,你可能会注意到事情比我这里的解释看起来更复杂。由于我一开始的技能问题/犯的错误添加了很多复杂性,要纠正这些问题需要花费很长时间。如果我重写这个,我认为可能只需要 1/3 的代码行数。

无论如何,我尽我所能将代码库的核心思想提炼成这里可解释的内容。如果你对如何实现某些东西有任何疑问,或者你遇到了 bug(我敢肯定有很多),请随时在 GitHub 上提出问题,我一定会回应的!

未来,我确实想以几种方式进一步发展这个项目:

  • 实现服务器端渲染
    • 这将涉及到从视图树构建一个字符串,而不是一个 dom
    • 我们将不得不找到一种方法,将服务器生成的 HTML 映射到客户端生成的视图/依赖树上。这通常被称为 hydration。
  • 不同的渲染目标
    • 我们没有理由只从我们的视图树生成一个 dom。任何具有任何层次结构的 UI 都可以相当容易地使用这里实现的 react 内部机制
  • 用 swift 重新实现这个
  • 最近因为工作原因我不得不使用 UIKit,非常怀念 react。我的内心深处非常想将这个项目移植到 swift 上。
  • 与其创建 dom 元素,这个库将会创建 UIViews

希望你从阅读这篇文章中有所收获。如果你对 react 内部机制感兴趣,并且这篇文章对你来说有些许帮助,我推荐你继续前进,直接阅读 react 源代码。没有比 react 本身更好的地方来深入了解 react 了。