自定义组件
组件是可复用的 Vue 实例,在开发过程中,我们可以把重复用到的功能封装成自定义组件,达到便捷开发的目的。
组件的组织
通常一个应用会以一棵嵌套的组件树的形式来组织:
你可能会有头部导航、内容区、侧边栏等组件,每个组件内部又包含了导航链接、博文之类的组件。
为了能在模板 <template>
中使用,这些组件必须先注册以便 Vue 能够识别。
组件的注册
在 Vue 中,组件的注册分全局注册和局部注册两种:
- 全局注册:用
Vue.component
来创建组件,注册之后可以在任何新创建的 Vue 根实例中使用; - 局部注册:在单个 Vue 格式的文件中创建组件,在需要用到的地方进行注册;
但通常我们都是基于 Vue 工程进行开发的,会用到 webpack 这样的构建系统,而通过全局注册的组件在构建系统中即使没被使用依然会存在于构建结果中,所以我们通常选择局部注册。
组件的创建
每个 Vue 格式的文件都可以作为一个组件来使用。
组件的局部注册
- 首先我们需要有一个 Vue 格式的文件
在通过 vue-cli 创建的 vue 工程中默认存在一个组件 HelloWorld.vue:
这个文件中有些基础内容,由于内容过多,我们进行了删减,留下了一个标题和一个段落,我们可以在文件中指定组件名称:
注意
template
里的根元素只有一个。
- 将其作为组件,在 App.vue 中使用,组件可以重复使用:
小结一下:
组件的局部注册需要:
- 一个组件文件 HelloWorld;
- 在需要使用组件 HelloWorld 的 vue 文件 App 中引入组件 HelloWorld;
- 在 App 的 components 中注册组件 HelloWorld;
- 在 App 的 template 中使用组件;
当组件树形成时,组件间就有了层级关系,像上面这样的组件使用中,App.vue 就是父组件,而在它内部使用的 HelloWorld 就是子组件。
组件中的数据
自定义组件内的数据 data 必须是一个函数:
data: function () {
return {
count: 0
}
}
重复使用的组件间的 data 是相互独立的。这一点是很重要的,现在不理解没关系,我们在之后会再次遇到这种情况的。
组件单向数据流
在上一节中的作业里,由于组件 HelloVue 中 data 里的数据 str 是固定的,所以页面上显示的时候内容就是一样的:
而实际开发中,复用的组件里显示的内容往往是不同的,比如下面的图片中显示的每个文章介绍其实用的是一个组件,但是每个介绍的内容却是不同的,这就需要从父组件中传递不同的内容给子组件。
那么如何从父组件中把内容传给子组件呢?
prop 的使用方法
基础使用
当父组件给子组件的 prop 传递一个值的时候,这个值就变成了子组件实例的一个属性。
现在我们给 HelloVue 组件传递一个标题:
- 父组件中,传一个 title 给子组件:
<template>
<div id="app">
<!-- 注意!title1 和 title2 是父组件的 data 中定义的数据,title 则是子组件中接收数据时的变量名 -->
<HelloVue :title="title1"></HelloVue>
<HelloVue :title="title2"></HelloVue>
</div>
</template>
有两点需要大家注意一下:
- title1 和 title2 是父组件的 data 中定义的数据,而 title 则是子组件中接收数据时的变量名;
- 因为 title1 和 title2 是变量,所以 title 前需要加
:
;
如果不加 :
,那么在子组件中收到的 title 值就是 “title1” 和 “title2”。
- 子组件中,用 prop 接收 title:
<template>
<div class="hello">
<!-- 第二步:在页面上显示 title 的值,写法和显示 data 里定义的数据一样 -->
<h1>{{ title }}</h1>
</div>
</template>
<script>
export default {
name: 'HelloVue',
// 第一步:在 prop 属性中接收 title
props: ['title']
};
</script>
注意,在子组件中 prop 属性的写法,因为传过来的值可能不止一个,因此这里是个数组,其中的每一项均为传过来的值的名称。
上面这种写法没有声明所传值的类型,如果要声明类型可以参看进阶使用方法。
进阶使用:附带类型声明
我们可以用下面的写法给值声明类型,类型首字母要大写哦:
<script>
export default {
name: 'HelloVue',
// 在 prop 属性中接收 title,其类型为 String
props: {
title: String
}
};
</script>
这里 prop 是一个对象,当传入的值有多个的时候,可以用逗号隔开,我们还可以用下面的写法给值设置一些要求:
props: {
title: String,
// 多类型
likes: [String, Number],
// 带有默认值
isPublished: {
type: Boolean,
default: true
},
// 必填
commentIds: {
type: Array,
required: true
},
author: Object,
callback: Function,
contactsPromise: Promise
}
从上面的示例中,我们知道了怎么通过 prop 实现父到子的数据传递,那么能不能用 prop 实现子到父的数据传递呢?
答案是,不能。现在我们就来解释一下什么叫“单向数据流”。
单向数据流
单向数据流指的是父子 prop 之间形成了一个单向下行绑定:父级 prop 的更新会向下流动到子组件中,但是反过来则不行。
这样可以防止从子组件意外改变父级组件的状态,从而导致你的应用的数据流向难以理解。
简单来说:
- prop 可以实现父到子的数据传递;
- 父组件中的数据变化,会通过 prop 传递到子组件;
- 子组件不能直接修改父组件通过 prop 传递过来的数据;
但实际开发中常会遇到传到子组件中的数据需要处理后才能使用的情况,比如排序、格式化等,这时候怎么办呢?
1. prop 传入的数据需要处理
这时我们就可以用前面提到的计算属性对数据进行处理:
props: ['initialTitle'],
computed: {
normalizedTitle: function () {
// 对传入的 initialTitle 进行去空格、字母小写化处理
return this.initialTitle.trim().toLowerCase()
}
}
2. prop 传入的数据作为本地数据使用
如果想将 prop 传递的初始值作为一个本地数据使用,可以定义一个本地的 data 属性并将这个 prop 用作其初始值:
props: ['initialTitle'],
data: function () {
return {
// 要读取 prop 里的 initialTitle,要在前面加 “this.”
// 将传入的 initialTitle 作为本地属性 title 的初始值
title: this.initialTitle
}
}
接下来可以直接修改 title,而不必改变父组件的 initialTitle。
在某些情况下,我们还可以通过自定义事件,调用父组件中定义的方法,在父组件中修改数据,然后让数据的更新自动向下流动到子组件中。“自定义事件”这个概念我们会在后面的章节进行讲解。
文章列表的实现
现在我们来尝试做上面的文章列表,完成后是这样的:
注意,这里我们从父组件 App.vue 传到子组件 Article.vue 里的是一个对象 article:
<Article
v-for="article in articleList"
:key="article.title"
:article="article"
></Article>
article 是 App.vue 中的 data 数据 articleList 中的一项。
在子组件 Article.vue 中使用 article 数据时,通过计算属性对时间和文章内容做了处理,注意,这种做法并不是直接改变 article 的内容:
computed: {
formatTime: function() {
if (this.article) {
const dt = new Date(this.article.time)
const month = dt.getMonth()
const date = dt.getDate()
return `${month}月${date}日`
}
return '';
},
brief: function() {
return this.article && (this.article.content.substr(0, 35) + '...') || ''
}
}
自定义组件绑定原生事件
事件修饰符
在自定义组件(Article.vue)的根元素上监听一个原生事件和在 html
原生标签上监听一个原生事件是有区别的,请看下面的例子:
App.vue 中:
<!-- 给自定义组件添加点击事件 print -->
<Article
v-for="article in articleList"
:key="article.title"
:article="article"
@click="print(article)"
></Article>
Article.vue 中:
<div class="article-title" @click="printTitle">{{ article && article.title }}</div>
// 在 `methods` 对象中定义方法
methods: {
printTitle() {
alert("cilcked a title");
}
}
现在试着点击一下文章列表的标题部分,从控制台中的打印结果可以看出,只有子组件中的点击事件被执行了。
即使点击的是标题以外的部分,父组件中的点击事件 print 也不会被执行。
那么如何让父组件里的 print 也能被执行呢?这时候我们就需要用到 Vue 中的修饰符了。
修饰符是由点开头的指令后缀来表示的,在前面的课程中我们已经接触过了 .prevent
、.capture
等常用修饰符。
现在要让父组件里的 print 也能被执行,我们可以这样添加 .native
修饰符:
<Article
v-for="article in articleList"
:key="article.title"
:article="article"
@click.native="print(article)"
></Article>
大家手动给 App.vue 中的 @click="print(article)"
添加 .native
修饰符后再试着点击标题部分。
观察一下控制台中的输出,可以看到这时候,print 和 printTitle 都执行了。
除了事件修饰符,Vue 还提供了按键修饰符,用来监听键盘事件。
按键修饰符
这里我们主要讲一下回车键监听。
现在我们把 App.vue 中的 print 事件改成发生回车事件时执行:
<button @keyup.enter="print(article)">按回车键执行 print</button>
代码播放完毕后,先点击按钮让它聚焦,然后按回车键查看控制台的输出。可以看到 print 方法执行了。
因为回车键(enter 键)的 ASCII 码是 13,所以也可以这样写:
<button @keyup.13="print(article)">按回车键执行 print</button>
其他按键的监听大同小异,这里就不赘述了。
自定义事件
上节课的作业中,我们通过 App.vue 中的 handleLikes 方法来处理文章的点赞行为。
这个方法定义在 App.vue 中,改变的也是 App.vue 中定义的 articleList 数据,这样是没有问题的。
但是通常我们会在子组件 Article.vue 里写点赞按钮,像这样:
但是之前我们提到过,我们不能在子组件中直接修改父组件传来的 prop 数据。
如果想要修改父组件中的原数据要怎么办呢?
这里我们可以通过自定义事件来完成这个任务。
自定义事件
现在我们用自定义事件 “upVote” 来实现点赞功能。
- 给子组件 Article.vue 绑定自定义事件:
在 App.vue 中用 v-on:upVote="handleLikes"
给 Article.vue 绑定自定义事件:
<!-- 自定义事件 upVote,调用该事件时会执行 handleLikes 方法 -->
<article
v-for="article in articleList"
:key="article.title"
:article="article"
v-on:upVote="handleLikes"
></article>
“upVote” 是我们给自定义事件取的事件名,就像点击事件叫 “click” 一样。比较一下:点击事件
v-on:click
; 自定义事件v-on:upVote
;
- 在 Article.vue 中调用自定义事件 “upVote” :
<!-- 在 template 中直接调用自定义事件 upVote -->
<button @click="$emit('upVote')">点赞</button>
如果在点赞的同时还有其他要执行的代码可以这样写:
<button @click="childEvent">点赞</button>
methods: {
childEvent: function() {
// 调用自定义事件 upVote
this.$emit('upVote');
// do other things
}
}
特别注意,这里出现了三个名称:
- handleLikes:父组件中修改点赞数的方法;
- upVote:自定义事件名;
- childEvent:子组件中的按钮点击时调用的方法。
自定义事件的参数
prop 可以完成父组件到子组件的数据传递,自定义事件则可以帮我们完成子组件到父组件的数据传递。
下面我们就通过自定义事件的参数把数据从子组件传到父组件:
父组件 App.vue 中:
<!-- 自定义事件 upVote,调用该事件时会执行 handleLikes 方法 -->
<!-- 注意:我们接下来会在子组件里给 handleLikes 传参数 -->
<article
v-for="article in articleList"
:key="article.title"
:article="article"
v-on:upVote="handleLikes"
></article>
// 在 `methods` 对象中定义方法
methods: {
handleLikes(article) {
article.likes++
}
}
注意,虽然这里 handleLikes 方法需要传入参数 article,但v-on:upVote="handleLikes"
没有传入参数 article。
在子组件 Article.vue 中调用自定义事件 “upVote” 时会把参数传入:
<button @click="childEvent">点赞</button>
methods: {
childEvent: function() {
// 调用自定义事件 upVote,这里的第二个参数最后会传到父组件中的 handleLikes 方法里
this.$emit('upVote', this.article);
// do other things
}
}
$emit
的第一个参数是自定义事件的名称,它还可以有第二个、第三个参数,甚至更多的参数,这些参数最终会成为自定义事件对应的那个方法的参数。
小结一下,自定义事件可以:
- 在子组件中调用父组件的方法;
- 把子组件的数据通过自定义事件参数的形式传给父组件;
自定义事件中的双向绑定
之前讲过 v-model 可以实现双向绑定,如果是自定义组件,如何实现双向绑定呢?
这里我们要用到修饰符 .sync
。
父组件 App.vue 中,用修饰符 .sync
完成 count 的"双向绑定":
<MyCount class="count" :count.sync="count"></MyCount>
// 在 `methods` 对象中定义方法
data: function() {
return {
count: 0
}
}
子组件 MyCount.vue 中用 update:count
的模式触发事件,把 count+1
赋值给 count
:
<div class="my-count">
<button @click="$emit('update:count', count+1)">加一</button>
{{ count }}
</div>
props: ['count'],
虽然 count 是定义在 App.vue 里的,但是通过双向绑定,我们在子组件中改变 count 值,App.vue 里的 count 值也会有相同的变化。
组件函数调用
除了用自定义组件双向绑定的方法完成弹框的显示和隐藏外,我们还可以把 visible 直接写在子组件中,通过在父组件里调用子组件方法的形式,修改子组件中的 visible 的值,完成弹框的显示和隐藏。
想要调用子组件中的方法,其实就是访问子组件实例,调用实例中的方法。
下面我们利用 Vue 提供的 ref
属性来访问子组件实例,并调用子组件中的方法。
改写子组件 Modal.vue
首先我们需要修改一下子组件 Modal.vue 的写法:
这个代码演示我们不运行,请忽略运行报错,大家看一下改写后的 Modal.vue 的写法即可。
调用子组件中的方法
接下来我们使用 ref
属性来访问子组件实例,并调用子组件中的方法:
1. 给要访问的子组件添加 ref
属性
<template>
<Modal ref="modal"></Modal>
</template>
现在,我们可以通过 this.$refs.modal
来访问自定义组件 Modal.vue。
2. 调用子组件中的方法
现在我们调用子组件中的 show
方法来改变子组件中的 visible
的值,使弹框出现:
<script>
export default {
methods: {
showModal() {
// 调用子组件中的 show 方法
this.$refs.modal.show();
}
}
};
</script>
ref 访问子元素
ref
除了可以访问组件实例,还可以访问子元素:
<template>
<div id="app">
<input ref="input" type="text" />
<button @click="focusInput">点击使输入框获取焦点</button>
</div>
</template>
<script>
export default {
name: 'app',
methods: {
focusInput() {
// this.$refs.input 访问输入框元素,并调用 focus() 方法使其获取焦点
this.$refs.input.focus();
}
}
}
</script>
实现效果:
组件 slot 入门
slot
即插槽,相当于在子组件的 DOM 中留一个位置,父组件如果有需要,就可以在插槽里添加内容。
插槽的基础使用
这节里我们会讲解插槽的简单用法。
- 在子组件 Modal.vue 中用
slot
标签预留一个位置,slot
标签中的内容是后备内容,也可以为空:
<div class="modal-content">
<slot>这是个弹框</slot>
<div class="footer">
<button @click="close">close</button>
<button @click="confirm">confirm</button>
</div>
</div>
后备内容:当父组件不在插槽里添加内容时,插槽显示的内容。
- 在父组件中使用子组件
-
在父组件中使用子组件,但不向自定义组件的插槽
slot
中添加内容:<Modal :visible.sync="visible"></Modal>
此时如果打开弹框,弹框中显示的是后备内容“这是个弹框”:
-
在父组件中使用子组件,并给插槽加入个性化内容:
<Modal :visible.sync="visible">个性化内容</Modal>
组件 slot 进阶
有时我们需要多个插槽。例如对于一个带有如下模板的组件:
<div class="modal" v-if="visible">
<div class="modal-content">
<header>
<!-- 我们希望把页头放这里 -->
</header>
<main>
<!-- 我们希望把主要内容放这里 -->
</main>
<footer>
<!-- 我们希望把页脚放这里 -->
</footer>
</div>
</div>
对于这样的情况,<slot>
元素有一个特殊的属性:name
。这个属性可以用来定义额外的插槽:
<div class="modal" v-if="visible">
<div class="modal-content">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</div>
上节课中的插槽 <slot>
我们没有写 name 属性,但其实它会自动带隐含的名字“default”,也就是我们所说的“匿名插槽”。
而带有 name 属性的插槽,我们称为“具名插槽”。
在向具名插槽提供内容的时候,我们可以在一个 <template>
元素上使用 v-slot 指令,并以 v-slot 的参数的形式提供其名称:
<Modal :visible.sync="visible">
<template v-slot:header>
<h1>Modal title</h1>
</template>
<div>main content</div>
<div>main content</div>
<template v-slot:footer>
<p>Modal footer</p>
</template>
</Modal>