写在前面
注:本文首发掘金签约专栏,此文为文章同步!
本文为 Vue3+Vite
项目实战系列教程文章第三篇,系列文章建议从头观看效果更佳,大家可关注专栏防走失!点个赞再看有助于全文完整阅读!
此系列文章主要使用到的主要技术栈为 Vue3+Vite
,那既然是 Vue3
,状态库我们使用的是 Pinia
而不是 Vuex
,在写法上也肯定是以 CompositionAPI
为主而不是 OptionsAPI
,组件库方面我们使用的是 ArcoDesign
(赶紧丢掉 ElementUI
吧!)。
这是一份保姆级Vue3+Vite实战教程 - 介绍
Vue3+Vite
项目的搭建以及项目上的一些配置。保姆级Vue3+Vite项目实战多布局(上) - 介绍多布局实现思路以及默认布局搭建。
上文中由于核心内容过长,所以为了阅读体验分成了两篇文章,本文接上文还是继续介绍多布局,核心是边栏布局的搭建以及布局的动态切换!也是因为本文被分割成了两篇文章,所以新同学请先看上文!
👉🏻 项目 GitHub 地址[1]
那么截止到目前项目运行效果如下:
OK,我们接着上文的来!
边栏布局组件 SidebarLayout
默认布局我们已经写的差不多了,那接下来就开始写边栏布局 SidebarLayout
,这个组件在上文中已经建好了,所以无需再建。
首先我们需要修改下 src/layout/SwitchIndex.vue
文件,先把布局组件写死 SidebarLayout
,如下:
<script setup></script>
<template>
<div class="switch-index">
<!-- <component :is="" /> -->
<!-- <DefaultLayout /> -->
<SidebarLayout />
</div>
</template>
<style scoped></style>
接着修改 src/layout/switch/SidebarLayout.vue
边栏布局组件如下:
<script setup></script>
<template>
<div>
SidebarLayout
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</div>
</template>
<style scoped></style>
OK,看一下页面,如下图中页面中出现侧边栏布局组件文字即可:
码一下页面布局
还是先码一下布局,再次看下这个图中画的侧边栏布局结构:
其实就是多一个侧边栏嘛!至于侧边栏,其实组件库中也有组件,我们可以直接使用 ArcoDesign
组件库中的 a-layout-sider
组件即可,OK,开始写布局,修改 SidebarLayout
组件,如下:
<script setup>
// 侧边栏收缩状态
const collapsed = ref(false)
// 侧边栏收缩触发事件
const handleCollapse = (val, type) => {
const content = type === 'responsive' ? '响应式触发' : '点击触发'
console.log(`${content}侧边栏,当前状态:${val}`)
collapsed.value = val
}
</script>
<template>
<div class="sidebar-layout">
<a-layout>
<a-affix>
<a-layout-header> Navbar </a-layout-header>
</a-affix>
<a-layout>
<a-affix :offsetTop="58">
<a-layout-sider
breakpoint="lg"
:width="220"
height="calc(100vh-58px)"
collapsible
:collapsed="collapsed"
@collapse="handleCollapse"
>
Menu
</a-layout-sider>
</a-affix>
<a-layout>
<a-layout-content class="min-h-[calc(100vh-58px)]">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</a-layout-content>
<a-layout-footer> Footer </a-layout-footer>
</a-layout>
</a-layout>
</a-layout>
</div>
</template>
<style scoped>
.sidebar-layout :deep(.arco-layout-header),
.sidebar-layout :deep(.arco-layout-footer),
.sidebar-layout :deep(.arco-layout-content) {
@apply text-[var(--color-text-1)] text-14px;
}
.sidebar-layout :deep(.arco-layout-header) {
@apply w-full h-58px;
@apply bg-[var(--color-bg-3)] border-b-[var(--color-border-1)] border-b-solid border-b-width-1px box-border;
}
.sidebar-layout :deep(.arco-layout-content) {
@apply flex flex-col items-center;
@apply bg-[var(--color-bg-1)] relative;
}
.sidebar-layout :deep(.arco-layout-footer) {
@apply w-full flex justify-center items-center;
@apply border-t-[var(--color-border-1)] border-t-solid border-t-width-1px box-border;
@apply bg-[var(--color-bg-2)] text-[var(--color-text-1)] text-14px;
}
.sidebar-layout :deep(.arco-layout-sider) {
@apply h-[calc(100vh-58px)];
}
.sidebar-layout :deep(.arco-layout-sider),
.sidebar-layout :deep(.arco-layout-sider-trigger) {
@apply border-r-[var(--color-border-1)] border-r-solid border-r-width-1px box-border;
}
</style>
如上代码,其实没有什么特别复杂的地方,这里由于布局中模块变多,我们使用 ArcoDesign
组件库中的 a-layout
组件把多个模块分割布局,额,不会不知道 a-layout
组件能嵌套使用吧!先把布局分成上下两个模块,把下模块再分成左右两个模块即可,大概就这样。
样式的话,除了多了一个 a-layout-sider
组件的样式修改,其他大多数和默认布局的一致,所以也不多说了。
看下效果:
接下来我们把之前写的公用组件填充一下,这时候就能体现出分割模块写组件的好处了,整个页面拼凑就行了,后面我们写一个个 hooks
其实核心理念也是一个道理,唯一不同的一个是拼凑页面,一个是拼凑 JS
模块,Vue3
的 CompositionAPI
让 Vue
用户可以像 React
一样写 hooks
,这种写法之所以那么多人喜欢,是因为它让我们写 JS
就像搭建积木一样(不理解 hooks
的没关系,其实本质上它就是函数的一种写法,看名字也可以理解,hook
就是钩子的意思,你是不是立刻想到了钩子函数,其实 hooks
就是函数的一种写法而已,最早是 React
提出,简单理解就是将一些单独或者可以复用的 JS
功能模块抽离成一个一个文件去写,并约定 hooks
方法均以 use
开头大驼峰命名、顶层使用,一个 hooks
做一件事,嗯,大概就是这样子,概念而已,咳咳,跑题了,后面会有实战讲到了再详细说吧)。
OK,先填充组件吧,我们看看都什么组件可以填充进去,Navbar、Logo、Github、Footer
,这些组件都可以,我们找到对应的位置填充下,如下(其他的没改就不写了,只看下有改动的 template
模板):
<template>
<div class="sidebar-layout">
<a-layout>
<a-affix>
<a-layout-header>
<Navbar>
<template #left> <Logo /> </template>
<template #right> <Github /> </template>
</Navbar>
</a-layout-header>
</a-affix>
<a-layout>
<a-affix :offsetTop="58">
<a-layout-sider
breakpoint="lg"
:width="220"
height="calc(100vh-58px)"
collapsible
:collapsed="collapsed"
@collapse="handleCollapse"
>
Menu
</a-layout-sider>
</a-affix>
<a-layout>
<a-layout-content class="min-h-[calc(100vh-58px)]">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</a-layout-content>
<a-layout-footer> <Footer /> </a-layout-footer>
</a-layout>
</a-layout>
</a-layout>
</div>
</template>
Navbar
组件在写的时候注意下,由于导航的菜单要放在侧边栏,所以该组件中的中间插槽或者默认插槽都不需要写了,填充完毕看下效果:
其实大家可能也发现了,其实我们之前写的 Menu
组件还是可以复用的,只需要把菜单的 mode
设置成垂直即 vertical
就行了,OK,接下来我们修改下 Menu
组件,让它可以复用。
修改 Menu 菜单组件
修改 src/layout/components/Menu/index.vue
文件如下:
<script setup>
import { menuRouterFormat, menuRouter } from '@/router/menuRouter.js'
// 新增
const props = defineProps({
mode: {
type: String,
default: 'horizontal'
}
})
// 菜单模式,horizontal 水平,vertical 垂直
const mode = toRef(props, 'mode')
const menuList = ref(menuRouterFormat(menuRouter))
const router = useRouter()
const onClickMenuItem = key => {
router.push(key)
}
const route = useRoute()
const selectedKeys = computed(() => [route.path])
</script>
<template>
<a-menu
class="menu"
auto-open-selected
:selected-keys="selectedKeys"
@menuItemClick="onClickMenuItem"
:mode="mode"
:accordion="true"
>
<MenuItem v-for="menu of menuList" :key="menu.path" :menu="menu" />
</a-menu>
</template>
<style scoped>
/* 没改动,略... */
</style>
如上,我们定义了一个 defineProps
属性 mode
,字符串类型,非必传,不传默认为水平 horizontal
,随后使用了 Vue3
的 toRef
方法,还记得上面我们使用 toRefs
方法结构 props
对象属性吗?
先来看下官方定义:
toRef
── 基于响应式对象上的一个属性,创建一个对应的 ref。这样创建的ref
与其源属性保持同步:改变源属性的值将更新ref
的值,反之亦然。toRefs
── 将一个响应式对象转换为一个普通对象,这个普通对象的每个属性都是指向源对象相应属性的ref
。每个单独的ref
都是使用toRef()
创建的。
toRefs
上面我们说过了,简单来说就是将一个响应式对象转成普通对象,但是这个普通对象中的一个个属性会变成独立的响应式属性。
toRef
其实也一样,只是 toRefs
针对整个响应式对象,toRef
只针对响应式对象中的某个属性而已,其实 toRefs
内部转换属性为响应式对象时也是遍历属性使用 toRef
转的。
上面用 toRefs
而这里写 toRef
,只是想让大家都用一下,实际上用啥都行哈,看个人喜好,如果是用作转 props
对象的话,那就看 props
中属性多不多,多就用 toRefs
,少就用 toRef
,都可以,看哪个方便吧!
toRef
的语法就上面我们写的这样:
const mode = toRef(props, 'mode')
接着说,拿到传入的 mode
属性后,再改下模板中的 a-menu
组件的 mode
属性值为 :mode="mode"
即可。
OK,Menu
组件改完了,我们之前写的默认布局不需要改了,因为 Menu
目前不传参数默认就是水平菜单,那我们在侧边栏布局中使用一下 Menu
组件,修改 SidebarLayout
布局文件,在该组件的 a-layout-sider
标签下使用 Menu
组件如下:
<a-affix :offsetTop="58">
<a-layout-sider
breakpoint="lg"
:width="220"
height="calc(100vh-58px)"
collapsible
:collapsed="collapsed"
@collapse="handleCollapse"
>
<Menu mode="vertical" />
</a-layout-sider>
</a-affix>
保存运行看下效果:
如果你的代码运行后如上所示,那就没问题了,到此两个布局基本就写好了,接下来就写一下动态切换布局组件吧!
动态切换布局
切换布局的思路文章开头已经说过了,还是老套路,我们先处理一下可切换的布局数据,目前我们就两个布局,其实写个死列表就可以,但是为了显得高级一点,接下来我们就换一种相对高级点的方式处理它。
Vite 中 Glob
大家还记得 webpack
中有个 API
叫 require.context
吗?
require.context(directory, useSubdirectories, regExp)
directory
── 表示检索的目录useSubdirectories
── 表示是否检索子文件夹regExp
── 匹配文件的正则表达式,一般是文件名
有经验的同学可能知道,我们在 Vue2
还在使用 webpack
的时候经常会使用 require.context
这个 API
来批量引入组件,那不知道的同学没关系,我们现在用 Vite
,那么 Vite
有没有类似的 API
呢?
答案当然是有的,就是 import.meta.glob
,大家可以先简单看下文档有个初步了解,👉🏻 Vite Glob[2],其实之前旧版本中还有一个 import.meta.globEager
方法,不过目前已经废弃了,不再讨论。
那接下来我们就用 Vite Glob API
来批量处理布局组件,先解析一下各个布局组件,把他们组成我们想要的一个布局列表数据,当然,用法有很多,这里就当作给大家做个小示范吧。
在 src/layout/switch
文件夹下新建 index.js
文件,写入如下内容:
const modules = import.meta.glob('./*.vue', { eager: true })
let switchLayoutList = []
for (const path in modules) {
switchLayoutList.push(modules[path].default)
}
export default switchLayoutList
简单介绍下 import.meta.glob
方法,Vite
支持使用特殊的 import.meta.glob
函数从文件系统导入多个模块。
此方法第一个参数可以是字符串也可以是数组,分别代表一个或多个匹配方式,由于这个方法 Vite
是基于 fast-glob[3] 包来实现的,所以,第一个参数匹配的语法也和它一致,这里我列一下基础语法,高级语法我们也不咋能用到,不需要关注,用到的时候上面链接点击就是文档:
星号 (
.
) ── 匹配除斜杠(路径分隔符)、隐藏文件(以 开头的名称)之外的所有内容。双星或单星 (
*
) ── 匹配零个或多个目录。问号 (
?
) ── 匹配除斜杠(路径分隔符)之外的任何单个字符。序列 (
[seq]
) ── 匹配序列中的任何字符。
除此之外,它还支持反匹配模式,也就是在匹配字串前加个感叹号( !
)即代表忽略一些文件。
在使用 Glob API
还需要注意一下:
这只是一个
Vite
独有的功能而不是一个Web
或ES
标准该
Glob
模式会被当成导入标识符:必须是相对路径(以./
开头)或绝对路径(以/
开头,相对于项目根目录解析)或一个别名路径( resolve.alias 选项[4])。还需注意,所有
import.meta.glob
的参数都必须以字面量传入。「不」可以在其中使用变量或表达式。
这些其实仔细过一遍文档的同学可以发现文档中就有,奈何就是有些人它不喜欢看文档。。。
我们这里的匹配使用比较简单,本身其实也用不到太复杂的匹配,'./*.vue'
,它代表当前目录下所有以 .vue
结尾的文件。
再来看 Glob API
的第二个参数,第二个参数是一个对象,存在几个属性,如下:
as
── 特殊的字符串类型,指定导入url
的导入类型,即参照Import Reflection
语法对模块导入进行的一个断言。eager
── 布尔类型,导入为静态或动态,默认false
即默认动态,为true
时导入为静态import
── 字符串类型,仅导入指定的导出。设置为‘default’
以导入默认导出。query
── 自定义查询exhaustive
── 布尔类型,搜索node_modules/
和隐藏目录(例如.git/
)中的文件,默认false
关闭,开启可能会影响性能。
「PS:」 有人可能会问 Import Reflection[5] 是什么?其实它是 TC39
在第 91
次会议上针对模块导入相关的一个提案,想要了解的同学还是那句话,点击上面那个链接就是官方文档传送门。
这里可能会有同学看的有点模糊,不过不重要,大多都用不到,记住常用的就行,这里列出来是给有兴趣的同学一个查阅检索项。
我们只需要理解在第二个参数中将 eager
属性设置成了 true
,即设置了静态导入。
静态导入和动态导入的区别也很好理解,拿官网的一个例子来说吧:
// eager 属性不传默认 false,即动态导入,vite 转译前如下
const modules = import.meta.glob('./dir/*.js')
// 动态导入 vite 转译后如下
const modules = {
'./dir/foo.js': () => import('./dir/foo.js'),
'./dir/bar.js': () => import('./dir/bar.js')
}
// eager 属性true,即静态导入,vite 转译前如下
const modules = import.meta.glob('./dir/*.js', { eager: true })
// 静态导入 vite 转译后如下
import * as __glob__0_0 from './dir/foo.js'
import * as __glob__0_1 from './dir/bar.js'
const modules = {
'./dir/foo.js': __glob__0_0,
'./dir/bar.js': __glob__0_1
}
这下总理解了吧,其他参数都很少用到,用到我们查查文档吧,不一一演示了!!!
我们回到主题接着说,上文 index.js
文件中我们拿到这些布局组件的 modules
后,遍历 modules
将每个组件都 push
到了 switchLayoutList
布局数组列表中并导出,留待后用。
我们在 src/layout/SwitchIndex.vue
文件中导入 index.js
并输出一下 switchLayoutList
布局数组,修改如下:
<script setup>
import switchLayoutList from '@/layout/switch/index.js'
console.log(switchLayoutList)
</script>
<template>
<div class="switch-index">
<!-- <component :is="" /> -->
<!-- <DefaultLayout /> -->
<SidebarLayout />
</div>
</template>
<style scoped></style>
输出结果如下:
修改布局组件具名并填充布局信息
其实到此我们已经拿到了 src/layout/switch
文件夹下的所有可切换布局组件,但是,看上面的输出结果,其实我们并不知道哪个组件是对应的哪个布局(上面输出的 2 个组件对象中下面那个组件信息中有个 __name
属性,是因为我们目前页面中以文件名调用了该布局组件,但是将来要做动态切换默认是不会引入的,它还是没有这个属性,换句话说,哪怕就算有,Vue
中以双下划线开头的属性也是不想被我们调用到的)。之所以没有组件名的标识,这是因为我们写的布局组件都是匿名组件,那所以现在我们就要给他们变成具名组件。除此之外,为了使我们的布局列表信息更完善,我们还要给每个布局组件增加布局名称以及图标信息,这样后面做切换组件时就方便的多了。
先修改边栏布局组件吧,在 SidebarLayout
组件文件中新增如下代码:
<script>
import IconRiLayout5Fill from '~icons/ri/layout-5-fill'
export default {
name: 'SidebarLayout',
icon: IconRiLayout5Fill,
title: '边栏布局'
}
</script>
<script setup>
// ...
</script>
<template>
<!-- ... -->
</template>
如上,之前我们的组件是用 script setup
模式写的 CompositionAPI
组件,这种写法是没有办法给组件命名的,大家猜一下为什么?
因为我们使用了 setup
,试想 setup
在 Vue
组件中的哪个时期才会调用?它是在组件调用时被调用,并且是在组件的 beforeCreate
生命周期之前执行,也就是想要拿到 setup
中的数据,那至少得等组件调用了才行,组件还没调用的时候,是绝对获取不了 setup
中属性的,那么问题来了,不命名又只能以默认的文件名调用。。。无解,所以我们想要命名,只能再写一个 OptionsAPI
组件,OptionsAPI
组件中我们直接并为其添加 name
属性就 ok
了。
那除了为组件命名之外,我们还找了了一个 iconify
图标库的图标作为该布局的图标引入并写到了组件的自定义属性 icon
中,同时还自定义了一个 title
属性给组件起了个中文名,这是为了将来渲染布局切换菜单省事哈,后面就晓得了。
写完了边栏组件我们再写一下默认组件,在 组件中新增如下代码:
<script>
import IconRiLayoutTopFill from '~icons/ri/layout-top-fill'
export default {
name: 'DefaultLayout',
icon: IconRiLayoutTopFill,
title: '默认布局'
}
</script>
和上面一致,不过多解释了。现在,保存再刷新页面看下输出的布局组件列表信息:
OK,经过种种手段我们现在已经成功搞到了我们想要的布局列表数据!!!
Pinia 共享布局状态
由于将来我们的布局组件信息需要跨页面共享,所以这里就需要用到 Pinia
了,Pinia
和 Vuex
具有相同的功效,是 Vue
的核心存储库,它允许我们跨 组件/页面
共享状态,所以用在这儿很合适,本身 Pinia
就是作为下一代 Vuex
产生的,那现在我们使用官方包创建项目都只会询问我们是否安装 Pinia
而不是 Vuex
了,那 Pinia
同时支持 OptionsAPI
和 CompositionAPI
两种语法,为了让我们很轻松的从 Vuex
迁移过来,甚至还提供了一组类似的 map helpers like Vuex[6] (像 Vuex
的 mapState、mapActions
等方法),所以就不用我说什么了吧。。。
那由于我们是 Vue3
项目,所以 Pinia
也会使用 CompositionAPI
语法,没办法,当你用习惯 CompositionAPI
之后,绝对不会再想去用 OptionsAPI
,就是这么香。
初始化项目时我们就已经装了 Pinia
, src/stores
文件夹就是我们的共享状态文件夹,里面有个建项目时创建的 counter.js
文件,直接删掉即可。
接着,在 src/stores
文件夹下创建 system.js
文件,system
模块即项目的系统配置模块,布局相关的状态数据都放在这里即可。布局组件需要共享的状态其实就两个,一个当前布局对象,一个布局列表,OK,写一下:
export const useSystemStore = defineStore('system', () => {
// 当前可切换布局
const currentSwitchlayout = shallowRef(null)
// 可切换布局列表
const switchLayoutList = shallowRef([])
return {
currentSwitchlayout,
switchLayoutList
}
})
如上,其实用 CompositionAPI
语法写起来和平常在 setup
中没有太大区别。
上面我们创建了当前可切换布局对象 currentSwitchlayout
默认是 null
以及可切换布局列表 switchLayoutList
默认是空数组两个响应式属性。可能大家注意到了,我们这里使用的是 shallowRef
而不是 ref
,什么是 shallowRef
?
其实 shallowRef
和 ref
区别不大,shallowRef
是 ref
的浅层作用形式,使用 ref
时,如果传入数据是一个对象,那 Vue
内部会帮我们递归给对象中每个属性不管有多少层都会做响应式处理,而 shallowRef
只会做一层响应式处理,区别就在这。
「PS:」 reactive API
也有对应的 shallowReactive
,作用同上。
那我们这里为什么使用 shallowRef
,其实还是为了避免浪费资源,因为我们把整个布局组件都作为数据源了,如果使用 ref
,它会一直递归给布局组件的各个属性做响应式,而这些我们都不需要,太消耗资源,我们只需浅层响应就可以了。
OK,我们回归主题接着说,接下来我们还需要在 system
模块中写一个初始化布局的方法,如下:
export const useSystemStore = defineStore('system', () => {
// 当前可切换布局
const currentSwitchlayout = shallowRef(null)
// 可切换布局列表
const switchLayoutList = shallowRef([])
// 初始化可切换布局方法
const initSwitchLayout = list => {
if (list && list.length > 0) {
switchLayoutList.value = [...list]
if (!currentSwitchlayout.value) {
currentSwitchlayout.value = switchLayoutList.value[0]
}
}
}
return {
currentSwitchlayout,
switchLayoutList,
initSwitchLayout
}
})
初始化方法接收一个布局列表,方法内容也很简单,就是为 switchLayoutList
赋值,然后判断当前布局组件对象 currentSwitchlayout
是否有值,没有的话给它一个默认值仅此而已。
那么要在哪里进行布局初始化呢?没错就是 SwitchIndex
组件,修改 src/layout/SwitchIndex.vue
文件如下:
<script setup>
import switchLayoutList from '@/layout/switch/index.js'
import { useSystemStore } from '@/stores/system'
const systemStore = useSystemStore()
// 初始化布局列表
systemStore.initSwitchLayout(switchLayoutList)
</script>
<template>
<div class="switch-index">
<component :is="systemStore.currentSwitchlayout" />
</div>
</template>
<style scoped></style>
如上,我们在 SwitchIndex
组件中引入了 pinia system
模块方法 useSystemStore
,此方法返回一个 systemStore
对象,即我们 system
模块的 store
数据对象(就是上面写 useSystemStore
方法时 return
的那些数据集)。
接着使用布局初始化方法传入我们之前引入的布局组件列表 switchLayoutList
给布局组件进行初始化。
其实我们的当前布局对象本身就是布局组件,所以直接在模板中将当前布局组件对象 currentSwitchlayout
传入 component
组件 is
属性中渲染布局即可。
刷新一下浏览器页面,之前是写死的布局组件,换成动态之后看下页面有没有问题,没问题的话我们就可以写切换布局组件了。
切换布局组件 SwitchLayout
切换布局组件还是放在导航条上哈,在 src/layout/components
文件夹下新建 SwitchLayout.vue
文件,先看代码:
<script setup>
import { useSystemStore } from '@/stores/system.js'
const { currentSwitchlayout, switchLayoutList } = storeToRefs(useSystemStore())
// 下拉菜单选中事件
const handleSelect = val => (currentSwitchlayout.value = val)
const { next } = useCycleList(switchLayoutList.value, {
initialValue: currentSwitchlayout
})
</script>
<template>
<a-dropdown @select="handleSelect" trigger="hover" class="layout-dropdown">
<a-button type="text" @click="next()">
<template #icon>
<component
:is="currentSwitchlayout.icon"
class="text-[var(--color-text-1)] text-16px"
></component>
</template>
</a-button>
<template #content>
<a-doption
v-for="item in switchLayoutList"
:key="item.name"
:value="item"
>
<template #icon v-if="currentSwitchlayout.name === item.name">
<icon-material-symbols-check-small class="text-[var(--color-text-1)] text-14px" />
</template>
<template #default>{{ item.title }}</template>
</a-doption>
</template>
</a-dropdown>
</template>
<style scoped>
.layout-dropdown .arco-dropdown-option {
@apply flex justify-end items-center;
}
</style>
其实就是一个布局图标,悬浮展示下拉菜单( ArcoDesign
组件库 a-dropdown
组件)点击可选布局,另外点击布局图标可以按照顺序切换下一个布局这样子。
简单说下,可能大家看到了一个 API
:storeToRefs
,它其实是 Pinia
的一个 API
,Pinia
核心包我们之前也做了自动引入,所以无需手动导入。storeToRefs
和之前我们说过 Vue
中的一个 API
很像,就是 toRefs
,区别就是 toRefs
是针对所有响应式对象,而 storeToRefs
针对的则只是 Pinia
模块返回对象(也可以叫 Pinia
模块实例),直接输出 Pinia
模块返回对象就可以看到,其实这个对象上挂载了很多 Pinia
特有的属性及方法,如下:
const systemStore = useSystemStore()
console.log(systemStore)
上图我们也可以看出 Pinia
模块返回对象也是一个响应式 Reactive
类型响应式对象,所以它不能解构,一解构就丢失响应式了。
而使用 storeToRefs
它可以帮我们只把模块中返回的状态属性转成 Ref
类型然后全塞到一个普通对象中,如下:
const obj = storeToRefs(useSystemStore())
const { currentSwitchlayout, switchLayoutList } = obj
console.log(obj)
console.log(currentSwitchlayout, switchLayoutList)
shallowRef
也是一种 Ref
类型,所以没有转换,你可以尝试写一个 reactive
类型数据返回,就可以看到会被转成了 Ref
。
因为是普通对象所以我们可以直接解构 Pinia
模块里的状态属性,就如上面代码中写的那样,直接解构出了 currentSwitchlayout
和 switchLayoutList
属性,某些时候还是挺方便的。「但是但是但是」,只有状态属性,却没有方法,如果你使用状态的同时还需要使用模块中的方法,你得这样写:
const systemStore = useSystemStore()
const { currentSwitchlayout, switchLayoutList } = storeToRefs(systemStore)
systemStore.initSwitchLayout([...])
嗯,回到组件代码上,代码中我们还用到了 VueUse
库中的 useCycleList
方法,叫 hooks
也行。。。
useCycleList 文档传送门[7]
其实作用就是循环遍历一个数据列表,我们上面写的是:
const { next } = useCycleList(switchLayoutList.value, {
initialValue: currentSwitchlayout
})
其实意思就是循环遍历 switchLayoutList
布局列表,返回数据中我们解构出了一个 next
方法,该方法每次执行都会把布局列表中下一个元素(即布局对象)赋值给 currentSwitchlayout
。
至于 template
模板内容,我们使用了一个下拉菜单组件,展示到页面上的图标就是当前布局的图标,还记得我们写布局组件时给每个布局组件都自定义了一个 icon
属性并赋值了一个图标组件吗?这里直接使用 Vue
内置的 component
组件渲染出来就行。鼠标悬浮到当前布局图标上展示下拉菜单面板,这个面板就遍历一下布局组件列表 switchLayoutList
把对应的布局组件名放上去即可,除此之外还给选中的菜单项在下拉菜单中用一个 iconify
图标 material-symbols:check-small
标注了下(就是个对号图标)。
接下来使用一下 SwitchLayout
组件,两个布局组件都需要使用,放在 Navbar
组件右侧插槽中即可。
修改 DefaultLayout
组件(只展示了修改处代码):
<a-layout-header>
<Navbar>
<template #left> <Logo /> </template>
<template #center> <Menu /> </template>
<template #right>
<SwitchLayout />
<Github />
</template>
</Navbar>
</a-layout-header>
修改 SidebarLayout
组件(只展示了修改处代码):
<a-layout-header>
<Navbar>
<template #left> <Logo /> </template>
<template #right>
<SwitchLayout />
<Github />
</template>
</Navbar>
</a-layout-header>
OK,保存刷新页面,看看效果!
默认布局如下:
边栏布局如下:
Pinia 状态持久化
虽然布局做好了,但是我们点击切换布局之后刷新页面会重新走初始化布局流程,刷新一下布局就变回原来的样子了,所以我们还需要给当前布局对象做个持久化。
其实 Vue3
中我们完全可以写 Hooks
来做一些简单的状态共享(后面会有案例说到),并不一定需要 Pinia
,之所以还使用 Pinia
,是因为 Pinia
有两个好处:
Pinia
可以使用Vue
浏览器插件Vue Devtools
去追踪状态变化Pinia
有插件系统,可以使用插件处理一些东西
那 Pinia
模块状态持久化就可以用插件很便捷的做,这里我们使用一个开源的状态持久化插件(其实自己写也可以,也很简单,自己写的话更随意一点),但是这里就先不写了,麻烦,用现成的吧先,有兴趣的同学可以看看 Pinia
文档中对其插件系统的描述自己写个插件。
插件地址:pinia-plugin-persistedstate[8]
安装:
pnpm i pinia-plugin-persistedstate
// or
npm i pinia-plugin-persistedstate
使用:
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
OK,我们安装好了之后去使用一下此插件,我们是在入口文件 src/main.js
中创建的 Pinia
实例,所以要在这里使用插件,先看下目前的 main.js
文件内容:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import '@/styles/normalize.css'
// 导入Unocss样式
import 'uno.css'
import { getConfig } from '@/config/index'
console.log(getConfig('projectCode'))
console.log(getConfig('projectName'))
console.log(import.meta.env.VITE_APP_ENV)
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
把没有用的代码删一删,然后使用一下 Pinia
插件,修改 main.js
如下:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
// 引入 Pinia 状态持久化插件
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import '@/styles/normalize.css'
// 导入Unocss样式
import 'uno.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 创建 Pinia 实例
const pinia = createPinia()
// 使用 Pinia 状态持久化插件
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
app.mount('#app')
接下来去 src/stores/system.js
文件中做一下配置:
import { getConfig } from '@/config/index'
export const useSystemStore = defineStore(
'system',
() => {
// ...
},
// 新增第三个参数
{
persist: {
key: `${getConfig('appCode')}-pinia-system`,
enabled: true,
storage: window.localStorage,
paths: ['currentSwitchlayout']
}
}
)
如上,我们新增第三个参数对象,该对象中配置 persist
属性为 true
会默认开启该模块所有状态的持久化,显然我们只需要给模块中的当前布局对象 currentSwitchlayout
做持久化就可以了,所以我们需要将 persist
属性配置为一个对象,这个对象有如下几个参数:
key
属性用来配置持久化时缓存数据的key
,默认是模块名。enabled
属性代表是否开启持久化。storage
属性可以配置如何进行持久化存储,可以写成sessionStorage
,默认是使用localStorage
,所以这里我们其实不写也可以。paths
属性即配置模块中需要做持久化的状态列表,不写就是默认缓存该模块中的全部状态。serializer
此对象可以自定义序列化方法,默认使用JSON.stringify/JSON.parse
做序列化。
上面我们的配置是给模块中的 currentSwitchlayout
持久化存储到 localStorage
中。
你以为这样就好了?不,目前还存在一个问题,由于我们要缓存的当前布局对象其实是个 Vue
组件,并且该对象的 icon
也是个 Vue
组件,那只要是组件它就存在 render
方法,如下:
大家知道存储到浏览器缓存我们需要先做序列化(把数据转成 JSON
字符串)对象,pinia
插件源码中默认是使用 JSON.parse/JSON.stringify
做的序列化,这种序列化方式有很多问题:
「使用
JSON.Stringify
转换数据中如果包含function、undefined、Symbol
这几种类型,由于他们都是不可枚举属性,JSON.Stringify
序列化后,这个键值对会消失。」转换的数据中如包含
NaN、Infinity 值(含-Infinity)
,JSON
序列化后的结果会是null
。转换的数据中如包含
Date
对象,JSON.Stringify
序列化之后,其值会变成字符串。转换的数据中如包含
RegExp
引用类型序列化之后会变成空对象。无法序列化不可枚举属性。
无法序列化对象的循环引用,(例如:
obj[key] = obj
)。无法序列化对象的原型链。
所以经过插件帮我们持久化之后,其实我们再拿到的数据中就没有了 render
函数,如下:
上面也说了我们可以自定义序列化方法,但是我们需要吗?完全不需要,因为其实我们只需要把当前布局对象的标识也就是 name
属性存下来就可以了,没必要把渲染函数也存起来,甚至除了 name
属性,其他都无所谓的,存下来即没意义又浪费资源。
所以,我们干脆只缓存 name
属性就好了,那其实,这个持久化插件的 paths
属性配置还支持我们只缓存某个状态对象中的某个属性,那我们修改下配置如下:
import { getConfig } from '@/config/index'
export const useSystemStore = defineStore(
'system',
() => {
// ...
},
// 新增第三个参数
{
persist: {
key: `${getConfig('appCode')}-pinia-system`,
enabled: true,
storage: window.localStorage,
// 此处修改
paths: ['currentSwitchlayout.name']
}
}
)
这样的话,我们缓存下来的当前布局对象中就只有 name
属性了,如下:
但是如此一来,我们就需要在布局初始化方法中做一下处理了,修改 src/stores/system.js
中布局初始化方法如下:
const initSwitchLayout = list => {
if (list && list.length > 0) {
switchLayoutList.value = [...list]
if (!currentSwitchlayout.value) {
currentSwitchlayout.value = switchLayoutList.value[0]
} else {
// 通过name属性找到布局对象并赋值,因为持久化数据中没有组件渲染的render函数
currentSwitchlayout.value = switchLayoutList.value.find(
item => item.name === currentSwitchlayout.value.name
)
}
}
}
OK,到此我们就缓存了当前布局对象,每次刷新页面的时候会重新初始化布局,如果缓存中存在布局对象,就会通过 name
属性在布局列表中找到该布局对象并重新赋值。
保存刷新页面,切换一下布局再次刷新试试吧!!
终于把布局写完了。。。
写在最后
本文我们主要是接上文把多布局切换给写完了,由于还没有写功能,暂时还未发布线上预览版本,截止本文的代码已经打了 Tag
发布,可下载查看:
👉🏻 toolsdog tag v0.0.2-dev[9]
👉🏻 项目 GitHub 地址[10]
谢阅,如有错误请评论纠正,有什么疑问或者不理解的地方都可以私信咨询我,由于不经常写实战文章,也为了不同程度同学都可以看下去,文章可能稍微有些啰嗦,见谅,再次欢迎关注专栏!
如果您觉得文章不错,记得点赞,欢迎关注公众号👇
Reference
[1]
项目 GitHub 地址: https://github.com/isboyjc/toolsdog
[2]Vite Glob: https://cn.vitejs.dev/guide/features.html#glob-import
[3]fast-glob: https://github.com/mrmlnc/fast-glob
[4]resolve.alias 选项: https://cn.vitejs.dev/config/shared-options.html#resolve-alias
[5]Import Reflection: https://github.com/tc39/proposal-import-reflection
[6]map helpers like Vuex: https://vuex.vuejs.org/guide/state.html#%E5%9C%B0%E5%9B%BE%E7%8A%B6%E6%80%81%E5%8A%A9%E6%89%8B
[7]useCycleList 文档传送门: https://vueuse.org/core/usecyclelist/#usecyclelist
[8]pinia-plugin-persistedstate: https://github.com/prazdevs/pinia-plugin-persistedstate
[9]toolsdog tag v0.0.2-dev: https://github.com/isboyjc/toolsdog/releases/tag/v0.0.2-dev
[10]项目 GitHub 地址: https://github.com/isboyjc/toolsdog