夜间模式、深色模式、黑暗主题……无论你习惯于怎么称呼它,这种在同一站点中配置两套主题的功能,已经在越来越多的网站中内置,同时也会有许多用户使用浏览器插件(例如我们介绍过的 Dark Reader)来强制不支持的网站同样启用这种功能。
狭义上来说,网站需要配备亮色、暗色两种主题以供用户切换;从另一个角度,当你的网站已经具备了两种主题时,日后再添加更多主题似乎也并非难事了。
在这篇文章中,芒果帆帆将会结合他猛猛肝了两天的实践结果,演示两种为网站增加亮暗两套主题的方案。
方案一:纯CSS实现
上一篇文章中我们介绍了响应式技术,其核心技术是CSS中的媒体查询。媒体查询允许你设置特定的CSS在怎样的条件下被浏览器加载,例如你可以设置在视口宽度小于700像素时应用移动端的专用样式,或者在浏览器/系统启用深色模式/夜间模式/……时应用一套夜间模式的专用样式。
媒体查询的写法非常简单:
@media (xxxx) {
aaa: bbb;
ccc: ddd;
}
将xxxx
替换成prefers-color-scheme: dark
,即可限制花括号中的样式表仅在深色模式下生效。同理,你也可以设置为light
来让一些样式仅在浅色模式下生效,实际上就是默认生效,但深色模式下不生效。
优势:仅需要在CSS中配置,操作难度低。
缺点:似乎可玩性不够高?
这样简单的设置之后,网站的亮暗主题切换将完全与系统或浏览器的亮暗主题同步,网站将只能拥有这两个主题,并且不允许用户自行切换。在原生的前端三件套中,不使用Javascript而实现的亮暗主题切换,就只能做到这一步了。
方案二:动态切换样式表
这个方案需要Javascript的助力,从而能够实现跟随系统自动切换样式与用户手动切换样式的结合,并且能实现的效果不仅仅是明暗两套主题,理论上可以实现任意数量的主题切换。
编写样式表
我们不再采用CSS媒体查询,而是交由Javascript来让我们想要的样式表动态生效。只需要把CSS写成如下格式:
.light {
xxx: aaa;
}
.dark {
xxx: bbb;
}
yyy: ccc;
.light
作为class选择器,只会在class中有light
的元素上生效,于是方案二的原理就很显而易见了:我们通过JS,在需要切换主题时动态地修改需要变化的元素的class,即可修改在元素上生效的CSS,从而实现主题切换。乍一听这似乎不是一个好主意,事情当然没有这么简单,上面的只是原理,而真正实现起来的效果是下面这样的:
@mixin radius($radius) {
-webkit-border-radius: $radius;
-moz-border-radius: $radius;
border-radius: $radius;
}
@mixin custom_radius($radius, $radius2, $radius3, $radius4) {
-webkit-border-radius: $radius $radius2 $radius3 $radius4;
-moz-border-radius: $radius $radius2 $radius3 $radius4;
border-radius: $radius $radius2 $radius3 $radius4;
}
@mixin box_shadow($_1, $_2, $_3, $_4, $color) {
-webkit-box-shadow: $_1 $_2 $_3 $_4 $color;
-moz-box-shadow: $_1 $_2 $_3 $_4 $color;
box-shadow: $_1 $_2 $_3 $_4 $color;
}
// 全局色彩
:root {
--theme-focus-color: rgb(68, 197, 255);
}
.light {
--theme-text-color: rgb(40, 43, 44);
--theme-body-color: rgb(240, 248, 255);
--theme-background-color: rgb(232, 232, 232);
--theme-background-color-t: rgba(232, 232, 232, 0.6);
--theme-card-background-color: rgb(244, 248, 255);
--theme-card-background-color-t: rgba(244, 248, 255, 0.9);
--theme-card-bg-color-1: linen;
--theme-card-bg-color-2: floralwhite;
--theme-card-bg-color-3: honeydew;
--theme-border-color-t: rgba(0, 0, 0, 0.16);
--theme-link-color: grey;
--theme-link-hover-color: cornflowerblue;
// 覆写 bootstrap 标准色
--bs-info-text: #084298;
--bs-info-bg: #cfe2ff;
--bs-info-border: #b6d4fe;
--bs-success-text: #0f5132;
--bs-success-bg: #d1e7dd;
--bs-success-border: #badbcc;
--bs-primary-text: #055160;
--bs-primary-bg: #cff4fc;
--bs-primary-border: #b6effb;
}
.dark {
--theme-text-color: rgb(248, 248, 255);
--theme-body-color: rgb(57, 57, 57);
--theme-background-color: rgb(53, 53, 53);
--theme-background-color-t: rgba(53, 53, 53, 0.6);
--theme-card-background-color: rgb(50, 53, 57);
--theme-card-background-color-t: rgba(50, 53, 57, 0.9);
--theme-card-bg-color-1: #37325c;
--theme-card-bg-color-2: #34424e;
--theme-card-bg-color-3: #415c41;
--theme-border-color-t: rgba(255, 255, 255, 0.16);
--theme-link-color: #dadaf1;
--theme-link-hover-color: cornflowerblue;
// 覆写 bootstrap 标准色
--bs-info-text: #084298;
--bs-info-bg: #36848f;
--bs-info-border: #b6d4fe;
--bs-success-text: #0f5132;
--bs-success-bg: #346334;
--bs-success-border: #badbcc;
--bs-primary-text: #055160;
--bs-primary-bg: #2f4c57;
--bs-primary-border: #95d4e3;
}
// 全局样式
* {
color: var(--theme-text-color);
transition: all 0.3s ease-in-out;
}
body {
background-color: var(--theme-body-color);
background-image: var(--theme-background-image);
background-repeat: no-repeat;
background-attachment: fixed;
background-size: cover;
background-position: center 0;
transition: all 0.4s linear;
}
img {
width: 100%;
}
a {
text-decoration: none;
color: var(--theme-link-color);
transition: all 0.3s;
}
a:hover {
text-decoration: none;
color: var(--theme-link-hover-color);
}
// 导航栏
nav, .nav {
background-color: var(--theme-background-color-t) !important;
margin: 2px;
padding: 5px;
@media (min-width: 768px) {
padding-left: 60px !important;
padding-right: 60px !important;
}
}
.nav-custom-link {
color: midnightblue;
}
.nav-custom-link:hover {
color: navy;
}
.navbar-dropdown-menu {
background-color: var(--theme-background-color-t);
@include radius(8px);
transition: all 0.3s ease-in-out;
}
// 卡片
.border-card {
@include radius(8px);
@include box_shadow(0, 1px, 2px, 1px, var(--theme-border-color-t));
margin-bottom: 30px;
background-color: var(--theme-card-background-color-t);
}
.border-image {
width: 100%;
height: 100px;
object-fit: cover;
@include radius(8px);
@include box_shadow(0, 1px, 2px, 1px, var(--theme-border-color-t));
margin-bottom: 10px;
}
.sidebar_widget {
padding: 20px;
margin-bottom: 20px;
background-color: var(--theme-card-background-color-t)
}
.float-box {
transition: all 0.3s;
}
.float-box:hover {
transform: translateY(-6px);
}
.line-button {
width: 90%;
margin: 4px;
}
.widget-list {
display: flex !important;
flex-wrap: wrap;
flex-direction: row;
align-content: stretch;
justify-content: space-evenly;
align-items: center;
}
.list-widget {
@extend .no-margin-padding;
display: flex;
align-items: stretch;
}
.post-card {
width: 96%;
padding: 5px;
margin: 4px;
background-color: var(--theme-card-background-color)
}
.post-card-title {
font-size: 24px;
height: 1.4em;
line-height: 1.4em;
}
.post-card-text {
font-size: 14px;
height: 1.2em;
line-height: 1.2em;
}
.card-image {
width: 100%;
height: 156px;
object-fit: cover;
@include radius(8px);
}
.card-btn {
margin-top: 4px;
}
.card-image-top {
height: 110px;
width: calc(100% + 40px);
margin-left: -20px;
margin-top: -20px;
margin-bottom: 5px;
@include custom_radius(8px, 8px, 0, 0);
}
.card-bg-1, .card-bg-2, .card-bg-3 {
@extend .border-card;
}
.card-bg-1 {
background-color: var(--theme-card-bg-color-1);
}
.card-bg-2 {
background-color: var(--theme-card-bg-color-2);
}
.card-bg-3 {
background-color: var(--theme-card-bg-color-3);
}
// 头像
.avatar {
@include radius(50%);
width: 70px;
height: 70px;
object-fit: cover;
}
// 图片遮罩
.mask {
position: relative;
z-index: 1;
}
.mask:before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100px;
@include radius(8px);
opacity: 0;
background-color: rgba(0, 0, 0, 0.42);
transition: all 0.3s;
}
.mask:hover:before {
opacity: 1;
}
p.mask-text {
position: absolute;
margin-top: -40px;
padding-left: 3px;
z-index: 2;
margin-bottom: 0;
font-size: 1.2rem;
color: ghostwhite;
width: 100%;
}
p.mask-blank {
@extend .no-margin-padding;
}
// bootstrap 组件
.alert-info {
color: var(--bs-info-text);
background-color: var(--bs-info-bg);
border-color: var(--bs-info-border);
}
.alert-success {
color: var(--bs-success-text);
background-color: var(--bs-success-bg);
border-color: var(--bs-success-border);
}
.alert-primary {
color: var(--bs-primary-text);
background-color: var(--bs-primary-bg);
border-color: var(--bs-primary-border);
}
// Toast
.site-icon {
width: 30px;
}
.toast {
.toast-header {
strong {
color: black;
}
}
.toast-body {
color: black;
}
}
.toast:not(.showing):not(.show) {
opacity: 0;
display: none;
}
.toast.show {
opacity: 0.8;
display: block;
}
.toast-container {
pointer-events: none;
}
// 输入框
.input-group {
.input-group-prepend {
.input-group-text {
height: 100%;
border: 1px solid transparent;
background-color: var(--theme-focus-color);
@include custom_radius(5px, 0, 0, 5px);
}
}
.form-control {
color: var(--theme-text-color) !important;
background-color: var(--theme-background-color) !important;
border: 1px solid transparent;
transition: all 0.3s;
@include custom_radius(0, 5px, 5px, 0);
}
.form-control:focus {
border: 1px solid var(--theme-focus-color) !important;
}
}
// 标题样式
h1:after, h2:after, h3:after {
content: "";
display: block;
background: var(--theme-focus-color);
opacity: 0.3;
pointer-events: none;
border-radius: 15px;
margin-top: -15px;
margin-left: -3px;
}
h1:after {
width: 100px;
height: 18px;
}
h2:after, h3:after {
width: 70px;
height: 16px;
}
h1, h2, h3 {
margin-bottom: 15px;
font-weight: 600;
}
.display-1, .display-2, .display-3, .display-4 {
margin: 20px 20px 30px;
}
// 额外按钮
.btn-love {
color: #fff;
background-color: #ff62ea;
border-color: #ff62ea;
}
.btn-love:hover {
color: #fff;
background-color: #e345cd;
border-color: #ff62ea;
}
// 浮动按钮
.float-button-container {
pointer-events: none;
}
.float-button {
pointer-events: auto;
@extend .no-margin-padding;
height: 50px;
width: 50px;
transition: all 0.4s;
}
// 补充
.no-margin-padding {
padding: 0;
margin: 0;
}
@media (max-width: 991px) {
.sidebar-hide {
display: none;
}
}
@media (max-width: 767px) {
.main-container {
margin-top: 200px;
}
.hide-md {
display: none;
}
}
@media (min-width: 768px) {
.main-container {
margin-top: 300px;
}
.hide-lg {
display: none;
}
}
@
开头的东西,剩下的就是很普通的CSS了。在上面的样式表中,我们编写了一个叫做:root
的东西的样式,并且区分为了light
和dark
两种风格。:root
可以被直观且主观地理解为「根」,它当中定义的这些变量可以被样式表中的任何地方以var(xxxx)
的形式使用,并且在变量的值改变时,所有使用了这个变量的样式都会跟随着更新,这是CSS中的原生的「变量」。
然后,我们只需要为网站页面中的<html>
标签添加名为light
或dark
的class,就可以控制整个页面的主题了。
编写脚本
下面的脚本的代码语言是Typescript。类似于SCSS与CSS的关系,TS也可以当作JS的Plus版,不过我写的这段似乎同时也是合法的JS脚本?
const isDarkTheme = window.matchMedia("(prefers-color-scheme: dark)");
const main_html = document.getElementById("html");
function changeThemeToSystem () {
// 根据系统或浏览器设置切换主题
if (isDarkTheme.matches) {
main_html.setAttribute("class", "dark");
console.log("跟随系统设置,切换网站为深色模式。")
}
else {
main_html.setAttribute("class", "light");
console.log("跟随系统设置,切换网站为浅色模式。")
}
}
function changeThemeByUser () {
// 主动切换主题(用户点击切换按钮),并保存 cookie
// 切换主题之后,将主题保存在 cookie 中
let theme = main_html.getAttribute("class");
if (theme === "dark") {
main_html.setAttribute("class", "light");
document.cookie = "theme=light; path=/";
console.log("主动切换网站为浅色模式。")
}
else {
main_html.setAttribute("class", "dark");
document.cookie = "theme=dark; path=/";
console.log("主动切换网站为深色模式。")
}
}
function loadTheme () {
// 在页面加载时调用此函数以加载主题,如果有 cookie 会自动处理。
console.log("开始加载主题...")
let themeSet = false;
document.cookie.split(";").forEach(function (cookie) {
if (cookie.indexOf("theme=") >= 0) {
themeSet = true;
let theme = cookie.replace(" theme=", "").replace("theme=", "")
console.log("读取保存的主题设置 cookie:" + theme)
if (theme == "dark") {
main_html.setAttribute("class", "dark");
}
else {
main_html.setAttribute("class", "light");
}
}
})
if (!themeSet) {
changeThemeToSystem();
}
}
我们编写了三个函数:
changeThemeToSystem
将主题变为系统或浏览器的主题。脚本第一行是Javascript获取系统或浏览器的主题的标准方法,我们根据获得的值来判断系统的当前主题。changeThemeByUser
将当前的主题切换为另一主题,被绑定在切换主题的按钮上,在用户点击时执行。同时,在用户主动切换主题之后,自动切换主题的功能应当暂时关闭(否则一旦页面刷新或进入新页面,用户就需要再次切换主题),所以我们将用户切换之后的主题存储在浏览器的cookie中,这个cookie将在浏览器关闭时被废弃。loadTheme
是需要在页面加载时运行的函数,负责处理应当将页面切换为哪种主题。即,如果cookie中没有用户切换过的主题,则调用第一个函数让网站主题与系统同步;否则,切换为用户想要的主题。
确保在网站的每个页面加载时都调用最后一个函数,将第二个函数绑定到切换主题的按钮上,然后我们的明暗主题切换功能就完成辣~
![图片[1]-原生前端为网站增加主题切换功能的实践-帆域](https://ifanspace.top/wp-content/uploads/2025/03/20250311200512603-IMG_0522-1024x593.png)
![图片[2]-原生前端为网站增加主题切换功能的实践-帆域](https://ifanspace.top/wp-content/uploads/2025/03/20250311200514738-IMG_0523-1024x591.png)
结语
演示使用的代码是本人项目的实际代码,因此是切实可用的。本文将SCSS视作CSS,将Typescript视作Javascript,因为他们相比原生CSS和原生JS并没有对本文的两种方案作出任何额外贡献,且本身高度相似。
如果您使用一些现成的框架(例如Vue、Bootstrap等),这些框架可能会提供更方便的主题定制方案,但是呢——把原版的掌握好总是没错的,对吧?
图中项目的GitHub仓库:https://github.com/mangofanfan/MSOnlinePanel/
请登录后查看评论内容