Vue为什么需要虚拟DOM?优缺点有哪些
由于在浏览器中操作 DOM 是很昂贵的。频繁的操作 DOM,会产生一定的性能问题。这就是虚拟 Dom 的产生原因。Vue2 的 Virtual DOM 借鉴了开源库 snabbdom 的实现。Virtual DOM 本质就是用一个原生的 JS 对象去描述一个 DOM 节点,是对真实 DOM 的一层抽象
优点:
保证性能下限:
框架的虚拟 DOM 需要适配任何上层 API 可能产生的操作,它的一些 DOM 操作的实现必须是普适的,所以它的性能并不是最优的;但是比起粗暴的 DOM 操作性能要好很多,因此框架的虚拟 DOM 至少可以保证在你不需要手动优化的情况下,依然可以提供还不错的性能,即保证性能的下限;无需手动操作 DOM:
我们不再需要手动去操作 DOM,只需要写好 View-Model 的代码逻辑,框架会根据虚拟 DOM 和 数据双向绑定,帮我们以可预期的方式更新视图,极大提高我们的开发效率;跨平台:
虚拟 DOM 本质上是 JavaScript 对象,而 DOM 与平台强相关,相比之下虚拟 DOM 可以进行更方便地跨平台操作,例如服务器渲染、weex 开发等等。
缺点:
无法进行极致优化:
虽然虚拟 DOM + 合理的优化,足以应对绝大部分应用的性能需求,但在一些性能要求极高的应用中虚拟 DOM 无法进行针对性的极致优化。- 首次渲染大量DOM时,由于多了一层虚拟 DOM 的计算,会比 innerHTML 插入慢。
虚拟 DOM 实现原理?
虚拟 DOM 的实现原理主要包括以下 3 部分:
- 用 JavaScript 对象模拟真实 DOM 树,对真实 DOM 进行抽象;
- diff 算法 — 比较两棵虚拟 DOM 树的差异;
- pach 算法 — 将两个虚拟 DOM 对象的差异应用到真正的 DOM 树。
回答范例
- 虚拟dom顾名思义就是虚拟的dom对象,它本身就是一个 JavaScript 对象,只不过它是通过不同的属性去描述一个视图结构
- 通过引入vdom我们可以获得如下好处:
- 将真实元素节点抽象成 VNode,有效减少直接操作 dom 次数,从而提高程序性能
- 直接操作 dom 是有限制的,比如:diff、clone 等操作,一个真实元素上有许多的内容,如果直接对其进行 diff 操作,会去额外 diff 一些没有必要的内容;同样的,如果需要进行 clone 那么需要将其全部内容进行复制,这也是没必要的。但是,如果将这些操作转移到 JavaScript 对象上,那么就会变得简单了
- 操作 dom 是比较昂贵的操作,频繁的dom操作容易引起页面的重绘和回流,但是通过抽象 VNode 进行中间处理,可以有效减少直接操作dom的次数,从而减少页面重绘和回流
- 方便实现跨平台
- 同一 VNode 节点可以渲染成不同平台上的对应的内容,比如:渲染在浏览器是 dom 元素节点,渲染在 Native( iOS、Android)变为对应的控件、可以实现 SSR 、渲染到 WebGL 中等等
- Vue3 中允许开发者基于 VNode 实现自定义渲染器(renderer),以便于针对不同平台进行渲染
- 将真实元素节点抽象成 VNode,有效减少直接操作 dom 次数,从而提高程序性能
- vdom如何生成?在vue中我们常常会为组件编写模板 - template, 这个模板会被编译器 - compiler编译为渲染函数,在接下来的挂载(mount)过程中会调用render函数,返回的对象就是虚拟dom。但它们还不是真正的dom,所以会在后续的patch过程中进一步转化为dom。
- 挂载过程结束后,vue程序进入更新流程。如果某些响应式数据发生变化,将会引起组件重新render,此时就会生成新的vdom,和上一次的渲染结果diff就能得到变化的地方,从而转换为最小量的dom操作,高效更新视图
为什么要用vdom?案例解析
[ { name: "张三", age: "20", address: "北京"}, { name: "李四", age: "21", address: "武汉"}, { name: "王五", age: "22", address: "杭州"},
]
将该数据展示成一个表格,并且随便修改一个信息,表格也跟着修改。 用jQuery实现如下:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta http-equiv="X-UA-Compatible" content="ie=edge"><title>Document</title>
</head><body><div id="container"></div><button id="btn-change">改变</button><script src="https://cdn.bootcss.com/jquery/3.2.0/jquery.js"></script><script>const data = [{name: "张三",age: "20",address: "北京"},{name: "李四",age: "21",address: "武汉"},{name: "王五",age: "22",address: "杭州"},];//渲染函数function render(data) {const $container = $('#container');$container.html('');const $table = $('<table>');// 重绘一次$table.append($('<tr><td>name</td><td>age</td><td>address</td></tr>'));data.forEach(item => {//每次进入都重绘$table.append($(`<tr><td>${item.name}</td><td>${item.age}</td><td>${item.address}</td></tr>`))})$container.append($table);}$('#btn-change').click(function () {data[1].age = 30;data[2].address = '深圳';render(data);});</script>
</body>
</html>
- 这样点击按钮,会有相应的视图变化,但是你审查以下元素,每次改动之后,table标签都得重新创建,也就是说table下面的每一个栏目,不管是数据是否和原来一样,都得重新渲染,这并不是理想中的情况,当其中的一栏数据和原来一样,我们希望这一栏不要重新渲染,因为DOM重绘相当消耗浏览器性能。
- 因此我们采用JS对象模拟的方法,将DOM的比对操作放在JS层,减少浏览器不必要的重绘,提高效率。
- 当然有人说虚拟DOM并不比真实的DOM快,其实也是有道理的。当上述table中的每一条数据都改变时,显然真实的DOM操作更快,因为虚拟DOM还存在js中diff算法的比对过程。所以,上述性能优势仅仅适用于大量数据的渲染并且改变的数据只是一小部分的情况。
如下DOM结构:
<ul id="list"><li class="item">Item1</li><li class="item">Item2</li>
</ul>
映射成虚拟DOM就是这样:
{tag: "ul",attrs: {id: "list"},children: [{tag: "li",attrs: { className: "item" },children: ["Item1"]}, {tag: "li",attrs: { className: "item" },children: ["Item2"]}]
}
使用snabbdom实现vdom
这是一个简易的实现vdom功能的库,相比vue、react,对于vdom这块更加简易,适合我们学习vdom。vdom里面有两个核心的api,一个是h函数,一个是patch函数,前者用来生成vdom对象,后者的功能在于做虚拟dom的比对和将vdom挂载到真实DOM上
简单介绍一下这两个函数的用法:
h('标签名', {属性}, [子元素])
h('标签名', {属性}, [文本])
patch(container, vnode) // container为容器DOM元素
patch(vnode, newVnode)
现在我们就来用snabbdom重写一下刚才的例子:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta http-equiv="X-UA-Compatible" content="ie=edge"><title>Document</title>
</head>
<body><div id="container"></div><button id="btn-change">改变</button><script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom.js"></script><script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-class.js"></script><script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-props.js"></script><script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-style.js"></script><script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-eventlisteners.min.js"></script><script src="https://cdn.bootcss.com/snabbdom/0.7.3/h.js"></script><script>let snabbdom = window.snabbdom;// 定义patchlet patch = snabbdom.init([snabbdom_class,snabbdom_props,snabbdom_style,snabbdom_eventlisteners]);//定义hlet h = snabbdom.h;const data = [{name: "张三",age: "20",address: "北京"},{name: "李四",age: "21",address: "武汉"},{name: "王五",age: "22",address: "杭州"},];data.unshift({name: "姓名", age: "年龄", address: "地址"});let container = document.getElementById('container');let vnode;const render = (data) => {let newVnode = h('table', {}, data.map(item => { let tds = [];for(let i in item) {if(item.hasOwnProperty(i)) {tds.push(h('td', {}, item[i] + ''));}}return h('tr', {}, tds);}));if(vnode) {patch(vnode, newVnode);} else {patch(container, newVnode);}vnode = newVnode;}render(data);let btnChnage = document.getElementById('btn-change');btnChnage.addEventListener('click', function() {data[1].age = 30;data[2].address = "深圳";//re-renderrender(data);})</script>
</body>
</html>
你会发现,只有改变的栏目才闪烁,也就是进行重绘,数据没有改变的栏目还是保持原样,这样就大大节省了浏览器重新渲染的开销
vue中使用h函数生成虚拟DOM返回
const vm = new Vue({el: '#app',data: {user: {name:'poetry'}},render(h){// h()// h(App)// h('div',[])let vnode = h('div',{},'hello world');return vnode}
});
Vue中diff算法原理
DOM操作是非常昂贵的,因此我们需要尽量地减少DOM操作。这就需要找出本次DOM必须更新的节点来更新,其他的不更新,这个找出的过程,就需要应用diff算法
vue的diff算法是平级比较,不考虑跨级比较的情况。内部采用深度递归的方式+双指针(头尾都加指针)的方式进行比较。
简单来说,Diff算法有以下过程
-
同级比较,再比较子节点(根据key和tag标签名判断)
-
先判断一方有子节点和一方没有子节点的情况(如果新的children没有子节点,将旧的子节点移除)
-
比较都有子节点的情况(核心diff)
-
递归比较子节点
-
正常Diff两个树的时间复杂度是O(n3),但实际情况下我们很少会进行跨层级的移动DOM,所以Vue将Diff进行了优化,从O(n3) -> O(n),只有当新旧children都为多个子节点时才需要用核心的Diff算法进行同层级比较。
-
Vue2的核心Diff算法采用了双端比较的算法,同时从新旧children的两端开始进行比较,借助key值找到可复用的节点,再进行相关操作。相比React的Diff算法,同样情况下可以减少移动节点次数,减少不必要的性能损耗,更加的优雅
-
在创建VNode时就确定其类型,以及在mount/patch的过程中采用位运算来判断一个VNode的类型,在这个基础之上再配合核心的Diff算法,使得性能上较Vue2.x有了提升
vue3中采用最长递增子序列来实现diff优化
回答范例
- Vue中的diff算法称为patching算法,它由Snabbdom修改而来,虚拟DOM要想转化为真实DOM就需要通过patch方法转换
- 最初Vue1.x视图中每个依赖均有更新函数对应,可以做到精准更新,因此并不需要虚拟DOM和patching算法支持,但是这样粒度过细导致Vue1.x无法承载较大应用;Vue 2.x中为了降低Watcher粒度,每个组件只有一个Watcher与之对应,此时就需要引入patching算法才能精确找到发生变化的地方并高效更新
- vue中diff执行的时刻是组件内响应式数据变更触发实例执行其更新函数时,更新函数会再次执行render函数获得最新的虚拟DOM,然后执行patch函数,并传入新旧两次虚拟DOM,通过比对两者找到变化的地方,最后将其转化为对应的DOM操作
- patch过程是一个递归过程,遵循深度优先、同层比较的策略;
以vue3的patch为例
- 首先判断两个节点是否为相同同类节点,不同则删除重新创建
- 如果双方都是文本则更新文本内容
- 如果双方都是元素节点则递归更新子元素,同时更新元素属性
- 更新子节点时又分了几种情况
- 新的子节点是文本,老的子节点是数组则清空,并设置文本;
- 新的子节点是文本,老的子节点是文本则直接更新文本;
- 新的子节点是数组,老的子节点是文本则清空文本,并创建新子节点数组中的子元素;
- 新的子节点是数组,老的子节点也是数组,那么比较两组子节点,更新细节blabla
- vue3中引入的更新策略:静态节点标记等
vdom中diff算法的简易实现
以下代码只是帮助大家理解diff算法的原理和流程
1.将vdom转化为真实dom:
const createElement = (vnode) => {let tag = vnode.tag;let attrs = vnode.attrs || {};let children = vnode.children || [];if(!tag) {return null;}//创建元素let elem = document.createElement(tag);//属性let attrName;for (attrName in attrs) {if(attrs.hasOwnProperty(attrName)) {elem.setAttribute(attrName, attrs[attrName]);}}//子元素children.forEach(childVnode => {//给elem添加子元素elem.appendChild(createElement(childVnode));})//返回真实的dom元素return elem;
}
2.用简易diff算法做更新操作
function updateChildren(vnode, newVnode) {let children = vnode.children || [];let newChildren = newVnode.children || [];children.forEach((childVnode, index) => {let newChildVNode = newChildren[index];if(childVnode.tag === newChildVNode.tag) {//深层次对比, 递归过程updateChildren(childVnode, newChildVNode);} else {//替换replaceNode(childVnode, newChildVNode);}})
}