Skip to content
On this page

IE 下切换主题配色解决方案

所谓“切换主题配色”,可以是指:

  1. 不同页面自动使用不同配色

  2. 用户切换主题配色

两种需求的底层道理是一样的,前者更复杂一些,本文先以前者为例说明,最后补充后者的实现方案。

尽量追求最佳方案

一些大型项目由于业务板块多,会考虑给每个板块的设定不同的主题配色,这就涉及到多套主题配色的解决方案。

事实上,如果是只针对现代浏览器,那么解决方案相当简单,使用CSS Variables即可,但是 IE 浏览器不支持CSS Variables,所以还要再考虑其他解决方案。

或许你会说:“调整配色,生成多套主题 CSS 就可以解决这个问题,而且一般 UI 库都支持”。没错,这可以解决,但不是最佳方案,比如 Element UI 的 index.min.css 是 234KB,再生成一套就合计 468KB,两套 CSS 的区别仅仅是一些颜色的差异,差异代码只有 30KB 这样的规模,相同代码有 200KB 左右,严重冗余。另外,如果尝试使用 Element UI 官方的主题生成器,就更离谱了,它生成的压缩后 CSS 文件足有 500KB。所以,我们应该考虑更佳的解决方案。

怎样才算最佳解决方案?

  1. 步骤要尽量简单,效果要尽量优异,插件要尽量少装,代码要尽量少改。

  2. 开发环境下修改 SCSS 变量之后,页面能即时热更新。

  3. 页面跳转时主题也随之切换,至少生产环境必须做到。

  4. 差量切换 CSS,差量最小化,节省带宽,加快渲染。

  5. CSS 独立打包,便于缓存和复用。

最佳解决方案

本章以 Element UI 组件库为例详细介绍最佳解决方案。假设我正在开发一个教育行业的项目,它包含“老师”和“学生”两大业务板块,现在我们希望“教师”模块、“学生”模块、其他页面都有各自的主题配色。最佳解决方案是:

  1. 开发环境:根据 Element UI 官方文档,引入官方的 SCSS 文件并调色,直到调试出满意的多套主题配色方案。

  2. 生产环境:手写 webpack 插件,由 SCSS 生成各自的 CSS 文件,提取各个 CSS 中的相同代码和差异代码,写入@/assets/styles/目录。其中差异代码加命名空间,比如“教师”模块的差异 CSS 代码的每条规则要在选择器最前面加.theme-teacher类名。最终,各业务板块的根路由组件、非业务板块各页面各自加载各自的 CSS 文件即可。

筹备方案

1. 准备主题 SCSS 文件

@/assets/styles/目录创建 4 个文件:element-ui-components.scsstheme-default.scsstheme-teacher.scsstheme-student.scss

element-ui-components.scss

它的作用是指定加载哪些组件的样式。它会被所有的theme-*.scss引用。

  • 整体引入样式的话

格式类似这样,可以一字不改。

字库 CDN 可以替换成别的 CDN 或使用本地字库:../../../node_modules/element-ui/packages/theme-chalk/src/fonts

../../../相对路径是必须的,因为不仅项目脚手架要读取 SCSS 文件,之后手写的 webpack 插件也需要读取这个文件。

scss
$--font-path: 'https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.9/theme-chalk/fonts';
@import '../../../node_modules/element-ui/packages/theme-chalk/src/index.scss';
  • 按需引入样式的话

格式类似这样,根据实际需求引入即可,内容可以从/node_modules/element-ui/packages/theme-chalk/src/index.scss择取:

scss
$--font-path: 'https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.9/theme-chalk/fonts';
@import '../../../node_modules/element-ui/packages/theme-chalk/src/pagination.scss';
@import '../../../node_modules/element-ui/packages/theme-chalk/src/dialog.scss';
@import '../../../node_modules/element-ui/packages/theme-chalk/src/autocomplete.scss';
// ... more

theme-default.scss

  • 使用 Element UI 原装配色的话:
scss
@import './element-ui-components.scss';
  • 自定义配色的话,类似这样,只需调整色值即可,其他不要改。
scss
$--color-primary: #a100a0;
$--color-success: #c1f2a1;
$--color-danger: #a10026;
$--color-warning: #a17900;
$--color-info: #6dcbd7;
// ... 其他变量

@import './element-ui-components.scss';

theme-teacher.scss

类似这样,只需调整色值即可,其他不要改。

scss
$--color-primary: #a100a0;
$--color-success: #c1f2a1;
$--color-danger: #a10026;
$--color-warning: #a17900;
$--color-info: #6dcbd7;
// ... 其他变量

@import './element-ui-components.scss';

theme-student.scss

类似于theme-teacher.scss,只是变量色值不同,范例从略。

如果用命名空间包裹 theme-*.scss 文件是不是好主意?

也就是给 SCSS 代码外面再包裹一个总类名,比如“教师模块”的:

scss
.theme-teacher {
  $--color-primary: #a100a0;
  $--color-success: #c1f2a1;
  $--color-danger: #a10026;
  $--color-warning: #a17900;
  $--color-info: #6dcbd7;
  // ... 其他变量

  @import './element-ui-components.scss';
}

对比一下优缺点:

使用命名空间不用命名空间
优点开发环境下切换类名即可随意切换主题没有优点,但是可以不用担心 Dart Sass 编译缺陷
缺点Dart Sass 缺陷导致复杂 SCSS 编译出错开发环境下热更新机制导致主题 CSS 覆盖
如何克服上述的缺点解决方案其实也有,但会将整体方案复杂化,不值得,所以复杂 SCSS 不要用命名空间虽然有覆盖现象,但是程序员开发时只专注于一个页面,所以不碍事

解释一下:

  • 开发环境下切换主题的纠结

在开发环境下,Vue CLI 脚手架为迁就热更新机制,会将 CSS 写成行内代码,切换主题时会无脑追加新主题样式,最后追加的样式是全局生效的,如果没有使用命名空间包裹 SCSS,最后追加的主题样式会覆盖其他主题样式,导致全站都成了一个主题,只能刷新一下页面才能解决问题,这就造成了不好的开发体验。

如果使用命名空间包裹 SCSS,虽然可以解决上述问题,但是也会引来新的问题,见下文。

  • Dart Sass 缺陷导致的纠结

以目前 Dart Sass 的编译机制,如果 UI 库的 SCSS 代码中包含 @font-face 和复杂的 mixins,然后给 SCSS 外层包裹命名空间,那么 Dart Sass 编译出的 CSS 会有错误,因为 Dart Sass 不能正确处理在 @font-face 和复杂的 mixins 外面包裹命名空间。

  • 总结

如果你用的 UI 库的 SCSS 内容较为复杂,使用了 @font-face 和复杂的 mixins,那么不应考虑给 SCSS 外层包裹.theme-*。虽然开发时的体验不好,但是凑合着用也问题不大。

如果你用的 UI 库的 SCSS 内容非常简单,没有用 @font-face 和复杂的 mixins,那么可以尝试给 SCSS 外层包裹.theme-*,这样的好处是开发环境下就可以依靠切换.theme-*来切换主题,开发体验很好。

  • 如何判定某 UI 库是否适合被命名空间包裹?

原本我想写个工具来验证编译后的 CSS,后来觉得意义不大,因为极少有人愿意用这个工具,我自己都懒得用,事实上,打开编译好的 CSS,格式化,然后从上往下用眼睛扫一遍就能看出来。

  • Element UI 的 SCSS 属于哪一种情况?

因为本文是以 Element UI 为例,Element UI 的 SCSS 包含复杂情况,所以不能包裹.theme-*

  • 生产环境下会有命名空间吗?

当然!一定有!因为本方案就是要生产环境下利用切换命名空间来切换主题配色。即便开发环境没有使用命名空间,下文的 webpack 插件也会给生产环境的 CSS 加上命名空间,全自动操作,无需开发者操心。

2. 安装开发依赖包 css-tree

css-tree 是生成 CSS 的抽象语法树的最佳工具,我们用它来分析各个主题 CSS 文件。

bash
yarn add -D css-tree

3. 创建 webpack 插件 ExtractCssDiffWebpackPlugin.js

在项目根目录创建ExtractCssDiffWebpackPlugin.js,它的原理是将几套 SCSS 文件转换为 CSS 文件,然后对比差异,提取相同代码和差异代码,分别存入不同 CSS 文件。

注意,这个插件兼容了上文提到的使用与不使用命名空间的两种情况,根据传参的scssIsWrappedByThemeNametrue还是false区分。

具体原理见注释:

js
const fs = require('fs');
const sass = require('sass');
const { minify } = require('csso');
const csstree = require('css-tree');

class ExtractCssDiffWebpackPlugin {
  constructor(options) {
    // 业务板块名称
    this.themeNames = options.themeNames;
    // 开发环境的 SCSS 文件是否被 '.theme-*' 包裹?
    this.scssIsWrappedByThemeName = options.scssIsWrappedByThemeName;
  }
  apply() {
    // 各 SCSS 转换为抽象语法树再转为普通对象然后取children属性
    let plainObjectChildrens = {};
    // 储存相同代码
    let same = [];
    // 储存差异代码
    let diffs = {};
    // 给各变量注入内容
    // 其中 .replace 的作用是为解决 Element UI 的图标乱码现象,
    // 做法是将 unicode 码前面多加一个 \ ,后续会有代码删除多加的 \
    this.themeNames.forEach((themeName) => {
      const plainObject = csstree.toPlainObject(
        csstree.parse(
          minify(
            sass
              .compile(`./src/assets/styles/theme-${themeName}.scss`, {
                quietDeps: true,
                style: 'expanded',
              })
              .css.replace(/content: "\\([a-z0-9]{4})";/g, 'content: "\\\\$1";')
          ).css
        )
      );
      plainObjectChildrens[themeName] = plainObject.children;
      diffs[themeName] = [];
    });

    // 提取相同代码和差异代码
    for (let i = 0; i < plainObjectChildrens.default.length; i++) {
      // 如果 this.scssIsWrappedByThemeName 为 true,
      // 那么规则的差别除了 CSS 属性值的差别之外还有业务板块名称的差别,
      // 所以先要使用 .replace 消除业务板块名称的差别,避免被这个假的差别干扰,
      // 之后如果还有差别就一定是 CSS 属性值的差别,这才是真正的差别。
      // 如果 this.scssIsWrappedByThemeName 为 false,
      // 则 .replace 会无匹配,所以也不碍事。
      // 另外,这里判定相同代码和差异代码的算法使用了简陋但准确的算法,
      // 即:排除假的差别之后,只要 JSON 字串相同,就认为是相同的规则。
      const newSet = new Set();
      for (let themeName in diffs) {
        newSet.add(
          JSON.stringify(plainObjectChildrens[themeName][i]).replace(
            RegExp('theme-' + themeName, 'g'),
            ''
          )
        );
      }
      if (newSet.size === 1) {
        if (
          this.scssIsWrappedByThemeName &&
          plainObjectChildrens.default[i].type === 'Rule'
        ) {
          plainObjectChildrens.default[i].prelude.children.forEach((v) => {
            v.children.shift();
            v.children.shift();
          });
        }
        same.push(plainObjectChildrens.default[i]);
      } else {
        for (let themeName in diffs) {
          if (
            !this.scssIsWrappedByThemeName &&
            plainObjectChildrens[themeName][i].type === 'Rule'
          ) {
            plainObjectChildrens[themeName][i].prelude.children.forEach((v) => {
              v.children.unshift(
                {
                  type: 'ClassSelector',
                  loc: null,
                  name: 'theme-' + themeName,
                },
                {
                  type: 'Combinator',
                  loc: null,
                  name: ' ',
                }
              );
            });
          }
          diffs[themeName].push(plainObjectChildrens[themeName][i]);
        }
      }
    }
    // 将相同代码和差异代码写入文件
    fs.writeFileSync(
      './src/assets/styles/element-ui-common.css',
      minify(
        csstree
          .generate(csstree.fromPlainObject({ type: 'StyleSheet', children: same }))
          // 原因见上文注释
          .replace(/\\\\/g, '\\')
      ).css
    );
    for (let themeName in diffs) {
      fs.writeFileSync(
        `./src/assets/styles/theme-${themeName}.css`,
        minify(
          csstree
            .generate(
              csstree.fromPlainObject({ type: 'StyleSheet', children: diffs[themeName] })
            )
            // 原因见上文注释
            .replace(/\\\\/g, '\\')
        ).css
      );
    }
  }
}

module.exports = ExtractCssDiffWebpackPlugin;

4. 在 vue.config.js 调用插件

需要给插件传递参数,参数格式如范例所示,其中themeNames第一个元素必须是default

js
const ExtractCssDiffWebpackPlugin = require('./ExtractCssDiffWebpackPlugin');

module.exports = defineConfig({
  chainWebpack(config) {
    config.when(process.env.NODE_ENV === 'production', (config) => {
      // themeNames 的第一个元素必须为 'default'
      config.plugin('ExtractCssDiff').use(
        new ExtractCssDiffWebpackPlugin({
          themeNames: ['default', 'teacher', 'student'],
          scssIsWrappedByThemeName: false,
        })
      );
    });
  },
});

方案准备工作到此已经完成。

应用方案

现在我们追求最佳实践,开发环境使用上面准备的 SCSS 文件,生产环境必须实现按需引入相同代码和不同代码,所以我们要根据环境判断引入的内容。

1. 在路由表中使用beforeEnter钩子

  • 非业务的页面,比如首页、登录页、404 页等,引入theme-default相关 SCSS 和 CSS:
js
{
  path: '/',
  name: 'home',
  component: HomeView,
  beforeEnter: (to, from, next) => {
    if (process.env.NODE_ENV === 'development') {
      import('@/assets/styles/theme-default.scss').then(() => {
        next();
      });
    } else {
      Promise.all([
        import('@/assets/styles/theme-default.css'),
        import('@/assets/styles/element-ui-common.css')
      ]).then(() => {
        next();
      });
    }
  },
},
  • 给业务板块设 2 个父路由组件,分别引入theme-teachertheme-student相关 SCSS 和 CSS,以“教师”板块的父路由组件为例:
js
{
  path: '/teacher',
  name: 'teacher',
  component: () => import(/* webpackChunkName: "teacher" */ '../views/Teacher.vue'),
  beforeEnter: (to, from, next) => {
    if (process.env.NODE_ENV === 'development') {
      import('@/assets/styles/theme-teacher.scss').then(() => {
        next();
      });
    } else {
      Promise.all([
        import('@/assets/styles/theme-teacher.css'),
        import('@/assets/styles/element-ui-common.css')
      ]).then(() => {
        next();
      });
    }
  },
  children: [
    // ... 教师板块下所有路由
  ]
},

2. App.vue 监听$route.path

根据$route.path的特征判定当前页面应该使用哪个主题,然后给<body></body>元素设置对应class

js
export default {
  watch: {
    '$route.path': {
      handler(newVal) {
        let bodyClassName = '';
        if (/^\/teacher/.test(newVal)) {
          bodyClassName = 'theme-teacher';
        } else if (/^\/student/.test(newVal)) {
          bodyClassName = 'theme-student';
        } else {
          bodyClassName = 'theme-default';
        }
        document.body.className = bodyClassName;
      },
      immediate: true,
    },
  },
};

使用效果

开发环境下

首页和各个业务板块使用各自的 SCSS 文件,修改变量会即时生效。

如果使用了命名空间包裹 SCSS(前提是适合使用命名空间),则各个业务板块主题配色会正常切换。

如果没有使用命名空间,则只有最后加载的主题配色在全局生效,不过由于开发时程序员只会专注于一个页面,所以这个问题不严重。

生产环境下

  1. 实现了 CSS 文件按需加载:访问业务板块、非业务板块都按需加载对应的theme-*.css文件。

  2. 实现了最小量加载:举个例子,假设全量引入样式,且修改了$--color-primary$--color-success$--color-danger$--color-warning$--color-info这 5 个变量,且必须兼容 IE10 和 IE11,那么:相同文件的大小为 183KB,差异文件有 3 个,大小都是 33KB,可见,有 183KB 的文件被多次复用,这是很大的优化成果。

用户切换主题的解决方案

用户只需点击主题选择器,点击任一色卡即可全局切换主题,这个需求怎么实现呢?

筹备方案阶段

完全同上文方案,有无命名空间均可。

应用方案阶段

与上文完全不同。

  1. App.vue使用:
js
if (process.env.NODE_ENV === 'development') {
  require('@/assets/styles/theme-default.scss');
} else {
  require('@/assets/styles/element-ui-common.css');
  require('@/assets/styles/theme-default.css');
}

如果将default改成teacher等,就可以调试其他主题的配色,但最终应改回default

  1. 在主题选择器组件使用:
js
onChangeTheme(theme) {
  import('@/assets/styles/theme-' + theme + '.css').then(() => {
    document.body.className = 'theme-' + theme;
  });
}

这段代码没有考虑到开发环境,因为上文说过,如果开发环境没有使用命名空间,那么由于热更新机制的限制,切换后会停留在最后一个主题上,无法切换回去,所以考虑开发环境可能是徒劳的。而且其实这段代码非常简单,只需在生产环境测试通过就够了。

其他问题

如果我想用babel-plugin-import实现 Element UI 的按需引入,怎么办?

Element UI 官方手册要求在babel.config.js里加入:

js
"plugins": [
  [
    "component",
    {
      "libraryName": "element-ui",
      "styleLibraryName": "theme-chalk"
    }
  ]
]

在本方案中,你应该删除styleLibraryName这行,改为"style": false,因为我们不需要babel-plugin-import帮我们引入样式。其他一律按官方文档执行即可。

按需引入的话,本方案的 element-ui-components.scss 组件列表整理起来很麻烦,怎么办?

这个列表和 main.js 里的按需加载的列表是大致一一对应的,如果你觉得这个列表很麻烦,那么你就不适合搞按需加载,如果你坚持搞按需加载,就不应该觉得这个列表麻烦,事实上你可以初期先全量加载,开发接近完工再考虑优化。

ExtractCssDiffWebpackPlugin 插件是否可以用在其他 UI 库上?

不修改插件程序的情况下,只要满足这 4 个要求,就可以应用在 Element UI 之外的其他 UI 库上:

  1. 该插件只假设 UI 库使用了 Sass 或 SCSS。

  2. 该插件假设你在@/src/assets/styles/目录存放了若干个theme-*.scss文件,名称与传参的themeNames一致。

  3. 该插件假设 SCSS 编译出的几套 CSS 除了命名空间和属性值之外,没有任何区别。

  4. 传参的themeNames的第一个元素必须是default

杨亮的前端解决方案