什么是深色主题
深色主题到底是什么?这个讲法其实很宽泛,我们以往见过不少深色调为主的界面配色方案。比如程序员非常熟悉的代码编辑器,各种深色皮肤早就大行其道。而我们今天要讨论的,主要是苹果近年来的产品中推出的深色主题功能,以及其在 Electron 中的应用实现。

苹果在 2017 年发布的 macOS High Sierra (10.13) 首先实验性地支持了深色顶栏和 dock,继而在 Mojave (10.14) 提供了完整的深色主题支持。接下来的 Catalina (10.15) 则增加了 Auto 选项,可以随一天中的时间变化自动切换系统主题。后续的大版本更新也不断优化这个功能。在移动端,iOS 13 开始加入深色主题功能。macOS 启用深色主题后,内置的应用会切换为深灰色为主的配色,字体则显示为浅灰色。

第三方应用如果适配了深色主题,也会根据设置改变自身的界面配色。

目前主流的 App 及网站都适配了深色主题,那么接下来我们看下用 Electron 开发一个客户端软件,如何实现深色主题吧。
自定义界面
Electron 允许开发者使用 Web 技术构建桌面应用程序,因此可以使用网页技术来判断当前页面是否该显示深色主题。
你可以通过 prefers-color-scheme CSS 媒体查询来实现此功能
1 2 3 4 5 6 7 8 9 10 11 12 13
| @media (prefers-color-scheme: dark) { body { background: #333; color: white; } }
@media (prefers-color-scheme: light) { body { background: #ddd; color: black; } }
|
用 Media Query 来判断用户当前的系统主题,然后在根节点中添加不同的 class 来区分主题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const isDark = window.matchMedia('(prefers-color-scheme: dark)');
document.body.classList.remove('dark', 'light'); document.body.classList.remove(isDark ? 'dark' : 'light');
## style.css .dark { background: #333; color: white; }
.light { background: #ddd; color: black; }
|
虽然用 CSS 和 JS 都可以实现渲染不同的系统主题,但是如果要实现用户自主选择主题,这是就需要 localStorage 来记住用户选择,因此用 JS 来实现更好。
在 Vue 框架中要更好的使用主题切换的话,可以将这部分 js 逻辑抽离成一个 hook,下面给出个示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
| import { ref, computed } from "vue";
export type BasicColorMode = "light" | "dark"; export type BasicColorSchema = BasicColorMode | "system";
interface UseThemeOptions {
selector?: string;
attribute?: string;
initialValue?: BasicColorSchema;
storageKey?: string;
storage?: Storage;
valueDark?: string;
valueLight?: string;
onChanged?: (dark: boolean, mode: BasicColorSchema) => void; }
export const useTheme = (options: UseThemeOptions = {}) => { const { selector = "html", attribute = "class", initialValue = "system", storageKey = "color-scheme", storage = localStorage, valueDark = "dark", valueLight = "light", onChanged, } = options;
const mode = ref<BasicColorSchema>(initialValue);
const checkSystemDark = () => window.matchMedia("(prefers-color-scheme: dark)").matches;
const checkDark = (colorSchema: BasicColorSchema) => { if (colorSchema === "system") { return checkSystemDark(); } return colorSchema === "dark"; };
const setThemeClass = (colorSchema: BasicColorSchema) => { const _isDark = checkDark(colorSchema); const isClass = attribute === "class"; const domTarget = document.querySelector(selector); const valueMode = _isDark ? valueDark : valueLight;
if (isClass) { domTarget?.classList.remove(valueDark, valueLight); domTarget?.classList.add(valueMode); } else { domTarget?.setAttribute(attribute, valueMode); } };
const setMode = (_mode: BasicColorSchema) => { mode.value = _mode; storage.setItem(storageKey, _mode); setThemeClass(_mode); onChanged?.(checkDark(_mode), _mode); };
const localMode = storage.getItem(storageKey); localMode && setMode(localMode as BasicColorSchema);
const isDark = computed(() => checkDark(mode.value));
return { isDark, mode, setMode }; };
|
默认情况下,使用 Tailwind CSS 偏好的深色模式,当将 class dark 应用于 html 标签时启用深色模式,例如:
1 2 3 4 5 6 7 8 9
| <html> ... </html>
<html class="dark"> ... </html>
|
不过,还可以对其进行自定义,使其与大多数 CSS 框架兼容。例如:
1 2 3 4 5 6
| const { isDark } = useTheme({ selector: "body", attribute: "color-scheme", valueDark: "dark", valueLight: "light", });
|
最终会呈现
1 2 3 4 5 6 7 8 9
| <!--light--> <html> <body color-scheme="light"> ... </body> </html>
<!--dark--> <html> <body color-scheme="dark"> ... </body> </html>
|
如果上面的配置任然不满足你的需求,可以使用 onChange 完全控制你的主题切换
1 2 3 4 5
| const { isDark } = useTheme({ onChanged(dark: boolean) { }, });
|
如何在组件中使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <script setup lang="ts"> import { useTheme } from "./hooks/useTheme";
const { mode, isDark, setMode } = useTheme();
const resetMode = () => setMode("system"); const checkMode = () => setMode(isDark.value ? "light" : "dark"); </script>
<template> <div> <p> Current theme source: <strong>{{ mode }}</strong> </p> <button type="button" @click="checkMode">Toggle Dark Mode</button> <button type="button" @click="resetMode">Reset to System Theme</button> </div> </template>
|
切换效果如下:

原生界面
在切换主题时,会发现有部分页面并不会跟着切换,这是因为整个页面由两部分组成,原生界面和自定义界面,原生界面又分为标题栏和工具栏,这部分不能用 web 技术来操控,需要用系统的 API。

首先在 preload.js 中,暴露两个 IPC 通道到渲染器进程中
1 2 3 4
| const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('darkMode', { toggle: () => ipcRenderer.invoke('dark-mode:toggle'), system: () => ipcRenderer.invoke('dark-mode:system') })
|
然后在主进程中添加事件处理函数,用 nativeTheme API 修改系统样式
1 2 3 4 5 6 7 8 9 10 11 12
| const { app, BrowserWindow, ipcMain, nativeTheme } = require('electron') const path = require('node:path') const createWindow = () => { const win = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, 'preload.js') } }) win.loadFile('index.html') ipcMain.handle('dark-mode:toggle', () => { if (nativeTheme.shouldUseDarkColors) { nativeTheme.themeSource = 'light' } else { nativeTheme.themeSource = 'dark' } return nativeTheme.shouldUseDarkColors }) ipcMain.handle('dark-mode:system', () => { nativeTheme.themeSource = 'system' }) } app.whenReady().then(() => { createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } })
|
最后在渲染进程中通过 window.darkMode 发送消息到主进程中
1 2 3
| const { mode, isDark, setMode } = useTheme({ onChanged: (dark, _mode) => { if (_mode === 'system') { window.darkMode.system() } else { window.darkMode.toggle() } } });
|
现在还有个问题,每次启动时默认的都是系统主题,因此需要将用户选择的主题保存到本地,我们使用 electron-store 来保存用户配置(你也可以使用任何你喜欢的存储方式)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import { nativeTheme } from "electron"; import Store from "electron-store";
type ThemeColor = typeof nativeTheme.themeSource;
const store = new Store<{ theme: ThemeColor }>({ defaults: { theme: "system", }, });
export const getThemeStore = () => store.get("theme");
export const setThemeStore = (theme: ThemeColor) => store.set("theme", theme);
|
然后在主进程中,根据用户配置,重置默认主题
1 2
| const themeColor = getThemeStore(); nativeTheme.themeSource = themeColor;
|
整合一下
现在渲染进程和主进程都分别实现了主题的切换并且都能根据用户的配置选择默认主题。但是这里有些问题:
- 主题配置在主进程和渲染进程都保存了一遍有些冗余
- 主题的切换过程中数据流向有些混乱,最好能保持单向数据流。
因此最终的版本中我们删掉渲染进程的配置保存,将用户选择的主题保存到主进程中,最后梳理出的流程图如下:

整合之后的效果如下,示例代码:https://github.com/bijinfeng/electron-vue-theme-switch

总结
Electron 使用了 web 技术栈渲染自定义界面,因此在自定义界面实现暗黑主题和在网页端没有区别,技术都是通用的。但是 Electron 中还有原生界面存在,虽然有提供 API 去修改主题,但是要和自定义界面做到同步修改,整合两边的技术方案也是一个比较麻烦的事,这里提供一种技术实现思路。