vue项目

组件分类

通用型组件(ElementPlus)-Dialog模态框

业务定制化组件=商品热榜组件

主题色定制-sass引入

安装sass

1
npm i sass -D

axios实例化配置

使用实例化的 Axios 对象后,在发起请求时不需要再写完整的 URL。当使用实例化对象发起请求时,实例的 baseURL 会作为请求的基础 URL,而只需要提供相对路径即可。

比如,如果创建了一个实例 instance,设置了 baseURL'https://api.example.com',那么在使用这个实例发起请求时,只需要提供相对路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
javascriptCopy code
// 创建实例
const instance = axios.create({
baseURL: 'https://api.example.com',
// 其他配置...
});

// 使用实例发起请求
instance.get('/users') // 实际请求的URL为 'https://api.example.com/users'
.then(response => {
console.log(response.data);
})
.catch(error => {
console.error(error);
});

这种方式使得代码更加简洁,同时提高了代码的可维护性。全局的 axios 对象需要在每次请求时提供完整的 URL,而实例化的对象可以通过设置 baseURL 来减少在每个请求中写重复的 URL 部分。

吸顶交互

安装vueUse插件

引用useScroll函数

1
2
3
4
5
<script setup>
// vueUse
import { useScroll } from "@vueuse/core";
const { y } = useScroll(window);
</script>

样式嵌套:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.app-header-sticky {
width: 100%;
height: 80px;
position: fixed;
left: 0;
top: 0;
z-index: 999;
background-color: #fff;
border-bottom: 1px solid #e4e4e4;
// 此处为关键样式!!!
// 状态一:往上平移自身高度 + 完全透明
transform: translateY(-100%);
opacity: 0;

// 状态二:移除平移 + 完全不透明
&.show {
transition: all 0.3s linear;
transform: none;
opacity: 1;
}
...
}

1
2
<div class="app-header-sticky" :class="{ show: y > 78 }">
</div>

因为两个组件都需要请求接口数据,如何优化?

Pinia优化重复请求

在store文件夹里面新建category.js文件用来管理和共享分类接口的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { defineStore } from 'pinia'
import { getCategoryAPI } from '@/apis/layout'

export const useCategoryStore = defineStore({
id: 'category',
state: () => ({
categoryList: [],
}),
actions: {
async getCategory() {
try {
const res = await getCategoryAPI()
console.log(res);
this.categoryList = res.result
} catch (error) {
console.log('获取分类出错:', error);
}
}
}
})

组件如何调用?

方法一:父组件调用异步请求action方法,子组件分别调用state数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//子组件调用:
import { useCategoryStore } from "@/stores/category";
const categoryStore = useCategoryStore();

***
...
<ul class="app-header-nav">
<li
class="home"
v-for="item in categoryStore.categoryList"
:key="item.id"
>
<RouterLink to="/">{{ item.name }}</RouterLink>
</li>
</ul>
...

组件封装(数据传输问题)

把可复用的结构只写一次,把可能发送变化的部分抽象成组件参数(props/插槽)

逻辑拆分与组件封装的模块化优化,使用动态参数传递机制实现数据和结构的分离,提高了代码的可维护性和可拓展性;

如goodsitem组件封装

调用复用组件的组件:

1
2
3
4
5
6
import GoodsItem from "./GoodsItrm.vue";
<ul class="goods-list">
<li v-for="good in cate.goods" :key="good.id">
<GoodsItem :goods="good" />
</li>
</ul>

组装的复用组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup>
defineProps({
goods: {
type: Object,
default: () => {},
},
});
</script>
<template>
<RouterLink to="/" class="goods-item">
<img v-img-lazy="goods.picture" alt="" />
<p class="name ellipsis">{{ goods.name }}</p>
<p class="desc ellipsis">{{ goods.desc }}</p>
<p class="price">&yen;{{ goods.price }}</p>
</RouterLink>
</template>

实现图片懒加载

1、全局指令定义

2、调用vueUse的useIntersectionObserver函数:判断图片是否进入视图区域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useIntersectionObserver } from '@vueuse/core'
//定义全局指令
app.directive('img-lazy', {
mounted(el, binding) {
console.log(el, binding);
useIntersectionObserver(
el,
([{ isIntersecting }]) => {
console.log(isIntersecting);
//图片进入视图区域发送请求
if (isIntersecting) {
el.src = binding.value
}
},
)
}
})

//img标签使用指令
<img v-img-lazy="item.picture" alt="" />

懒加载优化

问题一:逻辑书写位置不合理,复杂度转移

前面是将代码写到了入口文件main.js,这样写会增加main.js的复杂度,不妨将懒加载指令封装成插件,入口文件只需要负责注册插件即可

创建插件文件夹,index.js文件中写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//定义懒加载插件
import { useIntersectionObserver } from '@vueuse/core'

export const lazyPlugin = {
install(app) {
app.directive('img-lazy', {
mounted(el, binding) {
console.log(el, binding);
useIntersectionObserver(
el,
([{ isIntersecting }]) => {
console.log(isIntersecting);
//图片进入视图区域发送请求
if (isIntersecting) {
el.src = binding.value
}
},
)
}
})
}
}

入口文件注册:

1
2
3
//引入懒加载指令插件并且注册
import {lazyPlugin} from '@/directives'
app.use(lazyPlugin)

问题二:useIntersectionObserver重复监听

增加stop函数,第一次图片加载完毕后调用stop函数停止监听,防止内存浪费

1
2
3
4
5
6
7
8
9
10
11
const { stop } = useIntersectionObserver(
el,
([{ isIntersecting }]) => {
// console.log(isIntersecting);
//图片进入视图区域发送请求
if (isIntersecting) {
el.src = binding.value
stop()
}
},
)

主页一级分类

思路:准备组件模板->封装接口函数->调用接口获取数据(使用路由参数)->渲染模板

路由缓存问题

使用带有参数的路由需要注意的是,当用户从/users/id1导航到/users/id2时,相同的组件实例将被重复使用。因为两个路由都渲染同个组件,比起销毁再创建,复用则显得更加高效。不过,这也意味着组件的生命周期钩子不会被调用。

如何解决问题?

思路1:让组件实例不再复用,强制销毁重建;

思路2:监听路由变化,变化之后执行数据更新操作;

方案一:给router-view添加key

以当前路由完整路径为key的值,给router-view组件绑定

为什么用key?我们最常见的用例是与v-for结合,但是key也可以用于强制替换一个元素/组件而不是复用它。

  • 在适当的时候触发组件的生命周期钩子
  • 触发过渡
1
<RouterView :key="$route.fullPath" />

但是这种方法也存在问题,过于粗暴,如果每次导航都销毁组件重建,说明每次导航都要重新发送一次数据请求,如果有相同数据,则存在浪费。那怎么做到只发送需要的数据请求呢?

方案二:使用beforeRouterUpdate导航钩子

该钩子函数可以在每次路由更新之前执行,在回调中执行需要数据更新的业务逻辑即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { onBeforeRouteUpdate, useRoute } from "vue-router";
const route = useRoute();
const getCategory = async (id = route.params.id) => {
// 如何在setup中获取路由参数 useRoute() -> route 等价于this.$route
const res = await getTopCategoryAPI(id);
categoryData.value = res.result;
};
onMounted(() => getCategory());

//目标:路由参数变化的时候,可以把分类数据接口重新发送
onBeforeRouteUpdate((to) => {
console.log(to);
getCategory(to.params.id);
});

useRoute 用于在 Vue 3 中访问当前路由对象。它返回一个包含当前路由信息的对象,类似于 this.$route 在 Vue 2 中的用法。它返回一个响应式的路由对象,包含了当前路由的各种信息,例如 pathparamsquery ,这里是使用当前路由对象的params的id。

在 Vue Router 中,onBeforeRouteUpdate 是一个导航守卫,它会在当前路由更新但是同一组件被复用时被调用。这个守卫的回调函数接受一个 to 参数,它表示即将导航到的目标路由对象。

其中 to.params.id 表示即将导航到的路由的 id 参数。

这部分代码涉及到 Vue Router 的路由导航守卫,具体是 onBeforeRouteUpdate 钩子函数的使用。这个函数在路由参数发生变化时被调用,允许你在组件复用时对路由参数的变化做出响应。在这里,你使用了该函数来重新获取分类数据接口,以确保在路由参数变化时,相应的分类数据会被重新加载。

这点的亮点在于实现了在路由参数变化时动态更新相关数据,为用户提供了更流畅的页面切换体验,同时也是在合理利用 Vue Router 提供的路由导航守卫进行数据预加载。

逻辑拆分

将独立的业务逻辑拆分出去再组合的过程

比如将请求分类和请求轮播图数据的业务拆分封装到独立的js文件中,函数use打头,内部封装逻辑便于后期维护,return组件需要用到的数据和方法给组件消费。

组件:.vue文件

1
2
3
4
import { useCategory } from "./composables/useCategory";
import { useBanner } from "./composables/useBanner";
const { categoryData } = useCategory();
const { bannerList } = useBanner();

内部封装函数:.js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 封装banner轮播图相关的业务代码
import { ref, onMounted } from 'vue'
import { getBannerAPI } from '@/apis/home'

export function useBanner() {
const bannerList = ref([])

const getBanner = async () => {
const res = await getBannerAPI({
distributionSite: '2'
})
console.log(res)
bannerList.value = res.result
}

onMounted(() => getBanner())

return {
bannerList
}
}

useCategory.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 封装分类数据业务相关代码
import { onMounted, ref } from 'vue'
import { getTopCategoryAPI } from "@/apis/category";
import { useRoute } from 'vue-router'
import { onBeforeRouteUpdate } from 'vue-router'
export function useCategory() {
const categoryData = ref({});
const route = useRoute();
const getCategory = async (id = route.params.id) => {
// 如何在setup中获取路由参数 useRoute() -> route 等价于this.$route
const res = await getTopCategoryAPI(id);
categoryData.value = res.result;
};
onMounted(() => getCategory());

//目标:路由参数变化的时候,可以把分类数据接口重新发送
onBeforeRouteUpdate((to) => {
console.log(to);
getCategory(to.params.id);
});
return {
categoryData
}
}

列表无限加载功能实现

获取二级分类商品列表的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @description: 获取导航数据
* @data {
categoryId: 1005000 ,
page: 1,
pageSize: 20,
sortField: 'publishTime' | 'orderNum' | 'evaluateNum'
}
* @return {*}
*/
export const getSubCategoryAPI = (data) => {
return request({
url: '/category/goods/temporary',
method: 'POST',
data
})
}

组件调用接口,获取基本数据列表:

1
2
3
4
5
6
7
8
//获取基本数据列表
const goodList = ref([]);
const repData = ref({
categoryId: route.params.id,
page: 1,
pageSize: 20,
sortField: "publishTime",//这里双向绑定组件的值
});

获取并加载商品列表:

1
2
3
4
5
6
7
8
9
const getGoodList = async () => {
const res = await getSubCategoryAPI(repData.value);
//调用接口传入repData参数获取商品分类数据
//赋值,goodList响应式数据发生变化,组件会重新渲染
goodList.value = res.result.items;
};
onMounted(() => {
getGoodList();
});

使用 Element-plus 提供的 v-infinite-scroll 指令监听是否满足触底条件,满足加载条件时让页数参数加一获取下一页数据,做新老数据拼接渲染。

1
2
3
4
5
6
7
8
9
<div
class="body"
//Element-Plus自定义指令,触底就触发事件load,定义load事件写业务逻辑
v-infinite-scroll="load"
//根据disabled的值来决定是否停止监听,当后端数据加载完毕,就将disabled只改为true,停止监听
:infinite-scroll-disabled="disabled"
>
<GoodsItem v-for="goods in goodList" :goods="goods" :key="goods.id" />
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
// 加载更多
const disabled = ref(false);
const load = async () => {
// 获取下一页的数据
repData.value.page++;
const res = await getSubCategoryAPI(repData.value);
//做新老数据拼接操作,组件同步更新
goodList.value = [...goodList.value, ...res.result.items];
//当获取过来的商品数组为空时, 加载完毕 停止监听
if (res.result.items.length === 0) {
disabled.value = true;
}
};

定制路由行为

在不同路由切换的时候,可以自动滚动到页面的顶部,而不是停留在原先的位置。

vue-router支持scrollBehavior配置项,可以指定路由切换时的滚动位置。在router实例文件中配置:

1
2
3
4
5
6
7
  //路由滚动行为定制
router:[...] ,
scrollBehavior() {
return {
top: 0
}
}

空对象多层属性访问报错

goods一开始是空对象,.categories[1]就是undefined

1
<el-breadcrumb-item :to="{ path: `/category/sub/${goods.categories[1].id}` }">{{goods.categories[1].name}} </el-breadcrumb-item>

如何解决?

1、可选链语法:

1
<el-breadcrumb-item :to="{ path: `/category/sub/${goods.categories?.[1].id}` }">{{goods.categories[1].name}} </el-breadcrumb-item>

2、v-if手动控制渲染时机,保证只有数据存在才渲染

1
2
3
<div class="container" v-if="goods.detail">
...
(在外层盒子加一个if判断语句,如果goods数据存在,则为真,才会进行后面的渲染)

登录表单校验-element-plus的form组件

定义表单和规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//表单对象
const form = ref({
account: "12056258282",
password: "hm#qd@23!",
agree: true,
});
// 规则数据对象
const rules = {
account: [{ required: true, message: "用户名不能为空", trigger: "blur" }],
//trigger为鼠标失去焦点时触发
password: [
{ required: true, message: "密码不能为空" },
{ min: 6, max: 24, message: "密码长度要求6-14个字符" },
],
agree: [
{//自定义表单规则
validator: (rule, val, callback) => {
console.log(val);
return val ? callback() : new Error("请先同意协议");
},
},
],
};

表单统一校验:

1
2
3
4
5
6
7
8
9
10
11
//获取form实例做统一校验
const formRef = ref(null);
const doLogin = () => {
formRef.value.validate( (vaild) => {
//vaild表示所有表单都通过校验才为true
console.log(vaild);
if (vaild) {
//其他逻辑
}
});
};

按需导入样式

如果使用 unplugin-element-plus 并且只使用组件 API,你需要手动导入样式。

Example:

1
2
import "element-plus/theme-chalk/el-message.css";
import { ElMessage } from 'element-plus'

持久化用户数据

插件安装

1
npm i pinia-plugin-persistedstate

将插件添加到 pinia 实例上

1
2
3
4
5
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

创建 Store 时,将 persist 选项设置为 true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export const useUserStore = defineStore({
id: 'user',
// 1. 定义管理用户数据的state
state: () => ({
userInfo: {}
}),
actions: { // 2. 定义获取接口数据的action函数
async getUserInfo({ account, password }) {
try {
const res = await loginAPI({ account, password })
console.log(res);
this.userInfo = res.result
}
catch (e) {
console.log('获取用户数据出错', e);
}
}
},
persist: true
})

(一开始本地存储无效,因为persist位置写错了)

运行机制:在设置state的时候会自动把数据同步给localstorage,在获取state数据的时候会优先从localstorage中获取。

如何存储登录用户数据?(刷新后还在)

思路:判断验证用户身份的token是否存在,如果存在则调用有用户信息的组件;否则调用请登录的组件

1
<template v-if="userStore.userInfo.token">

请求拦截器携带Token

请求拦截器可以在接口正式发起之前对请求参数做一些事情,通常token数据会被注入到请求header中,格式按照后端要求的格式进行拼接处理。

1
2
3
4
5
6
7
8
9
10
11
// axios请求拦截器
httpInstance.interceptors.request.use(config => {
//从pinia里面获取token数据
const userStore = useUserStore();
const token = userStore.userInfo.token
//拼接token数据
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
}, e => Promise.reject(e))

路由跳转问题

useRouter只能在组件内使用,要在独立的js文件或ts文件不能使用useRouter获取?

地址切换交互-tab切换交互

1、打开弹框交互:点击切换地址按钮,打开弹框,回显用户可选地址列表;

2、切换地址交互:点击切换地址,点击确定按钮,激活地址替换默认收货地址。

2:点击时记录当前激活地址对象activeAddress,点击哪个地址就把哪个地址对象记录下来;

通过动态类名:class控制激活样式类型active是否存在,判断条件:激活地址对象的id===当前项id。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- 切换地址 -->
<el-dialog v-model="showDialog" title="切换收货地址" width="30%" center>
<div class="addressWrapper">
<div
@click="switchAddress(item)"
class="text item"
:class="{ active: activeAddress.id === item.id }"
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>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="confirm">确定</el-button>
</span>
</template>
</el-dialog>

点击确定后将记录的地址传给显示到页面的参数重新渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const checkInfo = ref({}); // 订单对象
const defaultAddress = ref({});

const getcheckInfo = async () => {
const res = await getCheckoutInfoAPI();
checkInfo.value = res.result;
//适配默认地址
//从地址中选出isDefault===0的那一项
const item = checkInfo.value.userAddresses.find(
(item) => item.isDefault === 0
);
defaultAddress.value = item;
};

onMounted(() => {
getcheckInfo();
});

//控制弹框
const showDialog = ref(false);

//切换地址
const activeAddress = ref({});
const switchAddress = (item) => {
activeAddress.value = item;
};

//确认按钮
const confirm = () => {
defaultAddress.value = activeAddress.value;
showDialog.value = false;
};

总结:记录激活项(整个对象/id…)+动态类型控制。

支付业务流程

跳转支付地址(get请求)(订单id+回跳地址url)->跳转到回跳地址url(订单id+支付状态)

1
2
3
4
5
6
7
//跳转支付
//携带订单id以及回调地址跳转到支付地址(get)
// 支付地址
const baseURL = "http://pcapi-xiaotuxian-front-devtest.itheima.net/";
const backURL = "http://localhost:5173/paycallback";
const redirectUrl = encodeURIComponent(backURL);
const payUrl = `${baseURL}pay/aliPay?orderId=${route.query.id}&redirect=${redirectUrl}`;
1
2
3
4
5
<div class="item">
<p>支付平台</p>
<a class="btn wx" href="javascript:;"></a>
<a class="btn alipay" :href="payUrl"></a>
</div>

跳转到支付页面后输入账号密码支付密码即可,后面是后端的工作。

页面最后会自动跳转回backURL地址,所以要配置对应的路由以及页面组件。页面效果展示支付成功还是失败,以及一些订单信息。

获取订单信息调用接口:

1
2
3
4
5
export const getOrderAPI = (id) => {
return request({
url: `/member/order/${id}`
})
}
1
2
3
4
5
6
7
8
//获取订单信息
const getOrderInfo = async () => {
const res = await getOrderAPI(route.query.orderId);
orderInfo.value = res.result;
};
onMounted(() => {
getOrderInfo();
});

支付倒计时实现

因为接口给的时间参数是秒数,要写一个函数可以把秒数格式化为倒计时的显示状态。

安装一个格式化的插件:

1
npm i dayjs

函数封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { computed, onUnmounted, ref } from 'vue'
import dayjs from 'dayjs'

//封装倒计时逻辑函数
export const useCountDown = () => {
//1、响应式数据
let timer = null
const time = ref(0)
//格式化时间-xx分xx秒
const formatTime = computed(() => {
return dayjs.unix(time.value).format('mm分ss秒')
})
//2、开启倒计时函数
const start = (currentTime) => {
time.value = currentTime
timer = setInterval(() => {
time.value--
}, 1000)
}
//组件销毁时清除定时器
onUnmounted(() => {
timer && clearInterval(timer)
})
return {
formatTime,
start
}
}

组件调用函数:

1
2
3
4
5
6
7
8
9
10
11
12
import { useCountDown } from "@/composables/useCountDown";

//倒计时实现
const { formatTime, start } = useCountDown();
//获取订单数据
const getPayInfo = async () => {
const res = await getOrderAPI(route.query.id);
console.log("支付数据", res);
payInfo.value = res.result;
//初始化倒计时秒数
start(res.result.countdown);
};