S11-02 Vue-组件
[TOC]
现在可以说整个的大前端开发都是组件化的天下,无论从三大框架(Vue、React、Angular),还是跨平台方案的Flutter,甚至是移动端都在转向组件化开发,包括小程序的开发也是采用组件化开发的思想。
所以,学习组件化最重要的是它的思想,每个框架或者平台可能实现方法不同,但是思想都是一样的。
认识组件化
什么是组件化?
人面对复杂问题的处理方式:
- 任何一个人处理信息的逻辑能力都是有限的
- 所以,当面对一个非常复杂的问题时,我们不太可能一次性搞定一大堆的内容。
- 但是,我们人有一种天生的能力,就是将问题进行拆解。
- 如果将一个复杂的问题,拆分成很多个可以处理的小问题,再将其放在整体当中,你会发现大的问题也会迎刃而解。
复杂问题的处理
组件化也是类似的思想:
- 如果我们将一个页面中所有的处理逻辑全部放在一起,处理起来就会变得非常复杂,而且不利于后续的管理以及扩展;
- 但如果,我们讲一个页面拆分成一个个小的功能块,每个功能块完成属于自己这部分独立的功能,那么之后整个页面的管理和维护就变得非常容易了;
- 如果我们将一个个功能块拆分后,就可以像搭建积木一下来搭建我们的项目;
组件化的拆分
我们需要通过组件化的思想来思考整个应用程序:
- 我们将一个完整的页面分成很多个组件;
- 每个组件都用于实现页面的一个功能块;
- 而每一个组件又可以进行细分;
- 而组件本身又可以在多个地方进行复用;
Vue的组件化
组件化是Vue、React、Angular的核心思想,也是我们后续课程的重点(包括以后实战项目):
- 前面我们的createApp函数传入了一个对象App,这个对象其实本质上就是一个组件,也是我们应用程序的根组件;
- 组件化提供了一种抽象,让我们可以开发出一个个独立可复用的小组件来构造我们的应用;
- 任何的应用都会被抽象成一颗组件树;
组件化开发
组件化思想的应用:
- 有了组件化的思想,我们在之后的开发中就要充分的利用它;
- 尽可能的将页面拆分成一个个小的、可复用的组件;
- 这样让我们的代码更加方便组织和管理,并且扩展性也更强;
接下来,我们来学习一下在Vue中如何注册一个组件,以及之后如何使用这个注册后的组件。
注册一个组件
注册全局组件
如果我们现在有一部分内容(模板、逻辑等),我们希望将这部分内容抽取到一个独立的组件中去维护,这个时候如何注册一个组件呢?
我们先从简单的开始谈起,比如下面的模板希望抽离到一个单独的组件:
<h2>{{title}}</h2>
<p>{{message}}</p>
注册组件分成两种:
- 全局组件:在任何其他的组件中都可以使用的组件;
- 局部组件:只有在注册的组件中才能使用的组件;
我们先来学习一下全局组件的注册:
- 全局组件需要使用我们全局创建的app来注册组件;
- 通过component方法传入组件名称、组件对象即可注册一个全局组件了;
<template id="my-cpn">
<h2>我是组件标题</h2>
<p>我是组件内容,哈哈哈哈</p>
</template>
<script src="../js/vue.js"></script>
<script>
const app = Vue.createApp(App);
// 注册全局组件(使用app)
app.component("my-cpn", {
template: "#my-cpn"
});
app.mount('#app');
</script>
之后,我们可以在App组件的template中直接使用这个全局组件:
<template id="my-app">
<my-cpn></my-cpn>
<my-cpn></my-cpn>
<my-cpn></my-cpn>
<my-cpn></my-cpn>
</template>
当然,我们组件本身也可以有自己的代码逻辑:
// 注册全局组件(使用app)
app.component("my-cpn", {
template: "#my-cpn",
data() {
return {
title: "我是标题",
message: "我是内容, 哈哈哈哈"
}
},
methods: {
btnClick() {
console.log("btnClick");
}
}
});
组件的名称
在通过app.component注册一个组件的时候,第一个参数是组件的名称,定义组件名的方式有两种:
方式一:使用kebab-case(短横线分割符)
- 当使用 kebab-case (短横线分隔命名) 定义一个组件时,你也必须在引用这个自定义元素时使用 kebab-case,例如
<my-component-name>
;
app.component('my-component-name', {
/* ... */
})
方式二:使用PascalCase(驼峰标识符)
- 当使用 PascalCase (首字母大写命名) 定义一个组件时,你在引用这个自定义元素时两种命名法都可以使用。也就是说
<my-component-name>
和<MyComponentName>
都是可接受的;
app.component('MyComponentName', {
/* ... */
})
注册局部组件
全局组件往往是在应用程序一开始就会全局组件完成,那么就意味着如果某些组件我们并没有用到,也会一起被注册:
- 比如我们注册了三个全局组件:ComponentA、ComponentB、ComponentC;
- 在开发中我们只使用了ComponentA、ComponentB,如果ComponentC没有用到但是我们依然在全局进行了注册,那么就意味着类似于webpack这种打包工具在打包我们的项目时,我们依然会对其进行打包;
- 这样最终打包出的JavaScript包就会有关于ComponentC的内容,用户在下载对应的JavaScript时也会增加包的大小;
所以在开发中我们通常使用组件的时候采用的都是局部注册:
- 局部注册是在我们需要使用到的组件中,通过components属性选项来进行注册;
- 比如之前的App组件中,我们有data、computed、methods等选项了,事实上还可以有一个components选项;
- 该components选项对应的是一个对象,对象中的键值对是
组件的名称: 组件对象
;
接下来,我们看一下局部组件是如何注册的:
<div id="app"></div>
<template id="my-app">
<component-a></component-a>
<component-b></component-b>
</template>
<template id="component-a">
<h2>{{title}}</h2>
<p>{{message}}</p>
</template>
<template id="component-b">
<h2>{{title}}</h2>
<p>{{message}}</p>
</template>
<script src="../js/vue.js"></script>
<script>
const ComponentA = {
template: "#component-a",
data() {
return {
title: "我是ComponentA标题",
message: "我是ComponentA内容, 哈哈哈哈"
}
}
}
const ComponentB = {
template: "#component-b",
data() {
return {
title: "我是ComponentB标题",
message: "我是ComponentB内容, 呵呵呵呵"
}
}
}
const App = {
template: '#my-app',
components: {
'component-a': ComponentA,
'component-b': ComponentB,
},
data() {
return {
message: "Hello World"
}
}
}
Vue.createApp(App).mount('#app');
</script>
在开发中通常我们都会注册局部组件,而不是全局组件。
Vue的开发模式
Vue的开发模式
目前我们使用vue的过程都是在html文件中,通过template编写自己的模板、脚本逻辑、样式等。
但是随着项目越来越复杂,我们会采用组件化的方式来进行开发:
- 这就意味着每个组件都会有自己的模板、脚本逻辑、样式等;
- 当然我们依然可以把它们抽离到单独的js、css文件中,但是它们还是会分离开来;
- 也包括我们的script是在一个全局的作用域下,很容易出现命名冲突的问题;
- 并且我们的代码为了适配更一些浏览器,必须使用ES5的语法;
- 在我们编写代码完成之后,依然需要通过工具对代码进行构建、代码;
所以在真实开发中,我们可以通过一个后缀名为 .vue
的single-file components (单文件组件) 来解决,并且可以使用webpack或者vite或者rollup等构建工具来对其进行处理。
在这个组件中我们可以获得非常多的特性:
- 代码的高亮;
- ES6、CommonJS的模块化能力;
- 组件作用域的CSS;
- 可以使用预处理器来构建更加丰富的组件,比如TypeScript、Babel、Less、Sass等;
如何支持SFC
如果我们想要使用这一的SFC的.vue文件,比较常见的是两种方式:
- 方式一:使用Vue CLI来创建项目,项目会默认帮助我们配置好所有的配置选项,可以在其中直接使用.vue文件;
- 方式二:自己使用webpack或rollup或vite这类打包工具,对其进行打包处理;
我们最终,无论是后期我们做项目,还是在公司进行开发,通常都会采用Vue CLI的方式来完成。
但是在学习阶段,为了让大家理解Vue CLI打包项目的过程,我会接下来穿插讲解一部分webpack的知识,帮助大家更好的理解Vue CLI的原理以及其打包的过程。
认识组件的嵌套
App单独开发
前面我们是将所有的逻辑放到一个App.vue中:
- 在之前的案例中,我们只是创建了一个组件App;
- 如果我们一个应用程序将所有的逻辑都放在一个组件中,那么这个组件就会变成非常的臃肿和难以维护;
- 所以组件化的核心思想应该是对组件进行拆分,拆分成一个个小的组件;
- 再将这些组件组合嵌套在一起,最终形成我们的应用程序;
我们来分析一下下面代码的嵌套逻辑,假如我们将所有的代码逻辑都放到一个App.vue组件中:
<template>
<div>
<h2>Header</h2>
<h2>NavBar</h2>
</div>
<div>
<h2>Banner</h2>
<ul>
<li>商品列表1</li>
<li>商品列表2</li>
<li>商品列表3</li>
<li>商品列表4</li>
<li>商品列表5</li>
</ul>
</div>
<div>
<h2>Footer</h2>
<h2>免责声明</h2>
</div>
</template>
<script>
export default {
};
</script>
<style scoped></style>
我们会发现,将所有的代码逻辑全部放到一个组件中,代码是非常的臃肿和难以维护的。并且在真实开发中,我们会有更多的内容和代码逻辑,对于扩展性和可维护性来说都是非常差的。
所有,在真实的开发中,我们会对组件进行拆分,拆分成一个个功能的小组件。
组件的拆分
我们可以按照如下的方式进行拆分:
自定义组件的嵌套逻辑
Header.vue组件
<template>
<div>
<h2>Header</h2>
<h2>NavBar</h2>
</div>
</template>
Main.vue组件:
<template>
<div>
<banner></banner>
<product-list></product-list>
</div>
</template>
Banner.vue组件:
<template>
<h2>Banner</h2>
</template>
ProductList组件:
<template>
<ul>
<li>商品列表1</li>
<li>商品列表2</li>
<li>商品列表3</li>
<li>商品列表4</li>
<li>商品列表5</li>
</ul>
</template>
Footer.vue组件:
<template>
<div>
<h2>Footer</h2>
<h2>免责声明</h2>
</div>
</template>
按照如上的拆分方式后,我们开发对应的逻辑只需要去对应的组件编写就可。
组件的通信
上面的嵌套逻辑如下,它们存在如下关系:
- App组件是Header、Main、Footer组件的父组件;
- Main组件是Banner、ProductList组件的父组件;
在开发过程中,我们会经常遇到需要组件之间相互进行通信:
- 比如App可能使用了多个Header,每个地方的Header展示的内容不同,那么我们就需要使用者传递给Header一些数据,让其进行展示;
- 又比如我们在Main中一次性请求了Banner数据和ProductList数据,那么就需要传递给他们来进行展示;
- 也可能是子组件中发生了事件,需要有父组件来完成某些操作,那就需要子组件向父组件传递事件;
总之,在一个Vue项目中,组件之间的通信是非常重要的环节,所以接下来我们就具体学习一下组件之间是如何相互之间传递数据的;
父子组件的相互通信
父组件传递给子组件
在开发中很常见的就是父子组件之间通信,比如父组件有一些数据,需要子组件来进行展示:
- 这个时候我们可以通过props来完成组件之间的通信;
什么是Props呢?
- Props是你可以在组件上注册一些自定义的attribute;
- 父组件给这些attribute赋值,子组件通过attribute的名称获取到对应的值;
Props有两种常见的用法:
- 方式一:字符串数组,数组中的字符串就是attribute的名称;
- 方式二:对象类型,对象类型我们可以在指定attribute名称的同时,指定它需要传递的类型、是否是必须的、默认值等等;
props的数组用法
封装ShowMessage.vue组件:
<template>
<div>
<h2>组件展示的title:{{title}}</h2>
<p>组件展示的content: {{content}}</p>
</div>
</template>
<script>
export default {
props: ["title", "content"]
}
</script>
通过App.vue传递给组件数据:
<template>
<div>
<show-message title="哈哈哈" content="我是哈哈哈"></show-message>
<show-message title="呵呵呵" content="我是呵呵呵"></show-message>
</div>
</template>
<script>
import ShowMessage from './ShowMessage.vue';
export default {
components: {
ShowMessage
}
}
</script>
当然,我们也可以将data中的数据传递给子组件:
<template>
<div>
<show-message :title="title1" :content="content1"></show-message>
<show-message :title="title2" :content="content2"></show-message>
</div>
</template>
<script>
import ShowMessage from './ShowMessage.vue';
export default {
components: {
ShowMessage
},
data() {
return {
title1: "哈哈哈",
content1: "我是哈哈哈",
title2: "呵呵呵",
content2: "我是呵呵呵"
}
}
}
</script>
当然,我们也可以直接传递一个对象:
<template>
<div>
<show-message :title="message.title" :content="message.content"></show-message>
<show-message v-bind="message"></show-message>
</div>
</template>
<script>
import ShowMessage from './ShowMessage.vue';
export default {
components: {
ShowMessage
},
data() {
return {
message: {
title: "嘿嘿嘿",
content: "我是嘿嘿嘿"
}
}
}
}
</script>
props的对象用法
数组用法中我们只能说明传入的attribute的名称,并不能对其进行任何形式的限制,接下来我们来看一下对象的写法是如何让我们的props变得更加完善的。
ShowMessage.vue的props对象写法:
<template>
<div>
<h2>组件展示的title:{{title}}</h2>
<p>组件展示的content: {{content}}</p>
</div>
</template>
<script>
export default {
props: {
// 指定类型
title: String,
// 指定类型,同时指定是否必选、默认值
content: {
type: String,
require: true,
default: "哈哈哈"
}
}
}
</script>
细节一:那么type的类型都可以是哪些呢?
- String
- Number
- Boolean
- Array
- Object
- Date
- Function
- Symbol
细节二:对象类型的其他写法
<script>
export default {
props: {
// 基础的类型检查 (`null` 和 `undefined` 会通过任何类型验证)
propA: Number,
// 多个可能的类型
propB: [String, Number],
// 必填的字符串
propC: {
type: String,
required: true
},
// 带有默认值的数字
propD: {
type: Number,
default: 100
},
// 带有默认值的对象
propE: {
type: Object,
// 对象或数组默认值必须从一个工厂函数获取
default () {
return {
message: 'hello'
}
}
},
// 自定义验证函数
propF: {
validator(value) {
// 这个值必须匹配下列字符串中的一个
return ['success', 'warning', 'danger'].includes(value)
}
},
// 具有默认值的函数
propG: {
type: Function,
// 与对象或数组默认值不同,这不是一个工厂函数 —— 这是一个用作默认值的函数
default () {
return 'Default function'
}
}
}
}
</script>
细节三:Prop 的大小写命名(camelCase vs kebab-case)
- HTML 中的 attribute 名是大小写不敏感的,所以浏览器会把所有大写字符解释为小写字符;
- 这意味着当你使用 DOM 中的模板时,camelCase (驼峰命名法) 的 prop 名需要使用其等价的 kebab-case (短横线分隔命名) 命名;
ShowMessage.vue组件:
<template>
<div>
<p>{{messageInfo}}</p>
</div>
</template>
<script>
export default {
props: {
messageInfo: String,
}
}
</script>
App.vue组件中传入:
<template>
<div>
<show-message messageInfo="哈哈哈"></show-message>
<show-message message-info="哈哈哈"></show-message>
</div>
</template>
重申一次,如果你使用字符串模板,那么这个限制就不存在了。
非Prop的Attribute
什么是非Prop的Attribute呢?
- 当我们传递给一个组件某个属性,但是该属性并没有定义对应的props或者emits时,就称之为
非Prop的Attribute
; - 常见的包括class、style、id属性等;
Attribute继承
当组件有单个根节点时,非Prop的Attribute将自动添加到根节点的Attribute中:
禁用Attribute继承
如果我们不希望组件的根元素继承attribute,可以在组件中设置 inheritAttrs: false
:
- 禁用attribute继承的常见情况是需要将attribute应用于根元素之外的其他元素;
- 我们可以通过
$attrs
来访问所有的非props的attribute
;
<template>
<div> 我是NotPropAttribue组件 <h2 :class="$attrs.class"></h2>
</div>
</template>
<script>
export default {
inheritAttrs: false
}
</script>
多个根节点的attribute
多个根节点的attribute如果没有显示的绑定,那么会报警告,我们必须手动的指定要绑定到哪一个属性上:
<template>
<div :class="$attrs.class">我是NotPropAttribue组件1</div>
<div>我是NotPropAttribue组件2</div>
<div>我是NotPropAttribue组件3</div>
</template>
子组件传递给父组件
什么情况下子组件需要传递内容到父组件呢?
- 当子组件有一些事件发生的时候,比如在组件中发生了点击,父组件需要切换内容;
- 子组件有一些内容想要传递给父组件的时候;
我们如何完成上面的操作呢?
- 首先,我们需要在子组件中定义好在某些情况下触发的事件名称;
- 其次,在父组件中以v-on的方式传入要监听的事件名称,并且绑定到对应的方法中;
- 最后,在子组件中发生某个事件的时候,根据事件名称触发对应的事件;
自定义事件的流程
我们封装一个CounterOperation.vue的组件:
- 内部其实是监听两个按钮的点击,点击之后通过
this.$emit的方式发出去事件
;
<template>
<div> <button @click="increment">+1</button> <button @click="decrement">-1</button> </div>
</template>
<script>
export default {
emits: ["addOne", "subOne"],
methods: {
increment() {
this.$emit("addOne");
},
decrement() {
this.$emit("subOne");
}
}
}
</script>
我们可以在App.vue中来监听自定义组件发出的事件:
<template>
<div>
<h2>当前计数: {{counter}}</h2>
<counter-operation @addOne="add" @subOne="sub"></counter-operation>
</div>
</template>
<script>
import CounterOperation from './CounterOperation.vue';
export default {
components: {
CounterOperation
},
data() {
return {
counter: 0
}
},
methods: {
add() {
this.counter++
},
sub() {
this.counter--;
}
}
}
</script>
自定义事件的参数
自定义事件的时候,我们也可以传递一些参数给父组件:
<template>
<div>
<button @click="increment">+1</button>
<button @click="incrementTen">+10</button>
<button @click="decrement">-1</button>
</div>
</template>
<script>
export default {
methods: {
incrementTen() {
this.$emit("addTen", 10)
}
}
}
</script>
<style scoped></style>
自定义事件的验证
在vue3当中,我们可以对传递的参数进行验证:
<template>
<div>
<button @click="incrementTen">+10</button>
</div>
</template>
<script>
export default {
emits: {
addTen: function (payload) {
if (payload === 10) {
return true
}
return false;
}
},
methods: {
incrementTen() {
this.$emit("addTen", 10)
}
}
}
</script>
组件间通信案例练习
我们来做一个相对综合的练习:
综合练习
TabControl实现
TabControl.vue的实现代码:
<template>
<div class="tab-control">
<template v-for="(item, index) in titles" :key="item">
<div class="tab-control-item" @click="itemClick(index)" :class="{active: index === currentIndex}">
<span class="underline">{{ item }}</span>
</div>
</template>
</div>
</template>
<script>
export default {
props: {
titles: {
type: Array,
default () {
return [];
},
},
},
emits: ["titleClick"],
data() {
return {
currentIndex: 0
}
},
methods: {
itemClick(index) {
this.currentIndex = index;
this.$emit("titleClick", index);
},
},
};
</script>
<style scoped>
.tab-control {
display: flex;
justify-content: space-between;
}
.tab-control-item {
flex: 1;
text-align: center;
height: 40px;
line-height: 40px;
}
.tab-control-item.active {
color: red;
}
.tab-control-item.active span {
display: inline-block;
border-bottom: 4px solid red;
padding: 0 10px;
}
</style>
App中的使用
我们在App中的使用过程如下:
<template>
<div>
<tab-control :titles="titles" @titleClick="titleClick"></tab-control>
<h2>{{contents[currentIndex]}}</h2>
</div>
</template>
<script>
import TabControl from './TabControl.vue';
export default {
components: {
TabControl
},
data() {
return {
titles: ["衣服", "鞋子", "裤子"],
contents: ["衣服页面", "鞋子页面", "裤子页面"],
currentIndex: 0
}
},
methods: {
titleClick(index) {
this.currentIndex = index;
}
}
}
</script>
非父子组件的相互通信
在开发中,我们构建了组件树之后,除了父子组件之间的通信之外,还会有非父子组件之间的通信。
这里我们主要讲两种方式:
- Provide/Inject
- Mitt全局事件总线;
Provide/Inject
Provide/Inject用于非父子组件之间共享数据:
- 比如有一些深度嵌套的组件,子组件想要获取父组件的部分内容;
- 在这种情况下,如果我们仍然将props沿着组件链逐级传递下去,就会非常的麻烦;
对于这种情况下,我们可以使用 Provide
和 Inject
:
- 无论层级结构有多深,父组件都可以作为其所有子组件的依赖提供者;
- 父组件有一个
provide
选项来提供数据; - 子组件有一个
inject
选项来开始使用这些数据;
实际上,你可以将依赖注入看作是“long range props”,除了:
- 父组件不需要知道哪些子组件使用它 provide 的 property
- 子组件不需要知道 inject 的 property 来自哪里
基本使用
我们开发一个这样的结构:
案例结构
App.vue组件提供数据:
<template>
<div>
<home></home>
</div>
</template>
<script>
import Home from './Home.vue';
export default {
components: {
Home
},
provide: {
name: "why",
age: 18
}
}
</script>
Home.vue是中间的组件:
<template>
<div>
<home-content></home-content>
</div>
</template>
<script>
import HomeContent from './HomeContent.vue';
export default {
components: {
HomeContent
}
}
</script>
HomeContent.vue是获取数据的组件:
<template>
<div>
<h2>HomeContent</h2>
<h2>{{name}}-{{age}}</h2>
</div>
</template>
<script>
export default {
inject: ["name", "age"]
}
</script>
函数写法
如果Provide中提供的一些数据是来自data,那么我们可能会想要通过this来获取:
引入data的数据
这个时候会报错:
- 这里给大家留一个思考题,我们的this使用的是哪里的this?
报错信息
如何解决这个问题呢?
- 我们需要将provide转成为返回对象的函数;
修改为函数的写法
处理响应式
我们先来验证一个结果:如果我们修改了this.names的内容,那么使用length的子组件会不会是响应式的?
<template>
<div>
<home></home>
<button @click="addName">添加name</button>
</div>
</template>
<script>
import Home from './Home.vue';
export default {
components: {
Home
},
data() {
return {
names: ["abc", "cba"]
}
},
provide() {
return {
name: "why",
age: 18,
length: this.names.length
}
},
methods: {
addName() {
this.names.push("why");
}
}
}
</script>
我们会发现对应的子组件中是没有反应的:
- 这是因为当我们修改了names之后,之前在provide中引入的
this.names.length
本身并不是响应式的;
那么怎么样可以让我们的数据变成响应式的呢?
- 非常的简单,我们可以使用响应式的一些API来完成这些功能,比如说computed函数;
- 当然,这个computed是vue3的新特性,在后面我会专门讲解,这里大家可以先直接使用一下;
注意:我们在使用length的时候需要获取其中的value
- 这是因为computed返回的是一个ref对象,需要取出其中的value来使用;
使用computed
Provide和Inject也可以在Composition API中使用,后续我们会讲到的。
全局事件总线
Vue3从实例中移除了 $on
、$off
和 $once
方法,所以我们如果希望继续使用全局事件总线,要通过第三方的库:
- Vue3官方有推荐一些库,例如 mitt 或 tiny-emitter;
- 这里我们主要讲解一下mitt库的使用;
首先,我们需要先安装这个库:
npm install mitt
其次,我们可以封装一个工具eventbus.js:
import mitt from 'mitt';// 可以创建很多个emitter对象
const emitter = mitt();
export default emitter;
在项目中可以使用它们:
- 我们在Home.vue中监听事件;
- 我们在App.vue中触发事件;
Home.vue组件中监听事件:
<template>
<div> </div>
</template>
<script>
import emitter from './eventBus';
export default {
created() {
emitter.on("why", (info) => {
console.log("why event:", info);
});
emitter.on("kobe", (info) => {
console.log("kobe event:", info);
})
emitter.on("*", (type, e) => {
console.log("* event:", type, e);
});
}
}
</script>
App.vue中触发事件:
<template>
<div>
<home />
<button @click="triggerEvent">触发事件</button>
</div>
</template>
<script>
import Home from './Home.vue';
import emitter from './eventBus';
export default {
components: {
Home
},
methods: {
triggerEvent() {
emitter.emit("why", {
name: "why",
age: 18
});
}
}
}
</script>
其他API的补充,如果在某些情况下我们想要取消事件,可以使用下面的API:
// 取消emitter中所有的监听
emitter.all.clear()
// 定义一个函数
function onFoo() {}
emitter.on('foo', onFoo)
插槽的使用
认识插槽slot
在开发中,我们会经常封装一个个可复用的组件:
- 前面我们会通过props传递给组件一些数据,让组件来进行展示;
- 但是为了让这个组件具备更强的通用性,我们不能将组件中的内容限制为固定的div、span等等这些元素;
- 比如某种情况下我们使用组件希望组件显示的是一个按钮,某种情况下我们使用组件希望显示的是一张图片;
- 我们应该让使用者可以决定某一块区域到底存放什么内容;
举个栗子:假如我们定制一个通用的导航组件 - NavBar
- 这个组件分成三块区域:左边-中间-右边,每块区域的内容是不固定;
- 左边区域可能显示一个菜单图标,也可能显示一个返回按钮,可能什么都不显示;
- 中间区域可能显示一个搜索框,也可能是一个列表,也可能是一个标题,等等;
- 右边可能是一个文字,也可能是一个图标,也可能什么都不显示;
京东导航
这个时候我们就可以来定义插槽slot:
- 插槽的使用过程其实是抽取共性、保留不同;
- 我们会将共同的元素、内容依然在组件内进行封装;
- 同时会将不同的元素使用slot作为占位,让外部决定到底显示什么样的元素;
如何使用slot呢?
- Vue中将
<slot>
元素作为承载分发内容的出口; - 在封装组件中,使用特殊的元素
<slot>
就可以为封装组件开启一个插槽; - 该插槽插入什么内容取决于父组件如何使用;
插槽的使用
插槽的基本使用
我们一个组件MySlotCpn.vue:
- 该组件中有一个插槽,我们可以在插槽中放入需要显示的内容;
<template>
<div>
<h2>MySlotCpn开始</h2>
<slot></slot>
<h2>MySlotCpn结尾</h2>
</div>
</template>
我们在App.vue中使用它们:
- 我们可以插入普通的内容、html元素、组件元素,都可以是可以的;
<template>
<div>
<my-slot-cpn>
<!-- 1.普通的内容 -->
Hello World
<!-- 2.html元素 -->
<button>我是按钮</button>
<!-- 3.组件元素 -->
<my-button></my-button>
</my-slot-cpn>
</div>
</template>
插槽的默认内容
有时候我们希望在使用插槽时,如果没有插入对应的内容,那么我们需要显示一个默认的内容:
- 当然这个默认的内容只会在没有提供插入的内容时,才会显示;
插槽的默认值
具名插槽的使用
我们先测试一个知识点:如果一个组件中含有多个插槽,我们插入多个内容时是什么效果?
- 我们会发现默认情况下每个插槽都会获取到我们插入的内容来显示;
多个插槽的效果
事实上,我们希望达到的效果是插槽对应的显示,这个时候我们就可以使用 具名插槽
:
- 具名插槽顾名思义就是给插槽起一个名字,
<slot>
元素有一个特殊的 attribute:name; - 一个不带 name 的slot,会带有隐含的名字
default
;
<template>
<div class="nav-bar">
<div class="left">
<slot></slot>
</div>
<div class="center">
<slot></slot>
</div>
<div class="right">
<slot></slot>
</div>
</div>
</template>
在向具名插槽提供内容的时候,我们可以在一个 <template>
元素上使用 v-slot
指令,并以 v-slot
的参数的形式提供其名称:
<template>
<div>
<nav-bar>
<template v-slot:left>
<button>左边按钮</button>
</template>
<template v-slot:center>
<h2>中间标题</h2>
</template>
<template v-slot:right>
<i>右边i元素</i>
</template>
</nav-bar>
</div>
</template>
插槽的使用过程如下:
动态插槽名:
- 目前我们使用的插槽名称都是固定的;
- 比如
v-slot:left
、v-slot:center
等等; - 我们可以通过
v-slot:[dynamicSlotName]
方式动态绑定一个名称;
动态插槽名
具名插槽使用的时候缩写:
- 跟
v-on
和v-bind
一样,v-slot
也有缩写; - 即把参数之前的所有内容 (
v-slot:
) 替换为字符#
;
<template>
<div>
<nav-bar>
<template #left>
<button>左边按钮</button>
</template>
<template #center>
<h2>中间标题</h2>
</template>
<template #right>
<i>右边i元素</i>
</template>
</nav-bar>
</div>
</template>
作用域插槽
渲染作用域
在Vue中有渲染作用域的概念:
- 父级模板里的所有内容都是在父级作用域中编译的;
- 子模板里的所有内容都是在子作用域中编译的;
如何理解这句话呢?我们来看一个案例:
- 在我们的案例中ChildCpn自然是可以让问自己作用域中的title内容的;
- 但是在App中,是访问不了ChildCpn中的内容的,因为它们是跨作用域的访问;
案例作用域访问
作用域插槽
但是有时候我们希望插槽可以访问到子组件中的内容是非常重要的:
- 当一个组件被用来渲染一个数组元素时,我们使用插槽,并且希望插槽中没有显示每项的内容;
- 这个Vue给我们提供了作用域插槽;
我们来看下面的一个案例:
- 1.在App.vue中定义好数据
- 2.传递给ShowNames组件中
- 3.ShowNames组件中遍历names数据
- 4.定义插槽的prop
- 5.通过v-slot:default的方式获取到slot的props
- 6.使用slotProps中的item和index
作用域插槽的案
例
具体的代码如下:
App.vue代码:
<template>
<div>
<show-names :names="names">
<template v-slot:default="slotProps">
<span>{{slotProps.item}}-{{slotProps.index}}</span>
</template>
</show-names>
</div>
</template>
<script>
import ShowNames from './ShowNames.vue';
export default {
components: {
ShowNames,
},
data() {
return {
names: ["why", "kobe", "james", "curry"]
}
}
}
</script>
ShowNames.vue代码:
<template>
<div>
<template v-for="(item, index) in names" :key="item">
<!-- 插槽prop -->
<slot :item="item" :index="index"></slot>
</template>
</div>
</template>
<script>
export default {
props: {
names: {
type: Array,
default: () => []
}
}
}
</script>
独占默认插槽
如果我们的插槽是默认插槽default,那么在使用的时候 v-slot:default="slotProps"
可以简写为v-slot="slotProps"
:
<show-names :names="names">
<template v-slot="slotProps">
<span>{{slotProps.item}}-{{slotProps.index}}</span>
</template>
</show-names>
并且如果我们的插槽只有默认插槽时,组件的标签可以被当做插槽的模板来使用,这样,我们就可以将 v-slot
直接用在组件上:
<show-names :names="names" v-slot="slotProps">
<span>{{slotProps.item}}-{{slotProps.index}}</span>
</show-names>
但是,如果我们有默认插槽和具名插槽,那么按照完整的template来编写。
默认插槽和具名插槽
只要出现多个插槽,请始终为所有的插槽使用完整的基于 <template>
的语法:
完整的template写法
动态组件
比如我们现在想要实现了一个功能:
- 点击一个tab-bar,切换不同的组件显示;
案例截图
这个案例我们可以通过两种不同的实现思路来实现:
- 方式一:通过v-if来判断,显示不同的组件;
- 方式二:动态组件的方式;
v-if显示不同组件
我们可以先通过v-if来判断显示不同的组件,这个可以使用我们之前讲过的知识来实现:
<template>
<div>
<button v-for="tab in tabs"
:key="tab"
:class="{active: currentTab === tab}"
@click="tabClick(tab)">
{{tab}}
</button>
<template v-if="currentTab === 'home'">
<home></home>
</template>
<template v-else-if="currentTab === 'about'">
<about></about>
</template>
<template v-else>
<category></category>
</template>
</div>
</template>
<script>
import Home from "./pages/Home.vue";
import About from "./pages/About.vue";
import Category from "./pages/Category.vue";
export default {
components: {
Home, About, Category
},
data() {
return {
tabs: ["home", "about", "category"],
currentTab: "home"
}
},
methods: {
tabClick(tab) {
this.currentTab = tab;
}
}
}
</script>
<style scoped>
.active {
color: red;
}
</style>
这里不再给出过多解释,都是之前讲过的内容。
动态组件的实现
动态组件是使用 component
组件,通过一个特殊的attribute is
来实现:
<template>
<div>
<button v-for="tab in tabs"
:key="tab"
:class="{active: currentTab === tab}"
@click="tabClick(tab)">
{{tab}}
</button>
<component :is="currentTab"></component>
</div>
</template>
这个currentTab的值需要是什么内容呢?
- 可以是通过component函数注册的组件;
- 在一个组件对象的components对象中注册的组件;
动态组件的传值
如果是动态组件我们可以给它们传值和监听事件吗?
- 也是一样的;
- 只是我们需要将属性和监听事件放到component上来使用;
App.vue的代码如下:
<template>
<div>
<button v-for="tab in tabs"
:key="tab"
:class="{active: currentTab === tab}"
@click="tabClick(tab)">
{{tab}}
</button>
<component name="why"
:age="18"
@pageClick="pageClick"
:is="currentTab"/>
</div>
</template>
<script>
import Home from "./pages/Home.vue";
import About from "./pages/About.vue";
import Category from "./pages/Category.vue";
export default {
components: {
Home, About, Category
},
data() {
return {
tabs: ["home", "about", "category"],
currentTab: "home"
}
},
methods: {
tabClick(tab) {
this.currentTab = tab;
},
pageClick(payload) {
console.log("pageClick", payload);
}
}
}
</script>
<style scoped>
.active {
color: red;
}
</style>
Home.vue中的代码如下:
<template>
<div @click="pageClick">
Home组件: {{name}}-{{age}}
</div>
</template>
<script>
export default {
props: {
name: String,
age: Number
},
emits: ["pageClick"],
methods: {
pageClick() {
this.$emit("pageClick", "Home组件");
}
}
}
</script>
keep-alive使用
认识keep-alive
我们先对之前的案例中About组件进行改造:
- 在其中增加了一个按钮,点击可以递增的功能;
<template>
<div>
About组件
<button @click="counter++">{{counter}}</button>
</div>
</template>
<script>
export default {
data() {
return {
counter: 0
}
}
}
</script>
比如我们将counter点到10,那么在切换到home再切换回来about时,状态是否可以保持呢?
- 答案是否定的;
- 这是因为默认情况下,我们在切换组件后,about组件会被销毁掉,再次回来时会重新创建组件;
但是,在开发中某些情况我们希望继续保持组件的状态,而不是销毁掉,这个时候我们就可以使用一个内置组件:keep-alive。
<keep-alive>
<component name="why"
:age="18"
@pageClick="pageClick"
:is="currentTab"/>
</keep-alive>
keep-alive属性
keep-alive有一些属性
- include:
string | RegExp | Array
。只有名称匹配的组件会被缓存; - exclude:
string | RegExp | Array
。任何名称匹配的组件都不会被缓存; - max:
number | string
。最多可以缓存多少组件实例,一旦达到这个数字,那么缓存组件中最近没有被访问的实例会被销毁;
include
和 exclude
prop 允许组件有条件地缓存:
二者都可以用逗号分隔字符串、正则表达式或一个数组来表示;
匹配首先检查组件自身的
name
选项;- 如果
name
选项不可用,则匹配它的局部注册名称 (父组件components
选项的键值);
- 如果
<!-- 逗号分隔字符串 -->
<keep-alive include="a,b">
<component :is="view"></component>
</keep-alive>
<!-- regex (使用 `v-bind`) -->
<keep-alive :include="/a|b/">
<component :is="view"></component>
</keep-alive>
<!-- Array (使用 `v-bind`) -->
<keep-alive :include="['a', 'b']">
<component :is="view"></component>
</keep-alive>
缓存的生命周期
对于生命周期的知识下面的四有讲解,因为这部分知识和keep-alive联系紧密,所以放到了这里。
大家可以等学习了生命周期后,再回头看这部分的内容。
对于缓存的组件来说,再次进入时,我们是不会执行created或者mounted等生命周期函数的:
- 但是有时候我们确实希望监听到何时重新进入到了组件,何时离开了组件;
- 这个时候我们可以使用
activated
和deactivated
这两个生命周期钩子函数来监听;
<template>
<div>
About组件
<button @click="counter++">{{counter}}</button>
</div>
</template>
<script>
export default {
name: "about",
data() {
return {
counter: 0
}
},
// 当重新进入活跃状态时会回调
activated() {
console.log("about activated")
},
// 当离开活跃状态时会回调
deactivated() {
console.log("about deactivated")
}
}
</script>
异步组件
webpack的代码分包
默认的打包过程:
- 默认情况下,在构建整个组件树的过程中,因为组件和组件之间是通过模块化直接依赖的,那么webpack在打包时就会将
组件模块
打包到一起(比如一个app.js文件中); - 这个时候随着项目的不断庞大,app.js文件的内容过大,会造成首屏的渲染速度变慢;
打包时,代码的分包:
- 所以,对于一些不需要立即使用的组件,我们可以单独对它们进行拆分,拆分成一些小的代码块chunk.js;
- 这些chunk.js会在需要时从服务器加载下来,并且运行代码,显示对应的内容;
那么webpack中如何可以对代码进行分包呢?
默认情况下,我们直接使用import来依赖一个模块时,是不会进行分包的:
import {sum} from './utils/math';
console.log(sum(20, 30));
如果我们希望进行分包,那么可以使用import函数:
import("./utils/math").then(({ sum }) => {
console.log(sum(20, 30));
});
import打包后的效果
vue中实现异步组件
如果我们的项目过大了,对于某些组件我们希望通过异步的方式来进行加载(目的是可以对其进行分包处理),那么Vue中给我们提供了一个函数:defineAsyncComponent
。
defineAsyncComponent接受两种类型的参数:
- 类型一:工厂函数,该工厂函数需要返回一个Promise对象;
- 类型二:接受一个对象类型,对异步函数进行配置;
工厂函数类型一的写法:
<script>
import { defineAsyncComponent } from 'vue';
const AsyncHome = defineAsyncComponent(() => import("./AsyncHome.vue"));
export default {
components: {
AsyncHome
}
}
</script>
对象类型类型二的写法:
<script>
import { defineAsyncComponent } from "vue";
// const AsyncHome = defineAsyncComponent(() => import("./AsyncHome.vue"));
import Loading from "./Loading.vue";
import Error from "./Error.vue";
const AsyncHome = defineAsyncComponent({
// 工厂函数
loader: () => import("./AsyncHome.vue"),
// 加载过程中显示的组件
loadingComponent: Loading,
// 加载失败时显示的组件
errorComponent: Error,
// 在显示 loadingComponent 之前的延迟 | 默认值:200(单位 ms)
delay: 200,
// 如果提供了 timeout ,并且加载组件的时间超过了设定值,将显示错误组件
// 默认值:Infinity(即永不超时,单位 ms)
timeout: 3000,
// 定义组件是否可挂起 | 默认值:true
suspensible: false,
/**
*
* @param {*} error 错误信息对象
* @param {*} retry 一个函数,用于指示当 promise 加载器 reject 时,加载器是否应该重试
* @param {*} fail 一个函数,指示加载程序结束退出
* @param {*} attempts 允许的最大重试次数
*/
onError(error, retry, fail, attempts) {
if (error.message.match(/fetch/) && attempts <= 3) {
// 请求发生错误时重试,最多可尝试 3 次
retry();
} else {
// 注意,retry/fail 就像 promise 的 resolve/reject 一样:
// 必须调用其中一个才能继续错误处理。
fail();
}
},
});
export default {
components: {
AsyncHome,
},
};
</script>
异步组件和Suspense
注意,目前(2021-06-08)Suspense显示的是一个实验性的特性,API随时可能会修改。
Suspense是一个内置的全局组件,该组件有两个插槽:
- default:如果default可以显示,那么显示default的内容;
- fallback:如果default无法显示,那么会显示fallback插槽的内容;
<template>
<div>
<suspense>
<template #default>
<async-home></async-home>
</template>
<template #fallback>
<loading/>
</template>
</suspense>
</div>
</template>
模块引用
$refs
某些情况下,我们在组件中想要直接获取到元素对象或者子组件实例:
- 在Vue开发中我们是不推荐进行DOM操作的;
- 这个时候,我们可以给元素或者组件绑定一个ref的attribute属性;
组件实例有一个$refs属性:
- 它一个对象Object,持有注册过
ref
attribute 的所有 DOM 元素和组件实例。
App.vue的实现:
<template>
<div>
<h2 ref="title">哈哈哈</h2>
<hello-world ref="helloCpn"></hello-world>
<button @click="visitElement">访问元素或者组件</button>
</div>
</template>
<script>
import HelloWorld from './HelloWorld.vue';
export default {
components: {
HelloWorld
},
methods: {
visitElement() {
// 访问元素
console.log(this.$refs.title);
// 访问组件实例
this.$refs.helloCpn.showMessage();
}
}
}
</script>
HelloWorld.vue实现:
<template>
<div>
</div>
</template>
<script>
export default {
methods: {
showMessage() {
console.log("我是HelloWorld组件的showMessage方法");
}
}
}
</script>
$parent
我们可以通过$parent来访问父元素。
HelloWorld.vue的实现:
- 这里我们也可以通过$root来实现,因为App是我们的根组件;
<template>
<div>
<button @click="visitParent">访问父组件</button>
</div>
</template>
<script>
export default {
methods: {
showMessage() {
console.log("我是HelloWorld组件的showMessage方法");
},
visitParent() {
console.log(this.$parent.message);
}
}
}
</script>
注意:在Vue3中已经移除了$children的属性,所以不可以使用了。
生命周期
生命周期图片
什么是生命周期呢?
- 每个组件都会经历从创建、挂载、更新、卸载等一系列的过程;
- 在这个过程中的某一个阶段,用于可能会想要添加一些属于自己的代码逻辑(比如组件创建完后就请求一些服务器数据);
- 但是我们如何可以知道目前组件正在哪一个过程呢?Vue给我们提供了组件的生命周期函数;
生命周期函数:
- 生命周期函数是一些钩子函数,在某个时间会被Vue源码内部进行回调;
- 通过对生命周期函数的回调,我们可以知道目前组件正在经历什么阶段;
- 那么我们就可以在该生命周期中编写属于自己的逻辑代码了;
实例的生命周期
生命周期演练
我们通过一个App和Home来演练所有的生命周期函数。
App.vue组件对象:
<template>
<div>
<button @click="toggle">切换</button>
<div v-if="isShow">
<home></home>
</div>
</div>
</template>
<script>
import Home from './Home.vue';
export default {
components: {
Home
},
data() {
return {
isShow: true
}
},
methods: {
toggle() {
this.isShow = !this.isShow;
console.log(this.isShow);
}
}
}
</script>
Home.vue组件对象:
<template>
<div>
<button @click="changeMessage">修改message</button>
<h2 ref="titleRef">{{message}}</h2>
</div>
</template>
<script>
export default {
data() {
return {
message: "Hello World"
}
},
methods: {
changeMessage() {
this.message = "你好啊,李银河";
}
},
beforeUpdate() {
console.log("beforeUpdate");
console.log(this.$refs.titleRef.innerHTML);
},
updated() {
console.log("updated");
console.log(this.$refs.titleRef.innerHTML);
},
beforeCreate() {
console.log("beforeCreate");
},
created() {
console.log("created");
},
beforeMount() {
console.log("beforeMount");
},
mounted() {
console.log("mounted");
},
beforeUnmount() {
console.log("beforeUnmount");
},
unmounted() {
console.log("unmounted");
}
}
</script>
组件的v-model
组件的v-model
前面我们在input中可以使用v-model来完成双向绑定:
- 这个时候往往会非常方便,因为v-model默认帮助我们完成了两件事;
v-bind:value
的数据绑定和@input
的事件监听;
如果我们现在封装了一个组件,其他地方在使用这个组件时,是否也可以使用v-model来同时完成这两个功能呢?
- 也是可以的,vue也支持在组件上使用v-model;
当我们在组件上使用的时候,等价于如下的操作:
- 我们会发现和input元素不同的只是属性的名称和事件触发的名称而已;
那么,为了我们的MyInput组件可以正常的工作,这个组件内的 <input>
必须:
- 将其
value
attribute 绑定到一个名叫modelValue
的 prop 上; - 在其
input
事件被触发时,将新的值通过自定义的update:modelValue
事件抛出;
MyInput.vue的组件代码如下:
<template>
<div>
<input :value="modelValue" @input="inputChange">
</div>
</template>
<script>
export default {
props: ["modelValue"],
emits: ["update:modelValue"],
methods: {
inputChange(event) {
this.$emit("update:modelValue", event.target.value);
}
}
}
</script>
在App.vue中,我们在使用MyInput可以直接使用v-model:
<template>
<div>
<my-input v-model="message"/>
<button @click="changeMessage">修改message</button>
</div>
</template>
<script>
import MyInput from './MyInput.vue';
export default {
components: {
MyInput
},
data() {
return {
message: ""
}
},
methods: {
changeMessage() {
this.message = "Hello World"
}
}
}
</script>
computed实现
在上面的案例中,我们可能会想到一种实现方法:直接将Props中的属性双向绑定到input上
<template>
<div>
<input v-model="modelValue">
</div>
</template>
<script>
export default {
props: ["modelValue"]
}
</script>
上面这种方式可以实现组件的双向绑定吗?答案是不可以
- 因为我们在内部修改了props之后,外界并不知道我们对props的修改,所以并不会将事件传递出去;
- 另外,在开发中直接修改props中的属性不是一个好的习惯,不要这样去做;
那么,我们依然希望在组件内部按照双向绑定的做法去完成,应该如何操作呢?我们可以使用计算属性的setter
和getter
来完成。
<template>
<div>
<input v-model="value">
</div>
</template>
<script>
export default {
props: ["modelValue"],
emits: ["update:modelValue"],
computed: {
value: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value)
}
}
}
}
</script>
绑定多个属性
我们现在通过v-model是直接绑定了一个属性,如果我们希望绑定多个属性呢?也就是我们希望在一个组件上使用多个v-model是否可以实现呢?
- 我们知道,默认情况下的v-model其实是绑定了
modelValue
属性和@update:modelValue
的事件; - 如果我们希望绑定更多,可以给v-model传入一个参数,那么这个参数的名称就是我们绑定属性的名称;
我们先看一下在App.vue中我是如何使用的:
<template>
<div>
<my-input v-model="message" v-model:title="title"/>
<h2>{{message}}</h2>
<button @click="changeMessage">修改message</button>
<hr>
<h2>{{title}}</h2>
<button @click="changeTitle">修改title</button>
</div>
</template>
<script>
import MyInput from './MyInput.vue';
export default {
components: {
MyInput
},
data() {
return {
message: "",
title: ""
}
},
methods: {
changeMessage() {
this.message = "Hello World"
},
changeTitle() {
this.title = "Hello Title"
}
}
}
</script>
注意:这里我是绑定了两个属性的
<my-input v-model="message" v-model:title="title"/>
v-model:title
相当于做了两件事:
- 绑定了
title
属性; - 监听了
@update:title
的事件;
所以,我们MyInput中的实现如下:
<template>
<div>
<input :value="modelValue" @input="input1Change">
<input :value="title" @input="input2Change">
</div>
</template>
<script>
export default {
props: ["modelValue", "title"],
emits: ["update:modelValue", "update:title"],
methods: {
input1Change(event) {
this.$emit("update:modelValue", event.target.value);
},
input2Change(event) {
this.$emit("update:title", event.target.value);
}
}
}
</script>