Vue2非父子通信与动态组件

Vue2 跨层级通信与组件进阶:EventBus、全局组件与动态组件

在真实业务中,组件树往往不止父子两层。购物车的"加入购物车"按钮在商品卡片里,而购物车数量徽章却在导航栏顶部------两者没有直接的父子关系。本文系统拆解 Vue2 解决跨层级通信的三条路径:状态提升、EventBus 事件总线、全局事件总线 $bus,并深入讲解 .native 修饰符、全局组件注册、动态组件 <component :is> 的底层机制与生产实践要点。


目录

  1. 零、导读与学习价值
  2. 一、状态提升:最纯粹的跨组件通信
  3. [二、EventBus 事件总线](#二、EventBus 事件总线 "#%E4%BA%8Ceventbus-%E4%BA%8B%E4%BB%B6%E6%80%BB%E7%BA%BF")
  4. [三、全局事件总线 bus](#三、全局事件总线 bus "#%E4%B8%89%E5%85%A8%E5%B1%80%E4%BA%8B%E4%BB%B6%E6%80%BB%E7%BA%BF-bus")
  5. 四、为组件添加原生事件
  6. 五、全局组件注册与使用
  7. [六、动态组件:component :is](#六、动态组件:component :is "#%E5%85%AD%E5%8A%A8%E6%80%81%E7%BB%84%E4%BB%B6component-is")
  8. [七、实例引用通信: refs/refs / refs/parent / provide-inject](#七、实例引用通信: refs/refs / refs/parent / provide-inject "#%E4%B8%83%E5%AE%9E%E4%BE%8B%E5%BC%95%E7%94%A8%E9%80%9A%E4%BF%A1refs--parent--provideinject")
  9. 总结

零、导读与学习价值

0.1 示例覆盖清单

示例 场景 覆盖知识点
TodoList 任务管理 状态提升跨兄弟组件共享数据 props 下传 / $emit 上报
购物车通知 EventBus 跨任意层级推送消息 emit/emit / emit/on / off/off / off/once
全局消息通知栏 $bus 全局事件总线 Vue.prototype.$bus
原生点击弹窗关闭 .native 修饰符 组件根元素事件监听
UI 徽章全局组件 Vue.component 全局注册 跨实例/跨组件复用
Tab 内容切换 动态组件 component :is 组件按名切换

0.2 核心名词速查

名词 一句话定义
状态提升 (Lifting State Up) 将共享状态移至最近公共祖先,由祖先统一管理
EventBus 利用 Vue 实例的 emit/emit/ emit/on 实现发布-订阅的通信中间层
全局事件总线 $bus 挂载在 Vue.prototype 上的公共 EventBus 实例
.native 修饰符 让原生 DOM 事件监听器绑定到组件的根元素而非自定义事件系统
全局组件 通过 Vue.component 注册,在任意实例/组件中无需再次声明即可使用
动态组件 <component :is="name"> 根据变量名在运行时切换渲染的组件

0.3 为什么要学本篇

Vue2 的父子通信(props + $emit)解决了"直接父子"的问题,却无法优雅处理兄弟组件、跨多层级或完全无关联的组件之间的通信。本篇介绍的方案是 Vuex 出现之前的最常用手段,也是理解 Vuex 设计动机的前置知识。同时,全局组件和动态组件是构建复用性高的 UI 框架的基础工具。

0.4 跨层级通信方案全景

在动手写代码之前,先建立一张"按场景选方案"的全景图。同样是"非父子通信",组件树深浅、是否需要被 DevTools 追踪、是否固定层级,决定了应该选哪条路径:

【代码注释】该图把四种方案钉到各自的适用场景上。**黄色「难题」**是所有方案的共同起点------兄弟、跨多层、无关联组件没有直接父子关系;**蓝色「状态提升」**把数据上提到公共祖先,靠 props/emit 流转,只适合浅层树;**绿色「EventBus / 全局 bus」**用独立 Vue 实例做发布-订阅中转站,能跨任意层级解耦;**紫色「provide/inject」**适合"祖先一次注入、后代按需取用"的组件库/插件场景;**橙色「Vuex」**是集中式状态仓库,唯一能被 Vue DevTools 时间旅行追踪的方案,留给大型项目。为什么不无脑上 Vuex?因为它引入样板代码与心智负担,小项目用 $bus 三行就够了------工具选型的本质是匹配复杂度,而不是追求"最强"。本篇覆盖前三类(状态提升、EventBus/$bus、provide/inject),Vuex 留待后续专题。


一、状态提升:最纯粹的跨组件通信

1.1 名词解释

状态提升(Lifting State Up):当两个兄弟组件(或更远的亲属组件)需要共享同一份数据时,将该数据的定义位置"向上提"到它们最近的公共父组件,由父组件统一持有并分发。

这个概念来自 React 社区,但在 Vue2 中同样适用。

1.2 概念与底层原理

状态提升本质上是单向数据流的延伸:数据从上往下(props)流,事件从下往上($emit)冒泡。父组件既是"数据仓库"又是"消息中转站"。

Vue 官方对单向数据流有明确定义:"所有的 prop 都使得其父子 prop 之间形成了一个单向下行绑定:父级 prop 的更新会向下流动到子组件中,但是反过来则不行。这样会防止从子组件意外变更父级组件的状态,从而导致你的应用的数据流向难以理解。" 并且 "每次父级组件发生变更时,子组件中所有的 prop 都将会刷新为最新的值。这意味着你不应该在一个子组件内部改变 prop。如果你这样做了,Vue 会在浏览器的控制台中发出警告"Vue 官方文档 - Prop)。状态提升正是把这条规则用到极致:兄弟组件各自没有直接通道,唯有把共享数据上提到公共祖先,才能让它们读到"同一份单向下行的数据源"。需要留意一个底层陷阱:JavaScript 中对象和数组按引用传递 ,即便是单向下行,子组件若直接 push/改属性操作 prop 数组本身,仍会"穿透"影响父级状态------这也是为什么示例里删除任务要走 $emit('update:task-list', 过滤后的新数组) 返回一个新数组,而非就地 splice 原数组。

【代码注释】该图是状态提升的数据流闭环。紫色父组件 独占 taskList 这一份"事实来源"(Single Source of Truth);三个子组件分工------蓝色 Header 负责输入、绿色 Main 负责展示、黄色 Footer 负责统计。注意箭头方向:向下的三条是 props(数据 + 回调函数下传),向上的两条是事件上报(Header 调用父传入的函数、Main 通过 $emit('update:task-list'))。为什么数据非要放在父组件而不是各子组件各存一份?因为兄弟组件之间没有直接通道,只有"上提到公共祖先"才能让它们读到同一份数据------这正是 Vue 单向数据流"数据下行、事件上行"原则的直接体现。市面应用:表单页里"筛选条件"组件与"结果列表"组件共享查询参数、订单页"地址选择"与"运费计算"共享收货地址,都是把状态提到页面级父组件统一持有。

1.3 完整示例:TodoList 任务管理

以下是一个将 taskList 提升到根 Vue 实例的 TodoList,Header 负责添加,Main 负责展示与删除,两者通过父级共享数据:

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>状态提升 - TodoList</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <style>
    body { font-family: sans-serif; padding: 20px; }
    .todo-header input { width: 300px; padding: 6px; font-size: 14px; }
    .todo-main li { list-style: none; padding: 8px 0; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
    .todo-main button { color: red; border: none; background: none; cursor: pointer; }
    .todo-footer { margin-top: 12px; color: #666; }
  </style>
</head>
<body>
<div id="root">
  <todo-header :add-task="addTask"></todo-header>
  <todo-main :task-list.sync="taskList"></todo-main>
  <todo-footer :task-list="taskList"></todo-footer>
</div>

<script>
// 父组件(根实例)统一持有 taskList
// Header 通过函数参数向上传递新任务
// Main 通过 .sync 语法糖修改 taskList
// Footer 通过 props 只读地使用 taskList

const TodoHeader = {
  props: { addTask: { type: Function, required: true } },
  template: `
    <div class="todo-header">
      <input @keyup.enter="handleAdd" placeholder="输入任务标题,按回车确认" />
    </div>
  `,
  methods: {
    handleAdd(e) {
      const title = e.target.value.trim();
      if (!title) return;
      this.addTask(title);  // 调用父级传入的函数,将新标题传上去
      e.target.value = '';
    }
  }
};

const TodoMain = {
  props: { taskList: { type: Array, required: true } },
  template: `
    <ul class="todo-main">
      <li v-for="item in taskList" :key="item.id">
        <label>
          <input type="checkbox" v-model="item.isChecked" />
          {{ item.title }}
        </label>
        <button @click="remove(item.id)">删除</button>
      </li>
    </ul>
  `,
  methods: {
    remove(id) {
      // .sync 语法糖:触发 update:task-list 事件,父级自动更新 taskList
      this.$emit('update:task-list', this.taskList.filter(t => t.id !== id));
    }
  }
};

const TodoFooter = {
  props: { taskList: Array },
  template: `
    <div class="todo-footer">
      已完成 {{ doneCount }} / 全部 {{ taskList.length }}
    </div>
  `,
  computed: {
    doneCount() { return this.taskList.filter(t => t.isChecked).length; }
  }
};

new Vue({
  el: '#root',
  data: { taskList: [] },
  methods: {
    addTask(title) {
      // 判断重名
      if (this.taskList.some(t => t.title === title)) {
        alert('任务已存在!'); return;
      }
      this.taskList = [{ id: Date.now(), title, isChecked: false }, ...this.taskList];
    }
  },
  components: { TodoHeader, TodoMain, TodoFooter }
});
</script>
</html>

【代码注释】

  • addTask 以 props 函数形式传给 Header,Header 调用后将新标题传回父级,父级负责生成完整任务对象。这是"父级掌控数据,子级只负责触发"的典型写法。
  • .sync:task-list="taskList" @update:task-list="taskList=$event" 的语法糖,Main 通过 $emit('update:task-list', newList) 请求父级更新。
  • Footer 只读使用 taskList,通过计算属性统计完成数。

【实战要点】

  • 状态提升适合组件层级不深、共享数据结构简单的场景。一旦需要跨越 3 层以上,逐层传递的 props "链条"会让代码难以维护,此时应考虑 EventBus 或 Vuex。
  • 不要在子组件内直接修改 props,Vue 会发出警告且逻辑难以追踪。始终通过 $emit 或回调函数通知父级修改。

【本章小结】

状态提升是 Vue2 非父子通信的最基础方案。原理清晰、无副作用,代价是随着组件层级加深,数据传递路径变长,出现"props 穿透"问题。

维度 状态提升的表现
数据存放 共享数据上提到最近公共祖先,单一事实来源
数据下行 父组件用 props 把数据/回调函数传给各子组件
事件上行 子组件用 $emit 或调用父传回调请求父级修改
适用层级 浅层树(1~2 层);超过 3 层会 props 穿透
优点 数据流可追踪、可被 DevTools 观察、无隐式依赖
缺点 层级深时逐层透传冗长、中间组件被迫"过路转发"

记忆口诀"数据上提到祖先,下行 props 上行 emit;浅树清晰深树累,超过三层换方案"

【面试考点】

Q:什么是 props 穿透(prop drilling)?如何解决? A:当一个数据从祖先组件逐层通过 props 传给深层孙子组件,但中间组件本身并不使用该数据时,称为 props 穿透。解决方案:EventBus、provide/inject(Vue2 高级特性)、Vuex。
Q:状态提升时,子组件能不能直接修改父组件传下来的 props?为什么? A:不能。Vue 的 prop 是单向下行绑定 ,每次父级更新都会把 prop 刷成最新值,子组件内部改 prop 会被覆盖且 Vue 会在控制台告警。正确做法是子组件通过 $emit(或调用父级传入的回调函数)通知父级修改,由父级作为唯一数据源更新。特别注意:prop 若是对象/数组(引用类型),就地修改其内部会"穿透"影响父级状态,应返回新数组/新对象而非原地变更。
Q:状态提升和 Vuex 都能做跨组件共享,二者的本质区别是什么? A:状态提升把状态放在某个具体的父组件实例 上,作用域限于该子树,靠 props/$emit 流转,适合页面级的局部共享;Vuex 把状态放在全局独立的仓库里,任意组件都能读写,并提供 mutation/action 的规范化变更与 DevTools 时间旅行追踪,适合跨页面、大范围的全局状态。简言之:状态提升是"局部、轻量、隐式作用域",Vuex 是"全局、规范、可追踪"。


二、EventBus 事件总线

2.1 名词解释

EventBus(事件总线) :一个充当"消息中转站"的 Vue 实例,任何组件都可以向它发布($emit)消息,也可以向它订阅($on)消息,从而实现无直接关联组件之间的通信。

这是**发布-订阅模式(Publish-Subscribe Pattern)**在 Vue 中的具体应用。

2.2 概念与底层原理

Vue 实例内置了一套事件系统,核心 API 是:

方法 作用
$emit(event, ...args) 发布消息,触发该实例上名为 event 的所有订阅者
$on(event, callback) 订阅消息,每次触发都执行 callback
$once(event, callback) 订阅一次,触发后自动移除
$off(event, callback?) 取消订阅,不传 callback 则移除该事件所有订阅

关键约束$emit$on 必须作用在同一个 Vue 实例 上才能互通。这就是为什么需要专门创建一个独立的 Vue 实例作为"总线",而不能在不同组件各自的 this 上调用。

【代码注释】该图按时间顺序展开一次 EventBus 通信。绿色第 1 步 :购物车徽章组件 B 在 mounted 阶段调用 bus.$on('cart:add', callback),回调被存进总线实例的 _events 对象,结构是 { 'cart:add': [callback] }蓝色第 2 步 :用户点击商品卡片 A 的"加入购物车",A 调用 bus.$emit('cart:add', payload),总线按事件名取出回调数组;橙色第 3 步 :总线遍历数组用 apply 执行每个回调并传入 payload,徽章数量随之更新。关键在于 A 和 B 互不知晓对方存在,只共享中间这个 bus 实例------这就是发布-订阅"发布者不知道谁订阅、订阅者不知道谁发布"的解耦本质(Vue 源码事件系统分析)。市面应用:导航栏未读消息数、全局 Loading 遮罩、跨页面的"刷新列表"信号,都靠这种"组件挂载时订阅、动作触发时发布"的模式。

2.3 完整示例:购物车跨组件通知

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>EventBus - 购物车通知</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <style>
    body { font-family: sans-serif; padding: 20px; }
    .navbar { background: #333; color: #fff; padding: 12px 20px; display: flex; justify-content: space-between; border-radius: 4px; }
    .badge { background: #e74c3c; color: #fff; border-radius: 50%; padding: 2px 7px; font-size: 12px; margin-left: 4px; }
    .products { display: flex; gap: 16px; margin-top: 20px; }
    .product-card { border: 1px solid #ddd; border-radius: 6px; padding: 16px; width: 140px; text-align: center; }
    .product-card button { background: #3498db; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; margin-top: 8px; }
    .log { margin-top: 20px; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 12px; font-size: 13px; color: #555; }
  </style>
</head>
<body>
<div id="app">
  <!-- 导航栏组件:监听购物车更新事件 -->
  <nav-bar></nav-bar>
  <!-- 商品列表组件:发布购物车更新事件 -->
  <product-list></product-list>
</div>

<script>
// 创建独立的 EventBus 实例
// 它不挂载到任何 DOM,只作为消息中转站
const bus = new Vue();

const NavBar = {
  data() { return { cartCount: 0, lastItem: '' }; },
  template: `
    <div class="navbar">
      <span>我的商城</span>
      <span>
        购物车
        <span class="badge">{{ cartCount }}</span>
        <span v-if="lastItem" style="font-size:12px;opacity:.8"> · 刚加入:{{ lastItem }}</span>
      </span>
    </div>
  `,
  mounted() {
    // 在组件挂载后订阅消息
    // 注意:必须在 mounted 而非 created 中订阅,确保组件已完整初始化
    bus.$on('cart:add', (payload) => {
      this.cartCount += payload.qty;
      this.lastItem = payload.name;
    });
  },
  beforeDestroy() {
    // 组件销毁前取消订阅,防止内存泄漏
    bus.$off('cart:add');
  }
};

const ProductList = {
  data() {
    return {
      products: [
        { id: 1, name: 'AirPods Pro', price: 1899 },
        { id: 2, name: 'iPad Air', price: 4799 },
        { id: 3, name: 'MacBook Air', price: 8499 }
      ],
      log: []
    };
  },
  template: `
    <div>
      <div class="products">
        <div class="product-card" v-for="p in products" :key="p.id">
          <div>{{ p.name }}</div>
          <div style="color:#e74c3c;font-size:18px;margin:8px 0">¥{{ p.price }}</div>
          <button @click="addToCart(p)">加入购物车</button>
        </div>
      </div>
      <div class="log" v-if="log.length">
        <strong>操作日志:</strong>
        <div v-for="(msg, i) in log" :key="i">{{ msg }}</div>
      </div>
    </div>
  `,
  methods: {
    addToCart(product) {
      // 发布购物车新增消息,携带商品信息
      bus.$emit('cart:add', { id: product.id, name: product.name, qty: 1 });
      this.log.unshift(`${new Date().toLocaleTimeString()} 加入购物车:${product.name}`);
    }
  }
};

new Vue({
  el: '#app',
  components: { NavBar, ProductList }
});
</script>
</html>

【代码注释】

  • bus 是一个独立的 Vue 实例,不挂载到 DOM,只用来持有事件系统。
  • NavBarmounted 中调用 bus.$on('cart:add', ...) 订阅消息。注意这里用箭头函数,使 this 指向 NavBar 实例本身,而非触发事件的 bus 实例。
  • ProductList 调用 bus.$emit('cart:add', payload) 发布消息,payload 携带商品数据。
  • beforeDestroy 中调用 bus.$off('cart:add') 是防止内存泄漏的关键步骤------不取消订阅,组件销毁后监听函数仍然存在于内存中。

【实战要点:内存泄漏问题】

EventBus 最常见的生产 Bug 就是忘记在组件销毁时调用 $off。以下是两种常见的安全写法:

javascript 复制代码
// 方式一:在 beforeDestroy 钩子中取消订阅(推荐)
mounted() {
  bus.$on('notify', this.handleNotify);
},
beforeDestroy() {
  bus.$off('notify', this.handleNotify); // 传入具体函数引用,精准取消
}

// 方式二:使用 $once,只监听一次,自动移除
mounted() {
  bus.$once('user:login', this.handleLogin);
}

【代码注释】这两段是 EventBus 防内存泄漏的"标准姿势"。方式一把订阅与解绑配对:mounted$on(事件, this.handleNotify)beforeDestroy 里用同一个函数引用 $off(事件, this.handleNotify) 精准摘除------注意必须是命名方法而非匿名箭头函数,否则 $off 拿不到引用、删不掉。方式二用 $once 让总线在触发一次后自动 $off,适合"只处理一次"的信号(如首次登录欢迎语)。为什么必须传函数引用$off('event') 不传第二参时会移除该事件的所有 订阅者,多个组件订阅同名事件时会误伤其他组件。市面应用 :长列表里每个卡片都订阅"全局刷新"事件,若用 $off('refresh') 一删全没了,必须 $off('refresh', this.onRefresh) 只解绑自己。

2.4 源码级机制: on/on/ on/emit 到底做了什么

很多人把 EventBus 当"黑盒"用,但理解它的源码实现能彻底消除"为什么必须同一个实例""为什么大小写敏感"这类困惑。Vue2 的四个事件方法由 eventsMixin 一次性挂到 Vue.prototype 上,它们的本质就是一份手写的发布-订阅实现,核心数据结构是每个实例上的私有属性 _eventsVue 源码 events 流程):

【代码注释】该图拆解事件中心 _events 的全生命周期。蓝色 initEvents :实例初始化时执行 vm._events = Object.create(null),创建一个无原型的纯净空对象当事件中心(用 Object.create(null) 而非 {} 是为了避免 hasOwnProperty 等原型属性干扰事件名);绿色 on∗∗:核心一行'(eventsevent∣∣(eventsevent=\[\])).push(fn)',把回调推进以事件名为key的数组;∗∗黄色数据结构∗∗:最终'events'长成'′cart:add′:fn1,fn2,′user:login′:fn3'这种"事件名→回调数组"的字典;∗∗橙色on**:核心一行 `(_eventsevent || (_eventsevent=\[\])).push(fn)`,把回调推进以事件名为 key 的数组;**黄色数据结构**:最终 `_events` 长成 `{ 'cart:add': fn1, fn2, 'user:login': fn3 }` 这种"事件名 → 回调数组"的字典;**橙色 on∗∗:核心一行'(eventsevent∣∣(eventsevent=\[\])).push(fn)',把回调推进以事件名为key的数组;∗∗黄色数据结构∗∗:最终'events'长成'′cart:add′:fn1,fn2,′user:login′:fn3'这种"事件名→回调数组"的字典;∗∗橙色emit :取出 _events[event] 数组、用 slice 截掉第一个参数(事件名)得到附加参数、遍历 cb.apply(this, args) 逐个执行;**红色 off∗∗:从对应数组里'splice'删掉指定回调。这张图回答了两个高频疑问:①为什么'off**:从对应数组里 `splice` 删掉指定回调。这张图回答了两个高频疑问:① 为什么 ` off∗∗:从对应数组里'splice'删掉指定回调。这张图回答了两个高频疑问:①为什么'emit/$on必须同实例------因为_events是**实例私有**的,A 实例的 emit 只查 A 自己的_events,跨实例查不到;② 为什么事件名大小写敏感------因为事件名是对象的 key,cart:Addcart:add是两个不同的 key,开发环境下 Vue 还会检测到大小写不一致并告警。**市面应用**:理解了这套机制,就能看懂mittEventEmitter` 等任何事件库的源码------它们的内核都是这张"字典 + 数组"的发布-订阅图。

【本章小结】

EventBus 利用 Vue 实例自带的发布-订阅系统,将不相关的组件解耦。核心是"发布者不知道谁在监听,订阅者不知道谁在发布",底层是每个实例私有的 _events 字典(事件名 → 回调数组)。主要缺陷是:随着事件增多,事件链路难以追踪;多个组件订阅同名事件时管理成本较高。

API 作用 何时调用 关键陷阱
$on(事件, fn) 订阅消息,每次触发都执行 mounted 中订阅 必须配对 $off,否则内存泄漏
$emit(事件, payload) 发布消息并传参 动作触发时 必须与订阅在同一实例
$once(事件, fn) 只订阅一次,触发后自动解绑 只处理一次的信号 无需手动 $off
$off(事件, fn) 取消订阅 beforeDestroy 不传 fn 会移除该事件所有订阅

记忆口诀"独立实例当总线,on 订阅 emit 发;同实例才能通,destroy 别忘 off"

【面试考点】

Q:EventBus 的 emit和emit 和 emit和on 为什么必须在同一个实例上? A:Vue 的事件系统( emit/emit/ emit/on)是实例级别的。$emit 只会在当前实例的事件列表中查找并调用订阅者,不存在跨实例广播。因此必须保证 publish 和 subscribe 操作的是同一个 Vue 实例对象,这也是 EventBus 需要单独创建并共享引用的原因。
Q: on和on 和 on和once 有什么区别? A:$on 注册的监听函数每次 $emit 都会执行,直到手动 $off 移除。$once 只会执行一次,执行后自动调用 $off 移除自身,适合"只处理一次"的场景,如用户首次登录欢迎弹窗。


三、全局事件总线 $bus

3.1 名词解释

全局事件总线(Global EventBus) :将一个 Vue 实例挂载到 Vue.prototype.$bus,使得所有 Vue 实例和组件都能通过 this.$bus 访问同一个总线对象,无需手动传递 bus 引用。

3.2 概念与底层原理

Vue 实例与组件实例之间存在原型链继承关系:

【代码注释】该图画出 this.$bus 能被任意组件访问的原型链。一个组件实例的原型链是 蓝色组件实例 → 绿色 VueComponent.prototype → 紫色 Vue.prototype → 灰色 Object.prototype 。当我们执行 Vue.prototype.$bus = new Vue(),相当于把总线挂在链条上游的紫色节点;任何组件读 this.$bus 时,JS 引擎沿 __proto__ 逐级向上查找(黄色路径),在 Vue.prototype 上命中,于是全应用共享同一个 $bus 实例。这就是"零配置全局访问"的本质------靠的是 JS 原型链的属性查找机制,而非 Vue 的什么魔法 。这也解释了为什么 Vue.prototype.$bus = new Vue() 必须在第一个 new Vue() 之前执行:实例创建时会确定原型链,挂载晚了,早创建的实例链上还没有 $bus市面应用this.$routerthis.$storethis.$message 全部是同一套原型链注入------看懂这张图,就看懂了所有 Vue 插件的注入原理。

因此,在 Vue.prototype 上挂载的任何属性,所有 Vue 实例和组件实例都能通过 this.xxx 访问,这是 Vue 插件系统(Vue.use)的基础原理。Vue 官方在介绍插件开发时明确把"添加实例方法"列为标准模式之一:"通过把它们添加到 Vue.prototype 上实现"Vue 官方文档 - 插件)。也就是说,$bus 并不是什么特殊机制,而是与 this.$routerthis.$storethis.$message 完全相同的"原型方法注入"套路------它们都借由 Vue.use(插件) 触发插件的 install(Vue) 函数,在其中把对象挂到 Vue.prototype 上,从而被全应用共享。

javascript 复制代码
// 必须在 new Vue() 之前执行,确保所有组件都能访问
Vue.prototype.$bus = new Vue();

【代码注释】这一行是全局事件总线的"安装语句",本质是往 Vue.prototype 上挂一个共享的 Vue 实例当总线。关键约束是执行时机 :它必须排在所有 new Vue()(包括根实例)之前,否则早于它创建的实例,其原型链上还没有 $bus,运行时会得到 undefined 进而报错。市面应用 :实战中通常写在入口文件 main.js 顶部、new Vue({...}).$mount('#app') 之前;更规范的做法是封装成插件 Vue.use({ install(Vue){ Vue.prototype.$bus = new Vue() } }),把"挂载时机"收敛到插件安装阶段统一管理。

3.3 完整示例:全局消息通知栏

下面演示一个横跨多层组件的消息通知系统,任意组件都可以发出通知,通知栏始终在顶层:

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>全局事件总线 $bus</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <style>
    body { font-family: sans-serif; margin: 0; }
    .notification { position: fixed; top: 0; left: 50%; transform: translateX(-50%);
      background: #2ecc71; color: #fff; padding: 10px 24px; border-radius: 0 0 6px 6px;
      transition: opacity .3s; font-size: 14px; z-index: 999; }
    .notification.error { background: #e74c3c; }
    .notification.hidden { opacity: 0; pointer-events: none; }
    .page { padding: 20px; }
    .btn-group { display: flex; gap: 10px; margin: 20px 0; }
    button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; }
    .btn-success { background: #2ecc71; color: #fff; }
    .btn-error { background: #e74c3c; color: #fff; }
    .card { border: 1px solid #ddd; padding: 16px; border-radius: 6px; margin-top: 12px; }
  </style>
</head>
<body>
<div id="app">
  <!-- 通知栏:订阅通知事件 -->
  <notification-bar></notification-bar>

  <!-- 页面内容 -->
  <div class="page">
    <h2>用户操作面板</h2>
    <!-- 操作组件:发布通知事件 -->
    <action-panel></action-panel>
    <!-- 更深层的子组件也可以发通知 -->
    <deep-child></deep-child>
  </div>
</div>

<script>
// 注册全局总线,必须在所有 new Vue() / 组件定义之前执行
Vue.prototype.$bus = new Vue();

// 通知栏:全局唯一,顶部固定显示
const NotificationBar = {
  data() {
    return { message: '', type: 'success', visible: false, timer: null };
  },
  template: `
    <div class="notification" :class="[type, { hidden: !visible }]">
      {{ message }}
    </div>
  `,
  mounted() {
    // 通过 $bus 订阅全局通知事件
    this.$bus.$on('notify', ({ msg, type = 'success', duration = 2500 }) => {
      clearTimeout(this.timer);
      this.message = msg;
      this.type = type;
      this.visible = true;
      // duration 毫秒后自动隐藏
      this.timer = setTimeout(() => { this.visible = false; }, duration);
    });
  },
  beforeDestroy() {
    // 虽然通知栏通常不销毁,但良好习惯要解绑
    this.$bus.$off('notify');
    clearTimeout(this.timer);
  }
};

// 操作面板:发布通知
const ActionPanel = {
  template: `
    <div class="card">
      <h4>操作面板(第一层组件)</h4>
      <div class="btn-group">
        <button class="btn-success" @click="saveData">保存数据</button>
        <button class="btn-error" @click="deleteData">删除操作</button>
      </div>
    </div>
  `,
  methods: {
    saveData() {
      // 通过 $bus 向通知栏发布成功消息
      this.$bus.$emit('notify', { msg: '数据保存成功!', type: 'success' });
    },
    deleteData() {
      this.$bus.$emit('notify', { msg: '操作失败:权限不足', type: 'error' });
    }
  }
};

// 更深层的子组件,同样可以直接使用 $bus
const DeepChild = {
  template: `
    <div class="card">
      <h4>深层子组件(任意层级)</h4>
      <button @click="notify">发送网络错误通知</button>
    </div>
  `,
  methods: {
    notify() {
      this.$bus.$emit('notify', { msg: '网络连接超时,请重试', type: 'error', duration: 4000 });
    }
  }
};

new Vue({
  el: '#app',
  components: { NotificationBar, ActionPanel, DeepChild }
});
</script>
</html>

【代码注释】

  • Vue.prototype.$bus = new Vue() 在任何 Vue 实例创建之前执行,确保所有组件通过 this.$bus 访问同一个总线。
  • 通知栏在 mounted 中订阅 notify 事件,接收 { msg, type, duration } 格式的载荷。
  • 任意组件(无论在组件树的哪一层)调用 this.$bus.$emit('notify', payload) 都能触发通知栏更新,无需感知通知栏的存在。

【实战要点】

  • 挂载时机:Vue.prototype.$bus = new Vue() 必须 在第一个 new Vue() 之前执行,否则部分组件实例化时原型链上还没有 $bus
  • 命名规范:事件名建议采用 模块:动作 的格式(如 cart:adduser:logout),避免不同业务模块的事件名冲突。
  • 与独立 EventBus 的区别:独立 EventBus 需要手动 import bus from './bus'$bus 则通过原型链自动注入,更简便,但在大型项目中隐式依赖更难追踪。

【本章小结】

$bus 是对 EventBus 模式的工程化封装,借助 Vue 原型链注入实现零配置全局访问。适合中小型项目的跨组件通信,大型项目建议迁移至 Vuex 以获得 DevTools 的状态追踪支持。

维度 独立 EventBus(import bus 全局 $busVue.prototype.$bus
获取方式 每个文件手动 import bus from './bus' this.$bus 原型链自动注入
实现原理 模块单例,靠 ES Module 缓存共享 挂在 Vue.prototype,靠原型链查找
挂载时机 任意,import 即用 必须在第一个 new Vue() 之前
依赖可见性 显式 import,依赖清晰可追踪 隐式注入,大型项目较难追踪来源
适用规模 任意,模块化项目首选 中小项目、快速开发首选

记忆口诀"原型挂总线,组件链上找;new Vue 前先挂,全局 this 点 bus 用"

【面试考点】

Q:Vue.prototype. bus的实现原理是什么?为什么组件实例可以访问bus 的实现原理是什么?为什么组件实例可以访问 bus的实现原理是什么?为什么组件实例可以访问bus? A:Vue 组件实例的原型链为 组件实例 → VueComponent.prototype → Vue.prototype。将 $bus 挂载在 Vue.prototype 上后,所有组件实例通过原型链查找均可访问 this.$bus,无需手动传入。这与 Vue Router 中 this.$router、Vuex 中 this.$store 的注入原理相同。
Q:为什么 Vue.prototype.$bus = new Vue() 必须写在所有 new Vue() 之前?写在后面会怎样? A:JS 对象在创建时就确定了它的原型链。若在某个根/子实例创建之后才往 Vue.prototype$bus,理论上原型链是同一条引用、后挂也能读到;但实战中真正的风险是该实例在挂载前的生命周期钩子(如 createdmounted)里已经访问了 this.$bus ,此时它还是 undefined,调用 this.$bus.$on(...) 就会抛 Cannot read property '$on' of undefined。规范做法是把这行写在入口文件 main.js 顶部、new Vue({...}).$mount('#app') 之前,或封装成插件 Vue.use({ install(Vue){ Vue.prototype.$bus = new Vue() } }) 统一管理时机。
Q:$bus 这种全局事件总线有什么缺点?为什么大型项目更推荐 Vuex? A:$bus 是隐式依赖------组件间通过约定的事件名通信,谁发谁收没有显式声明,事件一多链路难以追踪,且无法被 DevTools 时间旅行调试;同名事件被多组件订阅时还容易互相干扰。Vuex 提供集中式、规范化(mutation/action)的状态管理与 DevTools 状态追踪,变更可预测、可回溯,更适合状态复杂、协作人数多的大型项目。


四、为组件添加原生事件

4.1 名词解释

.native 修饰符 :在父组件使用子组件时,直接监听子组件根元素 的原生 DOM 事件,而非监听子组件通过 $emit 发出的自定义事件。

4.2 概念与底层原理

当你在组件上写 @click="fn" 时,Vue 会将其理解为"监听子组件 $emit('click') 事件",而非 DOM 的 click 事件。这意味着子组件如果不显式调用 this.$emit('click'),父组件的监听器永远不会触发。Vue 官方对 .native 修饰符的定义是:".native ------ 监听组件根元素的原生事件"Vue 官方文档 - v-on 修饰符)。这句话点明了它的边界------监听的是"组件根元素 ",而非组件内部任意 DOM;若组件根元素不是你想监听的那个节点,.native 也会绑到根元素上而非你期望的子节点。

.native 修饰符改变了这一行为:它将事件监听器绑定到组件根元素的真实 DOM 上,相当于:

javascript 复制代码
// 等价于
document.querySelector(组件根元素).addEventListener('click', fn);

【代码注释】这行伪代码点出 .native 的底层语义:它不走 Vue 的自定义事件系统,而是直接在组件根元素的真实 DOM 上调用 addEventListener('click', fn)。编译层面,Vue 把带 .native 的监听器收集进虚拟节点 data 的 nativeOn 字段(普通自定义事件放 on 字段),patch 阶段 nativeOn 里的监听器被绑定到根元素 DOM 上。市面应用 :给第三方/自研组件的根元素加拖拽 mousedown、给整卡片加点击埋点,都是不改子组件源码、直接用 .native 在外层接管原生事件。

【代码注释】该图左红右绿对比两条事件路径。红色「不加 .native」 :父组件写 @click="fn" 被 Vue 理解成"监听子组件 $emit('click') 的自定义事件",可子组件内部从不 $emit('click'),于是 fn 永不触发------这是初学者最常踩的"点了没反应"坑。绿色「加 .native」@click.native="fn" 让监听器绕过自定义事件系统、直接挂到组件根元素的真实 DOM 上,用户点击根元素即触发 fn。一句话区分:没有 .native@click 等的是子组件"主动喊一嗓子";加了 .native,等的是"用户真实点到那块 DOM" 。注意 Vue3 已移除 .native,改为未被 emits 声明的监听器自动透传到根元素,迁移时要留意这处行为差异。

4.3 完整示例:弹窗关闭按钮

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>.native 修饰符</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <style>
    .modal-overlay { position: fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.5); display:flex; align-items:center; justify-content:center; }
    .modal { background:#fff; border-radius:8px; padding:24px; min-width:320px; }
    .modal-footer { display:flex; justify-content:flex-end; gap:10px; margin-top:16px; }
    .btn { padding:8px 20px; border:none; border-radius:4px; cursor:pointer; }
    .btn-close { background:#e0e0e0; }
    .btn-confirm { background:#3498db; color:#fff; }
    .open-btn { padding:10px 20px; background:#3498db; color:#fff; border:none; border-radius:4px; cursor:pointer; }
  </style>
</head>
<body>
<div id="app">
  <button class="open-btn" @click="showModal=true">打开弹窗</button>

  <!-- 使用 .native 监听根元素的原生 click,实现点击遮罩关闭 -->
  <confirm-modal
    v-if="showModal"
    title="确认删除"
    content="该操作不可恢复,确认要删除这条记录吗?"
    @confirm="handleConfirm"
    @cancel="showModal=false"
    @click.native="handleOverlayClick"
  ></confirm-modal>

  <div v-if="result" style="margin-top:20px;padding:12px;background:#f0f9ff;border-radius:4px;">
    操作结果:{{ result }}
  </div>
</div>

<script>
const ConfirmModal = {
  props: {
    title: String,
    content: String
  },
  // 根元素是 .modal-overlay(遮罩层)
  // .native 会将 click 绑定到这个 div 上
  template: `
    <div class="modal-overlay">
      <div class="modal" @click.stop>
        <h3>{{ title }}</h3>
        <p style="color:#666">{{ content }}</p>
        <div class="modal-footer">
          <button class="btn btn-close" @click="$emit('cancel')">取消</button>
          <button class="btn btn-confirm" @click="$emit('confirm')">确认删除</button>
        </div>
      </div>
    </div>
  `
  // 注意:此组件内部从不 $emit('click')
  // 若父组件不加 .native,@click 监听永远不会触发
};

new Vue({
  el: '#app',
  data: { showModal: false, result: '' },
  methods: {
    handleOverlayClick() {
      // 点击遮罩(根元素)触发,.native 让这里可以捕获根元素的原生 click
      // 注意:内部弹窗有 @click.stop,阻止冒泡,所以点击弹窗内容区不会触发此方法
      this.showModal = false;
      this.result = '点击遮罩关闭';
    },
    handleConfirm() {
      this.showModal = false;
      this.result = '确认删除操作已执行';
    }
  },
  components: { ConfirmModal }
});
</script>
</html>

【代码注释】

  • ConfirmModal 的根元素是 .modal-overlay 遮罩层,组件内部从不 $emit('click')
  • 父组件写 @click.native="handleOverlayClick",将 click 监听器绑定到遮罩层的真实 DOM 上,实现"点击遮罩关闭弹窗"。
  • 弹窗内容区 .modal 上写了 @click.stop,阻止 click 事件冒泡到遮罩层,避免点击弹窗内部时意外触发关闭逻辑。

【实战要点】

  • .native 只适用于自定义组件 ,普通 HTML 标签上使用 .native 无效(原生元素本来就是 DOM 事件,不经过 Vue 的自定义事件系统)。
  • Vue3 已移除 .native 修饰符,改为在组件的 emits 选项中声明所有自定义事件,未声明的 @xxx 监听器自动穿透到根元素。迁移时需注意。

【本章小结】

.native 修饰符解决了"父组件想监听组件根元素的原生 DOM 事件,但子组件没有 $emit 对应事件"的问题,是弹窗遮罩关闭、拖拽事件等场景的常见解法。

维度 @click(无 .native) @click.native
监听对象 子组件 $emit('click') 的自定义事件 子组件根元素的原生 DOM click
触发条件 子组件主动 $emit('click') 才触发 用户真实点击根元素 DOM 即触发
编译归宿 vnode data 的 on 字段 vnode data 的 nativeOn 字段
适用对象 自定义组件 仅自定义组件(普通标签上无效)
Vue3 状态 保留 已移除 ,改用 emits + 自动透传

记忆口诀"无 native 等组件喊(emit),有 native 听真实点;只对组件才有用,Vue3 改成自动穿"

【面试考点】

Q:Vue2 中 @click 和 @click.native 有什么区别? A:在组件上使用 @click 监听的是组件通过 $emit('click') 触发的自定义事件;@click.native 监听的是组件根元素的原生 DOM click 事件。前者依赖子组件主动 $emit,后者只要用户点击根元素 DOM 就会触发。
Q:.native 绑定的是组件的哪个 DOM?如果组件有多个根节点会怎样? A:.native 绑定到组件的根元素 真实 DOM 上(编译进 vnode 的 nativeOn 字段,patch 时挂到根元素)。Vue2 中组件必须单根节点,所以总能确定绑哪个元素。但如果用 <template> 或函数式组件造成"无明确单一根元素",.native 可能无处可绑或行为不符合预期,此时应让子组件用 $emit 显式抛事件、或用 $listeners 透传更可靠。
Q:Vue3 移除了 .native,那原来 @click.native 的写法该如何迁移? A:Vue3 取消了 .native 修饰符,改为"事件声明 + 自动透传"机制:组件用 emits 选项声明自己会 $emit 的所有自定义事件;未在 emits 中声明的 @xxx 监听器会被当作原生事件自动透传(fallthrough)到组件根元素 。所以 Vue2 的 @click.native="fn" 在 Vue3 里直接写 @click="fn" 即可(只要组件没在 emits 里声明 click),它会自动落到根元素的原生 click 上。


五、全局组件注册与使用

5.1 名词解释

全局组件(Global Component) :通过 Vue.component(name, options) 注册的组件,一次注册后在任意 Vue 实例、任意局部组件 的模板中均可直接使用,无需在 components 选项中重复声明。

局部组件 :在某个 Vue 实例或组件的 components 选项中声明,只在该实例/组件的作用域内可用。

5.2 概念与底层原理

Vue.component 将组件配置对象注册到 Vue 构造函数的全局组件表(Vue.options.components)。所有通过 new Vue() 创建的实例在解析模板时,先查找局部 components,若未找到则回溯到全局组件表。Vue 官方对二者的定位描述为:全局注册的组件**"可以用在其被注册之后的任何(通过 new Vue)新创建的 Vue 根实例,也包括其组件树中的所有子组件的模板中",而局部注册的组件 "只有在该类型的组件才可以访问"**(Vue 官方文档 - 组件注册)。底层上,每个实例创建时会把 Vue.options.components(全局表)合并进自己的 vm.$options.components,所以全局组件本质是被"扩展"到每个实例的组件表里,这正是它无需重复声明即可使用的原因。

【代码注释】该图展示模板编译时"组件名 → 组件定义"的解析顺序。蓝色 :模板解析遇到 <my-badge> 标签;黄色 :先查当前实例自己的 components 选项(局部组件表);紫色 :局部没找到才回溯到 Vue.options.components(全局表);绿色 :任一层命中即拿到定义渲染;红色 :两层都找不到,控制台抛 Unknown custom element 警告。这个"局部优先、全局兜底"的查找链解释了两件事:① 全局组件无需在每个 components 里重复声明就能用------它在回溯的最后一层兜底;② 局部同名组件会覆盖 全局组件,因为局部表先被命中。底层实现是 resolveAsset 函数,它依次按"原名 → 驼峰名 → 首字母大写名"在组件表上查找,所以 my-badge / myBadge / MyBadge 都能匹配到同一个注册项。市面应用 :Element UI 的 el-button 等基础组件全部走全局注册兜底,而页面级业务组件走局部注册做隔离,正是利用了这条解析链。

javascript 复制代码
// 必须在 new Vue() 之前注册
Vue.component('组件名', {
  // 与局部组件完全相同的配置格式
  data() { return { ... } },
  props: [...],
  template: `...`
});

【代码注释】这是全局注册的标准签名:Vue.component(name, options)options 与局部组件配置格式完全一致(data 必须是函数、propstemplate 等)。它把组件挂进 Vue.options.components,因此必须在 new Vue() 之前调用 ------根实例创建时会把 Vue.options.components 合并进自己的组件表,注册晚了根实例就拿不到。市面应用 :Vue CLI 项目里通常在 main.jsVue.component 逐个注册基础组件,或用 require.context 扫描 components/base/ 目录批量自动注册,避免在每个页面手写 import + components 声明。

5.3 完整示例:通用数字徽章组件

将一个"数字徽章"组件注册为全局组件,在任意地方都能直接使用:

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>全局组件注册</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <style>
    body { font-family: sans-serif; padding: 20px; }
    .v-badge { display: inline-flex; align-items: center; gap: 6px; }
    .v-badge-count { background: #e74c3c; color: #fff; border-radius: 10px;
      padding: 1px 7px; font-size: 12px; min-width: 18px; text-align: center; }
    .v-badge-count.zero { background: #95a5a6; }
    .v-badge-count.primary { background: #3498db; }
    .v-badge-count.success { background: #2ecc71; }
    .nav { display: flex; gap: 24px; padding: 12px 0; border-bottom: 1px solid #eee; }
    .card { border: 1px solid #ddd; border-radius: 6px; padding: 16px; margin-top: 20px; }
    .nested { margin-top: 16px; padding: 12px; background: #f8f9fa; border-radius: 4px; }
  </style>
</head>
<body>
<div id="app">
  <!-- 导航栏(根实例模板中直接使用全局组件) -->
  <div class="nav">
    <span>首页</span>
    <span>
      消息
      <v-badge :count="msgCount" type="primary"></v-badge>
    </span>
    <span>
      购物车
      <v-badge :count="cartCount"></v-badge>
    </span>
    <span>
      通知
      <v-badge :count="0"></v-badge>
    </span>
  </div>

  <!-- 子组件中也可以直接使用全局组件 -->
  <user-panel></user-panel>

  <div class="card">
    <button @click="msgCount++" style="margin-right:10px">新消息 +1</button>
    <button @click="cartCount++">购物车 +1</button>
  </div>
</div>

<script>
// 注册全局徽章组件
// 一次注册,到处使用------无需在每个 components 选项中重复声明
Vue.component('v-badge', {
  props: {
    count: { type: Number, default: 0 },
    type: { type: String, default: 'default' }   // default | primary | success
  },
  computed: {
    // 超过 99 显示 99+
    displayCount() {
      if (this.count <= 0) return '0';
      return this.count > 99 ? '99+' : String(this.count);
    }
  },
  template: `
    <span class="v-badge">
      <span class="v-badge-count" :class="[type, { zero: count <= 0 }]">
        {{ displayCount }}
      </span>
    </span>
  `
});

// UserPanel 是局部组件,内部直接使用了全局的 v-badge
// 无需在 components 中声明 v-badge
const UserPanel = {
  data() { return { followCount: 5, fansCount: 128 }; },
  template: `
    <div class="card">
      <h4>个人主页(局部组件内使用全局组件)</h4>
      <div class="nested">
        关注 <v-badge :count="followCount" type="success"></v-badge>
        &nbsp;&nbsp;
        粉丝 <v-badge :count="fansCount" type="primary"></v-badge>
      </div>
    </div>
  `
};

new Vue({
  el: '#app',
  data: { msgCount: 3, cartCount: 2 },
  components: { UserPanel }
  // 注意:无需声明 'v-badge',全局注册后自动可用
});
</script>
</html>

【代码注释】

  • Vue.component('v-badge', { ... }) 将徽章组件注册到全局,之后所有模板(无论是根实例还是任意子组件)都能直接写 <v-badge> 标签。
  • UserPanel 是局部组件,其内部模板使用了 <v-badge>,无需在自己的 components 选项中声明,这正是全局组件的优势。
  • 组件命名建议使用带连字符的小写格式(v-badge 而非 VBadge),与 HTML 原生元素区分,同时避免大小写问题。

【实战要点】

  • 注册时机:Vue.component 必须在 new Vue() 之前调用。
  • 适用组件:适合高频复用的基础 UI 组件(按钮、输入框、徽章、图标等)。业务逻辑组件建议使用局部注册,避免全局污染。
  • 在 Vue CLI 项目中,通常将全局组件注册逻辑放在 main.js 中,或使用 require.context 批量自动注册。

【本章小结】

全局组件是构建 UI 组件库(Element UI、Ant Design Vue 等)的基础机制。Vue.use(ElementUI) 的本质就是批量调用 Vue.component 注册所有基础组件。

维度 全局组件(Vue.component 局部组件(components 选项)
注册位置 Vue.options.components 全局表 当前实例的 vm.$options.components
使用范围 注册后任意实例/组件模板直接用 仅注册它的那个组件内部可用
是否重复声明 一次注册,处处可用 每个用到的组件都要单独声明
打包影响 即使没用到也会被打包(无法 tree-shaking) 按需引入,利于代码分割
适用场景 高频通用基础 UI(按钮、徽章、图标) 页面级业务组件,需隔离
注册时机 必须在 new Vue() 之前 随组件定义,无时机限制

记忆口诀"全局一次处处用,局部声明各自管;通用 UI 走全局,业务组件用局部"

【面试考点】

Q:全局组件和局部组件的区别?各自适用什么场景? A:全局组件通过 Vue.component 注册,在整个应用任意位置可用,无需重复声明,适合高频通用的基础 UI 组件;局部组件在 components 选项中声明,只在当前作用域可用,有更好的隔离性和按需加载特性,适合业务功能组件。
Q:为什么过度使用全局组件不利于大型项目? A:① 打包体积 :全局组件被注册进 Vue.options.components,即使某些页面根本没用到,构建工具也无法做 tree-shaking 把它摇掉,会增大最终包体;② 命名污染与隐式依赖 :全局组件谁都能用,看模板里的 <x-foo> 无法直观判断它来自哪里,重构时难以追踪;③ 隔离性差:局部同名组件会覆盖全局组件,容易引发意外覆盖。因此实践中只把真正高频的基础 UI 组件全局注册,业务组件一律局部注册。
Q:Vue.component('my-badge', ...) 注册后,模板里写 <MyBadge><my-badge> 都能识别吗? A:能。Vue 的组件解析函数 resolveAsset 会依次按"原名 → 驼峰名(camelCase)→ 首字母大写名(PascalCase)"在组件表中查找,所以注册名 my-badge 时,模板里 <my-badge><myBadge><MyBadge> 都能匹配到同一个注册项。不过在 DOM 模板(直接写在 HTML 里)中,由于浏览器会把标签名转小写,只能用短横线分隔(kebab-case)的 <my-badge>


六、动态组件:component :is

6.1 名词解释

动态组件(Dynamic Component) :Vue 内置的 <component> 元素配合 :is 属性,根据变量的值在运行时动态决定渲染哪个组件,无需写大量 v-if/v-else-if

keep-alive :Vue 内置组件,包裹动态组件后,被切换走的组件不会销毁,而是缓存在内存中,再次切换回来时直接恢复,不重新执行 created/mounted

6.2 概念与底层原理

<component> 是 Vue 的内置组件,编译后相当于一个"组件占位符",:is 的值可以是:

  • 字符串:组件的注册名称(需要预先注册为局部或全局组件)
  • 组件配置对象 :直接传入 { template, data, ... } 对象
javascript 复制代码
// 以下两种写法等价:

// 动态组件写法
<component :is="currentTab"></component>

// 手写 v-if 链等价写法(当组件多时非常冗余)
<tab-home v-if="currentTab === 'tab-home'"></tab-home>
<tab-profile v-else-if="currentTab === 'tab-profile'"></tab-profile>
<tab-setting v-else-if="currentTab === 'tab-setting'"></tab-setting>

【代码注释】这两段对比凸显动态组件的价值:上方一行 <component :is="currentTab"> 把"渲染哪个组件"交给运行时的 currentTab 变量,组件越多越简洁;下方的 v-if/v-else-if 链每加一个组件就要多写一行判断,三五个还行,十几个 Tab 就会变成难以维护的长链。底层上 <component> 是 Vue 内置的"占位组件",渲染时根据 :is 的值(字符串走全局/局部组件表查找,对象则直接作为组件定义)动态生成对应 vnode。市面应用 :表单设计器按字段类型渲染不同输入控件(<component :is="field.type + '-input'">)、可视化大屏按配置动态渲染图表组件,都靠 :is 把组件类型数据化。

不加 keep-alive 时的生命周期行为 :每次切换组件,旧组件执行 beforeDestroy + destroyed,新组件执行 created + mounted

加 keep-alive 后的生命周期行为 :被切换走的组件执行 deactivated(停用),再次切换回来执行 activated(激活),跳过 createdmountedbeforeDestroy

下面这张图把"加不加 keep-alive"两条生命周期路径并排画出来,对照看更清楚:

【代码注释】该图以"切换动态组件"(黄色)为起点分出两条路径。红色「不加 keep-alive」 :旧组件走完整销毁流程 beforeDestroy → destroyed,实例被回收、内部状态(如计数器)清零;新组件重新 created → mounted 初始化------每次切换都是"推倒重来"。绿色「加 keep-alive」 :切走的组件不销毁,只触发 deactivated 进入缓存对象(源码里挂在 keep-alive 实例的 cache 字段、key 记在 keys 数组),切回来触发 activated 从缓存恢复 DOM 与状态,跳过 created/mounted ------这就是计数器值能保留的原因。橙色 LRUkeep-alivemax prop 限制最大缓存数,一旦超出就按"最久未访问优先淘汰"(Least Recently Used)销毁最老的实例(keep-alive 官方文档)。实战提醒:activated 替代了 mounted 做"每次进入页面都要刷新的数据请求",deactivated 替代 beforeDestroy 做定时器清理------别把这类逻辑写在只执行一次的 created/mounted 里。市面应用 :列表页→详情页→返回列表时用 keep-alive 保留列表滚动位置和筛选条件,是中后台和移动端最高频的缓存场景。

6.3 完整示例:Tab 标签页内容切换

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>动态组件 component :is</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <style>
    body { font-family: sans-serif; padding: 20px; }
    .tabs { display: flex; gap: 0; border-bottom: 2px solid #3498db; margin-bottom: 0; }
    .tab-btn { padding: 10px 24px; border: none; background: #f0f0f0; cursor: pointer; font-size: 14px; transition: all .2s; }
    .tab-btn.active { background: #3498db; color: #fff; }
    .tab-content { border: 1px solid #ddd; border-top: none; padding: 20px; border-radius: 0 0 6px 6px; min-height: 120px; }
    .lifecycle-log { margin-top: 16px; background: #f8f9fa; border-radius: 4px; padding: 10px; font-size: 12px; color: #666; }
    .log-item { padding: 2px 0; }
    .created { color: #27ae60; }
    .destroyed { color: #e74c3c; }
    .activated { color: #3498db; }
    .deactivated { color: #e67e22; }
    .counter { font-size: 32px; text-align: center; padding: 20px; }
    .counter button { padding: 6px 14px; margin: 0 6px; border: 1px solid #3498db; background: transparent; color: #3498db; border-radius: 4px; cursor: pointer; font-size: 16px; }
  </style>
</head>
<body>
<div id="app">
  <h2>动态组件与 keep-alive</h2>

  <!-- Tab 按钮 -->
  <div class="tabs">
    <button
      class="tab-btn"
      :class="{ active: currentTab === item.name }"
      v-for="item in tabs"
      :key="item.name"
      @click="currentTab = item.name"
    >{{ item.label }}</button>
  </div>

  <div class="tab-content">
    <!-- keep-alive 缓存:组件切走不销毁,保留内部状态 -->
    <keep-alive>
      <component :is="currentTab"></component>
    </keep-alive>
  </div>

  <div class="lifecycle-log">
    <strong>生命周期日志(验证 keep-alive 效果):</strong>
    <div class="log-item" v-for="(log, i) in logs" :key="i" :class="log.type">
      {{ log.time }} [{{ log.component }}] {{ log.event }}
    </div>
  </div>
</div>

<script>
// 工厂函数:创建带生命周期日志的 Tab 组件
function createTabComponent(name, color, description) {
  return {
    data() { return { count: 0 }; },
    template: `
      <div>
        <p style="color:${color}"><strong>${name}</strong>:${description}</p>
        <div class="counter">
          <button @click="count--">-</button>
          {{ count }}
          <button @click="count++">+</button>
        </div>
        <p style="font-size:12px;color:#999;margin-top:8px">
          切换到其他 Tab 后再切回来,计数会保留(keep-alive 的效果)
        </p>
      </div>
    `,
    created() { app.addLog(name, 'created', 'created'); },
    mounted() { app.addLog(name, 'mounted', 'created'); },
    // keep-alive 专属钩子
    activated() { app.addLog(name, 'activated(恢复缓存)', 'activated'); },
    deactivated() { app.addLog(name, 'deactivated(进入缓存)', 'deactivated'); },
    beforeDestroy() { app.addLog(name, 'beforeDestroy', 'destroyed'); }
  };
}

// 注册三个 Tab 组件为全局组件(也可以局部注册)
Vue.component('tab-home', createTabComponent('首页', '#3498db', '首页的独立计数器'));
Vue.component('tab-profile', createTabComponent('个人资料', '#2ecc71', '个人资料的独立计数器'));
Vue.component('tab-setting', createTabComponent('设置', '#e74c3c', '设置页面的独立计数器'));

// 使用 var 是为了在 createTabComponent 中通过 app.addLog 访问
var app = new Vue({
  el: '#app',
  data: {
    currentTab: 'tab-home',
    tabs: [
      { name: 'tab-home', label: '首页' },
      { name: 'tab-profile', label: '个人资料' },
      { name: 'tab-setting', label: '设置' }
    ],
    logs: []
  },
  methods: {
    addLog(component, event, type) {
      const time = new Date().toLocaleTimeString();
      this.logs.unshift({ component, event, type, time });
      if (this.logs.length > 15) this.logs.pop();
    }
  }
});
</script>
</html>

【代码注释】

  • <component :is="currentTab"> 根据 currentTab 字符串值动态渲染对应名称的组件,切换时只改变 currentTab 的值即可。
  • <keep-alive> 包裹后,切走的组件不执行 beforeDestroy,而是执行 deactivated;切回来时不执行 created/mounted,而是执行 activated。计数器的值得以保留,验证了组件实例被缓存。
  • 若去掉 <keep-alive>,每次切换组件都会销毁旧组件、创建新组件,日志中会看到 beforeDestroycreated,计数器每次归零。

【实战要点】

  • keep-alive 适合切换频繁、组件内部有状态或有昂贵渲染逻辑的场景(如路由中的列表页切换详情再返回,保留列表滚动位置)。
  • keep-alive 支持 include/exclude 属性,精确控制缓存哪些组件:<keep-alive include="tab-home,tab-profile">
  • 不要无条件对所有组件使用 keep-alive,缓存过多组件会占用大量内存。

【本章小结】

动态组件将"渲染哪个组件"的决策从静态模板移到了运行时数据,配合 keep-alive 实现有状态的 Tab 切换、步骤向导等 UI 模式,是 Vue 路由实现的基础原语之一。

维度 不加 keep-alive keep-alive
切走时钩子 beforeDestroy → destroyed(销毁) deactivated(进入缓存,不销毁)
切回时钩子 created → mounted(重建) activated(从缓存恢复,跳过 created/mounted)
内部状态 每次切换归零、重新初始化 保留(计数器、滚动位置、表单填写)
内存占用 低(用完即销毁) 高(实例常驻,max 控制 LRU 淘汰)
数据刷新写在 mounted activated(每次进入都执行)
资源清理写在 beforeDestroy deactivated

记忆口诀"is 按名换组件,v-if 长链全省去;keep-alive 缓状态,activated 进 deactivated 出"

【面试考点】

Q:keep-alive 的作用是什么?它会引入哪些新的生命周期钩子? A:keep-alive 将被切走的动态组件缓存在内存中,避免重复创建/销毁,保留内部状态和 DOM。它引入了两个专属钩子:activated(组件从缓存中恢复时执行)和 deactivated(组件进入缓存时执行)。这两个钩子替代了 mountedbeforeDestroy 的作用,应在其中处理数据刷新和资源清理(如定时器)。
Q:<component :is="name"> 中的 name 可以是什么类型? A:可以是字符串(已注册组件的名称)或组件配置对象(直接传入 { template, data, ... })。传字符串时 Vue 去全局/局部组件表查找对应的定义;传对象时直接使用该对象作为组件配置,适合动态生成组件配置的场景。


七、实例引用通信: refs/refs / refs/parent / provide-inject

前面的方案要么靠数据流(状态提升)、要么靠事件流(EventBus)。Vue 还提供了第三类"直接拿到组件实例"的通信手段:$refs$parent/$childrenprovide/inject。它们更"命令式",是官方文档归为"处理边界情况"的逃生舱(Vue 处理边界情况),用得好能解决前两类方案的尴尬场景,用得滥则会让组件耦合、难以重构。

7.1 名词解释

名词 一句话定义
$refs 通过 ref 属性给子组件/DOM 打标记,父组件用 this.$refs.xxx 直接拿到子组件实例或真实 DOM
$parent / $children 子组件用 this.$parent 访问父实例,父组件用 this.$children 访问直接子实例数组
provide / inject 祖先用 provide 提供数据/方法,任意深度的后代用 inject 按 key 注入,跳过中间层

7.2 概念与底层原理

$refs 是命令式访问的逃生舱。 官方明确:"$refs 只会在组件渲染完成之后生效,并且它们不是响应式的。这只是一个用于直接操作子组件的『逃生舱』------你应该避免在模板或计算属性中访问 $refs"Vue 官方文档)。底层上,编译器把 ref="foo" 编译进 vnode 的 ref 字段,patch 阶段把对应实例/DOM 注册到 vm.$refs.foo------所以挂载前 $refs 为空,且它不参与依赖收集,改了不会触发重渲染。

$parent 通信耦合度高,慎用。 官方警告:"在大多数情况下,触达父级组件会使你的应用更难调试和理解,尤其是当你修改了父级的数据的时候。" 子组件直接 this.$parent.xxx = ... 会让组件强依赖"被放在某个特定父级下",复用性骤降。

provide/inject 解决"props 穿透",但默认不响应。 官方强调:"provideinject 绑定并不是可响应的。这是刻意为之的。然而,如果你传入了一个可监听的对象,那么其对象的 property 还是可响应的。" 这意味着 provide: { color: this.color } 传基本类型时后代拿到的是快照、祖先改了不更新;要响应必须 provide: { theme: this.themeObj } 传一个响应式对象,后代读它的属性才会响应。

javascript 复制代码
// $refs:父组件命令式调用子组件方法 / 操作 DOM
// 场景:父组件点"提交",命令式触发子表单组件的校验
this.$refs.userForm.validate();   // 调用子组件暴露的方法
this.$refs.videoEl.play();        // 操作真实 DOM 元素

// provide / inject:祖先提供响应式对象,后代按 key 注入
// 祖先组件
export default {
  provide() {
    // 传对象(而非基本类型),其内部 property 仍是响应式的
    return { theme: this.theme };
  },
  data() { return { theme: { color: 'blue' } }; }
};
// 任意深度的后代组件
export default {
  inject: ['theme'],   // 直接拿到祖先的 theme,无需逐层 props
  computed: {
    textColor() { return this.theme.color; }  // 读对象属性可响应
  }
};

【代码注释】这段展示两类"实例引用"通信的标准写法。$refs 部分是命令式的:父组件拿到子组件实例后直接调用它暴露的 validate(),或拿到真实 DOM 调用 play()------注意只能在 mounted 之后、事件回调里用,模板和 computed 里访问 $refs 会因"渲染未完成/不响应"而出错。provide/inject 部分专治"props 穿透":祖先一次 provide,跨任意层级的后代直接 inject,中间组件无需透传------但关键细节是 provide 要传对象而非基本类型 ,因为绑定本身不响应,只有对象内部的 property 借由响应式系统保持联动。市面应用 :Element UI 的 el-form 与深层 el-form-item 之间靠 provide/inject 共享表单校验上下文;父组件用 $refs.form.validate() 命令式触发整个表单校验,正是这两套机制的经典组合。

7.3 三种方案的选型边界

方案 通信方向 是否响应 适用场景 主要风险
$refs 父 → 子(命令式) 调子组件方法、操作 DOM、聚焦/播放 渲染后才有值、滥用导致命令式耦合
$parent/$children 子 ↔ 父 是(实例数据) 紧耦合的组合组件内部 强依赖父级结构,复用性差
provide/inject 祖先 → 后代 默认否(传对象可响应) 组件库跨层共享上下文、主题/配置注入 隐式依赖、绑定不响应易踩坑

【实战要点】

  • $refs 拿到的是"渲染后"的引用,改数据后想立刻用 $refs 须放进 this.$nextTick(() => { ... })
  • 业务代码优先用 props/$emit$bus$parent/$children 仅在"明确是一组紧耦合组件"(如 Tabs + TabPane)时使用。
  • provide/inject 要响应式,记住"提供一个对象,注入读它的属性";或在 Vue2.6+ 配合 Vue.observable() 创建响应式数据源再 provide。

【本章小结】

$refs/$parent/provide-inject 是 Vue 在"数据流 + 事件流"之外补充的"实例引用"通信。它们更直接但更易耦合:$refs 做命令式调用、$parent 做紧耦合组合、provide/inject 做跨层依赖注入。记住一句话------能用 props/ emit/emit/ emit/bus 解决的,就别用实例引用;非用不可时,把耦合控制在"明确成组"的组件内部

【面试考点】

Q:provide/inject 是响应式的吗?怎么让它响应? A:默认不是 响应式的(官方刻意设计)。provide 一个基本类型,后代拿到的是值快照,祖先后续修改不会同步。让它响应有两种做法:① provide 一个对象,后代读对象的 property------对象内部属性仍受响应式系统管控;② 用 Vue.observable()(Vue2.6+)创建响应式对象再 provide。
Q: refs为什么不能在模板或computed中用?A:因为'refs 为什么不能在模板或 computed 中用? A:因为 ` refs为什么不能在模板或computed中用?A:因为'refs只在组件**渲染完成后**才被填充,且**不是响应式**的。模板/computed 在渲染期间求值时 refs'可能还是空,且它的变化不会触发重新渲染,依赖它会得到过期或空值。'refs` 可能还是空,且它的变化不会触发重新渲染,依赖它会得到过期或空值。` refs'可能还是空,且它的变化不会触发重新渲染,依赖它会得到过期或空值。'refs只应在mounted` 之后的生命周期钩子或事件回调里作为"命令式逃生舱"使用。


总结

知识点回顾(思维导图)

【代码注释】该图把全篇五大主题串成一张知识脉络。蓝色状态提升 ------数据上提公共祖先、props/emit 流转,适合浅层树;**绿色 EventBus/ bus∗∗------独立Vue实例的'events'事件中心,'emit/on/once/off'四件套,'Vue.prototype.bus**------独立 Vue 实例的 `_events` 事件中心,`emit/on/once/off` 四件套,`Vue.prototype. bus∗∗------独立Vue实例的'events'事件中心,'emit/on/once/off'四件套,'Vue.prototype.bus 原型链注入,beforeDestroy必须off 防泄漏;**黄色实例引用通信**------ refs'命令式访问、'refs` 命令式访问、` refs'命令式访问、'parent/$children 紧耦合组合、provide/inject 跨层注入;**橙色 .native 修饰符**------绑定根元素原生事件、Vue3 已移除;**红色组件机制**------Vue.component 全局注册、component :is 动态切换、keep-alive缓存配合activated/deactivated`。复习时建议沿"数据流(状态提升)→ 事件流(EventBus)→ 实例引用(refs/inject)→ 组件机制(注册/动态/缓存)"四条主线回溯,每条主线对应一类典型业务场景。市面应用:这张脉络图也是一份面试复盘清单------把每个分支能讲清"是什么 + 原理 + 何时用 + 与 Vue3 差异",非父子通信这一章就过关了。

高频面试题速查

问题 要点
Vue2 非父子通信有哪些方案? 状态提升、EventBus/$bus、provide/inject、Vuex
EventBus 为什么要在 beforeDestroy 中 $off? 不取消订阅,组件销毁后监听函数仍存内存,造成内存泄漏
$bus 为什么所有组件都能访问? 挂在 Vue.prototype 上,所有实例通过原型链查找可访问
@click 和 @click.native 区别? 前者监听 $emit 自定义事件,后者绑定根元素原生 DOM 事件
全局组件和局部组件如何选择? 全局适合高频通用 UI 组件,局部适合业务功能组件
keep-alive 引入了哪些钩子? activated(从缓存恢复)和 deactivated(进入缓存)
:is 的值可以是什么类型? 字符串(组件名)或组件配置对象

学习建议

  1. 动手验证生命周期 :用本文第六章的示例,分别加上和去掉 <keep-alive>,对比日志观察生命周期差异,是理解 keep-alive 最直接的方式。

  2. 从 EventBus 迁移到 Vuex:当项目中 EventBus 的事件超过 10 个,或者事件触发链路超过 3 跳时,这是引入 Vuex 的信号。两者的共同点是"发布-订阅",Vuex 在此基础上增加了可预测的状态追踪。

  3. 理解原型链注入$bus$router$store 都是通过 Vue.prototype 注入的,掌握这一原理后,看任何 Vue 插件的源码都会轻松很多。

  4. Vue3 的变化对照 :EventBus 在 Vue3 中被官方移除($on/$off/$once 不再存在于 Vue 实例上),需要用第三方库(如 mitt)替代。.native 修饰符也被移除,keep-aliveinclude/exclude 配合 defineComponentname 属性使用,提前了解这些变化有助于平滑升级。

相关推荐
PedroQue991 小时前
Vite插件体系1.0.0:API稳定,生产就绪
前端·vite
用户059540174461 小时前
把LLM记忆测试从手工脚本换成Pytest参数化,回归时间从2小时降到10分钟
前端·css
donecoding1 小时前
3 条命令搞定闭环 Monorepo:Lerna 版本管理 + 拓扑构建 + 自定义分发
前端·前端框架·node.js
IT_陈寒1 小时前
Vue的这个响应式陷阱让我熬到凌晨三点
前端·人工智能·后端
爱勇宝10 小时前
大多数人不是在使用 AI 赚钱,而是在帮 AI 公司赚钱
前端·后端·程序员
冬奇Lab11 小时前
每日一个开源项目(第143篇):page-agent - 纯 JS 的网页 GUI Agent,无需截图、无需插件、无需后端
前端·人工智能·agent
IT_陈寒15 小时前
React的这个渲染问题连官方文档都没说清楚
前端·人工智能·后端
追逐时光者17 小时前
别再满网找零散工具了,腾讯 QQ 浏览器这个“帮小忙”工具箱真能省时间
前端·后端
如果超人不会飞17 小时前
脉络清晰的业务演进:TinyVue Timeline 时间线组件全方位实战指南
vue.js