从VirtualDom(虚拟Dom)到真实DOM
虚拟Dom实现为真实Dom结构并进行更新的集合说明,包含虚拟Dom和真实Dom的互相转换以及更新时候的比较,在react和Vue以及后续版本应用的逻辑说明。本文展示的代码仅仅是简单初级代码,关于React和Vue中的diff算法和真实dom以及虚拟dom的互相转换请参考react和Vue的官方源码。
浏览器中的Dom更新
在浏览器中渲染引擎将 node 节点添加到 另外节点中时会触发样式计算、布局、绘制、栅格化、合成等任务,这一过程称为重排。
除了重排之外,还有可能引起重绘或者合成操作,也就是“牵一发而动全身”。
另外,对于 DOM 的不当操作还有可能引发强制同步布局和布局抖动的问题,这些操作都会大大降低渲染效率。
因此,对于 DOM 的操作时刻都需要非常小心谨慎。
对于一些复杂的页面或者目前使用非常多的单页应用来说,其 DOM 结构是非常复杂的,而且还需要不断地去修改 DOM 树,每次操作 DOM 渲染引擎都需要进行重排、重绘或者合成等操作,因为 DOM 结构复杂,所生成的页面结构也会很复杂,对于这些复杂的页面,执行一次重排或者重绘操作都是非常耗时的,这就给浏览器带来了真正的性能问题。
这解决办法就是虚拟Dom。
加入虚拟dom之后的浏览器更新
- 将页面改变的内容应用到虚拟 DOM 上,而不是直接应用到 DOM 上;
- 变化被应用到虚拟 DOM 上时,虚拟 DOM 并不急着去渲染页面,而仅仅是调整虚拟 DOM 的内部状态,这样操作虚拟 DOM 的代价就变得非常轻了。
- 在虚拟 DOM 收集到足够的改变时,再把这些变化一次性应用到真实的 DOM 上。
如下图:
1、创建阶段
首先依据 JSX 和基础数据创建出来虚拟 DOM(并缓存起来),它反映了初始的真实的 DOM 树的结构。
然后由虚拟 DOM 树创建出真实 DOM 树,真实的 DOM 树生成完后,再触发渲染流水线往屏幕输出页面。
2、更新阶段
如果数据发生了改变,那么就需要根据新的数据创建一个新的虚拟 DOM 树;
然后比较(原虚拟Dom树和新虚拟Dom树,此时用到了Diff算法)两个树,找出变化的地方,并把变化的地方一次性更新到真实的 DOM 树上;
最后渲染引擎更新渲染流水线,并生成新的页面。
框架中的Dom更新
如上图,可以把虚拟 DOM 看成是 MVC 的视图部分,其控制器和模型可以是redux也可以是Vuex。其具体实现过程如下:
- 图中的控制器是用来监控 DOM 的变化,一旦 DOM 发生变化,控制器便会通知模型,让其更新数据;
- 模型数据更新好之后,控制器会通知视图,通知其模型的数据发生了变化;
- 视图接收到更新消息之后,会根据模型所提供的数据来生成新的虚拟 DOM;
- 新的虚拟 DOM 生成好之后,就需要与之前的虚拟 DOM 进行比较,找出变化的节点(使用了Diff算法);
- 比较出变化的节点之后,React 将变化的虚拟节点应用到 DOM 上,这样就会触发 DOM 节点的更新;
- DOM 节点的变化又会触发后续一系列渲染流水线的变化,从而实现页面的更新。
真实Dom转化为虚拟Dom
具体实现思路:
// 将真实DOM转化为虚拟DOM
// <div /> => {tag:'div'} 元素转化
// 文本节点 => {tag:undefined,value:'文本节点'} 文本节点转化
// <div title="1" class="c" /> => { tag:'div',data:{ title:'1',class:"c" } } 多属性转化
// <div><div /></div> => {tag:'div',children:[{ tag:'div' }]}
// 用构造函数来 进行以上转换
其中进行元素节点和文本节点以及多属性节点的区分是利用了node节点的nodeType属性:
- 如果节点是一个元素节点,nodeType 属性返回 1。
- 如果节点是属性节点, nodeType 属性返回 2。
- 如果节点是一个文本节点,nodeType 属性返回 3。
- 如果节点是一个注释节点,nodeType 属性返回 8。
该属性是只读的。
大概实现代码:
class VNode {
// 构造函数
constructor( tag,data,value,type ){
// tag:用来表述 标签
// data:用来描述属性
// value:用来描述文本
// type:用来描述类型
this.tag = tag && tag.toLowerCase();//文本节点时 tagName是undefined
this.data = data;
this.value = value;
this.type = type;
this.children = [];
}
appendChild(vnode){
this.children.push(vnode);
}
}
/**
利用递归 来遍历DOM元素 生成虚拟DOM
Vue中的源码使用栈结构,使用栈存储父元素来实现递归生成
*/
function getVNode(node){
let nodeType = node.nodeType;
let _vnode = null;
if(nodeType === 1){
// 元素
let nodeName = node.nodeName;//元素名,什么标签?
let attrs = node.attributes;//属性伪数组元素上的属性
let _attrObj = {};
//attrs[i] 属性节点(nodeType == 2) 是对象
for(let i=0;i<attrs.length;i++){
//attrs[i].nodeName:属性名 attrs[i].nodeValue:属性值
_attrObj[attrs[i].nodeName] = attrs[i].nodeValue;
}
//标签名(DIV UI LI...)、所有属性对象、value值(只有文本标签有)、type类型(是元素还是文本)
_vnode = new VNode( nodeName,_attrObj,undefined,nodeType);
// 考虑node的子元素
let childNodes = node.childNodes;
for(let i = 0;i<childNodes.length;i++){
_vnode.appendChild(getVNode(childNodes[i]));//递归
}
}else if(nodeType === 3){
// 文本节点
_vnode = new VNode(undefined,undefined,node.nodeValue,nodeType);//无标签名、无属性、有value、有type
}
return _vnode;
}
// 读取根节点下dom数据,或者改成指定dom下数据
let root = document.querySelector("#root");
let vroot = getVNode ( root );//虚拟DOM
console.log(vroot);
虚拟Dom转化为真实Dom
无论是什么类型的节点,只有三种类型的节点会被创建并插入到的Dom中:元素节点、注释节点、和文本节点:
// {tag:'div'} => <div /> 元素转化
// {tag:undefined,value:'文本节点'} => 文本节点 文本节点转化
// { tag:'div',data:{ title:'1',class:"c" } } => <div title="1" class="c" /> 多属性转化
// {tag:'div',children:[{ tag:'div' }]} => <div><div /></div>
具体实现思路:
vnode数据类型:
vnode = {
tag:'div', // 以div为例
// attrs中包含插在节点上的属性,就比如类或者样式,内联样式等
attrs:{
class:'a'
}
children:[], // children之中可能是另外的vnode
}
- 将字符串转化为文本节点;
- 将数字转化为字符串再转化为文本节点;
- 将多属性节点转换为文本节点,子节点再延续上面的过程;
大概实现代码如下:
//Virtual DOM => DOM
function render(vnode, container) {
container.appendChild(_render(vnode));
}
function _render(vnode) {
// 如果是数字类型转化为字符串,然后转到生成文本节点
if (typeof vnode === 'number') {
vnode = String(vnode);
}
// 字符串类型直接就是文本节点
if (typeof vnode === 'string') {
return document.createTextNode(vnode);
}
// 普通DOM
const dom = document.createElement(vnode.tag);
if (vnode.attrs) {
// 遍历属性
Object.keys(vnode.attrs).forEach(key => {
const value = vnode.attrs[key];
dom.setAttribute(key, value);
})
}
// 子数组进行递归操作
vnode.children.forEach(child => render(child, dom));
return dom;
}
使用到的document方法解析:
1.DOM appendChild() :
appendChild() 方法向节点添加最后一个子节点。
也可以使用 appendChild() 方法从一个元素向另一个元素中移动元素。
2.DOM createElement():
createElement() 方法通过指定名称创建一个元素:
3.DOM setAttribute():
setAttribute() 方法添加指定的属性,并为其赋指定的值。
如果这个指定的属性已存在,则仅设置/更改值。
DOM-Diff算法
DOM-Diff就是一种比较算法,比较两个虚拟DOM的区别,也就是比较两个对象的区别。
也有真实DOM与虚拟DOM的比较,不过这里先只讨论虚拟DOM之间的比较。
DOM-Diff(传统)算法
处理方案: 循环递归每一个节点,如下图:
左侧树a节点依次进行如下对比:
a->e、a->d、a->b、a->c、a->a
之后左侧树其它节点b、c、d、e亦是与右侧树每个节点对比, 算法复杂度能达到O(n^2)
查找完差异后还需计算最小转换方式,最终达到的算法复杂度是O(n^3)。
将两颗树中所有的节点一一对比需要O(n²)的复杂度,在对比过程中发现旧节点在新的树中未找到,那么就需要把旧节点删除,删除一棵树的一个节点(找到一个合适的节点放到被删除的位置)的时间复杂度为O(n),同理添加新节点的复杂度也是O(n),合起来diff两个树的复杂度就是O(n³)
DOM-Diff(优化后)算法
vue和react的虚拟DOM的diff算法大致相同,其核心是基于两个简单的假设:
- 两个相同的组件产生类似的DOM结构,不同的组件产生不同的DOM结构
- 同一层级的一组节点,他们可以通过唯一的id进行区分
考虑到很少进行跨层移动,所以用平层对比,时间复杂度从O(n^3)缩短为O(n),在对比过程中直接对真实dom更新。变更一般有三种: 文本 ,节点属性,节点变更,增删节点(绑定key值的作用)
DOM-Diff三种优化策略
- web UI中DOM节点跨层级的移动操作特别少,可以忽略不计。
- 拥有相同类型的两个组件将会生成相似的树形结构,拥有不同类型的两个组件将会生成不同树形结构。
- 对于同一层级的一组自节点,他们可以通过唯一id进行区分。
也就是:
比较只会在同层级进行, 不会跨层级比较。
具体表现如下:
- 只比较平级
- 不会跨级比较
- 同一级的变化节点,如果节点相同只是位置交换,则会复用。(通过key来实现)
React优化Diff算法
基于以上优化的diff三点策略,react分别进行以下算法优化
- tree diff
- component diff
- element diff
tree diff
react对树的算法进行了分层比较。react 通过 updateDepth对Virtual Dom树进行层级控制,只会对相同颜色框内的节点进行比较,即同一个父节点下的所有子节点。当发现节点不存在,则该节点和其子节点都会被删除。这样是需要遍历一次dom树,就完成了整个dom树的对比
分层比较 :
如果是跨层级的移动操作,如图
当根结点发现A消失了,会删除掉A以及他的子节点。当发现D上多了一个A节点,会创建A(包括其子节点)节点作为子节点
所以:当进行跨层级的移动操作,react并不是简单的进行移动,而是进行了删除和创建的操作,这会影响到react性能。所以要尽量避免跨层级的操作。(例如:控制display来达到显示和隐藏,而不是真的添加和删除dom。
component diff
- 如果是组件类相同(class)的组件,则直接对比virtual Dom tree
- 如果组件类不同(结构相似)的组件,则判断为 dirty component(脏组件),整个替换掉组件以及组件下的所有子组件
- 如果组件类相同,但是可能virtual DOM 没有变化,这种情况下我们可以使用shouldComponentUpdate() 来判断是否需要进行diff
如果组件D和组件G,如果组件类不同,但是结构类似。这种情况下,因为组件类不同,所以react会删除D,创建G。所以我们可以使用shouldComponentUpdate()返回false不进行diff。
针对react15, 16出了新的生命周期
所以:component diff 主要是使用shouldComponentUpdate() 来进行优化
element diff
element diff 涉及三种操作:
- 插入
- 移动
- 删除
不使用key的情况:
不使用key的话,react对新老集合对比,发现新集合中B不等于老集合中的A,于是删除了A,创建了B,依此类推直到删除了老集合中的D,创建了C于新集合。
这样会产生渲染性能瓶颈,于是react允许添加key进行区分。
使用key的情况:
react首先对新集合进行遍历,通过唯一key来判断老集合中是否存在相同的节点,如果没有的话创建,如果有的话进行移动操作
移动优化
在移动前,会将节点在新集合中的位置(_mountIndex)和在老集合中位置(lastIndex)进行比较,如果if (child._mountIndex < lastIndex) 进行移动操作,否则不进行移动操作。这是一种顺序移动优化。只有在 新集合的位置 小于 在老集合中的位置 才进行移动。
如果遍历的过程中,发现在新集合中没有,但是在老集合中的节点,会进行删除操作
所以:element diff 通过唯一key 进行diff 优化。
总结:
- react中尽量减少跨层级的操作。
- 可以使用shouldComponentUpdate() 来避免react重复渲染。
- 尽量添加唯一key,以减少不必要的重渲染
Vue2.x优化Diff
vue2.0加入了virtual dom,和react拥有相同的 diff 优化原则
差异就在于, diff的过程就是调用patch函数,就像打补丁一样修改真实dom。也就是通过js层面的计算,根据两个虚拟对象创建出差异的补丁对象patch,用来描述改变了哪些内容,然后用特定的操作解析patch对象,更新dom完成页面的重新渲染。
使用的主要方法:
- patchVnode
- updateChildren
updateChildren是vue diff的核心
过程可以概括为:oldCh和newCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和newCh至少有一个已经遍历完了,就会结束比较。
Vue2的核心Diff算法采用了双端比较的算法,同时从新旧children的两端开始进行比较,借助key值找到可复用的节点,再进行相关操作。相比React的Diff算法,同样情况下可以减少移动节点次数,减少不必要的性能损耗,更加的优雅。
Vue 3.x
Vue3.x借鉴了 ivi算法和 inferno算法。在创建VNode时就确定其类型,以及在mount/patch的过程中采用位运算来判断一个VNode的类型,在这个基础之上再配合核心的Diff算法,使得性能上较Vue2.x有了提升。
该算法中还运用了动态规划的思想求解最长递归子序列。
参考及复制文章链接
- 彻底搞懂虚拟Dom到真实Dom的生成过程 - 浅笑· - 博客园
- DIff算法优化策略_马超19991128的博客-CSDN博客
- react学习笔记1(diff算法和Component相关)_weixin_41837346的博客-CSDN博客
- DOM-DIFF原理及实现 - 简书
- 浏览器工作原理:浅析浏览器中的页面 - 虚拟DOM与实际DOM有何不同 - 古兰精 - 博客园
- 虚拟dom到真实dom - 简书
- 虚拟dom转化到真实dom - 简书
引用不分前后
更多推荐
所有评论(0)