先拜讀源碼,最后總結,以及其他實現思路。如有錯誤,歡迎指正!
項目介紹
名稱: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)