保姆级Vue3+Vite项目实战多布局(上)

写在前面

本文为 Vue3+Vite 项目实战系列教程文章第二篇,系列文章建议从头观看效果更佳,大家可关注 Vue3 实战系列 防走失!点个赞再看有助于全文完整阅读!

此系列文章主要使用到的主要技术站栈为 Vue3+Vite,那既然是 Vue3,状态库我们使用的是 Pinia 而不是 Vuex,在写法上也肯定是以 CompositionAPI 为主而不是 OptionsAPI,组件库方面我们使用的是 ArcoDesign (赶紧丢掉 ElementUI 吧!)。

上文 这是一份保姆级Vue3+Vite实战教程 中我们主要介绍了 Vue3+Vite 项目的搭建以及项目上的一些配置,当然后续在开发过程中如果有需要也会陆陆续续的补充一些配置。

本文我们终于要开始写代码了,但其实依旧还是在为项目做准备,因为此文的核心是项目的多布局实战,先把页面布局搭建好,随后再开始写项目,这也是项目开发中必不可少的一环,当然重要的还是写代码过程中的细节以及小技巧,新同学可以跟着码一遍,老同学可以快速阅读一遍看有没有什么用得上的实战小技巧。

👉🏻 项目 GitHub 地址

如果大家不想从头来过可以直接下载截止到上文内容的代码,👉🏻 toolsdog tag v0.0.1-dev

代码拉下来之后,npm install || pnpm install 下载依赖,然后 npm run serve || pnpm serve 启动,如果一切没问题的话,当前项目运行起来是这样的:

PS: 在开始写代码之前,你需要简单看下 Vue3 官方文档,去了解一下基础 API,这很重要!!!

OK,接下来开始本文内容!

项目多布局思路

你想要的多布局是哪种?

我们平常所说的多布局比较笼统,仔细分来其实有两种需要多布局的场景,大家可以自行匹配一下:

  • 项目有很多页面,有些页面是一样的布局,但还有些页面是另外一种布局,所以我们需要多种布局提供给不同的页面。
  • 项目有很多页面,页面都是统一的布局,但是我们需要提供多种可以自由切换的布局,让用户在生产环境自己去选择。

多页面不同布局

如果你只是需要在不同的页面使用不同的布局,那么很简单。

因为你只需要写多个不同的布局组件,然后使用二级路由通过指定父级路由的 component 就可以决定采用哪个布局,如下:

假如我们有 2 个布局:

// layout 1
Layout1.vue

// layout 2
Layout2.vue

页面 page_a 想要使用 Layout1 布局,页面 page_b 想要使用 Layout2 布局,那么只需在配置路由时如下:

{
  routes: [
    {
      path: '/layout1',
      name: 'Layout1',
      component: () => import('***/Layout1.vue'),
      redirect: '/layout1/page_a',
      children: [
        {
          path: 'page_a',
          name: 'PageA',
          component: () => import('***/PageA.vue')
        },

        // ...
      ]
    },
    {
      path: '/layout2',
      name: 'Layout2',
      component: () => import('***/Layout2.vue'),
      redirect: '/layout2/page_b',
      children: [
        {
          path: 'page_b',
          name: 'PageB',
          component: () => import('***/PageB.vue')
        },

        // ...
      ]
    }
  ]
}

如上所示,我们只需要在根组件和布局组件中写上 <router-view /> 就 OK 了!

可动态切换的布局

再来看可以动态切换的布局,其实也很简单,一般来说,我们使用 Vuecomponent 组件,通过 is 属性去动态的渲染布局组件就可以了,如下:

<!-- SwitchLayout.vue -->
<script setup>
const isOneLayout = ref(true)
import Layout1 from "./Layout1.vue"
import Layout2 from "./Layout2.vue"
</script>

<template>
  <button @click="isOneLayout = !isOneLayout" />
  <component :is="isOneLayout ? Layout1 : Layout2" />
</template>

然后,我们直接在父路由中引入此页面,就可以通过改变状态来动态切换所有的子路由布局了,如下:

{
  routes: [
    {
      path: '/',
      component: () => import('***/SwitchLayout.vue'),
      redirect: '/page_a',
      children: [
        {
          path: 'page_a',
          name: 'PageA',
          component: () => import('***/PageA.vue')
        },

        // ...
      ]
    },
}

PS: Vue 内置的component 组件在 Vue2is 也可以通过组件名称切换, Vue3 中只能通过组件实例切换!

OK,到此本文就结束了,谢谢大家观看!!!🤨

准备工作

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

咱们先写一个可以动态切换的布局,首先,在项目 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,同样也配置了其组件自动引入,这里我们直接使用 ArcoDesignlayout 布局组件做一个常规的上中下三分布局即可,需要注意的是,我们给 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 都可以看懂写的样式是啥意思,@applyUnoCSSstyle 标签中的写法,那在 HTML 标签中 class 属性中可以直接去写 UnoCSS 样式,如上面我们在 a-layout-content 标签中写的那样,如果装了插件,悬浮上去会有原生样式的提示,如下:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/19f1fca99b614d3ab5df5783043b32e0~tplv-k3u1fbpfcp-zoom-1.image

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

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/85283dc8cfa6462daeecd263bd6a9fc9~tplv-k3u1fbpfcp-zoom-1.image

导航组件 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 会跳转首页。

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

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

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

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

如上图,既然已经拿图标当了 logo,那一不做二不休,把 Tab 签中的 ico 图标也换了吧,就是上图中默认的 Vue 图标,再次去 iconify 图标库在线网站中找到我们使用的 logo 图标,下载个 png 下来,然后找个免费在线的图片 pngico 格式网站转一下格式(百度、谷歌找),把转换后的文件名改成 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/index.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 中直接使用的,包括我们做项目经常会做的菜单配置,都是只存一个图标名,它是靠我们在运行时通过图标名去匹配组件,这是一个运行时动态的过程,开发时是做不了自动引入的,这类情况我们需要手动引入一下。

还有一个大家可能发现了,在写入图标组件时,我们使用了 Vue3markRaw 方法,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 返回了当前路由对象 routepath 属性值组成的数组,这样每次路由改变该方法就会被触发,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 才能调用父组件传入的属性并且该属性是单向传递,也就是我们不能在子组件中修改它,但是我们可以使用 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 组件的中间插槽或者默认插槽中即可:

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

太长了,看下文吧!

写在最后

由于此文内容篇幅比较长,为了阅读体验,边栏布局以及动态切换这些内容我放在下文里了,由于本文描述核心内容并未结束,所以没有给代码打 Tag,大家有精力可以直接点击下文链接接着看,两文应该会一块发布,项目分支代码随时会变动,不建议直接看哈,下文结束代码会打一个 Tag 发布供大家下载查看当前版本代码,就这样。

保姆级 Vue3+Vite 项目实战多布局(下)

谢阅,如有错误请评论纠正,有什么疑问或者不理解的地方都可以私信咨询我,由于不经常写实战文章,也为了不同程度同学都可以看下去,文章可能稍微有些啰嗦,见谅,再次欢迎关注专栏 Vue3实战系列 ,点赞关注不迷路,回见!

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容