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

模式切换实战

处理基础数据

和可切换布局一样,还是要先处理数据,我们需要先写一下模式列表,因为后面还要做持久化,也为了这些全局系统配置信息的统一,我们还是写在 pinia system 模块中。

修改 stores/system.js 文件如下:

jsx
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,模式列表数组我们直接写死了,除了 lightdark 之外,我们还写了一个 auto,它代表使用操作系统偏好主题模式,同时在 iconify 图标库找了几个对应的图标引入(图标库配置请看第一篇文章),由于直接在 JS 中引入的图标组件,所以还是得手动引入不能自动引入,我们还是使用 markRaw 方法标记一下该组件不被做响应式处理来避免不必要的开销,OK,模式列表就写好了。

我们写了一个初始化模式的方法 initMode,内部逻辑其实和上文布局初始化方法一样,同时我们也给当前模式对象 currentModename 属性做了持久化处理。

最后把这几个主题模式相关的属性和方法 return 出去即可,接下来我们写模式切换组件 SwitchMode 会用到。

模式切换组件 SwitchMode

写下模式切换组件,在 src/layout/components 文件夹下新增 SwitchMode.vue 文件,写入下面内容:

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

VuestoreToRefs 方法以及 VueUseuseCycleList 方法我们上文都有介绍,这里不多做解释了,不了解可以回顾下上文,唯一不同的是模式切换组件中写了模式初始化方法,而之前布局初始化我们实在 SwitchIndex 可切换布局入口文件中写的,这是因为布局未渲染之前不会加载布局切换组件,所以我们必须得在入口处先调用布局初始化方法,而模式初始化不存在这个问题,因为只要布局加载了,就会渲染模式切换组件,在模式切换组件中初始化模式也就没问题。

接下来我们使用一下 SwitchMode 组件,因为配置了自动引入所以无需引入组件直接使用即可,两个布局组件里都需要使用,放在 Navbar 组件右侧插槽中。

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

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

    <template #right>
			<!-- 新增 -->
			<SwitchMode />
      <SwitchLayout />
      <Github />
    </template>
  </Navbar>
</a-layout-header>

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

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

    <template #right>
			<!-- 新增 -->
			<SwitchMode />
      <SwitchLayout />
      <Github />
    </template>
  </Navbar>
</a-layout-header>

OK,保存刷新下页面:

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

如上图,我们使用之后导航栏有个模式切换菜单,点击切换即可切换模式,切换的当前模式对象 name 已经缓存到浏览器中。但是点击切换页面并没有什么改变,因为我们还没做切换的逻辑处理,接下来我们在 pinia system 模块中做一下修改,为了方便呢,我们这里借助 VueUseuseColorMode 方法去做切换逻辑,后面会给大家介绍方法,如下:

jsx
// ...

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']
    }
  }
)

其实上面代码中我们只增加了下面这段代码:

jsx
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 - 自定义存储引用,如果提供,将跳过 useStorageuseStorageVueUse 中一个做持久化的方法。
  • storageKey - 将数据持久化到 localStorage/sessionStorage 的密钥(key),传 null 则禁用持久化。
  • storage - 存储对象,可以是 localStoragesessionStorage,默认 localStorage
  • emitAuto - 从状态切换为 auto ,选项设置为 true 时,首选模式不会转换为 lightdark,当我们需要知道 auto 状态下的模式值时会很有用。
  • onChanged - 用于处理更新的自定义处理程序,指定后,将覆盖默认行为。

useColorMode 方法内也可以做数据持久化,但是我们将 storageKey 属性设置为 null,统一在 pinia 中做持久化,由于我们使用 ArcoDesignCSS 变量,所以遵循 UI 库的方式将目标元素即 selector 设置为 body,目标元素的自定义属性 attribute 设置为 arco-theme

useColorMode 返回的 mode 默认值为 light、dark,切换模式如下:

jsx
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 是一个方法,调用该方法传入模式标识符即切换模式,如下(只是例子,和项目无关):

jsx
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 模块代码:

jsx
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 模式:

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

我这边系统设置的黑色主题,所以 auto 模式同 dark 模式:

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

OK,到此我们的模式切换就搞定了!

如上,我们搞了黑白两个模式,其实都是围绕着一个主题色来的,由于我们直接使用了组件库颜色变量,所以这个主题色就是组件库的主题色。如果我们要再写一套主题色做主题色切换,其实和模式切换差不多,但是一套主题色我们需要设计两套颜色来适配黑白模式,这样说大家能够理解主题和模式的区别吗🙄?

Tips:多用 Ref 少用 Reactive

大家可能也发现了,虽然写的代码还不多,但到目前为止不论是基础数据类型还是复杂数据类型我们都在使用 ref 还没有用过 reactive ,因为实在找不到理由用它,建议大家能使用 ref 还是使用 ref ,在 ref 的源码实现中,如果传入的是对象等复杂类型,其实内部还会使用 reactive 实现响应式,使用 ref 传入一个基础数据类型,返回的是一个 RefImpl 类型对象,而传入复杂类型返回的则是和 reactive 一致都是 Proxy 类型对象。至于为何推荐 ref,原因有下:

ref 创建的数据是显示调用,因为要使用就必须加 .value,可以让我们一眼就能知道它是响应式数据,代码写多了不会和普通 JS 变量混淆,相当于一个响应式数据的类型检查,所以不要觉得这是个缺点,相反它是优点,代码写多了就知道多香了。

相比 reactive 局限更少,因为 reactive 创建的对象使用 ES6 解构会使响应性丢失。

jsx
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 类型上和对象没有区别,不容易观察是否为响应式对象,包括修改响应式对象中属性值时都和普通对象一致,再一个大型项目里,时刻要观察一个对象是响应式对象还是普通对象很痛苦。

jsx
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 则不需要。

jsx
const counter = ref(0)

watch(counter, (count) => {  
	console.log(count) // == count.value
})

Tips:更好的开发体验

开发时,比如我们定义了一个 ref

jsx
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 选项,如下图:

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

接下来,我们再打印上面的 count ,就会变得非常直观了,如下:

jsx
console.log(count) // Ref<0>

赶紧自己设置设置尝试下吧!!!

最后

OK,就到这,下文开始写项目中的功能模块了!

截止本文的代码已经打了 Tag 发布,可下载查看:

👉🏻 toolsdog tag v0.0.3-dev

👉🏻 项目 GitHub 地址

不正经的前端