最近各种应用竞相支持黑暗模式(或称夜间模式),浏览器也增加了检测用户操作系统主题配色的支持,因此网页也可以很好的支持黑暗模式的开发了,本文将在TDesign组件库基础上实现黑暗模式的切换,并支持跟随操作系统主题模式自动切换,在具体的黑暗模式实现上不做过多展开,而仅对黑暗模式的用户设置实现展开描述。
实现方式
在TDesign中,有主题和主题模式的区分,主题切换是指各种主要配色例如按钮颜色、链接颜色等页面中的主要元素的颜色搭配切换,主题模式则是指明亮模式和黑暗模式的切换,换句话说,主题+模式可以是蓝色明亮也可以是蓝色黑暗,可以是红色明亮也可以是红色黑暗。
具体的主题和模式的更换TDesign是采用了css变量实现的,参考文档如下:
简单来说,TDesign中,只要页面html标签上 theme-mode
属性为 dark
,则页面将采用黑暗配色,当该属性为 light
或不存在时,则页面采用明亮模式。
因此,页面需要存储用户的模式设置,故采用Pinia进行存储,一般来说用户可以选择明亮模式或黑暗模式,或是跟随操作系统,因此创建 useThemeSettingsStore.ts
存储用户设置的模式,并提供计算属性方便快速获取实际应该显示的模式:
import { defineStore } from "pinia"; import { ref } from "vue"; import type { Ref } from "vue"; export const useThemeSettingsStore = defineStore( "themeSettings", () => { const theme: Ref<string> = ref("default"); /** * 主题模式(不对外开放的) */ const mode: Ref<"dark" | "light" | "auto"> = ref("auto"); /** * 获取实际显示主题模式 */ const displayMode = computed((): "dark" | "light" => { if (mode.value === "auto") { const media = window.matchMedia("(prefers-color-scheme:dark)"); if (media.matches) { return "dark"; } return "light"; } return mode.value; }); /** * 获取当前主题模式是否是黑暗模式 */ const isDarkMode = computed(() => displayMode.value === "dark"); return { theme, mode, displayMode, isDarkMode }; } );
当用户选择跟随系统设置时,也就是模式为“auto”时,实际显示模式应该根据浏览器的环境判断得出,检测系统主题色的方式是根据CSS媒体特性 prefers-color-scheme
进行查询得出的,相关文档如下:
prefers-color-scheme – CSS(层叠样式表) | MDN (mozilla.org)
因此实际显示模式 displayMode
应该是一个计算属性,不仅受用户设置影响,而且受浏览器环境影响。
目前为止,仅实现了用户的设置如何存储的问题,如何跟随用户设置修改实际页面显示还没有实现。这里为了划分职责,不在store中修改实际的页面模式。
新建 themeMode.ts
文件如下,并将其挂载到 app.vue
上(或是其它需要的位置):
import { useThemeSettingsStore } from "@/stores/themeSettings"; import { onMounted, onUnmounted, watch } from "vue"; import { storeToRefs } from "pinia"; export function useThemeMode() { const settingsStore = useThemeSettingsStore(); const { mode, displayMode } = storeToRefs(settingsStore); /** * 修改html的主题模式 * @param newMode 变更后的模式 */ const changeActualMode = (newMode: "dark" | "light") => { const isDarkMode = newMode === "dark"; document.documentElement.setAttribute( "theme-mode", isDarkMode ? "dark" : "" ); }; /** * 监听用户主题模式设置改变 */ watch(displayMode, changeActualMode); onMounted(() => { //首次打开时根据用户设置设置主题模式 changeActualMode(displayMode.value); }); return { displayMode }; }
其主要功能就是监听 ThemeSettingsStore
中的 displayMode
值改变,当 displayMode
值改变时,则修改页面实际显示模式。
优化改进
类型检查
在以上代码中有一些问题,第一个问题就是 mode
变量的合法值只有“light”、“dark”、“auto”,但由于TypeScript仅能在编译时发现类型问题,在运行时则无法对类型进行检查,故很容易在运行时被改变为其它值导致其它代码运行时出现错误,一个很自然的想法是在对 mode
修改时再次进行类型检查,若是非法值则不进行修改。不难想到采用getter、setter这样的方式进行改造,然而 mode
变量是Ref类型,采用JS原生的getter、setter无法进行改造,幸好在Vue3中,提供了可写计算属性,算是变相的getter、setter实现,采用可写计算属性改写后的代码如下:
import { defineStore } from "pinia"; import { computed, ref } from "vue"; import type { Ref } from "vue"; export const useThemeSettingsStore = defineStore( "themeSettings", () => { const theme: Ref<string> = ref("default"); /** * 主题模式(不对外开放的) */ const __mode: Ref<"dark" | "light" | "auto"> = ref("auto"); /** * 主题模式(对外部修改开放的) */ const mode = computed({ get: () => __mode.value, set: (value: string) => { if (value !== "dark" && value !== "light" && value !== "auto") { return; } __mode.value = value; }, }); /** * 获取实际显示主题模式 */ const displayMode = computed((): "dark" | "light" => { if (mode.value === "auto") { const media = window.matchMedia("(prefers-color-scheme:dark)"); if (media.matches) { return "dark"; } return "light"; } return mode.value; }); /** * 获取当前主题模式是否是黑暗模式 */ const isDarkMode = computed(() => displayMode.value === "dark"); return { theme, mode, displayMode, isDarkMode, __mode}; } );
这样,我们就可以在修改 mode
时对修改操作进行检查,防止非法值出现了。在组件中使用时,也可以像正常的变量一样进行绑定、修改:
//someComponent.vue <template></template> <script setup> import { useThemeSettingsStore } from "@/stores/themeSettings"; import { storeToRefs } from "pinia"; const settingsStore = useThemeSettingsStore(); const { mode, displayMode, isDarkMode } = storeToRefs(settingsStore); </script> <style scoped></style>
自动切换
另一个问题则会发生在用户操作系统主题模式改变时。当用户操作系统主题模式改变时,若此时用户的模式设置为“auto”,则页面应该跟随用户操作系统主题模式改变。而实际情况是,由于页面依赖 displayMode
进行主题切换,当用户操作系统主题模式改变时,由于 mode
的值仍为 “auto”, 故 displayMode
值实际不会发生改变,因而也不会切换页面的实际显示模式,所以当用户操作系统主题模式改变时页面显示模式不会跟随改变,仅当刷新页面后才会进行改变。
显然,这是一个不好的体验,我们必须想办法在用户操作系统主题模式改变时通知 displayMode
值重新计算。为了监听用户操作系统主题模式改变,我们可以通过 addEventListener
添加监听器,在 themeMode.ts
文件中修改如下(至于为何将 mode
的值重新设置一下即可通知 displayMode
值重新计算见下文):
import { useThemeSettingsStore } from "@/stores/themeSettings"; import { onMounted, onUnmounted, watch } from "vue"; import { storeToRefs } from "pinia"; export function useThemeMode() { const settingsStore = useThemeSettingsStore(); const { mode, displayMode } = storeToRefs(settingsStore); /** * 修改html的主题模式 * @param newMode 变更后的模式 */ const changeActualMode = (newMode: "dark" | "light") => { const isDarkMode = newMode === "dark"; document.documentElement.setAttribute( "theme-mode", isDarkMode ? "dark" : "" ); }; /** * 监听用户主题模式设置改变 */ watch(displayMode, changeActualMode); /** * 浏览器主题模式变化时通知store更新计算值 */ const onBrowserThemeModeChanged = () => { if (mode.value === "auto") { // 通知 displayMode 值重新计算 mode.value = "auto"; } }; onMounted(() => { //添加系统主题模式监听器 window .matchMedia("(prefers-color-scheme: dark)") .addEventListener("change", onBrowserThemeModeChanged); //首次打开时根据用户设置设置主题模式 changeActualMode(displayMode.value); }); onUnmounted(() => { //移除系统主题模式监听器 window .matchMedia("(prefers-color-scheme: dark)") .removeEventListener("change", onBrowserThemeModeChanged); }); return { displayMode }; }
如何通知 displayMode
值重新计算呢?
在Vue中,计算属性的值会被缓存,当计算属性依赖的值未发生改变时,则不再重新计算,直接使用缓存的值。因此,只要 displayMode
值依赖的属性变更了,其就会被重新计算。目前, displayMode
值只依赖 mode
,而用户操作系统主题模式改变时 mode
是不会发生变化的。既然如此,不妨为 displayMode
值添加一个其它依赖,当需要更新 displayMode
值时,修改添加的依赖值即可,具体实现如下:
import { defineStore } from "pinia"; import { computed, ref } from "vue"; import type { Ref } from "vue"; export const useThemeSettingsStore = defineStore( "themeSettings", () => { const theme: Ref<string> = ref("default"); /** * 主题模式(不对外开放的) */ const __mode: Ref<"dark" | "light" | "auto"> = ref("auto"); /** * 计数器,用于手动更新计算属性 */ const __counter = ref(0); /** * 主题模式(对外部修改开放的) */ const mode = computed({ get: () => __mode.value, set: (value: string) => { if (value !== "dark" && value !== "light" && value !== "auto") { return; } __mode.value = value; // 通知 displayMode 值重新计算 __counter.value = (__counter.value + 1) % 10; }, }); /** * 获取实际显示主题模式 */ const displayMode = computed((): "dark" | "light" => { if (__counter.value == null) { console.log("添加计数器以支持跟随系统更换主题模式!"); } if (mode.value === "auto") { const media = window.matchMedia("(prefers-color-scheme:dark)"); if (media.matches) { return "dark"; } return "light"; } return mode.value; }); /** * 获取当前主题模式是否是黑暗模式 */ const isDarkMode = computed(() => displayMode.value === "dark"); return { theme, mode, displayMode, isDarkMode, __mode, __counter }; } );
在这里,通知 displayMode
值重新计算操作放在了 mode
的写操作中,当想要通知 displayMode
值重新计算时仅需对 mode
进行赋值操作即可,也可以单独写一个方法修改 __counter
通知 displayMode
值重新计算。
至此,就完美实现了对黑暗模式的支持,效果如下:
