商品评论数据

  1. 获取评论数据

    这里,并不是每个商品都有评论,这里根据每个商品是否有评论在进行数据请求并渲染

    1
    2
    3
    //获取评论数据
    if (data.rate.cRate !== 0) {}
    this.commentInfo = data.rate.list[0]
  2. 创建组件(DetailCommentInfo)

    这里需要展示评论的发表日期,但是接口传过来的是时间戳,所以我们需要讲时间戳转化为具体的日期格式,通过直接使用利用正则表达式的时间戳转化函数以及vue的过滤器将时间进行展示

    在utils加入以下转化代码(别人写好的转化函数)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    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);
    }

    在DetailCommentInfo组件进行引用并在过滤器使用

    1
    2
    3
    4
    5
    6
    7
    filters: {
    showDate: function (value) {
    let date = new Date(value*1000)
    //参数1:时间戳;参数2:具体的时间格式yyyy-mm-dd
    return formatDate(date, 'yyyy-MM-dd')
    }
    }

    具体使用

    1
    <span class="date">{{commentInfo.created | showDate}}</span>

    完整组件代码

    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
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    <template>
    <div>
    <div v-if="Object.keys(commentInfo).length !== 0" class="comment-info">
    <div class="info-header">
    <div class="header-title">用户评价</div>
    <div class="header-more">
    更多
    <i class="arrow-right"></i>
    </div>
    </div>
    <div class="info-user">
    <img :src="commentInfo.user.avatar" alt="">
    <span>{{commentInfo.user.uname}}</span>
    </div>
    <div class="info-detail">
    <p>{{commentInfo.content}}</p>
    <div class="info-other">
    <span class="date">{{commentInfo.created | showDate}}</span>
    <span>{{commentInfo.style}}</span>
    </div>
    <div class="info-imgs">
    <img :src="item" v-for="(item, index) in commentInfo.images" :key="index">
    </div>
    </div>
    </div>
    </div>
    </template>

    <script>
    import {formatDate} from 'common/utils';

    export default {
    name: 'DetailCommentInfo',
    props:{
    commentInfo:{
    type: Object,
    default(){
    return {};
    }
    }
    },
    data() {
    return {

    }
    },
    filters: {
    showDate: function (value) {
    let date = new Date(value*1000);
    return formatDate(date, 'yyyy-MM-dd')
    }
    }
    }
    </script>

    <style scoped>
    .comment-info {
    padding: 5px 12px;
    color: #333;
    border-bottom: 5px solid #f2f5f8;
    }

    .info-header {
    height: 50px;
    line-height: 50px;
    border-bottom: 1px solid rgba(0,0,0,.1);
    }

    .header-title {
    float: left;
    font-size: 15px;
    }

    .header-more {
    float: right;
    margin-right: 10px;
    font-size: 13px;
    }

    .info-user {
    padding: 10px 0 5px;
    }

    .info-user img {
    width: 42px;
    height: 42px;
    border-radius: 50%;
    }

    .info-user span {
    position: relative;
    font-size: 15px;
    top: -15px;
    margin-left: 10px;
    }

    .info-detail {
    padding: 0 5px 15px;
    }

    .info-detail p {
    font-size: 14px;
    color: #777;
    line-height: 1.5;
    }

    .info-detail .info-other {
    font-size: 12px;
    color: #999;
    margin-top: 10px;
    }

    .info-other .date {
    margin-right: 8px;
    }

    .info-imgs {
    margin-top: 10px;
    }

    .info-imgs img {
    width: 70px;
    height: 70px;
    margin-right: 5px;
    }
    </style>

  3. 导入组件并使用

    1
    <detail-comment-info :commentInfo="commentInfo"></detail-comment-info>

    相关商品推荐数据

这里请求的数据是另外一个接口,所以我们封装一个推荐商品的网络请i去,在network的detail.js中,书写如下代码

1
2
3
4
5
export function getRecommend() {
return request({
url: '/api/h8/recommend'
})
}

回到detail组件,导入该函数,并在created请求以及保存数据

1
2
3
4
//获取商品推荐数据
getRecommend().then((res) => {
this.recommends = res.data.list
})

这里我们用到GoodsList组件用于展示推荐的商品数据

在导入前我们需要对GoodListItem进行相应的小修改,因为数据来源的不同,所以得到各自的商品数据也会不同,首页的商品数据是通过goodsItem.image属性得到
而详情页的推荐数据是通过goodsItem.show.img得到,所以这里我们定义一个computed计算属性,对数据进行判断,存在哪个数据就对哪个数据进行渲染

1
2
3
4
5
computed: {
showImage() {
return this.goodsItem.image || this.goodsItem.show.img
}
}

标签进行相应的修改,动态获取链接

1
<img :src="showImage" alt="" @load="imgLoad">

然后就可以在Detail组件中进行引用了

1
<goods-list :goods="recommends"></goods-list>

OK。推荐信息的展示到此就完成啦

根据判断推荐信息是否需要刷新

在前面,推荐页引用了GoodsList组件,但是会有一个问题,我们之前在使用首页引用该组件时,因为scroll刷新的问题,我们让其图片加载完后发送一个事件出去,这就导致我们在详情页面调用该组件时也会使其向主页发送图片加载的事件,从而浪费性能,所以我们需要解决该问题,同时,因为详情页也需要刷新,所以我们需要采取合适的方法同时解决这两个问题,解决方案有两个

  1. 路由判断解决

    根据this.$route.path.indexOf('路径')判断引用该组件的当前页面是哪个,从而进行对应的事件发送

    在GoodsListItem中,我们修改imgLoad方法,根据.$route.path.indexOf()方法得到当前页面为哪个,从而发送相应事件,同时,你需要将相关页面的方法itemImageLoad改为homeItemImageLoad/detailItemImageLoad

    1
    2
    3
    4
    5
    6
    7
    8
    9
    imgLoad() {
    //解决判断推荐页或首页数据是否需要刷新①
    if (this.$route.path.indexOf('/home') !== -1) {
    this.$bus.$emit('homeItemImageLoad')
    }
    if(this.$route.path.indexOf('/detail' !== -1)){
    this.$bus.$emit('detailItemImageLoad')
    }
    }
  2. deactivated+destroyed

    这个方法可以不修改imgLoad的内容,转而添加Home和Detail的相关代码

    1
    2
    3
    imgLoad() {
    this.$bus.$emit('itemImageLoad')
    }

    我们可以在引用该组件的相关父组件的生命周期函数中,当该组件处于非活跃状态(即跳转其他组件)时,关闭改事件(this.$bus.$off(“事件名”))

    在Home.vue中,因为keep-alive的存在,所以即使跳转到其它路由,该组件也只是(deactivated)而不是销毁(destroyed),所以我们在deactivated函数中,取消该事件

    1
    2
    3
    4
    deactivated() {
    //取消监听
    this.$bus.$off('itemImgLoad')
    }

    在Detail.vue中,因为每次跳转其它路由,该组件会直接销毁,所以我们在其销毁(destroyed)时关闭该事件

    1
    2
    3
    destroyed() {
    this.$bus.$off('itemImageLoad')
    }

    mixin

我们需要同Home一样对Detail的相关图片加载事件也做scroll刷新处理,这些步骤和Home是完全一致的,所以,是否有一个办法能将这些共有的生命周期函数或者data数据进行统一管理,再在各个需要的组件去复用呢,答案就是——mixin

这里主要用到的是vue的混入(mixin)——官网介绍

混入 (mixin) 提供了一种非常灵活的方式,来分发 Vue 组件中的可复用功能。一个混入对象可以包含任意组件选项。当组件使用混入对象时,所有混入对象的选项将被“混合”进入该组件本身的选项。

这是Detail.vue和Home.vue中共有的生命周期函数,也就是监听图片的加载事件并作出防抖刷新操作,在这里,我们用data去接收防抖函数的返回值this.newRefresh以及监听图片加载做出的反应函数this.itemImgListener,然后再在该组件活跃时启动该函数this.$bus.$on('监听事件', 该监听事件要启动的函数)

1
2
3
4
5
6
7
8
mounted() {
this.newRefresh = debounce(this.$refs.scroll.refresh, 100) //注意不要加括号,否则传入的是函数返回值
this.itemImgListener = () => {
this.newRefresh()
// this.$refs.scroll.refresh()
}
this.$bus.$on('itemImageLoad', this.itemImgListener)
}

所以我们在common文件夹中新建一个mixin.js文件,将该生命周期函数导出,记得将防抖函数一并导入,同时你会发现,他们也有共同的data需要进行管理,所以我们在mixin.js中也一并将与scroll的refresh有关的data添加进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { debounce } from "common/utils"

export const itemListenerMixin = {
data() {
return {
itemImgListener: null,
newRefresh: null,
}
},
mounted() {
this.newRefresh = debounce(this.$refs.scroll.refresh, 100) //注意不要加括号,否则传入的是函数返回值

this.itemImgListener = () => {
this.newRefresh()
// this.$refs.scroll.refresh()
}

this.$bus.$on('itemImageLoad', this.itemImgListener)
},
}

然后在Detail和Home组件中分别导入该模块以及函数

1
2
//mixin操作
import {itemListenerMixin} from 'common/mixin.js'

使用方式非常简单,将需要用到的mixin函数定义在mixins数组中即可

1
mixins: [itemListenerMixin],

OK,这就是mixin的使用方式

最后,因为一个监听事件可能会有多个执行函数,所以$off同$on一样提供了第二个参数供我们添加将要关闭的具体函数,回到destroyed和deactivated函数中,修改相关代码

1
2
//取消监听
this.$bus.$off('itemImgLoad', this.itemImgListener)

点击标题跳转至指定内容

首先我们需要理清该业务的实现思路

  1. DetailNavBar组件发送每个item的点击事件以及索引值
  2. 父组件监听点击事件
  3. 定义一个数组存储每个子组件的起始Y值
  4. 监听每个子组件的起始Y值,将值添加至数组中🔺
  5. 在父组件的监听点击事件中,根据索引值跳转至指定位置

ok,前三步问题不大,重点在第四步,我们要在哪里监听每个组件的起始Y值,首先,我们先将需要监听的代码放出来

1
2
3
4
this.themeTopYs.push(0)
this.themeTopYs.push(this.$refs.params.$el.offsetTop - 44)
this.themeTopYs.push(this.$refs.comment.$el.offsetTop - 44)
this.themeTopYs.push(this.$refs.recommend.$el.offsetTop - 44)
  1. mounted()❌

    当我们在该函数中监听具体的值并存入数组时,发现不可行,原因就是,这时候的子组件还未完全创建好,高度并没有真正定下来

  2. updated()❌

    可以实现,但是该生命周期函数是在每次页面刷新时就会在执行一次,也就是说,你加载一张图片它就刷新一次,最终导致push一堆值进去,当然这个问题很好解决,在每次执行前加上一句this.themeTopYs = [] ,但是还是有问题,他执行太过频繁,浪费太多性能

  3. create中的this.$nextTick(回调函数)函数❌

    不可行,虽然他在组件创建完才执行,但是他只渲染出来DOM,这个时候异步请求过来的图片数据还未渲染完成,高度依然不准确

  4. 图片加载函数(imgLoad())✔

    我们首先在created()中去定义防抖后的高度获取函数,即组件创建后执行函数创建,同时定义一个datagetThemeTopYs接收该函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    created() {
    this.getThemeTopYs = debounce(() => {
    this.themeTopYs = []
    this.themeTopYs.push(0)
    this.themeTopYs.push(this.$refs.params.$el.offsetTop - 44)
    this.themeTopYs.push(this.$refs.comment.$el.offsetTop - 44)
    this.themeTopYs.push(this.$refs.recommend.$el.offsetTop - 44)
    this.themeTopYs.push(Number.MAX_VALUE)
    }, 100)
    }

    然后再在每次执行图片刷新时执行一次该函数

    1
    2
    3
    4
    imageLoad() {
    this.$refs.scroll.refresh()
    this.getThemeTopYs()
    }

    这样,每次在防抖执行时间内图片加载完就会执行刷新

滚动内容显示对应标题

通过监听scroll的滚动事件,动态改变所选标题的样式,同样理清思路

  1. 父组件接收Scroll滚动事件(@scroll="contentScroll"
  2. 判断当前滚动位置处于之前定义数组哪个区间内,是则返回该区间对应标题索引🔺
  3. DetailNavBar绑定ref同时将currentIndex修改为当前选项索引并传递过去(this.$refs.nav.currentIndex)实现样式改变

重点在于第二个的判断

  • for循环进行遍历for(let i = 0; i < this.themeTopYs.length; i++)

  • 内部进行判断,有点长

    1
    (this.currentIndex !== i && ((i < length - 1 && positionY >= this.themeTopYs[i] && positionY < this.themeTopYs[i + 1]) || (i === length - 1 && positionY >=  this.themeTopYs[i])))

    简短的总结一下

    • 当前的currentIndex是否为遍历的值(注意这是detaili组件定义的data属性,默认为0),作用是避免重复执行if语句内的代码

    • 当前的i是否小于该数组最大索引

      • 当前滚动位置是否大于 this.themeTopYs[i]
      • 是否小于 this.themeTopYs[i+1]
    • 当前的i是否等于该数组最大索引

      • 当前滚动位置是否大于 this.themeTopYs[i]

      上流程图理解下

    ![未命名文件 (6.详情页下/未命名文件 (1).png)](C:/Users/12524/OneDrive/note/vue/vue-mall/未命名文件 (1).png)

  • 满足条件,执行下列代码

    1
    2
    this.currentIndex = i
    this.$refs.nav.currentIndex = this.currentIndex

    上述逻辑看似完美无缺(当然有点逻辑复杂),但是执行起来效率可能会有点慢,接下来就是考虑如何缩短代码量

1
2
3
4
5
6
for (var i = 0; i < length - 1; i++) {
if(this.currentIndex !== i && (positionY > this.themeTopYs[i] && positionY < this.themeTopYs[i+1])){
this.currentIndex = i
this.$refs.nav.currentIndex = this.currentIndex
}
}

这边利用Number.MAX_VALUE获取js的理论最大值,在前面获取各个组件位置后面加上

1
this.themeTopYs.push(Number.MAX_VALUE)

从而摆脱判断当前索引值是否为最大的问题,同时将 for语句内的i < length 改为i < length - 1,因为我们实际上也不需要最后一个索引值,这样,就可以判断当前高度符合哪个区间范围,实现索引赋值,减少代码量,以空间换时间(操作系统)

底部工具栏封装

步骤

  1. 创建组件
  2. 引用组件
  3. 样式修改(根据该底部栏的高度,讲父组件Detail的scroll样式高度改为 height: calc(100% - 44px - 58px);

没啥可说的,直接丢代码

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
<template>
<div class="bottom-bar">
<div class="bar-item bar-left">
<div>
<i class="icon service"></i>
<span class="text">客服</span>
</div>
<div>
<i class="icon shop"></i>
<span class="text">店铺</span>
</div>
<div>
<i class="icon select"></i>
<span class="text">收藏</span>
</div>
</div>
<div class="bar-item bar-right">
<div class="cart" @click="addToCart">加入购物车</div>
<div class="buy">购买</div>
</div>
</div>
</template>

<script>
export default {
name: "DetailBottomBar",
methods: {
addToCart() {
this.$emit('addToCart')
}
}
}
</script>

<style scoped>
.bottom-bar {
height: 58px;
position: relative;
background-color: #fff;
left: 0;
right: 0;
bottom: 0;

display: flex;
text-align: center;
}

.bar-item {
flex: 1;
display: flex;
}

.bar-item>div {
flex: 1;
}

.bar-left .text {
font-size: 13px;
}

.bar-left .icon {
display: block;
width: 22px;
height: 22px;
margin: 10px auto 3px;
background: url("~assets/img/detail/detail_bottom.png") 0 0/100%;
}

.bar-left .service {
background-position:0 -54px;
}

.bar-left .shop {
background-position:0 -98px;
}

.bar-right {
font-size: 15px;
color: #fff;
line-height: 58px;
}

.bar-right .cart {
background-color: #ffe817;
color: #333;
}

.bar-right .buy {
background-color: #f69;
}
</style>

回到顶部工具

同Home一样,将相关代码以及组件引入即可

加入购物车

这里实现点击加入购物车按钮,将该商品添加至购物车,实现过程较为简单,首先在将子组件DeatilBottomNav的点击事件发送到父组件中去

1
<div class="cart" @click="addToCart">加入购物车</div>
1
2
3
addToCart() {
this.$emit('addToCart')
}

Detail.vue监听该组件点击事件

1
<detail-bottom-bar @addToCart="addToCart"></detail-bottom-bar>

实现点击方法同时新建一个数组用于存储需要传递给购物车的值

1
2
3
4
5
6
7
8
9
//添加购物车
addToCart() {
const product = {}
product.image = this.topImages[0]
product.title = this.goods.title
product.desc = this.goods.desc
product.price = this.goods.realPrice
product.iid = this.iid
}