文章标题,强烈建议填写此选项
title: “7. 组件详解”
发布时间,强烈建议填写此选项,且最好保证全局唯一
date: 2022-06-09
分组名
categories:
- E-BOOKS
Tag标签
tags:
- E-BOOKS
- VueActualCombat
- Article_1-Based_Vue.js
::: tip
组件(Component)
是Vue.js
最核心的功能,也是整个框架设计最精彩的地方,当然也是最难掌握的,本章将带领你由浅入深地学习组件的全部内容,并通过几个实战项目熟练使用 Vue 组件。
:::
7.1 组件与复用
7.1.1 为什么使用组件
在正式介绍组件前,我们先来看一个简单的场景。如图所示:
图中是个很常见的聊天界面,虽然有点简陋,但有有些标准的控件,比如右上角的关闭按钮
、输入框
、发送按钮
等,你可能要问了,这有什么难的,不就是几个div
、input
吗?,好,那现在需求升级了,这几个控件还有别的地方要用到。没问题,复制粘贴呗,那如果输入框要带数据验证,按钮的图标支持自定义呢?这样用JavaScript
封装后一起复制吧,那等到项目快完结时,产品经理说,所有使用输入框的地方都要改成支持回车键提交
, 好吧,给我一天的时间,我一个个加上去。
上面的需求虽然有点变态,但却是业务中很常见的,那就是一些控件
、JavaScript
能力的复用。没错,Vue.js的组件就是提高重用性的让代码可复用
,当学习完组件后,上面的问题就可以分分钟搞定了,再也不用害怕产品经理的奇随需求。
我们先看一下上图,的示例用组件来编写是怎样的,示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>示例</title>
</head>
<body>
<Card style="width:350px;">
<p slot="title">与xxx聊天中</p>
<a href="#" slot="extra" >
<Icon type="android-close"size="18"></Icon>
</a>
<div style="height:lOOpx;">
</div>
<div>
<Row :gutter="16">
<i-col span="17">
<i-input v-model="value" placeholder="请输入...."></i-input>
</i-col>
<i-col span="4">
<i-button type=”primary” icon="paper-airplane">发送<i-button>
</i-col>
</Row>
</div>
</Card>
</body>
</html>
是不是很奇怪,有很多我们从来都没有见过的标签,比如<Card>
、<Row>
、<i-col>
、<i-input>
和<i-button>
等,而且整段代码除了内联的几个样式外,一句 CSS 代码也没有,但最终实现的 UI 就是图的效果
这些没见过的自定义标签就是组件,每个标签代表一个组件,在任何使用 Vue 的地方都可以直接使用。我们就来看看组件的具体用法
7.1.2 组件用法
回顾一下我们创建 Vue 实例的方法:
var app = new Vue({
el: "#app",
});
组件与之类似,需要注册后才可以使用。注册有全局注册
和局部注册
两种方式。全局注册后
,任何Vue
实例都可以使用。全局注册示例代码如下:
Vue.component("my-component", {
// 组件选项
});
my-component
就是注册的组件自定义标签名称
,推荐使用小写加减号分割的形式命名。
要在父实例中使用
这个组件,必须要在实例创建前注册
,之后就可以用<my-component>
的形式来使用组件了,示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
// 组件选项
});
var app = new Vue({
el: "#app",
data: {
text: "",
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
此时打开页面还是空白的,因为我们注册的组件没有任何内容,在组件选项中添加template
就可以显示组件内容了,示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
template: "<div>我的第一个全局组件</div>",
});
var app = new Vue({
el: "#app",
data: {
text: "",
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
<!-- 此时会渲染成这样的 -->
<div id="app" v-cloak>
<div>我的第一个全局组件</div>
</div>
template
的 DOM 结构必须被一个元素包含,如果直接写成这里是组件的内容
,<div></div>
是无法渲染的。
在Vue.js
实例中,使用components
选项可以局部注册组件
,注册后的组件只有在该实例作用域下有效
,组件中
也可以使用components
选项来注册组件,使组件可以嵌套。示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
var Child = {
template: "<div>局部注册组件的内容</div>",
};
var app = new Vue({
el: "#app",
components: {
"my-component": Child,
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
Vue
组件的模板在某些情况下会受到HTML的限制
,比如<table>
内规定只允许是<tr>
、<td>
、<th>
等这些表格元素,所以在<table>
内直接使用组件是无效的。这种情况下,可以使用特殊的is
属性来挂载组件,示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<table>
<tbody is="my-component"></tbody>
</table>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
template: "<div>我的第一个全局组件</div>",
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
tbody在渲染时
会被替换为组件的内容。常见的限制元素还有<ul>
、<ol>
和<select>
。
提示:如果使用的是字符串模板,是不受限制的,比如后面的章节介绍.vue 单文件用法等
除了template
选项外,组件中还可以像Vue
实例那样使用其他的选项,比如data
、computed
、methods
等,但是在使用data
时,和实例稍有区别,data
必须是函数,然后将数据return
出去, 例如:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
template: "<div>{{ message }}</div>",
data: function() {
return {
message: "组件内容",
};
},
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
JavaScript对象
是引用关系
,所以return
出的对象引用了外部的一个对象,那这个对象就是共享的,任何一方修改都会同步, 比如下面的示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component></my-component>
<my-component></my-component>
<my-component></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
var data = {
counter: 0,
};
Vue.component("my-component", {
template: '<button @click="counter ++ ">{{ counter }}</button>',
data: function() {
return data;
},
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
组件使用了 3 次,但是点击任意一个<button>
,3 个数字都会加 1,那时因为data
引用的是外部的对象,这肯定不是我们期望的效果,所以给组件返回一个新的data
的对象来独立,示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component></my-component>
<my-component></my-component>
<my-component></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
template: '<button @click="counter ++ ">{{ counter }}</button>',
data: function() {
return {
counter: 0,
};
},
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
这样,点击这 3 个按钮就互不影响了,完全达到复用的目的。
7.2 使用 props 传递数据
7.2.1 基本用法
组件不仅仅是要把模板的内容进行复用,更重要的是组件间要进行通信
。通常父组件的模板中包含子组件
,父组件要正向地向子组件传递数据或参数
,子组件接收到后根据参数的不同来渲染不同的内容或执行操作。这个正向传递数据的过程就是通过props来实现的
。
在组件中,使用选项props来声明需要从父级接收的数据,props的值可以是两种,一种是字符串数组,一种是对象
。本小节先介绍数组的用法。比如我们构造一个数组,接收一个来自父级的数据message
,并把它在组件模板中渲染,示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component message="来自父组件的数据"></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
props: ["message"],
template: "<div>{{ message }}</div>",
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
<!-- 渲染后的结果为:-->
<div id="app" v-cloak>
<div>来自父组件的数据</div>
</div>
</html>
props中声明的数据与组件data函数return的数据主要区别就是props的来自父级
,而data
中的是组件自己的数据,作用域是组件本身,这两种数据都可以在模板template
及计算属性computed
和方法methods
中使用。上例的数据message就是通过props从父级传递过来的
,在组件的自定义标签上直接写该props名称
,如果要传递多个数据
,在props数组中添加
项即可。
由于HTML
特性不区分大小写,当使用DOM模板
时,驼峰命名(CcamelCase)
的props
的名称要转为短横分隔命名(kebab-case)
,例如:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component warning-text="提示信息"></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
props: ["warningText"],
template: "<div>{{ warningText }}</div>",
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
<!-- 渲染后的结果为:-->
<div id="app" v-cloak>
<div>来自父组件的数据</div>
</div>
</html>
提示:如果使用的是字符串模板,仍然可以忽略这些限制。
有时候,传递的数据并不是直接写死的,而是来自父级的动态数据,这时可以使用指令V-bind
来动态绑定props
的值,当父组件的数据变化时,也会传递给子组件。示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<input type="text" v-model="parentMessage" />
<my-component :message="parentMessage"></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
props: ["message"],
template: "<div>{{ message }}</div>",
});
var app = new Vue({
el: "#app",
data: {
parentMessage: "",
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
这里用v-model
绑定了父级的数据parentMessage
,当通过输入框任意输入时,子组件接收到的props
,message
也会实时响应,并更新组件模板。
注意:如果你要直接传递数字、布尔值、数组、对象,而且不使用 v-bind,传递的仅仅是字符串,尝试下面的示例来对比:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component message="[1,2,3]"></my-component>
<my-component :message="[1,2,3]"></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
props: ["message"],
template: "<div>{{ message.length }}</div>",
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
同一个组件使用了两次,区别仅仅是第二个使用的是v-bind
,渲染后的结果,第一个是 7,第二个才是数组的长度 3。
7.2.2 单向数据流
Vue2.X
与Vue1.X
比较大的一个改变就是,Vue2.X通过props传递数据是单向的了,也就是父组件数据变化时会传递给子组件,但是反过来不行。
,而在Vue1.X里提供了.sync修饰符来支持双向绑定。之所以这样设计,是尽可能将父子组件解耦,避免子组件无意中修改了父组件的状态。
业务中会经常遇到两种需要改变prop情况,
一种是父组件传递初始值进来,子组件将它作为初始值保存起来,在自己的作用域下可以随意使用和修改
。这种情况可以在组件data内再声明一个数据,引用父组件的 prop,
示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component :init-count="1"></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
props: ["initCount"],
template: "<div>{{ count }}</div>",
data() {
return {
count: this.initCount,
};
},
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
组件中声明了数据count
, 它在组件初始化时会获取来自父组件的initCount
,之后就与之无关了,只用维护count,
这样就可以避免直接操作initCount
。
另一种情况就是prop作为需要被转变的原始值传入
。这种情况用计算属性就可以了,示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component :width="100"></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
props: ["width"],
template: '<div :style="style">组件内容</div>',
computed: {
style() {
return {
width: this.width + "px",
};
},
},
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
因为用CSS传递宽度要带单位(px),但是每次都写太麻烦,而且数值计算一般是不带单位的,所以统一在组件内使用计算属性就可以了
。
注意:在 JavaScript 中对象和数组是引用类型,指向同一个内存,所以 props 是对象和数组时,在子组件内改变是会影响父纽件的。
7.2.3 数据验证
我们上面所介绍的props选项的值都是一个数组
,一开始也介绍过,除了数组外,还可以是对象,当prop需要验证时,需要对象写法。
一般当你的组件需要提供给别人使用时,推荐都进行数据验证,比如某个数据必须是数字类型,如果传入字符串,就会在控制台弹出警告。
以下是几个prop
示例:
Vue.component("my-component", {
props: {
// 必须是数字类型
propA: Number,
// 必须是字符串或数字类型
propB: [String, Number],
// 布尔值,如果没有定义,默认值就是true
propC: {
type: Boolean,
default: true,
},
// 数字,而且是必传
propD: {
type: Number,
required: true,
},
// 如果是数组或对象,默认值必须是一个函数来返回
propE: {
type: Array,
default: () => [],
},
// 自定义一个验证函数
propF: {
validator: function(value) {
return value > 10;
},
},
},
});
验证的type
类型可以是:
- String
- Number
- Boolean
- Object
- Array
- Function
type也可以是一个自定义构造器,使用instanceof检测
当prop验证失败时,在开发版本下会在控制台抛出一条警告。
7.3 组件通信
我们已经知道,从父组件向子组件通信,通过props传递数据就可以了,
但是Vue
组件通信的场景不止有这一种,归纳起来,组件之间通信可以用如图所示:
组件关系可分为父子组件通信
、兄弟组件通信
、跨级组件通信
,本节将介绍各种组件之间通信的方法。
7.3.1 自定义事件
当子组件需要向父组件传递数据时,就要用到自定义事件
,我们在介绍指令v-on时有提到
,v-on
除了监昕DOM事件外
,还可以用于组件之间的自定义事件。
如果你了解过JavaScript的设计模式一一观察者模式
,一定知道dispatchEvent
和addEventListener
,这两个方法。Vue
组件也有与之类似的一套模式,子组件用$emit()来触发事件
,父组件用$on()来监昕子组件的事件
。
父组件也可以直萨在子组件的自定义标签上使用v-on来监昕子组件触发的自定义事件,
示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<p>总数: {{ total }}</p>
<my-component
@increase="handleGetTotal"
@reduce="handleGetTotal"
></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
template: `\
<div>\
<button @click="handleIncrease">+1</button>\
<button @click="handleReduce">-1</button>\
<div>`,
data() {
return {
counter: 0,
};
},
methods: {
handleincrease() {
this.counter++;
this.$emit("increase", this.counter);
},
handleReduce() {
this.counter--;
this.$emit("reduce", this.counter);
},
},
});
var app = new Vue({
el: "#app",
data: {
total: 0,
},
methods: {
handleGetTotal(total) {
this.total = total;
},
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
上面示例中,子组件有两个按钮,分别时加 1 和减 1 的效果,在改变组件的data中counter后,通过$emit()再把它传递给父组件,父组件用v-on:increase和v-on:reduce(示例使用的是语法糖)。
$emit()方法的第一个参数是自定义事件的名称,
例如示例的increase
和reduce
后面的参数都是要传递的数据
,可以不填或填写多个
。
除了用v-on
在组件上监听自定义事件外,也可以监听DOM事件
,这时可以用.native修饰符
表示监听的是一个原生事件,监听的是该组件的根元素,示例代码如下:
<my-component v-on:click.native="handleClick"></my-component>
7.3.2 使用 v-model
Vue2.x
可以在自定义组件上使用v-model指令
,我们先来看一看示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<p>总数: {{ total }}</p>
<my-component v-model="total"></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
template: `<button @click="handleClick">+1</button>`,
data() {
return {
counter: 0,
};
},
methods: {
handleClick() {
this.counter++;
this.$emit("input", this.counter);
},
},
});
var app = new Vue({
el: "#app",
data: {
total: 0,
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
仍然是点击按钮加 1 的效果,不过这次组件$emit()
的事件名称是特殊的input
,在使用组件的父级,井没有在<my-component>上使用@input="handler"而是直接用了v-model绑定的一个数据total,这也可以称作是一个语法糖,因为上面的示例可以间接地用自定义事件来实现:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<p>总数: {{ total }}</p>
<my-component @input="handleGetTotal"></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
template: `<button @click="handleClick">+1</button>`,
data() {
return {
counter: 0,
};
},
methods: {
handleClick() {
this.counter++;
this.$emit("input", this.counter);
},
},
});
var app = new Vue({
el: "#app",
data: {
total: 0,
},
methods: {
handleGetTotal(total) {
this.total = total;
},
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
v-model
还可以用来创建自定义的表单输入组件,进行数据双向绑定,例如:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<p>总数: {{ total }}</p>
<my-component v-model="total"></my-component>
<button @click="handleReduce">-1</button>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-component", {
props: ["value"],
template: `<input :value="value" @input="updateValue">+1</input>`,
methods: {
updateValue(event) {
this.$emit("input", event.target.value);
},
},
});
var app = new Vue({
el: "#app",
data: {
total: 0,
},
methods: {
handleReduce(total) {
this.total--;
},
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
实现这样一个具有双向绑定的v-model
组件要满足下面两个要求:
- 接收一个
value属性
。 - 在有新的
value时触发input事件
。
7.3.3 非父子组件通信
在实际业务中,除了父子组件通信
,还有很多非父子组件通信
的场景,非父子组件
一般有两种,兄弟组件
和跨多级组件
。为了更加彻底地了解Vue2.X
中的通信方法。我们先来看一下在Vue1.X
中是如何实现的,这样便于我们了解Vue
的设计思想。
在Vue1.X
中,除了$emit()
方法外,还提供了$dispatch()
和$broadcast()
这两个方法。$dispatch()
用于向上级派发事件,只要是它的父级(一级或多级以上)
,都可以在Vue实列的events选项内接收
,示例代码如下:
<!-- 该示例代码使用的是1.X版本 -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
{{ message }}
<my-component></my-component>
</div>
<script src="https://cdn.staticfile.org/vue/1.0.0/vue.min.js"></script>
<script>
Vue.component("my-component", {
props: ["value"],
template: `<button @click="handleDispatch">派发事件</button>`,
methods: {
handleDispatch() {
this.$dispatch("on-message", "来自内部组件的数据");
},
},
});
var app = new Vue({
el: "#app",
data: {
message: "",
},
events: {
"on-message": function(msg) {
this.message = msg;
},
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
同理,$broadcast()
是由上级向下级广播事件的,用法完全一致,只是方向相反。
这两种方法一旦发出事件后,任何组件都是可以接收到的,就近原则,而且会在第一次接收到后停止冒泡,除非返回true
。
这两个方法虽然看起来很好用,但是在Vue2.X
中都废弃了,因为基于组件树结构的事件流方式让人难以理解,并且在组件结构扩展的过程中会变得越来越脆弱,并且不能解决兄弟组件通信的问题
在Vue2.X
中,推荐使用一个空的Vue实例作为中央事件总线(bus),,也就是一个中介
,为了更形象地了解它,我们举一个生活中的例子。
比如你需要租房子,你可能会找房产中介来登记你的需求,然后中介把你的信息发给满足要求的出租者,出租者再把报价和看房时间告诉中介,由中介再转达给你,整个过程中,买家和卖家并没有任何交流,都是通过中间人来传话的。
或者你最近可能要换房了,你会找房产中介登记你的信息,订阅与你找房需求相关的资讯,一旦有符合你的房子出现时,中介会通知你,并传达你房子的具体信息。
这两个例子中,你和出租者担任的就是两个跨级的组件,而房产中介就是这个中央事件总线(bus),比如下面的示例代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
{{ message }}
<component-a></component-a>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
var bus = new Vue();
Vue.component("component-a", {
template: `<button @click="handleEvent">传递事件</button>`,
methods: {
handleEvent() {
bus.$emit("on-message", "来自component-a组件的数据");
},
},
});
var app = new Vue({
el: "#app",
data: {
message: "",
},
mounted: function() {
let that = this;
// 在实例初始化时,监听来自 bus 实例的事件
bus.$on("on-message", function(msg) {
that.message = msg;
});
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
首先创建了一个名为bus的空Vue实例
,里面没有任何内容;然后全局定义了组件component-a
;最后创建Vue实例app
,在app初始化时
,也就是在生命周期mounted钩子函数里监听了来自bus的事件on-message
,而在组件component-a中
,点击按钮会通过bus把事件on-message发出去
,此时app
就会接收到来自bus
的事件,进而在回调里完成自己的业务逻辑。
这种方法巧妙而轻量地实现了任何组件间的通信,包括父子
、兄弟
、跨级
,而且Vue1.X
和Vue2.X
都适用;如果深入使用,可以扩展bus实例
,给它添加data
、methods
、computed
等选项, 这些都是可以公用的,在业务中,尤其是协同开发时非常有用,因为经常需要共享一些通用的信息,比如用户登录的昵称
、性别
、邮箱
等,还有用户的授权token 等
。只需在初始化时让bus获取一次
,任何时间、任何组件就可以从中直接使用了
,在单页面富应用(SPA)中会很实用,我们会在进阶篇里逐步介绍这些内容。
当你的项目比较大,有更多的小伙伴参与开发时,也可以选择更好的状态管理解决方案Vuex
,进阶篇里会详细介绍关于它的用法。
除了中央事件总线(bus)
外,还有两种方法可以实现组件间通信:父链和子组件索引
。
- 父链
在子组件
中,使用this.$parent可以直接访问该组件的父实例或组件
,父组件
也可以通过this.$children访问它所有的子组件
,而且可以递归向上或向下无线访问
,直到根实例或最内层的组件。
例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
{{ message }}
<component-a></component-a>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("component-a", {
template: `<button @click="handleEvent">通过父链直接修改数据</button>`,
methods: {
handleEvent() {
this.$parent.message = "来自组件component-a的内容";
},
},
});
var app = new Vue({
el: "#app",
data: {
message: "",
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
尽管 vue 允许这样操作,但在业务中,子组件应该尽可能地避免依赖父组件的数据,更不应该去主动修改它的数据,因为这样使得父子组件紧藕合,只看父组件,很难理解父组件的状态,因为它可能被任意组件修改,理想情况下,只有组件自己能修改它的状态。
父子组件最好还是通过props
和$emit
来通信
- 子组件索引
当子组件较多时,通过this.$children来一一遍历出我们需要的一个组件实例是比较困难的,尤其是组件动态渲染时,它们的序列是不固定的。
Vue 提供了子组件索引的方法,用特殊的属性ref来为子组件指定一个索引名称,
示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<button @click="handleRef">〉通过ref获取子组件实例</button>
<component-a ref="comA"></component-a>
<p>此时console.log的值为:{{ newMessage }}</p>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("component-a", {
template: `<div>子组件</div>`,
data() {
return {
message: "子组件内容",
};
},
});
var app = new Vue({
el: "#app",
data() {
return {
newMessage: "",
};
},
methods: {
handleRef() {
// 通过$refs来访问指定的实例
this.newMessage = this.$refs.comA.message;
console.log("通过$refs来访问指定的实例", this.newMessage);
},
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
在父组件模板中,子组件标签上使用ref指定一个名称,井在父组件内通过this.$refs来访问指定名称的子组件
注意: r e f s 只在渲染完成后才填充 , 并且它是非响应式的 , 它仅仅作为一个直接访问子组件的应急方案 , 应当避免在模板或计算属性中使用 refs只在渲染完成后才填充,并且它是非响应式的,它仅仅作为一个直接访问子组件的应急方案,应当避免在模板或计算属性中使用 refs只在渲染完成后才填充,并且它是非响应式的,它仅仅作为一个直接访问子组件的应急方案,应当避免在模板或计算属性中使用refs
与Vue1.X不同的是, Vue2.X将v-el和v-ref合并为了ref,Vue会自动去判断是普通标签还组件
7.4 使用 slot 分发内容
7.4.1 什么是 slot
我们先看一个比较常规的网站布局,如图:
这个网站由一级导航
、 二级导航
、左侧列表
、正文以及底部版权信息
5 个模块组成,如果要将它们都组件化,这个结构可能会是:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<app>
<menu-main></menu-main>
<menu-sub></menu-sub>
<div class="container">
<menu-left></menu-left>
<container>></container>
</div>
<app-footer></app-footer>
</app>
</body>
</html>
当需要让组件组合使用,混合父组件的内容与子组件的模板时,就会用到slot,这个过程叫作内容分发(transclusion)。
以<app>
为例,它有两个特点:
<app>
组件不知道它的挂载点会有什么内容。挂载点的内容是由<app>
的父组件决定的。<app>
组件很可能有它自己的模板。
props传递数据、events发事件和slott内容分发
就构成了Vue
组件的 3 个 API 来源,再复杂的组件也是由这3部分构成的。
7.4.2 作用域
正式介绍slot
前,需要先知道一个概念: 编译的作用域。
比如父组件中有如下模板:
<child-component>
{{message }}
</child-component>
这里的message
就是一个slot
,但是它绑定的是父组件的数据而不是组件<child-component>的数据
父组件模板的内容是在父组件作用域内编译
,子组件模板的内容是在子组件作用域内编译。
例如下面的代码示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<child-component v-show="showChild"></child-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("child-component", {
template: `<div>子组件</div>`,
});
var app = new Vue({
el: "#app",
data() {
return {
showChild: true,
};
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
这里的状态showChild
绑定的是父组件的数据,如果想在子组件上绑定,那应该是:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<child-component></child-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("child-component", {
template: `<div v-show="showChild">子组件</div>`,
data() {
return {
showChild: true,
};
},
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
因此,slot分发的内容,作用域是在父组件上的
。
7.4.3 slot 用法
- 单个 slot
在子组件内使用特殊的<slot>元素就可以为这个子组件开启一个slot(插槽),
在父组件模板,插入在子组件标签内的所有内容将替代子组件的<solt>标签及它内容。
示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<child-component>
<p>分发的内容</p>
<p>更多分发的内容</p>
</child-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("child-component", {
template: `
<div>
<slot>
<p>如果父组件没有插入内容,我将作为默认出现</p>
</slot>
</div>
`,
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
子组件<child-componentt>模板内定义了一个<slot>元素,
并且用一个<p>
标签作为默认的内容,在父组件没有使用slot
时,会渲染这段默认的文本:如果写入了solt,那就会替换整个<slot>
, 所以上例渲染后的结果为:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<div>
<p>分发的内容</p>
<p>更多分发的内容</p>
</div>
</div>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
注意:子组件<slot>
内的备用内容,它的作用域是子组件本身。
- 具名 slot
给<slot>元素指定一个name后可以分发多个内容
,具名slot
可以与单个slot
共存,例如下面的的示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<child-component>
<h2 slot="header">标题</h2>
<p>正文的内容</p>
<p>更多的正文内容</p>
<div slot="footer">底部信息</div>
</child-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("child-component", {
template: `
<div class="container">
<div class="header">
<slot name="header"></slot>
</div>
<div class="main">
<slot></slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
</div>
`,
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
子组件内声明了3个<slot>元素,
其中在<div class="main">
内的<slot>
没有使用name
特性,它将作为默认slot出现
,父组件没有使用slot特性的元素与内容都将出现在这里
。
如果没有指定默认的匿名slot,父组件内多余的内容片段都将被抛弃。
上例最终渲染后的结果为:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<div class="container">
<div class="header">
<h2>标题</h2>
</div>
<div class="main">
<p>正文的内容</p>
<p>更多的正文内容</p>
</div>
<div class="footer">
<div>底部信息</div>
</div>
</div>
</div>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
在组合使用组件时,内容分发API至关重要。
7.4.4 作用域插槽
作用域插槽是一种特殊的slot,使用一个可以复用的模板替换己渲染元素。
概念比较难理解,我们先看一个简单的示例来了解它的基本用法。示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<child-component>
<template scope="props">
<p>来组父组件的内容</p>
<p></p>
<p>{{ props.msg }}</p>
</template>
</child-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("child-component", {
template: `
<div class="container">
<solt msg="来自子组件的内容"></solt>
</div>
`,
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
观察子组件的模板,<slot>元素上有一个类似props传递数据给组件的写法msg="xxx",
将数据传到了插槽。父组件中使用了<template>元素
,而且拥有一个scope="props"
的特性,这里的props
只是一个临时变量,就像v-for="item in items"
里的item
一样。template
内可以通过临时变量props
访问来自子组件插槽的数据msg
。
将上面的示例渲染后的最终结果为:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<div class="container">
<p>来组父组件的内容</p>
<p>来自子组件的内容</p>
</div>
</div>
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak]{
display:none
}
</style>
</body>
</html>
作用域插槽更具代表性的用例是列表组件
,允许组件自定义应该如何渲染列表每一项
。示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-list :books="books">
<!-- 作用域插槽也可以是具名的slot -->
<template slot="book" scope="props">
<li>{{ props.bookName }}</li>
</template>
</my-list>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("my-list", {
props: {
books: {
type: Array,
default: () => [],
},
},
template: `
<ul>
<slot name="book" v-for="book in books" :book-name="book.name"></slot>
</ul>
`,
});
var app = new Vue({
el: "#app",
data: {
books: [
{ name: "《Vue.js实战》" },
{ name: "《JavaScript 语言精粹》" },
{ name: "《JavaScript 高级程序设计》" },
],
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
子组件my-list
接收一个来自父级的prop
数组books
,并且将它在name
为book
的slot
上使用v-for指令循环
,同时暴露一个变量bookName
。
如果你仔细揣摩上面的用法,你可能会产生这样的疑问:我直接在父组件用v-for不就好了吗,为什么还要绕一步在子组件里面循环呢?
,的确:如果只是针对上面的示例,这样写是多此一举的。此例的用意主要是介绍作用域插槽的用法,并没有加入使用场景,而作用域插槽的使用场景就是既可以复用子组件的slot,又可以使slot内容不一致。
如果上例还在其它组件内使用,<li>的内容渲染权是由使用者掌握的,而数据却可以通过临时变量(比如props)从子组件内获取。
7.4.5 访问 slot
在Vue1.x中
,想要获取某个slot是比较麻烦的
,需要用v-el
间接获取,Vue2.x
提供了用来访问被slot
分发的内容的方法$slots
, 请看下面的示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<child-component>
<h2 slot="header">标题</h2>
<p>正文内容</p>
<p>更多的正文内容</p>
<div slot="footer">底部信息</div>
</child-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("child-component", {
template: `
<div class="container">
<div class="header">
<slot name="header"></slot>
</div>
<div class="main">
<slot></slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
</div>
`,
mounted: function() {
let header = this.$slots.header;
let main = this.$slots.default;
let footer = this.$slots.footer;
console.log("footer", footer); // [mo]
console.log("footer2", footer[0].elm.innerHTML); // 底部信息
},
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
通过$slots
可以访问某个具名slot,this.$slots.default包括了所有没有被包含在具名slot中的节点
。尝试编写代码,查看两个console打印
的内容。
$slots
在业务中几乎用不到,在用render数(进阶篇中将介绍)创建组件时
会比较有用,但主要还是用于独立组件开发中
。
7.5 组件高级用法
本节会介绍组件的一些高级用法,这些用法在实际业务中不是很常用,在独立组件开发时可能会用到。
如果你感觉以上内容已经足够完成你的业务开发了,可以跳过本节;如果你想继续探索Vue
组件的奥秘,读完本节会对你有很大的启发。
7.5.1 递归组件
组件在它的模板内可以递归地调用自己,只要给组件设置name选项就可以了
。示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<child-component :count="1"></child-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("child-component", {
name: "child-component",
props: {
count: {
type: Number,
default: 1,
},
},
template: `
<div class="child">
<child-component :count="count + 1" v-if="count < 3"></child-component>
</div>
`,
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
设置name
后,在组件模板内就可以递归使用了,不过需要注意的是,必须给一个条件来限制递归数量,否则会抛出错误Maximum call stack size exceeded
组件递归使用可以用来开发一些具有未知层级关系的独立组件,比如级联选择器
和树形控件
等,在实战篇里,我们会详细介绍级联选择器的实现。
7.5.2 内联模板
组件的模板一般都是在<template>
选项内定义的, Vue 提供了一个内联模板的功能,在使用组件时,给组件标签使用inline-template
特性,组件就会把它的内容当作模板,而不是把它当内容分发,这让模板更灵活。示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<child-component inline-template>
<div>
<h2>在父组件中定义子组件的模板</h2>
<p>{{ message }}</p>
<p>{{ msg }}</p>
</div>
</child-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("child-component", {
data() {
return {
msg: "在子组件声明的数据",
};
},
});
var app = new Vue({
el: "#app",
data() {
return {
message: "在父组件声明的数据",
};
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
渲染后的结果为:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app">
<div>
<h2>在父组件中定义子组件的模板</h2>
<p>在子组件声明的数据</p>
<p>在父组件声明的数据</p>
</div>
</div>
</body>
</html>
在父组件中声明的数据message
和子组件中声明的数据msg
,两个都可以渲染(如果同名,优先使用子组件的数据),这反而是内联模板的缺点,就是作用域比较难理解,如果不是非常特殊的场景,建议不要轻易使用内联模板。
7.5.2 动态组件
Vue.js
提供了一个特殊的元素<component>
用来动态地挂载不同的组件
,使用is特性来选择要挂载的组件
, 示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<component :is="currentView"></component>
<button @click="handleChangeView('A')">切换到A</button>
<button @click="handleChangeView('B')">切换到B</button>
<button @click="handleChangeView('C')">切换到C</button>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
var app = new Vue({
el: "#app",
components: {
comA: {
template: `<div>组件A</div>`,
},
comB: {
template: `<div>组件B</div>`,
},
comC: {
template: `<div>组件C</div>`,
},
},
data() {
return {
currentView: "comA",
};
},
methods: {
handleChangeView(component) {
this.currentView = "com" + component;
},
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
动态地改变currentView值就可以动态挂载组件了
。也可以直接绑定在组件对象上:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<component :is="currentView"></component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
var Home = {
template: `<p>Welcome home!</p>`,
};
var app = new Vue({
el: "#app",
data() {
return {
currentView: Home,
};
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
7.5.4 异步组件
当你的工程足够大,使用的组件足够多时,是时候考虑下性能问题了,
因为一开始把所有的组件都加载是没必要的一笔开销,好在Vue.js
允许将组件定义为一个工厂函数
,动态地解析组件。Vue.js
只在组件需要渲染时触发工厂函数,并且把结果缓存起来,用于后面的再次渲染。例如下面的示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<child-component></child-component>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component("child-component", function(resolve, reject) {
window.setTimeout(function() {
resolve({
template: `<div>我是异步渲染的</div>`,
});
}, 2000);
});
var app = new Vue({
el: "#app",
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
工厂函数
接收resolve回调
,在收到从服务器下载的组件定义时调用。也可以调用reject(reason)
指示加载失败。这里setTimeout
只是为了演示异步,具体的下载逻辑可以自己决定,比如把组件配置写成一个对象配置,通过Ajax
来请求,然后调用resolve
传入配置选项。
在进阶篇里,我们还会介绍主流的打包编译工具webpack
和.vue单文件
的用法。更优雅地实现异步组件(路由)
。
7.6 其他
7.6.1 $nextTick
我们先来看这样一个场景:有一个div,默认用v-if将它隐藏,点击一个按钮后,改变v-if的值,让它显示出来,同时拿到这个div的文本内容。如果v-if的值是false,直接去获取div的内容是获取不到的,因为此时div还没有被创建出来,那么应该在点击按钮后,改变v-if值为true,div才会被创建,此时再去获取。
示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<div id="div" v-if="showDiv">这是一段文本</div>
<button @click="getText">获取div内容</button>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
var app = new Vue({
el: "#app",
data() {
return {
showDiv: false,
};
},
methods: {
getText() {
this.showDiv = true;
let text = document.getElementById("div").innerHTML;
console.log(text);
},
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
这段代码并不难理解,但是运行后在控制台会抛出一个错误:Cannot read properties of null (reading 'innerHTML'),
意思就是获取不到div元素
。这里就涉及Vue
一个重要的概念:异步更新队列
。
Vue
在观察到数据变化时并不是直接更新DOM
,而是开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。在缓冲时会去除重复数据,从而避免不必要的计算和 DOM 操作。然后,在下一个事件循环tick
中,Vue 刷新队列井执行实际(己去重的)工作。所以如果你用一个 for 循环来动态改变数据 100 次,其实它只会应用最后一次改变,如果没有这种机制,DOM 就要重绘 100 次,这固然是一个很大的开销。
Vue
会根据当前浏览器环境优先使用原生的Promise.then
和MutationObserver
,如果都不支持,就会采用setTimeout
代替。
知道了Vue
异步更新DOM
原理,上面示例的报错也就不难理解了,事实上,在执行this.showDiv = true; 时,
div 仍然还是没有被创建出来,直到下一个Vue事件
循环时,才开始创建。$nextTick
就是用来知道什么时候 DOM 更新完成的。所以上面的示例代码需要修改为:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<div id="div" v-if="showDiv">这是一段文本</div>
<button @click="getText">获取div内容</button>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
var app = new Vue({
el: "#app",
data() {
return {
showDiv: false,
};
},
methods: {
getText() {
this.showDiv = true;
this.$nextTick(function() {
let text = document.getElementById("div").innerHTML;
console.log(text);
});
},
},
});
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
这时再点击按钮,控制台就打印出 div 的内容这是一段文本
了。
理论上,我们应该不用去主动操作DOM,因为Vue的核心思想就是数据驱动DOM,但在很多业务里,我们避免不了会使用一些第三方库,
比如popper.js、swi per等,这些基于原生 JavaScript 库都有创建
和更新及销毁
的完整生命周, 与Vue
配合使用时,就要利用好$nextTick
7.6.2 X-Templates
如果你没有使用webpack
、gulp
等工具,试想一下你的组件template
的内容很冗长、复杂,如果都在 JavaScript 里拼接字符串,效率是很低的,因为不能像写 HTML 那样舒服。Vue
提供了另一种定义模板的方式,在<script>
标签里使用 text/x-template 类型,井且指定一个 id,将这个 id 赋给 template 示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>示例</title>
</head>
<body>
<div id="app" v-cloak>
<my-component><my-component>
<script type="text/x-template" id="my-component">
<div>这是组件的内容</div>
</script>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
Vue.component('my-component',{
template: '#my-component'
})
var app = new Vue ({
el :"#app",
})
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak]{
display:none
}
</style>
</body>
</html>
在<script>
标签里,你可以愉快地写HTML
代码,不用考虑换行等问题。
很多刚接触Vue
开发的新手会非常喜欢这个功能,因为用它,再加上组件知识,就可以很轻松地完成交互相对复杂的页面和应用了。如果再配合一些构建工具(gulp)组织好代码结构,开发一些中小型产品是没有问题的。不过,Vue
的初衷并不是滥用它,因为它将模板和组件的其他定义隔离了。在进阶篇里,我们会介绍如何使用webpack
来编译.vue
从而优雅地解决 HTML 书写的问题。
7.6.3 手动挂载实例
我们现在所创建的实例都是通过new Vue()
的形式创建出来的。在一些非常特殊的情况下,我们需要动态地去创建Vue
实例,Vue
提供了Vue.extend
和$mount
两个方法来手动挂载一个实例。
Vue.extend
基础Vue构造器
,创建一个子类
,参数是一个包含组件选项的对象。
如果Vue
实例在实例化时没有收到el
选项,它就处于未挂载
状态,没有关联的DOM
元素。可以使用$mount()
手动地挂载一个未挂载
的实例。这个方法返回实例自身,因而可以链式调用其他实例方法。示例代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="mount-div" v-cloak></div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
var MyComponent = Vue.extend({
template: `<div>Hello: {{ name }}</div>`,
data() {
return {
name: "Forever",
};
},
});
new MyComponent().$mount("#mount-div");
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
运行后,id为mount-div的div元素会被替换为组件MyComponent的template内容
:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="mount-div" v-cloak>
<div>Hello: Forever</div>
</div>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
除了这种写法外,以下两种写法也是可以的:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="mount-div" v-cloak></div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
var MyComponent = Vue.extend({
template: `<div>Hello: {{ name }}</div>`,
data() {
return {
name: "Forever",
};
},
});
new MyComponent({
el: "#mount-div",
});
// 或者,在文档之外渲染并且随后挂载
var component = new MyComponent().$mount();
document.getElemetByid("mount-div").appendChild(component.$el);
</script>
<style>
/* 解决屏幕闪动 */
[v-cloak] {
display: none;
}
</style>
</body>
</html>
手动挂载实例(组件)是一种比较极端的高级用法,在业务中几乎用不到,只有在开发一些复杂的独立组件时可能会使用,所以只做了解就好。
7.7 实战:两个常用组件的开发
本节以组件知识为基础,整合指令
、事件
等前面章节的内容,开发两个业务中常用的组件,即数字输入框
和标签页
。
7.7.1 开发一个数字输入框组件
数字输入框是对普通输入框的扩展,用来快捷输入一个标准的数字,如图
数字输入框只能输入数字,而且有两个快捷按钮
,可以直接减1或加1
。除此之外,还可以设置初始值、最大值、最小值,
在数值改变时,触发一个自定义事件来通知父组件
。
了解了基本需求后,我们先定义目录文件:
- index.html--------入口文件
- input-number.js—数字输入框组件
- index.js----------根实例
因为该示例是以交互功能为主,所以就不写CSS美化样式了
。
首先写入基本的结构代码,初始化项目。
- index.html--------入口文件
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
</head>
<body>
<div id="app"></div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script src="input-number.js"></script>
<script src="index.js"></script>
</body>
</html>
- index.js----------根实例
var app = new Vue({
el: "#app",
});
- input-number.js—数字输入框组件
Vue.component("input-number", {
template: `
<div class="input-number">
</div>
`,
props: {
max: {
type: Number,
default: Infinity,
},
min: {
type: Number,
default: -Infinity,
},
value: {
type: Number,
default: 0,
},
},
});
该示例的主角是input-number.js
,所有的组件配置都在这里面定义
,先在tmplate
里定义了组件的根节点,因为是独立组件,所以应该对每个prop
进行校验,这里根据需求有最大值
、最小值
、默认值(也就是绑定值)
3 个prop
,max
和min
都是数字类型,默认值是正无限大
和负无限大
,value
也是数字类型默认值是0
。
接下来,我们先在父组件引入input-number
组件,并给它一个默认值5
,最大值10
,最小值0
- index.js----------根实例
var app = new Vue({
el: "#app",
data() {
return {
value: 5,
};
},
});
- index.html--------入口文件
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>示例</title>
</head>
<body>
<div id="app">
<input-number v-model="value" :max="10" :min="0"><input-number>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script src="input-number.js"></script>
<script src="index.js"></script>
</body>
</html>
value
是一个关键的绑定值,所以用了v-model
,这样既优雅地实现了双向绑定,也让API
看起来很合理。大多数的表单类组件都应该有v-model
比如输入框
、单选框
、多选框
、下拉选择器
等。
剩余的代码量就都聚焦到了input-number.js
上
我们之前介绍过,Vue组件是单向数据流
,所以无法从组件内部直接修改prop、value的值
。解决办法也介绍过:就是给组件声明一个data,默认引用value的值,然后在组件内部维护这个data
:
- input-number.js—数字输入框组件
Vue.component("input-number", {
template: `
<div class="input-number">
</div>
`,
data() {
return {
currentValue: this.value,
};
},
props: {
max: {
type: Number,
default: Infinity,
},
min: {
type: Number,
default: -Infinity,
},
value: {
type: Number,
default: 0,
},
},
});
这样只解决了初始化时引用父组件value
的问题,但是如果从父组件修改了value
, input-number.js
组件的currentValue
也要一起更新。为了实现这个功能,我们需要用到一个新的概念
:监听(watch)
。
监听(watch)
选项用来监昕某个prop
和data
的改变,当它们发生变化时, 就会触发watch
配置的函数,从而完成我们的业务逻辑。在本例中,我们要监听两个量:value
和currentValue
,监听value
是要知晓从父组件修改了value
,监听currentValue
是为了当currentValue
改变时,更新value
,相关代码如下:
- input-number.js—数字输入框组件
Vue.component("input-number", {
template: `
<div class="input-number">
</div>
`,
data() {
return {
currentValue: this.value,
};
},
watch: {
currentValue(value) {
this.$emit("input", value);
this.$emit("on-change", value);
},
value(val) {
this.updateValue(val);
},
},
props: {
max: {
type: Number,
default: Infinity,
},
min: {
type: Number,
default: -Infinity,
},
value: {
type: Number,
default: 0,
},
},
methods: {
updateValue(val) {
if (val > this.max) val = this.max;
if (val < this.min) val = this.min;
this.currentValue = val;
},
},
mounted() {
this.updateValue(this.value);
},
});
从父组件传递过来value
有可能是不符合当前条件的(大于 max,或小于 min),所以在选项methods
里写了一个方法updateValue
用来过滤一个正确currentValue
watch
监听的数据回调函数有2个参数可用
,第一个是新的值,第二个是旧的值
,这里没有太复杂的逻辑,就只用了第一个参数。 在回调函数里,this指向当前组件实例
的,所以可以直接调用this.currentValue()
,因为Vue
代理props
、data
、computed
及methods
。
监听updateValue
的回调里,this.$emit('input', value)
是在使用v-model
时改变value
的;this.$emit ('on-change', value)
是触发自定义事件on-change
,用于告诉父组件数字输入的值有所改变(示例中没有使用该事),
在生命周期mounted
钩子里也调用了updateValue()
方法,是因为第一次初始化时,也对value
进行了过滤。这里也有另一种写法,在data
选项返回对象前进行过滤
。
- input-number.js—数字输入框组件
Vue.component("input-number", {
template: `
<div class="input-number">
</div>
`,
data() {
let val = this.value;
if (val > this.max) val = this.max;
if (val < this.min) val = this.min;
return {
currentValue: val,
};
},
watch: {
currentValue(value) {
this.$emit("input", value);
this.$emit("on-change", value);
},
value(val) {
this.updateValue(val);
},
},
props: {
max: {
type: Number,
default: Infinity,
},
min: {
type: Number,
default: -Infinity,
},
value: {
type: Number,
default: 0,
},
},
methods: {},
mounted() {
this.updateValue(this.value);
},
});
实现的效果是一样的。
最后剩余的就是补全模板template
,内容是一个输入框
和两个按钮
,相关代码如下
- input-number.js—数字输入框组件
function isValueNurnber(value) {
return /^(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*))$/.test(
value + ""
);
}
Vue.component("input-number", {
template: `
<div class="input-number">
<input type="text" :value="currentValue" @change="handleChange">
<button @click="handleDown" :disabled="currentValue <= min"> - </button>
<button @click="handleUp" :disabled="currentValue >= max"> + </button>
</div>
`,
props: {
max: {
type: Number,
default: Infinity,
},
min: {
type: Number,
default: -Infinity,
},
value: {
type: Number,
default: 0,
},
},
data() {
return {
currentValue: this.value,
};
},
watch: {
currentValue(value) {
this.$emit("input", value);
this.$emit("on-change", value);
},
value(val) {
this.updateValue(val);
},
},
methods: {
handleDown() {
if (this.currentValue <= this.min) return;
this.currentValue -= 1;
},
handleUp() {
if (this.currentValue >= this.max) return;
this.currentValue += 1;
},
updateValue(val) {
if (val > this.max) val = this.max;
if (val < this.min) val = this.min;
this.currentValue = val;
},
handleChange(event) {
let val = event.target.value.trim();
let max = this.max;
let min = this.min;
if (isValueNumber(val)) {
val = Number(val);
this.currentValue = val;
if (val > max) {
this.currentValue = max;
} else if (val < min) {
this.currentValue = min;
}
} else {
event.target.value = this.currentValue;
}
},
},
mounted() {
this.updateValue(this.value);
},
});
input绑定
了数据currentValue
和原生的change事件
,在句柄handleChange函数
中,判断了当前输入的是否是数字。注意,这里绑定的currentValue是单向数据流
,并没有用 v-model
,所以在输入时,currentValue
的值并没有实时改变。如果输入的不是数字(比如英文和汉字等),就将输入的内容重置为之前的currentValue
,果输入的是符合要求的数字,就把输入的值赋给currentValue
,
数字输入框组件的核心逻辑就是这些。回顾一下我们设计一个通用组件的思路,首先,在写代码前一定要明确需求,然后规划好 API。一个组件的 API 只来自props
、events
、slots
,确定好这 3 部分的命名
、规则
,剩下的逻辑即使第一版没有做好,后续也可以迭代完善,但是 API 如果没有设计好,后续再改对使用者成本就很大了。
完整的示例代码如下:
- index.html--------入口文件
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>示例</title>
</head>
<body>
<div id="app">
<input-number v-model="value" :max="10" :min="0"><input-number>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script src="input-number.js"></script>
<script src="index.js"></script>
</body>
</html>
- input-number.js—数字输入框组件
function isValueNurnber(value) {
return /^(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*))$/.test(
value + ""
);
}
Vue.component("input-number", {
template: `
<div class="input-number">
<input type="text" :value="currentValue" @change="handleChange">
<button @click="handleDown" :disabled="currentValue <= min"> - </button>
<button @click="handleUp" :disabled="currentValue >= max"> + </button>
</div>
`,
props: {
max: {
type: Number,
default: Infinity,
},
min: {
type: Number,
default: -Infinity,
},
value: {
type: Number,
default: 0,
},
},
data() {
return {
currentValue: this.value,
};
},
watch: {
currentValue(value) {
this.$emit("input", value);
this.$emit("on-change", value);
},
value(val) {
this.updateValue(val);
},
},
methods: {
handleDown() {
if (this.currentValue <= this.min) return;
this.currentValue -= 1;
},
handleUp() {
if (this.currentValue >= this.max) return;
this.currentValue += 1;
},
updateValue(val) {
if (val > this.max) val = this.max;
if (val < this.min) val = this.min;
this.currentValue = val;
},
handleChange(event) {
let val = event.target.value.trim();
let max = this.max;
let min = this.min;
if (isValueNumber(val)) {
val = Number(val);
this.currentValue = val;
if (val > max) {
this.currentValue = max;
} else if (val < min) {
this.currentValue = min;
}
} else {
event.target.value = this.currentValue;
}
},
},
mounted() {
this.updateValue(this.value);
},
});
- index.js----------根实例
var app = new Vue({
el: "#app",
data() {
return {
value: 5,
};
},
});
衍生拓展练习:
-
练习 1:
在输入框聚焦时,增加对键盘上下按键的支持,相当于加1减1
。这个比较简单,只需要在
input-number.js
组件input
里加两个事件(@keyup.down 键盘下和@keyup.up 键盘上),然后调用我们写的handleDown
和handleUp
方法就可以了,完整代码如下:- input-number.js—数字输入框组件
function isValueNurnber(value) { return /^(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*))$/.test( value + "" ); } Vue.component("input-number", { template: ` <div class="input-number"> <input type="text" :value="currentValue" @change="handleChange" @keyup.down="handleDown" @keyup.up="handleUp"> <button @click="handleDown" :disabled="currentValue <= min"> - </button> <button @click="handleUp" :disabled="currentValue >= max"> + </button> </div> `, props: { max: { type: Number, default: Infinity, }, min: { type: Number, default: -Infinity, }, value: { type: Number, default: 0, }, }, data() { return { currentValue: this.value, }; }, watch: { currentValue(value) { this.$emit("input", value); this.$emit("on-change", value); }, value(val) { this.updateValue(val); }, }, methods: { handleDown() { if (this.currentValue <= this.min) return; this.currentValue -= 1; }, handleUp() { if (this.currentValue >= this.max) return; this.currentValue += 1; }, updateValue(val) { if (val > this.max) val = this.max; if (val < this.min) val = this.min; this.currentValue = val; }, handleChange(event) { let val = event.target.value.trim(); let max = this.max; let min = this.min; if (isValueNumber(val)) { val = Number(val); this.currentValue = val; if (val > max) { this.currentValue = max; } else if (val < min) { this.currentValue = min; } } else { event.target.value = this.currentValue; } }, }, mounted() { this.updateValue(this.value); }, });
-
练习 2:
增加一个步进器(step),比如设置10,点击加号按钮,一次增加10,点击减号按钮,一次减10
在这我将 index.html 中做了修改,将最大值 max 去掉了,使其最大数为正无穷,在
input-number.js
组件中加了一个输入框,并用stepChange
来监听步进器(step)改变,并将handleDown
和handleUp
做了修改。完整代码如下:- input-number.js—数字输入框组件
function isValueNurnber(value) { return /^(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*))$/.test( value + "" ); } Vue.component("input-number", { template: ` <div class="input-number"> <input type="text" :value="stepValue" @change="stepChange"> <input type="text" :value="currentValue" @change="handleChange" @keyup.down="handleDown" @keyup.up="handleUp"> <button @click="handleDown" :disabled="currentValue <= min"> - </button> <button @click="handleUp" :disabled="currentValue >= max"> + </button> </div> `, props: { max: { type: Number, default: Infinity, }, min: { type: Number, default: -Infinity, }, value: { type: Number, default: 0, }, }, data() { return { currentValue: this.value, stepValue: 1, }; }, watch: { currentValue(value) { this.$emit("input", value); this.$emit("on-change", value); }, value(val) { this.updateValue(val); }, }, methods: { handleDown() { if (this.currentValue <= this.min) return; this.currentValue -= this.stepValue; }, handleUp() { if (this.currentValue >= this.max) return; this.currentValue += this.stepValue; }, updateValue(val) { if (val > this.max) val = this.max; if (val < this.min) val = this.min; this.currentValue = val; }, stepChange(event) { let val = event.target.value.trim(); let min = this.min; if (isValueNurnber(val)) { val = Number(val); this.stepValue = val; if (val < min) { this.stepValue = 1; } } else { this.stepValue = event.target.value = 1; } }, handleChange(event) { let val = event.target.value.trim(); let min = this.min; if (isValueNurnber(val)) { val = Number(val); this.currentValue = val; if (val < min) { this.currentValue = min; } } else { event.target.value = this.currentValue; } }, }, mounted() { this.updateValue(this.value); }, });
7.7.2 开发一个标签页组件
本小节将开发一个比较有挑战的组件:标签页组件。标签页(即选项卡切换组件)是网页布局中经常用到的元素,用于平级区域大块内容的收纳和展现,如图
根据上个示例的经验,我们先分析业务需求,制定出 API,这样不至于一上来就无从下手。
每个标签页的主体内容肯定是由使用组件的父级控制的
,所以这部分是一个slot
,而且slot
的数量决定了标签切换按钮的数量。假设我们有 3 个标签页,点击每个标签按钮时,另外的两个标签对应的slot
应该被隐藏掉。一般这个时候,比较容易想到的解决办法是,在slot里写3个div
,在接收到切换通知时,显示和隐藏相关的div
。这样设计没有问题,只不过体现不出组件的价值来,因为我们还是写了一些与业务无关的交互逻辑,而这部分逻辑最好组件本身帮忙处理了,我们只用聚焦在 slot 内容本身,这才是与我们业务最相关的。这种情况下,我们再定义一个子组件pane
,聚焦在slot
内容本身,这才是与我们业务最相关的。这种情况下,我们再定义一一个子组件pane
,嵌套在标签页组件 tabs 里,我们的业务代码都放在 pane 的 slot 内,而 3 个 pane 组件作为整体成为tabs
的slot
。
由于tabs
和pane
两个组件是分离的,但是tabs
组件上的标题应该由pane
组件来定义,因为slot
是写在pane
里,因此在组件初始化(及标签标题动态改变)
时,tabs要从pane
里获取标题,并保存起来,自己使用。
确定好了结构,我们先创建所需的文件:
- index.html--------入口文件
- style.css---------样式表
- tabs.js-----------标签页外层的组件 tabs
- pane.js-----------标签页嵌套的组件 pane
首先写入基本的结构代码,初始化项目。
- index.html--------入口文件
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
<link rel="stylesheet" type="text/css" href="style.css" />
</head>
<body>
<div id="app" v-cloak></div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script src="pane.js"></script>
<script src="tabs.js"></script>
<script type="text/javascript">
var app = new Vue({
el: "#app",
});
</script>
</body>
</html>
- tabs.js-----------标签页外层的组件 tabs
Vue.component("tabs", {
template: `
<div class="tabs">
<div class="tabs-bar">
<!--标签页标题,这里要用v-for-->
</div>
<div class="tabs-content">
<!--这里的slot就是嵌套的pane -->
<slot></slot>
</div>
</div>
`,
});
- pane.js-----------标签页嵌套的组件 pane
Vue.component("pane", {
name: "pane",
template: `
<div class="pane">
<slot></slot>
</div>
`,
});
pane
需要控制标签页内容的显示与隐藏, 设置data:show
,井且用v-show指令来控制元素
:
- pane.js-----------标签页嵌套的组件 pane
Vue.component("pane", {
name: "pane",
template: `
<div class="pane" v-show="show">
<slot></slot>
</div>
`,
data() {
return {
show: true,
};
},
});
当点击到这个pane
对应的标签页标题按钮时,此pane
的show
值设置为true
,否则应该是false
,这步操作是在tabs组件完成
的,我们稍后再介绍。
既然要点击对应的标签页标题按钮,那应该有一个唯一的值来标识这个pane
,我们可以设置一个prop:name
让用户来设置,但它不是必需的。如果使用者不设置,可以默认从0开始自动设置
,这步操作仍然是tabs
执行的,因为pane
本身并不知道自己是第几个。除了name
,还需要标签页标题prop:label
, tabs
组件需要将它显示在标签页标题里,。这部分代码如下:
- pane.js-----------标签页嵌套的组件 pane
Vue.component("pane", {
name: "pane",
template: `
<div class="pane" v-show="show">
<slot></slot>
</div>
`,
data() {
return {
show: true,
};
},
props: {
name: {
type: String,
},
label: {
type: String,
default: "",
},
},
});
上面的prop:label
用户是可以动态调整的,所以在pane
初始化及label
更新时,都要通知父组件也更新,因为是独立组件,所以不能依赖像bus.js
或vuex
这样的状态管理办法,我们可以直接通过this.$parent
访问tabs
组件的实例来调用它的方法更新标题,该方法名暂定为updateNav
。注意,在业务中尽可能不要使用$parent 来操作父链,这种方法适合于标签页这样的独立组件。分代码如下:
- pane.js-----------标签页嵌套的组件 pane
Vue.component("pane", {
name: "pane",
template: `
<div class="pane" v-show="show">
<slot></slot>
</div>
`,
data() {
return {
show: true,
};
},
props: {
name: {
type: String,
},
label: {
type: String,
default: "",
},
},
methods: {
updateNav() {
this.$parent.updateNav();
},
},
watch: {
label() {
this.updateNav();
},
},
mounted() {
this.updateNav();
},
});
在生命周期mounted
,也就是pane
初始化时,调用一遍tabs
的updateNav
方法,同时监听了prop:label
, 在label
更新时,同样调用。
剩余任务就是完成tabs.js
组件。
首先需要把pane
组件设置的标题动态渲染出来,也就是当pane触发tabs的updateNav
方法时,更新标题内容。我们先看一下这部分的代码:
- tabs.js-----------标签页外层的组件 tabs
Vue.component("tabs", {
template: `
<div class="tabs">
<div class="tabs-bar">
<!--标签页标题,这里要用v-for-->
</div>
<div class="tabs-content">
<!--这里的slot就是嵌套的pane -->
<slot></slot>
</div>
</div>
`,
data() {
return {
// 用于渲染tabs的标题
navList: [],
};
},
methods: {
getTabs() {
return this.$children.filter((item) => {
return item.$options.name === "pane";
});
},
updateNav() {
this.navList = [];
// 设置对this的引用,在function回调里,this指向的并不是Vue实例
let that = this;
this.getTabs().forEach((pane, index) => {
that.navList.push({
label: pane.label,
name: pane.name || index,
});
// 如果没有给pane设置name默认设置它的索引
if (!pane.name) pane.name = index;
// 设置当前选中的 ab 的索引,在后面介绍
if (index === 0) {
if (!that.currentValue) {
that.currentValue = pane.name || index;
}
}
});
this.updateStatus();
},
updateStatus() {
let tabs = this.getTabs();
let that = this;
// 显示当前选中的tab对应的pane组件,隐藏没有选中的
tabs.forEach((tab) => {
return (tab.show = tab.name === that.currentValue);
});
},
},
});
getTabs
是一个公用的方法
,使用this.$children
来拿到所有的pane组件实例
。
需要注意的是,在methods
里使用了有function回调的方法时(例如遍历数组的方法forEach)
,在回调内的this不再执行当前的Vue实例
,也就是tabs组件本身
,所以要在外层设置一个that = this
的局部变量来间接使用this
。如果你熟悉ES2015
, 也可以直接使用箭头函数=>
,我们会在实战篇里介绍相关的用法。
遍历了每一个pane组件
后,把它的label
和name
提取出来,构成一一个Object
并添加到数据navList
数组里,后面我们会在template
里用到它。
设置完navList
数组后,我们调用了updateStatus
方法,又将pane
组件遍历了一遍,不过这时是为了将当前选中的tab对应的pane组件内容显示出来
,把没有选中的隐藏掉
。因为在上一步操作里,我们有可能需要设置currentValue来标识当前选中项
的name(在用户没有设置value时,才会自动设置)
,所以必须要遍历2次才可以
。
拿到navList
后,就需要对它用v-for
指令把tab
的标题渲染出来,并且判断每个tab
当前的状态。这部分代码如下:
Vue.component("tabs", {
template: `
<div class="tabs">
<div class="tabs-bar">
<div
:class="tabCls(item)"
v-for="(item, index) in navList"
@click="handleChange(index)"
>
{{ item.label }}
</div>
</div>
<div class="tabs-content">
<slot></slot>
</div>
</div>
`,
props: {
// 这里的 value 是为了可以使用 model
value: [String, Number],
},
data() {
return {
// 因为不能修改飞ralue ,所以复制一份自己维护
currentValue: this.value,
navList: [],
};
},
methods: {
tabCls(item) {
return [
"tabs-tab",
{
// 给当前选中的tab加一个class
"tabs-tab-active": item.name === this.currentValue,
},
];
},
// 点击 tab 标题时触发
handleChange(index) {
let nav = this.navList[index];
let name = nav.name;
// 改变当前选中的 tab ,并触发下面的 watch
this.currentValue = name;
// 更新 value
this.$emit("on-click", name);
},
updateStatus() {
let tabs = this.getTabs();
let that = this;
// 显示当前选中的tab对应的pane组件,隐藏没有选中的
tabs.forEach((tab) => {
return (tab.show = tab.name === that.currentValue);
});
},
},
watch: {
value(val) {
this.currentValue = val;
},
currentValue() {
// 在当前选中的tab发生变化时,更新pane的显示状态
this.updateStatus();
},
},
});
在使用v-for
指令循环显示tab标题时
,使用v-bind:class
指向了一个名为tabCls的methods
来动态设置class
名称。因为计算属性不能接收参数
,无法知道当前tab是否是选中
的,所以这里我们才用到methods
,不过要知道,methods是不缓存
的,可以回顾关于计算属性的章节
。
点击每个tab标题
时,会触发handleChange方法来改变当前选中tab的索引
,也就是pane组件的name
。在watch选项里,我们监听了currentValue
, 当其发生变化时
,触发updateStatus方法
来更新pane组件的显示状态
。
以上就是标签页组件的核心代码分解。总结一下该示例的技术难点:使用了组件嵌套的方式,将一系列pane组件作为tabs组件的slot; tabs 组件和pane组件通信上,使用了$parent和$children的方法访问父链和子链;定义了prop: value和data: currentValue, 使用$emit(input)来实现v-model的用法
。
以下是标签页组件的完整代码:
- index.html--------入口文件
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>示例</title>
<link rel="stylesheet" type="text/css" href="style.css" />
</head>
<body>
<div id="app" v-cloak>
<tabs v-model="activeKey">
<pane label="标签一" name="1">
标签一内容
</pane>
<pane label="标签二" name="2">
标签二内容
</pane>
<pane label="标签三" name="3">
标签三内容
</pane>
</tabs>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script src="pane.js"></script>
<script src="tabs.js"></script>
<script type="text/javascript">
var app = new Vue({
el: "#app",
data() {
return {
activeKey: "1",
};
},
});
</script>
</body>
</html>
- style.css---------样式表
[v-cloak] {
display: none;
}
.tabs {
font-size: 14px;
color: #657180;
}
.tabs-bar:after {
content: "";
display: block;
width: 100%;
height: 1px;
background: #d7dde4;
margn-top: -1px;
}
.tabs-tab {
display: inline-block;
padding: 4px 16px;
margin-right: 6px;
background: #fff;
border: 1px solid #d7dde4;
cursor: pointer;
position: relative;
}
.tabs-tab-active {
color: #3399ff;
border-top: 1px solid #3399ff;
border-bottom: 1px solid #fff;
}
.tabs-tab-active:before {
content: "";
display: block;
height: 1px;
background: #3399ff;
position: absolute;
top: 0;
left: 0;
right: 0;
}
.tabs-content {
padding: 8px 0;
}
- tabs.js-----------标签页外层的组件 tabs
Vue.component("tabs", {
template: `
<div class="tabs">
<div class="tabs-bar">
<div
:class="tabCls(item)"
v-for="(item, index) in navList"
@click="handleChange(index)"
>
{{ item.label }}
</div>
</div>
<div class="tabs-content">
<slot></slot>
</div>
</div>
`,
props: {
// 这里的 value 是为了可以使用 model
value: [String, Number],
},
data() {
return {
// 因为不能修改飞ralue ,所以复制一份自己维护
currentValue: this.value,
navList: [],
};
},
methods: {
tabCls(item) {
return [
"tabs-tab",
{
// 给当前选中的tab加一个class
"tabs-tab-active": item.name === this.currentValue,
},
];
},
getTabs() {
return this.$children.filter((item) => {
return item.$options.name === "pane";
});
},
updateNav() {
this.navList = [];
// 设置对this的引用,在function回调里,this指向的并不是Vue实例
let that = this;
this.getTabs().forEach((pane, index) => {
that.navList.push({
label: pane.label,
name: pane.name || index,
});
// 如果没有给pane设置name默认设置它的索引
if (!pane.name) pane.name = index;
// 设置当前选中的 ab 的索引,在后面介绍
if (index === 0) {
if (!that.currentValue) {
that.currentValue = pane.name || index;
}
}
});
this.updateStatus();
},
updateStatus() {
let tabs = this.getTabs();
let that = this;
// 显示当前选中的tab对应的pane组件,隐藏没有选中的
tabs.forEach((tab) => {
return (tab.show = tab.name === that.currentValue);
});
},
// 点击 tab 标题时触发
handleChange(index) {
let nav = this.navList[index];
let name = nav.name;
// 改变当前选中的 tab ,并触发下面的 watch
this.currentValue = name;
// 更新 value
this.$emit("on-click", name);
},
},
watch: {
value(val) {
this.currentValue = val;
},
currentValue() {
// 在当前选中的tab发生变化时,更新pane的显示状态
this.updateStatus();
},
},
});
- pane.js-----------标签页嵌套的组件 pane
Vue.component("pane", {
name: "pane",
template: `
<div class="pane" v-show="show">
<slot></slot>
</div>
`,
data() {
return {
show: true,
};
},
props: {
name: {
type: String,
},
label: {
type: String,
default: "",
},
},
methods: {
updateNav() {
this.$parent.updateNav();
},
},
watch: {
label() {
this.updateNav();
},
},
mounted() {
this.updateNav();
},
});
衍生拓展练习:
- 练习 1:
给pane组件新增一个prop:closable的布尔值,来支持是否可以关闭这个pane, 如果开启,在tabs的标签标题上会有一个关闭的按钮。
注意:在初始化 pane 时, 我们是在 mounted 里通知的,关闭时,你会用到 beforeDestroy
修改代码如下:
- index.html--------入口文件
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>示例</title> <link rel="stylesheet" type="text/css" href="style.css" /> </head> <body> <div id="app" v-cloak> <tabs v-model="activeKey" @on-click="handleOnclick" @on-close="handleOnclose" > <pane v-for="item in pans" :key="item.mes" :label="item.label" :name="item.name" :closable="item.closable" > {{item.mes}} </pane> </tabs> </div> <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script> <script src="pane.js"></script> <script src="tabs.js"></script> <script type="text/javascript"> var app = new Vue({ el: "#app", data() { return { activeKey: "1", pans: [ { label: "标签一", name: "1", closable: true, mes: "标签一的内容", }, { label: "标签二", name: "2", closable: true, mes: "标签二的内容", }, { label: "标签三", name: "3", closable: true, mes: "标签三的内容", }, ], }; }, methods: { handleOnclick(name) { this.activeKey = name; }, handleOnclose(name) { var index = 0; for (var i = 0; i < this.pans.length; i++) { if (this.pans[i].name === name) { index = i; break; } } this.pans.splice(index, 1); }, }, }); </script> </body> </html>
- tabs.js-----------标签页外层的组件 tabs
Vue.component("tabs", { template: ` <div class="tabs"> <div class="tabs-bar"> <div :class="tabCls(item)" v-for="(item, index) in navList" @click="handleChange(index)" > {{ item.label }} <span class="del" v-show="item.name === currentValue" @click="handleClose(item,index)">x</span> </div> </div> <div class="tabs-content"> <slot></slot> </div> </div> `, props: { // 这里的 value 是为了可以使用 model value: [String, Number], }, data() { return { // 因为不能修改飞ralue ,所以复制一份自己维护 currentValue: this.value, navList: [], }; }, methods: { tabCls(item) { return [ "tabs-tab", { // 给当前选中的tab加一个class "tabs-tab-active": item.name === this.currentValue, }, ]; }, getTabs() { return this.$children.filter((item) => { return item.$options.name === "pane"; }); }, handleClose(item, index) { this.navList.splice(index, 1); //console.log(this.navList.length); var name = item.name; //console.log(name) this.handleChange(0); this.$emit("on-close", name); }, updateNav() { this.navList = []; // 设置对this的引用,在function回调里,this指向的并不是Vue实例 let that = this; this.getTabs().forEach((pane, index) => { that.navList.push({ label: pane.label, name: pane.name || index, closable: pane.closable, }); // 如果没有给pane设置name默认设置它的索引 if (!pane.name) pane.name = index; // 设置当前选中的 ab 的索引,在后面介绍 if (index === 0) { if (!that.currentValue) { that.currentValue = pane.name || index; } } }); this.updateStatus(); }, updateStatus() { let tabs = this.getTabs(); let that = this; // 显示当前选中的tab对应的pane组件,隐藏没有选中的 tabs.forEach((tab) => { return (tab.show = tab.name === that.currentValue); }); }, // 点击 tab 标题时触发 handleChange(index) { let nav = this.navList[index]; let name = nav.name; // 改变当前选中的 tab ,并触发下面的 watch this.currentValue = name; // 更新 value this.$emit("on-click", name); }, }, watch: { value(val) { this.currentValue = val; }, currentValue() { // 在当前选中的tab发生变化时,更新pane的显示状态 this.updateStatus(); }, }, });
- 练习 2:
尝试在切换pane显示与隐藏时,使用滑动的动画。提示:可以使用CSS3的transform:translateX
- 在template中,用transition组件包裹住 div 元素,设置 name 和mode 特性。
- 在style中,添加 .pane等 class。其实 类 pane在书中已经有设置,只是未填写具体内容,算是作者给的一个提示线索吧。
修改代码如下:
- pane.js-----------标签页嵌套的组件 pane
Vue.component('pane', { name: 'pane', template: ` <transition name="fade" mode="out-in"> <div class="pane" v-show="show"> <slot></slot> </div> </transition> `, data() { return { show: true } }, props: { name: { type: String, }, label: { type: String, default: '' } }, methods: { updateNav() { this.$parent.updateNav() } }, watch: { label() { this.updateNav() } }, mounted() { this.updateNav() } })
style.css---------样式表
[v-cloak] { display: none; } .tabs { font-size: 14px; color: #657180; } .tabs-bar:after { content: ""; display: block; width: 100%; height: 1px; background: #d7dde4; margn-top: -1px; } .tabs-tab { display: inline-block; padding: 4px 16px; margin-right: 6px; background: #fff; border: 1px solid #d7dde4; cursor: pointer; position: relative; } .tabs-tab-active { color: #3399ff; border-top: 1px solid #3399ff; border-bottom: 1px solid #fff; } .tabs-tab-active:before { content: ""; display: block; height: 1px; background: #3399ff; position: absolute; top: 0; left: 0; right: 0; } .tabs-content { padding: 8px 0; } .pane { display: inline-block; } .fade-enter-active, .fade-leave-active { position: absolute; transition: all 0.8s ease; } .fade-enter, .fade-leave-to { transform: translateX(100px); opacity: 0; }