帆域
帆域

原生前端为网站增加主题切换功能的实践

夜间模式、深色模式、黑暗主题……无论你习惯于怎么称呼它,这种在同一站点中配置两套主题的功能,已经在越来越多的网站中内置,同时也会有许多用户使用浏览器插件(例如我们介绍过的 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;

乍一听这似乎不是一个好主意,事情当然没有这么简单,上面的只是原理,而真正实现起来的效果是下面这样的:

@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;
  }
}

在上面的样式表中,我们编写了一个叫做:root的东西的样式,并且区分为了lightdark两种风格。:root可以被直观且主观地理解为「根」,它当中定义的这些变量可以被样式表中的任何地方以var(xxxx)的形式使用,并且在变量的值改变时,所有使用了这个变量的样式都会跟随着更新,这是CSS中的原生的「变量」。

然后,我们只需要为网站页面中的<html>标签添加名为lightdark的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中没有用户切换过的主题,则调用第一个函数让网站主题与系统同步;否则,切换为用户想要的主题。

确保在网站的每个页面加载时都调用最后一个函数,将第二个函数绑定到切换主题的按钮上,然后我们的明暗主题切换功能就完成辣~

结语

演示使用的代码是本人项目的实际代码,因此是切实可用的。本文将SCSS视作CSS,将Typescript视作Javascript,因为他们相比原生CSS和原生JS并没有对本文的两种方案作出任何额外贡献,且本身高度相似。

如果您使用一些现成的框架(例如Vue、Bootstrap等),这些框架可能会提供更方便的主题定制方案,但是呢——把原版的掌握好总是没错的,对吧?

图中项目的GitHub仓库:https://github.com/mangofanfan/MSOnlinePanel/

© 版权声明
THE END
喜欢就支持一下吧
点赞32赞赏 分享
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容