组件是 Vue 应用的组织单位。我真正理清它,是在搞清楚父子生命周期顺序、provide/inject 默认不响应、keep-alive 缓存哪些内容之后。
Q1: Vue 2 和 Vue 3 的生命周期有什么区别?
1
Vue 2 的 Options API 生命周期
Vue 2 用 Options API 的钩子函数:
beforeCreate、created、beforeMount、mounted、beforeUpdate、updated、beforeDestroy、destroyed。这些钩子绑定在组件实例上,通过 this 访问组件状态。2
Vue 3 的 Composition API 生命周期
Vue 3 保留了 Options API 的钩子(改名
beforeUnmount/unmounted),同时引入了 Composition API 的钩子:onBeforeMount、onMounted、onBeforeUpdate、onUpdated、onBeforeUnmount、onUnmounted。这些钩子在 setup() 中调用。3
执行时机不变, 调用方式变了
生命周期的执行时机在 Vue 2 和 Vue 3 中完全一样—区别只是调用方式:Options API 用
this,Composition API 用 onXxx() 函数。4
组合式 API 的钩子注册灵活性
Composition API 的生命周期钩子可以在
setup() 中多次调用—你可以在不同的 Composable 函数中分别注册 onMounted,它们会按注册顺序依次执行。这比 Options API 的”一个组件只能有一个 mounted”更灵活,让逻辑复用时可以带上自己的生命周期逻辑。5
关键启示: 生命周期是组件与外部世界交互的入口
理解生命周期不只是背诵钩子名字,而是要清楚每个阶段能做什么、不能做什么—比如
onMounted 中才能访问 DOM,onBeforeUnmount 中必须清理副作用。面试中回答时结合实际场景(获取数据、绑定事件、清理定时器)说明使用方式,比单纯罗列钩子名称更能体现你的工程经验。没有生命周期
你不知道组件什么时候挂载完成、什么时候被销毁。就像养了一只宠物,但不知道它什么时候出生、什么时候生病、什么时候老去—你无法在正确的时机喂药、体检、送终。
有生命周期
onMounted 中获取初始数据,onUpdated 中响应数据变化,onUnmounted 中清理定时器。就像有了宠物的健康档案—出生时打疫苗(mounted)、生病时喂药(updated)、老去时安葬(unmounted)。Q2: 父子组件的生命周期执行顺序
1
父组件先开始挂载
Vue 的组件挂载是递归的。父组件先进入
beforeCreate → created → beforeMount,然后遇到子组件标签,暂停父组件的挂载,开始子组件的挂载流程。2
子组件先完成挂载
子组件走完
beforeCreate → created → beforeMount → mounted 后,控制权返回父组件,父组件继续执行 mounted。3
销毁顺序: 父先开始,子先完成
销毁也是递归的。父组件进入
beforeUnmount,然后递归销毁子组件(子走完 beforeUnmount → unmounted),父组件最后执行 unmounted。4
更新顺序: 父先 beforeUpdate,子先 updated
数据变化触发更新时,父组件先执行
beforeUpdate,然后递归更新子组件(子走完 beforeUpdate → updated),父组件最后执行 updated。5
关键洞察: 父子嵌套决定顺序
所有顺序都遵循同一个规律:父先开始,子先完成。这是因为 Vue 的渲染是深度优先递归——遇到子组件就递归进去,子组件处理完才返回父组件继续。
不理解执行顺序
在父组件的
mounted 中访问子组件的 DOM,以为子组件还没挂载完——实际上子组件的 mounted 先于父组件执行,此时子组件 DOM 已经就绪。理解执行顺序
知道子组件先 mounted,就能在父组件的
mounted 中安全地访问子组件 DOM。知道父组件先 beforeUnmount,就能在子组件的 onBeforeUnmount 中访问父组件提供的数据。Q3: 组件通信有哪些方式?
Vue 提供了多种组件通信方式,适用于不同的场景:Q4: provide / inject 和 props 有什么区别?
基础梳理
专家视角深度剖析
很多人知道 provide/inject,但不知道怎么让它保持响应式。Q5: provide/inject 原理详解
provide/inject 的实现机制
provide/inject 是 Vue 的依赖注入机制,允许祖先组件向后代组件传递数据,不管隔了多少层。provide/inject 的响应式问题
provide/inject vs props 的区别
Q6: keep-alive 的原理?缓存了什么?
keep-alive 是什么?<keep-alive> 是 Vue 的内置组件,用于缓存不活跃的组件实例,避免重复渲染和销毁。
Q7: keep-alive 的 LRU 缓存是怎么实现的?
1
问题: 组件切换导致重复创建和销毁
在 Tab 切换、路由切换等场景中,每次切换都会销毁旧组件、创建新组件。如果组件内部有大量数据(如列表页的滚动位置、表单的填写内容),销毁意味着这些状态全部丢失。
2
keep-alive 的缓存策略
keep-alive 用一个 Map 缓存组件的 VNode,用一个数组记录组件的 key 顺序(实现 LRU)。当缓存数量超过
max 时,淘汰最久未使用的组件。3
LRU 的核心: 最近使用的放最前面
每次访问缓存的组件时,把它的 key 移到数组最前面。淘汰时从数组末尾移除——末尾的就是最久未使用的。
4
源码实现: pruneCacheEntry
Vue 3 的 KeepAlive 组件内部用
Map 存缓存、keys 数组记录顺序。pruneCacheEntry 函数负责淘汰最久未使用的缓存(详见 KeepAlive.ts#L187)。5
关键洞察: 缓存的是 VNode 与组件实例
keep-alive 缓存的是 VNode 以及其中挂载的组件实例,不是一份独立拷贝。恢复时通过 DOM 移动(move)把缓存的 DOM 子树放回容器,而不是重新 patch 创建。
没有 keep-alive
每次 Tab 切换都销毁重建组件。就像你每次离开办公室都把桌上的文件全部扔掉,回来后重新打印一份——之前的进度全部丢失。
有 keep-alive
切换时把组件存入缓存,切回复用。就像你离开办公室时把文件放进抽屉(缓存),回来后从抽屉里拿出来继续用——进度都在。
Q8: keep-alive 的 include/exclude 和缓存策略
include/exclude 的匹配机制
keep-alive 通过include 和 exclude 属性控制哪些组件需要缓存,匹配的是组件的 name 选项。支持字符串、正则、数组三种格式。
keep-alive 的生命周期
keep-alive 缓存的组件首次挂载仍会触发mounted;之后再次进入缓存或恢复时,不会重复触发 mounted / unmounted,而是触发专属的 activated / deactivated 钩子。
keep-alive 的性能优化策略
keep-alive 的核心优化是避免重复创建和销毁组件,但使用不当反而会造成性能问题。keep-alive 的缓存策略不是”缓存越多越好”,而是精确控制缓存范围 + 限制缓存数量 + 在 deactivated 中清理副作用。
Q9: 组件通信方案的完整选型指南
组件通信不只是知道有哪些方式,更关键的是在什么场景下选什么方案。各方案的深度对比
provide/inject 的陷阱
provide/inject 看起来很方便,但有三个常见陷阱:底层源码:组件的挂载流程是什么?
Vue 3 的组件挂载流程是什么?
Vue 3 的组件挂载流程在 mountComponent 函数中(详见 Vue 3 源码 packages/runtime-core/src/renderer.ts):
- createComponentInstance:创建组件实例,初始化 props、slots、attrs 等。
- setupComponent:执行
setup()函数(如果有的话),收集响应式依赖。 - setupRenderEffect:创建一个响应式副作用(effect),在其中执行渲染函数。当依赖的响应式数据变化时,effect 重新执行,触发组件更新。
keep-alive 在源码中是怎么实现的?
keep-alive 是一个特殊的抽象组件,它的 render 函数会缓存子组件的 VNode(详见 Vue 3 源码 packages/runtime-core/src/components/KeepAlive.ts)。当子组件被切换走时,keep-alive 把它的 VNode 存入缓存(Map 或 LRU 缓存);当子组件被切换回来时,直接从缓存中取出 VNode,跳过创建和挂载流程。
十、及格回答 vs 满分回答
问题:“Vue 的组件通信有哪些方式?”
及格回答(60-70分):“父子通信用 props 和 emit,跨层级用 provide/inject,全局用 Vuex/Pinia。”问题出在:知道方式但不理解选型。 满分回答(90-100分):
Vue 的组件通信方式按数据流向分三类:父子通信(props/emit、v-model、refs)、跨层级通信(provide/inject)、全局通信(Pinia)。选型的关键是数据流的可追踪性—props/emit 的数据流最清晰(父→子、子→父),provide/inject 的数据流不直观(你不知道数据是从哪个祖先提供的),Pinia 的数据流有 DevTools 支持(可以追踪每个状态的变化来源)。[1]
props/emit 在源码中的实现是:父组件通过props选项声明要传给子组件的数据,Vue 在initProps(packages/runtime-core/src/componentProps.ts)中把 props 解析并挂载到组件实例上。子组件通过emit触发事件,Vue 在emit(packages/runtime-core/src/componentEmits.ts)中查找对应的事件处理函数并执行。provide/inject 的实现更简单—provide 把数据存入当前组件实例的provides对象,inject 沿着父组件链向上查找,直到找到对应的 key。[2]
Vue 3 移除了 Event Bus($on/$off),因为 Event Bus 在大型应用中会导致数据流变成”蜘蛛网”—你不知道谁在监听、谁在触发,调试极其困难。在 Vue 2 的源码中,$on/$off实现在src/core/instance/events.js中,用一个简单的对象存储事件名到回调数组的映射。Vue 3 移除了这些实例方法,替代方案是 Pinia:它用reactive包装 state,用computed实现 getters,DevTools 可以追踪每个状态的变化历史和来源。[3]
实际项目中的最佳实践是:80% 的场景用 props/emit(父子通信),15% 的场景用 Pinia(跨组件共享状态),5% 的场景用 provide/inject(框架级注入,如主题、国际化)。不要滥用 provide/inject—如果一个数据需要在 10 个组件中使用,用 Pinia 比 provide/inject 更好维护,因为 Pinia 有 DevTools 支持和 TypeScript 类型推断。
常见追问
生命周期钩子的执行顺序?父子组件的 mounted 谁先?
生命周期钩子的执行顺序?父子组件的 mounted 谁先?
答题要点: 父组件先开始挂载,子组件先完成挂载。高分回答:
“父组件先开始挂载,子组件先完成挂载。执行顺序:所以子组件的 mounted 先于父组件的 mounted。销毁顺序相反:在 Vue 3 源码中,组件挂载在
mountComponent(packages/runtime-core/src/renderer.ts)中执行。遇到子组件时递归调用 mountComponent,子组件先完成挂载,返回后父组件继续。”provide/inject 怎么保持响应式?
provide/inject 怎么保持响应式?
答题要点: provide 的是 ref/reactive 对象,而不是普通值。高分回答:
“如果 provide 的是普通值(如字符串),后代组件拿到的是值的快照,不会响应变化。要保持响应式,provide 的必须是 ref 或 reactive 对象。后代组件 inject 后拿到的是 ref,访问
.value 时自动追踪依赖。”keep-alive 缓存了什么?LRU 是怎么实现的?
keep-alive 缓存了什么?LRU 是怎么实现的?
答题要点: 缓存组件实例和虚拟 DOM,用 Map + 数组实现 LRU 策略。高分回答:
“keep-alive 缓存的是组件的虚拟 DOM 树和组件实例(包含状态)。源码中(
packages/runtime-core/src/components/KeepAlive.ts)用一个 Map 存储 vnode.key → VNode 的映射,用一个 keys 数组记录访问顺序。LRU 的实现:每次访问缓存的组件时,把它的 key 从数组中移到最前面。淘汰时从数组末尾移除——末尾的就是最久未使用的。通过 <keep-alive :max="10"> 限制缓存数量,超出时调用 pruneCacheEntry 淘汰最久未使用的组件。还支持 include 和 exclude 属性,用正则匹配组件名称来控制哪些组件需要缓存。onActivated 和 onDeactivated 钩子分别在组件从缓存中恢复和进入缓存时触发。”keep-alive 的 include/exclude 怎么匹配组件名?
keep-alive 的 include/exclude 怎么匹配组件名?
答题要点: 用组件的 name 选项匹配,支持字符串、正则、数组。高分回答:
“keep-alive 的
include 和 exclude 属性通过匹配组件的 name 选项来决定是否缓存。支持三种格式:字符串(精确匹配)、正则表达式、数组(多个名称)。源码中用 matches 函数(KeepAlive.ts)来判断:如果是字符串,直接比较组件名称;如果是正则,用 regex.test(name) 匹配;如果是数组,遍历数组逐一比较。常见的坑:<script setup> 中没有显式 name,Vue 3.3+ 支持 defineOptions({ name: 'MyComponent' }) 来定义组件名。如果没有定义,编译器会用文件名作为组件名。”Vue 3 为什么移除了 Event Bus?
Vue 3 为什么移除了 Event Bus?
答题要点: Event Bus 导致数据流难以追踪,大型应用中变成”蜘蛛网”。高分回答:
“Event Bus 的问题是数据流不透明—你不知道谁在监听、谁在触发。在大型应用中,Event Bus 会变成’蜘蛛网’:A 组件触发事件,B 组件监听,但你不知道还有 C、D、E 也在监听同一个事件。调试时你必须全局搜索事件名,极其痛苦。Vue 3 移除了
$on/$off,强制开发者用 props/emit(父子)或 Pinia(全局),让数据流变得可追踪。Pinia 有 DevTools 支持,可以查看每个状态的变化历史和来源。”全局注册和局部注册的区别?对 Tree-shaking 有什么影响?
全局注册和局部注册的区别?对 Tree-shaking 有什么影响?
答题要点: 全局注册的组件无法 Tree-shaking,局部注册可以。高分回答:
“全局注册(Vue 3 的
app.component('MyButton', MyButton))的组件会被注册到全局组件映射表中。即使你在模板中没有使用 <MyButton>,它也会被打包到最终的 bundle 中—因为编译器不知道你是否会在运行时动态使用它(如 <component :is='xxx'>)。
局部注册(在 setup 中 import)的组件,编译器可以静态分析哪些组件被使用了,未使用的可以被 Tree-shaking 删除。<script setup> 默认就是局部注册—import 的组件只在当前模板中可用。”父子组件的 updated 钩子谁先执行?
父子组件的 updated 钩子谁先执行?
答题要点: 父组件先 beforeUpdate,子组件先 updated。高分回答:
“更新阶段的顺序和挂载阶段类似,遵循’父先开始、子先完成’的规律:在 Vue 3 源码中,组件更新在
updateComponent(packages/runtime-core/src/renderer.ts)中执行。父组件的 effect 触发更新时,先执行父组件的 beforeUpdate 钩子,然后重新执行渲染函数生成新的 VNode 树。diff 算法发现子组件的 props 变化时,递归更新子组件。子组件更新完成后,父组件执行 updated 钩子。如果子组件的 props 没有变化且没有动态插槽等需要更新的内容,updateComponent 中的 shouldUpdateComponent 会返回 false,跳过子组件更新;这不是 diff 算法本身做的判断。”setup() 和 created 谁先执行?为什么 setup 中没有 this?
setup() 和 created 谁先执行?为什么 setup 中没有 this?
答题要点: setup() 在 beforeCreate 之前执行,且不通过 this 暴露组件实例。高分回答:
“
setup() 在 beforeCreate 之前执行,比 created 更早。执行顺序是:setup() → beforeCreate → created → ...为什么 setup 中没有 this?组件实例其实已经由 createComponentInstance 创建,但 setup(props, context) 的调用签名不包含实例,Vue 有意不把实例代理暴露给组合式 API;<script setup> 的顶层代码又位于模块作用域,this 自然是 undefined。Vue 3 在 setupStatefulComponent(packages/runtime-core/src/component.ts)中调用 setup(),再把返回的对象挂到实例的 setupState 上。这也是为什么 Composition API 用 ref()、reactive() 声明状态,而不是用 data() 返回对象。”props 的单向数据流是什么意思?为什么不能直接修改 props?
props 的单向数据流是什么意思?为什么不能直接修改 props?
答题要点: props 是父组件数据的单向投影,直接修改会导致数据流不可追踪。高分回答:
“Vue 的 props 遵循单向数据流原则:数据只能从父组件流向子组件,子组件不能直接修改 props。原因有三个:
1)数据流可追踪:如果子组件能直接修改 props,你就无法确定数据是在哪里被修改的——父组件还是子组件?调试时必须全局搜索。
2)父组件覆盖:父组件重新渲染时会把新的 props 传给子组件,子组件的修改会被覆盖——导致’改了又没改’的诡异 bug。
3)源码限制:Vue 3 在开发模式下,props 是只读的 Proxy(
packages/runtime-core/src/componentProps.ts),直接修改会报警告。正确做法:子组件通过 emit 通知父组件修改,或者用 v-model 实现双向绑定。如果确实需要在子组件中修改,用 const localCopy = ref(props.xxx) 创建本地副本。“Vue 全局注册和局部注册的区别
面试中偶尔会遇到这道冷门题,考察的是你对 Vue 组件注册机制与构建工具 Tree Shaking 关系的理解。
为什么全局注册无法 Tree Shaking?
Q10: 插槽原理详解
插槽的本质:函数
Vue 3 的插槽编译为函数。父组件传入的插槽内容是函数,子组件调用这些函数来渲染插槽内容。默认插槽
具名插槽
作用域插槽
插槽的惰性渲染
插槽的内容是函数,只有当子组件实际调用renderSlot 时,函数才会执行,VNode 才会被创建。如果子组件有条件地渲染插槽(v-if 控制),不满足条件时插槽的 VNode 不会被创建。
v-slot 的编译产物
延伸阅读与引用参考
- Vue 3 官方文档 - 组件基础:Vue 官方对组件基础的权威教程。[1]
- Vue 3 源码 packages/runtime-core/src/renderer.ts:组件挂载和更新流程的核心实现。[2]
- Vue 3 源码 packages/runtime-core/src/components/KeepAlive.ts:keep-alive 的缓存实现。[3]
- Vue 3 官方文档 - Provide/Inject:跨层级通信的官方教程。
