前言:上一篇項目總結介紹了頁面骨架的開發、header組件的開發,這一篇主要梳理:商品組件開發、商品詳情頁實現。項目github地址:https://github.com/66Web/ljq_eleme,歡迎Star。
![]() |
![]() |
goods |
一、商品組件開發 |
- App.vue主組件傳seller對象給每個路由:
<router-view :seller="seller"></router-view>
兩欄布局-flex布局手機屏幕自適應
- 設計:無高度滾動條,高度超過視口高度就會隱藏
<div class="goods">
<div class="menu-wrapper"></div>
<div class="foods-wrapper"></div>
</div>
.goods
display: flex
position: absolute
top: 174px
bottom: 46px
width: 100%
overflow: hidden
.menu-wrapper
flex: 0 0 80px
width: 80px
background: #f3f5f7
.foods-wrapper
flex: 1
左側布局-菜單列表
- 需求:文字標題可能單行,也可能多行,但都要在列表項中垂直居中
- 小技巧:使用列表項display:table,文字標題disable:table-cell
.menu-item
display: table
height: 54px
width: 56px
line-height: 14px
.text
display: table-cell
width: 56px
vertical-align: middle
font-size: 12px
- 關於box-sizing:border-box; 規定兩個並排的帶邊框的框
右側布局-食品列表
- 列表嵌套:第一層遍歷商品項item in goods, 第二層遍歷單個商品的信息項food in item.foods
列表滾動-better-scroll 第三方JS庫
- 基於iscroll重寫的庫:better-scroll詳解博客【重點突破】—— 當better-scroll 遇見Vue
- 安裝:
cnpm install better-scroll --save
- 使用步驟:
- goods.vue 引入:
import BScroll from 'better-scroll';
- ref 屬性獲取dom元素:駝峰命名法
<div class="menu-wrapper" ref="menuWrapper"> <div class="foods-wrapper" ref="foodsWrapper">
-
better-scroll初始化:
methods: { _initScroll(){ this.meunScroll=new BScroll(this.$refs.menuWrapper,{}); this.foodsScroll=new BScroll(this.$refs.foodsWrapper,{}); } }
-
成功回調函數中調用_initScroll方法:
this.$nextTick(()=>{ this._initScroll(); })
-
this.$nextTick()這個方法作用是當數據被修改后使用這個方法會回調獲取異步更新后的dom再render出來
-
如果不在下面的this.$nextTick()方法里回調這個方法,數據改變后再來計算滾動軸就會出錯
左右聯動
- 需求:滾動右側,左側跟着變化;點擊左側,右側滾動到相應位置
- 原理:依賴右側滾動列表實時變化的Y值(縱坐標),移動到哪個區間,左側列表就要顯示哪個區間
- 【滾動右側時左側相應滾動】思路&實現:
- 在data中定義數組用來存儲不同區間的高度
data () { return { goods:[], listHeight: [] } }
-
為了獲取高度,給food-list定義一個class--food-list-hook,不用來編寫css,專門用來獲取DOM元素,沒有實際的效果,只是用來被js選擇的
<li v-for="item in goods" :key="item.id" class="food-list food-list-hook">
-
定義foodList拿到每個li,每個li是包括包括標題在內的每一類food的高度,不是單獨的一種good,將_calculateHeight放在nextTick中初始化_initScroll的后面,保證其能正確計算到高度
_calculateHeight() { //food-list-hook類的添加知識為了能拿到food列表,例如,拿到的是多個類似整個粥品的區塊 let foodList = this.$refs.foodsWrapper.getElementsByClassName('food-list-hook'); let height = 0; this.listHeight.push(height); //listHeight是一個遞增的區間數組,是每個專區高度的累加 for (let i = 0; i < foodList.length; i++) { let item = foodList[i]; height += item.clientHeight; this.listHeight.push(height); } }
-
在data中定義一個scrollY對象,用來跟蹤滾動的高度 scrollY:0;在初始化betterScroll時,為右側添加probeType--可以檢測到右側實時滾動的位置,監聽scroll,將其實時滾動的位置暴露出來
data () { return { goods:[], listHeight: [], scrollY: 0 } }
_initScroll() { this.meunScroll=new BScroll(this.$refs.menuWrapper,{ click: true //使better-scroll可點擊,默認派發一個點擊事件 }); this.foodsScroll=new BScroll(this.$refs.foodsWrapper,{ click: true, probeType: 3 //BScroll滾動時,能實時告訴我們滾動的位置,類似探針的效果 }); //foodsScroll監聽事件,在scroll滾動時能見位置實時暴露出來 this.foodsScroll.on('scroll', (pos) => { this.scrollY = Math.abs(Math.round(pos.y));//本身是個負值,取正值 }) }
-
拿到滾動的高度和內容區的固定高度之后, 查看scrollY落在哪個區間,並返回那個區間的索引(!height2是測試最后一個區間的)其中,>= 向下的是一個閉區間,這樣第一個就會高亮了
computed: { currentIndex() { //currentIndex對應菜單欄的下標 for (let i = 0; i < this.listHeight.length; i++) { //不要忘了加this引用 let height1 = this.listHeight[i]; let height2 = this.listHeight[i + 1]; //獲得了一個區間的上下范圍,判斷scrollY落到這個區間,!height2是判斷最后一個區間 //避免i溢出,>= 向下的是一個閉區間,這樣第一個就會高亮了 if (!height2 || (this.scrollY >= height1 && this.scrollY < height2)) { return i; //映射到第5行menu的變化 } } return 0; }
-
拿到index之后,回到左側的menu區,當我們遍歷menu的時候,如果$index等於我們計算得到的currentIndex時,就為當前的li添加一個current樣式
<!-- 如果index等於currentIndex,就為這個li添加一個current類,改變左側導航欄的背景顏色--> <li v-for="(item,index) in goods" :key="item.id"
class="menu-item" :class="{'current': currentIndex === index}" @click = "selectMenu($index, $event)">&.current position: relative z-index: 10 margin-top: -1px background: #ffffff font-weight: 700 .text border-none()
- 【點擊左側右側滾動】思路&實現:
- 在左側菜單欄添加點擊事件selectMenu, @click = "selectMenu($index, $event)",將index傳進去,就可以知道點選的是哪個區域,然后利用原生DOM操作將高度滾動到相應的位置
- 點擊左側菜單欄的時候沒有反應,因為BScroll默認阻止點擊事件,所以在 _initScroll()中獲取DOM對象時添加click: true,並解決PC端雙點擊問題,event是點擊時的event
selectMenu (index, event) { if (!event._constructed) { //瀏覽器直接return掉,去掉自帶click事件的點擊 return; } let foodList = this.$refs.foodsWrapper.getElementsByClassName('food-list-hook'); let ref = foodList[index]; //取到index對應的DOM this.foodsScroll.scrollToElement(ref, 300);//滾動到DOM所在位置 //console.log(index); }
購物車組件
- 定位在視口底部: fixed布局——右側寬度固定,左側自適應
<div class="content"> <div class="content-left"></div> <div class="content-right"></div> </div>
.content display: flex background: #141d27 .content-left flex: 1 /*讓所有彈性盒模型對象的子元素都有相同的長度,忽略它們內部的內容*/ .content-right flex: 0 0 105px /*flex三個參數依次表示:等分、內容縮放情況、站位空間*/ width: 105p
-
display:inline-block有一個默認間隙的問題 —— 解決:父級font-size:0
- 三種狀態轉換: 數據驅動Dom變化
cartcontrol組件
- 在設計尺寸基礎上增加點擊區域: padding
- 檢測數據的變化:Vue.set
- 引入:
Vue import Vue from 'vue';
-
使用Vue.set接口:
Vue.set(this.food, 'count', 1);
- 按鈕平移+漸隱漸現+滾動動畫:
<transition name="move">...</transition>
.cart-decrease display: inline-block padding: 6px transform: translate3d(0, 0, 0) transform: rotate(0) &.move-enter-active, &.move-leave-active transition: all 0.4s linear transform: translate3d(0, 0, 0) transform: rotate(0) &.move-enter, &.move-leave-active opacity: 0 transform: translate3d(24px, 0, 0)/*開啟硬件加速,讓動畫更流暢*/ transform: rotate(180deg) .inner display: inline-block line-height: 24px font-size: 24px color: rgb(0, 160, 220)
購物車拋物線小球動畫
- data數據中定義一個數組,存放5個小球,這5個小球可以滿足的動畫的運行
data() { return { balls: [{ //每一個成員都用來維護當前小球的狀態,初始狀態都是隱藏的 show: false }, { show: false }, { show: false }, { show: false }, { show: false }], //添加一個變量,用來存貯已經下落的小球 dropBalls: [], fold: true //購物車詳情列表默認折疊 }; }
- 布局
<div class="ball-container"> <div v-for="(ball, index) in balls" :key="index"> <div class="ball-container"> <div v-for="(ball, index) in balls" :key="index"> <transition name="drop" > <div v-show="ball.show" class="ball"> <div class="inner inner-hook"></div> </div> </transition> </div> </div> </div> </div>
- :key報錯問題:key值是必須唯一的,如果重復就會報錯。可以把key值改為index,就可以避免這個情況
<div v-for="(ball, index) in balls" :key="index" v-show="ball.show"></div>
- 動畫需求:小球有拋物線軌跡運動的過渡,而且發射出去就不會再回來了
- 動畫屬性只用enter,不用leave,並且小球起始點需要動態計算
.ball-container position: fixed left: 32px bottom: 22px z-index: 200 .inner width: 15px height: 15px border-radius: 50% background-color: #00A0DC transition: all 1s linear &.drop-enter-active transition: all 1s cubic-bezier(0.49, -0.29, 0.75, 0.41)
-
CSS3 三次貝塞爾曲線(cubic-bezier):
貝塞爾曲線通過控制曲線上的四個點(起始點、終止點以及兩個相互分離的中間點)來創造、編輯圖形,
繪制出一條光滑曲線並以曲線的狀態來反映動畫過程中速度的變化。 - 動畫分為兩層,外層控制小球y軸方向和運動的軌道,內層控制x軸方向的運動
-
使用js動畫鈎子,vue在實現動畫的時候提供了幾個javascript鈎子,可配合css動畫一起使用,也可單獨使用
methods: { dropMove(el) { // console.log(el) for(let i=0; i<this.balls.length; i++) { let ball = this.balls[i]; if(!ball.show) { ball.show = true; ball.el = el; this.dropBalls.push(ball); return; } } }, beforeEnter(el, done) { let count = this.balls.length; while (count--) { let ball = this.balls[count]; if(ball.show) { let rect = ball.el.getBoundingClientRect();//返回元素的大小及其相對於視口的位置 let x = rect.left - 32 //ball-container left:32 let y = -(window.innerHeight - rect.top -22); el.style.display = ''; el.style.transform = `translate3d(0,${y}px,0)`;//外層元素縱向移動 el.style.webkitTransform = `translate3d(0,${y}px,0)`; let inner = el.getElementsByClassName('inner-hook')[0];//內層元素橫向移動 inner.style.webkitTransform = `translate3d(${x}px, 0, 0)`; inner.style.transform = `translate3d(${x}px, 0, 0)`; // console.log(el); } } }, dropEnter(el, done) { /*手動取到offsetHeight, 觸發瀏覽器重繪*/ let rf = el.offsetHeight; this.$nextTick(() => { //樣式重置回來 el.style.webkitTransform = 'translate3d(0, 0, 0)'// 設置小球掉落后最終的位置 el.style.transform = 'translate3d(0, 0, 0)' let inner = el.getElementsByClassName('inner-hook')[0] inner.style.webkitTransform = 'translate3d(0, 0, 0)' inner.style.transform = 'translate3d(0, 0, 0)' el.addEventListener('transitionend', done) // Vue為了知道過渡的完成,必須設置相應的事件監聽器。它可以是transitionend或 animationend }) // console.log(el); }, afterEnter(el) { let ball = this.dropBalls.shift(); if(ball) { ball.show = false; el.style.display = 'none'; } // console.log(el); }
每個鈎子都有一個參數el: 當前執行transition動畫的DOM對象
當我們點擊觸發一個過渡的時候,我們在beforeEnter里先拿到當前元素的偏移位置,
然后給過渡元素設置其起始位置,在enter里需要重新觸發下瀏覽器的重繪,然后在下一幀重新設置元素的結束位置,
這時就會產生過渡效果,在過渡完成后我們將當前元素隱藏即可。關於tansition實踐詳解博客:【重點突破】—— Vue2.0 transition 動畫Demo實踐填坑
二、food商品詳情頁實現 |
![]() |
![]() |
food |
商品詳情頁實現-food.vue組件
- 設計時:父組件可以調用子組件方法,子組件不能調用父組件方法
- 常見命名習慣:如果是父組件調用的方法,命名如show();如果是組件私有方法,命名會在前面加_, 如_show()
- 詳情頁從右往左飛入動畫:
.food position: fixed left: 0 top: 0 bottom: 48px z-index: 30 width: 100% background: #ffffff transform: translate3d(0, 0, 0) &.move-enter-active, &.move-leave-active transition: all 0.2s linear transform: translate3d(0, 0, 0) &.move-enter, &.move-leave-active opacity: 0 transform: translate3d(100%, 0, 0)
- 坑:頭圖顯示是異步加載的,不能寫死寬高,因為視口是自適應的,但不設置高度,頁面內容會有圖片撐開高度的抖動過程。
- 解決:一開始給<img>限制寬高,設為容器的100%
.image-header position: relative width: 100% height: 0 padding-top: 100% // 百分比是相對於盒子的寬度來計算的,看起來就像是一個正方形 img position: absolute top: 0 left: 0 width: 100% height: 100%
- 坑:“加入購物車”一點擊就會display:none,這樣執行better-scroll動畫的時候,找ball.el.getBoundingClientRect()會找不到,小球就不能找到正確的初始位置。
- 解決:給消失的過程加一個opcity的transition動畫,時長0.2s,這樣就不會立刻消失
&.buy-enter-active, &.buy-leave-active transition: all 0.2s opacity: 1 &.buy-enter, &.buy-leave-active opacity: 0
- 坑:菜單列表的“+”“-”按鈕,每次點擊都會觸發詳情頁顯示,這是因為點擊事件被穿透了。
- 解決:給cart-control.vue組件中的“+”“-”按鈕的點擊事件,都添加阻止事件冒泡
@click.stop.prevent="decreaseCart($event)" @click.stop.prevent="addCart($event)"
- 同理,詳情頁的“加入購物車”按鈕,最好也加上阻止事件冒泡
@click.stop.prevent="addFirst($event)"
split組件實現
- 一個很簡單的樣式模板組件,分隔區
<template> <div class="split"></div> </template> <script type="text/ecmascript-6"> export default {}; </script> <style lang="stylus" rel="stylesheet/stylus"> .split width 100% height 16px border-top: 1px solid rgba(1, 17, 27, 0.1); border-bottom: 1px solid rgba(1, 17, 27, 0.1); background: #f3f5f7 </style>
商品評價 - ratingselect 組件
- 設置ratingselect組件中需要的props接收的數據,數據應從food.vue組件傳入<ratingselect></ratingselect>,並由ratingselect.vue的props接收
<v-ratingselect :select-type="selectType" :only-content="onlyContent" :desc="desc" :ratings="food.ratings" @increment="incrementTotal"> </v-ratingselect>
- props的值如下:首先是有一個變量【only-content】是否顯示只看內容,還有一個【select-type】控制選擇的類型,還有要維護一個【ratings】所有評價的數據,因為這里有一個評價數量;還要去維護一個【desc】描述,是(全部,推薦,吐槽)還是(全部,滿意,不滿意),按照以上標准設置外部組件傳入ratingselect的props值
const POSITIVE = 0; const NEGATIVE = 1; const ALL = 2; export default { //需要一些評價數據才能完成評價組件 props: { ratings: { type: Array, default() { return []; } }, selectType: { //全部,滿意,不滿意 type: Number, default: ALL //默認情況時ALL,值等於2 }, onlyContent: { //只看有內容的評價還是所有的評價 type: Boolean, default: false //設置為可以看到所有的評價 }, desc: { //描述 type: Object, default() { //默認desc是這三種,在商品詳情頁的時候傳入推薦或者吐槽 return { all: '全部', positive: '滿意', negative: '不滿意' }; } } },
-
在food.vue(商品詳情頁)中引入ratingSelect組件的時候,將desc改成"全部","推薦"和"吐槽",接下來寫DOM布局:
<template> <div class="ratingselect"> <div class="rating-type" border-1px> <span>{{desc.all}}</span> <span>{{desc.positive}}</span> <span>{{desc.negative}}</span> </div> <div @click="toggleContent($event)" class="switch" :class="{'on':oContent}"> <span class="icon-check_circle"></span> <span class="text">只看有內容的評價</span> </div> </div> </template>
-
在food.vue(商品詳情頁)的data中掛載對上述對象的跟蹤,並對其進行初始化
const POSITIVE = 0; const NEGATIVE = 1; const ALL = 2; data () { return { showFlag: false, selectType: ALL, onlyContent: false, //先設置組件一開始顯示有內容的評價 desc: { //desc做了改變 all: '全部', positive: '推薦', negative: '吐槽' } }; }
-
需求:在切換不同商品的時候能有相同的初始化狀態 —— 定義show()作為goods組件中調用food組件的函數,即點開商品詳情的顯示函數,將初始化設置傳入到show()中
show() { //可以被父組件調用到,方法前加下划線一般是私有方法 this.showFlag = true; //初始化部分,ratingselect組件是被被不同的商品使用的,所以我們希望在點開不同的商品時,能有一樣的初始化狀態 this.selectType = ALL; this.onlyContent = false; //展示界面時用到BScroll this.$nextTick(() => { if (!this.scroll) { this.scroll = new BScroll(this.$refs.food, { click: true // 可以被點擊 }); } else { this.scroll.refresh(); } }); }
- 兩種樣式:公用樣式、特殊樣式
.ratingselect .rating-type padding 18px 0 margin 0 18px //保證橫線的長度 border-1px(rgba(7,17,27,0.1)) font-size 0 .block //沒有寫文字的時候是沒有被撐開的 display inline-block padding 8px 12px margin-right 8px border-radius 1px line-height 16px font-size 12px color rgb(77,85,93) &.active // block的active要設置一下 color #ffffff .count margin-left 2px font-size 8px &.positive background rgba(0,160,220,.2) &.active background rgb(0,160,220) &.negative background rgba(77,85,93,0.2) &.active background rgb(77,85,93) .switch padding 12px 18px line-height 24px border-bottom 1px solid rgba(7,17,27,0.1) color rgb(147,153,159) font-size 0 &.on .icon-check_circle color #00c850 .icon-check_circle display inline-block vertical-align top margin-right 4px font-size 24px .text display inline-block vertical-align top font-size 12px
- 被選中
:class="{'active':selectType===2}"
- 居中對齊:
display: inline-block vertical-align: top
-
因為rating下有一條border,所以在rating下不可以設置四周的padding值,如果設置了border就撐不開整個屏幕了
.rating //因為要在rating title下方畫一條橫線,所以不能用padding-left,改用title的margin代替 padding-top: 18px .title line-height 14px margin-left 18px font-size 14px color rgb(7,17,27)
- 坑:[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop’s value. Prop being mutated: “gems” (found in component: )
- 這是因為在vue2.0中,直接修改prop是被視作反模式的。由於在新的渲染機制中,每當父組件重新渲染時,子組件都會被覆蓋,所以應該把props看做是不可變對象。
- 解決:在props中接收到父組件傳過來的selectType和onlyContent的值之后,在data中重新定義變量接收,以便觀測值的變化(因為子組件將改變data中的值,子組件要將這些變化的值傳遞個父組件)
data() { return { sType : this.selectType, oContent : this.onlyContent } }
- 之后,sType就替代了this.selectType,所以DOM就變成了
<template> <div class="ratingselect"> <div class="rating-type" border-1px> <span class="block positive" @click="select(2,$event)" :class="{'active':sType === 2}">{{desc.all}}<span class="count">{{ratings.length}}</span> </span> <span class="block positive" @click="select(0,$event)" :class="{'active':sType === 0}">{{desc.positive}}<span class="count">{{positives.length}}</span></span> <span class="block negative" @click="select(1,$event)" :class="{'active':sType === 1}">{{desc.negative}}<span class="count">{{negatives.length}}</span></span> </div> <div @click="toggleContent($event)" class="switch" :class="{'on':oContent}"> <span class="icon-check_circle"></span> <span class="text">只看有內容的評價</span> </div> </div> </template>
-
編寫rating-type和swicth切換有內容評價部分的綁定函數:select(type, event) —— 在點擊的時候就把類型123傳進去,傳入event是因為外層是一個betterScroll,要進行點擊事件的判斷,將sType的值更新之后通過emit將函數派發出去;
methods: { select (type, event) { //點擊的時候外層是有一個BScroll的,所以要傳遞event阻止默認點擊 if (!event._constructed) { //瀏覽器直接return掉,去掉自帶click事件的點擊 return; } //將this.selectType設置成傳入的參數,而不是food傳過來的初始化的值,之后樣式就可以隨着點擊改變了 this.sType = type; /派發事件通知父組件food.vue selectType的改變,將type值傳出去
console.log('ratingselect.vue ' + type); this.$emit('increment', 'selectType', this.sType); }, toggleContent (event) { if (!event._constructed) { //瀏覽器直接return掉,去掉自帶click事件的點擊 return; } this.oContent = !this.oContent; console.log('ratingselect.vue ' + this.oContent); this.$emit('increment', 'onlyContent', this.oContent); } } -
統計不同評價的數量(過濾評價類型),添加計算屬性 -- positives和negitives數組,長度即為評價數量
<div class="rating-type" border-1px> <span class="block positive" @click="select(2,$event)" :class="{'active':sType === 2}">{{desc.all}}<span class="count">{{ratings.length}}</span> </span> <span class="block positive" @click="select(0,$event)" :class="{'active':sType === 0}">{{desc.positive}}<span class="count">{{positives.length}}</span></span> <span class="block negative" @click="select(1,$event)" :class="{'active':sType === 1}">{{desc.negative}}<span class="count">{{negatives.length}}</span></span> </div>
computed: { positives() { //對應所有正向評價的數組 return this.ratings.filter((rating) => { return rating.rateType === POSITIVE; }); }, negatives() { return this.ratings.filter((rating) => { return rating.rateType === NEGATIVE; }); } }
評價列表
- 切換子組件的按鈕之后,父組件就可以根據子組件的選擇進行內容的切換
- 為列表的顯示添加選擇
<li v-show="needShow(rating.rateType, rating.text)" //v-show特殊用法:綁定函數返回值
v-for="rating in food.ratings"
:key="rating.id"
class="rating-item border-1px"> - 定義needshow()
needShow(type, text) { // console.log('this.selectType: ' + this.selectType + ' type: ' + type + ' out ' + text); if (this.onlyContent && !text) { return false; } if (this.selectType === ALL) { return true; } else { //console.log('this.selectType: ' + this.selectType + 'type: ' + type + ' in ' + text); return type === this.selectType; } }
- ratingselect.vue 中進行rating.rateType的切換,變量更改后的結果要傳遞到父組件中,這時用到了incrementTotal()
incrementTotal(type, data) { // 對子組件更改的數值進行監聽 this[type] = data; this.$nextTick(() => { // 當我們改變數據的時候,DOM的更新是異步的 this.scroll.refresh(); }); }
- 觸發事件increment: 在子組件ratingselect中使用select和toggleContent中進行emit派發
- 時間的顯示添加過濾器,將時間戳轉化為時間字符串
<div class="time">{{rating.rateTime | formatDate}}</div> import {formatDate} from 'common/js/date.js';
export function formatDate(date, fmt) { if (/(y+)/.test(fmt)) { fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length)); } let o = { 'M+': date.getMonth() + 1, 'd+': date.getDate(), 'h+': date.getHours(), 'm+': date.getMinutes(), 's+': date.getSeconds() }; for (let k in o) { if (new RegExp(`(${k})`).test(fmt)) { let str = o[k] + ''; fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? str : padLeftZero(str)); } } return fmt; } function padLeftZero(str) { return ('00' + str).substr(str.length); }
filters: { formatDate(time) { let date = new Date(time); return formatDate(date, 'yyyy-MM-dd hh:mm'); } }
注:項目來自慕課網