對於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
還是sass
或stylus
,都能夠支援。
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-plugin
將css
檔案分chunk
匯出成多個css
檔案並動態載入,所以我們需要解決:如何按主題匯出樣式檔案,如何動態載入,如何在html
入口只載入當前主題的樣式檔案。
我們先簡單介紹下mini-css-extract-plugin
匯出css
樣式檔案的工作流程:
第一步:在loader
的pitch
階段,將樣式轉為dependency
(該外掛使用了一個擴充套件自webpack.Dependency
的自定義CssDependency
);
第二步:在plugin
的renderManifest
鉤子中,呼叫renderContentAsset方法,用於自定義css
檔案的輸出結果。該方法會將一個js
模組依賴的多個樣式輸出到一個css
檔案當中。
第三步:在entry
的requireEnsure
鉤子中,根據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
中,在動態載入時,根據chunkId
和theme
從map
中找出最終的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]);
});
}
});
}
複製程式碼
其餘工作
我們通過webpack
的loader
和plugin
,把樣式檔案按主題切分成了單個的css
檔案;並通過一個單獨的模組實現了entry
和chunk
對應主題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
外掛,實現上仍然不夠優雅,後續將考慮如何不修改原有外掛程式碼的基礎上去實現。如果有好的思路也歡迎一起探討。