本章概要
- 购物车
- 购物车状态管理配置
- 购物车组件
- 结算页面
- 用户管理
- 用户状态管理配置
- 用户注册组件
- 用户登录组件
17.8 购物车
在一个电商网站中,购物车在很多页面都需要用到,因此非常适合放在 Vuex 的 store 中进行集中管理。在本项目中,采用模块化的方式管理应用中不同的状态。
17.8.1 购物车状态管理配置
在项目的 store 目录下新建 modules 文件夹,在该文件下新建 cart.js。如下:
store/modules/cart.js
const state = {
items: []
}
//mutations
const mutations = {
//添加商品到购物车中
pushProductToCart(state, { id, imgUrl, title, price, quantity }) {
if (!quantity)
quantity = 1;
state.items.push({ id, imgUrl, title, price, quantity });
},
//增加商品数量
incrementItemQuantity(state, { id, quantity }) {
let cartItem = state.items.find(item => item.id == id);
cartItem.quantity += quantity;
},
//用于清空购物车
setCartItems(state, { items }) {
state.items = items
},
//删除购物车中的商品
deleteCartItem(state, id) {
let index = state.items.findIndex(item => item.id === id);
if (index > -1)
state.items.splice(index, 1);
}
}
//getters
const getters = {
//计算购物车中所有商品的总价
cartTotalPrice: (state) => {
return state.items.reduce((total, product) => {
return total + product.price * product.quantity
}, 0)
},
//计算购物车中单项商品的价格
cartItemPrice: (state) => (id) => {
if (state.items.length > 0) {
const cartItem = state.items.find(item => item.id === id);
if (cartItem) {
return cartItem.price * cartItem.quantity;
}
}
},
//获取购物车中商品的数量
itemsCount: (state) => {
return state.items.length;
}
}
//actions
const actions = {
//增加任意数量的商品到购物车
addProductToCart({ state, commit },
{ id, imgUrl, title, price, inventory, quantity }) {
if (inventory > 0) {
const cartItem = state.items.find(item => item.id == id);
if (!cartItem) {
commit('pushProductToCart', { id, imgUrl, title, price, quantity })
} else {
commit('incrementItemQuantity', { id, quantity })
}
}
}
}
export default {
namespaced: true,
state,
mutations,
getters,
actions
}
items 数组用于保存购物车中所有商品信息的状态属性。
接下来,编辑 store 目录下的 index.js ,导入 cart 模块。如下:
store/index.js
import { createStore } from 'vuex'
import cart from './modules/cart'
import createPersistedState from "vuex-persistedstate"
export default new Vuex.Store({
modules: {
cart
},
plugins: [createPersistedState()]
})
在刷新浏览器窗口时,store 中存储的状态信息会被重置,这样就会导致加入购物车中的商品信息丢失。所以一般会选择一种浏览器端持久存储方案解决这个问题,比较常见且简单的方案就是 localStorage ,保存在 store 中的状态信息也要同步加入 localStorage ,在刷新浏览器窗口前,或者用用户重新访问网站时,从 localStorage 中读取状态信息保存到 store 中。
在整个应用期间,需要考虑各种情况下 store 与 localStorage 数据同步的问题,这比较麻烦。为此,可以使用一个第三方的插件解决 store 与 localStorage 数据同步的问题,即 vuex-persistedstate 插件。
首先安装 vuex-persistedstate 插件,在 Visual Studio Code 的终端窗口中执行以下命令进行安装。
npm install vuex-persistedstate -S
vuex-persistedstate 插件的使用非常简单,只需要两句代码就可以实现 store 的持久化存储,这会将整个 store 的状态以 vuex 为键名存储到 localStorage 中。
如果只想持久化存储 store 中的部分状态信息,那么可以在调用 createPersistedState() 方法时传递一个选项对象,在该选项对象的 reducer() 函数中返回要存储的数据。例如:
plugins:[createPersistedState({
reducer (data){
return {
// 设置只存储 cart 模块中的状态
cart:data.cart,
// 或者设置只存储 cart 模块中的 items 数据
// products:data.cart.items
}
}
})]
reducer() 函数的 data 参数是完整的 state 对象。
如果想改变底层使用的存储机制,如使用 sessioniStorage,那么可以在选项对象中通过 storage 指定。代码如下:
plugins:[createPersistedState({
reducer (data){
storage:window.sessionStorage,
...
}
})]
配置好 Vuex 的状态管理后,就可以开始编写购物车组件了。
17.8.2 购物车组件
在 views 目录下新建 ShoppingCart。如下:
views/ShoppingCart.vue
<template>
<div class="shoppingCart">
<table>
<tr>
<th></th>
<th>商品名称</th>
<th>单价</th>
<th>数量</th>
<th>金额</th>
<th>操作</th>
</tr>
<tr v-for="book in books" :key="book.id">
<td><img :src="book.imgUrl"></td>
<td>
<router-link :to="{ name: 'book', params: { id: book.id } }" target="_blank">
{{ book.title }}
</router-link>
</td>
<td>{{ currency(book.price) }}</td>
<td>
<button @click="handleSubtract(book)">-</button>
{{ book.quantity }}
<button @click="handleAdd(book.id)">+</button>
</td>
<td>{{ currency(cartItemPrice(book.id)) }}</td>
<td>
<button @click="deleteCartItem(book.id)">删除</button>
</td>
</tr>
</table>
<p>
<span><button class="checkout" @click="checkout">结算</button></span>
<span>总价:{{ currency(cartTotalPrice) }}</span>
</p>
</div>
</template>
<script>
import { mapGetters, mapState, mapMutations } from 'vuex'
export default {
name: "ShoppingCart",
inject: ['currency'],
computed: {
...mapState('cart', {
books: 'items'
}),
...mapGetters('cart', [
'cartItemPrice',
'cartTotalPrice'
])
},
methods: {
itemPrice(price, count) {
return price * count;
},
...mapMutations('cart', [
'deleteCartItem',
'incrementItemQuantity',
'setCartItems'
]),
handleAdd(id) {
this.incrementItemQuantity({ id: id, quantity: 1 });
},
handleSubtract(book) {
let quantity = book.quantity - 1;
if (quantity <= 0) {
this.deleteCartItem(book.id);
}
else
this.incrementItemQuantity({ id: book.id, quantity: -1 });
},
checkout() {
this.$router.push("/check");
}
}
};
</script>
<style scoped>
.shoppingCart {
text-align: center;
margin-left: 45px;
width: 96%;
margin-top: 70px;
}
.shoppingCart table {
border: solid 1px black;
width: 100%;
background-color: #eee;
}
.shoppingCart th {
height: 50px;
}
.shoppingCart th,
.shoppingCart td {
border-bottom: solid 1px #ddd;
text-align: center;
}
.shoppingCart span {
float: right;
padding-right: 15px;
}
.shoppingCart img {
width: 60px;
height: 60px;
}
.shoppingCart .checkout {
float: right;
width: 60px;
height: 30px;
margin: 0;
border: none;
color: white;
background-color: red;
cursor: pointer;
}
</style>
ShoppingCart 组件提供了两种方式删除购物车中的某项商品:
(1)单击“删除”按钮,将直接删除购物车中的该商品
(2)用户单击数量下的减号按钮时,如果判断数量减一后为零,则删除该商品
17.9 结算页面
在购物车页面中单击“结算”按钮,则进入结算页面,结算页面再一次列出购物车中的所有商品,不同的是,在结算页面不能再对商品进行修改。
在 views 目录下新建 Checkout.vue。如下:
views/checkout.vue
<template>
<div class="shoppingCart">
<h1 v-if="success">{{ msg }}</h1>
<table>
<caption>商品结算</caption>
<tr>
<th></th>
<th>商品名称</th>
<th>单价</th>
<th>数量</th>
<th>金额</th>
</tr>
<tr v-for="book in books" :key="book.id">
<td><img :src="book.imgUrl"></td>
<td>
<router-link :to="{ name: 'book', params: { id: book.id } }" target="_blank">
{{ book.title }}
</router-link>
</td>
<td>{{ currency(book.price) }}</td>
<td>
{{ book.quantity }}
</td>
<td>{{ currency(cartItemPrice(book.id)) }}</td>
</tr>
</table>
<p>
<span><button class="pay" @click="pay">付款</button></span>
<span>总价:{{ currency(cartTotalPrice) }}</span>
</p>
</div>
</template>
<script>
import { mapGetters, mapState, mapMutations } from 'vuex'
export default {
name: "Checkout",
data() {
return {
success: false,
msg: '付款成功!'
};
},
inject: ['currency'],
computed: {
...mapState('cart', {
books: 'items'
}),
...mapGetters('cart', [
'cartItemPrice',
'cartTotalPrice'
])
},
methods: {
itemPrice(price, count) {
return price * count;
},
...mapMutations('cart', [
'setCartItems'
]),
pay() {
this.setCartItems({ items: [] });
this.success = true;
}
}
};
</script>
<style scoped>
.shoppingCart {
text-align: center;
margin-left: 45px;
width: 96%;
margin-top: 70px;
}
.shoppingCart h1 {
color: red;
}
.shoppingCart table {
border: solid 1px black;
width: 100%;
background-color: #eee;
}
.shoppingCart table>caption {
font-size: 1.5em;
font-weight: bold;
margin: 5px 0 8px 0;
}
.shoppingCart th {
height: 50px;
}
.shoppingCart th,
.shoppingCart td {
border-bottom: solid 1px #ddd;
text-align: center;
}
.shoppingCart span {
float: right;
padding-right: 15px;
}
.shoppingCart img {
width: 60px;
height: 60px;
}
.shoppingCart .pay {
float: right;
width: 60px;
height: 30px;
margin: 0;
border: none;
color: white;
background-color: red;
cursor: pointer;
}
</style>
在线支付涉及各个支付平台或银联的调用接口,所以本项目的购物车流程到这一步就结束了,当用户单击“付款”按钮时,只是简单地清空购物车,稍后提示用户“付款成功”。
17.10 用户管理
在实际场景中,当用户提交购物订单准备结算时,系统会判断用户是否已经登录,如果没有登录,会提示用户先进行登录,本节实现用户注册和用户登录组件。
17.10.1 用户状态管理配置
用户登录后的状态需要保存,不仅可以用于向用户显示欢迎信息,还可以用于对受保护的资源进行权限验证。同样,用户的状态存储也使用 Vuex 管理。
在 store/modules 目录下新建 user.js 。如下:
store/modules/user.js
const state = {
user: null
}
// mutations
const mutations = {
saveUser(state, { username, id }) {
state.user = { username, id }
},
deleteUser(state) {
state.user = null;
}
}
export default {
namespaced: true,
state,
mutations,
}
对于前端,存储用户名和用户 ID 已经足以,像用户中心等功能的实现,是需要重新向服务端去请求数据的。
编辑 store/index.js 文件,导入 user 模块,并在 modules 选项下进行注册。如下:
store/index.js
import { createStore } from 'vuex'
import cart from './modules/cart'
import user from './modules/user'
import createPersistedState from "vuex-persistedstate"
export default createStore({
modules: {
cart,
user
},
plugins: [createPersistedState()]
})
17.10.2 用户注册组件
当用户单击 Header 组件中的 “注册”链接时,将跳转到用户注册页面。
在 components 目录下新建 UserRegister.vue 。如下:
components/UserRegister.vue
<template>
<div class="register">
<form>
<div class="lable">
<label class="error">{{ message }}</label>
<input name="username" type="text" v-model="username" placeholder="请输入用户名" />
<input type="password" v-model.trim="password" placeholder="请输入密码" />
<input type="password" v-model.trim="password2" placeholder="请输入确认密码" />
<input type="tel" v-model.trim="mobile" placeholder="请输入手机号" />
</div>
<div class="submit">
<input type="submit" @click.prevent="register" value="注册" />
</div>
</form>
</div>
</template>
<script>
import { mapMutations } from 'vuex';
export default {
name: "UserRegister",
props: [""],
data() {
return {
username: "",
password: "",
password2: "",
mobile: "",
message: ''
};
},
watch: {
username(newVal) {
// 取消上一次请求
if (newVal) {
this.cancelRequest();
this.axios.get("/user/" + newVal, {
cancelToken: new this.axios.CancelToken(
cancel => this.cancel = cancel
)
})
.then(response => {
if (response.data.code == 200) {
let isExist = response.data.data;
if (isExist) {
this.message = "该用户名已经存在";
} else {
this.message = "";
}
}
})
.catch(error => {
if (this.axios.isCancel(error)) {
//如果是请求被取消产生的错误,输出取消请求的原因
console.log("请求取消:", error.message);
//alert(error.message);
//throw new Error("请求取消:" + error.message)
} else {
//处理错误
console.log(error);
//throw new Error(error.message)
}
});
}
}
},
methods: {
register() {
this.message = '';
if (!this.checkForm())
return;
this.axios.post("/user/register",
{ username: this.username, password: this.password, mobile: this.mobile })
.then(response => {
if (response.data.code === 200) {
this.saveUser(response.data.data);
this.username = '';
this.password = '';
this.password2 = '';
this.mobile = '';
this.$router.push("/");
} else if (response.data.code === 500) {
this.message = "用户注册失败";
}
})
.catch(error => {
alert(error.message)
})
},
cancelRequest() {
if (typeof this.cancel === "function") {
this.cancel("终止请求");
}
},
checkForm() {
if (!this.username || !this.password || !this.password2 || !this.mobile) {
this.$msgBox.show({ title: "所有字段不能为空" });
return false;
}
if (this.password !== this.password2) {
this.$msgBox.show({ title: "密码和确认密码必须相同" });
return false;
}
return true;
},
...mapMutations('user', [
'saveUser'
])
},
};
</script>
<style scoped>
.register {
margin: 5em auto 0;
width: 44%;
}
.register input {
padding: 15px;
width: 94%;
font-size: 1.1em;
margin: 18px 0px;
color: gray;
float: left;
cursor: pointer;
font-family: "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
outline: none;
font-weight: 600;
margin-left: 3px;
background: #eee;
transition: all 0.3s ease-out;
border: solid 1px #ccc;
}
.register input:hover {
color: rgb(180, 86, 9);
border-left: solid 6px #40A46F;
}
.register .submit {
padding: 5px 4px;
text-align: center;
}
.register input[type="submit"] {
padding: 17px 17px;
color: #fff;
float: right;
font-family: "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
background: #40a46f;
border: solid 1px #40a46f;
cursor: pointer;
font-size: 18px;
transition: all 0.5s ease-out;
outline: none;
width: 100%;
}
.register .submit input[type="submit"]:hover {
background: #07793d;
border: solid 1px #07793d;
}
.register .error {
color: red;
font-weight: bold;
font-size: 1.1em;
}
</style>
说明:
红框处在这里实现了一个功能,当用户输入用户名时,实时去服务端检测该用户名是否已经存在,如果存在,则提示用户,这是通过 Vue 的监听器来实现的。
不过由于 v-model 指令内部实现机制的原因(对于文本输入框,默认绑定的是 input 事件),如果用户快速输入或快速用退格键删除用户名时,监听器将触发多次,由此导致频繁地向服务端发起请求。为了解决这个问题,可以利用 axios 的 cancel token 取消重复的请求。
使用 axios 发送请求时,可以传递一个配置对象,在配置对象中使用 cacelToken 选项,通过传递一个 executor() 函数到 CancelToken 的构造函数中创建 cancel token。
将 cancel() 函数保存为组件实例的方法,之后如果要取消请求,调用 this.cancel() 即可。cancel() 函数可以接收一个可选的消息字符串参数,用于给出取消请求的原因。同一个 cancel token 可以取消多个请求。
在发生错误时,可以在 catch() 方法中使用 this.axios.isCancel(error) 判断该错误是否是由取消请求而引发的。
当然,这里也可以通过修改 v-model 的监听事件为 change 解决快速输入和删除导致的重复请求问题,只需要给 v-model 指令添加 .lazy 修饰符即可。
用户名是否已注册的判断,请求的服务端数据接口如下:
http://111.229.37.167/api/user/{用户名}
返回的数据结构如下:
{
"code": 200,
"data": true //如果要注册的用户名存在,则返回 false
}
用户注册请求的服务端数据接口如下:
http://111.229.37.167/api/user/register。
需要采用 Post() 方法向该接口发起请求,提交的数据是一个 JSON 格式的对象,该对象要包含 username、password 和 mobile 三个字段。
返回的数据结构如下:
{
"code":200,
"data":{
"id":18,
"username":"小鱼儿",
"password":"1234",
"mobile":"13222222222"
}
}
实际开发时,服务端不把密码返回给前端,如果前端需要用到密码,则可以采用加密形式传输。
当用户注册成功后,将用户名和 ID 保存到 store 中,并跳转到根目录下,即网站的首页。然后 Header 组件会自动渲染出用户名,显示欢迎信息。
17.10.3 用户登录组件
当用户单击 Header 组件中的“登录”链接时,将跳转到用户登录页面。
在 components 目录下新建 UserLogin.vue 。如下:
components/UserLogin.vue
<template>
<div class="login">
<div class="error">{{ message }}</div>
<form>
<div class="lable">
<input name="username" type="text" v-model.trim="username" placeholder="请输入用户名" />
<input type="password" v-model.trim="password" placeholder="请输入密码" />
</div>
<div class="submit">
<input type="submit" @click.prevent="login" value="登录" />
</div>
</form>
</div>
</template>
<script>
import { mapMutations } from 'vuex';
export default {
name: "UserLogin",
data() {
return {
username: '',
password: '',
message: ''
};
},
methods: {
login() {
this.message = '';
if (!this.checkForm())
return;
this.axios.post("/user/login",
{ username: this.username, password: this.password })
.then(response => {
if (response.data.code === 200) {
this.saveUser(response.data.data);
this.username = '';
this.password = '';
//如果存在查询参数
if (this.$route.query.redirect) {
const redirect = this.$route.query.redirect;
//跳转至进入登录页前的路由
this.$router.replace(redirect);
} else {
// 否则跳转至首页
this.$router.replace('/');
}
} else if (response.data.code === 500) {
this.message = "用户登录失败";
} else if (response.data.code === 400) {
this.message = "用户名或密码错误";
}
})
.catch(error => {
console.log(error.message);
})
},
...mapMutations('user', [
'saveUser'
]),
checkForm() {
if (!this.username || !this.password) {
this.$msgBox.show({ title: "用户名和密码不能为空" });
return false;
}
return true;
}
}
};
</script>
<style scoped>
.login {
margin: 5em auto 0;
width: 44%;
}
.login input {
padding: 15px;
width: 94%;
font-size: 1.1em;
margin: 18px 0px;
color: gray;
float: left;
cursor: pointer;
font-family: "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
outline: none;
font-weight: 600;
margin-left: 3px;
background: #eee;
transition: all 0.3s ease-out;
border: solid 1px #ccc;
}
.login input:hover {
color: rgb(180, 86, 9);
border-left: solid 6px #40A46F;
}
.login {
padding: 5px 4px;
text-align: center;
}
input[type="submit"] {
padding: 17px 17px;
color: #fff;
float: right;
font-family: "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
background: #40a46f;
border: solid 1px #40a46f;
cursor: pointer;
font-size: 18px;
transition: all 0.5s ease-out;
outline: none;
width: 100%;
}
.submit input[type="submit"]:hover {
background: #07793d;
border: solid 1px #07793d;
}
.login .error {
color: red;
font-weight: bold;
font-size: 1.1em;
}
</style>
用户登录组件并不复杂,值得一提的就是在用户登录后需要跳转到进入登录页面前的路由,这会让用户体验更好,实现方式已经在 14.10.1 小节介绍过了,本项目也是利用 beforeEach() 注册的全局前置守卫保存用户登录前的路由路径,可以参看 17.11 节。
用户登录请求的数据接口如下:
http://111.229.37.167/api/user/login
同样是以 Post() 方法发起请求,提交的数据是一个 JSON 格式的对象,该对象要包含 username 和 password 两个字段。
返回的数据格式与用户注册返回的数据格式相同。