vue-music 音乐 App 之 cube-ui 重构
去年 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
添加几个插件,先简单对它们做一些介绍。
-
babel-plugin-transform-modules
babel-plugin-transform-modules
是从babel-transform-imports
fork 来的,加上了对 style 的支持,为了解决组件按需引入的问题。 - stylus & stylus-loader
stylus
和 stylus-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 的歌手页面有一个歌手列表,如下图所示:
它恰好可以使用 cube-ui 提供的 IndexList
组件,在我的教学课程中,我也是把它单独抽象出来的一个基础组件,所以替换就变的很容易了。
学会使用一个组件,最好的方式就是看它的文档。cube-ui 提供的 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-group
和 cube-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 的推荐页面用到了轮播图,如下图所示:
在我们的项目中已经封装了轮播图组件,它恰好可以使用 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" :class="lazyload" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC" data-original="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
组件下方的列表是根据检索的关键词动态渲染的,它不仅可以局部滚动,还有一个上拉加载的功能,它就是移动端场景下分页功能的实现。我们完全可以用 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 能帮你解决一个小小的需求,那么我们觉得开源这件事情都是非常有意义的。
如果 cube-ui 对你有帮助,也不要吝啬你的 star。
另附上 vue-music 项目的线上地址,扫下方二维码体验:
如果想跟着我学习这门 Vue.js 的进阶课程,真心想学到知识,请务必购买正版课程,你一定不会失望。
共同学习,写下你的评论
评论加载中...
作者其他优质文章