jtwang7 / React-Note

React 学习笔记
8 stars 2 forks source link

React - Virtual DOM #31

Open jtwang7 opened 2 years ago

jtwang7 commented 2 years ago

参考:

什么是 Virtual DOM ?

与其将 “Virtual DOM” 视为一种技术,不如说它是一种模式,是一种编程概念。在这个概念里, UI 以一种理想化的,或者说“虚拟的”表现形式被保存于内存中,并通过如 ReactDOM 等类库使之与“真实的” DOM 同步。

DOM 只存在于浏览器环境下,因此实际上虚拟 DOM 这个命名并不准确,React 用 Fiber 节点来进一步完善了虚拟 DOM 的相关概念

从 React 到 Vue ,虚拟 DOM 为这两个框架都带来了跨平台的能力(React-Native 和 Weex)。因为很多人是在学习 React 的过程中接触到的虚拟 DOM ,所以为先入为主,认为虚拟 DOM 和 JSX 密不可分。其实不然,虚拟 DOM 和 JSX 固然契合,但 JSX 只是虚拟 DOM 的充分不必要条件,Vue 即使使用模版,也能把虚拟 DOM 玩得风生水起,同时也有很多人通过 babel 在 Vue 中使用 JSX。

概念

Virtual DOM 本质就是一个普通的 JavaScript 对象,包含了 tag、props、children 三个属性:

DOM 的 JavaScript 对象表示形式

<!-- 真实 DOM -->
<div id="app">
  <p class="text">hello world!!!</p>
</div>
// 虚拟 DOM
{
  tag: 'div',
  props: {
    id: 'app'
  },
  children: [
    {
      tag: 'p',
      props: {
        className: 'text'
      },
      children: [
        'hello world!!!'
      ]
    }
  ]
}

tag 存放 DOM 元素的标签名 props 存放标签内的所有属性 children 存放标签内嵌子元素

由于 DOM 本身就以树形结构展示,所以使用 JavaScript 对象类型就能很简单的表示。用 JavaScript 对象来表示 DOM 的好处有两点:1. 提升性能;2. 跨平台;具体优势参照如下。

虚拟 DOM 带来的优势:

  1. 提升性能:原生 DOM 因为浏览器厂商需要实现众多的规范(各种 HTML5 属性、DOM事件),即使创建一个空的 div 也要付出昂贵的代价(JS 操作真实 DOM 元素会带来巨大的性能消耗),而虚拟 DOM 则可通过 diff 算法比对新旧 Virtual DOM Tree (JavaScript 原生对象),找到需要变更的 DOM 节点,然后仅在真实 DOM 上对改动节点及其子节点进行更新操作,而不是更新整个视图,从而减少 JavaScript 操作真实 DOM 的带来的性能消耗。
  2. 跨平台:抽象渲染过程,实现跨平台的能力。(真实 DOM 局限于浏览器,而表示为 JavaScript 对象,就可以应用于安卓 / IOS等不同平台,甚至是小程序)

很多人认为虚拟 DOM 最大的优势是 diff 算法,减少 JavaScript 操作真实 DOM 的带来的性能消耗。虽然这一个虚拟 DOM 带来的一个优势,但并不是全部。虚拟 DOM 最大的优势在于抽象了原本的渲染过程,实现了跨平台的能力,而不仅仅局限于浏览器的 DOM,可以是安卓和 IOS 的原生组件,可以是近期很火热的小程序,也可以是各种GUI。 -- 摘自 虚拟 DOM 到底是什么?

HTML 代码 -> Virtual DOM

h 函数是实现 HTML 代码向 Virtual DOM 转换的关键。主流的虚拟 DOM 库(snabbdom、virtual-dom),通常都有一个 h 函数。React 是通过 babel 将 jsx 转换为 h 函数渲染的形式,而 Vue 是使用 vue-loader 将模版转为 h 函数渲染的形式(也可以通过 babel-plugin-transform-vue-jsx 插件在 vue 中使用 jsx,本质还是转换为 h 函数渲染形式)。

h 函数

function h(tag, props, ...children) {
  return {
    tag,
    props: props || {},
    children: children.flat()
  }
}

h 函数接受三个参数,对应位置分别为 DOM 元素的标签名 tag、属性 props、子节点 children,最终返回一个虚拟 DOM 的对象。以 React JSX 为例,babel 会将 jsx 转换为以 h 函数实现的形式,最终返回 Virtual DOM 对象:

// jsx
function getVDOM() {
  return (
    <div id="app">
      <p className="text">
        hello world
      </p>
    </div>
  )
}
// jsx -> h
function getVDOM() {
  return (
    h('div', {id: 'app'}, h('p', {className: 'text'}, 'hello world'));
  )
}

渲染 Virtual DOM

前面提到,虚拟 DOM 一大优势在于它可以跨平台,这也就意味着,不同平台上,渲染虚拟 DOM 的方式也有所不同。以浏览器为例,虚拟 DOM 会被渲染为真实 DOM,然后被浏览器解析。

Virtual DOM 不一定被渲染为真实 DOM,因为真实 DOM 这一概念只针对浏览器环境而言。

以浏览器环境为例,渲染 Virtual DOM 的流程如下:

// 接收 Virtual DOM 作为参数
function render(vdom) {
  // 如果是字符串或者数字,创建一个文本节点
  if (typeof vdom === 'string' || typeof vdom === 'number') {
    return document.createTextNode(vdom)
  }
  const { tag, props, children } = vdom
  // 创建真实DOM
  const element = document.createElement(tag)
  // 设置属性
  setProps(element, props)
  // 遍历子节点,并获取创建真实DOM,插入到当前节点
  children
    .map(render)
    .forEach(element.appendChild.bind(element))

  // 虚拟 DOM 中缓存真实 DOM 节点
  vdom.dom = element

  // 返回 DOM 节点
  return element
}

function setProps (element, props) {
  Object.entries(props).forEach(([key, value]) => {
    setProp(element, key, value)
  })
}

function setProp (element, key, vlaue) {
  element.setAttribute(
    // className使用class代替
    key === 'className' ? 'class' : key,
    vlaue
  )
}

将虚拟 DOM 渲染成真实 DOM 后,只需要插入到对应的根节点即可:

const vdom = <div>hello world!!!</div> // 首先 jsx 会被 babel 转为 h 函数渲染形式: h('div', {}, 'hello world!!!')
const app = document.getElementById('app')
const ele = render(vdom) // 然后将 h 函数返回的 Virtual DOM 转为真实 DOM
app.appendChild(ele) // 插入目标节点

JSX -> 虚拟DOM -> 真实DOM

  1. JSX代码(JSX实际上仅仅是React.createElement(type, config, children)方法的语法糖)经过bable编译,经过React.createElement()方法调用,返回我们对应的ReactElement对象树(虚拟DOM树)
  2. 对应的ReactElement对象树经过ReactDOM.render()方法转换为真正的DOM在我们的浏览器进行渲染。

diff 算法

本质:对比新旧 Virtual DOM 对象的差异,将改动的部分更新到视图上。 代码实现:实现一个 diff 函数,接收新旧 Virtual DOM 作为参数,并将改动部分以某种方式渲染到视图上。

Virtual DOM 将改动部分返回为 patches(补丁),然后通过 patch 方法将其渲染到视图中。而 cito.js 移除了 patch 更新,在 diff 的过程中,直接更新真实 DOM ,省去了 patch 的存储,一定程度上节省了内存,后面其他的 VDOM 库基本使用这种方式。

// Virtual DOM 实现
const before  = h('div', {}, 'before text')
const after   = h('div', {}, 'after text')
const patches = diff(before, after)
patch(this.$el, patches)

虚拟 DOM 库对 diff 算法的实现

不同虚拟 DOM 库对于 diff 算法有着自己不同的实现: 最开始出现的是 virtual-dom 这个库,通过深度优先搜索与 in-order tree 来实现高效的 diff 。 然后是 cito.js 采用两端同时进行比较的算法,将 diff 速度拉高到几个层次。 紧随其后的是 kivi.js,在 cito.js 的基础上提出两项优化方案,使用 key 实现移动追踪以及基于 key 的最长自增子序列算法应用(算法复杂度 为O(n^2))。但这样的 diff 算法太过复杂了,于是后来者 snabbdom 将 kivi.js 进行简化,去掉编辑长度矩离算法,调整两端比较算法。速度略有损失,但可读性大大提高。 再之后,就是著名的 vue2.0 把 sanbbdom 整个库整合掉了。

VDOM 对比规则

  1. 旧节点不存在,插入新节点;新节点不存在,删除旧节点
  2. 新旧节点如果都是 VNode,且新旧节点 tag 相同 2.1 对比新旧节点的属性 2.2 对比新旧节点的子节点差异,通过 key 值进行重排序,key 值相同节点继续向下遍历
  3. 新旧节点如果都是 VText,判断两者文本是否发生变化
  4. 其他情况直接用新节点替代旧节点

子节点对比

关于子节点的对比,可以说是 diff 算法中,变动最多的部分,因为前面的部分,各个库对比的方向基本一致,而关于子节点的对比,各个仓库都在前者基础上不断得进行改进。 为什么需要改进子节点的对比方式? 如果我们直接按照深度优先遍历的方式,一个个去对比子节点,子节点的顺序发生改变,那么就会导致 diff 算法认为所有子节点都需要进行 replace,重新将所有子节点的虚拟 DOM 转换成真实 DOM,这种操作是十分消耗性能的。但是,如果我们能够找到新旧虚拟 DOM 对应的位置,然后进行移动,那么就能够尽量减少 DOM 的操作。

移动节点不需要重新创建,减少了 JavaScript 操纵 DOM 所带来的性能消耗

virtual-dom 对子节点添加 key 值,其中 key 在当前子节点集合中必须是唯一标识的,通过 key 值的对比,来判断子节点是否进行了移动。通过 key 值对比子节点是否移动的模式,被各个库沿用,这也就是为什么主流的视图库中,子节点如果缺失 key 值,会有 warning 的原因。 cito.js 再此基础上改动了对子节点对比的算法,引入了两端对比,将 diff 算法的速度提升了几个量级。 kivi 的 diff 算法在 cito 的基础上,引入了最长增长子序列,通过子序列找到最小的 DOM 操作数。

为什么不用 index 作为 key ?

参考:轻松理解为什么不用Index作为key

具体例子参考文章,总结如下: 数组遍历时产生的 index 满足子节点 key 唯一标识的要求,当遍历的数组不发生更改时(增 / 删),看不出 index 作为 key 所带来的问题。一旦数组发生了更改,数组遍历时就会重新对新的子节点赋予 index,新 VDOM 中的子节点对应的 key 被重新赋值,导致 新旧 VDOM 中相同 key 的子节点对应的值不同。致命的问题在于,若数组改动发生在第一个子节点,无论是删除还是增加,都会让 diff 算法将后续值不相同的一连串子节点识别为需要重新渲染的目标,增大了开销。