🔥Vue 面试进阶篇:Composition API、插槽、自定义指令......8 个章节 + 高频面试题全解析
上一篇讲了 Vue 核心基础,这一篇进入进阶------也是中高级前端面试的分水岭。
写在前面
这是 Vue 面试系列的第 2 篇 ([第 1 篇:核心基础 + 响应式 + 组件通信,点这里回看](#第 1 篇:核心基础 + 响应式 + 组件通信,点这里回看))。基础篇决定你能不能过初筛,进阶篇决定你能拿多少薪。
Composition API 为什么诞生?watch 和 watchEffect 到底怎么选?作用域插槽怎么实现"逻辑复用"?自定义指令在业务里怎么用?keep-alive 底层到底缓存了什么?------ 这一篇全部讲透。
适合谁看?
- ✅ 已经掌握 Vue 基础,想冲击中高级前端的同学
- ✅ 被面试官问过"Composition API 解决了什么问题"答不上来的人
- ✅ 写过 Vue3 但只用过
ref+onMounted,想系统补齐进阶 API 的开发者
阅读建议 :全文约 1.2 万字,代码片段均可直接运行。文末照例附「面试知识点速查表」,建议收藏后配合实际项目消化。
📑 目录
- [Composition API](#Composition API)
- 侦听器与生命周期钩子
- [组件进阶(异步组件 / keep-alive / Teleport / Suspense)](#组件进阶(异步组件 / keep-alive / Teleport / Suspense))
- 自定义指令
- 插槽
- 过渡与动画
- [Provide / Inject](#Provide / Inject)
📌 附:面试知识点速查表
12. Composition API
💡 一句话理解 Composition API:把散落在一个组件各处的同一业务逻辑,收拢到同一块代码里。它是 Vue 3 最重要的升级,也是面试的分水岭。
12.1 setup 函数
vue
<script>
import { ref } from 'vue';
export default {
// setup 在 beforeCreate 之前执行,此时组件实例还没创建
setup(props, context) {
// props:父组件传递的 props(本身是响应式的,不可解构)
console.log(props);
// context:上下文对象
// context.attrs ------ 非 prop 属性
// context.slots ------ 插槽
// context.emit ------ 触发事件
// context.expose ------ 暴露公共方法(配合父组件 ref)
const count = ref(0);
function increment() {
count.value++;
}
// ⚠️ 必须返回模板中用到的数据和方法
return { count, increment };
}
};
</script>
12.2 <script setup>:写法进化
<script setup> 是 setup 函数的语法糖,编译时自动处理返回值,顶层变量直接暴露给模板:
vue
<script setup>
import { ref, computed } from 'vue';
// 顶层变量/函数自动暴露,无需 return
const count = ref(0);
const double = computed(() => count.value * 2);
function increment() { count.value++; }
// defineProps:声明 props
const props = defineProps({
title: String,
count: { type: Number, default: 0 }
});
// defineEmits:声明事件
const emit = defineEmits(['update', 'delete']);
// defineExpose:暴露给父组件 ref 调用
defineExpose({ reset() { count.value = 0; } });
// defineOptions:定义组件选项(Vue 3.3+)
defineOptions({ name: 'MyComponent', inheritAttrs: false });
// useSlots / useAttrs:模板外访问插槽和透传属性
import { useSlots, useAttrs } from 'vue';
const slots = useSlots();
const attrs = useAttrs();
</script>
💡 注意 :
defineProps/defineEmits是编译器宏 ,无需 import;且props对象不能解构 (会丢失响应性),需要解构时用toRefs或 getter。
12.3 ref 全家桶
vue
<script setup>
import { ref, reactive, toRef, toRefs, shallowRef, shallowReactive, triggerRef } from 'vue';
// ---------- ref:基本类型首选,也能包对象 ----------
const count = ref(0);
const list = ref([1, 2, 3]);
count.value++; // JS 中需要 .value(模板中自动解包)
// ---------- reactive:仅对象/数组,直接访问 ----------
const state = reactive({ name: 'John', age: 30, hobbies: ['reading'] });
state.name = 'Jane'; // 无需 .value
// ---------- toRef / toRefs:解构不丢响应性 ----------
const nameRef = toRef(state, 'name'); // 单个属性,与 state.name 同步
const { name, age } = toRefs(state); // 批量解构
// ---------- 浅层响应式:性能优化手段 ----------
const shallow = shallowRef({ nested: { count: 0 } });
// 只有整体替换 .value 才触发;嵌套修改不触发
// shallow.value.nested.count++; // ❌ 不触发更新
shallow.value.nested.count++;
triggerRef(shallow); // ✅ 手动触发更新
const shallowState = shallowReactive({ nested: { count: 0 } });
// 只有第一层属性是响应式的
</script>
12.4 computed:缓存是灵魂
vue
<script setup>
import { ref, computed } from 'vue';
const firstName = ref('John');
const lastName = ref('Doe');
// 只读
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
// 可写(v-model 绑定计算属性时常用)
const fullNameRW = computed({
get: () => `${firstName.value} ${lastName.value}`,
set(value) {
const [first, last] = value.split(' ');
firstName.value = first;
lastName.value = last;
}
});
// 缓存验证:依赖不变时,多次访问只计算一次
const expensive = computed(() => {
console.log('计算执行'); // 只会打印一次
return count.value * 1000;
});
</script>
12.5 组合式函数(Composables):逻辑复用的终极答案
javascript
// composables/useMouse.js ------ 鼠标位置追踪
import { ref, onMounted, onUnmounted } from 'vue';
export function useMouse() {
const x = ref(0);
const y = ref(0);
function update(event) {
x.value = event.pageX;
y.value = event.pageY;
}
onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update));
return { x, y };
}
// composables/useFetch.js ------ 数据请求(含 loading/error 状态)
import { ref, watchEffect } from 'vue';
export function useFetch(url) {
const data = ref(null);
const error = ref(null);
const loading = ref(true);
async function fetchData() {
loading.value = true;
try {
const res = await fetch(url.value || url);
data.value = await res.json();
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
// url 是 ref 时自动追踪,否则立即执行一次
if (typeof url === 'object' && url.value !== undefined) {
watchEffect(fetchData);
} else {
fetchData();
}
return { data, error, loading, refetch: fetchData };
}
vue
<!-- 使用:逻辑复用就这么简单 -->
<script setup>
import { useMouse } from './composables/useMouse';
import { useFetch } from './composables/useFetch';
const { x, y } = useMouse();
const { data, loading, error } = useFetch('/api/users');
</script>
💬 面试题精选
Q1:Composition API 和 Options API 的区别?为什么要有 Composition API? ⭐必考
答:
| 特性 | Options API | Composition API |
|---|---|---|
| 代码组织 | 按选项类型分类(data/methods/computed 分家) | 按逻辑功能组织 |
| 逻辑复用 | Mixins(命名冲突、来源不清、隐式依赖) | Composables(显式、可传参、可嵌套) |
| TypeScript | 类型推断弱(this 上下文难推断) | 原生支持,类型友好 |
| 代码量 | 小组件更简洁 | 小组件稍显啰嗦 |
存在的核心动机 :Options API 中,同一个业务逻辑(如一个搜索功能)的数据、计算、方法散落在七八个选项里 ,组件一复杂就"上下翻飞";Mixins 复用逻辑又有命名冲突和来源不清的问题。Composition API 让相关逻辑内聚,并提供了 Composables 这种干净的复用方式。
💡 加分话术 :官方说法是"按逻辑关注点组织代码"。可以补一个实例------一个包含搜索+分页+导出的表格组件,Options API 下同一功能的代码被拆散,Composition API 下可以把每个功能抽成一个
useXxx函数,甚至抽到单独文件供其他页面复用。
Q2:什么是组合式函数?和 Mixins 的区别?
答:组合式函数(Composable)是利用 Composition API 封装有状态逻辑的函数 ,约定以 use 开头命名。
与 Mixins 的关键区别:
- 命名冲突 :Mixins 的属性/方法直接合并到组件,重名互相覆盖;Composables 返回值由调用方显式接收并命名
- 来源清晰 :
const { x, y } = useMouse()一眼看出数据来自哪 - 可传参、可组合:函数天然支持参数,也可以在一个 Composable 里调用另一个
- 类型友好:就是普通函数,TypeScript 推断完整
Q3:setup 中能不能访问 this?
答:不能 。setup 在组件实例创建之前 执行(替代 beforeCreate/created),此时 this 还没指向实例。这是故意的------让逻辑不依赖实例上下文,才能被抽离成 Composables 复用,也是 Composition API 对 TS 友好的原因之一。
13. 侦听器与生命周期钩子
13.1 watch 完全指南
vue
<script setup>
import { ref, reactive, watch } from 'vue';
const count = ref(0);
const state = reactive({ name: 'John', age: 30 });
// ① 侦听 ref
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} → ${newVal}`);
});
// ② 侦听 reactive 对象:自动深度侦听
watch(state, (newVal) => console.log('state changed', newVal));
// ③ 侦听 reactive 的某个属性:⚠️ 必须用 getter 函数
watch(() => state.name, (newVal, oldVal) => {
console.log(`name: ${oldVal} → ${newVal}`);
});
// ④ 侦听多个源
watch([count, () => state.name],
([newCount, newName], [oldCount, oldName]) => { /* ... */ }
);
// ⑤ 常用选项
watch(count, callback, {
immediate: true, // 立即先执行一次
deep: true, // 深度侦听(大对象有性能开销)
flush: 'pre', // 'pre'(默认,更新前)| 'post'(DOM更新后)| 'sync'
once: true // 只触发一次(Vue 3.4+)
});
// ⑥ 停止侦听(组件卸载自动停止,手动场景:异步回调里创建的侦听)
const stop = watch(count, callback);
stop();
</script>
13.2 watchEffect 与副作用清理
vue
<script setup>
import { ref, watchEffect, watchPostEffect, watchSyncEffect } from 'vue';
const url = ref('/api/users');
// 自动追踪回调中用到的所有响应式数据
watchEffect(async () => {
console.log('请求:', url.value); // 用到 url 就自动追踪
const res = await fetch(url.value);
});
// ⚠️ 关键能力:清理副作用(防止竞态)
watchEffect(async (onCleanup) => {
let cancelled = false;
onCleanup(() => { cancelled = true; }); // 下次执行前 / 卸载时调用
const data = await fetch(url.value).then(r => r.json());
if (!cancelled) {
// 只有没被取消才使用数据 ------ 防止旧请求覆盖新结果
}
});
// 变体:DOM 更新后执行(等效于 watch + flush: 'post')
watchPostEffect(() => {
// 可以拿到更新后的 DOM
});
// 变体:同步执行(等效于 flush: 'sync',调试专用)
watchSyncEffect(() => {});
</script>
13.3 生命周期钩子速查
vue
<script setup>
import {
onBeforeMount, onMounted,
onBeforeUpdate, onUpdated,
onBeforeUnmount, onUnmounted,
onActivated, onDeactivated,
onErrorCaptured, onRenderTracked, onRenderTriggered
} from 'vue';
// setup 本身 = beforeCreate + created
onMounted(() => { /* DOM 就绪:请求、DOM 操作、第三方库初始化 */ });
onBeforeUnmount(() => { /* 清理:定时器、全局监听、销毁第三方实例 */ });
// keep-alive 专用
onActivated(() => {}); // 缓存组件被激活(回到前台)
onDeactivated(() => {}); // 缓存组件被停用(切到后台)
// 错误边界:捕获所有后代组件错误
onErrorCaptured((err, instance, info) => {
console.error('捕获错误:', err, info);
return false; // 返回 false 阻止错误继续向上传播
});
// 调试钩子:排查"是谁触发了更新"
onRenderTriggered((event) => {
console.log('触发更新:', event.key, event.newValue);
});
</script>
💬 面试题精选
Q1:watch 和 watchEffect 的区别?
答:
| 特性 | watch | watchEffect |
|---|---|---|
| 侦听源 | 显式指定(精确控制) | 自动追踪用到的数据 |
| 旧值 | ✅ 可访问 oldValue | ❌ 拿不到 |
| 执行时机 | 懒执行(变化才跑) | 定义时立即执行一次 |
| 适用场景 | 需要新旧值对比、条件触发 | 副作用函数(请求、同步存储) |
💡 加分话术:watchEffect 的自动追踪是双刃剑------重构时多引用了一个响应式变量,侦听范围就悄悄变了;需要精确控制或要做新旧值对比时用 watch,纯副作用同步用 watchEffect。
Q2:如何清理 watchEffect 的副作用?(进阶高频)
答:通过回调的第一个参数 onCleanup。它在下次副作用执行前 和组件卸载时被调用,用来取消过期操作:
javascript
watchEffect(async (onCleanup) => {
let cancelled = false;
onCleanup(() => { cancelled = true; });
const data = await fetch(url.value).then(r => r.json());
if (!cancelled) result.value = data; // 防止旧请求覆盖新数据(竞态)
});
💡 加分话术 :搜索联想场景必考这个------用户连续输入"vue"、"vue3",如果第一次请求后返回慢,不清理就会把旧结果覆盖到新结果上。
onCleanup就是 Vue 给出的竞态解决方案。
Q3:flush: 'post' 有什么用?
答:默认 flush: 'pre' 在组件更新之前 执行回调,此时 DOM 还是旧的;改成 'post'(或直接用 watchPostEffect)会在 DOM 更新之后 执行,适合需要在回调里读取更新后 DOM 的场景(如获取元素新高度、charts 重绘)。
14. 组件进阶
14.1 异步组件:首屏优化的利器
vue
<script setup>
import { defineAsyncComponent } from 'vue';
import LoadingSpinner from './LoadingSpinner.vue';
// 基本用法:结合动态 import 实现按需加载
const AsyncComponent = defineAsyncComponent(() =>
import('./components/MyComponent.vue')
);
// 完整选项
const HeavyComponent = defineAsyncComponent({
loader: () => import('./components/HeavyComponent.vue'),
loadingComponent: LoadingSpinner, // 加载中显示
delay: 200, // 200ms 内加载完成则不显示 loading(防闪烁)
errorComponent: ErrorDisplay, // 加载失败显示
timeout: 3000 // 超过 3s 视为失败
});
</script>
14.2 keep-alive:缓存与它的边界
vue
<template>
<!-- 基本:缓存所有切走的组件 -->
<keep-alive>
<component :is="currentComponent" />
</keep-alive>
<!-- include/exclude:按组件名精确控制 -->
<keep-alive :include="['ComponentA', 'ComponentB']" :max="10">
<router-view />
</keep-alive>
<!-- 也支持正则 / 数组 -->
<keep-alive :include="/^Tab/" :exclude="['TabDebug']" :max="10">
<router-view />
</keep-alive>
</template>
<script>
// ⚠️ 注意:组件名必须与 include/exclude 匹配
// <script setup> 下用 defineOptions({ name: 'ComponentA' })
export default { name: 'ComponentA' };
</script>
💡
:max="10"是 LRU 缓存------超出上限时,最久未被访问的组件会被销毁。
14.3 Teleport:把弹窗送到 body 下
vue
<template>
<button @click="showModal = true">打开弹窗</button>
<!-- 模板写在组件里,但实际挂载到 body 下 -->
<Teleport to="body">
<div v-if="showModal" class="modal-mask">
<div class="modal">
<p>这是一个弹窗</p>
<button @click="showModal = false">关闭</button>
</div>
</div>
</Teleport>
</template>
14.4 Suspense:优雅处理异步依赖(实验性)
vue
<template>
<Suspense>
<template #default>
<AsyncComponent /> <!-- 含异步依赖的组件 -->
</template>
<template #fallback>
<LoadingSpinner /> <!-- 加载占位 -->
</template>
</Suspense>
</template>
14.5 动态组件
vue
<script setup>
import { ref, shallowRef } from 'vue';
import ComponentA from './ComponentA.vue';
import ComponentB from './ComponentB.vue';
const current = ref('ComponentA');
const components = { ComponentA, ComponentB };
// ⚠️ 组件对象用 shallowRef 包裹即可(不需要深层响应式,还省性能)
const currentComponent = shallowRef(ComponentA);
</script>
<template>
<component :is="components[current]" />
</template>
💬 面试题精选
Q1:keep-alive 的作用和原理? ⭐高频
答:
作用 :缓存动态切换的组件实例,切走时不销毁 ,回来时不重建------保留组件状态(表单内容、滚动位置)且避免重复渲染和重复请求。
原理:
- 组件切走时,
keep-alive将其实例从 DOM 移除,但缓存在内部的 Map 中(key 为组件 key/name) - 切回时直接从缓存取出重新挂载,跳过初始化
- 用
include/exclude控制缓存名单,:max实现 LRU 淘汰
配套生命周期 :onActivated(激活时)、onDeactivated(停用时)------首次挂载不会触发 onActivated,只会触发 onMounted。
💡 加分话术 :典型场景是 Tab 切换 + 列表页保留筛选状态,或后台管理的多页签(像 Chrome 标签页)。要注意内存问题:大表格组件被缓存多了占内存,配合
:max限制。
Q2:Teleport 的使用场景?
答:任何需要脱离当前组件 DOM 层级的场景,最典型的是全局弹窗/对话框:
- 弹窗写在深层嵌套的组件里,但样式上需要全屏居中、盖在一切内容之上------如果挂在原组件下,会被父级的
overflow: hidden、z-index 层叠上下文坑惨 - Teleport 把 DOM 真正移到
body下,样式和定位彻底独立 - 其他场景:全局 Toast/通知、Tooltip、固定在视口的工具按钮
Q3:异步组件的实现原理?
答:核心是 动态 import() ------打包工具(Webpack/Vite)会把异步组件拆成单独的 chunk,运行时按需加载。defineAsyncComponent 在此基础上包装了 loading/error/delay/timeout 状态管理,让加载过程有完整的 UX 兜底。
15. 自定义指令
💡 指令是操作底层 DOM 的逃生舱------当你需要直接碰 DOM 而模板语法表达不了时,就该用指令。
15.1 指令定义与钩子
javascript
// 局部指令(<script setup> 中,变量名 vXxx 自动成为 v-xxx)
const vFocus = {
mounted(el) {
el.focus();
}
};
// 全局指令
app.directive('focus', {
mounted(el) {
el.focus();
}
});
javascript
// 指令钩子全集与参数
const myDirective = {
created(el, binding, vnode, prevVnode) {
// el:绑定的 DOM 元素
// binding.value ------ 指令的值
// binding.oldValue ------ 旧值(仅 update 系列钩子)
// binding.arg ------ 参数(v-pin:top 中的 "top")
// binding.modifiers ------ 修饰符对象
// binding.instance ------ 使用指令的组件实例
},
beforeMount(el, binding) {},
mounted(el, binding) {},
beforeUpdate(el, binding, vnode, prevVnode) {},
updated(el, binding, vnode, prevVnode) {},
beforeUnmount(el, binding) {},
unmounted(el, binding) {} // ⚠️ 清理全局监听必须在这里做
};
15.2 五个实战指令(面试直接当项目经验讲)
vue
<script setup>
// ① v-focus:自动聚焦
const vFocus = { mounted: (el) => el.focus() };
// ② v-click-outside:点击元素外部触发(下拉菜单/弹窗关闭标配)
const vClickOutside = {
mounted(el, binding) {
el._clickOutside = (event) => {
if (!el.contains(event.target)) binding.value(event);
};
document.addEventListener('click', el._clickOutside);
},
unmounted(el) {
// ⚠️ 必须移除监听,否则内存泄漏
document.removeEventListener('click', el._clickOutside);
}
};
// ③ v-debounce:输入防抖(binding.arg 作为延迟毫秒数)
const vDebounce = {
mounted(el, binding) {
let timer;
el.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => binding.value(el.value), Number(binding.arg) || 300);
});
}
};
// ④ v-lazy:图片懒加载(IntersectionObserver 实现)
const vLazy = {
mounted(el, binding) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
el.src = binding.value;
observer.unobserve(el); // 加载完停止观察
}
});
});
observer.observe(el);
}
};
// ⑤ v-pin:固定定位
const vPin = {
mounted(el, binding) {
el.style.position = 'fixed';
el.style.top = binding.value + 'px';
}
};
</script>
<template>
<input v-focus />
<div v-click-outside="closeDropdown">下拉菜单</div>
<input v-debounce:500="search" placeholder="防抖搜索" />
<img v-lazy="imageUrl" />
<div v-pin="200">固定在顶部 200px</div>
</template>
💬 面试题精选
Q1:自定义指令的使用场景?什么情况下该用指令而不是组件?
答:判断标准------这段逻辑是否产出独立的 UI。不产出 UI、只是给已有元素附加行为的,用指令:
- 自动聚焦
v-focus - 点击外部关闭
v-click-outside - 防抖/节流
v-debounce/v-throttle - 权限控制
v-permission(无权限直接移除元素) - 图片懒加载
v-lazy - 拖拽 / 长按 / 复制粘贴 等 DOM 行为
💡 加分话术 :指令和组件的本质区别是抽象层级------组件封装"UI + 逻辑",指令封装"纯 DOM 行为"。真实项目里我们团队沉淀了一个 directives 目录,防抖、权限、点击外部这些全局复用,组件代码里一行
v-xxx就搞定。
Q2:指令钩子里有哪些参数?
答:四个参数:
el:指令绑定的真实 DOM 元素binding:指令信息对象------value(值)、oldValue(旧值)、arg(参数)、modifiers(修饰符)、instance(组件实例)vnode:编译产生的虚拟节点prevVnode:上一个虚拟节点(仅beforeUpdate/updated中有)
16. 插槽
💡 插槽的本质是内容分发------组件提供位置和接口,父组件决定填什么。
16.1 具名插槽
vue
<!-- 子组件:Card.vue 定义结构,留出插槽口 -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">默认标题</slot> <!-- 带后备内容 -->
</div>
<div class="card-body">
<slot>默认内容</slot> <!-- 默认插槽 -->
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
<!-- 父组件:填充内容 -->
<template>
<Card>
<template #header><h2>自定义标题</h2></template>
<p>这是卡片内容</p> <!-- 默认插槽无需 template -->
<template #footer><button>确定</button></template>
</Card>
</template>
16.2 作用域插槽:子传数据给父
vue
<!-- 子组件:列表组件只负责数据,渲染交给父组件 -->
<template>
<ul>
<li v-for="(item, i) in items" :key="item.id">
<slot :item="item" :index="i" :remove="removeItem">
{{ item.name }} <!-- 后备渲染 -->
</slot>
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue';
const items = ref([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' }
]);
function removeItem(id) {
items.value = items.value.filter(item => item.id !== id);
}
</script>
<!-- 父组件:同一份数据,完全不同的渲染 -->
<template>
<MyList :items="items">
<template #default="{ item, index, remove }">
<span>{{ index }}. {{ item.name }}</span>
<button @click="remove(item.id)">删除</button>
</template>
</MyList>
<!-- v-slot 直接写在组件上 = 只使用默认插槽时的简写 -->
<MyList v-slot="{ item }">
<strong>{{ item.name }}</strong>
</MyList>
</template>
16.3 无渲染组件:逻辑复用的又一招
vue
<!-- MouseTracker.vue:只提供逻辑,UI 完全交给使用者 -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
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));
</script>
<template>
<slot :x="x" :y="y" />
</template>
<!-- 使用:逻辑复用,渲染自由 -->
<MouseTracker v-slot="{ x, y }">
<p>鼠标位置: {{ x }}, {{ y }}</p>
</MouseTracker>
💬 面试题精选
Q1:插槽的类型有哪些?
答:
- 默认插槽 :
<slot>不带 name,一个组件最多一个 - 具名插槽 :
<slot name="xxx">,父组件用<template #xxx>对应填充 - 作用域插槽 :
<slot :data="xxx">,子组件向父组件回传数据,父组件可自定义渲染
Q2:作用域插槽和普通插槽的区别? ⭐高频
答:方向相反------
- 普通插槽 :父 → 子传内容(父组件编译好 HTML 塞进去)
- 作用域插槽 :子 → 父传数据(子组件把数据通过插槽回传,父组件拿着数据自己渲染)
💡 加分话术 :典型场景是通用列表/表格组件------数据获取、分页、排序逻辑封装在组件内部,但每一行怎么渲染由使用方决定(有的要按钮、有的要图片),用作用域插槽把
item、index、actions抛出去,一份组件适配 N 种页面。Element Plus 的 Table 组件就是标准案例。
Q3:什么是无渲染组件?
答:只提供逻辑、不提供 UI 的组件------内部维护状态和逻辑,通过作用域插槽把数据/方法全部暴露给父组件,渲染什么完全由父组件决定。适合"逻辑确定、UI 多变"的场景(鼠标追踪、地理定位、数据请求封装等),与 Composables 是逻辑复用的两种形态:复用在模板里用无渲染组件,复用在 script 里用 Composables。
17. 过渡与动画
17.1 单元素过渡
vue
<template>
<Transition name="fade">
<p v-if="show">Hello</p>
</Transition>
<!-- mode="out-in":先出后进,避免两个元素同时存在的布局跳动 -->
<Transition name="slide" mode="out-in">
<component :is="currentComponent" />
</Transition>
<!-- 配合 Animate.css 等第三方库 -->
<Transition
enter-active-class="animate__animated animate__bounceIn"
leave-active-class="animate__animated animate__bounceOut"
>
<p v-if="show">Hello</p>
</Transition>
</template>
<style>
/* Vue 自动在合适的时机添加这 6 个类(v 被 name 替换) */
.fade-enter-active,
.fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>
17.2 六个过渡类(必背)
| 类名 | 时机 |
|---|---|
v-enter-from |
进入开始状态(插入前的一帧) |
v-enter-active |
进入整个过程(放 transition 定义) |
v-enter-to |
进入结束状态 |
v-leave-from |
离开开始状态 |
v-leave-active |
离开整个过程 |
v-leave-to |
离开结束状态(移除前的一帧) |
17.3 列表过渡
vue
<template>
<!-- ⚠️ 每个子元素必须有 key;不能使用 mode -->
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.name }}
<button @click="remove(item.id)">×</button>
</li>
</TransitionGroup>
</template>
<style>
.list-enter-active, .list-leave-active { transition: all 0.5s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); }
/* .list-move:元素位置变化时的平滑移动( FLIP 动画) */
.list-move { transition: transform 0.5s ease; }
/* 离开的元素脱离文档流,让后面的兄弟元素平滑补位 */
.list-leave-active { position: absolute; }
</style>
💬 面试题精选
Q1:Vue 过渡的类名有哪些?
答:六个------enter-from / enter-active / enter-to / leave-from / leave-active / leave-to。v- 前缀会被 name 属性值替换。
💡 记忆技巧:
-active类管过程(写 transition 动画定义),-from和-to类管首尾状态(写起始/结束样式)。
Q2:Transition 和 TransitionGroup 的区别?
答:
Transition:单个元素/组件的进入离开,支持mode(in-out / out-in)TransitionGroup:列表过渡,子元素必须有 key ,不支持 mode ,额外提供.v-move类实现元素位置变化的平滑移动
18. Provide / Inject
💡 场景定位:跨多级传值,又不想一层层 props 透传("prop drilling" 地狱)。
18.1 基本使用
vue
<!-- 祖先组件 -->
<script setup>
import { ref, provide, readonly } from 'vue';
// 提供静态值
provide('message', 'Hello');
// 提供响应式值
const count = ref(0);
provide('count', count);
// Symbol 作 key:避免命名冲突(多人协作/插件开发推荐)
const ThemeSymbol = Symbol('theme');
provide(ThemeSymbol, 'dark');
</script>
<!-- 后代组件(中间隔多少层都行) -->
<script setup>
import { inject } from 'vue';
const message = inject('message');
const count = inject('count'); // 仍是响应式
const theme = inject('theme', 'light'); // 带默认值
const obj = inject('obj', () => ({ a: 1 }));// 默认值是对象/数组时用工厂函数
</script>
18.2 响应式 Provide 的正确姿势
vue
<!-- 祖先:数据 + 修改方法一起提供,数据用 readonly 保护 -->
<script setup>
import { ref, provide, readonly } from 'vue';
const count = ref(0);
provide('count', readonly(count));
provide('increment', () => { count.value++; });
provide('decrement', () => { count.value--; });
</script>
<!-- 后代:只读数据 + 调用方法修改,单向数据流不破 -->
<script setup>
import { inject } from 'vue';
const count = inject('count');
const increment = inject('increment');
// count.value++ ❌ 失败(readonly 拦截)------这就是我们要的效果
</script>
💬 面试题精选
Q1:Provide/Inject 和 Props 的区别? ⭐高频
答:
| 特性 | Props | Provide/Inject |
|---|---|---|
| 方向 | 父 → 子(单层) | 祖先 → 后代(跨任意层) |
| 响应式 | 天然响应式 | 提供 ref/reactive 才响应式 |
| 类型检查 | 支持 props 校验 | 无校验 |
| 数据追溯 | 清晰(模板可见) | 隐式(来源不明显) |
| 适用场景 | 常规父子通信 | 深层嵌套、全局配置(主题、语言、用户信息) |
💡 加分话术:Provide/Inject 和 Props 不是替代关系,是互补------Props 解决"数据从哪来可见"的常规场景,Provide/Inject 解决"prop 逐层透传地狱"。但要克制使用:数据流向变隐式,滥用会让组件依赖难以追踪,官方定位是"长距离传值 + 插件式开发"(如 Vue Router、Element Plus 的 config-provider 都是这个思路)。
Q2:如何让 Provide 的值是响应式的?
答:提供 ref / reactive 本身即可(inject 出来的对象引用不变,内部属性变化天然响应)。工程上的最佳实践是配合 readonly :数据用 readonly 包装防止后代直接改,修改只通过 provide 的方法完成,保持单向数据流。
📋 面试知识点速查表
| 问题 | 一句话答案 |
|---|---|
| Composition API 解决了什么 | Options API 逻辑分散 + Mixins 复用缺陷 → 按功能组织、Composables 复用 |
| setup 能访问 this 吗 | 不能(实例未创建),也是它对 TS 友好的原因 |
| 组合式函数 vs Mixins | 显式命名/可传参/来源清晰 vs 命名冲突/隐式合并 |
| watch vs watchEffect | 显式指定源+有旧值 vs 自动追踪+立即执行 |
| watchEffect 副作用清理 | onCleanup,防竞态(搜索联想场景) |
| flush: 'post' | DOM 更新后执行回调(要读新 DOM 时用) |
| keep-alive 原理 | 切走不销毁、缓存进 Map;onActivated/onDeactivated;:max 是 LRU |
| Teleport 场景 | 弹窗/Toast/Tooltip------脱离父级层叠上下文和 overflow 陷阱 |
| 异步组件原理 | 动态 import 拆 chunk + loading/error/delay/timeout 状态管理 |
| 自定义指令场景 | 聚焦/点击外部/防抖/权限/懒加载------只加行为、不产出 UI 的 DOM 逻辑 |
| 作用域插槽 | 子组件通过 <slot :data> 把数据回传给父组件自定义渲染 |
| 无渲染组件 vs Composables | 模板层逻辑复用 vs script 层逻辑复用 |
| 过渡 6 个类 | enter-from/enter-active/enter-to + leave-from/leave-active/leave-to |
| Transition vs TransitionGroup | 单个(支持 mode)vs 列表(必须 key,有 .move) |
| Provide/Inject 响应式 | 提供 ref/reactive;最佳实践 readonly + 方法 |
📌 最后
- 觉得有用欢迎 点赞 ❤️ + 收藏 ⭐,进阶篇值得反复看
- 第 3 部分(Vue Router / Pinia / Vue3 新特性 / 响应式原理深入)整理中,关注追更
- 有不同见解或补充,评论区见,我会持续更新 🙏