實現Vue專案主題切換

樓東發表於2019-12-09

對於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外掛,實現上仍然不夠優雅,後續將考慮如何不修改原有外掛程式碼的基礎上去實現。如果有好的思路也歡迎一起探討。

相關文章