Web主题切换和个性化定制方法总结

切换主题

切换主题:主题是由开发者定义,一般来说只有有限的主题可选。

方法一:class 命名空间

定制不同主题,存于不同的 class 命名空间下,通过 JS 修改 body 或其他需要换主题的标签上的 class 名来实现主题切换。

该方法的关键在于如何为不同主题,生成不同的 class。如果纯手动写多个主题的 class,成本太高,而且后期维护也比较困难。

以下是两种解决方案:postcss-themes 插件、less 混合

postcss-themes 插件(推荐)

将主题相关的变量提取到一个主题变量文件中, postcss-themes 会为使用了主题文件中变量的选择器,新添加一份带有命名空间的选择器。

/* postcss.config.js */
module.exports = {
  plugins: {
    'postcss-themes': {
      themes: { // themes 可以为数组,配置多个主题
        filePath: 'red_theme.css'
      }
    },
    'postcss-css-variables': {},
  }
}

输入 css:

/* index.css */
:root {
  --main-color: green;
}
.foo {
  font-size: 16px;
  color: var(--main-color);
}
/* red_theme */
:root {
  --main-color: red;
}

输出 css:

.foo {
  font-size: 16px;
  color: green;
}
.red_theme .foo {
  color: red;
}

Less 混合

less 混合是将一组属性从一个规则集包含(或混入)到另一个规则集的方法。规则集可以通过传参生成不同的新规则集。该方案需要将所有主题相关的样式集中在一个规则集中,实际执行起来比较困难。

/*variables.less*/
.base_theme(@background: #e2e9f9, @color: #337ab7){
  .class_1 {
    background: @background;
  }
  .class_1 a:hover{
    color: saturate(@color, 20%);
  }
  .class_2 {
    color: @color;
  }
}
@import 'variables.less';
.default_theme { /*默认主题,经典蓝*/
  .base_theme();
}
.coffee_theme { /*咖啡色主题*/
  .base_theme(#f5e2c9, #9f754d);
}
.purple_theme { /*紫色主题*/
  .base_theme(#e5d3ed, #b88bcb);
}

JS 切换主题

JS 修改 body 或其他需要换主题的元素的 class 名。

document.body.className = `${document.body.className} ${theme_class_name}`

方法二:多个 CSS 文件

定制不同主题,每个主题生成一份 CSS 文件,然后再通过 JS 切换主题样式文件来实现。

实现步骤上如下:

  • 创建主题变量文件,包含主题相关的变量定义;

  • 根据不同的主题变量文件,编译生成不同主题的 CSS 文件;

  • 通过 JS 切换主题样式文件。

其中重点是,如何根据不同的主题变量文件,编译生成不同主题的 CSS 文件。

以下是两种用 webpack 的实现方式:

多次运行 webapck,生成多个 CSS 文件

一次打包同时生成多份主题 CSS 文件比较麻烦,因此通过打包多次,每次生成一个主题对应的 css 文件来实现,而 JS 文件在这个过程中是不会改变。

// webpack.prod.conf.js
module.exports = theme => {
  return {
    plugins: [
      new MiniCssExtractPlugin({
        filename: `[name]_${theme}.css`,
      }),
    ],
    resolve: {
      alias: {
        themeVars: path.join(__dirname, `../src/style/themes/theme-${theme}.css`),
      }
    }
  }
}
/*variable/color.less*/
@import "~themeVars"
const webpack = require('webpack')
const webpackConfig = require('./webpack.prod.conf')
['default', 'broker'].forEach(theme => {
  webpack(webpackConfig(theme), (err, stats) => {})
})

注意: 使用 less 预处理,@import 可以直接引用 webpack.prod.conf.js 中定义的 alias,而 postcss-loader 是无法使用该 alias 的。

postcss-loader 是通过 postcss-import 来处理 @import 语句。 postcss-import 是不支持 alias 配置的,需要用另一个插件 postcss-import-alias-resolver 来实现。

// postcss.config.js
const path = require('path')
const aliasResolver = require('postcss-import-alias-resolver');
module.exports = {
  plugins: {
    'postcss-import': {
      resolve: aliasResolver({
        alias: {
          themeVars: path.join(__dirname, `src/style/themes`),
        }
      })      
    }
  }
}
/*variable/color.less*/
@import '~themeVars/theme-broker.css';

但是,postcss.config.js 中,只能静态指定 alias,想要动态指定,需要在 webpack.prod.conf.js 中配置。实际运行时,发现 postcss-import-alias-resolver 的指定的 aliaswebpack.prod.conf.js 中无效(??具体原因未找到)

最后,改用自定义 postcss-import中的 resolve 方法来实现的:

// webpack.prod.conf.js
module.exports = theme => {
  return {
    plugins: [
      new MiniCssExtractPlugin({
        filename: `[name]_${theme}.css`,
      }),
    ],
    module: {
      rules: [
        {
          test: /\.(css|postcss)$/,
          use: [
            'style-loader',
            'css-loader',
            {
              loader: 'postcss-loader',
              options: {
                ident: 'postcss',
                plugins: (loader) => [
                  require('postcss-import')({
                    resolve: (id, basedir, importOptions) => {
                      // 这里会将 @import "./color.css" 的导入文件,指向`../themes/theme-${theme}.css`。所以 color.css 可以是一个并不存在的文件。
                      if (id === './color.css') {
                        return path.join(basedir, `../themes/theme-${theme}.css`)
                      }
                      return path.join(basedir, id)
                    }
                  }),
                  // 注意,webpack的配置会覆盖postcss.config.js。
                  // 所以,如有其他 postcss 插件,也要移到这里
                  ...
                ]
              }
            }
          ]
        }
      ]
    }
  }
}

运行一次 webpack, 生成多个 css 文件 (不推荐)

JS 切换主题样式文件

  • JS 改变 link 标签的 href 属性

    实现: 腾讯首页个性化换肤demo页面

    <link id="skincolor" href="skin-default.css" rel="stylesheet" type="text/css">
    document.getElementById('#skincolor').href = 'skin-red.css';

    注意:该方法需重新加载样式表,会带来加载延迟。样式切换不流畅,体验不太好。

  • JS 修改 link 标签的 disabled 属性

    实现: MDN Alternative style sheets

    <link href="reset.css" rel="stylesheet" type="text/css">
    <link href="default.css" rel="stylesheet" type="text/css" title="Default Style">
    <link href="fancy.css" rel="alternate stylesheet" type="text/css" title="Fancy" disabled>
    <link href="basic.css" rel="alternate stylesheet" type="text/css" title="Basic" disabled>

    link 标签的 disabled 属性用来启用或禁用样式表。通过 JS 设置 link 标签 disabled="false",可以让默认不渲染的 CSS 开始渲染。

    以上样式表都可分为3类:

    • 无 title 属性: <link rel="stylesheet">,无论如何都会加载并渲染。如:reset.css

    • 有 title 属性: <link rel="stylesheet" title="Default Style">,默认样式,CSS 文件加载并渲染。如:default.css

    • 有 title 属性: <link rel=“alternate stylesheet” title="Fancy">,作为备选样式,CSS 文件加载,默认不渲染。如:fancy.css

      alternate 意味备用,相当于是 css 预加载进来备用,所以不会有加载换延问题

个性化定制主题

个性化定制主题:主题是由用户定义,一般具有无限种可能,用户可以为主题变量设置任意色值。

方法一:动态创建 style 标签

该方法实现原理就是通过 JS 创建一个 style 元素,填充新样式,appendhead 元素中,覆盖 css 文件中定义的样式。

一个简单的DEMO

<div class="text_color"> 一个简单的示意 </div>
.text_color { color: red; }
const styleDom = document.createElement('style')
styleDom.innerHTML = '.text_color { color: cyan; }'
document.head.appendChild(styleDom)

该方法可以说是最简单的,也可以说是最复杂的。说简单,是因为它的实现方式直接明了;说复杂,是因为它必需逐个找到需要修改的元素的 class 名,元素越多,实现起来就越复杂。

此外,使用了 css modules 的项目,我们无法锁定需要修改的元素的 class 名。

案例分析

Element-UI 换肤: Element-UI 换肤预览 Element-UI 换肤原理

换肤实现过程:

  • 用 Ajax 将当前页面的 CSS 文件请求回来;

  • 处理请求回来的 CSS 数据:将需要替换的色值全部替换成新颜色值。

    Element-UI 换肤的处理过程分三步:

    • 把默认主题文件中涉及到颜色的 CSS 值替换成关键词源码

    • 根据用户选择的色值,生成一系列对应的新颜色值。源码

    • 关键词换回刚刚生成的相应的新颜色值。源码

  • 创建 style 标签,把处理好的数据填进去,再 appendhead 元素。相关代码

简单源码示例

function changeTheme (url, colorMap = {}) {
  const request = new XMLHttpRequest()
  request.onreadystatechange = res => {
    const resTarget = res.target
    if (resTarget.readyState !== 4) {
      return
    }
    if (resTarget.status === 200) {
      let originalStyle = resTarget.response
      for (let k in colorMap) {
        originalStyle = originalStyle.replace(new RegExp(k, 'ig'), colorMap[k])
      }
      const styleDom = document.createElement('style')
      styleDom.innerText = originalStyle
      document.head.appendChild(styleDom)
    }
  }
  request.open('GET', url)
  request.send()
}
changeTheme('https://xxx.xxx.com/css_file_path/index.css', {
  '#508cee': '#ff5521',
  '#D2DFFE': '#FFDDD2'
})

方法二:LESS 在线修改变量

基于 less 写样式,为主题相关的颜色定义变量。在 HTML 中直接引入 .less 文件,再引入 less.jsless.js 会在线将 .less 文件编译成浏览器可识别的样式,然后填充在新创建的 style 标签中。我们通过调用 less.modifyVars 方法修改主题变量实现个性化定制主题。

一个简单的DEMO

<body>
  <link rel="stylesheet/less" type="text/css" href="theme.less" />
  <script src="https://cdnjs.cloudflare.com/ajax/libs/less.js/4.1.1/less.min.js"></script>
  <div class="text_color">LESS 在线修改变量</div>
</body>
/**theme.less**/
@major_color: red;
.text_color {
  color: @major_color;
}

less.js 在线编译 .less 文件,创建一个 style 元素,填入编译后的样式:

<link rel="stylesheet/less" type="text/css" href="theme.less">
<style type="text/css" id="less:pages-common-templatev1-theme">.text_color {color: red;}</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/less.js/4.1.1/less.min.js"></script>

JS 修改变量:

window.less.modifyVars({"@major_color": 'cyan'})
<style type="text/css" id="less:pages-common-templatev1-theme">.text_color {color: cyan;}</style>

方法分析

  • 引入的 .less 文件,需要在浏览器中编译,这对浏览器性能有影响,不建议在生产环境使用。

  • 此方法仅限于用 less 的项目才能使用。sass 没有类似 less.modifyVars 的方法。

  • 引入 .less 文件的 <link> 标签:rel 属性值必需是 stylesheet/less

  • less.js 一定要在所有的 .less 文件后引用。

  • 最后一个关键问题:如何把项目中的 less 样式(.less文件、.vue 文件中的 <style lang="less"> 等)提取到一个 .less 文件中。

    目前还没有找到可实现的方案。 Ant Design Pro 在线更换主题 有正在试验的方案,但目前还有问题。它的实现步骤:

    • 合并 less:通过 antd-pro-merge-less 插件扫描 src 中所有的 less,将其合并为一个 .less 文件。

    • 转化 css-module。

    • 抽取 less 变量:通过 antd-theme-webpack-plugin 来做到的。它通过遍历 less 的语法树,抽取配置中所有拥有 less 变量的选择器,并且将其组合成一个 color.less 的文件。antd-theme-generator 可以查看具体实现。

最优雅的切换/自定义主题方案

CSS 变量: CSS 预设主题相关变量, 通过 JS 动态修改 CSS 变量,进而修改主题。

css 变量的设置及使用:

:root {
  --major-color: red;
}
.text_color {
  color: var(--major-color);
}

JS 操作 CSS 变量:

document.documentElement.style.getPropertyValue('--major-color')
document.documentElement.style.setProperty('--major-color', 'purple')
document.documentElement.style.removeProperty('--major-color')

/*
注意:js操作css变量是在标签的style对象上,所以,对于在css文件中定义的变量,如:`:root { --major-color: red; }`,调用 getPropertyValue 方法,返回的值是空字符。
可以改用下面方法获取:
*/
getComputedStyle(document.documentElement).getPropertyValue('--major-color')

CSS 变量最大的问题是浏览器兼容性

目前来看(2021.07),大部分主流浏览器(chrome、safari、firefox、edge等)是支持的,但是,IE 浏览器不支持。

https://caniuse.com/css-variables

总结

  • class 命名空间:这是个值得推荐的方案。它的唯一问题是,css 文件将多个主题的样式打包进来,文件会增大。不过,使用 postcss-themes 生成多主题样式,只会新增引用了主题相关变量的选择器,文件不会增加太多,并且,它还有个优势:切换主题不会有延迟问题。

  • 多个 CSS 文件:相比 class 命名空间,这个方案优势在于首次加载的 CSS 文件不需要包含其他主题相关的样式。它的缺点:切换主题可能会有加截延迟;webpack 类的打包工具生成的 CSS 文件,我们通常习惯给文件名指定 hash 值,这会导致 JS 动态加载 CSS 时,无法确定正确的文件名;如果有按需加载的模块,切换主题会变得不好处理。

  • 动态创建 style 标签:这个方案是最灵活的,可以任意修改元素的样式。其他方案都有个前提,必需先确定主题相关的变量,主题切换只能修改引用了变量的样式。它的缺点也很明显:主题切换涉及的元素越多,实现成本就越高;不适合使用了 css module 的项目;如果是基于请求已有 css 文件的数据,替换色值来实现,则会有加截延迟问题,另外,按需加载的模块也不好处理。

  • LESS 在线修改变量:这是个不太实用的方案。因为,在浏览器中实时编译 .less 文件,非常影响性能,不适合用于生产环境,另外,将所有主题变量相关的样式提取到一个单独的 .less 文件也比较麻烦。

  • css 变量方法 (推荐):这是最完美的方案,除了不支持 IE 浏览器(目前已确定的是 IE,也可能还有些其他非主流浏览器)。

参考链接

使用 css/less 动态更换主题色(换肤功能)

一文总结前端换肤换主题

实现三方库按需引入与多主题方案

阮一峰-CSS 变量教程

最后更新于