本章概要
- 商品列表
- 商品列表项组件
- 商品列表组件
- 分类商品和搜索结果页面
- Loading 组件
- Books 组件
17.4 商品列表
商品列表页面以列表形式显示所有商品,将商品列表和商品列表项分别定义为单独的组件,商品列表组件作为父组件在其内部循环渲染商品列表项子组件。
17.4.1 商品列表项组件
在 components 目录下新建 BookLisItem.vue。如下:
BookLisItem.vue
<template>
<div class="bookListItem">
<div>
<img :src="item.bigImgUrl" />
</div>
<p class="title">
<router-link :to="{ name: 'book', params: { id: item.id } }" target="_blank">
{{ item.title }}
</router-link>
</p>
<p>
<span class="factPrice">
{{ currency(factPrice(item.price, item.discount)) }}
</span>
<span>
定价:<i class="price">{{ currency(item.price) }}</i>
</span>
</p>
<p>
<span>{{ item.author }}</span>
<span>{{ item.publishDate }}</span>
<span>{{ item.bookConcern }}</span>
</p>
<p>
{{ item.brief }}
</p>
<p>
<button class="addCartButton" @click="addCartItem(item)">
加入购物车
</button>
</p>
</div>
</template>
<script>
import { mapActions } from 'vuex';
export default {
name: 'BookListItem',
props: {
item: {
type: Object,
default: () => { }
}
},
methods: {
...mapActions('cart', {
// 将 this.addCart() 映射为 this.$store.commit('cart/addProductToCart')
addCart: 'addProductToCart'
}),
factPrice(price, discount) {
return price * discount;
},
addCartItem(item) {
let quantity = 1;
let newItem = {
...item,
price: this.factPrice(item.price, item.discount),
quantity
};
this.addCart(newItem);
this.$router.push("/cart")
}
}
}
</script>
<style scoped>
.bookListItem {
border-bottom: solid 1px #ccc;
margin-top: 10px;
margin-left: 30px;
margin-right: 30px;
}
.bookListItem p {
text-align: left;
}
.bookListItem p span {
padding-left: 10px;
}
.bookListItem img {
width: 180px;
height: 180px;
float: left;
}
.addCartButton {
padding: 5px 10px 6px;
color: #fff;
border: none;
border-bottom: solid 1px #222;
border-radius: 5px;
box-shadow: 0 1px 3px #999;
text-shadow: 0 -1px 3px #444;
cursor: pointer;
background-color: #e33100;
}
.addCartButton:hover {
text-shadow: 0 1px 1px #444;
}
.bookListItem .price {
color: #cdcdcd;
text-decoration: line-through;
}
.bookListItem .factPrice {
color: red;
font-weight: bold;
}
.bookListItem .title {
color: #e00;
}
</style>
说明:
to 属性使用了表达式,因此要用 v-bind 指令(这里使用的是简写语法)进行绑定。params 和 path 字段不能同时存在,如果使用了 path 字段,那么 params 将被忽略,所以这里使用命名路由。当然,也可以采用前面例子中拼接路径字符串的方式。
router-link 默认渲染为 a 标签,所有路由的跳转都是在当前浏览器窗口中完成的,但有时希望在新的浏览器窗口中打开目标页面,那么可以使用 target=“_blank”。但要注意,如果使用 v-slot API 定制 router-link ,将其渲染为其它标签,那么就不能使用 a 标签的target 属性,只能编写单击事件响应代码,然后通过 window.open() 方法打开一个新的浏览器窗口。
BookListItem 组件需要的商品数据是由父组件通过 prop 传进来的,所以这里定义了一个 item prop。
单击“加入购物车”按钮时,会调用 addCartItem() 方法将该商品加入购物车中,由于购物车中的商品不需要商品的定价,所以这里先计算出商品的实际价格。
购物车中保存的每种商品都有一个数量,通过 quantity 字段表示,在商品列表项页面中的“加入购物车”功能是一种便捷方式,商品的数量默认为 1,后面会看到商品详情页面中加入任意数量商品功能的实现。
在添加商品到购物车中后,路由跳转到购物车页面,这也是电商网站通常采用的方式,可以刺激用户的冲动消费。
17.4.2 商品列表组件
商品列表组件作为商品列表项组件的父组件,负责为商品列表项组件提供商品数据,并通过 v-for 指令循环渲染商品列表项组件。
在 components 目录下新建 BookList.vue 。如下:
BookList.vue
<template>
<div>
<div v-for="book in list" :key="book.id">
<BookListItem :item="book" />
</div>
</div>
</template>
<script>
import BookListItem from './BookLisItem.vue';
export default {
name: 'BookList',
props: {
list: {
type: Array,
default: () => []
}
},
components: {
BookListItem
}
}
</script>
BookList 组件的代码比较简单,主要就是通过 v-for 命令循环渲染 BookListItem 子组件。某些项目的实现是在列表组件中向服务端请求数据渲染列表项,但在本项目中,BookList 组件会被多个页面复用,并且请求的数据接口是不同的,因此 BookList 组件仅仅是定义了一个 list prop 用来接收父组件传递进来的商品列表数据。
17.5 分类商品和搜索结果页面
单击某个分类链接,将跳转到分类商品页面,在该页面下,将以列表形式列出该分类下的所有商品信息;当搜索框中输入某个关键字,单击“搜索”按钮后,将跳转到搜索结果页面,在该页面下,也是以列表形式列出匹配关键字的所有商品信息。
既然这两个页面都是以列表形式显示商品信息,那么可以将他们合并为一个页面组件来实现,在该页面中无非就是根据路由的路径来动态切换页面标题,以及向服务端请求不同的数据接口。
先给出这两个页面的路由配置,编辑 router 目录下的 index.js 文件。如下:
router/index.js
...
const routes = [
{
path: '/',
redirect: {
name: 'home'
}
},
{
path: '/home',
name: 'home',
meta: {
title: '首页'
},
component: Home
},
{
path: '/category/:id',
name: 'category',
meta: {
title: '图书分类'
},
component: () => import('../views/Books.vue')
},
{
path: '/search',
name: 'search',
meta: {
title: '搜索结果'
},
component: () => import('../views/Books.vue')
}
]
routes.afterEach((to) => {
document.title = to.meta.title;
})
...
在路由配置中,采用的是延迟加载路由的方式,只有在路由到该组件时才加载。关于延时加载路由,可以参看 14.14 节。
将分类图书(/category/:id)和搜索结果(/search)的导航链接对应到同一个目标路由组件 Books 上,同时根据 14.10.1 小节介绍的知识,利用全局后置钩子来为路由跳转后的页面设置标题。
17.5.1 Loading 组件
考虑到图书列表的数据是从服务端去请求数据及网络状况的原因,图书列表的显示可能会有延迟,为此,决定编写一个 Loading 组件,在图书列表数据还没有渲染时,给用户一个提示,让用户稍安勿躁。
在 10.9 节中,已经给出了一个使用 loading 图片实现加载提示的示例,也可以沿用该示例实现加载提示。在这里换一种实现方式,考虑到图片本身加载也需要时间(虽然 loading 图片一般都很小),采用 CSS 实现 loading 加载的动画效果,这种实现在网上有很多,本项目从中找了一个实现,并将其封装为组件。
在 components 目录下新建 Loading.vue 。如下:
Loading.vue
<template>
<div class="loading">
<div class="shadow">
<div class="loader">
<div class="mask"></div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "Loading",
};
</script>
<style scoped>
.shadow {
position: absolute;
top: 50%;
left: 50%;
border-radius: 50%;
margin-top: -50px;
margin-left: -50px;
box-shadow: -2px 2px 10px 0 rgba(0, 0, 0, 0.5),
2px -2px 10px 0 rgba(255, 255, 255, 0.5);
}
.loader {
background: -webkit-linear-gradient(left,
skyblue 50%,
#fafafa 50%);
/* Foreground color, Background color */
border-radius: 100%;
height: 100px;
/* Height and width */
width: 100px;
/* Height and width */
animation: time 8s steps(500, start) infinite;
}
.mask {
border-radius: 100% 0 0 100% / 50% 0 0 50%;
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 50%;
animation: mask 8s steps(250, start) infinite;
transform-origin: 100% 50%;
}
@keyframes time {
100% {
transform: rotate(360deg);
}
}
@keyframes mask {
0% {
background: #fafafa;
/* Background color */
transform: rotate(0deg);
}
50% {
background: #fafafa;
/* Background color */
transform: rotate(-180deg);
}
50.01% {
background: skyBlue;
/* Foreground color */
transform: rotate(0deg);
}
100% {
background: skyBlue;
/* Foreground color */
transform: rotate(-180deg);
}
}
</style>
主要代码就是 CSS 的样式规则,没必要去深究具体的实现细节,当然想研究 CSS 如何实现该种动画效果另当别论。
17.5.2 Books 组件
有了 Loading 组件,接下来就可以开始编写 Books 组件了。在 views 目录下新建 Books.vue 。如下:
views/Books.vue
<template>
<div>
<Loading v-if="loading" />
<h3 v-else>{{ title }}</h3>
<BookList :list="books" v-if="books.length" />
<h1>{{ message }}</h1>
</div>
</template>
<script>
import BookList from "@/components/BookList.vue";
import Loading from "@/components/Loading.vue";
export default {
name: 'Books',
data() {
return {
title: '',
books: [],
message: '',
loading: true
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
vm.title = to.meta.title;
let url = vm.setRequestUrl(to.fullPath);
vm.getBooks(url);
})
},
beforeRouteUpdate(to) {
let url = this.setRequestUrl(to.fullPath);
this.getBooks(url);
return true;
},
components: {
BookList,
Loading
},
methods: {
getBooks(url) {
this.message = '';
this.axios.get(url).then(res => {
if (res.status == 200) {
this.loading = false;
this.books = res.data;
if (this.books.length === 0) {
if (this.$route.name === "category") {
this.message = "当前分类下没有图书!"
} else {
this.message = "没有搜索到匹配的图书";
}
}
}
}).catch(err => {
alert(err)
})
},
// 动态设置服务端数据接口的请求 URL
setRequestUrl(path) {
let url = path;
if (path.indexOf("/category") != -1) {
url = "/book" + url;
}
}
}
}
</script>
说明:
为了控制 Loading 组件的显示与删除,定义一个数据属性 loading ,其默认值为 true,然后使用 v-if 指令进行条件判断。当成功接收到服务端发回的数据时,将数据属性 loading 设置为 false,这样 v-if 指令就会删除 Loading 组件。
因为分类商品和搜索结果使用的是同一个组件,但是向服务端请求的数据接口是不同的,分类商品请求的接口是 /book/category/6,而搜索请求的接口是 /search?wd=keyword,为此定义了 setRequestUrl 方法动态设置请求的接口 URL。
判断目标路由有多种方式,可以在导航守卫中通过 to.path 或 to.fullPath 判断,也可以使用 this.route.path 和 this.$route.fullPath 判断,如果在路由配置中使用了命名路由,还可以使用 this.route.name 判断,如本例所示。
在组件内导航守卫 beforeRouteEnter() 中请求初次渲染的数据,当然也可以利用 created 生命周期钩子完成相同的功能。
由于搜索框是独立的,用户可能会多次进行搜索行为,所以使用组件内守卫 beforeRouteUpdate() ,在组件被复用的时候再次请求数据。
BookList 组件所需要的数据是通过 list prop 传进去的,由于父子组件生命周期的调用时机问题,可能会出现子组件已经 mounted ,而父组件的数据才传过去,导致子组件不能正常渲染,为此可以添加一个 v-if 指令,使用列表数据的长度作为条件判断,确保子组件能正常接收到数据并渲染。在本项目使用的 Vue.js 版本和采用的实现方式下,不添加 v-if 指令也能正常工作,如果以后遇到子组件的列表数据不能正常渲染,可以试试这种解决方案。
Books 组件的渲染效果与 BookList 组件渲染的效果是类似的,只是多了一个标题,以及在没有请求到数据时给出的一个提示信息。