Skip to content
快看这页儿写了啥...

多布局实战

准备工作

多布局本身其实很简单,更重要的其实还是我们写的过程中遇到的一些技术点和细节,那接下来我们开始安排!!!

咱们先写一个可以动态切换的布局,首先,在项目 src 目录下创建一个布局文件夹 layout

接下来我们在 src/layout 文件下创建一个可切换布局的入口组件 SwitchIndex.vue,内容和上面所写的差不多,如下:

html
<script setup></script>

<template>
  <div class="switch-index">
    <!-- <component :is="" /> -->
  </div>
</template>

<style scoped></style>

component 组件我们暂且注释,因为目前还没有布局组件。

接下来我们创建两个布局组件,由于我们要把这两种布局的选择权交给用户,所以我们在 layout 文件夹下新建一个 switch 文件夹,把可以切换的这两个布局组件放到里面统一管理下。

创建可切换的默认布局文件:layout/switch/DefaultLayout.vue

html
<script setup></script>

<template>
  <div>DefaultLayout</div>
</template>

<style scoped></style>

创建可切换的边栏布局文件:layout/switch/SidebarLayout.vue

html
<script setup></script>

<template>
  <div>SidebarLayout</div>
</template>

<style scoped></style>

这两个布局的预期如下:

https://qiniu.isboyjc.com/picgo/202211020031436.png

其实就是两种很普通很常见的布局,一种是有侧边栏的 SidebarLayout( 下文叫它边栏布局)、一种无侧边栏的 DefaultLayout(下文叫它默认布局),大家先了解下要写的样子即可。

OK,接下来我们先完善两种布局然后再写动态切换。

默认布局组件 DefaultLayout

上面我们已经创建好了 DefaultLayout 组件,那先来把它用上。

修改一下 DefaultLayout 组件,如下:

html
<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 目录加上,如下:

jsx
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 布局组件了,我们写的组件是匿名组件,默认组件名即文件名,如下:

html
<script setup></script>

<template>
  <div class="switch-index">
    <!-- <component :is="" /> -->
    <DefaultLayout />
  </div>
</template>

<style scoped></style>

然后,我们需要修改下路由文件 router/index.js ,把 SwitchIndex 组件作为一级路由组件,那此路由下的所有子路由就都可以使用我们的布局了:

jsx
routes: [
  {
    path: '/',
    name: 'Layout',
    component: () => import('@/layout/SwitchIndex.vue'),
    redirect: '/',
    children: [
      {
        path: '/',
        name: 'HomePage',
        meta: {
          title: 'TOOLSDOG'
        },
        component: () => import('@/views/HomePage.vue')
      }
    ]
  }
]

保存看页面:

https://qiniu.isboyjc.com/picgo/202211052351418.png

如果你的运行效果也同上,那就已经用上了布局组件,到此都是 OK 的,接下来就可以调整布局 UI 样式了!

码一下页面布局

上面也说了,我们的默认布局其实很简单,就是很普通的上中下三分布局。

上文我们已经装好了 ArcoDesign,同样也配置了其组件自动引入,这里我们直接使用 ArcoDesignlayout 布局组件做一个常规的上中下三分布局即可,需要注意的是,我们给 Navbar 导航部分加了一个固钉组件 a-affix,用于固定在页面顶部。

PS: ArcoDesign 组件均以子母 a 开头。

修改 DefaultLayout 组件,如下:

jsx
<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变量传送们

https://qiniu.isboyjc.com/picgo/202211082316065.png

如上,我们直接使用对应的 CSS 变量即可,这样后期我们处理黑白模式时,直接可以用 UI 库自带的黑暗模式。

OK,我们简单的写下布局样式

html
<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 都可以看懂写的样式是啥意思,@applyUnoCSSstyle 标签中的写法,那在 HTML 标签中 class 属性中可以直接去写 UnoCSS 样式,如上面我们在 a-layout-content 标签中写的那样,如果装了插件,悬浮上去会有原生样式的提示,如下:

https://qiniu.isboyjc.com/picgo/202211082337781.png

一切都没问题的话,我们的项目保存运行就如下所示了(Footer 需要滚动查看)

https://qiniu.isboyjc.com/picgo/202211082341705.png

导航组件 Navbar

简单的布局组件做好了,接下来我们慢慢填充布局内容,先来做 Navbar 组件。

上文我们已经看了我画的草图,好吧,再看一遍

https://qiniu.isboyjc.com/picgo/202211020031436.png

我们想要实现的两种布局都有导航栏,唯一的区别就是菜单的位置,所以我们这里把导航栏中的各个元素单独拆分作为独立的组件,使用插槽的方式在 Navbar 组件去使用,Navbar 组件相当于导航栏的一个布局组件。这样导航栏组件在哪种布局中都是可用的,避免重复代码。

好,开始做了,在 src/layout 文件夹下新建 components 文件夹存放布局相关的公共组件。

src/layout/components 文件夹下创建 Navbar.vue 文件,内容如下:

html
<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 标签中使用一下导航条组件,同样无需引入直接使用,如下:

html
<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 文件,写入如下内容:

html
<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 组件的 左侧插槽中即可:

html
<Navbar>
  <template #left>
		<Logo />
	</template>
</Navbar>

运行如下:

https://qiniu.isboyjc.com/picgo/202211131403772.png

OK,解释下 Logo 组件,其实就是一个图标加上一个路由标题。

样式我就不解释了,跟着一块写的同学有什么不知道的样式就用编译器鼠标悬浮看一下原生 CSS 是什么即可,老同学应该都能大致看懂啥样式。

那关于 logo 我们直接在 iconify 图标库中找了一个图标用,我们这里用的是 ri:hammer-fill 图标,关于 icon 的配置以及使用这块上文已经说过了,不了解的请看上文,下文中再使用该库图标我就直接写个图标名不再解释了哈。另外,点击 logo 会跳转首页。

标题呢,我们直接用 VuewatchEffect 方法监听了当前路由 meta 对象中的 title 属性并赋值给响应式变量 title ,这样后面我们每次跳转到某个功能页面时, Logo 旁边的文字信息以及浏览器 Tab 页签都会变成该页面路由中配置的 title 信息。

useRoute 方法是 Vue3 组合式 API,它返回一个当前页面路由的响应式对象,同样 Vue 的核心 API 我们都做了自动引入,所以这里没有引入。

watchEffect 也是 Vue3 的 API,该方法会立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。简单来说就是只要该回调中有响应式数据,这些响应式数据的依赖发生改变时就会重新执行此回调,默认会立即执行一次。那在这个场景下就比 watch 好用多了。

那响应式变量 title 是怎么来的呢?代码中我们使用了 useTitle 方法,同样没有引入,它不是 VueAPI,其实,它是 VueUse 库中的一个方法,VueUse useTitle 传送门,在上文我们已经给 VueUse 这个库的方法做了自动引入,所以可以直接用,该方法会返回一个响应式变量,这个响应式变量在改变时会自动改变我们的网页标题,注意这里的标题指的是浏览器 Tab 标签中的标题,如下:

https://qiniu.isboyjc.com/picgo/202211100023823.png

如上图,既然已经拿图标当了 logo,那一不做二不休,把 Tab 签中的 ico 图标也换了吧,就是上图中默认的 Vue 图标,再次去 iconify 图标库在线网站中找到我们使用的 logo 图标,下载个 png 下来,然后找个免费在线的图片 pngico 格式网站转一下格式(百度、谷歌找),把转换后的文件名改成 favicon.ico,替换掉项目根目录下的 public/favicon.ico 文件即可,然后我们浏览器中的 Tab ico 图片就换好了,如下:

https://qiniu.isboyjc.com/picgo/202211100038358.png

OK,到此 Logo 组件就搞好了。

Github 跳转小组件

Github 跳转组件之前我们需要在 config/index.js 文件中配置一下 GitHub Url 地址,方便日后我们在项目中使用或统一修改,Config 配置文件具体内容看文章开头的代码或者看上文配置讲解。

config/index.js 文件的 configSource 对象中新增一个 github 属性,属性值写上我们的项目地址,如下:

jsx
const configSource = {
	// ...

	github: 'https://github.com/isboyjc/toolsdog'
}

Github 跳转组件很简单,就是字面意思,我们搞一个图标放上去,然后能够点击打开一个新标签跳转到项目的 GitHub 地址就行了。在 src/layout/components 文件夹下新建 Github.vue 文件,写入如下内容:

html
<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 组件的右侧插槽中即可:

html
<Navbar>
  <template #right>
		<Github />
	</template>
</Navbar>

运行如下:

https://qiniu.isboyjc.com/picgo/202211131404554.png

SwitchLayout 组件、SwitchMode 组件我们得放到后面再说,接下来我们来写导航菜单组件。

菜单组件 Menu

由于我们目前只有一个路由,就是首页,还没有其他正八经儿的功能页面,所以,这里我们要先写一个路由页面。

那布局写完之后我们第一个功能应该是要写正则可视化校验功能,这里我们就提前给它把路由以及页面定义好吧!

首先,在 src/views 文件夹下新建 RegularPage.vue 文件作为正则校验页面组件:

html
<script setup></script>

<template>
  <div>正则在线校验</div>
</template>

<style scoped></style>

接着我们要配置一下路由,注意,由于现在写的页面路由它同时还是个菜单,所以我们把这些可以作为菜单的路由单独写一个路由文件,这样我们后期可以直接可以导出当作菜单项配置用。

src/router 文件夹下新建 menuRouter.js 文件,导出一个菜单路由数组,如下:

jsx
export const menuRouter = []

src/router/index.js 中使用一下:

jsx
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 文件如下:

jsx
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 中直接使用的,包括我们做项目经常会做的菜单配置,都是只存一个图标名,它是靠我们在运行时通过图标名去匹配组件,这是一个运行时动态的过程,开发时是做不了自动引入的,这类情况我们需要手动引入一下。

还有一个大家可能发现了,在写入图标组件时,我们使用了 Vue3markRaw 方法,markRaw 方法会标记一个对象,使其不能成为一个响应式对象,因为后面我们会将整个菜单路由数据作为一个响应式对象传入菜单组件渲染,那如果我们在这个数据中存在 Vue 组件,将会造成一些不必要的性能开销,所以这里我们直接使用 markRaw 对象给它标记下,使该对象不会被递归解析成响应式对象即可。

接下来在浏览器访问下 /devtools/regular 路由,看看效果,同下即没问题:

https://qiniu.isboyjc.com/picgo/202211131400074.png

已经有菜单了数据了,我们去写菜单 Menu 组件。先理一下思路,通常组件库中会有 Menu 组件,当然 ArcoDesign 也不例外,我们可以直接拿过来封装一层去使用。封装什么呢?虽然我们目前只有一个路由,但是我们在应该要考虑到多级的情况,那其实解决办法就是做一个可以无限递归的菜单组件。

OK,在写菜单组件之前,路由菜单数据还需要处理下,我们写个递归方法拼接一下每个菜单的完整路由,并把每个路由菜单中的 meta 对象压平到菜单里,方便我们后面使用,还是在 src/router 文件夹下的 menuRouter.js 文件,新增一个 menuRouterFormat 方法处理菜单数据并将处理后的数据导出,如下:

jsx
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 文件:

html
<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 返回了当前路由对象 routepath 属性值组成的数组,这样每次路由改变该方法就会被触发,selectedKeys 数组值就会响应式的改变。key 值即子菜单的唯一标识,下面我们写子菜单组件时会将每个子菜单的 key 设置为菜单对应的路由 path

上面我们用到了一个还没有创建的 MenuItem 组件,它其实就是我们的子菜单组件,接下来我们还是在 src/layout/components/Menu 文件夹下新建 MenuItem.vue 文件,内容如下:

html
<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 才能调用父组件传入的属性并且该属性是单向传递,也就是我们不能在子组件中修改它,但是我们可以使用 Vue3toRefs 方法将整个 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.vueVue 会自动将上层的文件夹名作为组件名调用,注意,仅仅是调用而已,其他的都操作不了,想要通过组件名操作一个组件,还是得具名,Vue3 组合式 API 组件的具名方法下文有案例会提到,这里不多说。如上代码,还是像父组件 Menu 中调用 MenuItem 组件一样调用自己调自己就行了,到此我们的菜单组件就写好了。

Menu 组件中还写了一些样式,其实就是简单调整一下组件库中菜单组件的样式,具体样式这里就不多说了,因为会的能看懂,不会的自己跟着实践一下就懂了(太占篇幅)。

OK,写完了使用一下看看效果,把 Menu 组件填充到默认布局 DefaultLayout 组件下 Navbar 组件的中间插槽或者默认插槽中即可:

html
<a-layout-header>
  <Navbar>
    <!-- ... -->

    <!-- 默认插槽和center插槽,默认插槽可不加template直接写内容,作用同center插槽 -->
    <template #center>
      <Menu />
    </template>

    <!-- ... -->
  </Navbar>
</a-layout-header>

看下页面效果:

https://qiniu.isboyjc.com/picgo/202211132215006.png

到此默认布局的导航组件就写的差不多了,下面来写下页尾组件!

页尾区域我们在布局组件中没有设置高度,因为页尾的高度不固定,可能随时会在页尾加个内容啥的,所以就让它随组件内容高度自由撑开吧。。

由于页尾需要展示一些个人信息,所以我们统一把这些数据都放在 config/index.js 中的基础配置对象里,Config 文件内容配置以及使用上文已经说过了,不再描述,这里的数据没什么重要的,不需要脱敏,如需脱敏,可以配合 env.local 环境变量配置文件去做,env.local 环境变量配置文件默认会被 git 忽略,写入该环境变量文件,并在 config 文件中引入环境变量即可,关于环境变量的配置也请看上文或者直接看文档 👉🏻 Vite 中 env 配置文档

jsx
// ...
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 组件比较简单,暂时也没写太多内容,这里我就不会多描述了,直接看代码吧。

html
<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 组件,同样无需引入直接使用,如下:

html
<a-layout-footer>
  <Footer />
</a-layout-footer>

保存后浏览器中看看效果吧:

https://qiniu.isboyjc.com/picgo/202211132232346.png

默认布局到此告一段落,目前首页是我们上文留下的示例代码,有点丑,我们稍微修改一下,让它有个网站的样子。

首页修改 HomePage

打开 src/views/HomePage.vue 文件,清空当前内容,写入下面代码:

html
<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 图标放上去,加了点样式,给它放到页面中间就行了,暂时先这样,保存查看效果如下:

https://qiniu.isboyjc.com/picgo/202211132240184.png

边栏布局组件 SidebarLayout

默认布局我们已经写的差不多了,那接下来就开始写边栏布局 SidebarLayout,这个组件在上文中已经建好了,所以无需再建。

首先我们需要修改下 src/layout/SwitchIndex.vue 文件,先把布局组件写死 SidebarLayout,如下:

html
<script setup></script>

<template>
  <div class="switch-index">
    <!-- <component :is="" /> -->
    <!-- <DefaultLayout /> -->
    <SidebarLayout />
  </div>
</template>

<style scoped></style>

接着修改 src/layout/switch/SidebarLayout.vue 边栏布局组件如下:

html
<script setup></script>

<template>
  <div>
    SidebarLayout
    <router-view v-slot="{ Component }">
      <component :is="Component" />
    </router-view>
  </div>
</template>

<style scoped></style>

OK,看一下页面,如下图中页面中出现侧边栏布局组件文字即可:

https://qiniu.isboyjc.com/picgo/202211132327344.png

码一下页面布局

还是先码一下布局,再次看下这个图中画的侧边栏布局结构:

https://qiniu.isboyjc.com/picgo/202211020031436.png

其实就是多一个侧边栏嘛!至于侧边栏,其实组件库中也有组件,我们可以直接使用 ArcoDesign 组件库中的 a-layout-sider 组件即可,OK,开始写布局,修改 SidebarLayout 组件,如下:

html
<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 组件的样式修改,其他大多数和默认布局的一致,所以也不多说了。

看下效果:

https://qiniu.isboyjc.com/picgo/202211140003356.png

接下来我们把之前写的公用组件填充一下,这时候就能体现出分割模块写组件的好处了,整个页面拼凑就行了,后面我们写一个个 hooks 其实核心理念也是一个道理,唯一不同的一个是拼凑页面,一个是拼凑 JS 模块,Vue3CompositionAPIVue 用户可以像 React 一样写 hooks,这种写法之所以那么多人喜欢,是因为它让我们写 JS 就像搭建积木一样(不理解 hooks 的没关系,其实本质上它就是函数的一种写法,看名字也可以理解,hook 就是钩子的意思,你是不是立刻想到了钩子函数,其实 hooks 就是函数的一种写法而已,最早是 React 提出,简单理解就是将一些单独或者可以复用的 JS 功能模块抽离成一个一个文件去写,并约定 hooks 方法均以 use 开头大驼峰命名、顶层使用,一个 hooks 做一件事,嗯,大概就是这样子,概念而已,咳咳,跑题了,后面会有实战讲到了再详细说吧)。

OK,先填充组件吧,我们看看都什么组件可以填充进去,Navbar、Logo、Github、Footer,这些组件都可以,我们找到对应的位置填充下,如下(其他的没改就不写了,只看下有改动的 template 模板):

html
<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 组件在写的时候注意下,由于导航的菜单要放在侧边栏,所以该组件中的中间插槽或者默认插槽都不需要写了,填充完毕看下效果:

https://qiniu.isboyjc.com/picgo/202211140013221.png

其实大家可能也发现了,其实我们之前写的 Menu 组件还是可以复用的,只需要把菜单的 mode 设置成垂直即 vertical 就行了,OK,接下来我们修改下 Menu 组件,让它可以复用。

修改 Menu 菜单组件

修改 src/layout/components/Menu/index.vue 文件如下:

html
<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,随后使用了 Vue3toRef 方法,还记得上面我们使用 toRefs 方法结构 props 对象属性吗?

先来看下官方定义:

  • toRef ── 基于响应式对象上的一个属性,创建一个对应的 ref。这样创建的 ref 与其源属性保持同步:改变源属性的值将更新 ref 的值,反之亦然。
  • toRefs ── 将一个响应式对象转换为一个普通对象,这个普通对象的每个属性都是指向源对象相应属性的 ref。每个单独的 ref 都是使用 toRef() 创建的。

toRefs 上面我们说过了,简单来说就是将一个响应式对象转成普通对象,但是这个普通对象中的一个个属性会变成独立的响应式属性。

toRef 其实也一样,只是 toRefs 针对整个响应式对象,toRef 只针对响应式对象中的某个属性而已,其实 toRefs 内部转换属性为响应式对象时也是遍历属性使用 toRef 转的。

上面用 toRefs 而这里写 toRef ,只是想让大家都用一下,实际上用啥都行哈,看个人喜好,如果是用作转 props 对象的话,那就看 props 中属性多不多,多就用 toRefs ,少就用 toRef ,都可以,看哪个方便吧!

toRef 的语法就上面我们写的这样:

jsx
const mode = toRef(props, 'mode')

接着说,拿到传入的 mode 属性后,再改下模板中的 a-menu 组件的 mode 属性值为 :mode="mode" 即可。

OK,Menu 组件改完了,我们之前写的默认布局不需要改了,因为 Menu 目前不传参数默认就是水平菜单,那我们在侧边栏布局中使用一下 Menu 组件,修改 SidebarLayout 布局文件,在该组件的 a-layout-sider 标签下使用 Menu 组件如下:

html
<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>

保存运行看下效果:

https://qiniu.isboyjc.com/picgo/202211140048889.png

如果你的代码运行后如上所示,那就没问题了,到此两个布局基本就写好了,接下来就写一下动态切换布局组件吧!

动态切换布局

切换布局的思路文章开头已经说过了,还是老套路,我们先处理一下可切换的布局数据,目前我们就两个布局,其实写个死列表就可以,但是为了显得高级一点,接下来我们就换一种相对高级点的方式处理它。

Vite 中 Glob

大家还记得 webpack 中有个 APIrequire.context 吗?

jsx
require.context(directory, useSubdirectories, regExp)
  • directory ── 表示检索的目录
  • useSubdirectories ── 表示是否检索子文件夹
  • regExp ── 匹配文件的正则表达式,一般是文件名

有经验的同学可能知道,我们在 Vue2 还在使用 webpack 的时候经常会使用 require.context 这个 API 来批量引入组件,那不知道的同学没关系,我们现在用 Vite,那么 Vite 有没有类似的 API 呢?

答案当然是有的,就是 import.meta.glob ,大家可以先简单看下文档有个初步了解,👉🏻 Vite Glob,其实之前旧版本中还有一个 import.meta.globEager 方法,不过目前已经废弃了,不再讨论。

那接下来我们就用 Vite Glob API 来批量处理布局组件,先解析一下各个布局组件,把他们组成我们想要的一个布局列表数据,当然,用法有很多,这里就当作给大家做个小示范吧。

src/layout/switch 文件夹下新建 index.js 文件,写入如下内容:

jsx
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 包来实现的,所以,第一个参数匹配的语法也和它一致,这里我列一下基础语法,高级语法我们也不咋能用到,不需要关注,用到的时候上面链接点击就是文档:

  • 星号 ( .) ── 匹配除斜杠(路径分隔符)、隐藏文件(以 开头的名称)之外的所有内容。
  • 双星或单星 ( * ) ── 匹配零个或多个目录。
  • 问号 ( ?) ── 匹配除斜杠(路径分隔符)之外的任何单个字符。
  • 序列 ( [seq]) ── 匹配序列中的任何字符。

除此之外,它还支持反匹配模式,也就是在匹配字串前加个感叹号( ! )即代表忽略一些文件。

在使用 Glob API 还需要注意一下:

  • 这只是一个 Vite 独有的功能而不是一个 WebES 标准
  • Glob 模式会被当成导入标识符:必须是相对路径(以 ./ 开头)或绝对路径(以 / 开头,相对于项目根目录解析)或一个别名路径( resolve.alias 选项)。
  • 还需注意,所有 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 是什么?其实它是 TC39 在第 91 次会议上针对模块导入相关的一个提案,想要了解的同学还是那句话,点击上面那个链接就是官方文档传送门。

这里可能会有同学看的有点模糊,不过不重要,大多都用不到,记住常用的就行,这里列出来是给有兴趣的同学一个查阅检索项。

我们只需要理解在第二个参数中将 eager 属性设置成了 true,即设置了静态导入。

静态导入和动态导入的区别也很好理解,拿官网的一个例子来说吧:

jsx
// 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 布局数组,修改如下:

html
<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>

输出结果如下:

https://qiniu.isboyjc.com/picgo/202211150043525.png

修改布局组件具名并填充布局信息

其实到此我们已经拿到了 src/layout/switch 文件夹下的所有可切换布局组件,但是,看上面的输出结果,其实我们并不知道哪个组件是对应的哪个布局(上面输出的 2 个组件对象中下面那个组件信息中有个 __name 属性,是因为我们目前页面中以文件名调用了该布局组件,但是将来要做动态切换默认是不会引入的,它还是没有这个属性,换句话说,哪怕就算有,Vue 中以双下划线开头的属性也是不想被我们调用到的)。之所以没有组件名的标识,这是因为我们写的布局组件都是匿名组件,那所以现在我们就要给他们变成具名组件。除此之外,为了使我们的布局列表信息更完善,我们还要给每个布局组件增加布局名称以及图标信息,这样后面做切换组件时就方便的多了。

先修改边栏布局组件吧,在 SidebarLayout 组件文件中新增如下代码:

html
<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,试想 setupVue 组件中的哪个时期才会调用?它是在组件调用时被调用,并且是在组件的 beforeCreate 生命周期之前执行,也就是想要拿到 setup 中的数据,那至少得等组件调用了才行,组件还没调用的时候,是绝对获取不了 setup 中属性的,那么问题来了,不命名又只能以默认的文件名调用。。。无解,所以我们想要命名,只能再写一个 OptionsAPI 组件,OptionsAPI 组件中我们直接并为其添加 name 属性就 ok 了。

那除了为组件命名之外,我们还找了了一个 iconify 图标库的图标作为该布局的图标引入并写到了组件的自定义属性 icon 中,同时还自定义了一个 title 属性给组件起了个中文名,这是为了将来渲染布局切换菜单省事哈,后面就晓得了。

写完了边栏组件我们再写一下默认组件,在 组件中新增如下代码:

html
<script>
import IconRiLayoutTopFill from '~icons/ri/layout-top-fill'
export default {
  name: 'DefaultLayout',
  icon: IconRiLayoutTopFill,
  title: '默认布局'
}
</script>

和上面一致,不过多解释了。现在,保存再刷新页面看下输出的布局组件列表信息:

https://qiniu.isboyjc.com/picgo/202211150121957.png

OK,经过种种手段我们现在已经成功搞到了我们想要的布局列表数据!!!

Pinia 共享布局状态

由于将来我们的布局组件信息需要跨页面共享,所以这里就需要用到 Pinia 了,PiniaVuex 具有相同的功效,是 Vue 的核心存储库,它允许我们跨 组件/页面 共享状态,所以用在这儿很合适,本身 Pinia 就是作为下一代 Vuex 产生的,那现在我们使用官方包创建项目都只会询问我们是否安装 Pinia 而不是 Vuex 了,那 Pinia 同时支持 OptionsAPICompositionAPI 两种语法,为了让我们很轻松的从 Vuex 迁移过来,甚至还提供了一组类似的 map helpers like Vuex (像 VuexmapState、mapActions 等方法),所以就不用我说什么了吧。。。

那由于我们是 Vue3 项目,所以 Pinia 也会使用 CompositionAPI 语法,没办法,当你用习惯 CompositionAPI 之后,绝对不会再想去用 OptionsAPI,就是这么香。

初始化项目时我们就已经装了 Piniasrc/stores 文件夹就是我们的共享状态文件夹,里面有个建项目时创建的 counter.js 文件,直接删掉即可。

接着,在 src/stores 文件夹下创建 system.js 文件,system 模块即项目的系统配置模块,布局相关的状态数据都放在这里即可。布局组件需要共享的状态其实就两个,一个当前布局对象,一个布局列表,OK,写一下:

jsx
export const useSystemStore = defineStore('system', () => {
	// 当前可切换布局
  const currentSwitchlayout = shallowRef(null)
  // 可切换布局列表
  const switchLayoutList = shallowRef([])

	return {
		currentSwitchlayout,
		switchLayoutList
	}
})

如上,其实用 CompositionAPI 语法写起来和平常在 setup 中没有太大区别。

上面我们创建了当前可切换布局对象 currentSwitchlayout 默认是 null 以及可切换布局列表 switchLayoutList 默认是空数组两个响应式属性。可能大家注意到了,我们这里使用的是 shallowRef 而不是 ref,什么是 shallowRef

其实 shallowRefref 区别不大,shallowRefref 的浅层作用形式,使用 ref 时,如果传入数据是一个对象,那 Vue 内部会帮我们递归给对象中每个属性不管有多少层都会做响应式处理,而 shallowRef 只会做一层响应式处理,区别就在这。

PS:reactive API 也有对应的 shallowReactive ,作用同上。

那我们这里为什么使用 shallowRef,其实还是为了避免浪费资源,因为我们把整个布局组件都作为数据源了,如果使用 ref,它会一直递归给布局组件的各个属性做响应式,而这些我们都不需要,太消耗资源,我们只需浅层响应就可以了。

OK,我们回归主题接着说,接下来我们还需要在 system 模块中写一个初始化布局的方法,如下:

jsx
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 文件如下:

html
<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 文件,先看代码:

html
<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 组件)点击可选布局,另外点击布局图标可以按照顺序切换下一个布局这样子。

简单说下,可能大家看到了一个 APIstoreToRefs,它其实是 Pinia 的一个 APIPinia 核心包我们之前也做了自动引入,所以无需手动导入。storeToRefs 和之前我们说过 Vue 中的一个 API 很像,就是 toRefs ,区别就是 toRefs 是针对所有响应式对象,而 storeToRefs 针对的则只是 Pinia 模块返回对象(也可以叫 Pinia 模块实例),直接输出 Pinia 模块返回对象就可以看到,其实这个对象上挂载了很多 Pinia 特有的属性及方法,如下:

jsx
const systemStore = useSystemStore()
console.log(systemStore)

https://qiniu.isboyjc.com/picgo/202211160104721.png

上图我们也可以看出 Pinia 模块返回对象也是一个响应式 Reactive 类型响应式对象,所以它不能解构,一解构就丢失响应式了。

而使用 storeToRefs 它可以帮我们只把模块中返回的状态属性转成 Ref 类型然后全塞到一个普通对象中,如下:

jsx
const obj = storeToRefs(useSystemStore())
const { currentSwitchlayout, switchLayoutList } = obj
console.log(obj)
console.log(currentSwitchlayout, switchLayoutList)

https://qiniu.isboyjc.com/picgo/202211160112931.png

shallowRef 也是一种 Ref 类型,所以没有转换,你可以尝试写一个 reactive 类型数据返回,就可以看到会被转成了 Ref

因为是普通对象所以我们可以直接解构 Pinia 模块里的状态属性,就如上面代码中写的那样,直接解构出了 currentSwitchlayoutswitchLayoutList 属性,某些时候还是挺方便的。但是但是但是,只有状态属性,却没有方法,如果你使用状态的同时还需要使用模块中的方法,你得这样写:

jsx
const systemStore = useSystemStore()
const { currentSwitchlayout, switchLayoutList } = storeToRefs(systemStore)

systemStore.initSwitchLayout([...])

嗯,回到组件代码上,代码中我们还用到了 VueUse 库中的 useCycleList 方法,叫 hooks 也行。。。

useCycleList 文档传送门

其实作用就是循环遍历一个数据列表,我们上面写的是:

jsx
const { next } = useCycleList(switchLayoutList.value, {
  initialValue: currentSwitchlayout
})

其实意思就是循环遍历 switchLayoutList 布局列表,返回数据中我们解构出了一个 next 方法,该方法每次执行都会把布局列表中下一个元素(即布局对象)赋值给 currentSwitchlayout

至于 template 模板内容,我们使用了一个下拉菜单组件,展示到页面上的图标就是当前布局的图标,还记得我们写布局组件时给每个布局组件都自定义了一个 icon 属性并赋值了一个图标组件吗?这里直接使用 Vue 内置的 component 组件渲染出来就行。鼠标悬浮到当前布局图标上展示下拉菜单面板,这个面板就遍历一下布局组件列表 switchLayoutList 把对应的布局组件名放上去即可,除此之外还给选中的菜单项在下拉菜单中用一个 iconify 图标 material-symbols:check-small 标注了下(就是个对号图标)。

接下来使用一下 SwitchLayout 组件,两个布局组件都需要使用,放在 Navbar 组件右侧插槽中即可。

修改 DefaultLayout 组件(只展示了修改处代码):

html
<a-layout-header>
  <Navbar>
    <template #left> <Logo /> </template>
    <template #center> <Menu /> </template>

    <template #right>
      <SwitchLayout />
      <Github />
    </template>
  </Navbar>
</a-layout-header>

修改 SidebarLayout 组件(只展示了修改处代码):

html
<a-layout-header>
  <Navbar>
    <template #left> <Logo /> </template>

    <template #right>
      <SwitchLayout />
      <Github />
    </template>
  </Navbar>
</a-layout-header>

OK,保存刷新页面,看看效果!

默认布局如下:

https://qiniu.isboyjc.com/picgo/202211162232860.png

边栏布局如下:

https://qiniu.isboyjc.com/picgo/202211162232111.png

Pinia 状态持久化

虽然布局做好了,但是我们点击切换布局之后刷新页面会重新走初始化布局流程,刷新一下布局就变回原来的样子了,所以我们还需要给当前布局对象做个持久化。

其实 Vue3 中我们完全可以写 Hooks 来做一些简单的状态共享(后面会有案例说到),并不一定需要 Pinia,之所以还使用 Pinia,是因为 Pinia 有两个好处:

  • Pinia 可以使用 Vue 浏览器插件 Vue Devtools 去追踪状态变化
  • Pinia 有插件系统,可以使用插件处理一些东西

Pinia 模块状态持久化就可以用插件很便捷的做,这里我们使用一个开源的状态持久化插件(其实自己写也可以,也很简单,自己写的话更随意一点),但是这里就先不写了,麻烦,用现成的吧先,有兴趣的同学可以看看 Pinia 文档中对其插件系统的描述自己写个插件。

插件地址:pinia-plugin-persistedstate

安装:

jsx
pnpm i pinia-plugin-persistedstate

// or

npm i pinia-plugin-persistedstate

使用:

jsx
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

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

OK,我们安装好了之后去使用一下此插件,我们是在入口文件 src/main.js 中创建的 Pinia 实例,所以要在这里使用插件,先看下目前的 main.js 文件内容:

jsx
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 如下:

jsx
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 文件中做一下配置:

jsx
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 方法,如下:

https://qiniu.isboyjc.com/picgo/202211162318903.png

大家知道存储到浏览器缓存我们需要先做序列化(把数据转成 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 函数,如下:

https://qiniu.isboyjc.com/picgo/202211162316414.png

上面也说了我们可以自定义序列化方法,但是我们需要吗?完全不需要,因为其实我们只需要把当前布局对象的标识也就是 name 属性存下来就可以了,没必要把渲染函数也存起来,甚至除了 name 属性,其他都无所谓的,存下来即没意义又浪费资源。

所以,我们干脆只缓存 name 属性就好了,那其实,这个持久化插件的 paths 属性配置还支持我们只缓存某个状态对象中的某个属性,那我们修改下配置如下:

jsx
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 属性了,如下:

https://qiniu.isboyjc.com/picgo/202211162335153.png

但是如此一来,我们就需要在布局初始化方法中做一下处理了,修改 src/stores/system.js 中布局初始化方法如下:

jsx
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

👉🏻 项目 GitHub 地址

不正经的前端