Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React - Virtual DOM #31

Open
jtwang7 opened this issue Sep 15, 2021 · 0 comments
Open

React - Virtual DOM #31

jtwang7 opened this issue Sep 15, 2021 · 0 comments

Comments

@jtwang7
Copy link
Owner

jtwang7 commented Sep 15, 2021

参考:

什么是 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 算法将后续值不相同的一连串子节点识别为需要重新渲染的目标,增大了开销。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant