Vue3 Pinia TypeScript TDesign 实现黑暗模式

最近各种应用竞相支持黑暗模式(或称夜间模式),浏览器也增加了检测用户操作系统主题配色的支持,因此网页也可以很好的支持黑暗模式的开发了,本文将在TDesign组件库基础上实现黑暗模式的切换,并支持跟随操作系统主题模式自动切换,在具体的黑暗模式实现上不做过多展开,而仅对黑暗模式的用户设置实现展开描述。

实现方式

在TDesign中,有主题和主题模式的区分,主题切换是指各种主要配色例如按钮颜色、链接颜色等页面中的主要元素的颜色搭配切换,主题模式则是指明亮模式和黑暗模式的切换,换句话说,主题+模式可以是蓝色明亮也可以是蓝色黑暗,可以是红色明亮也可以是红色黑暗。

具体的主题和模式的更换TDesign是采用了css变量实现的,参考文档如下:

主题配置 | TDesign (tencent.com)

暗色模式 | TDesign (tencent.com)

简单来说,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 值重新计算。

至此,就完美实现了对黑暗模式的支持,效果如下:

留下评论

您的电子邮箱地址不会被公开。

Captcha Code