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

写在前面

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

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

上文中由于核心内容过长,所以为了阅读体验分成了两篇文章,本文接上文还是继续介绍多布局,核心是边栏布局的搭建以及布局的动态切换!也是因为本文被分割成了两篇文章,所以新同学请先看上文!

👉🏻 项目 GitHub 地址

那么截止到目前项目运行效果如下:

OK,我们接着上文的来!

边栏布局组件 SidebarLayout

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

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

<script setup></script>

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

<style scoped></style>

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

<script setup></script>

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

<style scoped></style>

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

码一下页面布局

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

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

<script setup>
// 侧边栏收缩状态
const collapsed = ref(false)

// 侧边栏收缩触发事件
const handleCollapse = (val, type) => {
  const content = type === 'responsive' ? '响应式触发' : '点击触发'
  console.log(`${content}侧边栏,当前状态:${val}`)
  collapsed.value = val
}
</script>

<template>
  <div class="sidebar-layout">
    <a-layout>
      <a-affix>
        <a-layout-header> Navbar </a-layout-header>
      </a-affix>

      <a-layout>
        <a-affix :offsetTop="58">
          <a-layout-sider
            breakpoint="lg"
            :width="220"
            height="calc(100vh-58px)"
            collapsible
            :collapsed="collapsed"
            @collapse="handleCollapse"
          >
            Menu
          </a-layout-sider>
        </a-affix>

        <a-layout>
          <a-layout-content class="min-h-[calc(100vh-58px)]">
            <router-view v-slot="{ Component }">
              <component :is="Component" />
            </router-view>
          </a-layout-content>
          <a-layout-footer> Footer </a-layout-footer>
        </a-layout>
      </a-layout>
    </a-layout>
  </div>
</template>

<style scoped>
.sidebar-layout :deep(.arco-layout-header),
.sidebar-layout :deep(.arco-layout-footer),
.sidebar-layout :deep(.arco-layout-content) {
  @apply text-[var(--color-text-1)] text-14px;
}

.sidebar-layout :deep(.arco-layout-header) {
  @apply w-full h-58px;
  @apply bg-[var(--color-bg-3)]  border-b-[var(--color-border-1)] border-b-solid border-b-width-1px box-border;
}
.sidebar-layout :deep(.arco-layout-content) {
  @apply flex flex-col items-center;
  @apply bg-[var(--color-bg-1)] relative;
}
.sidebar-layout :deep(.arco-layout-footer) {
  @apply w-full flex justify-center items-center;
  @apply border-t-[var(--color-border-1)] border-t-solid border-t-width-1px box-border;
  @apply bg-[var(--color-bg-2)] text-[var(--color-text-1)] text-14px;
}

.sidebar-layout :deep(.arco-layout-sider) {
  @apply h-[calc(100vh-58px)];
}
.sidebar-layout :deep(.arco-layout-sider),
.sidebar-layout :deep(.arco-layout-sider-trigger) {
  @apply border-r-[var(--color-border-1)] border-r-solid border-r-width-1px box-border;
}
</style>

如上代码,其实没有什么特别复杂的地方,这里由于布局中模块变多,我们使用 ArcoDesign 组件库中的 a-layout 组件把多个模块分割布局,额,不会不知道 a-layout 组件能嵌套使用吧!先把布局分成上下两个模块,把下模块再分成左右两个模块即可,大概就这样。

样式的话,除了多了一个 a-layout-sider 组件的样式修改,其他大多数和默认布局的一致,所以也不多说了。

看下效果:

接下来我们把之前写的公用组件填充一下,这时候就能体现出分割模块写组件的好处了,整个页面拼凑就行了,后面我们写一个个 hooks 其实核心理念也是一个道理,唯一不同的一个是拼凑页面,一个是拼凑 JS 模块,Vue3CompositionAPIVue 用户可以像 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,随后使用了 Vue3toRef 方法,还记得上面我们使用 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 中有个 APIrequire.context 吗?

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

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,即设置了静态导入。

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

// 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,试想 setupVue 组件中的哪个时期才会调用?它是在组件调用时被调用,并且是在组件的 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 了,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,写一下:

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 模块中写一个初始化布局的方法,如下:

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

简单说下,可能大家看到了一个 APIstoreToRefs,它其实是 Pinia 的一个 APIPinia 核心包我们之前也做了自动引入,所以无需手动导入。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 模块里的状态属性,就如上面代码中写的那样,直接解构出了 currentSwitchlayoutswitchLayoutList 属性,某些时候还是挺方便的。但是但是但是,只有状态属性,却没有方法,如果你使用状态的同时还需要使用模块中的方法,你得这样写:

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

systemStore.initSwitchLayout([...])

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

useCycleList 文档传送门

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

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 发布,可下载查看:

👉🏻 toolsdog tag v0.0.2-dev

👉🏻 项目 GitHub 地址

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

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

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

昵称

取消
昵称表情代码图片

    暂无评论内容