小兔鲜项目
别名联想
jsconfig.json
文件
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
]
}
}
}
只做提示,在vite.config.js
中做路径转换
element plus 按需导入
element plus 更改主题色
axios 基础配置
utils
下http.js
import axios from 'axios'
const httpInstance = axios.create({
baseURL:'http://pcapi-xiaotuxian-front-devtest.itheima.net',
timeout:5000
})
//请求拦截器
httpInstance.interceptors.request.use(config => {
return config
}, e => Promise.reject(e))
//响应拦截器
httpInstance.interceptors.response.use(res => res.data, e => {
return Promise.reject(e)
})
export default httpInstance
为API
做服务
import httpInstance from "@/utils/http";
export function getCtegory () {
return httpInstance({
url:'home/category/head'
})
}
问题:需要的基地址不同?
axios.create()
可以执行多次,生成新的实例
路由配置
一级路由:整体页面变化
二级路由:一级路由的内部切换
//不强制组件命名
rules:{
'vue/multi-word-component-names':0
}
问题:路由设计依据
内容切换方式
默认二级路由设置:
path配置空
静态资源初始化
图片资源:由UI
提供,把images
文件夹放到assets
下
样式资源:初始化时进行样式重置,用开源normalize.css
或手写,把common.scss
文件放到styles
下
自动导入色值变量
在var.scss
内定义变量
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/element/index.scss" as *;
@use "@/styles/var.scss" as *;`
},
},
},
字体图标引入
字体图标采用的是阿里的字体图标库
<link rel="stylesheet" href="//at.alicdn.com/t/font_2143783_iq6z4ey5vu.css">
吸顶导航
vueuse
基于Vue组合式API的实用工具集
useScroll
获取滚动距离
import { useScroll } from '@vueuse/core'
const { y } = useScroll(window)
<div class="app-header-sticky" :class="{show:y>78}" >
pinia优化
//父组件中
const categoryStore = useCategoryStore()
onMounted(()=>categoryStore.getCategory())
//子组件中
const categoryStore = useCategoryStore()
轮播图
使用element plus中的组件实现,从后端调用接口
面板组件封装
组件参数:props
/插槽
props
传纯文本 插槽传复杂模板
图片懒加载
数据早拿到,只是在进入视口之后才进行渲染,不会多次发送请求,因为有缓存
<img v-img-lazy="item.picture" />
1.自定义命令
2.判断图片是否进入视口(vueuse
)
3.测试图片监控是否生效
4.若图片进入视口,发送图片资源请求
5.测试图片资源是否发出
app.directive('img-lazy',{
mounted(el,binding){
//el:指令绑定的元素 img
//binding:binding.value 后面绑定的表达式的值 图片url
console.log(el,binding.value)
}
})
<img v-img-lazy="item.picture" alt="">
//useIntersectionObserver函数
import { useIntersectionObserver } from '@vueuse/core'
useIntersectionObserver(
el,
([{ isIntersecting }], observerElement) => {
if(isIntersecting){
el.src = binding.value
}
},
)
懒加载优化
问题1:上面的懒加载指令直接写到main.js
入口文件里面,不可理(插件)
入口文件通常只做初始化的事情,不应包含逻辑代码,通过插件的方法把懒加载指令封装成插件,main.js
只需要负责注册插件
main.js
import { lazyPlugin } from '@/directives/index'
app.use(lazyPlugin)
index.js
import { useIntersectionObserver } from '@vueuse/core'
export const lazyPlugin = {
install(app){
app.directive('img-lazy',{
mounted(el,binding){
//el:指令绑定的元素 img
//binding:binding.value 后面绑定的表达式的值 图片url
console.log(el,binding.value)
useIntersectionObserver(
el,
([{ isIntersecting }], observerElement) => {
if(isIntersecting){
el.src = binding.value
}
// targetIsVisible.value = isIntersecting
},
)
}
})
}
}
问题2:重复监听, useIntersectionObserver
监听一直存在,有内存浪费,除非手动停止
stop()
方法
const { stop } = useIntersectionObserver(
el,
([{ isIntersecting }], observerElement) => {
if(isIntersecting){
console.log(isIntersecting)
el.src = binding.value
stop()
}
// targetIsVisible.value = isIntersecting
},
)
GoodsItem 封装
数据对象设计为props参数,纯展示类组件,
defineProps({
good:{
type:Object,
default:() => { }
}
})
路由一级分类
//在router里:
{
path:'category/:id',
component:Category
}
//相应的跳转
<RouterLink :to="`/category/${item.id}`">{{ item.name }}</RouterLink>
面包屑导航渲染
const categoryData = ref({})
const route = useRoute()
const getCategory = async () => {
const res = await getCategoryAPI(route.params.id)
categoryData.value = res.result
}
import request from '@/utils/http'
export function getCategoryAPI(id) {
return request({
url:'/category',
params:{
id
}
})
}
轮播图 A P I 修改
存在home页和分类页的轮播图,通过distributionSite
进行控制
export function getBanerAPI(params = {}) {
const {distributionSite = '1'} = params
return httpInstance({
url:'/home/banner',
params:{
distributionSite
}
})
}
const res = await getBanerAPI({
distributionSite:'2'
})
激活状态显示
RouterLink
有提供active-class
可以激活,写激活样式
<RouterLink active-class="active" :to="`/category/${item.id}`">{{ item.name }}</RouterLink>
.active {
color: $xtxColor;
border-bottom: 1px solid $xtxColor;
}
路由缓存问题
响应路由参数的变化:
使用带参数的路由时,当用户从/users/johnny
导航到/users/jolyne
时(即只有参数变化时),相同的组件实例被重复使用,因为两个路由都渲染同个组件,复用更加高效,但是,这时组件的生命周期钩子不会被调用
问题:一级分类的切换,组件实例复用,导致分类数据无法更新
-
让组件实例不复用,这时则强制销毁重建
-
监听路由变换,变化之后执行数据更新的操作
//方法一: :key,强制替换一个元素(组件),而不是复用它,但是这里banner和分类都会重新请求
<RouterView :key="$route.fullPath"/>
//方法二:监听路由变换 onBeforeRouteUpdate 只重新请求分类的数据
onBeforeRouteUpdate((to) => {
getCategory(to.params.id)
})
const getCategory = async (id = route.params.id) => {
const res = await getCategoryAPI(id)
categoryData.value = res.result
}
逻辑函数拆分业务
步骤:
- 按照业务 声明以
use
打头的逻辑函数 - 把独立的业务逻辑封装到各个函数内部
- 函数内部把组件中需要用到的数据或者方法
return
出去 - 在组件中调用函数把数据或者发方法组合回来使用
eg.
category ->composables->useBanner
import { getBanerAPI } from '@/apis/home'
import { ref,onMounted } from 'vue'
export function useBanner(){
const bannerList = ref([])
const getBanner = async () => {
const res = await getBanerAPI({
distributionSite:'2'
})
bannerList.value = res.result
}
onMounted(()=>{getBanner()})
return {
bannerList
}}
路由二级分类
<RouterLink :to="`/category/sub/${i.id}`">
二级分类商品列表
<div class="sub-container">
<el-tabs v-model="reqData.sortField">
<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>
<div class="body">
<!-- 商品列表-->
<GoodsItem v-for="goods in goodlist" :good="goods" :key="goods.id" />
</div>
const goodlist = ref([])
const reqData = ref({
categoryId: route.params.id,
page: 1,
pageSize: 20,
sortField: 'publishTime'
})
const getGoodList = async () => {
const res = await getSubCategoryAPI(reqData)
goodlist.value = res.result.items
}
onMounted(() => getGoodList())
const tabChange = () => {
reqData.value.page = 1
getGoodList()
}
列表无限加载功能:
v-infinite-scroll
监听是否触底,触底时页数(page)增加,新老数据做拼接渲染,结束监听
<div class="body" v-infinite-scroll="load" :infinite-scroll-disabled="disabled">
<!-- 商品列表-->
<GoodsItem v-for="goods in goodlist" :good="goods" :key="goods.id" />
</div>
const disabled = ref(false)
const load = async () => {
reqData.value.page++
const res = await getSubCategoryAPI(reqData.value)
goodlist.value = [...goodlist.value,...res.result.items]
if(res.result.items.length === 0){
disabled.value = true
}}
路由切换时自动滚动到顶部
//router -> index.js
scrollBehavior () {
return {
top:0
}
}
详情页
问题 :拿不到数据,goods一开始是{},{}.categories是undefined,拿不到数据
router 比 created 快,拼接 router 时,created 还没获取到 router 拼接的数据
<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>
- 可选链的语法
- v-if手动控制渲染时机,保证数据存在才渲染
1. 可选链
<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>
2. v-if手动控制渲染时机
<div class="xtx-goods-page">
<div class="container" v-if="goods.details">
</div>
</div>
思考:渲染模板时遇到对象的多层属性访问可能出现什么问题
出现undefined,解决方法:1. 可选链 2. v-if手动控制渲染
通过小图切换大图
<!-- 左侧大图-->
<div class="middle" ref="target">
<img :src="imageList[activeIndex]" alt="" />
<!-- 蒙层小滑块 -->
<div class="l2ayer" :style="{ left: `0px`, top: `0px` }"></div>
</div>
<!-- 小图列表 -->
<ul class="small">
<li v-for="(img, i) in imageList" :key="i" @mouseenter="enterhandler(i)" :class="{active:i===activeIndex}">
<img :src="img" alt="" />
</li>
</ul>
const activeIndex = ref(0)
const enterhandler= (i) => {
activeIndex.value = i
}
放大镜效果
<!-- 放大镜大图 -->
<div class="large" :style="[
{
backgroundImage: `url(${imageList[activeIndex]})`,
backgroundPositionX: `${positionX}px`,
backgroundPositionY: `${positionY}px`,
},
]" v-show="!isOutside"></div>
</div>
const { elementX, elementY, isOutside } = useMouseInElement(target)
watch([elementX, elementY,isOutside],() => {
if(isOutside.value) return
if(elementX.value > 100 && elementX.value < 300){
left.value = elementX.value - 100
}
if(elementY.value > 100 && elementY.value < 300){
top.value = elementY.value - 100
}
if(elementX.value > 300) {left.value = 200}
if(elementX.value < 100) {left.value = 0}
if(elementY.value > 300) {top.value = 200}
if(elementY.value < 100) {top.value = 0}
positionX.value = -left.value * 2
positionY.value = -top.value * 2
})
S K U 组件
sku
存货单位(库存单元),会计学名词,库存管理中的最小可用单元,例:纺织品中表示规格、颜色、款式。
sku
组件的作用:产出当前用户选择的商品规格,为购物车提供数据
问:实际工作中,遇到别人写的组件或第三方组件,首先看什么?
答:props和emit,props决定当前组件接收的数据,emit决定会产出的数据
通用组件统一注册全局
components下有很多其他通用型组件,在多个业务模块中共享,所以全局组件注册
插件化
components -> index.js
import ImageView from './ImageView/index.vue'
import Sku from './XtxSku/index.vue'
export const componentPlugin = {
install(app){
app.component('XtxImageView',ImageView)
app.component('XtxSku',Sku)
}
}
main.js
import { componentPlugin } from '@/components'
app.use(componentPlugin)
登录
<!--区分登录与非登录状态-->
<template v-if="true">...</template>
<template v-else>
<li><a href="javascript:;" @click="$router.push('/login')">请先登录</a></li>...
</template>
表单检验
过滤一些错误的请求提交,节省接口压力
el-form:绑定表单对象和规则对象
el-form-item:绑定使用的规则字段
el-input:双向绑定表单数据
<!--element plus组件内置表单校验功能-->
<!--准备表单对象并绑定-->
const form = ref({
account:'',
password:''
})
<el-form :model="form" label-position="right" label-width="60px" status-icon>
<!--准备规则对象并绑定-->
const rules = {
account:[
{required:true,message:'用户名不能为空',trigger:'blur'}
],
password:[
{required:true,message:'密码不能为空',trigger:'blur'},
{min:6,max:14,message:'密码长度6-14',trigger:'blur'}
]
}
<el-form :model="form" :rules="rules" label-position="right" label-width="60px" status-icon>
<!--指定表单域的校验字段名-->
<el-form-item prop="account" label="账户"><el-input/></el-form-item>
<el-form-item prop="password" label="密码"><el-input/></el-form-item>
<!--表单对象进行双向绑定v-model-->
<el-form-item prop="account" label="账户"><el-input v-model="form.account"/></el-form-item>
<el-form-item prop="password" label="密码"><el-input v-model="form.password"/></el-form-item>
自定义校验规则
const form = ref({
account:'',
password:'',
agree:true
})
agree:[
{
validator:(rule,value,callback) => {
if(value==true){
callback()
}else{
callback(new Error('请勾选协议'))
}
}
}
]
<el-form-item prop="agree" label-width="22px">
<el-checkbox size="large" v-model="form.agree">
我已同意隐私条款和服务条款</el-checkbox></el-form-item>
统一校验,对整个表单进行
//获取实例对象
const formRef = ref(null)
const doLogin = ()=> {
formRef.value.validate((valid)=>{
//valid所有表单都通过校验,为true
if(valid){
//去登陆
}
})
}
<el-form ref="formRef" :model="form" :rules="rules" label-position="right" label-width="60px" status-icon>
登录接口
export const loginAPI = ({ account, password }) => {
return request({
url:'/login',
method:'POST',
data:{
account,
password,
}
})
}
响应拦截器
//响应拦截器
httpInstance.interceptors.response.use(res => res.data, e => {
//做错误提示
ElMessage({
type:'warning',
message:e.response.data.message
})
return Promise.reject(e)
})
登录逻辑
import 'element-plus/theme-chalk/el-message.css'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
const router = useRouter()
const doLogin = () => {
const { account, password } = form.value
// 调用实例方法
formRef.value.validate(async (valid) => {
// valid: 所有表单都通过校验 才为true
console.log(valid)
// 以valid做为判断条件 如果通过校验才执行登录逻辑
if (valid) {
// TODO LOGIN
await loginAPI({ account, password })
// 1. 提示用户
ElMessage({ type: 'success', message: '登录成功' })
// 2. 跳转首页
router.replace({ path: '/' })
}
})
}
p i n i a 管理数据
由于用户数据特殊性,很多组件都进行共享,使用pinia
管理更方便
state + action 放到pinia
中,组件只负责触发action函数
//stores -> user.js
import { defineStore } from 'pinia'
import { loginAPI } from '@/apis/user'
import { ref} from 'vue'
export const useUserStore = defineStore('user',()=>{
const userInfo = ref({})
const getUserInfo = async ({account,password}) => {
const res = await loginAPI({account,password})
userInfo.value = res.result
}
return {
userInfo,
getUserInfo
}
})
用户数据的持久化
-
用户数据中有个Token,标识当前用户是否登录,Token持续一段时间才会过期
-
pinia
是基于内存存储,刷新就丢失,保持登陆状态就是刷新不丢失,需配合持久化存储
操作 state 时会自动把用户数据在本地localStorage
也存一份,刷新时从localStorage
中取
pinia-plugin-persistedstate
插件
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
{
persist: true,
},
登录与非登录的模板适配
<template v-if="userStore.userInfo.token"></template>
<template v-else></template>
用v-if
控制template条件渲染
请求拦截器携带Token
Token作为用户标识,多个接口都需要携带Token才能获得数据,为统一控制,采取请求拦截器携带的方案
//axios请求拦截器可以在接口正式发起之前对请求参数做一些事情,通常Token会被注入到请求header中
//请求拦截器
httpInstance.interceptors.request.use(config => {
const userStore = useUserStore()
const token = userStore.userInfo.token
if(token){
config.headers.Authorization = `Bearer ${token}`
}
return config
}, e => Promise.reject(e))
退出登录
//清楚用户数据,跳转登录页
const confirm =() => {
userStore.clearUserInfo()
router.push('/login')
}
const clearUserInfo = () => {
userInfo.value={}
}
<el-popconfirm @confirm="confirm" title="确认退出吗?" confirm-button-text="确认" cancel-button-text="取消">
Token失效401拦截处理
Token有效性保持一定时间,一段时间不做任何操作,Token失效,再去请求接口,会出401错误
1.如何确定哪个接口出401错误,什么位置拦截 | 2.检测到401后该干什么 |
---|---|
响应拦截器 | 清楚过期用户信息,跳转登录页 |
//响应拦截器
if(e.response.status === 401){
userStore.clearUserInfo()
router.push('/login')
}
购物车
非登录状态 | 登录状态 |
---|---|
本地购物车操作 | 接口购物车操作 |
所有操作直接操作pinia 中的本地购物车列表 | 所有操作走接口,操作完毕后,获取购物车列表更新本地购物车列表 |
本地购物车
//pinia存储
export const useCartStore = defineStore('cart',() => {
const cartList = ref([])
const addCart = (goods) => {
const item = cartList.value.find((item)=>goods.skuId === item.skuId)
if(item){
item.count++
}else{
cartList.value.push(goods)
}
}
return{
cartList,
addCart
}
},{
persist: true,
})
const addCart = () =>{
if(skuObj.skuId){
console.log(cartStore.cartList)
cartStore.addCart({
id:goods.value.id,
name:goods.value.name,
picture:goods.value.mainPictures[0],
price:goods.value.price,
count:count.value,
skuId:skuObj.skuId,
attrsText:skuObj.specsText,
selected:true
})
}else{
ElMessage.warning('请选择规格')
}
}
购物车渲染删除
const delCart = (skuId) => {
const idx = cartList.value.findIndex((item)=>skuId===item.skuId)
cartList.value.splice(idx,1)
}
购物车单选功能
把单选框的状态与pinia
中store对应的状态保持同步
v-model 双向绑定不方便进行命令式的操作,后续还需要调用接口,因此退回到一般模式:model-value和@change的配合实现
<el-checkbox :model-value="i.selected" @change="(selected)=>singleCheck(i,selected)"/>
const singleCheck = (skuId,selected)=>{
const item = cartList.value.find((item)=>item.skuId===skuId)
item.selected = selected
}
const singleCheck =(i,selected) =>{
carStore.singleCheck(i.skuId,selected)
}
购物车全选功能
pinia
中isAll
属性决定
<el-checkbox :model-value="cartStore.isAll" @change="allCheck" />
const allCheck = (selected) =>{
cartList.value.forEach(item => item.selected =selected)
}
const allCheck = (selected) =>{
cartStore.allCheck(selected)
}
接口购物车
。。。
合并购物车
const getUserInfo = async ({account,password}) => {
const res = await loginAPI({account,password})
userInfo.value = res.result
await mergeCartAPI(cartStore.cartList.map(item=>{
return{
skuId:item.skuId,
selected:item.selected,
count:item.count
}
}))
cartStore.updateNewList()
}
class切换激活状态
<div class="text item" :class="{active: activeAddress.id === item.id}" @click="switchAddress(item)" v-for="item in checkInfo.userAddresses" :key="item.id">
<ul>
<li><span>收<i />货<i />人:</span>{{ item.receiver }} </li>
<li><span>联系方式:</span>{{ item.contact }}</li>
<li><span>收货地址:</span>{{ item.fullLocation + item.address }}</li>
</ul>
</div>
const activeAddress = ref({})
const switchAddress = (item) =>{
activeAddress.value = item
}
携带ID跳转支付页
const createOrder = async () => {
const res = await createOrderAPI({
deliveryTimeType: 1,
payType: 1,
payChannel: 1,
buyerMessage: '',
goods: checkInfo.value.goods.map(item => {
return {
skuId: item.skuId,
count: item.count
}
}),
addressId: curAddress.value.id
})
const orderId = res.result.id
router.push({
path: '/pay',
query: {
id: orderId
}
})
cartStore.updateNewList()
}
export function createOrderAPI(data) {
return request({
url:'/member/order',
method:'POST',
data
})
}
支付流程
前端(跳转支付地址,get请求,订单id+回跳地址url
)
->后端(根据支付协议请求支付宝,需要付钱的商品必要参数)
->第三方支付宝服务(响应支付结果)
->后端(跳转到回跳地址url
,订单id+支付状态)
->前端
const baseURL = 'http://pcapi-xiaotuxian-front-devtest.itheima.net/'
const backURL = 'http://127.0.0.1:5173/paycallback'
const redirectUrl = encodeURIComponent(backURL)
const payUrl = `${baseURL}pay/aliPay?orderId=${route.query.id}&redirect=${redirectUrl}`
<a class="btn alipay" :href="payUrl"></a>
封装倒计时
编写函数框架,确定参数和返回值 -> 编写核心逻辑 -> 实现格式化
composables文件夹
:通用的逻辑函数
import dayjs from 'dayjs'
import { ref, computed } from 'vue'
export const useCountDown = () => {
let timer = null
const time = ref(0)
const formatTime = computed(() => dayjs.unix(time.value).format('mm分ss秒'))
const start = (currentTime) => {
time.value = currentTime
timer = setInterval(() => {
time.value--
}, 1000);
}
//组件销毁时,清除定时器
onUnmounted(() => {
timer && clearInterval(timer)
})
return {
formatTime,
start
}
}
//使用
import { useCountDown } from '@/composables/useCountDown'
const { formatTime,start } = useCountDown()
分页逻辑
const tabChange = (type) => {
params.value.orderState = type
getOrderList()
}
const pageChange = (page) =>{
params.value.page = page
getOrderList()
}
<!-- 分页 -->
<div class="pagination-container">
<el-pagination @current-change="pageChange" :total="total" :pagesize="params.pageSize" background layout="prev, pager, next" />
</div>