Vue 面试高频知识点详解
Vue 面试高频知识点详解
Vue 作为最流行的前端框架之一,是前端面试中的必考内容。本文系统整理了 Vue 面试中的高频考点,涵盖 Vue2 与 Vue3 的响应式原理、编译优化、组件通信、状态管理、路由、SSR 以及大量实际项目中的性能优化实战。每道题都遵循「核心答案 → 原理展开 → 代码示例 → 对比表格 → 常见追问 → 加分项」的结构,帮助你在面试中既能说清结论,又能讲透原理。
一、Vue 核心概念
1. Vue 的响应式原理是什么?Vue2 和 Vue3 有什么区别?
核心答案:
Vue 的响应式系统本质是「数据劫持 + 发布订阅」。Vue2 使用 Object.defineProperty 对每个属性做 getter/setter 劫持;Vue3 使用 ES6 Proxy 直接代理整个对象。两者都通过「依赖收集(track)」和「派发更新(trigger)」来驱动视图更新。
Vue2 响应式原理:
Vue2 在初始化时遍历 data 选项中的所有属性,通过 Object.defineProperty 将每个属性转换为 getter/setter。当属性被访问时触发 getter 进行依赖收集,把当前正在计算的 Watcher 存入该属性对应的 Dep(依赖管理器);当属性被修改时触发 setter 派发更新,通知 Dep 中所有 Watcher 重新求值。
// Vue2 响应式核心:defineReactive
function defineReactive(obj, key, val) {
// 每个属性对应一个 Dep,用于收集依赖该属性的 Watcher
const dep = new Dep();
// 递归处理嵌套对象
observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 依赖收集:Dep.target 是当前正在求值的 Watcher
if (Dep.target) {
dep.depend();
}
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
// 新赋的值如果是对象,也要变成响应式
observe(newVal);
// 派发更新:通知所有订阅者
dep.notify();
}
});
}
// Dep:依赖收集器
class Dep {
constructor() {
this.subs = [];
}
depend() {
if (Dep.target) {
this.subs.push(Dep.target);
}
}
notify() {
this.subs.forEach(watcher => watcher.update());
}
}
Dep.target = null;Vue2 处理数组的特殊方式:
由于 Object.defineProperty 无法监听数组索引变化,Vue2 通过「重写数组的 7 个变异方法」来实现数组响应式。
// Vue2 数组响应式:重写原型方法
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
const methodsToPatch = [
'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'
];
methodsToPatch.forEach(method => {
const original = arrayProto[method];
Object.defineProperty(arrayMethods, method, {
value: function mutator(...args) {
const result = original.apply(this, args);
const ob = this.__ob__;
// 新增元素也要变成响应式
let inserted;
if (method === 'push' || method === 'unshift') inserted = args;
else if (method === 'splice') inserted = args.slice(2);
if (inserted) ob.observeArray(inserted);
// 手动派发更新
ob.dep.notify();
return result;
}
});
});Vue2 的局限性:
Vue3 响应式原理:
Vue3 使用 ES6 Proxy 代理整个对象,配合 Reflect 完成读写。Proxy 支持 13 种拦截操作,能天然处理属性新增、删除、数组索引、length 变化。依赖收集通过全局的 WeakMap(targetMap)结构存储:target → key → Set(effect)。
// Vue3 响应式核心:reactive + track + trigger
const targetMap = new WeakMap();
let activeEffect = null;
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
track(target, key); // 依赖收集
const result = Reflect.get(target, key, receiver);
// 懒代理:只有访问到嵌套对象时才递归代理
if (typeof result === 'object' && result !== null) {
return reactive(result);
}
return result;
},
set(target, key, value, receiver) {
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (oldValue !== value) {
trigger(target, key); // 派发更新
}
return result;
},
deleteProperty(target, key) {
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
const result = Reflect.deleteProperty(target, key);
if (hadKey) trigger(target, key);
return result;
}
});
}
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
dep.add(activeEffect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) dep.forEach(effect => effect());
}Vue2 与 Vue3 响应式对比:
| 对比项 | Vue2 (Object.defineProperty) | Vue3 (Proxy) |
|--------|------------------------------|--------------|
| 劫持粒度 | 属性级别,逐个 defineProperty | 对象级别,代理整个对象 |
| 新增属性 | 无法检测,需 Vue.set | 自动检测 |
| 删除属性 | 无法检测,需 Vue.delete | 自动检测(deleteProperty) |
| 数组索引 | 无法检测,需重写方法 | 自动检测 |
| 初始化性能 | 递归遍历所有属性,开销大 | 懒代理,访问才递归,开销小 |
| 数据结构支持 | 仅对象/数组 | Map/Set/WeakMap/WeakSet |
| 依赖存储 | 每个属性一个 Dep | WeakMap 全局存储,可被 GC |
| 兼容性 | IE9+ | 不支持 IE(Proxy 无法 polyfill) |
常见追问:
加分项: 提到 Vue3 的 effect 是响应式副作用的最小单位,computed、watch、render 都是基于 effect 封装;提到 targetMap 用 WeakMap 是为了让 target 对象被回收时依赖也能被 GC,避免内存泄漏。
2. Vue 的生命周期有哪些?各阶段适合做什么?
核心答案:
Vue 生命周期是组件从创建、挂载、更新到销毁的一系列钩子函数。Vue2 用选项式钩子(created、mounted 等),Vue3 Composition API 用 onX 系列函数,且 setup 取代了 beforeCreate 和 created。
Vue2 生命周期详解:
| 钩子 | 触发时机 | 可访问 | 适合做什么 |
|------|----------|--------|-----------|
| beforeCreate | 实例初始化后,data/methods 注入前 | 无 data/methods | 插件初始化 |
| created | 实例创建完成 | data/methods 可用,无 DOM | 发起 API 请求、初始化数据 |
| beforeMount | 挂载前,模板已编译 | 虚拟 DOM,无真实 DOM | 很少使用 |
| mounted | 挂载完成 | 真实 DOM 已渲染 | 操作 DOM、初始化第三方库、绑定事件 |
| beforeUpdate | 数据变化,DOM 更新前 | 旧 DOM | 获取更新前的 DOM 状态 |
| updated | DOM 更新完成 | 新 DOM | 操作更新后的 DOM(慎用,易死循环) |
| beforeDestroy | 实例销毁前 | 实例仍完整 | 清理定时器、解绑事件、取消订阅 |
| destroyed | 实例销毁后 | 实例已拆除 | 收尾工作 |
Vue3 生命周期映射:
| Vue2 (Options) | Vue3 (Composition) |
|----------------|--------------------|
| beforeCreate | setup()(本身即代替) |
| created | setup()(本身即代替) |
| beforeMount | onBeforeMount |
| mounted | onMounted |
| beforeUpdate | onBeforeUpdate |
| updated | onUpdated |
| beforeDestroy | onBeforeUnmount |
| destroyed | onUnmounted |
| activated | onActivated |
| deactivated | onDeactivated |
| errorCaptured | onErrorCaptured |
// Vue3 生命周期使用示例
import {
onBeforeMount, onMounted,
onBeforeUpdate, onUpdated,
onBeforeUnmount, onUnmounted,
ref
} from 'vue';
export default {
setup() {
// setup 相当于 beforeCreate + created
const timer = ref(null);
onBeforeMount(() => {
console.log('挂载前:DOM 还未生成');
});
onMounted(() => {
console.log('挂载后:可以操作真实 DOM');
// 初始化定时器
timer.value = setInterval(() => {
console.log('tick');
}, 1000);
});
onBeforeUpdate(() => {
console.log('更新前:可获取旧 DOM');
});
onUpdated(() => {
console.log('更新后:DOM 已刷新');
});
onBeforeUnmount(() => {
// 清理副作用,防止内存泄漏
clearInterval(timer.value);
});
onUnmounted(() => {
console.log('卸载后:组件已销毁');
});
return {};
}
};父子组件生命周期执行顺序:
// 挂载阶段(由外到内创建,由内到外挂载)
// 父 beforeCreate -> 父 created -> 父 beforeMount
// -> 子 beforeCreate -> 子 created -> 子 beforeMount -> 子 mounted
// -> 父 mounted
// 更新阶段
// 父 beforeUpdate -> 子 beforeUpdate -> 子 updated -> 父 updated
// 销毁阶段
// 父 beforeDestroy -> 子 beforeDestroy -> 子 destroyed -> 父 destroyed常见追问:
加分项: 提到 setup 执行早于所有 Options 钩子;提到 errorCaptured 可以捕获后代组件错误做错误边界;提到 Vue3 的 onRenderTracked / onRenderTriggered 调试钩子可追踪响应式依赖。
3. v-if 和 v-show 的区别是什么?
核心答案:
v-if 是「真正的条件渲染」,条件为假时元素不存在于 DOM;v-show 只是切换 CSS 的 display 属性,元素始终存在于 DOM。v-if 有更高的切换开销,v-show 有更高的初始渲染开销。
| 特性 | v-if | v-show |
|------|------|--------|
| 实现方式 | 条件渲染,false 时元素不在 DOM | 元素始终在 DOM,切换 display |
| 初始渲染开销 | 低(false 时不渲染) | 高(始终渲染) |
| 切换开销 | 高(销毁/重建 DOM 和组件) | 低(仅改 CSS) |
| 生命周期 | 触发组件创建/销毁 | 不触发 |
| 是否支持 template | 支持 | 不支持 |
| 是否支持 v-else | 支持 | 不支持 |
| 适用场景 | 条件很少改变 | 需要频繁切换 |
<template>
<div>
<!-- 频繁切换(如 Tab 切换),使用 v-show 避免反复销毁重建 -->
<div v-show="activeTab === 'home'">首页内容</div>
<!-- 不常切换、且渲染成本高,使用 v-if 节省初始开销 -->
<heavy-chart v-if="showChart" :data="chartData" />
<!-- v-if / v-else-if / v-else 条件链 -->
<div v-if="status === 'loading'">加载中...</div>
<div v-else-if="status === 'error'">加载失败</div>
<div v-else>加载完成</div>
</div>
</template>常见追问:
加分项: 提到 v-show 不支持 template 是因为 template 是虚拟标签,不生成真实 DOM 无法设置 display;生产中大列表频繁切换用 v-show,权限控制、路由级内容用 v-if。
4. 虚拟 DOM 是什么?为什么需要它?
核心答案:
虚拟 DOM(Virtual DOM)是用 JS 对象描述真实 DOM 结构的一棵树。它的价值不在于「比操作真实 DOM 快」,而在于:1)提供跨平台能力(可渲染到 DOM、Canvas、原生等);2)通过 diff 算法批量、最小化地更新真实 DOM,屏蔽手动 DOM 操作的复杂度。
// 虚拟 DOM 就是一个描述节点的 JS 对象(VNode)
const vnode = {
tag: 'div',
props: { id: 'app', class: 'container' },
children: [
{ tag: 'h1', props: {}, children: ['标题'] },
{ tag: 'p', props: {}, children: ['内容'] }
]
};
// 对应真实 DOM
// <div id="app" class="container">
// <h1>标题</h1>
// <p>内容</p>
// </div>渲染流程: 模板 → 编译成 render 函数 → 执行生成 VNode 树 → 首次 mount 转真实 DOM;数据变化时生成新 VNode 树 → 与旧树 diff(patch)→ 只更新变化的真实 DOM。
常见追问:
加分项: 提到 React 的 Fiber 与 Vue 的 Block Tree 是两种不同的优化思路,Vue 依托编译期静态分析(模板受约束)比 React 运行时更容易做优化。
二、组件通信
5. Vue 组件通信有哪些方式?
核心答案:
按组件关系可分为:父子通信(props/emit、ref、$parent/$children、v-model)、跨层级通信(provide/inject、$attrs/$listeners)、全局通信(Vuex/Pinia、EventBus、mitt)。
1. props / emit(父子通信,最常用)
<!-- 父组件 -->
<template>
<ChildComponent
:message="parentMessage"
@child-event="handleEvent"
/>
</template>
<script setup>
import { ref } from 'vue';
import ChildComponent from './ChildComponent.vue';
const parentMessage = ref('来自父组件');
const handleEvent = (payload) => {
console.log('收到子组件事件:', payload);
};
</script>
<!-- 子组件(Vue3 script setup 写法) -->
<script setup>
const props = defineProps({
message: { type: String, default: '' }
});
const emit = defineEmits(['child-event']);
const sendMessage = () => {
emit('child-event', 'Hello from child');
};
</script>2. provide / inject(跨层级通信)
// 祖先组件提供数据(Vue3)
import { provide, ref, readonly } from 'vue';
export default {
setup() {
const theme = ref('light');
const updateTheme = (val) => { theme.value = val; };
// 用 readonly 防止后代直接修改,保持单向数据流
provide('theme', readonly(theme));
provide('updateTheme', updateTheme);
}
};
// 后代组件注入数据
import { inject } from 'vue';
export default {
setup() {
const theme = inject('theme', 'light'); // 第二个参数为默认值
const updateTheme = inject('updateTheme');
return { theme, updateTheme };
}
};3. ref 引用子组件(父调用子的方法/属性)
<script setup>
import { ref, onMounted } from 'vue';
const childRef = ref(null);
onMounted(() => {
// 调用子组件暴露的方法
childRef.value.doSomething();
});
</script>
<template>
<Child ref="childRef" />
</template>
<!-- 子组件需用 defineExpose 暴露 -->
<script setup>
const doSomething = () => console.log('子组件方法被调用');
defineExpose({ doSomething });
</script>4. EventBus / mitt(任意组件通信)
// Vue3 中 $on/$off/$emit 被移除,改用 mitt 库
import mitt from 'mitt';
export const emitter = mitt();
// 组件 A 发送
emitter.emit('custom-event', { data: 123 });
// 组件 B 监听(记得在卸载时 off,避免内存泄漏)
import { onMounted, onUnmounted } from 'vue';
const handler = (payload) => console.log(payload);
onMounted(() => emitter.on('custom-event', handler));
onUnmounted(() => emitter.off('custom-event', handler));5. Pinia(全局状态管理)
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
state: () => ({ user: null, token: '' }),
actions: {
login(userData) { this.user = userData; }
}
});
// 任意组件中使用
const userStore = useUserStore();
userStore.login(userData);通信方式选型对比:
| 方式 | 适用关系 | 数据流向 | 典型场景 |
|------|----------|----------|----------|
| props/emit | 父子 | 双向(props 下,emit 上) | 常规父子传参 |
| v-model | 父子 | 双向 | 表单、双向绑定组件 |
| ref/defineExpose | 父访问子 | 父调用子 | 调用子组件方法 |
| provide/inject | 跨层级 | 祖先到后代 | 主题、国际化、深层配置 |
| $attrs | 跨层级透传 | 从上到下 | 高阶组件透传属性 |
| EventBus/mitt | 任意 | 任意 | 无直接关系的组件 |
| Pinia/Vuex | 全局 | 任意 | 用户信息、购物车等全局状态 |
常见追问:
加分项: 提到 $attrs 在 Vue3 中合并了 $listeners,配合 inheritAttrs: false 可做属性透传;大型项目优先 Pinia,避免滥用 EventBus 造成数据流混乱。
6. props 和 data 有什么区别?父组件能修改子组件 props 吗?
核心答案:
props 是父组件传入的外部数据,遵循单向数据流,子组件不能直接修改;data 是组件私有的内部状态,可自由修改。
| 特性 | props | data |
|------|-------|------|
| 数据来源 | 父组件传递 | 组件内部定义 |
| 修改权限 | 只读(不能直接改) | 可读可写 |
| 响应式 | 是 | 是 |
| 用途 | 接收外部配置 | 管理内部状态 |
| 命名冲突 | props 与 data 不能同名 | - |
<script setup>
import { ref, computed, watch } from 'vue';
const props = defineProps({
title: { type: String, required: true, default: '默认标题' },
count: { type: Number, default: 0 }
});
// 错误:直接修改 props 会警告
// props.count++;
// 正确方式一:用 props 初始化本地 data
const localCount = ref(props.count);
// 正确方式二:用 computed 派生
const displayTitle = computed(() => props.title.toUpperCase());
// 正确方式三:需要修改则 emit 通知父组件
const emit = defineEmits(['update:count']);
const increment = () => emit('update:count', props.count + 1);
</script>常见追问:
加分项: 提到 props 校验的完整写法(type、required、default、validator);对象/数组的 default 必须用工厂函数返回。
三、计算属性与监听器
7. computed 和 watch 的区别是什么?
核心答案:
computed 是「计算属性」,有缓存,依赖不变则不重新计算,用于派生状态;watch 是「侦听器」,无缓存,用于监听数据变化后执行副作用(异步、复杂逻辑)。
| 特性 | computed | watch |
|------|----------|-------|
| 缓存 | 有(依赖不变不重算) | 无 |
| 返回值 | 必须有返回值 | 无返回值 |
| 异步 | 不支持 | 支持 |
| 触发时机 | 惰性求值(被访问才算) | 数据变化立即执行 |
| 多依赖 | 自动收集多个依赖 | 需显式指定 |
| 适用场景 | 派生数据、模板计算 | 副作用、异步请求、DOM 操作 |
<script setup>
import { ref, computed, watch } from 'vue';
const firstName = ref('John');
const lastName = ref('Doe');
const searchKey = ref('');
// computed:有缓存,firstName/lastName 不变则不重新计算
const fullName = computed(() => firstName.value + ' ' + lastName.value);
// 可写 computed
const fullNameWritable = computed({
get: () => firstName.value + ' ' + lastName.value,
set: (val) => {
[firstName.value, lastName.value] = val.split(' ');
}
});
// watch:监听搜索词,做防抖异步请求
let timer = null;
watch(searchKey, (newVal, oldVal) => {
clearTimeout(timer);
timer = setTimeout(async () => {
const res = await fetch('/api/search?q=' + newVal);
const data = await res.json();
console.log(data);
}, 300);
});
// 深度监听对象
const form = ref({ name: '', age: 0 });
watch(form, (val) => {
console.log('表单变化', val);
}, { deep: true, immediate: true });
</script>常见追问:
加分项: 提到 watch 的 flush 选项(pre/post/sync)控制回调相对于组件更新的时机;watch 监听 reactive 对象默认深度监听,监听其某个属性要用 getter 函数。
四、Vue3 新特性与 Composition API
8. Composition API 和 Options API 有什么区别?
核心答案:
Options API 按「选项类型」组织代码(data、methods、computed 分开),同一功能的逻辑分散;Composition API 按「功能逻辑」组织代码,相关逻辑聚合,且便于抽取 composable 复用、TypeScript 类型推导更好。
| 对比项 | Options API | Composition API |
|--------|-------------|-----------------|
| 代码组织 | 按选项分类 | 按功能聚合 |
| 逻辑复用 | mixins(易命名冲突) | composables(清晰) |
| TypeScript | 支持一般 | 支持优秀 |
| this 指向 | 依赖 this | 无 this,更易理解 |
| 学习曲线 | 平缓 | 稍陡 |
| 适用场景 | 中小型、简单组件 | 中大型、逻辑复杂组件 |
<!-- Options API:逻辑按选项分散 -->
<script>
export default {
data() {
return { count: 0, user: null };
},
computed: {
doubleCount() { return this.count * 2; }
},
methods: {
increment() { this.count++; }
},
async mounted() {
this.user = await this.fetchUser();
}
};
</script>
<!-- Composition API:逻辑按功能聚合 -->
<script setup>
import { ref, computed, onMounted } from 'vue';
// 计数功能内聚在一起
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
const increment = () => count.value++;
// 用户功能内聚在一起
const user = ref(null);
onMounted(async () => {
user.value = await fetchUser();
});
</script>逻辑复用对比(composable vs mixin):
// composable:清晰的输入输出,无命名冲突
// useCounter.js
import { ref } from 'vue';
export function useCounter(initial = 0) {
const count = ref(initial);
const increment = () => count.value++;
const decrement = () => count.value--;
return { count, increment, decrement };
}
// 组件中使用,明确知道 count 来自哪里
import { useCounter } from './useCounter';
const { count, increment } = useCounter(10);常见追问:
加分项: 提到两者可混用;提到 Composition API 不是要取代 Options API,官方两者都支持;提到 defineProps/defineEmits/defineExpose 等编译宏只能在 script setup 中用。
9. ref 和 reactive 有什么区别?toRef、toRefs 是做什么的?
核心答案:
ref 用于任意类型(基本类型和对象),通过 .value 访问;reactive 只能用于对象/数组,直接访问属性。ref 底层对对象也是用 reactive 实现。toRefs 用于解构 reactive 对象且保持响应式。
| 特性 | ref | reactive |
|------|-----|----------|
| 适用类型 | 基本类型和对象 | 仅对象/数组/集合 |
| 访问方式 | .value(模板中自动解包) | 直接访问属性 |
| 解构 | 可直接解构(是引用) | 解构会丢失响应式,需 toRefs |
| 整体替换 | 可以(count.value = 新对象) | 不能(会丢失响应式) |
| 底层实现 | RefImpl 类,对象值转 reactive | Proxy |
import { ref, reactive, toRef, toRefs, isRef } from 'vue';
// ref:基本类型
const count = ref(0);
count.value++; // 需要 .value
// reactive:对象
const state = reactive({ count: 0, user: { name: 'Alice' } });
state.count++;
state.user.name = 'Bob';
// 问题:解构 reactive 会丢失响应式
const { count: c } = state; // c 是普通值,不再响应式
// 解决:toRefs 把每个属性转成 ref,保持响应式
const { count: c2, user } = toRefs(state);
c2.value++; // 修改会同步回 state.count
// toRef:单个属性转 ref,且与源保持联动
const countRef = toRef(state, 'count');
countRef.value++; // 会改变 state.countcomposable 返回值为什么常用 toRefs:
function useFeature() {
const state = reactive({ loading: false, data: null });
// 返回时用 toRefs,让调用方可以解构而不丢响应式
return { ...toRefs(state) };
}
// 调用方
const { loading, data } = useFeature(); // 仍是响应式的 ref常见追问:
加分项: 提到 shallowRef/shallowReactive 只做浅层响应,用于大对象/第三方实例优化性能;提到 isRef/unref/isReactive 等工具函数。
10. watch 和 watchEffect 有什么区别?
核心答案:
watch 需要显式指定侦听源,能拿到新旧值,惰性执行(默认不立即执行);watchEffect 自动收集回调内用到的响应式依赖,立即执行一次,拿不到旧值。
| 特性 | watch | watchEffect |
|------|-------|-------------|
| 依赖来源 | 显式指定 | 自动收集回调内依赖 |
| 新旧值 | 能拿到 newVal/oldVal | 拿不到 |
| 立即执行 | 默认否(可配 immediate) | 是(立即执行一次) |
| 惰性 | 是 | 否 |
| 适用场景 | 精确监听特定数据、需要旧值 | 依赖多、不关心旧值的副作用 |
import { ref, watch, watchEffect } from 'vue';
const name = ref('');
const age = ref(0);
// watch:精确监听,能拿到旧值
watch(name, (newVal, oldVal) => {
console.log(`name 从 ${oldVal} 变为 ${newVal}`);
});
// watch 监听多个源
watch([name, age], ([newName, newAge], [oldName, oldAge]) => {
console.log('name 或 age 变化了');
});
// watchEffect:自动收集依赖,立即执行
watchEffect(() => {
// 用到了 name 和 age,两者任一变化都会重新执行
console.log(`当前用户:${name.value},年龄:${age.value}`);
});
// 停止侦听
const stop = watchEffect(() => { /* ... */ });
stop();常见追问:
加分项: 提到 flush: 'post' 让回调在 DOM 更新后执行以便操作最新 DOM;提到 watchPostEffect / watchSyncEffect 便捷 API。
11. nextTick 的原理是什么?为什么需要它?
核心答案:
Vue 的 DOM 更新是异步的。当响应式数据变化时,Vue 不会立即更新 DOM,而是把 watcher 推入队列,在同一事件循环的微任务阶段批量更新(去重、合并)。nextTick 的作用是「在下次 DOM 更新完成后执行回调」,让你能拿到更新后的 DOM。
原理: Vue 内部维护一个异步更新队列。数据变化 → watcher 入队(去重)→ 通过 Promise.then(微任务)在本轮同步代码执行完后 flush 队列,统一更新 DOM。nextTick 的回调被放到同一个微任务队列的末尾,因此能在 DOM 更新后执行。
import { ref, nextTick } from 'vue';
const count = ref(0);
const el = ref(null);
const update = async () => {
count.value = 100;
// 此时 DOM 还未更新,读取的是旧值
console.log(el.value.textContent); // 0
// 方式一:await nextTick
await nextTick();
console.log(el.value.textContent); // 100
// 方式二:回调形式
nextTick(() => {
console.log(el.value.textContent); // 100
});
};异步更新队列简化实现:
const queue = [];
let waiting = false;
const callbacks = [];
function nextTick(cb) {
callbacks.push(cb);
if (!waiting) {
waiting = true;
Promise.resolve().then(flushCallbacks);
}
}
function flushCallbacks() {
waiting = false;
const copies = callbacks.slice();
callbacks.length = 0;
copies.forEach(cb => cb());
}常见追问:
加分项: 提到修改数据后立即操作 DOM(如获取元素高度、聚焦输入框、初始化基于新 DOM 的第三方库)是 nextTick 的典型场景;提到 watch 的 flush: 'post' 本质也是利用了这个机制。
五、渲染机制与编译优化
12. key 的作用是什么?为什么 v-for 不能用 index 作为 key?
核心答案:
key 是 VNode 的唯一标识,diff 算法用它判断新旧节点是否为「同一个节点」,从而决定是复用、移动还是重建。用 index 作 key 在列表顺序变化(增删、排序)时会导致复用错误,引发状态错乱和性能下降。
为什么不能用 index:
当在列表中间插入或删除元素时,后续元素的 index 全部改变,Vue 会认为「这些位置的节点变了」,导致本可以复用的节点被错误复用(比如输入框内容、勾选状态串位),甚至触发不必要的重新渲染。
<template>
<!-- 错误:用 index 作 key,列表增删会导致状态错乱 -->
<div v-for="(item, index) in list" :key="index">
<input v-model="item.value" />
</div>
<!-- 正确:用唯一且稳定的 id -->
<div v-for="item in list" :key="item.id">
<input v-model="item.value" />
</div>
</template>举例说明问题: 列表 [A, B, C],key 用 index 为 0,1,2。在头部插入 X 变成 [X, A, B, C],index 变成 0,1,2,3。diff 时 Vue 认为 key=0 还是那个节点(内容从 A 变 X 就地更新),key=1 从 B 变 A... 最后多渲染一个,且如果元素有内部状态(如输入框、勾选框),状态会错位。用稳定 id 则 Vue 能正确识别 X 是新增,A/B/C 直接复用移动。
常见追问:
加分项: 提到 key 也可用于强制重新渲染组件(改变 key 让 Vue 销毁重建);提到 key 必须唯一且稳定,不能用随机数(每次都变会全部重建)。
13. Vue 的 diff 算法(patch)是怎么工作的?
核心答案:
Vue 的 diff 采用「同层比较 + 双端比较」策略,只在同一层级对比新旧 VNode,不跨层级移动,将 O(n³) 的树 diff 降到 O(n)。Vue2 用双端 diff(新旧列表各设头尾指针,四种命中方式);Vue3 用快速 diff(预处理头尾 + 最长递增子序列求最小移动)。
Vue2 双端 diff 四种命中:
// Vue3 快速 diff 的核心思路(简化)
function patchKeyedChildren(oldChildren, newChildren) {
// 1. 从头部开始比较相同节点
let i = 0;
let e1 = oldChildren.length - 1;
let e2 = newChildren.length - 1;
while (i <= e1 && i <= e2 && isSameVNode(oldChildren[i], newChildren[i])) {
patch(oldChildren[i], newChildren[i]);
i++;
}
// 2. 从尾部开始比较相同节点
while (i <= e1 && i <= e2 && isSameVNode(oldChildren[e1], newChildren[e2])) {
patch(oldChildren[e1], newChildren[e2]);
e1--;
e2--;
}
// 3. 处理新增、删除
// 4. 中间乱序部分:建立 key -> index 映射,
// 计算最长递增子序列(LIS),只移动不在 LIS 中的节点,最小化 DOM 移动
}
function isSameVNode(n1, n2) {
return n1.type === n2.type && n1.key === n2.key;
}Vue2 vs Vue3 diff 对比:
| 对比项 | Vue2 双端 diff | Vue3 快速 diff |
|--------|----------------|----------------|
| 核心策略 | 头尾双指针四种比较 | 头尾预处理 + 最长递增子序列 |
| 最小移动 | 逐个移动 | LIS 求最少移动次数 |
| 静态优化 | 无 | 配合 Block Tree 跳过静态节点 |
| 性能 | 较好 | 更优 |
常见追问:
加分项: 提到 Vue3 配合编译期的 Block 和 PatchFlag,diff 时只需遍历动态节点数组(dynamicChildren),跳过静态子树,进一步提速。
14. Vue3 编译优化有哪些?(静态提升、PatchFlag、Block Tree)
核心答案:
Vue3 借助模板可静态分析的特性,在编译期做了大量优化:静态提升(hoistStatic)、补丁标记(PatchFlag)、Block Tree、事件缓存(cacheHandlers)、静态节点树内联(tree flattening),使运行时只处理动态内容。
1. 静态提升(Static Hoisting)
把不会变化的静态节点提升到 render 函数外,只创建一次,后续渲染复用。
// 编译前模板
// <div>
// <span>静态文本</span>
// <span>{{ dynamic }}</span>
// </div>
// 编译后(简化):静态节点被提升到外部只创建一次
const _hoisted_1 = createVNode('span', null, '静态文本');
function render() {
return createVNode('div', null, [
_hoisted_1, // 复用提升的静态节点
createVNode('span', null, dynamic, 1 /* TEXT PatchFlag */)
]);
}2. PatchFlag(补丁标记)
编译时给动态节点打标记,标明「哪部分是动态的」(文本/class/style/props),diff 时只比对被标记的部分。
// PatchFlag 枚举(部分)
const PatchFlags = {
TEXT: 1, // 动态文本
CLASS: 2, // 动态 class
STYLE: 4, // 动态 style
PROPS: 8, // 动态 props
FULL_PROPS: 16, // 有动态 key 的 props
HYDRATE_EVENTS: 32,
STABLE_FRAGMENT: 64
};
// createVNode('div', { class: cls }, text, 3 /* TEXT | CLASS */)
// 表示这个节点只有 text 和 class 是动态的,其他跳过比对3. Block Tree
把动态节点收集到一个扁平数组 dynamicChildren,diff 时直接遍历这个数组而非递归整棵树,跳过所有静态子树。
4. 事件监听缓存(cacheHandlers)
内联事件处理函数会被缓存,避免每次渲染生成新函数导致子组件不必要更新。
优化效果对比:
| 优化项 | 作用 | 收益 |
|--------|------|------|
| 静态提升 | 静态节点只创建一次 | 减少 VNode 创建开销 |
| PatchFlag | 标记动态部分 | diff 只比对动态属性 |
| Block Tree | 收集动态节点扁平化 | 跳过静态子树遍历 |
| 事件缓存 | 缓存内联函数 | 避免子组件误更新 |
常见追问:
加分项: 提到 Vue3 SSR 还有 template 直接编译成字符串拼接的优化;提到 vue-vine、响应性语法糖等编译期方案思路一致。
六、模板语法进阶
15. v-model 的原理是什么?Vue2 的 .sync 和 Vue3 的多 v-model 有什么区别?
核心答案:
v-model 是「value 属性绑定 + input 事件监听」的语法糖,用于双向绑定。Vue2 组件默认用 value + input,一个组件只有一个 v-model;.sync 用于多个双向绑定。Vue3 统一为 modelValue + update:modelValue,支持一个组件多个 v-model,.sync 被移除。
<!-- 原生元素上 v-model 等价于 -->
<input v-model="text" />
<!-- 等价于 -->
<input :value="text" @input="text = $event.target.value" />
<!-- Vue3 组件上 v-model 等价于 -->
<CustomInput v-model="text" />
<!-- 等价于 -->
<CustomInput
:modelValue="text"
@update:modelValue="text = $event"
/><!-- Vue3 支持多个 v-model(带参数) -->
<UserForm
v-model:name="userName"
v-model:age="userAge"
/>
<!-- 子组件实现 -->
<script setup>
defineProps(['name', 'age']);
const emit = defineEmits(['update:name', 'update:age']);
const updateName = (val) => emit('update:name', val);
const updateAge = (val) => emit('update:age', val);
</script><!-- Vue2 的 .sync 写法(Vue3 已移除) -->
<CustomComp :title.sync="pageTitle" />
<!-- 等价于 -->
<CustomComp
:title="pageTitle"
@update:title="pageTitle = $event"
/>Vue2 与 Vue3 的 v-model 对比:
| 对比项 | Vue2 | Vue3 |
|--------|------|------|
| 默认 prop | value | modelValue |
| 默认事件 | input | update:modelValue |
| 多个绑定 | 用 .sync | 用 v-model:xxx |
| 自定义 prop 名 | model 选项 | v-model:参数 |
| .sync | 存在 | 移除 |
常见追问:
加分项: 提到 Vue3.4+ 引入 defineModel() 宏,进一步简化组件双向绑定的书写。
16. slot 插槽是什么?作用域插槽如何使用?
核心答案:
插槽是组件内容分发机制,让父组件向子组件「插入模板内容」。分为默认插槽、具名插槽和作用域插槽。作用域插槽的关键是「子组件把内部数据传给父组件的插槽模板」,实现数据向上暴露、渲染由父控制。
<!-- 子组件:定义插槽 -->
<template>
<div class="card">
<header>
<slot name="header">默认标题</slot>
</header>
<main>
<slot>默认内容</slot>
</main>
<!-- 作用域插槽:把 item 数据暴露给父组件 -->
<ul>
<li v-for="item in list" :key="item.id">
<slot name="item" :item="item" :index="item.id">
{{ item.name }}
</slot>
</li>
</ul>
</div>
</template>
<!-- 父组件:填充插槽 -->
<template>
<Card>
<!-- 具名插槽 -->
<template #header>
<h2>自定义标题</h2>
</template>
<!-- 默认插槽 -->
<p>这是主体内容</p>
<!-- 作用域插槽:接收子组件传出的 item -->
<template #item="{ item, index }">
<span>{{ index }} - {{ item.name }}(自定义渲染)</span>
</template>
</Card>
</template>插槽类型对比:
| 类型 | 用途 | 语法 |
|------|------|------|
| 默认插槽 | 分发主体内容 | slot / template #default |
| 具名插槽 | 分发到指定位置 | slot name / template #name |
| 作用域插槽 | 子暴露数据给父渲染 | slot :data / template #x="{ data }" |
常见追问:
加分项: 提到 Vue2.6+ 用 v-slot 统一了 slot 和 slot-scope 语法;提到动态插槽名 #[dynamicName];提到 renderless component(无渲染组件)模式就是靠作用域插槽实现逻辑与视图分离。
七、组件缓存与复用
17. keep-alive 的作用和原理是什么?activated 和 deactivated 什么时候触发?
核心答案:
keep-alive 是内置抽象组件,用于缓存组件实例,避免重复创建销毁,保留组件状态(如表单输入、滚动位置)。被缓存的组件不会走 mounted/unmounted,而是走 activated(激活)/deactivated(失活)钩子。
原理: keep-alive 内部维护一个缓存对象(cache)和 key 列表(keys)。首次渲染时把组件 VNode 的实例缓存起来,再次渲染时直接从缓存取出实例复用,配合 LRU 策略(max 属性)控制缓存数量。
<template>
<!-- 缓存所有切换的组件 -->
<keep-alive :max="10">
<component :is="currentComponent" />
</keep-alive>
<!-- include/exclude 控制哪些组件被缓存 -->
<keep-alive :include="['UserList', 'OrderList']" :exclude="['Detail']">
<router-view />
</keep-alive>
</template>
<script setup>
import { onActivated, onDeactivated } from 'vue';
// 被 keep-alive 缓存的组件用这两个钩子
onActivated(() => {
console.log('组件被激活,从缓存进入');
// 适合刷新数据、恢复定时器
});
onDeactivated(() => {
console.log('组件失活,进入缓存');
// 适合暂停定时器、保存状态
});
</script>缓存钩子触发时机对比:
| 场景 | 普通组件 | keep-alive 缓存组件 |
|------|----------|---------------------|
| 首次进入 | mounted | mounted + activated |
| 再次进入 | mounted(重新创建) | activated(复用实例) |
| 离开 | unmounted | deactivated(不销毁) |
常见追问:
加分项: 提到 keep-alive 配合路由缓存列表页、详情页返回不丢状态的实战场景;提到 max 用 LRU 淘汰最久未使用的缓存;提到 keep-alive 是抽象组件,不会渲染成真实 DOM 节点。
18. mixin 和 composable(自定义 hook)有什么区别?
核心答案:
两者都用于逻辑复用。mixin 是 Options API 的复用方式,通过合并选项注入,存在命名冲突、来源不清晰、隐式依赖等问题;composable 是 Composition API 的复用方式,是普通函数,输入输出明确、无命名冲突、可组合。
| 对比项 | mixin | composable |
|--------|-------|------------|
| 本质 | 选项对象合并 | 普通函数 |
| 命名冲突 | 有(多 mixin 同名覆盖) | 无(解构可重命名) |
| 数据来源 | 隐式,不清楚来自哪个 mixin | 显式,一眼看出来源 |
| 类型推导 | 差 | 好 |
| 可组合性 | 差 | 强(函数可互相调用) |
| 传参 | 不支持 | 支持 |
// mixin 写法(问题:来源不清、易冲突)
const counterMixin = {
data() { return { count: 0 }; },
methods: { increment() { this.count++; } }
};
export default {
mixins: [counterMixin],
// 模板里用 count,但不知道它来自 mixin
};
// composable 写法(推荐)
import { ref } from 'vue';
export function useCounter(initial = 0) {
const count = ref(initial);
const increment = () => count.value++;
return { count, increment };
}
// 使用:清楚知道 count 来自 useCounter,可重命名避免冲突
const { count: pageCount, increment } = useCounter(10);常见追问:
加分项: 提到 composable 内可以使用生命周期钩子(onMounted 等)并自动绑定到调用它的组件;提到复杂逻辑(请求、鼠标位置、防抖节流)都适合抽成 composable,VueUse 就是 composable 集合。
八、状态管理
19. Vuex 和 Pinia 有什么区别?为什么 Vue3 推荐 Pinia?
核心答案:
Pinia 是新一代官方状态管理库,去掉了 Vuex 的 mutations,API 更简洁,TypeScript 支持更好,天然模块化,体积更小(约 1KB)。Vuex 的严格「state → mutations → actions」流程在实践中显得繁琐。
| 特性 | Vuex | Pinia |
|------|------|-------|
| 官方推荐 | Vue2 时代 | Vue3 时代 |
| mutations | 必须(同步改 state) | 移除(action 直接改) |
| 模块化 | modules,嵌套繁琐 | 天然扁平多 store |
| TypeScript | 支持一般(需大量样板) | 支持优秀(自动推导) |
| 体积 | 较大 | 约 1KB |
| DevTools | 支持 | 支持(含时间旅行) |
| 组合式风格 | 弱 | 强(setup store) |
// Vuex 写法:state -> mutations -> actions 三段式
import { createStore } from 'vuex';
export default createStore({
state: { count: 0 },
getters: { doubleCount: (state) => state.count * 2 },
mutations: { increment(state, n) { state.count += n; } },
actions: {
async incrementAsync({ commit }, n) {
await someApi();
commit('increment', n); // 必须通过 mutation 改
}
}
});
// Pinia 写法(Options Store):无 mutations
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: { doubleCount: (state) => state.count * 2 },
actions: {
increment(n) { this.count += n; }, // 直接改
async incrementAsync(n) {
await someApi();
this.count += n;
}
}
});
// Pinia 写法(Setup Store):像写 composable
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCounterStore2 = defineStore('counter2', () => {
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
const increment = (n) => { count.value += n; };
return { count, doubleCount, increment };
});// 组件中使用 Pinia,注意解构要用 storeToRefs 保持响应式
import { storeToRefs } from 'pinia';
import { useCounterStore } from './stores/counter';
const store = useCounterStore();
// state 和 getters 解构需 storeToRefs,action 可直接解构
const { count, doubleCount } = storeToRefs(store);
const { increment } = store;
increment(5);常见追问:
加分项: 提到 Pinia 支持多 store 互相调用(在一个 store 里 use 另一个 store);提到 Pinia 的 \$patch 批量修改、\$reset 重置、\$subscribe 订阅变化、\$onAction 拦截 action。
九、Vue Router
20. Vue Router 有哪些导航守卫?执行顺序是怎样的?
核心答案:
导航守卫分三类:全局守卫(beforeEach、beforeResolve、afterEach)、路由独享守卫(beforeEnter)、组件内守卫(beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave)。它们按固定顺序串行执行,常用于登录鉴权、权限控制、离开确认。
完整导航解析流程:
// 全局前置守卫:登录鉴权
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
if (to.meta.requiresAuth && !token) {
next('/login'); // 重定向到登录
} else {
next(); // 放行
}
});
// Vue Router 4 支持返回值代替 next
router.beforeEach((to, from) => {
const token = localStorage.getItem('token');
if (to.meta.requiresAuth && !token) {
return '/login'; // 返回路径即重定向
}
// 返回 false 取消导航,返回 true / undefined 放行
});
// 路由独享守卫
const routes = [{
path: '/admin',
component: Admin,
beforeEnter: (to, from) => {
return checkAdminRole();
}
}];
// 组件内守卫:离开确认
export default {
beforeRouteLeave(to, from, next) {
if (this.hasUnsavedChanges) {
const answer = window.confirm('有未保存的修改,确定离开吗?');
next(answer);
} else {
next();
}
}
};
// Composition API 中的组件内守卫
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router';
onBeforeRouteLeave((to, from) => {
return window.confirm('确定离开吗?');
});常见追问:
加分项: 提到 next 只能调用一次,否则报错;提到守卫里做异步鉴权要注意避免死循环(如未登录跳 login,login 页本身要放行)。
21. 路由的两种模式(hash 和 history)有什么区别?动态路由和懒加载怎么实现?
核心答案:
hash 模式 URL 带 #,基于 location.hash 变化,不刷新页面,无需服务端配置,兼容性好;history 模式 URL 干净,基于 History API(pushState/replaceState),需要服务端配置 fallback 到 index.html,否则刷新 404。
| 对比项 | hash 模式 | history 模式 |
|--------|-----------|--------------|
| URL 形式 | example.com/#/user | example.com/user |
| 原理 | location.hash + hashchange | history.pushState |
| 服务端配置 | 不需要 | 需要 fallback 配置 |
| SEO | 较差 | 较好 |
| 刷新 404 | 不会 | 未配置会 404 |
| 兼容性 | 好(含老浏览器) | 需 HTML5 |
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
// hash 模式
const router = createRouter({
history: createWebHashHistory(),
routes
});
// history 模式
const router2 = createRouter({
history: createWebHistory(),
routes
});// 路由懒加载:动态 import,webpack/vite 自动分包
const routes = [
{
path: '/user/:id', // 动态路由参数
name: 'User',
// 懒加载:访问时才加载该组件的 chunk
component: () => import('./views/User.vue'),
meta: { requiresAuth: true }
},
{
path: '/user/:id/posts/:postId?', // 可选参数用 ?
component: () => import('./views/UserPost.vue')
}
];
// 组件内获取动态参数
import { useRoute } from 'vue-router';
const route = useRoute();
console.log(route.params.id);// 动态添加路由(常用于权限路由,登录后按角色注入)
const asyncRoutes = getRoutesByRole(userRole);
asyncRoutes.forEach(route => {
router.addRoute(route);
});常见追问:
加分项: 提到路由懒加载配合魔法注释 webpackChunkName 命名 chunk;提到大型后台系统的「动态路由 + 菜单权限」方案(后端返回权限,前端 addRoute)。
十、服务端渲染与工程化
22. 什么是 SSR?和 CSR 有什么区别?Nuxt 解决了什么问题?
核心答案:
SSR(服务端渲染)在服务器把 Vue 组件渲染成 HTML 字符串返回给浏览器,首屏直接可见并利于 SEO;CSR(客户端渲染)返回空 HTML + JS,由浏览器执行 JS 渲染。SSR 首屏快、SEO 好,但服务器压力大、开发复杂。Nuxt 是 Vue 的 SSR/全栈框架,封装了 SSR 的复杂配置。
| 对比项 | CSR(客户端渲染) | SSR(服务端渲染) |
|--------|-------------------|-------------------|
| 首屏速度 | 慢(需下载执行 JS) | 快(直接返回 HTML) |
| SEO | 差(爬虫看到空页面) | 好(HTML 有内容) |
| 服务器压力 | 小(只发静态资源) | 大(每次渲染消耗 CPU) |
| 开发复杂度 | 低 | 高(同构、水合) |
| TTFB | 快 | 稍慢(服务端渲染耗时) |
| 可交互时间 | 需水合后 | 需水合后 |
// SSR 核心流程(Vue3)
import { createSSRApp } from 'vue';
import { renderToString } from 'vue/server-renderer';
// 服务端:把应用渲染成 HTML 字符串
const app = createSSRApp(App);
const html = await renderToString(app);
// 把 html 塞进模板返回给浏览器
// 客户端:水合(hydration),复用服务端 DOM 并接管交互
import { createSSRApp } from 'vue';
const clientApp = createSSRApp(App);
clientApp.mount('#app'); // hydrate 而非重新渲染Nuxt 解决的问题: 约定式路由(pages 目录自动生成路由)、自动 SSR/SSG 配置、数据预取(useAsyncData/useFetch)、SEO 元信息管理、混合渲染(每个路由可选 SSR/SSG/CSR/ISR)、服务端 API 路由等。
常见追问:
加分项: 提到 SSR 中要避免使用 window/document(服务端没有),生命周期只有 beforeCreate/created 在服务端执行;提到用 SSG 处理内容型站点(博客、文档)性价比最高。
23. Teleport 和 Suspense 是什么?解决了什么问题?
核心答案:
Teleport 用于把组件的 DOM 渲染到指定的 DOM 节点(如 body),解决模态框、弹层被父级 overflow/z-index 限制的问题,同时逻辑仍留在组件树内。Suspense 用于协调异步组件的加载状态,在异步依赖 resolve 前显示 fallback 内容。
<!-- Teleport:把模态框渲染到 body,避免被父级样式影响 -->
<template>
<div class="component">
<button @click="open = true">打开弹窗</button>
<Teleport to="body">
<div v-if="open" class="modal">
<p>我被渲染到了 body 下</p>
<button @click="open = false">关闭</button>
</div>
</Teleport>
</div>
</template><!-- Suspense:处理异步组件加载状态 -->
<template>
<Suspense>
<!-- 默认插槽:异步组件 -->
<template #default>
<AsyncUserProfile />
</template>
<!-- fallback 插槽:加载中显示 -->
<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</template>
<script setup>
// 异步组件:setup 中用 await(顶层 await)
// AsyncUserProfile.vue
// <script setup>
// const user = await fetchUser(); // 顶层 await
// </script>
</script>常见追问:
加分项: 提到 Teleport 常用于全局 Toast、Dialog、Tooltip;提到 Suspense 配合 async setup 和 defineAsyncComponent 使用,可搭配 onErrorCaptured 处理加载失败。
十一、性能优化实战
24. Vue 项目性能优化有哪些手段?请结合实际项目说明。
核心答案:
性能优化分为:加载优化(减小首屏包体积)、渲染优化(减少不必要的渲染)、响应式优化(减少响应式开销)、运行时优化(虚拟列表、防抖节流)。
1. 加载优化
// 路由懒加载 + 组件懒加载
const Home = () => import('./views/Home.vue');
import { defineAsyncComponent } from 'vue';
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'));
// 第三方库按需引入(如 lodash-es、element-plus 按需)
import { debounce } from 'lodash-es'; // 而非 import _ from 'lodash'2. 渲染优化
<template>
<!-- v-for 用稳定 key -->
<div v-for="item in list" :key="item.id">{{ item.name }}</div>
<!-- 大数据展示用 v-show 复用 DOM,或用虚拟列表 -->
<!-- 用 v-once 渲染一次不再更新 -->
<div v-once>{{ staticConfig }}</div>
<!-- v-memo 缓存子树,依赖不变则跳过更新(Vue3.2+) -->
<div v-for="item in list" :key="item.id" v-memo="[item.selected]">
{{ item.name }}
</div>
</template>3. 响应式优化
import { shallowRef, shallowReactive, markRaw } from 'vue';
// 大列表/大对象只需引用变化时用 shallowRef,避免深层代理开销
const largeList = shallowRef([]);
largeList.value = newData; // 整体替换才触发更新
// 第三方实例(地图、图表)用 markRaw 标记,不做响应式
const chart = markRaw(new EchartsInstance());
// Vue2 中用 Object.freeze 冻结不变的大数据
const frozenData = Object.freeze(bigArray);4. 虚拟列表(长列表核心优化)
// 只渲染可视区域的列表项,几万条数据只渲染几十个 DOM
// 核心:根据滚动位置计算 startIndex / endIndex,只渲染这个区间
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleCount;
const visibleData = allData.slice(startIndex, endIndex);
// 用 padding 或 transform 撑起总高度,模拟滚动条
// 生产可直接用 vue-virtual-scroller / vueuc VVirtualList性能优化手段汇总:
| 分类 | 手段 | 收益 |
|------|------|------|
| 加载 | 路由/组件懒加载 | 减小首屏包 |
| 加载 | 第三方库按需引入 | Tree Shaking |
| 加载 | gzip/brotli 压缩、CDN | 减小传输体积 |
| 渲染 | 稳定 key、避免 v-for+v-if | 减少无效 diff |
| 渲染 | v-once / v-memo | 跳过静态子树更新 |
| 渲染 | keep-alive 缓存组件 | 避免重复创建 |
| 响应式 | shallowRef / markRaw | 减少代理开销 |
| 运行时 | 虚拟列表 | 长列表只渲染可视区 |
| 运行时 | 防抖节流 | 减少高频回调 |
常见追问:
加分项: 提到用 requestAnimationFrame 优化动画;提到图片懒加载、骨架屏提升体验;提到 Vue3 的编译优化本身已经减少了大量运行时开销。
25. 组件设计有哪些原则?如何设计一个可复用、可维护的组件?
核心答案:
好的组件应遵循:单一职责、高内聚低耦合、props 向下事件向上(单向数据流)、受控与非受控清晰、良好的默认值与校验、可扩展(插槽)、可测试。
设计原则清单:
| 原则 | 说明 |
|------|------|
| 单一职责 | 一个组件只做一件事,过大就拆分 |
| 单向数据流 | props 传入、emit 传出,不直接改 props |
| 受控设计 | 状态由父控制(v-model),组件更纯粹 |
| 插槽扩展 | 用插槽让使用方自定义内容,避免过多 props |
| 合理默认值 | props 提供默认值和校验,降低使用成本 |
| 逻辑抽离 | 复杂逻辑抽成 composable,视图与逻辑分离 |
| 命名规范 | 组件多单词命名,props 用 camelCase |
<!-- 设计良好的按钮组件示例 -->
<script setup>
defineProps({
type: {
type: String,
default: 'default',
// 校验:限定取值范围
validator: (val) => ['default', 'primary', 'danger'].includes(val)
},
size: { type: String, default: 'medium' },
loading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
});
const emit = defineEmits(['click']);
const handleClick = (e) => emit('click', e);
</script>
<template>
<button
:class="['btn', `btn-${type}`, `btn-${size}`]"
:disabled="disabled || loading"
@click="handleClick"
>
<span v-if="loading" class="spinner"></span>
<!-- 用插槽让使用方自定义内容 -->
<slot>按钮</slot>
</button>
</template>常见追问:
加分项: 提到「容器组件 vs 展示组件」分层思想;提到用 TypeScript 定义 props 类型(defineProps
26. Vue 的事件循环与异步更新机制是怎样的?
核心答案:
Vue 响应式数据变化后不会同步更新 DOM,而是把 watcher 加入队列,去重后在当前宏任务结束、微任务阶段统一 flush 更新。这基于 JS 的事件循环(同步任务 → 微任务 → 渲染 → 下一个宏任务)。
import { ref, nextTick } from 'vue';
const count = ref(0);
// 同一 tick 内多次修改,只触发一次 DOM 更新
function batchUpdate() {
count.value = 1;
count.value = 2;
count.value = 3;
// watcher 去重合并,最终只用 count=3 渲染一次
}
// 事件循环顺序演示
console.log('1 同步'); // 1
Promise.resolve().then(() => console.log('3 微任务')); // 3
setTimeout(() => console.log('4 宏任务'), 0); // 4
console.log('2 同步'); // 2
// 输出顺序:1 -> 2 -> 3 -> 4
// Vue 的 DOM 更新在微任务阶段,nextTick 回调也在微任务
async function demo() {
count.value = 100;
// 同步代码里 DOM 还是旧的
await nextTick(); // 等微任务 flush 完
// 现在 DOM 已更新
}常见追问:
加分项: 提到 Vue2 有 nextTick 的降级策略(Promise → MutationObserver → setImmediate → setTimeout),Vue3 统一用 Promise.then;提到理解事件循环有助于排查「改了数据 DOM 没变」类问题。
十二、综合与原理
27. Vue3 相比 Vue2 有哪些重大升级?
核心答案:
Vue3 在响应式、性能、体积、TypeScript、新特性等方面全面升级。
| 维度 | Vue2 | Vue3 |
|------|------|------|
| 响应式 | Object.defineProperty | Proxy |
| API 风格 | Options API | Composition API + Options API |
| 性能 | 无编译优化 | 静态提升/PatchFlag/Block Tree |
| 体积 | 较大 | 更小(Tree Shaking 友好) |
| TypeScript | 支持一般 | 源码 TS 重写,类型优秀 |
| 多根节点 | 不支持(需单根) | 支持 Fragment 多根 |
| 新组件 | 无 | Teleport、Suspense |
| 状态管理 | Vuex | Pinia |
| 生命周期 | 选项钩子 | onX 组合式钩子 |
常见追问:
加分项: 提到 Vue2 已于 2023 年底停止维护(EOL),新项目应直接用 Vue3;提到 Vue3 的 Tree Shaking 让未使用的功能(如 transition、keep-alive)不打包,减小体积。
28. Vue 中 this.\$set / Vue.set 是做什么的?Vue3 还需要吗?
核心答案:
Vue2 中由于 Object.defineProperty 的限制,给对象新增属性或通过索引修改数组不会触发响应式,需用 Vue.set / this.\$set 手动触发。Vue3 用 Proxy,天然支持,不再需要 \$set。
// Vue2:新增属性不响应,需 $set
export default {
data() {
return { user: { name: 'Alice' } };
},
methods: {
addAge() {
// 错误:不会触发视图更新
this.user.age = 18;
// 正确:用 $set
this.\$set(this.user, 'age', 18);
// 数组索引修改也要用 $set
this.\$set(this.list, 0, newValue);
}
}
};
// Vue3:直接赋值即可,Proxy 自动响应
import { reactive } from 'vue';
const user = reactive({ name: 'Alice' });
user.age = 18; // 自动触发更新,无需 $set常见追问:
加分项: 提到这是 Vue2 响应式的经典坑,Vue3 用 Proxy 从根本上解决;提到 Vue.delete 同理用于删除属性触发更新。
二、Vue3 编译优化深度追问
1. Vue3 相比 Vue2 在编译层面做了哪些优化?
核心答案: Vue3 引入了基于编译时静态分析的优化,主要包括静态提升(hoistStatic)、补丁标记(PatchFlag)、Block Tree(区块树)、事件缓存(cacheHandlers),把大量运行时的 diff 工作提前到编译期确定,从而只对"会变化"的节点做 diff。
原理展开:
<!-- 模板 -->
<div class="static">
<span>纯静态文本</span>
<p>{{ message }}</p>
</div>// 编译后(简化示意)
const _hoisted_1 = /*#__PURE__*/ createElementVNode("span", null, "纯静态文本", -1);
// span 被静态提升,最后一个参数 -1 表示永不更新
function render(_ctx) {
return (openBlock(), createElementBlock("div", { class: "static" }, [
_hoisted_1,
createElementVNode("p", null, toDisplayString(_ctx.message), 1 /* TEXT */)
// PatchFlag 1 表示只有文本会变
]))
}对比表格:
| 优化项 | Vue2 | Vue3 |
|--------|------|------|
| 静态节点处理 | 每次 diff 都遍历 | 提升后只创建一次 |
| 动态节点识别 | 全量 diff | PatchFlag 精准定位 |
| diff 范围 | 整棵 VNode 树 | 仅 Block 的 dynamicChildren |
| 事件函数 | 每次重建 | cacheHandlers 缓存 |
常见追问:
加分项: 提到这些优化让 Vue3 的更新性能相比 Vue2 提升约 1.3~2 倍,SSR 渲染速度提升约 2~3 倍;提到 Vue3 的 Tree-shaking 友好设计(按需引入 API)减小打包体积。
2. Vue 的虚拟 DOM diff 算法是怎么实现的?
核心答案: Vue 采用同层比较 + 双端 diff(Vue2)/ 快速 diff(Vue3)的策略。只比较同一层级的节点,不跨层移动;通过 key 判断节点是否可复用;对子节点列表使用双端指针或最长递增子序列算法最小化 DOM 操作。
原理展开(Vue3 快速 diff):
// 求最长递增子序列(Vue3 源码简化版)
function getSequence(arr) {
const result = [0];
const p = arr.slice();
for (let i = 1; i < arr.length; i++) {
if (arr[i] === 0) continue;
const last = result[result.length - 1];
if (arr[i] > arr[last]) {
p[i] = last;
result.push(i);
continue;
}
// 二分查找替换
let lo = 0, hi = result.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[result[mid]] < arr[i]) lo = mid + 1;
else hi = mid;
}
if (arr[i] < arr[result[lo]]) {
if (lo > 0) p[i] = result[lo - 1];
result[lo] = i;
}
}
// 回溯修正
let u = result.length, v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}常见追问:
加分项: 提到 LIS 的作用是找出"不需要移动的最长子序列",从而最小化 DOM 移动;提到 Vue2 用双端 diff(新旧头尾四个指针交叉比较)。
三、状态管理与工程化追问
3. Pinia 相比 Vuex 有哪些优势?
核心答案: Pinia 是 Vue 官方推荐的新一代状态管理库,相比 Vuex 去掉了 mutations,支持完整的 TypeScript 类型推导,天然支持组合式 API,没有嵌套模块的心智负担,且体积更小(约 1KB)。
代码示例:
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
// 组合式写法,像写 composable 一样定义 store
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const double = computed(() => count.value * 2);
function increment() {
count.value++;
}
return { count, double, increment };
});<script setup>
import { storeToRefs } from 'pinia';
import { useCounterStore } from '@/stores/counter';
const store = useCounterStore();
// 解构时用 storeToRefs 保持响应式,方法可以直接解构
const { count, double } = storeToRefs(store);
const { increment } = store;
</script>对比表格:
| 特性 | Vuex 4 | Pinia |
|------|--------|-------|
| mutations | 必须 | 无,直接改 state |
| TypeScript | 支持弱 | 完整类型推导 |
| 模块化 | 嵌套 modules | 扁平多 store |
| 组合式 API | 不友好 | 原生支持 |
| 体积 | 约 10KB | 约 1KB |
| Devtools | 支持 | 支持 |
常见追问:
加分项: 提到 Pinia 支持插件系统、SSR、热更新;提到多个 store 之间可以互相调用组合。
4. Vite 为什么比 Webpack 快?
核心答案: Vite 在开发环境利用浏览器原生 ES Module,不打包直接按需编译;使用 esbuild(Go 编写)做依赖预构建,比基于 JS 的 Webpack 快 10~100 倍;生产环境用 Rollup 打包。
原理展开:
对比表格:
| 维度 | Webpack | Vite |
|------|---------|------|
| 开发启动 | 秒~分钟级(随规模增长) | 恒定秒级 |
| HMR 速度 | 随模块数变慢 | 恒定快速 |
| 转译工具 | babel/ts-loader(JS) | esbuild(Go) |
| 生产打包 | Webpack | Rollup |
| 生态成熟度 | 极成熟 | 快速成熟中 |
常见追问:
加分项: 提到 Vite 首次访问会有依赖预构建耗时,之后走强缓存;提到 esbuild 不做类型检查,需配合 vue-tsc 单独校验类型。
四、手写实现类高频题
5. 手写一个简易的响应式系统
// 依赖收集容器
let activeEffect = null;
const targetMap = new WeakMap();
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
dep.add(activeEffect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) dep.forEach((effect) => effect());
}
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key);
return result;
}
});
}
function effect(fn) {
activeEffect = fn;
fn(); // 首次执行触发依赖收集
activeEffect = null;
}
// 使用
const state = reactive({ count: 0 });
effect(() => console.log('count 变了:', state.count));
state.count++; // 输出:count 变了:1这就是 Vue3 响应式的最小内核:Proxy 拦截读写,读时收集依赖(track),写时派发更新(trigger)。
6. 手写 nextTick
const callbacks = [];
let pending = false;
function flushCallbacks() {
pending = false;
const copies = callbacks.slice();
callbacks.length = 0;
copies.forEach((cb) => cb());
}
function nextTick(cb) {
callbacks.push(cb);
if (!pending) {
pending = true;
// 优先用微任务 Promise,回退到 setTimeout
Promise.resolve().then(flushCallbacks);
}
}核心考点: Vue 的更新是异步批量的,同一个 tick 内多次修改数据只会触发一次渲染;nextTick 把回调放进微任务队列,在 DOM 更新后执行,因此能拿到更新后的 DOM。
7. 手写虚拟列表核心逻辑
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
items: Array, // 全量数据,如 10 万条
itemHeight: Number, // 每项固定高度,如 40px
visibleCount: Number // 可视区能显示多少项
});
const scrollTop = ref(0);
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight));
const endIndex = computed(() => startIndex.value + props.visibleCount + 2); // 多渲染 2 项做缓冲
const visibleData = computed(() => props.items.slice(startIndex.value, endIndex.value));
const offsetY = computed(() => startIndex.value * props.itemHeight);
const totalHeight = computed(() => props.items.length * props.itemHeight);
function onScroll(e) {
scrollTop.value = e.target.scrollTop;
}
</script>
<template>
<div class="viewport" @scroll="onScroll" :style="{ height: visibleCount * itemHeight + 'px', overflow: 'auto' }">
<!-- 撑起总高度的占位 -->
<div :style="{ height: totalHeight + 'px' }">
<!-- 真实渲染的节点用 transform 定位 -->
<div :style="{ transform: 'translateY(' + offsetY + 'px)' }">
<div v-for="item in visibleData" :key="item.id" :style="{ height: itemHeight + 'px' }">
{{ item.text }}
</div>
</div>
</div>
</div>
</template>性能对比: 10 万条数据全量渲染会创建 10 万个 DOM 节点,首屏卡顿数秒甚至崩溃;虚拟列表只渲染可视区约 15 个节点,无论数据多大 DOM 数量恒定,滚动流畅 60fps。
五、场景与设计类追问
8. 项目中你做过哪些性能优化?举具体数字
回答框架(用真实数据说话):
| 优化手段 | 优化前 | 优化后 | 提升 |
|----------|--------|--------|------|
| 路由懒加载 + 分包 | 首屏 JS 2.4MB | 480KB | 体积降 80% |
| 图片懒加载 + WebP | LCP 4.2s | 1.8s | LCP 降 57% |
| 长列表虚拟滚动 | 卡顿掉帧 | 稳定 60fps | 流畅度显著提升 |
| 大对象 shallowRef | 更新 120ms | 20ms | 响应式开销降 83% |
| 接口合并 + 缓存 | 首屏 8 个请求 | 3 个请求 | 请求数降 62% |
| gzip + CDN | 传输 480KB | 140KB | 传输量降 70% |
加分项: 强调"先测量再优化",用 Lighthouse、Performance 面板、web-vitals 定位瓶颈;强调优化要抓大放小,优先处理占比最高的瓶颈。
9. 如何设计一个可复用的组件?
核心原则:
<!-- 一个可复用的确认弹窗 -->
<script setup>
defineProps({ title: String, visible: Boolean });
const emit = defineEmits(['confirm', 'cancel', 'update:visible']);
</script>
<template>
<div v-if="visible" class="dialog">
<header>
<slot name="header">{{ title }}</slot>
</header>
<main>
<slot /> <!-- 默认插槽放内容 -->
</main>
<footer>
<slot name="footer" :confirm="() => emit('confirm')">
<button @click="emit('cancel')">取消</button>
<button @click="emit('confirm')">确定</button>
</slot>
</footer>
</div>
</template>10. Vue3 中如何优雅地封装可复用逻辑?
核心答案: 使用组合式函数(Composable),即以 use 开头、返回响应式状态和方法的普通函数。相比 Vue2 的 mixin,composable 数据来源清晰、无命名冲突、类型友好。
// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue';
export function useMouse() {
const x = ref(0);
const y = ref(0);
function update(e) {
x.value = e.pageX;
y.value = e.pageY;
}
onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update));
return { x, y };
}<script setup>
import { useMouse } from '@/composables/useMouse';
const { x, y } = useMouse(); // 一行复用,且自动管理生命周期
</script>对比表格:
| 维度 | Mixin(Vue2) | Composable(Vue3) |
|------|---------------|--------------------|
| 数据来源 | 隐式合并,不清晰 | 显式解构,清晰 |
| 命名冲突 | 容易冲突且被覆盖 | 解构时可重命名 |
| 类型推导 | 差 | 完整 |
| 逻辑复用粒度 | 整个对象 | 任意函数组合 |
加分项: 提到 VueUse 库提供了几百个开箱即用的 composable;提到 composable 内部可以再调用其他 composable 实现逻辑组合。
面试技巧与总结
知识点分类汇总
| 分类 | 核心知识点 | 高频程度 |
|------|-----------|----------|
| 响应式原理 | Object.defineProperty、Proxy、依赖收集、派发更新 | 极高 |
| 生命周期 | 各钩子时机、父子执行顺序、SSR 差异 | 高 |
| 组件通信 | props/emit、provide/inject、Pinia、v-model | 高 |
| 计算与侦听 | computed 缓存、watch、watchEffect | 高 |
| Composition API | setup、ref/reactive、composable、toRefs | 极高 |
| 渲染机制 | 虚拟 DOM、diff、key、nextTick | 高 |
| 编译优化 | 静态提升、PatchFlag、Block Tree | 中高 |
| 模板语法 | v-model、slot、作用域插槽、指令 | 中 |
| 组件缓存 | keep-alive、activated、mixin vs composable | 中 |
| 状态管理 | Vuex vs Pinia、storeToRefs、持久化 | 高 |
| 路由 | 导航守卫、hash/history、懒加载、动态路由 | 高 |
| SSR/工程化 | SSR vs CSR、Nuxt、水合、SSG/ISR | 中 |
| Vue3 新特性 | Teleport、Suspense、多根节点、编译宏 | 中高 |
| 性能优化 | 懒加载、虚拟列表、shallowRef、v-memo | 极高 |
| 组件设计 | 单一职责、单向数据流、插槽扩展、可测试 | 中 |