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