Theming in Vue single file components
There are situations where it’s beneficial to build different CSS files for the same web app. An often seen example is theming an application. When you’re using Vue with its single file component Webpack loader, you’re in luck! You get a lot of flexibility that makes it straightforward to build such a feature.
Goal
The goal of the running example is to create a themed Vue app. Each Vue component can specify one or more theme styles. A build should only bundle one of these style themes together with the common styles.
Implementation
Let’s start with a basic Vue project, created with the Vue command line interface:
vue create themed-app
Now let’s open up App.vue
and add two theme styles for this component, called theme “a” and “b”. We’ll add extra root <style>
tags for these themes. Note the theme
attribute which differentiates the tags from the common style.
The component renders a couple of Header.vue
components. Let’s create it and give it some extra <style>
tags for theme “a” and “c”.
Note that those styles declare scoped
. Scoped means all styles get a component-specific classname to prevent name clashing. The scoped
functionality should still work when implementing theme support.
If you would run a Vue build now, all style
tags end up in the final CSS file (which is the default behavior). This is of course not what we want!
A solution can be to write a preprocessor before processing the files with Webpack. Or you could write your own Webpack loader to bundle the correct styles.
We’ll go with a rather elegant solution hidden in Webpack and vue-loader
. vue-loader
transforms each .vue
file into one or more resources of a specific type. This is based on the blocks defined in each file. If you define a <style>
tag, this creates a CSS resource stream. The resource is processed further as specified by the Webpack rules for CSS resources.
vue-loader
will transform attributes set on tags to resource query parameters. This is implictly documented. Let’s see if we can use those parameters to filter specific styles for a theme bundle.
First, we need to know which Webpack rules apply to CSS resources. We can use the Vue CLI again:
vue inspect --rule css
This gives the following Webpack configuration blurb (shortened for brevity):
/* config.module.rule('css') */
{
test: /\.css$/,
oneOf: [
/* config.module.rule('css').oneOf('vue-modules') */
/* config.module.rule('css').oneOf('vue') */
/* config.module.rule('css').oneOf('normal-modules') */
/* config.module.rule('css').oneOf('normal') */
]
}
So there are 4 rules for CSS resource processing, each with a specific purpose. The oneOf
parent rule makes sure that only one of these rules matches. Matching performs from top to bottom.
By the way, the default Vue CLI app also has out of the box rules configuration for
postcss
,scss
,sass
,less
andstylus
, which we also need to take in account.
Remember that we defined an “a”, “b” and “c” theme? Let’s say we only want to match theme “a” and the common style resources. We can get the desired result by nullifying theme styles “b” and “c” before any other matching.
We make this work by writing a resourceQuery
condition. As the docs state, a condition can be (among other things) an object with multiple matching conditions. We can use it to define the following pseudocode filter:
If the resource query contains theme=b
or theme=c
, stop parsing the resource and throw away the result.
Luckily, there’s a loader out there built for nullifying results: null-loader
. If we could change the Webpack config into the following, we get really close to our original goal.
/* config.module.rule('css') */
{
test: /\.css$/,
oneOf: [
/* config.module.rule('css').oneOf('nullify-other-themes') */
{
resourceQuery: {
or: [
/theme=b/,
/theme=c/
]
},
use: [
{
loader: 'null-loader'
}
]
},
/* config.module.rule('css').oneOf('vue-modules') */
/* config.module.rule('css').oneOf('vue') */
/* config.module.rule('css').oneOf('normal-modules') */
/* config.module.rule('css').oneOf('normal') */
]
}
By default, an app built with the Vue CLI keeps a layer between the project and the Webpack configuration. There’s no way to ‘eject’ the Vue configuration and change the Webpack config yourself. You need to use the APIs that Vue gives you.
If you want to change the Webpack config, there are many ways of changing the out of the box configuration. The most powerful way to mutate configuration is by defining a chainWebpack
function in thevue.config.js
file. This gives you access to the chainable API to mutate the config.
Yet, prepending a oneOf
rule is currently more complex than it needs to be due to a bit of missing API of webpack-chain
. After some tinkering, we can get this to work (and we can wait for the pull request to land to add the extra API to fix this).
We still need to decide how to define the selected theme for the current build. I settled with an environment variable that you can change, I’ll leave it up to the reader to think of alternative ways.
Why not bundle many CSS files with one build? This is quite hard, as a single Webpack configuration functions on a single graph of (conditional) dependencies. And because the Vue CLI doesn’t support building multiple Webpack configurations, this is something that we need to fix outside of the build.
Results
Now that we have our Vue theming support in place, we can start the dev server:
yarn serve
// or
VUE_THEME=a yarn serve
And see that we get the common style applied plus the first theme (“a”).
If we start with a specific theme:
VUE_THEME=b yarn serve
or
VUE_THEME=c yarn serve
we get different results:
You can create different production builds for all three themes:
VUE_THEME=a yarn build
VUE_THEME=b yarn build
VUE_THEME=c yarn build
Make sure to copy the generated CSS before kicking off the next build, as the out of the box setup clears the dist
folder.
Conclusion
I hope you like the proposed implementation of theming support for Vue. Thanks to Webpack’s flexibility and the lesser known features of vue-loader, we have very powerful ways of combining different styles into the final CSS bundle.
You can find the final code repo here.