Skip to main content

概览

Vue3 核心机制与演进

在面试中,经常会被问到“Vue3 到底比 Vue2 强在哪里?”。这不仅体现在响应式原理的变更,更体现在架构和编译层面的颠覆性优化。

1. Composition API (组合式 API)

  • 解决痛点:Vue2 的 Options API 强制将代码按 data, methods, computed 分割。当组件达到几百行时,同一个业务逻辑的相关代码会被拆得七零八落(逻辑碎片化)。Composition API(结合 <script setup>)允许将相同业务逻辑的代码聚合成一个个 Hook 函数,极大地提升了大型项目的可维护性。
  • 类型推导:Options API 中的 this 上下文非常复杂,对 TypeScript 的支持很不友好。Composition API 天然就是普通的 JS 函数,TS 类型推导极其完美。

2. 底层架构解耦 (Monorepo)

Vue3 的源码重构为了 Monorepo 结构,内部模块被拆分得极其细致:

  • @vue/reactivity:响应式系统。它甚至被设计成了框架无关的包,你完全可以在 Node.js 后端或者原生 JS 项目中独立引入这个包来做状态管理。
  • @vue/runtime-core@vue/runtime-dom:运行时的核心渲染逻辑与浏览器 DOM 平台的特定实现分离,这使得 Vue3 跨平台渲染(如 Weex, 小程序, Canvas)变得极其容易,只需重写平台的 Node 操作 API 即可。

3. 编译与性能飙升 (靶向更新)

这是 Vue3 性能远超 Vue2 的核心原因。Vue2 的 Diff 算法是全量遍历虚拟 DOM 树,而 Vue3 在编译阶段(AOT)做了大量极致的优化:

  • PatchFlags (靶向更新):编译器会为动态节点打上标记(如 TEXT = 1, CLASS = 2),在 Diff 时直接跳过静态节点,仅比对带有标记的属性。
  • Block Tree (区块树):将带有结构性指令(v-if, v-for)的节点划分为 Block,Block 会收集内部所有的动态节点形成一个扁平数组。更新时直接遍历这个数组,将树的遍历降维成线性遍历。
  • 静态提升 (Static Hoisting):将纯静态的节点提升到渲染函数之外,每次渲染直接复用。

从 Mixin 到 Composition API:逻辑复用的演进

Mixin

vue2 的 mixin 提供了一种分发 Vue 组件可复用功能的方式。其本质是在组件实例化前进行 mergeOptions(选项合并)。

四个核心合并策略:

  • 替换型(props, methods, computed):简单对象合并,冲突时组件自身优先级最高。
  • 合并型(data):递归合并,冲突时以组件自身为准。
  • 队列型(生命周期, watch):合并为数组,按顺序依次调用(全局 Mixin -> 组件 Mixin -> 组件自身)。
  • 叠加型(components, filters, directives):通过原型链委托实现,不会相互覆盖,而是形成链条。

为什么 Vue3 弃用 Mixin?(与 React HOC 的共同困境)

虽然 Mixin 解决了逻辑复用,但在中大型项目中,它与 React 早期推崇的高阶组件 (HOC) 面临着几乎相同的三个致命问题:

  1. 命名冲突 (Naming Collisions): 多个 Mixin 注入同名属性时会发生静默覆盖;HOC 注入同名 Props 也会相互顶替。
  2. 来源不明 (Implicit Dependencies): 在组件模板中使用一个变量,你无法一眼看出它是来自哪个 Mixin 还是哪个 HOC 包装器,调试如同大海捞针。
  3. 逻辑碎片化与灵活性差: Mixin 和 HOC 都是“被动注入”,很难根据组件运行时的状态动态调整逻辑,且容易造成组件树层级过深(HOC 尤甚,产生 Wrapper Hell)。

终极方案:Composition API / Hooks

Vue3 通过 Composition API 彻底终结了 Mixin,正如 React 用 Hooks 替代了 HOC。

  • 显式导入:变量来源清晰,一眼定位。
  • 解构重命名:完美解决命名冲突。
  • 函数式组合:逻辑可以像普通函数一样传参、嵌套,极大地提升了灵活性和类型推导友好度。

面试总结:从 Mixin/HOC 向 Composition API/Hooks 的转变,本质上是前端开发范式从“基于配置的隐式注入”“基于函数的显式组合”的跨时代跨框架进化。

组件通信

  1. 父子组件props / emit
  2. 跨层级透传 (Provide / Inject):底层原理是将提供的数据挂载到当前组件实例的 provides 对象上。子孙组件在 inject 时,会沿着组件的父链向上查找(通过原型链委托机制),直到找到最近的 provider。非常适合封装 UI 组件库(如 Element Plus 的 Form 表单上下文传递)。
  3. 全局状态共享:Pinia(Vue3 官方推荐,取代 Vuex,抛弃了繁琐的 mutations,完美支持 TS)。

高频常见考点汇总

这里补齐了生命周期、指令原理等面试中极其常见的零碎考点。

1. 生命周期 (Vue2 vs Vue3)

面试题:Vue 的生命周期有哪些?请求数据放在哪个钩子?

Vue2(Options)Vue3(Composition)时机
beforeCreatesetup 本身)实例初始化,data/methods 未就绪
createdsetup 本身)data/methods 就绪,未挂载 DOM
beforeMountonBeforeMount挂载前
mountedonMountedDOM 挂载完成,可访问真实 DOM
beforeUpdateonBeforeUpdate数据变、DOM 更新前
updatedonUpdatedDOM 更新后
beforeDestroyonBeforeUnmount卸载前(清理定时器/事件)
destroyedonUnmounted卸载后
  • 请求数据:在 created / setup 即可发起(此时更早发起请求,且对 SSR 友好);如果请求依赖于真实 DOM(比如要获取图表的挂载点),则放在 mounted
  • <keep-alive> 额外提供了 activated / onActivateddeactivated / onDeactivated

2. computed vs watch vs watchEffect

面试题:computed 和 watch 的区别?

  • computed(计算属性):有缓存,只有当依赖的数据变化时才会重新计算;必须有返回值;适合「由已有数据派生出新数据」。
  • watch(侦听器)惰性执行,主要用于在「数据变化时执行副作用」(如发异步请求、操作 DOM);可以拿到新旧值。
  • watchEffect(Vue3 新增):立即执行一次并自动收集回调内用到的响应式依赖,依赖变化时再次执行;不需要显式指定依赖数组,且拿不到旧值。

代码示例(Vue 3 Composition API):

<script setup>
import { ref, computed, watch, watchEffect } from "vue";

const count = ref(0);
const keyword = ref("");

// 1. computed: 依赖 count 派生出 doubleCount,具有缓存特性
const doubleCount = computed(() => count.value * 2);

// 2. watch: 明确指定侦听 keyword,执行副作用(如请求),可获取新旧值
watch(keyword, (newValue, oldValue) => {
console.log(`发送网络请求,搜索:${newValue},旧值为:${oldValue}`);
});

// 3. watchEffect: 立即执行一次,并自动追踪内部用到的 count,不需要显式指定依赖
watchEffect(() => {
console.log(`当前 count 的值是:${count.value} (会被自动依赖收集)`);
});
</script>

3. v-model 原理

面试题:v-model 的本质是什么?

本质是语法糖属性绑定 + 事件监听

  • Vue2 组件:默认等价于 :value + @input
  • Vue3 组件:默认等价于 :modelValue + @update:modelValue,并且支持多个 v-model(如 v-model:titlev-model:content),这直接取代了 Vue2 的 .sync 修饰符。

4.nextTick 原理与异步更新队列

在 Vue 中,当你修改了响应式数据时,视图不会立即同步更新。Vue 会将所有由数据变化引发的组件渲染任务(Watcher / Effect)推入一个异步的微任务队列(异步更新队列)中。这样做是为了避免同一个数据在一次事件循环中被多次修改而导致无意义的重复渲染(批量更新 / 批处理)

nextTick 的底层原理就是利用了 JavaScript 的 Event Loop 机制(优先使用 Promise.then,降级回退使用 MutationObserversetTimeout),将你的回调函数也推入微任务队列。

面试题:为什么使用 nextTick 必须放在修改响应式数据之后?

// ❌ 错误示范:拿不到最新 DOM
nextTick(() => {
console.log(document.getElementById("app").innerHTML);
});
this.name = "新名字";

// ✅ 正确示范:能拿到最新 DOM
this.name = "新名字";
nextTick(() => {
console.log(document.getElementById("app").innerHTML);
});

原理解析: 当你执行 this.name = '新名字' 时,Vue 会触发 trigger,把负责渲染视图的 flushJobs 任务推入微任务队列。 接着执行 nextTick,你的回调函数也会被推入微任务队列。 微任务队列是先进先出(FIFO)的。只要你把 nextTick 写在修改数据之后,你的回调就一定排在视图更新任务 flushJobs 的后面。等轮到你的回调执行时,DOM 已经被 Vue 更新完毕了!

Vue3 中 nextTick 的精简源码:

// currentFlushPromise 是 Vue 内部视图更新的 Promise 链
const p = currentFlushPromise || resolvedPromise;

export function nextTick<T = void>(
this: T,
fn?: (this: T) => void
): Promise<void> {
// 把你的回调追加到当前视图更新 Promise 的 then 后面
return fn ? p.then(this ? fn.bind(this) : fn) : p;
}

其他

  • v-if vs v-showv-if 是真正的销毁和重建组件(会触发完整的生命周期),v-show 仅仅是切换 CSS 的 display。频繁切换用 v-show 性能更好。
  • 为什么 data 必须是函数:组件可能会被实例化多次,如果 data 是一个普通对象,所有实例将共享同一份数据(内存引用相同)。使用函数返回一个新对象,保证了每个组件实例都有自己独立的作用域数据。
  • scoped CSS 原理:Webpack / Vite 在编译时,会给当前组件的所有 DOM 元素加上一个唯一的自定义属性(如 data-v-xxx),同时在编译后的 CSS 选择器末尾也加上这个属性选择器,从而实现样式隔离。
  • 为什么 Vue3 推荐 Pinia 取代 Vuex?:Pinia 砍掉了 Vuex 极其繁琐的 mutations,只有 state / getters / actions(actions 同步异步都可以写);提供了完美的 TypeScript 类型推导;且不需要嵌套模块,结构更扁平。

一些问题

v-show 对比 v-if

v-show 只是切换元素的 display,v-if 会重建虚拟 dom,会触发组件的一系列生命周期。如果需要频繁切换,就用 v-show

参考 https://cn.vuejs.org/guide/essentials/conditional.html#v-if-vs-v-show

为什么 v-for 不建议和 v-if 一起使用?

  • vue2: v-for 优先级比 if 高,所以会多做一些无意义的遍历。可以在外层嵌套 <template v-if> 或者通过 computed 提前过滤数组
  • vue3: if 优先级更高,但是此时 v-if 的条件将无法访问到 v-for 作用域内定义的变量别名,因此仍然不推荐一起使用

data 为什么是一个函数?

参考 Vue 关于 data 为什么是函数这件事

watch 和 watchEffect 的区别

参考 https://cn.vuejs.org/guide/essentials/watchers.html#watcheffect

虚拟 DOM 真的比操作真实 DOM 快吗?(经典陷阱题)

答案是:不一定。

  • 首次渲染:虚拟 DOM 甚至会更慢。因为除了创建真实 DOM 之外,还需要额外创建一层 VNode 对象。
  • 少量更新:如果只是修改一个节点的文本,原生 DOM 操作(直接定位节点并修改)绝对是最快的。虚拟 DOM 需要经过重新生成 VNode 树、执行 Diff 算法、最后再更新 DOM 这几步,必然有额外开销。
  • 为什么还要用虚拟 DOM?
    1. 跨平台能力:虚拟 DOM 抽象了渲染层,使得一套 Vue/React 代码可以渲染到浏览器、App (Weex/React Native)、甚至小程序和 Canvas 上。
    2. 批量合并更新:在复杂的大量更新场景下,虚拟 DOM 会收集所有变更,通过 Diff 算法计算出最小的更新代价,然后统一进行一次真实 DOM 操作,避免了频繁操作 DOM 导致的严重回流和重绘。
    3. 声明式编程:解放了开发者的心智,不需要手动追踪数据变化去精准操作 DOM。

Vue 项目中常见的性能优化策略有哪些?

  1. 路由懒加载:使用 const Home = () => import('./Home.vue'),配合 Webpack/Vite 实现代码分割(Code Splitting),减小首屏加载体积。
  2. 异步组件:使用 defineAsyncComponent 延迟加载那些首屏不需要立即渲染的重型组件(如弹窗、富文本编辑器)。
  3. v-memo (Vue3.2+):对于渲染包含大量静态内容或极少改变的超大列表,可以使用 v-memo 缓存模板树。只要依赖项不变,就直接跳过重新渲染。
  4. 长列表虚拟滚动:当遇到万级数据的列表时,使用 vue-virtual-scroller 等库只渲染可视区域的 DOM。
  5. 合理使用 computed 和 watch:避免在 computed 中执行昂贵的操作且不返回结果;及时销毁不必要的全局监听或定时器(在 onUnmounted 中)。
  6. 图片懒加载与 CDN:大图片使用 v-lazy 指令懒加载,静态资源尽量走 CDN。