前言
程序员向来推崇简单好用的设计思想,这也催生了各种优秀框架的繁荣,我们能够享受到开箱即用的便利都来源于程序员们不屑的努力。
现如今谈及前端框架都对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',//标签属性
},
...
}
上面的代码我们需要注意的点是标签名type
,children
和className
,现在我们好像知道了要插入的节点信息,是不是可以尝试写一个函数复用?
下面的代码不要细究语法,是用伪代码写的。
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
或者Vue
的h
函数,不过这种方式已经被克服,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
,现在我们假设div
的class
变成red
,于是整个patch函数就会开始遍历,遍历时,会对整个遍历结果进行记录。
上面的比较结果会变成下面的形式
下面我们再变化,将第一个span删除。
跟我们的预期不一样,它会认为是第一个span更新了,第二个span删除了。这是因为Vnode
中children
是一个数组。
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>
有了这个key
,diff
算法就可以知道原来我们删的是这个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。