For SaaS platforms, the need for a platform for different customers leads to the need for different themes to switch. This article focuses on how to implement such requirements in a Vue project.
Several kinds of schemes
There are product needs to find ways to meet through technology, after searching, found the following solutions:
- Plan one: definition
theme
Parameter, passprop
Delivers the sub-component according totheme
To dynamically bindstyle
Is implemented in a way. Please refer to:Unconventional – VUE enables theme switching for specific scenarios. - Option two, adopted
Ajax
To obtaincss
, and then replace the color variable, and then passstyle
The tag inserts the styleDOM
. Please refer to:Vue peel practice. - Plan three, use something that runs directly in the browser
less
, dynamically compiled by passing in variables.Ant Design ProThis is how the online theme switch function is implemented. - Plan four, for all
css
The selector adds a class selector with a style name and binds the class name tobody
On the element, and then passDOM API
To dynamically switch themes. The following code demonstrates how to passless
Compile uniformly for allcss
Selector Adds a class selector with a style name.
.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(a); }.generate(@n + 1);
}
.generate(1);
Copy the code
All of the above schemes can achieve the purpose of theme switching, but we can still think further, can there be a more delicate scheme?
Scene refinement
- Variable solutions do not satisfy complex topic definitions, such as changing the layout.
- How do you avoid a lot of conditional judgments that introduce a lot of business-neutral noise into your code and increase maintenance costs?
- To set aside
default
Functionality. If the new theme does not define the style of a functional module, the layout and visual style of the functional module should not affect the use of functionality. Similar to insufficient Chinese, still should be able to display the English menu, but can not affect the use of functions. - From a performance perspective, style files should also be able to be loaded on demand, and should only be loaded for the required theme
css
File. - In the case of dynamic routing, module scripts and corresponding styles are also loaded on demand. How do you dynamically switch themes in this case?
It can be seen that when the scenario is refined, none of the above schemes can meet the requirements. So, next I’ll show you a way to switch Vue project themes using the WebPack plug-in.
Demand analysis
We take a step by step look at the process of producing the solution from the perspective of the developer (the target audience of the solution).
First, we need to be able to easily retrieve the current theme to determine the current interface presentation. Of course, in order to switch in real time, this variable should be “responsive”! Such as:
{
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 }; }},}Copy the code
Secondly, it is better not to do a lot of conditional judgment in the style code, the style of the same theme together, easier to maintain.
<style lang="less" theme="dark">header { nav { background-color: #262990; .brand { color: #8183e2; } } .banner { background-color: #222222; }}</style>
Copy the code
Finally, it is best to be CSS dialect independent, that is, to be able to support either less or Sass or 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';
Copy the code
The specific implementation
Next, we will introduce the implementation details of the scheme in detail.
The development phase
During the development phase, it is common practice for vUE projects to extract styles through vue-style-loader and then dynamically insert them into the DOM with the
Insert and update styles
First, we can parse out the theme name of the style from this.resourcequery for later style insertions.
options.theme = /\btheme=(\w+?) \b/.exec(this.resourceQuery) && RegExp. $1;
Copy the code
In this way, the theme name of the style is passed into the addStylesClient method along with the Options object.
For this. ResourceQuery, check out the documentation for Webpack.
We then load the corresponding style based on the current theme by overwriting the addStyle method. At the same time, listen for the event of the topic name change, set the style corresponding to the current topic in the callback function and delete the style that is not the current topic.
if (options.theme && window.$theme) {
// At first load, load the corresponding style according to the theme name
if (window.$theme.style === options.theme) {
update(obj);
}
const { theme } = options;
// Listen for theme name changes, set the current theme style and delete non-current theme style
window.addEventListener('theme-change'.function onThemeChange() {
if (window.$theme.style === theme) {
update(obj);
} else{ remove(); }});// When hot reload is triggered, updateStyle is called to update the
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(); }}; }Copy the code
For theme-change events, see implementing theme switching later.
This allows us to switch between multiple themes during development.
The online environment
For an online environment, things are a little more complicated. Since we can use the mini-CSS-extract-plugin to export CSS files in chunks into multiple CSS files and load them dynamically, we need to solve: how to export style files by topic, how to load them dynamically, and how to load only the style files for the current topic in the HTML entry.
Let’s start with a brief introduction to the workflow for exporting CSS style files with mini-CSS-extract-Plugin:
Step 1: In the Loader pitch phase, convert the style to a dependency(this plug-in uses a custom CssDependency extension from Webpack.dependency);
Step 2: In the Plugin’s renderManifest hook, call the renderContentAsset method to customize the output of the CSS file. This method outputs multiple styles dependent on a JS module to a SINGLE CSS file.
Step 3: In the Entry’s requireEnsure hook, find the corresponding CSS file link according to chunkId and create a link tag for dynamic loading. This inserts a JAVASCRIPT script into the source code to dynamically load the style CSS file.
Next, the HTML-webpack-plugin injects the CSS corresponding to the entry into the HTML to ensure the style rendering of the entry page.
Export style files by subject
We need to modify the renderContentAsset method to include theme judgment in the merge logic of the style file. The core logic is as follows:
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] = newConcatSource(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: (a)= > themes[theme],
filenameTemplate: resolveTemplate(options.filenameTemplate),
pathOptions: options.pathOptions,
identifier: options.identifier,
hash: options.hash,
};
});
Copy the code
Here we define a resolveTemplate method that supports the [theme] placeholder for the output CSS file name. At the same time, we return a string of query in the file name, which is to facilitate the query of the corresponding information of the style file after the compilation.
Dynamic loading stylecss
file
The key here is to find the corresponding CSS file link based on chunkId. In the implementation of the Mini-CSS-extract-Plugin, it is possible to calculate the final file link directly, but this is not applicable in our scenario because at compile time we do not know what theme to load. One possible approach is to insert a resolve method that resolves the full CSS file link based on the current theme at runtime and inserts it into the DOM. Here we use another idea: collect the CSS style file addresses of all themes and store them in a map, and when loading dynamically, find the final CSS file links from the map based on chunkId and theme.
Here is an implementation of the code injected at compile time:
compilation.mainTemplate.hooks.requireEnsure.tap(
PLUGIN_NAME,
(source) => webpack.Template.asString([
source,
' '.` / /${PLUGIN_NAME} - CSS loading chunk`.'$theme.__loadChunkCss(chunkId)',]));Copy the code
Here is an implementation of loading CSS at runtime with chunkId:
function loadChunkCss(chunkId) {
const id = `${chunkId}#${theme.style}`;
if(resource && resource.chunks) { util.createThemeLink(resource.chunks[id]); }}Copy the code
injectionentry
The correspondingcss
The file link
Since entry may generate multiple CSS files based on multiple themes after being divided into multiple themes, which will be injected into the HTML, we need to remove CSS file references that are not the default theme.
The HTML-webpack-plugin provides hooks to help us do this without having to change the plugin’s source code.
Register the alterAssetTags hook callback to remove all non-default themes from the link tag:
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);
});
Copy the code
Implement theme switching
injectiontheme
variable
Using vue.util.definereActive, you can define a “reactive” variable that supports component evaluation property updates and component rendering.
export function install(Vue, options = {}) {
Vue.util.defineReactive(theme, 'style');
const name = options.name || '$theme';
Vue.mixin({
beforeCreate() {
Object.defineProperty(this, name, {
get() {
returntheme.style; }, set(style) { theme.style = style; }}); }}); }Copy the code
Gets and sets the current theme
By intercepting the value and assignment of the current theme with Object.defineProperty, the user-selected theme value can be cached locally so that the next time the page is opened, the current theme will be set.
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 } })); ,}}});Copy the code
Load the corresponding themecss
file
Dynamic loading of CSS files can be achieved by creating link labels with JS. The only point that needs to be paid attention to is the destruction of link labels after changing the theme. Given that the created link tag is essentially an object, remember the map where we stored the address of the CSS style file? References to the created Link label object can also be stored on the map so that you can quickly find the corresponding link label for the topic.
const resource = window.$themeResource;
// NODE_ENV = production
if (resource) {
/ / load 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); }}); }/ / update the 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]); }); }}); }Copy the code
The rest of the work
We use webpack loader and plugin to slice the style files into single CSS files according to the theme. In addition, a separate module is used to load CSS files corresponding to entry and chunk and dynamically switch themes. The next thing you need to do is inject the CSS resource list into a global variable so that window.$theme can be used to look up the style CSS file.
We still use the hooks provided by the HTML-webpack-plugin to help us with this step:
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);
});
Copy the code
Is not perfect
For a complete code implementation, see vue-theme-switch-webpack-plugin. However, this method modified two WebPack plug-ins, the implementation is still not elegant, the future will consider how to implement without modifying the original plug-in code. If you have a good idea is also welcome to discuss.