对于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
插件,实现上仍然不够优雅,后续将考虑如何不修改原有插件代码的基础上去实现。如果有好的思路也欢迎一起探讨。