vue-music 音樂 App 之 cube-ui 重構

黃軼發表於2019-03-04

背景

去年 6 月初,我在慕課網上線了一門 Vue.js 2.0 的高階實戰課程音樂 WebApp 課程,教同學們如何去開發基礎元件和業務元件。在一般大公司的實際專案中,並不會為每一個專案都去開發基礎元件,他們往往會把基礎元件收斂成一個元件庫,供各個專案複用。滴滴也是如此,我們在去年初使用 Vue.js 去重構了我們的叫車 WebApp,也抽象出了一套移動端元件庫,在經過一年多的業務考驗後,我們決定做開源,一方面是想把好的東西分享出去,並通過社群的反饋去完善我們的元件庫;另一方面也是想讓大家瞭解滴滴的前端,能吸引一些優秀的人才加入滴滴。於是在去年的 11 月份,我們團隊開源了 cube-ui,到現在為止收到的反饋還算不錯,也陸續有一些同學在生產環境也開始使用。

cube-ui 和其它同型別的開源元件庫有一個很大的不同,它內部了使用了一個我們團隊玩出來的“後編譯”技術,它能幫我們玩出很多花樣,比如減少元件包體積、支援 rem、支援自定義元件顏色等等,但帶來好處的同時也會有一些不便(webpack 的配置會略顯複雜),因此我們團隊也為 cube-ui 在 vue-cli 的基礎上擴充套件了一套腳手架,方便大家開箱即用。

其實相對於 PC 端的元件庫,移動端元件庫有一個比較大的不同就是定製化要求較高。比如做 PC 端的 MIS 類的專案,如果使用 Vue 技術棧,大家往往會選擇 element 或者是 iview,幾乎都是拿來即用,最多換一下主題,很少會摳元件的細節,因為 MIS 類的專案是 to b 的,很多也是內部人員使用,所以對一些細節的要求並不高。而對於移動端專案,往往都是 to c 的,都有專門的 UI 設計,很少有完全符合要求的現成元件庫能拿來用,所以 cube-ui 儘量提供一些通用性強的元件,並提供了自定義元件顏色的能力、和元件擴充套件能力,目的是讓使用方 cube-ui 的基礎上做二次開發,去滿足自己的定製化需求。

因為畢竟 cube-ui 是從滴滴的業務中抽象出來的,在做滴滴相關業務的時候,這些元件都能很好的滿足需求,但是換成一個新的專案,cube-ui 好不好用呢,於是我想到了我的音樂課程專案,它有一些基礎元件是可以從 cube-ui 裡拿的,但是整體的配色風格和 cube-ui 的預設配色又完全不一樣,正好可以來檢驗一波,接下來我分享一下 cube-ui 重構音樂課程專案的經驗。

Webpack 配置修改

由於我們是現有專案,並不能使用腳手架去初始化專案,所以我們需要根據官網的文件去做 webpack 的相關配置。這裡我要稍微提醒一些同學,在使用一個開源專案的時候,最好的方式就是閱讀它的文件,遇到問題首先想的是檢視它的 issue。那麼 cube-ui 的文件在這裡,我們來看一下快速上手部分。

安裝 cube-ui

首先需要安裝 cube-ui,這塊很簡單,直接執行命令就好了。

npm install cube-ui --save
複製程式碼

後編譯配置

後編譯簡單的理解就是把編譯工作交給應用來完成,也就是使用 cube-ui 的專案vue-music 來完成編譯。由於是現成的專案,我們不能用腳手架初始化專案,那麼所有的後編譯相關的 webpack 配置都需要自己來動手,接下來我會一邊教大家配置,一邊來解釋這些配置的作用。

修改 package.json 並安裝依賴

{
  // webpack-post-compile-plugin 依賴 compileDependencies
  "compileDependencies": ["cube-ui"],
  "devDependencies": {
    "babel-plugin-transform-modules": "^0.1.0",
    // 新增 stylus 相關依賴
    "stylus": "^0.54.5",
    "stylus-loader": "^2.1.1",
    "webpack-post-compile-plugin": "^0.1.2"
  }
}
複製程式碼

首先需要修改的是 package.json 檔案,我們需要在 devDependencies 新增幾個外掛,先簡單對它們做一些介紹。

stylusstylus-loader 是為了編譯 stylus 檔案用的,因為 cube-ui 原始碼的 css 部分使用了 stylus 前處理器。

  • webpack-post-compile-plugin

webpack-post-compile-plugin 是為了解決後編譯巢狀問題編寫的 webpack 外掛,因為在預設情況下,webpack 是不會編譯 node_modules目錄下的模組的,而我們的 cube-ui 是安裝在 node_modules 下的,為了編譯它,需要在 webpack 配置檔案中顯示地宣告 include 指向 node_modules 下的 cube-ui,例如:

module: {
  rules: [
    {
      test: /\.js$/,
      loader: 'babel-loader',
      include: [resolve('src'), resolve('node_modules/cube-ui')]
    },
    // ...
  ]
 }    
複製程式碼

但這裡會有一個問題,如果 cube-ui 一旦也後編譯依賴其它模組,作為編譯的應用方也需要把它們顯示地寫進 include 裡,但這顯然是不合理的,因為應用不應該知道 cube-ui 依賴的模組,每個模組只應該宣告它自身的後編譯依賴即可。那麼 webpack-post-compile-plugin 就是來解決這個問題的,它會讀取每個模組 package.json 檔案中宣告的 compileDependencies,並遞迴去查詢後編譯依賴,然後新增到應用 webpack 配置的 include 中,所以在我們應用專案中的 package.json 檔案中,我們指定了 compileDependencies[cube-ui]

修改 .babelrc

{
  "plugins": [
    ["transform-modules", {
      "cube-ui": {
        // 注意: 這裡的路徑需要修改到 src/modules 下
        "transform": "cube-ui/src/modules/${member}",
        "kebabCase": true
      }
    }]
  ]
}
複製程式碼

這個配置項是為了配合 babel-plugin-transform-modules 使用的,給按需引入提供了一個語法糖。舉個例子,當我們在程式碼中按需引入 cube-ui 的元件,如:

import { Button } from 'cube-ui'
複製程式碼

相當於:

import Button from 'cube-ui/src/modules/button'
複製程式碼

因為是引入原始碼,所以 import 的路徑指向了 src 目錄,顯然前者的寫法比後者優雅了很多,並且一旦我們不用後編譯,也不用去修改原始碼的 import 方式,只需要修改 .babelrc 檔案即可。

修改 webpack.base.conf.js

var PostCompilePlugin = require('webpack-post-compile-plugin')
module.exports = {
  // ...
  plugins: [
    // ...
    new PostCompilePlugin()
  ]
  // ...
} 
複製程式碼

這裡就是對 webpack-post-compile-plugin 外掛的應用,把它新增到 plugins 中即可。

修改 build/utils.js 中的 exports.cssLoaders 函式

exports.cssLoaders = function (options) {
  // ...
  const stylusOptions = {
    'resolve url': true
  }
  // https://vue-loader.vuejs.org/en/configurations/extract-css.html
  return {
    css: generateLoaders(),
    postcss: generateLoaders(),
    less: generateLoaders('less'),
    sass: generateLoaders('sass', { indentedSyntax: true }),
    scss: generateLoaders('sass'),
    stylus: generateLoaders('stylus', stylusOptions),
    styl: generateLoaders('stylus', stylusOptions)
  }
}
複製程式碼

這裡了一個 stylus 的配置項 'resovle url':true,目的是為了解決被引入的 stylus 檔案再去引入資源的相對路徑的問題,參考官方文件

修改 vue-loader.conf.js

module.exports = {
  loaders: utils.cssLoaders({
    sourceMap: sourceMapEnabled,
    extract: false
  }),
  // ...
}
複製程式碼

這裡需要強制指定 css-loader 的選項 extract 為 false,否則我們通過 npm run build 編譯後的專案非同步載入 vue 元件會有問題。

那麼到這裡,後編譯的 webpack 配置就告一段落了,核心思想就是讓我們的應用引入 cube-ui 的原始碼,並且接管 cube-ui 的編譯工作。

Vue-music 原始碼修改

這篇文章我不會把所有程式碼的修改都 forEach 一遍,那樣太浪費時間,我會挑重點的地方講,具體的修改都可以在專案程式碼的 use-cube-ui 分支裡看到。這裡我想強調一下,我的專案程式碼託管在 GitHub 私倉,並不開源,只有購買正版課程的學生才能訪問,那些不知道從哪些途徑搞到我專案初始程式碼還開源大肆宣傳的人,你們不尊重我的勞動成果看盜版視訊也就罷了,拿這個騙 star,不害臊嗎? BTW,官方正版的專案程式碼是一直維護的,並且修復了 70+ issue,如果真心想學知識的同學,花幾百塊錢買正版課程一定是物超所值。

接下來就是修改我們專案的原始碼,我們會用到 cube-ui 的基礎樣式、Scroll 滾動元件、Slide 輪播圖元件、IndexList 索引列表元件以及 createAPI 模組去把我們已有的 Confirm 元件變成 API 式的呼叫。我們會在 main.js 裡引用這些元件和模組:

import {
  Style,
  IndexList,
  Scroll,
  Slide,
  createAPI
} from 'cube-ui'

import Confirm from 'base/confirm/confirm.vue'

Vue.use(IndexList)
Vue.use(Scroll)
Vue.use(Slide)

createAPI(Vue, Confirm, ['confirm', 'click'], true)
複製程式碼

這裡我們會 import Style,它的作用是引入 cube-ui 提供的一些 reset 樣式、基礎樣式和字型圖示樣式,那麼對於我們的專案,就可以把 reset 樣式移除了。

對於元件的引用我們會使用 Vue.use 註冊外掛的方式,它內部會呼叫 Vue.component 全域性註冊元件,這樣我們就可以在任何元件內部裡使用這些元件了。

createAPI 是把我們之前宣告式的元件使用方式改變成 API 式的呼叫,這塊兒稍後我們會詳細說明。

IndexList 元件修改

音樂 App 的歌手頁面有一個歌手列表,如下圖所示:

singer

它恰好可以使用 cube-ui 提供的 IndexList 元件,在我的教學課程中,我也是把它單獨抽象出來的一個基礎元件,所以替換就變的很容易了。

學會使用一個元件,最好的方式就是看它的文件。cube-ui 提供的 IndexList 樣式如下:

indexlist

可以看到相對於 cube-ui 的 IndexList,我們的歌手頁面的背景顏色、列表的樣式都有所不同,幸好 cube-ui 支援自定義元件顏色和 IndexList 的插槽功能,我們可以很好的解決這兩個問題。

  • 修改 IndexList 元件的顏色

cube-ui 提供了自定義元件顏色的能力,我們開啟它的文件,實際上只需要做兩件事情。 首先在 src 目錄下新建 theme.styl 檔案,然後填入如下程式碼:

@import "./common/stylus/variable.styl"

// index-list
$index-list-bgc := $color-background
$index-list-anchor-color := $color-text-l
$index-list-anchor-bgc := $color-highlight-background
$index-list-nav-color := $color-text-l
$index-list-nav-active-color := $color-theme
複製程式碼

這裡我們用到了 stylus 的一個條件賦值的語法,它會先判斷有沒有對這個變數賦值,如果已經賦值了,則不會去覆蓋這個變數的值。那麼這裡我們引入了 vue-music 專案中對於顏色定義的一些變數,把它賦值給了 cube-ui 關於 IndexList 元件所引用的一些顏色變數。

接下來配置 webpack,修改 build/utils.js 裡的 exports.cssLoaders 函式中的 stylusOptions

 const stylusOptions = {
    'resolve url': true,
    // 這裡 新增 import 配置項,指向自定義主題檔案
    import: [path.resolve(__dirname, '../src/theme')]
  }
複製程式碼

這裡通過配置 stylus 選項,新增 import 配置項指向我們剛才建立的 theme.styl 檔案,可以達到的效果是在 stylus 的編譯過程中,對每一個 .styl 檔案以及 .vue 中的 stylus 部分都優先 import 這個主題檔案,這樣就實現了元件顏色的自定義,會優先使用我們在 theme.styl 檔案中的顏色。

  • 自定義 IndexList 的插槽

由於我們的列表項是圖文混排的佈局,和預設的樣式不一樣,因此我們需要用到插槽來自定義列表項佈局,參考文件,我們對模板程式碼的修改如下:

<template>
  <div class="singer" ref="singer">
    <cube-index-list :data="singers" ref="list">
      <cube-index-list-group v-for="(group, index) in singers" :key="index" :group="group" class="list-group">
        <cube-index-list-item v-for="(item, index) in group.items" :key="index" :item="item" @select="selectSinger" class="list-group-item">
          <img class="avatar" v-lazy="item.avatar">
          <span class="name">{{item.name}}</span>
        </cube-index-list-item>
      </cube-index-list-group>
    </cube-index-list>
    <router-view></router-view>
  </div>
</template>
複製程式碼

我們使用 cube-ui 提供的 cube-index-list-groupcube-index-list-item 做二重迴圈,因為是元件的迴圈,所以迴圈的過程中需要設定 key。這裡有個地方需要注意一下,我們給 IndexList 元件傳的資料是 singers,而 singers 的資料結構是有要求的,它本身是一個陣列,對於陣列的每一項,它有組名 name 和資料項 items。這個欄位名和我們專案之前定義的略微不同,所以我們在處理從服務端拿到的歌手資料的時候,需要構造符合 IndexList 約定的資料結構。

最後還有一處細節的修改,我們專案中的每一組的標題樣式和 cube-ui 的 IndexList 略微不同,可以通過覆蓋 CSS 的方式對樣式做修改。

.singer
  .cube-index-list-anchor
    padding: 8px 0 8px 20px
複製程式碼

這裡要注意的是,一旦我們要覆蓋某個子元件的樣式,那麼引用該子元件的父元件(在我們這個 case 是 Singer 元件)樣式部分就不能使用 scoped 特性,因為如果設定了 scoped,Vue 在初始化的過程中會給元件的樣式加上屬性 id,那麼就不能夠覆蓋 cube-ui 中的元件樣式了。

Slide 元件修改

音樂 App 的推薦頁面用到了輪播圖,如下圖所示:

slide
在我們的專案中已經封裝了輪播圖元件,它恰好可以使用 cube-ui 的 Slide 元件無縫替換,同樣的我們來看一下 Slide 元件 的文件,修改程式碼如下:

 <cube-slide ref="slider">
   <cube-slide-item v-for="(item,index) in recommends" :key="index">
     <a :href="item.linkUrl">
       <img @load="loadImage" :src="item.picUrl">
     </a>
   </cube-slide-item>
   <template slot="dots" slot-scope="props">
     <span class="dot" :class="{active: props.current === index}" v-for="(item, index) in props.dots"></span>
   </template>
 </cube-slide>
複製程式碼

對於 Slide 元件內部的元素,我們用 cube-slide-item 元件來做迴圈,由於底部的 dots 樣式很不一樣,我們使用了作用域插槽,因為需要根據子元件的 current 來決定它渲染的 active 樣式;並且我們想讓 dots 的位置向上偏移,所以我們依然採用覆蓋 CSS 的方式:

.recommend
  .cube-slide-dots
    bottom: 12px
複製程式碼

同樣,我們也需要把 Recommend 元件 stylus 部分的 scoped 移除。

Scroll 元件修改

音樂 App 專案在 better-scroll 的基礎上外掛封裝了 Scroll 元件,並在專案中大量應用,比如推薦頁面、歌手詳情頁、搜尋頁面、歌曲列表、甚至是歌詞列表。cube-ui 中也基於 better-scroll 封裝了 Scroll 元件,它的功能更完善,所以我們決定替換 Scroll 元件。

Scroll 元件在專案中應用的地方非常多,這裡我挑一個比較有代表性的場景,就是搜尋頁面的 Suggest 元件,如下所圖所示:

suggest
Suggest 元件下方的列表是根據檢索的關鍵詞動態渲染的,它不僅可以區域性滾動,還有一個上拉載入的功能,它就是移動端場景下分頁功能的實現。我們完全可以用 cube-ui 的 Scroll 元件來實現它,同樣我們也是先去閱讀它的文件,然後做如下程式碼的修改:

 <cube-scroll ref="suggest"
             :data="result"
             :options="scrollOptions"
             @pulling-up="searchMore"
>
  <ul class="suggest-list">
    <li @click="selectItem(item)" class="suggest-item" v-for="item in result">
      <div class="icon">
        <i :class="getIconCls(item)"></i>
      </div>
      <div class="name">
        <p class="text" v-html="getDisplayName(item)"></p>
      </div>
    </li>
  </ul>
</cube-scroll>

<script type="text/ecmascript-6">
  // ...
  export default {
    data() {
      return {
       // ...
        scrollOptions: {
          pullUpLoad: {
            threshold: 0,
            txt: ''
          }
        }
      }
    },
    methods: {
      searchMore() {
        if (!this.hasMore) {
          this.$refs.suggest.forceUpdate()
          return
        }
        this.page++
        search(this.query, this.page, this.showSinger, perpage).then((res) => {
          if (res.code === ERR_OK) {
            this.result = this.result.concat(this._genResult(res.data))
            this._checkMore(res.data)
          } else {
            this.$refs.suggest.forceUpdate()
          }
        }).catch(() => {
          this.$refs.suggest.forceUpdate()
        })
      }
      // ...
    } 
    // ...
  }
</script>


複製程式碼

這裡需要注意兩個地方,一個是 scrollOptions,另一個是 pullingUp 事件的回撥函式 searchMore

  • scrollOptions 這個引數是 better-scroll 的 options 配置,由於我們使用了上拉載入的功能,所以需要配置 pullUpLoad,這裡我們指定了 threshold 為 0,也就是剛到底部就觸發 pullingUp 事件,txt 設定為空因為在我們的專案中上拉載入不需要任何文案。

  • searchMore 這個回撥函式的作用就是根據條件去載入新的資料,如果沒有更多資料了,我們直接呼叫 this.$refs.suggest.forceUpdate() 通知 Scroll 元件結束上拉的過程,另外單次載入資料發生任何異常的時候我們也都應該呼叫一次 this.$refs.suggest.forceUpdate()

Scroll 元件在其它地方都可以直接替換,另外除了有上拉載入和下拉重新整理的場景,我們可以不給 Scroll 元件傳 data 了,因為 1.5+ 版本的 better-scroll 已經有了根據 DOM 變化在合適時機自動 refresh 的能力了。

createAPI 的應用

前面我們簡單地提到了 createAPI 的作用是把我們之前宣告式的元件使用方式改變成 API 式的呼叫,為什麼會有這樣的需求呢?我們知道 Vue 推薦的就是宣告式的元件使用方式,比如在使用一個元件 xxx,我們簡單在使用的地方宣告它就好了,就像這樣:

<tempalte>
  <xxx/>
</tempalte>
複製程式碼

對於一般元件,這樣使用並沒有問題,但對於全屏類的彈窗元件,如果在一個層級巢狀很深的子元件中使用,仍然通過宣告式的方式,很可能它的樣式會受到父元素某些 CSS 的影響導致渲染不符合預期。這類元件最好的使用方式就是掛載到 body 下,但是我們如果是宣告式地把這些元件掛載到最外層,對它們的控制也非常不靈活。其實最理想的方式是動態把這類元件掛載到 body 下,createAPI 就是幹這個事情的。

先來看一下 createAPI文件,它可以把任何元件變成 API 式的呼叫。在我們的專案中有一個 Confirm 元件,它就是一個彈窗型別的元件。cube-ui 提供了所有彈窗類元件的基類元件 Popup,如果是新增一個彈窗類元件,推薦基於 Popup 做二次開發,不過我們的專案已經實現了全屏 Confirm 元件,目前需要實現的是呼叫它的使用可以動態掛載到 body 下,首先我們使用 createAPI 包裝一下它:

createAPI(Vue, Confirm, ['confirm', 'click'], true)
複製程式碼

接著我們就可以在元件內部通過 this.$createConfirm 的方式呼叫它,我們在 Search 元件中改變一下 Confirm 元件的呼叫方式:

methods: {
  showConfirm() {
    this.$createConfirm({
       text: '是否清空所有搜尋歷史',
       confirmBtnText: '清空',
       onConfirm: () => {
         this.clearSearchHistory()
       }
     }).show()
   },
}
複製程式碼

當執行 .show 的時候,cube-ui 內部會把 Confirm 元件動態掛載到 body 下。

總結

到此這篇文章的主體內容就介紹完了,看似簡單,但實際上我在重構的過程中還是發現了一些問題,順便也對 cube-ui 和 better-scroll 做了一些優化。希望我的學生在看完這篇文章後能真正自己嘗試著做一遍重構,因為很多細節的問題只有你去嘗試做了才能發現,只有發現並解決問題你才能積累更多的經驗;重構的過程中務必要看文件,遇到問題一定要自己先思考一遍,實在解決不了再求助。另外我也希望大家也多多使用 cube-ui,哪怕 cube-ui 能幫你解決一個小小的需求,那麼我們覺得開源這件事情都是非常有意義的。如果大家在使用的過程中遇到一些問題,歡迎給我們提 issue & pr,幫助我們一起共建 cube-ui,也可以加 qq 群與我們交流,二維碼如下:

QQ Community QR

如果 cube-ui 對你有幫助,也不要吝嗇你的 star

另附上 vue-music 專案的線上地址,掃下方二維碼體驗:

music QR

如果想跟著我學習這門 Vue.js 的進階課程,真心想學到知識,請務必購買正版課程,你一定不會失望。

相關文章