本节以组件知识为基础,整合指令、事件等前面章节的内容,开发两个业务中常用的组件, 即数字输入框和标签页。
- 开发一个数字输入框组件
数字输入框是对普通输入框的扩展,用来快捷输入一个标准的数字,如下图所示。
数字输入框只能输入数字,而且有两个快捷按钮,可以直接减 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.jsdelivr.net/npm/vue/dist/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 ,所有的组件配置都在这里面定义。先在 template 里定义了组件的根节点,因为是独立组件,所以应该对每个 prop 进行校验。这里根据需求有最大值、最小值、 默认值(也就是绑定值) 3 个 prop, max 和 min 都是数字类型,默认值是正无限大和负无限大; value 也是数字类型, 默认值是 0。
接下来,我们先在父组件引入 input-number 组件,并给它一个默认值 5,最大值 10, 最小值 0。
- index.js
var app = new Vue({ el: '#app', data: { value: 5 } });
- index.html
<div id="app"> <input-number v-model="value" :max="10" :min="0"></input-number> </div>
value 是一个关键的绑定值, 所以用了 v-model,这样既优雅地实现了双向绑定,也让 API 看起来很合理。大多数的表单类组件都应该有一个 v-model, 比如输入框、单选框、多选框、下拉选择器等。
剩余的代码量就都聚焦到了 input-number.js 上。
我们之前介绍过, Vue 组件是单向数据流,所以无法从组件内部直接修改 prop value 的值。 解决办法也介绍过, 就是给组件声明一个 data,默认引用 value 的值,然后在组件内部维护这个 data:data: function (){ return { currentValue: this.value } },
这样只解决了初始化时引用父组件 value 的问题,但是如果从父组件修改了 value, input-number 组件的 currentValue 也要一起更新。为了实现这个功能, 我们需要用到一个新的概念: 监听(watch)。 watch 选项用来监听某个 prop 或 data 的改变, 当它们发生变化时,就会触发 watch 配置的函数, 从而完成我们的业务逻辑。在本例中,我们要监听两个量: value 和 currentValue。 监听 value 是要知晓从父组件修改了 value,监听 currentValue 是为了当 currentValue 改变时,更新 value。 相关代码如下:
data: function (){ return { currentValue: this.value } }, watch: { currentValue: function (val){ this.$emit('input',val); this.$emit('on-change',val); }, value: function (val){ this.updateValue(val); } }, methods: { handleDown: function (){ if(this.currentValue <= this.min) return; this.currentValue -= 1; }, handleUp: function (){ if(this.currentValue >= this.max) return; this.currentValue += 1; }, handleValue: function (val){ if(val > this.max) val = this.max; if(val < this.min) val = this.min; this.currentValue = val; }, handleChange: function (event){ var val = event.target.value.trim(); var max = this.max; var 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: function (){ this.updateValue(this.value); }
从父组件传递过来的 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 选项返回对象前进行过滤:Vue.component('input-number',{ //... data: function(){ var val = this.value; if(val > this.max) val = this.max; if(val < this.min) val = this.min; return { currentValue: val } } });
实现的效果是一样的。
最后剩余的就是补全模板 template,内容是一个输入框和两个按钮,相关代码如下:function isValueNumber (){ return (/(^-?[0-9]+\.{1}\d+$)|(^-?[1-9][0-9]*$)|(^-?0{1}$)/).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: function (){ return { currentValue: this.value } }, watch: { currentValue: function (val){ this.$emit('input',val); this.$emit('on-change',val); }, value: function (val){ this.updateValue(val); } }, methods: { handleDown: function (){ if(this.currentValue <= this.min) return; this.currentValue -= 1; }, handleUp: function (){ if(this.currentValue >= this.max) return; this.currentValue += 1; }, handleValue: function (val){ if(val > this.max) val = this.max; if(val < this.min) val = this.min; this.currentValue = val; }, handleChange: function (event){ var val = event.target.value.trim(); var max = this.max; var 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: function (){ this.updateValue(this.value); } });
input 绑定了数据 currentValue 和原生的 change 事件, 在旬柄 handleChange 函数中,判断了 当前输入的是否是数字。注意,这里绑定的 currentValue 也是单向数据流,并没有用 v-model,所 以在输入时, currentValue 的值并没有实时改变。如果输入的不是数字(比如英文和汉字等),就将输入的内容重置为之前的 currentValue 。 如果输入的是符合要求的数字,就把输入的值赋给 current Value。
数字输入框组件的核心逻辑就是这些。回顾一下我们设计一个通用组件的思路,首先,在写代码前一定要明确需求,然后规划好 API。一个 Vue 组件的 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.jsdelivr.net/npm/vue/dist/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', data: { value: 5 } });
- input-number.js
function isValueNumber (){ return (/(^-?[0-9]+\.{1}\d+$)|(^-?[1-9][0-9]*$)|(^-?0{1}$)/).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: function (){ return { currentValue: this.value } }, watch: { currentValue: function (val){ this.$emit('input',val); this.$emit('on-change',val); }, value: function (val){ this.updateValue(val); } }, methods: { handleDown: function (){ if(this.currentValue <= this.min) return; this.currentValue -= 1; }, handleUp: function (){ if(this.currentValue >= this.max) return; this.currentValue += 1; }, handleValue: function (val){ if(val > this.max) val = this.max; if(val < this.min) val = this.min; this.currentValue = val; }, handleChange: function (event){ var val = event.target.value.trim(); var max = this.max; var 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: function (){ this.updateValue(this.value); } });
- 开发一个标签页组件
本小节将开发一个比较有挑战的组件:标签页组件。标签页(即选项卡切换组件)是网页布局中经常用到的元素,常用于平级区域大块内容的收纳和展现,如图所示。
根据上个示例的经验,我们先分析业务需求,制定出 API,这样不至于一上来就无从下手。
每个标签页的主体内容肯定是由使用组件的父级控制的,所以这部分是一个 slot,而且 slot 的数量决定了标签切换按钮的数量。假设我们有 3 个标签页,点击每个标签按钮时,另外的两个标签对应的 slot 应该被隐藏掉。 一般这个时候,比较容易想到的解决办法是,在 slot 里写 3 个 div, 在接收到切换通知时,显示和隐藏相关的 div。这样设计没有问题,只不过体现不出组件的价值来, 因为我们还是写了一些与业务无关的交互逻辑,而这部分逻辑最好组件本身帮忙处理了,我们只用聚焦在 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. 标签页嵌套的组件 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"> </div> <script src="https://cdn.jsdelivr.net/npm/vue/dist/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
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
Vue.component('pane',{ name: 'pane', template: '\ <div class="pane">\ <slot></slot>\ </div>' });
pane 需要控制标签页内容的显示与隐藏, 设置一个 data: show,并且用 v-show 指令来控制元素:
template: '\ <div class="pane" v-show="show">\ <slot></slot>\ </div>', data: function (){ return { show: true } }
当点击到这个 pane 对应的标签页标题按钮时, 此 pane 的 show 值设置为 true, 否则应该是 false, 这步操作是在 tabs 组件完成的,我们稍后再介绍。
既然要点击对应的标签页标题按钮,那应该有一个唯一的值来标识这个 pane,我们可以设置 一个 prop: name 让用户来设置,但它不是必需的,如果使用者不设置,可以默认从 0 开始自动设置,这步操作仍然是 tabs 执行的,因为 pane 本身并不知道自己是第几个。除了 name,还需要标签页标题的 prop: label, tabs 组件需要将它显示在标签页标题里。这部分代码如下:props: { name: { type: String }, label: { type: String, default: '' } }
上面的 prop: label 用户是可以动态调整的,所以在 pane 初始化及 label 更新时,都要通知父组件也更新,因为是独立组件,所以不能依赖像 bus.js 或 vuex 这样的状态管理办法,我们可以直接通过 this.$parent 访问 tabs 组件的实例来调用它的方法更新标题,该方法名暂定为 updateNav。注意,在业务中尽可能不要使用 $parent 来操作父链,这种方法适合于标签页这样的独立组件。这部分代码如下:
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 方法时, 更新标题内容。我们先看一下这部分的代码:data: function(){ return{ currentValue: this.value, navList: [] } }, methods: { tabCls: function (item){ return [ 'tabs-tab', { 'tabs-tab-active': item.name === this.currentValue } ] }, getTabs () { return this.$children.filter(function (item){ return item.$options.name === 'pane'; }); }, updateNav () { this.navList = []; var _this = this; this.getTabs().forEach(function (pane,index){ _this.navList.push({ label: pane.label, name: pane.name || index }); if(!pane.name) pane.name = index; if(index === 0){ if(!_this.currentValue){ _this.currentValue = pane.name || index; } } }); this.updateStatus(); }, updateStatus() { var tabs = this.getTabs(); var _this = this; tabs.forEach(function (tab){ return tab.show = tab.name === _this.currentValue; }) }, handleChange: function(index){ var nav = this.navList[index]; var name = nav.name; this.currentValue = name; this.$emit('input',name); this.$emit('on-click',name); } }
getTabs 是一个公用的方法,使用 this.$children 来拿到所有的 pane 组件实例。
需要注意的是, 在 methods 里使用了有 function 回调的方法时(例如遍历数组的方法 forEach), 在回调内的 this 不再执行当前的 Vue 实例,也就是 tabs 组件本身,所以要在外层设置一个_this = 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 就是嵌套的 pane -->\ <slot></slot>\ </div>\ </div>', props: { value: { type: [String,Number] } }, data: function(){ return{ currentValue: this.value, navList: [] } }, methods: { tabCls: function (item){ return [ 'tabs-tab', { 'tabs-tab-active': item.name === this.currentValue } ] }, getTabs () { return this.$children.filter(function (item){ return item.$options.name === 'pane'; }); }, updateNav () { this.navList = []; var _this = this; this.getTabs().forEach(function (pane,index){ _this.navList.push({ label: pane.label, name: pane.name || index }); if(!pane.name) pane.name = index; if(index === 0){ if(!_this.currentValue){ _this.currentValue = pane.name || index; } } }); this.updateStatus(); }, updateStatus() { var tabs = this.getTabs(); var _this = this; tabs.forEach(function (tab){ return tab.show = tab.name === _this.currentValue; }) }, handleChange: function(index){ var nav = this.navList[index]; var name = nav.name; this.currentValue = name; this.$emit('input',name); this.$emit('on-click',name); } }, watch: { value: function (val){ this.currentValue = val; }, currentValue: function(){ 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 的用法。
以下是标签页组件的完整代码。<!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.jsdelivr.net/npm/vue/dist/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: { activeKey: '1' } }); </script> </body> </html>
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 就是嵌套的 pane -->\ <slot></slot>\ </div>\ </div>', props: { value: { type: [String,Number] } }, data: function(){ return{ currentValue: this.value, navList: [] } }, methods: { tabCls: function (item){ return [ 'tabs-tab', { 'tabs-tab-active': item.name === this.currentValue } ] }, getTabs () { return this.$children.filter(function (item){ return item.$options.name === 'pane'; }); }, updateNav () { this.navList = []; var _this = this; this.getTabs().forEach(function (pane,index){ _this.navList.push({ label: pane.label, name: pane.name || index }); if(!pane.name) pane.name = index; if(index === 0){ if(!_this.currentValue){ _this.currentValue = pane.name || index; } } }); this.updateStatus(); }, updateStatus() { var tabs = this.getTabs(); var _this = this; tabs.forEach(function (tab){ return tab.show = tab.name === _this.currentValue; }) }, handleChange: function(index){ var nav = this.navList[index]; var name = nav.name; this.currentValue = name; this.$emit('input',name); this.$emit('on-click',name); } }, watch: { value: function (val){ this.currentValue = val; }, currentValue: function(){ this.updateStatus(); } } });
Vue.component('pane',{ name: 'pane', template: '\ <div class="pane" v-show="show">\ <slot></slot>\ </div>', props: { name: { type: String }, label: { type: String, default: '' } }, data: function (){ return { show: true } }, methods: { updateNav () { this.$parent.updateNav(); } }, watch: { label () { this.updateNav(); } }, mounted () { this.updateNav(); } });
[v-cloak] { display: none; } .tabs{ font-size: 14px; color: #657180; } .tabs-bar:after{ content: ''; display: block; width: 100%; height: 1px; background-color: #d7dde4; margin-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; }