一、为什么需要深色模式自动切换

现在很多操作系统都支持深色模式了,比如Windows 10/11的夜间模式、macOS的暗色主题。作为一款现代化的桌面应用,如果能够跟随系统自动切换深色/浅色模式,用户体验会好很多。想象一下,当用户把系统切换到深色模式时,你的应用也能自动变成暗色系,这种无缝衔接的感觉特别棒。

Electron作为跨平台桌面应用开发框架,天然支持这个功能。不过要实现得优雅,还是需要一些技巧的。下面我们就来详细聊聊具体怎么做。

二、检测系统主题变化的核心API

Electron提供了nativeTheme模块来获取和监听系统主题变化。这个模块在渲染进程和主进程都能使用,但最佳实践是在主进程监听,然后通知渲染进程。

// 主进程代码 (main.js)
const { app, BrowserWindow, nativeTheme } = require('electron')

let mainWindow

app.whenReady().then(() => {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false
    }
  })
  
  // 监听系统主题变化
  nativeTheme.on('updated', () => {
    updateTheme()
  })
  
  function updateTheme() {
    const isDarkMode = nativeTheme.shouldUseDarkColors
    mainWindow.webContents.send('theme-changed', isDarkMode)
  }
  
  // 初始化时发送一次当前主题
  updateTheme()
})

三、渲染进程的主题切换实现

收到主进程的通知后,渲染进程需要做两件事:修改CSS样式和保存用户偏好。这里我们使用CSS变量来实现主题切换,这样维护起来更方便。

// 渲染进程代码 (renderer.js)
const { ipcRenderer } = require('electron')

// 监听主题变化
ipcRenderer.on('theme-changed', (event, isDarkMode) => {
  setTheme(isDarkMode)
  saveThemePreference(isDarkMode)
})

function setTheme(isDarkMode) {
  const root = document.documentElement
  
  if (isDarkMode) {
    root.style.setProperty('--bg-color', '#1e1e1e')
    root.style.setProperty('--text-color', '#ffffff')
    root.style.setProperty('--primary-color', '#64b5f6')
  } else {
    root.style.setProperty('--bg-color', '#ffffff')
    root.style.setProperty('--text-color', '#333333')
    root.style.setProperty('--primary-color', '#1976d2')
  }
}

function saveThemePreference(isDarkMode) {
  localStorage.setItem('darkMode', isDarkMode)
}

// 初始化时检查本地存储的用户偏好
const savedMode = localStorage.getItem('darkMode')
if (savedMode !== null) {
  setTheme(savedMode === 'true')
}

对应的CSS可以这样写:

/* styles.css */
:root {
  --bg-color: #ffffff;
  --text-color: #333333;
  --primary-color: #1976d2;
}

body {
  background-color: var(--bg-color);
  color: var(--text-color);
  transition: all 0.3s ease;
}

button {
  background-color: var(--primary-color);
  color: white;
}

四、处理用户自定义主题偏好

有时候用户可能想覆盖系统设置,单独为你的应用指定主题。这时候我们需要在设置界面添加切换选项,并处理好与系统自动切换的优先级关系。

// 设置界面代码 (settings.js)
document.getElementById('dark-mode-toggle').addEventListener('change', (e) => {
  const useDarkMode = e.target.checked
  ipcRenderer.send('set-theme-manually', useDarkMode)
  localStorage.setItem('theme-override', useDarkMode ? 'dark' : 'light')
})

// 主进程处理手动设置
ipcMain.on('set-theme-manually', (event, useDarkMode) => {
  nativeTheme.themeSource = useDarkMode ? 'dark' : 'light'
  updateTheme()
})

这里有个关键点:nativeTheme.themeSource可以设置为darklightsystem。当设置为system时会跟随系统,设置为另外两个值则会覆盖系统设置。

五、完整实现方案的最佳实践

结合前面几点,这里给出一个更完整的实现方案:

  1. 应用启动时,检查本地存储是否有用户手动设置的主题偏好
  2. 如果没有手动设置,则跟随系统主题
  3. 监听系统主题变化,但如果用户有手动设置则忽略
  4. 提供设置界面允许用户切换"跟随系统"或手动选择主题
// 增强版主进程代码
const { app, BrowserWindow, nativeTheme, ipcMain } = require('electron')
const Store = require('electron-store')

const schema = {
  theme: {
    type: 'string',
    enum: ['system', 'dark', 'light'],
    default: 'system'
  }
}

const store = new Store({ schema })

app.whenReady().then(() => {
  // 应用启动时应用存储的主题设置
  applyThemeSetting()
  
  // 监听系统主题变化(仅在theme=system时生效)
  nativeTheme.on('updated', () => {
    if (store.get('theme') === 'system') {
      updateTheme()
    }
  })
  
  // 处理渲染进程的主题设置请求
  ipcMain.handle('get-theme', () => {
    return {
      preference: store.get('theme'),
      current: nativeTheme.shouldUseDarkColors
    }
  })
  
  ipcMain.on('set-theme', (event, theme) => {
    store.set('theme', theme)
    applyThemeSetting()
  })
})

function applyThemeSetting() {
  const theme = store.get('theme')
  nativeTheme.themeSource = theme === 'system' ? 'system' : theme
  updateTheme()
}

function updateTheme() {
  const isDarkMode = nativeTheme.shouldUseDarkColors
  BrowserWindow.getAllWindows().forEach(win => {
    win.webContents.send('theme-changed', isDarkMode)
  })
}

六、可能遇到的问题和解决方案

  1. Flash of Unstyled Content (FOUC)
    在页面加载初期可能会有短暂的主题闪烁。解决方法是在HTML中内联关键CSS,或者使用preload脚本在页面加载前设置初始主题。
// preload.js
const { ipcRenderer } = require('electron')

document.addEventListener('DOMContentLoaded', () => {
  ipcRenderer.send('get-initial-theme')
})

ipcRenderer.on('set-initial-theme', (event, isDarkMode) => {
  const root = document.documentElement
  // 和内联CSS相同的逻辑
})
  1. 第三方UI库的兼容性
    如果你使用了像Material-UI这样的UI库,它们可能有自己的主题系统。这时需要将Electron的主题变化传递给这些库。
// 对于Material-UI
import { createTheme, ThemeProvider } from '@material-ui/core/styles'

function App() {
  const [darkMode, setDarkMode] = useState(false)
  
  useEffect(() => {
    window.electron.onThemeChanged(setDarkMode)
    return () => window.electron.offThemeChanged(setDarkMode)
  }, [])
  
  const theme = createTheme({
    palette: {
      type: darkMode ? 'dark' : 'light'
    }
  })
  
  return (
    <ThemeProvider theme={theme}>
      {/* 你的组件 */}
    </ThemeProvider>
  )
}

七、进阶技巧:根据时间自动切换

除了跟随系统设置,我们还可以实现根据日出日落时间自动切换。这需要使用地理位置和日出日落时间API。

// 主进程中
const SunCalc = require('suncalc')

function calculateSunTimes() {
  // 获取用户位置(简化示例,实际需要用户授权)
  const latitude = 39.9042 // 北京
  const longitude = 116.4074
  
  const times = SunCalc.getTimes(new Date(), latitude, longitude)
  return {
    sunrise: times.sunrise,
    sunset: times.sunset
  }
}

function startThemeScheduler() {
  const { sunrise, sunset } = calculateSunTimes()
  const now = new Date()
  
  if (now > sunset || now < sunrise) {
    nativeTheme.themeSource = 'dark'
  } else {
    nativeTheme.themeSource = 'light'
  }
  
  // 每天重新计算
  setInterval(() => {
    const now = new Date()
    if (now.getHours() === 0 && now.getMinutes() === 0) {
      startThemeScheduler()
    }
  }, 60 * 1000)
}

八、总结与最佳实践建议

实现一个完善的深色模式自动切换功能需要考虑很多细节。以下是我的几点建议:

  1. 始终尊重用户选择,提供"跟随系统"、"始终深色"、"始终浅色"三个选项
  2. 使用CSS变量管理主题颜色,方便维护和扩展
  3. 处理好初始加载时的主题闪烁问题
  4. 对于复杂应用,考虑使用状态管理工具(如Redux)来管理主题状态
  5. 测试在不同操作系统下的表现,特别是边界情况

深色模式不仅仅是颜色反转,更是一种用户体验的全面提升。通过Electron提供的API,我们可以相对容易地实现这个功能,让应用看起来更专业、用起来更舒适。