Fork me on GitHub

关于系统的动态主题切换

前段时间换了工作,从以前的天天跟产品对需求写业务,变成了现在天天翻 Github 看别人都怎么搭项目做组件库,虽然依然都是每天写代码,但博客却断更了。。。。话说从业务线转变到技术线之后,最大的体会就是,很多技术,不再是光会用就可以的了,还是得深入学习一下。

这篇博客,对我研究动态的系统主题切换这个问题过程中的一些尝试做一个总结。

动态主题切换,通俗的说,就是用户可以自主的切换系统页面中的某一些元素的样式,例如字体,背景,边框颜色等等这些涉及到元素外观的 css 属性,当然可能还有一些少量的元素布局和尺寸上的一些调整。

动态主题切换,可以算做典型的看起来很简单,但做起来很麻烦的功能,主要的问题有以下几点:

  1. Css 有限的逻辑表达能力,没有逻辑运算,继承,mixin,函数之类的特性
  2. 各前端框架带来了组件化潮流,使前端样式的组织不再对应于页面,而是内聚于组件,以及前端各种自带样式的组件库和Css框架的广泛使用
  3. lesssass的流行使得我们开发时编写的样式多了编译这一步
  4. 大部分项目并不注重对 Css 进行有效的抽离,组织,划分
  5. 可能涉及到页面自适应和媒体查询等其他逻辑

问题说了这么多,我们再来看一下通常的主题切换功能的实现方式

传统的动态主题切换

对于不使用前端框架或样式预处理器的传统网页,因为样式文件一般都是写好,然后直接引入的,因此切换主题还是比较简单的,在前端来说,通常有三种方式: 切换类名,切换 Css 文件,使用 Css 变量

  1. 切换类名

    这种方式是提前将元素主题相关的样式各自抽离到一个 Css 类中,然后通过 js 切换元素类名,使其呈现不同主题。

    一个示例
    HTML
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    <style>
    .container {
    height: 400px;
    width: 300px;
    border: 1px solid #fda;
    }.theme-one.container {
    background: #c4000c;
    }.theme-two.container {
    background: #a08f37;
    }
    </style>

    <body>
    <div class="container theme-one">demo page</div>
    <button id="toggleBtn">主题切换</button>
    </body>

    <script>
    function toggleTheme () {
    const con = document.getElementsByClassName('container')
    const conClass = con[0].classList

    if (conClass.contains('theme-one')) {
    conClass.remove('theme-one')
    conClass.add('theme-two')
    } else {
    conClass.remove('theme-two')
    conClass.add('theme-one')
    }
    }

    window.onload = function (){
    const btn = document.getElementById('toggleBtn')
    btn.addEventListener('click', toggleTheme)
    }
    </script>


    这种方式的优点是切换主题是瞬时响应的,没有延迟,体验效果很好。

    但缺点在于,在主题涉及到元素较多时,非常难以维护。我们不可能特地为每个元素去单独切换类名,所以就需要为相应元素添加一个标记从而使用通用的逻辑去处理所有涉及到的元素,例如为涉及主题切换的元素设置特定 id , 或者逐一检测元素类是否包含特定类名等,虽然可以通过类似于 btn-theme1 这种统一的class 命名规则合并一些相同元素,减少一些工作量,仍相当繁琐。

  1. 切换 Css 文件

    将特定主题的样式都集中在单独的 Css 文件中,然后当用户切换主题时,使用js 切换对应的主题样式文件链接将其引入页面即可。

    一个示例
    HTML
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    # one.css #
    .container {
    height: 400px;
    width: 300px;
    border: 1px solid #fda;
    background: #c4000c;
    }

    # two.css #
    .container {
    height: 400px;
    width: 300px;
    border: 1px solid #fda;
    background: #a08f37;
    }


    # page.html #
    <link id="one" rel="stylesheet" type="text/css" href="style/one.css">
    <link id="two" rel="stylesheet" type="text/css" href="style/two.css">

    <body>
    <div class="container">demo page</div>
    <button id="toggleBtn">主题切换</button>
    </body>

    <script>
    function toggleTheme () {
    const styleLink = document.getElementById('one')
    const href = styleLink.href

    styleLink.href = href.indexOf('one') === -1'style/one.css' : 'style/two.css'
    }

    window.onload = function (){
    const btn = document.getElementById('toggleBtn')
    btn.addEventListener('click', toggleTheme)
    }
    </script>


    这种方式的优点是可以将各个主题相关样式集中在一起,便于维护,同时在页面加载时不需要请求多余主题样式,从性能上说也好一些。

    缺点在于切换主题时,需要发起网络请求去获取样式文件,不能做到瞬时切换,且容易受到网络状况影响。

  2. 使用 Css 变量

    Css 变量类似于其他编程语言中的变量,可以为 Css 的编写提供较好的可维护性和语义化优势,同时又省去了使用预处理器所需要的编译步骤。

    Css 变量使用 --variable 来声明,使用 var() 方式来调用,类似如下:

    Css
    1
    2
    3
    4
    5
    6
    7
    8
    9
     // 声明变量
    :root {
    --global-color: #666;
    }

    // 使用变量
    .demo{
    color: var(--global-color);
    }
    一个基础的使用 Css 变量实现主题切换的例子
    HTML
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    <style>
    :root {
    --main-color: #c4000c;
    }

    .container {
    height: 400px;
    width: 300px;
    border: 1px solid #fda;
    background: var(--main-color);
    }
    </style>

    <body>
    <div class="container theme-one">demo page</div>
    <button id="toggleBtn">主题切换</button>
    </body>

    <script>
    function toggleTheme() {
    const themeConfig = {
    themeOneColor: '#c4000c',
    themTwoColor: '#a08f37'
    }

    const rootStyle = document.querySelector(':root').style
    const mainColor = rootStyle.getPropertyValue('--main-color');
    const newColor = mainColor === themeConfig.themeOneColor ? themeConfig.themTwoColor :themeConfig.themeOneColor

    rootStyle.setProperty('--main-color', newColor)
    }

    window.onload = function () {
    const toggleBtn = document.getElementById('toggleBtn');
    toggleBtn.addEventListener('click', toggleTheme);
    }
    </script>


    主流浏览器目前基本都已经兼容了Css 变量,但遗憾的是 IE 全线不支持,包括 IE 11 ,因此如果需要考虑主题切换兼容 IE 的话,这种方式就不合适了。
    如果不考虑IE 的情况下,即使使用了预处理器,我们也可以通过这种方式来实现变量切换,是十分有效和简单的主题切换方式。

现代前端项目的动态主题切换

一个现代的前端项目,通俗的说也就是实现了前后端分离,组件化开发的 SPA 项目,这类项目一般都使用了某个前端框架,同时也通过WebPack等工具进行资源的打包,使用了 Css 预处理器对样式进行处理。
Css 预处理器目前主流的就是 lesssass, 这两者基本是大同小异。各前端组件库中,使用两者的也都有,例如阿里的 ant-design 就是使用的 less,饿了么的 Element-UI 就使用的是 sass
现代前端项目相比传统的后端直出页面,因为存在了组件化开发,打包,样式编译等步骤,实现动态主题切换,相对来说,更麻烦一些。
最麻烦的一点,就是Css预处理器这一关,我们在开发时,使用 lesssass 来书写样式,在经过相应编译后变成 Css ,并通过 link 标签插入到页面,通常这些流程都是通过 webpack 自动实现的,这也意味着如果我们想实现动态的切换样式,就需要介入编译或者打包这个过程。
目前主要有两种方式来实现前端 SPA 项目的动态主题切换。

  1. 重新编译
    这种方式将打包过程中样式预处理器的编译过程也搬到了前端一份,具体的,我们将各主题相关的样式集中在一个lessscss 文件中,在用户切换主题时,使用用户选定的主题相应值重新编译, Ant-DesignAngular 实现 Ng-Zorro 官方网站就是使用的这种方式。

    核心实现
    TypeScript
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
      // 将主题相关的 `less` 样式插入到页面
    initColor() {
    const node = document.createElement('link');
    node.rel = 'stylesheet/less';
    node.type = 'text/css';
    node.href = '/assets/color.less';
    document.getElementsByTagName('head')[ 0 ].appendChild(node);
    }

    // 切换主题
    changeTheme(primaryColor: string) {
    const changeColor = () => {
    window.less.modifyVars({
    '@primary-color': primaryColor
    }).then(() => {
    window.scrollTo(0, 0);
    });
    };

    const lessUrl = 'https://cdnjs.cloudflare.com/ajax/libs/less.js/2.7.2/less.min.js';

    if (window.lessLoaded) {
    changeColor();
    } else {
    window.less = {
    async: true
    };
    loadScript(lessUrl).then(() => {
    window.lessLoaded = true;
    changeColor();
    });
    }
    }

    // 加载 less 编译器
    loadScript(src: string) {
    return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.type = 'text/javascript';
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    });
    }


    实现的主要原理即通过在前端页面引入 lessless运行时会自动的将标识为 stylesheet/less 的文件编译为 css 插入页面,随后可以通过 modifyVars API 来修改其编译的 less 文件中的变量值 less 会自动使用此变量值重新编译和插入 less 文件,从而实现主题的动态切换。

    less 最初就考虑了服务器端和浏览器端的编译,完全使用 js实现不同, sass 最初实现是完全基于 ruby 的,并未考虑浏览器端编译,只服务于服务器端编译,即使后来有了 sass.js,对浏览器端的编译支持也相当不完善,没有 modifyVars 类似的 API 来修改变量值并触发所有 less 样式标签重新编译,所以这个主题切换时重新编译的过程,就需要我们自己去手动的实现,带来的困难就是我们不能再将主题样式通过标签引入页面,而只能通过字符串的形式引入,并在每次切换主题时,在编译时向 sass.js 传递主题变量值,并在编译后手动将样式插入到页面替换原样式,十分的繁琐,但也不是不能实现。

    这种动态切换主题的方式,因为引入了编译过程,不光能实现主题的切换,还可以实现用户完全自定义主题相关的变量并动态展示。

    不过,这种将编译过程引入到前端的方式,在主题样式较多时,可能会存在较大的性能问题,同时还需要一段明显的用户等待转圈的时间,同时将编译过程引入到生产环境中,很多时候也是我们不能接受的。

    所以这种方式很多时候是不合适的。

    下面来介绍一下另一种方式,通过预处理器的模块化逻辑来实现打包多个主题样式,从而实现动态切换主题

  2. 通过预处理器的模块化逻辑
    这种方式中,我们首先将包含了所有相关主题的元素样式文件导入到多个新文件中,并重新赋值为各主题相关的变量,再在一个出口文件中,通过多个不同类名下的@import 来导入,从而实现为样式文件中的所有选择器添加不同的顶级类名,再将入口文件编译后引入页面中,这样,我们甚至可以实现通过切换 body 的类名就实现切换主题,同时还可以完全不入侵系统。

    核心实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
     # gather_theme.less #
    // 在此文件中集中所有和主题相关的样式和样式文件
    @import '....'

    a,.link {
    color: @link-color;
    }
    .widget {
    color: #fff;
    background: @link-color;
    }

    # theme-one.less #
    // 使用当前主题的值覆盖 less 变量
    @import "gather_theme";
    @link-color: #f03818;

    # theme-two.less #
    // 同样使用当前主题的值覆盖 less 变量
    @import "gather_theme";
    @link-color: #428bca;


    # theme-mixin.less #
    // 将主题文件集中到此出口文件中
    .theme-one {
    @import "theme-one";
    }
    .theme-two {
    @import "theme-two";
    }


    随后,我们可以通过手动编译或者配置 webpack 来将此文件引入到页面中,就可以实现通过 body 类名来切换主题了。

    这种方式类似上面我们提到的通过类名切换主题,实现基础即 lesssass 都会将选择器中 @import 进来的样式都加上选择器前缀。不过需要注意,在 less 中的导入需要使用 (multiple) 参数,来告诉 less 允许重复导入一个文件并编译,从而使不同主题对应的类选择器下的导入都会被编译。

    你也可以不将所有主题文件都集中到入口文件中一起打包,而是各自分别打包,然后通过动态切换 link 标签来实现动态切换主题,这种方式,不需要将同一个元素的不同主题样式全部导入,而是在主题切换时才动态导入。Angular Material主站就是这样实现的。

    通过 @import 来实现的优势之处在于,当我们需要为现有项目添加主题切换,又不想大幅改动样式的组织和结构时,就可以通过将全局所有样式,包括组件库样式集中,并通过选择器下的 @import 导入和覆盖的方式,重新生成多套在特定类名的全局新样式,通过切换类名或文件,从而实现动态的主题切换。当然这种情况还需要注意组件的样式作用域问题,防止组件的样式泄漏到全局影响其他地方。

    这种动态切换主题的方式下,关于主题文件的打包,不建议每次都手动打包或者通过类似 sh 脚本去打包,而应该去修改 webpack 配置来使其自动的单独去编译和打包我们主题配置的 lessscss 文件。
    如果在 Angular 项目中也可以直接修改 angular.json 中的打包配置指定将特定的预处理样式文件单独编译打包后直接插入到页面,从而使我们的样式系统完全独立于项目。

以上就是关于前端项目动态切换主题的一些叙述,动态主题切换涉及到的地方还是比较多的,很难面面俱到,总觉得有遗漏,后面想起来再补充吧。

感谢阅读。

----本文结束感谢阅读----