最近各种应用竞相支持黑暗模式(或称夜间模式),浏览器也增加了检测用户操作系统主题配色的支持,因此网页也可以很好的支持黑暗模式的开发了,本文将在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实现,采用可写计算属性改写后的代码如下:
这样,我们就可以在修改 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
值重新计算。
至此,就完美实现了对黑暗模式的支持,效果如下: