《前端面试题之 Vue 篇(第二集)》
目录
- 1、对虚拟DOM的理解
- 什么是虚拟DOM
- 虚拟DOM的作用
- 虚拟DOM在Vue中的工作方式
- 为什么现代前端框架依赖虚拟DOM
- 2、虚拟DOM的解析过程
- 初始虚拟 DOM 构建
- 状态变更时的处理
- 3、为什么要用虚拟DOM
- 提升性能
- 跨平台能力
- 4、虚拟DOM真的比真实DOM性能好吗
- 5、DIFF算法的原理
- 节点类型判断
- 相同节点的处理(patchVnode)
- 同层比较原则
- 6、Vue中key的作用
- v-if 中 key 的作用
- v-for 中 key 的作用
- 7、为什么不建议用index作为key?
1、对虚拟DOM的理解
什么是虚拟DOM
虚拟DOM本质上是一个JavaScript对象。用对象的形式把DOM结构描述出来。以一个简单的 HTML 片段<div id="container"><span class="text">Hello</span></div>
为例,在虚拟 DOM 中,它会被转化为类似如下的 JavaScript 对象结构:
{tag: 'div',attrs: { id: 'container' },children: [{tag:'span',attrs: { class: 'text' },children: ['Hello']}]
}
虚拟DOM的作用
- 跨平台渲染:把页面状态变成JavaScript对象这种形式后,配合不同的渲染工具,就能在不同平台上渲染了。比如说Node.js环境里没有DOM,但如果想做服务器端渲染(SSR),就可以借助虚拟DOM。因为虚拟DOM本身就是普通的JavaScript对象,在Node.js里也能轻松处理,然后再把它渲染成不同平台需要的样子。
- 提升渲染性能:它有个事务处理机制。当页面有多次DOM修改时,不会每次修改都立刻更新到页面上。而是先把这些修改都攒起来,最后一次性更新到页面。这样就能有效减少页面渲染的次数,因为每次更新DOM都可能触发浏览器的重绘和重排,开销很大。通过虚拟DOM减少这种操作,就能大大提高渲染性能。
虚拟DOM在Vue中的工作方式
在Vue里,代码在渲染到页面之前,会先被转成虚拟DOM对象。它用这个对象来描述真实DOM的结构,最终再根据这个对象渲染到页面上。每次数据发生变化前,Vue都会缓存一份当时的虚拟DOM。一旦数据有变化,新生成的虚拟DOM就会和缓存的虚拟DOM进行比较。Vue内部封装了一个叫diff的算法,这个算法会仔细对比两个虚拟DOM,只找出那些有变化的部分。渲染的时候,就只修改这些发生变化的地方,原来没变化的部分还用原来的数据渲染,这样就能高效地更新页面,又不会浪费性能在没变化的地方。
为什么现代前端框架依赖虚拟DOM
现代前端框架基本都要求不用手动操作DOM。一方面,手动操作DOM很难保证程序性能。在多人一起开发的项目里,如果代码审查不严格,有的开发者可能会写出让性能变差的DOM操作代码。另一方面,更关键的是,不手动操作DOM能极大地提高开发效率。有了虚拟DOM,开发者只要关注数据和业务逻辑,框架会自动根据数据变化通过虚拟DOM高效地更新页面,不用再去操心复杂又易错的DOM操作细节了 。
2、虚拟DOM的解析过程
虚拟 DOM 是现代前端框架中用于优化页面渲染性能的重要概念,其解析过程主要包含以下几个关键步骤:
初始虚拟 DOM 构建
在前端应用初始化或组件首次渲染时,会对即将插入到文档中的 DOM 树结构进行分析。具体来说,会使用 JavaScript 对象来表示真实的 DOM 结构。例如,对于一个简单的 HTML 片段 <div id="box" class="container"><p>Hello, World!</p></div>
,对应的虚拟 DOM 对象可能如下所示:
{tag: 'div', // 标签名(TagName)props: { id: 'box', class: 'container' }, // 元素属性(props)children: [{tag: 'p',props: {},children: ['Hello, World!'] // 子元素或文本内容(Children)}]
}
通过这种方式,将整个 DOM 树转化为一个 JavaScript 对象树,这个对象树就是虚拟 DOM。然后,会将这个虚拟 DOM 对象树保存下来,以便后续进行对比和更新操作。
状态变更时的处理
当页面的状态发生改变,导致需要对页面的 DOM 结构进行调整时:
- 新虚拟 DOM 构建:根据变更后的状态,重新构建一棵新的虚拟 DOM 对象树。例如,如果上述例子中的
<p>
元素的文本内容变为了Hello, Vue!
,那么新的虚拟 DOM 对象树中对应的部分会更新为:
{tag: 'div',props: { id: 'box', class: 'container' },children: [{tag: 'p',props: {},children: ['Hello, Vue!']}]
}
- 虚拟 DOM 对比:将新构建的虚拟 DOM 对象树和之前保存的旧虚拟 DOM 对象树进行比较。这个比较过程通常使用 Diff 算法来实现,它会递归地遍历两棵树的节点,比较节点的标签名、属性以及子节点等信息。在比较过程中,会记录下两棵树之间的差异,比如哪些节点是新增的、哪些节点被删除了、哪些节点的属性或文本内容发生了变化等。
- 更新真实 DOM:根据记录的差异,将这些变化应用到真正的 DOM 树中。例如,如果发现某个节点是新增的,就会在真实 DOM 中相应的位置插入该节点;如果某个节点被删除了,就会从真实 DOM 中移除该节点;如果节点的属性或文本内容发生了变化,就会更新真实 DOM 中该节点的对应属性或文本内容。通过这种方式,实现了视图的更新,使得页面能够反映出最新的状态。
虚拟 DOM 通过将真实 DOM 抽象为 JavaScript 对象树,在状态变更时进行高效的对比和更新,从而减少了直接操作真实 DOM 的次数,提高了页面的渲染性能和响应速度。
3、为什么要用虚拟DOM
提升性能
- 页面渲染流程基础:要理解虚拟DOM为何能提升性能,得先清楚页面渲染的流程。当我们打开一个网页,浏览器会先解析HTML代码,把它转化为DOM树(文档对象模型树,这是网页的结构表示)。接着,解析CSS代码生成CSSOM树(层叠样式表对象模型树,用于描述样式)。然后,浏览器将DOM树和CSSOM树结合起来,计算每个元素在页面中的位置和大小,这个过程叫Layout(布局)。再之后,根据布局结果,把各个元素绘制到屏幕上,这就是Paint(绘制)。最后,可能还会涉及到Compiler(编译,例如对一些动态加载的代码进行编译执行等,与渲染性能关联相对复杂且不直接影响这里讨论的重点,可简单理解为辅助页面完整呈现的一些处理)。
- 真实DOM操作的性能问题:当我们需要修改页面上的DOM元素时,如果直接操作真实DOM,比如改变一个元素的样式或者添加新元素,整个流程可能会变得复杂且性能消耗大。因为这意味着要重新生成HTML字符串,然后浏览器得重建所有相关的DOM元素。每一次重建DOM元素,都可能触发Layout和Paint过程,也就是重排(重新计算布局)和重绘(重新绘制画面)。这两个操作非常耗费浏览器的计算资源和时间,尤其是在页面复杂、DOM元素众多的情况下,频繁的真实DOM操作会让页面变得卡顿,用户体验变差。
- 虚拟DOM的性能优势:虚拟DOM的出现就是为了解决这个问题。当使用虚拟DOM时,在更新DOM前,先会生成vNode(虚拟节点,也就是用JavaScript对象来表示的DOM节点)。然后,通过DOMDiff算法,对比新生成的虚拟DOM和旧的虚拟DOM,找出其中的差异。最后,只对那些有差异的部分进行必要的DOM更新。虽然虚拟DOM在更新DOM前,在JavaScript层面的准备工作会耗费更多时间,但是和频繁且大量的真实DOM操作相比,这些准备工作的消耗是非常小的。就像尤雨溪(Vue.js的作者,在前端社区很有影响力)在社区论坛里说的,使用框架里的虚拟DOM,即便你不手动去优化代码性能,它也能保证页面有还不错的性能表现,不会因为DOM操作不当而变得卡顿。
跨平台能力
虚拟DOM本质上就是JavaScript对象。这个特性让它在不同平台间操作变得很方便。比如说在服务端渲染(SSR)中,服务器需要生成HTML页面发送给浏览器。传统的直接操作真实DOM在服务器环境里很难实现,因为服务器没有浏览器那样的图形界面和DOM环境。但虚拟DOM就可以在服务器端用JavaScript轻松处理,生成HTML片段后再发给浏览器。还有像uniapp这样的跨平台开发框架,通过虚拟DOM,一套代码可以同时生成适配不同平台(如微信小程序、H5页面、APP等)的界面。因为不同平台对界面的渲染方式有差异,虚拟DOM作为中间层,能把JavaScript层面的界面描述,转化为各个平台所需要的原生组件或视图,大大提高了开发效率,减少了为不同平台单独开发代码的工作量 。
4、虚拟DOM真的比真实DOM性能好吗
- 首次渲染大量DOM时,由于多了一层虚拟DOM的计算,会比innerHTML插入慢。
- 正如它能保证性能下限,在真实DOM操作的时候进行针对性的优化时,还是更快的。
5、DIFF算法的原理
在现代前端框架中,Diff算法是虚拟DOM实现高效更新的关键技术。当新的虚拟DOM树和旧的虚拟DOM树进行对比时,Diff算法主要遵循以下步骤和规则:
节点类型判断
首先,Diff算法会对比新老虚拟DOM中的节点本身,判断它们是否为同一节点。这里判断同一节点的依据通常是节点的类型(比如是 <div>
还是 <span>
等标签名)以及节点的唯一标识(如果设置了 key
属性,key
也会作为判断的重要依据)。如果两个节点的类型不同,那么它们被认为不是同一节点。此时,旧的节点会被直接删除,然后重新创建新的节点来进行替换。例如,旧虚拟DOM中有一个 <div>
节点,新虚拟DOM中对应的位置变成了 <span>
节点,那么Diff算法会删除原来的 <div>
节点,然后在页面上创建一个新的 <span>
节点。
相同节点的处理(patchVnode)
当判断两个节点为相同节点时,就会进入 patchVnode
阶段,这个阶段主要处理该节点的子节点情况:
- 一方有子节点一方没有子节点:如果新的虚拟DOM节点的子节点列表为空,而旧的虚拟DOM节点有子节点,那么Diff算法会将旧节点的所有子节点从页面上移除。反之,如果旧的虚拟DOM节点的子节点列表为空,而新的虚拟DOM节点有子节点,那么会在页面上为该节点添加新的子节点。比如,旧虚拟DOM中有一个
<ul>
节点且包含多个<li>
子节点,新虚拟DOM中对应的<ul>
节点没有子节点了,那么Diff算法会把页面上原来的那些<li>
子节点都删除掉。 - 双方都有子节点(updateChildren):当新老虚拟DOM节点都有子节点时,就进入了Diff算法的核心部分
updateChildren
。在这个过程中,算法会对新老节点的子节点进行操作。具体来说,它会从两端开始向中间进行比较(也称为双端比较)。- 首先比较新老节点子节点列表的开始位置的子节点是否相同(根据节点类型和
key
判断),如果相同,则递归地比较这两个子节点的子节点(也就是继续深入比较它们的下一层子节点)。 - 然后比较新老节点子节点列表的结束位置的子节点是否相同,如果相同,同样递归地比较这两个子节点的子节点。
- 如果开始位置和结束位置的子节点都不相同,算法会尝试在新子节点列表中查找与旧子节点列表开始位置的子节点相同的节点(根据
key
进行查找),如果找到,就将其移动到合适的位置,然后继续进行比较。 - 这个过程会一直持续,直到处理完所有的子节点。
- 首先比较新老节点子节点列表的开始位置的子节点是否相同(根据节点类型和
同层比较原则
Diff算法只对同层的子节点进行比较,放弃跨级的节点比较。这是因为如果对所有节点都进行递归比较(包括跨级比较),时间复杂度会达到 O ( n 3 ) O(n^3) O(n3),这是非常高的计算成本。而通过只进行同层比较,Diff算法将时间复杂度降低到了 O ( n ) O(n) O(n)。也就是说,只有当新老虚拟DOM的子节点列表都包含多个子节点时,才会使用上述核心的Diff算法进行同层级的比较和处理。
通过以上这些步骤和规则,Diff算法能够高效地找出新老虚拟DOM之间的差异,并将这些差异应用到真实DOM上,从而实现页面的快速更新,减少不必要的DOM操作,提升前端应用的性能。
6、Vue中key的作用
vue 中 key 值的作用可以分为两种情况来考虑:
第一种情况是 v-if 中使用 key。由于 Vue 会尽可能高效地渲染元素,通常会复用已有元素而不是从头开始渲染。因此当使用 v-if 来实现元素切换的时候,如果切换前后含有相同类型的元素,那么这个元素就会被复用。如果是相同的 input 元素,那么切换前后用户的输入不会被清除掉,这样是不符合需求的。因此可以通过使用 key 来唯一的标识一个元素,这个情况下,使用 key 的元素不会被复用。这个时候 key 的作用是用来标识一个独立的元素。
v-if 中 key 的作用
想象你有一个页面,上面有个登录框和注册框,通过点击按钮切换显示。代码可能像这样:
<template><div><button @click="showLogin =!showLogin">切换</button><form v-if="showLogin"><input type="text" placeholder="用户名"><input type="password" placeholder="密码"></form><form v-else><input type="text" placeholder="新用户名"><input type="password" placeholder="新密码"><input type="password" placeholder="确认密码"></form></div>
</template>
<script>
export default {data() {return {showLogin: true};}
};
</script>
这里两个 form
都是表单元素。当你从登录框切换到注册框,再切回来时,Vue 会复用表单元素。结果就是,切换回登录框时,用户名输入框可能还保留着注册时输入的新用户名,这不是我们想要的。
解决办法就是给 form
加上 key
:
<template><div><button @click="showLogin =!showLogin">切换</button><form v-if="showLogin" key="login-form"><input type="text" placeholder="用户名"><input type="password" placeholder="密码"></form><form v-else key="register-form"><input type="text" placeholder="新用户名"><input type="password" placeholder="新密码"><input type="password" placeholder="确认密码"></form></div>
</template>
<script>
export default {data() {return {showLogin: true};}
};
</script>
加上 key
后,Vue 就知道这是两个完全不同的表单,不会复用,每次切换都能保持各自输入框的状态正确。
第二种情况是 v-for 中使用 key。用 v-for 更新已渲染过的元素列表时,它默认使用“就地复用”的策略。如果数据项的顺序发生了改变,Vue 不会移动 DOM 元素来匹配数据项的顺序,而是简单复用此处的每个元素。因此通过为每个列表项提供一个 key 值,来以便 Vue 跟踪元素的身份,从而高效的实现复用。这个时候 key 的作用是为了高效的更新渲染虚拟 DOM。
v-for 中 key 的作用
假设有一个待办事项列表,你可以点击按钮把完成的事项移到列表底部。代码如下:
<template><div><button @click="moveDoneToBottom">完成的移到最后</button><ul><li v-for="(item, index) in todos" :key="index">{{ item.text }} - {{ item.done? '已完成' : '未完成' }}</li></ul></div>
</template>
<script>
export default {data() {return {todos: [{ text: '买菜', done: false },{ text: '做饭', done: true },{ text: '洗碗', done: false }]};},methods: {moveDoneToBottom() {this.todos = this.todos.filter(item =>!item.done).concat(this.todos.filter(item => item.done));}}
};
</script>
这里用 index
作为 key
。当你点击按钮,Vue 会复用 li
元素,不会按新顺序重新排列。结果可能是已完成的事项虽然在数据里移到了最后,但在页面上显示还是乱的。
正确做法是用每个事项的唯一标识作为 key
,比如给每个待办事项加个 id
:
<template><div><button @click="moveDoneToBottom">完成的移到最后</button><ul><li v-for="item in todos" :key="item.id">{{ item.text }} - {{ item.done? '已完成' : '未完成' }}</li></ul></div>
</template>
<script>
export default {data() {return {todos: [{ id: 1, text: '买菜', done: false },{ id: 2, text: '做饭', done: true },{ id: 3, text: '洗碗', done: false }]};},methods: {moveDoneToBottom() {this.todos = this.todos.filter(item =>!item.done).concat(this.todos.filter(item => item.done));}}
};
</script>
这样,Vue 就能根据 key
准确跟踪每个 li
元素,按新顺序更新页面,让显示和数据一致。
总结来说,在 v-if 中,key 能防止相同类型元素被错误复用,保证元素独立性;在 v-for 中,key 帮助 Vue 准确高效地更新虚拟 DOM,确保列表元素顺序和状态正确。
- key 是为 Vue 中 vnode 的唯一标记,通过这个 key,diff 操作可以更准确、更快速。
- 更准确:因为带 key 就不是就地复用了,在 sameNode 函数a.key === b.key对比中可以避免就地复用的情况。所以会更加准确。
- 更快速:利用 key 的唯一性生成 map 对象来获取对应节点,比遍历方式更快。
7、为什么不建议用index作为key?
使用index 作为 key和没写基本上没区别,因为不管数组的顺序怎么颠倒,index 都是 0, 1, 2…这样排列,导致 Vue 会复用错误的旧子节点,做很多额外的工作。
如果数据列表没id只能用index区分,可尝试:
- 加临时唯一标识:加载数据后遍历数组,为每项添加类似
tempId
的临时唯一属性,如item.tempId =
temp_${index}`` ,再用其作key
。 - 用组合键:若对象中
name
和category
等属性组合唯一,可将其组合成key
,如``:key=“${item.name}_${item.category}
”` 。 - 自定义标识算法:创建函数生成唯一标识,像基于全局计数器,如
let uniqueIdCounter = 0; function generateUniqueId() { return
unique_${uniqueIdCounter++}; }
,为数据项添加生成的标识作key
。