动态改变站点主题色

848 阅读5分钟

背景:

产品A:现在小蓝APP的这个xx功能要同步到小黄APP,你们看看这些页面的主题色给改为小黄的吧?
开发B:没问题!

动态改变站点主题色,主流方案有两种:

方案一:webpack-theme-color-replacer

原理:插件分为两部分,编译时和浏览器运行时。
在打包时,利用webpack钩子,将含有指定css色值的样式提取出来,生成一个单独的theme.css文件,跟最终产物一起丢到服务器上。
运行时,在入口文件调用webpack-theme-color-replacer/client端API,并传入目标色值,请求theme.css文件,利用正则匹配,用目标色值替换指定色值,将最终的css包含在style标签插入index.html,覆盖掉原有色值。

原理详细分析

在理解详细原理之前,先让我们简单了解几个开发webpack 插件相关的定义:

Compiler
webpack的核心模块之一,提供了一系列插件编译过程的钩子,该类扩展自tapable类,因此可以通过tapable API在不同时期的钩子上注册,然后在该时期触发。另外在创建compilation的实例时,会传入cli、node API相关的options。

this.hooks = Object.freeze({
 /** @type {SyncHook<[]>} */
 initialize: new SyncHook([]),

 /** @type {SyncBailHook<[Compilation], boolean>} */
 shouldEmit: new SyncBailHook(["compilation"]),
 /** @type {AsyncSeriesHook<[Stats]>} */
 done: new AsyncSeriesHook(["stats"]),
 /** @type {SyncHook<[Stats]>} */
 afterDone: new SyncHook(["stats"]),
 /** @type {AsyncSeriesHook<[]>} */
 additionalPass: new AsyncSeriesHook([]),
 /** @type {AsyncSeriesHook<[Compiler]>} */
 beforeRun: new AsyncSeriesHook(["compiler"]),
 /** @type {AsyncSeriesHook<[Compiler]>} */
 run: new AsyncSeriesHook(["compiler"]),
 /** @type {AsyncSeriesHook<[Compilation]>} */
 emit: new AsyncSeriesHook(["compilation"]),
 /** @type {AsyncSeriesHook<[string, AssetEmittedInfo]>} */
 assetEmitted: new AsyncSeriesHook(["file", "info"]),
 /** @type {AsyncSeriesHook<[Compilation]>} */
 afterEmit: new AsyncSeriesHook(["compilation"]),

 /** @type {SyncHook<[Compilation, CompilationParams]>} */
 thisCompilation: new SyncHook(["compilation", "params"]),
 /** @type {SyncHook<[Compilation, CompilationParams]>} */
 compilation: new SyncHook(["compilation", "params"]),
 /** @type {SyncHook<[NormalModuleFactory]>} */
 normalModuleFactory: new SyncHook(["normalModuleFactory"]),
 /** @type {SyncHook<[ContextModuleFactory]>}  */
 contextModuleFactory: new SyncHook(["contextModuleFactory"]),

 /** @type {AsyncSeriesHook<[CompilationParams]>} */
 beforeCompile: new AsyncSeriesHook(["params"]),
 /** @type {SyncHook<[CompilationParams]>} */
 compile: new SyncHook(["params"]),
 /** @type {AsyncParallelHook<[Compilation]>} */
 make: new AsyncParallelHook(["compilation"]),
 /** @type {AsyncParallelHook<[Compilation]>} */
 finishMake: new AsyncSeriesHook(["compilation"]),
 /** @type {AsyncSeriesHook<[Compilation]>} */
 afterCompile: new AsyncSeriesHook(["compilation"]),

 /** @type {AsyncSeriesHook<[]>} */
 readRecords: new AsyncSeriesHook([]),
 /** @type {AsyncSeriesHook<[]>} */
 emitRecords: new AsyncSeriesHook([]),

 /** @type {AsyncSeriesHook<[Compiler]>} */
 watchRun: new AsyncSeriesHook(["compiler"]),
 /** @type {SyncHook<[Error]>} */
 failed: new SyncHook(["error"]),
 /** @type {SyncHook<[string | null, number]>} */
 invalid: new SyncHook(["filename", "changeTime"]),
 /** @type {SyncHook<[]>} */
 watchClose: new SyncHook([]),
 /** @type {AsyncSeriesHook<[]>} */
 shutdown: new AsyncSeriesHook([]),

 /** @type {SyncBailHook<[string, string, any[]], true>} */
 infrastructureLog: new SyncBailHook(["origin", "type", "args"]),

 // TODO the following hooks are weirdly located here
 // TODO move them for webpack 5
 /** @type {SyncHook<[]>} */
 environment: new SyncHook([]),
 /** @type {SyncHook<[]>} */
 afterEnvironment: new SyncHook([]),
 /** @type {SyncHook<[Compiler]>} */
 afterPlugins: new SyncHook(["compiler"]),
 /** @type {SyncHook<[Compiler]>} */
 afterResolvers: new SyncHook(["compiler"]),
 /** @type {SyncBailHook<[string, Entry], boolean>} */
 entryOption: new SyncBailHook(["context", "entry"])
});

Compilation
扩展自Tapable,也提供了一些生命周期钩子,见官方文档。可以访问所有的模块和它们的依赖(大部分是循环依赖)
一个 compilation 对象代表了一次单一的版本构建和生成资源,它储存了当前的模块资源、编译生成的资源、变化的文件、以及被跟踪依赖的状态信息。简单来说,Compilation的职责就是对所有 require 图(graph)中对象的字面上的编译,构建 module 和 chunk,在编译阶段,模块会被加载(load)、封存(seal)、优化(optimize)、 分块(chunk)、哈希(hash)和重新创建(restore)。
compilation 编译可以多次执行,如在watch模式下启动 webpack,每次监测到源文件发生变化,都会重新实例化一个compilation对象,从而生成一组新的编译资源。

插件核心代码:webpack编译层(适用webpack5)

const { Compilation } = require('webpack')
const Handler = require('./Handler')

const pluginName = 'ThemeColorReplacer'

class ThemeColorReplacer {
  constructor(options) {
    this.handler = new Handler(options)
  }

  apply(compiler) {
    compiler.hooks.compilation.tap(pluginName, (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: pluginName,
          stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, // see below for more stages
        },
        (assets) => {
          this.handler.handle(assets)
        }
      )
    })
  }
}

ThemeColorReplacer.varyColor = require('../client/varyColor')

module.exports = ThemeColorReplacer

上述代码即在compilation时期,在processAssets阶段,执行Handler实例的handle方法,参数为需要处理的资源文件列表,如下图:

image.png

Handler核心逻辑

image.png

通过上图,可知在processAssets阶段,webpack会提取能匹配传入色值样式,额外生成一个样式文件,并在匹配的第一个js文件中插入window.__theme_COLOR_cfg变量,上面有样式文件地址和匹配的色值,如果配置传入injectCss,还会插入window.__theme_COLOR_css,即将所有样式直接插入js中。如下图

image.png image.png

插件核心代码:浏览器运行层(适用webpack5)

该插件还导出了一个client文件,导出了一个changer类,用来读取webpack插件生成的样式文件,并用style包裹插入index.html中,来覆盖原本样式

image.png

总结

webpack-theme-color-replacer 整体原理较为简单,但目前使用下来存在两点问题:

  • 需要去拉取提取后的文件或将整体文件塞入js中,对页面性能有一定影响
  • 微前端这种多个业务站点需要改变主题色,需要多个站点去配置,并保持一致性,有一定复杂度

方案二:CSS变量

浏览器原生支持的方式,目前vant, ant等改变主题色的主流方案,使用较简单,且除IE浏览器外,兼容性较好。

使用方法

1. root节点定义css变量


:root {
    --blue-50: #F0F6FF;
    --blue-100: #D6E4FF;
    --blue-200: #ACC9FE;
    --blue-300: #598BFF;
    --blue-400: #3D6FF5;
    --blue-500: #305DF0;
    --blue-600: #1F4DCC;
    --blue-700: #0D40A6;
    --gray-500: #666F80;
}

2. 组件样式使用变量设置

// 由于我们组件库最开始使用less定义的样式,因此在这直接用css变量覆盖less定义即可
@blue-50: var(--blue-50);
@blue-100: var(--blue-100);
@blue-200: var(--blue-200);
@blue-300: var(--blue-300);
@blue-400: var(--blue-400);
@blue-500: var(--blue-500);
@blue-600: var(--blue-600);
@blue-700: var(--blue-700);

3. 项目里直接在入口文件改变css变量颜色,即可做到动态替换

  • 方式一:root根节点覆盖,跟上面一致
  • 方式二:根dom style样式覆盖
<div :style="varStyle">
  <router-view></router-view>
</div>
<script lang="tsx">
import Vue from 'vue';
export default {
  data() {
    return {
      varStyle: {
        '--blue-500': '#3ab'
      }
    };
  }
};
</script>

演示.gif

总结

该方式较为简单,推荐!