Vue 面试题教程版(基础~工程化)¶
适合对象:刚学完 Vue 基础、准备 Vue/Vue3 前端面试、1~3 年经验复习。
使用方式:先看“核心回答”,再看“详细解释”,最后尝试自己用 1~2 分钟复述。
目录¶
一、Vue 基础¶
1. Vue 是什么?核心思想是什么?¶
核心回答¶
Vue 是一个用于构建用户界面的 JavaScript 渐进式框架。它的核心思想是数据驱动视图和组件化开发。
详细解释¶
在传统 DOM 开发中,开发者经常需要手动操作 DOM,例如:
document.querySelector('#title').innerText = 'Hello'
Vue 的思路是:开发者只需要维护数据,页面会根据数据自动更新。
<template>
<h1>{{ title }}</h1>
</template>
<script setup>
import { ref } from 'vue'
const title = ref('Hello')
</script>
当 title 改变时,页面会自动重新渲染。
这就是“数据驱动视图”。
Vue 另一个核心思想是组件化。页面可以拆成很多独立组件,例如:
- Header
- Sidebar
- UserList
- UserForm
- Pagination
每个组件负责自己的结构、样式和逻辑,方便复用和维护。
面试表达¶
Vue 是一个构建前端页面的渐进式框架,核心思想主要有两个:第一是数据驱动视图,也就是数据变化后视图自动更新;第二是组件化开发,把复杂页面拆成多个可复用、可维护的组件。
2. Vue 的优缺点?¶
优点¶
- 学习成本相对低,模板语法接近 HTML。
- 数据响应式,开发效率高。
- 组件化清晰,适合中大型项目维护。
- 生态成熟,有 Vue Router、Pinia、Vite 等工具。
- Vue3 对 TypeScript、组合式 API、性能都有更好支持。
缺点¶
- 生态规模整体小于 React。
- Vue2 到 Vue3 有一定迁移成本。
- 过度使用响应式和 watch,可能导致逻辑分散。
- 大型项目中,如果没有规范,组件和状态管理容易混乱。
面试表达¶
Vue 的优点是上手快、响应式和组件化开发效率高,生态也比较完整。缺点是大型项目中需要良好的代码规范,否则组件通信和状态管理容易变复杂。另外 Vue2 到 Vue3 在写法和响应式原理上有变化,迁移时需要注意。
3. MVC / MVP / MVVM 区别?¶
MVC¶
MVC 分为:
- Model:数据和业务逻辑
- View:视图
- Controller:控制器,负责接收用户操作并更新 Model 或 View
在 MVC 中,Controller 是 View 和 Model 的协调者。
MVP¶
MVP 分为:
- Model
- View
- Presenter
Presenter 负责业务逻辑,View 通常比较被动。
View 和 Model 不直接通信,而是通过 Presenter 交互。
MVVM¶
MVVM 分为:
- Model:数据模型
- View:视图
- ViewModel:连接 View 和 Model 的桥梁
Vue 更接近 MVVM。
在 Vue 中,模板可以看作 View,响应式数据和方法可以看作 ViewModel。
<template>
<p>{{ name }}</p>
</template>
<script setup>
import { ref } from 'vue'
const name = ref('Tom')
</script>
name 改变后,视图自动更新。开发者不用直接操作 DOM。
面试表达¶
MVC、MVP、MVVM 都是为了分离视图和业务逻辑。Vue 更接近 MVVM,通过响应式系统把数据和视图绑定起来,数据变化后视图自动更新。
4. Vue 为什么是渐进式框架?¶
核心回答¶
渐进式是指 Vue 可以按需使用。可以只在页面中引入 Vue 做局部增强,也可以配合 Vue Router、Pinia、Vite 做完整单页应用。
详细解释¶
Vue 的使用方式可以从简单到复杂:
- 只在普通 HTML 页面中引入 Vue。
- 使用组件开发页面。
- 使用 Vue Router 做单页应用。
- 使用 Pinia 管理全局状态。
- 使用 Vite、TypeScript、测试、工程化工具构建大型项目。
这就是“渐进式”:不强制一次性接受完整生态。
面试表达¶
Vue 是渐进式框架,因为它可以根据项目复杂度逐步引入功能。简单页面可以只使用响应式和模板,大型项目可以继续引入路由、状态管理、构建工具和 TypeScript。
5. Vue 和 React 的核心思想区别?¶
核心回答¶
Vue 更强调模板、响应式和渐进式开发;React 更强调 JavaScript 函数式思想和单向数据流。
对比¶
| 方面 | Vue | React |
|---|---|---|
| 视图写法 | 模板语法 | JSX |
| 数据更新 | 响应式系统自动追踪依赖 | setState / hooks 触发渲染 |
| 学习曲线 | 相对平缓 | 更偏 JS 思维 |
| 官方生态 | Vue Router、Pinia 等较统一 | 生态选择更多 |
| 组件逻辑复用 | Composition API / hooks 风格函数 | Hooks |
面试表达¶
Vue 和 React 都是组件化框架。Vue 更偏模板和响应式,数据变化后依赖自动更新;React 更强调 UI 是状态的函数,通过 state 变化重新执行组件函数生成 UI。Vue 对初学者更友好,React 更偏 JavaScript 表达能力。
6. 什么是单向数据流?¶
核心回答¶
单向数据流是指数据按固定方向流动,通常是父组件通过 props 传给子组件,子组件不能直接修改父组件数据,只能通过事件通知父组件修改。
示例¶
<!-- Parent.vue -->
<Child :count="count" @update="count++" />
<!-- Child.vue -->
<template>
<button @click="$emit('update')">增加</button>
</template>
数据方向:
父组件数据 → props → 子组件显示
子组件事件 → emit → 父组件修改数据
好处¶
- 数据来源清晰。
- 问题排查方便。
- 避免子组件随意修改父组件状态。
7. 什么是响应式数据?¶
核心回答¶
响应式数据是指数据被 Vue 代理或包装后,Vue 能追踪它的读取和修改。当数据变化时,依赖它的视图或计算逻辑会自动更新。
示例¶
import { ref } from 'vue'
const count = ref(0)
count.value++
当模板中使用 count 时,count 改变后页面会自动更新。
本质¶
响应式系统主要做两件事:
- 读取数据时收集依赖。
- 修改数据时触发依赖更新。
8. 什么是模板编译?¶
核心回答¶
模板编译是指 Vue 把 <template> 模板转换成渲染函数 render 的过程。
详细解释¶
例如模板:
<template>
<div>{{ message }}</div>
</template>
会被编译成类似:
function render() {
return h('div', message)
}
Vue 最终不是直接运行模板,而是执行渲染函数生成虚拟 DOM。
面试表达¶
模板编译就是把 Vue 模板解析成 AST,再生成 render 函数。组件渲染时执行 render 函数,生成虚拟 DOM,最后更新真实 DOM。
9. 什么是虚拟 DOM?¶
核心回答¶
虚拟 DOM 是用 JavaScript 对象描述真实 DOM 结构的一种方式。
示例¶
真实 DOM:
<div id="app">
<p>Hello</p>
</div>
虚拟 DOM 可以理解成:
{
type: 'div',
props: { id: 'app' },
children: [
{ type: 'p', children: 'Hello' }
]
}
作用¶
- 用 JS 对象描述 UI。
- 数据变化后生成新的虚拟 DOM。
- 通过 diff 算法比较新旧虚拟 DOM。
- 找出最小变更后更新真实 DOM。
10. 虚拟 DOM 一定比真实 DOM 快吗?¶
核心回答¶
不一定。虚拟 DOM 的优势不是每次都比手写 DOM 快,而是让复杂应用的更新更加可预测、可维护,并通过 diff 减少不必要的 DOM 操作。
详细解释¶
如果只是简单修改一个 DOM:
document.querySelector('#title').innerText = 'Hello'
手写真实 DOM 可能更快。
但在复杂页面中,数据变化可能影响多个节点。虚拟 DOM 可以统一计算差异,再批量更新 DOM,降低开发复杂度。
面试表达¶
虚拟 DOM 不一定比直接操作真实 DOM 快。它的价值主要是抽象 DOM 操作,让框架可以用 diff 算法优化更新,并提升复杂项目的可维护性。
11. v-if 和 v-show 的区别?¶
核心回答¶
v-if 是条件渲染,条件为 false 时元素不会存在于 DOM 中。
v-show 是条件显示,元素一直存在,只是通过 display: none 控制显示隐藏。
对比¶
| 对比项 | v-if | v-show |
|---|---|---|
| DOM 是否存在 | false 时不存在 | 始终存在 |
| 切换成本 | 较高 | 较低 |
| 初始渲染成本 | 较低 | 较高 |
| 适用场景 | 条件很少变化 | 频繁切换显示隐藏 |
示例¶
<p v-if="isLogin">已登录</p>
<p v-show="isLogin">已登录</p>
12. v-for 为什么要加 key?¶
核心回答¶
key 是 Vue 识别节点身份的标识,用于 diff 算法判断哪些节点可以复用、移动或删除。
为什么需要 key?¶
没有 key 时,Vue 可能按位置复用 DOM。
如果列表顺序变化,可能导致输入框状态、组件状态错乱。
<li v-for="item in list" :key="item.id">
{{ item.name }}
</li>
面试表达¶
key 的作用是给每个节点一个稳定身份,帮助 Vue 在 diff 时更准确地复用节点,提高更新准确性和性能。
13. v-for 中 key 可以用 index 吗?¶
核心回答¶
可以,但不推荐。只有列表不会新增、删除、排序时才可以用 index。
原因¶
如果使用 index:
<li v-for="(item, index) in list" :key="index">
<input v-model="item.name" />
</li>
当删除中间一项时,后面的 index 会变化,Vue 可能复用错误的 DOM,导致输入框内容错乱。
推荐¶
使用唯一且稳定的业务 ID:
<li v-for="item in list" :key="item.id">
{{ item.name }}
</li>
14. v-if 和 v-for 为什么不推荐一起用?¶
核心回答¶
因为可读性差,并且 Vue2 中 v-for 优先级高于 v-if,会导致每次循环都判断条件,性能不好。Vue3 中优先级变化,但仍不推荐混用。
不推荐写法¶
<li v-for="user in users" v-if="user.active" :key="user.id">
{{ user.name }}
</li>
推荐写法¶
用 computed 先过滤:
const activeUsers = computed(() => users.value.filter(user => user.active))
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
15. v-bind 和 v-model 的区别?¶
v-bind¶
v-bind 是单向绑定,把数据绑定到属性上。
<input :value="name" />
数据变化会影响 input 的 value,但用户输入不会自动修改 name。
v-model¶
v-model 是双向绑定,本质是:
<input :value="name" @input="name = $event.target.value" />
面试表达¶
v-bind 是单向绑定,主要用于绑定属性;v-model 是双向绑定,常用于表单元素,本质是 value 绑定加事件更新。
16. v-html 有什么安全问题?¶
核心回答¶
v-html 会把字符串当作 HTML 插入页面,如果内容来自用户输入,可能导致 XSS 攻击。
示例¶
<div v-html="content"></div>
如果 content 是:
<img src=x onerror="alert('xss')" />
就可能执行恶意代码。
解决方案¶
- 尽量不用
v-html。 - 如果必须使用,需要服务端或前端过滤危险标签。
- 不要渲染用户未经过滤的输入内容。
17. 插值表达式中能写复杂逻辑吗?¶
核心回答¶
技术上可以写简单表达式,但不推荐写复杂逻辑。复杂逻辑应放到 computed 或 methods 中。
不推荐¶
<p>{{ user.age > 18 ? user.name.toUpperCase() + '成年' : '未成年' }}</p>
推荐¶
const userText = computed(() => {
return user.age > 18 ? `${user.name.toUpperCase()} 成年` : '未成年'
})
<p>{{ userText }}</p>
18. 事件修饰符有哪些?¶
常见事件修饰符:
| 修饰符 | 作用 |
|---|---|
.stop |
阻止事件冒泡 |
.prevent |
阻止默认行为 |
.capture |
使用捕获模式 |
.self |
只有事件源是自身时触发 |
.once |
事件只触发一次 |
.passive |
提高滚动性能,不阻止默认行为 |
示例¶
<a href="https://example.com" @click.prevent="handleClick">点击</a>
<button @click.stop="handleClick">按钮</button>
19. 按键修饰符有哪些?¶
常见按键修饰符:
| 修饰符 | 作用 |
|---|---|
.enter |
回车 |
.tab |
Tab |
.delete |
Delete 或 Backspace |
.esc |
Esc |
.space |
空格 |
.up |
上 |
.down |
下 |
.left |
左 |
.right |
右 |
示例¶
<input @keyup.enter="search" />
20. .sync 是什么?(Vue2)¶
核心回答¶
.sync 是 Vue2 中父子组件双向绑定的一种语法糖,本质是父组件传 prop,子组件触发 update:xxx 事件。
示例¶
<Child :title.sync="title" />
等价于:
<Child :title="title" @update:title="title = $event" />
在子组件中:
this.$emit('update:title', 'new title')
Vue3 中更推荐使用 v-model:xxx。
二、响应式系统¶
1. Vue2 响应式原理?¶
核心回答¶
Vue2 使用 Object.defineProperty 对对象属性进行 getter/setter 拦截。读取属性时收集依赖,修改属性时通知依赖更新。
简化原理¶
function defineReactive(obj, key, value) {
Object.defineProperty(obj, key, {
get() {
console.log('收集依赖')
return value
},
set(newValue) {
value = newValue
console.log('通知更新')
}
})
}
缺点¶
Vue2 不能天然监听:
- 新增对象属性。
- 删除对象属性。
- 通过数组下标修改数组。
- 修改数组 length。
因此 Vue2 中需要:
Vue.set(obj, 'name', 'Tom')
this.$set(obj, 'name', 'Tom')
2. Vue3 响应式原理?¶
核心回答¶
Vue3 使用 Proxy 实现响应式。Proxy 可以代理整个对象,拦截属性读取、修改、删除、in 操作等。
简化原理¶
const state = new Proxy({ count: 0 }, {
get(target, key) {
console.log('收集依赖')
return target[key]
},
set(target, key, value) {
target[key] = value
console.log('触发更新')
return true
}
})
优点¶
- 可以监听新增属性。
- 可以监听删除属性。
- 对数组支持更好。
- 不需要初始化时递归劫持所有属性。
- 性能和类型支持更好。
3. Object.defineProperty 有什么缺点?¶
主要缺点:
- 只能劫持对象已有属性,不能直接监听新增和删除。
- 需要递归遍历对象每个属性。
- 数组变化监听有限。
- 对 Map、Set 等数据结构支持不好。
- API 设计上是属性级别,不是对象级别。
面试表达¶
Object.defineProperty 的问题是它只能针对具体属性做 getter/setter,新增属性和删除属性无法自动监听,数组下标和 length 的变化也不能很好处理,所以 Vue2 需要额外处理数组方法和 Vue.set。
4. Proxy 为什么可以监听数组变化?¶
核心回答¶
因为 Proxy 是代理整个对象或数组,可以拦截数组的下标访问、下标修改、length 修改以及数组方法调用引起的变化。
示例¶
const arr = new Proxy([], {
set(target, key, value) {
console.log('修改了', key)
target[key] = value
return true
}
})
arr[0] = 'A'
arr.length = 0
arr[0] = 'A' 和 arr.length = 0 都可以被 Proxy 拦截。
5. ref 和 reactive 的区别?¶
ref¶
ref 用于包装基本类型,也可以包装对象。
const count = ref(0)
count.value++
reactive¶
reactive 用于创建对象类型的响应式代理。
const user = reactive({
name: 'Tom',
age: 20
})
user.age++
对比¶
| 对比项 | ref | reactive |
|---|---|---|
| 适合数据 | 基本类型、对象都可以 | 主要是对象、数组 |
| 访问方式 | JS 中需要 .value |
直接访问属性 |
| 解构影响 | 解构需要注意 | 直接解构会丢失响应式 |
| 常用场景 | 单个值 | 表单、对象状态 |
6. ref 为什么要用 .value?¶
核心回答¶
因为基本类型不是对象,不能被 Proxy 直接代理。Vue 需要把基本类型包装成对象,通过 .value 的 getter/setter 实现依赖追踪和更新。
示例¶
const count = ref(0)
// 实际可以理解成
const count = {
value: 0
}
在 <template> 中 Vue 会自动解包,所以模板里可以直接写:
<p>{{ count }}</p>
在 JS 中必须写:
count.value++
7. reactive 可以解构吗?¶
核心回答¶
可以解构,但直接解构会丢失响应式。
错误示例¶
const user = reactive({ name: 'Tom', age: 20 })
const { name } = user
此时 name 是普通变量,不再和 user.name 保持响应式联系。
正确方式¶
使用 toRefs:
import { reactive, toRefs } from 'vue'
const user = reactive({ name: 'Tom', age: 20 })
const { name, age } = toRefs(user)
模板或 JS 中使用:
name.value = 'Jack'
8. shallowRef / shallowReactive 是什么?¶
shallowRef¶
只让 .value 本身是响应式,不会深层代理对象内部属性。
const state = shallowRef({ count: 0 })
state.value.count++ // 不一定触发更新
state.value = { count: 1 } // 触发更新
shallowReactive¶
只代理对象第一层属性,不深层代理嵌套对象。
const state = shallowReactive({
user: { name: 'Tom' }
})
state.user = { name: 'Jack' } // 触发
state.user.name = 'Lucy' // 深层不触发
使用场景¶
- 大型对象。
- 第三方库对象。
- 不希望深层响应式带来性能开销。
9. watch 和 watchEffect 的区别?¶
watch¶
watch 需要明确指定监听源。
watch(count, (newVal, oldVal) => {
console.log(newVal, oldVal)
})
特点:
- 可以拿到新值和旧值。
- 默认懒执行。
- 适合监听指定数据变化。
watchEffect¶
watchEffect 会立即执行,并自动收集内部用到的响应式依赖。
watchEffect(() => {
console.log(count.value)
})
特点:
- 自动收集依赖。
- 默认立即执行。
- 不方便直接拿 oldVal。
- 适合副作用逻辑。
10. computed 是怎么实现缓存的?¶
核心回答¶
computed 会基于响应式依赖进行缓存。依赖没有变化时,多次读取会直接返回缓存结果;依赖变化后,才重新计算。
示例¶
const total = computed(() => {
console.log('重新计算')
return price.value * count.value
})
如果 price 和 count 没变,多次访问 total.value 不会重复执行函数。
面试表达¶
computed 内部会记录依赖,并维护一个 dirty 标记。依赖变化时 dirty 变为 true,下次读取 computed 时重新计算;依赖没变化时直接返回缓存值。
11. computed 能否修改?¶
核心回答¶
默认 computed 是只读的,不能直接修改。如果需要修改,可以使用带 getter 和 setter 的 computed。
示例¶
const fullName = computed({
get() {
return firstName.value + ' ' + lastName.value
},
set(value) {
const arr = value.split(' ')
firstName.value = arr[0]
lastName.value = arr[1]
}
})
12. watch 的 immediate 和 deep 有什么用?¶
immediate¶
让 watch 在初始化时立即执行一次。
watch(userId, fetchUser, { immediate: true })
常用于页面加载时先请求一次接口。
deep¶
深度监听对象内部属性变化。
watch(
() => form,
() => {
console.log('form changed')
},
{ deep: true }
)
注意¶
deep 会递归监听对象内部变化,数据很大时可能影响性能。
能监听具体字段时,尽量不要深度监听整个对象。
13. Vue 是如何追踪依赖的?¶
核心回答¶
Vue 在响应式数据被读取时收集当前正在执行的副作用函数,在数据被修改时触发这些副作用函数重新执行。
简化流程¶
组件渲染 / computed / watchEffect 执行
↓
读取响应式数据
↓
track 收集依赖
↓
数据变化
↓
trigger 触发更新
示例理解¶
watchEffect(() => {
console.log(count.value)
})
执行 watchEffect 时读取了 count.value,Vue 记录“这个函数依赖 count”。
之后 count.value++ 时,Vue 重新执行这个函数。
三、生命周期¶
1. Vue 生命周期有哪些?¶
Vue2 常见生命周期¶
| 生命周期 | 说明 |
|---|---|
| beforeCreate | 实例初始化前 |
| created | 实例创建完成,数据可用,DOM 未挂载 |
| beforeMount | 挂载前 |
| mounted | DOM 挂载完成 |
| beforeUpdate | 数据更新后,DOM 更新前 |
| updated | DOM 更新完成 |
| beforeDestroy | 销毁前 |
| destroyed | 销毁完成 |
Vue3 常见生命周期¶
| Vue3 Hook | 说明 |
|---|---|
| setup | 组件初始化时执行 |
| onBeforeMount | 挂载前 |
| onMounted | 挂载后 |
| onBeforeUpdate | 更新前 |
| onUpdated | 更新后 |
| onBeforeUnmount | 卸载前 |
| onUnmounted | 卸载后 |
2. created 和 mounted 的区别?¶
created¶
组件实例已经创建,响应式数据、methods、computed 等可以使用,但真实 DOM 还没有挂载。
mounted¶
组件已经挂载到页面上,真实 DOM 可以访问。
适用场景¶
| 场景 | 生命周期 |
|---|---|
| 初始化数据 | created / setup |
| 请求接口 | created / mounted / setup |
| 操作 DOM | mounted |
| 初始化图表 | mounted |
3. 请求接口放在哪个生命周期?¶
常见回答¶
一般可以放在 created、mounted 或 Vue3 的 setup 中。
实际项目建议¶
- 如果请求不依赖 DOM,可以在
setup或created中。 - 如果请求依赖 DOM 或图表容器尺寸,可以放在
mounted中。 - SSR 场景需要考虑服务端数据获取方式。
Vue3 示例¶
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
const list = ref([])
onMounted(async () => {
const res = await axios.get('/api/users')
list.value = res.data
})
</script>
4. 父子组件生命周期执行顺序?¶
初次渲染¶
父 beforeCreate
父 created
父 beforeMount
子 beforeCreate
子 created
子 beforeMount
子 mounted
父 mounted
Vue3 可理解为:
父 setup
父 onBeforeMount
子 setup
子 onBeforeMount
子 onMounted
父 onMounted
更新时¶
通常是:
父 beforeUpdate
子 beforeUpdate
子 updated
父 updated
销毁时¶
通常是:
父 beforeUnmount
子 beforeUnmount
子 unmounted
父 unmounted
5. keep-alive 生命周期?¶
核心回答¶
keep-alive 用于缓存组件。被缓存的组件不会真正卸载,而是在激活和失活之间切换。
特有生命周期¶
| 生命周期 | 说明 |
|---|---|
| activated | 组件被激活时触发 |
| deactivated | 组件被缓存失活时触发 |
示例¶
<keep-alive>
<RouterView />
</keep-alive>
onActivated(() => {
console.log('页面重新进入')
})
onDeactivated(() => {
console.log('页面离开但被缓存')
})
6. activated / deactivated 什么时候触发?¶
activated¶
缓存组件重新显示时触发。
例如从详情页返回列表页,列表页被 keep-alive 缓存,重新显示时触发 activated。
deactivated¶
缓存组件离开当前视图时触发,但不会被销毁。
使用场景¶
- 返回页面时刷新数据。
- 记录滚动位置。
- 暂停定时器。
- 恢复页面状态。
7. setup 是什么时候执行的?¶
核心回答¶
setup 在组件实例创建之前执行,比 beforeCreate 和 created 更早。它是 Composition API 的入口。
注意点¶
在 setup 中:
- 没有
this。 - 可以定义响应式数据。
- 可以注册生命周期。
- 可以使用 props 和 emit。
export default {
setup(props, { emit }) {
// 没有 this
}
}
<script setup> 是 setup 的语法糖。
8. beforeUnmount 和 unmounted 的区别?¶
beforeUnmount¶
组件卸载前触发。
此时组件实例还在,DOM 也还在,适合清理定时器、取消请求、解绑事件。
unmounted¶
组件卸载完成后触发。
组件相关 DOM 已经从页面移除。
示例¶
let timer = null
onMounted(() => {
timer = setInterval(() => {}, 1000)
})
onBeforeUnmount(() => {
clearInterval(timer)
})
四、组件通信¶
1. 父 → 子通信方式?¶
常见方式:
- props
- v-model
- ref + defineExpose
- provide/inject
- 全局状态 Pinia
props 示例¶
<Child :title="title" />
<script setup>
defineProps({
title: String
})
</script>
面试表达¶
最常见的父传子是 props。对于表单类组件可以用 v-model。跨层级可以用 provide/inject 或 Pinia。如果父组件需要调用子组件方法,可以用 ref 配合 defineExpose。
ref + defineExpose(Vue3 组件通信)¶
什么是 ref + defineExpose?¶
在 Vue3 中,父组件如果想调用子组件的方法,需要先通过 ref 获取子组件实例。
但在 <script setup> 语法中,子组件内部定义的变量和方法默认是私有的,父组件无法直接访问,因此需要使用 defineExpose 主动暴露。
作用¶
ref → 获取子组件实例
defineExpose → 暴露允许父组件访问的内容
两者通常配合使用,实现父组件调用子组件方法。
子组件¶
<script setup>
const open = () => {
console.log('打开弹窗')
}
const close = () => {
console.log('关闭弹窗')
}
defineExpose({
open,
close
})
</script>
父组件¶
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const childRef = ref()
const showDialog = () => {
childRef.value.open()
}
</script>
<template>
<Child ref="childRef" />
<button @click="showDialog">
打开弹窗
</button>
</template>
执行流程¶
父组件
│
▼
ref 获取子组件实例
│
▼
defineExpose 暴露方法
│
▼
childRef.value.open()
为什么需要 defineExpose?¶
Vue2 中:
this.$refs.child.open()
默认可以访问子组件的 methods、data 等内容。
Vue3 的 <script setup> 默认是私有的:
<script setup>
const open = () => {}
</script>
此时父组件无法访问 open。
只有通过:
defineExpose({
open
})
主动暴露后才能访问。
这样可以提高组件封装性,避免父组件随意操作子组件内部状态。
2. 子 → 父通信方式?¶
常见方式:
- emit 事件
- v-model
- 回调函数 props
- Pinia
emit 示例¶
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['submit'])
function handleClick() {
emit('submit', 'hello')
}
</script>
<!-- Parent.vue -->
<Child @submit="handleSubmit" />
3. 兄弟组件通信?¶
常见方案:
- 共同父组件中转。
- Pinia / Vuex。
- event bus。
- provide/inject。
- URL 参数或缓存。
推荐¶
简单场景:父组件中转。
复杂共享状态:Pinia。
不推荐大量使用 event bus。
Event Bus(事件总线)¶
什么是 Event Bus?¶
Event Bus(事件总线)是一种组件通信方案,通过一个公共事件中心实现组件之间的数据传递。
本质上采用的是发布-订阅(Publish / Subscribe)模式。
组件A
│ emit
▼
Event Bus
▲
│ on
组件B
组件之间不需要直接引用即可通信。
使用场景¶
适用于:
- 兄弟组件通信
- 非父子组件通信
- 跨层级简单通信
例如:
Header
│
├── emit('login')
│
Sidebar
└── on('login')
Vue2 实现方式¶
Vue2 中通常利用 Vue 实例作为事件中心:
// bus.js
import Vue from 'vue'
export default new Vue()
发送事件:
import bus from './bus'
bus.$emit('login', user)
接收事件:
import bus from './bus'
bus.$on('login', (user) => {
console.log(user)
})
Vue3 实现方式¶
Vue3 已移除:
$on
$off
$once
因此无法继续使用:
new Vue()
作为事件总线。
通常使用第三方库:
npm install mitt
mitt 实现 Event Bus¶
创建事件中心¶
// bus.js
import mitt from 'mitt'
export default mitt()
发送事件¶
import bus from './bus'
bus.emit('login', {
name: 'Tom'
})
接收事件¶
import bus from './bus'
bus.on('login', (data) => {
console.log(data)
})
销毁监听¶
import { onUnmounted } from 'vue'
const handler = (data) => {
console.log(data)
}
bus.on('login', handler)
onUnmounted(() => {
bus.off('login', handler)
})
Event Bus 的缺点¶
- 代码难维护
- 事件过多难管理
- 容易造成内存泄漏
- 数据流不清晰
Event Bus 与 Pinia 的区别¶
| 对比项 | Event Bus | Pinia |
|---|---|---|
| 通信方式 | 事件通知 | 状态共享 |
| 数据管理 | 不擅长 | 非常适合 |
| 可维护性 | 较差 | 较好 |
| 调试能力 | 弱 | 强 |
| 适用场景 | 简单通知 | 全局状态管理 |
为什么现在不推荐 Event Bus?¶
Vue2 时代 Event Bus 使用较多。
Vue3 官方更推荐:
父子通信 → props / emit
跨层级通信 → provide / inject
全局状态共享 → Pinia
大型项目通常不再使用 Event Bus 管理业务数据。
面试回答¶
Event Bus(事件总线)是一种基于发布订阅模式的组件通信方案,通过统一的事件中心实现非父子组件之间的数据传递。Vue2 中通常使用 Vue 实例实现 Event Bus,Vue3 中则常配合 mitt 使用。由于 Event Bus 会导致数据流不清晰、事件难以维护以及内存泄漏等问题,因此在大型项目中更推荐使用 Pinia、provide/inject 等方案。
4. 跨层级通信方案?¶
跨层级通信可以使用:
- provide / inject
- Pinia / Vuex
- props 逐层传递
- mitt 事件总线
- 组合式函数封装共享状态
建议¶
- 层级少:props。
- 层级深但范围局部:provide/inject。
- 多页面、多组件共享:Pinia。
5. provide / inject 使用场景?¶
核心回答¶
provide/inject 用于祖先组件向后代组件传递数据,避免 props 层层传递。
示例¶
// 父组件
provide('theme', 'dark')
// 子孙组件
const theme = inject('theme')
使用场景¶
- 主题配置。
- 表单上下文。
- 表格上下文。
- 多层组件共享局部状态。
注意¶
provide/inject 会让数据来源不如 props 直观,所以不适合滥用。
6. event bus 的缺点?¶
主要缺点:
- 事件来源不清晰。
- 事件越来越多后难维护。
- 组件销毁时容易忘记解绑事件。
- 调试困难。
- TypeScript 支持不如显式 props/emit 清晰。
面试表达¶
event bus 适合小项目或简单跨组件事件,但大型项目中容易造成隐式依赖。现在 Vue3 项目更推荐 Pinia、props/emit 或 provide/inject。
7. v-model 的实现原理?¶
Vue2¶
默认等价于:
<Child :value="msg" @input="msg = $event" />
Vue3¶
默认等价于:
<Child :modelValue="msg" @update:modelValue="msg = $event" />
子组件:
<script setup>
const props = defineProps({
modelValue: String
})
const emit = defineEmits(['update:modelValue'])
function update(value) {
emit('update:modelValue', value)
}
</script>
8. 多个 v-model 如何实现?¶
Vue3 支持多个 v-model:
<UserForm v-model:name="name" v-model:age="age" />
子组件:
const props = defineProps({
name: String,
age: Number
})
const emit = defineEmits(['update:name', 'update:age'])
emit('update:name', 'Tom')
emit('update:age', 20)
9. 插槽(slot)的作用?¶
核心回答¶
插槽用于让父组件向子组件传入一段结构内容,提高组件灵活性。
示例¶
<Card>
<p>这是卡片内容</p>
</Card>
<!-- Card.vue -->
<div class="card">
<slot></slot>
</div>
使用场景¶
- 弹窗内容。
- 表格列自定义。
- 卡片组件。
- 布局组件。
10. 具名插槽和作用域插槽?¶
具名插槽¶
用于定义多个插槽位置。
<Layout>
<template #header>头部</template>
<template #default>内容</template>
<template #footer>底部</template>
</Layout>
子组件:
<header><slot name="header"></slot></header>
<main><slot></slot></main>
<footer><slot name="footer"></slot></footer>
作用域插槽¶
子组件向父组件暴露数据,让父组件决定如何渲染。
<Child v-slot="{ user }">
{{ user.name }}
</Child>
子组件:
<slot :user="user"></slot>
11. slot 为什么是函数?¶
核心回答¶
在 Vue3 中,slot 本质上是函数。这样可以让插槽内容的渲染时机由子组件控制,也有利于依赖收集和性能优化。
理解¶
父组件传入的是“如何渲染内容”的函数,子组件在需要的位置调用这个函数。
slots.default?.()
这样可以避免不必要的渲染,也能让作用域插槽更自然地传参。
五、Vue Router¶
1. hash 模式和 history 模式区别?¶
hash 模式¶
URL 中带 #:
https://example.com/#/user
特点:
- 不需要后端特殊配置。
- 刷新不会 404。
- URL 不够美观。
history 模式¶
URL 没有 #:
https://example.com/user
特点:
- URL 更美观。
- 需要后端配置 fallback。
- 刷新页面时服务器需要返回
index.html。
2. history 模式刷新 404 如何解决?¶
原因¶
用户访问:
https://example.com/user
服务器会去找 /user 这个真实路径。
如果服务器没有这个路径,就会返回 404。
解决方案¶
配置服务器把所有前端路由都返回 index.html。
Nginx 示例¶
location / {
try_files $uri $uri/ /index.html;
}
3. 路由懒加载如何实现?¶
核心回答¶
使用动态 import() 按需加载页面组件。
const routes = [
{
path: '/user',
component: () => import('@/views/User.vue')
}
]
好处¶
- 减少首屏 bundle 体积。
- 用户访问对应路由时才加载页面代码。
- 配合打包工具自动拆分 chunk。
4. 路由守卫有哪些?¶
全局守卫¶
router.beforeEach((to, from, next) => {})
router.beforeResolve((to, from, next) => {})
router.afterEach((to, from) => {})
路由独享守卫¶
{
path: '/admin',
component: Admin,
beforeEnter: (to, from) => {}
}
组件内守卫¶
beforeRouteEnter(to, from) {}
beforeRouteUpdate(to, from) {}
beforeRouteLeave(to, from) {}
Vue3 Composition API 中也有:
onBeforeRouteLeave(() => {})
onBeforeRouteUpdate(() => {})
5. 全局 / 路由独享 / 组件内守卫区别?¶
| 类型 | 作用范围 | 使用场景 |
|---|---|---|
| 全局守卫 | 所有路由 | 登录鉴权、权限判断、页面标题 |
| 路由独享守卫 | 某个路由配置 | 某个页面单独权限 |
| 组件内守卫 | 某个组件 | 离开确认、组件复用参数变化 |
6. 路由传参的几种方式?¶
常见方式:
- query
- params
- path 参数
- props 解耦
- state 或缓存
query¶
router.push({
path: '/user',
query: { id: 1 }
})
URL:
/user?id=1
params¶
router.push({
name: 'user',
params: { id: 1 }
})
路由配置:
{
path: '/user/:id',
name: 'user',
component: User
}
URL:
/user/1
state 传参¶
基于浏览器 History API 实现。
router.push({ path: '/user', state: { id: 1001, name: 'Tom' } })
获取参数:
console.log(history.state)
特点:
参数不会显示在地址栏 URL 更简洁 适合临时数据传递 页面刷新后可能丢失
7. query 和 params 区别?¶
| 对比项 | query | params |
|---|---|---|
| URL 表现 | /user?id=1 |
/user/1 |
| 是否需要配置路径参数 | 不需要 | 通常需要 |
| 刷新是否保留 | 保留 | 路径参数保留 |
| 适用场景 | 搜索、筛选、分页 | 详情页 ID |
注意¶
使用 params 时,推荐用命名路由:
router.push({ name: 'user', params: { id: 1 } })
8. 路由跳转方式有哪些?¶
声明式导航¶
<RouterLink to="/home">首页</RouterLink>
编程式导航¶
router.push('/home')
router.replace('/login')
router.back()
router.forward()
router.go(-1)
push 和 replace 区别¶
push会新增历史记录。replace会替换当前历史记录,返回时不会回到当前页。
9. 动态路由是什么?¶
核心回答¶
动态路由是指路由路径中包含动态参数,或者运行时根据权限动态添加路由。
路径参数¶
{
path: '/user/:id',
component: UserDetail
}
访问:
/user/1001
动态添加路由¶
router.addRoute({
path: '/admin',
component: () => import('@/views/Admin.vue')
})
10. 如何做权限路由?¶
常见流程¶
- 用户登录,获取 token。
- 根据 token 获取用户信息和权限列表。
- 根据权限过滤路由表。
- 使用
router.addRoute动态添加可访问路由。 - 在全局路由守卫中判断是否登录、是否有权限。
- 根据权限生成菜单。
简化示例¶
router.beforeEach(async (to) => {
const token = getToken()
if (!token && to.path !== '/login') {
return '/login'
}
if (token && !permissionStore.hasRoutes) {
await permissionStore.generateRoutes()
return to.fullPath
}
})
六、状态管理(Vuex / Pinia)¶
1. Vuex 核心概念有哪些?¶
Vuex 常见核心概念:
| 概念 | 说明 |
|---|---|
| state | 状态数据 |
| getter | 类似 computed,对 state 派生计算 |
| mutation | 同步修改 state |
| action | 处理异步逻辑,提交 mutation |
| module | 模块化 store |
流程¶
组件 dispatch action
↓
action 异步请求
↓
commit mutation
↓
mutation 修改 state
↓
组件视图更新
2. 为什么 mutation 必须同步?¶
核心回答¶
因为 Vuex 需要通过 mutation 记录每一次状态变化,方便 devtools 调试和时间旅行。如果 mutation 是异步的,就无法准确追踪状态是在什么时候改变的。
面试表达¶
mutation 必须同步,是为了保证状态变更可追踪。异步逻辑应该放在 action 中,异步完成后再 commit mutation 修改 state。
3. action 和 mutation 区别?¶
| 对比项 | action | mutation |
|---|---|---|
| 是否可异步 | 可以 | 不建议,必须同步 |
| 作用 | 业务逻辑、接口请求 | 修改 state |
| 调用方式 | dispatch | commit |
| 是否直接改 state | 不直接 | 直接修改 |
4. Vuex 的缺点?¶
常见缺点:
- 写法较繁琐。
- mutation/action/getter 概念较多。
- TypeScript 支持不够自然。
- 模块化使用起来比较重。
- 小项目中容易显得复杂。
5. Pinia 相比 Vuex 的优势?¶
核心回答¶
Pinia 是 Vue 官方推荐的新一代状态管理方案。相比 Vuex,它写法更简单,去掉 mutation,TypeScript 支持更好,模块化更自然。
优势¶
- 没有 mutation,action 可同步也可异步。
- TypeScript 类型推导更好。
- 每个 store 天然模块化。
- 代码更简洁。
- 支持 Vue Devtools。
示例¶
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
name: 'Tom'
}),
actions: {
setName(name) {
this.name = name
}
}
})
6. Pinia 如何做模块化?¶
核心回答¶
Pinia 通过多个 store 文件天然实现模块化。
示例目录¶
src/stores/
user.js
permission.js
product.js
user store¶
export const useUserStore = defineStore('user', {
state: () => ({
token: '',
userInfo: null
}),
actions: {
setToken(token) {
this.token = token
}
}
})
permission store¶
export const usePermissionStore = defineStore('permission', {
state: () => ({
routes: []
})
})
7. Pinia 如何持久化?¶
常见方式:
- 手动使用 localStorage/sessionStorage。
- 使用插件,例如 pinia-plugin-persistedstate。
- 自己写 Pinia plugin。
手动示例¶
export const useUserStore = defineStore('user', {
state: () => ({
token: localStorage.getItem('token') || ''
}),
actions: {
setToken(token) {
this.token = token
localStorage.setItem('token', token)
}
}
})
插件思路¶
export const useUserStore = defineStore('user', {
state: () => ({
token: ''
}),
persist: true
})
8. 多个组件共享状态的最佳方式?¶
简单场景¶
父子组件:props / emit。
兄弟组件:父组件中转。
中等复杂度¶
局部跨层级:provide/inject。
大型项目¶
全局共享状态:Pinia。
面试表达¶
如果只是父子通信,我会优先用 props 和 emit。如果多个页面或多个无直接关系组件共享登录信息、权限、用户信息、购物车等状态,我会使用 Pinia。
七、性能优化¶
1. 如何减少组件重新渲染?¶
常见方法:
- 合理拆分组件。
- 使用 computed 缓存派生数据。
- 避免在模板中写复杂表达式。
- 列表加稳定 key。
- 使用
v-once渲染静态内容。 - 使用
v-memo跳过不必要更新。 - 大列表使用虚拟滚动。
- 不滥用 deep watch。
- 避免父组件频繁传递新对象或新函数。
- 使用 keep-alive 缓存页面。
2. v-once 的作用?¶
核心回答¶
v-once 只渲染元素或组件一次,后续数据变化不会再更新该部分。
示例¶
<h1 v-once>{{ title }}</h1>
使用场景¶
- 静态标题。
- 不会改变的说明文字。
- 一次性渲染的静态内容。
3. v-memo 是什么?¶
核心回答¶
v-memo 是 Vue3 的性能优化指令,用于根据依赖数组判断是否跳过某一部分的更新。
示例¶
<div v-for="item in list" :key="item.id" v-memo="[item.id === selectedId]">
{{ item.name }}
</div>
当依赖没有变化时,Vue 可以跳过该部分更新。
使用场景¶
大列表中只有部分项会变化时,可以用 v-memo 减少更新成本。
4. 如何做列表虚拟滚动?¶
核心回答¶
虚拟滚动是指只渲染当前可视区域内的数据,而不是一次性渲染全部列表。
原理¶
假设有 10000 条数据,但屏幕上只能看到 20 条。
虚拟滚动只渲染这 20 条附近的数据,并用占位高度模拟完整滚动条。
实现方式¶
- 使用第三方库,例如
vue-virtual-scroller。 - 自己根据滚动位置计算 startIndex 和 endIndex。
简化思路¶
容器滚动位置 scrollTop
↓
计算开始索引 startIndex
↓
计算结束索引 endIndex
↓
只渲染 list.slice(startIndex, endIndex)
5. 大列表如何优化?¶
常见方案:
- 分页加载。
- 虚拟滚动。
- 懒加载。
- 后端分页和搜索。
- 减少每一行组件复杂度。
- 避免每一项绑定大量事件。
- 使用稳定 key。
- 图片懒加载。
- 批量更新,避免频繁 setState。
- 使用 Web Worker 处理复杂计算。
6. 图片懒加载怎么做?¶
原生方式¶
<img src="a.jpg" loading="lazy" />
IntersectionObserver¶
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src
observer.unobserve(img)
}
})
})
Vue 指令方式¶
可以封装自定义指令:
app.directive('lazy', {
mounted(el, binding) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
el.src = binding.value
observer.unobserve(el)
}
})
observer.observe(el)
}
})
7. keep-alive 使用场景?¶
适合缓存不希望频繁销毁的组件,例如:
- 列表页返回后保持筛选条件和滚动位置。
- 多 tab 页面切换。
- 表单编辑页面临时保存输入。
- 成本较高的组件,例如复杂图表。
示例¶
<keep-alive include="UserList">
<RouterView />
</keep-alive>
8. 如何减少首屏加载时间?¶
常见方案:
- 路由懒加载。
- 组件按需加载。
- 第三方库按需引入。
- 打包拆包。
- CDN 加速静态资源。
- 图片压缩和懒加载。
- 开启 gzip / brotli。
- SSR 或 SSG。
- 预加载关键资源。
- 删除无用代码。
9. 如何分析页面性能?¶
常用工具:
- Chrome DevTools Performance。
- Lighthouse。
- Network 面板分析资源加载。
- Vue Devtools 查看组件更新。
- 打包分析工具,例如 rollup-plugin-visualizer。
- Web Vitals 指标。
常见指标¶
| 指标 | 含义 |
|---|---|
| FCP | 首次内容绘制 |
| LCP | 最大内容绘制 |
| CLS | 布局偏移 |
| INP | 交互响应 |
| TTFB | 首字节时间 |
10. Vue 项目如何拆包?¶
常见方式:
- 路由级懒加载。
- 组件异步加载。
- 第三方库单独拆包。
- 使用 Vite/Rollup 的 manualChunks。
- CDN 外链大型库。
Vite 示例¶
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
echarts: ['echarts']
}
}
}
}
}
八、Vue3 新特性(重点)¶
1. Composition API 优点?¶
核心回答¶
Composition API 可以按功能组织代码,而不是按 options 分类组织代码,适合复杂组件逻辑复用和 TypeScript 类型推导。
Options API 问题¶
export default {
data() {},
computed: {},
methods: {},
watch: {}
}
一个功能可能分散在 data、methods、watch 中。
Composition API¶
function useUser() {
const user = ref(null)
async function fetchUser() {}
return { user, fetchUser }
}
同一个功能可以集中维护。
2. setup 为什么更好?¶
优点¶
- 更适合逻辑复用。
- 更接近普通 JavaScript 函数。
- TypeScript 支持更好。
- 可以把业务逻辑拆成自定义 Hook。
- 避免 Options API 中 this 指向问题。
注意¶
setup 不是一定比 Options API 好。
简单组件使用 Options API 也可以。大型复杂逻辑组件中,Composition API 优势更明显。
3. script setup 是什么?¶
核心回答¶
<script setup> 是 Vue3 单文件组件中使用 Composition API 的编译时语法糖。
示例¶
普通 setup:
<script>
export default {
setup() {
const count = ref(0)
return { count }
}
}
</script>
script setup:
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
优点:
- 写法更简洁。
- 顶层变量可直接在模板中使用。
- defineProps / defineEmits 等宏更方便。
- TypeScript 支持更自然。
4. Teleport 的作用?¶
核心回答¶
Teleport 可以把组件模板的一部分渲染到当前组件 DOM 层级之外的指定位置。
示例¶
<Teleport to="body">
<div class="modal">弹窗内容</div>
</Teleport>
使用场景¶
- 弹窗。
- 消息提示。
- 下拉浮层。
- 全局 loading。
为什么需要?¶
弹窗如果放在深层组件内部,可能受到父元素的 overflow:hidden、z-index 影响。
Teleport 可以直接渲染到 body 下。
5. Suspense 的使用场景?¶
核心回答¶
Suspense 用于处理异步组件或异步 setup,在异步内容加载完成前显示 fallback 内容。
示例¶
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<div>加载中...</div>
</template>
</Suspense>
使用场景¶
- 异步组件加载。
- 页面骨架屏。
- 异步 setup。
- 需要统一 loading 状态的组件树。
6. Fragment 是什么?¶
核心回答¶
Fragment 指 Vue3 组件支持多个根节点,不再要求组件模板必须只有一个根元素。
Vue2¶
<template>
<div>
<Header />
<Main />
</div>
</template>
Vue3¶
<template>
<Header />
<Main />
</template>
Vue3 不需要额外包一层无意义的 div。
7. 自定义 Hook 如何写?¶
核心回答¶
自定义 Hook 是把可复用的组合式逻辑封装成函数,通常以 useXxx 命名。
示例:useCounter¶
import { ref } from 'vue'
export function useCounter() {
const count = ref(0)
function increment() {
count.value++
}
return {
count,
increment
}
}
使用:
<script setup>
import { useCounter } from '@/hooks/useCounter'
const { count, increment } = useCounter()
</script>
使用场景¶
- 请求逻辑。
- 表单逻辑。
- 分页逻辑。
- 权限逻辑。
- WebSocket 逻辑。
8. defineExpose 的作用?¶
核心回答¶
defineExpose 用于在 <script setup> 中显式暴露组件内部属性或方法给父组件通过 ref 调用。
子组件¶
<script setup>
function open() {
console.log('open modal')
}
defineExpose({
open
})
</script>
父组件¶
<Child ref="childRef" />
const childRef = ref(null)
childRef.value.open()
注意¶
不要滥用 ref 调用子组件方法。优先 props/emit。
只有弹窗打开、表单校验等命令式场景比较适合。
9. defineProps / defineEmits 是什么?¶
defineProps¶
用于在 <script setup> 中声明 props。
const props = defineProps({
title: String
})
defineEmits¶
用于声明子组件可以触发的事件。
const emit = defineEmits(['submit'])
emit('submit', data)
特点¶
它们是编译器宏,不需要 import,编译后会被处理掉。
10. Vue3 为什么更快?¶
主要原因:
- Proxy 响应式系统更高效。
- 编译器优化更多。
- 静态节点提升。
- Patch Flag 精准标记动态节点。
- Tree-shaking 支持更好。
- Fragment 减少无意义 DOM。
- Composition API 更利于逻辑组织和压缩。
- diff 算法优化。
面试表达¶
Vue3 更快不是单一原因,而是响应式系统、编译器和运行时一起优化。比如 Proxy 改善响应式能力,编译阶段会标记动态节点,运行时 diff 时可以更精准地更新。
九、真实项目 & 场景题¶
1. 表格编辑如何实现?¶
常见方案¶
- 每一行维护编辑状态。
- 点击编辑时复制当前行数据。
- 编辑完成后校验并提交。
- 保存成功后更新表格数据。
- 取消时恢复原数据。
示例结构¶
const tableData = ref([])
const editingId = ref(null)
const editForm = reactive({})
function editRow(row) {
editingId.value = row.id
Object.assign(editForm, row)
}
async function saveRow() {
await api.update(editForm)
const index = tableData.value.findIndex(item => item.id === editForm.id)
tableData.value[index] = { ...editForm }
editingId.value = null
}
注意点¶
- 不要直接修改原始行数据,避免取消时无法恢复。
- 表单校验要和保存动作结合。
- 大表格要考虑分页或虚拟滚动。
- 并发编辑要以后端数据为准。
2. 表单校验怎么做?¶
常见方式¶
- 使用组件库表单校验,例如 Element Plus。
- 自己封装校验函数。
- 前端校验 + 后端校验双重保证。
校验内容¶
- 必填。
- 长度。
- 格式,如邮箱、手机号。
- 数值范围。
- 自定义业务规则。
流程¶
用户提交
↓
前端校验
↓
校验通过
↓
调用接口
↓
后端校验
↓
保存成功或显示错误
3. 登录鉴权流程?¶
常见流程¶
- 用户输入账号密码。
- 调用登录接口。
- 后端返回 token。
- 前端保存 token。
- 请求拦截器携带 token。
- 路由守卫判断是否登录。
- 获取用户信息和权限。
- token 过期时刷新或退出登录。
示例¶
axios.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
4. 权限菜单怎么做?¶
常见方案¶
- 后端返回用户权限码或菜单树。
- 前端根据权限过滤路由。
- 根据过滤后的路由生成菜单。
- 按钮级权限用指令或函数判断。
示例¶
function hasPermission(code) {
return userStore.permissions.includes(code)
}
模板:
<button v-if="hasPermission('user:add')">新增用户</button>
自定义指令¶
app.directive('permission', {
mounted(el, binding) {
if (!hasPermission(binding.value)) {
el.remove()
}
}
})
5. 接口失败怎么统一处理?¶
使用 axios 响应拦截器¶
axios.interceptors.response.use(
response => response.data,
error => {
const status = error.response?.status
if (status === 401) {
// 跳转登录或刷新 token
} else if (status === 403) {
// 无权限
} else {
// 通用错误提示
}
return Promise.reject(error)
}
)
常见处理¶
- 401:未登录或 token 过期。
- 403:无权限。
- 404:资源不存在。
- 500:服务器错误。
- 网络错误:提示检查网络。
- 业务错误码:根据后端约定处理。
6. 请求防抖节流怎么做?¶
防抖 debounce¶
一段时间内多次触发,只执行最后一次。
适合搜索输入框。
function debounce(fn, delay) {
let timer = null
return function (...args) {
clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
节流 throttle¶
一段时间内最多执行一次。
适合滚动、窗口 resize。
function throttle(fn, delay) {
let last = 0
return function (...args) {
const now = Date.now()
if (now - last >= delay) {
last = now
fn.apply(this, args)
}
}
}
7. token 过期如何刷新?¶
常见流程¶
- access token 用于接口请求,时间较短。
- refresh token 用于刷新 access token,时间较长。
- 接口返回 401 时,前端调用刷新接口。
- 刷新成功后重试原请求。
- 刷新失败则退出登录。
注意并发问题¶
多个请求同时 401 时,不应该同时刷新 token。
通常需要一个刷新队列。
简化思路¶
请求返回 401
↓
判断是否正在刷新 token
↓
如果没有,发起刷新
↓
如果正在刷新,把请求加入队列
↓
刷新成功后重放队列请求
8. 多标签页登录同步?¶
场景¶
用户在一个标签页退出登录,其他标签页也应该同步退出。
方案一:storage 事件¶
window.addEventListener('storage', (event) => {
if (event.key === 'token' && !event.newValue) {
router.push('/login')
}
})
当一个标签页修改 localStorage,其他标签页会收到 storage 事件。
方案二:BroadcastChannel¶
const channel = new BroadcastChannel('auth')
channel.postMessage({ type: 'logout' })
channel.onmessage = (event) => {
if (event.data.type === 'logout') {
router.push('/login')
}
}
9. 如何封装 axios?¶
常见封装内容¶
- baseURL。
- timeout。
- 请求头。
- 请求拦截器。
- 响应拦截器。
- 错误统一处理。
- token 自动携带。
- token 刷新。
- loading 控制。
- 取消重复请求。
示例¶
import axios from 'axios'
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000
})
service.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
service.interceptors.response.use(
response => response.data,
error => {
return Promise.reject(error)
}
)
export default service
10. 如何做错误监控?¶
前端错误类型¶
- JS 运行时错误。
- Promise 未捕获错误。
- Vue 组件错误。
- 接口错误。
- 资源加载错误。
- 白屏错误。
- 性能异常。
Vue 错误捕获¶
app.config.errorHandler = (err, instance, info) => {
console.error(err, info)
}
全局错误捕获¶
window.addEventListener('error', (event) => {
console.log(event.message)
})
window.addEventListener('unhandledrejection', (event) => {
console.log(event.reason)
})
上报内容¶
- 错误信息。
- 堆栈。
- 页面 URL。
- 用户信息。
- 浏览器信息。
- 发生时间。
- 接口请求信息。
十、工程化 & 构建¶
1.Vite 是什么?¶
Vite(法语,意为“快速”)是新一代前端构建工具,由 Vue 作者尤雨溪开发。
主要用于:
- Vue 项目开发
- React 项目开发
- TypeScript 项目开发
- 前端工程化构建
Vite 解决了什么问题?¶
传统 Webpack 项目启动时:
启动项目
↓
打包整个项目
↓
启动完成
项目越大:
启动越慢
Vite 采用:
浏览器原生 ESM
方式运行。
启动项目
↓
启动开发服务器
↓
按需加载模块
因此启动速度非常快。
Vite 的工作原理¶
开发环境:
源码
↓
浏览器请求
↓
Vite 实时编译
↓
返回模块
不需要提前打包整个项目。
生产环境:
源码
↓
Rollup 打包
↓
生成静态资源
Vite 为什么快?¶
1. 基于 ES Module¶
浏览器直接加载模块:
import App from './App.vue'
无需提前打包全部代码。
2. 按需编译¶
只编译当前访问的模块。
访问页面A
↓
只编译页面A相关代码
3. 使用 ESBuild¶
ESBuild 使用 Go 语言开发。
TS/JS 编译速度远快于 Babel
Vite 与 Webpack 区别¶
| 对比项 | Vite | Webpack |
|---|---|---|
| 开发启动速度 | 快 | 较慢 |
| 热更新(HMR) | 快 | 较慢 |
| 开发模式 | ESM按需加载 | 整体打包 |
| 生产打包 | Rollup | Webpack |
| 学习成本 | 低 | 较高 |
常用命令¶
创建项目:
npm create vite@latest
安装依赖:
npm install
启动项目:
npm run dev
打包项目:
npm run build
预览打包结果:
npm run preview
面试回答¶
Vite 是由 Vue 作者尤雨溪开发的新一代前端构建工具。开发环境下利用浏览器原生 ES Module 实现按需加载,无需像 Webpack 一样先打包整个项目,因此启动速度和热更新速度更快;生产环境则使用 Rollup 进行打包优化。目前 Vue3 官方推荐使用 Vite 作为默认构建工具。
2. Tree Shaking 原理?¶
核心回答¶
Tree Shaking 是打包时删除未使用代码的优化手段,主要依赖 ES Module 的静态结构。
示例¶
// utils.js
export function a() {}
export function b() {}
import { a } from './utils'
a()
如果 b 没被使用,生产构建时可能被删除。
注意¶
Tree Shaking 更适合 ESM,因为 import/export 是静态的。
CommonJS 的 require 是运行时加载,静态分析较困难。
3. ESM 和 CommonJS 区别?¶
| 对比项 | ESM | CommonJS |
|---|---|---|
| 导入 | import | require |
| 导出 | export | module.exports |
| 加载时机 | 静态编译分析 | 运行时加载 |
| 是否利于 Tree Shaking | 是 | 较差 |
| 常见环境 | 浏览器、现代前端 | Node.js 传统模块 |
示例¶
ESM:
import { add } from './math.js'
export const count = 1
CommonJS:
const { add } = require('./math')
module.exports = { count: 1 }
4. import 和 require 区别?¶
import¶
- ESM 语法。
- 通常在文件顶部静态导入。
- 支持 Tree Shaking。
- 可以使用动态 import。
import Vue from 'vue'
动态 import:
const module = await import('./module.js')
require¶
- CommonJS 语法。
- 运行时加载。
- 可以写在条件语句中。
- 不利于静态分析。
const fs = require('fs')
5. 如何配置环境变量?¶
Vite 环境变量文件¶
.env
.env.development
.env.test
.env.production
注意¶
Vite 中暴露给客户端的变量必须以 VITE_ 开头。
VITE_API_BASE_URL=https://api.example.com
使用:
const baseURL = import.meta.env.VITE_API_BASE_URL
6. 如何区分开发 / 测试 / 生产环境?¶
package.json¶
{
"scripts": {
"dev": "vite --mode development",
"build:test": "vite build --mode test",
"build": "vite build --mode production"
}
}
环境文件¶
.env.development
.env.test
.env.production
代码中判断¶
if (import.meta.env.MODE === 'production') {
console.log('生产环境')
}
常见变量:
import.meta.env.MODE
import.meta.env.DEV
import.meta.env.PROD
7. 打包体积过大怎么优化?¶
常见方案:
- 路由懒加载。
- 组件按需加载。
- 第三方库按需引入。
- 使用 Tree Shaking。
- 分析 bundle 体积。
- 图片压缩。
- 使用 CDN。
- 删除无用依赖。
- 拆分 vendor chunk。
- gzip / brotli 压缩。
分析工具¶
npm install rollup-plugin-visualizer -D
8. 代码分割怎么做?¶
路由级代码分割¶
const User = () => import('@/views/User.vue')
组件级异步加载¶
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() => import('./AsyncComp.vue'))
手动拆包¶
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
return 'vendor'
}
}
}
}
}
}
面试复习建议¶
1. 回答结构¶
回答面试题时可以按这个结构:
先说结论
↓
解释原理
↓
给一个例子
↓
说项目中怎么用
例如回答 v-if 和 v-show 的区别:
v-if 是条件渲染,条件为 false 时 DOM 不存在;v-show 是显示隐藏,DOM 一直存在,只是 display 控制。
如果切换频率低用 v-if,如果频繁切换用 v-show。
比如弹窗内容很重且不常打开,可以用 v-if;tab 内容频繁切换,可以考虑 v-show 或 keep-alive。
2. Vue 面试重点优先级¶
必须掌握¶
- Vue 响应式原理。
- ref 和 reactive。
- computed / watch。
- 生命周期。
- props / emit / v-model。
- Vue Router 路由守卫。
- Pinia。
- axios 封装。
- 登录鉴权。
- Vite 基础。
加分项¶
- 虚拟 DOM 和 diff。
- keep-alive。
- v-memo。
- 权限路由。
- token 刷新队列。
- 性能优化。
- 错误监控。
- 打包优化。
- TypeScript + Vue3。
- 自定义 Hook。
参考资料¶
- Vue 官方文档:Vue 是渐进式 JavaScript 框架,提供可逐步采用的生态能力。
- Vue
<script setup>官方文档:defineProps和defineEmits是<script setup>中的编译器宏。 - Pinia 官方文档:Pinia 的 state 通常定义为返回初始状态的函数,并支持插件扩展。
- Vite 官方文档:Vite 包含基于原生 ESM 的开发服务器和生产构建能力,开发环境支持快速 HMR。