Darkmode.js 源码解析
先拜读源码,最后总结,以及其他实现思路。如有错误,欢迎指正!
项目介绍
名称:Darkmode.js
功能:给你的网站添加暗色模式
项目链接:https://github.com/sandoche/Darkmode.js
使用插件
使用这个插件非常简单,只需要实例化 class,即可在页面创建一个 button,点击它就能够切换亮色\暗色模式。
new Darkmode({
bottom: "32px",
right: "32px",
time: "0.5s",
label: "🌓",
}).showWidget();
调用 showWidget
以显示切换按钮,也可以通过编程的方式调用 toggle
切换。效果:
项目结构
- lib 打包文件夹 .js & min.js
- src 主要源码 index.js & darkmode.js
- test 测试用例
- ...一大推常见的配置文件
核心概念
mix-blend-mode
描述元素的内容应该与元素的直系父元素的内容和元素的背景如何混合。值为时 difference 反相。
视图
通过几张图片有助于你弄清楚插件的机制。这是上面例子的3D视图,你能够清楚的看到每一层。
下面简要分析每一层,亮色模式状态下:
- 按钮:右下角黑色小方块,效果图中就是点击切换它切换暗色\亮色模式。
- 页面内容:图中蓝色部分。即该实例中的文本所在的层,包含其父级容器。
- 混合层:按钮下方小块。混合层亮色模式下不可见,通过上面的效果图你能明白该层在切换到夜间时经过过渡动画覆盖整个页面,除了 button。
- 自定义背景层:图中绿色边框所在层。用户自定义背景色,插件创建的层。
暗色模式状态下:
与上图对比明显之处就是藏在按钮下方的小方块展开了,覆盖整个页面。这就是混合层,这个层包含css 属性 mix-blend-mode: difference
。正是如此实现的暗色模式。
darkmode.js
// es module
// 通过 typeof 判断当前是否为浏览器环境,并导出常量
export const IS_BROWSER = typeof window !== "undefined";
// es6 支持导出 class
// class 只是一个语法糖,babel 转化
export default class Darkmode {
// constructor -> class实例化时执行
// 用户通过实例化该类并传递一个 options
// 构造函数接收 options -> 用户配置
constructor(options) {
if (!IS_BROWSER) {
return;
}
// 默认配置
const defaultOptions = {
bottom: "32px", // 按钮位置
right: "32px", // 按钮位置
left: "unset", // 按钮位置
time: "0.3s", // 过渡时间
mixColor: "#fff", // 混合层背景色
backgroundColor: "#fff", // 创建的背景层背景色
buttonColorDark: "#100f2c", // 亮色状态下的按钮颜色
buttonColorLight: "#fff", // 暗色状态下的按钮色
label: "", // 按钮中的内容
saveInCookies: true, // 是否存在cookie 默认 local storage
autoMatchOsTheme: true, // 跟随系统设置
};
// 通过 Object.assign 合并默认配置和用户配置
// 浅拷贝
options = Object.assign({}, defaultOptions, options);
// 需要在 css 使用配置
// style 以字符串的形式呈现
// 如果单独抽离css,需要更多的逻辑代码
const css = `
.darkmode-layer {
position: fixed;
pointer-events: none;
background: ${options.mixColor};
transition: all ${options.time} ease;
mix-blend-mode: difference;
}
.darkmode-layer--button {
width: 2.9rem;
height: 2.9rem;
border-radius: 50%;
right: ${options.right};
bottom: ${options.bottom};
left: ${options.left};
}
.darkmode-layer--simple {
width: 100%;
height: 100%;
top: 0;
left: 0;
transform: scale(1) !important;
}
.darkmode-layer--expanded {
transform: scale(100);
border-radius: 0;
}
.darkmode-layer--no-transition {
transition: none;
}
.darkmode-toggle {
background: ${options.buttonColorDark};
width: 3rem;
height: 3rem;
position: fixed;
border-radius: 50%;
border:none;
right: ${options.right};
bottom: ${options.bottom};
left: ${options.left};
cursor: pointer;
transition: all 0.5s ease;
display: flex;
justify-content: center;
align-items: center;
}
.darkmode-toggle--white {
background: ${options.buttonColorLight};
}
.darkmode-toggle--inactive {
display: none;
}
.darkmode-background {
background: ${options.backgroundColor};
position: fixed;
pointer-events: none;
z-index: -10;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
img, .darkmode-ignore {
isolation: isolate;
display: inline-block;
}
@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
.darkmode-toggle {display: none !important}
}
@supports (-ms-ime-align:auto), (-ms-accelerator:true) {
.darkmode-toggle {display: none !important}
}
`;
// 混合层 -> 反相
const layer = document.createElement("div");
// 按钮 -> 点击切换夜间模式
const button = document.createElement("button");
// 背景层 -> 用户自定义背景色
const background = document.createElement("div");
// 初始化类(初始样式)
button.innerHTML = options.label;
button.classList.add("darkmode-toggle--inactive");
layer.classList.add("darkmode-layer");
background.classList.add("darkmode-background");
// 通过 localStorage 储存状态
// darkmodeActivated 获取当前是否在darkmode下
const darkmodeActivated =
window.localStorage.getItem("darkmode") === "true";
// 系统是否默认开启暗色模式
// matchMedia 方法的值可以是任何一个 CSS @media 规则 的特性。
// matchMedia 返回一个新的 MediaQueryList 对象,表示指定的媒体查询字符串解析后的结果。
// matches boolean 如果当前document匹配该媒体查询列表则其值为true;反之其值为false。
const preferedThemeOs =
options.autoMatchOsTheme &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
// 是否储存localStorage
const darkmodeNeverActivatedByAction =
window.localStorage.getItem("darkmode") === null;
if (
(darkmodeActivated === true && options.saveInCookies) ||
(darkmodeNeverActivatedByAction && preferedThemeOs)
) {
// 激活夜间模式
layer.classList.add(
"darkmode-layer--expanded",
"darkmode-layer--simple",
"darkmode-layer--no-transition"
);
button.classList.add("darkmode-toggle--white");
// 激活 darkmode 时,将类 darkmode--activated 添加到body
document.body.classList.add("darkmode--activated");
}
// 插入
document.body.insertBefore(button, document.body.firstChild);
document.body.insertBefore(layer, document.body.firstChild);
document.body.insertBefore(background, document.body.firstChild);
// 将 css 插入 <style/>
this.addStyle(css);
// 初始化变量 button layer saveInCookies time
// 方便函数中调用
this.button = button;
this.layer = layer;
this.saveInCookies = options.saveInCookies;
this.time = options.time;
}
// 接收样式 css 字符串
// 创建 link 标签在 head 中插入
addStyle(css) {
const linkElement = document.createElement("link");
linkElement.setAttribute("rel", "stylesheet");
linkElement.setAttribute("type", "text/css");
// 使用encodeURIComponent将字符串编码
linkElement.setAttribute(
"href",
"data:text/css;charset=UTF-8," + encodeURIComponent(css)
);
document.head.appendChild(linkElement);
}
// 切换按钮
showWidget() {
if (!IS_BROWSER) {
return;
}
const button = this.button;
const layer = this.layer;
// s -> ms
const time = parseFloat(this.time) * 1000;
button.classList.add("darkmode-toggle");
button.classList.remove("darkmode-toggle--inactive");
layer.classList.add("darkmode-layer--button");
// 监听点击事件
button.addEventListener("click", () => {
// 当前是否在暗色模式
// isActivated()返回 bool 见下方
const isDarkmode = this.isActivated();
if (!isDarkmode) {
// 添加过渡样式
layer.classList.add("darkmode-layer--expanded");
// 禁用按钮
button.setAttribute("disabled", true);
setTimeout(() => {
// 清除过渡动画
layer.classList.add("darkmode-layer--no-transition");
// 显示混合层
layer.classList.add("darkmode-layer--simple");
// 取消禁用
button.removeAttribute("disabled");
}, time);
} else {
// 逻辑相反
layer.classList.remove("darkmode-layer--simple");
button.setAttribute("disabled", true);
setTimeout(() => {
layer.classList.remove("darkmode-layer--no-transition");
layer.classList.remove("darkmode-layer--expanded");
button.removeAttribute("disabled");
}, 1);
}
// 处理按钮样式,黑暗模式下背景色为白色调,反之为暗色调
// 如果 darkmode-toggle--white 类值已存在,则移除它,否则添加它
button.classList.toggle("darkmode-toggle--white");
// 如果 darkmode--activated 类值已存在,则移除它,否则添加它
document.body.classList.toggle("darkmode--activated");
// 取反存 localStorage
window.localStorage.setItem("darkmode", !isDarkmode);
});
}
// 允许使用方法 toggle()启用/禁用暗模式
// 即以编程的方式切换模式,而不是使用内置的按钮
// new Darkmode().toggle()
toggle() {
if (!IS_BROWSER) {
return;
}
const layer = this.layer;
const isDarkmode = this.isActivated();
// 处理样式
layer.classList.toggle("darkmode-layer--simple");
document.body.classList.toggle("darkmode--activated");
// 存状态
window.localStorage.setItem("darkmode", !isDarkmode);
}
// 检查是否激活了暗色模式
isActivated() {
if (!IS_BROWSER) {
return null;
}
// 通过判断body是否包含激活css class
// contains 数组方法 返回 bool
return document.body.classList.contains("darkmode--activated");
}
}
index.js
import Darkmode, { IS_BROWSER } from "./darkmode";
export default Darkmode;
// 将 Darkmode 挂载到 window 对象
if (IS_BROWSER) {
(function (window) {
window.Darkmode = Darkmode;
})(window);
}
总结
缺点
通过 mix-blend-mode:difference
达到切换夜间模式的效果,存在明显的短板,当你的网站色调不是白色或其相近的颜色时,通过这个插件无法实现夜间模式。以及对图像的处理等。
使用 css 变量
周全的办法是通过 css 变量(自定义属性)实现,可以处理暗色\亮色模式下的各个细节。具体思路是先创建默认使用的 css 变量:
:root {
--default-text-0: #555;
/* ... */
--text-0: var(--dark-text-0, var(--default-text-0));
/* ... */
}
body {
color: var(--text-0);
}
/* ... */
然后通过 JavaScript 创建 --dark-text-0
及其值。初始状态下 --text-0
的值为 --default-text-0
的值 (找不到第一个值找第二个值,从左往右)。
兼容性
mix-blend-mode
css Variable(Custom Properties)