【二十四】虚拟DOM

在这里插入图片描述

前言

本篇博客主要回顾了vue.js的虚拟DOM以及Diff算法的知识点。

面试回答

1.虚拟DOM渲染:简单来讲,虚拟DOM就是用对象去表示DOM结构,状态变更时记录新树和旧树的差异,最后把差异更新到真实DOM上。实际上有四个主体,分别是template模板->render函数->vnode节点->真实DOM。render函数中会通过createrElement创建一个新的元素,至于创建怎样的元素,则由template模板将信息传到render函数里,然后render函数会创建vnode节点。在vnode节点与真实DOM之间会通过一个patch函数,传入容器以及vnode节点渲染成真实DOM。而这个patch函数,做的事情,主要是在数据改变前后会生成两份vnode节点进行比较,diff算法会计算出最小的改动,大体上就是生成一个object,里面包含tag标签、prop属性以及children子节点,然后进行遍历对比,最后根据这个变更去操作真实DOM完成渲染。

2.对虚拟DOM的看法:首先原生DOM操作肯定要比框架操作快,因为本质上虚拟DOM最终也是得通过原生操作去更新页面,更何况它还需要进行一系列的处理。而虚拟DOM优势在于保证性能下限,因为它能在数据频繁变动下的情况下,通过Diff算法计算出最小差异然后再进行渲染,这种方式比起频繁操作DOM来说要快上不少,因为它减少了重绘。框架的意义在于掩盖底层DOM操作,让开发组件化,代码解耦分层,从而提高开发效率,而且框架拥有更完善的生态。

知识点

本篇章内容如下图

在这里插入图片描述

1.虚拟DOM优势

  • 具备跨平台的优势

由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说web、ios、Android等。

  • 简化开发

原生JS很多时候要关注DOM操作,对于虚拟DOM开发者只需要关注数据和状态的变化,而不必考虑如何手动更新 DOM 。

  • 提升渲染性能

使用虚拟 DOM,可以避免频繁地对实际 DOM 进行操作,从而减少浏览器的重绘和回流,提高应用程序的性能和效率。但Virtual DOM不一定能提升性能,它的优势不在于单次的操作,而是在大量、频繁的数据更新下,通过diff算法等优化策略对比差异,对视图进行合理、高效的更新,从而减少对真实DOM的操作次数。比如在首次渲染上,虚拟DOM会多一层计算,消耗一些性能,可能比html渲染慢。

浏览器处理 DOM 很慢的原因主要有以下几点:

1.DOM 操作会引起页面的重绘和重排,这是非常消耗性能的。每次对 DOM 进行修改都需要重新计算布局和重新绘制元素,这个过程非常耗费时间。

2.DOM 结构是树形结构,它需要通过遍历来查找和访问节点。当 DOM 结构非常庞大时,遍历的时间成本也会相应增加。

3.DOM 操作涉及到网络请求和 I/O 操作,这些操作通常是异步执行的,需要等待操作完成后才能进行下一步操作,这也会影响到 DOM 操作的性能。

2.生成虚拟DOM树

在这里插入图片描述

此图为从代码到视图的生成逻辑,生成虚拟DOM树,主要是从模板到vnode的过程。虚拟DOM(Virtual DOM)简而言之就是,用JS去按照DOM结构来实现的树形结构对象,可以理解为一个简单的JS对象,并且至少含有标签名(tag)、属性(attr)、和子元素对象(children)三个属性。

2.1代码模板
<div id="virtual-dom">
    <p>Virtual DOM</p>
    <ul id="list">
      <li class="item">Item 1</li>
      <li class="item">Item 2</li>
      <li class="item">Item 3</li>
    </ul>
    <div>Hello World</div>
</div> 
2.2转换方法
//或者用h函数
var el = require("./element.js");
var ul = el('div',{id:'virtual-dom'},[
  el('p',{},['Virtual DOM']),
  el('ul', { id: 'list' }, [
	el('li', { class: 'item' }, ['Item 1']),
	el('li', { class: 'item' }, ['Item 2']),
	el('li', { class: 'item' }, ['Item 3'])
  ]),
  el('div',{},['Hello World'])
]) 

element.js代码逻辑

![222](C:\Users\pert\Desktop\博客\24虚拟DOM\222.jpg)/**
 * Element virdual-dom 对象定义
 * @param {String} tagName - dom 元素名称
 * @param {Object} props - dom 属性,包括class,click事件,id等属性
 * @param {Array<Element|String>} - 子节点
 */
function Element(tagName, props, children) {
    this.tagName = tagName
    this.props = props
    this.children = children
    // dom 元素的 key 值,用作唯一标识符
    if(props.key){
       this.key = props.key
    }
    var count = 0
    children.forEach(function (child, i) {
        if (child instanceof Element) {
            count += child.count
        } else {
            children[i] = '' + child
        }
        count++
    })
    // 子元素个数
    this.count = count
}

function createElement(tagName, props, children){
	return new Element(tagName, props, children);
}

module.exports = createElement;
2.3转换结果

在这里插入图片描述

3.比较新旧虚拟DOM树

3.1 Diff算法
React算法优化
  • tree diff(同级比较)

tree diff是虚拟DOM协调过程中的一种算法,用于查找并比较新旧虚拟DOM树之间的差异。它通过深度优先遍历虚拟DOM树,并逐个比较节点来查找差异,并标记需要更新的部分,从而提高应用程序的性能和效率。它主要针对的是React DOM节点跨层级的操作。由于跨层级的DOM移动操作较少,所以React diff算法的tree diff没有针对此种操作进行深入比较,只是简单进行了删除和创建操作,如:

在这里插入图片描述

当出现节点跨层级移动时,并不会出现想象中的移动操作,而是以 A 为根节点的整个树被删除然后,重新创建。这是一种影响React性能的操作,因此官方建议不要进行 DOM 节点跨层级的操作。

  • component diff(组件比较)

component diff是专门针对更新前后的同一层级间的React组件比较的diff算法,拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。Component diff算法的实现方式与tree diff算法类似,都是采用深度优先遍历的方式来遍历虚拟DOM树。但Component diff算法比tree diff算法更加复杂,因为它不仅要比较虚拟DOM节点之间的差异,还要比较组件的状态和属性。在比较组件时,React会根据组件类型和key值来确定它们是否相同,从而决定是否需要更新组件。

  • element diff(节点比较)

element diff是专门针对同一层级的所有节点(包括元素节点和组件节点)的diff算法。当节点处于同一层级时,diff 提供了 3 种节点操作,分别为 INSERT_MARKUP(插入)、MOVE_EXISTING(移动)和 REMOVE_NODE(删除)。Element diff算法的实现方式比Component diff算法更加简单,它只需要比较同一层级的子节点之间的key值,以确定它们的位置是否有变化。如果子节点的位置没有变化,则只需要比较其它属性是否有变化,并更新需要更新的部分。如果子节点的位置有变化,则需要将原来的子节点移动到新的位置,而不是创建一个新的子节点。对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。

  • key的作用

在React中,key是用来标识列表中每个子元素的唯一标识符。当使用列表渲染(如 ‘map()’ 方法)时,React 会根据每个子元素的 key 值来进行优化,从而提高列表的渲染性能和效率。React 使用 key 来追踪哪些子元素被修改、添加或删除。当进行列表更新时,React 会首先使用 key 来判断新旧子元素是否相同,从而减少对真实 DOM 的操作。如果没有 key,React 只能通过比较子元素的内容和顺序来判断子元素是否相同,这样会增加 React 的运算负担,降低应用程序的性能。需要注意的是,key 值必须是唯一的,并且稳定不变的。如果列表中的 key 值发生变化,React 会认为该子元素已经被删除,而不是被更新,这样可能会导致不必要的性能损失。

vue算法优化
  • 深度优先遍历,记录差异

在实际的代码中,会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个唯一的标记。在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。如果有差异的话就记录到一个对象里面,如

在这里插入图片描述

比较方式:

// diff 函数,对比两棵树
function diff(oldTree, newTree) {
  var index = 0 // 当前节点的标志
  var patches = {} // 用来记录每个节点差异的对象
  dfsWalk(oldTree, newTree, index, patches)
  return patches
}

// 对两棵树进行深度优先遍历
function dfsWalk(oldNode, newNode, index, patches) {
  var currentPatch = []
  if (typeof (oldNode) === "string" && typeof (newNode) === "string") {
    // 文本内容改变
    if (newNode !== oldNode) {
      currentPatch.push({ type: patch.TEXT, content: newNode })
    }
  } else if (newNode!=null && oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) {
    // 节点相同,比较属性
    var propsPatches = diffProps(oldNode, newNode)
    if (propsPatches) {
      currentPatch.push({ type: patch.PROPS, props: propsPatches })
    }
    // 比较子节点,如果子节点有'ignore'属性,则不需要比较
    if (!isIgnoreChildren(newNode)) {
      diffChildren(
        oldNode.children,
        newNode.children,
        index,
        patches,
        currentPatch
      )
    }
  } else if(newNode !== null){
    // 新节点和旧节点不同,用 replace 替换
    currentPatch.push({ type: patch.REPLACE, node: newNode })
  }

  if (currentPatch.length) {
    patches[index] = currentPatch
  }
} 
  • 差异类型
类型现象类型标识
节点替换节点改变了,例如div换成 h1var REPLACE = 0
顺序互换移动、删除、新增子节点,例如div的子节点中的p和ul顺序互换var REORDER = 1
属性更改修改了节点的属性,例如 li 的 class 样式类删除var PROPS = 2
文本改变改变文本节点的文本内容var TEXT = 3
  • 列表对比算法

子节点的对比算法,例如p, ul, div 的顺序换成了 div, p, ul。如果按照同层级进行顺序对比的话,它们都会被替换掉。如 p 和 div 的 tagName 不同,p 会被 div 所替代。最终,三个节点都会被替换,这样 DOM 开销就非常大。而实际上是不需要替换节点,而只需要经过节点移动就可以达到。这里抽象出来涉及到动态规划的求解,时间复杂度为O(M*N)。

  • 实例

两个虚拟 DOM 对象如下所示,其中 ul1 表示原有的虚拟 DOM 树,ul2 表示改变后的虚拟 DOM

var ul1 = el('div',{id:'virtual-dom'},[
  el('p',{},['Virtual DOM']),
  el('ul', { id: 'list' }, [
	el('li', { class: 'item' }, ['Item 1']),
	el('li', { class: 'item' }, ['Item 2']),
	el('li', { class: 'item' }, ['Item 3'])
  ]),
  el('div',{},['Hello World'])
]) 
var ul2 = el('div',{id:'virtual-dom'},[
  el('p',{},['Virtual DOM']),
  el('ul', { id: 'list' }, [
	el('li', { class: 'item' }, ['Item 21']),
	el('li', { class: 'item' }, ['Item 23'])
  ]),
  el('p',{},['Hello World'])
]) 
var patches = diff(ul1,ul2);

代码输入如下图所示,我们能通过差异对象得到,两个虚拟 DOM 对象之间进行了哪些变化,从而根据这个差异对象(patches)更改原先的真实 DOM 结构,从而将页面的 DOM 结构进行更改。

在这里插入图片描述

3.2 patch函数

第一种是第一次渲染的时候 patch将vnode丢到container空容器中

var vnode = el('ul',{ id: 'list' },[
	el('li',{ class: 'item' }, ['Item 1']),
	el('li',{ class: 'item' }, ['Item 2']),
	el('li',{ class: 'item' }, ['Item 3']),
])

patch(container, vnode) // vnode 将 container 节点替换

第二种是更新节点的时候,newVnode将oldVnode替换

btn.addEventListener('click',function() {
	var newVnode = el('ul',{ id: 'list'},[
        el('li',{ class: 'item'},['changeItem 1']),
        el('li',{ class: 'item'},['changeItem 2']),
        el('li',{ class: 'item'},['changeItem 3']),
        el('li',{ class: 'item'},['changeItem 4']),
      ])
  	patch(vnode, newVnode)
})

在这里插入图片描述

patch函数即为精细化比较,具体逻辑如下:

在这里插入图片描述

function patch(  
    oldVnode: VNode | Element | DocumentFragment,  
    vnode: VNode  
): VNode {  
    let i: number, elm: Node, parent: Node;  
    const insertedVnodeQueue: VNodeQueue = [];  
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();  
    if (isElement(api, oldVnode)) { 
    	oldVnode = emptyNodeAt(oldVnode); 
    } else if (isDocumentFragment(api, oldVnode)) {
    	oldVnode = emptyDocumentFragmentAt(oldVnode); 
    }  
    // 判断是否为相同vnode  
    if (sameVnode(oldVnode, vnode)) {  
    	patchVnode(oldVnode, vnode, insertedVnodeQueue);  
    } else { 
    	elm = oldVnode.elm!;
    	parent = api.parentNode(elm) as Node; 
    	// 元素不同时,直接创建新DOM
    	createElm(vnode, insertedVnodeQueue);
    	if (parent !== null) {
    		// 插入页面DOM结构中
    		api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
    		// 删除旧DOM
    		removeVnodes(parent, [oldVnode], 0, 0);
    	}
    } 
    /** some handle */
    return vnode;  
};

4.转换虚拟DOM生成DOM元素

将一个vnode(vdom)添加到空容器生成真实dom的过程,主要的代码流程:

function creatElement(vnode) {
  let tag = vnode.tag
  let attrs = vnode.attrs || {}
  let children = vnode.children || []
  // 无标签 直接跳出
  if (!tag) {
    return null
  }
  // 创建元素
  let elem = document.createElement(tag)
  // 添加属性
  for(let attrName in attrs) {
    if (attrs.hasOwnProperty(attrName)) {
      elem.setAttribute(arrtName, arrts[attrName])
    }
  }
  // 递归创建子元素
  children.forEach((childVnode) => {
    elem.appendChild(createElement(childVnode))
  })
  return elem
}

渲染方法:

/**
 * render 将virdual-dom 对象渲染为实际 DOM 元素
 */
Element.prototype.render = function () {
    var el = document.createElement(this.tagName)
    var props = this.props
    // 设置节点的DOM属性
    for (var propName in props) {
        var propValue = props[propName]
        el.setAttribute(propName, propValue)
    }

    var children = this.children || []
    children.forEach(function (child) {
        var childEl = (child instanceof Element)
            ? child.render() // 如果子节点也是虚拟DOM,递归构建DOM节点
            : document.createTextNode(child) // 如果字符串,只构建文本节点
        el.appendChild(childEl)
    })
    return el
} 

具体DOM操作:我们根据不同类型的差异对当前节点进行不同的 DOM 操作 ,例如如果进行了节点替换,就进行节点替换 DOM 操作;如果节点文本发生了改变,则进行文本替换的 DOM 操作;以及子节点重排、属性改变等 DOM 操作,相关代码如下所示

function applyPatches (node, currentPatches) {
  currentPatches.forEach(currentPatch => {
    switch (currentPatch.type) {
      case REPLACE:
        var newNode = (typeof currentPatch.node === 'string')
          ? document.createTextNode(currentPatch.node)
          : currentPatch.node.render()
        node.parentNode.replaceChild(newNode, node)
        break
      case REORDER:
        reorderChildren(node, currentPatch.moves)
        break
      case PROPS:
        setProps(node, currentPatch.props)
        break
      case TEXT:
        node.textContent = currentPatch.content
        break
      default:
        throw new Error('Unknown patch type ' + currentPatch.type)
    }
  })
} 

5.真实DOM生成

所有的浏览器渲染引擎工作流程大致分为5步:创建DOM 树-> 创建 Style Rules -> 构建 Render 树 -> 布局 Layout -> 绘制 Painting

在这里插入图片描述

  • 第一步,构建 DOM 树:用 HTML 分析器,分析 HTML 元素,构建一棵 DOM 树;

PS:构建 DOM 树是一个渐进过程,为达到更好的用户体验,渲染引擎会尽快将内容显示在屏幕上,它不必等到整个 HTML 文档解析完成之后才开始构建 render 树和布局。

  • 第二步,生成样式表:用 CSS 分析器,分析 CSS 文件和元素上的 inline 样式,生成页面的样式表;

PS:CSS 的解析是从右往左逆向解析的,嵌套标签越多,解析越慢。

  • 第三步,构建 Render 树:将 DOM 树和样式表关联起来,构建一棵 Render 树(Attachment)。每个 DOM 节点都有 attach 方法,接受样式信息,返回一个 render 对象(又名 renderer),这些 render 对象最终会被构建成一棵 Render 树;

PS:Render 树、 DOM 树、 CSS 样式表这三个过程在实际进行的时候并不是完全独立的,而是会有交叉,会一边加载,一边解析,以及一边渲染。

  • 第四步,确定节点坐标:根据 Render 树结构,为每个 Render 树上的节点确定一个在显示屏上出现的精确坐标;
  • 第五步,绘制页面:根据 Render 树和节点显示坐标,然后调用每个节点的 paint 方法,将它们绘制出来。

6.代码验证

我们可以选择Vue2中使用的虚拟dom库snabbdom,核心内容就是两个函数:h函数 和 patch函数,下面图是截得它github主页的示范案例:

在这里插入图片描述

链接直达:https://github.com/fengshi123/virtual-dom-example

参考博客

https://juejin.cn/post/6844903767473651720?searchId=20230829171054DCB0EB331DE579B3CC04

https://juejin.cn/post/7238432094601756732?searchId=20230829171054DCB0EB331DE579B3CC04

https://juejin.cn/post/6844903895467032589?searchId=20230829171054DCB0EB331DE579B3CC04

如有遗漏,请联系~

最后

走过路过,不要错过,点赞、收藏、评论三连~

  • 20
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值