How I cut my Webpack bundle size in half
In the fall, a young man’s fancy lightly turns to thoughts of front-end performance.
When I initially built out Buttondown, I was focused on two aspects above all else:
- It being built quickly.
- It working reasonably well.
Notably excluded from that list is performance. Buttondown isn’t a slow app, but it is a heavy one: the bundle size while developing is measured in megabytes, and there’s a non-trivial loading time for first-time users.
Now that the core feature base has stabilized and nothing is particularly in an “on fire” state, I wanted to turn my eye towards maintenance work, and a big piece of that was seeing what I could do to shrink that bundle.
So, yeah. A hair under four megabytes. Sure, that’s unminified and unuglified, but it’s way too large for an app of Buttondown’s size. I resolved to cut that size in half, and here’s how: a combination of best practices and common sense front-end principles that I really should have adopted from the start.
0. Analyzing the Bundle
I knew the bundle was big, but I didn’t really know why it was big. Thankfully, there’s a tool for that! webpack-bundle-analyzer analyzes your webpack stats and spits out a tree-map visualizing what’s taking up so much space:
What the bundle looks like
1. Ditch jQuery
“Do you really need jQuery” is such a common question that it has its own site, and in this case the answer was, uh, no. Especially because Vue makes it exceptionally easy to capture events and reference the DOM, which were the only two things I was actually using it for. Here’s what the diff looks like:
- .eslintrc.js +0 -1
- assets/components/DraftBody.vue +5 -9
- assets/screens/Share.vue +2 -2
- package.json +0 -1
- webpack.config.js +0 -4
@@ -17,7 +17,6 @@ module.exports = {
|
|
17
17
|
'Urls': true,
|
18
18
|
'SITE_URL': true,
|
19
19
|
'ga': true,
|
20
|
-
'$: true,
|
21
20
|
'StripeCheckout': true,
|
22
21
|
'STRIPE_PUBLIC_KEY': true,
|
23
22
|
'amplitude': true,
|
@@ -19,6 +19,7 @@
|
|
19
19
|
@scroll="handleTextAreaScroll"
|
20
20
|
@mouseup="handleTextAreaResize"
|
21
21
|
v-model="body"
|
22
|
+
ref="input"
|
22
23
|
/>
|
23
24
|
<div
|
24
25
|
:class="{
|
@@ -37,6 +38,7 @@
|
|
37
38
|
:class="{'hidden': !showMarkdownPreview}"
|
38
39
|
@scroll="handlePreviewScroll"
|
39
40
|
v-html="compiledBody"
|
41
|
+
ref="preview"
|
40
42
|
/>
|
41
43
|
</div>
|
42
44
|
</div>
|
@@ -78,21 +80,15 @@
|
|
78
80
|
|
79
81
|
methods: {
|
80
82
|
handleTextAreaScroll() {
|
81
|
-
|
83
|
+
this.$refs.preview.scrollTop = this.$refs.input.scrollTop;
|
82
|
-
const $preview = $('.draft-body__preview');
|
83
|
-
$preview.scrollTop($textarea.scrollTop());
|
84
84
|
},
|
85
85
|
|
86
86
|
handlePreviewScroll() {
|
87
|
-
|
87
|
+
this.$refs.input.scrollTop = this.$refs.preview.scrollTop;
|
88
|
-
const $preview = $('.draft-body__preview');
|
89
|
-
$textarea.scrollTop($preview.scrollTop());
|
90
88
|
},
|
91
89
|
|
92
90
|
handleTextAreaResize() {
|
93
|
-
|
91
|
+
this.$refs.preview.style.height = this.$refs.input.offsetHeight;
|
94
|
-
const $preview = $('.draft-body__preview');
|
95
|
-
$preview.height($textarea.height());
|
96
92
|
},
|
97
93
|
|
98
94
|
startDrag(e) {
|
@@ -26,7 +26,7 @@
|
|
26
26
|
action="https://jsfiddle.net/api/post/library/pure/"
|
27
27
|
>
|
28
28
|
<input type="hidden" name="html" :value="embedCode" />
|
29
|
-
<input style="display: none" type="submit" />
|
29
|
+
<input id="jsFiddleSubmitButton" style="display: none" type="submit" />
|
30
30
|
<a
|
31
31
|
class="tiny"
|
32
32
|
href="#"
|
@@ -133,7 +133,7 @@ src="${this.newsletterUrl}?as_embed=true"
|
|
133
133
|
},
|
134
134
|
methods: {
|
135
135
|
submitForm() {
|
136
|
-
|
136
|
+
document.getElementById('jsFiddleSubmitButton').click();
|
137
137
|
},
|
138
138
|
},
|
139
139
|
head: {
|
@@ -69,7 +69,6 @@
|
|
69
69
|
"inject-loader": "^2.0.1",
|
70
70
|
"jest": "^19.0.2",
|
71
71
|
"jest-vue-preprocessor": "^0.1.3",
|
72
|
-
"jquery": "^3.1.1",
|
73
72
|
"karma": "^1.4.1",
|
74
73
|
"karma-coverage": "^1.1.1",
|
75
74
|
"karma-mocha": "^1.3.0",
|
@@ -65,10 +65,6 @@ module.exports = {
|
|
65
65
|
|
66
66
|
plugins: [
|
67
67
|
new webpack.ProvidePlugin({
|
68
|
-
$: 'jquery',
|
69
|
-
jquery: 'jquery',
|
70
|
-
'window.jQuery': 'jquery',
|
71
|
-
jQuery: 'jquery',
|
72
68
|
Promise: 'es6-promise-promise',
|
73
69
|
}),
|
74
70
|
new BundleTracker({ filename: './webpack-stats.json' }),
|
What the bundle looks like
2. Exclude moment locales.
Moment is a heavyweight solution to an evergreen problem: date handling that doesn’t suck. It ships with really strong locale support, which is great, but literally all I’m using it for is to format some UTC date-times, so including all of those locales seems unnecessary. Thankfully, I found a way to exclude them from webpack altogether.
- webpack.config.js +6 -0
@@ -69,6 +69,12 @@ module.exports = {
|
|
69
69
|
}),
|
70
70
|
new BundleTracker({ filename: './webpack-stats.json' }),
|
71
71
|
new ExtractTextPlugin('[name].css'),
|
72
|
+
|
73
|
+
// The locales are non-trivially large and we don't use 'em for anything.
|
74
|
+
// So we ignore them:
|
75
|
+
// - https://github.com/moment/moment/issues/2373
|
76
|
+
// - https://stackoverflow.com/questions/25384360/how-to-prevent-moment-js-from-loading-locales-with-webpack/25426019#25426019
|
77
|
+
new webpack.IgnorePlugin(/^\.\/locale#x2F;, /moment#x2F;),
|
72
78
|
],
|
73
79
|
|
74
80
|
resolve: {
|
What the bundle looks like
3. Lazy load zxcvbn
Zxcvbn is a very dope library by Dropbox that handles password validation. Buttondown uses it to, well, validate passwords:
However, it comes shipped with a 600KB list of frequently used passwords, which, uh, is non-trivially heavy. My original instinct was to fork the repository and shrink that list down, as others have done, but that didn’t seem very futureproof. Instead, I decided to try something that I’ve put off for a long time — implementing Webpack lazy loading.
It was definitely annoying to suss out some of the configuration requirements for lazy loading (notably the correct value of publicPath
), but the final diff ended up being nice and compact. The actual code change is a little clumsy (and I’m not thrilled about having the actual implementation being so tightly coupled with webpack), but it’s hard to argue with the results.
- .babelrc +1 -0
- assets/components/PasswordValidator.vue +1 -1
- webpack.config.js +4 -1
@@ -10,6 +10,7 @@
|
|
10
10
|
]
|
11
11
|
],
|
12
12
|
"plugins": [
|
13
|
+
"syntax-dynamic-import",
|
13
14
|
"transform-object-rest-spread",
|
14
15
|
"transform-runtime"
|
15
16
|
]
|
@@ -13,7 +13,6 @@
|
|
13
13
|
|
14
14
|
<script>
|
15
15
|
import _ from 'lodash';
|
16
|
-
import zxcvbn from 'zxcvbn';
|
17
16
|
|
18
17
|
export default {
|
19
18
|
props: {
|
@@ -36,6 +35,7 @@
|
|
36
35
|
|
37
36
|
watch: {
|
38
37
|
password: _.debounce(async function() {
|
38
|
+
const zxcvbn = await import(/* webpackChunkName: "zxcvbn" */ 'zxcvbn');
|
39
39
|
this.strength = zxcvbn(this.password).score;
|
40
40
|
this.checkedPassword = this.password;
|
41
41
|
}, 500),
|
@@ -12,6 +12,8 @@ module.exports = {
|
|
12
12
|
output: {
|
13
13
|
path: process.env.NODE_ENV === 'production' ? path.resolve('./staticfiles/webpack_bundles/') : path.resolve('./assets/webpack_bundles/'),
|
14
14
|
filename: '[name]-[hash].js',
|
15
|
+
chunkFilename: '[name]-[hash].js',
|
16
|
+
publicPath: '/static/webpack_bundles/',
|
15
17
|
},
|
16
18
|
|
17
19
|
devtool: 'eval-source-map',
|
@@ -51,10 +53,11 @@ module.exports = {
|
|
51
53
|
options: {
|
52
54
|
cacheDirectory: true,
|
53
55
|
presets: [
|
54
|
-
|
56
|
+
'es2015',
|
55
57
|
'stage-2'
|
56
58
|
],
|
57
59
|
plugins: [
|
60
|
+
'syntax-dynamic-import',
|
58
61
|
"transform-object-rest-spread",
|
59
62
|
"transform-runtime"
|
60
63
|
]
|
What the bundle looks like
4. Selectively import lodash
Lastly, I’ll close with perhaps the most canonical example of “a thing everyone tells me I should do for performance reasons but I don’t because I’m lazy”: replacing the full-on lodash import with selected ones.
This is was very easy (and boring) to do, and resulted in a very boring diff, so I’m only showing you one instance of it. Rest assured, the rest looked exactly like this:
@@ -20,7 +20,7 @@
|
|
20
20
|
</span>
|
21
21
|
</template>
|
22
22
|
<script>
|
23
|
-
import
|
23
|
+
import debounce from 'lodash/debounce';
|
24
24
|
|
25
25
|
import { EmailValidationStatus } from 'types';
|
26
26
|
import validateEmail from 'services/validateemail';
|
@@ -38,7 +38,7 @@
|
|
38
38
|
},
|
39
39
|
|
40
40
|
watch: {
|
41
|
-
email:
|
41
|
+
email: debounce(async function() {
|
42
42
|
this.status = EmailValidationStatus.PENDING;
|
43
43
|
this.status = await validateEmail(this.email);
|
44
44
|
}, 500),
|
What the bundle looks like
Next steps
Two megabytes is still a bunch (even if its unminified), and it’s larger than I want it to be, but I’m really happy with the results! I got to learn more about Webpack, improve the quality of the codebase, and the smaller bundle size has had a non-trivial impact on bounce rate.
There’s definitely some stuff I could do to shrink the code footprint even more:
- Remove extra fonts.
- Get rid of
moment
entirely and write a bespoke date management utility. - Lazy-load flatpickr (it’s only being used in a couple places).
But those are projects for another day. Now it’s time for more features.
And, to end with another bread gif:
Further reading
- Paul Irish’s perf audit of Reddit
- The Critical Request
- Why our website is faster than yours 1
- Optimizing your bundle size
- Classic case of “bad headline, good content”. [return]