Vue2.x重构饿了么App项目总结

前言

接触Vue2.x已经有一段时间,该项目是基于最新的Vue2.x技术实现的,用到的技术栈有

1
vue2 + vue-router2 + vue-cli2 + vue-resource + stylus + flex布局 + es6 + eslint + webpack2

实现的功能:

  • 商家优惠信息浮层,内容不足时关闭按钮仍能定位到底部(Sticky footers布局)
  • 商品、评价、商家页面实现平滑滚动
  • 商品页点击左侧menu,右侧内容跳转到相应位置
  • 评论内容支持分类查看,无内容时不显示筛选组件
  • 加入购物车按钮动画,商品飞入购物车动画,购物车展开收起动画
  • 商家实景图片超过屏幕宽度时,可左右滚动
  • 收藏商家使用localStorage缓存到浏览器本地

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
build/----文件是 webpack 的打包编译配置文件
config/----文件夹存放的是一些配置项,比如我们服务器访问的端口配置等
node_moudules/----Node的组件,通过npm安装
resource/----设计稿、标注图、通过icomoon生产的字体图标等
src/----项目的入口文件,App.vue和main.js
src/common/----存放通用的fonts、css和js文件
src/components/----存放项目组件,每个组件独立管理自己的资源
router/----文件夹存放的是vue-router相关配置
dist/----该文件夹一开始是不存在,在我们的项目经过 build 之后才会产出
prod.server.js----该文件是测试时模拟的服务器配置,用来运行dist里面的文件,在config/index.js中,build对象中添加一条端口设置port:9000,
App.vue----根组件,所有的子组件都将在这里被引用index.html----整个项目的入口文件,将会引用我们的根组件 App.vue
main.js----入口文件的 js 逻辑,在 webpack 打包之后将被注入到 index.html 中

项目组件分析

1
2
3
4
5
6
7
8
9
10
11
组件目录:src/components/
cartcontrol/---加入购物车按钮组件
food/---商品详情页组件
goods/---商品列表页
header/---头部商家信息
ratings/---商品评价页
ratingselect/---商品评论筛选组件
seller/---商家页
shopcart/---购物车组件
shplit/---分割线组件
star/---评分组件

遇到的问题汇总

better-scroll组件的使用问题

1、better-scroll在移动端使用,初始化时要设置click:true,否则移动端无法点击。
2、better-scroll使用时,需在代码中使用如下判断,否则PC端将产生两次点击事件。

1
2
3
4
if (!event._constructed) {
return;
};
此处书写点击事件代码...

3、better-scroll的使用,必须保证子容器的高度或宽度超过父容器,否则无法滚动。如果子容器包含多个孙容器,可手动给子容器设置高度或宽度,使其能滚动(例如:项目中商家实景横向滚动效果,手动设置了容器的宽度,使其能滚动)。

4、better-scroll对DOM的依赖很强,所以不需保证DOM已经渲染完毕,才能创建或刷新better-scroll实例。在Vue中,可以使用$nextTick()保证文档已经渲染完毕。

CSS样式问题

1、项目图标,可以使用icomoon.io将其转化为字体图标,这样可以保证icon的清晰度

2、border-1px的实现,通过@media媒体查询,针对不同屏幕分辨率执行不同的缩放。即 @media + scale

公式:设备上像素 = 样式像素 * 设备像素比

1
2
3
4
5
6
7
屏幕宽度: 320px 480px 640px
设备像素比: 1 1.5 2
通过查询它的设备像素比 devicePixelRatio
在设备像素比为1.5倍时, round(1px 1.5 / 0.7) = 1px
在设备像素比为2倍时, round(1px 2 / 0.5) = 1px
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// stylus语法
border-1px($color)
position:relative
&:after
content:''
display:block
position:absolute
left:0
bottom:0
width:100%
border:1px solid $color
@media(-webkit-min-device-pixel-ratio:1.5),(min-device-pixel-ratio:1.5)
.border-1px
&::after
-webkit-transform:scaleY(0.7)
transform:scaleY(0.7)
@media(-webkit-min-device-pixel-ratio:2),(min-device-pixel-ratio:2)
.border-1px
&::after
-webkit-transform:scaleY(0.5)
transform:scaleY(0.5)

更多关于设备像素比devicePixelRatio:http://www.zhangxinxu.com/wordpress/2012/08/window-devicepixelratio/

header组件商家优惠中浮层采用了sticky footer布局,该布局的特点是,如果内容不足以充满整个屏幕,页脚也会贴在视窗底部,内容足够长时,页脚会被内容自动撑开。

1
2
3
父容器:position:fixed;
内容:padding-bottom:64px //距离底部的距离
页脚相对定位:margin-top:-64px;

更多关于sticky-footer布局:https://aotu.io/notes/2017/04/13/Sticky-footer/index.html

flex 布局

项目中多次使用flex布局,例如购物车组件的布局,左侧内容自适应,右侧宽度固定。

1
2
3
4
5
6
7
.content
display: flex
.content-left
flex:1
.content-right
flex 0 0 105px
width 105px

更多关于flex布局:http://www.ruanyifeng.com/blog/2015/07/flex-grammar.html

padding-top = 100%的用法

为了防止图片加载过程中闪烁,且让图片的宽度自适应为屏幕宽度,高度与宽度相对(即正方形)。例如,商品详情页头图展示样式

1
2
3
4
5
.image-header
position: relative
height: 0
width: 100% /*宽度为100%*/
padding-top: 100% /*高度与高度相等*/

背景模糊效果

本项目有两种不同的背景模糊,一个是filter:blur(10px),另一个是backdrop-filter:blur(10px)

1、filter:blur(10px) 所有子元素也会模糊,包括文字,所以header组件中,商家背景图单独成一个容器,采用绝对布局,z-index:-1,这样可以避免背景模糊对其他元素的影响。另外,如果父容器下方有背景的阴影,设置overflow:hidden即可!

1
2
3
4
5
6
7
8
9
.bulletin-wrapper
overflow hidden
.background
position absolute /*绝对定位*/
top 0
left 0
width 100%
z-index -1 /*定位在后面*/
filter blur(10px) /*背景模糊*/

2、backdrop-filter:blur(10px) 只模糊背景,不模糊其子元素。但这个特性只在ios设备有效,Android不支持!

transition过渡

transition在Vue2.0以后升级为标签,用法也完全变了。Vue2.x中,transition的基本用法如下(Vue官网的例子,比较通俗易懂~~)

html:

1
2
3
4
5
6
7
8
<div id="demo">
<button v-on:click="show = !show">
Toggle
</button>
<transition name="fade">
<p v-if="show">hello</p>
</transition>
</div>

javascript:

1
2
3
4
5
6
7
new Vue({
el: '#demo',
data: {
show: true
}
})

CSS:

1
2
3
4
5
6
.fade-enter-active, .fade-leave-active {
transition: opacity .5s
}
.fade-enter, .fade-leave-to /* .fade-leave-active in below version 2.1.8 */ {
opacity: 0
}

更多关于组件,请看Vue2.x官网:https://cn.vuejs.org/v2/guide/transitions.html

seller(商品)组件的滚动问题

1、初次打开seller页面,无法滚动
问题分析:我们知道better-scroll插件是一个依赖DOM的,页面刚刚打开的时候,DOM没有渲染完毕,程序此时就调用better-scroll所以无法滚动。
解决办法:mounted()钩子函数(Vue1.x用的是ready()函数,)中创建/刷新better-scroll实例。

2、点击其他页面,再次回到seller页面时,无法滚动
问题分析:出现这个情况,是因为mounted()函数在整个生命周期中只会执行一次。
解决办法:使用watch()方法,并配合$nextTick()。在watch方法中,监控seller数据的变化,在文档渲染完毕后,重新创建/刷新better-scroll实例。

3、seller页面中,商家实景图片横向滚动问题
问题分析:better-scroll插件要求子容器宽度/高度要大于父容器才会滚动。而ul列表默认不会被内容撑开,
解决办法:给每个li设置display:inline-block,然后手动设置ul的宽度,(每张图片的宽度+margin-right的值)*图片数量-最后一个marin-right。同时,new better-scroll对象时,需要设置scrollX: true,eventPassthrough: 'vertical'(具体看下方代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// watch()函数
watch: {
'seller' () { //seller是数据对象,单引号可去掉
this.$nextTick(() => {
this._initScroll();
this._initPics();
});
}
},
// methods 方法
methods: {
// seller(商品)组件滚动
_initScroll () {
if (!this.scroll) {
this.scroll = new BScroll(this.$refs.seller, {
click: true
});
} else {
this.scroll.refresh();
},
// 商家实景滚动,注意:并不是在同一个容器中设置宽度和设置滚动
_initPics () {
if (this.seller.pics) {
let picList = this.$refs.picList;
let picWidth = 120;
let margin = 6;
let picNum = this.$refs.picList.getElementsByClassName('pic-item').length;
// 设置picList(子组件)的宽度
picList.style.width = (picWidth + margin) * picNum - margin + 'px';
// 注意此处 $nextTick() 的用法,设置了宽度浏览器要重新渲染
this.$nextTick(() => {
if (!this.picScroll) {
// 设置picWrapper(父组件)滚动
this.picScroll = new BScroll(this.$refs.picWrapper, {
// 设置为横向滚动
scrollX: true,
eventPassthrough: 'vertical'
});
} else {
this.picScroll.refresh();
}
});
}
}
}

window.localStorage的用法

此项目,将localStorage封装成一个通用的方法,并保存在src/common/js/store.js中。收藏商家功能,体现了localStorage的用法。

App.vue动态给seller绑定id:

1
2
3
4
5
6
7
8
9
10
11
data () {
return {
seller: {
// 创建自动执行函数,动态添加商家ID
id: (() => {
let queryParam = urlParse();
return queryParam.id;
})()
}
};
},

seller组件,使用loadFormLocal()加载本地内容:

1
2
3
4
5
6
7
data () {
return {
favoriate: (() => {
return loadFormLocal(this.seller.id, 'favoriate', false);
})()
};
},

seller组件,使用saveToLocal()把内容存储本地

1
2
3
4
5
6
7
toggleFavoriate (event) {
if (event._constructed) {
return;
}
this.favoriate = !this.favoriate;
saveToLocal(this.seller.id, 'favoriate', this.favoriate);
},

store.js源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 通用localStorage 存数据方法
export function saveToLocal (id, key, value) {
if (!window.localStorage) {
return false;
} else {
let store = window.localStorage.__store__;
if (!store) {
// 如果store不存在,先创建
store = {};
store[id] = {};
} else {
store = JSON.parse(store);
if (!store[id]) {
store[id] = {};
}
}
store[id][key] = value;
localStorage.__store__ = JSON.stringify(store);
}
}
// 取数据方法
export function loadFormLocal (id, key, def) {
if (!window.localStorage) {
return false;
} else {
let store = window.localStorage.__store__;
if (!store) {
return def;
}
store = JSON.parse(store)[id];
if (!store) {
return def;
}
let ret = store[key];
return ret || def;
}
};

解析地址栏中的url,并返回数据对象

使用window.localStorage.search可以获取地址栏中问号后面的数据。本项目,我们把用于解析url的函数封装在一个util.js函数中。供app.vue动态给seller数据对象绑定商家的id,该函数源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
* 解析url参数
* @example ?id=12345&a=b
* @return Object {id:12345,a:b}
*/
export function urlParse () {
let url = window.location.search;
let obj = {};
let reg = /[?&][^?&]+=[^?&]+/g;
let arr = url.match(reg);
// ['?id=12345','&a=b']
if (arr) {
arr.forEach((item) => {
let tempArr = item.substring(1).split('=');
let key = decodeURIComponent(tempArr[0]);
let val = decodeURIComponent(tempArr[1]);
obj[key] = val;
});
}
return obj;
}

此外,我们需要将得到的id和a带到数据中,实际上在获取数据的时候,并没有带着id和a,这时就要用到es6语法中Object.assign(),官方解释为:可以把任意多个的源对象自身的可枚举属性拷贝给目标对象,然后返回目标对象。

在app.vue组件创建时,使用Object.assign()将id和a属性帮绑定到seller中:

1
2
3
4
5
6
7
8
9
created () {
this.$http.get('/api/seller?id=' + this.seller.id).then((response) => {
response = response.body;
if (response.errno === ERR_OK) {
//即将vm.seller属性和请求返回数据对象合并到空对象,然后赋值给vm.seller。
this.seller = Object.assign({}, this.seller, response.data);
}
});
},

使用keepalive属性

商品、评论、商家三个组件之间切换时,数据会被重新渲染,这不符合预期。解决办法是:在外层使用keepalive

1
2
3
<keep-alive>
<router-view :seller="seller"></router-view>
</keep-alive>

vue-router2的使用

vue-router2 的用法与第一版有所变化,下方html代码中,注释部分是第一版的用法。

html代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div class="tab border-1px">
<!--vue-router1.x的用法-->
<!--<div class="tab-item">-->
<!--<a v-link :to="{path:'/goods'}">商品</a>-->
<!--</div>-->
<!--<div class="tab-item">-->
<!--<a v-link to="{path:'/ratings'}">评论</a>-->
<!--</div>-->
<!--<div class="tab-item">-->
<!--<a v-link :to="{path:'/seller'}">商家</a>-->
<!--</div>-->
<!--导航-->
<router-link class="tab-item" to="/goods">商品</router-link>
<router-link class="tab-item" to="/ratings">评价</router-link>
<router-link class="tab-item" to="/seller">商家</router-link>
</div>
<!--出口路由,用于承载路由指向的组件-->
<keep-alive>
<router-view :seller="seller"></router-view>
</keep-alive>

路由配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import Vue from 'vue';
import App from './App';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
const router = new VueRouter({
linkActiveClass: 'active', // 配置激活时候的类名
routes: [
{path: '/goods', component: goods},
{path: '/ratings', component: ratings},
{path: '/seller', component: seller}
]
}
);
new Vue({
// el: '#app',
router: router,
template: '<App/>',
components: { App }
}).$mount('#app');

vue-resource的使用

这个插件vue官方出的插件,这个插件的用法与vue-router的用法类似。其基本用法是使用Vue的实例调用get()方法获取数据,使用then()方法处理数据。

基本用法:

1
this.$http.get(url).then((回调数据)=>{处理回调数据})

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 导入vue-resource
import VueResource from 'vue-resource';
// 使用use()来函数调用vue-resource的实例
Vue.use(VueResource);
// 然后就可以使用了(代码来自APP.vue,仅供演示)
created () {
this.$http.get('/api/seller?id=' + this.seller.id).then((response) => {
response = response.body;
if (response.errno === ERR_OK) {
this.seller = Object.assign({}, this.seller, response.data);
}
});
},

Vue2.x组件间的通信问题

这是另一个话题了,打算另外开篇,感觉给自己挖了一个坑..

—2017-7-26 23:32—