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 中
beforeCreate和created这两个钩子函数消失了?是因为它们直接被setup替代了。setup可以直接执行初始化操作,直接执行初始化函数。所以它直接替代了beforeCreate和created的作用。第二点:为什么我们要尽量把逻辑写在
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(避坑指南)
既然我们要把代码抽离到各个函数里,ref 和 reactive 的选择就非常关键了。
面试官追问:
- 为什么
ref访问要加.value? 它的底层原理(RefImpl类)是怎样的? - 解构
reactive对象会导致响应式丢失吗? 比如const { count } = reactive({ count: 0 }),后续修改count会触发视图更新吗?如果丢失了,你会用哪个官方工具函数(如toRefs或toRef)来修复它?
为什么 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 的迁移中,自定义指令的生命周期钩子发生了很大的变化。
- 请问 Vue 3 的指令钩子现在是和什么对齐的?
- 举一个你实际开发中用到自定义指令的例子(比如:权限控制、图片懒加载、自动聚焦等),并简单说说实现思路。”
自定义指令的生命周期钩子 是什么 为什么我从来没有听说过
没关系,没听说过说明你之前的项目可能主要在使用 Vue 的常用功能(如 v-if, v-for, v-model),而没有深入到直接操作底层 DOM 的场景。
简单来说:v-if、v-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 的极端场景,才需要自定义指令:
- 自动聚焦:页面加载后,某个 input 框自动获得焦点。
- 权限控制:根据用户角色,直接从 DOM 中删掉某个按钮(
v-permission)。 - 图片懒加载:监听图片是否进入可视区域,进入后再给
src赋值。 - 外部点击:点击下拉菜单以外的区域时,自动关闭菜单(
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
这是你逃不掉的必考题。
面试官: “watch 和 watchEffect 都能监听响应式数据的变化。
watchEffect为什么不需要像watch那样指定监听哪个变量?- 为什么说
watchEffect是‘立即执行’的?”
监听方式:两者都可以监听响应式数据,但
watchEffect不需要指定特定的变量;比如声明一个count变量,若在watchEffect的回调函数中使用了count,watchEffect就会自动监听这个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 的性能?
shallowRef和shallowReactive是用来做什么的?- 为什么要使用它们,而不是直接用
ref或reactive?”
不知道
1. 什么是 shallowRef 和 shallowReactive?
简单来说,它们是 “浅层” 的响应式工具。
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);
核心理由:
- 节省内存和 CPU:跳过了递归代理的过程,减少了大量的
Proxy实例创建。 - 避免不必要的干扰:有些第三方库(比如 ECharts 实例、地图实例)的对象非常复杂,如果 Vue 对其进行深度响应式劫持,可能会破坏库的内部逻辑,甚至导致页面卡死。
💡 查漏补缺:面试官的“坑”
如果面试官问: “我用了 shallowRef,修改了对象内部的一个属性,视图没更新,我该怎么办?”
你的大厂范儿回答:
“因为
shallowRef只监听.value的变化。如果想触发更新,我们应该替换整个对象(即data.value = { ...data.value, newProp: 1 }),或者手动调用triggerRef(data)强制触发视图更新。”
Vue Router 4 的实战细节
面试官: “在 Vue 3 的 <script setup> 语法糖里,我们没有 this 了。
- 你会用哪两个 Hooks 来操作路由和获取当前参数?
- 什么是导航守卫?在 Vue 3 中全局守卫怎么写?”
路由操作与参数获取:在 Vue3 的 setup 中没有
this,此时可以从vue-router中导入useRouter和useRoute(注:你表述中均写为 use router,按通用写法修正):
useRouter:里面包含路由跳转相关的方法,比如点击跳转页面的操作,都可以通过useRouter提供的方法实现;useRoute:如果想拿到 URL 上的query和params参数,就使用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 更推荐的。