create-vue
,即官方的项目脚手架工具,提供了搭建基于 Vite 且 TypeScript 就绪的 Vue 项目的选项。
当创建vue应用的时候需要注意:npm init vue@latest
该指令会安装并执行create-vue
。
vue3推荐使用volar插件,格式化的时候需要设置该插件为默认格式化工具。且需要把vue2的vetur关闭。
"[vue]": {
// "editor.defaultFormatter": "octref.vetur"
"editor.defaultFormatter": "Vue.volar"
},
知识补充:在setup语法糖中,父传子的数据,需要接收,但是如何接收和普通的setup函数有什么区别。
假设在App组件中引入了Son组件,并传入数据,那么在setup语法糖中如何接收使用?
<Son :count="count" :str="str"></Son>
本质props一样,都需要接收,但是在语法糖中使用defineProps()
宏来接收,该函数不需要引入即可直接使用。该宏函数功能会被转换为props
配置项接收各个属性。同时在defineProps()
中,参数可以写成完整写法,规定每一个属性的规范。
<script setup>
import { onMounted, onUpdated } from 'vue';
const props = defineProps(["count", "str"])
onMounted(() => {
console.log(props.count, props.str)
})
onUpdated(() => {
console.log(props.count, props.str)
})
</script>
<template>
<div>
Son组件中:{{ str }} -- {{ count }}
</div>
</template>
同时自定义事件传递也需要有注意点:在自定义事件中,基于v-on监听事件,该事件名不能含有大写字母,否则控制台会一直给出报错提示,尽量修改为-
字符连接起来使用。在接收父组件传递来的事件时,子组件使用defineEmits()
宏接收。
<Son :count="count" @add-count="AddCount"></Son>
<script setup>
const props = defineProps(["count"])
const emits = defineEmits(["add-count"])
setInterval(() => {
emits("add-count")
}, 1000);
</script >
<template>
<div>
Son组件:{{ count }}
</div>
</template>
在setup语法糖中,如果存在一个父子关系组件,如果在父组件中使用ref
获取该组件实例,则默认是不能获取子组件中的属性和方法,因为<script setup>
中默认一个组件不会向外暴露属性了。需要主动暴露。这个时候defineExpose()
功能就体现了出来。该方法也不需要引入。
在父组件中使用子组件的实例,子组件未暴露情况
<Son ref="comSon"></Son>
子组件暴露情况,由于子组件向外暴露了自身的属性,所以父组件获取该组件实例的时候可以看见该属性并使用。
//Son组件中向外暴露自身属性
const sonName = ref('zs')
function setName() {
sonName.value = 'ls'
}
defineExpose({ sonName })
项目起步
首先需要安装环境,使用npm init vue@latest
快速安装一个项目环境
在src目录下新增目录:apis目录封装接口,composables目录封装组合函数文件夹(可以复用),directives目录封装全局指令,styles目录封装全局样式,utils目录封装工具函数。
基于create-vue
创建的项目默认没有初始化git仓库,需要手动初始化。
- git init
- git add .
- git commit -m ‘init’
安装UI组件库
选择安装element plus: npm install element-plus --save
,并设置按需引入,按需引入需要额外安装插件支持
安装插件:npm install -D unplugin-vue-components unplugin-auto-import
在vite.config.js配置文件中引入并使用
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
plugins: [
// ...
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
引入一个组件库按钮页面成功显示即可。
主题定制
在一些场景的页面中使用UI组件提供的默认样式是可以的,但是在一些多样化的页面中显示是不够的,所以我们需要定制我们自己的主题。
该项目采用scss,所以先安装scss。使用命令安装:npm i sass -D
(scss和sass一样,文件后缀名不同,语法限制要求不同)
创建一个sass样式文件,通过变量覆盖默认UI样式:styles/element/index.scss
,代码如下
/* 只需要重写你需要的即可 */
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: (
'primary': (
// 主色
'base': #27ba9b,
),
'success': (
// 成功色
'base': #1dc779,
),
'warning': (
// 警告色
'base': #ffb302,
),
'danger': (
// 危险色
'base': #e26237,
),
'error': (
// 错误色
'base': #cf4444,
),
)
)
按需定制主题还需要安装插件实现:npm i -D unplugin-element-plus
在vite.config.js问中添加如下代码
plugins: [
//修改原先代码,通知UI组件库使用sass
Components({
resolvers: [ElementPlusResolver({importStyle:"sass"})],
}),
],
css: {
preprocessorOptions: {
scss: {
// 自动导入定制化样式文件进行样式覆盖
additionalData: `
@use "@/styles/element/index.scss" as *;
`,
}
}
}
axios配置
在一个项目中,绝大部分接口的请求域名都是一致的,只是路径不同而已,所以可以统一设置这些域名信息,这样子就可以减少代码的重复。
在uitls目录下创建一个http.js文件,其中配置axios实例代码。(之后写一个接口,根据文档测试一下能否正常获取数据即可)
import axios from "axios";
// 注册一个axios实例,之后使用该实例注册的接口信息都会与该默认配置进行融合
const instance = axios.create({
baseURL: 'http://pcapi-xiaotuxian-front-devtest.itheima.net',
timeout:5000 //超时时间
})
// 添加请求拦截器
axios.interceptors.request.use(config => {
return config
}, e => {
Promise.reject(e)
});
// 添加响应拦截器
axios.interceptors.response.use(res => {
return res
}, e => {
Promise.reject(e)
});
export default instance
扩展:如果项目中多个接口的基地址不同,就需要配置多个实例,axios.create({baseUrl:...})
可以调用多次返回不同的实例并各自配置。
路由设计
一级路由
创建两个一级路由:views/Login/index.vue
和views/Layout/index.vue
首先直接在App组件中,放置<router-view></router-view>
显示一级路由。
之后在router/index.js
文件中的路由配置信息中添加如下信息。测试后页面可以正常根据路由切换页面。
可以使用history: createWebHashHistory(),
切换路由为hash模式
routes: [
{
path: "/",
component: () => import('@/views/Layout/Index.vue')
},
{
path: '/login',
name: "Login",
component: () => import('@/views/Login/Login.vue')
}
]
二级路由
创建两个二级路由:views/Home/index.vue
和views/Category/index.vue
。这个两个路由位于一级路由Layout组件之下。
{
path: "/",
component: () => import('@/views/Layout/Index.vue'),
children: [
{
path: "", //默认二级路由
name: 'Home',
component: () => import('@/views/Home/Index.vue')
},
{
/* category相对路径:url会带上一级路由,如:localhost:3000/api/category*/
/* /category绝对路径:url不会带上一级路由,如:localhost:3000/category */
path: "category",
name: "Category",
component: () => import('@/views/Category/Index.vue')
}
]
},
处理静态资源
将images
文件夹拖入assets
目录中,并将common.scss
样式文件引入main.js
文件中使用。(将默认样式注释)
import '@/styles/common.scss'
scss变量自动导入
在styles/var.scss
目录下创建一个保存scss变量的样式文件。在不设置自动导入的情况下使用如下
<div class="test">scss</div>
//每次都需要手动引入过于繁琐
@import './styles/var.scss';
.test {
color: $priceColor;
}
设置自动引入,每次都不需要自己引入。在vite.config.js
文件中继续添加如下代码,跟着之前的复制修改路径即可。每次都会自动引入直接使用。
additionalData: `
@use "@/styles/element/index.scss" as *;
@use "@/styles/var.scss" as *;
`,
layout-静态页面搭建
创建对应的文件夹格式,不在将不适应路由的组件全部统一放在components目录中,统一就近创建该目录在当前文件下,方便管理。
引入字体图标
在上面的代码中未引入字体图标,所以部分样式未显示全。将下面的地址引入到index.html
中使用即可
<link rel="stylesheet" href="//at.alicdn.com/t/font_2143783_iq6z4ey5vu.css">
一级导航栏渲染
如图所示区域,需要动态从后台获取,而非写死
在api/layout.js
文件中封装获取分类的代码
import instance from "../utils/http";
export function getCategoryAPI() {
return instance({
method: "GET",
url: "/home/category/head"
})
}
在LaytouHeader.vue
中引入并测试,可以正确获取结果数据
import { onMounted } from 'vue';
import { getCategoryAPI } from '../../../apis/layout';
const categoryList = ref([])
// 渲染导航分类模块
const getCategory = async () => {
let res = await getCategoryAPI()
categoryList.value = res.data.result
}
onMounted(() => {
getCategory()
})
<ul class="app-header-nav">
<li class="home" v-for="item in categoryList" :key="item.id">
<RouterLink to="/">{{ item.name }}</RouterLink>
</li>
</ul>
吸顶导航交互
当页面滚动到某一个距离的时候,将导航栏固定在顶部,不随页面滚动而滚动,方便用户操作导航栏。
首先搭建吸顶静态页面
根据样式代码,控制结构显示与隐藏是通过动态赋值类名show
实现。如何控制类名的添加和隐藏需要根据当前滚动的距离决定。 这里使用VueUse库实现获取滚动距离 ,安装该工具库npm i @vueuse/core
。
这里使用该库中获取滚动距离的函数工具,调用该函数会返回多个参数,这里使用对象解构获取我们需要的y坐标即可
import { useScroll } from '@vueuse/core'
const { y } = useScroll(window) //监听window滚动的距离
之后动态添加类名,根据y坐标的值决定
<div class="app-header-sticky" :class="{ show: y > 78 }">...</div>
这个时候页面顶部吸顶效果就ok了。
利用pinia优化重复请求
观察页面会发现,吸顶组件和头部导航栏组件的内容有部分基本一样,如果每一个组件总都发送网络请求则会降低性能,所以可以利用pinia进行管理。
封装一个stores/category
文件,用于获取种类数据。这里采用对象式写法,也可以采用函数返回式写法。
import { defineStore } from 'pinia'
import { getCategoryAPI } from '@/apis/layout.js'
export const useCategoryStore = defineStore('category', {
state: () => {
return {
categoryList: []
}
},
actions: {
async getCategory() {
let res = await getCategoryAPI()
this.categoryList = res.data.result
}
}
})
需要在Layout/index.vue文件中引入并使用该store中的函数发送数据,因为该组件是两个子组件的父级,子组件挂载到父组件身上,可以在父组件身上事先完成子组件所需要的数据。
// layout/index.vue
import { useCategoryStore } from '@/stores/category.js'
const useCategory = useCategoryStore()
onMounted(() => {
useCategory.getCategory()
})
之后在两个子组件中引入store注册使用即可。
import { useCategoryStore } from '@/stores/category.js'
const useCategory = useCategoryStore()
同时修改页面中的部分代码如:v-for="item in useCategory.categoryList"
Home 搭建组件
搭建完成后再index,vue文件中引入并使用
<template>
<div class="container">
<HomeCategory></HomeCategory>
<HomeBanner></HomeBanner>
</div>
<!-- 新鲜好物 -->
<HomeNew></HomeNew>
<!-- 人气推荐 -->
<HomeHot></HomeHot>
<!-- 产品列表 -->
<HomeProduct></HomeProduct>
</template>
搭建HomeCategory组件
HomeCategory组件中的内容可以使用pinia中保存的数据,该组件主要就是做分类显示,所以可以使用刚才获取的数据完成页面布局。
在该组件中编写如下代码
import { useCategoryStore } from '@/stores/category.js'
const useCategory = useCategoryStore()
图片显示区域采用了绝对定位的方式,然后根据用户鼠标是否触发hover,修改display:none
来让右侧图片区域显示。部分样式如省略号在全局样式文件中已经引入,所以这里直接使用即可。
<template>
<div class="home-category">
<ul class="menu">
<li v-for="item in useCategory.categoryList" :key="item.id">
<RouterLink to="/">{{ item.name }}</RouterLink>
<RouterLink v-for="i in item.children.slice(0, 2)" :key="i.id" to="/">{{ i.name }}</RouterLink>
<!-- 弹层layer位置 -->
<div class="layer">
<h4>分类推荐 <small>根据您的购买或浏览记录推荐</small></h4>
<ul>
<li v-for="i in item.goods" :key="i.id">
<RouterLink to="/">
<img :src="i.picture" alt="" />
<div class="info">
<p class="name ellipsis-2">
{{ i.name }}
</p>
<p class="desc ellipsis">{{ i.desc }}</p>
<p class="price"><i>¥</i>{{ i.price }}</p>
</div>
</RouterLink>
</li>
</ul>
</div>
</li>
</ul>
</div>
</template>
HomeBanner组件搭建
封装一个/api/home,js
文件用于获取轮播图数据
import instance from '@/utils/http.js'
// 获取轮播图
export function getBannerApi() {
return instance({
url: "/home/banner",
method: "GET"
})
}
结构部分代码如下,该组件外侧包含了一个container
类名,该类名被修饰为相对定位作为父级。所以在轮播图中才可以使用绝对定位不丢失位置。轮播图使用了UI组件库实现即可。
<template>
<div class="home-banner">
<el-carousel height="500px">
<el-carousel-item v-for="item in bannerArr" :key="item.id">
<img :src="item.imgUrl" alt="">
</el-carousel-item>
</el-carousel>
</div>
</template>
这里将每次获取的数据保存到本地存储中,以便每次刷新的时候不发送网络请求,增加页面加载速度
<script setup>
import { onMounted, ref } from 'vue';
import { getBannerApi } from '../../../apis/home';
const bannerArr = ref([])
onMounted(async () => {
if (JSON.parse(localStorage.getItem("banner"))) {
bannerArr.value = JSON.parse(localStorage.getItem("banner"))
return
}
let res = await getBannerApi()
bannerArr.value = res.data.result
localStorage.setItem("banner", JSON.stringify(bannerArr.value))
})
</script>
HomeNew和HomeHot组件封装
查看效果图的时候发现。这两个组件中结构部分基本一致,区别是内容的不同,所以可以将组件结构进行封装使用。并且抽离出动态变化的部分利用组件传递所需的参数(props传递简单数据,插槽传递复杂结构数据)。
创建一个HomePanel
组件用于存放复用的代码,将页面中动态变化的部分提取为参数传递过来使用。
<script setup>
defineProps(["title", "subTitle"])
</script>
<template>
<div class="home-panel">
<div class="container">
<div class="head">
<!-- 主标题和副标题 -->
<h3>
{{ title }}<small>{{ subTitle }}</small>
</h3>
</div>
<!-- 主体内容区域 -->
<div>
<!-- 插槽 -->
<slot></slot>
</div>
</div>
</div>
</template>
测试代码:页面按照预期正常显示内容
<HomePanel title="新鲜好物" sub-title="哈哈哈哈">
<h1>你好</h1>
</HomePanel>
<HomePanel title="人气推荐" sub-title="嘿嘿嘿">
<h1>不好</h1>
</HomePanel>
测试成功后就是整体代码编写
编写接口获取数据
export function findNewAPI() {
return instance({
url: "/home/new",
method: "GET"
})
}
export function findHotAPI() {
return instance({
url: "/home/hot",
method: "GET"
})
}
在HomeNew组件中编写代码如下
<script setup>
import { onMounted, ref } from 'vue';
import HomePanel from './HomePanel.vue';
import { findNewAPI } from '@/apis/home.js'
const list = ref([])
onMounted(async () => {
let res = await findNewAPI()
list.value = res.data.result
})
</script>
<template>
<HomePanel title="新鲜好物" sub-title="新鲜出炉 品质靠谱">
<ul class="goods-list">
<li v-for="item in list" :key="item.id">
<RouterLink to="/">
<img :src="item.picture" alt="" />
<p class="name">{{ item.name }}</p>
<p class="price">¥{{ item.price }}</p>
</RouterLink>
</li>
</ul>
</HomePanel>
</template>
在HomeHot组件中编写如下代码
<script setup>
import HomePanel from './HomePanel.vue';
import { onMounted, ref } from 'vue';
import { findHotAPI } from '../../../apis/home';
let list = ref([])
onMounted(async () => {
let res = await findHotAPI()
list.value = res.data.result
})
</script>
<template>
<HomePanel title="人气推荐" sub-title="人气推荐 品质靠谱">
<ul class="goods-list">
<li v-for="item in list" :key="item.id">
<RouterLink to="/">
<img :src="item.picture" alt="" />
<p class="name">{{ item.title }}</p>
<p class="name">{{ item.alt }}</p>
</RouterLink>
</li>
</ul>
</HomePanel>
</template>
图片懒加载指令
在一个页面中,如果在一个可视窗口中,底部有很多图片需要发送网络请求才能获取,而用户在顶部的时候就点击了感兴趣的部分,那么底部应该被显示,所以这些图片不应该被过早的发送请求回来。
这里首先封装了一个指令,用于获取需要懒加载图片的DOM元素信息。
其中el是使用自定义指令绑定的DOM元素信息,而binding.value存放使用指令的时候赋予的值,在这里该值为图片的URL地址。
// 图片懒加载指令
app.directive('img-lazy', {
mounted(el, bingding) {
console.log(el, bingding.value);
}
})
这个时候需要进行判断图片是否进入了视口区域,在这里引入了VueUse中的工具函数实现。useIntersectionObserver ()
函数响应式监听目标元素的可见性。
// 引入监听图片进入窗口的函数
import { useIntersectionObserver } from '@vueuse/core'
useIntersectionObserver ()
函数第一个参数为监听的DOM元素,后面的函数参数isIntersecting
为当前DOM元素是否进入了视口区域。
// 图片懒加载指令
app.directive('img-lazy', {
mounted(el, bingding) {
console.log(el, bingding.value);
// 监听哪一个元素,isIntersecting为返回的布尔值
useIntersectionObserver(el, ([{ isIntersecting }]) => {
console.log(isIntersecting)
},
)
}
})
在这里我们给的是人气推荐中的图片绑定自定义指令,所以刷新的时候页面中该区域未进入视口可见区域,所以控制打印四个false(四个img循环绑定了自定义指令)
当页面滑动到人气推荐模块的时候,由于只有三个图片进入了视口区域,所以只打印三个true。这个时候判断图片是否进入视口区域就完成了。
主要代码如下,给需要懒加载图片的地方绑定自定义指令
<li v-for="item in list" :key="item.id">
.....
<img v-img-lazy="item.picture" />
.....
</li>
根据DOM元素是否进入视口区域进行赋值
useIntersectionObserver(el, ([{ isIntersecting }]) => {
if (isIntersecting) {
el.src = binding.value
}
},
经过测试,在浏览器的网络测试模块中,页面初始化的时候将所以图片请求数据清空,这个时候监视的图片未进入所以没有发送网络请求,所以网络请求中为空。滑动页面显示人气推荐部分,这个时候会发现图片的网络请求中会多出四个图片的请求就代表这个效果模块完成了。
懒加载指令优化
之前的自定义指令全部封装在入口文件main中,而在开发中,应该封装为一个插件,在入口文件中引入使用。
在directives/imgLazy.js
文件中创建插件代码。如下代码中还新增了功能:如果不手动取消监听,则会一直监听下去,这个时候就可以从useIntersectionObserver函数的返回值中结构出stop方法,该方法用于解绑当前监听的元素。 在代码中,默认四个图片都是被监听的,假如三个图片已经完成任务解除绑定了,那么再次移除移入视口就不会触发该监听了,而另一个未解绑的图片会触发。
// 引入监听图片进入窗口的函数
import { useIntersectionObserver } from '@vueuse/core'
export const imgLazyPlugin = {
install(app) {
// 配置此应用
// 图片懒加载指令
app.directive('img-lazy', {
mounted(el, binding) {
console.log(el, binding.value);
// 监听哪一个元素,isIntersecting为返回的布尔值
const { stop } = useIntersectionObserver(el, ([{ isIntersecting }]) => {
console.log(isIntersecting);
if (isIntersecting) {
el.src = binding.value
stop()
}
},
)
}
})
}
}
在入口文件中引入并使用插件
import { imgLazyPlugin } from './directives/imgLazy'
const app = createApp(App)
.......
app.use(imgLazyPlugin)
封装产品列表页面
首先封装一个获取商品列表数据的接口
// 获取商品列表信息
export function getGoodsApi() {
return instance({
url: '/home/goods',
method: "GET"
})
}
在HomeProduct
组件中引入并发送网络请求获取数据
//省略引入
const goodsProduct = ref([])
const getGoods = async () => {
let res = await getGoodsApi()
goodsProduct.value = res.data.result
}
onMounted(() => {
getGoods()
})
结构部分代码如下,复用之前的HomePanel
组件,通过修改样式和数据显示不同的内容。并且对于图片也使用懒加载指令
<template>
<div class="home-product">
<HomePanel :title="cate.name" v-for="cate in goodsProduct" :key="cate.id">
<div class="box">
<RouterLink class="cover" to="/">
<img v-img-lazy="cate.picture" />
<strong class="label">
<span>{{ cate.name }}馆</span>
<span>{{ cate.saleInfo }}</span>
</strong>
</RouterLink>
<ul class="goods-list">
<li v-for="good in cate.goods" :key="good.id">
<RouterLink to="/" class="goods-item">
<img v-img-lazy="good.picture" alt="" />
<p class="name ellipsis">{{ good.name }}</p>
<p class="desc ellipsis">{{ good.desc }}</p>
<p class="price">¥{{ good.price }}</p>
</RouterLink>
</li>
</ul>
</div>
</HomePanel>
</div>
</template>
最终页面效果
封装GoodsItem组件
对于上面的最终显示图片,我们可以将每一个小的图片显示区域封装起来,方便在其他地方使用到。
将结构代码提取封装的时候注意将样式代码也提取到当前组件。对于内容的显示,采用props传参处理
<script setup>
defineProps(["good"])
</script>
<template>
<RouterLink to="/" class="goods-item">
<img v-img-lazy="good.picture" alt="" />
<p class="name ellipsis">{{ good.name }}</p>
<p class="desc ellipsis">{{ good.desc }}</p>
<p class="price">¥{{ good.price }}</p>
</RouterLink>
</template>
在HomeProduct
组件中引入该封装的GoodsItem
组件使用
<ul class="goods-list">
<li v-for="good in cate.goods" :key="good.id">
<GoodsItem :good="good"></GoodsItem>
</li>
</ul>
页面效果不变,但是在其他组件中依旧可以使用该复用组件结构使用。
一级分类
路由配置
点击不同的导航栏就渲染不同的分类信息,因此会涉及到路由传参。
修改原先的路由信息,采用占位符占用
path: "category/:id",
修改导航栏和吸顶导航栏中的路由跳转参数,点击的时候,页面路径可有正常修改
<RouterLink :to="`/category/${item.id}`">{{ item.name }}</RouterLink>
面包屑导航渲染
每次点击不同的导航栏就会动态显示不同的导航提示信息
封装一个动态获取分类信息的接口文件category.js
,编写代码如下。该接口需要传入id作为必选参数
import instance from '../utils/http'
// 传入id获取分类信息
export function getTopCategoryAPI(id) {
return instance({
url: "/category",
method: "GET",
params: {
id
}
})
}
<template>
<div class="top-category">
<div class="container m-top-20">
<!-- 面包屑 -->
<div class="bread-container">
<el-breadcrumb :separator-icon="ArrowRight">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ getTopCategoryData.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
</div>
</template>
const route = useRoute()
const getTopCategoryData = ref({})
// 调用分类数据
const getTopCategory = async (id = route.params.id) => {
let res = await getTopCategoryAPI(id)
getTopCategoryData.value = res.data.result
}
// 初始化渲染页面
onMounted(() => getTopCategory())
轮播图实现
因为轮播图的大部分功能和接口都一样的,所以这里可以选择复用之前的轮播图逻辑。不过需要注意的是:根据接口文档提示,获取轮播图的接口可以传入参数,广告区域展示位置(投放位置 投放位置,1为首页,2为分类商品页) 默认是1。
修改之前的代码,实现复用,要求默认不传入参数的时候指定为字符串1,
export function getBannerApi(id = '1') {
const distributionSite = id
return instance({
url: "/home/banner",
method: "GET",
params: {
distributionSite
}
})
}
在Category/index.vue文件中引入该接口并使用
import { getBannerApi } from '../../apis/home'
const bannerArr = ref([])
onMounted(async () => {
.....
let res = await getBannerApi('2') //调用轮播图
bannerArr.value = res.data.result
})
激活状态显示和分类列表渲染
<router-link>
组件标签默认支持激活样式显示的类名,只需要给当前激活的选项绑定active-class
属性设置对应的类名即可。
给吸顶导航和一级导航的<router-link>
添加激活样式绑定属性,这样子做后不管在哪一个导航栏中激活都可以保持同步激活的效果。
<RouterLink active-class="active" :to="`/category/${item.id}`">{{ item.name }}</RouterLink>
在Category/index.vue
组件中希望每次点击切换的时候导航选项的时候,面包屑的内容可以时刻更新就需要引入监视配置项监听路径的改变。
//监听路由改变,更新页面数据,每次监听的route.params.id都是更新后的
watch(() => route.params.id, () => {
getTopCategory()
})
编写结构部分代码,这里面用到了复用的GoodsItem
组件,在该组件中对图片进行了懒加载操作
<template>
<div class="top-category">
<div class="container m-top-20">
<!-- 面包屑 -->
<div class="bread-container">
<el-breadcrumb :separator-icon="ArrowRight">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ getTopCategoryData.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<!-- 轮播图 -->
<div class="home-banner">
<el-carousel height="500px">
<el-carousel-item v-for="item in bannerArr" :key="item.id">
<img :src="item.imgUrl" alt="">
</el-carousel-item>
</el-carousel>
</div>
<!-- 主体展示数据 -->
<div class="sub-list">
<h3>全部分类</h3>
<ul>
<li v-for="i in getTopCategoryData.children" :key="i.id">
<RouterLink to="/">
<img :src="i.picture" />
<p>{{ i.name }}</p>
</RouterLink>
</li>
</ul>
</div>
<div class="ref-goods" v-for="item in getTopCategoryData.children" :key="item.id">
<div class="head">
<h3>- {{ item.name }}-</h3>
</div>
<div class="body">
<GoodsItem v-for="good in item.goods" :good="good" :key="good.id" />
</div>
</div>
</div>
</div>
</template>
路由缓存问题
在上诉代码中,如果我们不添加watch
监视路由参数变化,那么每次点击不同的导航栏的时候,页面数据不会更新。
路由缓存问题的产生原因:路由只有参数变化的时候,会复用组件实例而非销毁,而复用同一个组件中,onMounted生命周期函数只会执行一次。这就导致了页面不会更新。
- 第一种解决方法,给
<router-view >
绑定一个唯一key
属性,强制替换一个元素或组件而不是继续复用它。
//给二级路由绑定唯一key值,这样子页面可以正常响应点击显示内容
<router-view :key="$route.fullPath"></router-view>
但是这样子有一个缺点:整个页面的请求都会重新发送一次,大大降低了效率
- 路由守卫
onBeforeRouteUpdate()
,每次路由改变的时候作出操作,引入该API使用。该函数传入一个函数作为参数,在参数中,接收的参数和路由守卫一直,有to,from等,这里我们需要知道切换后的路径参数信息,如果直接调用该getTopCategory(),则页面还是不会变化。所以需要给该函数传入参数,因此需要对接口进行修改,指定route.params.id
为默认值。这个时候就可以提高页面性能指定哪些内容需要获取新数据。
(该方法和watch监视思路一致)
// 调用分类面包屑数据
const getTopCategory = async (id = route.params.id) => {
let res = await getTopCategoryAPI(id)
getTopCategoryData.value = res.data.result
}
onBeforeRouteUpdate((to) => {
getTopCategory(to.params.id)
})
拆分业务逻辑
出于维护的目的,尽可能的让组件中的代码减少,便于维护。如一个组件中,出现多个逻辑代码,倘若这些逻辑代码交叉在一起,那么维护起来就及其困难。拆分业务逻辑的思想就是将同一个逻辑功能的代码封装在一个文件中,将必要的数据返回出去使用即可,(如果定义的为响应式数据,则返回的数据结构出来也是响应式)。最后在组件中引入使用即可。
如图就是就近创建了两个文件useCategory.js
和useBanner.js
封装逻辑代码
最后在Category/index.vue
文件中引入使用即可,这样子会发现整个页面非常简洁易于维护。
import { useBanner } from './composables/useBanner.js'
import { useCategory } from './composables/useCategory'
const { bannerArr } = useBanner()
const { getTopCategoryData } = useCategory()
二级分类
路由配置
点击页面中的该区域实现二级分类跳转
首先配置路由信息如下
{
path: "category/sub/:id",
name: "SubCategory",
component: () => import('@/views/SubCategory/Index.vue')
}
之后给Category/index.vue
组件中绑定路由跳转
<li v-for="i in getTopCategoryData.children" :key="i.id">
<RouterLink :to="`/category/sub/${i.id}`">
<img :src="i.picture" />
<p>{{ i.name }}</p>
</RouterLink>
</li>
同时准备好如下组件结构用于接收二级分类显示
面包屑导航实现
在二级分类中希望点击一级分类的时候还能跳转回去显示对应的内容该如何处理。
首先封装一个如下获取二级分类的接口函数,该函数同样需要传入id信息
// 获取二级分类数据
export function getCategoryFilterAPI(id) {
return instance({
url: '/category/sub/filter',
params: {
id
}
})
}
在SubCategory/index,vue
组件中引入并使用,控制台打印需要的数据如图,其中两个parantId
和parentName
属性是实现效果的关键。
const route = useRoute()
const categoryFilterData = ref({})
const getCategoryFilter = async () => {
let res = await getCategoryFilterAPI(route.params.id)
categoryFilterData.value = res.data.result
}
onMounted(() => getCategoryFilter())
修改该组件中的代码实现效果,其中to
属性本质就是路由跳转
<el-breadcrumb-item :to="{ path: `/category/${categoryFilterData.parentId}` }">
{{ categoryFilterData.parentName }}
</el-breadcrumb-item>
<el-breadcrumb-item>{{ categoryFilterData.name }}</el-breadcrumb-item>
基础商品列表展示
将二级分类获取的数据展示在对应的盒模型中显示
首先封装我们的接口,其中data为一个对象,需要包含必要的参数信息如categoryId
表示获取商品分类的编号,page
为页码,pageSize
为每页获取的数据,sortField
参数为筛选条件字段,有三个值 'publishTime' | 'orderNum' | 'evaluateNum'
export function getSubCategoryAPI(data) {
return instance({
url: '/category/goods/temporary',
method: 'POST',
data
})
}
在SubCategory/index,vue
组件中引入并使用,首先制作一个假数据作为getSubCategoryAPI()
的参数,其次引入复用商品信息组件GoodsItem
import GoodsItem from '../Home/components/GoodsItem.vue';
const goodList = ref([])
const reqData = ref({
categoryId: route.params.id,
page: 1,
pageSize: 20,
sortField: "publishTime"
})
const getSubCategory = async () => {
let res = await getSubCategoryAPI(reqData.value)
goodList.value = res.data.result.items
}
onMounted(() => getSubCategory())
在页面中使用该组件并传入数据显示内容
<div class="body">
<!-- 商品列表-->
<GoodsItem v-for="good in goodList" :key="good.id" :good="good"></GoodsItem>
</div>
页面显示如下
列表筛选功能
为tab栏切换双向绑定数据, v-model="reqData.sortField"
的值会根据name
属性的值改变,默认情况下 v-model="reqData.sortField"
为publishTime
所以会默认选择第一个分组。然后每次点击tab栏的时候,双向数据绑定,将name的值赋给reqData.sortField。同时绑定一个事件,该事件在reqData.sortField
改变的时候触发。
<el-tabs v-model="reqData.sortField" @tab-change="handleTabChange">
<el-tab-pane label="最新商品" name="publishTime"></el-tab-pane>
<el-tab-pane label="最高人气" name="orderNum"></el-tab-pane>
<el-tab-pane label="评论最多" name="evaluateNum"></el-tab-pane>
</el-tabs>
只要每次切换的时候,发送的参数改变就代表成功
// 处理筛选
const handleTabChange = () => {
goodList.value.page = 1 //建议重置页数
getSubCategory() //重新调用接口请求数据
}
同时修改一下页面的UI组件默认样式
:deep(.el-tabs__item.is-active) {
color: $xtxColor;
}
:deep(.el-tabs__item):hover {
color: $xtxColor;
}
:deep(.el-tabs__active-bar) {
background-color: $xtxColor;
}
列表无限加载
如果列表要无限加载,就必须监听滚动条的位置,判断何时才能获取新数据。同时再将新数据和旧数据进行拼接显示。最后还需要进行解绑监听,也就是说当没有数据返回的时候需要解除监听操作。
在这里可以使用UI组件库的Infinite Scroll 无限滚动
实现。给需要无限加载列表的父元素绑定指令:v-infinite-scroll="load"
,该指令会监听滚动是否到底部,如果到底部就执行回调函数。
取消监听使用指令:infinite-scroll-disabled="disabled"
绑定disabled
的值,该值默认为false,代表不禁用。
<div class="body" v-infinite-scroll="load" :infinite-scroll-disabled="disabled">
<!-- 商品列表-->
<GoodsItem v-for="good in goodList" :key="good.id" :good="good"></GoodsItem>
</div>
// 无限加载
const disabled = ref(false)
const load = async () => {
// 加载下一页的数据
goodList.value.page++
let res = await getSubCategoryAPI(reqData.value)
goodList.value = [...goodList.value, ...res.data.result.items]
// 返回没数据的时候解除监听
if (res.data.result.items.length === 0) {
disabled.value = true
}
}
定制路由滚动行为
在当前页面中会出现一个问题,就是当前页面滚动到页面的某一个位置,切换到另一个页面的时候依旧会保存在当前页面滚动的位置。这很明显会影响用户体验,如何做,让每次切换路由的时候都从最顶部开始。这个时候可以借助vue-router提供的scrollBehavior
配置项。下面提供两种方法参考。
- 方法一,采用前置路由守卫配合
window.scrollTo(0, 0)
,这个方法在路由每次切换的时候都定位到页面顶部。
router.beforeEach((to, from, next) => {
window.scrollTo(0, 0)
next()
})
- 方法二,配置路由滚动行为
const router = createRouter({
scrollBehavior() {
return {
top: 0
}
}
}
同时在全局样式中给HTML
绑定css样式实现平滑滚动效果,这样子每次滚动到页面顶部的时候不是一瞬间的行为了。
html {
scroll-behavior: smooth;
}
另一个方法平滑滚动的效果代码如下,可以简单理解vue-router中scrollBehavior
就是scrollTo
。两者操作一模一样。
scrollBehavior() {
return {
top: 0,
behavior: "smooth"
}
}
window.scrollTo({
top: 0,
behavior: "smooth"
})
详情页
路由配置
首先配置一个二级路由,该路由位于Layout
组件Children配置项中。
{
path: "detail/:id",
component: () => import('@/views/Detail/Index.vue')
}
同时封装该组件如图,之后在新鲜好物HomeNew
组件中给商品绑定跳转测试,可以正常跳转到详情页。
详情页基本数据渲染
首先封装获取详情页的接口,在apis/detail.js
目录下封装如下代码
export function getDetail(id) {
return instance({
url: "/goods",
method: "GET",
params: {
id
}
})
}
在Detail/Index.vue
组件中引入并使用
const route = useRoute()
const goods = ref({}) //这里为对象
const getGoods = async () => {
let res = await getDetail(route.params.id)
goods.value = res.data.result
console.log(goods.value);
}
onMounted(() => {
getGoods()
})
首先处理页面中简单的数据展示区域
处理面包屑显示,根据打印结果可得,在数组中二级目录和一级目录位于数组中的位置
这里需要注意,因为详情页的的数据量大,可能数据还没返回的时候就已经开始渲染了,这个时候读取某些数据的时候就会报错,所以需要对整个容器进行v-if
判断处理
<el-breadcrumb-item :to="{ path: `/category/${goods.categories[1].id}` }">
{{ goods.categories[1].name }}
</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: `/category/sub/${goods.categories[0].id}` }">
{{ goods.categories[0].name }}
</el-breadcrumb-item>
<el-breadcrumb-item>{{ goods.name }}</el-breadcrumb-item>
更新如图所示的区域信息
//主要代码如下
<p> {{ goods.salesCount }}+ </p>
<p>{{ goods.commentCount }}+</p>
<p>{{ goods.collectCount }}+</p>
<p>{{ goods.brand.name }}</p>
更新商品信息区域
<!-- 商品信息区 -->
<p class="g-name"> {{ goods.name }} </p>
<p class="g-desc">{{ goods.desc }} </p>
<p class="g-price">
<span>{{ goods.price }}</span>
<span> {{ goods.oldPrice }}</span>
</p>
更新如图所示区域
<div class="goods-detail">
<!-- 属性 -->
<ul class="attrs">
<li v-for="item in goods.details.properties" :key="item.value">
<span class="dt">{{ item.name }}</span>
<span class="dd">{{ item.value }}</span>
</li>
</ul>
<!-- 图片,绑定懒加载指令 -->
<img v-for="item in goods.details.pictures" :key="item" v-img-lazy="item">
</div>
封装热榜和周榜区域
首先封装接口函数
// 获取热榜和周榜数据,type决定:1代表24小时热销榜 2代表周热销榜
export function getHotGoodsAPI({ id, type, limit = 3 }) {
return instance({
url: '/goods/hot',
params: {
id,
type,
limit
}
})
}
在Detail/Index.vue
组件中引入DetailHot
组件标签并传入数据,以便决定获取不同的数据
<!-- 24热榜+专题推荐 -->
<div class="goods-aside">
<!-- 24h热榜 -->
<DetailHot :hot-type="1" title="24小时热销榜"></DetailHot>
<!-- 周榜 -->
<DetailHot :hot-type="2" title="周热销帮"></DetailHot>
</div>
在DetailHot
组件中编写如下代码
const props = defineProps(["hotType", "title"]) //接收传递来的参数使用
const route = useRoute()
const goodsList = ref([])
const getGoodsList = async () => {
let res = await getHotGoodsAPI({
id: route.params.id,
type: props.hotType //决定获取不同热度的数据
})
goodsList.value = res.data.result
}
onMounted(() => getGoodsList())
在这里可以是GoodsItem
组件复用代码,只不过需要调整样式即可
<div class="goods-hot">
<h3>{{ title }}</h3>
<!-- 商品区块 -->
<RouterLink to="/" class="goods-item" v-for="item in goodsList" :key="item.id">
<img v-img-lazy="item.picture" alt="" />
<p class="name ellipsis">{{ item.name }}</p>
<p class="desc ellipsis">{{ item.desc }}</p>
<p class="price">¥{{ item.price }}</p>
</RouterLink>
//或者如下
<!-- <GoodsItem :style="{ width: '100%' }" v-for="item in goodsList" :good="item" :key="item.id"></GoodsItem> -->
</div>
图片预览组件
在很多电商网站中,都有点击对应的小图,大图就会跟着预览,其次还会有一个放大镜的效果。在这里将逐步拆分步骤完成。
小图切换大图
在src/components/ImageView/Index.vue
文件中编写基本模板,并在引入使用,先在该组件中写假数据查看效果。
思路:将小图的图片地址保存在一个数组中,并指定一个下标,当触发哪一个小图的时候,就将对应下标的图片地址赋予给大图显示。
结构代码如下
<!-- 左侧大图-->
<div class="middle" ref="target">
<img :src="imageList[curIndex]" alt="" />
<!-- 蒙层小滑块 -->
<div class="layer" :style="{ left: `0px`, top: `0px` }"></div>
</div>
<!-- 小图列表 -->
<ul class="small">
<li v-for="(img, i) in imageList" :key="i" @mouseenter="handleMouseEnter(i)" :class="{ active: i === curIndex }">
<img :src="img" alt="" />
</li>
</ul>
封装了一个假图片数据,并定义了一个下标。每次鼠标经过小图的时候,就更新下标值,然后每次大图都读取对应位置的图片数组显示。同时在小图中动态绑定了类样式,:class="{ active: i === curIndex }
通过这层判断就可以在鼠标离开的时候依旧保持激活状态。
const imageList = [
"https://yanxuan-item.nosdn.127.net/d917c92e663c5ed0bb577c7ded73e4ec.png",
"https://yanxuan-item.nosdn.127.net/e801b9572f0b0c02a52952b01adab967.jpg",
"https://yanxuan-item.nosdn.127.net/b52c447ad472d51adbdde1a83f550ac2.jpg",
"https://yanxuan-item.nosdn.127.net/f93243224dc37674dfca5874fe089c60.jpg",
"https://yanxuan-item.nosdn.127.net/f881cfe7de9a576aaeea6ee0d1d24823.jpg"
]
const curIndex = ref(0) //当前小图的下标
// 更新当前小图下标
const handleMouseEnter = (i) => {
curIndex.value = i
}
获取鼠标相对位置
每当鼠标在大图区域移动的时候都需要获取鼠标相对于大图容器的位置信息。这里使用VueUse提供的函数useMouseInElement
,该函数需要传入一个DOM元素监听,并且返回一个对象,从中解构出elementX
,elementY
,isOutside
三个参,前两个参数为鼠标相对于容器的位置。
// 获取大图容器,提供鼠标的相对位置
const target = ref() //获取DOM元素
//获取当前鼠标距离容器的位置
const { elementX, elementY, isOutside } = useMouseInElement(target)
//大图外层容器
<div class="middle" ref="target">。。。</div>
控制滑块跟随鼠标移动
如图所示橙色正方形为鼠标的可移动范围,超过该区间就需要进行处理。
在代码中滑块区域通过子绝父相定位在容器中,所以可以修改其top
或left
的值来决定滑块的位置。
编写代码如下,对每次鼠标移动进行监听处理
const left = ref(0) //控制滑块x轴的位置
const top = ref(0)//控制滑块y轴的位置
watch([elementX, elementY], () => {
// 滑块没有超出临界范围
if (elementX.value >= 100 && elementX.value <= 300) {
left.value = elementX.value - 100 //100为滑块的大小一半
}
if (elementY.value >= 100 && elementY.value <= 300) {
top.value = elementY.value - 100 //100为滑块的大小一半
}
// 滑块超出了临界范围
if (elementX.value < 100) {
left.value = 0
}
if (elementX.value > 300) {
left.value = 200
}
if (elementY.value < 100) {
top.value = 0
}
if (elementY.value > 300) {
top.value = 200
}
})
并将获取的top
和left
值动态的赋值给滑块使用即可。isOutside
控制滑块何时显示,移除容器的时候为true
<!-- 蒙层小滑块 -->
<div class="layer" v-show="!isOutside" :style="{ left: `${left}px`, top: `${top}px` }"></div>
效果图如下
放大镜显示图片
在放大镜显示区域的图为正常显示区域的两倍。所以需要注意移动的时候 * 2。
const positionX = ref(0) //放大镜背景图的x轴位置
const positionY = ref(0) //放大镜背景图的y轴位置
watch([elementX, elementY], () => {
........
// 让放大镜的背景图相对于鼠标做反方向运动
positionX.value = -left.value * 2
positionY.value = -top.value * 2
}
isOutside
控制滑块和放大镜效果图何时显示
<!-- 放大镜大图 -->
<div class="large" v-show="!isOutside" :style="[
{
backgroundImage: `url(${imageList[curIndex]})`,
backgroundPositionX: `${positionX}px`,
backgroundPositionY: `${positionY}px`,
},
]"></div>
优化watch
监视,当鼠标不在容器范围的时候,后续逻辑就不用执行
watch([elementX, elementY, isOutside], () => {
// 如果鼠标不在容器范围内,后续逻辑不执行
if (isOutside.value) return //wathc首句代码
}
props动态接收图片显示
修改Detail/index.vue
组件,动态的将图片预览图片传入组件标签中作为参数使用
<ImageView :imageList="goods.mainPictures"></ImageView>
在ImageVIew.vue
组件中使用defineProps()
接收使用即可。这样子每次图片区域都是不同显示模块了
defineProps(["imageList"])
sku组件认识
在这里sku组件的作用:产出当前用户选择的商品规格,为加入购物车提供数据信息。
在Detail/index.vue
组件中引入sku组件模块并使用
const handleChange = (data) => {
console.log(data);
}
<!-- sku组件 -->
<XtxSky :goods="goods" @change="handleChange"></XtxSky>
最终效果图如下,sku组件中封装的逻辑为商品的规格信息必须全部选择,否则返回一个空对象。如果商品规格信息全部选择则返回一个SKU信息对象,供后期开发使用。
通用组件全局注册
src/components/
目录下的组件可以理解为通用性组件,在任何地方都可以被使用,那么就可以注册为全局组件。这样子在其他地方使用的时候就不要再次引入,直接使用即可。
封装一个全局组件文件作为插件,并在入口文件中引入使用
export const componentPlugin = {
install(app) {
app.component('ImageView', import('@/components/ImageView/ImageView.vue'))
app.component('XtxSku', import('@/components/XtxSku/index.vue'))
}
}
import { componentPlugin } from './components'
app.use(componentPlugin)