前端面试题:你能讲一下对Vue中虚拟DOM及Diff算法的理解吗?

vue采用的虚拟dom对比实现最小化视图更新,相比以前jquery操作真实dom,大大提高提高页面的渲染性能,提升用户体验。那为什么虚拟dom提高了性能,虚拟dom又是如何对比的?本文将带你一步步分析其工作原理。

一、虚拟DOM

虚拟DOM通常用英文virtual DOM来表达,有时会简写为vdom。虚拟DOM和真实DOM的结构一样,都是由一个个节点组成的树型结构。所以,我们常能听到“虚拟节点”这样的词,即virtual node,有时也会简写为vnode。

虚拟DOM的本质是JavaScript对象,并且最少包含标签名( type)、属性(attrs)和子元素对象( children)三个属性。不同的框架对这三个属性的命名会有点差别。总而言之,虚拟dom就是用js对象来描述dom节点。

const title = {

// 标签名称

type: "h1",

// 标签属性

props: {

onclick: handler,

},

// 子节点

children: [

{

tag: "span",

},

],

};

对应到Vue.js模板,其实就是:

<h1 @click="handler"><span></span></h1>

其实我们在Vue.js组件中手写渲染函数就是使用虚拟DOM来描述UI的,如以下代码所示:

import { h } from "vue";

export default {

render() {

return h("h1", { onclick: handler });// 虚拟DOM

},

};

此处h函数调用,也不是JavaScript对象,怎么是虚拟DOM呢?其实h函数的返回值就是一个对象,其作用是让我们编写虚拟DOM更加方便。如果把上面的h函数调用的代码改成JavaScript对象,就需要写更多的内容:

export default {

render() {

return {

// 标签名称

type: "h1",

// 标签属性

props: {

onclick: handler,

},

};

},

};

如果还有子节点,那么需要编写的内容就更多了,所以h函数就是一个辅助创建虚拟DOM的工具函数,仅此而已。

二、Diff算法

了解Diff算法

简单来说,当新旧vnode的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作Diff算法。我们知道,操作DOM的性能开销通常比较大,而Diff算法就是为了解决这个问题诞生的。

Diff算法的过程就是Vue里面patch函数的调用,比较新旧节点,一边比较一边给真实的DOM打补丁。它的核心是参考 Snabbdom,通过新旧虚拟 DOM 对比(即 patch 过程),找出最小变化的地方转为进行 DOM 操作。

在页面首次渲染的时候会调用一次 patch 并创建新的 vnode,不会进行更深层次的比较。

然后是在组件中数据发生变化时,会触发 setter 然后通过 Notify 通知 Watcher,对应的 Watcher 会通知更新并执行更新函数,它会执行 render 函数获取新的虚拟 DOM,然后执行 patch 对比上次渲染结果的老的虚拟 DOM,并计算出最小的变化,然后再去根据这个最小的变化去更新真实的 DOM。

体验Diff算法

先通过第三方包snabbdom,先来感受一下Diff算法的强大之处。

import {

init,

classModule,

propsModule,

styleModule,

eventListenersModule,

h,

} from "snabbdom";

//创建出patch函数

const patch = init([

classModule,

propsModule,

styleModule,

eventListenersModule,

]);

const myVnode1 = h("ul", {}, [

h("li", "A"),

h("li", "B"),

h("li", "C"),

h("li", "D"),

]);

// 得到盒子和按钮

const container = document.getElementById("container");

const btn = document.getElementById("btn");

// 第一次上树

patch(container, myVnode1);

// 新节点

const myVnode2 = h("ul", {}, [

h("li", "A"),

h("li", "B"),

h("li", "C"),

h("li", "D"),

h("li", "E"),

]);

btn.onclick = function () {

patch(myVnode1, myVnode2);

};

 

当我们点击改变DOM的时候,发现会新增一个 li标签 内容为 E,直接在 旧的虚拟DOM 上直接在后面添加一个节点,验证了是进行了 diff算法精细化的比较,以最小量进行更新。那么问题就来了,如果我在前面添加一个节点呢?是不是也是像在最后添加一样,直接在前面添加一个节点。我们不妨也来试一试看看效果:

const myVnode1 = h("ul", {}, [

h("li", "A"),

h("li", "B"),

h("li", "C"),

h("li", "D"),

]);

// 新节点

const myVnode2 = h("ul", {}, [

h("li", "Q"),

h("li", "T"),

h("li", "A"),

h("li", "B"),

h("li", "Z"),

h("li", "C"),

h("li", "D"),

h("li", "E"),

]);

 

结果跟我们想的不一样,你会发现,里面的文本内容全部发生了变化,也就是说将之前的 DOM 全部拆除,然后将新的DOM的重新渲染。这样是无法让人接受的,所以 Vue里使用 Diff 算法的时候都遵循深度优先,同层比较的策略做了一些优化,来计算出最小变化。

Diff算法的优化

1.同层比较(只比较同一层级,不跨级比较)

Diff 过程只会把同颜色框起来的同一层级的 DOM 进行比较,这样来简化比较次数。

2.深度优先

比对到相同的节点,会对两个节点的所有子节点都比较完成,才会回到同层的节点继续比对。

3.key唯一标识符

如果标签名相同,key 也相同,就会认为是相同节点,也不继续按这个树状结构做深度比较,比如我们写 v-for 的时候会比较 key,不写 key 就会报错,这也就是因为 Diff 算法需要比较 key。

三、key的作用

上述案例我们带上key再来看一下效果:

const myVnode1 = h("ul", {}, [

h("li", { key: "A" }, "A"),

h("li", { key: "B" }, "B"),

h("li", { key: "C" }, "C"),

h("li", { key: "D" }, "D"),

]);

// 新节点

const myVnode2 = h("ul", {}, [

h("li", { key: "Q" }, "Q"),

h("li", { key: "T" }, "T"),

h("li", { key: "A" }, "A"),

h("li", { key: "B" }, "B"),

h("li", { key: "Z" }, "Z"),

h("li", { key: "C" }, "C"),

h("li", { key: "D" }, "D"),

h("li", { key: "E" }, "E"),

]);

 

我们可以推出的结论一就是:key是当前节点的唯一标识,告诉 diff算法,在更改前后它们是同一个 DOM节点。

总结一下:

  • key 的作用主要是为了更高效的更新虚拟 DOM,因为它可以非常精确的找到相同节点,因此 patch 过程会非常高效。
  • Vue 在 patch 过程中会判断两个节点是不是相同节点时,key 是一个必要条件。比如渲染列表时,如果不写 key,Vue 在比较的时候,就可能会导致频繁更新元素,使整个 patch 过程比较低效,影响性能。
  • 应该避免使用数组下标作为 key,因为 key 值不是唯一的话可能会导致上面图中表示的 bug,使 Vue 无法区分它他,还有比如在使用相同标签元素过渡切换的时候,就会导致只替换其内部属性而不会触发过渡效果。
  • Vue 判断两个节点是否相同时主要判断两者的元素类型和 key 等,如果不设置 key,就可能永远认为这两个是相同节点,只能去做更新操作,就造成大量不必要的 DOM 更新操作,明显是不可取的。

四、Diff核心原理patch函数

path函数

patch函数 的主要作用就是:判断是否是同一个节点类型,是就在进行精细化对比,不是就进行暴力删除,插入新的。

我们在可以简单的画出patch函数现在的主要流程图如下:

// patch.js patch函数

import vnode from "./vnode";

import sameVnode from "./sameVnode";

import createElement from "./createElement";

export default function (oldVnode, newVnode) {

// 判断oldVnode是否是虚拟节点

if (oldVnode.type== "" || oldVnode.type== undefined) {

// console.log('不是虚拟节点');

// 创建虚拟DOM

oldVnode = emptyNodeAt(oldVnode);

}

// 判断是否是同一个节点

if (sameNode(oldVnode, newVnode)) {

console.log("是同一个节点");

} else {

// 暴力删除旧节点,插入新的节点

// 传入两个参数,创建的节点 插入到指定标杆的位置

createElement(newVnode, oldVnode.elm);

}

}

// 创建虚拟DOM

function emptyNodeAt(elm) {

return vnode(elm.tagName.toLowerCase(), {}, [], undefined, elm);

}

sameVnode函数

这个是用来判断是不是同一个节点的函数。

function sameVnode(a, b) {

return (

a.key === b.key && // key 是不是一样

a.asyncFactory === b.asyncFactory && // 是不是异步组件

((a.tag === b.tag && // 标签是不是一样

a.isComment === b.isComment && // 是不是注释节点

isDef(a.data) === isDef(b.data) && // 内容数据是不是一样

sameInputType(a, b)) || // 判断 input 的 type 是不是一样

(isTrue(a.isAsyncPlaceholder) && // 判断区分异步组件的占位符否存在

isUndef(b.asyncFactory.error)))

);

}

createElement函数

主要用来创建子节点的真实DOM

// createElement.js只负责创建真正节点

export default function createElement(vnode) {

// 创建上树的节点

let domNode = document.createElement(vnode.type)

// 判断有文本内容还是子节点

if (vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) {

// 文本内容 直接赋值

domNode.innerText = vnode.text

// 上树 往body上添加节点

// insertBefore() 方法:可在已有的字节点前中插入一个新的子节点。相对于子节点来说的

} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {

// 有子节点

for(let i = 0; i < vnode.children.length; i++) {

// console.log(vnode.children[i]);

let ch = vnode.children[i]

// 进行递归 一旦调用createElement意味着 创建了DOM 并且elm属性指向了创建好的DOM

let chDom = createElement(ch)

// 添加节点 使用appendChild 因为遍历下一个之前 上一个真实DOM(这里的domVnode)已经生成了 所以可以使用appendChild

domNode.appendChild(chDom)

}

}

vnode.elm = domNode

return vnode.elm

}

Diff处理新旧节点是同一个节点时

上面的 patch函数 流程图中,我们已经处理了不同节点的时候,进行暴力删除旧的节点,然后插入新的节点,现在我们进行处理相同节点的时候,进行精细化的比较,继续完善 patch函数 的主流程图:

总结

在Javascript中,渲染 真实DOM 的开销是非常大的,比如我们修改了某个数据,如果直接渲染到 真实DOM,会引起整个 DOM树 的 回流和重绘。那么有没有可能实现只更新我们修改的那一小块DOM而不会引起整个DOM更新?此时我们就需要先根据 真实DOM 生成 虚拟DOM ,当 虚拟DOM 某个节点的数据改变后会生成一个 新的Vnode,然后 新的Vnode 和 旧的Vnodde 进行比较,发现有不一样的地方就直接修改到 真实DOM 上,然后使 旧的Vnode 的值变成 新的Vnode。

Diff算法的过程就是 patch函数 的调用,比较新旧节点,一边比较一边给 真实的DOM 打补丁。在采用 diff算法 比较新旧节点的时候,只会进行同层级的比较。在 patch方法 中,首先比较新旧虚拟节点是否是同一个节点,如果不是同一个节点,那么就会将旧的节点删除掉,插入新的虚拟节点,然后再使用 createElement函数 创建 真实DOM,渲染到真实的 DOM树。如果是同一个节点,使用 patchVnode函数 比较新旧节点,包括属性更新、文本更新、子节点更新,新旧节点均有子节点,则需要进行 diff算法,调用updateChildren方法,如果新节点没有文本内容而旧节点有文本内容,则需要将旧节点的文本删除,然后再增加子节点,如果新节点有文本内容,则直接替换旧节点的文本内容。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值