React 虚拟DOM的前世今生

avatar
@比心

引文

随着 Web 应用程序的复杂性不断增加,我们需要一种更高效、更智能的方法来管理网页。虚拟 DOM 的出现,为前端开发者带来了一种全新的解决方案。

在本文中,我们将深入探讨 React 虚拟 DOM 的前世今生,包括它的优势、工作流程以及实现方式。

一、什么是 DOM

要了解虚拟 DOM,首先需要明确 DOM 的概念。

根据 MDN 的说法:

文档对象模型 (DOM) 是 HTML 和 XML 文档的编程接口。它提供了对文档的结构化的表述,并定义了一种方式可以使从程序中对该结构进行访问,从而改变文档的结构,样式和内容。DOM 将文档解析为一个由节点和对象(包含属性和方法的对象)组成的结构集合。简言之,它会将 web 页面和脚本或程序语言连接起来。

重点:

  • HTML 和 DOM 是一一映射的,DOM 是对 HTML 的结构化描述。

  • 通过 DOM 可以访问和修改文档的结构、样式和内容。

我们可以建立这样的对应关系:

640 (5).jpeg

JavaScript 通过浏览器提供的 API 操作 DOM,DOM 修改后浏览器会根据新的 DOM 结构重新渲染 HTML 界面。

看到这里可能会产生疑问:既然我们可以直接使用 JavaScript 操作 DOM,为什么还要使用虚拟 DOM?

这就是我们下面需要解释的问题。

二、真实 DOM 操作存在的问题

实际上,直接使用 JavaScript 操作 DOM 是最高效的方式。但在中大型项目中,开发者在开发时很难保证代码运行时的最佳性能,不良的代码逻辑可能会导致浏览器频繁重新渲染,从而导致页面整体性能不佳。

以下直接使用 JavaScript 操作 DOM 的操作,会导致浏览器立即执行重新排列和绘制:

  • 通过 JS 获取需要计算的 DOM 属性

  • 添加或删除 DOM 元素

  • resize 浏览器窗口大小

  • 改变字体

  • css 伪类的激活,比如:hover

  • 通过 JS 修改 DOM 元素样式且该样式涉及到尺寸的改变

所以,使用 Javascript 操作真实 DOM 的问题就在于开发者在关注代码逻辑的同时,还要兼顾 DOM 操作带来的性能问题,这会消耗很大的心力成本。大部分开发者无法做到极致。

三、React 虚拟 DOM 应运而生

Facebook 团队在构建 React 之初考虑性能问题的同时,也考虑了提升代码抽象能力、避免手动操作真实 DOM、降低代码整体风险等因素,于是设计出了“虚拟 DOM”。

虚拟 DOM 其实是一种用来模拟 DOM 结构的 Javascript 对象。

下面是一个真实 DOM 结构:

<ul id="list">
    <li class="item">itemA</li>
    <li class="item">itemB</li>
</ul>

这是虚拟 DOM 结构:

{

    tag:'ul',  // 元素的标签类型

    attrs:{  //  表示指定元素身上的属性

        id:'list'

    },

    children:[  // ul元素的子节点

        {

            tag: 'li',

            attrs:{

                className:'item'

            },

            children:['itemA']

        },

        {   tag: 'li',

            attrs:{

                className:'item'

            },

            children:['itemB']

        }

    ]

}

其实虚拟 DOM 并不能避免进行原生的 DOM 操作,想要改变页面状态,仍然需要通过浏览器提供的 DOM 接口。

这样看来,虚拟 DOM 不就是多此一举吗?

事实上,虚拟 DOM 给 React 带来了重要的优势。

四、虚拟 DOM 有哪些优势

渲染机制的优化

通过虚拟 DOM 掌握 DOM 树结构,可以为框架的性能优化提供可能。在更新页面状态时,可以做到最细粒度化更新 DOM。

例如,原本需要进行三次 DOM 操作,但经过虚拟 DOM 处理后,可以将三次操作简化为一次,然后将其交给浏览器修改真实的 DOM 树。这减少了渲染次数,从而提高性能。

浏览器兼容性最佳

React 中的虚拟 DOM 具有强大的兼容性。

在 React 中,所有事件都是合成的,不是原生 DOM 事件。合成事件用来抹平不同浏览器之间的差异,仅保留所需的操作方法,降低理解成本,提升可维护性。

跨平台能力

虚拟 DOM 抽象出一套数据模型,用于适配多种渲染平台,实现了跨平台的能力。因为 React 只是在 JavaScript 层面上操作虚拟 DOM,所以可以在不同平台上使用相同的代码来渲染用户界面。

React 自出现之后,随着互联网的发展,除了做页面应用外,也可以通过使用 React Native 开发原生应用。

可维护性更强

React 虚拟 DOM 的优势不仅仅体现在性能和效率方面,还使得前端开发变得更加易于维护和扩展。

使用虚拟 DOM,开发者可以通过 JSX 轻松地重构页面结构,添加新功能和优化页面性能,而无需担心破坏原有的代码和页面结构。

五、React 中虚拟 DOM 的工作流程

5.1 简介

React 中虚拟 DOM 的实现过程是非常复杂的,涉及到多种场景,并且 React16 版本(Facebook 在 React16 版本中推出了新的 Fiber 架构)更新前后的差异也是非常大的。

我们先从早期的 React 入手,了解一个 React 组件是如何转变为虚拟 DOM,最后转变为真实 DOM 的。大致的工作流程如下。

640 (57).png

5.2 虚拟 DOM 的生成

我们要关注虚拟 DOM 的实现过程,首先需要实现一个 React 组件。

通过 React render()方法,实现一个 React 组件可以选择两种编码方式:

  1. 使用 React.createElement()

  2. 使用 JSX 语法

5.2.1 React.createElement()

第一种是通过直接调用 React 内置的createElement方法编写,它也是 React 中生成虚拟 DOM 的函数。

class Hello extends Component {

  render() {

    return React.createElement("div", null, `Hello World`);

  }

}

React.createElement 函数的作用是生成 ReactElement,一个 ReactElement 是一个对象,它主要包含一下内容

*// $$typeof* 属性可以唯一标识一个*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,

  };

  return element;

};
  • type类型,用于判断如何创建节点

  • props新的属性内容

  • key 唯一标识

  • ref refs 提供了一种方式,允许我们访问 DOM 节点或在 render 方法中创建的 React 元素。

  • $$typeof用于确定是否属于 ReactElement

这就是虚拟 DOM 。

这种类型的数据和原生 DOM 是一一对应的,React 在调和(Reconcilation)过程中会对比新旧虚拟 DOM 树的信息,找出差异,然后再运用到真实 DOM 上,比如新建、更新、删除等。

调和(Reconcilation)是实现 UI 更新的一个重要过程,目的是为了提升渲染性能, 让用户无感知的情况下更新界面。

5.2.2 JSX

第二种实现虚拟 DOM 的方法是使用 JSX 语法进行开发:

class Hello extends Component {

  render() {

    return <div>Hello World!</div>;

  }

}

在 React 中使用 JSX 的好处

  • 允许使用熟悉的语法来定义 HTML 元素树;
  • 提供更加语义化且移动的标签;
  • 程序结构更容易被直观化;
  • 抽象了 React Element 的创建过程;
  • 是原生的 JavaScript;

这两种方式都可以实现虚拟 DOM,但是我们通过上面的工作流程图可知,即便我们使用 JSX 语法开发,运行时 React 也会通过babel将其编译后的结果传递给React.createElement(),由React.createElement()处理之后生成虚拟 DOM 结构。

在早期版本的 React 中,每次进行组件更新时,都会重新渲染整个组件树,使得某些场景下项目的整体性能非常差,严重影响了用户的体验。

那么 React 是怎么解决的呢?

5.3 Reconciliation

上面我们知道,在某一时间节点调用 React 的 render() 方法,会由React.createElement()生成虚拟 DOM 树,而当下一次 state 或 props 更新时,则会生成一颗最新的虚拟 DOM 树,React 需要基于这两棵树之间的差别来判断如何高效的更新 UI,以保证当前 UI 与最新的树保持同步。这个过程就是 React 的 Reconciliation 阶段,而这个过程的核心算法就是 Diff 算法。

下图为更新过程时虚拟 DOM 的工作流程。

640 (58).png

具体的更新流程如下:

  • 若当前为新建,则直接将当前虚拟 DOM 通过某些流程挂载至真实 DOM 上;

  • 若当前为更新,则通过 Diff 算法对比新旧 DOM 树,根据两者差异计算生成 patch,这个 patch 是一个结构化的数据,内容包含了增加、更新、移除、节点信息等;

  • 根据 patch 去更新真实的 DOM,重新渲染至界面上;

在这个过程中,React 通过引入 Diff 算法最小化更新必要的部分,避免了重新渲染整个组件树的问题。

那么 Diff 算法到底是一种什么算法呢?

Diff 算法是一种用于计算两个树形结构之间差异的算法,Diff 算法并非 React 推出的,我们管它叫传统 diff 算法,React 中的 Diff 算法则是对传统 Diff 算法的做了极大性能提升,它是 React 框架高效实现组件更新的关键所在。

Diff 算法从 React0.13 引入至今,已经更新了非常多的版本,下面我们针对不同时期的 Diff 算法原理进行分析,来了解 React 虚拟 DOM 的发展历程。

5.3.1 React16 之前

Tree Diff

在 React 0.1 - 0.12 版本中,并没有使用 Diff 算法,每当状态或属性发生改变时,整个组件的虚拟 DOM 都会被重新渲染,这样的代价是极高的,特别是在组件层级较深的情况下,性能消耗会非常大。

Diff 算法第一次出现在 React 框架在 0.13 版本,当时的 Diff 策略为 “Tree Diff”

此时的 Diff 算法仅仅是一个简单的递归算法,它会比较新旧虚拟 DOM 树,找出不同之处,并且将其更新到真实的 DOM 树中。

640 (59).png

Diff 过程:

进行同级比较,并非循环遍历比较。这样比较次数就降为一层一次,时间复杂度直接降为 O(n) 如果同级相同位置节点不一样,则直接删除替换,简单粗暴。而对于节点移动,同样道理,也是简单粗暴的删除重建。

这个原则主要是处理跨层级的节点移动。对于不同的节点类型,React 会直接删去旧的节点,建一个新的节点。因此,在开发组件时,保持稳定的 DOM 结构可以提高性能。例如,可以通过 CSS 隐藏或显示节点,而不是真的移除或添加 DOM 节点。

Component Diff

React 在 0.14 版本中引入了 Component Diff 策略。主要应用于组件间的比较。

组件间 Diff 策略过程如下:

  • 如果是同一类型的组件,则根据新节点的 props 去更新原来根节点的组件实例,按照原策略继续比较虚拟 DOM 树。

  • 如果不是同一类型的组件,直接替换整个组件下的所有子节点。

如果是同一个类型的组件,有可能经过一轮 Component Diff 比较之后,并没有发生变化。如果我们能够提前确切知道这一点,那么就可以省下大量的 diff 运算时间。

React 允许用户通过shouldComponentUpdate()来判断该组件是否需要进行 diff 算法分析。

需要注意的是,过度使用 shouldComponentUpdate() 方法可能会导致代码难以维护和理解。因此,我们需要在实际开发中合理使用这个方法,避免过度优化。

随着互联网的快速发展,网站的体积越来越大,即使此时的 Diff 算法做了非常多的优化,网站的性能还是得不到保障。

这是因为在当时的 React 版本中,React 是一个递归的,同步的调用栈。这使得 React 处理大型和复杂的 UI 组件树时变得困难,因为它不具备中断和恢复的能力。并且,长时间运行的 JavaScript 代码会阻塞 UI 线程,从而导致用户界面失去响应性,用户体验非常不佳。

那么 React 是如何解决的呢?React 虚拟 DOM 又做了哪些改变?

5.3.2 React16 更新之后

React Fiber 是 React16 中引入的一种新的协调机制,它采用了一种更灵活的调度方式,能够在不阻塞主线程的情况下,更好地控制组件的更新顺序和优先级,从而提高应用的性能和响应速度。它的出现就是为了解决 React 在处理大型应用程序时可能会遇到的性能问题。

React Fiber 对虚拟 DOM 的改进优化

主要包括以下几个方面:

  1. 拆分成小块。React Fiber 将虚拟 DOM 的生成和更新过程拆分成小块,通过异步渲染机制进行调度,避免了一次性递归整个组件树的性能问题。
  2. 基于优先级的调度。React Fiber 采用了一种基于优先级的调度方式,可以更好地控制组件的更新顺序和优先级,从而提高应用的性能和响应速度。
  3. 支持增量渲染。React Fiber 支持增量渲染,即只更新需要更新的部分,避免了全局重新渲染的性能问题。
  4. 支持暂停和恢复。React Fiber 支持在渲染过程中暂停和恢复操作,这对于处理大型应用程序或动画效果非常有用。

基于 Fiber 架构的更新,React 针对 Diff 过程也进行了优化,在原来两种策略之上又新增出了一种新的 Diff 策略

Element diff

具体过程如下:

640 (60).png

对于比较同一层级的节点们,每个节点都用唯一的 key 作为标识。当节点处于同一层级时,React Diff 提供了三种节点操作,分别为:INSERT_MARKUP(插入)、MOVE_EXISTING(移动)和 REMOVE_NODE(删除)。

React Fiber 的出现标志着 React 的发展进入了一个新的阶段。它主要是针对虚拟 DOM 的更新和渲染过程进行优化。React Fiber 不仅能够解决 React 在处理大型应用程序时可能会遇到的性能问题,还可以更好地处理复杂场景和错误情况。此外,React Fiber 的代码结构更加清晰和模块化,更易于维护和扩展。

5.4 Renderer

在经过 Diff 算法计算得到两个虚拟 DOM 树之间的差异之后,Reconciler 还会负责把需要更新的信息打包成一批计划,提交给 React Renderer,通过 React Render 对真实 DOM 树进行更新,并将结果呈现给用户。

React 的 Renderer 是一个用于将 React 元素渲染为实际 UI 的模块。它的作用是将 React 元素转换为实际可见的 DOM、Native 组件或其它平台特定的 UI 元素。

不同的 Renderer 实现可以用于不同的平台,例如 ReactDOM 用于将 React 元素渲染为浏览器 DOM,React Native Renderer 则用于将 React 元素渲染为 Native UI 组件。

5.5 本章小结

总的来说,React 虚拟 DOM 经历了多个版本的发展和改进,每个版本都带来了新的特性和优化。

其中,更新较大的 React 16 带来了全新的 Fiber 架构,大大提高了 React 的性能和可伸缩性,使其可以支持更大、更复杂的应用程序。

React 16 更新前后,通过render()实现的虚拟 DOM 工作流程对比图如下。

640 (6).jpeg

虚拟 DOM 从简单的数据描述,到可中断的任务调度,它的发展历程也反映了前端技术的发展趋势,越来越复杂的应用场景需要更加灵活和可扩展的架构设计。这些变化体现推动了前端技术的不断发展和进步,也为开发人员提供了更好的工具和平台。

六、总结

React 作为目前最为流行的前端框架之一,它的成功离不开两点:优秀的虚拟 DOM 机制、便捷的 JSX 语法的使用。

虚拟 DOM 是 React 的核心概念之一,它的出现解决了前端开发中的一个重要问题:如何高效地渲染大规模的组件树。虚拟 DOM 通过在内存中维护一棵虚拟的 DOM 树,实现了对 DOM 操作的批量处理和最小化渲染的优化。

然而,随着应用规模的不断增大和前端交互的不断复杂化,虚拟 DOM 算法也在不断地改进和优化。例如,React16 引入一项名为 “Fiber” 的新算法,该算法可以将渲染任务拆分成多个小任务,从而提高了渲染的效率。React 18 中引入了增量渲染技术,可以在不完全构建整个应用的情况下,只渲染用户实际请求的那部分组件,从而提高页面加载速度和性能。

此外,React 还开发了一些工具,如 React Profiler 和 React DevTools,帮助开发者更好地分析和优化应用性能。

七、展望未来

在前端框架市场中,React 和其它框架的竞争越来越激烈。Vue.js 和 Angular 等框架也都有自己的优点和特点,例如 Vue.js 的模板语法和 Angular 的依赖注入机制。在选择框架时,需要根据项目需求和团队技能做出合理的选择。

React 是当今前端开发领域最热门的框架之一,它的出现和发展代表了前端开发从传统的后端渲染和 jQuery 时代,向着组件化、函数式编程和声明式渲染等现代化方向的趋势去发展。

下图是统计 5 年内各 JS 框架的下载量统计图

640 (61).png

从上图看到 React 在整个图中一直处于领先地位,这不仅得利于它更高的性能、更良好的代码规范、更好的可维护性,也得利于 React 更广泛的生态系统。与 Angular 相比,React 的学习曲线更平缓,而与 Vue.js 相比,React 在处理大规模应用时表现更出色。因此,React 在许多大型互联网公司和开发者社区中都备受推崇。

随着前端应用的复杂性和规模的不断提升,前端开发者需要更好的工具和技术来提高开发效率、保证代码质量和提升用户体验。因此,在未来,虚拟 DOM 算法的持续优化将成为前端开发领域的重要研究方向之一。

总之,React 框架和虚拟 DOM 算法的发展代表了前端开发的一种新趋势和技术方向。随着技术的不断发展和前端应用的不断演进,React 和虚拟 DOM 算法也将不断改进和优化,为前端开发者提供更好的工具和技术支持。

wxg.JPG