React 框架 | React-Dom 原理浅析之 DOM 节点生成

1,997 阅读11分钟

React 框架 | React-Dom 原理浅析之 DOM 节点生成

关键词:react react-dom react-reconciler scheduler

这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

背景

工作中一直有在应用 React 相关的技术栈,但却一直没有花时间好好思考一下其底层的运行逻辑,碰巧身边的小伙伴们也有类似的打算,所以决定组团卷一波,对 React 本身探个究竟。

本文是基于众多的源码分析文章,加入自己的理解,然后输出的一篇知识梳理。如果你也感兴趣,建议多看看参考资料中的诸多引用文章,相信你也会有不一样的收获。

本文不会详细说明 React 中 react-reconciler 、 scheduler 、fiber 、dom diff、lane 等知识,仅针对 DOM 节点如何生成这一细节进行剖析。

目录

  • 本文目标
  • React 架构
  • react-dom 背景介绍
  • react-dom 使用说明
  • react-dom 源码调用链
  • react-dom DOM生成
  • 版本对比(v15.6.2 / v16.14.0 / v17.0.2)

本文目标

我们使用 React 全家桶,调用相关的 API 、生命周期等等,实现了复杂的各种业务逻辑,最后抵达用户眼前的 UI 界面,以及相应用户的各种 I/O 操作,对应的 UI 界面也会动态更新。

所以,React 是如何将一系列对外暴露的 API 转化为浏览器页面的 DOM 元素,其中 DOM 元素的生成过程,其实是一个很有意思的点,这也将是本文分析的核心目标。希望能够带领大家看一看这个 DOM 生成的一个过程。

简单一句话,本文的目标:React 的最后一步,是调用了什么方法产生出了真实 DOM 元素。

React 架构

  • 完整的架构图(来源于:7kms) 图1-1.jpg
  • 我理解的简版架构图(主要用于本文 react-dom 的分析)

图1-2.jpg

react-dom 背景介绍

React 在 v0.14 之前是没有 ReactDOM 的,所有功能都包含在 React 里。从 v0.14(2015-10) 开始,React 才被拆分成 React 和 ReactDOM。为什么要把 React 和 ReactDOM 分开呢?因为有了 ReactNative。React 只包含了 Web 和 Mobile 通用的核心部分,负责 Dom 操作的分到 ReactDOM 中,负责 Mobile 的包含在 ReactNative 中。

ReactDom 只做和浏览器或DOM相关的操作,例如:ReactDOM.render() 和 ReactDOM.findDOMNode()。如果是服务器端渲染,可以 ReactDOM.renderToString()。React 不仅能通过 ReactDOM 和 Web 页面打交道,还能用在服务器端 SSR,移动端 ReactNative 和桌面端 Electron。

react-dom 使用说明

This package serves as the entry point to the DOM and server renderers for React. It is intended to be paired with the generic React package, which is shipped as react to npm.

在 react 中,react-dom 主要有2个作用,一个是作为整个 react 的启动入口,一个是最终的 DOM 渲染出口。本文主要介绍 client 及浏览器环境下的渲染,暂不考虑 SSR 的服务端渲染,感兴趣的同学可以参考源码的 server 部分。

  • react-dom 作为入口:通过 ReactDOM.render 方法调用,示例如下
// 来自 github 中源码位置:/react/packages/react-dom/README.md
var React = require('react');
var ReactDOM = require('react-dom');

function MyComponent() {
  return <div>Hello World</div>;
}

ReactDOM.render(<MyComponent />, node);
  • react-dom 作为出口
    • 使用 react-reconciler 过程中产生的 fiber 树(此处认为是最终需要渲染的树,暂不考虑整个 diff 的过程),产生最终的 DOM,调用链可以参考下一节的“react-dom 生成 DOM 的调用链”
    • react-dom 与 react-reconciler 是强耦合关系,从源码中可以看出你中有我,我中有你的相互调用过程
  • react-dom API
    • 对于普通用户的 API,在源码的 READEM.md 及官方文档中暴露的API:
      • render:最常用的方法,整个 react 项目的执行入口
      • findDOMNode:如果组件已经被挂载到 DOM 上,此方法会返回浏览器中相应的原生 DOM 元素。此方法对于从 DOM 中读取值很有用,例如获取表单字段的值或者执行 DOM 检测(performing DOM measurements)。大多数情况下,你可以绑定一个 ref 到 DOM 节点上,可以完全避免使用 findDOMNode。
        • findDOMNode 是一个访问底层 DOM 节点的应急方案(escape hatch)。在大多数情况下,不推荐使用该方法,因为它会破坏组件的抽象结构。严格模式下该方法已弃用。
        • findDOMNode 只在已挂载的组件上可用(即,已经放置在 DOM 中的组件)。如果你尝试调用未挂载的组件(例如在一个还未创建的组件上调用 render() 中的 findDOMNode())将会引发异常。
        • findDOMNode 不能用于函数组件。
      • unmountComponentAtNode:从 DOM 中卸载组件,会将其事件处理器(event handlers)和 state 一并清除。如果指定容器上没有对应已挂载的组件,这个函数什么也不会做。如果组件被移除将会返回 true,如果没有组件可被移除将会返回 false。
    • 从源码中可以看到其 export 的所有方法,其中除了用户调用的几个方法之外,更多的是为了方便测试 case 调用
    // Export all exports so that they're available in tests.
    // We can't use export * from in Flow for some reason.
    export {
      createPortal,
      unstable_batchedUpdates,
      flushSync,
      __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
      version,
      findDOMNode,
      hydrate,
      render,
      unmountComponentAtNode,
      createRoot,
      createRoot as unstable_createRoot,
      createBlockingRoot,
      createBlockingRoot as unstable_createBlockingRoot,
      unstable_flushControlled,
      unstable_scheduleHydration,
      unstable_runWithPriority,
      unstable_renderSubtreeIntoContainer,
      unstable_createPortal,
      unstable_createEventHandle,
      unstable_isNewReconciler,
    } from './src/client/ReactDOM';
    

react-dom 源码调用链

react-dom 中,首次渲染 DOM 的入口为 ReactDOM.render 方法。在后续 DOM 更新时,能够触发 DOM 变化的入口为 forceUpdate 、setState、useState、useReducer 等方法。这些应用上层的方法,会通过 update 相关的对象来连接 react-reconciler 、scheduler 两个大流程,然后产出可渲染的 fiber 树结构,用于 react-dom 渲染 DOM。

需要注意的是,当前 v17.0.2 版本的仅对外暴露了 Legacy 的 DOM 渲染模式,所以可以看到 react-dom 的代码文件中,引入的 render() 方法来自于 ReactDOMLegacy.js 文件。

  • 调用链 Part 1

图2-1.jpg

  • 调用链 Part 2

图2-2.jpg

DOM 生成的源码

  • DOM 的调用方法
    • 包括了创建(插入):对应的就是 commitPlacement
    • 更新(修改属性):commitWork
    • 删除:commitDeletion
    • 注意事项:
      • 从源码中可以看出是上述3个方法是在 ReactFiberWorkLoop.old.js 文件中调用
      • 方法的实现是在 ReactFiberCommitWork.old.js 文件中,后续可能会都迁移至此文件中
      • 但是,commitPlacement、commitWork、commitDeletion 再往下的 DOM 操作,并不是在 ReactFiberHostConfig.js 文件实现,却从此文件中引入,代码示例如下:
      import {
        ... // 省略部分代码
        appendChild,
        appendChildToContainer,
        insertBefore,
        insertInContainerBefore,
        removeChild,
        removeChildFromContainer,
        ... // 省略部分代码
      } from './ReactFiberHostConfig';
      // 要注意的是,ReactFiberHostConfig.js 是与 ReactFiberCommitWork.js 在相同目录下,但是打开 ReactFiberHostConfig.js ,内容如下:
      import invariant from 'shared/invariant';
      
      invariant(false, 'This module must be shimmed by a specific renderer.');
      
      // 可以看到并没有上述的 import 的内容,而真正的内容是在  packages/react-dom/src/client/ReactDOMHostConfig.js 目录中
      // ReactFiberHostConfig.js 与 ReactDOMHostConfig.js 的内容是通过 rollup 打包时关联上的,这在阅读源码是要注意。
      // 感兴趣的话,可以在 scripts/rollup/forks.js 文件中进行 debug 查看具体实现
      
  • react 中对 DOM 做了一层封装
    • DOM 节点的生成,其实可以看出来 React 中是直接引用的 Element 的原生浏览器支持的对象
    // 源码地址:ReactDOMHostConfig.js 文件部分声明如下
    // 其中 Element 、Document、Text、Comment 对象都是浏览器支持的原生 BOM 对象
    export type Container = Element | Document; 
    export type Instance = Element; 
    export type TextInstance = Text;
    export type SuspenseInstance = Comment;
    export type HydratableInstance = Instance | TextInstance | SuspenseInstance;
    export type PublicInstance = Element | Text;
    
    • className 驼峰的属性
    // 源码地址:packages/react-dom/src/shared/DOMProperty.js
    // react 底层会在对其自定义的驼峰属性,做处理如下:
    // A few React string attributes have a different name.
    // This is a mapping from React prop names to the attribute names.
    [
      ['acceptCharset', 'accept-charset'],
      ['className', 'class'],
      ['htmlFor', 'for'],
      ['httpEquiv', 'http-equiv'],
    ].forEach(([name, attributeName]) => {
      properties[name] = new PropertyInfoRecord(
        name,
        STRING,
        false, // mustUseProperty
        attributeName, // attributeName
        null, // attributeNamespace
      );
    });
    
    • dangerouslySetInnerHTML 的属性如何实现
    // 源码地址:packages/react-dom/src/client/setInnerHTML.js
    // 可以看出,起本质还是 Element 对象的 innerHTML 属性的赋值操作
    /**
     * Set the innerHTML property of a node
     *
     * @param {DOMElement} node
     * @param {string} html
     * @internalc
     */
    const setInnerHTML = createMicrosoftUnsafeLocalFunction(function(
      node: Element,
      html: string,
    ): void {
      // IE does not have innerHTML for SVG nodes, so instead we inject the
      // new markup in a temp node and then move the child nodes across into
      // the target node
    
      if (node.namespaceURI === Namespaces.svg && !('innerHTML' in node)) {
        reusableSVGContainer =
          reusableSVGContainer || document.createElement('div');
        reusableSVGContainer.innerHTML = '<svg>' + html + '</svg>';
        const svgNode = reusableSVGContainer.firstChild;
        while (node.firstChild) {
          node.removeChild(node.firstChild);
        }
        while (svgNode.firstChild) {
          node.appendChild(svgNode.firstChild);
        }
      } else {
        node.innerHTML = html;
      }
    });
    
    • 除了上述两个被 react 特殊处理过的封装,其余的大多是正常的 DOM 操作,对应的源码地址,均可以在 react/packages/react-dom/src/client 中找到。比如:setTextContent.js、ReactDOMTextarea.js、ReactDOMSelect.js 等等都会对 Text、Textarea、HTMLSelectElement 等对象进行处理。

版本对比(v15.6.2 / v16.14.0 / v17.0.2)

版本对比只考虑当前较为稳定的版本,本文梳理时,最新的 react 稳定版即为 v17.0.2,为了简单对比出之前版本的差异,选取了大版本的稳定版,即 v16.14.0 和 v15.6.2

  • v17.0.2:可参考本文分析内容
  • v16.14.0:
    • 调用链参考(初次渲染/更新渲染):commitRoot -> commitAllHostEffects -> commitPlacement / commitWork / commitDeletion
    • 说明:此处忽略相应的细节,可以看出主要流程与 v17.0.2 版本类似,只不过少了部分的逻辑判断
  • v15.6.2:
    • 初次渲染:ReactReconciler.mountComponent -> ... ->ReactDOMComponent.mountComponent->ReactMount._mountImageIntoNode
    • 更新渲染:主要逻辑在源码的 ReactCompositeComponent 文件的 _updateRenderedComponent 方法中,整个更新渲染过程需要配合 diff 流程实现。更新渲染和 diff 逻辑是相对耦合的,也就是说,在 diff 的过程中基本也就完成了 DOM 的更新,这个版本和 v16.14.0 和 v17.0.2 有很大的不同。
  • 其他注意点:
    • 虽然在 v15.6.2 中没有 Fiber 架构相关的实现,但是在源码中已经存在了类似 16.14.0 相关的 Fiber 和 Reconciler 架构雏形,只是在 grunt 和 gulp 打包时,并没有将其应用到稳定版。所以在 15.6.2 中入口文件应当从 ReactDOM.js 的 render 开始,可以从build之后的入口文件中看出来,最终使用的是 ReactMount.render() 方法
    • v17.0.2 中也没有将 concurrent 模式暴露到稳定版
    • v15.6.2 是 grunt 里调用了 gulp 的任务,然后单独把 reactDOM 打成 modules,产生 react-dom。是在 src/renderers 中,需要注意的是此版本使用了 DI (依赖注入)的 IOC 设计模式,所以会造成在读源码时,很多方法不好理解其所在的文件和对象
    • v16.14.0 和 v17.0.2 采用了 Lerna 包管理工具,同时引入 rollup 构建方式,所以代码生成部分会有所不同,如果在读源码时,可以注意一下

杂谈

感谢 FaceBook 的团队,创造了 React,感谢已经剖析过 React 的同行大佬为大家做了详细的解读,感谢一起卷的小伙伴,最后也感谢自己。

  • 学习心得:在我寻找 react-dom 最终是如何渲染 DOM 的这一过程中,我隐约觉得有一些经验似乎可以整理出来,一来方便自己以后在学习其他知识,感到迷茫时也可有一个参考,二来也能给大家提供一些思考的角度。
    • 当知识点扩散时,要记得出发点,可以把发散的知识点做记录,但一定要及时回顾最开始的起点是什么,否则会让自己变得迷茫,而找不到重点。
    • 在阅读了两份 React 源码解析的系列文章(文末有链接,强烈推荐大家去阅读一下,作者写的都很棒)之后,没办法记住每个细节,但是脑子里已经有了模糊的概念,剩下的就是像输出本文一样,针对细节点,不断深入,然后再不断绘制属于自己的 react 知识架构图
    • 7kms 的文章有一个特点就是,很多子章节中都会不断提示读者回顾那张核心包的关系图,也是本文引用的那张 react 架构图。这一点,我的体会是,针对自己绘制的知识架构图,也要反复打磨,不断填充细节,然后在针对自己不理解的点,不断去拓展,去夯实。所以我们学习的时候也要反复串联,加深理解。
    • 死记硬背可以是起点,但不能是终点。
  • 知识点拓展
    • Lerna:是一个管理工具,用于管理包含多个软件包(package)的 JavaScript 项目,目前如 React、Babel 等开源项目都使用此工具维护。
    • React v18 版本会去除 reactDOM.render 方法,详情见参考资料中的 React 18 Replacing render with createRoot 部分。
    • React v17.0.2 的 react-reconciler 源码中存在 xxx.new.js 和 xxx.old.js ,而稳定版使用的是 xxx.old.js,如 ReactFiberReconciler.new.js 和 ReactFiberReconciler.old.js,最终使用的是后者。
    • flow:React 中仍然使用 flow 作为静态类型检查的工具,同 vue2.x 相同,但 vue3.x 已经用 TypeScript 来解决静态类型检查这一问题。
    • react-reconciler 与 react-dom 中的 DOM 类型如何关联,即 ReactFiberHostConfig.old.js 如何与 ReactDOMHostConfig.js 中的对象如何关联,因为在 ReactFiberHostConfig.old.js 中并没有对应的对象声明,但是却在源码中引用了很多“不存在的对象”,其实是通过 rollup 打包时做的映射关系。

参考资料

浏览知识共享许可协议

知识共享许可协议
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。