Vue 热门面试题
使用说明
本篇共 60 道题。
Vue 面试重点是响应式依赖、组件更新、Composition API 和工程取舍。口头回答先说机制,再补使用建议和常见陷阱。
Q1: Vue 3 为什么使用 Proxy?
答案:
Vue 2 的 Object.defineProperty 只能拦截已经定义到具体属性上的 getter/setter。新增或删除属性、数组索引和长度变化需要额外约定,初始化时还要递归遍历对象。
Proxy 包装整个对象,可以统一拦截 get、set、deleteProperty、has、ownKeys 等操作,因此 Vue 3 能自然观察属性增删、数组和集合类型,并在访问嵌套对象时按需创建代理。
代价是 Proxy 无法为不支持它的旧环境完整 polyfill;此外只有通过代理对象进行的操作才会被追踪。Vue 仍需用 WeakMap 缓存代理,并通过 toRaw、markRaw、浅层 API 处理特殊对象和性能边界。
Q2: Vue 3 响应式系统大致如何工作?
答案:
读取响应式属性时收集当前活跃副作用与“目标+键”的依赖关系,写入变化时找到相关副作用并调度执行。
Vue 会通过调度队列批量刷新组件和 watcher,避免一次同步流程中重复更新。没有被读取的属性不会凭空建立依赖。
- 读取响应式属性时
track当前副作用,写入时trigger相关副作用;依赖通常按 target、key 分层存储,避免无关更新。 computed在此之上增加缓存与脏标记,组件渲染也是一种响应式副作用。实际调试要关注意外解构、重复代理和过大的依赖范围。
Q3: ref 和 reactive 怎么选?
答案:
| 维度 | ref | reactive |
|---|---|---|
| 数据类型 | 任意类型 | 仅对象/数组 |
| 访问修改 | .value | 直接访问 |
| 整体替换 | ✅ 支持 | ❌ 不支持 |
| 解构 | 需要 toRefs | 需要 toRefs |
| TS 类型推导 | 更好 | 一般 |
选择建议:
- 基本类型:只能用
ref - 对象类型:两者都可以,推荐
ref(更一致的心智模型) - 组合式函数返回值:推荐
ref(方便解构)
// 推荐:统一使用 ref
const count = ref(0);
const user = ref({ name: 'Tom' });
// 组合式函数
function useCounter() {
const count = ref(0);
const increment = () => count.value++;
return { count, increment }; // 返回 ref,调用者可直接解构
}
Q4: computed 和 watch 有什么区别?
答案:
| 场景 | 选择 | 原因 |
|---|---|---|
| 从已有数据派生新数据 | computed | 有缓存,自动依赖追踪 |
| 需要执行异步操作 | watch | computed 不支持异步 |
| 需要访问旧值 | watch | computed 没有旧值 |
| 需要执行 DOM 操作 | watch | computed 不应有副作用 |
| 数据变化后发请求 | watch | 典型的副作用场景 |
// ✅ computed:派生数据
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
// ✅ watch:副作用
watch(userId, async (id) => {
userData.value = await fetchUser(id);
});
// ❌ 错误:computed 执行副作用
const data = computed(() => {
fetch('/api/data'); // 不要这样做!
return someValue.value;
});
Q5: watch 和 watchEffect 怎么选?
答案:
const count = ref(0);
const name = ref('Tom');
// watch:显式声明依赖
watch(count, (newVal, oldVal) => {
console.log('count changed:', oldVal, '→', newVal);
});
// watchEffect:自动追踪
watchEffect(() => {
// 自动追踪 count 和 name
console.log(count.value, name.value);
});
| 维度 | watch | watchEffect |
|---|---|---|
| 依赖声明 | 显式 | 自动推断 |
| 执行时机 | 依赖变化时 | 立即执行 + 依赖变化时 |
| 旧值访问 | ✅ (newVal, oldVal) | ❌ |
| 精确控制 | ✅ 只侦听指定源 | ❌ 追踪所有使用的响应式数据 |
选择建议:
- 需要旧值或精确控制 →
watch - 依赖复杂或需要立即执行 →
watchEffect
Q6: nextTick 的作用是什么?
答案:
Vue 会批量调度 DOM 更新,修改响应式状态后同步读取 DOM 可能还是旧结果。nextTick 等待当前这一轮更新提交完成。
它不是通用延时函数。大量依赖 nextTick 操作 DOM 往往说明数据流或组件抽象需要调整。
- Vue 会把同一轮同步状态变更批量放入微任务队列,DOM 不会在赋值后立刻逐次更新。
nextTick等待这轮渲染刷新完成。 - 它适合更新后读取尺寸或操作焦点,不应被当作修复数据流问题的“万能延时器”;连续依赖 DOM 的流程更适合封装为指令或组件。
Q7: Vue 的虚拟 DOM 与 Diff 大致如何工作?
答案:
组件渲染生成 VNode,更新时比较新旧树,按节点类型和 key 复用或替换,并把必要变化提交到真实 DOM。
Vue 编译器还能标注静态节点和动态部分,让运行时跳过不可能变化的区域。Diff 仍依赖稳定结构和正确 key。
- Vue 先比较节点类型和
key,再按编译期 PatchFlag 跳过静态内容,只更新动态属性或子节点。 - 带 key 的列表会同步头尾、建立 key 到索引的映射,并通过最长递增子序列尽量减少 DOM 移动;稳定 key 决定状态能否正确复用。
Q8: v-for 中的 key 为什么重要?
答案:
key 帮助 Vue 的 Diff 算法识别节点身份,实现:
- 准确复用:通过 key 精确匹配新旧节点
- 最小化 DOM 操作:只更新变化的部分
- 保持组件状态:确保状态与正确的节点关联
- 正确的 Transition 动画:动画应用到正确的元素
// 没有 key:按顺序就地复用(可能复用错误)
// 旧: [A, B, C] -> 新: [B, C, A]
// Vue 认为:A->B(更新), B->C(更新), C->A(更新) = 3 次更新
// 有 key:根据 key 匹配
// Vue 认为:A 移动到末尾 = 1 次移动
Q9: v-if 和 v-show 怎么选?
答案:
| 维度 | v-if | v-show |
|---|---|---|
| 实现方式 | 条件为假不渲染 DOM | CSS display: none |
| 切换开销 | 高(DOM 操作) | 低(CSS 切换) |
| 初始开销 | 低(惰性渲染) | 高(始终渲染) |
| 生命周期 | 每次切换触发 | 只首次触发 |
使用建议:
- 频繁切换(Tab、下拉菜单)→
v-show - 条件很少改变(权限控制、登录状态)→
v-if - 首屏优化(懒加载组件)→
v-if
Q10: Vue 组件有哪些通信方式?
答案:
父子用 props 和 emits;跨层但作用域明确可用 provide/inject;共享复杂状态用 Pinia 等 Store;路由和服务端数据各有独立状态来源。
不应为了少写几层 props 就把所有状态全局化。事件表达“发生了什么”,不要让子组件直接修改父组件内部状态。
Q11: Composition API 相比 Options API 有什么价值?
答案:
- 更好的代码组织:将相关逻辑放在一起,而非分散在不同选项中
- 更好的逻辑复用:Composables 替代 Mixins,解决了命名冲突、来源不清等问题
- 更好的 TypeScript 支持:不依赖 this,类型推断更准确
- 更小的打包体积:变量名可以被压缩(不像 Options API 的属性名)
- 更灵活的组合:可以自由组合多个 Composables
Q12: script setup 做了什么?
答案:
| 特性 | script setup | 普通 setup() |
|---|---|---|
| 语法 | 顶层代码即 setup | 需要在 setup() 函数内 |
| return | 自动暴露 | 需要手动 return |
| 组件注册 | 自动注册 | 需要 components 选项 |
| props/emits | 使用编译器宏 | 通过参数获取 |
| 性能 | 更好(编译优化) | 一般 |
| TypeScript | 更好的类型推断 | 需要 defineComponent |
Q13: Vue 生命周期中适合做什么?
答案:
setup 阶段建立响应式逻辑;mounted 后才能可靠访问客户端 DOM;updated 不应无条件再次改状态;unmounted 要清理监听、定时器和外部实例。
数据请求放在哪里取决于 SSR、路由和缓存策略,不应简单背“都放 mounted”。
Q14: keep-alive 的原理和风险是什么?
答案:
keep-alive 的核心原理:
- 缓存 VNode:使用
Map存储组件的 VNode 和实例 - 复用实例:再次渲染时,直接复用缓存的组件实例
- LRU 淘汰:设置
max后,超出限制会删除最久未使用的 - 特殊标记:通过
shapeFlag标记组件为 keep-alive 类型 - 生命周期:卸载时不销毁,而是调用
deactivated;恢复时调用activated
Q15: Teleport 和 Suspense 分别解决什么问题?
答案:
Teleport 解决的是DOM 挂载位置问题:组件逻辑仍属于原组件树,但实际 DOM 可以放到 body 或指定容器,适合弹窗、Toast 和全局浮层。
Suspense 解决的是异步依赖就绪前的边界展示问题:默认插槽中的异步 setup 或异步组件尚未完成时显示 fallback,完成后切换到正式内容。
二者职责不同,可以组合使用。Teleport 不会自动实现焦点管理和滚动锁定;Suspense 也不是任意请求的全局 loading 管理器。实际项目还要结合错误边界、超时、路由和 SSR 框架能力设计。
Q16: Vue Router 的前端路由原理是什么?
答案:
History 模式通过 History API 修改 URL 并监听导航,Hash 模式使用 # 后片段。Router 根据当前地址匹配记录、执行守卫并渲染对应组件。
History 模式需要服务端把未知前端路由回退到入口 HTML,同时正确处理真正的静态资源和 404。
Q17: Pinia 和 Vuex 的主要差异是什么?
答案:
Pinia API 更轻、模块天然、TypeScript 推断更直接,不再强制 mutation 层;Vuex 在旧项目和既有插件生态中仍可能存在。
迁移应关注状态边界、持久化、SSR 隔离和调试,而不是只替换 API 名称。
- Pinia 以组合式 Store、完整 TypeScript 推导和模块化设计简化了 Vuex 的 mutation/namespace 心智负担。
- Store 仍不应成为所有状态的默认归宿:组件局部 UI 状态留在组件,服务端缓存交给请求层;持久化插件还要处理版本和敏感数据。
Q18: Vue 项目如何做性能优化?
答案:
先用 Devtools 和浏览器工具定位更新、计算、DOM、网络或包体瓶颈。
- 保持 props 稳定,减少不必要的深层响应式和深度 watch。
- 长列表虚拟化,大组件拆分并按路由加载。
- 静态内容使用编译器可优化的写法,避免模板中重复昂贵计算。
Q19: Vue SSR 和 hydration 有哪些注意点?
答案:
服务端先生成 HTML,客户端用同一组件树接管交互。首屏标记必须确定一致,不能在渲染中直接依赖时间、随机数和仅浏览器存在的 API。
每个请求的状态要隔离,避免跨用户污染;数据获取、序列化和错误状态交给 SSR 框架统一管理更可靠。
- SSR 首先在服务端生成 HTML,客户端用相同状态执行 hydration 并绑定事件。两端输出不一致会产生 hydration mismatch。
- 时间、随机数、浏览器 API 和用户相关数据要延后到客户端或显式序列化;请求级状态必须隔离,避免跨用户污染。
Q20: Vue 2 升级 Vue 3 的主要风险是什么?
答案:
不仅是改响应式 API,还涉及全局 API、生命周期、v-model、事件、插件、构建工具和第三方组件兼容。
先盘点依赖和废弃能力,建立测试与监控,按模块迁移并保留回滚。不要在同一次变更中同时大规模重构业务。
- 主要风险包括 Proxy 响应式差异、生命周期名称变化、
v-model/事件 API 调整、全局 API 改造以及第三方库兼容。 - 实践中先升级到 Vue 2.7 并清理废弃写法,再使用兼容构建和迁移检查,按模块灰度替换,最后验证 SSR、路由和状态持久化。
Q21: Vue 2 的 Object.defineProperty 有哪些局限性?
答案:
-
无法检测属性的添加和删除
// Vue 2 中这些操作不会触发更新
this.obj.newProp = 'value'; // ❌ 添加属性
delete this.obj.prop; // ❌ 删除属性
// 必须使用特殊 API
this.$set(this.obj, 'newProp', 'value');
this.$delete(this.obj, 'prop'); -
无法检测数组索引和长度变化
this.arr[0] = 'new'; // ❌ 不会触发更新
this.arr.length = 0; // ❌ 不会触发更新 -
初始化时需要递归遍历所有属性,性能开销大
-
需要重写数组方法(push、pop、splice 等)
Q22: 为什么解构 reactive 对象会丢失响应性?怎么处理?
答案:
reactive 通过 Proxy 拦截对象属性访问。直接解构得到的是当时的普通值,后续读取不再经过原代理:
const state = reactive({ count: 0 });
const { count } = state; // 普通 number,不会继续更新
需要保留关联时,可以使用 toRef(state, 'count') 或 toRefs(state),模板和组合式函数中再按 Ref 使用。对于只读计算值,可直接用 computed。
不要无差别对大型对象调用 toRefs,它会为每个可枚举属性创建 Ref;更好的做法是只暴露组件真正需要的字段和操作。
Q23: Vue 2 和 Vue 3 的 Diff 算法有什么区别?
答案:
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 算法 | 双端 Diff | 快速 Diff |
| 核心优化 | 头尾双指针 | 最长递增子序列 |
| 静态提升 | ❌ | ✅ |
| 静态标记 | ❌ | ✅ PatchFlags |
Vue 3 的快速 Diff 优势:
- 预处理相同前缀/后缀:先处理头尾不变的节点
- 最长递增子序列:最小化 DOM 移动操作
- 静态提升:静态节点跳过 Diff
- PatchFlags:精确标记动态内容类型
Q24: computed 可以有 setter 吗?适合什么场景?
答案:
可以。传入 get/set 对象可创建可写 computed,常用于把组件的读写协议映射到已有状态,例如自定义 v-model:
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value: string) => {
[firstName.value, lastName.value] = value.split(' ');
},
});
Getter 应保持纯粹并只做派生计算,Setter 负责把外部赋值转换成明确的状态更新。若写入涉及异步请求、重试或复杂副作用,更适合显式方法或 Action,不要隐藏在 computed setter 里。
Q25: 在哪个生命周期可以访问 DOM?
答案:
mounted(或 onMounted) 是第一个可以访问 DOM 的生命周期。
// ❌ beforeMount 中访问 DOM
onBeforeMount(() => {
console.log(document.querySelector('#app')); // null 或上一次的 DOM
});
// ✅ mounted 中访问 DOM
onMounted(() => {
console.log(document.querySelector('#app')); // ✅ 可以访问
const el = templateRef.value; // ✅ ref 可用
});
但要注意:mounted 不保证所有子组件都已挂载。如果需要等待整个视图渲染完成:
import { onMounted, nextTick } from 'vue';
onMounted(async () => {
await nextTick();
// 此时所有子组件都已渲染完成
});
Q26: 父组件如何调用子组件的方法?
答案:
使用 ref + defineExpose:
<!-- Parent.vue -->
<template>
<Child ref="childRef" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Child from './Child.vue';
const childRef = ref<InstanceType<typeof Child>>();
// 调用子组件方法
childRef.value?.validate();
</script>
<!-- Child.vue -->
<script setup lang="ts">
function validate() {
// 验证逻辑
return true;
}
defineExpose({ validate });
</script>
Q27: 哪些组件不适合放进 keep-alive 缓存?
答案:
keep-alive 适合切换后希望保留局部状态、DOM 和滚动位置的组件,但以下场景要谨慎:
- 状态必须每次进入都重新初始化,例如支付或敏感表单。
- 组件持有大量 DOM、图表实例或数据,缓存会造成明显内存压力。
- 外部订阅在
deactivated后仍持续运行,可能产生后台请求和泄漏。 - 状态应由 URL 或 Store 恢复,而不是长期保留整个实例。
使用 include/exclude/max 控制范围,并在 activated/deactivated 中暂停和恢复资源。缓存命中要依赖稳定组件身份,动态路由还需明确按什么 key 区分实例。
Q28: v-once 和 v-memo 有什么区别?
答案:
v-once 让子树在首次渲染后永久跳过后续更新,适合生命周期内确定不变的内容。
v-memo="[deps]" 根据依赖数组决定是否跳过该子树更新;依赖值都未变化时复用上次 VNode,适合经过测量后确认昂贵的大列表项或复杂子树。
二者都属于有明确前提的优化工具。依赖写错会产生陈旧 UI,简单组件使用它们可能比正常 Diff 更复杂。应先保证 props 稳定、减少无关响应式依赖,再用性能分析验证收益。
Q29: v-for 中的模板 ref 为什么通常得到数组?要注意什么?
答案:
同一个模板 ref 出现在 v-for 中会对应多个元素或组件实例,因此挂载后通常得到集合。列表变化时,集合顺序不应被当作稳定业务映射。
更可靠的方式是使用函数 ref,按业务 ID 维护 Map:
const rows = new Map<string, HTMLElement>();
function bindRow(id: string, element: HTMLElement | null) {
if (element) rows.set(id, element);
else rows.delete(id);
}
DOM 只在挂载后可用,列表更新后读取要结合 nextTick。若只是为了定位、测量或滚动,也要评估是否能通过事件目标、CSS 或虚拟列表 API 避免持有大量节点引用。
Q30: 一个可复用 Composable 应怎样设计?
答案:
Composable 应围绕一项明确能力组织响应式状态和生命周期,而不是把组件代码机械搬进函数。
设计重点:
- 输入同时考虑普通值、Ref 或 getter,必要时用
toValue统一读取。 - 返回最小的只读状态和显式操作,避免调用方任意破坏内部不变量。
- 在所属 effect scope 内注册监听、定时器和订阅,并在清理时释放。
- SSR 时不要在模块顶层访问
window或共享用户状态。 - 错误、loading、取消和竞态策略由能力本身统一定义。
- 类型参数只表达真实关系,不要为了“通用”堆叠难推断泛型。
Q31: defineModel 解决了什么问题?
答案:
defineModel 是 <script setup> 编译器宏,用来声明组件的 v-model 协议,减少手写 modelValue Prop 和 update:modelValue Emit 的样板代码。
<script setup lang="ts">
const value = defineModel<string>({ required: true });
</script>
<template>
<input v-model="value" />
</template>
命名模型、修饰符和 get/set 转换也可以配置。它只是生成受控数据协议,不代表子组件可以绕过父级随意修改其他 Prop;默认值还要小心父子初始状态不一致。
Q32: Teleport 和第三方 Portal 方案有什么区别?
答案:
Teleport 是 Vue 3 内置能力,编译器、运行时、SSR 和组件生命周期都能理解它;Portal 是通用概念,Vue 2 常通过第三方库实现。
Teleport 只改变真实 DOM 的目标位置,逻辑父子关系、provide/inject、事件和 scoped 样式关系仍按 Vue 组件树工作。目标容器必须存在,SSR/Hydration 时还要保证服务端和客户端目标一致。
已有 Vue 3 项目通常优先使用 Teleport;只有需要额外的队列、层级管理或跨应用能力时,才在它之上封装浮层系统。
Q33: Hash 和 History 模式的区别?
答案:
| 维度 | Hash | History |
|---|---|---|
| URL 美观 | /#/path(不美观) | /path(美观) |
| 服务器配置 | 无需 | 需要 fallback 配置 |
| 原理 | hashchange | popstate + pushState |
| 兼容性 | IE8+ | IE10+ |
| SEO | # 后内容不被索引 | 友好 |
| 请求服务器 | # 后内容不发送到服务器 | 完整路径发送 |
Q34: 为什么 Pinia 移除了 mutations?
答案:
Vuex 的 mutations 设计初衷是:
- 追踪状态变化:devtools 可以记录每次 mutation
- 保证同步:mutation 必须同步,方便调试
但实际开发中:
- 样板代码过多:每个状态变化都要写 mutation + action
- 命名冗余:
SET_USER,UPDATE_USER,CLEAR_USER... - TypeScript 不友好:类型推断困难
Pinia 的解决方案:
- 直接在 action 中修改状态:减少样板代码
- devtools 仍然可以追踪:Pinia 同样支持时间旅行调试
- 更好的 TS 支持:类型自动推断
Q35: Vue 的编译发生在什么时候?
答案:
| 场景 | 编译时机 | 说明 |
|---|---|---|
| SFC + 构建工具 | 构建时 | vite/webpack 插件处理 |
| 运行时模板 | 运行时 | 需要完整版 Vue |
| JSX/TSX | 构建时 | Babel/esbuild 处理 |
// 构建时编译(推荐)
// .vue 文件在构建时被编译,产物只包含 render 函数
// 运行时编译(需要完整版 Vue)
import { createApp } from 'vue'; // 需要 'vue/dist/vue.esm-bundler.js'
createApp({
template: '<div>{{ msg }}</div>', // 运行时编译
data: () => ({ msg: 'Hello' })
});
Q36: Vue 应用首屏加载慢如何优化?
答案:
| 优化方向 | 具体措施 |
|---|---|
| 减少体积 | Tree Shaking、代码分割、压缩、Gzip |
| 加快加载 | CDN、HTTP/2、预加载关键资源 |
| 优化渲染 | 骨架屏、SSR/SSG、关键 CSS 内联 |
| 延迟加载 | 路由懒加载、图片懒加载、异步组件 |
// 路由懒加载
const routes = [
{ path: '/', component: () => import('./Home.vue') }
];
// 组件懒加载
const HeavyComponent = defineAsyncComponent(() =>
import('./Heavy.vue')
);
// 第三方库 CDN
// vite.config.ts
export default {
build: {
rollupOptions: {
external: ['vue', 'vue-router'],
output: {
globals: {
vue: 'Vue',
'vue-router': 'VueRouter'
}
}
}
}
};
Q37: Vue 3 相比 Vue 2 有哪些优势?
答案:
| 方面 | 优势 |
|---|---|
| 响应式 | Proxy 替代 defineProperty,支持新增/删除属性、数组索引 |
| 性能 | 编译优化(静态提升、PatchFlags)、更小体积、更快 SSR |
| API | Composition API 更好的逻辑复用和代码组织 |
| TypeScript | 原生支持,类型推断更完善 |
| 新特性 | Fragment、Teleport、Suspense、多 v-model |
Q38: Vue SSR 能带来什么收益,又增加哪些成本?
答案:
SSR 在服务端先输出 HTML,可改善内容更早可见、链接分享和部分抓取体验,并能结合流式响应与服务端数据加载减少首屏请求瀑布。
它并不保证页面一定更快:服务端增加渲染和缓存压力,客户端仍可能承担 Hydration 与 JavaScript 执行;两端输出不一致还会导致 Hydration mismatch。
实际选型应按路由决定。内容和首屏敏感页面可使用 SSR/SSG,后台工具可能保留 CSR。生产中优先使用 Nuxt 等框架管理请求级状态、数据序列化、缓存、错误边界和部署运行时。
Q39: effectScope 解决什么问题?
答案:
effectScope 可以把一组响应式副作用放进同一作用域统一停止,包括作用域中创建的 computed、watch 和 watchEffect。它适合组件之外的可复用逻辑、插件或临时业务会话,避免逐个保存停止函数。
在组件 setup 内创建的副作用通常已由组件生命周期管理,不需要为了形式再套一层。Composable 需要额外资源清理时,还应配合 onScopeDispose。
停止作用域不会自动取消未接入清理逻辑的网络请求或定时器,这些资源仍要显式接入 Abort 或清理函数。
Q40: Vue 3 响应式系统如何处理嵌套对象?
答案:
Vue 3 采用**懒代理(Lazy Proxy)**策略,只有在访问嵌套属性时才会递归创建代理:
const state = reactive({
user: {
profile: {
name: 'Tom'
}
}
});
// 初始化时只有最外层被代理
// 访问 state.user 时,user 对象被代理
// 访问 state.user.profile 时,profile 对象被代理
state.user.profile.name = 'Jerry'; // 触发更新
相比 Vue 2 的递归遍历,性能更好。
Q41: key 为什么不能用 index?
答案:
使用 index 作为 key 会导致:
// 原始数据: [{id: 1, name: 'A'}, {id: 2, name: 'B'}]
// key 使用 index: [A:0, B:1]
// 删除第一项后: [{id: 2, name: 'B'}]
// key 变成: [B:0]
// Diff 认为:key=0 的节点内容从 A 变成 B
// 实际上应该:删除 A 节点,复用 B 节点
问题:
- 错误的节点复用:状态混乱
- 性能下降:本可以复用的节点被更新
- 表单输入丢失:输入框等组件状态错乱
Q42: watch 中怎样取消过期的异步副作用?
答案:
监听值快速变化时,前一次请求可能晚于后一次返回,造成旧结果覆盖新结果。应在 watcher 清理阶段取消或忽略过期任务:
watch(query, async (value, _oldValue, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
const response = await fetch(`/api/search?q=${value}`, {
signal: controller.signal,
});
result.value = await response.json();
});
清理函数会在下一次回调运行前或 watcher 停止时执行。还要区分取消错误和真实失败,并结合防抖、缓存与服务端限流,而不是只在 UI 层忽略所有响应。
Q43: created 和 mounted 有什么区别?数据请求应该放哪里?
答案:
created 是 Options API 中实例已建立、响应式状态可用但 DOM 尚未挂载的阶段;mounted 发生在客户端 DOM 挂载后,适合测量元素或初始化依赖 DOM 的第三方库。
普通 CSR 中两处都能发请求,但“能发”不等于最佳位置:
- 与 DOM 无关的数据不必等到 mounted。
- SSR 中 mounted 不会在服务端执行;路由数据应优先交给框架的数据加载和缓存机制。
- 请求要处理取消、竞态、错误和重复导航。
- 组合式 API 中通常使用
setup、onMounted或框架提供的useAsyncData等能力。
Q44: provide/inject 和 props 有什么区别?
答案:
| 维度 | props | provide/inject |
|---|---|---|
| 传递深度 | 直接父子 | 任意深度 |
| 数据流向 | 单向向下 | 向下(可伪双向) |
| 类型推断 | 自动 | 需要手动指定 |
| 显式依赖 | 是 | 否(隐式) |
| 使用场景 | 组件接口 | 跨层级共享 |
// props:适合组件接口
<UserCard :user="user" />
// provide/inject:适合深层共享
// 主题、国际化、配置等全局性数据
provide('theme', theme);
Q45: activated 和 mounted 哪个先执行?
答案:
- 首次渲染:
mounted→activated - 从缓存恢复:只触发
activated(不会再触发mounted)
// 首次渲染
setup() → onBeforeMount() → onMounted() → onActivated()
// 缓存后再次激活
onActivated() // 只触发这一个
// 进入缓存
onDeactivated()
Q46: v-if 和 v-for 能一起用吗?
答案:
Vue 3 中不推荐一起使用,v-if 优先级高于 v-for。
<!-- ❌ 错误:v-if 无法访问 v-for 的 item -->
<li v-for="item in list" v-if="item.active">
{{ item.name }}
</li>
<!-- ✅ 正确方案1:使用 computed 过滤 -->
<li v-for="item in activeItems">
{{ item.name }}
</li>
<script setup lang="ts">
const activeItems = computed(() =>
list.value.filter(item => item.active)
);
</script>
<!-- ✅ 正确方案2:外层包装 template -->
<template v-for="item in list" :key="item.id">
<li v-if="item.active">{{ item.name }}</li>
</template>
Q47: 为什么不能用随机数作为 key?
答案:
每次渲染都生成新的随机数,导致:
<!-- ❌ 每次渲染 key 都不同 -->
<li v-for="item in list" :key="Math.random()">
{{ item.name }}
</li>
问题:
- 无法复用:每次 key 都是新的,所有节点都被认为是新增
- 性能下降:全部销毁重建,失去 Diff 优化
- 状态丢失:组件状态每次都重置
Q48: Options API 还有存在的必要吗?
答案:
有。两种 API 会长期共存:
- 学习曲线更低:对初学者更友好
- 简单场景够用:小型组件用 Options API 更直观
- 迁移成本:大量 Vue 2 项目仍在使用
- 混用支持:Vue 3 允许在同一组件中混用
// Vue 3 中混用两种 API
export default {
data() {
return { count: 0 };
},
setup() {
const name = ref('Vue');
return { name };
}
};
Q49: defineProps 是什么?为什么不需要 import?
答案:
defineProps 是编译器宏(Compiler Macro),不是运行时函数:
// 编译前
const props = defineProps<{ msg: string }>();
// 编译后
const props = __props; // 直接引用组件的 props
特点:
- 无需导入:编译时被处理,不存在于运行时
- 类型安全:支持 TypeScript 泛型语法
- 编译优化:生成更高效的代码
类似的编译器宏还有:defineEmits、defineExpose、defineOptions、defineSlots、defineModel、withDefaults
Q50: Teleport 的内容属于哪个组件?
答案:
逻辑上仍属于原组件,只是 DOM 渲染位置改变:
<template>
<Teleport to="body">
<!-- 这里的 props 和 events 仍然属于当前组件 -->
<Modal :title="title" @close="handleClose" />
</Teleport>
</template>
<script setup>
// title 和 handleClose 来自当前组件
const title = ref('Modal Title');
function handleClose() { /* ... */ }
</script>
- props/emits:正常工作
- provide/inject:可以跨 Teleport 传递
- 生命周期:与父组件关联
- 样式作用域:scoped CSS 仍然生效
Q51: 路由懒加载是如何实现的?
答案:
// 路由懒加载利用动态 import + Webpack/Vite 代码分割
// 编译前
const routes = [
{ path: '/about', component: () => import('./About.vue') }
];
// Webpack 编译后,About.vue 被打包成单独的 chunk
// 访问 /about 时才下载 about.chunk.js
// Vite 编译后生成
// /assets/About.abc123.js
// 原理:
// 1. () => import() 返回 Promise
// 2. 路由激活时,Vue Router 调用该函数
// 3. 浏览器动态加载对应的 chunk
// 4. 加载完成后渲染组件
配合 Suspense 显示加载状态:
<template>
<RouterView v-slot="{ Component }">
<Suspense>
<component :is="Component" />
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</RouterView>
</template>
Q52: Pinia 如何实现响应式?
答案:
Pinia 基于 Vue 3 的响应式系统:
// defineStore 内部实现(简化)
function defineStore(id, setup) {
return function useStore() {
// 检查是否已创建
if (!pinia._s.has(id)) {
// 创建响应式 store
const store = reactive({});
// 执行 setup 函数
const setupResult = setup();
// 合并到 store
Object.assign(store, setupResult);
pinia._s.set(id, store);
}
return pinia._s.get(id);
};
}
核心是使用 reactive 包装 store 对象。
Q53: Vue 3 编译优化有哪些?
答案:
| 优化 | 说明 | 效果 |
|---|---|---|
| PatchFlags | 标记动态内容类型 | Diff 时精确更新 |
| 静态提升 | 静态节点只创建一次 | 减少内存分配 |
| 事件缓存 | 缓存事件处理函数 | 避免子组件重渲染 |
| Block Tree | 收集动态节点 | Diff 跳过静态节点 |
| 预字符串化 | 连续静态节点合并为字符串 | 减少 VNode 数量 |
Q54: Vue 长列表应该怎样优化?
答案:
先判断瓶颈是一次性 DOM 数量、更新频率、数据计算还是网络加载,再选择方案:
- 数据量极大时使用虚拟列表,只渲染可见区域,并处理动态高度、滚动锚定和可访问性。
- 分页或无限加载减少一次获取的数据,配合取消、缓存和去重。
- 保持 item 的 key 与 Props 稳定,拆分高频更新区域。
- 大型不可变数据可使用
shallowRef,第三方实例可用markRaw,避免无意义深层代理。 - 经测量后再考虑
v-memo,不要把Object.freeze当作 Vue 3 的通用响应式优化开关。
最终用 Vue Devtools 和浏览器 Performance 验证渲染数量、脚本时间和滚动帧率。
Q55: Vue 3 为什么不支持 IE?旧系统应该怎么办?
答案:
Vue 3 的响应式核心依赖 Proxy,而 IE 不支持 Proxy,也无法用 JavaScript 完整 polyfill。Vue 3 还基于现代模块、语法和浏览器平台能力设计,因此官方没有提供 IE 兼容构建。
旧系统的处理原则是:
- 先确认是否真的存在活跃 IE 用户和合规要求,不为假设兼容长期冻结技术栈。
- 必须支持时,将旧应用隔离维护并控制安全风险,不再把已经结束维护的 Vue 2 当作长期安全方案。
- 通过服务端渲染的只读降级页、独立旧版入口或业务下线计划逐步迁移。
- 新功能与现代浏览器版本分离,避免把整个主应用拖回旧工具链。
Q56: Vue SSR 中为什么每个请求都要创建新的应用实例?
答案:
Node.js 服务端是一个长期运行的进程。如果所有请求共用同一个 Vue 实例和 Pinia store,用户 A 修改的状态会被用户 B 看到,造成跨请求状态污染。因此必须在每个请求中调用 createSSRApp() 和 createPinia() 创建全新实例。
Q57: 什么是惰性代理?它有什么好处?
答案:
惰性代理(Lazy Proxy)指的是只有在访问嵌套对象时,才对其进行代理,而不是在初始化时递归代理所有属性。
// Vue 2:初始化时递归所有属性(即使从不访问)
const data = { a: { b: { c: 1 } } };
// 会立即递归处理 a、a.b、a.b.c
// Vue 3:只在访问时才代理
const proxy = reactive({ a: { b: { c: 1 } } });
// 只有访问 proxy.a 时才代理 a 对象
// 只有访问 proxy.a.b 时才代理 b 对象
好处:
- 初始化更快:不需要遍历所有属性
- 内存占用更低:未访问的属性不会被代理
- 按需代理:对大型数据结构尤其有效
Q58: shallowRef 和 shallowReactive 的作用是什么?
答案:
它们只对第一层数据做响应式处理,常用于性能优化:
import { shallowRef, shallowReactive, triggerRef } from 'vue';
// shallowRef:只有 .value 的替换是响应式的
const state = shallowRef({ count: 0 });
state.value.count++; // ❌ 不会触发更新
state.value = { count: 1 }; // ✅ 触发更新
// 手动触发更新
state.value.count++;
triggerRef(state);
// shallowReactive:只有第一层属性是响应式的
const shallow = shallowReactive({
nested: { count: 0 }
});
shallow.nested = { count: 1 }; // ✅ 触发更新
shallow.nested.count++; // ❌ 不会触发更新
使用场景:大型不可变数据结构、第三方库对象。
Q59: 虚拟 DOM 一定比直接操作 DOM 快吗?
答案:
不一定。虚拟 DOM 还需要创建 VNode、执行协调并最终操作真实 DOM;对一个已知节点做一次精准修改,原生 DOM 通常更直接。
它的主要价值是让开发者用声明式状态描述 UI,并由框架统一组织更新、批处理、组件组合和跨平台渲染。Vue 还通过模板编译生成 PatchFlag、静态提升等信息,减少运行时需要比较的内容。
性能应在完整应用的数据流和维护成本下评估。极高频绘图或十万级图元可能更适合 Canvas/WebGL,但普通业务界面不应为了理论上的“少一层”放弃框架状态模型。
Q60: computed 的缓存是如何实现的?
答案:
computed 使用 dirty 标志位实现缓存:
function computed(getter) {
let cachedValue;
let dirty = true; // 脏标志
const effect = new ReactiveEffect(getter, () => {
// 依赖变化时,标记为脏,但不立即计算
dirty = true;
});
return {
get value() {
if (dirty) {
// 脏时才重新计算
cachedValue = effect.run();
dirty = false;
}
return cachedValue;
}
};
}
- 首次访问:
dirty=true,执行 getter,缓存结果,dirty=false - 再次访问:
dirty=false,直接返回缓存 - 依赖变化:
dirty=true,下次访问时重新计算