React:阅读代码

我非常害怕读我使用多年的框架的代码,我一直把 React 看作一个黑盒子,它接收 JSX,然后把相应内容展示在屏幕上。

我对虚拟 DOM 和协调算法有一定了解,至少了解相关理论。但直到今年,我的好奇心才促使我以阅读代码为目的打开了 React 资源库。

在阅读本文之前,我必须提醒大家,本文并不是一篇关于 React 在钩子下如何工作的详尽概述,而且,本文的一些假设可能是错误的。

我关注的是代码库的设计,读了本文,大家会了解 React 核心团队将哪些实践和模式应用于这个热门软件,希望我们能从中获得启发。


单一代码库(Monorepo)


之前有一次,我打开资源库,想找一个 `src` 目录,但看到了一个名为 `packages` 的目录,我立即就明白这是一个 monorepo,monorepo 是一个承载了多个不同应用程序或库的单一代码库。

React repo 拥有 30 多个包,包括 `react`、`react-dom`、`react-server`和 `react-devtools` 以及许多我不知道用途的包。

对于由多个独立部分组成的大型项目,React repo 的本地设置更容易,而且它的代码的重复使用性更好,这是 React repo 的主要优势,而这会增加代码部署和代码库整体的复杂性。

我一直没有机会说我不喜欢单一代码库的原因,这次我打算在本文一吐为快。

在过去的三年里,我使用了多个单一代码库设置,我的主要困扰在于它让大型重构变得太简单了。当数据被分割在不同的存储库中时,我们就被迫逐渐地执行改变,并且要在设计中投入更多的心血。

我发现使用 monorepo 会让高效工作流程更难实现,因为我们的本地环境与生产环境不同,我被这种事坑过太多次了。

但这并不是对 React 的批评,我相信维护人员已经找到了解决办法。


从哪儿开始?


阅读不熟悉的代码是很难的,尤其是如果没有人指导你阅读,就难上加难。因此,我们必须找到理解代码库的突破点,在此基础上理解,并注意在此过程中发现的模式。

无论任何软件,我都总是从它的其中一个公共 API 开始。

那么什么是 React 的公共 API 呢?像 `useState` 和 `useEffect` 这样的钩子就属于公共 API。但如果我们从 `useState` 和 `useEffect` 开始,我们就几乎无法理解当前在做什么,因为我们没有组件或渲染的上下文。

因此,我们会从 React 的一个公共方法入手,这个方法只在每个应用程序中被调用一次。

import ReactDOM from 'react-dom'

const root = ReactDOM.createRoot(container)
root.render(element)

这就是将应用程序从 React 18 挂载到 DOM 的语法。通过阅读这些函数的执行并追踪其操作,我们就可以开始循序渐进地阅读代码库了。

你可能已经注意到,我们将要探索的函数并不是核心库的一部分。它是 `react-dom` 的一部分,代表了 React 的浏览器绑定,并将其连接到 DOM。这让我意识到,核心库是以一种不可知论的方式编写的,因此可以在不同环境中使用。


创建根


`createRoot` 函数只封装了一个内部函数,并进行了一些简单的验证。作者们没有直接设置这些功能,而是将验证逻辑分开,将创建根实例的函数留在一个单独的文件中,这个文件定义了附加到实际根对象的方法。

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)
}

内部函数的名字相同,后面都有 Impl 的后缀。我本想给它起一个更具体的名字,比如 `createRootInstance` 或 `createRootEntity`,而不是通过这种模式来表明它是一个私有函数。

值得注意的是,这个私有函数的名字一样,但我用别名导入,避免名称冲突。一般来说,我不赞成将函数命名成相同的名字,除非是执行一些多态行为。

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

  // ...

  const root = createContainer(
    container,
    ConcurrentRoot,
    null,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onRecoverableError,
    transitionCallbacks
  )

  // ...

  return new ReactDOMRoot(root)
}

创建根函数做的第一件事是验证容器并提前退出,这就是所谓的卫语句,它可以帮助我们避免多层嵌套以及更复杂的条件语句。

这个函数大约有 80 行,但我省略了细节,这样可以把注意力放在最重要的部分。容器与 react 的协调器建立了连接。值得注意的是这个设计既使用像 `createContainer` 这样的工厂函数,也使用 `new` 关键字来创建 `ReactDOMRoot`。

如果我们看一下 `ReactDOMRoot` 函数,就会发现它只是将一个值绑定在 `this`。

function ReactDOMRoot(internalRoot: FiberRoot) {
  this._internalRoot = internalRoot
}

它的方法没有内联定义,而是附在 `ReactDomRoot.prototype` 上,从而使其对该函数的所有实例都适用。值得注意的是这个例子的多重赋值——将函数同时附加到 `ReactDOMRoot` 和 `ReactDOMHydrationRoot` 上。

ReactDOMHydrationRoot.prototype.render =
  ReactDOMRoot.prototype.render = function (
    children: ReactNodeList
  ): void {
    const root = this._internalRoot

    if (root === null) {
      throw new Error('Cannot update an unmounted root.')
    }

    if (__DEV__) {
      // ...
    }

    updateContainer(children, root, null, null)
  }

我不经常使用原型,我通常依靠工厂函数和闭包,但使用原型有好处,大家可以使用 `instanceof` 运算符来检查对象的类型。

使用原型可以提高性能,这可能是使用原型的主要原因。我们使用闭包时会产生了一些间接费用,所以基于原型的分类更快。附着在原型上的所有函数都只创建一次,而闭包则不然。


连接到协调器


容器是一个内部值,会在我们最初创建 `ReactDOMRoot` 时创建。我决定进一步跟踪 `updateContainer` 方法,尽管它是协调器的一部分。

export const updateContainer = enableNewReconciler
  ? updateContainer_new
  : updateContainer_old

这可能是我第一次看到使用条件导出。协调器中的所有导出值都是在 `enableNewReconciler` 功能开关的基础上以此种方式查看的。在正常的软件开发中,我们常用功能开关逐步部署或进行 A/B 测试,但功能开关也可以用来选择进入或退出同一个功能的不同版本。

不同的实现保存在名称一样的文件中,区别在于 `.new.js` 或 `.old.js` 后缀。尽管我在实现具体函数的过程中有点迷茫,但代码本身是可读的,所有的函数都有描述性的名称。

但我缺少协调算法的工作原理的上下文,无法更好的理解它。

export function updateContainer(
  element: ReactNodeList,
  container: OpaqueRoot,
  parentComponent: ?React$Component<any, any>,
  callback: ?Function
): Lane {
  // ...

  const eventTime = requestEventTime()
  const lane = requestUpdateLane(current)

  if (enableSchedulingProfiler) {
    markRenderScheduled(lane)
  }

  // ...

  const update = createUpdate(eventTime, lane)
  // Caution: React DevTools currently depends on this property
  // being called "element".
  update.payload = { element }

  // ...

  const root = enqueueUpdate(current, update, lane)

  // ...

  return lane
}

我只知道该函数需要排队等待下一个组件树的更新,但请注意 `createUpdate` 调用下面的注释,对于这种原因不太明显的操作来说,这是一个有价值的注释。

我们对协调器的研究就到此为止。在研究渲染器之前,了解 React 对其组件使用的内部结构很重要。


什么是组件?


如果大家看了 `ReactDOM.protoype.render` 的签名,就会知道它需要一个组件,好在 React 没有让用户直接使用内部对象表示,而是利用 JSX 中的一个方便使用的类似 HTML 的语法,让用户像其他网页一样描述组件。

然后,在应用程序运行之前,这个 JSX 被转译为创建对象的常规函数来调用。

每个 JSX 元素都可以用对 `React.createElement` 的调用来代替。我们可以直接用这个函数来描述 UI,只是会比替代语法难得多,所以我们的渲染方法将接收对上述函数的调用结果。

export function createElement(type, config, children) {
  let propName

  // Reserved names are extracted
  const props = {}

  let key = null
  let ref = null
  let self = null
  let source = null

  if (config != null) {
    // ...

    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName]
      }
    }
  }

  const childrenLength = arguments.length - 2
  if (childrenLength === 1) {
    props.children = children
  } else if (childrenLength > 1) {
    // ...
  }

  // ...

  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props
  )
}

但从 React 17 开始,JsX 不会自动转译到 `React.createElement`。在这次更新前, 每次使用 JSX 都必须在 React 的范围内转译。

但这对于不了解 JSX 工作原理的工程师来说不直观。这次更新后构建工具可以使用另一个不附在 React 对象上的函数,其实现与 `React.createElement` 非常相似。

export function jsx(type, config, maybeKey) {
  let propName

  // Reserved names are extracted
  const props = {}

  let key = null
  let ref = null

  // ...

  for (propName in config) {
    if (
      hasOwnProperty.call(config, propName) &&
      !RESERVED_PROPS.hasOwnProperty(propName)
    ) {
      props[propName] = config[propName]
    }
  }

  // ...

  return ReactElement(
    type,
    key,
    ref,
    undefined,
    undefined,
    ReactCurrentOwner.current,
    props
  )
}

最后,它们都委托给了一个叫作 `ReactElement` 的工厂函数。它们完全相同,但作者决定复制它们,这就引出一个重要问题:什么时候提取共同的函数?

常识告诉我们,要杜绝重复的出现。但似乎对于整洁代码的实用建议就是在创建共同抽象之前等待并复制。重复代码虽然管理起来很烦人,但也不难管理,不过,错误的抽象往往会让代码变得很复杂。

const ReactElement = function (
  type,
  key,
  ref,
  self,
  source,
  owner,
  props
) {
  const element = {
    $typeof: REACT_ELEMENT_TYPE,
    type: type,
    key: key,
    ref: ref,
    props: props,
    _owner: owner,
  }

  if (__DEV__) {
    element._store = {}

    Object.defineProperty(element._store, 'validated', {
      configurable: false,
      enumerable: false,
      writable: true,
      value: false,
    })

    Object.defineProperty(element, '_self', {
      configurable: false,
      enumerable: false,
      writable: false,
      value: self,
    })

    Object.defineProperty(element, '_source', {
      configurable: false,
      enumerable: false,
      writable: false,
      value: source,
    })

    // ...
  }

  return element
}

这个方法中没有太多很出彩的部分,只是为组件对象的属性分配适当的值。不过,要注意 `defineProperty` 方法在开发中的用法。

它能让你更精细地控制对象上的每个属性的行为。

指定writable:false 意味着它不能被重新分配或删除。`enumerable` 属性定义了该值是否会出现在 `for...in` 循环中,或是否会在 `Object.keys` 被调用时出现。而configurable值指定了这些选项是否可以更改。

我的日常工作中还不需要对一个对象的属性执行这样的控制,但这是让资源库创建只在开发中使用的内部属性的好方法,而且可以防止这些属性出现在不希望的地方。

`REACT_ELEMENT_TYPE` 常量实际上是一个Symbol,用来标记对象是组件类型。我其实没有使用过符号,但这似乎是一个非常好的用例。由于他们调用的是工厂函数,没有使用 new 关键字,所以用 instanceof 进行的检查不会起作用。

我的第一个想法是,这与 `ReactDOMRoot` 的创建方式明显不一致。令我惊讶的是, `ReactElement` 函数上方的一个评论解释了具体原因。一致性非常重要,一旦破坏了一致性,一定要解释原因。


渲染器和协调器的交互


我们知道渲染器是如何被创建的,以及 React 对其组件使用了什么内部表示。但要了解元素是如何在屏幕上显示的,我们需要阅读协调器的文档。

协调器有差异算法,在 React 的声明式语法下进行必要的工作,它会找出变化并让渲染器显示它们。渲染器必须定义一些特定的方法来处理组件的渲染,但它不知道何时使用这些方法。

这些方法只能被协调器调用。

我们不会立即协调,即使我们很想了解其中的奥秘,但在渲染方面,我们道阻且长。我阅读了协调器的 README,里面详细介绍了它与渲染器的交互方式。

从本质上讲,每个渲染器都必须有特定的接口。它必须提供一些方法和属性,而这些方法和属性是协调器所依赖的方法和属性。这意味着,只要我们拥有需要的方法,就可以构建自己的渲染器,并在控制台而非浏览器中显示组件。

const Reconciler = require('react-reconciler')

const HostConfig = {
  createInstance(type, props) {
    // e.g. DOM renderer returns a DOM node
  },
  // ...
  supportsMutation: true, // it works by mutating nodes
  appendChild(parent, child) {
    // e.g. DOM renderer would call .appendChild() here
  },
  // ...
}

const MyRenderer = Reconciler(HostConfig)

const RendererPublicAPI = {
  render(element, container, callback) {
    // Call MyRenderer.updateContainer() to schedule changes on the roots.
    // See ReactDOM, React Native, or React ART for practical examples.
  },
}

module.exports = RendererPublicAPI

用来提供这个接口的名字是HostConfig。我理解他们这样命名是因为渲染器要将 React 连接到主机环境,但当我读到配置这个词时,我想象的是与环境变量相关的东西。

命名是计算机科学中最难的事情之一,我不确定我对命名的偏好是否会让情况有所改善。


渲染器方法


大家可以在这里看到所有的渲染器方法,协调器会在特定时间调用这些方法。

渲染器非常复杂,我们不会介绍每个功能,只关注在屏幕上显示内容的主要功能,以便解开渲染工作的难题。

需要注意的是,尽管我们可以创建自己的自定义渲染器,但 `react-reconciler` API 并不遵循稳定性保证,因此,它需要的调整要比渲染器或核心的调整频繁得多。

我们调用 `createInstance` 函数来渲染每个组件的视觉表现。在这种情况下,它会准备 DOM 元素,但理论上讲,渲染器可以在屏幕上绘制任何东西。

export function createInstance(
  type: string,
  props: Props,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object
): Instance {
  let parentNamespace: string

  if (__DEV__) {
    // ...
  } else {
    parentNamespace = hostContext
  }

  const domElement: Instance = createElement(
    type,
    props,
    rootContainerInstance,
    parentNamespace
  )

  //...

  return domElement
}

本质上,这个函数是在做条件赋值,它使用一个工厂函数创建了一个 DOM 元素。话说回来,我本想在条件赋值之前给 `parentNamespace` 赋值,以避免出现 `else` 语句。

`createTextInstance` 函数对纯文本节点也有同样的作用。

export function createTextInstance(
  text: string,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object
): TextInstance {
  if (__DEV__) {
    const hostContextDev = hostContext
    validateDOMNesting(null, text, hostContextDev.ancestorInfo)
  }
  const textNode: TextInstance = createTextNode(
    text,
    rootContainerInstance
  )

  precacheFiberNode(internalInstanceHandle, textNode)
  return textNode
}

它使用的模式与我们看到的非常相似,在开发模式下进行任何必要的验证,然后将创建委托给另一个工厂函数。

这两个函数负责创建适当的元素,然后将其添加到 DOM 中,但我们还不了解这到底是如何发生的。`appendInitialChild` 和 `appendChild` 函数负责这个工作。

export function appendChild(
  parentInstance: Instance,
  child: Instance | TextInstance
): void {
  parentInstance.appendChild(child)
}

总结


至此,我们结束了对 React 渲染过程的初步概述。我们了解了渲染器是如何工作的,以及它们执行了哪些方法,知道了它们是如何连接到协调器,以及组件在内部是如何表示的。

同时,我们看到了 React 核心团队在代码库中应用了哪些设计实践,最重要的是,我们认识到阅读一个库的实现并没有那么可怕!!!



原文作者:Alex Kondov
原文链接:https://alexkondov.com/readint-source-code-react/


推荐阅读
相关专栏
前端与跨平台
90 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。