讀古今文學網 > Vue2實踐揭秘 > 第4章 頁面的區塊化與組件的封裝 >

第4章 頁面的區塊化與組件的封裝

組件式開發是Vue.js的開發基礎,同時也是我們在工程化開發時對功能的抽像與重用的根本。所謂的組件化就是將複雜的、充滿重複交互界面的組件逐步細化與抽像為簡單的、單一化的一個過程。

區塊的劃分

我們做前端開發都是從上而下地進行設計與佈局,如果按功能或者內容分類來對整個頁面進行劃分的話,你會很自然地將一個頁面的內容分為一個或多個功能區,事實上這是人們的閱讀習慣。從這種習慣入手,我們可以很容易將一個複雜的頁面劃分為功能單一的Vue組件,劃分區塊的目的就在於將複雜問題簡單化,將一個抽像的設計工作分解為具體的開發工作。

本章將通過組件化的思維來設計與構建Home頁,也會著重於實踐,通過實踐領悟箇中的理論比純粹講如何使用Vue會有意思得多。我們先從設計圖入手,將Home的頁面結構從功能區塊上來進行劃分:

這是一個從具象化到抽像化的基本過程,如果沒有這一過程,我們根本沒有辦法將這些功能與實際工作相結合,繼而進行工作的細分與實現步驟的安排。

首頁上的「新書上架」和「編輯推薦」明顯是由兩個功能相同的組件構成的,那麼我們可以將其視為兩個類型相同的功能組件,然後就可以得到以下幾個構成HOME頁的組件。

功能區 組件 命名 熱門推薦 滾動滑塊 slider 快訊 快訊 announcement 新書上架,編輯推薦 圖書列表 book-list

主導航除了在四個頂層頁面內顯示,不會在其他頁面或組件內使用,在實現上只需要寫在Main.vue內即可,可見它是沒有什麼重用性需求的,我們並不需要對其進行組件化。

接下來我們分別將slider、announcement 和book-list 寫成Vue的組件並用來裝配Home頁面。

我們一定要養成從框架入手的良好編程習慣,寫代碼就像是在畫畫一樣,一開始就應該從打草稿開始,然後慢慢地給草稿添加各種細節,多次細化後最終才完成這部作品。所以我們先不用關心這些組件如何來寫,先將文件按照之前的命名約定先創建出來,組件的模板和上一章的頁面模板一樣。

    └── src
          ├── App.vue
          ├── Main.vue
          ├── assets
          ├── components
          │    ├── Announcement.vue
          │    ├── Booklist.vue
          │    └── Slider.vue
          ├── Category.vue
          ├── Home.vue
          ├── Me.vue
          ├── Shoppingcart.vue
          └── main.js  

4.1 頁面邏輯的實現

正如我們前文所說,「頁面」在Vue中是不存在的,它只是一種邏輯上的概念。事實上, 「頁面」這個概念就是由DOM元素和一個甚至多個自定義Vue組件復合而成的複合型組件。我們應該如何分清楚哪些部分使用DOM元素實現,哪些部分又應該封裝為更小級別的Vue組件呢?

首先,我們要從原型設計圖入手,可以先從功能性佈局上劃分出對應的功能區域,如前面的設計圖所示。

然後用HTML的註釋標記作為頁面上的「區域佔位」,先給頁面搭一個最基本的結構,這就像作畫時先給整體打草稿勾一個輪廓一樣。當我們一步一步地將這些細節描繪出來後,再將這些「輪廓線」從畫中抹掉。

    <template>
       <p>
          <p>
             <p>
                <!-- 熱門推薦 -->
                <!-- 快訊 -->
             </p>
          </p>
          <p>
             <!-- 新書上架 -->
          </p>
          <p>
             <!-- 編輯推薦 -->
          </p>
       </p>
    </template>  

「熱門推薦」是一個最常見的圖片輪播功能,而且這是一款基於面向手機的應用,所以這個「熱門推薦」區域除了支持橫幅圖片輪播的功能,還應該支持手勢滑動換頁的功能。這樣的實現邏輯並不需要自己動手來從零開始,我們應該學會站在別人的肩膀上做開發,這樣才走得更遠走得更快。在這裡推薦大家使用Swiper這個組件,這個組件可以在https://github.com/nolimits4web/swiper/下載,它是一個具有9000多個star的代碼庫!可見其受歡迎程度了。按照Swiper官方文檔的要求,我們先將Swiper所需要的HTML格式和樣式編寫好,當然此時我們得嚴格地按照原型設計圖將數據的樣本和必要的圖片資源準備好。

代碼如下所示。

    <template>
       <p>
          <p>
             <!-- 熱門推薦 -->
             

<p
                 ref="slider">
                <p>
                   <p>
                      <img src="https://p.2015txt.com/./fixtures/sliders/t1.svg"/>
                   </p>
                </p>
                <p>
                   <p>
                      <img src="https://p.2015txt.com/./fixtures/sliders/t2.svg"/>
                   </p>
                </p>
                <p
                   ref="pagination">
                </p>
             </p>
             <!-- 快訊 -->
             </p>
          </p>
          <p>
             <!-- 新書上架 -->
          </p>
          <p>
             <!-- 編輯推薦 -->
          </p>
       </p>
    </template>  

接下就要在代碼中引入對.swiper-container DOM元素應用Swiper這個對象了。在它的官方文檔中,Swiper的使用是通過CSS選擇器聲明將Swiper應用到那個頁面元素上的:

    const swiper = new Swiper('.slider-container')  

如果我們直接將它抄過來,應用到我們的組件代碼中會出現問題。一個好的組件應該是與外部沒有依賴關係的,或者說依賴關係越少越好,這叫低耦合。如果我們用CSS選擇器作為Swiper定位頁面上元素依據的話,假如在一個頁面上同時有兩個.slider-container,那麼這個組件就會亂套!

所以,我們應該避免用這種模糊的指定方式,而應該使用Vue.js提供的更精確的指明方式在元素中添加ref屬性,然後在代碼內通過this.$refs.引用名來引用。

這是Vue.js 2.0後的變化,ref標記是標準的HTML屬性,它取代了Vue.js 1.x中v-ref的寫法。如果你曾是Vue.js 1.x的開發者,那麼必須要留意這一點,v-ref已經被廢棄了!

    

<script>
       import Swiper from "swiper"          // 引入Swiper庫
       import 'swiper/dist/css/swiper.css'  // 引入Swiper所需要的樣式

       export default {
          // 不要選用created鉤子而應該採用mounted
          // 否則Swiper不能生效,因為created調用時元素還沒掛載到DOM上
          mounted  {
             new Swiper(this.$refs.slider, {
                pagination: this.$refs.pagination,
                paginationClickable: true,
                spaceBetween: 30,
                centeredSlides: true,
                autoplay: 2500,
                autoplayDisableOnInteraction: false
             })
          }
       }
    </script>  

組件化的過程就是在不斷地對代碼進行去重與抽像封裝的過程,上述代碼中<p></p>元素內所包含的內容除了<img>的src屬性內的數據是不同的,其他的都是重複的,很明顯這些圖片的地址應該是從服務器中傳過來的。首先我們將這些重複的圖片地址先放到data屬性內並重新改寫上述代碼。其次,考慮到用戶點擊當前顯示的輪播圖片時應該跳轉到圖書的詳細頁面內,那麼這個slides內存儲的就不單單是一個圖片地址,應該還要有一個圖書ID,用於作為路由跳轉的參數:

    export default {
       data  {
          return {
             slides:[
                { id:1, img_url:'./fixtures/sliders/t2.svg'},
                { id:2, img_url:'./fixtures/sliders/t2.svg'}
             ]
          }
       },

       // ...省略
    }  

用v-for指令標籤對slides數組列表進行渲染:

    <p
        v-for="slide in slides">
        

<router-link
                  tag="p"
                  :to="{name: 'BookDetail', params:{ id: slide.id }}">
           <img :src="https://p.2015txt.com/slide.img_url"/>
        </router-link>
    </p>  

那麼,「熱門推薦」區域的功能就實現完成了,代碼從一堆被「壓縮」成一小段了!slides中的數據我們先暫時寫死,後面我們再對數據進行統一的處理。

「快訊」區域的實現比較簡單,思路與實現同「熱門推薦」是一樣的,先按原型圖直接編寫HTML,然後將應該服務器提取的數據部分抽取到data中,最後重構頁面。

    <p>
       <label>快訊</label>
       <span>{{ announcement }}</span>
    </p>  

data的定義代碼:

    export default {
       data  {
          return {
             announcement:'今日上架的圖書全部8折',
             slides:[
                { id:1, img_url:'./fixtures/sliders/t2.svg'},
                { id:2, img_url:'./fixtures/sliders/t2.svg'}
             ]
          }
       },

       // ... 省略
    }  

4.2 封裝可重用組件

接下來是「新書上架」和「編輯推薦」這兩個主要的圖書列表了。我們還是用前文的辦法,先按照原型圖來依葫蘆畫瓢, 準備好樣本數據和圖書封面實現代碼:

    <p>
        <p>
           <p>最新更新</p>
           <p>更多...</p>
        

</p>
        <p>
            <p>
                <p><img src="https://p.2015txt.com//assets/cover/1.jpg"></p>
                <p>揭開數據真相:從小白到數據分析達人</p>
                <p>Edward Zaccaro, Daniel Zaccaro</p>
            </p>
            <p>
                <p><img src="https://p.2015txt.com//assets/cover/2.jpg"></p>
                <p>Android高級進階</p>
                <p>顧浩鑫</p>
            </p>
            <p>
                <p><img src="https://p.2015txt.com//assets/cover/3.jpg"></p>
                <p>淘寶天貓電商運營與數據化選品完全手冊</p>
                <p>老夏</p>
            </p>
            <p>
                <p><img src="https://p.2015txt.com//assets/cover/4.jpg"></p>
                <p>大數據架構詳解:從數據獲取到深度學習</p>
                <p>朱潔,羅華霖</p>
            </p>
            <p>
                <p><img src="https://p.2015txt.com//assets/cover/5.jpg"></p>
                <p>Meteor全棧開發</p>
                <p>杜亦舒</p>
            </p>
            <p>
               <p><img src="https://p.2015txt.com//assets/cover/6.jpg"></p>
               <p>Kubernetes權威指南:從Docker到Kubernetes實踐
全接觸(第2版)</p>
               <p>龔正,吳治輝,王偉,崔秀龍,閆健勇</p>
            </p>
        </p>
    </p>
    <p>
        <p>
            <p>編輯推薦</p>
            <p>更多...</p>
        </p>
        <p>
            <p>
                <p><img src="https://p.2015txt.com//assets/cover/7.jpg"></p>
                <p>自己動手做大數據系統</p>
                

<p>張粵磊</p>
            </p>
            <p>
                <p><img src="https://p.2015txt.com//assets/cover/8.jpg"></p>
                <p>智能硬件安全</p>
                <p>劉健皓</p>
            </p>
            <p>
                <p><img src="https://p.2015txt.com//assets/cover/9.jpg"></p>
                <p>實戰數據庫營銷——大數據時代輕鬆賺錢之道(第2版)
</p>
                <p>羅安林</p>
            </p>
            <p>
                <p><img src="https://p.2015txt.com//assets/cover/10.jpg"></p>
                <p>大數據思維——從擲骰子到紙牌屋</p>
                <p>馬繼華</p>
            </p>
            <p>
                <p><img src="https://p.2015txt.com//assets/cover/11.jpg"></p>
                <p>從零開始學大數據營銷</p>
                <p>韓布偉</p>
            </p>
            <p>
                <p><img src="https://p.2015txt.com//assets/cover/12.jpg"></p>
                <p>數據化營銷</p>
                <p>龔正,吳治輝,王偉,崔秀龍,閆健勇</p>
            </p>
        </p>
    </p>  

這樣的代碼是不是很難看?大量的重複邏輯存在於代碼內。這並不要緊,正如上文提到的,這是一個勾勒輪廓的過程,重複性的內容就可以被提取出來封裝成一個或多個組件,但封裝之前我們得知道向這個組件輸入一些什麼樣的數據,這個組件應該具有什麼樣的行為或者事件。我們先從提取數據入手,將上面的內容提取成兩個數組對象,然後將多個重複性的元素用列表循環取代:

    {
        latestUpdated:[
        {
          "id": 1,
          "title": "揭開數據真相:從小白到數據分析達人",
          "authors": ["Edward Zaccaro", "Daniel Zaccaro"],
          "img_url": "1.svg"
        

},
        {
          "id": 2,
          "title": "Android高級進階",
          "authors": [
            "顧浩鑫"
          ],
          "img_url": "2.svg"
        },
        {
          "id": 3,
          "title": "淘寶天貓電商運營與數據化選品完全手冊",
          "authors": [
            "老夏"
          ],
          "img_url": "3.svg"
        },
        {
          "id": 4,
          "title": "大數據架構詳解:從數據獲取到深度學習",
          "authors": [
            "朱潔",
            "羅華霖"
          ],
          "img_url": "4.svg"
        },
        {
            "id": 5,
          "title": "Meteor全棧開發",
          "authors": [
            "杜亦舒"
          ],
          "img_url": "5.svg"
        },
        {
          "id": 6,
          "title": "Kubernetes權威指南:從Docker到Kubernetes實踐全接觸(第2版)",
          "authors": [
            "龔正",
            "吳治輝",
            "王偉",
            "崔秀龍",
            "閆健勇"
          ],
          

"img_url": "6.svg"
        }
        ],
        recommended:[...] //內容結構與latestUpdated相同,在此略過
      ]
    }  

用v-for標籤渲染上述的數據:

    <p>
        <p>
            <p>
                <p>新書上架</p>
                <p>更多...</p>
            </p>
            <p>
                <p
                     v-for="book in latestUpdated">
                    <p>
                        <img :src="https://p.2015txt.com/book.img_url"/>
                    </p>
                    <p>{{ book.title }}</p>
                    <p>{{ book.authors | join }}</p>
                </p>
            </p>
        </p>
    </p>
    <p>
        <p>
            <p>
                <p>編輯推薦</p>
                <p>更多...</p>
            </p>
            <p>
                <p
                     v-for="book in recommended">
                    <p>
                        <img :src="https://p.2015txt.com/book.img_url"/>
                    </p>
                    <p>{{ book.title }}</p>
                    <p>{{ book.authors | join }}</p>
                </p>
            </p>
        </p>
    </p>  

經過第一次的抽像,代碼減少了很多,但是這兩個Section內顯示的內容除了標題與圖書的數據源不同,其他的邏輯還是完全相同的。也就是說,它們應該是由一個組件渲染的結果,只是輸入參數存在差異,那麼就還存在一次融合抽像的可能。此時我們就可以動手將這兩個列表封裝成為一個BookList組件。

先對原頁面的內容進行重構,預先命名BookList組件,接著確定在頁面上的用法。這很重要,Home頁面就是BookList的調用方,BookList的輸入屬性是與Home之間的接口,當接口被確定了,組件的使用方式也同樣被固定下來了。

    <p>
        <book-list :books="latestUpdated"
                  heading="最新更新">
        </book-list>
    </p>
    <p>
        <book-list :books="recommended"
                  heading="編輯推薦">
        </book-list>
    </p>  

標記名稱被確定,類名與文件名也就被確定了,先在Home頁中引入BookList組件:

    // 按照工程結構約定,組件放置在components目錄
    import BookList from "./components/BookList.vue"

    export default {
        data {
            announcement:'今日上架的圖書全部8折',
            slides:[
                   { id:1, img_url:'./fixtures/sliders/t2.svg' },
                   { id:2, img_url:'./fixtures/sliders/t2.svg' }
                  ],
            latestUpdated: [...],// 這兩個數組內容太多,為了便於閱讀此處略去具體定義
            recommended : [...]
        },
        components: {
           BookList
        },

        ...
    }  

這裡通過import導入組件定義,用components註冊自定義組件,注意對引入的組件名 稱要採用大駝峰命名法 。在Vue.js的官方文檔中是這樣約定的:「所有引入的組件在<template>內使用時都以小寫形式出現,如果類名由兩個大寫開頭的單詞所組成,那麼在第二個大寫字母前面需要添加「-」來與之前的單詞進行分隔。」我們按照這個使用約定來構建<template>內的視圖內容。

組件與標記的對應關係如下表所示。

組件註冊名稱 模塊標記 BookList <book-list>

以上這點必須謹記,否則Vue將不能識別註冊的自定義組件。

接口與用法都確定了我們就能開始真正地編寫BookList組件了,在components目錄內創建一個BookList.vue的組件文件:

    export default {
        props: [
          'heading', // 標題
          'books' // 圖書對像數組
        ],
        filters: {
           join(args){
              return args.join(',')
           }
        }
    } 

要向組件輸入數據就不能使用data來作為數據的容器了,因為data是一個內部對象,此時就要換成props。

我們可以通過「作用域」來理解data和props,data的作用域是僅僅適用於內部而對於外部的調用方是不可見的,換句話說它是一個私有的組件成員變量;而props是內部外部都可見,是一個公共的組件成員變量。

將之前提取的HTML內容放置其中,並用props定義的屬性替換原有的數據對象。另外,這裡定義了一個join過濾器,用於將authors(作者)數組輸出為以逗號分隔的字符串,改寫後的組件模板如下:

    <template>
        <p>
            <p>
                <p>{{ heading }}</p>
                <p>更多...</p>
            

</p>
            <p>
                <p
                    v-for="book in books">
                   <p>
                      <img :src="https://p.2015txt.com/book.img_url"/>
                   </p>
                   <p>{{ book.title }}</p>
                   <p>{{ book.authors | join }}</p>
                </p>
            </p>
        </p>
    </template>  

4.3 自定義事件

BookList組件的封裝可以說是基本成形了,按照設計圖,當用戶點擊某一本圖書之時要彈出一個預覽的對話框,如下圖所示。

也就是說,每個圖書元素要響應用戶的點擊事件,顯示另一個窗口或對話框顯然應該由Home頁進行處理,所以就需要BookList在接收用戶點擊事件後,向Home組件發出一個事件通知,然後由Home組件接收並處理顯示被點擊圖書的詳情預覽。

在第1章我們就介紹過如何通過v-bind指令標記接收並處理DOM元素的標準事件。此時需要更深入一步,就是為BookList定義一個事件,並由它的父組件,也就是Home頁接收並進行處理。

Vue的組件發出自定義事件非常簡單,只要使用$emit("事件名稱")方法,然後輸入事件名稱就可以觸發指定「事件名稱」的組件事件,具體如下所示。

    <p
        v-for="book in books"
        @click="$emit('onBookSelect', book)">
       <p>
          <img :src="https://p.2015txt.com/book.img_url"/>
       </p>
       <p>{{ book.title }}</p>
       <p>{{ book.authors | join }}</p>
    </p>  

$emit的第一個參數是事件名稱,第二個參數是向事件處理者傳遞當前被點擊的圖書的具體數據對象。完成這一步後,BookList組件的封裝工作就宣告結束,可以回到Home頁中加入由BookList組件所發出的onBookSelect事件了。

$emit是Vue實例的方法,在<template>內所有調用的上下文都將默認指向Vue組件本身(this),所以無須聲明,但如果是在代碼內調用的話則需要通過this.$emit方式顯式引用。

在Home中增加一個preview(book)的方法用來顯示圖書詳情預覽的對話框,preview方法可以先不實現,我們會將其留在下文中進行處理。

    export default {
       data  {
           // ... 省略
       },
       methods: {
          preview (book) {
             alert("顯示圖書詳情")
          }
       },
       // ...
    }  

接收自定義事件與接收DOM事件的方式是一樣的,也是使用v-bind指令標記接收onBookSelect事件:

    <book-list :books="latestUpdated"
             heading="最新更新"
             @onBookSelect="preview($event)">
    </book-list>  

為什麼這裡會出現一個$event參數呢?其實這個參數是被Vue注入到this對像中的,當事件產生時,這個$event參數用於接收由$emit('onBookSelect', book)的第二個傳出參數book。每個採用v-bind指令接收的事件都會自動產生$event對象,如果事件本身沒有傳出的參數,那麼這個$event就是一個DOM事件對像實例。

4.4 數據接口的分析與提取

至此,我們已完成了Home頁中佈局所需要的基本元素與組件了。接下來就是要重構data內的數據了,現在的數據是被寫死在data內的。我們需要將這些數據變活,讓它們從服務端獲取。

我們先來回顧一下完整的data的結構,[...]在此表示略去,實際代碼應該寫成數組:

    data  {
        return {
           announcement:'今日上架的圖書全部8折',
           slides:[...],
           latestUpdated: [...],
           recommended : [...]
        }
    }  

如果要接入到服務端,在Home初始化時就應該自動從服務端獲取announcement、slides、latestUpdated和recommended四個參數,這裡我們已經在不知不覺中設計出了HOME頁的前端與服務端交互數據接口,現在的data內容正是這個數據接口。我們先將這些數據抽出來放到一個~/fixtures/home/home.json文件內,然後將data內的數據清空:

    data  {
        return {
           announcement:'',
           slides:,
           latestUpdated: ,
           

recommended : 
        }
    }  

最後,將與服務端通信的數據接口和API的用法保存到~/fixtures/home/README.md,以下是API文檔的樣本:

請求地址

    HTTP GET '/api/home'  

返回對像

    {
        annoouncement: ''                        // 快訊的內容
        slides:[                                 // 熱門推薦圖書
           {
               id: 1,                            // 圖書編號
               img_url:'/assets/banners/1.jpg'   // 滑塊圖地址大小
           }
           //...
        ],
        latestUpdated: [                         // 新書上架
           {
               id:1,                             // 圖書編號
               title:'BookName',                 // 書名
               img_url:'/assets/covers/1.jpg',   // 封面圖地址
               authors:["作者1", ... ,"作者n"],  // 作者列表
           }
           // ...
        }
        ],
        recommended:                 // 編輯推薦,對像定義與latestUpdated相同
    }  

在多人協作開發的情況下採用Vue架構的前端開發都很自然地會與後端開發獨立,或者說是齊頭並進式地並行式開發更為貼切。要確保前後端開發的一致性最關鍵是控制接口 。因為它們是前端與後端關鍵結合點,API接口的任何變化都會導致前端與後端代碼的修改甚至是進行新的迭代。

由於前端是消化用戶需求的第一站,所以由前端來制定接口是最合適不過的了。因此,我在團隊協作式開發過程中最重要的關鍵任務就是製作上述的這一份API接口說明文件,只有它被確立之後才能真正地實現前後端的協作式開發。

對於較小的項目我們的做法是將所有的文檔保存到項目根目錄下的docs內,同時也納入到Git的源碼管理中,方便平時查閱。而對於規模較大的項目我們會使用GitBook編寫一份更加完整的手冊,文檔在設計時編寫是最容易的,如果到項目驗收時才補充一定會有疏漏,不要讓文檔成為開發人員的技術債務。

4.5 從服務端獲取數據

有了文檔的定義,接下來就要實現從/api/home這個地址上獲取數據了。Vue的標準庫並沒有提供訪問遠程服務器(AJAX)功能,所以我們需要安裝另一個庫——vue-resource。vue-resource並不是Vue官方提供的庫,而是由Pagekit(https://github.com/pagekit)團隊所開發的,它的體積小,學習成本低,是Vue項目中用於訪問遠程服務器的一個優秀的第三方庫。

在講述vue-resource之前,先用最傳統的方法來獲取數據,然後再用vue-resource改寫這個過程。這樣做的目的是不想打斷我們現在的編程思路,因為vue-resource的使用還涉及它的安裝與配置等用法,因此先用jQuery.ajax的方式來編寫這個數據獲取的方法。

在Home頁的created鉤子內加入以下代碼來獲取data內的數據:

    export default {
       data  {
          return {
             announcement:'',
             slides:,
             latestUpdated: ,
             recommended : 
          }
       },
       created  {
          var self = this
          $.get('/api/home').then(res => {
             self.announcement = res.announcement
             self.slides = res.slides
             self.latestUpdated = res.latestUpdated
             self.recommended = res.recommanded
          })
       }
       // ... 省略
    }  

如果使用jQuery的話就需要引入jQuery內很多我們並不需要的內容(我們根本就不直接操作DOM,jQuery在此一點用處都沒有)。這樣將增大編譯後的文件大小,最終發佈包 的大小會影響下載速度,從而降低用戶的使用體驗。其次,從上述代碼中可見,我們需要用一個self變量來「hold」住當前的Vue對像實例,這未免讓代碼顯得很糟糕。而用vue-resource這個庫的話,就可以規避掉使用jQuery所帶來的這兩個壞處。

● vue-resource插件具有以下特點:

● 體積小——vue-resource非常小巧,壓縮以後大約只有12KB,服務端啟用gzip壓縮後只有4.5KB大小,這遠比jQuery的體積要小得多。

支持主流的瀏覽器——和Vue.js一樣,vue-resource除了不支持IE9以下的瀏覽器,其他主流的瀏覽器都支持。

支持Promise API和URI Templates——Promise是ES6的特性,Promise的中文含義為「承諾」,Promise對像用於異步計算。URI Templates表示URI模板,有些類似於ASP.NET MVC的路由模板。

支持攔截器——攔截器是全局的,攔截器可以在請求發送前和發送請求後做一些處理。攔截器在一些場景下會非常有用,比如請求發送前在headers中設置access_token,或者在請求失敗時,提供共通的處理方式。

安裝

我們可以用以下命令將v-resource安裝到本地的開發環境中:

    $ npm i vue-resource -D  

vue-resource是一個Vue的插件,在安裝完成後需要在main.js文件內載入這個插件,代碼如下所示。

    import Vue from 'vue'
    import VueResource from 'vue-resource'

    Vue.use(VueResource)  

對於那些不能處理REST/HTTP請求方法的老舊瀏覽器(例如IE6),vue-resource可以打開emulateHTTP開關,以取得兼容的支持:

        Vue.http.options.emulateHTTP = true  

通常RESTful API的一個約定俗成的規則是API的地址都以/api或/rest為資源根目錄,我們在此也採用此約定。為了在調用時省下更多的代碼,我們可以在Vue的實例配置內對HTTP進行配置:

    new Vue({
      http: {
        root: '/api',     // 指定資源根目錄
        

headers: {}       // 添加自定義的http頭變量
      },
      // ... 省略
    })  

headers參數用於對發出的請求的頭內容進行重寫與自定義,例如加入驗證信息或者代理信息等。

使用use方法引入vue-resource後,vue-resource就會向Vue的根實例「注入」一個$http的對象,那麼我們就可以在所有Vue實例內通過this.$http來引用它,它的用法與jQuery幾乎一樣,很容易上手。將前文的代碼使用vue-resouce來改寫:

    export default {
        data  {
            return {
               announcement:'',
               slides:,
               latestUpdated: ,
               recommended : 
            }
        },
        created  {
            // HTTP GET /api/home
            this.$http.get('/home').then(res=> {
               this.announcement = res.body.announcement
               this.slides = res.body.slides
               this.latestUpdated = res.body.latestUpdated
               this.recommended = res.body.recommanded
            })
        }
        ...
    }  

vue-resouce的一個最大的好處是它會自動為我們在異步成功調用返回後將Vue實例注入到回調方法中,這樣我們就不需要額外地去用另一個變量來「hold住」this了。

我們還可以讓代碼變得更加簡潔一些:

    this.$http.get('/api/home')
             .then((res) => {
                  for prop in res.body {
                     this[prop] = res.body[prop]
                  }
             },(error)=> {
                  console.log(`獲取數據失敗:${error}`)
             })  

附加說明:$http API參考

對應常用的HTTP方法,vue-resource在$http對像上提供了以下包裝方法:

● get(url, [options])

● head(url, [options])

● delete(url, [options])

● jsonp(url, [options])

● post(url, [body], [options])

● put(url, [body], [options])

● patch(url, [body], [options])

options對像參考:

參 數 類 型 描 述 url String 請求的URL body Object, FormData, string 寫入請求body屬性的數據對像(一般用於發送表單對像) headers Object 重寫發出請求的HTTP頭對像變量 params Object 用作生成帶參數URL的參數對像 method String HTTP方法(例如GET, POST, …) timeout Number 單位為毫秒的請求超時時間(0表示無超時時間) before function(request) 請求發送前的處理函數,類似於jQuery的beforeSend函數 progress function(event) 上傳數據時的ProgressEvent事件的處理函數(用於計算上傳進度) credentials Boolean 是否應使用憑據進行跨站點訪問控制請求 emulateHTTP Boolean 發送PUT、PATCH、DELETE請求時以HTTP POST的方式發送,並設置請求頭的X-HTTP-Method-Override emulateJSON Boolean 將request.body的內容以application/x-www-form-urlencoded編碼方式發送

回調參數response對像參考:

屬性說明
屬 性 類 型 描 述 url String 原請求的URL地址 body Object, Blob, string 響應對象的body內容 headers Header 響應的請求頭對像 ok Boolean 響應的HTTP狀態碼在200~299之間時,該屬性為true status Number 響應的HTTP狀態碼 statusText String 響應的狀態文本
方法說明
方 法 類 型 描 述 text Promise 以string形式返回response.body json Promise 以JSON對像形式返回response.body blob Promise 以二進制形式返回response.body,多用於從響應對像中直接以流的方式讀取文件內容或圖片數據

4.6 創建複合型的模板組件

Home頁組件內還有一個方法沒有實現,那就是preview,也就是當用戶點擊圖書時彈出的一個模態窗口,如右圖所示。

對於這個應用場景,應用之前先創建一個組件頁,然後加入代碼再重構的實現思路顯然不可行,我們必須先實現一個模態窗口組件才能在其上放置顯示圖書詳情的元素以及實現添加購物車和立即購買的功能。

這個模態窗口組件有一個特殊的地方,就是它自身是一個容器,我們需要在這個容器所提供的特定區域內放置其他的DOM元素或者組件。此時我們可以認為模態窗口組件就是一個複合型組件。Vue可以通過一特殊的指令標記來在組件模板內「劃」出一個獨立的區域讓調用方(父組件)可以向模態窗口組件中插入新的代碼,以擴充其功能。這種對外提供插入能力的指令就是所謂的「插槽」,也就是<slot>。

新建一個src/components/dialog.vue文件,具體內容如下:

    <template>
       <p>
          <p>
             <!-- 頭部及標題 -->
             

<slot name="header"></slot>
          </p>
          <p>
             <!-- 內容區域 -->
             <slot></slot>
          </p>
       </p>
    </template>
    <style>
      .dialog {
        position: absolute;
        top: 24px;
        left: 24px;
        right: 24px;
        bottom: 24px;
        display: none;
        background: #fff;
        box-shadow: 0 0 10px rgba(0,0,0,.5);
        z-index: 500; /*放置於頂層*/
      }
    </style>
    <script>
       export default {}
    </script>  

這裡同時採用了組件插槽與命名插槽兩種方式,默認插槽也就是直接在組件模板內放置<slot></slot>,那麼當外部使用此組件時,在組件標記內的元素都會被自動插入到默認插槽中,這是一個很簡潔的用法!一個組件只能擁有一個默認插槽,其他的插槽則需要採用name屬性進行命名,在使用的時候也需要對插槽進行聲明。

插槽本身只是一個佔位標記,當組件渲染時,<slot></slot>標記自身並不會輸出任何DOM元素。

我們馬上在Home頁內引入這個模態窗口組件來試試它的用法。

首先,引入dialog.vue組件,並進行子組件註冊操作:

    import ModalDialog from "./components/dialog.vue"
    export default {
       ...
       components: {
          ModalDialog
       }
    }  

然後在<template>內加入<modal-dialog>:

    <template>
       <p>
         ...
         <modal-dialog>
             <p slot="header">此處是header插槽的內容</p>
             <p>這個DIV將自動默認插槽的內容</p>
         </modal-dialog>
       </p>
    </template>  

我們將modal-dialog設計為默認情況下是不顯示的,所以我們需要給它加入一些方法,讓Home頁能通過編程方式對其進行顯示或隱藏的控制。

在dialog.vue組件中加入open和close方法對:

    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

           }
       }
    }  

在CSS中加入.open樣式類:

    <style>
    .dialog { ... }
    

.dialog.open {
       display: block;
    }
    </style> 

最後在dialog.vue的頂層元素內加入class屬性開關切換:

    <template>
        <p
            @class="{ 'open': is_poen }">
        </p>
    </template>  

模態對話框組件就宣告完成了。

以下為dialog.vue完整代碼:

    <template>
        <p
            :class="{'open':is_open}">
            <p @click="close"></p>
            <p>
                <p>
                    <slot name="heading"></slot>
                </p>
                <slot></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>  

樣式表dialog.less的代碼:

    .dialog-wrapper {
        &.open {
            display:block;
        }
        height: 100%;
        display:none;
        &>.overlay {
            background: rgba(0, 0, 0, 0.3);
            z-index: 1;
            position: absolute;
            left: 0px;
            top: 0;
            right: 0;
            bottom: 0;
        }

        &>.dialog {
            z-index: 10;
            background: #fff;
            position: fixed;
            top: 24px;
            left: 24px;
            right: 24px;
            bottom: 24px;
            padding: 24px 14px;
            box-shadow: 0 0 10px rgba(0, 0, 0, .8);
            & heading {
                padding: 12px;
            }
        }
    }  

接下來在Home頁組件對modal-dialog內增加引用聲明和事件處理:

    <modal-dialog ref="dialog"
                @dialogClose="selected=undefined">
        <p slot="header">
            <p
                @click.prevent="$refs.dialog.close"></p>
        </p>
        <p>
            <img :src="https://p.2015txt.com/selected.img_url">
        </p>
        <p>
            {{ selected.title }}
            ...
        </p>
    </modal-dialog>  

最後完成preview方法:

    export default {
           data  {
              return {
                  // ... 省略
                  selected:undefined
              }
           },
           methods: {
              preview (book) {
                  this.selected = book
                  this.$refs.dialog.open
              },
              // ... 省略
           },
           // ... 省略
    }  

4.7 數據模擬

雖然Home組件的代碼實現已經完成,而且已經通過vue-resource接入了與服務端通信的功能,但現在是一個純前端的開發環境,並沒有可以被訪問的後端服務,那麼如何讓我們的程序獲取服務端的數據呢?此時我們就需要運用另一種技術來解決這個問題了,這個技術就是「數據模擬」(或者稱數據仿真)。

「數據模擬」就是用一個對像直接模擬服務端的實現返回的數據結果,而這些數據結果是我們預先採樣收集來的,與真實運行數據幾乎是一樣的。通過數據模擬保證前端程序即使在沒有服務端支持的情況下也能運行。

在前文中已做了一個小小的鋪墊,就是將data的樣本數據抽取出來保存到了~/fixtures/home/home.json文件中。為了能先讓開發環境運行起來,我們可以加入一些助手類來模擬實際的運行數據。

我們需要定義一個獲取模擬數據的對象faker,在項目中所有模擬數據都通過它來獲取。

    // ~/fixtures/faker.js
    import HomePageData from "./home.json"

    var slider_images = require.context('./sliders', false,/\.(png|jpg|gif|svg)$/)
    var cover_images = require.context('./covers', false,/\.(png|jpg|gif|svg)$/)

    HomePageData.top.forEach((x)=> {
       x.img_url = slider_images('./' + x.img_url)
    })

    HomePageData.promotions.forEach((x)=> {
       x.img_url = cover_images('./' + x.img_url)
    })

    export default {
       getHomeData {
          return HomePageData
        }
    }  

這個faker使用了一個動態加載圖片的技巧,將一個指定目錄下的所有文件全部加載到一個模塊方法中,然後通過具體名稱返回它在webpack編譯加載後的真實地址。不使用「../assets/sliders/圖片.png」相對路徑的方式引用,是因為webpack將程序編譯並加載到開發服務器後,這些圖片地址的真實路徑並不是指向~/src/assets目錄的,因此我們要用require.context函數將編譯後的資源作為一個模塊加載進來,然後再通過名稱獲取其正確的地址。

    var slider_images = require.context('./sliders', false,/\.(png|jpg|gif|svg)$/)

    slider_images('./1.png') // =>獲取真正的/sliders/1.png的地址  

在home頁面組件中,在created鉤子方法內加入faker,我們可以加入一個開關變量debug用於判斷當前運行環境是否為開發環境,如果是則使用數據模擬方式獲取數據。

    import faker from "../fixtures/faker"

    // 判斷當前環境是否是開發環境
    const debug = process.env.NODE_ENV !== 'production'

    export default {
        data  {
          // ... 省略
        },
        created  {
          if (debug) {

            const fakeData = faker.getHomeData
            for prop in fakeData {
              this[prop] = fakeData[prop]
            }

          } else {
            this.$http.get('/api/home')
                    .then((res) => {
                         for prop in res.body {
                           this[prop] = res.body[prop]
                         }
                    },(error)=> {
                         console.log(`獲取數據失敗:${error}`)
                    })
            }
        },

        // ... 省略

    }  

現在我們就可以在終端運行$ npm run dev查看本示例的完整運行效果了。

4.8 小結

將本章中整個示例的實現過程畫成一個工作流程圖的話,你將會很清楚開發一個頁面 組件應該執行哪些步驟了:

(1)依葫蘆畫瓢 ——拿到界面設計圖後無須思考太多,先用框架圈出功能區塊,然後直接編寫視圖的HTML。

(2)代碼去重 ——將視圖模板中不斷重複的邏輯封裝為組件,減少頁面的重複邏輯。

(3)抽取數據結構 ——將頁面中的文字用數據對象與數組取代,並制定數據結構的說明文檔。

(4)採集與製作樣本數據 ——參照數據結構說明文檔採集更多的真實樣本,切忌胡亂地敲入一些字符,在數據不明確的情況下可能會遮蓋一些本應很明顯的使用需求。

(5)分析設計組件接口 ——簡化組件的使用接口,讓組件變得更好用。

(6)組件內部的細化與重構 ——優化組件的內部實現,使其變得更合理。

4.9 擴展閱讀:Vue組件的繼承——mixin

Vue開發是一種面向組件的開發,而面向組件開發的本質即是面向對象。既然是面向對象就離不開抽像、封裝與繼承三大基本特性。其實在前文中多處提及的眾多的組件編寫方法與分析,總結起來也不過是不斷地對組件進行抽像與封裝,力求使每個組件能盡量與外部保持一定的獨立性,以達到自容納(Self contains)和服務自治(Self services)的效果,這樣做的目的就是最大限度地增加組件的可重用性,減少代碼的重複。

三大特性中的繼承卻一點沒有提及,JavaScript一直被很多初學者詬病面向對像能力差,事實並非如此!自從原型模式被完全引入ES後,JS就具有完整的面向對像能力,並由於弱類型語言的特性令其開發的靈活度更優於一些傳統的純面向對像語言。在ES6的語言特性改善後就更為強大,ES6已經可以和一些面向對像語言一樣編寫類和進行類之間的繼承。雖然Vue2官方推薦我們採用ES6作為開發的主語言,但實質上並沒有在Vue中應用語言級別的繼承用法,而是選擇了另一種聰明的做法:混合。

混合(mixins)

混合是一種靈活的分佈式復用Vue組件的方式。混合對象可以包含任意組件選項。以 組件使用混合對像時,所有混合對象的選項將被混入該組件本身的選項。之所以說「混合」是一種聰明的做法,是因為這種方式更適合於JS的開發思維。首先,繼承雖然是面向對象的三大基本特性之一,也是極為常用的構造類庫的方法,可惜的是它往往被濫用。例如,當繼承深度超過三代以後,類族就會變得極為龐大,子類中往往存在大量毫無作用的祖先類中遺留的特性或者方法,可以想像到這樣的類庫必然臃腫不堪難以維護。其次,JS天生就是個弱類型語言,強類型化的繼承方式給JS的開發帶來的麻煩會比較多。如果既要使用繼承又希望避開由於繼承造成的複雜的多態性,復合/混合是一種非常不錯的解決方案,這個概念在Ruby中也很常用。所以說我非常喜歡Vue採用混合的方式來實現公共特性的共用而不是採用繼承。

在我參與的幾個面向商業應用的Vue項目中,使用到「混合」的場景其實並不多,這是因為通過複合型的Vue組件已經可以去除掉大量的重複性,而在開發一些基礎性的界面的套件時,「混合」就顯得很重要了。例如,在v-uikit項目(http://www.github.com/dotnetage.com/vue-ui)中就遇到這樣的一個場景,當開發列表控件uk-list和下拉列表控件uk-dropdown-list時,發現它們的界面實現完全不同,但代碼邏輯卻是相同的。首先,來看看uk-list控件原來的代碼:

    <template>
      <ul :class="{
        'uk-list':true,
        'uk-list-line':showLine,
        'uk-list-striped':striped,
        'uk-list-space':space
      }">
        <li v-for="item in listItems"
           @click.prevent="selectItem(item)">{{ item[textField] }}</li>
      </ul>
    </template>
    <script>
    export default {
      props: {
        showLine: {
          type: Boolean,
          default: false
        },
        space: {
          type: Boolean,
          default: false
        },
        striped: {
          

type: Boolean,
          default: false
        }
      },
      items: {
        type: Array,
        default:  => 
      },
      textField: {
        type: String,
        default: 'label'
      },
      valueField: {
        type: String,
        default: 'value'
      },
      data  {
        return {
          selectedItem: undefined
        }
      },
      computed: {
        selectedValue {
          return this.selectedItem ? this.selectedItem[this.valueField] : ''
        },
        listItems  {
          if (this.items && this.items.length) {
            const t = typeof(this.items[0])
            if (t === 'string' || t === 'number') {
              return this.items.map((i) => {
                const obj = {}
                obj[this.textField] = i
                obj[this.valueField] = i
                return obj
              })
            }
          }

          return this.items
        }
      },
      methods: {
        selectItem(item) {
          this.selectedItem = item
          

this.$emit('selectedChange', item)
        }
      }
    }
    </script>  

這個控件是將一個JS數組用UIkit的列表樣式展現出來,支持點擊選擇並發出selectedChange事件,以提供給其他的界面控件使用。

然後是uk-dropdown-list,這個控件將一組下拉列表包裝至任意的容器類元素上,使其具有一個下拉菜單的功能。例如在下拉件組內放入一個按鈕,點擊這個按鈕時在其下方就會出現一個下拉菜單。這個組件的代碼如下:

    <template>
      <p data-uk-dropdown="{mode:'click'}"
         >
       <slot></slot>
       <p>
         <ul>
           <li v-for="item in listItems"
               :class="{'uk-nav-header':item.isHeader}">
             <a
@click.prevent="selectItem(item)">{{ item[textField] }}</a></li>
          </ul>
        </p>
      </p>
    </template>
    <script>
    export default {
      props: {
        items: {
         type: Array,
         default:  => 
        },
        textField: {
         type: String,
         default: 'label'
        },
        valueField: {
         type: String,
         default: 'value'
        }
      },
      data  {
        

return {
          selectedItem: undefined
        }
      },
      computed: {
        selectedValue {
          return this.selectedItem ? this.selectedItem[this.valueField]: ''
        },
        listItems  {
          if (this.items && this.items.length) {
            const t = typeof(this.items[0])
            if (t === 'string' || t === 'number') {
             return this.items.map((i) => {
               const obj = {}
               obj[this.textField] = i
               obj[this.valueField] = i
               return obj
             })
            }
          }

          return this.items
        }
      },
      methods: {
        selectItem(item) {
          this.selectedItem = item
          this.$emit('selectedChange', item)
        }
      }
    }
    </script>  

這兩個組件在交互處理的邏輯上有很大一部分是相同的,或者說它們的控制部分應該是從一個組件中繼承下來的。這個時候我們就可以用Vue的mixins實現這種功能性的混合。首先將兩個控件中完全相同的部分提取出來,做成一個BaseListMixin.js的組件:

    export default {
      props: {
        items: {
          type: Array,
          default:  => 
        },
        textField: {
          

type: String,
          default: 'label'
        },
        valueField: {
          type: String,
          default: 'value'
        }
      },
      data  {
        return {
          selectedItem: undefined
        }
      },
      computed: {
        selectedValue {
          return this.selectedItem ? this.selectedItem[this.valueField] : ''
        },
        listItems  {
          if (this.items && this.items.length) {
            const t = typeof(this.items[0])
            if (t === 'string' || t === 'number') {
              return this.items.map((i) => {
                const obj = {}
                obj[this.textField] = i
                obj[this.valueField] = i
                return obj
              })
            }
          }

          return this.items
        }
      },
      methods: {
        selectItem(item) {
          this.selectedItem = item
          this.$emit('selectedChange', item)
        }
      }
    }  

然後將uk-list和uk-dropdown-list中相同的代碼刪除,用mixins引入BaseMixinList類,這樣在BaseMixinList中定義的屬性(props)、方法(methods)、計算屬性等所有的Vue組件內允許定義的字段都會被混合到新的組件中,其效果就如類繼承。

uk-list的代碼就變為:

    <script>
      import BaseListMixin from './BaseListMixin'
      export default {
        mixins: [BaseListMixin],
        props: {
          showLine: {
            type: Boolean,
            default: false
          },
          space: {
            type: Boolean,
            default: false
          },
          striped: {
            type: Boolean,
            default: false
          }
        }
      }
    </script>  
ul-dropdown-list的代碼變為:
    <script>
      import BaseListMixin from './BaseListMixin'
      export default {
        name: 'UkDropdown',
        mixins: [BaseListMixin]
      }
    </script>  

混合比繼承好的地方就是一個Vue組件類可以與多個不同的組件進行混合(mixins是一個數組,可以同時聲明多個混合類),復合出新的組件類。而大多數的繼承都是單根模式(從一個父類繼承)的,同時由於JS是弱類型語言,語言解釋引擎並不需要強制地瞭解每個實例來源於哪一個類才能進行實例化,由此就產生了無限的可能和極大的組件構型的靈活性。