本篇目录:
组件(Component)是Vue.js最核心的功能,也是整个框架设计最精彩的地方,当然也是最难掌握的。
本章将带领你由浅入深地学习组件的全部内容,并通过几个实战项目熟练使用Vue组件。
7.1 组件与复用
7.1.1 为什么使用组件
在正式介绍组件前,我们先来看一个简单的场景,如图7-1所示:
图7-1中是一个很常见的聊天界面,有一些标准的控件,比如右上角的关闭按钮、输入框、发送按钮等。
你可能要问了,这有什么难的,不就是几个<div>
、<input>
吗?
好,那现在需求升级了,这几个控件还有别的地方要用到。
没问题,复制粘贴呗。
那如果输入框要带数据验证,按钮的图标支持自定义呢?
这样用JavaScript封装后一起复制吧。
那等到项目快完结时,产品经理说,所有使用输入框的地方,都要改成支持回车键提交。
好吧,给我一天的事件,我一个一个加上去。
上面的需求虽然有点变态,但却是业务中很常见的,那就是一些控件、JavaScript能力的复用。
没错,Vue.js的组件就是提高重用性的,让代码可重用。
当学习完组件后,上面的问题就可以分分钟搞定了,再也不用害怕铲平经理的奇葩需求。
我们先看一下图7-1中的示例用组件来编写是怎么的,示例代码如下:
1 <Card style="width:350px;"> 2 <p slot="title">与 xxx 聊天中</p> 3 <a href="#" slot="extra"> 4 <Icon type="android-close" size="18"></Icon> 5 </a> 6 <div style="height:100px;"></div> 7 <div> 8 <Row :gutter="16"> 9 <i-col span="17"> 10 <i-input v-model="value" placeholder="请输入..."></i-input> 11 </i-col> 12 <i-col span="4"> 13 <i-button v-model="primary" icon="paper-airplane">发送</i-button> 14 </i-col> 15 </Row> 16 </div> 17 </Card>
是不是很奇怪,有很多我们从来都没有见过的标签,比如<Card>
、<Row>
、<i-col>
、<input>
和<i-button>
等。
而且整段代码除了内联的几个样式外,一句CSS代码也没有,但最终实现的UI就是图7-1的效果。
这些没见过的自定义标签就是组件,每个标签代表一个组件,在任何使用Vue的地方都可以直接使用。
接下来,我们就看看组件的具体用法。
7.1.2 组件用法
回顾一下我们创建Vue实例的方法:
1 var app = new Vue({ 2 el: "#app" 3 });
组件与之类似,需要注册之后才可以使用。注册有全局注册和局部注册两种方式。
全局注册后,任何Vue实例都可以使用。全局注册示例代码如下:
1 Vue.component("my-component", { 2 // 选项 3 });
my-component
就是注册的组件自定义标签名称,推荐使用小写加减号分割的形式命名。
要在父实例中使用这个组件,必须要在实例创建前注册。
之后就可以用<my-component></my-component>
的形式来使用组件了。
实例代码如下:
1 <div id="app"> 2 <my-component></my-component> 3 </div> 4 5 <script> 6 Vue.component("my-component", { 7 // 选项 8 }); 9 10 var app = new Vue({ 11 el: "#app" 12 }); 13 </script>
此时打开页面还是空白的,因为我们注册的组件没有任何内容。
在组件选项中添加template
就可以显示组件内容了。
实例代码如下:
1 Vue.component("my-component", { 2 template: "<div>这里是组件的内容</div>" 3 });
渲染后的结果是:
1 <div id="app"> 2 <div>这里是组件的内容</div> 3 </div>
template
的DOM结构必须被一个元素包含,如果直接写成“这里是组件的内容”,不带<div></div>
是无法渲染的。
在Vue实例中,使用components
选项可以局部注册组件,注册后的组件只有在该实例作用域下有效。
组件也可以使用components
选项来注册组件,该组件可以嵌套。
示例代码如下:
1 <div id="app"> 2 <my-component></my-component> 3 </div> 4 5 <script> 6 var Child = { 7 template: "<div>局部注册组件的内容</div>" 8 }; 9 10 var app = new Vue({ 11 el: "#app", 12 components: { 13 "my-component": Child 14 } 15 }); 16 </script>
Vue组件的模板在某些情况下回收到HTML的限制,比如<table>
内规定只允许是<tr>
、<td>
、<th>
等这些表格元素,所以在<table>
内直接使用组件是无效的。
这种情况下,可以使用特殊的is
属性来挂载组件,示例代码如下:
1 <div id="app"> 2 <table> 3 <tbody is="my-component"></tbody> 4 </table> 5 </div> 6 7 <script> 8 Vue.component("my-component", { 9 template: "<div>这里是组件的内容</div>" 10 }); 11 12 var app = new Vue({ 13 el: "#app" 14 }); 15 </script>
<tbody>
在渲染时,会被替换为组件的内容。
常见的限制元素还有<ul>
、<ol>
、<select>
。
提示:
如果使用的是字符串模板,是不受限制的,比如后面章节介绍的.vue
单文件用法等。
除了template
选项外,组件还可以像Vue实例那样使用其他的选项,比如data
、computed
、method
等。
但是在使用data
时,和实例稍有区别,data
必须是函数,然后将数据return
出去。
例如:
1 <div id="app"> 2 <my-component></my-component> 3 </div> 4 5 <script> 6 Vue.component("my-component", { 7 template: "<div>{{message}}</div>", 8 data: function() { 9 return { 10 message: "组件内容" 11 }; 12 } 13 }); 14 15 var app = new Vue({ 16 el: "#app" 17 }); 18 </script>
JavaScript对象是引用关系,所以如果return
出的对象引用了外部的一个对象,那这个对象就是共享的,任何一方修改都会同步。
比如下面的示例:
1 <div id="app"> 2 <my-component></my-component> 3 <my-component></my-component> 4 <my-component></my-component> 5 </div> 6 7 <script> 8 var data = { 9 counter: 0 10 }; 11 12 Vue.component("my-component", { 13 template: "<button @click='counter++'>{{counter}}</button>", 14 data: function() { 15 return data; 16 } 17 }); 18 19 var app = new Vue({ 20 el: "#app" 21 }); 22 </script>
组件使用了3次,但是点击任意一个<button>
,3个按钮的数字都会加1。
那是因为组件的data
引用的是外部的对象,这肯定不是我们期望的效果。
所以给组件返回一个新的data
对象来独立,示例代码如下:
1 <div id="app"> 2 <my-component></my-component> 3 <my-component></my-component> 4 <my-component></my-component> 5 </div> 6 7 <script> 8 Vue.component("my-component", { 9 template: "<button @click='counter++'>{{counter}}</button>", 10 data: function() { 11 return { 12 counter: 0 13 }; 14 } 15 }); 16 17 var app = new Vue({ 18 el: "#app" 19 }); 20 </script>
这样,点击3个按钮就互不影响了,完全达到复用的目的。
7.2 使用props传递数据
7.2.1 基本用法
组件不仅仅是要把模板的内容进行复用,更重要的是组件间要进行通信。
通常父组件的模板中包含子组件,父组件要正向地向子组件传递数据或参数,子组件接收到后根据参数的不同来渲染不同的内容或执行操作。
这个正向传递数据的过程就是通过props
来实现的。
在组件中,使用选项props
来声明需要从父级接收的数据。props
的值可以是两种,一种是字符串数组,一种是对象,本小节先介绍数组的用法。
比如我们构造一个数组,接收一个来自父级的数据message
,并把它在组件模板中渲染,示例代码如下:
1 <div id="app"> 2 <my-component message="来自父组件的数据"></my-component> 3 </div> 4 5 <script> 6 Vue.component("my-component", { 7 props: ["message"], 8 template: "<div>{{message}}</div>" 9 }); 10 11 var app = new Vue({ 12 el: "#app" 13 }); 14 </script>
渲染后的结果为:
1 <div id="app"> 2 <div>来自父组件的数据</div> 3 </div>
props
中声明的数据与组件data
函数return
的数据主要区别就是props
的来自父级,而data
中的是组件自己的数据,作用域是组件本身,这两种数据都可以在模板template
及计算属性computed
和方法methods
中使用。
上例的数据message
就是通过props
从父级传递过来的,在组件的自定义标签上直接写该props
的名称,如果要传递多个数据,在props
数组中添加项即可。
由于HTML特性不区分大小写,当使用DOM模板时,驼峰命名(camelCase)的props
名称要转为短横分隔命名(kebab-case)。例如:
1 <div id="app"> 2 <my-component warning-text="提示信息"></my-component> 3 </div> 4 5 <script> 6 Vue.component("my-component", { 7 props: ["warningText"], 8 template: "<div>{{warningText}}</div>" 9 }); 10 11 var app = new Vue({ 12 el: "#app" 13 }); 14 </script>
提示:
如果使用的是字符串模板,仍然可以忽略这些限制。
有时候,传递的数据并不是直接写死的,而是来自父级的动态数据,这是可以使用指令v-bind
来动态绑定props
的值,当父组件的数据变化时,也会传递给子组件。示例代码如下:
1 <div id="app"> 2 <input type="text" v-model="parentMessage"> 3 <my-component :message="parentMessage"></my-component> 4 </div> 5 6 <script> 7 Vue.component("my-component", { 8 props: ["message"], 9 template: "<div>{{message}}</div>" 10 }); 11 12 var app = new Vue({ 13 el: "#app", 14 data: { 15 parentMessage: "" 16 } 17 }); 18 </script>
这里用v-model
绑定了父级的数据parentMessage
。
当通过输入框任意输入时,子组件接收的props
(message
)也会实时响应,并更新组件模板。
提示:
注意,如果你要直接传递数字、布尔值、数组、对象,而且不使用v-bind
,传递的仅仅是字符串,尝试下面的示例来对比。1 <div id="app"> 2 <my-component message="[1,2,3]"></my-component> 3 <my-component :message="[1,2,3]"></my-component> 4 </div> 5 <script> 6 Vue.component("my-component", { 7 props: ["message"], 8 template: "<div>{{message.length}}</div>" 9 }); 10 var app = new Vue({ 11 el: "#app" 12 }); 13 </script>同一个组件使用了两次,区别仅仅是第二个使用的是
v-bind
。渲染后的结果:第一个是7,第二个才是数组的长度3。
7.2.2 单向数据流
Vue 2.x与Vue 1.x比较大的一个改变就是,Vue 2.x通过props
传递数据是单向的了,也就是父组件数据变化时会传递给子组件,但是反过来不行。
而在Vue 1.x里提供了.sync
修饰符来支持双向绑定。
之所以这样设计,是尽可能将父子组件解耦,避免子组件无意中修改了父组件的状态。
业务中经常用到两种需要改变prop
的情况,一种是父组件传递初始值进来,子组件将它作为初始值保存起来,在自己的作用域下可以随意使用和修改。
何种情况可以在组件data
内再声明一个数据,引用父组件的prop
,实例代码如下:
1 <div id="app"> 2 <my-component :init-count="1"></my-component> 3 </div> 4 5 <script> 6 Vue.component("my-component", { 7 props: ["initCount"], 8 template: "<div>{{count}}</div>", 9 data: function() { 10 return { 11 count: this.initCount 12 }; 13 } 14 }); 15 16 var app = new Vue({ 17 el: "#app" 18 }); 19 </script>
组件中声明了数据count
,它在组件初始化时会获取来自父组件的initCount
,之后就与之无关了,只用维护count
,这样就可以避免直接操作initCount
。
另一种情况就是prop
作为需要转变的原始值传入。
这种情况用计算属性就可以了,示例代码如下:
1 <div id="app"> 2 <my-component :width="100"></my-component> 3 </div> 4 5 <script> 6 Vue.component("my-component", { 7 props: ["width"], 8 template: "<div :style='style'>组件内容</div>", 9 computed: { 10 style: function() { 11 return { 12 width: this.width + "px" 13 }; 14 } 15 } 16 }); 17 18 var app = new Vue({ 19 el: "#app" 20 }); 21 </script>
因为用CSS传递宽度要带单位(px),但是每次都写太麻烦,而且数值计算一般是不带单位的,所以统一在组件内使用计算属性就可以了。
提示:
注意,在JavaScript中对象和数组是引用类型,指向同一个内存空间,所以props
是对象和数组时,在子组件内改变时会影响父组件的。
7.2.3 数据验证
我们上面所介绍的props
选项的值都是一个数组,一开始也介绍过,除了数组外,还可以是对象,当prop
需要验证时,就需要对象写法。
一般当你的组件需要提供给别人使用时,推荐都进行数据验证。
比如某个数据必须是数字类型,如果传入字符串,就会在控制台弹出警告。
一下是几个prop
的示例:
1 Vue.component("my-component", { 2 props: { 3 // 必须是数字类型 4 propA: Number, 5 // 必须是字符串或数字类型 6 propB: [String, Number], 7 // 布尔值,如果没有定义,默认值就是true 8 propC: { 9 type: Boolean, 10 default: true 11 }, 12 // 数字,而且是必传 13 propD: { 14 type: Number, 15 required: true 16 }, 17 // 如果是数组或对象,默认值必须是一个函数来返回 18 propE: { 19 type: Array, 20 default: function() { 21 rturn[]; 22 } 23 }, 24 // 自定义一个验证函数 25 propF: { 26 validator: function(value) { 27 return value > 10; 28 } 29 } 30 } 31 });
验证的type
类型可以是:
- String
- Number
- Boolean
- Object
- Array
- Function
type
也可以是一个自定义构造器,使用instanceof
检测。
当prop
验证失败时,在开发版本下会在控制台抛出一条警告。
7.3 组件通信
我们已经知道,从父组件向子组件通信,通过props
传递数据就可以了。
但Vue组件通信的场景不止有这一种,归纳起来,组件之间通信可以用图7-2表示。
组件关系可分为父子组件通信、兄弟组件通信、跨级组件通信。
本节将介绍组中组件之间通信的方法。
7.3.1 自定义事件
当子组件需要向父组件传递数据时,就要用到自定义事件。
我们在介绍指令 v-on 时有提到,v-on
除了监昕DOM事件外,还可以用于组件之间的自定义事件。
如果你了解过JavaScript的设计模式一一观察者模式,一定知道dispatchEvent
和addEventListener
这两个方法。
Vue组件也有与之类似的一套模式,子组件用$emit()
来触发事件,父组件用$on()
来监昕子组件的事件。
父组件也可以直接在子组件的自定义标签上使用v-on
来监昕子组件触发的自定义事件,示例代码如下:
1 <div id="app"> 2 <p>总数:{{total}}</p> 3 <my-component @increase="handleGetTotal" @reduce="handleGetTotal"></my-component> 4 </div> 5 6 <script> 7 Vue.component("my-component", { 8 template: "<div><button @click='handleIncrease'>+1</button><button @click='handleReduce'>-1</button></div>", 9 data: function() { 10 return { 11 counter: 0 12 } 13 }, 14 methods: { 15 handleIncrease: function() { 16 this.counter++; 17 this.$emit("increase", this.counter); 18 }, 19 handleReduce: function() { 20 this.counter--; 21 this.$emit("reduce", this.counter); 22 } 23 } 24 }); 25 26 var app = new Vue({ 27 el: "#app", 28 data: { 29 total: 0 30 }, 31 methods: { 32 handleGetTotal: function(total) { 33 this.total = total; 34 } 35 } 36 }); 37 </script>
上面示例中,子组件有两个按钮,分别实现加1和减1的效果,在改变组件的data
(counter
)后,通过$emit()
再把它传递给父组件,父组件用v-on:increase
和v-on:reduce
(示例使用的是语法糖)。$emit()
方法的第一个参数是自定义事件的名称,例如示例的increase
和reduce
后面的参数都是要传递的数据,可以不填或填写多个。
除了用v-on
在组件上监听自定义事件外,也可以监听DOM事件,这时可以使用.native
修饰符表示监听的是一个原生事件,监听的是该组件的根元素,实例代码如下:
1 <my-component v-on:click.native="handleClick"></my-component>
7.3.2 使用v-model
Vue 2.x可以在自定义组件上使用v-model
指令,我们先来看一个示例:
1 <div id="app"> 2 <p>总数:{{total}}</p> 3 <my-component v-model="total"></my-component> 4 </div> 5 6 <script> 7 Vue.component("my-component", { 8 template: "<button @click='handleClick'>+1</button>", 9 data: function() { 10 return { 11 counter: 0 12 } 13 }, 14 methods: { 15 handleClick: function() { 16 this.counter++; 17 this.$emit("input", this.counter); 18 } 19 } 20 }); 21 var app = new Vue({ 22 el: "#app", 23 data: { 24 total: 0 25 } 26 }); 27 </script>
仍然是点击按钮加1的效果,不过这次组件$emit()
的事件名是特殊的input
,在使用组件的父级,并没有在<my-component>
上使用@input='handler'
,而是直接用了v-model
绑定的一个数据total
。
这也可以称作是一个语法糖,因为上面的示例可以间接地用自定义事件来实现:
1 <div id="app"> 2 <p>总数:{{total}}</p> 3 <my-component @input="handleGetTotal"></my-component> 4 </div> 5 6 <script> 7 Vue.component("my-component", { 8 template: "<button @click='handleClick'>+1</button>", 9 data: function() { 10 return { 11 counter: 0 12 } 13 }, 14 methods: { 15 handleClick: function() { 16 this.counter++; 17 this.$emit("input", this.counter); 18 } 19 } 20 }); 21 22 var app = new Vue({ 23 el: "#app", 24 data: { 25 total: 0 26 }, 27 methods: { 28 handleGetTotal: function(total) { 29 this.total = total; 30 } 31 } 32 }); 33 </script>
v-model
还可以用来创建自定义的表单输入组件,进行数据双向绑定,例如:
1 <div id="app"> 2 <p>总数:{{total}}</p> 3 <my-component v-model="total"></my-component> 4 <button @click="handleReduce">-1</button> 5 </div> 6 7 <script> 8 Vue.component("my-component", { 9 props: ["value"], 10 template: "<input :value='value' @input='updateValue'>", 11 methods: { 12 updateValue: function(event) { 13 this.$emit("input", event.target.value); 14 } 15 } 16 }); 17 18 var app = new Vue({ 19 el: "#app", 20 data: { 21 total: 0 22 }, 23 methods: { 24 handleReduce: function() { 25 this.total--; 26 } 27 } 28 }); 29 </script>
实现这样一个具有双向绑定的v-model
组件要满足下面两个要求:
- 接收一个
value
属性。 - 在有新的
value
时触发input
事件。
7.3.3 非父子组件通信
在实际业务中,除了父子组件通信外,还有很多非父子组件通信的场景,非父子组件一般有两种:兄弟组件和跨多级组件。
为了更加彻底地了解Vue.js 2.x中的通信方法,我们先来看一下在Vue.js 1.x中是如何实现的,这样便于我们了解Vue.js的设计思想。
在Vue.js 1.x中,除了$emit()
方法外,还提供了$dispatch()
和$broadcast()
这两个方法。$dispatch()
用于向上级派发事件,只要是它的父级(一般或多级以上),都可以在Vue实例的events
选项内接收,示例代码如下:
1 <!-- 注意:该示例需要使用Vue.js 1.x的版本 --> 2 <div id="app"> 3 {{message}} 4 <my-component></my-component> 5 </div> 6 7 <script> 8 Vue.component("my-component", { 9 template: "<button @click='handleDispatch'>源发事件</button>", 10 methods: { 11 handleDispatch: function() { 12 this.$dispatch("on-message", "来自内部组件的数据"); 13 } 14 } 15 }); 16 17 var app = new Vue({ 18 el: "#app", 19 data: { 20 message: "" 21 }, 22 methods: { 23 "on-message": function(msg) { 24 this.message = msg; 25 } 26 } 27 }); 28 </script>
同理,$broadcast()
是由上级向下级广播事件的,用法完全一致,只是方向相反。
这两种方法一旦发出事件后,任何组件都是可以接收到的,就近原则,而且会在第一次接收到后停止冒泡,除非返回true
。
这两个方法虽然看起来很好用,但是在Vue.js 2.x中都废弃了,因为基于组件树结构的事件流方式让人难以理解,并且在组件结构扩展的过程中会变得越来越脆弱,并且不能解决兄弟组件通信的问题。
在Vue.js 2.x中,推荐使用一个空的Vue实例作为中央事件总线(bus),也就是一个中介。
为了更形象地了解它,我们举一个生活中的例子。
比如你需要租房子,你可能会找房产中介来等级你的需求,然后中介把你的信息发给满足要求的出租者,出租者再把报价和看房时间告诉中介,由中介再转达给你。
整个过程中,买家和卖家并没有任何交流,都是通过中间人来传话的。
或者你最近可能要换房了,你会找房产中介登记你的信息,订阅与你找房需求相关的资讯。
一旦有符合你的房子出现时,中介会通知你,并传达你房子的具体信息。
这两个例子中,你和出租者担任的就是两个跨级的组件,而房产中介就是这个中央事件总线(bus)。
比如下面的示例代码:
1 <div id="app"> 2 {{message}} 3 <my-component-a></my-component-a> 4 </div> 5 6 <script> 7 var bus = new Vue(); 8 9 Vue.component("my-component-a", { 10 template: "<button @click='handleEvent'>传递事件</button>", 11 methods: { 12 handleEvent: function() { 13 bus.$emit("on-message", "来自组件 component-a 的内容"); 14 } 15 } 16 }); 17 18 var app = new Vue({ 19 el: "#app", 20 data: { 21 message: "" 22 }, 23 mounted: function() { 24 var _this = this; 25 // 在实例初始化时,监听来自bus示例的事件 26 bus.$on("on-message", function(msg) { 27 _this.message = msg; 28 }); 29 } 30 }); 31 </script>
首先创建了一个名为bus
的空Vue实例,里面没有任何内容;然后全局定义了组件component-a
;最后创建Vue实例app
。
在app
初始化时,也就是在生命周期mounted
钩子函数里监听了来自bus
的事件on-message
,
而在组件component-a
中,点击按钮会通过bus
把事件on-message
发出去,
此时app
就会接受到来自bus
的事件,进而在回调里完成自己的业务逻辑。
这种方法巧妙而清凉地实现了任何组件间的通信,包括父子、兄弟、跨级,而且Vue 1.x和Vue 2.x都适用。
如果深入使用,可以扩展bus
实例,给它添加data
、methods
、computed
等选项,这些都是可以公用的。
在业务中,尤其是协同开发时非常有用,因为经常需要共享一些通用的信息。
比如用户登录的昵称、性别、邮箱等,还有用户的授权token等,只需在初始化时让bus
获取一次,任何时间、任何组件就可以从中直接使用了,在单页面富应用(SPA)中会很实用,我们会在进阶篇中逐步介绍这些内容。
当你的项目比较大,有更多的小伙伴参与开发时,也可以你选择更好的状态管理解决方案vuex,在进阶篇里会详细介绍关于它的用法。
除了中央事件总线bus
外,还有两种方法可以实现组件间通信:父链和子组件索引。
父链
在子组件中,使用this.$parent
可以直接访问该组件的父实例或组件,父组件也可以通过this.$children
访问它所有的子组件,而且可以递归向上或向下无限访问,直到根实例或最内层的组件。
示例代码如下:
1 <div id="app"> 2 {{message}} 3 <my-component-a></my-component-a> 4 </div> 5 6 <script> 7 Vue.component("my-component-a", { 8 template: "<button @click='handleEvent'>通过父链直接修改数据</button>", 9 methods: { 10 handleEvent: function() { 11 // 访问到父链后,可以做任何操作,比如直接修改数据 12 this.$parent.message = "来自组件 component-a 的内容"; 13 } 14 } 15 }); 16 17 var app = new Vue({ 18 el: "#app", 19 data: { 20 message: "" 21 } 22 }); 23 </script>
尽管Vue允许这样操作,但在业务中,子组件应该尽可能地避免依赖父组件的数据,更不应该去主动修改它的数据,因为这样使得父子组件紧耦合,只看父组件,很难理解父组件的状态,因为它可能被任意组件修改,理想情况下,只有组件自己能修改它的状态。
父子组件最好还是通过props
和$emit()
来通信。
子组件索引
当子组件较多时,通过this.$children
来一一遍历我们需要的一个组件实例是比较困难的,尤其是组件动态渲染时,它们的序列是不固定的。Vue提供了子组件索引的方法,用特殊的属性ref
来为子组件指定一个索引名称。
示例代码如下:
1 <div id="app"> 2 <button @click="handleRef">通过ref获取子组件实例</button> 3 <component-a ref="comA"></component-a> 4 </div> 5 6 <script> 7 Vue.component("component-a", { 8 template: "<div>子组件</div>", 9 data: function() { 10 return { 11 message: "子组件内容" 12 }; 13 } 14 }); 15 16 var app = new Vue({ 17 el: "#app", 18 methods: { 19 handleRef: function() { 20 // 通过$refs来访问指定的实例 21 var msg = this.$refs.comA.message; 22 console.log(msg); 23 } 24 } 25 }); 26 </script>
在父组件模板中,子组件标签上使用ref
指定一个名称,并在父组件内通过this.$refs
来访问指定名称的子组件。
提示:
\(refs只在组件渲染完成后才填充,并且它是非响应式的。
它仅仅作为一个直接访问子组件的应急方案,应当避免在模板或计算属性中使用\)refs。
与Vue 1.x不同的是,Vue 2.x将v-el
和v-ref
合并为了ref
,Vue会自动去判断是普通标签还是组件。
可以尝试补全下面的代码,分别打印出两个ref
看看都是什么:
1 <div id="app"> 2 <p ref="p">内容</p> 3 <child-component ref="child"></child-component> 4 </div>
7.4 使用slot分发内容
7.4.1 什么是slot
我们先看一个比较常规的网站布局,如图7-3所示。
这个网站由一级导航、二级导航、左侧列表、正文以及底部版权信息5个模块组成。
如果要将它们都组件化,这个结构可能会是:
1 <app> 2 <menu-main></menu-main> 3 <menu-sub></menu-sub> 4 <div class="container"> 5 <menu-left></menu-left> 6 <container></container> 7 </div> 8 <app-footer></app-footer> 9 </app>
当需要让组件组合使用,混合父组件的内容与子组件的模板时,就会用到slot
,这个过程叫做内容分发(transclusion)。
以<app>
为例,它有两个特点:
<app>
组件不知道它的挂载点会有什么内容。挂载点的内容是由<app>
的父组件决定的。<app>
组件很可能有它自己的模板。
props
传递数据、events
触发事件和slot
内容分发就构成了Vue组件的3个API来源,再复杂的组件也是由这3部分构成的。
7.4.2 作用域
正式介绍slot
前,需要先知道一个概念:编译的作用域。
比如父组件有如下模板:
1 <child-component> 2 {{message}} 3 </child-component>
这里的message
就是一个slot
。
但是它绑定的是父组件的数据,而不是组件<child-component>
的数据。
父组件模板的内容是在父组件作用域内编译,子组件模板的内容是在子组件作用域内编译。
例如下面的代码示例:
1 <div id="app"> 2 <child-component v-show="showChild"></child-component> 3 </div> 4 5 <script> 6 Vue.component("child-component", { 7 template: "<div>子组件</div>" 8 }); 9 var app = new Vue({ 10 el: "#app", 11 data: { 12 showChild: true 13 } 14 }) 15 </script>
这里的状态showChild
绑定的是父组件的数据,如果想在子组件上绑定,那应该是:
1 <div id="app"> 2 <child-component></child-component> 3 </div> 4 5 <script> 6 Vue.component("child-component", { 7 template: "<div v-show='showChild'>子组件</div>", 8 data: function() { 9 return { 10 showChild: true 11 }; 12 } 13 }); 14 15 var app = new Vue({ 16 el: "#app" 17 }) 18 </script>
因此,slot
分发的内容,作用域是在父组件上的。
7.4.3 solt用法
单个Slot
在子组件内使用特殊的<slot>
元素就可以为这个子组件开启一个slot(插槽),在父组件模板里,插入子组件标签内的所有内容将替代子组件的<slot>
标签及它的内容。
示例代码如下:
1 <div id="app"> 2 <child-component> 3 <p>分发的内容</p> 4 <p>更多分发的内容</p> 5 </child-component> 6 </div> 7 8 <template id="template"> 9 <div> 10 <slot> 11 <p>如果父组件没有插入内容,我将作为默认出现</p> 12 </slot> 13 </div> 14 </template> 15 16 <script> 17 Vue.component("child-component", { 18 template: "#template" 19 }); 20 var app = new Vue({ 21 el: "#app" 22 }) 23 </script>
子组件child-component
的模板内定义了一个<slot>
元素,并且用一个<p>
作为默认的内容,在父组件没有使用slot
时,会渲染这段默认的文本;如果写入了slot
,那就回替换整个<slot>
。
所以上例渲染后的结果为:
1 <div id="app"> 2 <div> 3 <p>分发的内容</p> 4 <p>更多分发的内容</p> 5 </div> 6 </div>
提示:
注意,子组件<slot>
内的备用内容,它的作用域是子组件本身。
具名Slot
给<slot>
元素指定一个name
后可以分发多个内容,具名Slot可以与单个Slot共存。
例如下面的示例:
1 <div id="app"> 2 <child-component> 3 <h2 slot="header">标题</h2> 4 <p>正文内容</p> 5 <p>更多的正文内容</p> 6 <div slot="footer">底部信息</div> 7 </child-component> 8 </div> 9 10 <template id="template"> 11 <div class="container"> 12 <div class="header"> 13 <slot name="header"></slot> 14 </div> 15 <div class="main"> 16 <slot></slot> 17 </div> 18 <div class="footer"> 19 <slot name="footer"></slot> 20 </div> 21 </div> 22 </template> 23 24 <script> 25 Vue.component("child-component", { 26 template: "#template" 27 }); 28 var app = new Vue({ 29 el: "#app" 30 }) 31 </script>
子组件内声明了3个<slot>
元素,其中在<div name="main">
内的<slot>
没有使用name
特性,它将作为默认slot出现,父组件没有使用slot特性的元素与内容都将会出现在这里。
如果没有指定默认的匿名slot,父组件内多余的内容片断都将被抛弃。
上例最终渲染后的结果为:
1 <div id="app"> 2 <div class="container"> 3 <div class="header"> 4 <h2>标题</h2> 5 </div> 6 <div class="main"> 7 <p>正文内容</p> 8 <p>更多的正文内容</p> 9 </div> 10 <div class="footer"> 11 <div>底部信息</div> 12 </div> 13 </div> 14 </div>
在组合使用组件时,内容分发API至关重要。
7.4.4 作用域插槽
作用域插槽是一种特殊的slot,使用一个可以复用的模板替换已渲染元素。
概念比较难理解,我们先看一个简单的示例来了解它的基本用法。
示例代码如下:
1 <div id="app"> 2 <child-component> 3 <template scope="props"> 4 <p>来自父组件的内容</p> 5 <p>{{props.msg}}</p> 6 </template> 7 </child-component> 8 </div> 9 10 <template id="template"> 11 <div class="container"> 12 <slot msg="来自子组件的内容"></slot> 13 </div> 14 </template> 15 16 <script> 17 Vue.component("child-component", { 18 template: "#template" 19 }); 20 var app = new Vue({ 21 el: "#app" 22 }) 23 </script>
观察子组件的模板,在<slot>
元素上有一个类似props
传递数据给组件的写法msg="xxx"
,数据传到了插槽。
父组件中使用了<template>
元素,而且拥有一个scope="props"
的特性,这里的props
只是一个临时变量,就像v-for="item in items"
里面的item
一样。template
内可以通过临时变量props
访问来自子组件插槽的数据msg
。
将上面的示例渲染后的最终结果为:
1 <div id="app"> 2 <div class="container"> 3 <p>来自父组件的内容</p> 4 <p>来自子组件的内容</p> 5 </div> 6 </div>
作用域插槽根据代表性的用力是列表组件,允许组件自定义应该如何渲染列表每一项。
示例代码如下:
1 <div id="app"> 2 <my-list v-bind:books="books"> 3 <template slot="book" scope="props"> 4 <li>{{props.bookName}}</li> 5 </template> 6 </my-list> 7 </div> 8 9 <template id="template"> 10 <ul> 11 <slot name="book" v-for="book in books" v-bind:book-name="book.name"> 12 <!-- 这里也可以写默认slot内容 --> 13 </slot> 14 </ul> 15 </template> 16 17 <script> 18 Vue.component("my-list", { 19 props: { 20 books: { 21 type: Array, 22 default: function() { 23 return {}; 24 } 25 } 26 }, 27 template: "#template" 28 }); 29 30 var app = new Vue({ 31 el: "#app", 32 data: { 33 books: [ 34 {name:"《Vue.js实战》"}, 35 {name:"《JavaScript语言精粹》"}, 36 {name:"《JavaScript高级程序设计》"} 37 ] 38 } 39 }); 40 </script>
子组件my-list
接收一个来自父级的prop
数组books
,并且将它在name
为book
的slot
上使用v-for
指令循环,同时暴露一个变量bookName
。
如果你仔细揣摩上面的用法,你可能会产生这样的疑问:我直接在父组件用v-for
不就好了吗?为什么还要绕一步,在子组件里面循环呢?
的确,如果只是针对上面的示例,这样写是多此一举的。此例的用意主要是介绍作用域插槽的用法,并没有加入使用场景,而作用域插槽的适用场景就是既可以复用子组件的slot,又可以使slot内容不一致。
如果上例还在其他组件内使用,<li>
的内容渲染权是由使用者掌握的,而数据却可以通过临时变量(比如props
)子组件内获取。
7.4.5 访问slot
在Vue.js 1.x中,想要获取某个slot是比较麻烦的,需要用v-el
间接获取。
而Vue.js 2.x提供了用来访问被slot分发的内容的方法$slots
,请看下面的示例:
1 <div id="app"> 2 <child-component> 3 <h2 slot="header">标题</h2> 4 <p>正文内容</p> 5 <p>更多的正文内容</p> 6 <div slot="footer">底部信息</div> 7 </child-component> 8 </div> 9 10 <template id="template"> 11 <div class="container"> 12 <div class="header"> 13 <slot name="header"></slot> 14 </div> 15 <div class="main"> 16 <slot></slot> 17 </div> 18 <div class="footer"> 19 <slot name="footer"></slot> 20 </div> 21 </div> 22 </template> 23 24 <script> 25 Vue.component("child-component", { 26 template: "#template", 27 mounted: function() { 28 var header = this.$slots.header; 29 var main = this.$slots.main; 30 var footer = this.$slots.footer; 31 console.log(footer); 32 console.log(footer[0].elm.innerHTML); 33 } 34 }); 35 36 var app = new Vue({ 37 el: "#app" 38 }); 39 </script>
通过$slots
可以访问某个具名slot,this.$slots.default
包括了所有没有被包含在具名slot中的节点。
尝试编写代码,查看两个console打印的内容。
$slots
在业务中几乎用不到,在用render
函数(进阶篇中将介绍)创建组件时会比较有用,但主要还是用于独立组件开发中。
7.5 组件高级用法
本节会介绍组件的一些高级用法,这些用法在实际业务中不是很常用,但会独立组件开发时可能会用到。
如果你感觉以上内容已经足骨完成你的业务开发了,可以跳过本节;如果你想继续探索Vue组件的奥秘,读完本节会对你有很大的启发。
7.5.1 递归组件
组件在它的模板内可以递归地调用自己,只要给组件设置name
的选项就可以了。
示例代码如下:
1 <div id="app"> 2 <child-component v-bind:count="1"></child-component> 3 </div> 4 5 <template id="template"> 6 <div class="child"> 7 <child-component v-bind:count="count+1" v-if="count<3"></child-component> 8 </div> 9 </template> 10 11 <script> 12 Vue.component("child-component", { 13 name: "child-component", 14 props: { 15 count: { 16 type: Number, 17 default: 1 18 } 19 }, 20 template: "#template" 21 }); 22 23 var app = new Vue({ 24 el: "#app" 25 }); 26 </script>
设置name
后,在组件模板内就可以递归使用了。
不过需要注意的是,必须给一个条件来限制递归数量,否则会抛出错误:max stack size exceeded
。
组件递归使用可以用来开发一些具有未知层级关系的阻力组件,比如级联选择器和树形控件等。
如图7-4和图7-5所示:

在实战篇里,我们会详细介绍级联选择器的实现。
7.5.2 内联模板
组件的模板一般都是在template
选项内自定义的,Vue提供了一个内联模板的功能,在使用组件时,给组件标签使用inline-template
特性,组件就会把它的内容当做模板,而不是把它当内容分发,这让模板更灵活。
示例代码如下:
1 <div id="app"> 2 <child-component inline-template :data="message"> 3 <div> 4 <h2>在父组件中定义了组件的模板</h2> 5 <p>{{data}}</p> 6 <p>{{msg}}</p> 7 </div> 8 </child-component> 9 </div> 10 11 <script> 12 Vue.component("child-component", { 13 props: ["data"], 14 data: function() { 15 return { 16 msg: "在子组件声明的数据" 17 }; 18 } 19 }); 20 21 var app = new Vue({ 22 el: "#app", 23 data: { 24 message: "在父组件声明的数据" 25 } 26 }); 27 </script>
渲染后的结果为:
1 <div id="app"> 2 <div> 3 <h2>在父组件中定义了组件的模板</h2> 4 <p>在父组件声明的数据</p> 5 <p>在子组件声明的数据</p> 6 </div> 7 </div>
7.5.3 动态组件
Vue.js提供了一个特殊的元素<component>
用来动态地挂载不同的组件,使用is
特性来选择要挂载的组件。
示例代码如下:
1 <div id="app"> 2 <input type="text" placeholder="请输入组件名称: A/B/C"> 3 <button @click="handleChangeView">确定切换</button> 4 <component v-bind:is="currentView"></component> 5 </div> 6 7 <script> 8 var app = new Vue({ 9 el: "#app", 10 components: { 11 comA: { 12 template: "<div>组件A</div>" 13 }, 14 comB: { 15 template: "<div>组件B</div>" 16 }, 17 comC: { 18 template: "<div>组件C</div>" 19 } 20 }, 21 data: { 22 currentView: "comA" 23 }, 24 methods: { 25 handleChangeView: function() { 26 var value = document.querySelector("input").value; 27 this.currentView = "com" + value; 28 } 29 } 30 }); 31 </script>
动态地改变currentView
的值就可以动态挂载组件了。
也可以直接绑定在组件对象上:
1 <div id="app"> 2 <component v-bind:is="currentView"></component> 3 </div> 4 5 <script> 6 var Home = { 7 template: "<p>Welcome home!</p>" 8 }; 9 var app = new Vue({ 10 el: "#app", 11 data: { 12 currentView: Home 13 } 14 }); 15 </script>
7.5.4 异步组件
当你的工程足够大,使用的组件足够多时,是时候考虑下性能的问题了,因为一开始把所有的组件都加载是没必要的一笔开销。
好在Vue.js允许将组件定义为一个工厂函数,动态地解析组件。
Vue.js值在组件需要渲染时触发工厂函数,并且把结果缓存起来,用于后面的再次渲染。
例如下面的示例:
1 <div id="app"> 2 <child-component></child-component> 3 </div> 4 5 <script> 6 Vue.component("child-component", function(resolve, reject) { 7 window.setTimeout(function() { 8 resolve({ 9 template: "<div>我是异步渲染的</div>" 10 }); 11 }, 2000); 12 }); 13 var app = new Vue({ 14 el: "#app" 15 }); 16 </script>
工厂函数接收一个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才会被创建,此时再去获取。
示例代码如下:
1 <div id="app"> 2 <div id="div" v-if="showDiv">这是一段文本</div> 3 <button v-on:click="getText">获取div内容</button> 4 </div> 5 6 <script> 7 var app = new Vue({ 8 el: "#app", 9 data: { 10 showDiv: false 11 }, 12 methods: { 13 getText: function() { 14 this.showDiv = true; 15 var text = document.getElementById("div").innerHTML; 16 console.log(text); 17 } 18 } 19 }); 20 </script>
这段代码并不难理解,但是运行后在控制台会抛出一个错误:Cannot read property 'innerHTML' of null
。意思就是获取不到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更新完成的,所以上面的示例代码需要修改为:
1 <div id="app"> 2 <div id="div" v-if="showDiv">这是一段文本</div> 3 <button v-on:click="getText">获取div内容</button> 4 </div> 5 6 <script> 7 var app = new Vue({ 8 el: "#app", 9 data: { 10 showDiv: false 11 }, 12 methods: { 13 getText: function() { 14 this.showDiv = true; 15 this.$nextTick(function() { 16 var text = document.getElementById("div").innerHTML; 17 console.log(text); 18 }); 19 } 20 } 21 }); 22 </script>
这时再点击按钮,控制台就打印出div的内容“这时一段文本了”。
理论上,我们应该不用去主动操作DOM,因为Vue的核心思想就是数据驱动DOM,但在很多业务里,我们避免不了会使用一些第三方库,比如popper.js、swiper等,这些基于原生JavaScript的库都有创建和更新及销毁的完整生命周期,与Vue配合使用时,就要利用好$nextTick
。
7.6.2 X-Templates
如果你没有使用webpack、gulp等工具,试想一下你的组件template
的内容很冗长、复杂,如果都在JavaScript里拼接字符串,效率是很低的,因为不能像写HTML那样舒服。
Vue提供了另一种定义模板的方式,在<script>
标签里使用text/x-template
类型,并且指定一个id
,将这个id
赋给template
。示例代码如下:
1 <div id="app"> 2 <my-component></my-component> 3 <script type="text/x-template" id="my-component"> 4 <div>这是组件的内容</div> 5 </script> 6 </div> 7 8 <script> 9 Vue.component("my-component", { 10 template: "#my-component" 11 }); 12 var app = new Vue({ 13 el: "#app" 14 }); 15 </script>
在<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()
手动地挂载一个未挂载的实例。
这个方法返回实例自身,因而可以链式调用其他实例方法。
示例代码如下:
1 <div id="mount-div"></div> 2 3 <script> 4 var MyComponent = Vue.extend({ 5 template: "<div>Hello: {{name}}</div>", 6 data: function() { 7 return { 8 name: "Jack" 9 }; 10 } 11 }); 12 13 new MyComponent().$mount("#mount-div"); 14 </script>
运行后,id
为mount-div
的div元素会被替换为组件MyComponent
的template
的内容:
1 <div>Hello: Jack</div>
除了这种写法外,以下两种写法也是可以的:
1 new MyComponent().$mount("#mount-div"); 2 // 同上 3 new MyComponent({ 4 el: "#mount-div" 5 }); 6 // 或者,在文档之外渲染并且随后挂载 7 var component = new MyComponent().$mount(); 8 document.getElementById("mount-div").appendChild(component.$el);
手动挂载实例(组件)是一种比较极端的高级用法,在业务中几乎用不到,只是开发一些复杂的独立组件时可能会使用,所以只做了解就好。
7.7 实战:两个常用组件的开发
本节以组件知识为基础,整合指令、事件等前面两章的内容,开发两个业务中常用的组件,即数字输入框和标签页。
7.7.1 开发一个数字输入框组件
数字输入框时对普通输入框的扩展,用来快捷输入一个标准的数字。
如图7-6所示:
数字输入框只能输入数字,而且有两个快捷按钮,可以直接减1或加1.
除此之外,还可以设置初始值、最大值、最小值,在数值改变时,触发一个自定义事件来通知父组件。
了解了基本需求后,我们先定义目录文件:
- index.html 入口页
- input-number.js 数字输入框组件
- index.js 根实例
因为该示例是以交互功能为主,所以就不写CSS美化样式了。
首先写入基本的结构代码,初始化项目。
index.html:
1 <!DOCTYPE html> 2 <html lang="zh"> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <meta http-equiv="X-UA-Compatible" content="ie=edge"> 7 <title>数字输入框组件</title> 8 </head> 9 <body> 10 <div id="app"></div> 11 12 <script src="vue.js"></script> 13 <script src="input-number.js"></script> 14 <script src="index.js"></script> 15 </body> 16 </html>
index.js:
1 var app = new Vue({ 2 el: "#app" 3 });
input-number.js:
1 Vue.component("input-number", { 2 template: "\ 3 <div class='input-number'> \ 4 \ 5 </div>", 6 props: { 7 max: { 8 type: Number, 9 default: Infinity 10 }, 11 min: { 12 type: Number, 13 default: -Infinity 14 }, 15 value: { 16 type: Number, 17 default: 0 18 } 19 } 20 });
该示例的主角是input-number.js
,所有的组件配置都在这里面定义。
现在template
里面定义了组件的根节点,因为是独立组件,所以应该对每个prop进行校验。
这里面根据需求有最大值、最小值、默认值(也就是绑定值)3个prop,max
和min
都是数字类型,默认值是正无限大和负无限大;value
也是数字类型,默认值是0。
接下来,我们先在父组件引入input-number
组件,并给它一个默认值5,最大值10,最小值0。
index.js:
1 var app = new Vue({ 2 el: "#app", 3 data: { 4 value: 5 5 } 6 });
index.html:
1 <div id="app"> 2 <input-number v-model="value" :max="10" :min="0"></input-number> 3 </div>
value
是一个关键的绑定至,所以用了v-model
,这样既优雅地实现了双向绑定,也让API看起来很合理。
大多数的表单类组件都应该有一个v-model
,比如输入框、单选框、多选框、下拉选择器等。
剩余的代码量就都聚焦到了input-number.js
上。
我们之前介绍过,Vue组件是单向数据流,所以无法从组件内部直接修改prop:value
的值。
解决办法也介绍过,就是给组件声明一个data
,默认引用value
的值,然后在组件内部维护这个data
:
1 Vue.component("input-number", { 2 // ... 3 data: function() { 4 return { 5 currentValue: this.value 6 }; 7 } 8 });
这样只解决了初始化时引用父组件value
的问题。
但是如果从父组件修改了value
,input-number组件的currentValue
也要一起更新。
为了实现这个功能,我们需要用到一个新的概念,监听(watch)。
watch
选项用来监听某个prop或data的改变,当他们发生变化时,就会触发watch
配置的函数,从而完成我们的业务逻辑。
在本例中,我们要监听两个量:value
和currentValue
。
监听value
是要知晓从父组件修改了value
,监听currentValue
是为了当currentValue
改变时,更新value
。
相关代码如下:
1 Vue.component("input-number", { 2 // ... 3 data: function() { 4 return { 5 currentValue: this.value 6 }; 7 }, 8 watch: { 9 currentValue: function(val) { 10 this.$emit("input", val); 11 this.$emit("on-change", val); 12 }, 13 value: function(val) { 14 this.updateValue(val); 15 } 16 }, 17 methods: { 18 updateValue: function(val) { 19 if (val > this.max) { 20 val = this.max; 21 } 22 23 if (val < this.min) { 24 val = this.min; 25 } 26 27 this.currentValue = val; 28 } 29 }, 30 mounted: function() { 31 this.updateValue(this.value); 32 } 33 });
从父组件传递过来的value
有可能是不符合当前条件的(大于max或小于min),所以在选项methods
里写了一个方法updateValue
,用来过滤出一个正确的currentValue
。
watch
监听的数据的回调函数有2个参数可用,第一个是新的智,第二个是旧的值,这里没有太复杂的逻辑,就只用了第一个参数。
再回调函数里,this
是指向当前组件实例的,所以可以直接调用this.updateValue()
,因为Vue代理了props
、data
、computed
及methods
。
监听currentValue
的回调里:
this.$emit("input", val)
是在使用v-model
时改变value
的;this.$emit("on-change", val)
是触发自定义事件on-change
,用于告知父组件数字输入框的值有所改变(示例中没有使用该事件〉。
在生命周期mounted
钩子里也调用了updateValue()
方法,是因为第一次初始化时,也对value
进行了过滤。
这里也有另一种写法,在data
选项返回对象前进行过滤:
1 Vue.component("input-number", { 2 // ... 3 data: function () { 4 var val = this.value; 5 6 if (val > this.max) { 7 val = this.max; 8 } 9 10 if (val < this.min) { 11 val = this.min; 12 } 13 14 return { 15 currentValue: val 16 }; 17 } 18 });
实现的效果是一样的。
最后剩余的就是补全模板template
,内容是一个输入框和两个按钮。
相关代码如下:
1 function isValueNumber(value) { 2 return (/(^-?[0-9]+\.(1)\d+$)|(^-?[1-9][0-9]*$)|(^-?0{1})/.test(value + "")); 3 } 4 5 Vue.component("input-number", { 6 // ... 7 template: "\ 8 <div class='input-number'> \ 9 <input type='text' :value='currentValue' @change='handleChange'/> \ 10 <button @click='handleDown' :disabled='currentValue<=min'>-</button> \ 11 <button @click='handleUp' :disabled='currentValue>=max'>+</button> \ 12 </div>", 13 methods: { 14 handleDown: function() { 15 if (this.currentValue <= this.min) { 16 return; 17 } 18 this.currentValue -= 1; 19 }, 20 handleUp: function() { 21 if (this.currentValue >= this.max) { 22 return; 23 } 24 this.currentValue += 1; 25 }, 26 handleChange: function(event) { 27 var val = event.target.value.trim(); 28 var max = this.max; 29 var min = this.min; 30 31 if (isValueNumber(val)) { 32 val = Number(val); 33 this.currentValue = val; 34 if (val > max) { 35 this.currentValue = max; 36 } else if (val < min) { 37 this.currentValue = min; 38 } 39 } else { 40 event.target.value = this.currentValue; 41 } 42 } 43 } 44 });
input绑定了数据currentValue
和原生的change事件,在句柄handleChange
函数中,判断了当前输入的是否是数字。
注意,这里绑定的currentValue
也是单向数据流,并没有用v-model
,所以在输入时,currentValue
的值并没有实时改变。
如果输入的不是数字(比如英文和汉字等〉,就将输入的内容重置为之前的currentValue。
currentValue`。
如果输入的是符合要求的数字,就把输入的值赋给
数字输入框组件的核心逻辑就是这些。
回顾一下我们设计一个通用组件的思路,首先,在写代码前一定要明确需求,然后规划好API。
一个Vue组件的API只来自props
、events
和slots
,确定好这3部分的命名、规则,剩下的逻辑即使第一版没有做好,后续也可以迭代完善。但是API如果没有设计好,后续再改对使用者成本就很大了。
完整的示例代码如下:
index.html:
1 <!DOCTYPE html> 2 <html lang="zh"> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <meta http-equiv="X-UA-Compatible" content="ie=edge"> 7 <title>数字输入框组件</title> 8 </head> 9 <body> 10 <div id="app"> 11 <input-number v-model="value" :max="10" :min="0"></input-number> 12 </div> 13 14 <script src="vue.js"></script> 15 <script src="input-number.js"></script> 16 <script src="index.js"></script> 17 </body> 18 </html>
index.js:
1 var app = new Vue({ 2 el: "#app", 3 data: { 4 value: 5 5 } 6 });
input-number.js:
1 function isValueNumber(value) { 2 return (/(^-?[0-9]+\.(1)\d+$)|(^-?[1-9][0-9]*$)|(^-?0{1})/.test(value + "")); 3 } 4 5 Vue.component("input-number", { 6 template: "\ 7 <div class='input-number'> \ 8 <input type='text' :value='currentValue' @change='handleChange'/> \ 9 <button @click='handleDown' :disabled='currentValue<=min'>-</button> \ 10 <button @click='handleUp' :disabled='currentValue>=max'>+</button> \ 11 </div>", 12 props: { 13 max: { 14 type: Number, 15 default: Infinity 16 }, 17 min: { 18 type: Number, 19 default: -Infinity 20 }, 21 value: { 22 type: Number, 23 default: 0 24 } 25 }, 26 data: function() { 27 return { 28 currentValue: this.value 29 }; 30 }, 31 watch: { 32 currentValue: function(val) { 33 this.$emit("input", val); 34 this.$emit("on-change", val); 35 }, 36 value: function(val) { 37 this.updateValue(val); 38 } 39 }, 40 methods: { 41 handleDown: function() { 42 if (this.currentValue <= this.min) { 43 return; 44 } 45 this.currentValue -= 1; 46 }, 47 handleUp: function() { 48 if (this.currentValue >= this.max) { 49 return; 50 } 51 this.currentValue += 1; 52 }, 53 handleChange: function(event) { 54 var val = event.target.value.trim(); 55 var max = this.max; 56 var min = this.min; 57 58 if (isValueNumber(val)) { 59 val = Number(val); 60 this.currentValue = val; 61 if (val > max) { 62 this.currentValue = max; 63 } else if (val < min) { 64 this.currentValue = min; 65 } 66 } else { 67 event.target.value = this.currentValue; 68 } 69 }, 70 updateValue: function(val) { 71 if (val > this.max) { 72 val = this.max; 73 } 74 75 if (val < this.min) { 76 val = this.min; 77 } 78 79 this.currentValue = val; 80 } 81 }, 82 mounted: function() { 83 this.updateValue(this.value); 84 } 85 });
- 练习1: 在输入框聚焦时,增加对键盘上下键的支持,相当于加1和减1;
- 练习2: 增加一个控制步伐的prop:step,比如设置为10,点击加号按钮,一次增加10。
7.7.2 开发一个标签组件
本小节将开发一个比较有挑战的组件:标签页组件。
标签页(即选项卡切换组件)是网页和布局中经常用到的元素,常用于平级区域大块内容的收纳和展现。
如图7-7所示:
根据上个示例的经验,我们先分析业务需求,制定出API,这样不至于一上来就无从下手。
每个标签页的主体内容肯定是由使用组件的父级控制的,所以这部分是一个slot,而且slot的数量决定了标签切换按钮的数量。
假设我们有3个标签页,点击每个标签按钮时,另外两个标签对应的slot应该被隐藏。
一般这个时候,比较容易想到的解决办法是,在slot里写3个div,在接收到切换通知时,显示和隐藏相关div。
这样设计没有问题,只不过提现不出组件的价值来,因为我们还是一些了一些与业务无关的业务逻辑,而这部分逻辑最好组件本身帮忙处理了,我们只用聚焦在slot内容本身,这才是我们业务最相关的。
这种情况下,我们在定义一个子组件panel,嵌套在标签页组件tabs里,我们的业务代码都放在panel的slot内,而3个panel组件作为整体成为tabs的slot。
由于tabs和panel两个组件是分离的,但是tabs组件上的标题应该由panel组件来定义,因为slot是卸载panel里,因此在组件初始化(及标签标题动态改变)时,tabs要从panel里获取标题,并保存起来,自己使用。
确定好了结构,我们先创建所需的文件:
- index.html 入口页
- style.css 样式表
- tabs.js 标签页外层的组件 tabs
- panel.js 标签页嵌套的组件 panel
先初始化各个文件:
index.html:
1 <!DOCTYPE html> 2 <html lang="zh"> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <meta http-equiv="X-UA-Compatible" content="ie=edge"> 7 <title>标签页组件</title> 8 <link rel="stylesheet" type="text/css" href="style.css"> 9 </head> 10 <body> 11 <div id="app"></div> 12 13 <script src="vue.js"></script> 14 <script src="panel.js"></script> 15 <script src="tabs.js"></script> 16 <script> 17 var app = new Vue({ 18 el: "#app" 19 }); 20 </script> 21 </body> 22 </html>
tabs.js:
1 Vue.component("tabs", { 2 template: "\ 3 <div class='tabs'> \ 4 <div class='tabs-bar'> \ 5 <!-- 标签页标题,这里要用v-for --> \ 6 </div> \ 7 <div class='tabs-content'> \ 8 <!-- 这里的slot就是嵌套的panel --> \ 9 <slot></slot> \ 10 </div> \ 11 </div>" 12 });
panel.js
1 Vue.component("panel", { 2 name: "panel", 3 template: "\ 4 <div class='panel'> \ 5 <slot></slot> \ 6 </div>" 7 });
panel需要控制标签页内容的显示与隐藏。
设置一个data:show
,并且用v-show
指令来控制元素:
1 Vue.component("panel", { 2 name: "panel", 3 template: "\ 4 <div class='panel' v-show='show'> \ 5 <slot></slot> \ 6 </div>", 7 data: function() { 8 return { 9 show: true 10 }; 11 } 12 });
当点击到这个panel对应的标签页标题按钮时,此panel的show
值设置为true
,否则应该是false
。
这步操作是在tabs组件完成的,我们稍后再介绍。
既然要单击对应的标签页标题按钮,那应该有一个唯一的值来标识这个panel,我们可以设置一个prop:name
让用户来设置,但它不是必须的,如果使用者不设置,可以默认从0开始自动设置,这不操作仍然是tabs执行的,因为panel本身并不知道自己是第几个。
除了name
,还需要标签页标题的prop:label
,tabs组件需要将它显示在标签页标题里。
这部分代码如下:
1 props: { 2 name: { 3 type: String 4 }, 5 label: { 6 type: String, 7 default: "" 8 } 9 }
上面的prop:label
用户是可以动态调整的,所以在panel初始化以及label
更新时,都要通知父组件也更新,因为是独立组件,所以不能依赖像bus.js或vuex这样的状态管理办法,我们可以直接通过this.$parent
访问tabs组件的实例来调用它的方法更新标题,该方法暂定为updateNav
。
注意,在业务中尽可能不要使用$parent
来操作父链,这种方法适合于标签页这样的独立组件。
这部分代码如下:
1 methods: { 2 updateNav() { 3 this.$parent.updateNav(); 4 } 5 }, 6 watch: { 7 label() { 8 this.updateNav(); 9 } 10 }, 11 mounted() { 12 this.updateNav(); 13 }
在生命周期mounted
,也就是panel初始化时,调用一遍tabs的updateNav
方法。
同时监听了prop:label
,在label
更新时,同样调用。
剩余任务就是完成tabs.js组件。
首先需要把panel组件设置的标题动态渲染出来,也就是当panel触发tabs的updateNav
方法时,更新标题内容。
我们先看一下这部分的代码:
1 Vue.component("tabs", { 2 // ... 3 data: function() { 4 return { 5 // 用于渲染tabs的标题 6 navList: [] 7 }; 8 }, 9 methods: { 10 getTabs() { 11 // 通过遍历子组件,得到所有的panel组件 12 return this.$children.filter(function(item) { 13 return item.$options.name === "panel"; 14 }); 15 }, 16 updateNav() { 17 this.navList = []; 18 // 设置对this的引用,在function回调里,this指向的并不是Vue实例 19 var _this = this; 20 this.getTabs().forEach(function(panel, index) { 21 _this.navList.push({ 22 label: panel.label, 23 name: panel.name || index 24 }); 25 // 如果没有给panel设置name,默认设置它的索引 26 if (!panel.name) { 27 panel.name = index; 28 } 29 // 设置当前选中的tab的索引,在后面介绍 30 if (index === 0) { 31 if (!_this.currentValue) { 32 _this.currentValue = panel.name || index; 33 } 34 } 35 }); 36 }, 37 updateStatus() { 38 var tabs = this.getTabs(); 39 var _this = this; 40 // 显示当前选中的tab对应的panel组件,隐藏没有选中的 41 tabs.forEach(function(tab) { 42 return tab.show = tab.name === _this.currentValue; 43 }); 44 } 45 } 46 });
getTabs
是一个公用的方法,使用this.$children
来拿到所有的panel组件实例。
需要注意的是,在methods
里使用了有function回调的方法时(例如遍历数组的方法forEach
),在回调内的this
不再执行当前的Vue实例,也就是tabs组件本身,所以要在外层设置一个_this=this
的局部变量来间接使用this
。
如果你熟悉ES2015,也可以直接使用箭头函数=>
,我们会在实战篇里介绍相关的用法。
遍历了每一个panel组件后,把它的label
和name
提取出来,构成一个Object并添加到数据navList
数组里,后面我们会在template
里用到它。
设置完navList
数组后,我们调用了updateStatus
方法,又将panel组件遍历了以便,不过这时是为了将当前选中的tab对应的panel组件内容显示出来,把没有选中的隐藏掉。
因为在上一步操作里,我们有可能需要设置currentValue
来标识当前选中项的name
(在用户没有设置value
时,才会自动设置),所以必须要遍历2次才可以。
拿到navList
后,就需要对它用v-for
指令把tab的标题渲染出来,并且判断每个tab当前的状态。
这部分代码如下:
1 Vue.component("tabs", { 2 template: "\ 3 <div class='tabs'> \ 4 <div class='tabs-bar'> \ 5 <div :class='tabCls(item)' v-for='(item,index) in navList' @click='handleChange(index)'>{{item.label}}</div> \ 6 </div> \ 7 <div class='tabs-content'> \ 8 <slot></slot> \ 9 </div> \ 10 </div>", 11 props: { 12 // 这里的value是为了可以使用v-model 13 value: { 14 type: [String, Number] 15 } 16 }, 17 data: function() { 18 return { 19 // 因为不能修改value,所以复制一份自己维护 20 currentValue: this.value, 21 navList: [] 22 }; 23 }, 24 methods: { 25 tabCls: function(item) { 26 return [ 27 "tabs-tab", 28 { 29 // 给当前选中的tab加一个class 30 "tabs-tab-active": item.name === this.currentValue 31 } 32 ]; 33 }, 34 // 点击tab标题时触发 35 handleChange: function(index) { 36 var nav = this.navList[index]; 37 var name = nav.name; 38 // 改变当前选中的tab,并触发下面的watch 39 this.currentValue = name; 40 // 更新value 41 this.$emit("input", name); 42 // 触发一个自定义事件,供父级使用 43 this.$emit("on-click", name); 44 } 45 }, 46 watch: { 47 value: function(val) { 48 this.currentValue = val; 49 }, 50 currentValue: function() { 51 // 在当前选中的tab发生变化时,更新panel的显示状态 52 this.updateStatus(); 53 } 54 } 55 });
在使用v-for
指令循环显示tab标题时,使用v-bind:class
指向了一个名为tabCls
的methods
来动态设置class
名称。
因为计算属性不能接收参数,无法知道当前tab是否是选中的,所以这里我们才用到methods。
不过要知道,methods是不缓存的,可以回顾关于计算属性的章节。
点击每个tab标题时,会触发handleChange
方法来改变当前选中tab的索引,也就是panel组件的name。
在watch选项里,我们监听了currentValue
,当其发生变化时,触发updateStatus
方法来更新panel组件的显示状态。
以上就是标签页组件的核心代码分解。
总结一下该示例的技术难点:
- 使用了组件嵌套的方式,将一系列panel组件作为tabs组件的slot;
- tabs组件和panel组件通信上,使用了
$parent
和$children
的方法访问父链和子链; - 定义了prop:
value
和data:currentValue
,使用$emit("input")
来实现v-model
的用法。
以下是标签页组件的完整代码:
index.html:
1 <!DOCTYPE html> 2 <html lang="zh"> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <meta http-equiv="X-UA-Compatible" content="ie=edge"> 7 <title>标签页组件</title> 8 <link rel="stylesheet" type="text/css" href="style.css"> 9 </head> 10 <body> 11 <div id="app"> 12 <tabs v-model="activeKey"> 13 <panel label="标签一" name="1">标签一的内容</panel> 14 <panel label="标签二" name="2">标签二的内容</panel> 15 <panel label="标签三" name="3">标签三的内容</panel> 16 </tabs> 17 </div> 18 19 <script src="vue.js"></script> 20 <script src="panel.js"></script> 21 <script src="tabs.js"></script> 22 <script> 23 var app = new Vue({ 24 el: "#app", 25 data: { 26 activeKey: "1" 27 } 28 }); 29 </script> 30 </body> 31 </html>
panel.js:
1 Vue.component("panel", { 2 name: "panel", 3 template: "\ 4 <div class='panel' v-show='show'> \ 5 <slot></slot> \ 6 </div>", 7 props: { 8 name: { 9 type: String 10 }, 11 label: { 12 type: String, 13 default: "" 14 } 15 }, 16 data: function() { 17 return { 18 show: true 19 }; 20 }, 21 methods: { 22 updateNav() { 23 this.$parent.updateNav(); 24 } 25 }, 26 watch: { 27 label() { 28 this.updateNav(); 29 } 30 }, 31 mounted() { 32 this.updateNav(); 33 } 34 });
tabs.js:
1 Vue.component("tabs", { 2 template: "\ 3 <div class='tabs'> \ 4 <div class='tabs-bar'> \ 5 <div :class='tabCls(item)' v-for='(item,index) in navList' @click='handleChange(index)'>{{item.label}}</div> \ 6 </div> \ 7 <div class='tabs-content'> \ 8 <slot></slot> \ 9 </div> \ 10 </div>", 11 props: { 12 value: { 13 type: [String, Number] 14 } 15 }, 16 data: function() { 17 return { 18 currentValue: this.value, 19 navList: [] 20 }; 21 }, 22 methods: { 23 tabCls: function(item) { 24 return [ 25 "tabs-tab", 26 { 27 "tabs-tab-active": item.name === this.currentValue 28 } 29 ]; 30 }, 31 // 点击tab标题时触发 32 handleChange: function(index) { 33 var nav = this.navList[index]; 34 var name = nav.name; 35 // 改变当前选中的tab,并触发下面的watch 36 this.currentValue = name; 37 // 更新value 38 this.$emit("input", name); 39 // 触发一个自定义事件,供父级使用 40 this.$emit("on-click", name); 41 }, 42 getTabs() { 43 // 通过遍历子组件,得到所有的panel组件 44 return this.$children.filter(function(item) { 45 return item.$options.name === "panel"; 46 }); 47 }, 48 updateNav() { 49 this.navList = []; 50 // 设置对this的引用,在function回调里,this指向的并不是Vue实例 51 var _this = this; 52 this.getTabs().forEach(function(panel, index) { 53 _this.navList.push({ 54 label: panel.label, 55 name: panel.name || index 56 }); 57 // 如果没有给panel设置name,默认设置它的索引 58 if (!panel.name) { 59 panel.name = index; 60 } 61 // 设置当前选中的tab的索引,在后面介绍 62 if (index === 0) { 63 if (!_this.currentValue) { 64 _this.currentValue = panel.name || index; 65 } 66 } 67 }); 68 }, 69 updateStatus() { 70 var tabs = this.getTabs(); 71 var _this = this; 72 // 显示当前选中的tab对应的panel组件,隐藏没有选中的 73 tabs.forEach(function(tab) { 74 return tab.show = tab.name === _this.currentValue; 75 }); 76 } 77 }, 78 watch: { 79 value: function(val) { 80 this.currentValue = val; 81 }, 82 currentValue: function() { 83 // 在当前选中的tab发生变化时,更新panel的显示状态 84 this.updateStatus(); 85 } 86 } 87 });
style.css:
1 [v-cloak]{display:none;} 2 3 .tabs{font-size:14px; color:#657180;} 4 .tabs-bar:after{ 5 content:""; display:block; 6 width:100%; height:1px; 7 background:#D7DDE4; margin-top:-1px; 8 } 9 .tabs-tab{ 10 display:inline-block; cursor:pointer; position:relative; 11 padding:4px 16px; margin-right:6px; 12 background:#FFF; border:1px solid #D7DDE4; 13 } 14 .tabs-tab-active{ 15 cursor:#3399FF; 16 border-top:1px solid #3399FF; border-bottom:1px solid #FFF; 17 } 18 .tabs-tab-active:before{ 19 content:""; display:block; height:1px; background:#3399FF; 20 position:absolute; top:0; left:0; right:0; 21 } 22 .tabs-content{padding:8px 0;}
练习1: 给panel组件新增一个prop:closable
的布尔值,来支持是否可以关闭这个panel,如果开启,在tabs的标签标题上会有一个关闭的按钮;
提示:
在初始化panel时,我们是在mounted里通知的。
关闭时,你会用到beforeDestroy。
练习2: 尝试在切换panel的显示与隐藏时,使用滑动的动画。提示:可以使用CSS3的transform:translateX
。