本章将围绕如何使用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
},
// ...
]
各字段说明如下表所示。
字 段
类 型
说 明
name
string
书名
authors
Array
作者名列表
editors
Array
编辑名列表
series
string
所属系列
isbn
string
书号
published
string
出版日期
pages
Number
页数
format
string
格式
status
string
发行状态
en_name
string
原名(仅外文书)
category
string
类别(以“、”分隔多个类别名称)
summary
string
全书摘要
price
Number
售价
由于表格的内容是同质化的,所以就不要像之前的示例那样将所有的数据都写成标记了,毕竟示例只是带出一种思路,在我们没有分析出数据结构时要这样做,反之则可以直接进入数据样本的准备阶段。本示例的样本数据比较多就不在这里罗列了,有兴趣的读者 可以到本书的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。
参数说明:
名 称
类 型
说 明
Tag
String/Object/Function
一个HTML标签,组件类型,或一个函数
Data
Object
一个对应属性的数据对象,用于向创建的节点对象设置属性值
Children
String/Array
子节点(Vnodes)
这三个参数中要对data参数进行附加说明,向构造的VNode对象设置文本时可以直接传入字符串,例如:
createElement('p','这是行内文本')
那它的输出结果就是:
<p>这是行内文本</p>
当data对象是一个Object类型的话就相当复杂了,可以参考下表:
属 性
名 称
说 明
Class
Object
和v-bind:class一样格式的对象
Style
Object
和v-sytle:class一样格式的对象
Attrs
Object
设置HTML属性对象
Props
Object
设置组件的属性对象
domProps
Object
设置DOM的内置属性
On
Object
设置组件事件处理方法如click、change等
navtiveOn
Object
仅对于组件,用于监听原生事件,而不是组件使用vm.$emit触发的事件
directives
Object
自定义指令标记对象
Slot
String
如果子组件有定义slot的名称
Key
String
—
Ref
String
定义当前组件的引用名称
以下是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])
])
}
}