Learning how to configure Webpack is not to be taken for granted: on the contrary, I believe it is necessary to consdier it as a gradual process – learning everything makes no sense and it is too complex, therefore I deem it wise to delve into the configurations related to specific issues as needed.
After having lost count of all the specific issues I’ve faced, I happened to lay my hands of my first Rails + Webpacker project, with the task of implementing a simple frontend feature. At first I was happy: I chose a JS library, I fancied a yarn add
, a configuration and I’d be go.
Except in Rails you don’t configure Webpack - you configure Webpacker. The experience was a bit profanity, a bit nice surprise.
Before giving opinions, let’s dive into the necessity and into how we reach the objective.
Configurations over conventions over configurations
Consistent with the Rails philosophy - and in general with the Agile approach - Webpacker too relies on the motto “conventions over configurations”.
By default, you don’t need to make any changes to config/webpack/*.js files since it’s all standard production-ready configuration.
But we know all too well that the moment will come when we add custom configurations to the standardized conventions. Webpacker, by default, does not integrate file-loader
. Let’s read the library’s instructions to understand how to install it, from https://www.npmjs.com/package/file-loader.
- To begin, you’ll need to install
file-loader
:
1
$ npm install file-loader --save-dev
First question, easy and widespread even outside the Rails+Webpacker context: we shouldn’t use npm
but rather yarn
; I am so knowledgeable and I decisively use
1
yarn add file-loader --dev
- Then add the loader to your
webpack
config. For example:
webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
module: {
rules: [
{
test: /\.(png|jpg|gif)$/,
use: [
{
loader: 'file-loader',
options: {},
},
],
},
],
},
};
We do not have the webpack.config.js
file (profanity), but luckily we have some documentation about how to intervene (nice surprise.)
Here are the steps to address our need.
For what environment do we add the configuration?
We have a general configuration in config/webpack/environment.js
and specific configurations in config/webpack/xxx.js
, where xxx
is the name of an environment of ours.
All of these configurations follow this logic:
- at the top they import
environment
- in the middle, they modify - if needed - some keys of this
const
- they export
environment
1
2
3
const { environment } = require('@rails/webpacker')
// do something on environment
module.exports = environment
or, the individual environments import environment
from the main configuration rather than by the node module and they export it “prepared” for Webpack:
1
2
3
4
5
process.env.NODE_ENV = process.env.NODE_ENV || 'production' // <-- set env
const environment = require('./environment') // <-- generic conf
// do something on environment
module.exports = environment.toWebpackConfig()
We shall intervene horizontally on all environments and therefore we will consider config/webpack/environment.js
.
Adding a loader
1
2
3
4
5
6
7
const { environment } = require('@rails/webpacker')
const fileLoader = require('./loaders/file')
// do something on environment
environment.loaders.append({key: 'file', value: fileLoader})
module.exports = environment
where this is the file loaders/file.js
:
1
2
3
4
5
6
7
8
9
module.exports = {
test: /\.(png|jpg|gif)$/,
use: [
{
loader: 'file-loader',
options: {},
},
]
}
Why did I choose to write this file? Just for cleanliness. There is no other reason.
At this point, let’s demystify the first magic, and in order to do this, let’s make an act of faith about what environment.loaders
is and what functions are available to it. We can sense that append
will add “something” at the end, the same way as we require with the argument
1
{key: 'file', value: fileLoader}
Let’s start with the argument:
key
is an arbitrary name. When we say arbitrary, we obviously imply the responsibility to give an understandable, recognizable and attributable name.value
instead is an object corresponding to an individual rule in therules
array, as given by the original documentation of the NPM packages.
Let’s see them again close to each other in order to better understand the object that comes in handy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = {
module: {
rules: [
// start
{
test: /\.(png|jpg|gif)$/,
use: [
{
loader: 'file-loader',
options: {},
},
],
},
// end
],
},
};
1
2
3
4
5
6
7
8
9
module.exports = { //start
test: /\.(png|jpg|gif)$/,
use: [
{
loader: 'file-loader',
options: {},
},
]
} //end
So, at this point it is clear what we can copy-paste from file-loader
‘s documentation: we know how to find the right snippet and we know where to put it. Also, the trick of isolating the snippet in an independent file allows us to identify it at first glance, to re-use it in different environments, perform atomic commits for the individual configurations.
Ok, but: why, and how?
Why?
Learning this caused me firstly anger and annoyance. After all, the effort I have made in the past to learn how to configure Webpack (rant: and to learn again, on every earthshaking update, the structure of its webpack.config.js
), the impression is that in this context my experience serves little purpose.
Incredulous, and in berserker mode, I decided to verify the fact that all this layering was not only and exclusively done for the sake of making Rails developers comfortable by providing them some generic configuration in a format more familiar to them: config/webpacker.yml
.
The tour began by looking at what we bring to our JavaScript configuration on the moment when we import const { environment } = require('@rails/webpacker')
. What does Webpacker do when we ask it environment
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// [...]
const createEnvironment = () => {
const path = resolve(__dirname, 'environments', `${nodeEnv}.js`)
// If nodeEnv is not set, Environment will do some magic by choosing one
// and giving us a helping hand
const constructor = existsSync(path) ? require(path) : Environment
return new constructor()
}
module.exports = {
// [...]
environment: createEnvironment(), // <--- Il nostro environment
// [...]
}
So, for a production
environment, we will obtain as environment
an instance of an object like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// [...]
module.exports = class extends Base {
constructor() {
super()
this.plugins.append(
// [...]
)
this.plugins.append(
// [...]
)
this.config.merge({
// [...]
})
}
}
where, of course, Base
is an object that contains the common configurations (I am not displaying it because it is too long, but it can be read here. As a matter of fact, we have a complicated, but “private”, way of building objects (Builder pattern), whereas a webpack.config.js
- as perverse as it might be - is a plain object to write.
The answer to my initial question is then: not only that. Yes: part of the code is devoted to taking RAILS_ENV
and understanding if we can use that, if we don’t have a NODE_ENV
, another part is devoted to read the configurations in YAML. But there are two distinct notes to take, in my opinion:
- The YML file is really only a palliative for rubyists with no specific Webpack competences
- All the rest of the code is a very elegant environment management solution. Whoever has written a solution to the same purpose by hand knows very well how complicated it can be and how irksome it is to maintain it within the code if you don’t have the right knowledge. And in the end, in the eternal struggle of quality/time ratio, integrating such things by project ends up producing faltering solutions.
How?
Net of all that we’ve seen, there is still at least one magic to demystify: environment.loaders.append
. I am used to write Webpack configurations with kilos of lines of plain objects; what is this append
doing?
In fact, to understand this we shall look at what loaders
is. A very poor, and very effective, trick is to write this in config/webpack/environment.js
:
1
2
3
4
5
6
7
8
const { environment } = require('@rails/webpacker')
const resolveConfig = require('./resolve/alias')
environment.loaders.add({key: 'file', value: fileLoader})
console.log(environment.loaders) // <--- \m/ O_O \m/
module.exports = environment
and then call the compiler from the command line with ./bin/webpack
.
We should see an output altogether like this in the console:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ConfigList [
{ key: 'babel',
value:
{ test: /\.(js|jsx)?(\.erb)?$/,
exclude: /node_modules/,
use: [Array] } },
{ key: 'css',
value:
{ test: /\.(css)$/i, use: [Array], exclude: /\.module\.[a-z]+$/ } },
{ key: 'sass',
value:
{ test: /\.(scss|sass)$/i,
use: [Array],
exclude: /\.module\.[a-z]+$/ } },
{ key: 'moduleCss',
value:
{ test: /\.(css)$/i, use: [Array], include: /\.module\.[a-z]+$/ } },
{ key: 'moduleSass',
value:
{ test: /\.(scss|sass)$/i,
use: [Array],
include: /\.module\.[a-z]+$/ } },
{ key: 'file',
value:
{ test: /\.(png|jpg|gif)$/,
use: [Array] } } ]
Speaking of the definition, “very elegant”, that I used above: what is a ConfigList
? We can see that on GitHub.
In a word, ConfigList
is an object extending Array
and returning objects of proto-type Array
, augmented as if JS’s standard library was something serious.
Among others, a simple values()
function:
1
2
3
values() {
return this.map(item => item.value)
}
After which, environment
has a toWebpackConfig()
function that, for what concerns the loaders, does the simplest thing in the world:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
toWebpackConfig() {
return this.config.merge({
// [...]
module: {
// [...]
rules: [
...this.loaders.values()
]
},
// [...]
})
}
rebuilding the structure of Webpack’s configuration object as we all(?) know it!
I don’t know about you, but after taking the magic away I feel way better ðŸ˜