實現Vue項目主題切換

對於SaaS平臺而言,由於須要一套平臺面向不一樣客戶,因此會有不一樣主題切換的需求。本篇主要探討如何在Vue項目中實現該類需求。css

幾種方案

有產品需求就要想辦法經過技術知足,通過搜索,找到了如下幾種方案:html

  • 方案一, 定義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);
複製代碼

以上幾種方案都能達到主題切換的目的,可是咱們仍是能夠再深刻思考一下,還能不能有更精緻一點的方案?vue

場景細化

  • 變量化的方案並不能知足複雜主題定義,好比改變佈局方式。
  • 如何避免大量的條件判斷,致使在代碼中引入不少業務無關的雜音,增長維護成本?
  • 要預留default功能,若是新主題沒有對某個功能模塊的樣式進行定義,這個功能模塊在佈局和視覺樣式上不該該影響功能的使用。相似於漢化不充分的時候,仍然要可以展現英文菜單,而不能影響功能的使用。
  • 從性能角度考慮,樣式文件最好也要可以按需加載,應該只加載須要的主題對應的css文件。
  • 對於動態路由的狀況,模塊腳本和對應的樣式也是按需加載,這種狀況下如何動態切換主題?

因而可知,當場景細化以後,上述幾種方案都不能知足需求了。所以,接下來我將介紹一種經過webpack插件的方案來實現Vue項目主題切換。webpack

訴求分析

咱們從開發者(即方案目標使用人羣)的角度出發,來一步步分析這套方案的產生過程。git

首先,咱們要可以方便地獲取到當前主題,以此來判斷當前界面展現形式。固然,爲了作到實時切換,這個變量要是「響應式」的!例如:github

{
  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 };
    },
  },
}
複製代碼

其次,最好不要大量的在樣式代碼中去進行條件判斷,同一個主題的樣式放在一塊兒,更便於維護。web

<style lang="less" theme="dark"> header { nav { background-color: #262990; .brand { color: #8183e2; } } .banner { background-color: #222222; } } </style>
複製代碼

最後,最好是css方言無關的,即不論是使用less仍是sassstylus,都可以支持。element-ui

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插件,實現上仍然不夠優雅,後續將考慮如何不修改原有插件代碼的基礎上去實現。若是有好的思路也歡迎一塊兒探討。

相關文章
相關標籤/搜索