Skip to content

4.3 组件体系

前面介绍了 Vue.js 的基本语法和组件的基础用法。组件的两大特性为独立性和可复用性。在一个项目中,往往由多个组件互相嵌套组成复杂的组件树,这就需要组件之间有数据传递和事件响应。

下面从组件复用的角度,结合组件原理深入地介绍组件。

4.3.1 data 与 props

组件的状态在 data 中定义,表示组件的内部状态。当一个组件需要复用时,必然会有状态从外部传入,这类状态被称为 props。

组件允许接收哪些 props,需要在组件内部定义。props 需要使用 props 属性来定义,属性值为允许接收的 props 字段名:

js
// Demo.vue
export default {
  props: ["foot"],
  created() {
    console.log(this.foot);
  },
};

上面代码中,我们定义了一个名为 foot 的 props。props 与 data 一样也通过 this 访问。

定义之后,在使用组件时,props 就可以像普通属性一样定义在组件上,如下:

js
<Demo foot="name"></Demo>

有时候我们需要限制 props 必须是指定类型,不可以随意传递。这个时候可以使用第二种 props 定义方式:对象定义。上面组件代码修改如下:

js
export default {
  props: {
    foot: {
      type: String,
    },
  },
};

使用对象定义 foot 的类型,使用组件时就能规范 props 值的传递。对象定义不光可以定义类型,还可以定义更多的验证规则,如下:

js
export default {
  props: {
    foot: {
      type: String,
      required: true,
      default: "",
      validator(value) {
        return ["val1", "val2"].includes(value);
      },
    },
  },
};

如上代码中,props 定义对象的各个属性含义如下:

  • type:数据类型,值为 JavaScript 原生构造函数,如 String,Boolean。
  • required:是否必传。
  • default:默认值。
  • validator:验证函数,自动义验证规则。

props 的多种定义方式,有的简单有的复杂。不过为了组件的健壮性,推荐使用更加严格的验证。

4.3.2 自定义事件

Vue 的自定义事件由子组件触发,父组件监听,从而实现向上的事件响应。

在子组件中,通过 this.$emit() 方法触发一个自定义事件;

js
// Child.vue
<button @click="$emit('updateMsg')">click me</button>

接着在父组件中,通过 v-on 指令来监听这个事件:

js
// Parent.vue
<div>
  <Child @update-msg="updateMsg"></Child>
</div>
export default {
  methods: {
    updateMsg() {
      console.log('自定义事件触发')
    }
  }
}

子组件触发自定义事件需要指定一个事件名,父组件监听这个事件名。与 props 一样,事件名也支持自动格式转换。上面代码中子组件触发 updateMsg 事件,父组件可以用 update-msg 来监听。

自定义事件还支持传递参数。子组件将参数传递给父组件,父组件可以在事件触发函数中收到:

js
// Child.vue
<button @click="$emit('updateMsg', 'hello')">click me</button>

// Parent.vue
<Child @update-msg="updateMsg"></Child>
methods: {
  updateMsg(msg) {
    console.log(msg)
  }
}

使用自定义事件修改 props 也很简单,我们写一个完整案例。子组件代码如下:

vue
<template>
  <div class="child-component">
    <h2>{{ userName }}</h2>
    <button @click="changeName">修改</button>
  </div>
</template>
<script>
export default {
  props: {
    userName: {
      type: String,
      required: true,
    },
  },
  methods: {
    changeName() {
      this.$emit("updateName", "王小五");
    },
  },
};
</script>

子组件接收 userName 属性,并触发 updateName 事件,父组件代码如下:

vue
<template>
  <div class="parent-component">
    <Child :user-name="userName" @update-name="changeName" />
  </div>
</template>
<script>
import Child from "./child.vue";
export default {
  data() {
    return {
      userName: "张小四",
    };
  },
  component: {
    Child,
  },
  methods: {
    changeName(name) {
      this.userName = name;
    },
  },
};
</script>

当点击子组件按钮时,父组件的函数被触发,并修改父组件的状态,此时子组件的状态也会自动变化。

4.3.3 生命周期

Vue 中的每个组件从创建到销毁都有一个完整的过程,这个过程被称为生命周期。

组件的生命周期意义非凡。Vue 提供了许多生命周期函数,允许我们在组件的不同的阶段做不同的事情。比如,在组件创建后发起 API 请求就是一个常见的需求。

组件的全部完整的生命周期如图 5-1-4:

上图中的声明周期较多,实际场景下我们常用的声明周期只有 4 个,列举如下:

  • created():组件实例创建后触发,此时 DOM 未挂载。
  • mounted():DOM 挂载后触发,组件彻底初始化完成。
  • updated():组件更新后出发,可获得更新后的状态和 DOM。
  • unmounted():组件卸载之前触发。

created() 是组件实例创建之后,最早触发的声明周期,因此在这里发起网络请求非常合适。

mounted() 在组件被挂载并生成 DOM 之后触发,如果要操作 DOM,在这个声明周期中最合适。切记不可在 created() 中操作 DOM,因此此时 DOM 还不存在。

组件中的状态更新会导致组件更新,组件更新后会触发 updated()。但是这个声明周期不常用,监听状态变化我们一般用监听器来实现更精准。

unmounted() 在组件卸载前触发,这个声明周期很有用。比如要清理定时器,关闭某种连接,重置全局状态等,都可以在这个声明周期内操作。

其他的生命周期钩子可能在特殊时候会用到,但这 4 个已经满足绝大部分要求了。

4.3.4 插槽动态渲染

组件可以通过 props 接收到任意类型的 JavaScript 数据,依据 props 可以动态渲染模版。

对于一些定制程度高、灵活性强的组件,我们更希望直接接收一个模版,这样比接收数据再渲染模版更加灵活。Vue 提供了一个“插槽”功能用于接收模版,插槽用 <slot></slot> 来表示。

假设现在有一个卡片组件 Card.vue,组件定义卡片的通用外部样式,卡片内可以填充任意内容,此类场景就非常适合用插槽。

js
// Card.vue
<div class="card-box">
  <h2>卡片</h2>
  <div>
    <slot></slot>
  </div>
</div>

使用卡片组件时,用卡片组件包裹任意内容,这些内容会替换组件的插槽部分,最终被卡片组件渲染。举例如下:

js
// 使用组件
<Card>卡片内容</Card>
// 渲染后的DOM
<div class="card-box">
  <h2>卡片</h2>
  <div>卡片内容</div>
</div>
js
// 使用组件
<Card>
  <div>卡片模版</div>
</Card>
// 渲染后的DOM
<div class="card-box">
  <h2>卡片</h2>
  <div>
    <div>卡片模版</div>
  </div>
</div>

插槽内容可以是文本、元素、组件,非常灵活,这些都由父组件提供。

对于一些复杂组件,可能不止一处需要插入模版。还是以上面的卡片组件为例,假设卡片头部需要自定义,卡片内容也需要自定义,这个时候就需要两处插槽。那么传入组件的模版应该替换哪个插槽呢?

为了在一个组件内区分多个插槽,Vue 提供了具名插槽。具体做法是:为 <slot> 元素添加一个 name 属性,这样就能区分了。

js
// Card.vue
<div class="card-box">
  <div class="header">
    <slot name="header"></slot>
  </div>
  <div class="content">
    <slot name="content"></slot>
  </div>
</div>

在使用 Card 组件时,组件可包裹 <template> 元素并配合 v-slot 指令与组件内的具名插槽匹配,实现如下:

js
<Card>
  <template v-slot:header>
    <h2>卡片标题</h2>
  </template>
  <template v-slot:content>
    <div>卡片内容</div>
  </template>
</Card>

指令 v-slot 可以用 # 符号简写,如下:

js
<template #header>
  <h2>卡片标题</h2>
</template>

组件内也可以将默认插槽和具名插槽结合使用,如果只有一处插槽,请使用默认插槽。

4.3.5 异步组件

在大型项目中,组件庞大会导致页面加载缓慢,这是一次性加载所有组件带来的性能问题。我们希望组件可以按需加载,进入页面时只加载需要的组件,这样会大大提升性能。

Vue 中按需加载的组件被称为异步组件。Vue 提供了 defineAsyncComponent() 方法来实现此功能:

js
import { defineAsyncComponent } from "vue";
const AsyncComp = defineAsyncComponent(() => {
  import("./TestComponent.vue");
});

如上,异步加载组件时还需要 import() 方法配合。加载之后,这个组件可以像普通组件一样使用:

js
<div>
  <AsyncComp></AsyncComp>
</div>

在单文件组件中,也可以用 defineAsyncComponent() 方法来异步加载一个组件:

js
export default {
  components: {
    AsyncComp = defineAsyncComponent(() =>
      import('./TestComponent.vue')
    )
  }
}

4.3.6 实现 v-model

前面介绍过,v-model 是一个语法糖,内部封装了值绑定和事件触发。很多时候自定义组件也需要双向绑定功能,该如何实现呢?

以 input 为例:绑定值的字段是 value,事件是 input,那么在组件内的定义方式如下:

js
// input
export default {
  name: "input",
  model: {
    prop: "value", // 对应 props value
    event: "input",
  },
  props: {
    value: String,
  },
};

代码可见,在组件中使用 model 属性来指定 v-model 指令关联的状态和事件。

假设现在有组件 CusModal.vue,我们定义如下:

js
// CusModal.vue
export default {
  name: "CusModal",
  model: {
    prop: "visible", // 对应 props value
    event: "toggle",
  },
  props: {
    visible: Boolean,
  },
};

现在这个组件绑定的 props 和事件分别是 visible 和 toggle。使用这个组件时,以下两种方式是一样的:

js
data() {
  return { value }
}
// 方式一
<CusModal :visible="value" @toggle="v=> value=v"></CusModal>
// 方式二
<CusModal v-model="value"></CusModal>