前言
本篇博客主要回顾了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代码逻辑
/**
* 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换成 h1 | var 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=202308291...
https://juejin.cn/post/7238432094601756732?searchId=202308291...
https://juejin.cn/post/6844903895467032589?searchId=202308291...
如有遗漏,请联系~
最后
走过路过,不要错过,点赞、收藏、评论三连~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。