「大白话」虚拟DOM和DOM diff

770 阅读8分钟

前言

程序员向来推崇简单好用的设计思想,这也催生了各种优秀框架的繁荣,我们能够享受到开箱即用的便利都来源于程序员们不屑的努力。

现如今谈及前端框架都对react和vue如雷贯耳,由于这些优秀好用框架的盛行,它们共同基于虚拟DOM来渲染浏览器的思想成为目前前端领域中比较重要的知识,这部分知识聚集了顶尖大神们共同思考的智慧,在这样的背景下,虚拟DOM和DOM diff算法就成为面试常考题。

刚开始听到虚拟DOM时,我一直觉得这是个高大上的名词(前端领域总是有各种各样高大上的名词,其实互联网都有这个毛病),直到接触后才知道原来这是非常基础的东西通过各种方法变身,我不由得更加敬佩框架开创者们的聪明脑袋。

话不多说,我们直接来谈谈什么是虚拟dom,它有什么优缺点

虚拟DOM是什么

虚拟DOM是一个能代表 DOM 树的对象,通常含有标签名、标签上的属性、事件监听和子元素们,以及其他属性

我们直接看react的虚拟dom的真实呈现

const vNode={
   key:null,
   props:{ 
     children:[
     {type: 'span', ...},{type: 'span', ...}//子元素们
     ]
     className:'red',//标签属性
     onClick:()=>{} //事件
   },
   ref:null,
   type:'div', //标签名
   ...
}

再来看一段Vue的

const vNode = {
  tag: "div", // 标签名 or 组件名
  data: {
    class: "red", // 标签上的属性
    on: {
      click: () => {} // 事件
    }
  },
  children: [ // 子元素们
    { tag: "span", ... },
    { tag: "span", ... }
  ],
  ...
}

可以看得出来,虚拟DOM是一个JS对象,里面有各种数据解构,Vue跟React的虚拟DOM几乎一样。

创建虚拟DOM

上面的虚拟dom要怎样来创建出来呢?

react使用了一个叫creatElement的函数,它是这样写的

createElement('div',{className:'red',onClick:()=> {}},[
    createElement('span', {}, 'span1'),
    createElement('span', {}, 'span2')
  ]
)

如果真的要这样写代码,那可真的丑的没边了,于是大神们想到使用编译器来编译上面的代码,让它变得更简单,于是就有了JSX

<div className="red" onClick={fn}>
    <span>span1</span>
    <span>span2</span>
</div>

上面的JSX会被babel编译,最后成为原始createlement的形式。

vue是使用vue-loader 进行编译的

创建了虚拟DOM,怎样渲染呢

回顾一下dom是怎样的呢?

<div class='dom'>
  <ul>
    <li></li>
    <li></li>
  <ul/>
</div>

上面就是一个dom树,那么我们来结合react的虚拟dom来分析一下dom结构树。

首先,上面的dom中,外层的是一个div的type,class(className)为dom,里面有个children,type是ul...

如果用js对象来表示呢?

{
   type:'div', //标签名
   props:{ 
     children:[
       {type: 'ul', 
          props:{
             children:[{type:'li'..}]}
       }//子元素们
     ]
     className:'dom',//标签属性
   }, 
   ...
}

上面的代码我们需要注意的点是标签名typechildrenclassName,现在我们好像知道了要插入的节点信息,是不是可以尝试写一个函数复用?

下面的代码不要细究语法,是用伪代码写的。

function render(type,className,children){
   let node=document.createNode(type);//创建外层div
   node.style.className=className //设置外层div的className
   let childNode=children.map((c)=>{
     return render(c.type,c.className,c.children)
     ...
   }) //遍历它的children属性
   node.append(childNode) //最后插入到node节点中
   return node
}
document.querySelector('#root').append(render('div','dom',[]))
//最后把整个创建的node插入到浏览器已有的根节点(id为root)中

但是因为元素的内容太多了,我们的参数也会很多,所以我们就使用一个对象来包裹所有跟元素相关的属性,并且把要root节点也放到函数参数内,这样就只需要传入两个参数,一个是JS对象(包含要插入节点的信息),一个是需要插入的节点。

这个函数是这样调用的。

render({type:'div',className:'dom'...},rootNode)

再来对比一下React渲染函数

//JSX
const App:FC=()=>{ 
   return <div className='demo'>这是一个简单的demo</div>
   // 翻译成return createElement('div',{className:'demo'},...)
   //createElement会返回JS对象---虚拟dom
}

ReactDOM.render(
    <App /> 
    //这里的App就是执行App() 获得一个JS对象---虚拟dom
  ,
  document.getElementById("root")
);
// render函数执行后的参数是虚拟dom对象和要插入在哪个节点内
//render是渲染用的函数

那么我们可以得出,JSX语法是用来得到一个JS对象的,这个JS对象就是虚拟DOM,最后的这个JS对象会随着ReactDOM.render变成被渲染成浏览器节点

react在中间做了更多工作,上面所有只是对其思想的一次简单介绍。

虚拟DOM更快?

江湖流言,虚拟DOM比普通DOM操作更快,很多前端都深信不疑,但是存在一个逻辑问题,虚拟DOM是用来对DOM进行操作(增删改查)的,怎么就会比原生DOM操作要快呢?

而且各大浏览器厂商也不傻,如果真的存在这种情况,那么都使用虚拟DOM来优化渲染引擎不是更棒的方案吗?为什么浏览器厂商不用呢,显然虚拟DOM更快是一种片面理解。

结合这个问题,我们来谈一谈虚拟DOM的优点--结合DOM diff算法

虚拟DOM的优点

  • 1、减少次数 例如添加节点,我们可以通过innerHTML或者append插入,但是实践证明,假设插入1000个节点,使用append方式和innerHTML字符串的方式效率并不高,那么虚拟DOM就是通过把节点情况都存入数组,然后再一次性渲染出来,这样能够有效将原本的1000次合并成一次,在减少次数的情况下,浏览器渲染效率提高了,优化变好了。
var arr = [];
var a = '<a href="javascript=;">添加</a>';
for (var j = 0; j < 100; j++) {
   arr.push(a); //把a都丢入数组中
	}
div.innerHTML = arr.join('') //把数组变成字符串 然后让innerHtml获取,得到添加元素的效果

使用数组的方式可以减少开辟字符串空间,上面的代码就是使用数组的方式增加100次子节点。

  • 2、减少范围 DOMdiff算法可以把多余的操作省略掉,比如已经在页面中渲染的,那么DOMdiff不回将他重复执行渲染,而是把多出来的部分给渲染出来
  • 3、跨平台 虚拟DOM支持小程序、IOS应用、安卓应用等多平台,因为本身它就是一个JS对象,自然支持各依赖环境平台的语法。

虚拟DOM的缺点

需要额外创建函数,也就是上面所说的很难写的createElement或者Vueh函数,不过这种方式已经被克服,React采用JSX语法,而Vue则通过模板语法。

//JSX
const App:FC=()=>{ 
   return <div className='demo'>这是一个简单的demo</div>
   // 翻译成return createElement('div',{className:'dom'},...)
}

ReactDOM.render(
    <App /> 
    //这里的App就是执行App() 获得一个JS对象---虚拟dom
  ,
  document.getElementById("root")
);
// render函数执行后的参数是虚拟dom对象和要插入在哪个节点内
//render是渲染用的函数

DOM diff是什么

DOM diff算法就是针对虚拟DOM的不同做的对比算法。

它本质上就是一个函数,我们称之为patch函数。它有两个参数,分别是旧的虚拟DOM和新的虚拟DOM

patches=patch(oldVnode,newVnode)

pathes就是调用patch函数后产生的不同的比较结果,这个结果会用来做DOM操作,它长得类似这个

[
  {type: 'INSERT', vNode: ... },
  {type: 'TEXT',  vNode: ... },
  {type: 'PROPS', propsPatch: [...]}
]

它是如何做比较的呢,来看下面的一段代码

<div :class="x">
    <span v-if="y">{string1}</span>
    <span>{string2}</span>
</div>

它的数结构可以画图表示

在这个树上,所有结构都是可以变化的,包括textNode,现在我们假设divclass变成red,于是整个patch函数就会开始遍历,遍历时,会对整个遍历结果进行记录。

上面的比较结果会变成下面的形式

下面我们再变化,将第一个span删除。 跟我们的预期不一样,它会认为是第一个span更新了,第二个span删除了。这是因为Vnodechildren是一个数组。

const arr=[1,2,3]
arr.splice(1,1) //删除2
arr // [1,3]

diff算法会认为上面的操作是删除了第三个数,把第二个数2改成了3。

那么有什么办法可以让diff算法也知道人类的思想呢?答案就是使用key(react跟vue都需要使用单独的key,这个key在同属于一个父节点的各兄弟节点上需要不同。)

//jsx语法
<div>
{[{id:1,value:1},{id:2,value:2},{id:3,value:3}].map((v)=>{
   return <span key={v.id}/>
})}
</div>

有了这个keydiff算法就可以知道原来我们删的是这个div

key解决了什么问题

codesandbox.io/s/vue-templ… 打开上面的链接,我们依次进行操作

一、把文本框的内容都修改成这样

二、删除第二个,我们预想的结果

三、实际的结果

为什么会出现这样的情况?这是因为diff算法认为我们是把第三个删除了,把第二个文本框里面的value给修改了。

当我们使用一个不同的key绑定在各个节点上时,此问题就会解除,因为diff知道了我们人类的意图。这也是key为什么会存在于vDOM上的原因。

解决方法: codesandbox.io/s/vue-templ…

Vue官方文档解释 cn.vuejs.org/v2/api/#key

总结

虚拟DOM是什么?虚拟DOM就是一个能代表 DOM 树的对象,通常含有标签名、标签上的属性、事件监听和子元素们,以及其他属性

虚拟DOM的优点是减少不必要的操作(减少范围),减少DOM操作的次数

虚拟DOM的缺点React和Vue分别采用各自的语法以弥补其缺陷

Dom diff就是为了对DOM进行动态化操作所做的算法处理,其主要是通过旧的虚拟dom跟新的虚拟dom做层级对比,进行挨个对比之后,形成一套优化的算法,这种算法的好处是无需我们关心内层,只需识别我们对数据的操作,再返回一个patch对象,通过这个对象来进行DOM操作,减少不必要的性能耗时,例如避免重复操作等。

我们还应当关注虚拟DOM的key问题,在写代码时,最好使用不一样的key来避免一些bug,这个key最好不要是index,而是一个不重复id。