DDFE/DDFE-blog

vue-music 音乐 App 之 cube-ui 重构

ustbhuangyi opened this issue · 5 comments

背景

去年 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 的进阶课程,真心想学到知识,请务必购买正版课程,你一定不会失望。

刚看完,在黄老师的粉丝们到来之前,我先给黄老师点个赞👍😄

👍

打 call

👍

没有提供重构过的源代码吗?我想入手研究呢

@MoonCheung 慕课网购买课程,申请代码权限,即可拿到源码。