组件式开发是Vue.js的开发基础,同时也是我们在工程化开发时对功能的抽象与重用的根本。所谓的组件化就是将复杂的、充满重复交互界面的组件逐步细化与抽象为简单的、单一化的一个过程。
区块的划分
我们做前端开发都是从上而下地进行设计与布局,如果按功能或者内容分类来对整个页面进行划分的话,你会很自然地将一个页面的内容分为一个或多个功能区,事实上这是人们的阅读习惯。从这种习惯入手,我们可以很容易将一个复杂的页面划分为功能单一的Vue组件,划分区块的目的就在于将复杂问题简单化,将一个抽象的设计工作分解为具体的开发工作。
本章将通过组件化的思维来设计与构建Home页,也会着重于实践,通过实践领悟个中的理论比纯粹讲如何使用Vue会有意思得多。我们先从设计图入手,将Home的页面结构从功能区块上来进行划分:
这是一个从具象化到抽象化的基本过程,如果没有这一过程,我们根本没有办法将这些功能与实际工作相结合,继而进行工作的细分与实现步骤的安排。
首页上的“新书上架”和“编辑推荐”明显是由两个功能相同的组件构成的,那么我们可以将其视为两个类型相同的功能组件,然后就可以得到以下几个构成HOME页的组件。
主导航除了在四个顶层页面内显示,不会在其他页面或组件内使用,在实现上只需要写在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>内的视图内容。
组件与标记的对应关系如下表所示。
以上这点必须谨记,否则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对象参考:
回调参数response对象参考:
属性说明
方法说明
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是弱类型语言,语言解释引擎并不需要强制地了解每个实例来源于哪一个类才能进行实例化,由此就产生了无限的可能和极大的组件构型的灵活性。