1、虚拟 DOM 是个啥?
虚拟 DOM 和 DOM diff 这两个概念在 Vue 和 React 中经常被提到,也是作为面试时的一个区分初级和高级前端的知识点,这两个概念可以说是非常重要了,但是其实只要你仔细研究一下就会发现它实际上并没有什么难度!
虚拟 DOM 与真实 DOM 相对应,它其实就是通过 JavaScript 对象来模拟真实的 DOM 树。只不过这个 JavaScript 对象比较特殊,它内部通常含有标签名、标签上的属性、事件监听和子元素,以及一些其他属性。
为什么要用虚拟 DOM 来模拟真实的 DOM 树,这样难道不像是脱裤子放屁一样吗,直接用真实 DOM 不好吗?
作为前端开发人员都知道一个名为 jQuery 的库,据统计,全世界排名前100万的网站,有 46% 使用 jQuery,远远超过其他库,感兴趣的同学可以看一下我的这篇博客:作为一个前端新人,还要不要学 jQuery,但是最近的这几年 jQuery 仿佛开始慢慢退出历史的舞台,渐渐的被现如今流行的 Vue、React、Angular 三大框架所代替。
其实通过上面的这个事情,我们就可以发现一些蛛丝马迹,为什么会出现这种现象,一定是 jQuery 存在某些方面的缺陷。
其实有一部分原因其实就是 jQuery 一直是基于原生 JavaScript API 封装的一个直接操作真实 DOM 的库,而如今流行的 Vue、React、Angular 三大框架是基于虚拟 DOM 的,为什么基于真实 DOM 思想的库慢慢被遗忘,基于虚拟 DOM 的框架越来越被开发人员所追捧,虚拟 DOM 到底是有什么魔法?
2、虚拟 DOM 的优点?
不知道大家有没有听说过 "DOM 操作慢,虚拟 DOM 快!" 这样的言论?这句言论的出处应该是 2010 年出版的一本高性能的 JavaScript 书中的观点,但是书上却并没有给出任何数据来证实这一点。
就个人观点而言,感觉 "DOM 操作慢,虚拟 DOM 快!" 这句话不够严谨,就比如有人说宇宙飞船快,确实快,但是和光速相比呢?DOM 操作慢是对比于 JS 原生 APl 来说的,如 DOM 操纵确实比数组操作要慢,不过任何基于 DOM 的库(Vue/React)都不可能在操作 DOM 时比 DOM 更快。
那为什么网上还是会有 "虚拟 DOM 快" 的言论呢?这是因为在某些情况下,虚拟 DOM 确实要更快。
优点一:虚拟 DOM 可以减少 DOM 操作
虚拟 DOM 可以将多次操作合并为一次操作:比如需要向页面中添加 1000 个节点,基于真实 DOM 的原生 JavaScript 是一个接一个操作,将 1000 个节点插入到页面上。而虚拟 DOM 则是会先用 JavaScript 对象模拟 DOM 树,先在该对象上执行 1000 次添加节点的操作,然后将这个虚拟 DOM 一次性的插入到页面上。
虚拟 DOM 借助 DOM diff 可以把多余的操作省掉,比如你添加 1000 个节点,其实页面上已经存在了前 9990 个节点了,只有 10 个是新增的,基于真实 DOM 的原生 JavaScript 会先将原来的 9990 个节点删掉,然后再将这 10000 个节点添加到页面上。而虚拟 DOM 可以借助 DOM diff 算法,对比两棵 DOM 树的不同点,得到最小更新的 patch(就是那 10 个节点),只将这 10 个新节点插入到页面上即可。
优点二:虚拟 DOM 的跨平台性
虚拟 DOM 不仅可以变成真实 DOM,还可以变成小程序、iOS 应用、安卓应用,因为虚拟 DOM 本质上只是一个 JS 对象。
3、虚拟 DOM 的真面目?
说完虚拟 DOM 的优点,我们来看一下这个虚拟 DOM 的真面目。文章开头也说了,虚拟 DOM 其实就是一个 JavaScript 对象,在 Vue 和 React 中可能会稍有不同,下面带大家分别来看一下:
首先是 React 的虚拟 DOM,在 React 中,使用 React.createElement() 来创建虚拟 DOM,具体如下:
const VNode = { /*React 中的虚拟 DOM*/ React.createElement('div',{className:'red',onClick:()=>{}}, [
key: null, React.createElement('span',{},'span1'),
props: { React.createElement('span',{},'span1'),
children: [ /*子元素s*/ ]) /*React 创建虚拟 DOM 的步骤*/
{type:'span', ...}, //这种写法比较麻烦,React支持jsx的写法
{type:'span', ...} //原理是通过babel将jsx转义为上述这种写法
],
className: "red" //标签上的属性
onClick: () => {} //事件
},
ref: null,
type: "div", //标签名or组件名
...
}
然后是 Vue 的虚拟 DOM,在 Vue 中只能在 render 函数中使用 h() 来创建虚拟 DOM,具体如下:
const VNode = { /*Vue 中的虚拟 DOM*/
tag: "div", /*标签名或组件名*/
data: {
class: "red", /*标签上的属性*/
on:{ click: ()=>{} /*事件*/}
},
children: [ //子元素s
{tag:"span", ...},
{tag:"span", ...}
],
...
}
h('div', { class: 'red',on: {click: () => {}} }, /*创建虚拟 DOM 的过程*/
[h('span', {}, 'span1'),h('span', {}, 'span2')] //这种写法写法比较麻烦,Vue 支持template写法
) //原理是通过compiler或vue-loader转义成上述这种h函数的写法
以上就是 Vue 和 React 创建虚拟 DOM 的过程了,你可能会发现:上述创建虚拟 DOM 的方式特别麻烦,长得丑且对开发人员特别不友好,这是虚拟 DOM 的第一个缺点。为了解决这个问题,React 和 Vue 分别推出了自己的解决方式。
React 通过 babel 转义,支持开发人员书写形如 html in js 的代码,这些代码最终会被 babel 转义为真的虚拟 DOM,而 Vue 则是走 vue-loader,将 Vue 中的 template 中写的 html 代码加载为真的虚拟 DOM。但是这样一来虚拟 DOM 的转义又非常依赖打包工具,这是虚拟 DOM 的第二个缺点。
React 和 Vue 两者在使用打包工具后的虚拟 DOM 写法可能和真实的 html 存在一定的差异,这里就不详细赘述了。
4、DOM diff 又是个啥?
DOM diff 其实就是虚拟 DOM 的对比算法,该算法主要是对比两棵虚拟 DOM 的不同点。在 Vue 和 React 中,两者的共同点是在 DOM diff 时都是:"只进行同级节点的比较,忽略跨级节点"。当然二者也有不同点,比如 Vue 内含有双指针对比,这里先不做解释,感兴趣的小伙伴可以自行研究。
这里通过例子来描述一下 DOM diff 的全过程,比如下面的虚拟 DOM,通过控制变量 visible 的值来动态的向 div 中添加和删除该节点,在添加和删除的过程中,就会有 DOM diff 算法的过程:
<!--visible 为 true--> <!--visible 为 false-->
<div :class="color"> <div :class="color">
<span v-if="visible">hello</span> <span v-if="visible">hello</span>
<span>world</span> <span>world</span>
</div> </div>
当 visible 变量从 true 变为 false 时,DOM diff 算法会发现,div 没变,不需要更新。第一个子元素的标签没变,但是内容发生变化了,更新 DOM。第二个子元素没了,删除其对应的 DOM。
其实这和我们常人的想想还是有所不同的,经过上面的 DOM diff 后,现在 div 内剩下的是一个内容为 hello 的 span 元素。但是这个 span 元素却并不是一开始的 hello 的 span,而是 world 改为的 hello。
上述 diff 过程其实是存在 bug 的,既然是删除第一个 span,为什么不直接删除呢?记住这个点,这个问题可以优化!
总结一下:DOM diff 其实就是一个函数,我们通常称之为 patch,这个函数接收两个虚拟 DOM 对象,然后通过函数内部的逻辑,找出两棵虚拟 DOM 的区别:patches = patch(oldVNode, newVNode) ,而这个 patches 就是需要更新的 DOM 操作。
对比过程中大概分为以下几种逻辑:
- Tree diff:将新旧两棵树逐层对比,找出哪些节点需要更新。如果节点是组件就走 Component diff,否则就走 Element diff
- Component diff:节点是组件,类型不同直接替换(删旧),类型相同则只更新属性,然后深入组件做 Tree diff(递归)
- Element diff:节点是原生标签,看标签名,标签名不同直接替换,相同则只更新属性,后进入标签后代做 Tree diff(递归)
5、DOM diff 中 key 的作用?
刚才说 DOM diff 是存在 bug 的,既然是删除第一个 span,为什么不直接删除呢?反而还浪费第二次 diff 的性能。
有问题就会有解决方式,Vue 和 React 都建议在执行列表渲染时给每个组件添加上 key 这个属性,这个 key 属性就是节点的唯一标识,有了这个标识再看刚才的例子,DOM diff 时发现第一个 span 的 key 不见了,就直接删掉第一个 span 元素即可。
有了这个 key,就会使 DOM diff 的过程变得更加高效,但是在使用的时候,这个 key 也是有注意点的: key 的值必须使一个唯一的且不可变的值,否则在某些情况下会出问题的。
至于什么问题,感兴趣的小伙伴可以参考一下我的这篇博客:Vue - v-for 中为什么不能用 index 作为 key
先说结论:在列表渲染时,key 值的绑定对象最好不要使用 index。
为什么不能用 index 作为 key,如果你用 index 作为 key,那么在删除第二项的时候,index 就会从 1 2 3 变成 1 2(而不是 1 3),那么 Vue 依然会认为你删除的是第三项。也就是会遇到上面一样的 bug。
所以,永远不要用 index 作为 key。永远不要!除非你是大神。能清楚地知道如何解决 index做 key 带来的 bug。有人说简单的场景可以用 key。问题在于,你如何确保需求会一直保持简单?只要出现了删除一项或新增一项的需求,而且这一项里面含有子组件,上面说的 bug 就有可能出现。