本節(jié)以組件知識為基礎(chǔ),整合指令、事件等前面章節(jié)的內(nèi)容,開發(fā)兩個業(yè)務(wù)中常用的組件, 即數(shù)字輸入框和標簽頁。
- 開發(fā)一個數(shù)字輸入框組件
數(shù)字輸入框是對普通輸入框的擴展,用來快捷輸入一個標準的數(shù)字,如下圖所示。
數(shù)字輸入框只能輸入數(shù)字,而且有兩個快捷按鈕,可以直接減 1 或加 1。除此之外,還可以設(shè)置初始值、最大值、最小值,在數(shù)值改變時,觸發(fā)一個自定義事件來通知父組件。
了解了基本需求后,我們先定義目錄文件:
- index.html 入口頁
- input-number.js 數(shù)字輸入框組件
- index.js 根實例
因為該示例是以交互功能為主,所以就不寫 css 美化樣式了。
首先寫入基本的結(jié)構(gòu)代碼,初始化項目。
- index.html
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>數(shù)字輸入框組件</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 里定義了組件的根節(jié)點,因為是獨立組件,所以應(yīng)該對每個 prop 進行校驗。這里根據(jù)需求有最大值、最小值、 默認值(也就是綁定值) 3 個 prop, max 和 min 都是數(shù)字類型,默認值是正無限大和負無限大; value 也是數(shù)字類型, 默認值是 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 是一個關(guān)鍵的綁定值, 所以用了 v-model,這樣既優(yōu)雅地實現(xiàn)了雙向綁定,也讓 API 看起來很合理。大多數(shù)的表單類組件都應(yīng)該有一個 v-model, 比如輸入框、單選框、多選框、下拉選擇器等。
剩余的代碼量就都聚焦到了 input-number.js 上。
我們之前介紹過, Vue 組件是單向數(shù)據(jù)流,所以無法從組件內(nèi)部直接修改 prop value 的值。 解決辦法也介紹過, 就是給組件聲明一個 data,默認引用 value 的值,然后在組件內(nèi)部維護這個 data:data: function (){ return { currentValue: this.value } },這樣只解決了初始化時引用父組件 value 的問題,但是如果從父組件修改了 value, input-number 組件的 currentValue 也要一起更新。為了實現(xiàn)這個功能, 我們需要用到一個新的概念: 監(jiān)聽(watch)。 watch 選項用來監(jiān)聽某個 prop 或 data 的改變, 當它們發(fā)生變化時,就會觸發(fā) watch 配置的函數(shù), 從而完成我們的業(yè)務(wù)邏輯。在本例中,我們要監(jiān)聽兩個量: value 和 currentValue。 監(jiān)聽 value 是要知曉從父組件修改了 value,監(jiān)聽 currentValue 是為了當 currentValue 改變時,更新 value。 相關(guān)代碼如下:
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 監(jiān)聽的數(shù)據(jù)的回調(diào)函數(shù)有 2 個參數(shù)可用 , 第一個是新的值, 第二個是舊的值, 這里沒有太復雜的邏輯, 就只用了第一個參數(shù)。在回調(diào)函數(shù)里, this 是指向當前組件實例的, 所以可以直接調(diào)用 this.updateValue() , 因為 Vue 代理了 props、 data 、 computed 及 methods。
監(jiān)聽 currentValue 的回調(diào)里 , this.$emit('input,val)是在使用 v-model 時改變 value 的; this.$emit('on-change’,val)是觸發(fā)自定義事件 on-change,用于告知父組件數(shù)字輸入框的值有所改變 (示例中沒有使用該事件)。
在生命周期 mounted 鉤子里也調(diào)用了 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 } } });實現(xiàn)的效果是一樣的。
最后剩余的就是補全模板 template,內(nèi)容是一個輸入框和兩個按鈕,相關(guān)代碼如下: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 綁定了數(shù)據(jù) currentValue 和原生的 change 事件, 在旬柄 handleChange 函數(shù)中,判斷了 當前輸入的是否是數(shù)字。注意,這里綁定的 currentValue 也是單向數(shù)據(jù)流,并沒有用 v-model,所 以在輸入時, currentValue 的值并沒有實時改變。如果輸入的不是數(shù)字(比如英文和漢字等),就將輸入的內(nèi)容重置為之前的 currentValue 。 如果輸入的是符合要求的數(shù)字,就把輸入的值賦給 current Value。
數(shù)字輸入框組件的核心邏輯就是這些?;仡櫼幌挛覀冊O(shè)計一個通用組件的思路,首先,在寫代碼前一定要明確需求,然后規(guī)劃好 API。一個 Vue 組件的 API 只來自 props、 events 和 slots,確定好這 3 部分的命名、規(guī)則,剩下的邏輯即使第一版沒有做好, 后續(xù)也可以迭代完善。但是 API 如果沒有設(shè)計好,后續(xù)再改對使用者成本就很大了。
完整的示例代碼如下:
- index.html
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>數(shù)字輸入框組件</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); } });
- 開發(fā)一個標簽頁組件
本小節(jié)將開發(fā)一個比較有挑戰(zhàn)的組件:標簽頁組件。標簽頁(即選項卡切換組件)是網(wǎng)頁布局中經(jīng)常用到的元素,常用于平級區(qū)域大塊內(nèi)容的收納和展現(xiàn),如圖所示。
根據(jù)上個示例的經(jīng)驗,我們先分析業(yè)務(wù)需求,制定出 API,這樣不至于一上來就無從下手。
每個標簽頁的主體內(nèi)容肯定是由使用組件的父級控制的,所以這部分是一個 slot,而且 slot 的數(shù)量決定了標簽切換按鈕的數(shù)量。假設(shè)我們有 3 個標簽頁,點擊每個標簽按鈕時,另外的兩個標簽對應(yīng)的 slot 應(yīng)該被隱藏掉。 一般這個時候,比較容易想到的解決辦法是,在 slot 里寫 3 個 div, 在接收到切換通知時,顯示和隱藏相關(guān)的 div。這樣設(shè)計沒有問題,只不過體現(xiàn)不出組件的價值來, 因為我們還是寫了一些與業(yè)務(wù)無關(guān)的交互邏輯,而這部分邏輯最好組件本身幫忙處理了,我們只用聚焦在 slot 內(nèi)容本身,這才是與我們業(yè)務(wù)最相關(guān)的。這種情況下,我們再定義一個子組件 pane, 嵌套在標簽頁組件 tabs 里,我們的業(yè)務(wù)代碼都放在 pane 的 slot 內(nèi),而 3 個 pane 組件作為整體成為 tabs 的 slot。
由于 tabs 和 pane 兩個組件是分離的,但是 tabs 組件上的標題應(yīng)該由 pane 組件來定義,因為 slot 是寫在 pane 里,因此在組件初始化(及標簽標題動態(tài)改變)時, tabs 要從 pane 里獲取標題, 并保存起來,自己使用。
確定好了結(jié)構(gòu), 我們先創(chuàng)建所需的文件:
- 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 需要控制標簽頁內(nèi)容的顯示與隱藏, 設(shè)置一個 data: show,并且用 v-show 指令來控制元素:
template: '\ <div class="pane" v-show="show">\ <slot></slot>\ </div>', data: function (){ return { show: true } }當點擊到這個 pane 對應(yīng)的標簽頁標題按鈕時, 此 pane 的 show 值設(shè)置為 true, 否則應(yīng)該是 false, 這步操作是在 tabs 組件完成的,我們稍后再介紹。
既然要點擊對應(yīng)的標簽頁標題按鈕,那應(yīng)該有一個唯一的值來標識這個 pane,我們可以設(shè)置 一個 prop: name 讓用戶來設(shè)置,但它不是必需的,如果使用者不設(shè)置,可以默認從 0 開始自動設(shè)置,這步操作仍然是 tabs 執(zhí)行的,因為 pane 本身并不知道自己是第幾個。除了 name,還需要標簽頁標題的 prop: label, tabs 組件需要將它顯示在標簽頁標題里。這部分代碼如下:props: { name: { type: String }, label: { type: String, default: '' } }上面的 prop: label 用戶是可以動態(tài)調(diào)整的,所以在 pane 初始化及 label 更新時,都要通知父組件也更新,因為是獨立組件,所以不能依賴像 bus.js 或 vuex 這樣的狀態(tài)管理辦法,我們可以直接通過 this.$parent 訪問 tabs 組件的實例來調(diào)用它的方法更新標題,該方法名暫定為 updateNav。注意,在業(yè)務(wù)中盡可能不要使用 $parent 來操作父鏈,這種方法適合于標簽頁這樣的獨立組件。這部分代碼如下:
methods: { updateNav () { this.$parent.updateNav(); } }, watch: { label () { this.updateNav(); } }, mounted () { this.updateNav(); }在生命周期 mounted,也就是 pane 初始化時,調(diào)用一遍 tabs 的 updateNav 方法,同時監(jiān)聽了 prop: label,在 label 更新時,同樣調(diào)用 。
剩余任務(wù)就是完成 tabs.js 組件。
首先需要把 pane 組件設(shè)置的標題動態(tài)渲染出來,也就是當 pane 觸發(fā) tabs 的 updateNav 方法時, 更新標題內(nèi)容。我們先看一下這部分的代碼: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 回調(diào)的方法時(例如遍歷數(shù)組的方法 forEach), 在回調(diào)內(nèi)的 this 不再執(zhí)行當前的 Vue 實例,也就是 tabs 組件本身,所以要在外層設(shè)置一個_this = this 的局部變量來間接使用 this。 如果你熟悉 ES2015,也可以直接使用箭頭函數(shù) =>,我們會在實戰(zhàn)篇里介紹相關(guān)的用法。
遍歷了每一個 pane 組件后,把它的 label 和 name 提取出來, 構(gòu)成一個 Object 并添加到數(shù)據(jù) navList 數(shù)組里, 后面我們會在 template 里用到它。
設(shè)置完 navList 數(shù)組后,我們調(diào)用了 updateStatus 方法,又將 pane 組件遍歷了一遍, 不過這時是為了將當前選中的 tab 對應(yīng)的 pane 組件內(nèi)容顯示出來, 把沒有選中的隱藏掉。因為在上一步操 作里, 我們有可能需要設(shè)置 currentValue 來標識當前選中項的 name (在用戶沒有設(shè)置 value 時, 才會自動設(shè)置) , 所以必須要遍歷 2 次才可以。
拿到 navList 后, 就需要對它用 v-for 指令把 tab 的標題渲染出來,井且判斷每個 tab 當前的狀態(tài)。這部分代碼如下: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 指令循環(huán)顯示 tab 標題時,使用 v-bind:class 指向了一個名為 tabCls 的 methods 來動態(tài)設(shè)置 class 名稱。因為計算屬性不能接收參數(shù),無法知道當前 tab 是否是選中的,所以這里我們才用到 methods,不過要知道, methods 是不緩存的,可以回顧關(guān)于計算屬性的章節(jié)。
點擊每個 tab 標題時,會觸發(fā) handleChange 方法來改變當前選中 tab 的索引,也就是 pane 組件的 name。在 watch 選項里,我們監(jiān)聽了 currentValue,當其發(fā)生變化時,觸發(fā) updateStatus 方法來更新 pane 組件的顯示狀態(tài)。
以上就是標簽頁組件的核心代碼分解??偨Y(jié)一下該示例的技術(shù)難點:使用了組件嵌套的方式, 將一系列 pane 組件作為 tabs 組件的 slot; tabs 組件和 pane 組件通信上,使用了 $parent 和 $children 的方法訪問父鏈和子鏈;定義了 prop: value 和 data: currentValue,使用 $emit(’input’) 來實現(xiàn) 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"> 標簽一的內(nèi)容 </pane> <pane label="標簽二" name="2"> 標簽二的內(nèi)容 </pane> <pane label="標簽三" name="3"> 標簽三的內(nèi)容 </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; }

