goods组件
前言
本节分为四大块:
1.商品 goods 组件(左侧 menu 布局、右侧食品列表布局、第三方插件库better-scroll 的应用)
2.购物车 shopcart 组件
3.购物车小球 cartcontrol 组件(动画实现)
4.购物车详情页
PS:本节所有代码在文章底部。
商品 goods 组件
1. 外壳 CSS 设置
1 .goods 2 display flex 3 position absolute 4 /*header:134px,tab:40px*/ 5 top 174px 6 bottom 46px 7 width 100% 8 overflow hidden
1)这一块的布局是左侧固定,右侧随屏幕宽度自适应,所以用 flex 布局。
2)左右两侧内容超过手机屏幕宽度时,并没有产生滚动,要定义视口高度,所以应该是绝对定位布局(要设置 top和 left)。并且超过视口高度的内容会被隐藏。
3).menu-wrapper 另外设置 width 80px,是为了解决兼容性问题。因为不设置的话在Android浏览器下会有问题。设置的话,在不兼容 flex 的浏览器也能有 80px 的宽。
2. 数据获取设置
1 const ERR_OK = 0; 2 ... 3 props: { 4 seller: { 5 type: Object 6 } 7 }, 8 data() { 9 return { 10 goods: [] 11 }; 12 }, 13 created() { 14 this.$http.get('/api/goods').then((response) => { 15 response = response.body; 16 if (response.errno === ERR_OK) { 17 this.goods = response.data; 18 this.$nextTick(() => { 19 this._initScroll(); 20 this.calculateHeight(); 21 }); 22 } 23 }); 24 },
先通过 vue-router 将 seller 传进来,使用 props 接收 seller 对象,后续内容会用到 seller 对象。
goods 组件内容要用到 goods 数据,这些数据后期会使用在 DOM 元素上。因为 data() 方法里的变量,会被自动增加 getter 和 setter 方法,其变化能直接映射到 DOM 上。所以这里先使用 data() 方法返回该空数组 goods,然后利用 vue-resource 这个技术去获取数据。
以下是在 data() 里面的变量和没在 data() 里面的变量的对照图:
3. 左侧 menu 布局
1 <div class="menu-wrapper" ref="menuWrapper"> 2 <ul> 3 <li v-for="(item,index) in goods" class="menu-item" :class="{'current':currentIndex===index}" @click="selectMenu(index,$event)"> 4 <span class="text border-1px"> 5 <span v-show="item.type>0" class="icon" :class="classMap[item.type]"></span>{{item.name}} 6 </span> 7 </li> 8 </ul> 9 </div> 10 ... 11 12 created() { 13 this.classMap = ['decrease', 'discount', 'special', 'invoice', 'guarantee']; 14 }, 15 ... 16 17 .menu-wrapper 18 /*三个参数:等分,占位情况,缩放空间*/ 19 flex 0 0 80px 20 width 80px 21 background #f3f5f7 22 .menu-item 23 display table 24 height 54px 25 width 56px 26 padding 0 12px 27 line-height 14px 28 &.current 29 position relative 30 /*要盖住border,要在最顶层*/ 31 z-index 10 32 /*盖住上边的border*/ 33 margin-top -1px 34 background #fff 35 .text 36 border-none() 37 font-weight 700 38 .icon 39 display inline-block 40 vertical-align top 41 width 12px 42 height 12px 43 margin-right 2px 44 background-size 12px 12px 45 background-repeat no-repeat 46 &.decrease 47 bg-image('decrease_3') 48 &.discount 49 bg-image('discount_3') 50 &.guarantee 51 bg-image('guarantee_3') 52 &.invoice 53 bg-image('invoice_3') 54 &.special 55 bg-image('special_3') 56 .text 57 display table-cell 58 width 56px 59 vertical-align middle 60 border-1px (rgba(7, 17, 27, 0.1)) 61 font-size 12px
1)侧边栏是列表,用 ul 元素
2)图片需要判断(type>0)才有图片,这里和上一节说到的 supports 一样,所以这里需要有 classMap 的数组。
3)技巧:用 class 不用 tag,因为查找速度上 class 会更快
4)dispay table:不定行的垂直居中,用 table 布局最有效。
4. 右侧食品列表布局
1 <div class="foods-wrapper" ref="foodsWrapper"> 2 <ul> 3 <li v-for="(item,index) in goods" class="food-list food-list-hook"> 4 <h1 class="title">{{item.name}}</h1> 5 <ul> 6 <li v-for="food in item.foods" class="food-item border-1px"> 7 <div class="icon"> 8 <img :src="food.icon" width="57" height="57"> 9 </div> 10 <div class="content"> 11 <h2 class="name">{{food.name}}</h2> 12 <p class="desc">{{food.description}}</p> 13 <div class="extra"> 14 <span class="count">月售{{food.sellCount}}份</span><span>好评率{{food.rating}}%</span> 15 </div> 16 <div class="price"> 17 <span class="now">¥{{food.price}}</span><span v-show="food.oldPrice" 18 class="old">¥{{food.oldPrice}}</span> 19 </div> 20 </div> 21 </li> 22 </ul> 23 </li> 24 </ul> 25 </div> 26 ... 27 28 .foods-wrapper 29 flex 1 30 .title 31 padding-left 14px 32 height 26px 33 line-height 26px 34 border-left 1px solid #d9dde1 35 font-size 12px 36 color rgb(147, 153, 159) 37 background #f3f5f7 38 .food-item 39 display flex 40 margin 18px 41 padding-bottom 18px 42 border-1px(rgba(7, 17, 27, 0.1)) 43 &:last-child 44 border-none() 45 margin-bottom 0 46 .icon 47 flex 0 0 57px 48 margin-right 10px 49 .content 50 flex 1 51 .name 52 margin 2px 0 8px 53 height 14px 54 line-height 14px 55 font-size 14px 56 color rgb(7, 17, 27) 57 .desc, .extra 58 font-size 10px 59 color rgb(147, 153, 159) 60 .desc 61 margin-bottom 8px 62 line-height 12px 63 .extra 64 line-height 10px 65 .count 66 margin-right 12px 67 .price 68 font-weight 700 69 line-height 24px 70 .now 71 margin-right 8px 72 font-size 14px 73 color rgb(240, 20, 20) 74 .old 75 text-decoration line-through 76 font-size 10px 77 color rgb(147, 153, 159)
1)先遍历 goods,获得食品分类,再遍历分类下的详细商品。使用列表嵌套。
2)不知道有没有 food.oldPrice ,要判断一下。
3)分为左右布局,左边图片固定,右边文字内容自适应,所以又用到了flex布局
4)有 1 像素的 border,但每个列表最后一个 li 是没有这个样式的,所以要去掉这条线。在 mixin.styl 中写 border-none 的代码,只需要设置 display 为 none 即可。
1 border-none() 2 &:after 3 display none
5)li 虽然设了 margin 18px,但是因为上下两个 li 的 margin 会重合的关系,实际只有18px,而不是36px。为解决这个问题,需要额外加上 padding-bottom 18px。但这里又会带来新的问题,即最后一个 li 因为这两行代码设置,会有总共 36px 的底部边距,所以要给最后一个子元素 li 去除其中一个边距,所以给最后一个 li 元素(&:last-chuild)加上 margin-bottom 0。
6)在浏览器中发现不能滚动,是因为设置了视口高度,并且设置了 overflow 为 hidden。
5. 第三方插件库 better-scroll 的应用
接下来添加滚动功能:该功能依赖第三方插件库 better-scroll。
1 <div class="menu-wrapper" ref="menuWrapper"> 2 <div class="foods-wrapper" ref="foodsWrapper"> 3 ... 4 5 import BScroll from 'better-scroll'; 6 7 methods: { 8 _initScroll() { 9 this.menuScroll = new BScroll(this.$refs.menuWrapper, {}); 10 this.foodsScroll = new BScroll(this.$refs.foodsWrapper, {}); 11 }
1)安装并导入 better-scroll 。
2)实例化 better-scroll 。实例化时需要接收 DOM,要把 menu-wrapper 和 foods-wrappe r传送给 BScroll 实例。在 methods 中定义方法_initScroll(DOM,option) 。better-scroll 中,这个方法的参数设置和 iscroll 一样,需要接收两个参数:第一个参数是 DOM 对象;第二个参数是 option 对象,是一个json对象。
3)DOM 在 vue 中如何获得?通过 vue 2.0 的特殊特性 ref 来获得。ref 被用来给元素或子组件注册引用信息。引用信息将会注册在父组件的 $refs 对象上。如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子组件上,引用就指向组件实例。
PS:
1.DOM 元素的获取,由 v-el 迁移为 ref 。
2.命名规则:HTML 中命名用中划线不用驼峰写法,是因为 HTML 大小写不敏感。JavaScript 中用驼峰写法。例如:
v-el:menu-wrapper /*HTML中用中划线*/
this.menuScroll = new BScroll(this.$refs.menuWrapper); // JavaScript中用驼峰法
这样通过 $refs 就获取到了 DOM,此处的 option 先不传。
4)在浏览器查看,发现已有相应样式,但页面却没办法滚动?
1 created() { 2 this.$http.get('/api/goods').then((response) => { 3 response = response.body; 4 if (response.errno === ERR_OK) { 5 this.goods = response.data; 6 // console.log(this.goods); 7 this.$nextTick(() => { 8 this._initScroll(); 9 this.calculateHeight(); 10 }); 11 } 12 }); 13 },
滚动原理:当页面内容的高度超过视口高度的时候,会出现滚动条。这里的页面没法滚动,是因为没有计算出正确高度。
问题:vue 中有一个 nextTick 方法,这个方法在数据修改之后会被立即调用,从而获取更新后的 DOM。因为是异步更新数据,所以这里虽然改变了数据,但此时 DOM 并没有变化,DOM 没有变化,则初始化 betterScroll,高度就会有问题。
解决办法:在 created() 中,调用 vue 的 nextTick 接口,传进去一个匿名函数 _initScroll(),就能正确计算高度。因为此时已经获取了内层 ul 的高度,当判断到内层高度大于外层 wrapper 的高度时,就可以发生滚动。
PS:关于 Vue.nextTick 和 vm.$nextTick的不同 。
5)实现左右联动。
可以实时计算右侧滚动时的坐标值,即 y 值落在哪个区间。通过判断右侧食品列表 y 值所在的区间,就可以确定左边 menu 就要显示到哪个区间。所以应该先计算整体区间高度(最高点即最低点),即没个商品分类区间高度,然后获取实时滚动的 y 值,判断y 值到底位于哪个区间,进而得到左侧 menu 高亮的索引值,最后用 vue 的 :class 绑定高亮设置即可。
第一步:计算右侧索引高度。
先定义 listHeight 数组,实现计算高度的方法 calculateHeight(),在 $nextTick() 接口中调用该方法。
1 <li v-for="(item,index) in goods" class="food-list food-list-hook"> 2 ... 3 4 data() { 5 return { 6 listHeight: [], // 存放大区间高度的数组 7 }; 8 }, 9 ... 10 11 created() { 12 ... 13 this.$nextTick(() => { 14 this.calculateHeight(); 15 }); 16 ... 17 }, 18 methods: { 19 calculateHeight() { 20 // 获取每个大区间的li(标题和内容) 21 let foodList = this.$refs.foodsWrapper.getElementsByClassName('food-list-hook'); 22 let height = 0; 23 this.listHeight.push(height); 24 for (let i = 0; i < foodList.length; i++) { 25 let item = foodList[i]; 26 height += item.clientHeight; 27 this.listHeight.push(height); 28 } 29 },
1. 每个食品分类区间高度的计算是一个数值累加的过程,为了方便跟踪依赖,在 data() 中定义数组 listHeight。
2. 实现计算高度的方法 calculateHeight()。先通过 $refs 获得外层 foodsWrapper 的 DOM 元素,再通过原生的 DOM 方法getElementsByClassName(),获得每个食品分类区间的 li 元素,存放在变量 foodList 数组中。
3. 应该在拿到数据后才计算高度,即在 $nextTick() 中,此时 DOM 已更新,可以正确计算高度。
PS:小技巧:给每个食品分类区间的 li 元素定义一个带 hook 的类名,例如:food-list-hook,在实际编程中,带 hook 的类名一般是没有样式定义的,只是供 js 代码中选择 DOM 元素时使用。
第二步:获得右侧实时的 y 值,与左侧的索引值做一个映射。
1 <li v-for="(item,index) in goods" class="menu-item" :class="{'current':currentIndex===index}" 2 ... 3 4 data() { 5 return { 6 listHeight: [], // 存放大区间高度的数组 7 scrollY: 0, // 右侧实时变化的y值 8 }; 9 }, 10 created() { 11 this.$http.get('/api/goods').then((response) => { 12 response = response.body; 13 if (response.errno === ERR_OK) { 14 this.goods = response.data; 15 this.$nextTick(() => { 16 this._initScroll(); 17 this.calculateHeight(); 18 }); 19 } 20 }); 21 }, 22 methods: { 23 _initScroll() { 24 this.menuScroll = new BScroll(this.$refs.menuWrapper, {}); 25 this.foodsScroll = new BScroll(this.$refs.foodsWrapper, { 26 probeType: 3 27 }); 28 this.foodsScroll.on('scroll', (pos) => { 29 this.scrollY = Math.abs(Math.round(pos.y)); 30 }); 31 }, 32 calculateHeight() { 33 let foodList = this.$refs.foodsWrapper.getElementsByClassName('food-list-hook'); 34 let height = 0; 35 this.listHeight.push(height); 36 for (let i = 0; i < foodList.length; i++) { 37 let item = foodList[i]; 38 height += item.clientHeight; 39 this.listHeight.push(height); 40 } 41 }, 42 }, 43 computed: { 44 currentIndex() { 45 for (let i = 0; i < this.listHeight.length; i++) { 46 let height1 = this.listHeight[i]; 47 let height2 = this.listHeight[i + 1]; 48 if (!height2 || (this.scrollY >= height1 && this.scrollY < height2)) { 49 return i; 50 } 51 } 52 return 0; 53 } 54 } 55 ... 56 &.current 57 position relative 58 z-index 10 59 margin-top -1px 60 background #fff
1. 在 data() 中定义变量 scrollY,存放右侧实时变化的 y 值。
2. 在右侧食品列表的 better-scroll 实例中将 probeType 的值设置为 3,用于实时监听滚动位置,即可以在滚动时实时派发 scroll 事件,告诉我们滚动的位置,相当于探针的作用。
3. 利用 better-scroll 的一个事件:scroll,获取实时的 scrollY。
this.foodsScroll.on('scroll', (pos) => { this.scrollY = Math.abs(Math.round(pos.y)); });
pos 是滚动的实时坐标,pos.y 是坐标的 y 值,是一个负值,使用 round 方法取整,再用 abs 方法将负数转为正数。
4. 计算 scrollY 位于哪个区间。
在计算属性 computed 中定义 currentIndex()。遍历 listHeight,获取当前索引值对应的高度及下一个索引值对应的高度,判断 scrollY 是不是位于这个范围中,是的话返回当前索引值。这里有一个问题,就是当遍历到最后一个 li 时,没有对应的下一个索引值的高度,也就是说此时 scrollY 是在最后一个食品分类区间,这样的话也要返回当前的索引值,其余情况下返回 0。
5. 与左侧索引做一个映射。
当滚动时,根据 vue 特性,currentIndex() 会重新计算。接下来给 .menu-item 绑定一个 :class,可以在其中加入简单的判断语句。
<li v-for="(item,index) in goods" class="menu-item" :class="{'current':currentIndex===index}"
currentIndex() 函数会返回 scrollY 所处的区间的索引值,遍历左侧 menu 列表,当找到与右侧函数返回的索引值相等的 index 值时,就会给当前的 li 设置一个 current 的类。接下来给 current 设置样式即可。
第三步:实现左侧 menu 点击功能,左侧点击,右侧就跳到相应位置。
1 <li v-for="(item,index) in goods" class="menu-item" :class="{'current':currentIndex===index}" @click="selectMenu(index,$event)"> 2 ... 3 methods: { 4 selectMenu(index, event) { 5 if (!event._constructed) { 6 // eslint-disable-next-line 7 return; 8 } 9 let foodList = this.$refs.foodsWrapper.getElementsByClassName('food-list-hook'); 10 let el = foodList[index]; 11 this.foodsScroll.scrollToElement(el, 300); 12 }, 13 _initScroll() { 14 this.menuScroll = new BScroll(this.$refs.menuWrapper, { 15 click: true 16 }); 17 }, 18 }
1. 给 .menu-item 添加点击事件selectMenu(),传进两个参数,一个是当前 li 对应的索引值index,另一个是事件 event。
2. 先通过 $refs 获得外层 foodsWrapper 的 DOM 元素,再通过原生的 DOM 方法getElementsByClassName(),获得每个食品分类区间的 li 元素,存放在变量 foodList 数组中。
3. 通过 index 值,找到右侧食品列表中对应的食品分类对应 index 的 DOM 元素,使用 better-scroll 的 scrollToElement 接口,直接滚动到相应元素位置。scrollToElement() 需要传入两个参数,第一个是 DOM 元素,第二个是滚动时间。这里设置滚动时间为300毫秒。
4. 在手机端调试时发现点击没有效果。这是因为 better-scroll 会监听 touchstar、touchmove、touchend 等事件,会使用 preventDefault 阻止掉这些默认事件,所以这里点击是没有效果的。需要在初始化时(_initScroll)设置 click:true,这样就实现了点击功能。
5. 当调试模式由手机到转到 PC 时,会发现点击效果被实现了两次。这是因为在PC端时,默认事件并没有被阻止,原生点击也能被监听到。又因为在初始化时设置 click:true,即派发了一个单击事件,合起来相当于派发了两个点击事件,所以点击的回调函数被执行了两次。
怎么解决:在 DOM 元素中添加点击事件时,传入事件 $event,selectMenu() 收到 event 变量,这个 event,就是点击时传递的 event 事件。better-scroll中,派发的事件与原生点击事件有一个属性区别:自定义派发的事件,会有一个私有属性 constructed,值 为 true,而浏览器原生点击事件是没有这个属性的。我们可以在函数中加上判断,如果监听到浏览器原生点击事件,可以将它 return 掉,即逻辑不执行。只有当自定义派发的事件,逻辑才会接着往下走,执行函数。如何理解呢?当我们点击时,程序没有监听到私有属性 constructed,表明现在是浏览器原生点击事件,此时 return,即不执行。这样浏览器原生点击事件就不会被执行,只是执行我们默认派发的事件,这样就可以达到手机端和 PC 端都是点击函数只执行一次的效果。
总结:
1. 在vue开发时,与原生库做交互时,可以通过 ref 定义变量,然后通过 $refs 访问它,这相当于拿到了原生 DOM 。
2. 要进行一些涉及到 DOM 的计算操作时,必须保证 DOM 元素已经被渲染。虽然在 vue 中有数据的自然映射(改变数据就改变了 DOM),但实际上 DOM 真正改变是在 nextTick 回调函数之后。所以要操作原生DOM时,一定要调用 $nextTick() 接口,在这个接口的回调函数中做修改,保证 DOM 已经渲染好,这样在计算 DOM 相关属性时就不会发生错误。
3. nextTick,$nextTick(),以及两者区别。
4. 当遇到报错是关于 undefined、no function,一般都是前边调用函数或者对象错了,可以检查它们是不是 undefined。
5. methods 不要写成 method!!!
购物车 shopcart 组件
1. 新建组件(components -> shopcart ->shopcart.vue),在 good.vue 引入注册并使用。
1 <template> 2 <div class="shopcart"></div> 3 </template> 4 5 <script type="text/ecmascript-6"> 6 export default {}; 7 </script> 8 9 <style lang="stylus" rel="stylesheet/stylus"> 10 11 </style>
1 <shopcart ref="shopcart" :select-foods="selectFoods" :delivery-price="seller.deliveryPrice" :min-price="seller.minPrice"></shopcart> 2 3 import shopcart from '../../components/shopcart/shopcart'; 4 5 export default { 6 components: { 7 shopcart 8 } 9 };
测试:
2. 布局及 CSS 设置
1 <template> 2 <div> 3 <div class="shopcart"> 4 <div class="content" @click="toggleList"> 5 <div class="content-left"> 6 <div class="logo-wrapper"> 7 <div class="logo" :class="{'highlight':totalCount>0}"> 8 <i class="icon-shopping_cart" :class="{'highlight':totalCount>0}"></i> 9 </div> 10 <div class="num" v-show="totalCount>0">{{totalCount}}</div> 11 </div> 12 <div class="price" :class="{'highlight':totalPrice>0}">¥{{totalPrice}}</div> 13 <div class="desc">另需配送费¥{{deliveryPrice}}元</div> 14 </div> 15 <div class="content-right" @click.stop.prevent="pay"> 16 <div class="pay" :class="payClass">{{payDesc}}</div> 17 </div> 18 </div> 19 </div> 20 </template> 21 22 <style lang="stylus" rel="stylesheet/stylus"> 23 @import "../../common/stylus/mixin.styl" 24 25 .shopcart 26 position fixed 27 left 0 28 bottom 0 29 /*多出的区块(购物车)要盖住上面的区块*/ 30 z-index 50 31 width 100% 32 height 48px 33 .content 34 display flex 35 background #141d27 36 /*消除空白间隙影响*/ 37 font-size 0 38 .content-left 39 flex 1 40 color rgba(255, 255, 255, 0.4) 41 .logo-wrapper 42 display inline-block 43 vertical-align top 44 position relative 45 top -10px 46 margin 0 12px 47 padding 6px 48 /*此处IF盒模型,包括padding,border在内*/ 49 width 56px 50 height 56px 51 box-sizing border-box 52 border-radius 50% 53 background #141d27 54 .logo 55 width 100% 56 height 100% 57 border-radius 50% 58 text-align center 59 background #2b343c 60 &.highlight 61 background rgb(0, 160, 220) 62 .icon-shopping_cart 63 line-height 44px 64 font-size 24px 65 color #80858a 66 &.highlight 67 color #fff 68 .num 69 position absolute 70 top 0 71 right 0 72 /*绝对定位,要指定宽高,才能撑开*/ 73 width 24px 74 heigt 16px 75 line-height 16px 76 text-align center 77 border-radius 16px 78 font-size 9px 79 font-weight 700 80 color #ffffff 81 background rgb(240, 20, 20) 82 box-shadow 0 4px 8px 0 rgba(0, 0, 0, 0.4) 83 .price 84 display inline-block 85 vertical-align top 86 margin-top 12px 87 padding-right 12px 88 line-height 24px 89 box-sizing border-box 90 border-right 1px solid rgba(255, 255, 255, 0.1) 91 font-size 16px 92 font-weight 700 93 &.highlight 94 color #fff 95 .desc 96 display inline-block 97 vertical-align top 98 margin 12px 0 0 12px 99 line-height 24px 100 font-size 10px 101 .content-right 102 flex 0 0 105px 103 width 105px 104 color rgba(255, 255, 255, 0.4) 105 .pay 106 height 48px 107 text-align center 108 line-height 48px 109 font-size 12px 110 font-weight 700 111 &.not-enough 112 background #2b333b 113 &.enough 114 background #00b43c 115 color #fff 116 </style>
1)购物车组件时定义在视口底部,所以应该是 fixed 布局。
2)右侧固定宽度,左侧自适应。右侧除了设置 flex 0 0 105px,还另外设置 width 105px,是为了解决兼容性问题。
3) 因为购物车 logo 是超出父元素,所以应该设置为相对定位,并设置 top 值。
4)将 logo-wrapper 设置为 IE 盒模型(border-box),即包括 padding 和 border 在内。
5)这里有两块需要垂直居中,但设置不一样。左侧的使用 line-height 与 margin-top 结合实现垂直居中,右侧的使用 height = line-height 实现垂直居中。为什么左侧要这么设置呢?这是因为左侧中间需要设置 border,如果使用 height = line-height,那么 border 的高度会等于区块高度,而不是像图中的设计效果。
6)商品总数是绝对定位
3. 在 goods 组件中使用 shopcart 组件
<shopcart :delivery-price="seller.deliveryPrice" :min-price="seller.minPrice"></shopcart>
在 good.vue 中传递参数最低起送费和配送费,如果仅仅是这么写会遇到以下报错信息:
vue.esm.js?efeb:576 [Vue warn]: Error in render: "TypeError: Cannot read property 'deliveryPrice' of undefined"
如何解决这个问题?遇到这种 undefined 问题,找找上层问题。
要先从 good.vue 找找是否有接收seller对象:
props: {
seller: {
type: Object
}
},
有的话,再到外层的 vue 找找是否有传入 seller,在 App.vue 文件中发现 router-view 并没有传入seller,因此在这了传入 seller:
<router-view :seller="seller"></router-view>
这样 goods组件 才能获得 seller,goods 组件才能再传给 shopcart 组件。
到这里这两个参数还没办法使用,需要在 shopcart 组件中通过 props 接收这两个参数:
1 props: { 2 deliveryPrice: { 3 type: Number, 4 default: 0 5 }, 6 minPrice: { 7 type: Number, 8 default: 0 9 } 10 },
至此,shopcart 组件才算被 goods 组件正确使用。
4. 购物车是对选择商品的映射
可以看到,购物车一共有三种变化。购物车是对选择商品的映射,商品的选择是在 goods 组件中进行的,应该通过 goods 组件告诉shopcart 组件一共选择了多少商品,然后根据商品的价格进行样式的调整。
1 <div class="content"> 2 <div class="content-left"> 3 <div class="logo-wrapper"> 4 <div class="logo" :class="{'highlight':totalCount>0}"> 5 <i class="icon-shopping_cart" :class="{'highlight':totalCount>0}"></i> 6 </div> 7 <div class="num" v-show="totalCount>0">{{totalCount}}</div> 8 </div> 9 <div class="price" :class="{'highlight':totalPrice>0}">¥{{totalPrice}}</div> 10 <div class="desc">另需配送费¥{{deliveryPrice}}元</div> 11 </div> 12 <div class="content-right"> 13 <div class="pay" :class="payClass">{{payDesc}}</div> 14 </div> 15 </div> 16 ... 17 18 export default { 19 props: { 20 selectFoods: { 21 type: Array, 22 default() { 23 return []; 24 } 25 }, 26 deliveryPrice: { 27 type: Number, 28 default: 0 29 }, 30 minPrice: { 31 type: Number, 32 default: 0 33 } 34 }, 35 computed: { 36 totalPrice() { 37 let total = 0; 38 this.selectFoods.forEach((food) => { 39 total += food.price * food.count; 40 }); 41 return total; 42 }, 43 totalCount() { 44 let count = 0; 45 this.selectFoods.forEach((food) => { 46 count += food.count; 47 }); 48 return count; 49 }, 50 payDesc() { 51 if (this.totalPrice === 0) { 52 return `¥${this.minPrice}元起送`; 53 } else if (this.totalPrice < this.minPrice) { 54 let diff = this.minPrice - this.totalPrice; 55 return `还差¥${diff}元起送`; 56 } else { 57 return '去结算'; 58 } 59 }, 60 payClass() { 61 if (this.totalPrice < this.minPrice) { 62 return 'not-enough'; 63 } else { 64 return 'enough'; 65 } 66 } 67 } 68 }; 69 ... 70 71 .content-left 72 ... 73 .logo 74 &.highlight 75 background rgb(0, 160, 220) 76 .icon-shopping_cart 77 &.highlight 78 color #fff 79 .price 80 &.highlight 81 color #fff 82 .content-right 83 ... 84 .pay 85 &.not-enough 86 background #2b333b 87 &.enough 88 background #00b43c 89 color #fff
1)在 props 中接收 selectFoods(selectFoods 应该是由父组件 goods 传过来的),它是选择了的商品的数组,为它添加默认 default 值(一般,如果 type 是 Array 或者 Object,则 default 是一个函数)。
2)totalPrice()
totalPrice(商品总价)是根据数组 selectFoods 进行计算的,商品总价是等于选择商品的个数乘以单价。在这里,希望 selectFoods 是存放每个 food 的内容,在 data.json 可以看到,每个 food 有很多描述信息,其中有单价。所以这里希望添加一个 count,用于存放选择商品的个数。
在计算属性 computed 中,添加 totalPrice() 方法。首先遍历数组 selectFoods,将数组中的每种商品的总价,最后再计算出 totalPrice。
如何测试?给 selectFoods 添加数值,在浏览器中看看有没有效果,如果 price 由 0 变成 10,证明设置成功。
<div class="price">¥{{totalPrice}}</div> ... selectFoods: { type: Array, default() { return [ price: 10, count: 1 ]; } }
3)totalCount()
购物总商品数目也是与 selectFoods 有关。在计算属性 computed 中,添加 totalCount() 方法。遍历数组 selectFoods,计算出 totalCount。
4)进行左侧高亮设置及 v-show 设置
有选择商品的时候,样式上会有一些变化。在 CSS 中进行高亮设置,在 HTML 中进行判断,如:购物车图标的变化,如果 totalCount>0 ,则显示高亮设置;而总价文字的变化,则需要通过 totalPrice>0 判断。购物车左上角关于商品总数的显示:当 totalCount>0 则显示。
1 <div class="logo-wrapper"> 2 <div class="logo" :class="{'highlight':totalCount>0}"> 3 <i class="icon-shopping_cart" :class="{'highlight':totalCount>0}"></i> 4 </div> 5 <div class="num" v-show="totalCount>0">{{totalCount}}</div> 6 </div> 7 <div class="price" :class="{'highlight':totalPrice>0}">¥{{totalPrice}}</div> 8 ... 9 .logo 10 &.highlight 11 background rgb(0, 160, 220) 12 .icon-shopping_cart 13 &.highlight 14 color #fff
5)进行右侧设置
文字变化(三种):¥X元起送,还差¥X元起送,去结算。
在计算属性 conputed 中定义变量 payDesc(),进行判断:当总价为 0 时,返回文字“¥X元起送”,当总价低于最低起送价时,返回文字“还差¥X元起送”,当总价高于最低起送价时,返回文字“去结算”。
样式变化:背景颜色改变。
先定义 not-enough 和 enough 德阳市,然后给 DOM 元素绑定 :class,给它定义一个变量 payClass。在计算属性 conputed 中定义变量 payClass(),进行判断:当总价低于最低起送价时,返回样式“not-enough”,当总价高于最低起送价时,返回样式“enough”。
PS:
1. 等号设置:要用三等号,二等号会进行隐式转换。
2. ES6的字符串扩展:使用反引号(键盘左上角波浪号),可以拼接变量和字符串,不需要写加号。可以用${}的方式传进变量。
没有变量时应该是用单引号,不能使用反引号,否则eslint会报错。
return `¥${this.minPrice}元起送`;
return '去结算';
接下来加上添加删除按钮,让购买和购物车组件联动起来。
购物车小球 cartcontrol 组件(动画实现)
按钮是多次出现的,被多次复用,所以抽象为组件 cartcontrol 。
1. 新建组件(components -> cartcontrol ->cartcontrol.vue),在 good.vue 引入注册并使用。
2. 布局及 CSS 样式
1 <template> 2 <div class="cartcontrol"> 3 <transition name="move"> 4 <div class="cart-decrease" v-show="food.count>0" @click="decreaseCart($event)"> 5 <!--inner:平移,inner1:滚动--> 6 <span class="inner inner1 icon-remove_circle_outline"></span> 7 </div> 8 </transition> 9 <div class="cart-count" v-show="food.count>0">{{food.count}}</div> 10 <div class="cart-add icon-add_circle" @click="addCart($event)"></div> 11 </div> 12 </template> 13 14 <style lang="stylus" rel="stylesheet/stylus"> 15 .cartcontrol 16 font-size 0 17 .cart-decrease 18 display inline-block 19 padding 6px 20 .inner 21 display inline-block 22 line-height 24px 23 font-size 24px 24 color rgb(0, 160, 220) 25 transition: all 0.4s linear 26 transform rotate(0) 27 &.move-enter-active, &.move-leave-active 28 transition: all 0.4s linear 29 transform: translate3d(0, 0, 0) 30 &.move-enter, &.move-leave-active //刚进入和离开后的状态 31 opacity: 0 32 transform: translate3d(24px, 0, 0) 33 .inner1 34 transform: rotate(180deg) 35 .cart-count 36 display inline-block 37 vertical-align top 38 width 12px 39 padding-top 6px 40 line-height 24px 41 text-align center 42 font-size 12px 43 color rgb(147, 153, 159) 44 .cart-add 45 display inline-block 46 padding 6px 47 line-height 24px 48 font-size 24px 49 color rgb(0, 160, 220) 50 </style>
为扩大小球点击的范围,可以设置 padding 6px
3. 给点击按钮添加逻辑
1 export default { 2 props: { 3 food: { 4 type: Object 5 } 6 }, 7 created() { 8 // console.log(this.food); 9 }, 10 methods: { 11 addCart(event) { 12 if (!event._constructed) { 13 // eslint-disable-next-line 14 return; 15 } 16 // console.log('click'); 17 if (!this.food.count) { 18 Vue.set(this.food, 'count', 1); 19 } else { 20 this.food.count++; 21 } 22 }, 23 decreaseCart(event) { 24 if (!event._constructed) { 25 // eslint-disable-next-line 26 return; 27 } 28 // console.log('click'); 29 if (this.food.count) { 30 this.food.count--; 31 } 32 } 33 } 34 };
1)cartcontrol 与商品有关联,且关联的应该是单个的 food 的 count,这个数据是从父级传过来的。使用 props 接收从父级传过来的 food 属性。
2)在 methods 中添加 addCart() 方法。判断有没有被选择商品数量,没有的话,先设为 1,;有的话,在原来的基础上加 1 。
addCart() {
if (!this.food.count) {
this.food.count = 1;
} else {
this.food.count++;
}
}
PS:以下具体知识点在上述已经提及过
1. 要记得将浏览器原生的点击事件 return 掉,这样在浏览器点击时才不会有两次
2. 要在 goods.vue 中的 _initScroll() 方法的 foodsScroll 的 Bscroll 实例中添加 click: true,这样移动端才能实现点击功能
3)将点击事件添加到相应 DOM 元素中。使用 v-show,给减号小球以及中间的文字加上判断:当商品数量不大于 0 时,这两部分都不显示。
4)调试时发现点击没有生效,因为减号没有出现。
因为 vue.js 有一个特性,当给观测对象添加一个本不存在的字段,像上面一样企图直接赋值是不行的,props 无法检测到新增字段的变化。所以新增或删除某字段时,要想观察到该字段的变化,需要通过 vue 的一个接口。这里需要先把 vue 导入,然后通过 vue.set() 方法去添加一个属性时,属性的变化就可以被观测到,这样最终可以通知DOM发生变化。
1 import Vue from 'vue'; 2 3 addCart(event) { 4 if (!event._constructed) { 5 // eslint-disable-next-line 6 return; 7 } 8 // console.log('click'); 9 if (!this.food.count) { 10 Vue.set(this.food, 'count', 1); 11 } else { 12 this.food.count++; 13 } 14 }
5)给减号按钮添加点击事件 decreaseCart()
4. 减号按钮动画
使用 vue 组件 transition 实现动画。要实现平移和滚动的动画:外层组件负责平移,内层组件负责滚动。
1 .cart-decrease 2 display inline-block 3 padding 6px 4 opacity: 1 5 transform: translate3d(0, 0, 0) //最终状态 6 .inner 7 display inline-block 8 line-height 24px 9 font-size 24px 10 color rgb(0, 160, 220) 11 transition: all 0.4s linear 12 transform rotate(0) // 最终状态 13 &.move-enter-active, &.move-leave-active //过程 14 transition: all 0.4s linear 15 &.move-enter, &.move-leave-active //刚进入和离开后的状态,只有一帧 16 opacity: 0 17 transform: translate3d(24px, 0, 0) 18 .inner 19 transform: rotate(180deg)
1)因为减号按钮其实是一个图标字体,而字体需要滚动,所以应该放在内层。
<div class="cart-decrease" v-show="food.count>0" @click="decreaseCart($event)"> <span class="inner icon-remove_circle_outline"></span> </div>
2)用 translate3d 属性实现平移动画,是因为这里可以利用 css 属性开启硬件加速,使动画更流畅。
3)程序首先应用 move-enter,设置透明度和小球位置,这个过程很快,只有一帧。接着应用 move-enter-active(这个类可以被用来定义过渡的过程时间,延迟和曲线函数) 实现平移动画,持续时间是 0.4s,平移最终状态应该是参照代码 transform: translate3d(0, 0, 0)。离开时的动画也是一样,但是为什么是设置 move-leave-active 而不是 move-leave 呢?因为 move-leave 只有一帧,如果设置 move-leave,透明度会瞬间变成 0,这样就看不到小球动画了。
4)滚动需要给 inner 设置 display:inline-block,这样按钮高度才不会为 0,才有宽高,滚动动画才能实现。
5. 完成 goods 组件中 selectFoods 属性
1 <div class="cartcontrol-wrapper"> 2 <cartcontrol @event="getEvent" :food="food"></cartcontrol> 3 </div> 4 5 <shopcart ref="shopcart" :select-foods="selectFoods" :delivery-price="seller.deliveryPrice" :min-price="seller.minPrice"></shopcart> 6 7 data() { 8 return { 9 goods: [] 10 }; 11 }, 12 13 computed: { 14 selectFoods() { 15 let foods = []; 16 // 遍历商品分类 17 this.goods.forEach((good) => { 18 // 遍历分类下的商品 19 good.foods.forEach((food) => { 20 // 如果food.count存在,即大于0,证明商品被选中,将被选中商品放进数组里 21 if (food.count) { 22 foods.push(food); 23 } 24 }); 25 }); 26 return foods; 27 } 28 },
cartconctrol 组件是修改 food.count 属性,而 food 是由父组件(good组件)传过来的对象。当修改对象,即给对象增加一些属性,会影响对象本身,也会影响父组件,即 goods 组件。food 与 selectFoods 有关。这里需要完善 selectFoods 属性。
selectFoods需要计算,在 good.vue 文件的计算属性 computed 中完善该属性。selectFoods 需要遍历 goods,拿到食品分类。再遍历分类下面的商品,接下来判断 food.count 即可,如果该值大于 0,证明商品被选中,将被选中的商品放进数组并返回。 最后将 selectFoods 传递给 shopcart 组件,将两个组件联动起来。
原理:cartcontrol 操作单个 food 对象,food 发生变化,则 selectFoods 也会跟着变化,这是因为 selectFoods 是计算属性,它观测的是 goods 对象(goods 对象是在 data 中定义的,凡是在 data 中定义变量或数组等,都会被加上 getter 和 setter 方法进行跟踪依赖),而 goods 对象又是由 food 组成的。一旦 goods 发生变化,selectFoods 就会重新计算,即逻辑再执行一遍,如果 food.count 存在,food 就会被放入返回的数组,selectFoods 也就有对应的值,这时,food.price 和 food.count 都有了,shopcart 组件就能正常显示,这样就将组件联动起来了。
6. 加号小球动画
加号小球进行抛物线动画,最终落点是购物车。与减号小球不同的是,加号小球只有 enter 过程没有 leave 过程。抛物线动画涉及到横轴坐标及纵轴坐标的变化,所以这里依旧需要两层,外层负责纵轴变化,内层负责横轴变化。这里的难点如何确定是小球初始位置。在确定小球位置后,可以使用 vue 组件 transition 的 JavaScript 钩子 完成小球下落动画。
1)在定义 shopcart.vue 文件一个类名为 ball-contain 的 div 元素,里面通过 v-for 遍历数组 balls。balls 数组中一共有五个小球,数组每个元素维护当前小球状态。
2)data 会返回一个对象,对象里面有一个数组 balls,将每个小球的 show 属性设置为 false。这样每个小球初始时都是隐藏的,通过后续代码设置使其进行动画演示。
3)在 CSS代码中设置小球落点的最终位置。
4)通过代码动态获取被点击小球的对应的 DOM 元素(这相当于获得了小球的初始位置)
当点击小球时会产生动画,点击就触发动作,所以可以在点击事件中派发事件,传送一个 DOM 对象。
vue 1.0:
思路:在 cartcontrol 中通过 $dispatch() 方法派发事件,DOM 对象作为事件参数传入,在 goods 组件添加事件 events,用于接收 cart.add 事件,这个事件会接收一个参数 target,这里就由父组件接收到 cart.add 传来的一个事件,接下来处理这个事件。父组件接收到这个事件后,应该调用子组件(shopcart)的一个方法,子组件通过方法传入一个 target,然后处理一个下落函数,然后就可以处理target,通过drop方法传入target。
实现:在 cartcontrol 的 methods 中定义 drop 方法,这个方法就是要调用 shopcart 的 drop 方法。在 shopcart 的 methods 中定义 drop 方法,这个方法支持定义参数 el,这个 el 就是 cartcontrol。在 goods 组件中,私有方法 drop 里,调用 drop 方法的 target,target 的获得是通过事件监听,将当前 target 传进来,再去调用 drop 方法。
顺序:通过 cartcontrol 里面的 DOM 元素传递给父组件(goods),父组件再调用子组件(shopcart)的 drop方 法,在将 target 传递给子组件。
vue 2.0 中语法改变:组件通信变化 $dispatch 废除,事件监听变化,废除 events 属性。
$dispatch() 废除原因:事件不能直接通过 $dispatch 派发到当前父组件上,所以实际上还是在子组件中监听了 addFood 事件,只是因为这个方法可以在父组件中定义,父组件就相当于接收到这个事件。
$dispatch 与 $emit 的区别:$dispatch是子组件通过冒泡,将事件一层层冒泡到父组件上 。$emit 是在当前实例上派发的事件。即当前事件只能在子组件上通过 on 监听到,而不能在父组件上监听到。
vue 2.0:
使用 vue 实例方法 $emit。父组件是通过 @add="addFood" 的方式监听add事件,而不是通过添加 events 属性后通过 events 监听事件。也就是说,使用 @add="addFood" 这个方式,实际上依旧是在 goods 组件上监听到 add 事件,只是 add 事件监听的回调事件是 addFood,这个方法可以在 goods 组件中定义,这样在父组件中就可以通过 addFood 方法去执行回调。
methods: {
addCart(event) {
// 将DOM对象作为事件的参数传递出去,用于计算小球的位置
this.$emit('add', event.target);
}
}
1 <cartcontrol :food="food" @add="addFood"></cartcontrol> 2 3 methods: { 4 addFood(target) { 5 this.drop(target); 6 }, 7 drop(el) { 8 console.log(el); 9 }, 10 }
1 <cartcontrol @add="addFood" :food="food"></cartcontrol> 2 3 methods: { 4 addFood(target) { 5 this._drop(target); 6 }, 7 // drop方法(父组件的私有方法)调用子组件(shopcart)的drop方法 8 _drop(target) { 9 // 体验优化,异步执行下落动画 10 this.$nextTick(() => { 11 this.$refs.shopcart.drop(target); 12 }); 13 }, 14 }
详细解析:
在 cartcontrol.vue 文件中的加号按钮点击事件 addCart 中通过 this.$emit('add', event.target); 将 DOM 元素作为参数,等待传出。在goods.vue 组件中通过 @add="addFood" 方式触发 add 事件,DOM 元素就传到了 goods.vue 中。goods.vue 通过 addFood 执行回调函数,调用 goods.vue 中的私有方法 drop,并将 DOM 元素传给 drop 方法。goods.vue 中的私有方法 drop 调用子组件 shopcart 中的 drop 方法。如何调用子组件呢?可以通过 ref 访问子组件,然后通过 this.$refs.shopcart 方法访问到子组件。接下来完善 shopcart 组件中的 drop 方法。
5)完善 shopcart 组件中的 drop 方法
遍历 balls 数组,取出第一个 show 值为 false 的小球(相当于隐藏),将这个小球的 show 值为 true,并用一个 el 对象保存当前小球的 element,最后将这个球放进数组 dropBalls 中。数组 dropBalls 是存放下落小球,是需要跟踪依赖的,所以在 data 中定义该数组。
6)完成小球下落动画
使用 vue 组件 transition 的 JavaScript 钩子 完成动画。drop有三个钩子:beforeEnter,enter,afterEnter,每个钩子都接受一个参数,及当前执行动画的DOM对象。
1 beforeDrop(el) { 2 // 遍历所有show为true的小球,给这些小球加上下落动画 3 // console.log('beforeDrop' + el); 4 let count = this.balls.length; 5 while (count--) { 6 let ball = this.balls[count]; 7 if (ball.show) { 8 // getBoundingClientRect:获取DOM元素相对于视口的位置,返回值的left和top就是相对于视口的偏移 9 let rect = ball.el.getBoundingClientRect(); 10 let x = rect.left - 32; 11 // y值本身是负值,所以取反 12 let y = -(window.innerHeight - rect.top - 22); 13 // 因为v-show会将display设置为none,即不显示;这里设置为空,即显示 14 el.style.display = ''; 15 // 兼容性设置 16 // 外层元素设置纵向动画 17 el.style.webkitTransform = `translate3d(0,${y}px,0)`; 18 el.style.transform = `translate3d(0,${y}px,0)`; 19 // 内层元素设置横向动画 20 let inner = el.getElementsByClassName('inner-hook')[0]; 21 // console.log('inner' + inner); 22 inner.style.webkitTransform = `translate3d(${x}px,0,0)`; 23 inner.style.transform = `translate3d(${x}px,0,0)`; 24 } 25 } 26 }, 27 // 下落动画完成,小球进入购物车的状态 28 dropping(el) { 29 // 需要先触发浏览器重绘,然后重置样式,单纯字符串,没有变量时,要用单引号而不是反引号 30 // re变量之后是不用的,所以要写一行eslint,让其跳过该检查 31 /* eslint-disable no-unused-vars */ 32 let rf = el.offsetHeight; 33 this.$nextTick(() => { 34 el.style.webkitTransform = 'translate3d(0,0,0)'; 35 el.style.transform = 'translate3d(0,0,0)'; 36 let inner = el.getElementsByClassName('inner-hook')[0]; 37 inner.style.webkitTransform = 'translate3d(0,0,0)'; 38 inner.style.transform = 'translate3d(0,0,0)'; 39 }); 40 }, 41 // 动画完成,将小球状态重置 42 afterDrop(el) { 43 // 将小球取出 44 let ball = this.dropBalls.shift(); 45 if (ball) { 46 ball.show = false; 47 el.style.display = 'none'; 48 } 49 },
beforeDrop(el):遍历所有 show 为 true 的小球,给这些小球加上下落动画。
1. ball.show 值为 true 时触发动画
2. getBoundingClientRect:获取DOM元素相对于视口的位置,返回值的 left 和 top 就是相对于视口的偏移
3. x 值和 y 值是小球现在所处位置
4. 样式设置:因为 v-show 指令会将 display 设置为 none,即不显示;这里先将设置 display 设置为空,即显示。然后设置动画。
5. CSS设置:transition 的默认值是(all 0 ease 0)。要达到抛物线效果,需要加上 贝塞尔曲线。x 轴不需要滑动设置,所以要设置 linear。
&.drop-enter-active, &.drop-leave-active
transition all 0.4s cubic-bezier(0.49, -0.29, 0.75, 0.41)
.inner
transition all 0.4s linear
6. 因为涉及变量还有字符串的拼接,可以使用 ES6 语法中的反引号,然后用 ${} 方式传入变量
dropping(el):下落动画完成,小球进入购物车的状态
1. 需要先触发浏览器重绘,然后重置样式。let rf = el.offsetHeight;
2. re变量之后是不用的,所以要写一行eslint,让其跳过该检查。 /* eslint-disable no-unused-vars */
3. 单纯字符串,没有变量时,要用单引号而不是反引号
4. 样式设置:x 和 y 坐标应该发生变化
afterDrop(el):动画完成,将小球状态重置
从数组 dropBalls 中取出小球,将其 show 值重置为 false,并将其 display 设置为 none 即可。
7)优化设计
当第一次选择商品时,即同时进行加号按钮和减号按钮动画时,动画会有点卡。这是因为两个动画开始时都需要计算,计算量比较大。如何优化?在 good.vue 文件的私有方法 drop 中设置,不要一开始就执行drop函数。
_drop(target) { // 体验优化,异步执行下落动画 this.$nextTick(() => { this.$refs.shopcart.drop(target); }); },
购物车详情页
1. 布局设置及样式设置
1 <template> 2 <div> 3 <div class="shopcart"> 4 <div class="content" @click="toggleList"> 5 <div class="content-left"> 6 <div class="logo-wrapper"> 7 <div class="logo" :class="{'highlight':totalCount>0}"> 8 <i class="icon-shopping_cart" :class="{'highlight':totalCount>0}"></i> 9 </div> 10 <div class="num" v-show="totalCount>0">{{totalCount}}</div> 11 </div> 12 <div class="price" :class="{'highlight':totalPrice>0}">¥{{totalPrice}}</div> 13 <div class="desc">另需配送费¥{{deliveryPrice}}元</div> 14 </div> 15 <div class="content-right" @click.stop.prevent="pay"> 16 <div class="pay" :class="payClass">{{payDesc}}</div> 17 </div> 18 </div> 19 <div class="ball-contain"> 20 <div v-for="ball in balls"> 21 <transition name="drop" @before-enter="beforeDrop" @enter="dropping" @after-enter="afterDrop"> 22 <!--inner-hook用于内层元素设置横向动画时选择--> 23 <div class="ball" v-show="ball.show"> 24 <div class="inner inner-hook"></div> 25 </div> 26 </transition> 27 </div> 28 </div> 29 <transition name="fold"> 30 <div class="shopcart-list" v-show="listShow"> 31 <div class="list-header"> 32 <h1 class="title">购物车</h1> 33 <span class="empty" @click="empty">清空</span> 34 </div> 35 <div class="list-content" ref="listContent"> 36 <ul> 37 <li class="food border-1px" v-for="food in selectFoods"> 38 <span class="name">{{food.name}}</span> 39 <div class="price"> 40 <span>¥{{food.price * food.count}}</span> 41 </div> 42 <div class="cartcontrol-wrapper"> 43 <cartcontrol :food="food" @add="addFood"></cartcontrol> 44 </div> 45 </li> 46 </ul> 47 </div> 48 </div> 49 </transition> 50 </div> 51 <transition name="fade"> 52 <div class="list-mask" @click="hideList" v-show="listShow"></div> 53 </transition> 54 </div> 55 </template> 56 57 <style lang="stylus" rel="stylesheet/stylus"> 58 @import "../../common/stylus/mixin.styl" 59 60 .shopcart 61 position fixed 62 left 0 63 bottom 0 64 /*多出的区块(购物车)要盖住上面的区块*/ 65 z-index 50 66 width 100% 67 height 48px 68 .content 69 display flex 70 background #141d27 71 /*消除空白间隙影响*/ 72 font-size 0 73 .content-left 74 flex 1 75 color rgba(255, 255, 255, 0.4) 76 .logo-wrapper 77 display inline-block 78 vertical-align top 79 position relative 80 top -10px 81 margin 0 12px 82 padding 6px 83 /*此处IE盒模型,包括padding,border在内*/ 84 width 56px 85 height 56px 86 box-sizing border-box 87 border-radius 50% 88 background #141d27 89 .logo 90 width 100% 91 height 100% 92 border-radius 50% 93 text-align center 94 background #2b343c 95 &.highlight 96 background rgb(0, 160, 220) 97 .icon-shopping_cart 98 line-height 44px 99 font-size 24px 100 color #80858a 101 &.highlight 102 color #fff 103 .num 104 position absolute 105 top 0 106 right 0 107 /*绝对定位,要指定宽高,才能撑开*/ 108 width 24px 109 heigt 16px 110 line-height 16px 111 text-align center 112 border-radius 16px 113 font-size 9px 114 font-weight 700 115 color #ffffff 116 background rgb(240, 20, 20) 117 box-shadow 0 4px 8px 0 rgba(0, 0, 0, 0.4) 118 .price 119 display inline-block 120 vertical-align top 121 margin-top 12px 122 padding-right 12px 123 line-height 24px 124 box-sizing border-box 125 border-right 1px solid rgba(255, 255, 255, 0.1) 126 font-size 16px 127 font-weight 700 128 &.highlight 129 color #fff 130 .desc 131 display inline-block 132 vertical-align top 133 margin 12px 0 0 12px 134 line-height 24px 135 font-size 10px 136 .content-right 137 flex 0 0 105px 138 width 105px 139 color rgba(255, 255, 255, 0.4) 140 .pay 141 height 48px 142 text-align center 143 line-height 48px 144 font-size 12px 145 font-weight 700 146 &.not-enough 147 background #2b333b 148 &.enough 149 background #00b43c 150 color #fff 151 .ball-contain 152 .ball 153 /*相对于视口进行变化*/ 154 position fixed 155 left 32px 156 bottom 22px 157 z-index 200 158 &.drop-enter-active, &.drop-leave-active 159 transition: all 0.4s cubic-bezier(0.49, -0.29, 0.75, 0.41) 160 .inner 161 width: 16px 162 height: 16px 163 border-radius: 50% 164 background: rgb(0, 160, 220) 165 transition: all 0.4s linear 166 .shopcart-list 167 position absolute 168 top 0 169 left 0 170 /*因为这个列表是从底部穿出来的,要设置z-index属性*/ 171 z-index -1 172 width 100% 173 transform: translate3d(0, -100%, 0) 174 &.fold-enter-active, &.fold-leave-active 175 transition: all 0.5s 176 &.fold-enter, &.fold-leave-active 177 transform translate3d(0, 0, 0) 178 .list-header 179 height 40px 180 line-height 40px 181 padding 0 18px 182 background #f3f5f7 183 border-bottom 1px solid rgba(7, 17, 27, 0.1) 184 .title 185 float left 186 font-size 14px 187 color rgb(7, 17, 27) 188 .empty 189 float right 190 font-size 14px 191 color rgb(0, 160, 220) 192 .list-content 193 padding 0 18px 194 max-height 217px 195 overflow hidden 196 background #fff 197 .food 198 position relative 199 padding 12px 0 200 box-sizing border-box 201 border-1px(rgba(7, 17, 27, 0.1)) 202 .name 203 line-height 24px 204 font-size 14px 205 color rgb(7, 17, 27) 206 .price 207 position absolute 208 right 90px 209 bottom 12px 210 line-height 24px 211 font-size 14px 212 font-weight 700 213 color rgb(240, 20, 20) 214 .cartcontrol-wrapper 215 position absolute 216 right 0 217 /*因为cartcontrol组件中设置了加号按钮有6像素边距*/ 218 bottom 6px 219 220 .list-mask 221 position fixed 222 left 0 223 top 0 224 width 100% 225 height 100% 226 /*z-index的值要小于shopcart层的z-index的值,因为是背景*/ 227 z-index 40 228 backdrop-filter blur(10px) 229 opacity 1 230 background rgba(7, 17, 27, 0.6) 231 &.fade-enter-active, &.fade-leave-active 232 transition: all 0.5s 233 &.fade-enter, &.fade-leave-active 234 opacity 0 235 background: rgba(7, 17, 27, 0) 236 </style>
1)购物详情层是从底部穿出的,设置 z-index -1。
2)需要添加 cartcontrol 组件。引入注册并使用。(import,components,<cartcontrol>)
3)购物车详情页是不定高的,动画设置是可以设置 y 轴变化可以设置为 -100%,即相对于当前自身高度进行偏移。
4)cartcontrol 组件时绝对定位,需要设置 bottom 属性,因为 cartcontrol 组件中设置了加号按钮有 6 像素边距,所以这里 bottom 的值是 6px
2. 购物详情层控制(点击展开或收起)
1 <div class="content" @click="toggleList"> 2 <div class="shopcart-list" v-show="listShow"> 3 4 data() { 5 fold: true // 默认折叠状态 6 }; 7 }, 8 9 computed: { 10 listShow() { 11 if (!this.totalCount) { 12 this.fold = true; 13 return false; 14 } 15 let show = !this.fold; 16 if (show) { 17 this.$nextTick(() => { 18 if (!this.scroll) { 19 // 初始化 20 this.scroll = new BScroll(this.$refs.listContent, { 21 click: true 22 }); 23 } else { 24 this.scroll.refresh(); 25 } 26 }); 27 } 28 return show; 29 } 30 }, 31 32 methods: { 33 toggleList() { 34 if (!this.totalCount) { 35 // eslint-disable-next-line 36 return; 37 } 38 // 取反 39 this.fold = !this.fold; 40 } 41 },
思路:购物详情层是需要可以隐藏的,添加 v-show 指令,绑定 listShow 变量。listShow 变量需要根据 totalcount 和 fold 属性进行计算:只有当totalCount>0 时,并且 fold 属性为 false,购物详情层才能展开。判断完 listShow 后,需要再给详情层添加点击事件,该事件改变 fold 属性。
实现:
第一步:给购物车详情层添加 v-show 指令。
第二步:在 data 中添加 fold 变量,并设置其默认值为 false。
第三步:在计算属性 computed 中进行 listShow 的计算。先判断 totalCount 是否存在,即是否大于 0,如果不大于 0 的话,设置 fold 的值为 true,并返回 false 值(即 listShow 的值为 false)。如果大于 0 的话,设置 show 的值为 fold 相反的值,并返回 show 的值(fold 为 true 时,表示折叠,那么此时 listShow 的值应该为 true,才能控制购物详情层隐藏)。
第四步:给购物车组件内容层(content)添加点击事件 toggleList。
第五步:在 methods 中定义 toggleList 方法。先判断 totalCount 是否存在,即是否大于 0,如果不大于 0 的话,则 return,逻辑不执行。如果大于 0 的话,就把 fold 值取反。
怎么验证:浏览器中点击购物车组件内容层,可以看到 .shopcart-list 的 display 属性有变化。
3. 实现 cartcontrol 按钮的点击功能
1 import BScroll from 'better-scroll'; 2 3 computed: { 4 listShow() { 5 if (!this.totalCount) { 6 this.fold = true; 7 return false; 8 } 9 let show = !this.fold; 10 if (show) { 11 this.$nextTick(() => { 12 if (!this.scroll) { 13 // 初始化 14 this.scroll = new BScroll(this.$refs.listContent, { 15 click: true 16 }); 17 } else { 18 this.scroll.refresh(); 19 } 20 }); 21 } 22 return show; 23 } 24 },
1)点击加号和减号小球,没有反应,为什么在这一层没办法实现点击功能?在 cartcontrol 中,可以看到 event._constructed,这是需要 better-scroll 派发事件,然后才可以点击。在这里刚好也需要使用到 better-scroll,因为这个列表之后也可能需要滚动。所以先把依赖的库 better-scroll 添加进来。
2)什么时候给列表做初始化?在 listShow 中,因为只有列表展示时才需要。所以在 listShow 中添加代码,先判断 show 存不存在(即列表展不展示),存在的话,在 $nextTick 接口中进行初始化设置(关于这个知识点,上述已经讲过,这是因为数据变化了,DOM 却没有立刻生效,而 better-scroll 是依赖于 DOM,所以需要在这个接口执行之后再写代码)。初始化需要把 listContent 传进来(用 ref 给其添加变量,通过 $refs 访问DOM元素),最后添加属性 click:true(派发点击事件)。到这里,初始化已经完成。
3)初始化完成了,还有一个问题:就是当 listShow 变化时,scroll 就会重新 new 一遍。为解决这个问题,可以判断是否有 this.scroll 的值,如果有,只需要调用 scroll 的 refresh 的接口,不需要重新实例化。这个方法会重新计算视口和内容的高度差,然后决定是否滚动。
4. 清空效果
1 <span class="empty" @click="empty">清空</span> 2 3 methods: { 4 empty() { 5 this.selectFoods.forEach((food) => { 6 food.count = 0; 7 }); 8 } 9 },
给清空按钮添加点击事件 empty,在 methods 中定义该方法。遍历数组 selectFoods,并将 food.count 的值设置为 0 即可。
5. 添加背景层
1 <transition name="fade"> 2 <div class="list-mask" @click="hideList" v-show="listShow"></div> 3 </transition> 4 5 .list-mask 6 position fixed 7 left 0 8 top 0 9 width 100% 10 height 100% 11 /*z-index的值要小于shopcart层的z-index的值,因为是背景*/ 12 z-index 40 13 backdrop-filter blur(10px) 14 opacity 1 15 background rgba(7, 17, 27, 0.6) 16 &.fade-enter-active, &.fade-leave-active 17 transition: all 0.5s 18 &.fade-enter, &.fade-leave-active 19 opacity 0 20 background: rgba(7, 17, 27, 0)
1)背景层绝对定位(fixed布局),需要与 shopcart 平级。
2)z-index 的值要小于 shopcart 层的 z-index 的值,因为是背景层
6. 添加 hideList 方法
1 <div class="list-mask" @click="hideList" v-show="listShow"></div> 2 3 methods: { 4 hideList() { 5 this.fold = true; 6 } 7 },
在购物车详情层之外的空白地方点击时,购物车详情车会隐藏 。在 methods 中定义该方法,将 fold 的值设置为 true即可。
7. 给结算按钮添加点击事件 pay
1 <div class="content-right" @click.stop.prevent="pay"> 2 3 methods: { 4 pay() { 5 if (this.totalPrice < this.minPrice) { 6 // eslint-disable-next-line 7 return; 8 } 9 window.alert(`支付${this.totalPrice}元`); 10 } 11 },
在 methods 中定义该方法,先进行判断,如果总价小于最低起送价,则不执行逻辑。反之,弹出提示总价提示页面。
8. 阻止冒泡事件
<div class="content-right" @click.stop.prevent="pay">
当点击结算按钮,发现购物车详情层弹了出来,这是因为冒泡的关系。如何解决,添加 阻止冒泡,可以在点击事件中加上串联修饰符( @click.stop.prevent)。
总价:
1)报错:Component template should contain exactly one root element. If you are using v-if on multiple elements, use v-else-if to chain them instead.
意思是 vue2.0 只允许有一个根元素。在外层套上一个 div。
2)本项目除了 better-scroll 用了原生 DOM,其余基本就没有 DOM 方法,这就是使用 vue 的好处。
3) 修饰符
1 <!-- 停止冒泡 --> 2 <button @click.stop="doThis"></button> 3 <!-- 阻止默认行为 --> 4 <button @click.prevent="doThis"></button> 5 <!-- 阻止默认行为,没有表达式 --> 6 <form @submit.prevent></form> 7 <!-- 串联修饰符 --> 8 <button @click.stop.prevent="doThis"></button>
至此,goods 组件已全部完成。
附代码:
1 <template> 2 <div> 3 <div class="goods"> 4 <div class="menu-wrapper" ref="menuWrapper"> 5 <ul> 6 <li v-for="(item,index) in goods" class="menu-item" :class="{'current':currentIndex===index}" 7 @click="selectMenu(index,$event)"> 8 <span class="text border-1px"> 9 <span v-show="item.type>0" class="icon" :class="classMap[item.type]"></span>{{item.name}} 10 </span> 11 </li> 12 </ul> 13 </div> 14 <div class="foods-wrapper" ref="foodsWrapper"> 15 <ul> 16 <li v-for="(item,index) in goods" class="food-list food-list-hook"> 17 <h1 class="title">{{item.name}}</h1> 18 <ul> 19 <li @click="selectFood(food,$event)" v-for="food in item.foods" class="food-item border-1px"> 20 <div class="icon"> 21 <img :src="food.icon" width="57" height="57"> 22 </div> 23 <div class="content"> 24 <h2 class="name">{{food.name}}</h2> 25 <p class="desc">{{food.description}}</p> 26 <div class="extra"> 27 <span class="count">月售{{food.sellCount}}份</span><span>好评率{{food.rating}}%</span> 28 </div> 29 <div class="price"> 30 <span class="now">¥{{food.price}}</span><span v-show="food.oldPrice" 31 class="old">¥{{food.oldPrice}}</span> 32 </div> 33 <div class="cartcontrol-wrapper"> 34 <cartcontrol @add="addFood" :food="food"></cartcontrol> 35 </div> 36 </div> 37 </li> 38 </ul> 39 </li> 40 </ul> 41 </div> 42 <!--最低起送费(minPrice)和配送费(deliveryPrice)--> 43 <shopcart ref="shopcart" :select-foods="selectFoods" :delivery-price="seller.deliveryPrice" 44 :min-price="seller.minPrice"></shopcart> 45 </div> 46 <!--商品详情层--> 47 <food :food="selectedFood" ref="food"></food> 48 </div> 49 </template> 50 51 <script type="text/ecmascript-6"> 52 import BScroll from 'better-scroll'; 53 import shopcart from '../../components/shopcart/shopcart'; 54 import cartcontrol from '../../components/cartcontrol/cartcontrol'; 55 import food from '../../components/food/food'; 56 57 const ERR_OK = 0; 58 59 export default { 60 props: { 61 seller: { 62 type: Object 63 } 64 }, 65 data() { 66 return { 67 goods: [], 68 listHeight: [], // 存放大区间高度的数组 69 scrollY: 0, // 右侧实时变化的y值 70 selectedFood: {} 71 }; 72 }, 73 created() { 74 this.classMap = ['decrease', 'discount', 'special', 'invoice', 'guarantee']; 75 this.$http.get('/api/goods').then((response) => { 76 response = response.body; 77 if (response.errno === ERR_OK) { 78 this.goods = response.data; 79 // console.log(this.goods); 80 this.$nextTick(() => { 81 this._initScroll(); 82 this.calculateHeight(); 83 }); 84 } 85 }); 86 }, 87 methods: { 88 selectFood(food, event) { 89 if (!event._constructed) { 90 // eslint-disable-next-line 91 return; 92 } 93 this.selectedFood = food; 94 this.$refs.food.show(); 95 }, 96 selectMenu(index, event) { 97 // console.log(index); 98 // console.log(event); 99 // 如果是浏览器原生点击事件,返回,不执行接下来的代码。(自定义派发的事件,constructed为true,而浏览器原生点击事件是没有这个属性的) 100 if (!event._constructed) { 101 // eslint-disable-next-line 102 return; 103 } 104 let foodList = this.$refs.foodsWrapper.getElementsByClassName('food-list-hook'); 105 // 找到点击对应的DOM元素 106 let el = foodList[index]; 107 this.foodsScroll.scrollToElement(el, 300); 108 }, 109 addFood(target) { 110 this._drop(target); 111 }, 112 // drop方法(父组件的私有方法)调用子组件(shopcart)的drop方法 113 _drop(target) { 114 // 体验优化,异步执行下落动画 115 this.$nextTick(() => { 116 this.$refs.shopcart.drop(target); 117 }); 118 }, 119 _initScroll() { 120 this.menuScroll = new BScroll(this.$refs.menuWrapper, { 121 // better-scroll 默认会阻止浏览器的原生 click 事件。当设置为 true,better-scroll 会派发一个 click 事件,我们会给派发的 event 参数加一个私有属性 _constructed,值为 true。 122 click: true 123 }); 124 this.foodsScroll = new BScroll(this.$refs.foodsWrapper, { 125 // click: true的设置是因为cartcontrol组件中需要添加点击事件 126 click: true, 127 probeType: 3 128 }); 129 // 当滚动时,实时监测滚动位置并返回 130 this.foodsScroll.on('scroll', (pos) => { 131 // pos.y是一个负值,abs:将负数转为正数,round:取整 132 this.scrollY = Math.abs(Math.round(pos.y)); 133 }); 134 }, 135 calculateHeight() { 136 // 获取每个大区间的li(标题和内容) 137 let foodList = this.$refs.foodsWrapper.getElementsByClassName('food-list-hook'); 138 let height = 0; 139 this.listHeight.push(height); 140 for (let i = 0; i < foodList.length; i++) { 141 let item = foodList[i]; 142 height += item.clientHeight; 143 this.listHeight.push(height); 144 } 145 } 146 }, 147 computed: { 148 // 左侧索引 149 currentIndex() { 150 for (let i = 0; i < this.listHeight.length; i++) { 151 // 获取区间上下范围 152 let height1 = this.listHeight[i]; 153 let height2 = this.listHeight[i + 1]; 154 // 如果索引值在区间内或者是是最后一个值,返回当前索引值,如果没有listHeight.length,返回0。 155 if (!height2 || (this.scrollY >= height1 && this.scrollY < height2)) { 156 return i; 157 } 158 } 159 return 0; 160 }, 161 // 返回被选中的商品(供shopcart组件使用) 162 selectFoods() { 163 let foods = []; 164 // 遍历商品分类 165 this.goods.forEach((good) => { 166 // 遍历分类下的商品 167 good.foods.forEach((food) => { 168 // 如果food.count存在,即大于0,证明商品被选中,将被选中商品放进数组里 169 if (food.count) { 170 foods.push(food); 171 } 172 }); 173 }); 174 return foods; 175 } 176 }, 177 components: { 178 shopcart, 179 cartcontrol, 180 food 181 } 182 }; 183 </script> 184 185 <style lang="stylus" rel="stylesheet/stylus"> 186 @import "../../common/stylus/mixin.styl" 187 .goods 188 display flex 189 position absolute 190 /*header:134px,tab:40px*/ 191 top 174px 192 bottom 46px 193 width 100% 194 overflow hidden 195 .menu-wrapper 196 /*三个参数:等分,占位情况,缩放空间*/ 197 flex 0 0 80px 198 width 80px 199 background #f3f5f7 200 .menu-item 201 display table 202 height 54px 203 width 56px 204 padding 0 12px 205 line-height 14px 206 &.current 207 position relative 208 /*要盖住border,要在最顶层*/ 209 z-index 10 210 /*盖住上边的border*/ 211 margin-top -1px 212 background #fff 213 .text 214 border-none() 215 font-weight 700 216 .icon 217 display inline-block 218 vertical-align top 219 width 12px 220 height 12px 221 margin-right 2px 222 background-size 12px 12px 223 background-repeat no-repeat 224 &.decrease 225 bg-image('decrease_3') 226 &.discount 227 bg-image('discount_3') 228 &.guarantee 229 bg-image('guarantee_3') 230 &.invoice 231 bg-image('invoice_3') 232 &.special 233 bg-image('special_3') 234 .text 235 display table-cell 236 width 56px 237 vertical-align middle 238 border-1px (rgba(7, 17, 27, 0.1)) 239 font-size 12px 240 .foods-wrapper 241 flex 1 242 .title 243 padding-left 14px 244 height 26px 245 line-height 26px 246 border-left 1px solid #d9dde1 247 font-size 12px 248 color rgb(147, 153, 159) 249 background #f3f5f7 250 .food-item 251 display flex 252 margin 18px 253 padding-bottom 18px 254 border-1px(rgba(7, 17, 27, 0.1)) 255 &:last-child 256 border-none() 257 margin-bottom 0 258 .icon 259 flex 0 0 57px 260 margin-right 10px 261 .content 262 flex 1 263 .name 264 margin 2px 0 8px 265 height 14px 266 line-height 14px 267 font-size 14px 268 color rgb(7, 17, 27) 269 .desc, .extra 270 font-size 10px 271 color rgb(147, 153, 159) 272 .desc 273 margin-bottom 8px 274 line-height 12px 275 .extra 276 line-height 10px 277 .count 278 margin-right 12px 279 .price 280 font-weight 700 281 line-height 24px 282 .now 283 margin-right 8px 284 font-size 14px 285 color rgb(240, 20, 20) 286 .old 287 text-decoration line-through 288 font-size 10px 289 color rgb(147, 153, 159) 290 .cartcontrol-wrapper 291 position absolute 292 right 0 293 bottom 12px 294 </style>
1 <template> 2 <div class="cartcontrol"> 3 <transition name="move"> 4 <div class="cart-decrease" v-show="food.count>0" @click.stop.prevent="decreaseCart"> 5 <span class="inner icon-remove_circle_outline"></span> 6 </div> 7 </transition> 8 <div class="cart-count" v-show="food.count>0">{{food.count}}</div> 9 <div class="cart-add icon-add_circle" @click.stop.prevent="addCart"></div> 10 </div> 11 </template> 12 13 <script type="text/ecmascript-6"> 14 import Vue from 'vue'; 15 16 export default { 17 props: { 18 food: { 19 type: Object 20 } 21 }, 22 created() { 23 // console.log(this.food); 24 }, 25 methods: { 26 addCart(event) { 27 if (!event._constructed) { 28 // eslint-disable-next-line 29 return; 30 } 31 // console.log('click'); 32 if (!this.food.count) { 33 Vue.set(this.food, 'count', 1); 34 } else { 35 this.food.count++; 36 } 37 // 将DOM对象作为事件的参数传递出去,用于计算小球的位置 38 // this.$bus.emit('add', event.target); 39 this.$emit('add', event.target); 40 }, 41 decreaseCart(event) { 42 if (!event._constructed) { 43 // eslint-disable-next-line 44 return; 45 } 46 // console.log('click'); 47 if (this.food.count) { 48 this.food.count--; 49 } 50 } 51 } 52 }; 53 </script> 54 55 <style lang="stylus" rel="stylesheet/stylus"> 56 .cartcontrol 57 font-size 0 58 .cart-decrease 59 display inline-block 60 padding 6px 61 opacity: 1 62 transform: translate3d(0, 0, 0) 63 .inner 64 display inline-block 65 line-height 24px 66 font-size 24px 67 color rgb(0, 160, 220) 68 transition: all 0.4s linear 69 transform rotate(0) 70 &.move-enter-active, &.move-leave-active 71 transition: all 0.4s linear 72 &.move-enter, &.move-leave-active //刚进入和离开后的状态 73 opacity: 0 74 transform: translate3d(24px, 0, 0) 75 .inner 76 transform: rotate(180deg) 77 .cart-count 78 display inline-block 79 vertical-align top 80 width 12px 81 padding-top 6px 82 line-height 24px 83 text-align center 84 font-size 12px 85 color rgb(147, 153, 159) 86 .cart-add 87 display inline-block 88 padding 6px 89 line-height 24px 90 font-size 24px 91 color rgb(0, 160, 220) 92 </style>
1 <template> 2 <div> 3 <div class="shopcart"> 4 <div class="content" @click="toggleList"> 5 <div class="content-left"> 6 <div class="logo-wrapper"> 7 <div class="logo" :class="{'highlight':totalCount>0}"> 8 <i class="icon-shopping_cart" :class="{'highlight':totalCount>0}"></i> 9 </div> 10 <div class="num" v-show="totalCount>0">{{totalCount}}</div> 11 </div> 12 <div class="price" :class="{'highlight':totalPrice>0}">¥{{totalPrice}}</div> 13 <div class="desc">另需配送费¥{{deliveryPrice}}元</div> 14 </div> 15 <div class="content-right" @click.stop.prevent="pay"> 16 <div class="pay" :class="payClass">{{payDesc}}</div> 17 </div> 18 </div> 19 <div class="ball-contain"> 20 <div v-for="ball in balls"> 21 <transition name="drop" @before-enter="beforeDrop" @enter="dropping" @after-enter="afterDrop"> 22 <!--inner-hook用于内层元素设置横向动画时选择--> 23 <div class="ball" v-show="ball.show"> 24 <div class="inner inner-hook"></div> 25 </div> 26 </transition> 27 </div> 28 </div> 29 <transition name="fold"> 30 <div class="shopcart-list" v-show="listShow"> 31 <div class="list-header"> 32 <h1 class="title">购物车</h1> 33 <span class="empty" @click="empty">清空</span> 34 </div> 35 <div class="list-content" ref="listContent"> 36 <ul> 37 <li class="food border-1px" v-for="food in selectFoods"> 38 <span class="name">{{food.name}}</span> 39 <div class="price"> 40 <span>¥{{food.price * food.count}}</span> 41 </div> 42 <div class="cartcontrol-wrapper"> 43 <cartcontrol :food="food" @add="addFood"></cartcontrol> 44 </div> 45 </li> 46 </ul> 47 </div> 48 </div> 49 </transition> 50 </div> 51 <transition name="fade"> 52 <div class="list-mask" @click="hideList" v-show="listShow"></div> 53 </transition> 54 </div> 55 </template> 56 57 <script type="text/ecmascript-6"> 58 import BScroll from 'better-scroll'; 59 import cartcontrol from '../../components/cartcontrol/cartcontrol'; 60 61 export default { 62 props: { 63 selectFoods: { 64 type: Array, 65 default() { 66 return []; 67 } 68 }, 69 deliveryPrice: { 70 type: Number, 71 default: 0 72 }, 73 minPrice: { 74 type: Number, 75 default: 0 76 } 77 }, 78 data() { 79 return { 80 balls: [ 81 { 82 show: false 83 }, 84 { 85 show: false 86 }, 87 { 88 show: false 89 }, 90 { 91 show: false 92 }, 93 { 94 show: false 95 } 96 ], 97 dropBalls: [], // 已经下落的小球 98 fold: true // 默认折叠状态 99 }; 100 }, 101 computed: { 102 totalPrice() { 103 let total = 0; 104 this.selectFoods.forEach((food) => { 105 total += food.price * food.count; 106 }); 107 return total; 108 }, 109 totalCount() { 110 let count = 0; 111 this.selectFoods.forEach((food) => { 112 count += food.count; 113 }); 114 return count; 115 }, 116 payDesc() { 117 if (this.totalPrice === 0) { 118 return `¥${this.minPrice}元起送`; 119 } else if (this.totalPrice < this.minPrice) { 120 let diff = this.minPrice - this.totalPrice; 121 return `还差¥${diff}元起送`; 122 } else { 123 return '去结算'; 124 } 125 }, 126 payClass() { 127 if (this.totalPrice < this.minPrice) { 128 return 'not-enough'; 129 } else { 130 return 'enough'; 131 } 132 }, 133 listShow() { 134 if (!this.totalCount) { 135 this.fold = true; 136 return false; 137 } 138 let show = !this.fold; 139 if (show) { 140 this.$nextTick(() => { 141 if (!this.scroll) { 142 // 初始化 143 this.scroll = new BScroll(this.$refs.listContent, { 144 click: true 145 }); 146 } else { 147 this.scroll.refresh(); 148 } 149 }); 150 } 151 return show; 152 } 153 }, 154 methods: { 155 // 此方法在小球动画中似乎没有用到 156 addFood(target) { 157 this.drop(target); 158 // console.log(target); 159 }, 160 drop(el) { 161 // console.log(el); 162 for (let i = 0; i < this.balls.length; i++) { 163 let ball = this.balls[i]; 164 if (!ball.show) { 165 ball.show = true; // 触发动画 166 ball.el = el; 167 this.dropBalls.push(ball); 168 // eslint-disable-next-line 169 return; 170 } 171 } 172 }, 173 beforeDrop(el) { 174 // 遍历所有show为true的小球,给这些小球加上下落动画 175 // console.log('beforeDrop' + el); 176 let count = this.balls.length; 177 while (count--) { 178 let ball = this.balls[count]; 179 if (ball.show) { 180 // getBoundingClientRect:获取DOM元素相对于视口的位置,返回值的left和top就是相对于视口的偏移 181 let rect = ball.el.getBoundingClientRect(); 182 let x = rect.left - 32; 183 // y值本身是负值,所以取反 184 let y = -(window.innerHeight - rect.top - 22); 185 // 因为v-show会将display设置为none,即不显示;这里设置为空,即显示 186 el.style.display = ''; 187 // 兼容性设置 188 // 外层元素设置纵向动画 189 el.style.webkitTransform = `translate3d(0,${y}px,0)`; 190 el.style.transform = `translate3d(0,${y}px,0)`; 191 // 内层元素设置横向动画 192 let inner = el.getElementsByClassName('inner-hook')[0]; 193 // console.log('inner' + inner); 194 inner.style.webkitTransform = `translate3d(${x}px,0,0)`; 195 inner.style.transform = `translate3d(${x}px,0,0)`; 196 } 197 } 198 }, 199 // 下落动画完成,小球进入购物车的状态 200 dropping(el) { 201 // 需要先触发浏览器重绘,然后重置样式,单纯字符串,没有变量时,要用单引号而不是反引号 202 // re变量之后是不用的,所以要写一行eslint,让其跳过该检查 203 /* eslint-disable no-unused-vars */ 204 let rf = el.offsetHeight; 205 this.$nextTick(() => { 206 el.style.webkitTransform = 'translate3d(0,0,0)'; 207 el.style.transform = 'translate3d(0,0,0)'; 208 let inner = el.getElementsByClassName('inner-hook')[0]; 209 inner.style.webkitTransform = 'translate3d(0,0,0)'; 210 inner.style.transform = 'translate3d(0,0,0)'; 211 }); 212 }, 213 // 动画完成,将小球状态重置 214 afterDrop(el) { 215 // 将小球取出 216 let ball = this.dropBalls.shift(); 217 if (ball) { 218 ball.show = false; 219 el.style.display = 'none'; 220 } 221 }, 222 toggleList() { 223 if (!this.totalCount) { 224 // eslint-disable-next-line 225 return; 226 } 227 // 取反 228 this.fold = !this.fold; 229 }, 230 hideList() { 231 this.fold = true; 232 }, 233 empty() { 234 this.selectFoods.forEach((food) => { 235 food.count = 0; 236 }); 237 }, 238 pay() { 239 if (this.totalPrice < this.minPrice) { 240 // eslint-disable-next-line 241 return; 242 } 243 window.alert(`支付${this.totalPrice}元`); 244 } 245 }, 246 components: { 247 cartcontrol 248 } 249 }; 250 </script> 251 252 <style lang="stylus" rel="stylesheet/stylus"> 253 @import "../../common/stylus/mixin.styl" 254 255 .shopcart 256 position fixed 257 left 0 258 bottom 0 259 /*多出的区块(购物车)要盖住上面的区块*/ 260 z-index 50 261 width 100% 262 height 48px 263 .content 264 display flex 265 background #141d27 266 /*消除空白间隙影响*/ 267 font-size 0 268 .content-left 269 flex 1 270 color rgba(255, 255, 255, 0.4) 271 .logo-wrapper 272 display inline-block 273 vertical-align top 274 position relative 275 top -10px 276 margin 0 12px 277 padding 6px 278 /*此处IE盒模型,包括padding,border在内*/ 279 width 56px 280 height 56px 281 box-sizing border-box 282 border-radius 50% 283 background #141d27 284 .logo 285 width 100% 286 height 100% 287 border-radius 50% 288 text-align center 289 background #2b343c 290 &.highlight 291 background rgb(0, 160, 220) 292 .icon-shopping_cart 293 line-height 44px 294 font-size 24px 295 color #80858a 296 &.highlight 297 color #fff 298 .num 299 position absolute 300 top 0 301 right 0 302 /*绝对定位,要指定宽高,才能撑开*/ 303 width 24px 304 heigt 16px 305 line-height 16px 306 text-align center 307 border-radius 16px 308 font-size 9px 309 font-weight 700 310 color #ffffff 311 background rgb(240, 20, 20) 312 box-shadow 0 4px 8px 0 rgba(0, 0, 0, 0.4) 313 .price 314 display inline-block 315 vertical-align top 316 margin-top 12px 317 padding-right 12px 318 line-height 24px 319 box-sizing border-box 320 border-right 1px solid rgba(255, 255, 255, 0.1) 321 font-size 16px 322 font-weight 700 323 &.highlight 324 color #fff 325 .desc 326 display inline-block 327 vertical-align top 328 margin 12px 0 0 12px 329 line-height 24px 330 font-size 10px 331 .content-right 332 flex 0 0 105px 333 width 105px 334 color rgba(255, 255, 255, 0.4) 335 .pay 336 height 48px 337 text-align center 338 line-height 48px 339 font-size 12px 340 font-weight 700 341 &.not-enough 342 background #2b333b 343 &.enough 344 background #00b43c 345 color #fff 346 .ball-contain 347 .ball 348 /*相对于视口进行变化*/ 349 position fixed 350 left 32px 351 bottom 22px 352 z-index 200 353 &.drop-enter-active, &.drop-leave-active 354 transition: all 0.4s cubic-bezier(0.49, -0.29, 0.75, 0.41) 355 .inner 356 width: 16px 357 height: 16px 358 border-radius: 50% 359 background: rgb(0, 160, 220) 360 transition: all 0.4s linear 361 .shopcart-list 362 position absolute 363 top 0 364 left 0 365 /*因为这个列表是从底部穿出来的,要设置z-index属性*/ 366 z-index -1 367 width 100% 368 transform: translate3d(0, -100%, 0) 369 &.fold-enter-active, &.fold-leave-active 370 transition: all 0.5s 371 &.fold-enter, &.fold-leave-active 372 transform translate3d(0, 0, 0) 373 .list-header 374 height 40px 375 line-height 40px 376 padding 0 18px 377 background #f3f5f7 378 border-bottom 1px solid rgba(7, 17, 27, 0.1) 379 .title 380 float left 381 font-size 14px 382 color rgb(7, 17, 27) 383 .empty 384 float right 385 font-size 14px 386 color rgb(0, 160, 220) 387 .list-content 388 padding 0 18px 389 max-height 217px 390 overflow hidden 391 background #fff 392 .food 393 position relative 394 padding 12px 0 395 box-sizing border-box 396 border-1px(rgba(7, 17, 27, 0.1)) 397 .name 398 line-height 24px 399 font-size 14px 400 color rgb(7, 17, 27) 401 .price 402 position absolute 403 right 90px 404 bottom 12px 405 line-height 24px 406 font-size 14px 407 font-weight 700 408 color rgb(240, 20, 20) 409 .cartcontrol-wrapper 410 position absolute 411 right 0 412 /*因为cartcontrol组件中设置了加号按钮有6像素边距*/ 413 bottom 6px 414 415 .list-mask 416 position fixed 417 left 0 418 top 0 419 width 100% 420 height 100% 421 /*z-index的值要小于shopcart层的z-index的值,因为是背景*/ 422 z-index 40 423 backdrop-filter blur(10px) 424 opacity 1 425 background rgba(7, 17, 27, 0.6) 426 &.fade-enter-active, &.fade-leave-active 427 transition: all 0.5s 428 &.fade-enter, &.fade-leave-active 429 opacity 0 430 background: rgba(7, 17, 27, 0) 431 </style>