Skip to main content
组件是 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 的渲染是深度优先递归——遇到子组件就递归进去,子组件处理完才返回父组件继续。
父子组件生命周期的完整执行顺序:
一句话总结:父子组件的生命周期遵循”父先开始、子先完成”的规律,这是 Vue 深度优先递归渲染的自然结果。

不理解执行顺序

在父组件的 mounted 中访问子组件的 DOM,以为子组件还没挂载完——实际上子组件的 mounted 先于父组件执行,此时子组件 DOM 已经就绪。

理解执行顺序

知道子组件先 mounted,就能在父组件的 mounted 中安全地访问子组件 DOM。知道父组件先 beforeUnmount,就能在子组件的 onBeforeUnmount 中访问父组件提供的数据。
回答时画出嵌套的执行顺序图(用缩进表示父子层级),比口头描述更清晰。面试里最常问的是”子组件的 mounted 和父组件的 mounted 谁先执行”——答案是子组件先。记住这个就够了。

Q3: 组件通信有哪些方式?

Vue 提供了多种组件通信方式,适用于不同的场景:
Vue 3 的通信方式:
Vue 2 中可以用 new Vue() 作为 Event Bus,通过 $on / $off 订阅和取消事件。Vue 3 移除了这些实例方法($emit 仍然保留),因为 Event Bus 在大型应用中会导致数据流难以追踪。替代方案:
  • 简单场景:用 provide / inject
  • 复杂场景:用 Pinia 状态管理
组件通信的核心是”数据流向的可追踪性”—props/emit 是父子直接通信,provide/inject 是跨层级注入,Pinia 是全局共享。 就像公司内部通信。props/emit 就像上下级之间的汇报—上级给下级分配任务(props),下级向上级汇报结果(emit)。provide/inject 就像公司公告—老板发公告(provide),所有员工都能看到(inject),不管隔了几层。Pinia 就像公司的共享文档—所有人都能读写,谁改了都有记录。

Q4: provide / inject 和 props 有什么区别?

基础梳理

专家视角深度剖析

很多人知道 provide/inject,但不知道怎么让它保持响应式。
如果 provide 的是普通值(不是 ref/reactive),后代组件拿到的是值的快照—祖先组件修改时,后代不会更新。

Q5: provide/inject 原理详解

provide/inject 的实现机制

provide/inject 是 Vue 的依赖注入机制,允许祖先组件向后代组件传递数据,不管隔了多少层。

provide/inject 的响应式问题

provide/inject vs props 的区别

  1. provide 普通值不响应:必须 provide ref 或 reactive 对象
  2. 数据流不透明:你不知道数据是从哪个祖先提供的,调试困难
  3. 不要滥用:如果数据只在父子两层间传递,用 props 更清晰

Q6: keep-alive 的原理?缓存了什么?

keep-alive 是什么? <keep-alive> 是 Vue 的内置组件,用于缓存不活跃的组件实例,避免重复渲染和销毁。
缓存了什么? keep-alive 缓存的是组件的 VNode 与组件实例(包含状态)。当组件被切换走时,keep-alive 不会销毁它,而是把它的真实 DOM 移入一个隐藏容器并保留实例;当组件被切换回来时,直接把缓存的 DOM 移回原位,不需要重新创建。 keep-alive 的钩子:
keep-alive 会占用内存来存储缓存的组件实例。如果缓存了大量组件或组件内部有大量数据,可能导致内存占用过高。可以通过 max 属性限制缓存数量:
keep-alive 的本质是”缓存组件实例,避免重复创建和销毁”——切换组件时不销毁,而是存入缓存;切回来时直接复用。 就像浏览器的标签页。你切换到其他标签页时,当前标签页不会关闭(组件不会销毁),它的状态(滚动位置、输入内容)都保留着。你切回来时,一切都在。但如果你打开了太多标签页,电脑会变卡(内存占用过高)。

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 的 LRU 缓存机制:
一句话总结:keep-alive 用 Map + 数组实现 LRU 缓存,淘汰最久未使用的组件,保留最近使用的组件状态。

没有 keep-alive

每次 Tab 切换都销毁重建组件。就像你每次离开办公室都把桌上的文件全部扔掉,回来后重新打印一份——之前的进度全部丢失。

有 keep-alive

切换时把组件存入缓存,切回复用。就像你离开办公室时把文件放进抽屉(缓存),回来后从抽屉里拿出来继续用——进度都在。
回答时先说”缓存了什么”(VNode + 组件实例),再说”怎么淘汰”(LRU 策略),最后说”钩子”(onActivated/onDeactivated)。想听的是你对 LRU 实现的理解,而不是只背 API。

Q8: keep-alive 的 include/exclude 和缓存策略

include/exclude 的匹配机制

keep-alive 通过 include 和 exclude 属性控制哪些组件需要缓存,匹配的是组件的 name 选项。支持字符串、正则、数组三种格式。
include 和 exclude 匹配的是组件的 name 选项,不是文件名。defineOptions 是 Vue 3.3+ 新增的宏,用于在 <script setup> 中声明组件选项;需要稳定的组件名时,应显式写 defineOptions({ name: 'MyComponent' }),否则只能依赖编译器从文件名推导的隐式名称,include / exclude 按文件名匹配容易失效。

keep-alive 的生命周期

keep-alive 缓存的组件首次挂载仍会触发 mounted;之后再次进入缓存或恢复时,不会重复触发 mounted / unmounted,而是触发专属的 activated / deactivated 钩子。

keep-alive 的性能优化策略

keep-alive 的核心优化是避免重复创建和销毁组件,但使用不当反而会造成性能问题。
keep-alive 的缓存策略不是”缓存越多越好”,而是精确控制缓存范围 + 限制缓存数量 + 在 deactivated 中清理副作用。

Q9: 组件通信方案的完整选型指南

组件通信不只是知道有哪些方式,更关键的是在什么场景下选什么方案。

各方案的深度对比

provide/inject 的陷阱

provide/inject 看起来很方便,但有三个常见陷阱:
props/emit 就像直属上下级的汇报——上级给下级分配任务(props),下级向上级汇报结果(emit),关系清晰但只能跨一级。provide/inject 就像公司公告板——老板贴公告(provide),所有员工都能看到(inject),但你不知道谁贴的、什么时候贴的。Pinia 就像公司的共享文档系统——所有人都能读写,有版本历史记录(DevTools),谁改了都有记录。

底层源码:组件的挂载流程是什么?

Vue 3 的组件挂载流程在 mountComponent 函数中(详见 Vue 3 源码 packages/runtime-core/src/renderer.ts):
这段代码做了三件事:
  1. createComponentInstance:创建组件实例,初始化 props、slots、attrs 等。
  2. setupComponent:执行 setup() 函数(如果有的话),收集响应式依赖。
  3. setupRenderEffect:创建一个响应式副作用(effect),在其中执行渲染函数。当依赖的响应式数据变化时,effect 重新执行,触发组件更新。
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。销毁顺序相反:
在 Vue 3 源码中,组件挂载在 mountComponent(packages/runtime-core/src/renderer.ts)中执行。遇到子组件时递归调用 mountComponent,子组件先完成挂载,返回后父组件继续。”
答题要点: provide 的是 ref/reactive 对象,而不是普通值。高分回答: “如果 provide 的是普通值(如字符串),后代组件拿到的是值的快照,不会响应变化。要保持响应式,provide 的必须是 ref 或 reactive 对象。后代组件 inject 后拿到的是 ref,访问 .value 时自动追踪依赖。”
答题要点: 缓存组件实例和虚拟 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 钩子分别在组件从缓存中恢复和进入缓存时触发。”
答题要点: 用组件的 name 选项匹配,支持字符串、正则、数组。高分回答: “keep-alive 的 include 和 exclude 属性通过匹配组件的 name 选项来决定是否缓存。支持三种格式:字符串(精确匹配)、正则表达式、数组(多个名称)。源码中用 matches 函数(KeepAlive.ts)来判断:如果是字符串,直接比较组件名称;如果是正则,用 regex.test(name) 匹配;如果是数组,遍历数组逐一比较。常见的坑:<script setup> 中没有显式 name,Vue 3.3+ 支持 defineOptions({ name: 'MyComponent' }) 来定义组件名。如果没有定义,编译器会用文件名作为组件名。”
答题要点: Event Bus 导致数据流难以追踪,大型应用中变成”蜘蛛网”。高分回答: “Event Bus 的问题是数据流不透明—你不知道谁在监听、谁在触发。在大型应用中,Event Bus 会变成’蜘蛛网’:A 组件触发事件,B 组件监听,但你不知道还有 C、D、E 也在监听同一个事件。调试时你必须全局搜索事件名,极其痛苦。Vue 3 移除了 $on/$off,强制开发者用 props/emit(父子)或 Pinia(全局),让数据流变得可追踪。Pinia 有 DevTools 支持,可以查看每个状态的变化历史和来源。”
答题要点: 全局注册的组件无法 Tree-shaking,局部注册可以。高分回答: “全局注册(app.component('MyButton', MyButton))的组件会被注册到全局组件映射表中。即使你在模板中没有使用 <MyButton>,它也会被打包到最终的 bundle 中—因为编译器不知道你是否会在运行时动态使用它(如 <component :is='xxx'>)。 局部注册(在 setup 中 import)的组件,编译器可以静态分析哪些组件被使用了,未使用的可以被 Tree-shaking 删除。
Vue 3 的 <script setup> 默认就是局部注册—import 的组件只在当前模板中可用。”
答题要点: 父组件先 beforeUpdate,子组件先 updated。高分回答: “更新阶段的顺序和挂载阶段类似,遵循’父先开始、子先完成’的规律:
在 Vue 3 源码中,组件更新在 updateComponent(packages/runtime-core/src/renderer.ts)中执行。父组件的 effect 触发更新时,先执行父组件的 beforeUpdate 钩子,然后重新执行渲染函数生成新的 VNode 树。diff 算法发现子组件的 props 变化时,递归更新子组件。子组件更新完成后,父组件执行 updated 钩子。如果子组件的 props 没有变化且没有动态插槽等需要更新的内容,updateComponent 中的 shouldUpdateComponent 会返回 false,跳过子组件更新;这不是 diff 算法本身做的判断。”
答题要点: 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 是父组件数据的单向投影,直接修改会导致数据流不可追踪。高分回答: “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?
全局注册不只是 Tree Shaking 的问题,还有三个隐藏成本:
  1. 命名冲突:全局组件名是扁平的命名空间,大型项目中容易冲突
  2. 隐式依赖:你不知道一个组件依赖了哪些全局组件,代码可读性差
  3. IDE 支持弱:全局注册的组件在模板中没有自动补全和类型检查
Vue 3 的 <script setup> 天然就是局部注册——import 的组件只在当前模板中可用,编译器可以精确分析依赖关系。
永远优先使用局部注册(<script setup> + import)。只有极少数真正全局通用的组件(如图标库、通用按钮)才考虑全局注册。如果必须全局注册,用 app.component() 在入口文件中统一注册,不要散落在各个文件中。

Q10: 插槽原理详解

插槽的本质:函数

Vue 3 的插槽编译为函数。父组件传入的插槽内容是函数,子组件调用这些函数来渲染插槽内容。

默认插槽

具名插槽

作用域插槽

插槽的惰性渲染

插槽的内容是函数,只有当子组件实际调用 renderSlot 时,函数才会执行,VNode 才会被创建。如果子组件有条件地渲染插槽(v-if 控制),不满足条件时插槽的 VNode 不会被创建。

v-slot 的编译产物

回答插槽问题时,重点说”插槽是函数”这个核心概念。具名插槽是带名字的函数,作用域插槽是带参数的函数。子组件调用函数来渲染插槽内容。这个理解能回答所有插槽相关的问题。

延伸阅读与引用参考