vue3前10面试题

65 阅读12分钟

1. Vue 3 到底是怎么处理深层嵌套对象的响应式的?

你的直觉是对的,Vue 3 并不是完全不递归,而是将递归从“初始化阶段”挪到了**“获取阶段(Getter)”**。

核心机制:懒代理 (Lazy Tracking)
在 Vue 2 中,无论你是否用到某个嵌套属性,Vue 都会在页面加载时一次性把整个 data 对象递归个底朝天。

而在 Vue 3 中,响应式的处理过程是这样的:

当你使用 reactive 包裹一个多层嵌套的对象时,Vue 3 只会代理第一层。

只有当你真正访问(即触发 Getter)某个深层属性时,Vue 3 才会去判断这个属性的值是不是一个对象。

如果是对象,Vue 3 会在该时刻对这个子对象调用 reactive 进行代理,并返回这个代理后的对象。

为什么要这么做?
这种“按需代理”的策略带来了巨大的性能红利:

首次渲染更快:如果你的页面只展示了一个长列表中的几个字段,那么其他没被用到的深层嵌套数据永远不会被代理,节省了大量的 CPU 和内存。

避开无效计算:对于那些定义了但没在模板中使用的深层数据,Vue 3 完全不做处理。

面试官评价: 这一块你能答出 “按需代理” 或 “在 Getter 中递归”,基本就能通过原理关了。

2. “在 Vue 2 中我们习惯用 Mixins 来复用代码,但 Vue 3 强推 Composables(组合式函数)。请问从你的实战经验来看,Mixins 到底有哪些难以忍受的缺点?而 Composables 又是如何优雅地解决这些问题的?”

1. Mixins 的三大痛点(Vue 2 的噩梦)
在 Vue 2 中,如果你引入了多个 Mixin,项目很快就会变得难以维护:

命名冲突(Naming Collisions): 如果 mixinA 里有个变量叫 name,mixinB 里也叫 name,它们会互相覆盖,而你很难发现是谁覆盖了谁。

来源不清晰(Unclear Data Source): 当你打开一个组件页面,看到代码里写着 this.user,但在当前 .vue 文件里根本搜不到 user 是哪来的。你得一个一个去翻引用的 Mixin 文件,非常痛苦。

隐式依赖: Mixin 之间可能存在互相依赖,比如 mixinB 必须依赖 mixinA 里的某个变量才能运行,这种逻辑耦合是“隐形的”,极易报错。

2. Composables (组合式函数) 的降维打击
Vue 3 的 Composition API 借鉴了 React Hooks 的思想,通过函数导出的方式完美解决了上述问题:

显式解构,来源明确:

JavaScript

// 在组件中
const { user, getUser } = useUser(); 
const { list, getList } = useList();
// 一眼就能看出 user 来源于 useUser,list 来源于 useList
重命名机制: 如果两个函数导出的变量名重复了,你可以直接在解构时重命名: const { name: userName } = useUser();

极佳的类型推导: 因为 Composables 本质是普通的 JavaScript 函数,TypeScript 能非常完美地推导出变量类型,而 Mixins 在 TS 下几乎是“灾难”。

3. 在 Vue 3 ,我们发现 beforeCreate 和 created 这两个生命周期钩子‘消失’了。请问它们去哪了?或者说用什么代替了?

为什么在 Vue 3 中,我们要尽量把逻辑写在 setup 里面,而不是像以前那样分散在各种钩子函数里?”

我的回复: 第一点:为什么 Vue3 中 beforeCreatecreated 这两个钩子函数消失了?是因为它们直接被 setup 替代了。setup 可以直接执行初始化操作,直接执行初始化函数。所以它直接替代了 beforeCreatecreated 的作用。

第二点:为什么我们要尽量把逻辑写在 setup 里,而不是分散在各个钩子函数里?之前把逻辑分散在不同的钩子函数里,不利于代码的聚合和复用,耦合度也比较高。而都写在 setup 里面的话,有利于代码的逻辑聚合和复用。

1. 关于消失的钩子:执行时机的重合

你说得对,setup 的执行时机是在 beforeCreate 之前。在 Vue 2 中,这两个钩子主要用于初始化数据,而 Vue 3 的 setup 本身就是为了“设置”组件的响应式状态和功能。因此,setup 中直接写的代码,其效果等同于在 created 中执行。 这种简化减少了开发者在选择“该把初始化逻辑放哪”时的纠结。

2. 逻辑聚合:从“按选项组织”到“按功能组织”

这是 Composition API 的灵魂。

  • Vue 2 (Options API) :是“按代码类型”组织。比如:所有的数据在 data,所有的逻辑在 methods,所有的监听在 watch
  • Vue 3 (Composition API) :是“按业务逻辑”组织。

面试官追问: 既然你提到了逻辑聚合,那我们深入聊聊。在 Vue 3 中,如果你在一个非常复杂的组件里写了 500 行 setup 代码,它依然会变得像“面条代码”一样难以维护。 你会采取什么手段或模式,利用 Composition API 来避免 setup 变得过于臃肿?

如果在 setup 里写了 500 行代码,要如何避免过于臃肿?

核心思路就是拆分逻辑。这就好比 React 用 Hooks 来实现一样,我们可以把对应的一些业务逻辑封装成一个 Hook(在 Vue 里叫 Composables)。

比如这 500 行代码里,其中有 100 行是关于一个数据计算的逻辑,那你就把这个数据计算逻辑封装成一个 Hook 函数拆分出去;另外 100 行是关于那个业务页面显示的逻辑,这一行也封装成一个函数拆分出去。

这样就相当于把 500 行代码拆成了两到三个子逻辑,然后再通过导入这些子逻辑来进行引用,就可以实现代码的简洁。

针对这个问题,我会从以下三个层面来优化:

1. 抽离逻辑:Composables 模式(最核心)

我会按照功能维度将逻辑拆分成独立的 useXxx 函数(即 Composables)。

  • 做法:将相关的状态(ref/reactive)和方法(functions)封装在一个独立的 JS/TS 文件中并导出。
  • 效果setup 变成了一个“调度中心”,它只负责引入这些函数并组合它们。

2. 局部组件化

如果 setup 臃肿,通常意味着模板(template)也很臃肿。

  • 做法:检查是否有可以复用的 UI 片段,或者某些逻辑是否可以下放到子组件中。
  • 思路:通过 props 传递数据,emits 回传事件。保持单个组件的职责单一(SRP 原则)。

3. 利用文件组织结构(Hooks 目录)

在项目工程化层面上,我会建立一个 hooks/ 文件夹:

  • 全局 Hooks:通用的逻辑(如 useWindowSize, useAuth)放在 src 下的 hooks。
  • 局部 Hooks:仅针对该复杂页面的逻辑,可以在该页面目录下建一个文件夹,将逻辑拆分到多个小文件中。

第四题:Ref vs Reactive(避坑指南)

既然我们要把代码抽离到各个函数里,refreactive 的选择就非常关键了。

面试官追问:

  1. 为什么 ref 访问要加 .value 它的底层原理(RefImpl 类)是怎样的?
  2. 解构 reactive 对象会导致响应式丢失吗? 比如 const { count } = reactive({ count: 0 }),后续修改 count 会触发视图更新吗?如果丢失了,你会用哪个官方工具函数(如 toRefstoRef)来修复它?

为什么 rap 要加 .volume?这个它的底层原理我不知道;

解构 react 对象会导致响应式丢失吗?这个会导致响应式丢失,所以后续修改 count 不会导致视图更新。如果响应式丢失了,该用哪个函数来修复?

如果是 react native 的话,我会用 two fingers 函数来修复,但不确定 two fingers 能不能用来修复这个问题。

1. 为什么 ref 要加 .value?(底层原理)

在 JavaScript 中,基本类型(如 String, Number, Boolean)是按值传递的,它们不是对象,无法被 Proxy 直接代理。

Vue 3 的做法: 为了让基本类型也变响应式,Vue 3 将它包装在一个对象里。这个对象有一个属性叫 value

  • 底层实现:当你写 ref(0) 时,Vue 内部创建了一个 RefImpl(Ref Implementation)类的实例。
  • 劫持方式:这个实例使用了传统的 getter/setter。当你访问 .value 时触发 track(追踪依赖),当你修改 .value 时触发 trigger(触发更新)。

为什么模板里不用加? 因为 Vue 在编译模板时,会自动对顶层的 ref 进行解包(Unwrapping) ,所以你直接写 {{ count }} 即可。


2. reactive 解构导致丢失响应式的原因

你说得对,直接解构会丢失响应式。

原因: reactive 的本质是 Proxy 代理。当你写 const { count } = reactive({ count: 0 }) 时,相当于把一个普通的数字 0 赋值给了变量 count。 这个 count 现在就是一个普通的局部数字变量,它和原来的 Proxy 对象完全断开了联系。

3. 如何修复?(toRefs 与 toRef)

你提到的那个功能,正确的函数是 toRefs

  • toRefs 的作用:它会将一个 reactive 对象转换为一个普通对象,但这个普通对象的每个属性都是一个指向原始对象属性的 ref

  • 代码示例

    JavaScript

    const state = reactive({ count: 0, name: 'Gemini' });
    
    // 使用 toRefs 包装后再解构
    const { count, name } = toRefs(state);
    
    // 此时 count 是一个 ref,修改 count.value 会同步修改 state.count
    count.value++; 
    

1. toRef 是干嘛的?

toRef 用于为响应式对象上的 某一个 属性创建一个 ref。这个新创建的 ref 会保持与源属性的双向连接

它的标准语法: const propRef = toRef(object, 'key')

2. 它的两个核心应用场景

场景 A:只解构单个属性,保持响应式

如果你只需要 reactive 对象里的某一个字段,且希望这个字段被抽离出来后依然是响应式的,就用 toRef

JavaScript

const state = reactive({ count: 0, name: 'Gemini' });

// 如果用 const count = state.count; -> 只是拷贝了一个值,丢失响应式
// 使用 toRef:
const countRef = toRef(state, 'count');

countRef.value++; 
console.log(state.count); // 也会变成 1,保持了同步

场景 B:为可能不存在的属性提供默认响应式(最强用途)

这是 toRef 相比于 toRefs 的独特之处。如果源对象上目前没有这个 key,toRef 依然会创建一个可用的 ref。而 toRefs 只能转换对象上已有的 key。

第五题:Vue 3 的“快”——模板编译优化

聊完了 API,我们聊聊性能。Vue 3 号称比 Vue 2 快很多,其中一个重要原因就是编译时优化

面试官: “请问 Vue 3 的模板编译器做了哪些优化来提升渲染性能?你能简单解释一下什么是 静态提升 (Static Hoisting)补丁标记 (Patch Flags) 吗?”

静态提升其实就是 Vue3 把一些写死的、不需要变动的变量直接提升到代码顶部;补丁标记则是 Vue3 会给特定的动态内容打上补丁(做标记),静态内容就不会打补丁。除此之外的相关细节,我暂时不太清楚,需要进一步解释。

1. 静态提升 (Static Hoisting)

在 Vue 2 中,无论你的 HTML 片段是否是静态的,每次组件更新重新渲染时,它都会被重新创建一遍虚拟 DOM(VNode)。

  • Vue 3 的做法:编译器发现有些元素(比如一个只有文字的 <span>)永远不会变,就会把它提到 render 函数之外定义。
  • 结果:这个静态节点只会被创建 一次。之后无论组件更新多少次,Vue 都会直接复用同一个内存地址的 VNode,完全跳过了创建过程。

2. 补丁标记 (Patch Flags)

这是 Vue 3 速度变快的核心原因

  • Vue 2 的痛点:它是“全量 Diff”。如果一个节点有 100 个属性,Vue 2 会把这 100 个属性全部对比一遍,看谁变了。
  • Vue 3 的优化:在编译阶段,如果发现某个 <div>id 是动态的,但 class 是静态的,它就会在该节点旁边打一个数字标记(比如 1 代表文本变动,2 代表 Class 变动)。
  • 质变:当触发更新(Diff)时,Vue 3 看到这个标记,只去比对特定的属性,剩下的完全不管。这种“靶向更新”让 Diff 的性能不再受模板大小的影响,只受动态内容多少的影响。

Pinia 和 Vuex 有什么区别?

用法的区别:Pinia 有它自身的使用流程,Vuex 也有专属的使用流程; 简洁性与性能:Pinia 更简洁,性能也更好; TypeScript 支持:Pinia 更好地支持 TypeScript(Type stream 为口误)。

1. 核心架构:从“树状”到“扁平”

  • Vuex:采用的是 Single State Tree(单状态树) 。所有的状态都集中在一个大的 Store 里面,通过 modules 进行嵌套,层级一深,访问起来非常麻烦(如 this.$store.state.user.profile.name)。
  • Pinia:采用的是 Flat Architecture(扁平化架构) 。每一个 Store 都是独立的(如 useUserStore, useCartStore),你只需要在用到的地方导入特定的 Store 即可,不需要全局路由。

2. 用法的区别:逻辑精简

你提到的“使用流程”区别,最核心的体现就是 Mutations 的消失

  • Vuex:流程非常严苛。修改状态必须经过 Action -> Mutation -> State。Mutation 的初衷是为了让 DevTools 追踪状态变化,但这也导致了代码异常冗余。
  • Pinia:取消了 Mutation。在 Pinia 中,Actions 同时支持同步和异步操作,你可以直接在 Action 里修改 State。这种改变极大地简化了开发流程。

3. TypeScript 支持的“质变”

正如你提到的,Pinia 对 TypeScript 的支持是原生级别的:

  • Vuex:对 TS 的支持很痛苦。你需要手动定义大量的 Interface,且通过 dispatch 字符串调用 Action 时,IDE 很难提供补全和类型校验。
  • Pinia:由于其 API 设计(基于函数),类型推导是自动的。当你定义好 State 后,在其他地方使用时,所有的属性和方法都有完美的智能提示。

4. 简洁性与性能:轻量化

  • 体积更小:Pinia 非常轻量(约 1KB),因为它没有 Vuex 那么多复杂的包装。
  • 更好的组合体验:Pinia 的设计非常符合 Composition API 的逻辑。你可以像写普通函数一样编写 defineStore,这让逻辑复用变得非常自然。

面试官: “在 Vue 2 到 Vue 3 的迁移中,自定义指令的生命周期钩子发生了很大的变化。

  1. 请问 Vue 3 的指令钩子现在是和什么对齐的?
  2. 举一个你实际开发中用到自定义指令的例子(比如:权限控制、图片懒加载、自动聚焦等),并简单说说实现思路。”

自定义指令的生命周期钩子 是什么 为什么我从来没有听说过

没关系,没听说过说明你之前的项目可能主要在使用 Vue 的常用功能(如 v-if, v-for, v-model),而没有深入到直接操作底层 DOM 的场景。

简单来说:v-ifv-show 是 Vue 内置的指令。而 Vue 允许你自己写一个指令(比如 v-focus 让输入框自动聚焦),这就是自定义指令

既然是指令,它就要和 HTML 元素绑定。当这个元素被创建、插入到页面、或者被销毁时,Vue 会给指令提供一些“钩子函数”,让你在特定时刻去执行逻辑。


1. 到底什么是自定义指令的钩子?

你可以把它们理解为指令专属的“生命周期”

在 Vue 2 中,这些钩子的名字很怪(bind, inserted, update...)。Vue 3 为了降低学习成本,把指令的钩子名字改成了和组件一模一样

钩子名称触发时机
created在绑定元素的属性或事件监听器应用之前调用。
beforeMount当指令第一次绑定到元素并且在挂载父组件之前调用。
mounted最常用。 绑定元素插入父节点时调用(此时可以操作 DOM)。
beforeUpdate在元素本身更新之前调用。
updated常用。 元素及其子节点更新完成后调用。
beforeUnmount在卸载绑定元素的父组件之前调用。
unmounted指令与元素解绑且父组件已卸载时调用。

2. 为什么你可能没用过?

在 Vue 3 中,我们提倡“声明式渲染”。大部分 DOM 操作 Vue 都帮我们做了。只有在以下必须直接操作 DOM 的极端场景,才需要自定义指令:

  1. 自动聚焦:页面加载后,某个 input 框自动获得焦点。
  2. 权限控制:根据用户角色,直接从 DOM 中删掉某个按钮(v-permission)。
  3. 图片懒加载:监听图片是否进入可视区域,进入后再给 src 赋值。
  4. 外部点击:点击下拉菜单以外的区域时,自动关闭菜单(v-click-outside)。

3. 写法示例(加深印象)

你在 main.js 里定义一个全局指令 v-focus

JavaScript

const app = createApp(App)

// 注册一个全局自定义指令 `v-focus`
app.directive('focus', {
  // 当被绑定的元素插入到 DOM 中时……
  mounted(el) {
    // el 就是原生的 DOM 元素
    el.focus()
  }
})

然后在组件里直接用:


💡 查漏补缺:针对面试的总结

如果面试官问你:“为什么 Vue 3 要改指令的钩子函数?”

你直接给出一个“大厂范儿”的回答:

“为了心智负担的统一。Vue 3 将指令钩子与组件生命周期对齐,让开发者不再需要记忆两套不同的生命周期,降低了学习和维护成本,体现了 Vue 3 追求 API 一致性的设计哲学。”

下一题:Watch vs WatchEffect

这是你逃不掉的必考题。

面试官:watchwatchEffect 都能监听响应式数据的变化。

  1. watchEffect 为什么不需要像 watch 那样指定监听哪个变量?
  2. 为什么说 watchEffect 是‘立即执行’的?”
  • 监听方式:两者都可以监听响应式数据,但 watchEffect 不需要指定特定的变量;比如声明一个 count 变量,若在 watchEffect 的回调函数中使用了 countwatchEffect 就会自动监听这个 count;而 watch 必须专门指定要监听的变量,比如要监听 count 这个 ref 值,就必须明确写出这个变量来进行监听。

  • 新旧值获取watch 能拿到旧值(old value)和新值,可对新旧值进行对比;但 watchEffect 拿不到旧值和新值,无法进行对比。

  • 新旧值获取的原因watch 能拿到新旧值,是因为它需要指定专门的监听变量,第一次执行时会把旧值缓存起来;而 watchEffect 因为没有指定专门的变量,所以拿不到旧值;不过 watchEffect 是立即执行的,它一开始就会先执行一遍,然后记住依赖的响应式数据。

1. 监听方式(显式 vs 隐式)

  • watch (显式声明)

    • 特点:必须明确指定监听谁。
    • 场景:你需要精准控制当某个特定的变量(如 count)变化时才触发逻辑。
  • watchEffect (自动追踪)

    • 特点:不需要指定监听对象,它会自动收集回调函数内部用到的所有响应式属性作为依赖。
    • 场景:当你有很多个变量联动,不想一个个写监听时,或者需要根据某些变量的变化自动执行一段复杂的初始化逻辑。

2. 执行时机(懒执行 vs 立即执行)

  • watch默认是“懒执行” (lazy)。只有当监听的源数据真正发生变化时,它才会运行。

  • watchEffect立即执行(immediate)。它在定义时就会立即跑一遍。

    • 原因:它必须先运行一遍,才能知道代码里到底用到了哪些变量(即“收集依赖”)。

3. 获取新旧值 (NewValue vs OldValue)

  • watch:可以同时拿到 (newValue, oldValue)
  • watchEffect拿不到旧值。因为它只关注当前的副作用执行。

💡 查漏补缺:一个容易被忽略的高级细节

既然你对这两个 API 的基本区别已经掌握得很好了,面试官可能会抛出一个进阶追问

“如果我监听的是一个对象里的某个具体属性,比如 watch(user.name),这样写行得通吗?如果不行,应该怎么写?”

正确姿势: 不能直接传属性值,必须传一个 Getter 函数

  • ❌ 错误写法:watch(user.name, (val) => { ... }) (这传的是一个死字符串/值)
  • ✅ 正确写法:watch(() => user.name, (val) => { ... }) (这传的是一个追踪函数)

第十一题:Vue 3 性能优化——响应式减负

面试官: “在处理大量数据(比如一个包含 10 万条记录的对象数组,且这些数据只用来展示,不需要修改)时,你会如何优化 Vue 3 的性能?

  1. shallowRefshallowReactive 是用来做什么的?
  2. 为什么要使用它们,而不是直接用 refreactive?”

不知道

1. 什么是 shallowRefshallowReactive

简单来说,它们是 “浅层” 的响应式工具。

  • reactive:深度代理,不管对象嵌套多少层,每一层都是响应式的(递归代理)。
  • shallowReactive只代理第一层。只有对象的第一层属性变化会触发更新,深层的属性变化 Vue 不管。
  • ref:深度代理。如果 ref 传入的是个对象,底层会自动调用 reactive 做深度代理。
  • shallowRef只监听 .value 的指向变化。它不会对传入的对象进行代理,它只关心 .value 是不是被重新赋值了。

2. 为什么要使用它们?(场景与性能)

场景:处理巨大的、只读的、或者第三方插件的实例数据。

假设你从后端拿到了 10 万条地图坐标数据:

JavaScript

// ❌ 性能差:Vue 会递归遍历这 10 万条数据,给每个坐标的 x, y 都加上 Proxy
const markers = ref(largeData); 

// ✅ 性能优:Vue 不会去碰数据内部,只在 markers.value = newData 时触发更新
const markers = shallowRef(largeData); 

核心理由:

  1. 节省内存和 CPU:跳过了递归代理的过程,减少了大量的 Proxy 实例创建。
  2. 避免不必要的干扰:有些第三方库(比如 ECharts 实例、地图实例)的对象非常复杂,如果 Vue 对其进行深度响应式劫持,可能会破坏库的内部逻辑,甚至导致页面卡死。

💡 查漏补缺:面试官的“坑”

如果面试官问: “我用了 shallowRef,修改了对象内部的一个属性,视图没更新,我该怎么办?”

你的大厂范儿回答:

“因为 shallowRef 只监听 .value 的变化。如果想触发更新,我们应该替换整个对象(即 data.value = { ...data.value, newProp: 1 }),或者手动调用 triggerRef(data) 强制触发视图更新。”

Vue Router 4 的实战细节

面试官: “在 Vue 3 的 <script setup> 语法糖里,我们没有 this 了。

  1. 你会用哪两个 Hooks 来操作路由和获取当前参数?
  2. 什么是导航守卫?在 Vue 3 中全局守卫怎么写?”
  • 路由操作与参数获取:在 Vue3 的 setup 中没有 this,此时可以从 vue-router 中导入 useRouteruseRoute(注:你表述中均写为 use router,按通用写法修正):

    • useRouter:里面包含路由跳转相关的方法,比如点击跳转页面的操作,都可以通过 useRouter 提供的方法实现;
    • useRoute:如果想拿到 URL 上的 queryparams 参数,就使用 useRoute 这个 API。
  • 导航守卫的定义与全局写法相关:导航守卫可以理解为路由的 “拦截器”:比如跳转到某个页面时,这个拦截器会在跳转前后执行一些副作用逻辑。举例来说,想跳转到百度页面,在跳转前想做一些操作(比如给地址栏加参数),就可以在这个拦截器里编写对应的副作用函数,这就是导航守卫的跳转拦截功能。

. 路由 Hooks 的核心区别(代码演示)

<script setup> 中,我们的标准写法是这样的:

JavaScript

import { useRouter, useRoute } from 'vue-router';

const router = useRouter(); // 相当于 Vue 2 的 this.$router
const route = useRoute();   // 相当于 Vue 2 的 this.$route

// 跳转操作
const goToDetail = (id) => {
  router.push({ name: 'Detail', params: { id } });
};

// 获取参数
console.log(route.params.id);
console.log(route.query.token);

2. 导航守卫:全局写法与实战场景

你把导航守卫比作“拦截器”非常形象。在 Vue 3 中,全局守卫最常用的场景是登录鉴权

全局前置守卫 (beforeEach) 的写法:

JavaScript

router.beforeEach((to, from, next) => {
  const isAuthenticated = !!localStorage.getItem('token');
  
  // 如果要去往需要登录的页面,且用户未登录
  if (to.meta.requiresAuth && !isAuthenticated) {
    next('/login'); // 拦截并重定向
  } else {
    next(); // 正常放行
  }
});

面试官加分点: 在 Vue Router 4 中,你可以不再调用 next(),而是直接 return false(取消导航)或者 return { name: 'Login' }(重定向),这种 Return 风格 是 Vue 3 更推荐的。