# Vue 兄弟组件间通信、发布与订阅,动态、异步组件

TIP

从本节内容开始,我们正式深入 Vue 组件的学习,也是组件学习过程中非常重要的部分

  • Vue 兄弟组件间通信 - 发布与订阅
  • 动态组件
  • 异步组件

# 一、兄弟组件间通信 - 发布与订阅

TIP

本小节我们重点学习兄弟组件间通信的两种方式:

  • 借助父组件完成兄弟组件间通信
  • 利用发布订阅模式实现兄弟组件间通信。

# 1、借助父组件完成兄弟组件通信

下图中的组件 A 与组件 B 为兄弟组件,组件 App 为他们共同的父组件。

image-20230625154316778

注:

如果组件 A 需要把变量 state 的值传递给到组件 B,可以先把 state 的值传递给到父组件 App,然后父组件 App 再把 state 的值传递给到 B 组件。

实现原理

  • 把变量 state 定义在 App 组件中,然后通过自定义属性递给到 B 组件。
  • 在 App 组件中监听自定义事件on-state,当事件触发时修改 state 的值。
  • 在 A 组件中触发onState事件,来修改 state 的值

代码示例

App.vue文件

  • 把变量state定义在组件<App>中,同时监听on-state事件,在事件触发时调用setState方法修改state变量的值。
  • 把变量state作为属性传递到 B 组件内部
<script>
  import A from "./components/A.vue";
  import B from "./components/B.vue";
  export default {
    data() {
      return {
        state: 1,
      };
    },
    components: {
      A,
      B,
    },
    methods: {
      setState(value) {
        this.state = value;
      },
    },
  };
</script>

<template>
  <a @on-state="setState"></a>
  <b :state="state"></b>
</template>

A.vue文件,在 A 组件中,触发onState事件,修改 state 的值

<script>
  export default {
    emits: ["onState"],
  };
</script>
<template>
  <button @click="$emit('onState', 10)">A组件中修改state的值</button>
</template>

B.vue文件,在 B 组件中接受传递过的 state 属性值,并显示

<script>
  export default {
    props: ["state"],
  };
</script>
<template>
  <div>B组件中state的值:{{ state }}</div>
</template>

以上代码,最终实现效果如下:

GIF2023-6-2515-17-04

# 2、借助父组件通信的问题

TIP

通过前面例子,我们知道借助父组件完兄弟组件间通信显然是非常麻烦的,原本属于 A 组件的 state 属性需要定义在 App 组件中,造成了数据管理的混乱。

如果通信的两个组件层级较深,如下图

image-20230625154139583

注:

A1 组件需要与 B1 组件通信,则数据传递链接从 A1 -> A -> App -> B -> B1 。这显然是非常麻烦,而且数据的管理将会变得更加混乱。

接下来讲到的发布与订阅模式是一种非常好的兄弟组件间通信的方式,他可以让 A1 组件直接与 B1 组件通信,不需要借助其它组件。

image-20230625155110811

# 3、发布与订阅模式

TIP

发布与订阅模式其实是对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。

现实中的发布订阅模式

比如小明最近看上了 xx 楼盘的房子,但是该楼盘还没有正式对外销售, 具体对外正式销售时间不清楚,于时小明每天都要打电话到售楼部问售楼 MM 楼盘对外销售时间。

如果有 100 个人都等着该楼盘,那售楼 MM 每天都要接到 100 个电话,而且回答的问题都是一样的,那估计售楼 MM 肯定会崩溃的离职。好在售楼 MM 很聪明,她让这 100 个人加上她的微信,等到楼盘一但对外销售,就会第一时间给这 100 个人群发消息通知他们。

你眼里的发布者与订阅者

你可以理解上面的售楼 MM 就是发布者,小明等购房者就是订阅者。但如果站在更全面的角度来思考,这样理解是不太准确的。

真实的发布者与订阅者

在实际生活中,我们知道售楼部其实是一个一中介,他负责给房地产公司发布售楼相关的消息,同时购买者通过售数部订阅售楼消息。所以

  • 发布者:是房产公司,因为真正要对外发布售楼信息的是房产公司,而售楼部只是代替房产公司对外发布消息。
  • 订阅者:是购买房子的用户,他需要向售楼部订阅房产公司的售楼消息
  • 发布订阅中心:是售楼中心,房产公司找售楼部发布售楼消息,购房者找售楼部订阅售楼消息

由此可以知道,发布消息与订阅消息的接口都是发布订阅中心提供的。

发布订阅的本质

发布订阅核心基于一个中心来建立整个体系。其中发布者和订阅者不直接进行通信,而是发布者将要发布的消息交由中心管理,订阅者也是根据自己的情况,按需订阅订阅中心的消息

我们用下面一张图来描述发布订阅模式

image-20230625190918103

发布订阅中心提供了发布消息订阅消息接口(API 方法),发布者调用发布消息接口即可发布消息,订阅者调用订阅消息接口就可以订阅消息。

# 3.1、简单版:发布与订阅

TIP

我们以上面售楼的故事为例子,利用 JS 实现简单版的发布与订阅。

实现思路

  • ①、定义一个对象,充当售楼部(发布订阅中心)
  • ②、在该对象上创建一个缓存列表,存放订阅的事件(消息),相当于存放订阅者微信号
  • ③、在对象上添加 on 方法,用于订阅消息,调用 on 方法把订阅的事件都添加到缓存列表中,相当于注册(监听)事件
  • ④、在对象上添加 emit 方法,用于发布消息,调用 emit 方法来触发事件(相当于发布消息)
// 1、定义一个对象,充当售楼部(发布订阅中心)
let salesOffices = {};
// 2、在该对象上创建一缓存列表,存放订阅的事件(消息),相当于存放订阅者微信号
salesOffices.clientList = [];
// 3、添加订阅方法,将订阅的事件(消息)添加进缓存列表
salesOffices.on = function (fn) {
  // 订阅的消息添加进缓存列表
  this.clientList.push(fn);
};
// 4、添加emit方法,用于发布消息
// 发布消息时,会去遍历缓存列表中的回调函数,并执行。相当于触发事件的事件处理函数
salesOffices.emit = function () {
  for (let i = 0; i < this.clientList.length; i++) {
    // 修改订阅者回调内的this
    // 发布消息时会带上一些消息相关的信息,这些信息做为参数传入即:arguments
    this.clientList[i].apply(this, arguments);
  }
};

测试以上代码

// 小明订阅消息
salesOffices.on((area, price) => {
  console.log(`当前房子面积是:${area}平方米,价格为:${price}`);
});
// 小花订阅消息
salesOffices.on((area, price) => {
  console.log(`当前房子面积是:${area}平方米,价格为:${price}`);
});

// 售楼部发布消息
salesOffices.emit(2000000, 110);
salesOffices.emit(1000000, 55);

最终在控制台输出如下内容

image-20230625171815424

# 3.2、按主题发布与订阅

TIP

上面我们已经实现了一个简单的发布与订阅模式,但还存在一些问题。我们看到订阅者接收到了发布者的每一个消息。如果小明只想买 55 平方米的房子,那发布者就不应该把 110 平方米房子的信息推送给小明,因为这对小明来说是一种困扰。

所以订阅者在订阅消息时应该要根据主题来订阅,发布者在发布消息时也要根据主题来发布,只有订阅了该主题的订阅者才能收到该主题发布的消息。

我们需要把缓存列表,修改成一个对象,根据订阅的主题来存放订阅者的回调函数

salesOffices.clientList = {};

以下代码实现了按主题订阅和发布消息

// 1、定义一个对象,充当售楼部(发布订阅中心)
let salesOffices = {};
// 2、在该对象上创建一缓存列表,存放订阅的事件(消息)
// 订阅的事件(消息)分为:事件名与事件处理函数,所以我们用一个对象来存放
salesOffices.clientList = {};
// 3、增加订阅事件,key为事件名,fn为事件名对应的事件处理函数
salesOffices.on = function (key, fn) {
  // 如果没有订阅此事件名,给该事件名创建一个缓存事件处理函数的列表
  if (!this.clientList[key]) {
    this.clientList[key] = [];
  }
  // 将事件处理函数添加到事件名对应的事件处理函数缓存列表
  this.clientList[key].push(fn);
};
// 4、售楼处发布消息
// 发布消息时,会根据事件名去遍历对应缓存列表中的事件处理函数,并执行。相当于触发事件的事件处理函数
salesOffices.emit = function () {
  // 取出事件名
  let key = Array.prototype.shift.call(arguments);
  // 根据事件名取出对应的缓存列表
  const fns = this.clientList[key];

  // 如果没有订阅
  if (!fns || fns.length === 0) {
    return false;
  }
  for (let i = 0; i < fns.length; i++) {
    // 修改订阅者回调内的this
    // 发布者在发布消息时会带上一些消息相关的信息,这些信息做为参数传入即:arguments
    fns[i].apply(this, arguments);
  }
};

测试以上代码

// 小明订阅消息
salesOffices.on("area110", (area, price) => {
  console.log(`当前房子面积是:${area}平方米,价格为:${price}`);
});
// 小花订阅消息
salesOffices.on("area80", (area, price) => {
  console.log(`当前房子面积是:${area}平方米,价格为:${price}`);
});

// 售楼部发布消息
salesOffices.emit("area110", 2000000, 110);
salesOffices.emit("area80", 1000000, 55);

最终在控制台输出如下内容

image-20230625175159258

# 3.3、完整版:发布与订阅

TIP

一个完整的发布订阅应该包含以下 4 个方法

  • on 方法:用来订阅事件,即根据事件名添加对应的事件处理回调函数到对应缓存列表
  • emit 方法:用来触发事件,即根据事件名查询缓存列表中的事件处理函数,并执行。
  • off 方法:用来取消订阅事件,即根据事件名查询缓存列表中的事件处理函数,并从列表中移除。
  • once 方法:用来订阅事件,但只订阅一次,即事件触发后,就会取消该事件的订阅
// 1、定义一个对象,充当售楼部(发布订阅中心)
let salesOffices = {};
// 2、在该对象上创建一缓存列表,存放订阅的事件(消息)
// 订阅的事件(消息)分为:事件名与事件处理函数,所以我们用一个对象来存放
salesOffices.clientList = {};
// 3、增加订阅事件,key为事件名,fn为事件名对应的事件处理函数
salesOffices.on = function (key, fn) {
  // 如果没有订阅此事件名,给该事件名创建一个缓存事件处理函数的列表
  if (!this.clientList[key]) {
    this.clientList[key] = [];
  }
  // 将事件处理函数添加到事件名对应的事件处理函数缓存列表
  // this.clientList[key].push(fn)
  this.clientList[key].unshift(fn);
};
// 4、售楼处发布消息
// 发布消息时,会根据事件名去遍历对应缓存列表中的事件处理函数,并执行。相当于触发事件的事件处理函数
salesOffices.emit = function () {
  // 取出事件名
  let key = Array.prototype.shift.call(arguments);
  // 根据事件名取出对应的缓存列表
  const fns = this.clientList[key];
  // 如果没有订阅
  if (!fns || fns.length === 0) {
    return false;
  }
  // 注意,这里要从后放前遍历,因为once的事件会执行一次后被取消
  for (let i = fns.length - 1; i >= 0; i--) {
    // 修改订阅者回调内的this
    // 发布者在发布消息时会带上一些消息相关的信息,这些信息做为参数传入即:arguments
    fns[i].apply(this, arguments);
  }
};

// 5、取消订阅
salesOffices.off = function (key, fn) {
  // 根据事件名取出对应的缓存列表
  const fns = this.clientList[key];
  // 如果没有订阅
  if (!fns || fns.length === 0) {
    return false;
  }
  // 不传订阅事件处理函数,意味着取消所有订阅
  !fn && fns && (fns.length = 0);
  // 传了事件处理函数,取消事件对应的事件处理函数
  for (let i = 0; i < fns.length; i++) {
    // 注意要判断 fns[i].fn===fn,主要用来判断once绑定时
    if (fns[i] === fn || fns[i].fn === fn) {
      fns.splice(i, 1);
      break;
    }
  }
};

// 6、只订阅一次
salesOffices.once = function (key, fn) {
  const _that = this;
  // emit触发时,执行的是on这个方法
  function on() {
    // 先执行一次,再取消
    fn.apply(_that, arguments);
    // 先取消
    _that.off(key, on);
  }
  // 取消时,要判断on.fn===fn,如果等于,则移除该项
  on.fn = fn;
  // 订阅
  this.on(key, on);
};

以上代码有几个需要注意的点

  • emit 方法中,遍历事件名对应的缓存列表,改成从后往前遍历。因为 once 订阅事件只订阅一次,触发一次后就会从列表中删除,造成数组长度变短,后续事件处理函数无法被执行
/*
for (let i =0 ; i < fns.length; i++) {
    fns[i].apply(this, arguments)
}
*/

for (let i = fns.length - 1; i >= 0; i--) {
  fns[i].apply(this, arguments);
}
  • on 方法中,将事件处理函数添加到事件名对应的缓存列表,改成从前往后加入。这样做的目的是保证从后往前遍历缓存列表时,先触发的是先订阅的事件。
// this.clientList[key].push(fn)
this.clientList[key].unshift(fn);
  • off 方法中,在移除事件名对应的事件处理函数时,要考虑 once 绑定的情况。
/*
	for (let i = 0; i < fns.length; i++) {
        // 注意要判断 fns[i].fn===fn , 主要用来判断once绑定时
        if (fns[i] === fn) {
            fns.splice(i, 1);
            break;
        }
    }
*/

for (let i = 0; i < fns.length; i++) {
  // 注意要判断 fns[i].fn===fn , 主要用来判断once绑定时
  if (fns[i] === fn || fns[i].fn === fn) {
    fns.splice(i, 1);
    break;
  }
}

最后,测试以上代码

const fna1 = function () {
  console.log("fna1");
};
const fna2 = function () {
  console.log("fna2");
};
const fnb = function () {
  console.log("fnb");
};
salesOffices.once("a", fna1);
salesOffices.once("a", fna2);
salesOffices.on("a", fna2);
salesOffices.on("b", fnb);

salesOffices.emit("a");
salesOffices.emit("a");
salesOffices.emit("b");

# 3.4、发布与订阅模式实现组件间通信

接下来我们利用发布与订阅来实现 A 组件与 B 组件间的通信,实现原理如下图:

image-20230625231009216

  • main.js中将订阅发布对象 pubsub(相当前面说的 salesOffices)绑定到全局$event变量上,这样在任意组件内部就可以通过this.$event访问到pubsub对象
app.config.globalProperties.$event = pubsub;
  • 在 B 组件中订阅setState事件,在事件回调函数中接受传过来的值state
// $event 为订阅发布中心
this.$event.on("setState", (state) => {
  this.state = state;
});
  • 当组件 A 中 state 变量值发生变化时,触发setState事件,将 state 变量的值做为 emit 方法参数传入
this.state = 10;
this.$event.emit("setState", this.state);

完整代码

创建项目目录结构如下:

vue-project
├─ index.html
├─ src
│  ├─ App.vue
│  ├─ common
│  │  └─ pubsub.js   // js实现发布订阅内容
│  ├─ components
│  │  ├─ A.vue
│  │  └─ B.vue
│  └─ main.js
├─ package-lock.json
├─ package.json
└─ vite.config.js
  • src/common/pubsub.js文件内容如下:
// 1、定义一个对象,充当售楼部(发布订阅中心)
let event = {};
// 2、在该对象上创建一缓存列表,存放订阅的事件(消息)
// 订阅的事件(消息)分为:事件名与事件处理函数,所以我们用一个对象来存放
event.clientList = {};
// 3、增加订阅事件,key为事件名,fn为事件名对应的事件处理函数
event.on = function (key, fn) {
  // 如果没有订阅此事件名,给该事件名创建一个缓存事件处理函数的列表
  if (!this.clientList[key]) {
    this.clientList[key] = [];
  }
  // 将事件处理函数添加到事件名对应的事件处理函数缓存列表
  // this.clientList[key].push(fn)
  this.clientList[key].unshift(fn);
};
// 4、售楼处发布消息
// 发布消息时,会根据事件名去遍历对应缓存列表中的事件处理函数,并执行。相当于触发事件的事件处理函数
event.emit = function () {
  // 根据事件名取出对应的缓存列表
  let key = Array.prototype.shift.call(arguments);
  const fns = this.clientList[key];
  // 如果没有订阅
  if (!fns || fns.length === 0) {
    return false;
  }
  // 注意,这里要从后放前遍历,因为once的事件会执行一次后被取消
  for (let i = fns.length - 1; i >= 0; i--) {
    // 修改订阅者回调内的this
    // 发布者在发布消息时会带上一些消息相关的信息,这些信息做为参数传入即:arguments
    fns[i].apply(this, arguments);
  }
};

// 5、取消订阅
event.off = function (key, fn) {
  // 根据事件名取出对应的缓存列表
  const fns = this.clientList[key];
  // 如果没有订阅
  if (!fns || fns.length === 0) {
    return false;
  }
  // 不传订阅事件处理函数,意味着取消所有订阅
  !fn && fns && (fns.length = 0);
  // 传了事件处理函数,取消事件对应的事件处理函数
  for (let i = 0; i < fns.length; i++) {
    // 注意要判断 fns[i].fn===fn,主要用来判断once绑定时
    if (fns[i] === fn || fns[i].fn === fn) {
      fns.splice(i, 1);
      break;
    }
  }
};

// 6、只订阅一次
event.once = function (key, fn) {
  const _that = this;
  // emit触发时,执行的是on这个方法
  function on() {
    // 先执行一次,再取消
    fn.apply(_that, arguments);
    // 先取消
    _that.off(key, on);
  }
  // 取消时,要判断on.fn===fn,如果等于,则移除该项
  on.fn = fn;
  // 订阅
  this.on(key, on);
};

export default event;
  • src/main.js文件内容
import { createApp } from "vue";
import App from "./App.vue";
// 导入 pubsub 对象
import pubsub from "./common/pubsub.js";

const app = createApp(App);
// 将pubsub对象绑定到全局$event变量上,这样在组件内部就可以通过this.$event访问到pubsub对象
app.config.globalProperties.$event = pubsub;

app.mount("#app");
  • App.vue文件内容
<script>
  import A from "./components/A.vue";
  import B from "./components/B.vue";
  export default {
    components: {
      A,
      B,
    },
  };
</script>

<template>
  <a></a>
  <b></b>
</template>
  • A.vue文件内容
<script>
  export default {
    data() {
      return {
        state: 1,
      };
    },
    methods: {
      update() {
        this.state = 10; // 修改 state的值
        // 在A组件中发布消息(即触发事件),将this.state的值传递给到B组件
        this.$event.emit("setState", this.state);
      },
    },
  };
</script>
<template>
  <button @click="update">更改state的值</button>
</template>
  • B.vue文件内容
<script>
  export default {
    data() {
      return {
        state: 0,
      };
    },
    created() {
      // 订阅(监听)setState事件
      // 在事件处理函数中接受传过来的state的值,并将其赋值给B组件中的state变量
      this.$event.on("setState", (value) => {
        console.log(value);
        this.state = value;
      });
    },
  };
</script>
<template>
  <div>B组件中state的值:{{ state }}</div>
</template>

整个项目最终渲染效果如下:

GIF2023-6-2523-18-34

# 4、mitt 插件

TIP

mitt 插件 (opens new window)就是一款实现发布与订阅功能的插件。

如果我们不想自己手写发布与订阅,在 Vue3 中就可以借助 mitt 插件来帮我们实现。

在 Vue3 中使用 mitt 插件的步骤如下:

  • 执行以下命令在项目中安装 mitt 插件
npm install --save mitt
  • main.js中导入 mitt 对象,并注册为全局变量,这样就可以任意组件中通过this.$event来访问
// 导入 mitt
import mitt from "mitt";
const app = createApp(App);
// 注册为全局变量 $event
app.config.globalProperties.$event = mitt();
app.mount("#app");
  • mitt 插件提供了以下方法来实现订阅与发布。
// 添加事件监听
this.$event.on("foo1", fn);
this.$event.on("foo2", fn);

// 移除事件监听
this.$event.off("foo1", fn);

// 移除所有事件监听
this.$event.all.clear();

// 触发事件
this.$event.emit("foo1", 1);
this.$event.emit("foo2", 2);

注:

该插件没有 once 方法,如果需要 once 方法,推荐大家使用 tiny-emitter (opens new window) 插件,用法一样

针对前面提到的 A 组件与 B 组件通信的案例,你只需要安装好 mitt 插件,然后把main.js内容替换成以上内容,其实现效果如前面一样。

# 5、总结

TIP

兄弟组件间通信可以采用以下两种方式:

借助父组件完成兄弟组件间通信

  • 这种方式并不是最适合的方式,其中最大的问题就是使用起来较复杂,而且数据管理混乱。所以实际开发中很少用

利用发布订阅模式实现兄弟组件间通信

  • 这种方式使用起来比较方便,如果自己不会手动实现订阅与发布可以借助mitttiny-emitter插件来实现。
  • 不过这种方式也有一定的缺陷,对于代码的根踪和维护不方便。我们在一个组件中使用了on方法订阅事件,我们很难追踪到该事件是由那个组件触发的,所以在使用时一定要加好注释。
  • 在触发事件的组件中要备注好订阅该事件的组件
  • 在订阅事件的组件中要备注好触发事件的组件

发布与订阅模式不仅可以用来实现兄弟组件间通信,其实也可以用来实现任意组件间通信。

后面我们还会讲到pinia全局状态管理,他可以实现任意组件间通信,并且他在代码维护和跟踪上都较方便。

# 二、动态组件

在有些场景下,我们需要在两个或多个组件间来回切换,如下图所示的 Tab 选项卡

GIF2023-5-1621-34-26

我们就可以利用本小节讲到的<component>内置组件来帮助我们实现动态渲染组件。当然我们也可以用前面学过的v-ifv-show来实现。

我们先用v-ifv-show来实现这个效果,然后再用<component>内置组件来实现,通过两者对比,看那种方式更简单。

# 1、v-if 或 v-show 实现选项卡效果

  • App.vue根组件
<script>
  import Tab1 from "./components/Tab1.vue";
  import Tab2 from "./components/Tab2.vue";
  import Tab3 from "./components/Tab3.vue";
  export default {
    data() {
      return {
        currentTab: "Tab1",
      };
    },
    components: {
      Tab1,
      Tab2,
      Tab3,
    },
  };
</script>

<template>
  <div class="container">
    <div class="tab-title">
      <button
        @click="currentTab = 'Tab1'"
        :class="{ active: currentTab === 'Tab1' }"
      >
        Tab1
      </button>
      <button
        @click="currentTab = 'Tab2'"
        :class="{ active: currentTab === 'Tab2' }"
      >
        Tab2
      </button>
      <button
        @click="currentTab = 'Tab3'"
        :class="{ active: currentTab === 'Tab3' }"
      >
        Tab3
      </button>
    </div>

    <div class="tab-content">
      <Tab1 v-show="currentTab === 'Tab1'" />
      <Tab2 v-show="currentTab === 'Tab2'" />
      <Tab3 v-show="currentTab === 'Tab3'" />
    </div>
  </div>
</template>
<style>
  button {
    width: 80px;
    height: 40px;
    border: none;
    margin-right: 10px;
    cursor: pointer;
  }

  .active {
    background-color: skyblue;
  }

  .tab {
    width: 400px;
    min-height: 100px;
    background-color: #ddd;
    margin-top: 10px;
  }
</style>
  • Tab1.vueTab2.vueTab3.vue 内容如下
<!--Tab1.vue-->
<script></script>
<template>
  <div class="tab tab1">
    <input type="text" />
    Tab1111.....内容....
  </div>
</template>

<!--Tab2.vue-->
<script></script>
<template>
  <div class="tab tab1">Tab2222.....内容....</div>
</template>

<!--Tab3.vue-->
<script></script>
<template>
  <div class="tab tab1">Tab3333.....内容....</div>
</template>

# 2、动态组件 component

  • <component>是一个内置组件,用来渲染动态组件或元素的"元组件"。
  • 该组件的is属性决定了最终要渲染的实际组件。
<component is="Tab1" />
<!--最终要渲染的是Tab1组件-->

is 属性值

  • is的值是字符串时,他既可以是 HTML 标签名也可以是组件的注册名。
    • 如果为HTML标签,则最终渲染出对应的 HTML 元素。
    • 如果为注册的组件名,则最终渲染这个组件
  • 在组合式 API 中,:is也可以直接绑定到组件的定义

示例

  • 按 HTML 元素的标签名,来渲染 HTML 元素
<component is="a" href="http://www.icodingedu.com">艾编程</component>
<!--最终渲染结果如下-->
<a href="http://www.icodingedu.com">艾编程</a>
  • 按组件名来渲染组件
<script>
  import List from "./components/List.vue";
  export default {
    components: {
      List,
    },
  };
</script>
<template>
  <!--List为组件名-->
  <component is="List"></component>
</template>
  • 按组件的定义来渲染组件
<script setup>
  import { h } from "vue";
  // 自定义组件
  const MyComponent = {
    render: () => {
      return h("div", "组件定义对象");
    },
  };
</script>
<template>
  <component :is="MyComponent"></component>
</template>
<script setup>
  import A from "./components/A.vue";
</script>
<template>
  <component :is="A"></component>
</template>

# 3、component 实现 Tab 选项卡

TIP

同比上面用v-if实现 Tab 选项卡,内容只需要做如下变动

  • App.vue文件中tab-conent元素中内容
<div class="tab-content">
  <Tab1 v-show="currentTab === 'Tab1'" />
  <Tab2 v-show="currentTab === 'Tab2'" />
  <Tab3 v-show="currentTab === 'Tab3'" />
</div>
  • 替换成如下内容
<div class="tab-content">
  <component :is="currentTab"></component>
</div>

观察修改后的代码可以得出,使用动态组件来实现,代码相对要简洁。

# 4、动态渲染 Tab 选项卡

<script>
  import Tab1 from "./components/Tab1.vue";
  import Tab2 from "./components/Tab2.vue";
  import Tab3 from "./components/Tab3.vue";
  export default {
    data() {
      return {
        currentTab: "Tab1", // 当前渲染的组件
        active: 0, // 被激活的下标
        tab: [
          {
            name: "Tab1组件",
            com: "Tab1",
          },
          {
            name: "Tab2组件",
            com: "Tab2",
          },
          {
            name: "Tab3组件",
            com: "Tab3",
          },
        ],
      };
    },
    components: {
      Tab1,
      Tab2,
      Tab3,
    },
    methods: {
      change(index, item) {
        this.active = index;
        this.currentTab = item.com;
      },
    },
  };
</script>
<template>
  <div class="container">
    <div class="tab-title">
      <button
        v-for="(item, index) in tab"
        :class="[active === index ? 'active' : '']"
        @click="change(index, item)"
      >
        {{item.name }}
      </button>
    </div>

    <div class="tab-content">
      <component :is="currentTab"></component>
    </div>
  </div>
</template>
<style>
  button {
    width: 80px;
    height: 40px;
    border: none;
    margin-right: 10px;
    cursor: pointer;
  }

  .active {
    background-color: skyblue;
  }

  .tab {
    width: 400px;
    min-height: 100px;
    background-color: #ddd;
    margin-top: 10px;
  }
</style>

# 5、注意事项

TIP

当使用 <component :is="..."> 来在多个组件间作切换时,被切换掉的组件会被卸载,这会导致丢失其中所有已变化的状态——当这个组件再一次被显示时,会创建一个只带有初始状态的新实例。

GIF2023-5-1711-52-57

注:

上图中,最开始在 Tab1 选项下的输入框中输入了内容,然后切换到 Tab2,随后再切找到 Tab1 时,输入框中的内容没有了。因为组件在被切换掉后会被卸载,再次切换回来相当于 Tab1 组件被重新渲染了。

但在实际开发中,我们希望组件被“切走”的时候保留言它们的状态。要解决这个问题,我们可以用 <KeepAlive> 内置组件将这些动态组件包装起来,这样被切换掉的组件仍然保持“存活”的状态。

<KeepAlive> 是一个内置组件,它的功能是在多个组件间动态切换时缓存被移除的组件实例。

<KeepAlive>
  <component :is="currentTab"></component>
</KeepAlive>

将动态组件用<KeepAlive>内置组件包裹后,最终渲染效果如下:

GIF2023-5-1712-07-23

# 6、总结

TIP

本小节我们重点掌握以下内容

动态组件<component>的用法

  • <component>内置组件的is属性决定了最终要渲染的实际组件
  • is属性的值可以是
    • 字符串类型的标签名
    • 字符串类型的组件名
    • 组件的定义对象
<component :is="currentTab"></component>
<component :is="a" href="xx"></component>

动态组件注意事项

当使用 <component :is="..."> 来在多个组件间作切换时,被切换掉的组件会被卸载。如果我们想要在组件被切换掉后仍保持”存活“的状态,可以将动态组件包裹在<KeepAlive>内置组件中。

<component><KeepAlive>包裹后,组件被切换后,生命周期函数unmountedbeforeUnmount周期函数不会被触发

# 三、异步组件

TIP

在大型项目中,我们可能需要拆分应用为更小的块,并仅在需要时再从服务器加载相关组件,这时候就可以使用异步组件。Vue 提供了 defineAsyncComponent方法来定义一个异步组件。

本小节涉及主要内容如下:

  • 如何定义一个异步组件
  • 异步组件的基本使用
  • 异步组件按需加载
  • 异步组件加载与错误状态

# 1、方法定义一个异步组件

TIP

defineAsyncComponent方法用来定义一个异步组件,该方法接受一个参数,参数可以是一个返回Promise的加载函数。

  • 在组件加载成功后调用resolve方法,把获取到的组件作为resolve方法的参数传入。
  • 在组件加载失败后调用reject方法,可以传入一个错误对象,对外告知组件加载失败
import { defineAsyncComponent } from "vue";
// 定义一个异步组件
const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // ...从服务器获取组件
    resolve(/* 获取到的组件 */);
  });
});

在实际开发中我们会利用import()方法来导入一个组件,因为import()方法返回的也是一个 Promise 对象,所以我们通常会使用以下方式来定义一个异步组件

import { defineAsyncComponent } from "vue";
// 定义一个异步组件
const AsyncComp = defineAsyncComponent(() =>
  import("./components/AsyncComp.vue")
);

最后得到的 AsyncComp 是一个外层包装过的组件,仅在页面需要它渲染时才会调用加载内部实际组件的函数。

# 2、异步组件的基本使用

TIP

异步组件即可以注册为全局的,也可以组册为局部的,其方式与普通组件一样。

将异步组件注册为局部组件,并使用,需要经过以下三步

  • 先利用defineAsyncComponent方法定义一个异步组件
  • 再在使用该异步组件的组件components选项中注册该组件
  • 最后就可以直接在父组件模板中通过注册名来使用组件
<script>
  import { defineAsyncComponent } from "vue";
  // 第一步: 定义一个异步组件
  const AsyncComp = defineAsyncComponent(() =>
    import("./components/AsyncComp.vue")
  );

  export default {
    // 第二步:注册成局部组件
    components: {
      AsyncComp: AsyncComp,
    },
  };
</script>

<template>
  <!--第三步:使用组件-->
  <AsyncComp />
</template>

TIP

将异步组件注册为全局组件,并使用,需要经过以下三步:

  • 先利用defineAsyncComponent方法定义一个异步组件
  • 使用app.component()方法将组件全局注册,注册后的异步组件在整个应用中全局可用
  • 在需要使用该组件的组件模板中通过注册名来使用组件
// 导入defineAsyncComponent方法,定义异步组件
import { defineAsyncComponent } from "vue";

// 第一步:定义异步组件
const AsyncComp = defineAsyncComponent(() =>
  import("./components/AsyncComp.vue")
);
// 第二步:全局注册
app.component("AsyncComp", AsyncComp);
<!-- 任意组件中使用 -->
<template>
  <!--第三步:使用组件-->
  <AsyncComp />
</template>

# 3、异步组件按需加载

TIP

异步组件在运行时是懒加载的,即在需要用到时才加载,不用不加载,正是这个特性,可以提高应用首次的加载速度。

如果一个较大的项目,所有组件都是同步的,那一开始需要加载的组件较多,页面打开速度会变慢,体验会变差。所以需要将应用拆分成更多的异步组件,使现按需加载。

代码演示

  • App.vue
<script>
  import { defineAsyncComponent } from "vue";
  // 定义异步组件
  const AsyncCompA = defineAsyncComponent(() =>
    import("./components/AsyncCompA.vue")
  );

  const AsyncCompB = defineAsyncComponent(() =>
    import("./components/AsyncCompB.vue")
  );

  export default {
    data() {
      return {
        currentComp: "AsyncCompA",
      };
    },
    components: {
      AsyncCompA,
      AsyncCompB,
    },
  };
</script>

<template>
  <button @click="currentComp = 'AsyncCompA'">加载A组件</button>
  <button @click="currentComp = 'AsyncCompB'">加载B组件</button>
  <!--KeepAlive将被切换掉的组件缓存起来-->
  <KeepAlive>
    <component :is="currentComp"></component>
  </KeepAlive>
</template>
  • AsyncCompA.vue
<template>
  <div>AsyncCompAAAA 组件中内容</div>
</template>
  • AsyncCompB.vue
<template>
  <div>AsyncCompBBBBB 组件中内容</div>
  <input type="text" name="" id="" />
</template>

注:

项目首次加载时,页面中只加载了AsyncCompA组件,当我们点击加载 B 组件时,才会去加载AsyncCompB组件。

具体效果如下:

GIF2023-5-1717-11-47

# 4、异步组件加载与错误状态

TIP

当我们在加载一个异步组件时,可能会因为组件比较大或网速等原因,需要等待一定的时间才能加载成功,所以在加载成功之前页面是空白的状态,这非常影响用户体验。

针对这种情况,我们可以在真正的组件被加载之前,先显示一些其它的内容,在组件加载成功后,再替换掉就好。

如下图:

GIF2023-5-1717-53-09

如果在加载一个组件时没有加载成功,我们同样需要对错误的状态做相关处理,比如告诉用户内容加载失败等

针对以上两种情况,我们想要在加载组件时做相关的状态处理,需要将defineAsyncComponent的参数定义成如下对象

import { defineAsyncComponent } from "vue";
// 内容正在加载中显示的组件
import LoadingComponent from "./components/LoadingComponent.vue";
// 内容加载失败后显示的组件
import ErrorComponent from "./components/ErrorComponent.vue";

const AsyncCompB = defineAsyncComponent({
  // 加载函数,用来加载真正要显示的组件
  loader: () => import("./components/AsyncCompB.vue"),
  // 在真正要显示的组件被加成功前,会先加载这个组件来显示内容
  loadingComponent: LoadingComponent,
  // 展示加载组件前的延迟时间,默认为 200ms
  delay: 200,
  // 加载失败后展示的组件
  errorComponent: ErrorComponent,
  // 如果提供了一个 timeout 时间限制,并超时了
  // 也会显示这里配置的报错组件,默认值是:Infinity
  timeout: 1000,
});

以下是完整的代码示例

  • App.vue
<script>
  import { defineAsyncComponent } from "vue";
  import LoadingComponent from "./components/LoadingComponent.vue";
  import ErrorComponent from "./components/ErrorComponent.vue";

  const AsyncCompA = defineAsyncComponent(() =>
    import("./components/AsyncCompA.vue")
  );

  const AsyncCompB = defineAsyncComponent({
    // 加载函数,用来加载真正要显示的组件
    loader: () => import("./components/AsyncCompB.vue"),
    // 在真正要显示的组件被加成功前,会先加载这个组件来显示内容
    loadingComponent: LoadingComponent,
    // 展示加载组件前的延迟时间,默认为 200ms
    delay: 200,
    // 加载失败后展示的组件
    errorComponent: ErrorComponent,
    // 如果提供了一个 timeout 时间限制,并超时了
    // 也会显示这里配置的报错组件,默认值是:Infinity
    timeout: 1000,
  });

  export default {
    data() {
      return {
        currentComp: "AsyncCompA",
      };
    },
    components: {
      AsyncCompA,
      AsyncCompB,
    },
  };
</script>

<template>
  <button @click="currentComp = 'AsyncCompA'">加载A组件</button>
  <button @click="currentComp = 'AsyncCompB'">加载B组件</button>
  <KeepAlive>
    <component :is="currentComp"></component>
  </KeepAlive>
</template>
  • AsyncCompA.vueAsyncCompB.vue
<!--AsyncCompA.vue-->
<template>
  <div>AsyncCompAAAA 组件中内容</div>
</template>

<!--AsyncCompB.vue-->
<template>
  <div>AsyncCompBBBBB 组件中内容</div>
  <input type="text" name="" id="" />
</template>
  • LoadingComponent.vue
<template>
  <div class="loading">正在拼命加载中.....</div>
</template>
  • ErrorComponent.vue
<template>
  <div class="error">内容加载失败....</div>
</template>

最终渲染后效果如下:

GIF2023-5-1717-43-07

注:

在点击加载 B 组件时,会先显示正在拼命加载中.....,因为设置了超时1000ms

所以在1s后组件还没有加载成功时显示了内容加载失败....,(一般超时会设置在 3s),最后加载成功则显示真正组件的内容。

# 5、总结

TIP

本小节重点掌握以下内容

  • 如果利用defineAsyncComponent方法定义一个异步组件,同时将其注册为全局组件或局部组件。
  • 异步组件最大的优点就是能实现按需加载,提高页面加载的性能。
  • 关于异步组件加载与错误状态的处理。
上次更新时间: 7/4/2023, 10:22:20 PM

大厂最新技术学习分享群

大厂最新技术学习分享群

微信扫一扫进群,获取资料

X