模式切换实战
处理基础数据
和可切换布局一样,还是要先处理数据,我们需要先写一下模式列表,因为后面还要做持久化,也为了这些全局系统配置信息的统一,我们还是写在 pinia system
模块中。
修改 stores/system.js
文件如下:
import { getConfig } from '@/config/index'
import IconMaterialSymbolsWbSunnyRounded from '~icons/material-symbols/wb-sunny-rounded'
import IconMaterialSymbolsDarkModeRounded from '~icons/material-symbols/dark-mode-rounded'
import IconMaterialSymbolsComputer from '~icons/material-symbols/computer'
export const useSystemStore = defineStore(
'system',
() => {
// ...
// 模式列表
const modeList = ref([
{
name: 'auto',
icon: markRaw(IconMaterialSymbolsComputer),
title: '自动模式'
},
{
name: 'light',
icon: markRaw(IconMaterialSymbolsWbSunnyRounded),
title: '亮色模式'
},
{
name: 'dark',
icon: markRaw(IconMaterialSymbolsDarkModeRounded),
title: '暗色模式'
}
])
// 当前模式
const currentMode = ref(null)
// 初始化模式
const initMode = () => {
if (!currentMode.value) {
currentMode.value = modeList.value[0]
} else {
currentMode.value = modeList.value.find(
item => item.name === currentMode.value.name
)
}
}
return {
currentMode,
modeList,
initMode,
// ...
}
},
{
persist: {
key: `${getConfig('appCode')}-pinia-system`,
enabled: true,
storage: window.localStorage,
// 新增 currentMode.name 属性持久化
paths: ['currentSwitchlayout.name', 'currentMode.name']
}
}
)
如上,我们在 system
模块中新增了当前模式对象 currentMode
和模式列表数组 modeList
,模式列表数组我们直接写死了,除了 light
和 dark
之外,我们还写了一个 auto
,它代表使用操作系统偏好主题模式,同时在 iconify
图标库找了几个对应的图标引入(图标库配置请看第一篇文章),由于直接在 JS
中引入的图标组件,所以还是得手动引入不能自动引入,我们还是使用 markRaw
方法标记一下该组件不被做响应式处理来避免不必要的开销,OK,模式列表就写好了。
我们写了一个初始化模式的方法 initMode
,内部逻辑其实和上文布局初始化方法一样,同时我们也给当前模式对象 currentMode
的 name
属性做了持久化处理。
最后把这几个主题模式相关的属性和方法 return
出去即可,接下来我们写模式切换组件 SwitchMode
会用到。
模式切换组件 SwitchMode
写下模式切换组件,在 src/layout/components
文件夹下新增 SwitchMode.vue
文件,写入下面内容:
<script setup>
import { useSystemStore } from '@/stores/system.js'
const systemStore = useSystemStore()
const { currentMode, modeList } = storeToRefs(systemStore)
// 初始化模式
systemStore.initMode()
// 下拉菜单选中事件
const handleSelect = val => (currentMode.value = val)
const { next } = useCycleList(modeList.value, {
initialValue: currentMode
})
</script>
<template>
<a-dropdown @select="handleSelect" trigger="hover" class="mode-dropdown">
<a-button type="text" @click="next()">
<template #icon>
<component
:is="currentMode.icon"
class="text-[var(--color-text-1)] text-16px"
></component>
</template>
</a-button>
<template #content>
<a-doption v-for="item of modeList" :key="item.name" :value="item">
<template #icon v-if="currentMode.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>
.mode-dropdown .arco-dropdown-option {
@apply flex justify-end items-center;
}
</style>
和切换布局组件 SwitchLayout
很像哈,其实就是一个模式图标,悬浮展示下拉菜单( ArcoDesign
组件库 a-dropdown
组件)点击可选模式,另外点击当前模式图标可以按照顺序切换下一个模式这样子。
Vue
的 storeToRefs
方法以及 VueUse
的 useCycleList
方法我们上文都有介绍,这里不多做解释了,不了解可以回顾下上文,唯一不同的是模式切换组件中写了模式初始化方法,而之前布局初始化我们实在 SwitchIndex
可切换布局入口文件中写的,这是因为布局未渲染之前不会加载布局切换组件,所以我们必须得在入口处先调用布局初始化方法,而模式初始化不存在这个问题,因为只要布局加载了,就会渲染模式切换组件,在模式切换组件中初始化模式也就没问题。
接下来我们使用一下 SwitchMode
组件,因为配置了自动引入所以无需引入组件直接使用即可,两个布局组件里都需要使用,放在 Navbar
组件右侧插槽中。
修改 DefaultLayout
组件(只展示了修改处代码):
<a-layout-header>
<Navbar>
<template #left> <Logo /> </template>
<template #center> <Menu /> </template>
<template #right>
<!-- 新增 -->
<SwitchMode />
<SwitchLayout />
<Github />
</template>
</Navbar>
</a-layout-header>
修改 SidebarLayout
组件(只展示了修改处代码):
<a-layout-header>
<Navbar>
<template #left> <Logo /> </template>
<template #right>
<!-- 新增 -->
<SwitchMode />
<SwitchLayout />
<Github />
</template>
</Navbar>
</a-layout-header>
OK,保存刷新下页面:
如上图,我们使用之后导航栏有个模式切换菜单,点击切换即可切换模式,切换的当前模式对象 name
已经缓存到浏览器中。但是点击切换页面并没有什么改变,因为我们还没做切换的逻辑处理,接下来我们在 pinia system
模块中做一下修改,为了方便呢,我们这里借助 VueUse
的 useColorMode
方法去做切换逻辑,后面会给大家介绍方法,如下:
// ...
export const useSystemStore = defineStore(
'system',
() => {
// ...
const modeList = ref([
{
name: 'auto',
icon: markRaw(IconMaterialSymbolsComputer),
title: '自动模式'
},
{
name: 'light',
icon: markRaw(IconMaterialSymbolsWbSunnyRounded),
title: '亮色模式'
},
{
name: 'dark',
icon: markRaw(IconMaterialSymbolsDarkModeRounded),
title: '暗色模式'
}
])
const currentMode = ref(null)
// 新增
const mode = useColorMode({
attribute: 'arco-theme',
emitAuto: true,
selector: 'body',
initialValue: currentMode.value?.name,
storageKey: null
})
watchEffect(() => (mode.value = currentMode.value?.name))
const initMode = () => {
if (!currentMode.value) {
currentMode.value = modeList.value[0]
} else {
currentMode.value = modeList.value.find(
item => item.name === currentMode.value.name
)
}
}
return {
currentMode,
modeList,
initMode,
// ...
}
},
{
persist: {
key: `${getConfig('appCode')}-pinia-system`,
enabled: true,
storage: window.localStorage,
paths: ['currentSwitchlayout.name', 'currentMode.name']
}
}
)
其实上面代码中我们只增加了下面这段代码:
const mode = useColorMode({
attribute: 'arco-theme',
emitAuto: true,
selector: 'body',
initialValue: currentMode.value?.name,
storageKey: null
})
watchEffect(() => (mode.value = currentMode.value?.name))
我们先来看看 useColorMode 的用法,其实它的大致原理我们上面都说过了,核心就是自定义属性切换那一套,该方法接受一个对象参数,对象有下面几个属性:
selector
-string
类型,应用于目标元素的CSS
选择器,作用就是将HTML
自定义的属性添加到对应元素上。attribute
-string
类型,应用目标元素的HTML
属性,其实就是HTML
自定义属性的key
。initialValue
- 初始模式值。modes
- 向属性添加值时的前缀。storageRef
- 自定义存储引用,如果提供,将跳过useStorage
,useStorage
是VueUse
中一个做持久化的方法。storageKey
- 将数据持久化到localStorage/sessionStorage
的密钥(key
),传null
则禁用持久化。storage
- 存储对象,可以是localStorage
或sessionStorage
,默认localStorage
。emitAuto
- 从状态切换为auto
,选项设置为true
时,首选模式不会转换为light
或dark
,当我们需要知道auto
状态下的模式值时会很有用。onChanged
- 用于处理更新的自定义处理程序,指定后,将覆盖默认行为。
useColorMode
方法内也可以做数据持久化,但是我们将 storageKey
属性设置为 null
,统一在 pinia
中做持久化,由于我们使用 ArcoDesign
的 CSS
变量,所以遵循 UI
库的方式将目标元素即 selector
设置为 body
,目标元素的自定义属性 attribute
设置为 arco-theme
。
useColorMode
返回的 mode
默认值为 light、dark
,切换模式如下:
const mode = useColorMode({
// ...
})
mode.value = 'light' // 切换基础白色模式
mode.value = 'dark' // 切换黑色模式
mode.value = 'auto' // 跟随系统偏好设置切换模式
默认情况下,当我们把 mode
设置为 auto
时,useColorMode
方法内部会检查系统偏好设置,将项目模式目标元素属性值设置为系统偏好然后将 mode
修改为系统偏好的 dark
或者 light
,但当我们将 emitAuto
属性设置为 true
后,当切换为 auto
时,会将项目模式目标元素属性值设置为系统偏好,但是 mode
值还是 auto
,这么说大家应该对 emitAuto
属性更容易理解些,也可以自己将 emitAuto
属性分别设置 true/false
然后监听 mode
变化输出看看就知道了。
初始值 initialValue
我们直接设置成当前选中模式对象的 name
属性即可。
我们目前只有黑、白、自动三种模式,这几种也是最普遍的,useColorMode
方法中默认的也是这几种,如果我们想再自定义一个模式(比如色弱模式),我们就需要去配置 modes
属性了,大家可以翻翻文档都尝试下,这里不多赘述了。
那如果我们想在模式改变前做一些事情,可以使用 onChanged
属性,onChanged
属性值是一个回调,回调中有两个参数,第一个参数 mode
指的是要改变的模式标识,第二个参数 defaultOnChanged
是一个方法,调用该方法传入模式标识符即切换模式,如下(只是例子,和项目无关):
const mode = useColorMode({
attribute: 'arco-theme',
emitAuto: true,
selector: 'body',
onChanged: (mode, defaultOnChanged) => {
// 再设置一个自定义属性 theme
document.body.setAttribute('theme', mode)
// 修改模式
defaultOnChanged(mode)
}
})
目前我们也不需要用到,回到代码逻辑,我们调用 useColorMode
方法返回了一个响应式属性 mode
,接着使用 watchEffect
(上文有介绍)方法监听了其回调中的响应式属性,也就是当我们的当前模式对象中 name
属性 (currentMode.value?.name
) 值发生改变时,会重新赋值给 mode
,而 mode
改变则会触发 useColorMode
方法逻辑以此来切换模式。
OK,看下完整的 pinia system
模块代码:
import { getConfig } from '@/config/index'
import IconMaterialSymbolsWbSunnyRounded from '~icons/material-symbols/wb-sunny-rounded'
import IconMaterialSymbolsDarkModeRounded from '~icons/material-symbols/dark-mode-rounded'
import IconMaterialSymbolsComputer from '~icons/material-symbols/computer'
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]
} else {
currentSwitchlayout.value = switchLayoutList.value.find(
item => item.name === currentSwitchlayout.value.name
)
}
}
}
// 模式列表
const modeList = ref([
{
name: 'auto',
icon: markRaw(IconMaterialSymbolsComputer),
title: '自动模式'
},
{
name: 'light',
icon: markRaw(IconMaterialSymbolsWbSunnyRounded),
title: '亮色模式'
},
{
name: 'dark',
icon: markRaw(IconMaterialSymbolsDarkModeRounded),
title: '暗色模式'
}
])
// 当前模式
const currentMode = ref(null)
const mode = useColorMode({
attribute: 'arco-theme',
emitAuto: true,
selector: 'body',
initialValue: currentMode.value?.name,
storageKey: null
})
watchEffect(() => (mode.value = currentMode.value?.name))
// 初始化模式
const initMode = () => {
if (!currentMode.value) {
currentMode.value = modeList.value[0]
} else {
currentMode.value = modeList.value.find(
item => item.name === currentMode.value.name
)
}
}
return {
currentMode,
modeList,
initMode,
currentSwitchlayout,
switchLayoutList,
initSwitchLayout
}
},
{
persist: {
key: `${getConfig('appCode')}-pinia-system`,
enabled: true,
storage: window.localStorage,
paths: ['currentSwitchlayout.name', 'currentMode.name']
}
}
)
保存刷新页面看看效果吧!
light
模式:
我这边系统设置的黑色主题,所以 auto
模式同 dark
模式:
OK,到此我们的模式切换就搞定了!
如上,我们搞了黑白两个模式,其实都是围绕着一个主题色来的,由于我们直接使用了组件库颜色变量,所以这个主题色就是组件库的主题色。如果我们要再写一套主题色做主题色切换,其实和模式切换差不多,但是一套主题色我们需要设计两套颜色来适配黑白模式,这样说大家能够理解主题和模式的区别吗🙄?
Tips:多用 Ref 少用 Reactive
大家可能也发现了,虽然写的代码还不多,但到目前为止不论是基础数据类型还是复杂数据类型我们都在使用 ref
还没有用过 reactive
,因为实在找不到理由用它,建议大家能使用 ref
还是使用 ref
,在 ref
的源码实现中,如果传入的是对象等复杂类型,其实内部还会使用 reactive
实现响应式,使用 ref
传入一个基础数据类型,返回的是一个 RefImpl
类型对象,而传入复杂类型返回的则是和 reactive
一致都是 Proxy
类型对象。至于为何推荐 ref
,原因有下:
ref
创建的数据是显示调用,因为要使用就必须加 .value
,可以让我们一眼就能知道它是响应式数据,代码写多了不会和普通 JS
变量混淆,相当于一个响应式数据的类型检查,所以不要觉得这是个缺点,相反它是优点,代码写多了就知道多香了。
相比 reactive
局限更少,因为 reactive
创建的对象使用 ES6
解构会使响应性丢失。
const obj = reactive({
aaa: { a1: 1, a2: 2 },
bbb: 'bbb'
})
let { aaa, bbb } = obj
aaa = { a1: 11111 }
bbb = 'bbbbbb'
console.log(obj) // { aaa: { a1: 1, a2: 2 }, bbb: 'bbb' }
reactive
类型上和对象没有区别,不容易观察是否为响应式对象,包括修改响应式对象中属性值时都和普通对象一致,再一个大型项目里,时刻要观察一个对象是响应式对象还是普通对象很痛苦。
const obj1 = reactive({
aaa: { a1: 1, a2: 2 },
bbb: 'bbb'
})
const obj2 = {
aaa: { a1: 1, a2: 2 },
bbb: 'bbb'
}
// obj1.aaa
// obj2.aaa
在使用 watch
监听响应式数据时 reactive
创建的数据需要有箭头函数包裹,而 ref
则不需要。
const counter = ref(0)
watch(counter, (count) => {
console.log(count) // == count.value
})
Tips:更好的开发体验
开发时,比如我们定义了一个 ref
:
const count = ref(0)
console.log(count) // RefImpl {_rawValue: 0, _shallow: false, _v_isRef: true, _value: 0}
console.log(count.value) // 0
可以看到,直接输出 count
控制台的打印是 Vue
内部处理之后的对象,这个对象塞进去了很多开发时用不到的数据,我们必须加上 .value
,才会像普通 JS
一样打印值,很不直观。
如果你想让它的输出信息直观,Vue3
源码中有一个名为 initCustomFormatter
的函数,用来在开发环境下初始化自定义 formatter
。
开发时打开 Chrome DevrTools
,勾选 Console -> Enable custom formatters
选项,如下图:
接下来,我们再打印上面的 count
,就会变得非常直观了,如下:
console.log(count) // Ref<0>
赶紧自己设置设置尝试下吧!!!
最后
OK,就到这,下文开始写项目中的功能模块了!
截止本文的代码已经打了 Tag
发布,可下载查看:
👉🏻 项目 GitHub 地址