实现Vue项目主题切换

25,116 阅读6分钟

对于SaaS平台而言,因为需要一套平台面向不同客户,所以会有不同主题切换的需求。本篇主要探讨如何在Vue项目中实现该类需求。

几种方案

有产品需求就要想办法通过技术满足,经过搜索,找到了以下几种方案:

  • 方案一, 定义theme参数,通过prop下发,子组件根据theme来动态绑定style的方式实现。具体可以参考:非常规 - VUE 实现特定场景的主题切换
  • 方案二,通过Ajax获取css,然后替换其中的颜色变量,再通过style标签将样式插入DOM。具体可以参考:Vue 换肤实践
  • 方案三,使用可以在浏览器上直接运行的less,通过传入变量动态编译。Ant Design Pro 的在线切换主题功能就是这样实现的。
  • 方案四,给所有css选择器加一个样式名的类选择器,并把这个类名绑定到body元素上,然后通过DOM API来动态切换主题。以下代码演示了如何通过less编译统一给所有的css选择器添加一个样式名的类选择器。
.white(@v) when(@v = 1) {
    @import "~@/assets/css/theme-white.less";
}
.dark(@v) when(@v = 2) {
    @import "~@/assets/css/theme-dark.less";
}
.generate(@n) when (@n < 3) {
    .theme-@{n} {
        .white(@n);
        .dark(@n);
        .fn();
    }
    .generate(@n + 1);
}
.generate(1);

以上几种方案都能达到主题切换的目的,但是我们还是可以再深入思考一下,还能不能有更精致一点的方案?

场景细化

  • 变量化的方案并不能满足复杂主题定义,比如改变布局方式。
  • 如何避免大量的条件判断,导致在代码中引入很多业务无关的杂音,增加维护成本?
  • 要预留default功能,如果新主题没有对某个功能模块的样式进行定义,这个功能模块在布局和视觉样式上不应该影响功能的使用。类似于汉化不充分的时候,仍然要能够展示英文菜单,而不能影响功能的使用。
  • 从性能角度考虑,样式文件最好也要能够按需加载,应该只加载需要的主题对应的css文件。
  • 对于动态路由的情况,模块脚本和对应的样式也是按需加载,这种情况下如何动态切换主题?

由此可见,当场景细化之后,上述几种方案都不能满足需求了。因此,接下来我将介绍一种通过webpack插件的方案来实现Vue项目主题切换。

诉求分析

我们从开发者(即方案目标使用人群)的角度出发,来一步步分析这套方案的产生过程。

首先,我们要能够方便地获取到当前主题,以此来判断当前界面展示形式。当然,为了做到实时切换,这个变量要是“响应式”的!例如:

{
  computed: {
    lineStyle() {
      let color;
      // eslint-disable-next-line default-case
      switch (this.$theme) {
        case 'dark':
          color = '#C0C4CC';
          break;
        case 'light':
        default:
          color = '#000000';
          break;
      }

      return { color };
    },
  },
}

其次,最好不要大量的在样式代码中去进行条件判断,同一个主题的样式放在一起,更便于维护。

<style lang="less" theme="dark">
  header {
    nav {
      background-color: #262990;

      .brand {
        color: #8183e2;
      }
    }
    .banner {
      background-color: #222222;
    }
  }
</style>

最后,最好是css方言无关的,即不管是使用less还是sassstylus,都能够支持。

import 'element-ui/lib/theme-chalk/index.css';
import './styles/theme-light/index.less?theme=light';
import './styles/theme-dark/index.scss?theme=dark';

具体实现

接下来就为大家具体介绍本文方案的实现细节。

开发阶段

在开发阶段,对于vue项目,通用做法是将样式通过vue-style-loader提取出来,然后通过<style>标签动态插入DOM

通过查看vue-style-loader源码可知,样式<style>的插入与更新,是通过 /lib/addStylesClient.js 这个文件暴露出来的方法实现的。

首先,我们可以从this.resourceQuery解析出样式对应的主题名称,供后续样式插入的时候判断。

options.theme = /\btheme=(\w+?)\b/.exec(this.resourceQuery) && RegExp.$1;

这样,样式对应的主题名称就随着options对象一起传入到了addStylesClient方法中。

关于this.resourceQuery,可以查看webpack的文档。

然后,我们通过改写addStyle方法,根据当前主题加载对应的样式。同时,监听主题名称变化的事件,在回调函数中设置当前主题对应的样式并删除非当前主题的样式。

if (options.theme && window.$theme) {
  // 初次加载时,根据主题名称加载对应的样式
  if (window.$theme.style === options.theme) {
    update(obj);
  }

  const { theme } = options;
  // 监听主题名称变化的事件,设置当前主题样式并删除非当前主题样式
  window.addEventListener('theme-change', function onThemeChange() {
    if (window.$theme.style === theme) {
      update(obj);
    } else {
      remove();
    }
  });

  // 触发hot reload的时候,调用updateStyle更新<style>标签内容
  return function updateStyle(newObj /* StyleObjectPart */) {
    if (newObj) {
      if (
        newObj.css === obj.css
        && newObj.media === obj.media
        && newObj.sourceMap === obj.sourceMap
      ) {
        return;
      }

      obj = newObj;
      if (window.$theme.style === options.theme) {
        update(obj);
      }
    } else {
      remove();
    }
  };
}

关于theme-change事件,可以查看后面的实现主题切换

这样,我们就支持了开发阶段多主题的切换。

线上环境

对于线上环境,情况会更复杂一些。因为我们可以使用mini-css-extract-plugincss文件分chunk导出成多个css文件并动态加载,所以我们需要解决:如何按主题导出样式文件,如何动态加载,如何在html入口只加载当前主题的样式文件。

我们先简单介绍下mini-css-extract-plugin导出css样式文件的工作流程:

第一步:在loaderpitch阶段,将样式转为dependency(该插件使用了一个扩展自webpack.Dependency的自定义CssDependency);

第二步:在pluginrenderManifest钩子中,调用renderContentAsset方法,用于自定义css文件的输出结果。该方法会将一个js模块依赖的多个样式输出到一个css文件当中。

第三步:在entryrequireEnsure钩子中,根据chunkId找到对应的css文件链接,通过创建link标签实现动态加载。这里会在源码中插入一段js脚本用于动态加载样式css文件。

接下来,html-webpack-plugin会将entry对应的css注入到html中,保障入口页面的样式渲染。

按主题导出样式文件

我们需要改造renderContentAsset方法,在样式文件的合并逻辑中加入theme的判断。核心逻辑如下:

const themes = [];

// eslint-disable-next-line no-restricted-syntax
for (const m of usedModules) {
  const source = new ConcatSource();
  const externalsSource = new ConcatSource();

  if (m.sourceMap) {
    source.add(
      new SourceMapSource(
        m.content,
        m.readableIdentifier(requestShortener),
        m.sourceMap,
      ),
    );
  } else {
    source.add(
      new OriginalSource(
        m.content,
        m.readableIdentifier(requestShortener),
      ),
    );
  }

  source.add('\n');

  const theme = m.theme || 'default';
  if (!themes[theme]) {
    themes[theme] = new ConcatSource(externalsSource, source);
    themes.push(theme);
  } else {
    themes[theme] = new ConcatSource(themes[theme], externalsSource, source);
  }
}

return themes.map((theme) => {
  const resolveTemplate = (template) => {
    if (theme === 'default') {
      template = template.replace(REGEXP_THEME, '');
    } else {
      template = template.replace(REGEXP_THEME, `$1${theme}$2`);
    }
    return `${template}?type=${MODULE_TYPE}&id=${chunk.id}&theme=${theme}`;
  };

  return {
    render: () => themes[theme],
    filenameTemplate: resolveTemplate(options.filenameTemplate),
    pathOptions: options.pathOptions,
    identifier: options.identifier,
    hash: options.hash,
  };
});

在这里我们定义了一个resolveTemplate方法,对输出的css文件名支持了[theme]这一占位符。同时,在我们返回的文件名中,带入了一串query,这是为了便于在编译结束之后,查询该样式文件对应的信息。

动态加载样式css文件

这里的关键就是根据chunkId找到对应的css文件链接,在mini-css-extract-plugin的实现中,可以直接计算出最终的文件链接,但是在我们的场景中却不适用,因为在编译阶段,我们不知道要加载的theme是什么。一种可行的思路是,插入一个resolve方法,在运行时根据当前theme解析出完整的css文件链接并插入到DOM中。这里我们使用了另外一种思路:收集所有主题的css样式文件地址并存在一个map中,在动态加载时,根据chunkIdthememap中找出最终的css文件链接。

以下是编译阶段注入代码的实现:

compilation.mainTemplate.hooks.requireEnsure.tap(
  PLUGIN_NAME,
  (source) => webpack.Template.asString([
    source,
    '',
    `// ${PLUGIN_NAME} - CSS loading chunk`,
    '$theme.__loadChunkCss(chunkId)',
  ]),
);

以下是在运行阶段根据chunkId加载css的实现:

function loadChunkCss(chunkId) {
  const id = `${chunkId}#${theme.style}`;
  if (resource && resource.chunks) {
    util.createThemeLink(resource.chunks[id]);
  }
}

注入entry对应的css文件链接

因为分多主题之后,entry可能会根据多个主题产生多个css文件,这些都会注入到html当中,所以我们需要删除非默认主题的css文件引用。

html-webpack-plugin提供了钩子帮助我们进行这些操作,这次终于不用去改插件源码了。

注册alterAssetTags钩子的回调,可以把所有非默认主题对应的link标签删去:

compilation.hooks.htmlWebpackPluginAlterAssetTags.tapAsync(PLUGIN_NAME, (data, callback) => {
  data.head = data.head.filter((tag) => {
    if (tag.tagName === 'link' && REGEXP_CSS.test(tag.attributes && tag.attributes.href)) {
      const url = tag.attributes.href;
      if (!url.includes('theme=default')) return false;
      // eslint-disable-next-line no-return-assign
      return !!(tag.attributes.href = url.substring(0, url.indexOf('?')));
    }
    return true;
  });
  data.plugin.assetJson = JSON.stringify(
    JSON.parse(data.plugin.assetJson)
      .filter((url) => !REGEXP_CSS.test(url) || url.includes('theme=default'))
      .map((url) => (REGEXP_CSS.test(url) ? url.substring(0, url.indexOf('?')) : url)),
  );

  callback(null, data);
});

实现主题切换

注入theme变量

使用Vue.util.defineReactive,可以定义一个“响应式”的变量,这样就可以支持组件计算属性的更新和组件的渲染了。

export function install(Vue, options = {}) {
  Vue.util.defineReactive(theme, 'style');
  const name = options.name || '$theme';
  Vue.mixin({
    beforeCreate() {
      Object.defineProperty(this, name, {
        get() {
          return theme.style;
        },
        set(style) {
          theme.style = style;
        },
      });
    },
  });
}

获取和设置当前主题

通过Object.defineProperty拦截当前主题的取值和赋值操作,可以将用户选择的主题值存在本地缓存,下次打开页面的时候就是当前设置的主题了。

const theme = {};

Object.defineProperties(theme, {
  style: {
    configurable: true,
    enumerable: true,
    get() {
      return store.get();
    },
    set(val) {
      const oldVal = store.get();
      const newVal = String(val || 'default');
      if (oldVal === newVal) return;
      store.set(newVal);
      window.dispatchEvent(new CustomEvent('theme-change', { bubbles: true, detail: { newVal, oldVal } }));
    },
  },
});

加载主题对应的css文件

动态加载css文件通过js创建link标签的方式即可实现,唯一需要注意的点是,切换主题后link标签的销毁操作。考虑到创建好的link标签本质上也是个对象,还记得我们之前存css样式文件地址的map吗?创建的link标签对象的引用也可以存在这个map上,这样就能够快速找到主题对应的link标签了。

const resource = window.$themeResource;

// NODE_ENV = production
if (resource) {
  // 加载entry
  const currentTheme = theme.style;
  if (resource.entry && currentTheme && currentTheme !== 'default') {
    Object.keys(resource.entry).forEach((id) => {
      const item = resource.entry[id];
      if (item.theme === currentTheme) {
        util.createThemeLink(item);
      }
    });
  }

  // 更新theme
  window.addEventListener('theme-change', (e) => {
    const newTheme = e.detail.newVal || 'default';
    const oldTheme = e.detail.oldVal || 'default';

    const updateThemeLink = (obj) => {
      if (obj.theme === newTheme && newTheme !== 'default') {
        util.createThemeLink(obj);
      } else if (obj.theme === oldTheme && oldTheme !== 'default') {
        util.removeThemeLink(obj);
      }
    };

    if (resource.entry) {
      Object.keys(resource.entry).forEach((id) => {
        updateThemeLink(resource.entry[id]);
      });
    }

    if (resource.chunks) {
      Object.keys(resource.chunks).forEach((id) => {
        updateThemeLink(resource.chunks[id]);
      });
    }
  });
}

其余工作

我们通过webpackloaderplugin,把样式文件按主题切分成了单个的css文件;并通过一个单独的模块实现了entrychunk对应主题css文件的加载和主题动态切换。接下来需要做的就是,注入css资源列表到一个全局变量上,以便window.$theme可以通过这个全局变量去查找样式css文件。

这一步我们依然使用html-webpack-plugin提供的钩子来帮助我们完成:

compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tapAsync(PLUGIN_NAME, (data, callback) => {
  const resource = { entry: {}, chunks: {} };
  Object.keys(compilation.assets).forEach((file) => {
    if (REGEXP_CSS.test(file)) {
      const query = loaderUtils.parseQuery(file.substring(file.indexOf('?')));
      const theme = { id: query.id, theme: query.theme, href: file.substring(0, file.indexOf('?')) };
      if (data.assets.css.indexOf(file) !== -1) {
        resource.entry[`${theme.id}#${theme.theme}`] = theme;
      } else {
        resource.chunks[`${theme.id}#${theme.theme}`] = theme;
      }
    }
  });

  data.html = data.html.replace(/(?=<\/head>)/, () => {
    const script = themeScript.replace('window.$themeResource', JSON.stringify(resource));
    return `<script>${script}</script>`;
  });

  callback(null, data);
});

并不完美

完整的代码实现可以参考vue-theme-switch-webpack-plugin。但是该方法修改了两个webpack插件,实现上仍然不够优雅,后续将考虑如何不修改原有插件代码的基础上去实现。如果有好的思路也欢迎一起探讨。