目录


前言: 本文是对整个练习过程的记录,记录重点知识以及不太了解的知识。

1. 项目概述

本次练习是基于Vue全家桶的仿小米商城系统,商城的流程如下:

  • 登录 -> 产品首页 -> 产品站 -> 产品详情
  • 购物车 -> 订单确认 -> 订单支付 -> 订单列表

总共有上面的八个页面,还有若干个组件。

商城系统整体架构图:

【练习】基于Vue全家桶的仿小米商城系统_ide

2. 项目基础架构

2.1 跨域及解决

跨域时浏览器为了安全而做出的限制策略,浏览器请求必须遵循同源策略:同域名、同端口、同协议。

这里使用接口代理的方式来解决跨域问题:

接口代理就是通过修改Nginx服务器配置来实现(前端修改,后台不变)

在根目录创建配置文件:​​vue.config.js​​,在里面配置以下内容:

module.exports = {
devServer:{
host:'localhost',
port:8080,
proxy:{
'/api':{
target:'url',
changeOrigin:true,
pathRewrite:{
'/api':''
}
}
}
}
}
// 注意:在target里面需要写上接口代理的目标地址,这里就不写了,用url代替

原理: 因为我们需要使用的接口地址可能很多,不可能挨个去进行拦截。所以,这里可以设置一个虚拟的地址​​/api​​​,实际上,是没有这个地址的。当拦截到​​/api​​​时,就将主机的点设置为原点(changeOrigin:true),然后添加路径的转发规则,将​​/api​​​ 置为空,转发时就没有​​/api​​了

2.2 项目目录结构

  • api: 对api的一些处理
  • util:对公共的方法的定义、封装
  • store:使用Vuex的目录
  • pages:项目页面文件
  • storage:数据储存相关
  • assets:小图片、样式文件等
  • components:组件
  • 【练习】基于Vue全家桶的仿小米商城系统_ide_02

2.3 插件的安装

  • vue-lazyload :懒加载
  • element-ui :Element UI
  • node-sass :Sass
  • sass-loader: Sass加载
  • vue-awesome-swiper :首页轮播
  • vue-axios : 结合使用axios
  • vue-cookie :Cookie
  • 【练习】基于Vue全家桶的仿小米商城系统_javascript_03

需要注意的是:​​axios​​​是一个库,并不是vue中的第三方插件,所以使用时需要在每个页面进行导入操作,这样就很麻烦。我们可以使​​vue-axios​​​将​​axios​​​的作用域对象挂载到vue实例中,这样就可以在需要使用的时候用​​this​​来调用。

Vue.use(VueAxios, axios)

2.4 storage封装

Cookie、localStorage、 sessionStorage三者 区别?
这个问题可以参考之前写的一个总结:​​​链接地址​

storage本身虽然有API,但是只是简单的key/value形式,storage只能存储字符串,需要手工转化为json对象,并且storage只能一次性的清空,不能进行单个的清空,所有我们需要对storage进行封装。

这里封装的是sessionStorage,实际上就是可以在sessionStorage存储JSON对象,并且可以对这些对象进行一些操作:

// 设置一个key
const STORAGE_KEY = 'mall';
export default{
// 存储值
setItem(key,value,module_name){
if (module_name){
// 如果模块名称存在,就递归找到这个模块,然后给这个模块设置key和value
let val = this.getItem(module_name);
val[key] = value;
// 最后将设置的值存储到整个数据中
this.setItem(module_name, val);
}else{
// 如果模块名称不存在,就直接进行设置,并存储在sessionStorage中
let val = this.getStorage();
val[key] = value;
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(val));
}
},
// 获取某一个模块下面的属性
getItem(key, module_name){
// 如果模块的名称存在,就获取模块的名称,并将返回该模块中某个key的value值
if (module_name){
// 这里是不断往内层遍历,直到寻找到那个模块
let val = this.getItem(module_name);
if(val) {
return val[key];
}
}
// 如果模块名称不存在,就直接返回该key的value值
return this.getStorage()[key];
},
// 获取Storage的信息
getStorage(){
// 获取sessionStorage中的整个数据,并将其转化为对象的形式
return JSON.parse(window.sessionStorage.getItem(STORAGE_KEY) || '{}');
},
// 清空某一个值
clear(key, module_name){
// 首先要获取到整个对象的值
let val = this.getStorage();
// 如果这个模块存在
if (module_name){
// 如果这个模块的值为空,就直接返回
if (!val[module_name])return;
// 否则就删除这个模块中的key对应的值
delete val[module_name][key];
}else{
// 如果模块不存在,就说明key就在第一层,直接删除key
delete val[key];
}
// 删除之后,将删除之后的值设置到sessionStorage中
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(val));
}
}

举个例子来解释一下上面的模块的概念:

mall = {
'a': 1,
'b':{
'c': 2,
'd':{
'e': 3
}
}
}

在这个JSON对象中,如果我们想找到e,就需要进行递归,找到d模块中的key(也就是e),然后取出他的值。

2.5 接口错误拦截

对于接口请求,我们要将错误进行统一处理:

  • 统一报错
  • 未登录统一拦截
  • 请求值、返回值统一处理
// 由于使用的是接口代理的方式进行跨域,所以这里baseURL设置为/api,超时时间设置为8s
axios.defaults.baseURL = '/api'
axios.defaults.timeout = 8000

// 接口错误拦截,根据接口返回状态码,来进行不同的处理(状态码是后台设置的)
axios.interceptors.response.use(function(response){
let res = response.data
let path = location.hash

if(res.status == 0){
return res.data
}else if(res.status == 10){
if(path !== '#/index'){
window.location.href = '/#/login'
}
}else{
alert(res.msg)
// 抛出异常,避免返回的错误信息进入成功的结果中
return Promise.reject(res)
}
})

2.6 Mock设置

在开发阶段,我们可能还不能拿到API文档,所以可以使用Mock模拟数据来进行数据的交互操作。Mock有以下特点:

  • 开发阶段,为了提高效率,需要提前Mock
  • 减少代码冗余,灵活插拔
  • 较少沟通,减少接口联调时间

使用mock的方法有很多:

  • 本地创建json:在本地创建json文件,然后进行调用
  • easy-mock平台:将baseURL设置为easy-mock的接口地址,调用时和正常调用一样
  • 集成Mock API

(1)首先要安装mockjs:​​npm install mockjs --save-dev​​​’
(2)在src中建Mock的API:src/mock/api.js

import Mock from 'mockjs'
Mock.mock('/api/user/login', {
//接口数据...
})

(3)之后在main.js设置一个mock的开关:

const mock = true
if(mock){
require('./mock/api')
}

需要注意的是​​require​​​和​​import​​​是不同的,​​import​​是编译的时候就进行加载,而require是执行到这句代码的时候才执行。

3. 商城首页的实现

(1)由于在布局时,很多地方出现了代码的重复,所以可以建一个​​mixin​​文件,来定义一些css函数,再在样式中引用。例如,我们多次使用到了flex布局,多次使用到了背景图片的设置,可以定义一个函数(定义函数时,可以设置一些默认值):

@mixin flex($hov:space-between,$col:center){
display:flex;
justify-content:$hov;
align-items:$col;
}
@mixin bgImg($w:0,$h:0,$img:'',$size:contain){
display:inline-block;
width:$w;
height:$h;
background:url($img) no-repeat center;
background-size:$size;

/*使用定义的函数*/
@include bgImg(18px,18px,'/imgs/icon-search.png');
@include flex();

注意:

  • 不传参数就意味着使用默认值。
  • 使用之前要引入​​mixin.scss​​文件

(2)因为使用到的字体大小、颜色值有很多重复的,所以可以建立一个config.scss文件,来定义一些常用的字体大小和颜色值:

【练习】基于Vue全家桶的仿小米商城系统_vue_04

使用:

color:$colorA;
font-size: $fontA;

(4)首页轮播图使用的是swiper,但是在编译报错​​"Can’t resolve ‘swiper/dist/css/swiper.css’"​​,经查,是因为swiper版本过高的问题,在安装vue-awesome-swiper的时候,会自动安装一个swiper。默认swiper是最高版本,但是我们此时使用的不是最高版本。最后的解决方法是,重新安装指定的vue-awesome-swiper和swiper,问题就解决了:

npm install swiper vue-awesome-swiper@3.1.3 --save-dev
npm install swiper swiper@3.4.2 --save-dev

(4)在首页,总共使用到了四个组件:头部导航栏、底部信息栏、下方服务条、弹窗组件。因为这些组件不仅会在首页使用,还会在其他的页面使用,所以把他们都拆分出来,在需要的时候进行引用。这里说一下弹窗组件。

弹窗组件总共分为三部分:左上角的弹窗标题,中间的弹窗内容,下方的按钮。因为在每个页面中使用的弹窗可能不太一样,所以要把每一部分都定义成活的,便于修改,中间的内容区域定义成插槽。

弹窗结构:

<template>
<transition name="slide">
<div class="modal" v-show="showModal">
<div class="mask"></div>
<div class="modal-dialog">
<div class="modal-header">
<span>{{title}}</span>
<a href="javascript:;" class="icon-close" @click="$emit('cancel')"></a>
</div>
<div class="modal-body">
<slot name="body"></slot>
</div>
<div class="modal-footer">
<a href="javascript:;" class="btn" v-if="btnType==1" @click="$emit('submit')">{{sureText}}</a>
<a href="javascript:;" class="btn" v-if="btnType==2" @click="$emit('cancel')">{{cancelText}}</a>
<div class="btn-group" v-if="btnType==3">
<a href="javascript:;" class="btn" v-on:click="$emit('submit')">{{sureText}}</a>
<a href="javascript:;" class="btn btn-default" @click="$emit('cancel')">{{cancelText}}</a>
</div>
</div>
</div>
</div>
</transition>
</template>

数据传值: 这里是父组件向子组件传值,使用props接收。

export default {
name: 'modal',
props: {
// 弹框类型:小small、中middle、大large、表单form
modalType: {
type: String,
default: 'form'
},
// 弹框标题
title: String,
// 按钮类型: 1:确定按钮 2:取消按钮 3:确定取消
btnType: String,
sureText: {
type: String,
default: '确定'
},
cancelText: {
type: String,
default: '取消'
},
showModal: Boolean
}
}

首页使用弹窗:

<modal 
title="提示"
sureText="查看购物车"
btnType="1"
modalType="middle"
:showModal="showModal"
@submit="goToCart"
@cancel="showModal=false"
>
// 新版本的vue插槽必须使用template来包含内容,这里使用的是具名插槽
<template v-slot:body>
<p>商品添加成功!</p>
</template>
</modal>

这里对使用Vue的过渡动画来实现弹窗的过渡效果:

(这里就只写一下过渡效果的代码)

.modal{
@include position(fixed);
z-index: 10;
transition: all .5s;
&.slide-enter-active{
top:0;
}
&.slide-leave-active{
top:-100%;
}
&.slide-enter{
top:-100%;
}
}

需要注意的是,使用动画的部分必须要用​​<transition>​​标签进行包裹,在定义动画的时候,在以下类名中定义:

  • ​v-enter​​:定义进入过渡的开始状态。在元素被插入之前生效,在元素被插入之后的下一帧移除。
  • ​v-enter-active​​:定义进入过渡生效时的状态。在整个进入过渡的阶段中应用,在元素被插入之前生效,在过渡/动画完成之后移除。这个类可以被用来定义进入过渡的过程时间,延迟和曲线函数。
  • ​v-enter-to​​:2.1.8 版及以上定义进入过渡的结束状态。在元素被插入之后下一帧生效 (与此同时 v-enter 被移除),在过渡/动画完成之后移除。
  • ​v-leave​​:定义离开过渡的开始状态。在离开过渡被触发时立刻生效,下一帧被移除。
  • ​v-leave-active​​:定义离开过渡生效时的状态。在整个离开过渡的阶段中应用,在离开过渡被触发时立刻生效,在过渡/动画完成之后移除。这个类可以被用来定义离开过渡的过程时间,延迟和曲线函数。
  • ​v-leave-to​​:2.1.8 版及以上定义离开过渡的结束状态。在离开过渡被触发之后下一帧生效 (与此同时 v-leave 被删除),在过渡/动画完成之后移除。

对于这些在过渡中切换的类名来说,如果使用一个没有名字的 ​​<transition>​​​,则 ​​v-​​​ 是这些类名的默认前缀。因为这里定义​​name​​​为​​slide​​​,所以以​​slide-​​开头。

(5)图片懒加载

适用于片的懒加载可以在一定程度上提高网页的性能。在vue中使用图片的懒加载还是比较简单的,来看一些具体的步骤:

  • 安装​​vue-lazyload​​插件
  • 在​​mian.js​​​ 中引入:​​import VueLazyLoad from 'vue-lazyload'​
  • 注册并配置插件:
Vue.use(VueLazyLoad, {
loading: '/imgs/loading-svg/loading-bars.svg' // 设置了一个加载时的动画效果
})
  • 使用​​vue-lazyload​​​:将​​:src​​​换成​​v-lazy​​即可
// 原来
<img :src="item.mainImage">
// 懒加载形式
<img v-lazy="item.mainImage">

4. 登录页面的实现

对于登录功能,主要使用​​vue-cookie​​来储存登录信息,使得登录后保持登录状态。

vue-cookie的用法如下:

  • 安装​​vue-cookie​​插件
  • 在​​mian.js​​中引入并注册
import VueCookie from 'vue-cookie'
Vue.use(VueCookie)
  • 使用vue-cookie
data () {
return {
username: '',
password: '',
userId: ''
}
},
methods: {
login () {
// 这里使用ES6的解构赋值来获取this中的两个值
const { username, password } = this
this.axios.post('/user/login', {
username,
password
}).then((res) => {
// 设置cookie值:将userId设置为res.id,并设置cookie的过期时间expires
this.$cookie.set('userId', res.id, { expires: 'Session' })
// 进行页面的跳转
this.$router.push('/index')
})
}
}

在登录完之后,控制台报错:​​Error: Avoided redundant navigation to current location:​​,这个报错显示是路由重复,虽然没有影响功能使用,但是看着很难受,所以就查了一些解决方案。

在引入VueRouter的时候加上下面代码就解决了:

import Router from 'vue-router'

Vue.use(Router)
const originalPush = Router.prototype.push
Router.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err)
}

5. Vuex的使用

Vue官网中关于Vuex使用的图示:

【练习】基于Vue全家桶的仿小米商城系统_vue_05

我们需要在主页面显示用户的名称以及购物车商品的数量,这些数据需要登录状态下才显示。使用Vuex将获取到的的数据保存在store中,在需要的时候调用。

使用比较规范的目录定义形式:

src
└──store
├── index.js # Store实例化
├── state.js # 存储共享的数据
├── actions.js # 解决异步改变共享数据
├── mutations.js # 用来注册改变数据状态
└── getters.js # 对共享数据进行过滤操作(本次未用到)
  • 在​​main.js​​​中注册​​store​
import store from './store'

new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
  • 在​​index.js​​中将三个特性进行实例化:
import Vue from 'vue'
import Vuex from 'vuex'
import state from './state'
import mutations from './mutations'
import actions from './actions'
Vue.use(Vuex);

export default new Vuex.Store({
state,
mutations,
actions
})

注意:​​mutations​​​和​​actions​​​不要写成​​mutation​​​或​​action​​。

  • 在​​state.js​​中定义要共享的数据:
export default {
username: '',
cartCount: 0
}
  • 触发异步:

触发actions:

this.axios.post('/user/login', {
username,
password
}).then((res) => {
this.$store.dispatch('saveUserName', res.username);
})

当我们刷新页面时,发现刚刚获取到的数据又不能显示在页面上了。这是因为接口获取的接口还没有存储,所以需要在​​APP.vue​​中再次设置:

mounted(){
this.getUser()
this.getCartCount()
},
methods:{
getUser(){
this.axios.get('/user').then((res) => {
this.$store.dispatch('saveUserName', res.username);
})
},
getCartCount(){
this.axios.get('/carts/products/sum').then((res) => {
this.$store.dispatch('saveCartCount', res);
})
}
}

这样无论怎么刷新页面,数据都不会消失了。

  • actions:传输数据
export default {
saveUserName (context, username) {
context.commit('saveUserName', username)
},
saveCartCount (context, count) {
context.commit('saveCartCount', count)
}
}
  • mutations:存储数据
export default {
saveUserName (state, username) {
state.username = username
},
saveCartCount (state, count) {
state.cartCount = count
}
}
  • 使用数据
{{usrname}}
{{cartCount}}

computed:{
username(){
return this.$store.state.username
},
cartCount(){
return this.$store.state.cartCount
}
}

这里使用到了computed计算属性,如果我们将这些数据直接定义在data中,他就是纯渲染,没有请求的时间。当我们进入​​APP.vue​​文件时,会执行两个接口请求,执行请求需要一定的时间,执行完获得数据之后,页面数据早已经渲染出来,渲染的值时请求之前的默认值,所以数据就不对了。

使用​​computed​​​属性,当数据的值发生变化时,​​computed​​就会执行,来更新数据,这样就可以保证数据是正确的了。

6. 产品站的实现

(1)吸顶效果的实现

在商品详情页有一个顶部的信息组件,这个组件我们可以单独的定义成一个组件ProductParam,然后这组件有一个吸顶的效果,下面来记录一下实现的过程。

【练习】基于Vue全家桶的仿小米商城系统_vue_06

也就是上图中红色方框中的内容,在页面滚动到它的顶部的时候,就吸附在顶部,当滚动回来的时候,就还是原来的样子:

【练习】基于Vue全家桶的仿小米商城系统_css_07


组件的结构:

<div class="nav-bar" :class="{'is_fixed':isFixed}">
<div class="container">
<div class="pro-title">{{title}}</div>
<div class="pro-param">
<a href="javascript:;">概述</a><span>|</span>
<a href="javascript:;">参数</a><span>|</span>
<a href="javascript:;">用户评价</a>
<slot name='buy'></slot>
</div>
</div>
</div>

吸顶的实现:

data(){
return {
isFixed: false
}
},
mounted(){
// 监听页面的滚动事件
window.addEventListener('scroll', this.initHeight)
},
destroyed(){
// 销毁页面的滚动事件
window.removeEventListener('scroll', this.initHeight)
},
methods: {
initHeight(){
// 定义事件的监听的内容
let scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;
this.isFixed = scrollTop > 152;
}
}

其中, pageYOffset 属性返回文档在窗口垂直方向滚动的像素。如果找不到就找滚动的距离,chrome中使用​​document.documentElement.scrollTop​​​,IE浏览器使用​​document.body.scrollTop​​来定义。由于上面Header组件的高度为152px,所以只要滚动距离大于152,就给组件添加定位属性。

定位实现:

.nav-bar{
&.is_fixed{
position: fixed;
top: 0;
width: 100%;
}
}

(2)视频动画实现

视频内容的基本结构:

<div class="video-bg" @click="showSlide='slideDown'"></div>
<div class="video-box" v-show="showSlide">
<div class="overlay"></div> // 遮罩层
<div class="video" :class="showSlide"> // 视频盒子
<span class="icon-close" @click="closeVideo"></span> // 关闭按钮
<video src="/imgs/product/video.mp4" muted autoplay controls="controls"></video> // 视频
</div>
</div>

可以是使用​​translation​​来实现,这里我们使用animation动画来实现一下,点击出现遮罩层,视频实现在屏幕正中央,点击关闭,视频划走,遮罩层消失。

.video-box{
// 定义进入的动画
@keyframes{
from{
top:-50%;
opacity:0;
}
to{
top:50%;
opacity:1;
}
}
// 定义出去的动画
@keyframes{
from{
top:50%;
opacity:1;
}
to{
top:-50%;
opacity:0;
}
}
.video{
position:fixed;
top:-50%;
left:50%;
transform:translate(-50%,-50%);
z-index:10;
width:1000px;
height:536px;
opacity:1;

// 执行完动画之后, top还会变回-50%,所以需要我们手动设置为50%
&.slideDown{
animation:slideDown .6s linear;
top:50%;
}
&.slideUp{
animation:slideUp .6s linear;
}
}

​animation​​三个参数分别是:动画的名称、动画的执行时间、进入的形式(这里是匀速进入)

有一个小问题就是,当关闭视频之后,视频整个盒子还在,所以要对其进行设置:

<div class="video-box" v-show="showSlide"></div>

// 点击关闭按钮,执行离开的动画,然后0.6s动画执行完,就将showSlide置为空,这样整个盒子就隐藏了
closeVideo () {
this.showSlide = 'slideUp'
setTimeout(() => {
this.showSlide = ''
}, 600)
}

还有一个小问题尚未解决,没有找到合适的方法,就是关闭视频实际上是将视频放在了我们看不到的地方,实际上视频依旧在播放着,没有暂停,需要手动设置进行暂停。之后看看有没有什么比较好的解决方案…

7. 退出功能的实现

退出功能的实现,需要考虑以下因素:

  • 退出后要清空顶部的用户名称
  • 退出后要清空购物车内商品的数量
  • 退出后要清空cookie值
  • 接口优化

(1)首显示定义退出功能的结构:

<a href="javascript:;" v-if="username" @click="logout">退出</a>

(2)逻辑实现

logout(){
this.axios.post('/user/logout').then(() => {
// 清空cookie,将cookie过期时间设置为-1,就是立刻失效
this.$cookie.set('userId', '', {expires: '-1'})
// 将Vuex中的用户名称和购物车商品数量进行初始化(清空)
this.$store.dispatch('saveUserName', '')
this.$store.dispatch('saveCartCount', '0')
Message.success('退出成功')
})
}

(3)当我们点击退出之后,虽然vuex中的数据清空了,这时购物车内数量显示为0,但是在我们重新登录之后显示购物车的数量还是显示为0。这是因为这个应用是单页面应用,从退出到登录,这只是单页面的跳转,并没有重新调用​​APP.vue​​这个入口文件,所以不会再请求购物车商品的数量。所以需要在首页中在含有退出功能的NavHeader组件中重新请求一次购物车内商品的数量,来让他显示。

但是这样的话,每次进入主页面都会记进行购物车数量请求,会影响性能,而我们只是想在登录之后才进行请求,所以可以在登录时设置一个参数,若主页面接收到这个参数,说明是从登录页面过来的,就进行数据请求:

getCartCount(){
this.axios.get('/carts/products/sum').then((res=0) => {
this.$store.dispatch('saveCartCount', res);
})
}
// 在 login中设置一个from参数吗,这里使用params传参,这样的话,跳转路径必须使用名称的形式
this.$router.push({
name: 'index',
params: {
from: 'login'
}
})
// 接收参数,如果参数是login,就进行数据的请求
mounted(){
let params = this.$route.params
if(params && params.from == 'login'){
this.getCartCount()
}
},

(4)优化

在​​APP.vue​​中,我们默认每次打开页面时就进行数据请求,如果没有登录,就会报错,这样实际上也会造成资源的浪费,我们可以判断是否登录,只有登录状态下才进行请求:

mounted(){
if(this.$cookie.get('userId')){
this.getUser()
this.getCartCount()
}
}

在登陆之后,会储存一个后台的会话ID,它的持续时间为Session(也就是当浏览器关闭之后,就自动清空,结束会话),所以我们的cookie过期时间和它的会话持续时间保持一致就可以了:

【练习】基于Vue全家桶的仿小米商城系统_ide_08

this.$cookie.set('userId', res.id, { expires: 'Session' })

8. Element UI的使用

在使用Element UI的时候,按照官网的导入方式,遇到报错 ​​Error: Cannot find module 'babel-preset-es2015'​​​问题,需要在 ​​.babelrc​​ 文件中,进行如下修改:

{
"presets": [["@babel/preset-env", { "modules": false}]],
"plugins": [
[
"component",
{
"libraryName": "element-ui",
"styleLibraryName": "theme-chalk"
}
]
]
}

这样问题就解决了。

9. 订单确认页面的实现

由于对收货地址的操作有三个:添加、编辑、删除。为减少代码的冗余,我们可以将每个操作定义一个标识符,对点击的标识符进行判断,根据不同标识符来发起不同的请求:

submitAddress(){
// 使用解构赋值来来获取data中的数据
let {checkedItem,userAction} = this;
let method,url,params={};
if(userAction == 0){
method = 'post',url = '/shippings';
}else if(userAction == 1){
method = 'put',url = `/shippings/${checkedItem.id}`;
}else {
method = 'delete',url = `/shippings/${checkedItem.id}`;
}
//表单验证略过...
// params中的参数是在表单中解构赋值出来的
params = {receiverName,receiverMobile,receiverProvince,receiverCity,receiverDistrict,receiverAddress,receiverZip}
this.axios[method](url,params).then(()=>{
this.closeModal(); // 关闭弹窗
this.getAddressList(); // 重新刷新地址列表
Message.success('操作成功');
});
}

10. 订单支付功能的实现

10.1 支付宝支付

支付宝支付的逻辑比较简单。首先,请求之后,跳转至一个新的页面。我们这里使用window.open来实现空白页面的打开,点击支付宝支付后,触发下面的方法:

window.open('/#/order/alipay?orderId='+this.orderId,'_blank')

进入该页面后之后,触发提交的请求,后台会返回一个content,这是包含支付的一个表单代码,触发这段代码就会跳转到支付宝的支付页面。这里我们使用​​document.forms[0].submit()​​来触发这个表单的提交:

<template>
<div class="ali-pay">
<loading v-if="loading"></loading>
<div class="form" v-html="content"></div>
</div>
</template>
// 获取订单的Id
orderId:this.$route.query.orderId,
// 支付请求
paySubmit(){
this.axios.post('/pay', {
orderId: this.orderId,
orderName: '小米商城',
amount: 0.01,
payType: 1
}).then((res) => {
this.content = res.content
setTimeout(() =>{
document.forms[0].submit()
}, 100)
})
}

这里还使用了一个loading组件来作为从支付页面到支付宝的页面的过渡。

这两步完成之后,就可以跳到了支付宝支付页面,然后就可以进行支付操作了。

10.2 微信支付

微信支付相对于支付宝支付就相对复杂一些了,来看一下具体的步骤:

-首先是点击微信支付,发起请求

this.axios.post('/pay', {
orderId: this.orderId,
orderName: '小米商城',
amount: 0.01,
payType: 2
}).then((res) => {
QRCode.toDataURL(res.content)
.then(url => {
this.showPay = true;
this.payImg = url;
this.loopOrderState();
})
.catch(() => {
Message.error('微信二维码生成失败,请稍后重试');
})
})

返回的结果是微信支付的链接:

【练习】基于Vue全家桶的仿小米商城系统_javascript_09

我们需要将返回的结果显示页面上,所以创建于了一个ScanPayCode的组件,用来显示二维码。

而想要将链接转化为二维码,需要使用一个插件:​​qrcode​​,

  • 安装:​​npm install --save qrcode​
  • 引入:​​import QRCode from 'qrcode'​
  • 使用:
.toDataURL(res.content)
.then(url => {
this.showPay = true; // 展示二维码页面
this.payImg = url; // 将图片赋给页面
this.loopOrderState(); // 轮询
})
.catch(() => {
Message.error('微信二维码生成失败,请稍后重试');
})

最后就是轮询订单支付的状态,如果支付完成,就清除定时器,跳到订单列表页面:

loopOrderState(){
// 设置定时器
this.T = setInterval(() => {
this.axios.get(`/orders/${this.orderId}`).then((res) => {
if(res.status == 20) {
clearInterval(this.T) // 清除定时器
this.goOrderList() // 跳转到订单列表
}
})
}, 1000);
}

需要注意的是,当我们支付成功,如果回到刚才的订单页面,在点击微信支付,就会有错误:

【练习】基于Vue全家桶的仿小米商城系统_ide_10

之前已经做了异常的拦截,但是那个咋请求成功的基础上,对业务请求进行拦截,而没有对状态码进行拦截,所以要对除200以外的状态码进行拦截:

axios.interceptors.response.use((response) => {
let res = response.data
let path = location.hash
if(res.status == 0){
return res.data
}else if(res.status == 10){
if(path !== '#/index'){
window.location.href = '/#/login'
}
return Promise.reject(res)
}else{
Message.warning(res.msg)
return Promise.reject(res)
}
}, (error) => {
let res = error.response
Message.error(res.data.message)
return Promise.reject(error)
})

实际上,拦截器的第一个参数方法是对业务请求的拦截,第二个参数方法是对状态信息的拦截,只要获取到错误信息,提示用户,并将错误抛出,避免记性res的状态里,就可以了。

11. 订单列表的实现

订单列表页面主要就是订单的加载,这里记录一下订单加载更多的三种方式。

11.1 分页器

  • 这里使用的是element ui的分页器,在页面按需引入,并注册:
import {Pagination} from 'element-ui'
// 由于我们引入的是Pagination,而使用时前面有一个el-,所以使用这种方式加载
components:{
[Pagination.name]: Pagination,
}
  • 定义结构
<el-pagination
class="pagination" // 样式
background // 背景
layout="prev, pager, next" // 分页
:pageSize = "pageSize" // 每页订单数
:total="total" // 总共的数量
@current-change="handleChange" // 触发分页器
></el-pagination>
  • 数据交互
handleChange(pageNum){
this.pageNum = pageNum // 更改页面
this.getOrderList() // 刷新列表
}

之前也用过​​element ui​​ 的分页器了,还是比较简单的。来看一下之前没有用到过的方法。

11.2 按钮加载

  • 在最底部放一个“加载更多”的按钮,这里也使用element uI的button:
import { Button } from 'element-ui'

components:{
[Button.name]: Button,
}
  • 定义结构

这里定义一个数据​​showNextPage​​​,默认为​​true​​,就是可以显示下一页

<div class="load-more" v-if="showNextPage">
<el-button type="primary" :loading="loading" @click="loadMore">加载更多</el-button>
</div>
  • 数据交互

这里需要注意,我们想要的是加载更多之后与前面加载的数据进行拼接,所以需要对订单的List进行改造:

getOrderList(){
this.loading = true
this.axios.get('orders', {
params:{
pageNum: this.pageNum
}
}).then((res) => {

this.loading = false;
// 将数据与前一页进行拼接
this.list = this.list.concat(res.list)
this.total = res.total
// 判断是否还有下一页,如果没有就会隐藏加载按钮
this.showNextPage = res.hasNextPage
this.busy = false
}).catch(() => {
this.loading = false
})
}

loadMore(){
// 页数加一
this.pageNum++
// 重新刷新订单列表
this.getOrderList()
}

11.3 滚动加载

// 安装
npm install vue-infinite-scroll --save
// 引入
import infiniteScroll from 'vue-infinite-scroll'
// 注册(与data同级)
directives:{
infiniteScroll
}
  • 定义结构
<div class="scroll-more"
v-infinite-scroll="scrollMore" // 触发滚动
infinite-scroll-disabled="busy" // 是否禁用
infinite-scroll-distance="410"> // 距离底部多少像素的时候,进行加载
<img src="/imgs/loading-svg/loading-spinning-bubbles.svg" alt="" v-show="loading">
</div>
  • 数据交互
getList(){
this.loading = true;
this.axios.get('/orders',{
params:{
pageSize:10,
pageNum:this.pageNum
}
}).then((res)=>{
this.list = this.list.concat(res.list);
this.loading = false;
if(res.hasNextPage){
this.busy=false;
}else{
this.busy=true;
}
});
}

scrollMore(){
this.busy = true;
setTimeout(()=>{
this.pageNum++;
this.getList();
},500);
},

其中,​​busy​​​代表是否触发加载,如果是​​true​​就加载,反之就不加载。

总之,这三种方法都能对列表加载更多数据,只是方式不同,还要根据需求去使用。

12. 项目优化

懒加载使用import的方式,由于import方式是ES7的语法,所以我们需要引入一个插件,来解析ES7的语法:​​@babel/plugin-syntax-dynamic-import​

安装:​​npm install --save-dev @babel/plugin-syntax-dynamic-import​

然后将路由改成按需加载的形式:

{
path: 'confirm',
name: 'order-confirm',
component: () => import('./pages/orderConfirm.vue')
}

这样就实现了路由的懒加载,但是在首页刷新的时候,还是会在空闲时间把所有的​​js​​​代码都加载下来,在network中的​​js​​看不到,只能在other看到。只有在需要加载时,js内容才会出现在script中,js文件中华也就出现了相应的文件内容。

【练习】基于Vue全家桶的仿小米商城系统_js_11

这时,所有的​​js​​​文件都被放在​​<link rel="prefetch" ></link>​​​中,它告诉浏览器,这段资源将会在未来某个导航或者功能要用到,但是本资源的下载顺序权重比较低。也就是说​​prefetch​​​通常用于加速下一次导航,而不是本次的。被标记为​​prefetch​​的资源,将会被浏览器在空闲时间加载。

【练习】基于Vue全家桶的仿小米商城系统_css_12


所以,如果想要真正的做到按需按需加载,就要清除​​prefetch​​​。在​​vue.config.js​​文件中加入以下代码:

chainWebpack:(config)=>{
config.plugins.delete('prefetch');
}

13. 总结

(1)做了什么?

  • 完成了11个页面组件的开发,9个小组件的开发
  • 对SessionStorage进行封装
  • 解决了跨域的问题
  • 对路请求进行拦截,接口统一管理,避免重复拦截,代码冗余
  • 使用cookie来管理用户登录的权限
  • 使用Vuex来管理共享的数据
  • 使用微信、支付宝进行支付
  • 使用element ui来丰富商城的内容
  • 使用Vue 过渡动画以及CSS3 animation动画效果
  • 使用了几个npm 的插件
  • 对页面进行优化,提高页加载的性能
  • 使用路由懒加载来提高性能
  • 页面布局,页面的逻辑实现
  • 使用Sass、mixin来对对公共样式抽离,减少冗余代码

(2)难的是什么?

个人觉得比较难的地方是以下几点:

  • Vuex状态管理,过程不是很熟悉
  • 插件的使用,不是很熟练
  • Bug的解决,有时不知道问题出在哪
  • 业务逻辑的实现,有时缺少条件,致使不能实现想要的功能
  • 页面布局(个人不是很擅长)
  • 项目优化不知从哪里入手
  • 项目部署

(3)收获是什么?

  • 更加理解组件化的开发的概念,将页面重复的地方拆分成组件,然后进行复用,就减少代码的冗余
  • 页面动画的实现,Vue动画过渡、CSS3动画
  • 了解了多种跨域的解决方案
  • 对支付流程有所了解(微信支付、支付宝支付)
  • 之前没有用过cookie来管理权限,这次有所了解了
  • 对Sass更加了解,真的很方便,提高了代码的可复用性
  • 最多的还是对业务流程、业务逻辑的了解