Vue 面试进阶篇:Composition API、插槽、自定义指令……8 个章节 + 高频面试题全解析

🔥Vue 面试进阶篇:Composition API、插槽、自定义指令......8 个章节 + 高频面试题全解析

上一篇讲了 Vue 核心基础,这一篇进入进阶------也是中高级前端面试的分水岭。


写在前面

这是 Vue 面试系列的第 2 篇 ([第 1 篇:核心基础 + 响应式 + 组件通信,点这里回看](#第 1 篇:核心基础 + 响应式 + 组件通信,点这里回看))。基础篇决定你能不能过初筛,进阶篇决定你能拿多少薪

Composition API 为什么诞生?watchwatchEffect 到底怎么选?作用域插槽怎么实现"逻辑复用"?自定义指令在业务里怎么用?keep-alive 底层到底缓存了什么?------ 这一篇全部讲透。

适合谁看?

  • ✅ 已经掌握 Vue 基础,想冲击中高级前端的同学
  • ✅ 被面试官问过"Composition API 解决了什么问题"答不上来的人
  • ✅ 写过 Vue3 但只用过 ref + onMounted,想系统补齐进阶 API 的开发者

阅读建议 :全文约 1.2 万字,代码片段均可直接运行。文末照例附「面试知识点速查表」,建议收藏后配合实际项目消化。


📑 目录

  1. [Composition API](#Composition API)
  2. 侦听器与生命周期钩子
  3. [组件进阶(异步组件 / keep-alive / Teleport / Suspense)](#组件进阶(异步组件 / keep-alive / Teleport / Suspense))
  4. 自定义指令
  5. 插槽
  6. 过渡与动画
  7. [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 的关键区别:

  1. 命名冲突 :Mixins 的属性/方法直接合并到组件,重名互相覆盖;Composables 返回值由调用方显式接收并命名
  2. 来源清晰const { x, y } = useMouse() 一眼看出数据来自哪
  3. 可传参、可组合:函数天然支持参数,也可以在一个 Composable 里调用另一个
  4. 类型友好:就是普通函数,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:watchwatchEffect 的区别?

答:

特性 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、只是给已有元素附加行为的,用指令:

  1. 自动聚焦 v-focus
  2. 点击外部关闭 v-click-outside
  3. 防抖/节流 v-debounce / v-throttle
  4. 权限控制 v-permission(无权限直接移除元素)
  5. 图片懒加载 v-lazy
  6. 拖拽 / 长按 / 复制粘贴 等 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:插槽的类型有哪些?

答:

  1. 默认插槽<slot> 不带 name,一个组件最多一个
  2. 具名插槽<slot name="xxx">,父组件用 <template #xxx> 对应填充
  3. 作用域插槽<slot :data="xxx">,子组件向父组件回传数据,父组件可自定义渲染

Q2:作用域插槽和普通插槽的区别? ⭐高频

答:方向相反------

  • 普通插槽 :父 → 子传内容(父组件编译好 HTML 塞进去)
  • 作用域插槽 :子 → 父传数据(子组件把数据通过插槽回传,父组件拿着数据自己渲染)

💡 加分话术 :典型场景是通用列表/表格组件------数据获取、分页、排序逻辑封装在组件内部,但每一行怎么渲染由使用方决定(有的要按钮、有的要图片),用作用域插槽把 itemindexactions 抛出去,一份组件适配 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-tov- 前缀会被 name 属性值替换。

💡 记忆技巧:-active 类管过程(写 transition 动画定义),-from-to 类管首尾状态(写起始/结束样式)

Q2:TransitionTransitionGroup 的区别?

答:

  • 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 新特性 / 响应式原理深入)整理中,关注追更
  • 有不同见解或补充,评论区见,我会持续更新 🙏
相关推荐
程序员江念1 小时前
【最经典的79个】软件测试面试题(内含答案)提前备战“金九银十”
软件测试·面试·职场和发展
SEO_juper1 小时前
外贸多语言站最隐蔽的流量杀手:hreflang 错了,谷歌把德语页推给美国人(附审计脚本)
开发语言·前端·python·seo·独立站·谷歌优化
亿元程序员1 小时前
为什么现在 AI 这么发达了,还要坚持手搓教程?
前端
三小河2 小时前
在 Codex 桌面端接入 DeepSeek 模型(CC Switch 代理中转)
前端·javascript·人工智能
shmily麻瓜小菜鸡2 小时前
JS/TS 易踩坑知识点 — 自动分号插入(ASI)
开发语言·javascript·ecmascript
CoderYanger2 小时前
前端基础——JavaScript(WebAPI)代码案例
java·开发语言·前端·javascript·css·前端框架·html5
恋猫de小郭2 小时前
Shopify 从 React Native 回到 Swift/Kotlin,但是你以为有手就行??
android·前端·ios
Bs_MoneyMagnet3 小时前
基于springboot+vue的家居生活商城平台的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring
BillKu3 小时前
Element Plus 的 el-tab-pane:lazy 的说明
前端·javascript·vue.js