
📋 目录
Q1. Vue 的核心是什么?对比 Vue 2 和 Vue 3 的响应式原理有什么区别
Q2. 什么是 MVVM 模式?Vue 是如何体现 MVVM 的?
Q3. Vue 实例的选项有哪些?(例如 data、computed、methods、watch、生命周期等)
Q4. v-if 和 v-show 有什么区别?分别适用于什么场景?
Q5. v-for 和 v-if 为什么不推荐一起使用?有什么替代方案?
Q6. Vue 中如何实现父子组件通信?(props + $emit / v-model)
Q7. 什么是插槽(Slot)?slot 和 slot-scope 在 Vue 2 和 Vue 3 中有什么区别?
Q8. computed 和 watch 有什么区别?各自的使用场景是什么?
Q9. 什么是 nextTick?它的实现原理是什么?
Q10. Vue 3 的 Composition API 和 Options API 有什么区别?
Q7. 什么是插槽(Slot)?slot 和 slot-scope 在 Vue 2 和 Vue 3 中有什么区别?
考点
插槽的概念、作用域插槽的原理、Vue 2 与 Vue 3 插槽写法的变化、以及插槽的实际应用场景。
答案
插槽(Slot) 是 Vue 中用于内容分发的机制,允许父组件向子组件的指定位置插入 HTML 内容,让子组件变得更加灵活和可复用。
插槽的核心价值
内容由父组件决定:子组件只负责"占位"和"提供数据",具体渲染什么由使用它的父组件决定。
复用公共结构:可以封装通用的"框架"(如对话框、表格、卡片),把可变部分用插槽暴露出去。
保持组件封装性:子组件不依赖父组件的具体内容,实现解耦。
插槽的分类
1. 默认插槽
子组件用 <slot /> 声明一个"插槽位",父组件在使用时把内容放在子组件标签内部,内容会自动填充到子组件的 <slot /> 位置。可以给 <slot> 设置默认值(写在标签内的内容),当父组件不传内容时显示。
2. 具名插槽
子组件用 <slot name="header" /> 声明多个带名字的插槽位,父组件用 <template #header> 形式指定内容对应哪个插槽。常用于多区域布局(页头/内容/页脚)。
3. 作用域插槽
子组件在 <slot :prop="value"> 上绑定数据,父组件在 <template #default="{ prop }"> 中接收这个数据。作用域插槽让子组件的数据能传到父组件的插槽模板中,是封装可复用组件的关键能力。
4. 动态插槽名(Vue 3)
Vue 3 支持 #[dynamicName] 语法,插槽名可以是动态字符串。适合"根据配置决定插槽名"的场景。
Vue 2 vs Vue 3 插槽写法对比
默认插槽:两种版本写法相同。
具名插槽:声明侧写法相同(
<slot name="header" />)。父组件使用侧:
Vue 2:使用
slot="header"和slot-scope="{ item }"写在子元素上。Vue 3:使用
<template #header="{ item }">,语法更简洁、模板更整洁。动态插槽名:Vue 2 不支持,Vue 3 支持
#[dynamicName]。废弃语法:Vue 3 中
slot和slot-scope属性已完全移除,必须用v-slot或#简写。
实际项目中的插槽应用
场景:通用表格组件 Table.vue
子组件 Table 提供通用的表格框架(表头、表格体、列定义),把"每个单元格怎么渲染"交给父组件用插槽决定:
表格组件接受 columns 配置和 data 数据。
每个单元格用
<slot :name="column.key" :row="row" :column="column" :index="index">声明作用域插槽。默认插槽内容是直接显示原始值。
父组件可以自定义"状态列"显示带颜色的 badge、"操作列"放编辑/删除按钮、"金额列"格式化显示。
父组件可以自定义表头(
#header插槽)。
这样表格组件完全不关心业务逻辑,所有展示规则由父组件决定,可以复用到任何数据列表场景。
知识点详解
作用域插槽的本质
作用域插槽的核心是子组件渲染时把数据传给父组件的插槽模板。
<!-- 子组件 Table.vue -->
<template>
<table>
<tr v-for="row in data" :key="row.id">
<td>
<!-- 把 row、column、index 传给父组件的插槽模板 -->
<slot:row="row" :column="column" :index="index">
{{ row[column.key] }} <!-- 默认值:直接显示 -->
</slot>
</td>
</tr>
</table>
</template>
<!-- 父组件使用 -->
<Table :columns="columns" :data="users">
<!-- 父组件的插槽模板能访问子组件传入的数据 -->
<template #status="{ row }">
<span :class="['badge', `badge-${row.status}`]">
{{ statusText[row.status] }}
</span>
</template>
</Table>
实现原理:父组件在解析插槽模板时,模板被编译成一个"插槽函数",子组件在调用 <slot :row="row"> 时把这个函数执行并传入数据。
Vue 2 vs Vue 3 写法对比
<!-- Vue 2:slot 和 slot-scope 写在子元素上 -->
<ChildComponent>
<div slot="header" slot-scope="{ user }">
{{ user.name }}
</div>
<div slot-scope="{ items }">
{{ items.length }} 个项目
</div>
</ChildComponent>
<!-- Vue 3:必须写在 <template> 上,更清晰 -->
<ChildComponent>
<template #header="{ user }">
{{ user.name }}
</template>
<template #default="{ items }">
{{ items.length }} 个项目
</template>
</ChildComponent>
v-slot 简写
<!-- 完整写法 -->
<ChildComponent v-slot:header="{ user }">
{{ user.name }}
</ChildComponent>
<!-- 简写 -->
<ChildComponent #header="{ user }">
{{ user.name }}
</ChildComponent>
<!-- 默认插槽的简写 -->
<ChildComponent v-slot="{ item }">
{{ item.name }}
</ChildComponent>
<!-- 等价于 -->
<ChildComponent #default="{ item }">
{{ item.name }}
</ChildComponent>
默认插槽的简写规则
<!-- ✅ 只有一个默认插槽时,可以省略 template -->
<MyButton>点击我</MyButton>
<!-- ❌ 存在具名插槽时,必须用 template -->
<Layout>
<template #header>标题</template>
<main>内容</main><!-- 这个是默认插槽,但与其他具名插槽共存时建议也用 template -->
</Layout>
动态插槽名(Vue 3)
<!-- 插槽名是动态的 -->
<DynamicComponent>
<template #[dynamicSlotName]>
内容
</template>
</DynamicComponent>
exportdefault {
data() {
return {
dynamicSlotName: 'header'// 可以动态切换
};
}
};
通用表格组件完整示例
<!-- Table.vue 子组件 -->
<template>
<table class="table">
<thead>
<tr>
<th v-for="col in columns" :key="col.key">
<!-- 表头插槽 -->
<slot name="header" :column="col">
{{ col.label }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in data" :key="row.id">
<t dv-for="col in columns" :key="col.key">
<!-- 单元格插槽,传递行数据 -->
<slot
:name="col.key"
:row="row"
:column="col"
:index="index"
:value="row[col.key]"
>
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<!-- 父组件使用 -->
<Table :columns="columns" :data="userList">
<template #status="{ value }">
<span :class="['status', `status-${value}`]">
{{ statusText[value] }}
</span>
</template>
<template #actions="{ row }">
<button @click="edit(row.id)">编辑</button>
<button @click="remove(row.id)">删除</button>
</template>
<template #amount="{ value }">
¥{{ value.toFixed(2) }}
</template>
</Table>
Q8. computed 和 watch 有什么区别?各自的使用场景是什么?
考点
computed 与 watch 的本质区别、缓存机制、异步处理、以及实际场景的选择。
答案
computed(计算属性)和 watch(侦听器)虽然都能"响应数据变化",但本质不同,使用场景也不同。
核心区别
computed 是"派生数据":从已有数据计算出新数据,有缓存(依赖不变多次访问只算一次),必须有返回值,同步执行。
watch 是"监听副作用":监听数据变化后执行一段逻辑(请求接口、修改其他状态、弹窗提示等),无缓存(每次数据变化都执行),不一定有返回值,可以执行异步。
为什么 computed 有缓存
computed 会追踪它依赖的所有响应式数据,并记录"上次计算的依赖版本"。当模板或其他地方访问 computed 时,Vue 会先检查依赖是否变化,没变化就直接返回缓存值,变化了才重新计算。这意味着 computed 不能在内部执行异步,因为异步返回的值不会进入缓存机制。
为什么 watch 没有缓存
watch 监听的数据每次变化都会触发回调,没有"依赖版本"的概念。这虽然开销大,但允许执行异步操作(比如防抖搜索、发起 API 请求),适合处理副作用。
computed 适用场景
从已有数据派生新数据:原价+折扣 → 折后价、姓+名 → 全名、列表+筛选条件 → 过滤后的列表。
模板中需要多次使用的复杂表达式:把
user.firstName + user.lastName抽成fullName。依赖其他 computed 的派生数据:可以多个 computed 串联。
需要 setter 双向绑定的场景:computed 支持
get/set形式,可以做成"可写"的派生属性(如格式化全名反写回姓/名)。
watch 适用场景
异步操作:搜索关键词变化 → 发起搜索请求(需要 debounce)。
数据变化时执行副作用:用户名变化 → 验证是否被占用、保存到 localStorage。
监听路由变化:路由参数变化 → 重新加载数据。
深度监听对象/数组:开启
deep: true监听对象内部属性变化。立即执行一次:开启
immediate: true在组件创建时就执行一次(如根据初始 ID 加载数据)。
computed vs watch 对比
本质:computed 是计算属性,watch 是观察副作用。
返回值:computed 必须返回,watch 不需要。
缓存:computed 有缓存,watch 没有。
异步:computed 不支持,watch 支持。
使用频率:computed 更常用(绝大多数派生场景),watch 只在需要副作用时用。
实际项目中的最佳实践
能用 computed 就不用 watch:computed 语义更清晰、性能更好(缓存)。
watch 处理异步或副作用:搜索防抖、表单校验、保存草稿。
避免在 watch 中直接修改被监听的数据:可能造成无限循环。
对象/数组监听加 deep:默认 watch 不会深度监听对象内部属性。
需要立即执行加 immediate:否则 watch 只在数据变化时触发。
知识点详解
计算属性的"惰性求值"
const a = computed( () => {
console.log('计算');
return 1+1;
});
// 此时不会执行 '计算',因为没有访问
a; // 第一次访问:执行 '计算',返回 2 并缓存
a; // 第二次访问:依赖未变,直接返回缓存,不再执行 '计算'
a.value; // 依赖未变,返回缓存
惰性的价值:如果计算逻辑很重(比如深度遍历大数组),不访问就不算,节省性能。
计算属性的 getter / setter
export default {
data() {
return { firstName: '张', lastName: '三' };
},
computed: {
// ✅ 只有 getter(只读派生)
fullName() {
return this.firstName + this.lastName;
},
// ✅ 有 getter 和 setter(可写派生)
fullNameWithSetter: {
get() {
return `${this.firstName}${this.lastName}`;
},
set(value) {
const [first, last] = value.split(' ');
this.firstName = first;
this.lastName = last || '';
}
}
}
};
// v-model 绑定可写 computed
<input v-model="fullNameWithSetter"/>
// 用户输入 "李 四" 时,setter 把 firstName 改为 "李",lastName 改为 "四"
watch 的 deep 监听
export default {
data() {
return {
user: { name: '张三', info: { age: 25 } }
};
},
watch: {
// ❌ 浅监听:user 对象引用变了才触发
user(newVal, oldVal) {
console.log('变化了');
},
// ❌ 仍然浅监听:user.name 是值类型可以直接监听
'user.name'(newVal) {
console.log('姓名变化了');
},
// ✅ 深度监听:user 内部任何属性变化都触发
user: {
handler(newVal) {
console.log('user 内部变化了');
},
deep: true
}
}
};
deep 的实现:Vue 内部会递归遍历被监听对象的每个属性,逐个建立依赖。开销大,应精确指定要监听的属性。
watch 的 immediate 选项
export default {
data() { return { userId: null, userInfo: null }; },
watch: {
// ❌ 默认只在数据变化时触发,初始 userId=null 时不会执行
userId(id) {
this.fetchUser(id);
},
// ✅ immediate: true 组件创建时立即执行一次
userId: {
handler(id) {
this.fetchUser(id);
},
immediate: true
}
},
created() {
this.userId =123; // 此时会立即触发 fetchUser(123)
}
};
watchEffect(Vue 3)
import { watchEffect, ref } from'vue';
const count = ref(0);
const message = ref('hello');
// 自动追踪函数内访问的所有响应式数据
watchEffect(() => {
console.log(count.value, message.value);
// count 或 message 任一变化都会重新执行
});
count.value=1; // 触发执行
message.value='world'; // 触发执行
vs watch:
watch显式指定监听源(watch(count, cb)),依赖清晰。watchEffect自动追踪,代码更简洁,但依赖不明确。团队中建议优先用
watch,副作用明确时用watchEffect。
不要在 watch 中修改被监听的数据
// ❌ 错误:watch 内部又修改了 count,count 又触发 watch,无限循环
watch: {
count(newVal) {
this.count = newVal+1;
}
}
// ✅ 正确:用中间变量
watch: {
count(newVal) {
this.nextCount = newVal+1; // 不修改被监听的数据
}
}
防抖搜索的 watch 用法
export default {
data() {
return {
keyword: '',
results: [],
debounceTimer: null
};
},
watch: {
keyword(newKeyword) {
// ✅ watch 适合做防抖
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(async () => {
const res = await fetch(`/api/search?q=${newKeyword}`);
this.results = awaitres.json();
}, 300);
}
}
};