> ## Content Index
> Fetch the complete content index at: https://onflow.wayne-wu.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# 有趣的黑暗模式切换效果
- URL: https://onflow.wayne-wu.com/-e6-9c-89-e8-b6-a3-e7-9a-84-e9-bb-91-e6-9a-97-e6-a8-a1-e5-bc-8f-e5-88-87-e6-8d-a2-e6-95-88-e6-9e-9c/
- Published: 2023-01-01T19:27:00.000Z
- Updated: 2025-09-12T07:57:41.000Z
- Description: 上面的动图是 Bilibili Ma…
- Author: Wayne Wu
- Tags: #wayne-2, Style & CSS, #wp, #wp-post, #Import 2026-08-22 02:29

![](https://onflow.wayne-wu.com/content/files/static-wayne-wu-com/wp-content/uploads/2023/01/bilibili_2022-12-27-22_30_23/imageMogr2/auto-orient/format/webp/quality/75-imagemogr2-auto-orient-format-webp-quality-75.gif)

上面的动图是 Bilibili Mac 客户端的黑暗模式切换效果，颜色在点击处进行分散和聚拢，整体界面的过渡十分平滑，没有分割感。目前很多产品的切换效果只是整体的变化，或许为了表现更自然一点，会加上时间的过渡。但这种新颖有趣的黑暗模式切换效果总体还是非常少见，我打算复刻一下。或许别的应用也有类似的效果，但在这里我以 Bilibili Mac 客户端为目标效果。

> 本篇文章将用前端技术（Electron）实现效果，不考虑 Bilibili 选用的技术栈（虽然我推测 Bilibili 也用了 Electron）。

## 如何实现这种效果

下图显示了切换效果某一刻的样子，我们从这一帧可以提取很多信息。

![](https://onflow.wayne-wu.com/content/images/static-wayne-wu-com/wp-content/uploads/2023/01/image-2-1024x639/imageMogr2/auto-orient/format/quality/75-imagemogr2-auto-orient-format-quality-75.webp)

我们将 UI 分为不同的元素和块，在 WEB 技术中等同于 HTML 的各个 Element。图中颜色变化不区分元素，即不是单个元素的变化。我们不能简单的设置每个元素的颜色，于是我们考虑这么实现：

在黑暗模式按钮点击时，将切换前的窗口视图进行截图，并覆盖在窗口最上层以遮挡实际内容，然后将实际的窗口内容立刻切换到另一个效果，此时窗口看起来还是切换前的效果，接着我们将截图形状不断进行裁剪，慢慢露出遮挡下的内容，直到实际窗口完整显示出来。

## 代码的具体实现

> 以下实现基于 Electron，并且我假设你了解并熟悉 Electron，关于具体 Electron 细节并不会有太多说明。以下代码中，`main.js` 指主进程代码，`preload.js` 为预加载脚本，`renderer.js` 渲染进程 HTML 页面的 JS 代码。

我们使用 Electron 按照官网示例新建一个窗口，主线程代码是这样的（你完全可以从官网拿到基本相同的代码）：

```
// main.js
const { app, BrowserWindow } = require('electron')
const path = require('path')

let mainWindow;

function createWindow () {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    titleBarStyle: 'hidden',
  })

  mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
  createWindow()

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

```

同时窗口加载 HTML 页面，我们给页面加点内容，完成整体的布局和样式（包括黑暗模式样式），将模式切换按钮放置在左下角：

![](https://onflow.wayne-wu.com/content/images/static-wayne-wu-com/wp-content/uploads/2023/01/image-1-1024x772/imageMogr2/auto-orient/format/quality/75-imagemogr2-auto-orient-format-quality-75.webp)

Electron 的窗口对象向外提供了一个 [capturePage](https://www.electronjs.org/docs/latest/api/web-contents?ref=onflow.wayne-wu.com#contentscapturepagerect) 接口，利用该接口可以获取窗口的截图。该接口调用运行在主进程中，我们通过预加载脚本将截图方法提供给渲染进程使用。

```
// preload.js
const { contextBridge, ipcRenderer } = require('electron')

// 向渲染进程 window 对象注入 APP 对象，该对象下有 capturePage 方法
contextBridge.exposeInMainWorld('APP', {
  capturePage: () => ipcRenderer.invoke('ACTION:CAPTURE_PAGE')
})

// main.js
// 修改创建窗口方法
function createWindow () {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    // 添加 webPreferences 属性，将预加载脚本注入程序中
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    },
    titleBarStyle: 'hidden',
  })

  mainWindow.loadFile('index.html')
}

// 监听 ACTION:CAPTURE_PAGE 事件，截图后转为 base64 向渲染进程传递
ipcMain.handle('ACTION:CAPTURE_PAGE', () => {
  return mainWindow.webContents.capturePage()
    .then(page => page.toDataURL())
})

```

至此，主进程的代码已全部完成，剩下的我们来实现渲染端的效果。给 HTML 添加 Canvas 标签，并设定一些基本的样式：

```
<!-- index.html -->
<style>
#canvas {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  /* 首先是不显示的 */
  display: none;
  width: 100%;
  height: 100%;
  z-index: 9999;
}
</style>

<canvas id="canvas" />

```

HTML 侧代码也完成了，剩下的就是如何实现整体效果了。我们给按钮元素添加点击事件来处理模式切换。

```
// renderer.js
const btn = document.querySelector('#switcher') // 模式切换按钮

let isDarkMode = false

const toggleTheme = () => {
  if (isDarkMode) {
    document.querySelector('html').classList.remove('dark')
    isDarkMode = false
    return
  }
  document.querySelector('html').classList.add('dark')
  isDarkMode = true
}

btn.addEventListener('click', ({ clientX, clientY }) => {
  toggleTheme()
})

```

以上代码仅仅实现了不同模式的切换，还没有任何动画效果。为了实现动画效果，我们应该在切换模式前（调用 `toggleTheme` 方法前）完成一些工作：获取窗口截图，并绘制在 Canvas 中，然后悬浮在页面最顶层。

```
// renderer.js
const btn = document.querySelector('#switcher') // 模式切换按钮

btn.addEventListener('click', () => {
  window.APP.capturePage()
    .then(loadImage)
    .then(img => {
      // 先调用 spread 方法
      spread(img)
      toggleTheme()
    })
})

const canvas = document.querySelector('#canvas')
const ctx = canvas.getContext('2d')

const spread = (img) => {
  const w = window.innerWidth
  const h = window.innerHeight
  canvas.width = w
  canvas.height = h
  canvas.style.display = 'block'
  ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, w, h)
}

const loadImage = src => {
  const img = new Image()
  return new Promise((resolve, reject) => {
    img.onload = () => resolve(img)
    img.onerror = reject
    img.src = src
  })
}

```

此时只是单纯的把截图绘制出来覆盖在页面上，即使页面已经切换模式，页面看起来与切换前没有变化，接着我们只剩下不断裁剪 Canvas 的工作了。

为了实现 Canvas 的裁剪，我们需要用到绘制上下文的 `clip` 方法和 **非零与奇偶环绕规则**。`clip` 方法可以将 Canvas 裁剪出一个特定区域，后续任何绘制操作都不会影响区域以外的地方；关于 **非零与奇偶环绕规则** 可以参考[这篇文章](https://zhuanlan.zhihu.com/p/113411760?ref=onflow.wayne-wu.com)，这里不过多赘述。

我们期望以鼠标点击处为圆心裁剪一个圆形，并将图像绘制在圆形当中，通过不断裁剪以达到 Canvas 分散或聚集的效果，因此可以这么实现：

```
// renderer.js
const spread = (img, { x, y, reverse }) => {
  // x, y 为点击位置
  // reverse 控制发散还是收缩
  return new Promise(resolve => {
    const w = window.innerWidth
    const h = window.innerHeight
    canvas.width = w
    canvas.height = h
    canvas.style.display = 'block'
    // 这里我们通过 getMaxRadius 获取最大的圆形半径
    const radius = Math.ceil(getMaxRadius(x, y))
    const now = performance.now()
    const DURATION = 400
    // 使用 requestAnimationFrame 接口去操作Canvas，以提高流畅度
    const raf = () => {
      const percentage = (performance.now() - now) / DURATION
      // 此时应该画多大的圆
      const r = radius * (reverse ? 1 - percentage : percentage)
      ctx.clearRect(0, 0, w, h)
      ctx.save()
      ctx.beginPath()
      ctx.arc(x, y, r >= 0 ? r : 0, 0, 2 * Math.PI, !reverse)
      if (!reverse) {
        ctx.rect(0, 0, w, h)
      }
      ctx.closePath()
      ctx.clip() // 裁剪出特定区域
      ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, w, h)
      ctx.restore()
      if (percentage >= 1) {
        // 此时整个过渡效果已经完成，将 Canvas 隐藏
        canvas.style.display = 'none'
        return resolve()
      }
      requestAnimationFrame(raf)
    }
    requestAnimationFrame(raf)
  })
}

const getMaxRadius = (x, y) => {
  // x, y 为鼠标点击位置，分别计算该位置到 Canvas 四个角的距离，并取最大值
  const { width, height } = canvas
  return Math.max(
    calcLength(0, 0, x, y),
    calcLength(width, 0, x, y),
    calcLength(width, height, x, y),
    calcLength(0, height, x, y),
  )
}

const calcLength = (x0, y0, x1, y1) => {
  return Math.sqrt((y1 - y0) ** 2 + (x1 - x0) ** 2)
}

```

调用 `spread` 方法需要做一点改动：

```
// renderer.js
btn.addEventListener('click', ({ clientX, clientY }) => {
    window.APP.capturePage()
      .then(loadImage)
      .then(img => {
        spread(img, { x: clientX, y: clientY, reverse: isDarkMode })
        toggleTheme()
      })
  })

```

最终我们实现了它，效果出乎意料的好，与 Bilibili Mac 端的表现几乎一致。唯一不足的地方是：`capturePage` 获取的截图质量差强人意（Bilibili Mac 端同样存在这个问题），但在几百毫秒的动画中，用户是难以察觉的，因此这个缺点也就可以忽略不计了。

![](https://onflow.wayne-wu.com/content/files/static-wayne-wu-com/wp-content/uploads/2023/01/bilibili_2022-12-27-22_30_23/imageMogr2/auto-orient/format/webp/quality/75-imagemogr2-auto-orient-format-webp-quality-75.gif)

> 该代码已推送到 Github [awesome-light-dark-switching](https://github.com/WayneWu98/awesome-light-dark-switching?ref=onflow.wayne-wu.com) 项目，可以查看源码了解整个实现。