5

在使用 React 时,我们会引用 react 和 react-dom 。而在 react-dom 中依赖 react-reconciler。那么三者各自负责什么部分,又有什么联系呢?

注:本文源码依据 React 16.14 版本。

React 和 ReactDOM 各自负责什么?

react 负责描述特性,提供React API。

类组件、函数组件、hooks、contexts、refs...这些都是React特性,而 react 模块只描述特性长什么样、该怎么用,并不负责特性的具体实现。

react-dom 负责实现特性。

react-dom、react-native 称为渲染器,负责在不同的宿主载体上实现特性,达到与描述相对应的真实效果。比如在浏览器上,渲染出DOM树、响应点击事件等。

ReactDOM.render 的输入—— ReactElement

import React from 'react';
import ReactDOM from "./ReactDOM";
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

上面是一段常见的 React 代码。在项目的入口,人为显示地调用ReactDOM.renderReactDOM.render 接受 “根组件实例”和“挂载节点”,然后进行内部逻辑转换,最终将DOM树渲染到挂载节点上。

那么,ReactDOM.render拿到的 “根组件实例” 具体是什么?
组件实例其实是一个对象,以children属性关联组件的父子关系。由React.createElement函数创建。

ReactElement 是 React.createElement 的输出,ReactDOM.render的输入,是 react 和 react-dom 之间最直观的联系。那么,我们来扒一扒这个数据结构。

我们一般会用JSX来描述组件结构,JSX本质上是一种语法扩展,通过Babel编译最终生成下面的语句:

React.createElement(
  type,
  [props],
  [...children]
)

JSX最终将对组件的描述转换为对React.createElement的调用。React.createElement做了什么?
React.createElement接受typepropschildren,然后进行一些操作:

  • 处理props,从props中提取出keyref
  • 处理children,将children以单体或者数组的形式附加到props
  • 返回一个符合 ReactElement 数据结构的对象

如果用TypeScript简单描述 ReactElement 数据结构,它长这样👇

interface ReactElement {
  $$typeof: Symbol | number; // 标识该对象是React元素,REACT_ELEMENT_TYPE = symbolFor('react.element') || 0xeac7,用Symbol获得一个全局唯一值
  type: string | ReactComponent | ReactFragment
  key: string | null
  ref: null  | string | object
  props: {
    [propsName: string]: any
      children?: ReactElement | Array<ReactElement>
  },
  _owner: {
    current:  null | Fiber
  }
}

下面这段实例代码,直接输出组件实例对象,可以更加直观了解

import React from "react";
import "./styles.css";

export default function App() {
  return (
    <div className="App">
      <Heading />
      <SubHeading className="secondary"/>
    </div>
  );
}

function Heading() {
  return <h1>Hello CodeSandbox</h1>;
}

function SubHeading() {
  return <h2>Start editing to see some magic happen!</h2>;
}
console.log(<App />);

// Output
{
  type: function App() {}
  key: null
  ref: null
  props: {}
  _owner: null
}
console.log(<App />.type());
// Output
{
  type: "div"
  key: null
  ref: null
  props: {}
  className: "App"
  children: [
    {
      type: function Heading() {}
      key: null
      ref: null
      props: {}
      _owner: null
      _store: {}
    },
    {
      type: function SubHeading() {}
      key: null
      ref: null
      props: {
        className: "secondary"
      }
      _owner: null
    }
  ],
  _owner: null
}

只调用了一次ReactDOM.render,如何实现状态响应?

既然负责实现特性的是 react-dom,那么在没有人为调用的情况下,react 中的 setState 和 hooks 是怎么触发状态响应、视图更新的呢?

首先描述结论。

通过 setState、hooks 特性去修改组件状态时,其实是直接调用了渲染器里的方法。那么渲染器里的方法是如何注入到特性中的呢?

在创建类组件实例时,ReactDOM 会设置实例的 updater 属性,在 setState 时实质上是调用 updater.enqueueSetState

在生成函数组件之前,ReactDOM 用自己的 hooks 实现设置 dispatcher,在调用 useState 时实质上是调用 dispatcher.current.useState

接下来,我们来探索向类组件和函数组件注入更新器的过程。

类组件

首先,react 定义了 Component 类的属性和方法。从 Component 定义里看,有一个 updater 实例属性。

image

在 setState 中,调用 this.updater.enqueueSetState 进行有效操作。
image

那么 updater 是在何处设置的呢?

创建类组件实例,执行的是 react-reconciler/src/ReactFiberClassComponent.old.js 文件中的constructClassInstance函数。函数中相关的逻辑梳理如下:

  1. 创建类组件实例 instance
  2. 设置instance.updaterclassComponentUpdater对象
  3. instance挂载到 workInProgress(实例对应的Fiber节点) 的stateNode属性上
  4. 设置instance._reactInternals为 workInProgress
function constructClassInstance(
  workInProgress: Fiber,
  ctor: any,
  props: any,
): any {
  const instance = new ctor(props, context);
  const state = (workInProgress.memoizedState =
    instance.state !== null && instance.state !== undefined
      ? instance.state
      : null);
  adoptClassInstance(workInProgress, instance);
}

function adoptClassInstance(workInProgress: Fiber, instance: any): void {
  instance.updater = classComponentUpdater;
  workInProgress.stateNode = instance;
  // The instance needs access to the fiber so that it can schedule updates
  setInstance(instance, workInProgress);
}

const classComponentUpdater = {
  isMounted,
  enqueueSetState(inst, payload, callback) {
    const fiber = getInstance(inst);
    const eventTime = requestEventTime();
    const lane = requestUpdateLane(fiber);

    const update = createUpdate(eventTime, lane);
    update.payload = payload;
    if (callback !== undefined && callback !== null) {
      update.callback = callback;
    }

    enqueueUpdate(fiber, update);
    scheduleUpdateOnFiber(fiber, lane, eventTime);
  },
  enqueueReplaceState(inst, payload, callback) {},
  enqueueForceUpdate(inst, callback) {}
};

通过上述过程,在实例化组件阶段设置组件实例的updater

函数组件

// module: react/src/React.js
export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

// module: react/src/ReactHooks.js
import ReactCurrentDispatcher from './ReactCurrentDispatcher';
function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  return dispatcher;
}

从上面的代码分析useState本质上是执行ReactCurrentDispatcher.current.useState

那么这里的ReactCurrentDispatcher.current是在何处设置的呢?

生成函数组件实例的过程中,react-dom 执行 react-conciler/src/ReactFiberHooks.old.js 文件中的renderWithHooks函数。在创建实例前,对ReactCurrentDispatcher.current进行设置。如下述代码所示。

import ReactSharedInternals from 'shared/ReactSharedInternals';
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
    ? HooksDispatcherOnMount
  : HooksDispatcherOnUpdate;

  let children = Component(props, secondArg);

  // We can assume the previous dispatcher is always this one, since we set it
  // at the beginning of the render phase and there's no re-entrancy.
  ReactCurrentDispatcher.current = ContextOnlyDispatcher;

  return children;
}

这里的ReactCurrentDispatcher来自于shared/ReactSharedInternals.js模块,但 react 中的调用来自于react/src/ReactCurrentDispatcher.js模块,并且两者并没有直接依赖。那么它们是怎么联系在一起的?

奥秘在于rollup。

image

在rollup打包时,通过useForks插件进行路径映射。上述代码在打包 react 时,将shared/ReactSharedInternals路径映射到react/src/ReactSharedInternals模块。
注:useForks插件定义在scripts/rollup/plugins/use-forks-plugin.js文件
注:路径映射配置在scripts/rollup/forks.js文件,上述代码截图来自于此

react/src/ReactSharedInternals中,react/src/ReactCurrentDispatcher作为接口属性导出。

import ReactCurrentDispatcher from './ReactCurrentDispatcher';
const ReactSharedInternals = {
  ReactCurrentDispatcher
};

export default ReactSharedInternals;

总结起来就是:

  • shared/ReactSharedInternals 通过rollup映射到react/src/ReactSharedInternals模块;
  • react/src/ReactSharedInternals 模块中导出 react/src/ReactCurrentDispatcher
  • 赋值ReactCurrentDispatcher.current,其实是修改了引用对象的属性,从而达到互通效果。

react-dom 和 react-reconciler 的分工

不如试着跟随Hello World Custom React Renderer自制一个 react-dom 吧。动手实现代码后,会有直观感受。

react-dom 负责DOM实现(调用载体API创建、插入、删除);具体的命令则是由 react-reconciler 给出。

react-dom 提供行为的具体实现,将其集合在hostConfig对象中,传给 react-reconciler。

“行为的具体实现”,这个描述或许有些抽象。举个具体的例子来说,比如 react-reconciler 需要的appendChildToContainer行为,在DOM上的具体实现是调用element.appendChild方法。

在 react-dom 的源码中并没有显式初始化 react-reconciler ,它是如何向 react-reconciler 传递 hostConfig 的呢?同样是通过 rollup 的路径映射实现的。

具体的操作是,将'react-reconciler/src/ReactFiberHostConfig'路径映射为当前打包情景对应的 hostConfig 模块。

比如,当前打包情景是打包 react-dom ,那么就映射到'react-reconciler/src/forks/ReactFiberHostConfig.dom.js'模块,该模块中导入导出 react-dom/src/client/ReactDOMHostConfig模块 —— 真正的 hostConfig 文件。

image

除了负责DOM实现,react-dom还做了什么?

我们来看一看 ReactDOM.render 逻辑:

  • 创建了一个 ReactDOMBlockingRoot 类型的实例 root,记录到挂载节点的 _reactRootContainer 属性上,往后根据这个属性判断是否已有 React 应用挂载。
  • root 实例的 _internalRoot 属性记录由 react-reconciler createContainer函数创建的 FiberRoot
  • 调用 react-reconciler updateContainer ,传入 FiberRoot 和 ReactElement
  • 进入 react-reconciler 的掌控范围,生成 Fiber 树,遍历优化,生成组件实例/原生节点,渲染到挂载节点上。

用代码大体描述上述过程:

class ReactDOMBlockingRoot {
    _internalRoot: FiberRoot,
    render () {
        updateContainer()
    }
    unmount () {
        updateContainer(null, root, null, () => {
          unmarkContainerAsRoot(container);
        });
    }
}

function legacyRenderSubtreeIntoContainer(
  parentComponent: ?React$Component<any, any>,
  children: ReactNodeList,
  container: Container,
  forceHydrate: boolean,
  callback: ?Function,
) {
    const root = container._reactRootContainer = new ReactDOMBlockingRoot(container, LegacyRoot, options)
    fiberRoot = root._internalRoot;
    unbatchedUpdates(() => {
      updateContainer(children, fiberRoot, parentComponent, callback);
    });
}

总结

在这篇文章中,我们了解到 react 和 react-dom 各自的职责:react 负责描述特性,react-dom 负责实现特性。

同时,研究了 ReactDOM.render 接收的参数,也是React.createElement的返回值 —— ReactElement,揭开它的庐山真面目:一个对象,包含type,props,key,ref属性,通过 children 属性描述父子关系。

既然负责实现特性的是 react-dom,那么在没有人为调用的情况下,react 中的 setState 和 hooks 是怎么触发状态响应、视图更新的呢?于是我们探究 react-dom 对类组件、函数组件的更新器注入。

  • 在创建类组件实例阶段,react-dom 设置updatersetState时调用的是updaterenqueueSetState方法;
  • 在创建函数组件前,react-dom 覆盖了ReactCurrentDispatchercurrent。创建函数组件时,调用的是 react-dom 中定义的 hooks 实现。

在 react-dom 的源码中,我们经常见到 react-reconciler,他们两者的关系是?react-reconciler 负责生成Fiber树、协调和调度、产生操作指令。而 react-dom 负责调用DOM API,将操作指令实施到DOM树上,可以将 react-dom 类比为 react-reconciler 和 DOM 之间的翻译器。

在探索过程中,还发现React项目对 rollup 路径映射的运用,使其能够应对不同打包场景,避免代码衔接处理。


程然然然然然
59 声望4 粉丝