多布局实战
准备工作
多布局本身其实很简单,更重要的其实还是我们写的过程中遇到的一些技术点和细节,那接下来我们开始安排!!!
咱们先写一个可以动态切换的布局,首先,在项目 src
目录下创建一个布局文件夹 layout
。
接下来我们在 src/layout
文件下创建一个可切换布局的入口组件 SwitchIndex.vue
,内容和上面所写的差不多,如下:
<script setup></script>
<template>
<div class="switch-index">
<!-- <component :is="" /> -->
</div>
</template>
<style scoped></style>
component
组件我们暂且注释,因为目前还没有布局组件。
接下来我们创建两个布局组件,由于我们要把这两种布局的选择权交给用户,所以我们在 layout
文件夹下新建一个 switch
文件夹,把可以切换的这两个布局组件放到里面统一管理下。
创建可切换的默认布局文件:layout/switch/DefaultLayout.vue
<script setup></script>
<template>
<div>DefaultLayout</div>
</template>
<style scoped></style>
创建可切换的边栏布局文件:layout/switch/SidebarLayout.vue
<script setup></script>
<template>
<div>SidebarLayout</div>
</template>
<style scoped></style>
这两个布局的预期如下:
其实就是两种很普通很常见的布局,一种是有侧边栏的 SidebarLayout
( 下文叫它边栏布局)、一种无侧边栏的 DefaultLayout
(下文叫它默认布局),大家先了解下要写的样子即可。
OK,接下来我们先完善两种布局然后再写动态切换。
默认布局组件 DefaultLayout
上面我们已经创建好了 DefaultLayout
组件,那先来把它用上。
修改一下 DefaultLayout
组件,如下:
<script setup></script>
<template>
<div>
DefaultLayout
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</div>
</template>
<style scoped></style>
然后直接在 SwitchIndex
组件引入使用这个布局,上文中我们虽然配置了组件自动引入,但是并没有配置 layout
目录,所以 layout
文件夹下的组件是不会被自动引入的,那我们还需要现在 vite.config.js
配置文件中把 layout
目录加上,如下:
export default defineConfig(({ mode }) => {
return {
// ...
plugins: [
// ...
Components({
// 新增 'src/layout' 目录配置
dirs: ['src/components/', 'src/view/', 'src/layout'],
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
resolvers: [
ArcoResolver({
sideEffect: true
}),
VueUseComponentsResolver(),
VueUseDirectiveResolver(),
IconsResolver({
prefix: 'icon',
customCollections: ['user', 'home']
})
]
}),
]
}
})
OK,然后我们就可以直接在 SwitchIndex
组件中使用 DefaultLayout
布局组件了,我们写的组件是匿名组件,默认组件名即文件名,如下:
<script setup></script>
<template>
<div class="switch-index">
<!-- <component :is="" /> -->
<DefaultLayout />
</div>
</template>
<style scoped></style>
然后,我们需要修改下路由文件 router/index.js
,把 SwitchIndex
组件作为一级路由组件,那此路由下的所有子路由就都可以使用我们的布局了:
routes: [
{
path: '/',
name: 'Layout',
component: () => import('@/layout/SwitchIndex.vue'),
redirect: '/',
children: [
{
path: '/',
name: 'HomePage',
meta: {
title: 'TOOLSDOG'
},
component: () => import('@/views/HomePage.vue')
}
]
}
]
保存看页面:
如果你的运行效果也同上,那就已经用上了布局组件,到此都是 OK 的,接下来就可以调整布局 UI
样式了!
码一下页面布局
上面也说了,我们的默认布局其实很简单,就是很普通的上中下三分布局。
上文我们已经装好了 ArcoDesign
,同样也配置了其组件自动引入,这里我们直接使用 ArcoDesign
的 layout
布局组件做一个常规的上中下三分布局即可,需要注意的是,我们给 Navbar
导航部分加了一个固钉组件 a-affix
,用于固定在页面顶部。
PS: ArcoDesign
组件均以子母 a
开头。
修改 DefaultLayout
组件,如下:
<script setup></script>
<template>
<div>
<div class="default-layout">
<a-layout>
<a-affix>
<a-layout-header> Navbar </a-layout-header>
</a-affix>
<a-layout-content>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</a-layout-content>
<a-layout-footer> Footer </a-layout-footer>
</a-layout>
</div>
</div>
</template>
<style scoped></style>
接着我们简单调整一下样式。
注意,CSS
这里我们接上文的配置,使用的是原子化 CSS
引擎(叫框架也行哈) UnoCSS ,不太了解的可以看下文档教程,如果你也跟着上文配置了它并下载了 UnoCSS for VSCode
插件,那就跟着我写,配合插件提供悬浮提示原生样式,跟着我这边写一写其实就会了,很简单,毕竟常用的 CSS
样式也就那些,语法记住就 OK 了,当然,你如果不习惯或者没有配置此项,也无所谓,因为都是些基础 CSS
样式,直接用 CSS
写一下也可以,咱就是图个省事儿 ~
还有一点,由于我们想保证风格统一,还有就是后面想搞一下黑白模式切换,对于一些颜色、字体、尺寸方面,我这边直接全使用了 ArcoDesign
抛出的 CSS
变量,没有自己去自定义一套基础变量,没错,同样也是图省事儿 ~
其实目前正经的 UI
库都会抛出其统一设计的颜色变量(您要是说 ElementUI
,OK,当我没说),那对于我们这个项目来说,ArcoDesign
自身的这些变量已经够了,如果你是真的写正式项目,建议遵循自家 UI
的设计风格,搞一套自己的基础 CSS
变量来用(对一些有主题、颜色切换需求的同学来说,没有此需求写死颜色即可),同时如果项目中使用了开源 UI
库,你还需要定制一下 UI
库样式以匹配自家 UI
设计风格,目前正经的开源 UI
库对定制主题这块那都没得说,清晰明了且 Easy (您要是还提 ElementUI
,再次当我没说,苦 ElementUI
良久 😄)
那说了这么多,我们先来看下 ArcoDesign
的颜色变量吧!👉🏻 ArcoDesign CSS变量传送们
如上,我们直接使用对应的 CSS
变量即可,这样后期我们处理黑白模式时,直接可以用 UI 库自带的黑暗模式。
OK,我们简单的写下布局样式
<script setup></script>
<template>
<div>
<div class="default-layout">
<a-layout class="min-h-[calc(100vh+48px)]">
<a-affix>
<a-layout-header> Navbar </a-layout-header>
</a-affix>
<a-layout-content>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</a-layout-content>
<a-layout-footer> Footer </a-layout-footer>
</a-layout>
</div>
</div>
</template>
<style scoped>
@apply
.default-layout :deep(.arco-layout-header),
.default-layout :deep(.arco-layout-footer),
.default-layout :deep(.arco-layout-content) {
@apply text-[var(--color-text-1)] text-14px;
}
.default-layout :deep(.arco-layout-header) {
@apply w-full h-58px overflow-hidden;
@apply bg-[var(--color-bg-3)] border-b-[var(--color-border-1)] border-b-solid border-b-width-1px box-border;
}
.default-layout :deep(.arco-layout-content) {
@apply flex flex-col justify-center items-center;
@apply bg-[var(--color-bg-1)] relative;
}
.default-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;
}
</style>
如上,我们给 Navbar
一个下边框以及 58px
高度,给 Footer
一个上边框,同时,我们给 Navbar、Content、Footer
加了不同级别的背景颜色(AcroDesign
背景色 CSS
变量),最后我们为了让 Footer
首页不显示出来,给 a-layout-content
组件加了一个最小高度,使用视口高度 100vh
减去 Navbar
的高度就是该组件的最小高度了!
再说一次:相信大家无论用没用过 UnoCSS
都可以看懂写的样式是啥意思,@apply
是 UnoCSS
在 style
标签中的写法,那在 HTML
标签中 class
属性中可以直接去写 UnoCSS
样式,如上面我们在 a-layout-content
标签中写的那样,如果装了插件,悬浮上去会有原生样式的提示,如下:
一切都没问题的话,我们的项目保存运行就如下所示了(Footer
需要滚动查看)
导航组件 Navbar
简单的布局组件做好了,接下来我们慢慢填充布局内容,先来做 Navbar
组件。
上文我们已经看了我画的草图,好吧,再看一遍
我们想要实现的两种布局都有导航栏,唯一的区别就是菜单的位置,所以我们这里把导航栏中的各个元素单独拆分作为独立的组件,使用插槽的方式在 Navbar
组件去使用,Navbar
组件相当于导航栏的一个布局组件。这样导航栏组件在哪种布局中都是可用的,避免重复代码。
好,开始做了,在 src/layout
文件夹下新建 components
文件夹存放布局相关的公共组件。
在 src/layout/components
文件夹下创建 Navbar.vue
文件,内容如下:
<template>
<div class="w-full h-full flex px-20px box-border">
<div class="h-full flex">
<slot name="left" />
</div>
<div class="h-full flex-1">
<slot />
<slot name="center" />
</div>
<div class="h-full flex flex-shrink-0 items-center">
<div>
<slot name="right" />
</div>
</div>
</div>
</template>
如上,我们给 Navbar
组件做了三个具名插槽,采用左中右这种结构并使用 flex
布局将中间的插槽撑满,同时我们也将默认插槽放在了中间的插槽位置,这样默认会往布局中间填充内容。
注意,导航区域的高度在布局组件中已经固定写死 58px
了,导航组件这里我没有设置高度,让它自己撑满就行了。因为在任何布局下,导航栏高度是相同的。
我们在 DefaultLayout
布局组件中的 a-layout-header
标签中使用一下导航条组件,同样无需引入直接使用,如下:
<a-layout-header>
<Navbar>
<!-- left插槽 -->
<template #left></template>
<!-- 默认插槽和center插槽,默认插槽可不加template直接写内容,作用同center插槽 -->
<template #center></template>
<!-- right插槽 -->
<template #right></template>
</Navbar>
</a-layout-header>
由于插槽中没有写内容,所以页面上没有东西,导航条壳子搞好了,接下来我们开始填充内容。
左侧插槽我们写一个 Logo
组件,中间插槽就是导航菜单 Menu
组件了,右侧插槽则是一些页面小功能组件,暂定为 Github
组件(用来跳转 Github
的)、做布局切换的 SwitchLayout
组件、切换模式的 SwitchMode
组件,暂时就这些。
OK,接下来我们依次写一下这些组件。
Logo 组件
在 src/layout/components
文件夹下新建 Logo.vue
文件,写入如下内容:
<script setup>
const route = useRoute()
const title = useTitle()
watchEffect(() => {
title.value = route.meta.title || 'TOOLSDOG'
})
</script>
<template>
<div
class="h-full flex items-center text-16px font-700 text-shadow-sm cursor-pointer"
@click="$router.push('/')"
>
<div
class="w-36px h-36px rounded-[50%] flex justify-center items-center mr-2px cursor-pointer"
hover="bg-[var(--color-fill-1)]"
>
<icon-ri-hammer-fill class="text-18px" />
</div>
{{ title }}
</div>
</template>
然后把 Logo
组件填充到我们 DefaultLayout
组件下 Navbar
组件的 左侧插槽中即可:
<Navbar>
<template #left>
<Logo />
</template>
</Navbar>
运行如下:
OK,解释下 Logo
组件,其实就是一个图标加上一个路由标题。
样式我就不解释了,跟着一块写的同学有什么不知道的样式就用编译器鼠标悬浮看一下原生 CSS
是什么即可,老同学应该都能大致看懂啥样式。
那关于 logo
我们直接在 iconify 图标库中找了一个图标用,我们这里用的是 ri:hammer-fill
图标,关于 icon
的配置以及使用这块上文已经说过了,不了解的请看上文,下文中再使用该库图标我就直接写个图标名不再解释了哈。另外,点击 logo
会跳转首页。
标题呢,我们直接用 Vue
的 watchEffect
方法监听了当前路由 meta
对象中的 title
属性并赋值给响应式变量 title
,这样后面我们每次跳转到某个功能页面时, Logo
旁边的文字信息以及浏览器 Tab
页签都会变成该页面路由中配置的 title
信息。
useRoute
方法是 Vue3
组合式 API
,它返回一个当前页面路由的响应式对象,同样 Vue
的核心 API
我们都做了自动引入,所以这里没有引入。
watchEffect
也是 Vue3
的 API
,该方法会立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。简单来说就是只要该回调中有响应式数据,这些响应式数据的依赖发生改变时就会重新执行此回调,默认会立即执行一次。那在这个场景下就比 watch
好用多了。
那响应式变量 title
是怎么来的呢?代码中我们使用了 useTitle
方法,同样没有引入,它不是 Vue
的 API
,其实,它是 VueUse
库中的一个方法,VueUse useTitle 传送门,在上文我们已经给 VueUse
这个库的方法做了自动引入,所以可以直接用,该方法会返回一个响应式变量,这个响应式变量在改变时会自动改变我们的网页标题,注意这里的标题指的是浏览器 Tab
标签中的标题,如下:
如上图,既然已经拿图标当了 logo
,那一不做二不休,把 Tab
签中的 ico
图标也换了吧,就是上图中默认的 Vue
图标,再次去 iconify
图标库在线网站中找到我们使用的 logo
图标,下载个 png
下来,然后找个免费在线的图片 png
转 ico
格式网站转一下格式(百度、谷歌找),把转换后的文件名改成 favicon.ico
,替换掉项目根目录下的 public/favicon.ico
文件即可,然后我们浏览器中的 Tab ico
图片就换好了,如下:
OK,到此 Logo
组件就搞好了。
Github 跳转小组件
写 Github
跳转组件之前我们需要在 config/index.js
文件中配置一下 GitHub Url
地址,方便日后我们在项目中使用或统一修改,Config
配置文件具体内容看文章开头的代码或者看上文配置讲解。
在 config/index.js
文件的 configSource
对象中新增一个 github
属性,属性值写上我们的项目地址,如下:
const configSource = {
// ...
github: 'https://github.com/isboyjc/toolsdog'
}
Github
跳转组件很简单,就是字面意思,我们搞一个图标放上去,然后能够点击打开一个新标签跳转到项目的 GitHub
地址就行了。在 src/layout/components
文件夹下新建 Github.vue
文件,写入如下内容:
<script setup>
import { getConfig } from '@/config'
const openNewWindow = () => window.open(getConfig('github'), '_blank')
</script>
<template>
<a-button type="text" @click="openNewWindow">
<template #icon>
<icon-mdi-github class="text-[var(--color-text-1)] text-16px" />
</template>
</a-button>
</template>
GitHub
的图标我们用的 iconify
图标库中 mdi:github
图标,这个就没啥需要说的了,什么?你问我为啥不直接用 a
标签或者 UI
库的 Link
组件?答案是这个组件库按钮悬浮的交互很好看~
接着我们去使用一下,把 Github
组件填充到默认布局 DefaultLayout
组件下 Navbar
组件的右侧插槽中即可:
<Navbar>
<template #right>
<Github />
</template>
</Navbar>
运行如下:
SwitchLayout
组件、SwitchMode
组件我们得放到后面再说,接下来我们来写导航菜单组件。
菜单组件 Menu
由于我们目前只有一个路由,就是首页,还没有其他正八经儿的功能页面,所以,这里我们要先写一个路由页面。
那布局写完之后我们第一个功能应该是要写正则可视化校验功能,这里我们就提前给它把路由以及页面定义好吧!
首先,在 src/views
文件夹下新建 RegularPage.vue
文件作为正则校验页面组件:
<script setup></script>
<template>
<div>正则在线校验</div>
</template>
<style scoped></style>
接着我们要配置一下路由,注意,由于现在写的页面路由它同时还是个菜单,所以我们把这些可以作为菜单的路由单独写一个路由文件,这样我们后期可以直接可以导出当作菜单项配置用。
在 src/router
文件夹下新建 menuRouter.js
文件,导出一个菜单路由数组,如下:
export const menuRouter = []
在 src/router/index.js
中使用一下:
import { createRouter, createWebHistory } from 'vue-router'
// 导入菜单路由
import { menuRouter } from './menuRouter'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'Layout',
component: () => import('@/layout/SwitchIndex.vue'),
redirect: '/',
children: [
{
path: '/',
name: 'HomePage',
meta: {
title: 'TOOLSDOG'
},
component: () => import('@/views/HomePage.vue')
},
// 使用菜单路由
...menuRouter
]
}
]
})
export default router
OK,接下来我们配置菜单路由数组,由于我们将来可能会写到很多不同种类的功能,所以我们使用多级路由的方式给这些页面做个分类,正则可视化校验属于开发工具类,所以我们给它一个 devtools
的父级路由,另外,在菜单路由中,每个父级菜单我们给他在 meta
对象中添加一个 icon
属性,然后导入一个图片组件作为对应 icon
的值,这样做的目的是将来要在导航菜单中给每个分类的菜单都加个图标。
OK,修改 menuRouter.js
文件如下:
import IconMaterialSymbolsCodeBlocksOutline from '~icons/material-symbols/code-blocks-outline'
export const menuRouter = [
{
path: 'devtools',
name: 'DevTools',
meta: {
title: '开发工具',
icon: markRaw(IconMaterialSymbolsCodeBlocksOutline)
},
redirect: { name: 'RegularPage' },
children: [
{
path: 'regular',
name: 'RegularPage',
meta: {
title: '正则在线校验'
},
component: () => import('@/views/RegularPage.vue')
}
]
}
]
如上,我们如果想要访问此页面,只需要访问 /devtools/regular
路由即可,那可能有些人注意到该配置中的父级路由的重定向中我们使用的是 name
来做的重定向,这里不用 path
是为了更安全,这个安全指的是由于我们单独抽离出了这个菜单路由模块,虽然目前是在把它引入并写在了 /
路由下,但是将来万一改变了一级路由,那整体的 path
都会改变,而使用 name
字段重定向就不存在这个问题,我们只需要注意下各个路由的 name
字段配置不重复即可。
注意,我们上面手动引入了 iconify
图标库中的图标,可能有人会问不是做了 iconify
的自动引入吗?为什么还要手动去引入?其实,组件的自动引入是靠解析识别组件模板中引入的组件再做的匹配,而这里我们没有在组件模板中使用,而是在 JS
中直接使用的,包括我们做项目经常会做的菜单配置,都是只存一个图标名,它是靠我们在运行时通过图标名去匹配组件,这是一个运行时动态的过程,开发时是做不了自动引入的,这类情况我们需要手动引入一下。
还有一个大家可能发现了,在写入图标组件时,我们使用了 Vue3
的 markRaw
方法,markRaw
方法会标记一个对象,使其不能成为一个响应式对象,因为后面我们会将整个菜单路由数据作为一个响应式对象传入菜单组件渲染,那如果我们在这个数据中存在 Vue
组件,将会造成一些不必要的性能开销,所以这里我们直接使用 markRaw
对象给它标记下,使该对象不会被递归解析成响应式对象即可。
接下来在浏览器访问下 /devtools/regular
路由,看看效果,同下即没问题:
已经有菜单了数据了,我们去写菜单 Menu
组件。先理一下思路,通常组件库中会有 Menu
组件,当然 ArcoDesign
也不例外,我们可以直接拿过来封装一层去使用。封装什么呢?虽然我们目前只有一个路由,但是我们在应该要考虑到多级的情况,那其实解决办法就是做一个可以无限递归的菜单组件。
OK,在写菜单组件之前,路由菜单数据还需要处理下,我们写个递归方法拼接一下每个菜单的完整路由,并把每个路由菜单中的 meta
对象压平到菜单里,方便我们后面使用,还是在 src/router
文件夹下的 menuRouter.js
文件,新增一个 menuRouterFormat
方法处理菜单数据并将处理后的数据导出,如下:
export const menuRouter = [
// ...
]
/**
* @description 菜单路由数组 format
* @param { Array } router 路由数组
* @param { String } parentPath 父级路由 path
* @return { Array }
*/
export const menuRouterFormat = (router, parentPath) => {
return router.map(item => {
// 拼接路由,例:'devtools' -> '/devtools' 'regular' -> '/devtools/regular'
item.path = parentPath ? `${parentPath}/${item.path}` : `/${item.path}`
// 存在 children 属性,且 children 数组长度大于 0,开始递归
if (item.children && item.children.length > 0) {
item.children = menuRouterFormat(item.children, item.path)
}
return Object.assign({}, item, item.meta || {})
})
}
// 解析后 路由菜单列表
export const menuRouterFormatList = menuRouterFormat([...menuRouter])
在 src/layout/components
文件夹下新建 Menu/index.vue
文件:
<script setup>
import { menuRouterFormatList } from '@/router/menuRouter.js'
// 菜单数据
const menuList = ref(menuRouterFormatList)
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="horizontal"
:accordion="true"
>
<MenuItem v-for="menu of menuList" :key="menu.path" :menu="menu" />
</a-menu>
</template>
<style scoped>
.menu.arco-menu-horizontal {
@apply bg-[var(--color-bg-3)];
}
.menu.arco-menu-horizontal :deep(.arco-menu-icon) {
@apply mr-4px leading-[1.2] flex-none align-inherit;
}
.menu.arco-menu-horizontal :deep(.arco-menu-pop-header) {
@apply bg-transparent;
}
.menu.arco-menu-horizontal :deep(.arco-menu-pop-header):hover {
@apply bg-[var(--color-fill-2)];
}
.menu :deep(.arco-menu-overflow-wrap) {
@apply flex justify-end;
}
</style>
简单说一下上面代码,我们先导入了之前 menuRouter.js
中的菜单解析后的数据 menuRouterFormatList
对菜单数据进行了一个初始化。
再来看模板,我们用到了 arcoDesign
组件库的 a-menu
组件。
accordion
开启手风琴效果。mode
属性是设置菜单模式(水平或垂直),我们给它设置成水平即horizontal
。menuItemClick
子菜单点击时触发,该回调参数为key
。selected-keys
选中的菜单项key
数组。auto-open-selected
默认展开选中的菜单。
这块还是建议看下组件库文档哈。
子菜单点击方法中我们直接使用 router.push
传入 key
跳转路由即可。那对于 selectedKeys
,我们直接用计算属性 computed
返回了当前路由对象 route
中 path
属性值组成的数组,这样每次路由改变该方法就会被触发,selectedKeys
数组值就会响应式的改变。key
值即子菜单的唯一标识,下面我们写子菜单组件时会将每个子菜单的 key
设置为菜单对应的路由 path
。
上面我们用到了一个还没有创建的 MenuItem
组件,它其实就是我们的子菜单组件,接下来我们还是在 src/layout/components/Menu
文件夹下新建 MenuItem.vue
文件,内容如下:
<script setup>
const props = defineProps({
menu: {
type: Object,
required: true
}
})
const { menu } = toRefs(props)
</script>
<template>
<template v-if="!menu.children">
<a-menu-item :key="menu.path">
<template #icon v-if="menu?.icon">
<component :is="menu?.icon"></component>
</template>
{{ menu.title }}
</a-menu-item>
</template>
<a-sub-menu v-else :key="menu.path" :title="menu.title">
<template #icon v-if="menu?.icon">
<component :is="menu?.icon"></component>
</template>
<MenuItem
v-for="menuChild of menu.children"
:key="menuChild.path"
:menu="menuChild"
/>
</a-sub-menu>
</template>
<style scoped></style>
子菜单组件 MenuItem
如上,首先,它是一个完全受控组件,就是字面意思,这个组件完全依靠父组件传入的数据,它只做渲染使用,所以叫完全受控组件。
我们在 Menu
组件中遍历了菜单数据,并给每个子菜单组件传入了一个 menu
属性即对应的菜单信息对象。
在子菜单中,我们使用 defineProps
定义了一个 menu
属性,Vue3
有个 toRefs
方法可以将响应式对象转换成普通对象,但是这个普通对象中的属性会变成一个个响应式属性。正常情况下我们需要使用 props.menu
才能调用父组件传入的属性并且该属性是单向传递,也就是我们不能在子组件中修改它,但是我们可以使用 Vue3
的 toRefs
方法将整个 props
对象解构,解构出的每个属性值都是响应式的对象,并且在子组件中直接修改该值可以同步父组件。我们这里使用 toRefs
只是为了方便,并没有在子组件去修改父组件传入的值,这里简单提一下可以这样做,算是一个小技巧吧,不然我们想要在子组件修改父组件传入的值,只能先定义一个响应式数据接收传入的值,再在该响应值改变时,emit
出去一个事件,那在 Vue3
中使用 emit
还需要使用 defineEmits
去定义一下事件名,还是比较麻烦的,不理解没关系,后面有案例会用到这种方法,到时候还会说!!!
OK,接着说,我们拿到了父组件中传入的 menu
对象,接下来看组件模板,首先我们在模板中校验了一下传入的 menu
对象中是否存在 children
属性。
如果不存在 children
属性,那它就是一个菜单,我们直接使用 a-menu-item
组件写入子菜单并把唯一标识 key
设置为 menu
对象的 path
属性即可(菜单路由),该组件有两个插槽,默认插槽我们写入菜单标题 menu.title
,接着校验了一下传入的 menu
对象中有没有 icon
属性,有的话就在 icon
插槽中使用 component
直接渲染 icon
组件(这里传入的 icon
属性值必须为组件对象,其实目前我们只是在带有子级的菜单传入了 icon
组件)。
如果存在 children
属性,那它就是一个带子级的菜单项,我们使用 a-sub-menu
组件去渲染它,和之前不一样的是,带子级的菜单组件 a-sub-menu
我们直接把菜单标题传入该组件的 title
属性就可以了,icon
还是一样的操作,划重点,那既然是带子级的菜单,我们还需要把它的子级再渲染出来,那它的子级可能还有子级(无限套娃),这里我们只需要递归的调用一下组件自身就可以无限的渲染直到没有子级数据。那 Vue3
的递归组件(组件自身调用自身),写法和 Vue2
其实也没太大差别,我们这里由于配置了自动引入,所以都不需要引入,直接像在外部调用该组件一样,直接调用自己就行,这里其实涉及到匿名组件和具名组件的问题,我们这个组件是个匿名组件,但是在匿名状态下,我们可以直接将该组件文件名作为组件名调用即可,那如果组件文件名是 index.vue
,Vue
会自动将上层的文件夹名作为组件名调用,注意,仅仅是调用而已,其他的都操作不了,想要通过组件名操作一个组件,还是得具名,Vue3
组合式 API
组件的具名方法下文有案例会提到,这里不多说。如上代码,还是像父组件 Menu
中调用 MenuItem
组件一样调用自己调自己就行了,到此我们的菜单组件就写好了。
在 Menu
组件中还写了一些样式,其实就是简单调整一下组件库中菜单组件的样式,具体样式这里就不多说了,因为会的能看懂,不会的自己跟着实践一下就懂了(太占篇幅)。
OK,写完了使用一下看看效果,把 Menu
组件填充到默认布局 DefaultLayout
组件下 Navbar
组件的中间插槽或者默认插槽中即可:
<a-layout-header>
<Navbar>
<!-- ... -->
<!-- 默认插槽和center插槽,默认插槽可不加template直接写内容,作用同center插槽 -->
<template #center>
<Menu />
</template>
<!-- ... -->
</Navbar>
</a-layout-header>
看下页面效果:
到此默认布局的导航组件就写的差不多了,下面来写下页尾组件!
页尾组件 Footer
页尾区域我们在布局组件中没有设置高度,因为页尾的高度不固定,可能随时会在页尾加个内容啥的,所以就让它随组件内容高度自由撑开吧。。
由于页尾需要展示一些个人信息,所以我们统一把这些数据都放在 config/index.js
中的基础配置对象里,Config
文件内容配置以及使用上文已经说过了,不再描述,这里的数据没什么重要的,不需要脱敏,如需脱敏,可以配合 env.local
环境变量配置文件去做,env.local
环境变量配置文件默认会被 git
忽略,写入该环境变量文件,并在 config
文件中引入环境变量即可,关于环境变量的配置也请看上文或者直接看文档 👉🏻 Vite 中 env 配置文档
// ...
const configSource = {
// ...
// 个人配置
me: {
name: 'isboyjc',
// 公众号
gzhName: '不正经的前端',
gzhUrl: 'https://qiniu.isboyjc.com/picgo/202210030159449.jpeg',
// github
github: 'https://github.com/isboyjc'
}
}
我们在 src/components
文件夹下新建 Footer.vue
文件,Footer
组件比较简单,暂时也没写太多内容,这里我就不会多描述了,直接看代码吧。
<script setup>
import { getConfig } from '@/config'
</script>
<template>
<div class="w-1200px flex justify-between items-center min-h-48px">
<div class="w-full h-48px flex justify-center items-center">
<a-trigger
position="top"
auto-fit-position
:unmount-on-close="false"
:popup-offset="10"
:show-arrow="true"
>
<a-link :href="getConfig('me.gzh')">
{{ getConfig('me.gzhName') }}
</a-link>
<template #content>
<a-image width="100" :src="getConfig('me.gzh')" />
</template>
</a-trigger>
<span> Copyright ⓒ 2022</span>
<a-link :href="getConfig('me.github')" target="_blank">
{{ getConfig('me.name') }}
</a-link>
<a-link href="https://beian.miit.gov.cn/" target="_blank">
{{ getConfig('icp') }}
</a-link>
</div>
</div>
</template>
在布局文件中使用一下:
在 DefaultLayout
默认布局组件中的 a-layout-footer
组件标签中使用一下 Footer 组件,同样无需引入直接使用,如下:
<a-layout-footer>
<Footer />
</a-layout-footer>
保存后浏览器中看看效果吧:
默认布局到此告一段落,目前首页是我们上文留下的示例代码,有点丑,我们稍微修改一下,让它有个网站的样子。
首页修改 HomePage
打开 src/views/HomePage.vue
文件,清空当前内容,写入下面代码:
<template>
<div class="w-full flex justify-center items-center flex-1">
<div class="w-full h-300px flex justify-center items-center">
<div
class="w-150px h-150px rounded-[50%] bg-[var(--color-fill-1)] flex justify-center items-center"
>
<icon-ri-hammer-fill class="text-52px" />
</div>
</div>
</div>
</template>
不再细说了,因为没啥东西,还是那个 logo
图标放上去,加了点样式,给它放到页面中间就行了,暂时先这样,保存查看效果如下:
边栏布局组件 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
呢?
答案当然是有的,就是 i
,大家可以先简单看下文档有个初步了解,👉🏻 Vite Glob,其实之前旧版本中还有一个 i
方法,不过目前已经废弃了,不再讨论。
那接下来我们就用 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
简单介绍下 i
方法,Vite
支持使用特殊的 i
函数从文件系统导入多个模块。
此方法第一个参数可以是字符串也可以是数组,分别代表一个或多个匹配方式,由于这个方法 Vite
是基于 fast-glob 包来实现的,所以,第一个参数匹配的语法也和它一致,这里我列一下基础语法,高级语法我们也不咋能用到,不需要关注,用到的时候上面链接点击就是文档:
- 星号 (
.
) ── 匹配除斜杠(路径分隔符)、隐藏文件(以 开头的名称)之外的所有内容。 - 双星或单星 (
*
) ── 匹配零个或多个目录。 - 问号 (
?
) ── 匹配除斜杠(路径分隔符)之外的任何单个字符。 - 序列 (
[seq]
) ── 匹配序列中的任何字符。
除此之外,它还支持反匹配模式,也就是在匹配字串前加个感叹号( !
)即代表忽略一些文件。
在使用 Glob API
还需要注意一下:
- 这只是一个
Vite
独有的功能而不是一个Web
或ES
标准 - 该
Glob
模式会被当成导入标识符:必须是相对路径(以./
开头)或绝对路径(以/
开头,相对于项目根目录解析)或一个别名路径( resolve.alias 选项)。 - 还需注意,所有
i
的参数都必须以字面量传入。不可以在其中使用变量或表达式。mport.meta.glob
这些其实仔细过一遍文档的同学可以发现文档中就有,奈何就是有些人它不喜欢看文档。。。
我们这里的匹配使用比较简单,本身其实也用不到太复杂的匹配,'./*.vue'
,它代表当前目录下所有以 .vue
结尾的文件。
再来看 Glob API
的第二个参数,第二个参数是一个对象,存在几个属性,如下:
as
── 特殊的字符串类型,指定导入url
的导入类型,即参照Import Reflection
语法对模块导入进行的一个断言。eager
── 布尔类型,导入为静态或动态,默认false
即默认动态,为true
时导入为静态import
── 字符串类型,仅导入指定的导出。设置为‘default’
以导入默认导出。query
── 自定义查询exhaustive
── 布尔类型,搜索node_modules/
和隐藏目录(例如.git/
)中的文件,默认false
关闭,开启可能会影响性能。
**PS:**有人可能会问 Import Reflection 是什么?其实它是 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 (像 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
也行。。。
其实作用就是循环遍历一个数据列表,我们上面写的是:
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
安装:
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
发布,可下载查看:
👉🏻 项目 GitHub 地址