1、前言(废话)
随着codewhy的vue2.0学习结束,想着做个项目练练手便跟着老师的视频项目学习一下思路。本篇记录一下项目的大概思路。
首先还是来划分一下目录结构,这样做项目会格外清晰,不至于找不到自己写的都是个啥。(vuecli3.0为例)
在脚手架自动搭建好之后,大目录分为:
1、node_modules文件夹(不用细究,npm install自动生成,跟node有关)
2、public文件夹(相当于vue2.0的static文件夹,一般是.html,.icno之类的文件)
3、src文件夹(这就是我们写源码的主要部分了)。接下来我们重点分析src文件夹里的目录结构。(1)assets文件夹是资源文件夹,主要放图片资源和css文件资源。(2)common文件夹主要为混入(mixin)与数据处理的函数。(3)components文件夹主要是组件所放位置。common文件夹是可以通用的组件,就是别人项目也能用。content文件夹是放具有本项目特色的组件。(4)network文件夹是存放网络请求相关的函数。(5)router则是该项目路由配置文件的项目。(6)store文件夹是vuex用来管理各组件公共变量的。(7)views文件夹则是项目各个页面进行分类编码,属于该页面的个性化组件也可以定义在该文件夹。(8)APP.vue就是进行组件展示和渲染。(9)main.js就是项目入口文件。相当于vue的入口,系统进入vue中,先起作用的就是main. js,通过main. js内的代码引导系统下一步怎么做。
4、各种配置文件
配置别名,避免重复写多余路径。
module.exports = {
configureWebpack: {
resolve: {
alias: {
'assets': '@/assets',
'common': '@/common',
'components': '@/components',
'network': '@/network',
'router': '@/router'
}
}
}
}
2、底部导航栏(TabBar)
废话不多说,直接进入正题。做这类移动商城类项目,底部导航栏写好,整个项目的框架也就大致搭建完成。
1、封装一个单独的TabBar组件,让TabBar位于底部,并且设置相关的样式(就是位于底部就可以)。
在这里需要引入一些公共的css样式文件,目前在APP.vue的style中通过@import处理。
@import url('./assets/css/base.css');
通过display:fixed布局到页面底部,然后就是弄背景色调阴影。
2、为了实现重用性和个性化,自定义TabBarItem,使其可以传入图片和文字。这就需要slot插槽的帮助。
定义一定数量的插槽,使用flex布局进行布局,在每个插槽外层包装div,用于设置插槽的样式。且每个插槽提供图片和文字两部分。
在TabBarItem里定义三个插槽,前两个是具有选择关系的图片插槽,最后一个文字插槽。
然后对该项目配置路由,在router文件夹下配置每个组件的路由,然后对TabBarItem进行点击事件,根据项目需要配置路由,
this. r o u t e r . r e p l a c e ( t h i s . p a t h ) (不可返回)或者 t h i s . router.replace(this.path)(不可返回) 或者 this. router.replace(this.path)(不可返回)或者this.router.push(this.path)(可返回)
这样TabBar这一部分算基本结束。
3、首页(home页面)
3.1、首页导航栏
鉴于该项目多处都用到导航栏的部分,所以在此处封装一个NavBar的组件。考虑到导航栏的个性化,同时放三个具名插槽,为左中右三部分。同时注意,为了给插槽调整样式,但如果直接给slot添加class类名,以后会出现很多奇奇怪怪的报错,所以在这里用一个div套一个slot,然后进行样式的调整。
3.2、首页网络数据的请求
目前vue中经常使用的网络请求是axios,但难免以后网络请求淘汰axios,而选择一个新的网络请求库。为了以后修改方便以及方便给axios修改一些默认配置,在这里选择二次封装网络请求。
在network文件夹,新建request.js(随便取)文件,对axios进行二次封装。(接口别期待找到了,需要找老师,10yuan体验)因为请求回来的数据很多,也很乱。拦截处理,返回有用的data里面的数据。
新建home.js,以后与home网络请求相关的都放这。(美观,也方便管理)
把请求下来的数据放在各个组件的data中保存,避免垃圾处理回收。
getHomeMultidata() {
getHomeMultidata().then(res => {
// console.log(res);
this.banners = res.data.banner.list;
this.recommends = res.data.recommend.list;
});
},
3.3、首页轮播图
这里老师有写好的,可以直接拿来用(但有一定的bug照片第一次刷新的时候,有时候出现,有时候不出现,很无语),也可以用现成的ui库(比如vant库)。但我还是建议手撸一遍源码,保证自己会写的前提下,再使用组件库。在这里,我用的是vant组件库,只把轮播图数据传进去即可。
3.4、首页推荐模块
这一部分很简单,继续封装成一个属于home的独立组件,然后div包装,flex布局,因为图片和文字放在一个超链接中,后面的页面直接用的人家的连接,咱也不用写,也不用管。
3.5、首页本周流行模块
老师懒省事,就是一张图片放那了,自己改改样式和布局,弄成一个组件即可。
3.6、首页商品展示模块
兄弟萌,它来了它来了,它迈着嚣张的步子走来了——better-scroll(bug-scroll,可能是我菜,反正用的不舒服)。
3.6.1、展示模块的TabControl
由于该项目,这种控制模块长得都一样,就文字不一样。这里就不采用插槽而是固定格式三个位置,父与子传递数据,传入文字数组即可。
吸顶效果: 老师用了一个很巧妙的方式,放两个TabControl组件,一个紧跟导航栏,一个放在推荐模块下,通过实时位置与TabControl组件位置进行比较,小于第二个TabControl组件高度,第二个出现,大于TabControl组件高度,第一个出现。
3.6.2、展示模块的数据请求与划分
对数据请求划分三类,流行,新款,精选。每一类按页进行划分与展示。
goods: {
pop: {
page: 0, list: [] },
new: {
page: 0, list: [] },
sell: {
page: 0, list: [] }
}
在home.js对展示数据进行按页请求
export function getHomeGoods(type,page) {
return request({
url:'/home/data',
params:{
type,
page
}
})
}
在vue的生命周期函数created,一旦开始创建,就需要申请数据。
用于请求更多的数据
3.6.3、数据的展示结构
然后封装GoodsList和GoodsListItem两个组件。把对应的数据传进去进行展示,然后调样式。
GoodsList组件:
GoodsListItem组件
3.6.4、TabControl控制不同类型数据展示
在TabControl组件的点击事件中,发送事件,暴露点击的选项参数。然后在home组件对参数进行处理。
tabClick(index) {
switch (index) {
case 0:
this.currenttype = "pop";
break;
case 1:
this.currenttype = "new";
break;
case 2:
this.currenttype = "sell";
break;
}
然后通过currenttype这个变量把对应的数据传给goodList。
<goods-list :goods="showGoods"></goods-list>
computed: {
showGoods() {
return this.goods[this.currenttype].list;
}
}
从而实现TabControl控制不同类型数据展示。
但有一个小问题,就是两个TabControl状态无法保持一致。在TabControl的点击事件中保证两个选项相同即可。
this.$refs.tabControl1.currentIndex = index;
this.$refs.tabControl2.currentIndex = index;
3.6.5、better-scroll进行对滚动重构
2.0版本的better-scroll我个人感觉不是太好用,这里采用1.13.2版本的。通过npm install @[email protected] --save
进行局部安装。同样为了避免以后滚动更换库,对better-scroll封装成一个组件scroll。
<template>
<div ref="wrapper">
<div class="content">
<slot></slot>
</div>
</div>
</template>
import BScroll from "better-scroll";
export default {
name: "myScroll",
data() {
return {
scroll: null,
message: "哈哈哈",
};
},
props: {
probeType: {
type: Number,
default: 0,
},
pullUpLoad: {
type: Boolean,
default: false
}
},
//之前是mouted
mounted() {
// 1.创建BScroll对象
this.scroll = new BScroll(this.$refs.wrapper, {
observeDOM: true,
observeImage: true,
click: true,
/*1不可以监听
2 监听滚动的位置,惯性不记录
3监听滚动的位置,惯性记录*/
probeType: this.probeType,
pullUpLoad: this.pullUpLoad,
mouseWheel: true, //开启鼠标滚轮
disableMouse: false, //启用鼠标拖动
disableTouch: false, //启用手指触摸
});
//监听滚动的位置
if (this.probeType === 2 || this.probeType === 3) {
this.scroll.on("scroll", (position) => {
this.$emit("scroll", position);
});
}
if (this.pullUpLoad) {
this.scroll.on("pullingUp", () => {
this.$emit("pullingUp");
})
}
绿色部分是包装器,也称为父容器,具有固定的高度。黄色部分是内容,它是父容器的第一个子元素,其高度会随着内容的大小而增长。然后,当内容的高度不超过父容器的高度时,内容不会滚动。一旦超过,内容可以滚动。这就是 BetterScroll 的原理。
所以我们在使用better-scroll的时候,一定要对wrapper这个div设置一个固定高度。设置固定高度有两种办法。
或者
.content {
height:calc(100% - 44px - 49px);
overflow:hidden;
margin-top:44px;
}
但在这里我们往往会出现无法滚动的bug,这是因为对于better-scroll如果您的内容元素包含非固定大小的图像,则必须调用该refresh()方法以确保在加载图像后正确计算高度。
这里有一个小tips,在vue中可以用@load来监听是否图片加载完成,后面可以绑定一个函数<img v-lazy="showImage" alt="" @load="imageLoad">
通过refresh()重新计算一下高度即可。
3.6.6、首页回到顶部小按钮
效果:当高度下降到一定位置右下方出现一个小按钮,点击可以回到顶部。
继续封装啊,兄弟们,封装一个backTop的组件,里面设置样式,通过fixed设置布局,使其固定在页面上。正常情况点击backtop小组件会调用this.$refs.scroll.scrollTo(x坐标,y坐标,返回用时毫秒);返回顶部
,然而scroll和backtop都是home的一个组件彼此互相通信不太方便(当然可以使用事件总线 $ bus),在这时候直接选择对组件进行click监听,但一般情况click是无法监听组件的,这就用到了v-on的修饰符——.native:监听组件根元素的原生事件。然后通过this.$refs.scroll拿到scroll对象,最后返回顶部。
而想实现backTop在一定高度消失和出现,就需要用到better-scroll内部的一个方法。
if (this.probeType === 2 || this.probeType === 3) {
this.scroll.on("scroll", (position) => {
this.$emit("scroll", position);
});
}
将实时位置发送给home组件,通过this.tapOffsetTop = this.$refs.tabControl2.$el.offsetTop;
获取组件的位置,从而与实时位置进行比较,然后通过v-show对其进行展示。
3.6.7、首页上拉加载更多
同样借用了better-scroll里的一个方法。为实现个性化,有的需要下拉加载更多,有的不需要,根据父元素传的pullUpLoad决定
if (this.pullUpLoad) {
this.scroll.on("pullingUp", () => {
this.$emit("pullingUp");
})
}
一旦到底,向home发送一个pullingUp事件,与loadMore进行绑定(自定义)。
loadMore() {
this.getHomeGoods(this.currenttype);
},
getHomeGoods(type) {
const page = this.goods[type].page + 1;
getHomeGoods(type, page).then(res => {
this.goods[type].list.push(...res.data.list);
this.goods[type].page += 1;
//完成上拉加载更多,保障下一次下拉加载更多
this.$refs.scroll.finishPullUp();
});
}
3.6.8、home组件离开时记录状态和位置
通常保存历史状态用keep-alive即可,详情页
<keep-alive exclude="Detail">
<router-view />
</keep-alive>
但幸好有“bug-scroll”,导致出现状态无法保存。这里就要借用vue生命周期钩子函数之activated和deactivated,在组件离开时,记录y的位置,当组件回来时,直接跳转到离开的位置(但注意不要设置为0),这里离开的位置可以通过this.$refs.scroll.scroll.y
来获取。当回归后,一般强制刷新一下。
activated() {
this.$refs.scroll.scrollTo(0,this.saveY,1);
this.$refs.scroll.refresh();
},
deactivated(){
this.saveY = this.$refs.scroll.scroll.y;
},
4、详情页
4.1、跳转到详情页并且携带商品id
首先需要配置详情页detail的路由,保证可以进行跳转。
{
path: '/detail/:id',
component: Detail
}
因为需要传参数,此处采用动态路由。
this.$router.push('/detail/' + this.goodsItem.iid);
但也可以用query方法。
this.$router.push({
path: '/detail',
query:{
id: this.goodsItem.iid
}
})
4.2、详情页导航栏
因为此处导航栏没首页那么简单,所以继续封。对居中的插槽,和左边插槽进行编写。
<div class="product-detail-nav-bar">
<nav-bar>
<div @click="backHomePage" class="back-img" slot="left">
<img alt="" src="@/assets/img/common/back.svg" />
</div>
<div class="title" slot="center">
<span
:class="{ active: index === currentIndex }"
:key="index"
@click="titleItemClick(index)"
class="title-item"
v-for="(item, index) in titles"
>
{
{
item }}
</span>
</div>
</nav-bar>
</div>
4.3、详情页数据请求与存储
商品的详细信息:
import {
request } from "./request";
export function getDetail(iid){
return request({
url:'/detail',
params:{
iid
}
})
}
将数据以类的方式分类保存。
export class Goods {
constructor(itemInfo, columns, services) {
this.title = itemInfo.title;
this.desc = itemInfo.desc;
this.newPrice = itemInfo.price;
this.lowNowPrice = itemInfo.lowNowPrice;
this.oldPrice = itemInfo.oldPrice;
this.discount = itemInfo.discountDesc;
this.discountBgColor = itemInfo.discountBgColor;
this.columns = columns;
this.services = services;
this.relPrice = itemInfo.lowNowPrice;
}
}
// 店铺数据
export class Shop {
constructor(shopInfo) {
this.logo = shopInfo.shopLogo;
this.name = shopInfo.name;
this.fans = shopInfo.cFans;
this.sells = shopInfo.cSells;
this.score = shopInfo.score;
this.goodsCount = shopInfo.cGoods;
this.shopurl = shopInfo.allGoodsUrl;
}
}
// 尺寸数据
export class GoodsParams {
constructor(info, rule) {
// 注: images可能没有值(某些商品有值, 某些没有值)
this.image = info.images ? info.images[0] : "";
this.infos = info.set;
this.sizes = rule.tables;
}
}
然后将数据进行保存。
//根据iid请求所需要的数据
getDetail(this.iid).then(res => {
console.log(res);
const data = res.result;
//获取轮播图数据
this.topImages = data.itemInfo.topImages;
//获取商品信息
this.goods = new Goods(data.itemInfo, data.columns, data.shopInfo.services);
//获取店铺信息
this.shop = new Shop(data.shopInfo);
//获取商品图片详细信息
this.detailInfo = data.detailInfo;
//获取参数信息
this.paramInfo = new GoodsParams(data.itemParams.info, data.itemParams.rule);
// 获取评论数据
if (data.rate.cRate !== 0) {
this.commentInfo = data.rate.list[0] || {
};
}
}),
getRecommend().then(res => {
console.log(res);
this.recommend = res.data.list
})
4.4、详情页轮播图
这里老师的swiper封装的组件有问题,在这里我用的是vant组件库,把上述请求到的topImages数据传到轮播图组件,然后进行渲染。
<detail-swiper :top-images="topImages" class="detail-set-scroll"></detail-swiper>
<van-swipe :autoplay="2500" indicator-color="#ff0000" class="swiper-list" duration="400">
<van-swipe-item :key="index" v-for="(item, index) in topImages">
<img :src="item" @load="swiperLoad" alt="" />
</van-swipe-item>
</van-swipe>
4.5、详情页基本信息展示
这一部分,包括商品的基本信息,店铺信息,商品详情数据,商品参数信息,商品推荐信息的展示。都是一个思路,封装组件,把分好类的数据分别传入,改样式展现数据。
4.6、导入scroll组件
把已经封装好的scroll组件导入,注意一定要给scroll一个固定高度。
.scroll-height {
position: absolute;
top: 44px;
right: 0;
bottom: 50px;
left: 0;
overflow: hidden;
width: 100%;
background-color: white;
}
4.7、标题与内容的联动效果
点击标题滚动到相应位置:
我们可以通过detail中监听标题的点击,获取index,然后在点击事件中滚动到对应的内容,所以如何获取各部分的内容成为了难题。
- 如何正确获取各部分内容的offsetTop
- 1、在created中,不可以,此时的dom元素都没挂载,
this.$refs.comment.$el
根本不存在。 - 2、在mounted中,不可以,此时仅仅简单挂载dom元素,还无法获取数据。
- 3、在updated中,不可以,获取到数据的回调也不行,dom还没渲染完。
- 4、在$nextTick中,不可以,dom渲染完了,但图片的高度可能无法计算在内。
- 5、最终选择,在图片加载完成的监听中,进行赋值,因为频率过高,选择防抖来进行处理。
created(){
this.getThemeTopY = debounce(() => {
this.themeTopYs = [];
this.themeTopYs.push(0);
this.themeTopYs.push(this.$refs.params.$el.offsetTop);
this.themeTopYs.push(this.$refs.comment.$el.offsetTop);
this.themeTopYs.push(this.$refs.recommend.$el.offsetTop);
this.themeTopYs.push(Number.MAX_VALUE)
console.log(this.themeTopYs);
}, 250)
},
methods:{
imgLoad() {
this.$refs.scroll.refresh();
this.getThemeTopY();
},
}
Number.MAX_VALUE是极大值的意思。
滚动内容,标题相应选中:
contentScroll(position) {
this.isShowBackTop = (-position.y) > 1000;
const positionY = -position.y;
const length = this.themeTopYs.length;
for (let i = 0; i < length - 1; i++) {
//这里的i是string类型
// if (this.currentIndex !== i && (i < length - 1 && positionY >= this.themeTopYs[i] && positionY < this.themeTopYs[i + 1]) ||
// (i === length - 1 && positionY > this.themeTopYs[i])) {
// this.currentIndex = i;
// this.$refs.nav.currentIndex = this.currentIndex;
// }
if (this.currentIndex !== i && (positionY >= this.themeTopYs[i] && positionY < this.themeTopYs[i + 1])) {
this.currentIndex = i;
this.$refs.nav.currentIndex = this.currentIndex;
}
}
},
4.8、详情页底部工具栏
在这里我用的是vant组件库,继续封装。
<div class="bottom-bar">
<van-goods-action>
<van-goods-action-icon icon="chat-o" text="客服" />
<!-- :badge="cartLength" -->
<van-goods-action-icon icon="shop-o" text="店铺" />
<van-goods-action-icon :color="isShouCang ? '#ff5000' : '#000000'" :icon="isShouCang ? 'star' : 'star-o'"
:text="isShouCang ? '已收藏' : '收藏'" @click="starClick" />
<van-goods-action-button @click="addToCart" text="加入购物车" type="warning" />
<van-goods-action-button @click="$router.push('/shopcart')" text="立即购买" type="danger" />
</van-goods-action>
</div>
4.9、加入回到顶部的按钮
因为backTop组件已经封装好,在其他很多组件都会复用,代码格式都一样,为了避免代码的重复性,这里使用mixin混入。
export const backTopMixin = {
components:{
backTop
},
data(){
return {
isShowBackTop: false
}
},
methods:{
backClick() {
this.$refs.scroll.scrollTo(0, 0, 500);
},
}
}
5、购物车页面
5.1、将商品添加到购物车
通过监听底部导航栏加入购物车选项的监听,来把数据传给购物车。
addToCart(){
//获取购物车需要获取的信息
const product = {
};
product.image = this.topImages[0];
product.title = this.goods.title;
product.desc = this.goods.desc;
product.price = this.goods.relPrice;
product.iid = this.iid;
//将商品添加到购物车
//this.$store.commit('addCart',product)
this.$store.dispatch("addCart",product).then(res => {
this.$toast.show(res);
})
}
由于详情页的子组件与购物车组件不方便传递,一般使用vuex来进行管理。
export default {
addCounter(state,payload){
payload.count++;
},
addToCart(state,payload){
payload.checked = false;
state.cartList.push(payload);
},
clearCarList(state) {
// 判断选中哪些数据,过滤没选中的数组返回一个新数组即可
let result = state.cartList.filter(item => item.checked !== true);
if (result.length === 0) {
localStorage.removeItem("cartList");
state.cartList = [];
} else {
state.cartList = result;
localStorage.setItem("cartList", JSON.stringify(state.cartList));
}
},
setCartList(state, data) {
state.cartList = data;
}
}
5.2、购物车导航栏
有一个知识点需要注意,就是…mapGetters。
computed: {
//使用mapGetters可以让getters中的方法自动转换到计算属性一共两种用法
//不起别名
//...mapGetters(['cartLength','cartList']),
//起别名
...mapGetters({
length: 'cartLength',
list: 'cartList'
})
}
5.3、商品列表展示
同之前,封装,调样式。
5.4、底部工具栏
计算总价钱
totalPrice() {
return this.$store.state.cartList
.filter(item => item.checked)
.reduce((preValue, item) => preValue += item.price * item.count, 0)
.toFixed(2);
},
全选的双向绑定
通过判断每一个添加的商品的checked属性,如果全部选中,则全选框选中。点击全选框,则选中商品的checked属性与全选框的属性保持一致。
5.5、Toast(吐司)插件模式
这里同样先弄一个Toast组件,但不是常规的导入了,而是要把他弄成一个插件。第一步先封装一个Toast组件。
然后建立Toast.js文件,保证main.js导入的时候,Toast.js也可以导入。
import toast from './components/common/toast'
Vue.use(toast);
在Toast.js内部,对安装函数进行重构。
import Toast from "./Toast.vue";
const obj = {
}
// 将对象安装到Vue上
obj.install = function (Vue) {
// 1.创建组件构造器
const toastConstructor = Vue.extend(Toast);
// 2.new的方式,根据组件构造器,可以创建出来一个组件对象
const toast = new toastConstructor();
// 3.将组件对象手动挂载到某个元素上
toast.$mount(document.createElement("div"));
// 4.myToast.$el就已经挂载到上面创建的div了,然后将div挂载到body上即可
document.body.appendChild(toast.$el);
// 最后将myToast挂载到Vue的原型上
Vue.prototype.$toast = toast;
}
export default obj
6、一些优化的方面
6.1、解决移动端点击300ms延迟
进行安装
npm install fastclick --save
进行使用,在main.js文件里。
FastClick.attach(document.body);
6.2、图片懒加载
图片懒加载就是当图片出现在视图再进行加载。
官方讲的很明白。这里最简单的使用
安装
$ npm i vue-lazyload -S
导入
import VueLazyload from 'vue-lazyload'
Vue.use(Lazyload, {
// 未加载的占位图片
loading: require("@/assets/img/common/placeholder.png")
})
7、大结局
本来准备随便写写的,结果洋洋洒洒写了这么多。做这个项目bug不少,但是根据弹幕还是可以解决的,大部分老师都带着解决。注意弹幕!注意弹幕!注意弹幕!另外,还有两个部分,老师没做,所以不适合放简历上,但有兴趣的大佬可以补了。此文章供个人以后的移动电商的思路加上第一次写,可能有很多部分写的不清楚,实在抱歉!