本章將圍繞如何使用Vue實現一個對圖書資料維護功能的示例展開講解。我從2005年開始進入互聯網開發的領域,用過很多種不同的語言,開發過許許多多的互聯網應用,在開發這些項目或產品的過程中,表單與視圖的處理其實是最多的。甚至可以說,只要涉及數據操作的功能都能被劃分到表單處理與視圖處理的範圍之中。
首先,對表單 與視圖 這兩個我們最常用的邏輯概念和它們自身所發揮的作用進行定義。
視圖
用於處理多行的數據集,所以它通常會以列表和表格的方式呈現。正如其名字一樣,它只是從不同角度、維度查看一個或多個數據表的一種界面組件。
視圖的常規操作有:
● 數據分頁——對於數據量很大的數據表我們會將其分成很多個數據頁顯示,在移動端會表現為以滑動加載的方式漸入分頁;
● 條件查詢——包括快速查詢或者多個條件組合性的查詢,用於過濾和篩選目標數據;
● 排序——對各個列進行正向或逆向的數據排序;
● 多行選定——當我們需要對一多行數據進行同一個操作時就需要視圖能支持多行選定功能,例如批量刪除;
● 添加/編輯/顯示單行數據的入口——這是數據視圖的一個很重要的功能,即使是一個只讀視圖我們也應該提供一個能查看數據的詳情表單。
視圖設計的成功關鍵是:只呈現最少量的數據字段與數據行 。視圖是一種信息量很大的頁面,很多程序員都喜歡將所有的數據列都顯示到一個視圖當中,甚至將數據行顯示得超過了屏幕的高度,每次必須拖動屏幕才能將數據行顯示完整。這是一種相當差的使用體驗!只要我們站在使用者的角度來思考一下就能體會到:用戶通常只關心視圖內的“某些數據”,視圖只是一個“找”數據的集中地而已,找到他們需要的數據後用戶自然會點擊詳情來瞭解更多的內容。也就是說,一個視圖只需要提供足夠的線索讓用戶快速找到數據就夠了。因此,視圖最重要的是“突出重點,快速定位 ”。
視圖不屬於CRUD中的任何一個操作,嚴格點來命名的話它屬於Query,是進行CRUD操作的一個極為必要的入口。
表單
在HTML中表單就是form,每個form必然會對應一個action(操作),所以CRUD可以看作表單的四種常規行為。
CRUD就是Create(創建)、Read(讀取)、Update(更新)和Delete(刪除)。刪除操作在界面上呈現出來的只是點一下按鈕,然後出現一個刪除提示,確認後就被執行的一種隱性的界面行為,所以我們可以不將其納入到表單處理之內。而CRU這三個操作剛好能對應三種表單:
● C——空白表單,用於增加數據項;
● R——詳情表單,用於顯示只讀數據,通常用於前台界面;
● U——編輯表單,用於修改數據項。
一個表單設計得是否好用取決於它提供了什麼樣的輸入方式,簡單點說就是盡量讓用戶的輸入變得簡單,糾正各種可能出現的錯誤。表單也是使用組件最多、組件結構最複雜的地方,因此以CRUD作為Vue的組件化示例將是非常有代表性的。
只要對表單與視圖這兩種基本的“大組件”抽像概念進行分析,配合Vue強大的組件化能力,在前端項目開發中你將會有一種如魚得水的感覺。
6.1 為Vue2集成UIkit
Vue只是為我們提供了一個很優秀的前端組件式開發框架,但從前面的例子我們都已經瞭解到,單純依靠Vue是做不出一個漂亮的網頁應用的,甚至連“不難看”這個標準都達不到(畢竟它只是一個組件框架),我們總是離不開那種耗費時間的CSS或者Less的樣式表製作過程。在實際開發中,還有很多常用組件,例如,分頁、按鈕、輸入框、導航欄、日期/時間選擇器、圖片輸入,等等。很明顯的是這些組件的通用性已不單單存在於一個項目內,而是所有的項目都需要!這是個比拚開發速度的年代,我們已經沒有時間重複發明輪子了,最正確的選擇是使用界面框架,例如Bootstrap、UIkit、Foundation等來代替這種大量的重複性極強的界面樣式開發工作。
UIkit
Bootstrap已經有很多年歷史了,在業界的應用也相當普遍,無論是前端開發或者後端開發,為了能快速做一個不算太難看的界面,它自然成為眾多工程師的選擇,包括我。多年下來,Bookstrap的改進實在是太緩慢了。不客氣地說,它基本上就沒讓我們這些用戶感覺它改進過,同質化嚴重,功能性組件一直不見增加,等等,都讓我們只能是痛並用著。
UIkit給我們帶來了福音,無論從界面上的樣式,還是實用組件的數目,甚至到易用性來說都要比Bootstrap好上一個層次。唯一的缺陷是它出生得比較晚,可選的主題樣式資源不多,畢竟還需要時間讓第三方社區來推動發展。但用它來做一個漂亮的交互性強的應用絕對是一個最佳的推薦方案。
Vue社區上也有一些包裝UIkit的庫,如vuikit,但它的文檔實在太少了,甚至從一開始的安裝配套都做得非常差,基本上是脫離了UIkit的核心樣式包和核心腳本編寫的。雖然努力可嘉,但這種功能性複製的包建議還是不要用,前端最耗不起的就是編譯包的大小。每個引入的第三方包我們都得吝嗇地測算一下得失,即使webpack可以用chuck來分包,但也不能濫用,否則加載速度緩慢就是破壞使用體驗的最大因素。
安裝
雖然在AngularJS、React和Vue的項目中jQuery從來都是一個不受歡迎的庫。首先是它編譯出來後就非常大,而且影響我們的MVVM思維,容易因為圖方便而又回到jQuery那種直接操控DOM的死路上去。但jQuery的強大在於它的普及性,幾乎我們能找到的很多優秀小組件都會有jQuery版本,甚至只有jQuery的版本。而UIkit正是其中一員,不能抗拒的話也只能學會享受。我們得同時安裝jQuery、UIkit兩個庫:
$ npm i jquery uikit -D
配置
我們需要將jQuery和UIkit的引用以及一些字體的引用配置添加到webpack中(UIkit內置引用了Fontawesome字體庫),確保已安裝了url-loader這個庫,如果沒有安裝的話用以下指令進行安裝:
$ npm i url-loader --D
在webpack.config.js的module.rules配置中加入字體引用配置:
rules: [ // ... 省略 { test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, loader: 'url', query: { limit: 10000, name: '[name].[hash:7].[ext]' } } ]
當然,如果你採用vue-cli webpack模板來構造項目的話,可以跳過以上的配置。
UIkit的運行主要依賴於一個主樣式文件uikit.css、一個主題文件uikit.almost-flat.css(主題文件內置有三個可選項)和一個腳本文件uikit.js。使用UIkit時,需要在代碼中同時import它們才能讓webpack在編譯時正確地引用。界麵包都是全局性的,那麼可以選擇在main.js文件一開始加入引用:
import 'jquery' import 'uikit' import 'uikit/dist/css/uikit.almost-flat.css'
這樣寫就違反了在第2章工程化Vue.js開發中的一個配置約定,我們不應該將“庫”或“依賴包”以全路徑方式引入到代碼文件中,而應該用webpack的resolve配置項,用別名來代替全路徑。以下是在webpack中配置UIkit的樣式引用別名:
resolve: { alias: { 'vue$': 'vue/dist/vue', 'uikit-css$': 'uikit/dist/css/uikit.almost-flat.css' } }
在main.js代碼內引入UIkit,代碼就變為:
import 'jquery' import 'uikit' import "uikit-css"
製作 UIkit 的 Vue 插件
上述的寫法還是不夠DRY,為了使用一個包就得引入多個不同的依賴庫,這種做法實在很難看,此時我們可以選擇一個Vue的最佳做法,就是用插件形式來包裝這種零碎化的引入方式。在src根目錄下新建一個uikit.js的文件,然後用Vue的插件格式來進行包裝。以下代碼中直接向Vue實例注入了UIkit的一些常用的幫助方法:
import 'jquery' import 'uikit' import 'uikit-css' export default (Vue, options) { // 向實例注入UIkit的對話框類方法 Vue.prototype.$ui = { alert: UIkit.modal.alert confirm: UIkit.modal.confirm, prompt: UIkit.modal.prompt, block: UIkit.modal.block } }
完成uikit.js的編寫就可以改寫main.js的內容了:
import UIkit from './uikit' Vue.use(UIKit)
由於對Vue.prototype進行了擴展,那麼就可以像vue-resource那樣在每個Vue實例內的this方法中注入一個$ui對象,用以下方法來顯示簡單的對話框:
methods: { delItem { this.$ui.confirm('您確認要刪除以下的數據嗎?', => { // 這裡編寫對數據進行刪除的代碼 }) } }
上述的confirm方法有一個明顯的弱點,就是在回調時this上下文會指向window而不是Vue實例本身,這樣的話對於編碼的使用體驗就很差了。我們可以在插件內對confirm做一個修飾,將回調方法的this重新指向Vue實例:
Vue.prototype.$ui = { // ... 省略 confirm (question,callback,cancelCallback,options) { UIkit.confirm(question, callback || callback.apply(this), cancelCallback || cancelCallback.apply(this), options) } }
apply函數是ECMA JavaScript的標準函數,用於更改調用方法上傳遞的上下文對象。上述代碼就是將回調函數的上下文強制替換為當前的Vue實例,避免了回調上下文丟失而需要手工去定義變量,“hold住”原有this上下文的痛苦。
關於apply函數詳細說明可以參考以下鏈接:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function/apply。
現在的代碼是不是感覺乾淨多了?那麼回過頭來看Vue的插件,在這裡面我們不僅可以像上述代碼那樣單純地對Vue實例進行擴展,還可以進行更多的全局化的處理。當然這裡的全局是指這個插件庫被引入Vue並調用use方法後,例如,我們可以將一些必要的組件或者指令混入插件方法內:
export default = (Vue, options) => { // 1.注入全局化的方法 Vue.myGlobalMethod = => { // ... } // 2.進行必要組件的註冊 Vue.component('html-editor', { HtmlEditor }) // 3.註冊一個全局化的指令標記 Vue.directive('sortable', { bind (el, binding, vnode, oldVnode) { // something logic ... } ... }) // 4.注入一些組件的選項 Vue.mixin({ created: function { // ... } ... }) // 5.擴展實例 Vue.prototype.$ui = {} }
UIkit 中的坑
當運行以上的代碼後,會很沮喪地發現瀏覽器中總會出現UI.$為空的異常,具體顯示如下:
Type error UI.$ is undefined.
我曾嘗試過直接跳入UIkit的源代碼中查找UI.$,這個變量其實是對jQuery的一個內部引用,準確地說這是在引用jQuery的腳本後由jQuery註冊到瀏覽器的window全局變量上的jQuery實例。估計是UIkit在生成加載代碼時變量的映射與初始化順序出現問題了。後來想了個辦法,直接在webpack.config.js配置內對全局變量進行改寫,具體代碼如下:
plugins: [ new webpack.ProvidePlugin({ $: "jquery", jQuery: "jquery", "window.jQuery": "jquery", "window.$": "jquery" }) ]
webpack.ProvidePlugin這個插件是用於JS代碼加載後在window上註冊全局變量的一個webpack插件,加入了以上的配置後程序就能正常運行了。最終幸運地從大坑中逃出生還!這樣UIkit就被集成到我們的Vue項目中來了。
6.2 表格視圖的實現
按照第4章組件化的設計與實現方法中總結的思路,一開始先不要考慮如何去組件化,好代碼是重構出來的不是寫出來的,所以一開始的冗余反而是讓我們找到重構點和組件化起源的地方。回顧組件化的工作流程:
首先是畫出功能區塊,填入佔位符:
<tempalte> <p> <!-- 頁頭 --> <!-- 工具欄 --> <!-- 圖書統計 --> <!-- 搜索框 --> <!-- 按鈕組 --> <!-- 工具欄 --> <!-- 頁頭 --> <!-- 正文 --> <!-- 圖書數據表格 --> <!-- 正文 --> <!-- 對話框--> <!-- 圖書編輯/新建數據表單 --> <!-- 對話框--> </p> </tempalte>
接下來就是分別將各佔位符上的頁面模板寫出,這個過程我們在之前的章節已經很詳細地論述過,此處就不再贅述了,直接上代碼:
<template> <p> <!-- 頁頭 --> <p> <p> <h1>圖書 <small>Vue CRUD示例</small> </h1> </p> </p> <!-- 頁頭 --> <!-- 頁面正文 --> <p> <!-- 工具欄 --> <p> <p> <p> <!-- 圖書統計 --> <p> <span>共有 <span class="uk-text-bold">{{ books.length }}</span>本圖書</span> </p> <!-- 圖書統計 --> <!-- 搜索框 --> <p> <p> <p> <i></i> <input type="search" placeholder="請輸入您要篩選的書名 "/></p> </p> </p> <!-- 搜索框 --> </p> </p> <p> <p> <button title="刪除已選中的圖書" ><i></i> </button> <button> <i></i> <span>添加</span> </button> </p> </p> </p> <!-- 工具欄 --> <!-- 圖書數據表格 --> <table> <thead> <tr> <th>書名</th> <th> 類別</th> <th> 出版日期</th> </tr> </thead> <tbody> <tr v-for="book in books"> <td> <p> <input type="checkbox" /> </p> <p> <a href="javascript:void(0)" :title="book.name">{{ book.name }}</a> <p>{{ book.authors }}</p> </p> </td> <td>{{ book.category }}</td> <td>{{ book.published }}</td> </tr> </tbody> </table> <!-- 圖書數據表格 --> </p> <!-- 頁面正文 --> <!-- 對話框 --> <!-- 圖書編輯/新建數據表單 --> <!-- 對話框--> </p> </template> <script> import "./assets/site.less" export default { data { return { books: , } } } </script>
然後是提出數據結構:
[ { "name": "書名", "authors": [ "作者" ], "editors": [ "" ], "series": "電商精英寶典系列", "isbn": "978-7-121-28410-6", "published": "2016-04-22", "pages": 288, "format": "16(185*235)", "status": "上市銷售", "en_name": "", "category": "新經濟、互聯網思維與電子商務", "summary": " ", "price": 79.0 }, // ... ]
各字段說明如下表所示。
由於表格的內容是同質化的,所以就不要像之前的示例那樣將所有的數據都寫成標記了,畢竟示例只是帶出一種思路,在我們沒有分析出數據結構時要這樣做,反之則可以直接進入數據樣本的準備階段。本示例的樣本數據比較多就不在這裡羅列了,有興趣的讀者 可以到本書的github上查看具體的文件內容。
同樣地,我們將數據樣本保存到~/fixtures/books.json文件內,然後直接引入到當前的App.vue代碼內使用:
import BookData from "./fixtures/books.json" export default { data { return { books: BookData } }, // ... }
6.2.1 實時數據篩選
界面元素與數據結構的設計與實現都已基本完成,可以說整個程序的輪廓已基本顯現。完成外觀後就要實現程序的“行為”邏輯了。我們從易到難逐步地實現,首先實現數據的篩選功能。
我們希望在搜索框中一邊輸入文字,下方的數據行界面就自動按照輸入的內容進行篩選,僅顯示與搜索框內容相匹配的內容,當沒有找到任何數據時顯示提示文字“抱歉,沒有找到任何的圖書數據”,以下是實現的思路流程:
首先,將搜索框input的value保存到一個terms的變量內,由於我們希望界面的刷新會隨著這個變量的變化產生改變,那麼就應該使用雙向綁定的方式,代碼如下:
<!-- 搜索框 --> <p> <p> <p> <i></i> <input type="search" v-model="terms" placeholder="請輸入您要篩選的書名"/> </p> </p> </p> <!-- 搜索框 -->
當terms產生變化時,我們得計算出與terms相關的搜索結果,上文將數據行的循環綁定到books數組上,但是這個books數組是原數據,是不應該變化的,那麼這裡我們就可以用計算屬性來進行結果的篩選,並在行循環中將原有的books替換掉:
export default { data { return { terms: '', books: BookData } }, computed: { bookFilter { // 用函數式將書名包含有terms內容的圖書都篩選出來,如果沒有則返回原數組 return this.terms.length ? this.books.filter(x => x.name.indexOf(this.terms) > -1) : this.books }, // ... 省略 } }
這個bookFilter屬性一旦被放置於template內,只要terms發生任何變化,界面都將被重繪,那麼將template的行循環替換為bookFilter:
<!-- 圖書數據表格 --> <table v-if="bookFilter.length"> <!-- 省略 --> <tbody> <tr v-for="book in bookFilter"> <!-- 省略 --> </tr> </tbody> </table> <p v-if="bookFilter.length==0">抱歉,尚沒有找到任何符合條件的圖書</p>
對bookFilter的數組長度進行判斷,有數據才顯示表格,反之則顯示提示文字,最終的運行效果如下:
當沒有找到數據時應該顯示成這樣:
接下來就要為程序寫E2E測試了,創建test/e2e/books.spec.js文件,按照前文流程圖的 邏輯來寫E2E測試,代碼如下所示。
describe('圖書管理視圖', => { it('應該篩選與搜索框輸入匹配的圖書數據', (client) => { const terms = '大數據' client.url(client.launchUrl) .waitForElementVisible('body', 30000) .setValue('input[type="search"]', [terms, client.Keys.ENTER]) .assert.containsText('.book-name', terms) .setValue('input[type="search"]', ['不存在的數據', client.Keys.ENTER]) .assert.elementPresent('.empty-holder') .end }) })
6.2.2 多行數據的選擇
想想這樣一個使用場景,如果點擊圖書行中的一個“刪除按鈕”,然後出現一個提示對話框,詢問是否確認刪除這行數據,確認後數據就被刪除掉了,但是如果要一次性刪除10條圖書數據,那是不是就得按10次刪除按鈕,做10次刪除確認?這種使用體驗就太差了,所以需要有多行選擇一次性確認刪除的功能。
具體的操作效果設計如下圖所示。
這裡我們可以運用Vue的雙向綁定和計算屬性兩個技術點來實現。我們需要為book添加一個標識屬性,selected用來記錄是否被選擇,並將其綁定到input[type=checkbox]上。雖然這個selected是一個無中生有的屬性,但這不是問題,因為雙向綁定會幫我們處理,沒有的話會自動為book添加上這一屬性,下圖是具體的設計思路。
請留意: 這裡出現了多處共享book.selected的狀態,此時狀態的變更開始變得複雜。
在模板上實現雙向綁定使book.selected與input的checked關聯起來,另外就是在<tr>上進行屬性綁定,當book.selected為真時加入樣式類book-selected,具體寫法如下:
<tr v-for="book in books" :class="{'book-selected': book.selected}"> <td> <p> <input type="checkbox" v-model="book.selected" :data-isbn="book.isbn" @change="selectChanged(book,$event)"/> </p> </td> <!-- 省略 --> </tr>
這裡出現了兩個沒有解釋的地方:data-isbn="book.isbn"和@change="selectChanged (book,$event)" ,這裡先賣個關子,在下文中會解釋它們的作用。
在左上方的選擇統計標籤上加上selection.length的字面量引用selection(這個變量現在還沒有,我們在下方的組件代碼內才會補充實現):
<!-- 圖書統計 --> <p> <span>共有<span >{{ books.length }}</span>本圖書 <span v-if="hasSelection"> 已選中<span >{{ selection.length }}</span>本圖書 </span> </span> </p>
最後在刪除按鈕上加上v-if指令進行自動消隱控制:
<!-- 按鈕組 --> <p> <p> <button title="刪除已選中的圖書" v-if="hasSelection" ><i></i> </button> <!--省略--> </p> </p>
這裡用一個計算屬性hasSelection對selection.length進行包裝,直接在此寫上表達式是為了讓代碼都易讀。
最後在組件代碼內實現selection、hasSelection和selectChanged這些屬性和事件處理器:
// ... 省略 import _ from 'lodash' export default { data { return { terms: '', books: Bookdata, selection: } }, computed: { hasSelection { return this.selection.length > 0 } }, methods: { selectionChanged (book) { if (e.target.checked) { this.selection.push(book.isbn) // 取唯一值 this.selection = _.uniq(this.selection) } else { // 排除符合條件的數據並返回新的數組 this.selection = _.reject(this.selection, b => book.isbn === b) } }, // ... 省略 } }
這就是selectionChanged的真相,這個事件處理器是將圖書的isbn保存到selection數組內,這樣做是為下文中對數據進行批量刪除時做數據準備的。
如果你沒有用過underscore(http://underscorejs.org)/lodash(https://lodash.com),或者沒有接觸過函數式編程,可能會對上述代碼有所困惑。這裡使用了loadash中的兩個高階函數uniq和reject,分別對selection數組進行處理。函數式編程可以極大地提高代碼運行效能,大幅度減少代碼量,而且代碼可讀性更強。可能你一時間不明白這兩個函數是怎麼實現的,但這並不重要,因為函數名已解釋了它們自身的用法,這是函數式能自描述(Self-Describe)的一種特點。
函數式編程是一個很廣泛的內容,它是一種通用的方法論。在本書短短的篇幅內實在無法過多地討論,但如果你對它有興趣,那麼可以關注我寫的另一本書《攀登架構之巔》,在那裡深度地瞭解函數編程的方方面面,另外還有一本非常好的書《Functional JavaScript》[2013 Micbel Fogus O'REALY],這是一本將我引入函數式編程領域的極好的範本。
underscore和lodash是在JavaScript中使用函數式編程的必備高階函數庫,這是兩個同質的類庫,引用了其中一個另一個就沒有存在意義了,lodash會比underscore更好用一些。
lodash的安裝很簡單:
$ npm i lodash -D
然後如上文一樣直接引入使用即可。
最後,將上文中分析的交互邏輯寫E2E測試加入到~/test/e2e/book.spec.js中:
it('多行數據選定時應該顯示刪除按鈕、顯示選中的數量以及選中的樣式', client => { const isbns = ['978-7-121-28410-6', '978-7-121-28817-3', '978-7-121-28413-7'] // 對Element的定位很重要,這裡只能是個體 client.url(client.launchUrl) .waitForElementVisible('body', 30000) .assert.elementNotPresent('.selection') .assert.elementNotPresent('#btn-delete') .assert.cssClassNotPresent(`tr[data-isbn="${isbns[0]}"]`, 'book-selected') .assert.cssClassNotPresent(`tr[data-isbn="${isbns[1]}"]`, 'book-selected') .assert.cssClassNotPresent(`tr[data-isbn="${isbns[2]}"]`, 'book-selected') .click(`input[type="checkbox"][data-isbn="${isbns[0]}"]`) .click(`input[type="checkbox"][data-isbn="${isbns[1]}"]`) .click(`input[type="checkbox"][data-isbn="${isbns[2]}"]`) .assert.containsText('.selection', '3') .assert.elementPresent('#btn-delete') .assert.cssClassPresent(`tr[data-isbn="${isbns[0]}"]`, 'book-selected') .assert.cssClassPresent(`tr[data-isbn="${isbns[1]}"]`, 'book-selected') .assert.cssClassPresent(`tr[data-isbn="${isbns[2]}"]`, 'book-selected') .end })
6.2.3 排序的實現
接下來就要實現更為複雜的交互效果了,在這個示例中我希望這個表格能實現像Excel一樣的排序效果,具體如下圖所示。
首先我們要保持住兩個狀態:
● sortingKey——當前排序的字段名稱;
● direction——排序的方向。
然後在模板上對表格內容進行重構:
<table v-if="totalBooks"> <thead> <tr> <th :class="{'sorting':sorted('name')}" data-col="name" @click="sortBy('name')"> <p>書名 <span :class="{ 'uk-icon-sort-asc': direction=='asc', 'uk-icon-sort-desc': direction=='desc' }" v-if="sortingKey=='name'"></span></p> </th> <th :class="{'sorting':sorted('category')}" data-col="category" @click="sortBy('category')"> <p>類別 <span :class="{ 'uk-icon-sort-asc': direction=='asc', 'uk-icon-sort-desc': direction=='desc' }" v-if="sortingKey=='category'"></span></p> </th> <th :class="{'sorting':sorted('published')}" data-col="published" @click="sortBy('published')"> <p>出版日期 <span :class="{ 'uk-icon-sort-asc': direction=='asc', 'uk-icon-sort-desc': direction=='desc' }" v-if="sortingKey=='published'"></span></p> </th> </tr> </thead> <tbody> <tr v-for="book in bookFilter" :class="{'book-selected': book.selected}" :data-isbn="book.isbn"> <td> <p :class="{'sorting':sorted('name')}"> <!--書名單元內容省略--> </p> </td> <td> <p :class="{'sorting':sorted('category')}"> {{ book.category }} </p> </td> <td> <p :class="{'sorting':sorted('published')}"> {{ book.published }} </p> </td> </tr> </tbody> </table>
這樣一次性地看代碼是否有點眼花繚亂?全貼出來是為了方便對照閱讀,下面就分開來解釋,這樣會更清楚其中的邏輯。排序的觸發是由列的點擊事件引起的,將上面模板的代碼抽像化成一種模式的話就會變成以下這樣:
<th :class="{'sorting':sorted('字段名')}" @click="sortBy('字段名')"> <p>書名 <span :class="{ 'uk-icon-sort-asc': direction=='asc', 'uk-icon-sort-desc': direction=='desc' }" v-if="sortingKey=='字段名'"></span></p> </th>
代碼雖多但實際邏輯並不複雜,一個是調用排序方法,另一個是進行樣式與排序圖標的消隱控制。用同樣的方法來看數據單元格就更簡單了,只是實現了樣式的切換:
<td> <p :class="{'sorting':sorted('字段名')}"> {{ 字面量 }} </p> </td>
這樣我們需要在組件代碼內實現sorted(fieldName)來判斷輸入的字段是否正在排序;sortBy(fieldName)是對數據進行排序。
export default { data { return { terms: '', sortingKey: '', direction: 'asc', statusText: '', books: BookData, selection: } }, methods: { sorted (key) { return key === this.sortingKey }, sortBy (key) { if (key === this.sortingKey) { // 對排序方向進行互斥式交換 this.direction = this.direction === 'asc' ? 'desc' : 'asc' } this.sortingKey = key this.books = _.orderBy(this.books, key, this.direction) }, // ... 省略 }, // ... 省略 }
實際上最終只需要調用loadash中的orderBy方法就可以對指定key和排序方向上的對象數組實現排序。
上文的代碼中為每一個th都加入了一個data-col屬性,這是為E2E測試而準備的,以下是排序操作的E2E測試代碼:
it('點擊列頭時應該進行排序', client => { const colName = 'th[data-col="name"]' const colCat = 'th[data-col="category"]' const colPub = 'th[data-col="published"]' const sortingClass = 'sorting' const asc = 'p>span.uk-icon-sort-asc' const desc = 'p>span.uk-icon-sort-desc' client.url(client.launchUrl) .waitForElementVisible('body', 30000) .assert.cssClassNotPresent(colName, sortingClass) .assert.cssClassNotPresent(colCat, sortingClass) .assert.cssClassNotPresent(colPub, sortingClass) .assert.elementNotPresent(`${colName}>p>span`) .assert.elementNotPresent(`${colCat}>p>span`) .assert.elementNotPresent(`${colPub}>p>span`) .getAttribute('tbody>tr:first', 'data-isbn', result => { this.assert.equal(result.value, '978-7-121-28410-6') // 無排序 }) .click(colName) // 對名稱進行排序 .assert.elementPresent(`${colName}>${asc}`) .getAttribute('tbody>tr:first', 'data-isbn', result => { this.assert.equal(result.value, '978-7-121-28413-7') // 升序 }) .click(colName) // 反向排序 .getAttribute('tbody>tr:first', 'data-isbn', result => { this.assert.equal(result.value, '978-7-121-28381-9') //降序 }) .assert.elementPresent(`${colName}>${desc}`) .assert.cssClassPresent(colName, sortingClass) .assert.cssClassNotPresent(colCat, sortingClass) .assert.cssClassNotPresent(colPub, sortingClass) .click(colCat) // 對類別進行排序 .assert.elementPresent(`${colCat}>${asc}`) .assert.cssClassPresent(colCat, sortingClass) .assert.cssClassNotPresent(colName, sortingClass) .assert.cssClassNotPresent(colPub, sortingClass) .end })
在寫E2E測試的時候你才會發現原來的代碼中有很多要進行操作的目標元素,通過代碼是沒有辦法定位的,這個時候我們就得向這些元素加入一些特殊的屬性或者CSS類作為標識。當然,這些輔助屬性的命名仍然要按照我們編碼前約定的規則,要有可讀性,千萬不要用拼音或者數字一類讓人摸不著頭腦的方式命名,否則這將會毀掉你的E2E測試。加入了輔助屬性,代碼才真正完整,因為具有輔助屬性的HTML結構才基本符合SEO(搜索引擎優化)要求,可以說這是一種額外的收穫吧。
6.3 單一職責原則與高級組件開發方法
到此已經完成了視圖的實現,接下來就要實現表單部分的功能。但是,現在的代碼已經變得越來越“胖” 了,此時視圖頁中的代碼行已經超過200多行了,要在源碼中找一個組件代碼上定義的方法已經越來越不方便了。其實,有這種感覺就對了,最怕的是當我們寫到1000行或者3000行的時候還麻木不仁地在加代碼。一個文件代碼行最多不超過100 ,這是最基本的編碼約定,無論何種語言都適用。
從架構設計的角度來看,現在這個App在功能上肩負了太多的職責——頁面佈局、數據獲取、排序、CRUD、數據表單,分頁、數據篩選,等等——已經嚴重地違反了“單一職責原則”。這就好像是一個人雖然他很能幹,但是如果你什麼事都讓他來幹,他做錯事的機率會大大增加。
單一職責原則: 不要存在多於一個導致類變更的原因。通俗地說,即一個類只負責一項職責。
現在正是對代碼進行全面梳理和重構的時候,我們的代碼已經大量充斥著各種UIkit 的樣式與結構,一堆的結構下頁只能完成一個小小的功能。按這種趨勢發展,代碼已經在逐漸進入“意大利麵條式代碼”(意思是糾纏在一起無法分開)的狀態了。我們要將這些囉唆的邏輯全面重構為各個小的組件,每個組件承擔起單一的職責,直到不可細分為止。
從一開始頁頭的代碼就能被組件化:
<p> <p> <h1>圖書 <small>Vue CRUD示例</small> </h1> </p> </p>
組件化後變成:
<page-header header="圖書" sub-header="Vue CRUD示例"> </page-header>
這個PageHeader組件實現很簡單,就是header和sub-header兩個輸入參數,具體的代碼如下:
<tempalte> <p> <p> <h1>{{ header }} <small v-if="subHeader">{{ subHeader }}</small> </h1> </p> </p> </tempalte> <script> export default { props: ['header', 'subHeader'] } </script>
6.3.1 搜索區的組件化
搜索區組件化的思路與上文一致,在此略過不表,先看看它的代碼:
搜索區 <template> <p> <p> <i></i> <input type="search" :placeholder="placeholder" @keyup.enter="$emit('search', $event.target.value)" : /> </p> </p> </template> <script> export default { name: 'SearchBox', props: ['terms', 'placeholder'] } </script>
這裡有一點要注意,搜索區中的input與App.vue中的terms變量進行了雙向綁定,當用戶輸入搜索關鍵字時就會自動更新terms。在Vue2以前我們還可以使用.sync這個屬性修飾符來使屬性也能具有雙向綁定功能,但在Vue2中這一功能完全被廢除了,組件的狀態是不可變的(Immutable),只能輸入,不能在組件實例內的任何地方進行修改,一旦我們對props定義的變量進行修改,馬上就會觸發一個異常。
雙向綁定已不能用於自定義組件的問題應當如何解決?答案是事件 ,組件內對變量做出修改只能向父容器發出一個事件,將修改值傳遞給父組件,由真正維護狀態的組件來更新值。
所以在SearchBox中將原有的v-model="terms"換成了:
<input : @keyup.enter="$emit('search', $event.target.value)" />
:value用於將外部輸入的屬性值寫到input內,當用戶敲擊鍵盤的回車鍵時用$emit方法發出一個search事件,通知父容器進行處理。
那App.vue內的代碼就要進行這樣的修改:
<search-box :terms="terms" placeholder="請輸入您要篩選的書名" @search="terms=$event"> </search-box>
雖然這樣寫比原來的代碼多了一些,但為了狀態共享 ,這一點付出也是值得的。
6.3.2 母板組件
將這兩個組件重構並封裝成為新組件後,頁面的總代碼行數減少得並不多。此時,如果我們從上自下仔細地閱讀一次代碼,會發現最大量的代碼都是一些UIkit佈局結構代碼,將這些代碼以從屬關係折疊起來,正好是我們一開始劃分出來的幾大功能區代碼。
如果將這些功能區用插槽slot取代後會得到這樣一種結構:
<p> <p slot="header"> <!-- 頁頭 --> </p> <!-- 工具欄 --> <p slot="counting"> <!-- 圖書統計 --> </p> <p slot="search"> <!-- 搜索框 --> </p> <p slot="buttons"> <!-- 按鈕組 --> </p> <!-- 工具欄 --> <!-- 正文(默認插槽) --> <!-- 圖書數據表格 --> <!-- 對話框 --> <!-- 圖書編輯/新建數據表單 --> <!-- 對話框 --> <!-- 正文 --> <p slot="footer"> <!-- 頁腳 --> </p> </p>
從這個角度來看,整個頁面其實就是一個更高層級的容器類頁面——母板頁。母板頁負責封裝UIkit定義的各種容器類佈局,最終以插槽形式進行內容分發。根據上面的分析,完整的母板頁的代碼如下所示。
<template> <p> <slot name="header"> <page-header :header="title" :sub-header="subTitle"> </page-header> </slot> <p> <p> <p> <p> <p> <slot name="counting"></slot> </p> <p> <slot name="search"></slot> </p> </p> </p> <p> <p> <slot name="buttons"></slot> </p> </p> </p> <slot></slot> </p> <p> <slot name="footer"></slot> </p> </p> </template> <script> import PageHeader from './pageheader' export default { name: 'ViewPage' props: ['title', 'subTitle'], components: {PageHeader} } </script>
這樣一封裝,我們就無須再去理會現在用的到底是UIkit還是BootStrap了,要改變佈局的樣式、位置,可以在組件內不改變插槽名稱情況下進行了,這樣就能從很大的程度上將界面代碼與UIkit“解耦”了。
由此及彼,既然我們可以製作一個專門用於數據維護(CRUD)的母板頁組件,那麼還可以製作如登錄、相冊、博客、產品展台等各種具有較高重用性的母板組件,形成我們 的母板庫,以便於在各個項目中使用。
母板組件實質上是借用了像razor、jade、jinja這一類服務端模板中的“母板”特性,因為Vue是一個面向組件的開發框架,用它特有的插槽(slot)將“佈局”(Layout)封裝成為一種組件,為大規模頁面開發帶來了非常大的便利性。從頁面佈局的角度我們稱之為母板,但如果從局部入手又可以得到各種容器類組件,例如Panel、Tabs、SideBar,等等。
6.3.3 重構模態對話框組件
在第4章組件化的設計與實現方法中我們實現了一個模態對話框組件,先來回顧一下它的代碼:
<template> <p :class="{'open':is_open}"> <p @click="close"></p> <p> <p> <slot name="header"></slot> </p> <slot></slot> <slot name="footer"></slot> </p> </p> </template> <script> import "./dialog.less" export default { data { return { is_open:false } }, methods: { open { if (!this.is_open) { // 觸發模態窗口打開事件 this.$emit('dialogOpen') } this.is_open = true }, close { if (this.is_open) { // 觸發模態窗口關閉事件 this.$emit('dialogClose') } this.is_open = false } } } </script>
為了減少篇幅此處略去對話框的樣式表部分。
UIkit的模態對話框比我們上面這個純手工打造的粗陋無比的對話框有更多的效果,例如一些動畫效果、更美觀的樣式等。在不改變這個組件的用法接口的前提下,我們在組件內部將其改寫為一個基於UIkit模態對話框的組件。
請謹記一點,改寫組件一定要避免隨意地改變組件的外部接口,因為這樣做會讓原有的代碼出現不可預測的異常,這對於一個已被工程化的項目來說是危險的!
<template> <p ref="modal"> <p> <slot name="header"> <p slot="header"> <a></a> <h2>{{ headerText }}</h2> </p> </slot> <slot></slot> <slot name="footer"></slot> </p> </p> </template> <script> export default { data { return { dialog: undefined } }, props: ['headerText'], mounted { this.dialog = this.$ui.modal(this.$refs.modal) var self = this this.dialog.on('show.uk.modal', => self.$emit('dialogOpen')) this.dialog.on('hide.uk.modal', => self.$emit('dialogClose')) }, methods: { open { this.dialog.show }, close { this.dialog.hide } } } </script>
你會發現其實這樣改寫後代碼變得更少了,也省去了自已寫樣式表的煩惱,而且改寫的內容並不多,只是mounted鉤子的內容變化了一下,另外為header插槽增加了默認的對話框頭,子組件也可以聲明這個插槽,對默認的頭內容進行重定義。
以下就是改寫模態對話框後的效果:
現在這種模態對話框的形式更適合用戶的使用習慣,同時也可以在不同的頁面內重用了。
6.3.4 高級組件與Render方法
本書開篇也提到過Vue2為了提高運行效能是集成了VirtualDOM的,之前我們所使用的template屬性和<template>標記最終都會被編譯為一個Render對象,這才是Vue2的真相!但官方網站上對Render的很多高級用法都諱莫如深,少之又少。能在百度或者谷歌上找到 的關於Render方法的案例更是不多。除非你曾經做過React的開發,否則一開始很難體驗到Render方法帶來的好處,只能從官網文章中瞭解到Render是一種用JS代碼繪製組件的方法,用以取代<template>而已。
Render方法需要在好幾個組件的應用中進行深度的解讀,如果將它的使用方法直接從官網貼到這裡,對你是毫無幫助的,這樣的話本書就毫無價值可言了。
在開始通過真實示例講述用Render方式開發Vue組件之前,我們需要對Render方法的基本原理與知識進行學習,這樣才有助於我們理解Virtual DOM的方法論。
首先,Vue2提供了一個很有趣的全局方法,叫作compile(編譯),這個方法就是將HTML模板的字符串編譯為一個Render對象。
例如:
let renderObject = Vue.compile(`<p> <h1>模板</h1> <p v-if="message"> {{ message }} </p> <p v-else> 尚無消息 </p> </p>`)
我們可以用WebStorm的調試器來觀察上面renderObject的結果,會發現它是一個擁有兩個方法的對象:
{ render: function anonymous { with(this){ return _h('p',[_m(0),(message)?_h('p',[_s(message)]):_h('p',["尚無 消息"])]) } }, staticRenderFns:[ _m(0): function anonymous { with(this){return _h('h1',["模板"])} } ] }
如果想試試這個編譯效果,可以訪問Vue官網的一個在線小工具:https://vuejs.org/v2/guide/render-function.html#Template-Compilation。
在一般情況下,我們並不會在代碼內直接執行compile來生成這個Render對象,這個操作是由Vue2的運行時幫我們完成的。由上述的內容我們可以瞭解到,template模板是為了讓我們可以像Angular那樣來使用雙向綁定、指令標記、過濾器等面向HTML標記的用法,而它們最終還是會變成Render函數。也就是說,Vue2組件有另一種寫法:純JS代碼。這種組件頁可以看作一個代碼型的單頁組件,與*.vue編寫的組件不同的是,使用Render方法的組件是一個標準的AMD模塊。具體格式如下:
export default { // 組件名,這是必需的,如果沒有的話會報出“渲染匿名組件”的異常 // 另外這個組件名同時定義了在頁面中使用的標籤 // 例如這個UkButton組件使用時就是<uk-button> name: 'UkButton', props:, // 公共屬性定義 data:{ // 內部變量定義 //... }, methods:{}, // 其他的定義與.vue單頁組件的定義是一致的 // .. 略去 render (createElement) { // 與.vue單頁組件不同的只在於此 return createElement('button', { class:{ 'uk-button':true } }) } }
這種純JS方式的組件與普通的單頁式組件(*.vue)唯一不同的地方是,普通的單頁式組件需要<template>模板或者聲明template屬性,而純JS方式的組件只需要定義Render方法。
createElement 函數
Render方法會傳入一個createElement函數,它是一個用於創建DOM元素或者用於實例化其他組件的構造方法。Render方法必須返回一個createElement函數的調用結果,也就是模板內的頂層元素(這個方法在Vue2的習慣性使用中經常用h來命名)。
它有以下的用法:
1.構造 DOM
export default { // ...省略 render (createElement) { const menu_items = ["首頁","搜索","分類","系統"] return createElement('ul', { class: { 'uk-nav':true } }, menu_items.map(item => createElement('li', item))) } }
上述的Render方法用<template>來寫的話應該如下所示。
<template> <ul> <li v-for="item in menu_items"> {{ item }} </li> </ul> </template>
2.實例化 Vue 組件
import UkButton from 'components/button' export default { // ... 省略 render (createElement) { return createElement('p',[ createElement(UkButton,{ class: {'confirm-button':true}, domProps : { innerHTML: "確定" }, propsData: { color: "primary", icon: "plus" } }) ]) } }
對照為模板的語法,則為:
<template> <p> <uk-button color="primary" icon="plus" >確定</uk-button> </p> </template>
有了以上的對照,你是否已經清楚地知道為何Vue2一定需要有“頂層元素”的存在了(Vue1.x是沒有頂層元素限制的)?因為Render只能返回一個createElement,這就是真相。
使用Render與template的區別有兩點:首先在Render方法內是不能再使用任何指令標記的,指令標記從本質上說只是用HTML的方式表示某一種JS的語法功能,例如v-for就相當於map函數,v-if就相當於條件表達式,兩者的思維模式是完全不一樣的。其次,對組件或網頁元素的屬性賦值是通過createElement函數的第二個參數data進行的,domProps會向元素傳遞標準的DOM屬性,而propsData則用於對其他的Vue組件的自定義屬性(props內的定義)進行賦值。
從這個角度來對照解釋是不是感覺思路開闊了很多?用Render方法乍一看可能覺得所有指令標記都沒有,之前Vue的好多基礎性的技巧都不能用了,但實質上Render方法卻能讓我們更靈活地用JS代碼去做更多的控制,避免了從JS實例到HTML屬性這樣的一種思維的轉換。
接下來我們就全面地看看createElement的定義,讓我們能對它有一個更深的理解:
createElement(tag,data,children)
返回值——VNode。
參數說明:
這三個參數中要對data參數進行附加說明,向構造的VNode對像設置文本時可以直接傳入字符串,例如:
createElement('p','這是行內文本')
那它的輸出結果就是:
<p>這是行內文本</p>
當data對象是一個Object類型的話就相當複雜了,可以參考下表:
以下是data屬性的使用範例:
// 假設有EmptyHolder自定義組件 createElement(EmptyHolder,{ // 和v-bind:class一樣的API 'class': { 'uk-container': true, 'uk-container-center': false }, // 和v-bind:style一樣的API style: { color: 'red', fontSize: '14px' }, // 正常的HTML特性 attrs: { id: 'page-container' }, // 組件props props: { emptyText: '尚無任何內容' }, // DOM屬性 domProps: { innerHTML: '請手動刷新' }, // 事件監聽器基於"on" // 所以不再支持如v-on:keyup.enter的修飾器 // 需要手動匹配keyCode on: { click: this.clickHandler }, // 僅對於組件,用於監聽原生事件,而不是組件使用vm.$emit觸發的事件 nativeOn: { click: this.nativeClickHandler }, // 自定義指令。注意事項:不能對綁定的舊值設置 // Vue會持續追蹤 directives: [ { name: 'my-custom-directive', value: '2' expression: '1 + 1', arg: 'foo', modifiers: { bar: true } } ], // 如果子組件有定義slot的名稱 slot: 'name-of-slot' // 其他特殊頂層屬性 key: 'myKey', ref: 'myRef' })
此時你可能又會提出一個疑問,從代碼量與代碼的直觀程度來講,Render方法顯得很累贅,可讀性又非常差。而且一旦輸出的組件結構複雜,這個Render方法就會變得極為可怕。如果想像不到它的可怕程度達到哪一級別,在下文中講述的一個datatable組件中用createElement函數來寫一次,作為它可怕的證明:
export default { // ... 省略 render (createElement) { let _fs = this.fields // 顯示排序標記 const sortFlag = header => createElement('span', { class: { 'hidden': this.sortingKey !== header.name, 'uk-icon-sort-asc': this.direction === 'asc', 'ui-icon-sort-desc': this.direction === 'desc' } }) // 繪製表頭 const colHeader = (header, index) => { var dataOpts = { class: { 'uk-text-center': true, 'disable-select': true, 'sorting': this.sorted(header.name) }, on: { click: => this.sortBy(header.name) } } if (index === 0) { dataOpts = _.extend({}, dataOpts, { attrs: { colspan: 2 } }) } return createElement('th', dataOpts, [createElement('p', { domProps: { innerHTML: header.title } }, [sortFlag(header)])]) } const toolCellNode = item => createElement('td', [createElement('input', { domProps: { type: 'checkbox' }, attrs: { 'data-id': item[this.keyField] }, on: { change: e => this.selectionChanged(item, e) } })]) // 繪製單元格 const cellNodes = item => [toolCellNode(item)].concat(this.dataFields. map(df => { if (_fs[df.name]) { // 動態裝配組件 return createElement(_.extend({}, _fs[df.name].data.inlineTemplate, { name: 'CustomField', props: ['name', 'item'] }), {props: {name: df.name, item: item}}) } else { return createElement('td', {}, [createElement('p', { class: { 'fill': true, 'sorting': this.sorted(df.name) }, domProps: { innerHTML: item[df.name] } })]) } })) // 繪製行對像 const rowNodes = this.dataItems.map(item => createElement('tr', {}, cellNodes(item))) return createElement('table', { class: { 'uk-table': true, 'uk-table-striped': true } }, [ createElement('thead', [createElement('tr', {}, this.dataFields. map(colHeader))]), createElement('tbody', [rowNodes]) ]) } }