持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情
Vue3
项目总结
效果实现
组件封装
创建引用
自定义封装一个组件,以按钮为例。首先先创建一个子组件。
<script setup lang="ts">
// 步骤:
// 1. 准备静态结构
// 2. 分析按钮组件的自定义属性
// 3. `defineProps` 定义 Props 接收值
// 4. 模板中使用父组件传过来的值设置按钮样式
</script>
<template>
<button class="xtx-button ellipsis" :class="">
<slot></slot>
</button>
</template>
<style scoped lang="less">
// 基于类名定义一些和定制样式无关的样式
.xtx-button {
appearance: none;
border: none;
outline: none;
background: #fff;
text-align: center;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
}
// ---------大小类名-------------
// 大
.large {
width: 240px;
height: 50px;
font-size: 16px;
}
// 中
.middle {
width: 180px;
height: 50px;
font-size: 16px;
}
// ---------颜色类名----------
// 默认色
.default {
border-color: #e4e4e4;
color: #666;
}
// 确认
.primary {
border-color: @xtxColor;
background-color: @xtxColor;
color: #2cb4b4;
}
观察代码,发现封装的实质就是通过传参调用不同的类名实现不同的样式效果。
在父组件中引入使用。
<script setup lang="ts">
import MyButtonVue from './components/MyButton.vue';
</script>
<MyButtonVue></MyButtonVue>
设置TS类型
接下来结合 ts 设置子组件传参的类型,使得使用子组件时有更好的代码提示。
-
方法一:
PropType
方法(通用写法)子组件中引入
vue
的PropType
方法,通过defineProps
设置传参的类型与变量。defineProps({ size: { type: String as PropType<'middle' | 'large' | 'mini' | 'small'>, default: 'small', }, type: { type: String as PropType<'gray' | 'plain' | 'primary' | 'default'>, default: 'primary', }, });
这样就能设置两个参数
size
和type
的数据类型为字符串和各自的默认值。优点是适用于组合式和选项式的
API
,缺点是写法繁琐。 -
方法二:泛型 方法(实验性写法)
通过泛型的写法,设置变量的类型。通过问号来表示可传属性。
defineProps<{ type?: 'gray' | 'plain' | 'primary' | 'default'; size?: 'middle' | 'large' | 'mini' | 'small'; }>();
但是我们发现虽然可以检查给出报错信息,但是没有默认属性了,官方文档
TS with Composition
中给出了解决方法,用解构的思路附默认值。const { type = 'default', size = 'large' } = defineProps<{ type?: 'gray' | 'plain' | 'primary' | 'default'; size?: 'middle' | 'large' | 'mini' | 'small'; }>();
但是又出现了报错,而这次是
ESlint
的错误,需要手动关闭。去到.eslint.cjs
文件中规则部分关闭。module.exports = { root: true, // eslint规则 rules: { 'vue/multi-word-component-names': 'off', 'vue/no-setup-props-destructure': 'off', }, };
重新打开子组件,发现没报错了,去实验一下,发现依旧没效果,为什么呢?重新回去看一下文档,发现下面还有一段小字:此行为当前需要明确的 opt-in。
点击进去查看,上面描述了详细的需求,比如
vite
版本,还要添加一条设置。复制代码去vite.config.ts
文件粘贴即可。// vite.config.js export default { plugins: [ vue({ reactivityTransform: true }) ] }
重启项目,最终能够成功实现。我们可以在优化一下,把泛型里的类型单独提取出来,设置成一个类型对象接口,使用时再引入。
interface Prop { type?: 'gray' | 'plain' | 'primary' | 'default'; size?: 'middle' | 'large' | 'mini' | 'small'; } const { type = 'default', size = 'large' } = defineProps<Prop>();
-
方法三:
withDefaults
方法这个方法一开始使用的比较多,现在官方文档把这个方法从
TS with Composition
中剔除了,隐藏在其他地方中。interface Props { // 按钮大小 size?: "mini" | "small" | "middle" | "large"; // 按钮颜色 type?: "gray" | "plain" | "primary" | "default"; } withDefaults(defineProps<Props>(), { size: 'mini', type: 'primary', });
父子传值
v-model
是一个语法糖。
- 在
vue2
中,v-model
实际上的作用是:value
动态绑定输入框的数据,@input
为输入框绑定修改事件修改其值。 - 在
vue3
中,双向绑定语法糖其实由:modelValue
和@update:modelValue
两部分组合而成。
父传子
父组件中:ref
声明一个变量,注册子组件并用 v-model
传值。
<script setup lang="ts">
import MyCount from './components/MyCount.vue';
const count = ref(1);
</script>
<MyCount v-model="count"></MyCount>
子组件中,通过实验性方法接收数据 modelValue
,并通过 :value
把数据绑定给输入框 input
,让其显示。
<script setup lang="ts">
const prop = defineProps<{
modelValue: number;
}>();
</script>
<template>
<button>-</button>
<input type="text" :value="modelValue" />
<button @click="add">+</button>
</template>
子传父
通过 defineEmits
方法,定义事件 update:modelValue
,为按钮声明点击事件,事件处理函数中,调用 emit
声明好的事件,传值。
<script setup lang="ts">
const { modelValue } = defineProps<{
modelValue: number;
}>();
const emit = defineEmits<{
(event: 'update:modelValue', val: number): void;
}>();
const add = () => {
// 为了遵循单项数据流,这里不宜直接 += 1,而是用一个中间变量来接收新值和传值
let temp = modelValue + 1;
emit('update:modelValue', temp);
};
</script>
<template>
<button>-</button>
<input type="text" :value="modelValue" />
<button @click="add">+</button>
</template>
踩坑处理
路由
路由缓存
分类路由 category
是固定的,单纯id动态路由发生变化,因此vue3会把路由缓存起来,如果路由没发生变化则读取缓存的数据,造成点击导航不发请求的现象。
解决方法:在二级路由挂载的地方添加 :key
,绑定他们各自的路由 $route.fullPath
,只要动态路由发生变化, :key
值也会发生变化,就重新发送请求。
<RouterView :key="$route.fullPath"></RouterView>
路由滚动行为
vue
是单页面,因此路由切换时滚动条会在原来的位置,需要添加属性让其滚动条返回顶部。通过官方文档可查到相应的代码。
注意:
这里如果上网百度,很有可能会查到过时的、旧的解决方法,如
return
内设置的是y
,那是vue2
的处理方法,不适用于vue3
。一切请以官方文档为准。
// 使用createRouter创建路由实例
const router = createRouter({
...
// 始终滚动到顶部
scrollBehavior: () => {
return { top : 0 }
}
})
ts文件使用路由
在做登录效果时,为了方便管理,特地把路由跳转的那部分代码写在 actions
函数中,如下所示。
import { useRouter } from 'vue-router';
const useMemberStore = defineStore('member', {
state: () => ({
profile: {} as Profile,
}),
actions: {
async loginAccount(form: { account: string; password: string }) {
const res = await http<Profile>('post', '/login', form);
// console.log(res.data.result);
this.profile = res.data.result;
const router = useRouter();
console.log(router);
},
},
});
发现跳转不成功,打印 router
,发现打印出来的是 undefined
,并且控制台还出现这么一条警告:
[Vue warn]: inject() can only be used inside setup() or functional components.
翻译过来就是,我们不能在 setup
以外的地方使用 useRouter
,怎么办呢?在之前做路由封装时有导出过路由,可以直接引入使用。
import router from '@/router';
const useMemberStore = defineStore('member', {
actions: {
async loginAccount(form: { account: string; password: string }) {
const res = await http<Profile>('post', '/login', form);
// console.log(res.data.result);
this.profile = res.data.result;
router.push('/');
message({ type: 'success', text: '登录成功~' });
},
},
});
ts文件使用pinia
情况同路由一致,pinia
在非 setup
的使用方法不太一样。查看官方文档 pinia 。
官方文档解释的很清楚,在非 setup
文件中,代码会有解析的顺序问题,如果过早的使用会出现一些报错。正确的使用方式是哪里要使用就在哪里引入。
import useStore from '@/store';
instance.interceptors.request.use(
(config) => {
// 在发送请求前做什么
const { member } = useStore();
const { token } = member.profile;
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}
);
这样就能够在ts文件中获取到 pinia
内的 token
来封装请求头了。
项目优化
图片懒加载
当视图滚动到对应位置再发送请求,使用 @vueuse/core
中的 useIntersectionObserver
来实现监听组件进入可视区域行为,实现图片懒加载,优化。
官方文档: useIntersectionObserver
先分析下这个useIntersectionObserver
函数:
🔔核心单词解释:
- useIntersectionObserver 检查元素是否进入可视区函数
- target 目标元素,🎯需配合模板 ref 使用
- isIntersecting 是否进入可视区(布尔值)
- stop 用于停止检测的函数
因此,可以在函数内添加一个 if
判断,判断 isIntersecting
值是否为真,为真则说明此时已滚动到可视范围,执行获取数据的函数事件,实现懒加载。
执行完数据获取的事件后别忘记调用 stop
方法停止侦听追踪,不然用户滑来滑去还要发好几次请求,得不偿失。
<script setup lang="ts">
import { useIntersectionObserver } from '@vueuse/core';
import { ref } from 'vue';
// 准备目标元素(DOM节点或组件,需配合模板 ref 使用)
const target = ref(null);
const { stop } = useIntersectionObserver(target, ([{ isIntersecting }]) => {
// 需求:如果目标元素进入可视区,就发送请求,并停止检测
if (isIntersecting) {
// 当目标元素进入可视区域时,才发送请求
console.log('进入可视区,需要发送请求');
// 请求已发送,主动停止检查
stop();
}
});
</script>
<template>
<div style="height: 2000px"></div>
<!-- 🎯目标元素需添加模板 ref -->
<div ref="target">
<h1>Hello world</h1>
</div>
<div style="height: 2000px"></div>
</template>
步骤总结:
- 通过
ref
属性获取组件的实例- 使用
useIntersectionObserver
监听函数- 判断
isIntersecting
属性,如果为真则说明视图已滚动到相应位置,发送请求- 请求发送一次后调用
stop
停止监听
复用
import { useIntersectionObserver } from "@vueuse/core";
import { ref } from "vue";
/**
* 请求按需加载
* @param apiFn 发送请求函数
* @returns 🚨 target 用于模板绑定
*/
export const useObserver = (apiFn: () => void) => {
// 准备个 ref 用于绑定模板中的某个目标元素(DOM节点或组件)
const target = ref(null);
const { stop } = useIntersectionObserver(target, ([{ isIntersecting }]) => {
console.log("是否进入可视区域", isIntersecting);
if (isIntersecting) {
// 当目标元素进入可视区域时,才发送请求
apiFn();
// 请求已发送,主动停止检查
stop();
}
});
// 🚨返回 ref 用于模板绑定,建议返回对象格式支持解构获取
return { target };
};
持久化存储
通过 pinia
的插件 pinia-plugin-persistedstate
实现快速本地存储。
插件官方文档:插件
-
下载
yarn add pinia-plugin-persistedstate
-
引入注册
注意:
这里是给
pinia
注册插件,而不是给vue
。+import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' const pinia = createPinia(); +pinia.use(piniaPluginPersistedstate); app.use(pinia);
-
模块开启持久化
const useHomeStore = defineStore("home",{ + persist: true // ...省略 });
进阶
不想所有数据都持久化处理,需要选择按需持久化所需数据,可以用配置式写法,按需缓存某些模块的数据。
查看官网的代码片段,可通过 paths
数组内设置相应变量来实现按需保存。
import { defineStore } from 'pinia'
export const useStore = defineStore('main', {
// 所有数据持久化
// persist: true,
// 持久化存储插件其他配置
persist: {
// 修改存储中使用的键名称,默认为当前 Store的 id
key: 'storekey',
// 修改为 sessionStorage,默认为 localStorage
storage: window.sessionStorage,
// 部分持久化状态的点符号路径数组,[]意味着没有状态被持久化(默认为undefined,持久化整个状态)
paths: ['nested.data'],
},
})
登录跳转对应页面
作为一个电商网站,用户可能会在长久的商品挑选后才决定登录购买,此时如果在登录业务中写死 router.push('/')
,极其不方便用户体验。
为了解决这个方法,可以分两步走:
- 让登录路由路径动态保存跳转过来的页面路径,点击登录按钮后不是固定写死跳转到首页,而是获取保存的路径动态跳转。
- 动态获取用户点击去登录时当前的路由路径,并保存到动态路由中。
动态跳转
在去登录的 to
属性先写死一个路由。
<RouterLink to="/login?target=/category/1013001">请先登录</RouterLink>
此时在登录页可以看到路径上保存着相应的路由信息,怎么获取呢?我们此时能依靠的只有一开始做页面跳转时引入的 router
。
打印 router
看看有啥东西。
可以发现,有很多属性方法,其中,我们需要的路径参数在 currentRoute
下的 query
中,获取参数,把参数赋值给 router.push()
中,即可实现动态跳转。
actions: {
async loginAccount(form: { account: string; password: string }) {
const res = await http<Profile>('post', '/login', form);
this.profile = res.data.result;
// 动态解构出路径参数
const { target = '/' } = router.currentRoute.value.query;
// 为了ts不报类型错误,把类型固定写死为字符串
router.push(target as string);
message({ type: 'success', text: '登录成功~' });
},
},
注意:
如果用户一开始进入的就是登录页面,此时动态路由是没有参数保存的,即
undefined
,这种情况我们给个默认值让其登录后去到首页即可。
动态获取
当然,我们不能直接写死一个路由,用户很有可能从任何地方跳转,因此需要动态获取路由。获取方式简单,只需要 $route.fullPath
就能获取成功。
<RouterLink :to="`/login?target=${$route.fullPath}`"
>请先登录</RouterLink
>
Tab
栏切换
点击 Tab
栏可以切换下方内容,显示对应的内容,有三种方法可以实现:
v-if
v-show
keppalive
一般情况下, Tab
栏业务切换需要保留用户之前输入的信息,这个时候第三种方法显然是最合适的。
查看 vue.js
的官方文档,告诉我们 keppalive
的使用方式,就是通过 keppalive
包裹 component
标签,通过设置 is
属性来实现切换效果。
使用 keppalive
最大的好处在于它能够实现缓存,即使切换上一次的数据也不会被销毁,而是缓存起来在后台,用户频繁切换也不会需要频繁生成,极大的提高了性能。