Vue 面试高频知识点详解

困难 🔴Vue 生态
10 个标签
预计阅读时间:115 分钟
Vue面试题高频考点Vue2Vue3响应式原理Composition APIPiniaVue Router性能优化

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 重新求值。

javascriptCode
// 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 个变异方法」来实现数组响应式。

javascriptCode
// 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 的局限性:

1.无法检测对象属性的新增或删除(需要 Vue.set / Vue.delete)
2.无法检测通过索引直接修改数组元素(arr[0] = x)或修改 length
3.初始化时需要递归遍历所有属性,数据量大时性能开销大

Vue3 响应式原理:

Vue3 使用 ES6 Proxy 代理整个对象,配合 Reflect 完成读写。Proxy 支持 13 种拦截操作,能天然处理属性新增、删除、数组索引、length 变化。依赖收集通过全局的 WeakMap(targetMap)结构存储:target → key → Set(effect)。

javascriptCode
// 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) |

常见追问:

Q:为什么 Vue3 不用 Proxy polyfill 支持 IE?A:Proxy 的能力(如拦截任意 key 访问)无法用 ES5 模拟,因此 Vue3 直接放弃 IE。
Q:Proxy 只能代理一层,深层对象怎么办?A:Vue3 采用懒代理,在 get 时判断返回值是对象就递归 reactive,只有真正访问到才代理,比 Vue2 一次性递归更高效。
Q:ref 的响应式怎么实现?A:ref 用一个包含 value 的对象,通过 get value / set value 的类访问器实现,内部同样调用 track/trigger。

加分项: 提到 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 |

javascriptCode
// 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 {};
  }
};

父子组件生命周期执行顺序:

javascriptCode
// 挂载阶段(由外到内创建,由内到外挂载)
// 父 beforeCreate -> 父 created -> 父 beforeMount
//   -> 子 beforeCreate -> 子 created -> 子 beforeMount -> 子 mounted
// -> 父 mounted

// 更新阶段
// 父 beforeUpdate -> 子 beforeUpdate -> 子 updated -> 父 updated

// 销毁阶段
// 父 beforeDestroy -> 子 beforeDestroy -> 子 destroyed -> 父 destroyed

常见追问:

Q:为什么 API 请求推荐放在 created 而不是 mounted?A:created 时数据已初始化但 DOM 未渲染,尽早发请求可减少白屏时间;如果请求结果需要操作 DOM,则放 mounted。SSR 环境下没有 mounted,只能用 created。
Q:mounted 一定能拿到所有子组件渲染完的 DOM 吗?A:父 mounted 时子组件已挂载,但如果子组件里有异步渲染(如异步组件、v-if 异步数据),需要 nextTick 才能拿到。
Q:keep-alive 缓存的组件用什么钩子?A:activated / deactivated,而非 mounted / unmounted。

加分项: 提到 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 | 支持 | 不支持 |

| 适用场景 | 条件很少改变 | 需要频繁切换 |

vueCode
<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>

常见追问:

Q:v-for 和 v-if 能一起用吗?A:不推荐。Vue2 中 v-for 优先级高于 v-if,导致每次循环都判断,浪费性能;Vue3 中 v-if 优先级更高,会报错拿不到循环变量。正确做法是用 computed 先过滤,或外层包一层 template 放 v-if。
Q:v-show 能用于组件吗?A:可以,但组件不会走 activated/deactivated,只是根元素被隐藏。

加分项: 提到 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 操作的复杂度。

javascriptCode
// 虚拟 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。

常见追问:

Q:虚拟 DOM 一定比直接操作真实 DOM 快吗?A:不一定。对于简单、少量、可精确控制的 DOM 操作,手写原生更快。虚拟 DOM 的意义是「在复杂应用中,用可接受的性能换取可维护性和跨平台」。
Q:Vue3 对虚拟 DOM 做了什么优化?A:编译期打 PatchFlag 标记动态节点、静态提升、Block Tree,使 diff 时跳过静态内容,只比对动态部分。

加分项: 提到 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(父子通信,最常用)

vueCode
<!-- 父组件 -->
<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(跨层级通信)

javascriptCode
// 祖先组件提供数据(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 引用子组件(父调用子的方法/属性)

vueCode
<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(任意组件通信)

javascriptCode
// 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(全局状态管理)

javascriptCode
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 | 全局 | 任意 | 用户信息、购物车等全局状态 |

常见追问:

Q:provide/inject 是响应式的吗?A:如果 provide 的是 ref/reactive 则响应式;provide 普通值则不是。推荐 provide 响应式数据并用 readonly 包裹。
Q:Vue3 为什么移除了 $on/$off?A:EventBus 模式难以维护、事件散乱、易泄漏,官方建议用外部库(mitt)或状态管理替代。

加分项: 提到 $attrs 在 Vue3 中合并了 $listeners,配合 inheritAttrs: false 可做属性透传;大型项目优先 Pinia,避免滥用 EventBus 造成数据流混乱。

6. props 和 data 有什么区别?父组件能修改子组件 props 吗?

核心答案:

props 是父组件传入的外部数据,遵循单向数据流,子组件不能直接修改;data 是组件私有的内部状态,可自由修改。

| 特性 | props | data |

|------|-------|------|

| 数据来源 | 父组件传递 | 组件内部定义 |

| 修改权限 | 只读(不能直接改) | 可读可写 |

| 响应式 | 是 | 是 |

| 用途 | 接收外部配置 | 管理内部状态 |

| 命名冲突 | props 与 data 不能同名 | - |

vueCode
<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>

常见追问:

Q:为什么 props 要单向数据流?A:保证数据流向清晰可追踪,避免子组件意外修改导致父组件状态不可预测。
Q:props 传对象/数组时子组件改了内部属性会怎样?A:由于引用传递,子组件修改对象内部属性会影响父组件,虽不报错但违反规范,应避免。

加分项: 提到 props 校验的完整写法(type、required、default、validator);对象/数组的 default 必须用工厂函数返回。

三、计算属性与监听器

7. computed 和 watch 的区别是什么?

核心答案:

computed 是「计算属性」,有缓存,依赖不变则不重新计算,用于派生状态;watch 是「侦听器」,无缓存,用于监听数据变化后执行副作用(异步、复杂逻辑)。

| 特性 | computed | watch |

|------|----------|-------|

| 缓存 | 有(依赖不变不重算) | 无 |

| 返回值 | 必须有返回值 | 无返回值 |

| 异步 | 不支持 | 支持 |

| 触发时机 | 惰性求值(被访问才算) | 数据变化立即执行 |

| 多依赖 | 自动收集多个依赖 | 需显式指定 |

| 适用场景 | 派生数据、模板计算 | 副作用、异步请求、DOM 操作 |

vueCode
<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>

常见追问:

Q:什么时候用 computed,什么时候用 watch?A:需要根据现有数据「算出一个新值」用 computed;需要在数据变化时「执行一段逻辑(尤其异步)」用 watch。
Q:computed 为什么有缓存?A:computed 内部是一个带 dirty 标记的 effect,依赖变化时才把 dirty 置真,下次访问才重新求值,否则返回缓存值。

加分项: 提到 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,更易理解 |

| 学习曲线 | 平缓 | 稍陡 |

| 适用场景 | 中小型、简单组件 | 中大型、逻辑复杂组件 |

vueCode
<!-- 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):

javascriptCode
// 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);

常见追问:

Q:Composition API 会导致代码更零散吗?A:不会,反而更聚合。可以按逻辑关注点组织,配合 composable 抽取,比 mixin 更可控。
Q:script setup 和普通 setup 区别?A:script setup 是编译时语法糖,无需 return,变量自动暴露给模板,性能更好、代码更简洁。

加分项: 提到两者可混用;提到 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 |

javascriptCode
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.count

composable 返回值为什么常用 toRefs:

javascriptCode
function useFeature() {
  const state = reactive({ loading: false, data: null });
  // 返回时用 toRefs,让调用方可以解构而不丢响应式
  return { ...toRefs(state) };
}
// 调用方
const { loading, data } = useFeature(); // 仍是响应式的 ref

常见追问:

Q:什么时候用 ref,什么时候用 reactive?A:基本类型必须用 ref;对象两者皆可。团队常约定统一用 ref(心智一致、可整体替换),或用 reactive 组织一组相关状态。
Q:ref 在模板里为什么不用 .value?A:模板编译时会自动解包顶层 ref,但嵌套在对象里的 ref 不会自动解包。

加分项: 提到 shallowRef/shallowReactive 只做浅层响应,用于大对象/第三方实例优化性能;提到 isRef/unref/isReactive 等工具函数。

10. watch 和 watchEffect 有什么区别?

核心答案:

watch 需要显式指定侦听源,能拿到新旧值,惰性执行(默认不立即执行);watchEffect 自动收集回调内用到的响应式依赖,立即执行一次,拿不到旧值。

| 特性 | watch | watchEffect |

|------|-------|-------------|

| 依赖来源 | 显式指定 | 自动收集回调内依赖 |

| 新旧值 | 能拿到 newVal/oldVal | 拿不到 |

| 立即执行 | 默认否(可配 immediate) | 是(立即执行一次) |

| 惰性 | 是 | 否 |

| 适用场景 | 精确监听特定数据、需要旧值 | 依赖多、不关心旧值的副作用 |

javascriptCode
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();

常见追问:

Q:watchEffect 如何清理副作用?A:回调接收 onCleanup 参数,在依赖变化或组件卸载前调用,用于清理定时器、取消请求。
Q:watch 监听 reactive 对象为什么 oldVal 和 newVal 一样?A:因为是同一个引用对象,深度变化时新旧值指向同一对象。要拿旧值需监听其副本或用 getter 返回基本类型。

加分项: 提到 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 更新后执行。

javascriptCode
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
  });
};

异步更新队列简化实现:

javascriptCode
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());
}

常见追问:

Q:为什么 Vue 要异步更新 DOM?A:批量合并,避免同一 tick 内多次数据变化触发多次渲染,大幅提升性能(如循环里改 100 次只渲染 1 次)。
Q:nextTick 用的宏任务还是微任务?A:优先微任务(Promise.then),降级到 MutationObserver、setImmediate、setTimeout(Vue2 有降级策略,Vue3 直接用 Promise)。

加分项: 提到修改数据后立即操作 DOM(如获取元素高度、聚焦输入框、初始化基于新 DOM 的第三方库)是 nextTick 的典型场景;提到 watch 的 flush: 'post' 本质也是利用了这个机制。

五、渲染机制与编译优化

12. key 的作用是什么?为什么 v-for 不能用 index 作为 key?

核心答案:

key 是 VNode 的唯一标识,diff 算法用它判断新旧节点是否为「同一个节点」,从而决定是复用、移动还是重建。用 index 作 key 在列表顺序变化(增删、排序)时会导致复用错误,引发状态错乱和性能下降。

为什么不能用 index:

当在列表中间插入或删除元素时,后续元素的 index 全部改变,Vue 会认为「这些位置的节点变了」,导致本可以复用的节点被错误复用(比如输入框内容、勾选状态串位),甚至触发不必要的重新渲染。

vueCode
<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 直接复用移动。

常见追问:

Q:什么情况下用 index 作 key 没问题?A:列表纯静态展示、不涉及增删排序、且列表项无内部状态时,用 index 影响不大。但仍建议用稳定唯一值。
Q:不写 key 会怎样?A:Vue 默认使用「就地复用」策略,性能上对无状态列表可以,但对有状态列表会出问题,且开发环境会警告。

加分项: 提到 key 也可用于强制重新渲染组件(改变 key 让 Vue 销毁重建);提到 key 必须唯一且稳定,不能用随机数(每次都变会全部重建)。

13. Vue 的 diff 算法(patch)是怎么工作的?

核心答案:

Vue 的 diff 采用「同层比较 + 双端比较」策略,只在同一层级对比新旧 VNode,不跨层级移动,将 O(n³) 的树 diff 降到 O(n)。Vue2 用双端 diff(新旧列表各设头尾指针,四种命中方式);Vue3 用快速 diff(预处理头尾 + 最长递增子序列求最小移动)。

Vue2 双端 diff 四种命中:

1.旧头 vs 新头:命中则复用,双指针后移
2.旧尾 vs 新尾:命中则复用,双指针前移
3.旧头 vs 新尾:命中则把旧头节点移到末尾
4.旧尾 vs 新头:命中则把旧尾节点移到开头
5.四种都不命中:用 key 建映射查找可复用节点,否则创建新节点
javascriptCode
// 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 跳过静态节点 |

| 性能 | 较好 | 更优 |

常见追问:

Q:为什么 diff 只做同层比较?A:真实场景中跨层级移动 DOM 极少,同层比较把复杂度从 O(n³) 降到 O(n),是工程上的权衡。
Q:最长递增子序列在 diff 中的作用?A:找出不需要移动的最大有序子集,其余节点才需要移动,最小化 DOM 操作次数。

加分项: 提到 Vue3 配合编译期的 Block 和 PatchFlag,diff 时只需遍历动态节点数组(dynamicChildren),跳过静态子树,进一步提速。

14. Vue3 编译优化有哪些?(静态提升、PatchFlag、Block Tree)

核心答案:

Vue3 借助模板可静态分析的特性,在编译期做了大量优化:静态提升(hoistStatic)、补丁标记(PatchFlag)、Block Tree、事件缓存(cacheHandlers)、静态节点树内联(tree flattening),使运行时只处理动态内容。

1. 静态提升(Static Hoisting)

把不会变化的静态节点提升到 render 函数外,只创建一次,后续渲染复用。

javascriptCode
// 编译前模板
// <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 时只比对被标记的部分。

javascriptCode
// 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 | 收集动态节点扁平化 | 跳过静态子树遍历 |

| 事件缓存 | 缓存内联函数 | 避免子组件误更新 |

常见追问:

Q:为什么 React 做不到这些编译优化?A:JSX 表达能力太灵活,静态分析困难;Vue 模板有约束,编译器能精确识别静态/动态部分。
Q:这些优化需要开发者手动开启吗?A:不需要,全部在编译期自动完成,开发者写普通模板即可享受。

加分项: 提到 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 被移除。

vueCode
<!-- 原生元素上 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"
/>
vueCode
<!-- 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>
vueCode
<!-- 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 | 存在 | 移除 |

常见追问:

Q:v-model 可以加修饰符吗?A:可以,如 .trim / .number / .lazy;Vue3 还支持自定义修饰符,通过 modelModifiers prop 拿到。
Q:为什么 checkbox、radio、select 的 v-model 表现不同?A:v-model 会根据元素类型编译成不同的属性和事件(checkbox 用 checked + change,select 用 value + change)。

加分项: 提到 Vue3.4+ 引入 defineModel() 宏,进一步简化组件双向绑定的书写。

16. slot 插槽是什么?作用域插槽如何使用?

核心答案:

插槽是组件内容分发机制,让父组件向子组件「插入模板内容」。分为默认插槽、具名插槽和作用域插槽。作用域插槽的关键是「子组件把内部数据传给父组件的插槽模板」,实现数据向上暴露、渲染由父控制。

vueCode
<!-- 子组件:定义插槽 -->
<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 }" |

常见追问:

Q:作用域插槽的典型应用?A:封装表格、列表等 UI 组件时,让使用方自定义每行/每列的渲染(如 Element Table 的 template slot-scope)。
Q:Vue3 中插槽是怎么实现的?A:插槽被编译成子组件的 \$slots 对象,每个插槽是一个返回 VNode 的函数,作用域插槽即带参数的函数。

加分项: 提到 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 属性)控制缓存数量。

vueCode
<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(不销毁) |

常见追问:

Q:keep-alive 缓存的组件数据不刷新怎么办?A:在 onActivated 里重新拉取数据,或用路由 meta 判断是否需要刷新。
Q:include/exclude 匹配的是什么?A:匹配组件的 name 选项(Vue3 script setup 需用 defineOptions({ name }) 或额外 script 块声明 name)。

加分项: 提到 keep-alive 配合路由缓存列表页、详情页返回不丢状态的实战场景;提到 max 用 LRU 淘汰最久未使用的缓存;提到 keep-alive 是抽象组件,不会渲染成真实 DOM 节点。

18. mixin 和 composable(自定义 hook)有什么区别?

核心答案:

两者都用于逻辑复用。mixin 是 Options API 的复用方式,通过合并选项注入,存在命名冲突、来源不清晰、隐式依赖等问题;composable 是 Composition API 的复用方式,是普通函数,输入输出明确、无命名冲突、可组合。

| 对比项 | mixin | composable |

|--------|-------|------------|

| 本质 | 选项对象合并 | 普通函数 |

| 命名冲突 | 有(多 mixin 同名覆盖) | 无(解构可重命名) |

| 数据来源 | 隐式,不清楚来自哪个 mixin | 显式,一眼看出来源 |

| 类型推导 | 差 | 好 |

| 可组合性 | 差 | 强(函数可互相调用) |

| 传参 | 不支持 | 支持 |

javascriptCode
// 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);

常见追问:

Q:mixin 的选项合并规则?A:data 递归合并(组件优先),生命周期钩子合并成数组都执行(mixin 先执行),methods/components/directives 冲突时组件优先。
Q:composable 命名约定?A:以 use 开头(useXxx),返回响应式数据和方法。

加分项: 提到 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) |

javascriptCode
// 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 };
});
javascriptCode
// 组件中使用 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);

常见追问:

Q:Pinia 为什么去掉 mutations?A:mutations 唯一价值是让 DevTools 追踪同步变更,但 Pinia 用其他方式实现了追踪,去掉后 API 更简单,且 action 支持同步和异步统一处理。
Q:Pinia 如何持久化?A:用 pinia-plugin-persistedstate 插件,把指定 store 同步到 localStorage/sessionStorage。

加分项: 提到 Pinia 支持多 store 互相调用(在一个 store 里 use 另一个 store);提到 Pinia 的 \$patch 批量修改、\$reset 重置、\$subscribe 订阅变化、\$onAction 拦截 action。

九、Vue Router

20. Vue Router 有哪些导航守卫?执行顺序是怎样的?

核心答案:

导航守卫分三类:全局守卫(beforeEach、beforeResolve、afterEach)、路由独享守卫(beforeEnter)、组件内守卫(beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave)。它们按固定顺序串行执行,常用于登录鉴权、权限控制、离开确认。

完整导航解析流程:

1.导航被触发
2.失活组件的 beforeRouteLeave
3.全局 beforeEach
4.重用组件的 beforeRouteUpdate
5.路由配置的 beforeEnter
6.解析异步路由组件
7.激活组件的 beforeRouteEnter
8.全局 beforeResolve
9.导航确认
10.全局 afterEach
11.DOM 更新
12.beforeRouteEnter 中传给 next 的回调(此时能访问组件实例)
javascriptCode
// 全局前置守卫:登录鉴权
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('确定离开吗?');
});

常见追问:

Q:为什么 beforeRouteEnter 拿不到 this?A:因为守卫执行时组件实例还没创建,需通过 next(vm => {...}) 回调访问实例。
Q:afterEach 为什么没有 next?A:它是后置钩子,导航已确认,无需再控制流程,常用于修改页面标题、埋点统计。

加分项: 提到 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 |

javascriptCode
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';

// hash 模式
const router = createRouter({
  history: createWebHashHistory(),
  routes
});

// history 模式
const router2 = createRouter({
  history: createWebHistory(),
  routes
});
javascriptCode
// 路由懒加载:动态 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);
javascriptCode
// 动态添加路由(常用于权限路由,登录后按角色注入)
const asyncRoutes = getRoutesByRole(userRole);
asyncRoutes.forEach(route => {
  router.addRoute(route);
});

常见追问:

Q:history 模式部署到 Nginx 怎么配置?A:try_files \$uri \$uri/ /index.html; 让所有未匹配的路径都返回 index.html。
Q:懒加载的好处?A:首屏只加载必要代码,其余路由按需加载,减小初始包体积,加快首屏。
Q:动态路由参数变化时组件会重新创建吗?A:不会,同一组件复用,需用 beforeRouteUpdate 或 watch route.params 响应变化。

加分项: 提到路由懒加载配合魔法注释 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 | 快 | 稍慢(服务端渲染耗时) |

| 可交互时间 | 需水合后 | 需水合后 |

javascriptCode
// 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 路由等。

常见追问:

Q:什么是 hydration(水合)?A:客户端接管服务端返回的静态 HTML,把事件监听、响应式状态「激活」,让页面可交互。水合不匹配会报警告。
Q:SSR、SSG、ISR 区别?A:SSR 每次请求实时渲染;SSG 构建时生成静态 HTML;ISR 增量静态再生,构建后按需/定时更新静态页。

加分项: 提到 SSR 中要避免使用 window/document(服务端没有),生命周期只有 beforeCreate/created 在服务端执行;提到用 SSG 处理内容型站点(博客、文档)性价比最高。

23. Teleport 和 Suspense 是什么?解决了什么问题?

核心答案:

Teleport 用于把组件的 DOM 渲染到指定的 DOM 节点(如 body),解决模态框、弹层被父级 overflow/z-index 限制的问题,同时逻辑仍留在组件树内。Suspense 用于协调异步组件的加载状态,在异步依赖 resolve 前显示 fallback 内容。

vueCode
<!-- 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>
vueCode
<!-- 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>

常见追问:

Q:Teleport 的 to 可以动态改变吗?A:可以,to 支持 CSS 选择器或 DOM 元素,也可用 disabled 属性动态开关传送。
Q:Suspense 稳定了吗?A:Suspense 在 Vue3 仍标记为实验性 API,生产使用需注意版本变化。

加分项: 提到 Teleport 常用于全局 Toast、Dialog、Tooltip;提到 Suspense 配合 async setup 和 defineAsyncComponent 使用,可搭配 onErrorCaptured 处理加载失败。

十一、性能优化实战

24. Vue 项目性能优化有哪些手段?请结合实际项目说明。

核心答案:

性能优化分为:加载优化(减小首屏包体积)、渲染优化(减少不必要的渲染)、响应式优化(减少响应式开销)、运行时优化(虚拟列表、防抖节流)。

1. 加载优化

javascriptCode
// 路由懒加载 + 组件懒加载
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. 渲染优化

vueCode
<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. 响应式优化

javascriptCode
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. 虚拟列表(长列表核心优化)

javascriptCode
// 只渲染可视区域的列表项,几万条数据只渲染几十个 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 | 减少代理开销 |

| 运行时 | 虚拟列表 | 长列表只渲染可视区 |

| 运行时 | 防抖节流 | 减少高频回调 |

常见追问:

Q:你在实际项目里做过哪些优化,效果如何?A:可举例「某后台列表页原本一次渲染 5000 行导致卡顿,引入虚拟列表后 DOM 节点从 5000 降到约 30,滚动帧率从 20fps 提升到 60fps」「首屏包从 3MB 通过路由懒加载 + 按需引入降到 800KB,首屏时间从 4s 降到 1.5s」。
Q:如何定位性能瓶颈?A:Chrome Performance 面板录制、Vue DevTools 的组件渲染耗时、Lighthouse 打分、webpack-bundle-analyzer 分析包体积。

加分项: 提到用 requestAnimationFrame 优化动画;提到图片懒加载、骨架屏提升体验;提到 Vue3 的编译优化本身已经减少了大量运行时开销。

25. 组件设计有哪些原则?如何设计一个可复用、可维护的组件?

核心答案:

好的组件应遵循:单一职责、高内聚低耦合、props 向下事件向上(单向数据流)、受控与非受控清晰、良好的默认值与校验、可扩展(插槽)、可测试。

设计原则清单:

| 原则 | 说明 |

|------|------|

| 单一职责 | 一个组件只做一件事,过大就拆分 |

| 单向数据流 | props 传入、emit 传出,不直接改 props |

| 受控设计 | 状态由父控制(v-model),组件更纯粹 |

| 插槽扩展 | 用插槽让使用方自定义内容,避免过多 props |

| 合理默认值 | props 提供默认值和校验,降低使用成本 |

| 逻辑抽离 | 复杂逻辑抽成 composable,视图与逻辑分离 |

| 命名规范 | 组件多单词命名,props 用 camelCase |

vueCode
<!-- 设计良好的按钮组件示例 -->
<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>

常见追问:

Q:什么时候该拆分组件?A:模板过长(几百行)、逻辑关注点混杂、有可复用片段、单元测试难写时就该拆。
Q:如何避免 props 爆炸?A:相关配置合并成对象 props,可变内容用插槽,行为用事件,而非堆砌大量布尔 props。

加分项: 提到「容器组件 vs 展示组件」分层思想;提到用 TypeScript 定义 props 类型(defineProps())提升可维护性;提到组件应有清晰的文档和 Storybook 用例。

26. Vue 的事件循环与异步更新机制是怎样的?

核心答案:

Vue 响应式数据变化后不会同步更新 DOM,而是把 watcher 加入队列,去重后在当前宏任务结束、微任务阶段统一 flush 更新。这基于 JS 的事件循环(同步任务 → 微任务 → 渲染 → 下一个宏任务)。

javascriptCode
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 已更新
}

常见追问:

Q:为什么用微任务而不是宏任务更新 DOM?A:微任务在当前宏任务结束后、浏览器渲染前执行,能更快更新且不阻塞渲染,减少延迟。
Q:watcher 去重是怎么做的?A:每个 watcher 有唯一 id,入队时检查是否已在队列,避免同一 watcher 重复更新。

加分项: 提到 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 组合式钩子 |

常见追问:

Q:Vue3 一定比 Vue2 快吗?A:多数场景更快(尤其大列表、复杂组件),得益于编译优化和 Proxy 懒代理;但小型简单页面差异不明显。
Q:Vue2 项目如何迁移 Vue3?A:可用官方迁移构建版(compat build)渐进迁移,逐步替换废弃 API。

加分项: 提到 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。

javascriptCode
// 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

常见追问:

Q:Vue2 中数组哪些操作不响应?A:通过索引设置元素(arr[0]=x)、修改 length(arr.length=0),需用 splice 或 \$set。
Q:为什么 Object.defineProperty 无法监听新增属性?A:它只能劫持初始化时已存在的属性,新增属性没有走 defineReactive。

加分项: 提到这是 Vue2 响应式的经典坑,Vue3 用 Proxy 从根本上解决;提到 Vue.delete 同理用于删除属性触发更新。

二、Vue3 编译优化深度追问

1. Vue3 相比 Vue2 在编译层面做了哪些优化?

核心答案: Vue3 引入了基于编译时静态分析的优化,主要包括静态提升(hoistStatic)、补丁标记(PatchFlag)、Block Tree(区块树)、事件缓存(cacheHandlers),把大量运行时的 diff 工作提前到编译期确定,从而只对"会变化"的节点做 diff。

原理展开:

静态提升:模板中不含动态绑定的节点(纯静态 DOM),会被提升到 render 函数之外,只创建一次,后续更新直接复用,不参与 diff。
PatchFlag:给动态节点打标记,标明它"哪部分会变"(class、style、props、text 等),diff 时只比较被标记的部分,而不是遍历所有属性。
Block Tree:将模板切分为一个个 Block,每个 Block 收集其内部的动态子节点到 dynamicChildren 数组,更新时直接遍历这个扁平数组,跳过静态节点层级,把树形 diff 变成线性 diff。
事件缓存:内联事件处理函数会被缓存,避免每次渲染都生成新函数导致子组件不必要更新。
htmlCode
<!-- 模板 -->
<div class="static">
  <span>纯静态文本</span>
  <p>{{ message }}</p>
</div>
javascriptCode
// 编译后(简化示意)
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 缓存 |

常见追问:

Q:PatchFlag 的值是怎么组合的?A:使用位运算按位或组合,例如同时有 class 和 style 变化时 flag = CLASS | STYLE,diff 时用按位与判断。
Q:为什么带 v-if/v-for 的节点会成为 Block?A:结构会动态变化的节点自身作为 Block,保证其子树的 dynamicChildren 能正确收集。

加分项: 提到这些优化让 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):

1.先做头头预处理:从头开始比较相同的节点,直接 patch 复用。
2.再做尾尾预处理:从尾开始比较相同的节点。
3.处理剩余:如果只剩新增则挂载,只剩删除则卸载。
4.处理未知序列:为剩余新节点建立 key→index 映射,求最长递增子序列(LIS),只移动不在 LIS 中的节点,把 DOM 移动次数降到最低。
javascriptCode
// 求最长递增子序列(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;
}

常见追问:

Q:为什么不用 key 会有问题?A:不写 key 时 Vue 默认按索引就地复用,列表顺序变化或中间插入删除时会导致状态错乱(如输入框内容串位)。
Q:为什么不能用 index 作 key?A:index 在增删排序后会变化,等价于没有稳定标识,同样导致复用错误。

加分项: 提到 LIS 的作用是找出"不需要移动的最长子序列",从而最小化 DOM 移动;提到 Vue2 用双端 diff(新旧头尾四个指针交叉比较)。

三、状态管理与工程化追问

3. Pinia 相比 Vuex 有哪些优势?

核心答案: Pinia 是 Vue 官方推荐的新一代状态管理库,相比 Vuex 去掉了 mutations,支持完整的 TypeScript 类型推导,天然支持组合式 API,没有嵌套模块的心智负担,且体积更小(约 1KB)。

代码示例:

javascriptCode
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 };
});
vueCode
<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 | 支持 | 支持 |

常见追问:

Q:Pinia 如何做持久化?A:通过 pinia-plugin-persistedstate 插件,或在 store 中监听变化写入 localStorage。
Q:为什么解构 store 会丢失响应式?A:store 是 reactive 对象,直接解构会拿到普通值,需用 storeToRefs 转成 ref。

加分项: 提到 Pinia 支持插件系统、SSR、热更新;提到多个 store 之间可以互相调用组合。

4. Vite 为什么比 Webpack 快?

核心答案: Vite 在开发环境利用浏览器原生 ES Module,不打包直接按需编译;使用 esbuild(Go 编写)做依赖预构建,比基于 JS 的 Webpack 快 10~100 倍;生产环境用 Rollup 打包。

原理展开:

Webpack:启动时需要先构建整个依赖图、打包成 bundle 才能启动 dev server,项目越大启动越慢。
Vite:启动时不打包,dev server 秒开;浏览器请求某个模块时才实时编译该模块(利用 esbuild 转译 TS/JSX);依赖(node_modules)用 esbuild 预构建成 ESM 并缓存。

对比表格:

| 维度 | Webpack | Vite |

|------|---------|------|

| 开发启动 | 秒~分钟级(随规模增长) | 恒定秒级 |

| HMR 速度 | 随模块数变慢 | 恒定快速 |

| 转译工具 | babel/ts-loader(JS) | esbuild(Go) |

| 生产打包 | Webpack | Rollup |

| 生态成熟度 | 极成熟 | 快速成熟中 |

常见追问:

Q:Vite 生产为什么不用 esbuild 打包?A:esbuild 的代码分割和 CSS 处理等功能尚不如 Rollup 成熟稳定,故生产用 Rollup。
Q:什么是依赖预构建?A:把 CommonJS/UMD 依赖转成 ESM、把多文件依赖合并成单文件,减少浏览器请求数。

加分项: 提到 Vite 首次访问会有依赖预构建耗时,之后走强缓存;提到 esbuild 不做类型检查,需配合 vue-tsc 单独校验类型。

四、手写实现类高频题

5. 手写一个简易的响应式系统

javascriptCode
// 依赖收集容器
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

javascriptCode
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. 手写虚拟列表核心逻辑

vueCode
<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. 如何设计一个可复用的组件?

核心原则:

1.单一职责:一个组件只做一件事,复杂 UI 拆成小组件组合。
2.单向数据流:props 向下传,事件向上抛,不直接修改 props。
3.插槽扩展:用具名插槽和作用域插槽提供定制点,而非堆砌 props。
4.受控与非受控:通过 v-model 支持受控,也支持内部默认状态。
5.可测试:逻辑抽到 composable,UI 与逻辑解耦,便于单测。
vueCode
<!-- 一个可复用的确认弹窗 -->
<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 数据来源清晰、无命名冲突、类型友好。

javascriptCode
// 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 };
}
vueCode
<script setup>
import { useMouse } from '@/composables/useMouse';
const { x, y } = useMouse(); // 一行复用,且自动管理生命周期
</script>

对比表格:

| 维度 | Mixin(Vue2) | Composable(Vue3) |

|------|---------------|--------------------|

| 数据来源 | 隐式合并,不清晰 | 显式解构,清晰 |

| 命名冲突 | 容易冲突且被覆盖 | 解构时可重命名 |

| 类型推导 | 差 | 完整 |

| 逻辑复用粒度 | 整个对象 | 任意函数组合 |

加分项: 提到 VueUse 库提供了几百个开箱即用的 composable;提到 composable 内部可以再调用其他 composable 实现逻辑组合。

面试技巧与总结

1.理解原理:不仅要会用 API,更要能讲清底层原理(响应式、diff、编译优化)。
2.对比分析:善于横向对比(Vue2 vs Vue3、computed vs watch、ref vs reactive)。
3.代码示例:能手写核心实现(简易响应式、nextTick、diff 思路)。
4.结合项目:用真实项目场景和具体数据佐证(性能优化前后对比)。
5.追问准备:每个知识点都想想面试官可能的追问,提前准备加分点。

知识点分类汇总

| 分类 | 核心知识点 | 高频程度 |

|------|-----------|----------|

| 响应式原理 | 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 | 极高 |

| 组件设计 | 单一职责、单向数据流、插槽扩展、可测试 | 中 |