跳到主要内容

Vue 面试题库

一、Vue 基础

1.1 什么是 Vue.js?它的核心特性是什么?

面试回答思路:

Vue.js 是一个渐进式 JavaScript 框架,主要用于构建用户界面。我可以从以下几个方面来介绍它的核心特性:

1. 响应式数据绑定

  • Vue 会自动追踪数据的依赖关系,当数据发生变化时,视图会自动更新
  • 开发者不需要手动操作 DOM,只需要关注数据的变化
  • 这大大简化了开发流程,提高了开发效率

2. 组件化开发

  • Vue 采用组件化的开发模式,每个组件都是独立的、可复用的
  • 组件之间通过 props 和事件进行通信
  • 这种模式让代码更易维护,也便于团队协作

3. 虚拟 DOM

  • Vue 使用虚拟 DOM 来提高渲染性能
  • 通过 diff 算法,只更新真正需要变化的部分
  • 减少了直接操作 DOM 的次数,提升了应用性能

4. 指令系统

  • Vue 提供了丰富的指令,如 v-if、v-for、v-model 等
  • 这些指令让模板更加简洁和易读
  • 也可以自定义指令来扩展功能

5. 渐进式框架

  • Vue 的核心库只关注视图层,可以逐步集成到现有项目中
  • 不需要一次性重写整个应用
  • 可以根据项目需求选择性地使用 Vue Router、Vuex 等配套工具

补充说明: 在实际项目中,我使用 Vue 开发过多个项目,它的学习曲线相对平缓,文档也很完善,对新手比较友好。同时 Vue 3 引入了 Composition API,让代码组织更加灵活,TypeScript 支持也更好了。

javascript
1// Vue 3 基本示例
2import { createApp, ref } from 'vue'
3
4const app = createApp({
5 setup() {
6 const message = ref('Hello Vue!')
7
8 const updateMessage = () => {
9 message.value = 'Hello World!'
10 }
11
12 return {
13 message,
14 updateMessage
15 }
16 }
17})
18
19app.mount('#app')

1.2 Vue 2 和 Vue 3 的主要区别是什么?

面试回答思路:

Vue 3 相比 Vue 2 有很多重要的改进,我主要从以下几个方面来说明:

1. 性能提升

  • Vue 3 的渲染速度比 Vue 2 快了约 1.3-2 倍
  • 这主要得益于重写了虚拟 DOM 的实现
  • 内存占用也减少了约 54%
  • 打包体积更小,支持 Tree-shaking,可以按需引入

2. Composition API

  • Vue 3 引入了 Composition API,这是一种新的代码组织方式
  • 相比 Options API,它能更好地组织和复用逻辑
  • 特别适合大型项目和复杂组件
  • 但 Options API 仍然可以使用,向后兼容

3. 响应式系统升级

  • Vue 2 使用 Object.defineProperty 实现响应式
  • Vue 3 改用 Proxy,性能更好
  • 可以监听动态添加的属性,不需要 Vue.set
  • 可以监听数组索引和 length 的变化

4. TypeScript 支持

  • Vue 3 使用 TypeScript 重写,类型推导更准确
  • 开发体验大幅提升
  • IDE 的代码提示和类型检查更完善

5. 新特性

  • 支持多个根节点(Fragment)
  • Teleport 组件,可以将内容渲染到 DOM 的其他位置
  • Suspense 组件,更好地处理异步组件
  • 更好的自定义渲染器 API

实际应用建议: 如果是新项目,我会推荐使用 Vue 3,因为它性能更好,功能也更强大。但如果是维护老项目,Vue 2 也完全够用,而且生态更成熟。Vue 3 的迁移成本需要评估,不是所有项目都需要立即升级。

主要区别:

特性Vue 2Vue 3
API 风格Options APIComposition API(可选)
响应式系统Object.definePropertyProxy
性能较好更快(约 1.3-2 倍)
TypeScript 支持有限完整支持
包体积较大更小(Tree-shaking)
生命周期beforeCreate, created 等setup, onMounted 等
多根节点不支持支持(Fragment)
Teleport不支持支持
Suspense不支持支持
javascript
1export default {
2 data() {
3 return {
4 count: 0
5 }
6 },
7 methods: {
8 increment() {
9 this.count++
10 }
11 },
12 mounted() {
13 console.log('Component mounted')
14 }
15}

1.3 解释 Vue 的响应式原理

面试回答思路:

Vue 的响应式原理是面试中的高频考点,我会分别介绍 Vue 2 和 Vue 3 的实现方式:

Vue 2 的响应式原理(基于 Object.defineProperty):

当我们创建一个 Vue 实例时,Vue 会遍历 data 对象的所有属性,使用 Object.defineProperty 将这些属性转换为 getter 和 setter。

工作流程:

  1. 初始化阶段:Vue 会递归遍历 data 对象,为每个属性添加 getter 和 setter
  2. 依赖收集:当组件渲染时,会访问数据的 getter,此时会收集当前组件作为依赖(Watcher)
  3. 派发更新:当数据被修改时,会触发 setter,setter 会通知所有依赖的 Watcher 进行更新
  4. 异步更新队列:Vue 会将所有的更新放入一个队列中,在下一个事件循环中统一执行,避免重复渲染

Vue 2 的局限性:

  • 无法检测对象属性的添加或删除,需要使用 Vue.set
  • 无法检测数组索引和 length 的变化
  • 需要递归遍历所有属性,初始化性能开销大

Vue 3 的响应式原理(基于 Proxy):

Vue 3 使用 ES6 的 Proxy 来实现响应式,这是一个更强大的方案。

Proxy 的优势:

  1. 可以监听整个对象:不需要遍历每个属性
  2. 可以监听动态添加的属性:新增属性自动具有响应式
  3. 可以监听数组的变化:包括索引和 length
  4. 性能更好:懒代理,只有访问到的属性才会被代理
  5. 支持 Map、Set 等数据结构

实现细节:

  • 使用 Proxy 的 get 拦截器进行依赖收集(track)
  • 使用 Proxy 的 set 拦截器触发更新(trigger)
  • 使用 Reflect 确保正确的 this 指向

总结: Vue 的响应式系统是其核心特性,理解它的原理对于调试问题和性能优化都很有帮助。Vue 3 的 Proxy 方案解决了 Vue 2 的很多痛点,是一个重要的升级。

javascript
1// Vue 2 响应式简化实现
2function defineReactive(obj, key, val) {
3 const dep = new Dep() // 依赖收集器
4
5 Object.defineProperty(obj, key, {
6 get() {
7 if (Dep.target) {
8 dep.depend() // 收集依赖
9 }
10 return val
11 },
12 set(newVal) {
13 if (newVal !== val) {
14 val = newVal
15 dep.notify() // 通知更新
16 }
17 }
18 })
19}

Vue 3 响应式原理(Proxy):

  1. 使用 Proxy 代理整个对象
  2. 可以监听动态添加的属性
  3. 可以监听数组索引和 length 变化
  4. 性能更好
javascript
1// Vue 3 响应式简化实现
2function reactive(target) {
3 return new Proxy(target, {
4 get(target, key, receiver) {
5 track(target, key) // 收集依赖
6 return Reflect.get(target, key, receiver)
7 },
8 set(target, key, value, receiver) {
9 const result = Reflect.set(target, key, value, receiver)
10 trigger(target, key) // 触发更新
11 return result
12 }
13 })
14}

二、组件通信

2.1 Vue 组件间通信的方式有哪些?

面试回答思路:

Vue 组件间通信是开发中经常遇到的问题,根据组件关系的不同,有多种通信方式可以选择:

1. Props 和 $emit(父子组件通信)

这是最基本也是最常用的通信方式:

  • Props:父组件向子组件传递数据,是单向数据流
  • $emit:子组件通过事件向父组件传递数据
  • 优点:简单直观,符合单向数据流的设计理念
  • 适用场景:父子组件之间的直接通信

2. Provide 和 Inject(跨层级通信)

用于祖先组件向后代组件传递数据:

  • 祖先组件通过 provide 提供数据
  • 后代组件通过 inject 注入数据
  • 优点:可以跨越多层组件传递数据,不需要逐层传递
  • 缺点:数据来源不够明确,不是响应式的(Vue 2)
  • 适用场景:主题、语言等全局配置

3. EventBus(兄弟组件通信 - Vue 2)

通过一个空的 Vue 实例作为事件总线:

  • 任何组件都可以触发和监听事件
  • 优点:简单灵活
  • 缺点:事件管理混乱,难以维护,Vue 3 已移除
  • 适用场景:小型项目的兄弟组件通信

4. Vuex 或 Pinia(全局状态管理)

用于管理应用的全局状态:

  • 集中式存储和管理应用的所有组件状态
  • 优点:状态可预测,易于调试,支持时间旅行
  • 缺点:增加了代码复杂度
  • 适用场景:中大型项目,多个组件共享状态

5. $refs(父组件访问子组件)

父组件可以直接访问子组件的实例:

  • 可以调用子组件的方法或访问数据
  • 优点:直接方便
  • 缺点:破坏了组件的封装性,不推荐过度使用
  • 适用场景:需要直接操作子组件的特殊情况

6. $parent 和 $children(不推荐)

可以访问父组件或子组件实例:

  • 耦合度太高,不利于维护
  • Vue 3 已移除 $children
  • 不推荐使用

选择建议:

  • 父子组件:优先使用 Props/$emit
  • 跨层级:使用 Provide/Inject
  • 全局状态:使用 Pinia(Vue 3)或 Vuex
  • 避免使用 EventBus 和 $parent/$children

在实际项目中,我会根据组件关系和数据流向选择合适的通信方式,保持代码的清晰和可维护性。

1. Props / $emit(父子组件)

vue
1<!-- 父组件 -->
2<template>
3 <ChildComponent :message="parentMsg" @update="handleUpdate" />
4</template>
5
6<script>
7export default {
8 data() {
9 return {
10 parentMsg: 'Hello from parent'
11 }
12 },
13 methods: {
14 handleUpdate(data) {
15 console.log('Received from child:', data)
16 }
17 }
18}
19</script>
20
21<!-- 子组件 -->
22<template>
23 <div>
24 <p>{{ message }}</p>
25 <button @click="sendToParent">Send to Parent</button>
26 </div>
27</template>
28
29<script>
30export default {
31 props: ['message'],
32 methods: {
33 sendToParent() {
34 this.$emit('update', 'Data from child')
35 }
36 }
37}
38</script>

2. Provide / Inject(跨层级)

javascript
1// 祖先组件
2export default {
3 provide() {
4 return {
5 theme: 'dark',
6 user: this.currentUser
7 }
8 }
9}
10
11// 后代组件
12export default {
13 inject: ['theme', 'user'],
14 mounted() {
15 console.log(this.theme) // 'dark'
16 }
17}

3. EventBus(兄弟组件 - Vue 2)

javascript
1// eventBus.js
2import Vue from 'vue'
3export const EventBus = new Vue()
4
5// 组件 A
6EventBus.$emit('custom-event', data)
7
8// 组件 B
9EventBus.$on('custom-event', (data) => {
10 console.log(data)
11})

4. Vuex / Pinia(全局状态管理)

javascript
1// Pinia Store
2import { defineStore } from 'pinia'
3
4export const useUserStore = defineStore('user', {
5 state: () => ({
6 name: 'John',
7 age: 30
8 }),
9 actions: {
10 updateName(newName) {
11 this.name = newName
12 }
13 }
14})
15
16// 组件中使用
17import { useUserStore } from '@/stores/user'
18
19const userStore = useUserStore()
20console.log(userStore.name)
21userStore.updateName('Jane')

5. $refs(父访问子)

vue
1<template>
2 <ChildComponent ref="childRef" />
3 <button @click="callChildMethod">Call Child</button>
4</template>
5
6<script>
7export default {
8 methods: {
9 callChildMethod() {
10 this.$refs.childRef.someMethod()
11 }
12 }
13}
14</script>

6. $parent / $children(不推荐)

javascript
1// 子组件访问父组件
2this.$parent.someMethod()
3
4// 父组件访问子组件
5this.$children[0].someMethod()

2.2 v-model 的原理是什么?

面试回答思路:

v-model 是 Vue 中实现双向数据绑定的语法糖,理解它的原理对于自定义组件很有帮助。

本质原理:

v-model 实际上是两个操作的组合:

  1. 数据绑定:将数据绑定到表单元素的 value 属性
  2. 事件监听:监听表单元素的 input 事件,更新数据

简单来说,v-model="message" 等价于 :value="message" 加上 @input="message = $event.target.value"

在原生表单元素上:

  • 对于 input、textarea:监听 input 事件,绑定 value 属性
  • 对于 checkbox、radio:监听 change 事件,绑定 checked 属性
  • 对于 select:监听 change 事件,绑定 value 属性

在自定义组件上:

Vue 2 和 Vue 3 的实现略有不同:

Vue 2:

  • 默认使用 value 作为 prop
  • 默认监听 input 事件
  • 可以通过 model 选项自定义 prop 和事件名

Vue 3:

  • 默认使用 modelValue 作为 prop
  • 默认监听 update:modelValue 事件
  • 支持多个 v-model,如 v-model:name、v-model:email

修饰符:

v-model 还支持几个常用的修饰符:

  • .lazy:将 input 事件改为 change 事件,失去焦点时才更新
  • .number:自动将输入值转换为数字类型
  • .trim:自动去除首尾空格

实际应用:

在开发自定义表单组件时,我经常需要实现 v-model 支持。理解它的原理后,就能轻松实现组件的双向绑定,让组件的使用更加简洁。Vue 3 的多 v-model 特性让复杂表单组件的开发更加灵活。

vue
1<!-- 使用 v-model -->
2<input v-model="message" />
3
4<!-- 等价于 -->
5<input
6 :value="message"
7 @input="message = $event.target.value"
8/>
9
10<!-- 自定义组件 -->
11<CustomInput v-model="value" />
12
13<!-- 等价于 -->
14<CustomInput
15 :value="value"
16 @input="value = $event"
17/>
18
19<!-- CustomInput 组件实现 -->
20<template>
21 <input
22 :value="value"
23 @input="$emit('input', $event.target.value)"
24 />
25</template>
26
27<script>
28export default {
29 props: ['value']
30}
31</script>

三、生命周期

3.1 Vue 的生命周期钩子有哪些?

面试回答思路:

Vue 的生命周期是指组件从创建到销毁的整个过程,Vue 在不同阶段提供了钩子函数,让我们可以在特定时机执行代码。

Vue 2 的生命周期钩子:

我按照执行顺序来介绍:

1. 创建阶段:

  • beforeCreate:实例初始化之后,数据观测和事件配置之前调用。此时无法访问 data、computed、methods 等。很少使用。
  • created:实例创建完成后调用。此时可以访问 data、computed、methods,但 DOM 还未生成。常用于数据初始化、API 调用。

2. 挂载阶段:

  • beforeMount:在挂载开始之前调用,相关的 render 函数首次被调用。很少使用。
  • mounted:实例被挂载后调用,此时可以访问 DOM。常用于 DOM 操作、第三方库初始化、图表渲染等。

3. 更新阶段:

  • beforeUpdate:数据更新时调用,发生在虚拟 DOM 重新渲染之前。可以访问更新前的 DOM。
  • updated:数据更新导致的虚拟 DOM 重新渲染后调用。可以访问更新后的 DOM,但要避免在这里修改数据,可能导致无限循环。

4. 销毁阶段:

  • beforeDestroy:实例销毁之前调用。常用于清理定时器、取消事件监听、取消网络请求等。
  • destroyed:实例销毁后调用。此时所有的事件监听器已被移除,子实例也被销毁。

5. 特殊钩子(keep-alive):

  • activated:被 keep-alive 缓存的组件激活时调用
  • deactivated:被 keep-alive 缓存的组件停用时调用

Vue 3 的变化:

Vue 3 在 Composition API 中对生命周期钩子进行了重命名:

  • beforeCreate 和 created 被 setup() 替代
  • beforeDestroy 改名为 beforeUnmount
  • destroyed 改名为 unmounted
  • 其他钩子在前面加上 "on",如 onMounted、onUpdated

使用建议:

  1. 数据初始化、API 调用:放在 created 或 setup 中
  2. DOM 操作:放在 mounted 中
  3. 清理工作:放在 beforeUnmount 中
  4. 避免在 updated 中修改数据:容易造成死循环

实际经验:

在项目中,我最常用的是 created/setup(数据初始化)、mounted(DOM 操作)和 beforeUnmount(清理)。理解生命周期对于调试组件问题、优化性能都很重要。

Vue 2 生命周期:

钩子说明使用场景
beforeCreate实例初始化后,数据观测前很少使用
created实例创建完成,数据观测完成数据初始化、API 调用
beforeMount挂载开始前很少使用
mounted挂载完成,DOM 可访问DOM 操作、第三方库初始化
beforeUpdate数据更新前访问更新前的 DOM
updated数据更新后访问更新后的 DOM
beforeDestroy实例销毁前清理定时器、事件监听
destroyed实例销毁后清理工作
activatedkeep-alive 组件激活缓存组件恢复
deactivatedkeep-alive 组件停用缓存组件暂停

Vue 3 生命周期:

Vue 2Vue 3 (Composition API)说明
beforeCreatesetup()组件创建前
createdsetup()组件创建后
beforeMountonBeforeMount挂载前
mountedonMounted挂载后
beforeUpdateonBeforeUpdate更新前
updatedonUpdated更新后
beforeUnmountonBeforeUnmount卸载前
unmountedonUnmounted卸载后
activatedonActivated激活
deactivatedonDeactivated停用
javascript
1// Vue 3 Composition API 示例
2import { ref, onMounted, onUpdated, onUnmounted } from 'vue'
3
4export default {
5 setup() {
6 const count = ref(0)
7
8 onMounted(() => {
9 console.log('Component mounted')
10 // DOM 操作、API 调用
11 })
12
13 onUpdated(() => {
14 console.log('Component updated')
15 })
16
17 onUnmounted(() => {
18 console.log('Component unmounted')
19 // 清理工作
20 })
21
22 return { count }
23 }
24}

3.2 父子组件生命周期执行顺序

面试回答思路:

父子组件的生命周期执行顺序是一个经典的面试题,理解这个顺序对于调试组件问题很有帮助。

核心原则:

Vue 的组件渲染是一个递归的过程,遵循"深度优先"的原则。简单来说就是:

  • 创建过程:从外到内(父 → 子)
  • 挂载过程:从内到外(子 → 父)

详细执行顺序:

1. 挂载阶段(初始化):

1父 beforeCreate
2→ 父 created
3→ 父 beforeMount
4→ 子 beforeCreate
5→ 子 created
6→ 子 beforeMount
7→ 子 mounted
8→ 父 mounted

为什么是这个顺序?

  • 父组件先开始创建和初始化
  • 当父组件开始挂载时,遇到子组件标签
  • 子组件开始创建、初始化、挂载
  • 子组件挂载完成后,父组件才算挂载完成

2. 更新阶段:

1父 beforeUpdate
2→ 子 beforeUpdate
3→ 子 updated
4→ 父 updated

说明:

  • 父组件的数据变化可能影响子组件
  • 子组件先完成更新
  • 父组件最后完成更新

3. 销毁阶段:

1父 beforeDestroy
2→ 子 beforeDestroy
3→ 子 destroyed
4→ 父 destroyed

说明:

  • 父组件先开始销毁
  • 子组件完全销毁后
  • 父组件才完成销毁

实际应用场景:

  1. 数据初始化时机:如果子组件依赖父组件的数据,要确保在父组件 created 之后传递
  2. DOM 操作时机:如果需要操作子组件的 DOM,要在父组件 mounted 之后
  3. 清理资源:父组件销毁时,子组件会自动销毁,不需要手动处理

记忆技巧:

可以把组件想象成俄罗斯套娃:

  • 组装时:先组装外层,再组装内层,最后内层装好了,外层才算装好
  • 拆卸时:先拆外层,再拆内层,最后内层拆完了,外层才算拆完

这个顺序在调试问题时特别有用,比如子组件的 mounted 钩子中可以确保父组件的 DOM 已经存在。


四、指令与过滤器

4.1 常用的 Vue 指令有哪些?

面试回答思路:

Vue 的指令系统是其模板语法的核心,我会介绍最常用的几个指令及其使用场景:

1. v-if / v-else-if / v-else(条件渲染)

这是真正的条件渲染,元素会根据条件被创建或销毁:

  • 适用于条件不经常改变的场景
  • 有更高的切换开销
  • 可以配合 template 标签使用,不会渲染额外的 DOM 元素
  • 惰性渲染:初始条件为 false 时,不会渲染

2. v-show(条件显示)

通过 CSS 的 display 属性控制显示隐藏:

  • 适用于频繁切换的场景
  • 有更高的初始渲染开销
  • 元素始终会被渲染,只是切换 display 属性
  • 不支持 template 标签

选择建议:

  • 频繁切换用 v-show
  • 运行时条件很少改变用 v-if
  • 涉及权限控制用 v-if(不渲染 DOM 更安全)

3. v-for(列表渲染)

用于渲染列表数据:

  • 必须使用 key 属性,且 key 要唯一
  • 可以遍历数组、对象、数字
  • 可以配合 template 使用
  • 不要和 v-if 一起使用(优先级问题)

4. v-bind(属性绑定,简写 :)

动态绑定 HTML 属性:

  • 可以绑定任何 HTML 属性
  • 支持对象和数组语法(class 和 style)
  • 可以绑定 props

5. v-on(事件监听,简写 @)

绑定事件监听器:

  • 支持所有原生事件
  • 支持事件修饰符:.stop、.prevent、.capture、.self、.once、.passive
  • 支持按键修饰符:.enter、.tab、.delete、.esc、.space 等
  • 可以传递参数和 $event 对象

6. v-model(双向绑定)

表单输入和应用状态的双向绑定:

  • 支持修饰符:.lazy、.number、.trim
  • 本质是 :value 和 @input 的语法糖
  • 可用于自定义组件

7. v-slot(插槽,简写 #)

用于分发内容:

  • 默认插槽、具名插槽、作用域插槽
  • 让组件更加灵活和可复用

8. v-pre(跳过编译)

跳过这个元素和它的子元素的编译过程:

  • 可以用来显示原始 Mustache 标签
  • 加快编译速度

9. v-once(只渲染一次)

只渲染元素和组件一次,后续不再更新:

  • 用于优化性能
  • 适用于静态内容

10. v-cloak(隐藏未编译的模板)

保持在元素上直到关联实例结束编译:

  • 配合 CSS 规则使用
  • 解决页面闪烁问题

实际应用经验:

在项目中,我最常用的是 v-if、v-for、v-bind、v-on 和 v-model。理解每个指令的特点和适用场景,可以写出更高效、更易维护的代码。特别是 v-if 和 v-show 的选择,以及 v-for 必须使用 key,这些都是实际开发中的重点。

1. v-if / v-else-if / v-else:条件渲染

vue
1<template>
2 <div v-if="type === 'A'">A</div>
3 <div v-else-if="type === 'B'">B</div>
4 <div v-else>C</div>
5</template>

2. v-show:条件显示(CSS display)

vue
1<div v-show="isVisible">Content</div>

3. v-for:列表渲染

vue
1<ul>
2 <li v-for="(item, index) in items" :key="item.id">
3 {{ index }}: {{ item.name }}
4 </li>
5</ul>

4. v-bind(:):属性绑定

vue
1<img :src="imageSrc" :alt="imageAlt" />
2<div :class="{ active: isActive }" :style="{ color: textColor }"></div>

5. v-on(@):事件监听

vue
1<button @click="handleClick">Click</button>
2<input @input="handleInput" @keyup.enter="handleEnter" />

6. v-model:双向绑定

vue
1<input v-model="message" />
2<input v-model.trim="username" />
3<input v-model.number="age" />

7. v-slot(#):插槽

vue
1<template #header>
2 <h1>Header Content</h1>
3</template>

8. v-pre:跳过编译

vue
1<span v-pre>{{ this will not be compiled }}</span>

9. v-once:只渲染一次

vue
1<span v-once>{{ message }}</span>

10. v-cloak:隐藏未编译的模板

vue
1<style>
2[v-cloak] { display: none; }
3</style>
4
5<div v-cloak>{{ message }}</div>

4.2 如何自定义指令?

答案:

javascript
1// 全局注册
2Vue.directive('focus', {
3 inserted(el) {
4 el.focus()
5 }
6})
7
8// 局部注册
9export default {
10 directives: {
11 focus: {
12 inserted(el) {
13 el.focus()
14 }
15 }
16 }
17}
18
19// 完整钩子函数
20Vue.directive('example', {
21 bind(el, binding, vnode) {
22 // 只调用一次,指令第一次绑定到元素时
23 },
24 inserted(el, binding, vnode) {
25 // 被绑定元素插入父节点时
26 },
27 update(el, binding, vnode, oldVnode) {
28 // 所在组件的 VNode 更新时
29 },
30 componentUpdated(el, binding, vnode, oldVnode) {
31 // 所在组件的 VNode 及其子 VNode 全部更新后
32 },
33 unbind(el, binding, vnode) {
34 // 只调用一次,指令与元素解绑时
35 }
36})

实用自定义指令示例:

javascript
1// 1. 权限指令
2app.directive('permission', {
3 mounted(el, binding) {
4 const { value } = binding
5 const permissions = store.state.user.permissions
6
7 if (!permissions.includes(value)) {
8 el.parentNode?.removeChild(el)
9 }
10 }
11})
12
13// 使用
14<button v-permission="'admin'">Delete</button>
15
16// 2. 防抖指令
17app.directive('debounce', {
18 mounted(el, binding) {
19 let timer
20 el.addEventListener('click', () => {
21 if (timer) clearTimeout(timer)
22 timer = setTimeout(() => {
23 binding.value()
24 }, 500)
25 })
26 }
27})
28
29// 使用
30<button v-debounce="handleClick">Submit</button>
31
32// 3. 图片懒加载指令
33app.directive('lazy', {
34 mounted(el, binding) {
35 const observer = new IntersectionObserver(([entry]) => {
36 if (entry.isIntersecting) {
37 el.src = binding.value
38 observer.unobserve(el)
39 }
40 })
41 observer.observe(el)
42 }
43})
44
45// 使用
46<img v-lazy="imageUrl" />

五、Computed 与 Watch

5.1 computed 和 watch 的区别

答案:

特性computedwatch
用途计算派生数据监听数据变化执行操作
缓存有缓存,依赖不变不重新计算无缓存
返回值必须有返回值无返回值
异步不支持异步支持异步
使用场景数据转换、过滤、计算API 调用、复杂逻辑
javascript
1export default {
2 data() {
3 return {
4 firstName: 'John',
5 lastName: 'Doe',
6 searchQuery: ''
7 }
8 },
9
10 // computed:计算属性
11 computed: {
12 // 简单计算
13 fullName() {
14 return `${this.firstName} ${this.lastName}`
15 },
16
17 // getter 和 setter
18 fullNameWithSetter: {
19 get() {
20 return `${this.firstName} ${this.lastName}`
21 },
22 set(value) {
23 const names = value.split(' ')
24 this.firstName = names[0]
25 this.lastName = names[1]
26 }
27 }
28 },
29
30 // watch:侦听器
31 watch: {
32 // 简单侦听
33 searchQuery(newVal, oldVal) {
34 console.log(`Search changed from ${oldVal} to ${newVal}`)
35 this.fetchResults(newVal)
36 },
37
38 // 深度侦听
39 user: {
40 handler(newVal, oldVal) {
41 console.log('User changed')
42 },
43 deep: true,
44 immediate: true
45 },
46
47 // 侦听对象属性
48 'user.name'(newVal) {
49 console.log('User name changed:', newVal)
50 }
51 },
52
53 methods: {
54 async fetchResults(query) {
55 // 异步操作
56 const results = await api.search(query)
57 this.results = results
58 }
59 }
60}

Vue 3 Composition API:

javascript
1import { ref, computed, watch, watchEffect } from 'vue'
2
3export default {
4 setup() {
5 const firstName = ref('John')
6 const lastName = ref('Doe')
7 const searchQuery = ref('')
8
9 // computed
10 const fullName = computed(() => {
11 return `${firstName.value} ${lastName.value}`
12 })
13
14 // computed with setter
15 const fullNameWithSetter = computed({
16 get: () => `${firstName.value} ${lastName.value}`,
17 set: (value) => {
18 const names = value.split(' ')
19 firstName.value = names[0]
20 lastName.value = names[1]
21 }
22 })
23
24 // watch
25 watch(searchQuery, (newVal, oldVal) => {
26 console.log(`Search changed from ${oldVal} to ${newVal}`)
27 })
28
29 // watch 多个源
30 watch([firstName, lastName], ([newFirst, newLast]) => {
31 console.log(`Name: ${newFirst} ${newLast}`)
32 })
33
34 // watchEffect:自动追踪依赖
35 watchEffect(() => {
36 console.log(`Full name: ${firstName.value} ${lastName.value}`)
37 })
38
39 return {
40 firstName,
41 lastName,
42 fullName,
43 searchQuery
44 }
45 }
46}

六、虚拟 DOM 与 Diff 算法

6.1 什么是虚拟 DOM?为什么需要虚拟 DOM?

答案:

虚拟 DOM(Virtual DOM) 是用 JavaScript 对象描述真实 DOM 的一种数据结构。

为什么需要虚拟 DOM:

  1. 性能优化:减少直接操作 DOM,批量更新
  2. 跨平台:可以渲染到不同平台(Web、Native、SSR)
  3. 开发体验:声明式编程,不需要手动操作 DOM
javascript
1// 虚拟 DOM 示例
2const vnode = {
3 tag: 'div',
4 props: {
5 class: 'container',
6 id: 'app'
7 },
8 children: [
9 {
10 tag: 'h1',
11 props: {},
12 children: 'Hello World'
13 },
14 {
15 tag: 'p',
16 props: { class: 'text' },
17 children: 'This is a paragraph'
18 }
19 ]
20}
21
22// 对应的真实 DOM
23<div class="container" id="app">
24 <h1>Hello World</h1>
25 <p class="text">This is a paragraph</p>
26</div>

6.2 Vue 的 Diff 算法原理

答案:

Vue 使用 同层比较 的 Diff 算法,时间复杂度为 O(n)。

核心策略:

  1. 同层比较:只比较同一层级的节点
  2. 类型判断:不同类型直接替换
  3. key 优化:使用 key 提高复用率
  4. 双端比较:从两端向中间比较

Diff 流程:

javascript
1// 简化的 Diff 算法
2function patch(oldVNode, newVNode) {
3 // 1. 节点类型不同,直接替换
4 if (oldVNode.tag !== newVNode.tag) {
5 return replaceNode(oldVNode, newVNode)
6 }
7
8 // 2. 文本节点
9 if (isTextNode(newVNode)) {
10 if (oldVNode.text !== newVNode.text) {
11 return updateText(oldVNode, newVNode)
12 }
13 return
14 }
15
16 // 3. 更新属性
17 updateProps(oldVNode, newVNode)
18
19 // 4. 更新子节点
20 updateChildren(oldVNode.children, newVNode.children)
21}
22
23// 双端比较算法
24function updateChildren(oldChildren, newChildren) {
25 let oldStartIdx = 0
26 let oldEndIdx = oldChildren.length - 1
27 let newStartIdx = 0
28 let newEndIdx = newChildren.length - 1
29
30 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
31 // 1. 旧开始 vs 新开始
32 if (sameVNode(oldChildren[oldStartIdx], newChildren[newStartIdx])) {
33 patch(oldChildren[oldStartIdx], newChildren[newStartIdx])
34 oldStartIdx++
35 newStartIdx++
36 }
37 // 2. 旧结束 vs 新结束
38 else if (sameVNode(oldChildren[oldEndIdx], newChildren[newEndIdx])) {
39 patch(oldChildren[oldEndIdx], newChildren[newEndIdx])
40 oldEndIdx--
41 newEndIdx--
42 }
43 // 3. 旧开始 vs 新结束
44 else if (sameVNode(oldChildren[oldStartIdx], newChildren[newEndIdx])) {
45 patch(oldChildren[oldStartIdx], newChildren[newEndIdx])
46 // 移动节点
47 oldStartIdx++
48 newEndIdx--
49 }
50 // 4. 旧结束 vs 新开始
51 else if (sameVNode(oldChildren[oldEndIdx], newChildren[newStartIdx])) {
52 patch(oldChildren[oldEndIdx], newChildren[newStartIdx])
53 // 移动节点
54 oldEndIdx--
55 newStartIdx++
56 }
57 // 5. 使用 key 查找
58 else {
59 // 在旧节点中查找相同 key 的节点
60 const idxInOld = findIdxInOld(newChildren[newStartIdx], oldChildren)
61 if (idxInOld) {
62 patch(oldChildren[idxInOld], newChildren[newStartIdx])
63 // 移动节点
64 } else {
65 // 创建新节点
66 createNode(newChildren[newStartIdx])
67 }
68 newStartIdx++
69 }
70 }
71
72 // 处理剩余节点
73 if (oldStartIdx > oldEndIdx) {
74 // 添加新节点
75 addNodes(newChildren, newStartIdx, newEndIdx)
76 } else {
77 // 删除旧节点
78 removeNodes(oldChildren, oldStartIdx, oldEndIdx)
79 }
80}

为什么需要 key:

vue
1<!-- 不使用 key -->
2<div v-for="item in items">{{ item.name }}</div>
3
4<!-- 使用 key -->
5<div v-for="item in items" :key="item.id">{{ item.name }}</div>

使用 key 的好处:

  1. 提高 Diff 效率
  2. 避免就地复用导致的问题
  3. 保持组件状态

七、Vue Router

7.1 Vue Router 的路由模式有哪些?

答案:

1. Hash 模式(默认)

  • URL 格式:http://example.com/#/user/123
  • 原理:监听 hashchange 事件
  • 优点:兼容性好,不需要服务器配置
  • 缺点:URL 不美观,SEO 不友好
javascript
1import { createRouter, createWebHashHistory } from 'vue-router'
2
3const router = createRouter({
4 history: createWebHashHistory(),
5 routes: [...]
6})

2. History 模式

  • URL 格式:http://example.com/user/123
  • 原理:使用 HTML5 History API(pushState、replaceState)
  • 优点:URL 美观,SEO 友好
  • 缺点:需要服务器配置,刷新会 404
javascript
1import { createRouter, createWebHistory } from 'vue-router'
2
3const router = createRouter({
4 history: createWebHistory(),
5 routes: [...]
6})

服务器配置(Nginx):

nginx
1location / {
2 try_files $uri $uri/ /index.html;
3}

3. Memory 模式(SSR)

javascript
1import { createRouter, createMemoryHistory } from 'vue-router'
2
3const router = createRouter({
4 history: createMemoryHistory(),
5 routes: [...]
6})

7.2 路由守卫有哪些?如何使用?

答案:

全局守卫:

javascript
1// 全局前置守卫
2router.beforeEach((to, from, next) => {
3 // 权限验证
4 if (to.meta.requiresAuth && !isAuthenticated()) {
5 next('/login')
6 } else {
7 next()
8 }
9})
10
11// 全局解析守卫
12router.beforeResolve((to, from, next) => {
13 // 在导航被确认之前,所有组件内守卫和异步路由组件被解析之后调用
14 next()
15})
16
17// 全局后置钩子
18router.afterEach((to, from) => {
19 // 不接受 next 函数
20 // 修改页面标题
21 document.title = to.meta.title || 'Default Title'
22})

路由独享守卫:

javascript
1const routes = [
2 {
3 path: '/admin',
4 component: Admin,
5 beforeEnter: (to, from, next) => {
6 if (hasAdminPermission()) {
7 next()
8 } else {
9 next('/403')
10 }
11 }
12 }
13]

组件内守卫:

javascript
1export default {
2 // 进入组件前
3 beforeRouteEnter(to, from, next) {
4 // 不能访问 this,因为组件实例还未创建
5 next(vm => {
6 // 通过 vm 访问组件实例
7 })
8 },
9
10 // 路由更新时
11 beforeRouteUpdate(to, from, next) {
12 // 可以访问 this
13 // 例如:/user/1 -> /user/2
14 this.fetchData(to.params.id)
15 next()
16 },
17
18 // 离开组件时
19 beforeRouteLeave(to, from, next) {
20 // 可以访问 this
21 const answer = window.confirm('确定要离开吗?未保存的更改将丢失。')
22 if (answer) {
23 next()
24 } else {
25 next(false)
26 }
27 }
28}

完整的导航解析流程:

  1. 导航被触发
  2. 在失活的组件里调用 beforeRouteLeave
  3. 调用全局的 beforeEach
  4. 在重用的组件里调用 beforeRouteUpdate
  5. 在路由配置里调用 beforeEnter
  6. 解析异步路由组件
  7. 在被激活的组件里调用 beforeRouteEnter
  8. 调用全局的 beforeResolve
  9. 导航被确认
  10. 调用全局的 afterEach
  11. 触发 DOM 更新
  12. 调用 beforeRouteEnter 中传给 next 的回调函数

八、Vuex / Pinia

8.1 Vuex 的核心概念

答案:

Vuex 核心概念:

  1. State:单一状态树
  2. Getters:计算属性
  3. Mutations:同步修改状态
  4. Actions:异步操作
  5. Modules:模块化
javascript
1// store/index.js
2import { createStore } from 'vuex'
3
4const store = createStore({
5 // 1. State
6 state: {
7 count: 0,
8 user: null,
9 todos: []
10 },
11
12 // 2. Getters
13 getters: {
14 doubleCount: state => state.count * 2,
15 completedTodos: state => {
16 return state.todos.filter(todo => todo.completed)
17 },
18 getTodoById: state => id => {
19 return state.todos.find(todo => todo.id === id)
20 }
21 },
22
23 // 3. Mutations(同步)
24 mutations: {
25 INCREMENT(state) {
26 state.count++
27 },
28 SET_USER(state, user) {
29 state.user = user
30 },
31 ADD_TODO(state, todo) {
32 state.todos.push(todo)
33 }
34 },
35
36 // 4. Actions(异步)
37 actions: {
38 async fetchUser({ commit }, userId) {
39 const user = await api.getUser(userId)
40 commit('SET_USER', user)
41 },
42 async addTodo({ commit }, todo) {
43 const newTodo = await api.createTodo(todo)
44 commit('ADD_TODO', newTodo)
45 }
46 },
47
48 // 5. Modules
49 modules: {
50 user: {
51 namespaced: true,
52 state: () => ({ ... }),
53 mutations: { ... },
54 actions: { ... }
55 }
56 }
57})
58
59export default store

在组件中使用:

vue
1<template>
2 <div>
3 <p>Count: {{ count }}</p>
4 <p>Double: {{ doubleCount }}</p>
5 <button @click="increment">Increment</button>
6 <button @click="fetchUser">Fetch User</button>
7 </div>
8</template>
9
10<script>
11import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'
12
13export default {
14 computed: {
15 // 方式1:直接访问
16 count() {
17 return this.$store.state.count
18 },
19
20 // 方式2:使用辅助函数
21 ...mapState(['count', 'user']),
22 ...mapGetters(['doubleCount', 'completedTodos'])
23 },
24
25 methods: {
26 // 方式1:直接调用
27 increment() {
28 this.$store.commit('INCREMENT')
29 },
30 fetchUser() {
31 this.$store.dispatch('fetchUser', 123)
32 },
33
34 // 方式2:使用辅助函数
35 ...mapMutations(['INCREMENT', 'SET_USER']),
36 ...mapActions(['fetchUser', 'addTodo'])
37 }
38}
39</script>

8.2 Pinia 相比 Vuex 的优势

答案:

Pinia 优势:

  1. 更简单的 API,去除了 mutations
  2. 完整的 TypeScript 支持
  3. 更好的代码分割
  4. 没有嵌套的模块结构
  5. 支持 Vue 2 和 Vue 3
javascript
1// stores/user.js
2import { defineStore } from 'pinia'
3
4export const useUserStore = defineStore('user', {
5 // State
6 state: () => ({
7 name: 'John',
8 age: 30,
9 todos: []
10 }),
11
12 // Getters
13 getters: {
14 doubleAge: (state) => state.age * 2,
15 completedTodos: (state) => {
16 return state.todos.filter(todo => todo.completed)
17 }
18 },
19
20 // Actions(同步和异步)
21 actions: {
22 updateName(newName) {
23 this.name = newName
24 },
25
26 async fetchTodos() {
27 this.todos = await api.getTodos()
28 },
29
30 async addTodo(todo) {
31 const newTodo = await api.createTodo(todo)
32 this.todos.push(newTodo)
33 }
34 }
35})

在组件中使用:

vue
1<template>
2 <div>
3 <p>Name: {{ userStore.name }}</p>
4 <p>Age: {{ userStore.doubleAge }}</p>
5 <button @click="userStore.updateName('Jane')">Update Name</button>
6 </div>
7</template>
8
9<script setup>
10import { useUserStore } from '@/stores/user'
11import { storeToRefs } from 'pinia'
12
13const userStore = useUserStore()
14
15// 解构保持响应式
16const { name, age } = storeToRefs(userStore)
17const { updateName, fetchTodos } = userStore
18</script>

九、性能优化

9.1 Vue 性能优化的方法有哪些?

答案:

1. 代码层面优化:

javascript
1// ① v-if vs v-show
2// v-if:条件渲染,切换开销大
3// v-show:CSS 显示隐藏,初始渲染开销大
4<div v-if="isShow">Conditional</div> // 频繁切换用 v-show
5<div v-show="isShow">Toggle</div> // 初始不显示用 v-if
6
7// ② 使用 key
8<div v-for="item in items" :key="item.id">{{ item.name }}</div>
9
10// ③ 路由懒加载
11const Home = () => import('./views/Home.vue')
12const About = () => import('./views/About.vue')
13
14// ④ 组件懒加载
15components: {
16 AsyncComponent: () => import('./AsyncComponent.vue')
17}
18
19// ⑤ keep-alive 缓存组件
20<keep-alive :include="['Home', 'About']">
21 <router-view />
22</keep-alive>
23
24// ⑥ 长列表优化 - 虚拟滚动
25import VirtualList from 'vue-virtual-scroll-list'
26
27// ⑦ 事件销毁
28beforeUnmount() {
29 window.removeEventListener('resize', this.handleResize)
30 clearInterval(this.timer)
31}
32
33// ⑧ 图片懒加载
34<img v-lazy="imageUrl" />
35
36// ⑨ 第三方插件按需引入
37import { Button, Select } from 'element-plus'
38
39// ⑩ 使用函数式组件(无状态组件)
40export default {
41 functional: true,
42 render(h, context) {
43 return h('div', context.props.text)
44 }
45}

2. 打包优化:

javascript
1// vite.config.js
2export default {
3 build: {
4 // 代码分割
5 rollupOptions: {
6 output: {
7 manualChunks: {
8 'vue-vendor': ['vue', 'vue-router', 'pinia'],
9 'ui-vendor': ['element-plus']
10 }
11 }
12 },
13 // 压缩
14 minify: 'terser',
15 terserOptions: {
16 compress: {
17 drop_console: true,
18 drop_debugger: true
19 }
20 }
21 }
22}

3. 使用 computed 缓存:

javascript
1// ❌ 不好的做法
2<div>{{ getFullName() }}</div>
3
4methods: {
5 getFullName() {
6 return this.firstName + ' ' + this.lastName
7 }
8}
9
10// ✅ 好的做法
11<div>{{ fullName }}</div>
12
13computed: {
14 fullName() {
15 return this.firstName + ' ' + this.lastName
16 }
17}

4. 使用 Object.freeze 冻结数据:

javascript
1export default {
2 data() {
3 return {
4 // 大量不需要响应式的数据
5 largeList: Object.freeze([...])
6 }
7 }
8}

5. 使用 v-once 和 v-memo:

vue
1<!-- 只渲染一次 -->
2<div v-once>{{ staticContent }}</div>
3
4<!-- Vue 3: 条件缓存 -->
5<div v-memo="[item.id, item.name]">
6 {{ item.name }}
7</div>

十、常见问题

10.1 Vue 中的 $nextTick 是什么?

答案:

$nextTick 在下次 DOM 更新循环结束之后执行延迟回调。

使用场景:

  • 在数据变化后立即操作更新后的 DOM
  • 在 created 钩子中操作 DOM
javascript
1export default {
2 data() {
3 return {
4 message: 'Hello'
5 }
6 },
7 methods: {
8 updateMessage() {
9 this.message = 'Updated'
10
11 // 此时 DOM 还未更新
12 console.log(this.$refs.msg.textContent) // 'Hello'
13
14 // 等待 DOM 更新后执行
15 this.$nextTick(() => {
16 console.log(this.$refs.msg.textContent) // 'Updated'
17 })
18
19 // Vue 3 也可以使用 Promise
20 this.$nextTick().then(() => {
21 console.log(this.$refs.msg.textContent) // 'Updated'
22 })
23 }
24 }
25}

原理:

Vue 使用异步队列来批量更新 DOM,优先使用微任务(Promise、MutationObserver),降级使用宏任务(setTimeout)。


10.2 Vue 中如何实现组件的递归?

答案:

组件可以在自己的模板中递归调用自己,需要设置 name 选项。

vue
1<!-- TreeNode.vue -->
2<template>
3 <div class="tree-node">
4 <div class="node-content">
5 <span @click="toggle">{{ node.name }}</span>
6 </div>
7
8 <div v-if="isExpanded && node.children" class="children">
9 <!-- 递归调用自己 -->
10 <TreeNode
11 v-for="child in node.children"
12 :key="child.id"
13 :node="child"
14 />
15 </div>
16 </div>
17</template>
18
19<script>
20export default {
21 name: 'TreeNode', // 必须设置 name
22 props: {
23 node: {
24 type: Object,
25 required: true
26 }
27 },
28 data() {
29 return {
30 isExpanded: false
31 }
32 },
33 methods: {
34 toggle() {
35 this.isExpanded = !this.isExpanded
36 }
37 }
38}
39</script>
40
41<!-- 使用 -->
42<template>
43 <TreeNode :node="treeData" />
44</template>
45
46<script>
47export default {
48 data() {
49 return {
50 treeData: {
51 id: 1,
52 name: 'Root',
53 children: [
54 {
55 id: 2,
56 name: 'Child 1',
57 children: [
58 { id: 3, name: 'Grandchild 1' },
59 { id: 4, name: 'Grandchild 2' }
60 ]
61 },
62 {
63 id: 5,
64 name: 'Child 2'
65 }
66 ]
67 }
68 }
69 }
70}
71</script>

10.3 Vue 中的 slot 插槽如何使用?

答案:

1. 默认插槽:

vue
1<!-- 子组件 Card.vue -->
2<template>
3 <div class="card">
4 <slot>默认内容</slot>
5 </div>
6</template>
7
8<!-- 父组件使用 -->
9<Card>
10 <p>自定义内容</p>
11</Card>

2. 具名插槽:

vue
1<!-- 子组件 Layout.vue -->
2<template>
3 <div class="layout">
4 <header>
5 <slot name="header"></slot>
6 </header>
7 <main>
8 <slot></slot>
9 </main>
10 <footer>
11 <slot name="footer"></slot>
12 </footer>
13 </div>
14</template>
15
16<!-- 父组件使用 -->
17<Layout>
18 <template #header>
19 <h1>页面标题</h1>
20 </template>
21
22 <p>主要内容</p>
23
24 <template #footer>
25 <p>页脚信息</p>
26 </template>
27</Layout>

3. 作用域插槽:

vue
1<!-- 子组件 List.vue -->
2<template>
3 <ul>
4 <li v-for="item in items" :key="item.id">
5 <slot :item="item" :index="index">
6 {{ item.name }}
7 </slot>
8 </li>
9 </ul>
10</template>
11
12<!-- 父组件使用 -->
13<List :items="users">
14 <template #default="{ item, index }">
15 <span>{{ index + 1 }}. {{ item.name }} - {{ item.email }}</span>
16 </template>
17</List>

10.4 Vue 中如何实现权限控制?

答案:

1. 路由级别权限控制:

javascript
1// router/index.js
2const routes = [
3 {
4 path: '/admin',
5 component: Admin,
6 meta: { requiresAuth: true, roles: ['admin'] }
7 }
8]
9
10// 路由守卫
11router.beforeEach((to, from, next) => {
12 const user = store.state.user
13
14 if (to.meta.requiresAuth) {
15 if (!user) {
16 next('/login')
17 } else if (to.meta.roles && !to.meta.roles.includes(user.role)) {
18 next('/403')
19 } else {
20 next()
21 }
22 } else {
23 next()
24 }
25})

2. 按钮级别权限控制:

javascript
1// directives/permission.js
2export default {
3 mounted(el, binding) {
4 const { value } = binding
5 const permissions = store.state.user.permissions
6
7 if (value && !permissions.includes(value)) {
8 el.parentNode?.removeChild(el)
9 }
10 }
11}
12
13// 使用
14<button v-permission="'user:delete'">删除</button>

3. 组件级别权限控制:

vue
1<!-- Permission.vue -->
2<template>
3 <div v-if="hasPermission">
4 <slot></slot>
5 </div>
6</template>
7
8<script>
9export default {
10 props: ['permission'],
11 computed: {
12 hasPermission() {
13 const permissions = this.$store.state.user.permissions
14 return permissions.includes(this.permission)
15 }
16 }
17}
18</script>
19
20<!-- 使用 -->
21<Permission permission="user:edit">
22 <button>编辑</button>
23</Permission>

十一、前后台交互

11.1 Vue 中的异步请求方式

面试回答思路:

Vue 本身不提供 HTTP 请求功能,需要使用第三方库或原生 API。主要有三种方式:fetch、axios 和 async/await。

1. Fetch API(原生)

Fetch 是浏览器原生提供的 API,基于 Promise。

基本使用:

javascript
1// GET 请求
2fetch('https://api.example.com/users')
3 .then(response => {
4 if (!response.ok) {
5 throw new Error('Network response was not ok')
6 }
7 return response.json()
8 })
9 .then(data => {
10 console.log(data)
11 })
12 .catch(error => {
13 console.error('Error:', error)
14 })
15
16// POST 请求
17fetch('https://api.example.com/users', {
18 method: 'POST',
19 headers: {
20 'Content-Type': 'application/json',
21 },
22 body: JSON.stringify({
23 name: 'John',
24 email: 'john@example.com'
25 })
26})
27 .then(response => response.json())
28 .then(data => console.log(data))
29 .catch(error => console.error('Error:', error))

在 Vue 组件中使用:

vue
1<template>
2 <div>
3 <ul v-if="users.length">
4 <li v-for="user in users" :key="user.id">
5 {{ user.name }}
6 </li>
7 </ul>
8 <p v-else-if="loading">Loading...</p>
9 <p v-else-if="error">{{ error }}</p>
10 </div>
11</template>
12
13<script>
14export default {
15 data() {
16 return {
17 users: [],
18 loading: false,
19 error: null
20 }
21 },
22
23 mounted() {
24 this.fetchUsers()
25 },
26
27 methods: {
28 fetchUsers() {
29 this.loading = true
30 this.error = null
31
32 fetch('https://api.example.com/users')
33 .then(response => {
34 if (!response.ok) {
35 throw new Error(`HTTP error! status: ${response.status}`)
36 }
37 return response.json()
38 })
39 .then(data => {
40 this.users = data
41 this.loading = false
42 })
43 .catch(error => {
44 this.error = error.message
45 this.loading = false
46 })
47 }
48 }
49}
50</script>

Fetch 的优缺点:

优点:

  • 原生 API,不需要额外安装
  • 基于 Promise,支持 async/await
  • 更现代的 API 设计

缺点:

  • 不支持请求取消(需要 AbortController)
  • 不支持请求超时
  • 不支持上传进度
  • 默认不携带 cookie(需要设置 credentials)
  • 错误处理不够友好(404、500 不会 reject)
  • 不支持拦截器

11.2 Axios 的使用和封装

面试回答思路:

Axios 是目前 Vue 项目中最常用的 HTTP 库,功能强大且易用。

为什么选择 Axios:

  1. 功能丰富:支持请求/响应拦截、取消请求、超时设置等
  2. 自动转换:自动转换 JSON 数据
  3. 错误处理:更好的错误处理机制
  4. 浏览器兼容:支持老版本浏览器
  5. 防御 XSRF:内置 CSRF 防护
  6. 进度监控:支持上传/下载进度

基本使用:

javascript
1import axios from 'axios'
2
3// GET 请求
4axios.get('https://api.example.com/users')
5 .then(response => {
6 console.log(response.data)
7 })
8 .catch(error => {
9 console.error(error)
10 })
11
12// POST 请求
13axios.post('https://api.example.com/users', {
14 name: 'John',
15 email: 'john@example.com'
16})
17 .then(response => {
18 console.log(response.data)
19 })
20 .catch(error => {
21 console.error(error)
22 })
23
24// 并发请求
25axios.all([
26 axios.get('https://api.example.com/users'),
27 axios.get('https://api.example.com/posts')
28])
29 .then(axios.spread((users, posts) => {
30 console.log(users.data, posts.data)
31 }))

在 Vue 中封装 Axios:

javascript
1// api/request.js
2import axios from 'axios'
3import { ElMessage } from 'element-plus'
4import router from '@/router'
5
6// 创建 axios 实例
7const service = axios.create({
8 baseURL: process.env.VUE_APP_BASE_API, // API 基础路径
9 timeout: 10000, // 请求超时时间
10 headers: {
11 'Content-Type': 'application/json;charset=UTF-8'
12 }
13})
14
15// 请求拦截器
16service.interceptors.request.use(
17 config => {
18 // 在发送请求之前做些什么
19
20 // 1. 添加 token
21 const token = localStorage.getItem('token')
22 if (token) {
23 config.headers['Authorization'] = `Bearer ${token}`
24 }
25
26 // 2. 显示 loading
27 // showLoading()
28
29 // 3. 请求参数处理
30 if (config.method === 'get') {
31 // GET 请求参数序列化
32 config.params = {
33 ...config.params,
34 _t: Date.now() // 防止缓存
35 }
36 }
37
38 return config
39 },
40 error => {
41 // 请求错误处理
42 console.error('Request error:', error)
43 return Promise.reject(error)
44 }
45)
46
47// 响应拦截器
48service.interceptors.response.use(
49 response => {
50 // hideLoading()
51
52 const res = response.data
53
54 // 根据后端约定的状态码判断
55 if (res.code !== 200) {
56 ElMessage.error(res.message || 'Error')
57
58 // 特殊状态码处理
59 if (res.code === 401) {
60 // token 过期,跳转登录
61 ElMessage.error('登录已过期,请重新登录')
62 localStorage.removeItem('token')
63 router.push('/login')
64 }
65
66 return Promise.reject(new Error(res.message || 'Error'))
67 }
68
69 return res.data
70 },
71 error => {
72 // hideLoading()
73
74 console.error('Response error:', error)
75
76 // 处理不同的错误状态
77 if (error.response) {
78 switch (error.response.status) {
79 case 400:
80 ElMessage.error('请求参数错误')
81 break
82 case 401:
83 ElMessage.error('未授权,请重新登录')
84 localStorage.removeItem('token')
85 router.push('/login')
86 break
87 case 403:
88 ElMessage.error('拒绝访问')
89 break
90 case 404:
91 ElMessage.error('请求地址不存在')
92 break
93 case 500:
94 ElMessage.error('服务器错误')
95 break
96 default:
97 ElMessage.error(error.response.data.message || '请求失败')
98 }
99 } else if (error.request) {
100 ElMessage.error('网络错误,请检查网络连接')
101 } else {
102 ElMessage.error('请求配置错误')
103 }
104
105 return Promise.reject(error)
106 }
107)
108
109export default service

封装 API 接口:

javascript
1// api/user.js
2import request from './request'
3
4// 用户相关 API
5export const userApi = {
6 // 获取用户列表
7 getUsers(params) {
8 return request({
9 url: '/users',
10 method: 'get',
11 params
12 })
13 },
14
15 // 获取用户详情
16 getUserById(id) {
17 return request({
18 url: `/users/${id}`,
19 method: 'get'
20 })
21 },
22
23 // 创建用户
24 createUser(data) {
25 return request({
26 url: '/users',
27 method: 'post',
28 data
29 })
30 },
31
32 // 更新用户
33 updateUser(id, data) {
34 return request({
35 url: `/users/${id}`,
36 method: 'put',
37 data
38 })
39 },
40
41 // 删除用户
42 deleteUser(id) {
43 return request({
44 url: `/users/${id}`,
45 method: 'delete'
46 })
47 },
48
49 // 上传文件
50 uploadFile(file, onProgress) {
51 const formData = new FormData()
52 formData.append('file', file)
53
54 return request({
55 url: '/upload',
56 method: 'post',
57 data: formData,
58 headers: {
59 'Content-Type': 'multipart/form-data'
60 },
61 onUploadProgress: progressEvent => {
62 if (onProgress) {
63 const percentCompleted = Math.round(
64 (progressEvent.loaded * 100) / progressEvent.total
65 )
66 onProgress(percentCompleted)
67 }
68 }
69 })
70 }
71}

在组件中使用:

vue
1<template>
2 <div>
3 <ul v-if="users.length">
4 <li v-for="user in users" :key="user.id">
5 {{ user.name }}
6 </li>
7 </ul>
8 <button @click="loadMore">加载更多</button>
9 </div>
10</template>
11
12<script>
13import { userApi } from '@/api/user'
14
15export default {
16 data() {
17 return {
18 users: [],
19 loading: false,
20 page: 1,
21 pageSize: 10
22 }
23 },
24
25 mounted() {
26 this.fetchUsers()
27 },
28
29 methods: {
30 async fetchUsers() {
31 try {
32 this.loading = true
33 const data = await userApi.getUsers({
34 page: this.page,
35 pageSize: this.pageSize
36 })
37 this.users = data.list
38 } catch (error) {
39 console.error('获取用户列表失败:', error)
40 } finally {
41 this.loading = false
42 }
43 },
44
45 async loadMore() {
46 this.page++
47 try {
48 const data = await userApi.getUsers({
49 page: this.page,
50 pageSize: this.pageSize
51 })
52 this.users.push(...data.list)
53 } catch (error) {
54 this.page--
55 }
56 }
57 }
58}
59</script>

11.3 async/await 的使用

面试回答思路:

async/await 是 ES2017 引入的异步编程语法糖,让异步代码看起来像同步代码。

基本概念:

async 函数:

  • 返回一个 Promise
  • 可以使用 await 关键字
  • 自动将返回值包装成 Promise

await 关键字:

  • 只能在 async 函数中使用
  • 等待 Promise 完成
  • 返回 Promise 的结果

基本使用:

javascript
1// 传统 Promise 写法
2function fetchUser() {
3 return fetch('/api/user')
4 .then(response => response.json())
5 .then(user => {
6 return fetch(`/api/posts?userId=${user.id}`)
7 })
8 .then(response => response.json())
9 .then(posts => {
10 console.log(posts)
11 })
12 .catch(error => {
13 console.error(error)
14 })
15}
16
17// async/await 写法
18async function fetchUser() {
19 try {
20 const response = await fetch('/api/user')
21 const user = await response.json()
22
23 const postsResponse = await fetch(`/api/posts?userId=${user.id}`)
24 const posts = await postsResponse.json()
25
26 console.log(posts)
27 } catch (error) {
28 console.error(error)
29 }
30}

在 Vue 中使用:

vue
1<script>
2export default {
3 data() {
4 return {
5 user: null,
6 posts: [],
7 loading: false,
8 error: null
9 }
10 },
11
12 async mounted() {
13 await this.fetchData()
14 },
15
16 methods: {
17 // 方法 1:async 方法
18 async fetchData() {
19 this.loading = true
20 this.error = null
21
22 try {
23 // 串行请求
24 const user = await userApi.getUserById(1)
25 this.user = user
26
27 const posts = await postApi.getPostsByUserId(user.id)
28 this.posts = posts
29 } catch (error) {
30 this.error = error.message
31 console.error('获取数据失败:', error)
32 } finally {
33 this.loading = false
34 }
35 },
36
37 // 方法 2:并行请求
38 async fetchDataParallel() {
39 this.loading = true
40
41 try {
42 // 使用 Promise.all 并行请求
43 const [user, posts] = await Promise.all([
44 userApi.getUserById(1),
45 postApi.getPosts()
46 ])
47
48 this.user = user
49 this.posts = posts
50 } catch (error) {
51 this.error = error.message
52 } finally {
53 this.loading = false
54 }
55 },
56
57 // 方法 3:错误处理
58 async createUser(userData) {
59 try {
60 const user = await userApi.createUser(userData)
61 this.$message.success('创建成功')
62 return user
63 } catch (error) {
64 if (error.response?.status === 400) {
65 this.$message.error('参数错误')
66 } else if (error.response?.status === 409) {
67 this.$message.error('用户已存在')
68 } else {
69 this.$message.error('创建失败')
70 }
71 throw error
72 }
73 },
74
75 // 方法 4:循环中使用
76 async processUsers(userIds) {
77 const results = []
78
79 // 串行处理
80 for (const id of userIds) {
81 const user = await userApi.getUserById(id)
82 results.push(user)
83 }
84
85 // 并行处理(推荐)
86 const users = await Promise.all(
87 userIds.map(id => userApi.getUserById(id))
88 )
89
90 return users
91 }
92 }
93}
94</script>

Vue 3 Composition API 中使用:

vue
1<script setup>
2import { ref, onMounted } from 'vue'
3import { userApi } from '@/api/user'
4
5const users = ref([])
6const loading = ref(false)
7const error = ref(null)
8
9// 方式 1:在 onMounted 中使用
10onMounted(async () => {
11 await fetchUsers()
12})
13
14// 方式 2:定义 async 函数
15const fetchUsers = async () => {
16 loading.value = true
17 error.value = null
18
19 try {
20 const data = await userApi.getUsers()
21 users.value = data
22 } catch (err) {
23 error.value = err.message
24 } finally {
25 loading.value = false
26 }
27}
28
29// 方式 3:使用 VueUse 的 useAsyncState
30import { useAsyncState } from '@vueuse/core'
31
32const { state, isLoading, error, execute } = useAsyncState(
33 () => userApi.getUsers(),
34 [],
35 { immediate: true }
36)
37</script>

最佳实践:

1. 错误处理

javascript
1// ✅ 好的做法:使用 try-catch
2async function fetchData() {
3 try {
4 const data = await api.getData()
5 return data
6 } catch (error) {
7 console.error('Error:', error)
8 throw error // 或者返回默认值
9 }
10}
11
12// ❌ 不好的做法:不处理错误
13async function fetchData() {
14 const data = await api.getData() // 如果失败会导致未捕获的错误
15 return data
16}

2. 并行 vs 串行

javascript
1// ❌ 串行(慢)
2async function fetchAll() {
3 const users = await userApi.getUsers()
4 const posts = await postApi.getPosts()
5 return { users, posts }
6}
7
8// ✅ 并行(快)
9async function fetchAll() {
10 const [users, posts] = await Promise.all([
11 userApi.getUsers(),
12 postApi.getPosts()
13 ])
14 return { users, posts }
15}

3. 避免在循环中使用 await

javascript
1// ❌ 不好:串行执行
2async function processUsers(ids) {
3 const results = []
4 for (const id of ids) {
5 const user = await userApi.getUserById(id)
6 results.push(user)
7 }
8 return results
9}
10
11// ✅ 好:并行执行
12async function processUsers(ids) {
13 return await Promise.all(
14 ids.map(id => userApi.getUserById(id))
15 )
16}

4. 超时处理

javascript
1function timeout(ms) {
2 return new Promise((_, reject) => {
3 setTimeout(() => reject(new Error('Timeout')), ms)
4 })
5}
6
7async function fetchWithTimeout(url, ms = 5000) {
8 try {
9 const data = await Promise.race([
10 fetch(url),
11 timeout(ms)
12 ])
13 return data
14 } catch (error) {
15 console.error('请求超时或失败:', error)
16 throw error
17 }
18}

5. 取消请求

javascript
1// 使用 AbortController
2const controller = new AbortController()
3
4async function fetchData() {
5 try {
6 const response = await fetch('/api/data', {
7 signal: controller.signal
8 })
9 return await response.json()
10 } catch (error) {
11 if (error.name === 'AbortError') {
12 console.log('请求已取消')
13 } else {
14 console.error('请求失败:', error)
15 }
16 }
17}
18
19// 取消请求
20controller.abort()

11.4 三种方式的对比和选择

面试回答思路:

特性FetchAxiosasync/await
类型原生 API第三方库语法糖
浏览器支持现代浏览器所有浏览器ES2017+
包大小0(原生)~13KB0(语法)
自动转换 JSON❌ 需手动✅ 自动取决于使用的库
拦截器取决于使用的库
取消请求✅ AbortController✅ CancelToken取决于使用的库
上传进度取决于使用的库
超时设置需手动实现
错误处理较复杂简单简单
CSRF 防护取决于使用的库

选择建议:

  1. 小型项目或简单需求:使用 Fetch + async/await
  2. 中大型项目:使用 Axios + async/await(推荐)
  3. 需要兼容老浏览器:使用 Axios
  4. 需要拦截器、进度监控等高级功能:使用 Axios

实际项目中的最佳实践:

javascript
1// 推荐组合:Axios + async/await + 统一封装
2
3// 1. 封装 axios 实例
4// 2. 配置拦截器
5// 3. 封装 API 接口
6// 4. 在组件中使用 async/await 调用
7
8// 这样可以获得:
9// - Axios 的强大功能
10// - async/await 的简洁语法
11// - 统一的错误处理
12// - 良好的代码组织

十二、常见面试题补充

12.1 v-if 和 v-show 的区别

面试回答思路:

v-if 和 v-show 都可以控制元素的显示和隐藏,但它们的实现原理和使用场景完全不同。这是 Vue 面试中的高频考点,需要从原理、性能、使用场景等多个角度来回答。

核心区别

v-if vs v-show = 条件渲染 vs CSS 切换

  • 🎯 v-if:真正的条件渲染,DOM 元素的创建和销毁
  • 🎨 v-show:CSS display 属性切换,元素始终存在
  • 性能:v-if 切换开销大,v-show 初始渲染开销大
  • 🔄 生命周期:v-if 触发生命周期,v-show 不触发

实现原理对比:

v-if 是真正的条件渲染:

  1. 条件为 false:元素不会被渲染到 DOM 中
  2. 条件为 true:创建元素并渲染到 DOM
  3. 切换时:销毁和重建元素及其内部的组件
  4. 生命周期:触发 mounted、unmounted 等钩子
  5. 惰性渲染:初始条件为 false 时,什么都不做
vue
1<template>
2 <!-- 条件为 false 时,DOM 中不存在这个元素 -->
3 <div v-if="isShow">
4 <h1>使用 v-if</h1>
5 <ExpensiveComponent />
6 </div>
7</template>
8
9<script>
10export default {
11 data() {
12 return {
13 isShow: false // DOM 中不会渲染这个 div
14 }
15 }
16}
17</script>

编译后的渲染函数(简化):

javascript
1function render() {
2 return isShow
3 ? h('div', [h('h1', '使用 v-if'), h(ExpensiveComponent)])
4 : null // 不渲染任何内容
5}

性能对比分析:

特性v-ifv-show
渲染方式条件渲染(创建/销毁)CSS 切换(display)
初始渲染开销低(惰性渲染)高(总是渲染)
切换开销高(重建 DOM)低(只改 CSS)
生命周期✅ 触发❌ 不触发
v-else 支持✅ 支持❌ 不支持
template 支持✅ 支持❌ 不支持
适用场景条件很少改变频繁切换
内存占用低(不渲染时释放)高(始终占用)
性能陷阱

频繁切换时使用 v-if 会导致性能问题!

假设一个标签页组件每秒切换 10 次:

  • 使用 v-if:每次切换都要销毁和重建组件,触发生命周期,性能开销巨大
  • 使用 v-show:只是修改 CSS,性能开销极小

测试数据:1000 次切换

  • v-if:约 500ms
  • v-show:约 50ms(快 10 倍)

语法支持对比:

vue
1<template>
2 <div>
3 <!-- 1. 基本用法 -->
4 <div v-if="isShow">显示内容</div>
5
6 <!-- 2. v-else-if 和 v-else -->
7 <div v-if="type === 'A'">A</div>
8 <div v-else-if="type === 'B'">B</div>
9 <div v-else>C</div>
10
11 <!-- 3. 配合 template(不产生额外 DOM) -->
12 <template v-if="showGroup">
13 <h1>标题</h1>
14 <p>内容</p>
15 <button>按钮</button>
16 </template>
17
18 <!-- 4. 在组件上使用 -->
19 <MyComponent v-if="isShow" />
20
21 <!-- 5. 多条件判断 -->
22 <div v-if="isLoggedIn && hasPermission">
23 管理员面板
24 </div>
25 </div>
26</template>

使用场景指南:

选择原则

一句话总结:频繁切换用 v-show,条件很少改变用 v-if

使用 v-if 的场景:

  • ✅ 权限控制(不渲染 DOM 更安全)
  • ✅ 条件很少改变的情况
  • ✅ 需要配合 v-else 使用
  • ✅ 初始条件为 false,且可能永远不会变为 true
  • ✅ 包含大量子组件或复杂逻辑的元素
  • ✅ 需要触发生命周期钩子

使用 v-show 的场景:

  • ✅ 标签页切换
  • ✅ 模态框/对话框显示隐藏
  • ✅ 下拉菜单展开收起
  • ✅ 手风琴组件
  • ✅ 需要频繁切换显示状态
  • ✅ 元素结构简单,渲染成本低

实际案例对比:

vue
1<template>
2 <div>
3 <!-- ✅ 推荐:使用 v-if -->
4 <!-- 原因:不渲染 DOM 更安全,用户无法通过开发者工具看到隐藏的按钮 -->
5 <button v-if="hasDeletePermission" @click="deleteItem">
6 删除
7 </button>
8
9 <button v-if="hasEditPermission" @click="editItem">
10 编辑
11 </button>
12
13 <!-- ❌ 不推荐:使用 v-show -->
14 <!-- 问题:按钮仍在 DOM 中,只是隐藏,用户可以通过开发者工具修改 CSS 看到 -->
15 <button v-show="hasDeletePermission" @click="deleteItem">
16 删除
17 </button>
18 </div>
19</template>
20
21<script>
22export default {
23 computed: {
24 hasDeletePermission() {
25 return this.$store.state.user.permissions.includes('delete')
26 },
27 hasEditPermission() {
28 return this.$store.state.user.permissions.includes('edit')
29 }
30 }
31}
32</script>
常见错误

错误 1:在频繁切换的场景使用 v-if

vue
1<!-- ❌ 错误:标签页使用 v-if -->
2<div v-if="activeTab === 'home'">首页</div>
3<div v-if="activeTab === 'profile'">个人资料</div>
4<!-- 问题:每次切换都重建组件,性能差 -->
5
6<!-- ✅ 正确:使用 v-show -->
7<div v-show="activeTab === 'home'">首页</div>
8<div v-show="activeTab === 'profile'">个人资料</div>

错误 2:在权限控制中使用 v-show

vue
1<!-- ❌ 错误:权限控制使用 v-show -->
2<button v-show="hasPermission">删除</button>
3<!-- 问题:按钮仍在 DOM 中,不安全 -->
4
5<!-- ✅ 正确:使用 v-if -->
6<button v-if="hasPermission">删除</button>

错误 3:v-show 使用 v-else

vue
1<!-- ❌ 错误:v-show 不支持 v-else -->
2<div v-show="isShow">A</div>
3<div v-else>B</div> <!-- 不会生效! -->
4
5<!-- ✅ 正确:使用相反条件 -->
6<div v-show="isShow">A</div>
7<div v-show="!isShow">B</div>

总结:

记住这个决策树:

1需要条件渲染?
2 ├─ 是否频繁切换?
3 │ ├─ 是 → 使用 v-show
4 │ └─ 否 → 继续判断
5
6 ├─ 是否涉及权限/安全?
7 │ ├─ 是 → 使用 v-if
8 │ └─ 否 → 继续判断
9
10 ├─ 是否需要 v-else?
11 │ ├─ 是 → 使用 v-if
12 │ └─ 否 → 继续判断
13
14 ├─ 初始条件是否为 false?
15 │ ├─ 是 → 使用 v-if(惰性渲染)
16 │ └─ 否 → 使用 v-show
17
18 └─ 默认 → 使用 v-if(更安全)

在实际项目中,我会根据具体场景选择合适的指令,既要考虑性能,也要考虑代码的可读性和维护性。


12.2 v-for 为什么要加 key

面试回答思路:

key 是 Vue 中用于优化列表渲染的重要属性,理解它的作用对于写出高性能的 Vue 应用很重要。这个问题考察的是对 Vue 虚拟 DOM 和 Diff 算法的理解。

核心作用

key = 节点身份标识 + Diff 优化 + 状态保持

  • 🎯 身份标识:帮助 Vue 识别哪些元素是相同的
  • 性能优化:提高 Diff 算法效率,减少 DOM 操作
  • 🔄 状态保持:确保组件状态在列表重排时正确保持
  • 🎨 过渡动画:正确触发列表的过渡效果

为什么需要 key?

Vue 的默认行为:就地更新策略

当没有 key 时,Vue 使用"就地更新"策略。数据项顺序改变时,Vue 不会移动 DOM 元素,而是就地更新每个元素的内容。

问题演示:

vue
1<template>
2 <div>
3 <h3>没有使用 key</h3>
4 <div v-for="item in items">
5 <input type="checkbox" />
6 <span>{{ item.name }}</span>
7 </div>
8 <button @click="reverse">反转列表</button>
9 </div>
10</template>
11
12<script>
13export default {
14 data() {
15 return {
16 items: [
17 { id: 1, name: 'A' },
18 { id: 2, name: 'B' },
19 { id: 3, name: 'C' }
20 ]
21 }
22 },
23 methods: {
24 reverse() {
25 this.items.reverse()
26 }
27 }
28}
29</script>

操作步骤:

  1. 勾选第一个 checkbox(A)
  2. 点击"反转列表"按钮
  3. 结果:checkbox 的选中状态没有跟随数据移动!

原因分析:

1初始状态:
2DOM: [✓ A] [ B] [ C]
3Data: [ A ] [ B] [ C]
4
5反转后(没有 key):
6DOM: [✓ A] [ B] [ C] ← DOM 元素没有移动
7Data: [ C ] [ B] [ A] ← 只更新了文本内容
8
9期望结果:
10DOM: [ C] [ B] [✓ A] ← checkbox 应该跟随 A 移动

Vue 只更新了 <span> 的文本内容,但 checkbox 的状态没有更新,因为 Vue 认为这些是同一个 DOM 元素。

key 的工作原理:

Diff 算法中的 key

Vue 的 Diff 算法使用 key 来建立新旧节点的映射关系:

javascript
1// 简化的 Diff 算法
2function updateChildren(oldChildren, newChildren) {
3 // 1. 使用 key 建立旧节点的映射表
4 const oldKeyToIdx = {}
5 oldChildren.forEach((child, idx) => {
6 if (child.key) {
7 oldKeyToIdx[child.key] = idx
8 }
9 })
10
11 // 2. 遍历新节点列表
12 newChildren.forEach(newChild => {
13 if (newChild.key && oldKeyToIdx[newChild.key] !== undefined) {
14 // 找到相同 key 的旧节点,复用并更新
15 const oldIdx = oldKeyToIdx[newChild.key]
16 const oldChild = oldChildren[oldIdx]
17 patch(oldChild, newChild) // 复用 DOM,只更新差异
18 } else {
19 // 没找到,创建新节点
20 createNode(newChild)
21 }
22 })
23
24 // 3. 删除不再需要的旧节点
25 // ...
26}

有 key 的优势:

  • ⚡ O(1) 时间复杂度查找对应节点
  • 🎯 精确复用 DOM 元素
  • 🔄 正确保持组件状态

key 的选择原则:

1. 使用唯一 ID(最佳)

vue
1<template>
2 <!-- ✅ 使用数据的唯一标识 -->
3 <div v-for="item in items" :key="item.id">
4 {{ item.name }}
5 </div>
6
7 <!-- ✅ 使用唯一字符串 -->
8 <div v-for="user in users" :key="user.email">
9 {{ user.name }}
10 </div>
11
12 <!-- ✅ 使用 UUID -->
13 <div v-for="item in items" :key="item.uuid">
14 {{ item.title }}
15 </div>
16</template>
17
18<script>
19export default {
20 data() {
21 return {
22 items: [
23 { id: 1, name: 'Item 1' },
24 { id: 2, name: 'Item 2' },
25 { id: 3, name: 'Item 3' }
26 ],
27 users: [
28 { email: 'alice@example.com', name: 'Alice' },
29 { email: 'bob@example.com', name: 'Bob' }
30 ]
31 }
32 }
33}
34</script>

2. 组合多个字段(确保唯一)

vue
1<template>
2 <!-- ✅ 组合字段确保唯一性 -->
3 <div v-for="item in items" :key="`${item.type}-${item.id}`">
4 {{ item.name }}
5 </div>
6
7 <!-- ✅ 使用对象的多个属性 -->
8 <div v-for="order in orders" :key="`order-${order.userId}-${order.orderId}`">
9 {{ order.productName }}
10 </div>
11</template>

3. 静态列表可以使用索引

vue
1<template>
2 <!-- ✅ 静态列表(不会重排、增删)可以使用索引 -->
3 <div v-for="(color, index) in staticColors" :key="index">
4 {{ color }}
5 </div>
6</template>
7
8<script>
9export default {
10 data() {
11 return {
12 // 这个列表永远不会改变顺序或增删
13 staticColors: ['red', 'green', 'blue']
14 }
15 }
16}
17</script>

性能对比:

性能测试

测试场景:1000 个元素的列表反转

方式操作耗时说明
无 key反转列表~200ms更新所有元素的内容
有 key (ID)反转列表~50ms只移动 DOM 节点位置
错误 key (index)删除第一项~150ms错误复用,更新大量元素
错误 key (random)任何操作~500ms重新创建所有元素

结论:

  • 使用正确的 key 可以提升 4 倍性能
  • 使用错误的 key 比不用 key 更差

实际案例:

vue
1<template>
2 <div class="todo-list">
3 <h2>待办事项</h2>
4
5 <!-- ✅ 使用唯一 ID 作为 key -->
6 <div
7 v-for="todo in todos"
8 :key="todo.id"
9 class="todo-item"
10 >
11 <input
12 type="checkbox"
13 v-model="todo.completed"
14 :id="`todo-${todo.id}`"
15 />
16 <label :for="`todo-${todo.id}`">
17 {{ todo.text }}
18 </label>
19 <button @click="deleteTodo(todo.id)">删除</button>
20 <button @click="moveTodoUp(todo.id)">上移</button>
21 </div>
22
23 <button @click="addTodo">添加待办</button>
24 </div>
25</template>
26
27<script>
28export default {
29 data() {
30 return {
31 todos: [
32 { id: 1, text: '学习 Vue', completed: false },
33 { id: 2, text: '写代码', completed: true },
34 { id: 3, text: '看文档', completed: false }
35 ],
36 nextId: 4
37 }
38 },
39 methods: {
40 addTodo() {
41 this.todos.push({
42 id: this.nextId++,
43 text: `新待办 ${this.nextId}`,
44 completed: false
45 })
46 },
47 deleteTodo(id) {
48 const index = this.todos.findIndex(t => t.id === id)
49 this.todos.splice(index, 1)
50 },
51 moveTodoUp(id) {
52 const index = this.todos.findIndex(t => t.id === id)
53 if (index > 0) {
54 const todo = this.todos.splice(index, 1)[0]
55 this.todos.splice(index - 1, 0, todo)
56 }
57 }
58 }
59}
60</script>

为什么需要 key:

  • ✅ 删除待办时,其他待办的 checkbox 状态不会错乱
  • ✅ 上移待办时,checkbox 状态正确跟随
  • ✅ 添加新待办时,不会影响现有待办的状态
常见错误

错误 1:在动态列表中使用索引

vue
1<!-- ❌ 错误 -->
2<div v-for="(item, index) in items" :key="index">
3 <input v-model="item.value" />
4</div>
5<!-- 问题:删除、插入、排序时会导致状态混乱 -->
6
7<!-- ✅ 正确 -->
8<div v-for="item in items" :key="item.id">
9 <input v-model="item.value" />
10</div>

错误 2:不使用 key

vue
1<!-- ❌ 错误 -->
2<div v-for="item in items">
3 {{ item.name }}
4</div>
5<!-- 问题:Vue 会警告,且可能导致状态问题 -->
6
7<!-- ✅ 正确 -->
8<div v-for="item in items" :key="item.id">
9 {{ item.name }}
10</div>

错误 3:key 不唯一

vue
1<!-- ❌ 错误 -->
2<div v-for="item in items" :key="item.type">
3 {{ item.name }}
4</div>
5<!-- 问题:多个元素可能有相同的 type,导致 key 重复 -->
6
7<!-- ✅ 正确 -->
8<div v-for="item in items" :key="item.id">
9 {{ item.name }}
10</div>

总结:

key 的作用:

  1. 🎯 帮助 Vue 识别节点的身份
  2. ⚡ 提高 Diff 算法的效率
  3. 🔄 避免就地复用导致的问题
  4. 🎨 保持组件状态的正确性
  5. 🎭 正确触发过渡动画

使用原则:

  1. ✅ 必须使用唯一且稳定的值
  2. ✅ 优先使用数据的唯一标识(如 ID)
  3. ❌ 不要使用索引(除非列表是静态的)
  4. ❌ 不要使用随机数
  5. ❌ 不要使用非原始类型

记忆口诀: "v-for 必加 key,唯一稳定是关键,索引随机要避免,ID 最佳保平安"

在实际项目中,我总是为 v-for 添加 key,这是一个良好的编程习惯,可以避免很多潜在的问题。


12.3 v-for 和 v-if 能一起使用吗

面试回答思路:

这是一个非常常见的面试题,也是实际开发中容易犯的错误。需要理解 Vue 2 和 Vue 3 的优先级差异,以及正确的解决方案。

重要警告

不推荐在同一个元素上同时使用 v-for 和 v-if!

  • ⚠️ Vue 2:v-for 优先级高,会遍历所有元素再判断,性能浪费
  • ⚠️ Vue 3:v-if 优先级高,可能访问不到 v-for 的变量,导致报错
  • 推荐:使用计算属性过滤数据,或使用 template 标签分离

Vue 2 vs Vue 3 优先级对比:

Vue 2:v-for 优先级高于 v-if

vue
1<template>
2 <ul>
3 <!-- ❌ 不推荐:Vue 2 -->
4 <li v-for="user in users" v-if="user.isActive" :key="user.id">
5 {{ user.name }}
6 </li>
7 </ul>
8</template>
9
10<script>
11export default {
12 data() {
13 return {
14 users: [
15 { id: 1, name: 'Alice', isActive: true },
16 { id: 2, name: 'Bob', isActive: false },
17 { id: 3, name: 'Charlie', isActive: true },
18 // ... 假设有 1000 个用户
19 ]
20 }
21 }
22}
23</script>

问题分析:

javascript
1// Vue 2 的执行顺序
2for (let user of users) { // 1. 先执行 v-for,遍历所有 1000 个用户
3 if (user.isActive) { // 2. 再执行 v-if,判断每个用户
4 render(user) // 3. 渲染符合条件的用户
5 }
6}
7
8// 性能问题:
9// - 即使只有 100 个活跃用户,也要遍历所有 1000 个用户
10// - 每次渲染都要执行 1000 次判断
11// - 浪费了 900 次无效的判断

性能影响:

场景总用户数活跃用户数遍历次数判断次数渲染次数
小型应用1005010010050
中型应用100010010001000100
大型应用100005001000010000500

每次组件更新都要执行这么多次操作,性能开销巨大!

正确的解决方案:

使用计算属性过滤数据(最佳实践)

vue
1<template>
2 <ul>
3 <!-- ✅ 推荐:使用计算属性 -->
4 <li v-for="user in activeUsers" :key="user.id">
5 {{ user.name }}
6 </li>
7 </ul>
8</template>
9
10<script>
11export default {
12 data() {
13 return {
14 users: [
15 { id: 1, name: 'Alice', isActive: true },
16 { id: 2, name: 'Bob', isActive: false },
17 { id: 3, name: 'Charlie', isActive: true },
18 { id: 4, name: 'David', isActive: false },
19 { id: 5, name: 'Eve', isActive: true }
20 ]
21 }
22 },
23 computed: {
24 activeUsers() {
25 // 只过滤一次,返回活跃用户
26 return this.users.filter(user => user.isActive)
27 }
28 }
29}
30</script>

优点:

  • ✅ 只遍历需要显示的元素(100 次而不是 1000 次)
  • ✅ 有缓存,依赖不变时不重新计算
  • ✅ 代码清晰易读,逻辑分离
  • ✅ 性能最好

性能对比:

javascript
1// ❌ 同时使用 v-for 和 v-if
2// 每次渲染:1000 次遍历 + 1000 次判断 = 2000 次操作
3
4// ✅ 使用计算属性
5// 首次渲染:1000 次过滤 + 100 次遍历 = 1100 次操作
6// 后续渲染:100 次遍历(有缓存)
7// 性能提升:约 10 倍

实际案例对比:

vue
1<template>
2 <div class="product-list">
3 <h2>商品列表</h2>
4
5 <!-- ❌ 错误做法 -->
6 <div class="bad-example">
7 <h3>❌ 不推荐(同时使用)</h3>
8 <div
9 v-for="product in products"
10 v-if="product.inStock"
11 :key="product.id"
12 class="product-card"
13 >
14 {{ product.name }} - ¥{{ product.price }}
15 </div>
16 </div>
17
18 <!-- ✅ 正确做法 -->
19 <div class="good-example">
20 <h3>✅ 推荐(计算属性)</h3>
21 <div
22 v-for="product in inStockProducts"
23 :key="product.id"
24 class="product-card"
25 >
26 {{ product.name }} - ¥{{ product.price }}
27 </div>
28 </div>
29 </div>
30</template>
31
32<script>
33export default {
34 data() {
35 return {
36 products: [
37 { id: 1, name: '商品1', price: 99, inStock: true },
38 { id: 2, name: '商品2', price: 199, inStock: false },
39 { id: 3, name: '商品3', price: 299, inStock: true },
40 { id: 4, name: '商品4', price: 399, inStock: false },
41 { id: 5, name: '商品5', price: 499, inStock: true }
42 ]
43 }
44 },
45 computed: {
46 inStockProducts() {
47 return this.products.filter(p => p.inStock)
48 }
49 }
50}
51</script>

ESLint 规则:

代码规范

Vue 官方的 ESLint 插件会检测这个问题:

javascript
1// .eslintrc.js
2module.exports = {
3 extends: [
4 'plugin:vue/vue3-recommended'
5 ],
6 rules: {
7 // 禁止 v-if 和 v-for 在同一元素上使用
8 'vue/no-use-v-if-with-v-for': 'error'
9 }
10}

错误提示:

1error: The 'v-if' directive should not be used on the same element as 'v-for' (vue/no-use-v-if-with-v-for)

性能对比总结:

方案遍历次数判断次数缓存性能评分推荐度
同时使用(Vue 2)10001000
同时使用(Vue 3)---❌ 报错
计算属性1001000(一次)⭐⭐⭐⭐⭐✅✅✅
template 标签10001000⭐⭐⭐
外层 v-if取决于条件1⭐⭐⭐⭐✅✅
最佳实践总结

记忆口诀: "v-for 和 v-if 不同居,计算属性来过滤"

选择指南:

  1. 优先使用计算属性:性能最好,代码最清晰
  2. template 标签:逻辑复杂时使用
  3. 外层 v-if:控制整个列表显示时使用
  4. 绝不同时使用:无论 Vue 2 还是 Vue 3

核心原则:

  • 🎯 数据过滤在 JavaScript 中完成(计算属性)
  • 🎨 模板只负责渲染,不负责过滤
  • ⚡ 减少不必要的遍历和判断
  • 📝 保持代码清晰易维护

在实际项目中,我总是使用计算属性来过滤列表数据,这样不仅性能好,代码也更清晰易维护。这是 Vue 开发中的重要最佳实践。


十三、总结

这份 Vue 面试题库涵盖了:

  1. ✅ Vue 基础概念和核心特性
  2. ✅ 组件通信的多种方式
  3. ✅ 生命周期钩子详解
  4. ✅ 指令系统和自定义指令
  5. ✅ Computed 和 Watch 的使用
  6. ✅ 虚拟 DOM 和 Diff 算法
  7. ✅ Vue Router 路由管理
  8. ✅ Vuex/Pinia 状态管理
  9. ✅ 性能优化技巧
  10. ✅ 常见问题和最佳实践
  11. ✅ 前后台交互(Fetch、Axios、async/await)
  12. ✅ 常见面试题补充(v-if vs v-show、v-for key、v-for 与 v-if)

建议结合实际项目经验,深入理解每个知识点的应用场景。

forum

评论区 / Comments