ReactDOM.createRoot

1,112 阅读5分钟

ReactDOM.createRoot

创建项目并打断点

首先先用 React 的 CLI(Create-React-App)创建一个初始项目。

image.png

然后尝试在入口文件: src/main.jsx 打debugger断点:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";

debugger;
ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

运行,然后就可以开始调试啦。

image.png

createRoot

接下来一步步点击 step into next function call,我们可以看到首先被调用的是 ReactDOM.createRoot()

进入到这个方法,我们可以看到如下的代码:

image.png

这段代码根据环境的不同导出不同的 createRoot 函数和 hydrateRoot 函数。

createRoot 为受支持的 container 创建一个 React root。这个 root 可以用 render 方法将一个 React element 渲染到 DOM 中。

hydrateRoot则和 createRoot 类似,除了它是用来给 由 ReactDOMServer 渲染 HTML 内容的 container 添加额外内容的(hydrate the container,我不知道咋翻译,暂时先这样理解)。React 会尝试给存在的标记(markup)添加事件监听器。

让我们尝试在源码中找到这个函数。我们先根据 CRA 中的import ReactDOM from "react-dom/client";找到 packages/react-dom/client.js 中的 createRoot:

import type {
  RootType,
  HydrateRootOptions,
  CreateRootOptions,
} from "./src/client/ReactDOMRoot";

import {
  createRoot as createRootImpl,
  hydrateRoot as hydrateRootImpl,
  __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED as Internals,
} from "./";

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions
): RootType {
  if (__DEV__) {
    Internals.usingClientEntryPoint = true;
  }
  try {
    return createRootImpl(container, options);
  } finally {
    if (__DEV__) {
      Internals.usingClientEntryPoint = false;
    }
  }
}

我们可以看到 client 中createRoot 的外层逻辑。

它接受两个参数:

  • container: 必填参数,是用于创建 React root 所在的 DOM container。我们可以看到受支持的 container 包含三个类型,Element、Document 和 DocumentFragment。
  • options: 可选参数,是 createRoot 的一些配置项。

如果是 development 模式,将 Internals.usingClientEntryPoint 变为 true,标识当前是从 react-dom/clint 中正确引入了 createRoot,这个后面会用到。然后返回 createRootImpl(container, options),最后恢复 Internals.usingClientEntryPointfalse

然后根据 createRootImpl 找到 packages/react-dom/index.js,该文件导出的 createRoot 来自 packages/react-dom/src/client/ReactDOM.js,相关部分代码如下:

import {
  createRoot as createRootImpl,
  hydrateRoot as hydrateRootImpl,
  isValidContainer,
} from "./ReactDOMRoot";

// ...

function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions
): RootType {
  if (__DEV__) {
    if (!Internals.usingClientEntryPoint && !__UMD__) {
      console.error(
        'You are importing createRoot from "react-dom" which is not supported. ' +
          'You should instead import it from "react-dom/client".'
      );
    }
  }
  return createRootImpl(container, options);
}

// ...

我们看到函数中的这个 if (__DEV__)块, 如果是开发模式,且 Internals.usingClientEntryPointfalse(暂时先不管__UMD__),就报错提示:从 react-dom 引入 createRoot 是不支持的,应该从 react-dom/client 引入。而生产模式就会返回 createRootImpl。所以前面在 packages/react-dom/client.js 中,将Internals.usingClientEntryPoint置为 true 就是为了实现这个目的。不过目前为止似乎看不出分别从 packages/react-dom/client.js 和 packages/react-dom/index.js 中引入 createRoot 有什么太大的区别,让我们继续看下去。

然后让我们来到 packages/react-dom/src/client/ReactDOMRoot.js 中的 createRoot,这一段的逻辑相对来说就比较多,让我们一块块看。

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions
): RootType {
  if (!isValidContainer(container)) {
    throw new Error("createRoot(...): Target container is not a DOM element.");
  }

  warnIfReactDOMContainerInDEV(container);

  //省略后面,我们先看前两块
  // ...
}

首先是 if (!isValidContainer(container)){},这个就是判断传入的是否是一个有效的 container,暂时不是很感兴趣,所以先忽略这一块。

然后让我们看看 warnIfReactDOMContainerInDEV(container)

function warnIfReactDOMContainerInDEV(container: any) {
  if (__DEV__) {
    if (
      container.nodeType === ELEMENT_NODE &&
      ((container: any): Element).tagName &&
      ((container: any): Element).tagName.toUpperCase() === "BODY"
    ) {
      console.error(
        "createRoot(): Creating roots directly with document.body is " +
          "discouraged, since its children are often manipulated by third-party " +
          "scripts and browser extensions. This may lead to subtle " +
          "reconciliation issues. Try using a container element created " +
          "for your app."
      );
    }
    if (isContainerMarkedAsRoot(container)) {
      if (container._reactRootContainer) {
        console.error(
          "You are calling ReactDOMClient.createRoot() on a container that was previously " +
            "passed to ReactDOM.render(). This is not supported."
        );
      } else {
        console.error(
          "You are calling ReactDOMClient.createRoot() on a container that " +
            "has already been passed to createRoot() before. Instead, call " +
            "root.render() on the existing root instead if you want to update it."
        );
      }
    }
  }
}

这一段主要就是在 DEV 模式的时候,三种和 container 相关的警告。

  1. 当传入的 container 是 <body/> 的时候

    index.html

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <link rel="icon" type="image/svg+xml" href="/vite.svg" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Vite + React</title>
      </head>
      <body id="root">
        <script type="module" src="/src/main.jsx"></script>
      </body>
    </html>
    

    /src/main.jsx

    import React from "react";
    import ReactDOM from "react-dom/client";
    import App from "./App";
    import "./index.css";
    
    ReactDOM.createRoot(document.getElementById("root")).render(
      <React.StrictMode>
        <App />
      </React.StrictMode>
    );
    
  2. ReactDOMClient.createRoot()传入了一个之前已经传给 ReactDOM.render()的 container。

  3. ReactDOMClient.createRoot()传入了一个之前已经传给 createRoot()的 container。

接下来这一段就是 options 参数配置相关的内容:

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions
): RootType {
  //...

  let isStrictMode = false;
  let concurrentUpdatesByDefaultOverride = false;
  let identifierPrefix = "";
  let onRecoverableError = defaultOnRecoverableError;
  let transitionCallbacks = null;

  if (options !== null && options !== undefined) {
    if (__DEV__) {
      if ((options: any).hydrate) {
        console.warn(
          "hydrate through createRoot is deprecated. Use ReactDOMClient.hydrateRoot(container, <App />) instead."
        );
      } else {
        if (
          typeof options === "object" &&
          options !== null &&
          (options: any).$$typeof === REACT_ELEMENT_TYPE
        ) {
          console.error(
            "You passed a JSX element to createRoot. You probably meant to " +
              "call root.render instead. " +
              "Example usage:\n\n" +
              "  let root = createRoot(domContainer);\n" +
              "  root.render(<App />);"
          );
        }
      }
    }
    if (options.unstable_strictMode === true) {
      isStrictMode = true;
    }
    if (
      allowConcurrentByDefault &&
      options.unstable_concurrentUpdatesByDefault === true
    ) {
      concurrentUpdatesByDefaultOverride = true;
    }
    if (options.identifierPrefix !== undefined) {
      identifierPrefix = options.identifierPrefix;
    }
    if (options.onRecoverableError !== undefined) {
      onRecoverableError = options.onRecoverableError;
    }
    if (options.unstable_transitionCallbacks !== undefined) {
      transitionCallbacks = options.unstable_transitionCallbacks;
    }
  }

  //...
}

这一段单独看感觉没有太大意义,需要结合后面具体场景再理解学习。

那么让我们看到这个方法的最后一段:

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions
): RootType {
  //...

  const root = createContainer(
    container,
    ConcurrentRoot,
    null,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onRecoverableError,
    transitionCallbacks
  );
  markContainerAsRoot(root.current, container);

  const rootContainerElement: Document | Element | DocumentFragment =
    container.nodeType === COMMENT_NODE
      ? (container.parentNode: any)
      : container;
  listenToAllSupportedEvents(rootContainerElement);

  return new ReactDOMRoot(root);
}

首先看到这个 createContainer,它来自于 react-reconciler/src/ReactFiberReconciler.js 中。这一部分就是和 reconciler 相关的内容了:

import { enableNewReconciler } from "shared/ReactFeatureFlags";

import {
  createContainer as createContainer_old,
  // ...
} from "./ReactFiberReconciler.old";
import {
  createContainer as createContainer_new,
  // ...
} from "./ReactFiberReconciler.new";

export const createContainer = enableNewReconciler
  ? createContainer_new
  : createContainer_old;

但我们可以看到,react-reconciler/src/ReactFiberReconciler.js 中通过 enableNewReconciler 区分出了 new Reconciler 和 old Reconciler。在 shared/ReactFeatureFlags.js 中,enableNewReconciler为 false,然后我发现全局搜索 enableNewReconciler 时,发现在 packages/shared/forks/ReactFeatureFlags.www.js 存在不一样的内容:

// ...

// Enable forked reconciler. Piggy-backing on the "variant" global so that we
// don't have to add another test dimension. The build system will compile this
// to the correct value.
export const enableNewReconciler = __VARIANT__;

//...

而这个__VARIANT__,我们可以在 scripts/rollup/build.js 找到:

// ...
return [
  // ...,
  replace({
    __DEV__: isProduction ? "false" : "true",
    __PROFILE__: isProfiling || !isProduction ? "true" : "false",
    __UMD__: isUMDBundle ? "true" : "false",
    "process.env.NODE_ENV": isProduction ? "'production'" : "'development'",
    __EXPERIMENTAL__,
    // Enable forked reconciler.
    // NOTE: I did not put much thought into how to configure this.
    __VARIANT__: bundle.enableNewReconciler === true,
  }),
  // ...
];

// ...

我理解是在 rollup 的构建过程中,会根据不同 bundle 的配置使用不同的 reconciler。我一直对 rollup 的构建很感兴趣,所以我准备先大致过一遍 在 React 中是如何使用 rollup 构建的,主要是搞清楚什么情况下会启用新的 reconciler。

先暂停这篇,学习 rollup 构建的记录另起一篇

衍生知识点

1. flow 的 explicit inexact object types/显示不确定对象类型

我们看 createRoot 接受的第二个参数 options 的类型定义:

export type CreateRootOptions = {
  unstable_strictMode?: boolean,
  unstable_concurrentUpdatesByDefault?: boolean,
  unstable_transitionCallbacks?: TransitionTracingCallbacks,
  identifierPrefix?: string,
  onRecoverableError?: (error: mixed) => void,
  ...
};

我们可以看到对象内部最后有一个...,它标识着:除了已经定义的几个属性的类型,还可以自由传入其他任意类型的任意属性。

默认状态下,对象的每个属性以及对应的类型都是确定的。所以当我们需要使用不确定的对象类型时,需要显示的去标识。