Skip to content

Commit 5bde04e

Browse files
author
Jan Jaap van Deursen
authored
(Chore) Improve production bundle size (#1890)
* (Chore) Improve production bundle size * Chunks all dependencies * Sets minimum bundle size; splits into specific bundles * Revert babel.config change * Adds ADR * Combined before and after stats * Converts table to HTML * - Removes unused dependencies - Updates Babel config - Adds `canvas` package for Canvas support is JSDOM - Removes unused `tsconfig.test.json` * Set ADR status to accepted
1 parent 1f21148 commit 5bde04e

14 files changed

+840
-584
lines changed

babel.config.js

+18-26
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,12 @@
11
// SPDX-License-Identifier: MPL-2.0
22
// Copyright (C) 2019 - 2021 Gemeente Amsterdam
33
module.exports = (api) => {
4-
const presetEnv =
5-
api.env() === 'lint'
6-
? ''
7-
: [
8-
'@babel/preset-env',
9-
{
10-
modules: false,
11-
useBuiltIns: 'usage',
12-
targets: {
13-
esmodules: true,
14-
chrome: 42,
15-
firefox: 68,
16-
},
17-
corejs: 3,
18-
},
19-
]
4+
api.cache(false)
205

216
return {
227
plugins: [
238
'styled-components',
9+
'@babel/plugin-transform-modules-commonjs',
2410
'@babel/plugin-proposal-class-properties',
2511
[
2612
'inline-react-svg',
@@ -29,33 +15,39 @@ module.exports = (api) => {
2915
},
3016
],
3117
'@babel/plugin-syntax-dynamic-import',
18+
'@babel/plugin-transform-runtime',
3219
],
3320
presets: [
34-
presetEnv,
3521
[
3622
'@babel/preset-react',
3723
{
3824
runtime: 'automatic',
3925
},
4026
],
4127
'@babel/preset-typescript',
42-
].filter(Boolean),
28+
[
29+
'@babel/preset-env',
30+
{
31+
useBuiltIns: 'usage',
32+
targets: {
33+
esmodules: true,
34+
chrome: 42,
35+
firefox: 68,
36+
},
37+
corejs: 3,
38+
},
39+
],
40+
],
4341
env: {
4442
production: {
4543
only: ['src'],
46-
plugins: [
47-
'transform-react-remove-prop-types',
48-
'@babel/plugin-transform-react-inline-elements',
49-
'@babel/plugin-transform-react-constant-elements',
50-
],
44+
plugins: ['transform-react-remove-prop-types'],
5145
},
5246
test: {
5347
plugins: [
54-
'@babel/plugin-transform-modules-commonjs',
55-
'dynamic-import-node',
5648
[
5749
'babel-plugin-styled-components',
58-
{ ssr: false, displayName: false, namespace: 'sc' },
50+
{ ssr: false, displayName: false, namespace: 'sc', pure: true },
5951
],
6052
],
6153
},
+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Bundle splitting strategy
2+
3+
Date: 2021-11-19
4+
5+
## Status
6+
7+
Accepted
8+
9+
## Context
10+
11+
So far, no significant effort has been put into optimizing the way bundles and chunks are generated for the application, other than lazy loading major parts of the front and back-office modules by means of implementing [React code splitting](https://reactjs.org/docs/code-splitting.html).
12+
13+
Since the application has only one entry point, `app.js`, Webpack doesn't know how to split the code and its dependencies into usable chunks that are not too small and not too big. Too small would be less than a couple of kilobytes and too big would be a chunk of a couple of hundreds of kilobytes. It takes some tinkering to find the sweet spot.
14+
15+
Without custom settings in Webpack, one 'main' chunk is generated that contains most, if not all, code and dependencies from the entry point. At the time of writing this is a blob of close to 5 MB of code. If only a very small part of that blob changes, let's say a 20 Kb dependency or a 4 Kb component, the browser is forced to download the whole 5 MB instead of just the part that changed. Not a very big impact on a fast office or home connection, but detrimental to the experience on a mobile device with a 4G or 3G connection that has a narrow bandwidth and suffers from latency issues.
16+
17+
To improve loading times and rendering speed, the big main bundle needs to be split up into smaller logical parts. We not only help our website's visitors, we will also reduce the amount of data that has to be transferred over the network and this reducing the load on the web server.
18+
19+
## Goal
20+
21+
Split the code in such a way that the least amount of chunks has to be regenerated on each consecutive build.
22+
23+
## Approach
24+
25+
First, we need to see what the current situation is. Below is a screenshot from the overview that the bundle analyzer plugin generated. To the right of the screenshot we can see the chunks that come from the code splitting with React. On the left, we can also clearly see that the main chunk is really large and contains application code as well as dependencies. Even if the dependencies don't receive any updates, the whole main chunk will be updated, which will be the case for each release that we push to production.
26+
27+
<img src="./img/chunk_optimization_before.png" />
28+
29+
### Aggressive splitting
30+
31+
We can split the code in such a way that each dependency has its own chunk. This way only the updated dependencies need to be downloaded after a new build/release. This will, however, generate such a large amount of chunks that it becomes inefficient; too many requests with too low a payload size.
32+
33+
### Fully customized splitting
34+
35+
Looking at which dependencies belong together (for instance `Leaflet` and `proj4`), which dependencies are likely to be updated often (or not, like `react-reactive-form`), we can come up with a configuration that is fully tailored to the project's list of dependencies. Efficient in terms of load times, but not in terms of maintenance; whenever a dependency is removed, renamed or gets a breaking update, chances are we need to revisit the Webpack configuration to reflect the change.
36+
37+
### Middle ground
38+
39+
A solution that covers both aforementioned scenarios would:
40+
41+
- compile a separate chunk for each dependency
42+
- have a minimum size for generated chunks (combining chunks if they're too small)
43+
- have a maximum size for generated chunks (splitting chunks if they're too big)
44+
- combine dependencies that are unlikely to be updated often
45+
- have as less custom configuration as possible
46+
47+
## Outcome
48+
49+
Applying all the middle ground requirements, the result is as follows:
50+
51+
<img src="./img/chunk_optimization_after.png" />
52+
53+
### Comparison
54+
55+
__Chunk sizes__
56+
57+
Before: 3.17 MB
58+
59+
After: 2.69 MB
60+
61+
__Average load times (throttled, simulated network traffic)__
62+
63+
<table>
64+
<thead>
65+
<tr>
66+
<th rowspan="2"></th>
67+
<th colspan="2">WiFi</th>
68+
<th colspan="2">4G</th>
69+
<th colspan="2">Good 3G</th>
70+
</tr>
71+
<tr>
72+
<th>Before</th>
73+
<th>After</th>
74+
<th>Before</th>
75+
<th>After</th>
76+
<th>Before</th>
77+
<th>After</th>
78+
</tr>
79+
</thead>
80+
<tbody>
81+
<tr>
82+
<th>DOMContentLoaded (ms)</th>
83+
<td>180</td>
84+
<td>230</td>
85+
<td>4200</td>
86+
<td>3000</td>
87+
<td>13000</td>
88+
<td>6250</td>
89+
</tr>
90+
<tr>
91+
<th>load (s)</th>
92+
<td>950</td>
93+
<td>940</td>
94+
<td>6200</td>
95+
<td>5200</td>
96+
<td>15000</td>
97+
<td>13000</td>
98+
</tr>
99+
<tr>
100+
<th>completed (s)</th>
101+
<td>650</td>
102+
<td>712</td>
103+
<td>5500</td>
104+
<td>4800</td>
105+
<td>13500</td>
106+
<td>12000</td>
107+
</tr>
108+
</tbody>
109+
</table>
110+
111+
## Conclusion
112+
113+
Load times for WiFi connection are more or less the same, but the most significant change is for the 4G and 3G connections. Totally worth the effort.

docs/adr/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@
1212
9. [Add MSW for mocking fetch calls](./0009-stop-mocking-fetch-in-tests.md)
1313
10. [Naming conventions](./0010-naming-conventions.md)
1414
11. [Embedded application](./0011-embedded-application.md)
15+
12. [Bundle splitting strategy](./0012-bundle-splitting-strategy.md)
1.06 MB
Loading
928 KB
Loading

internals/mocks/svg.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
// SPDX-License-Identifier: MPL-2.0
22
// Copyright (C) 2021 Gemeente Amsterdam
3-
const content = 'svg'
4-
export const ReactComponent = content
5-
export default content
3+
import { createElement } from 'react'
4+
import type { FunctionComponent, SVGProps } from 'react'
5+
6+
const SvgMock: FunctionComponent<SVGProps<SVGSVGElement>> = (props) =>
7+
createElement('svg', props)
8+
9+
export default SvgMock

internals/webpack/webpack.base.babel.js

+29-29
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// SPDX-License-Identifier: MPL-2.0
22
// Copyright (C) 2018 - 2021 Gemeente Amsterdam
3+
// @ts-check
34
const path = require('path')
45
const webpack = require('webpack')
56
const pkgDir = require('pkg-dir')
@@ -8,50 +9,43 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin')
89
const BundleAnalyzerPlugin =
910
require('webpack-bundle-analyzer').BundleAnalyzerPlugin
1011
const CopyPlugin = require('copy-webpack-plugin')
12+
const mergeWith = require('lodash/mergeWith')
13+
const isArray = require('lodash/isArray')
1114

1215
const devMode = process.env.NODE_ENV !== 'production'
1316
const __rootdir = pkgDir.sync()
1417

1518
const esModules = [
1619
path.resolve(__rootdir, 'node_modules/@amsterdam/asc-assets'),
1720
path.resolve(__rootdir, 'node_modules/@amsterdam/asc-ui'),
21+
path.resolve(__rootdir, 'node_modules/@amsterdam/arm-core'),
22+
path.resolve(__rootdir, 'node_modules/@datapunt/matomo-tracker-js'),
23+
path.resolve(__rootdir, 'node_modules/@datapunt/matomo-tracker-react'),
24+
path.resolve(__rootdir, 'node_modules/@amsterdam/react-maps'),
1825
]
1926

20-
module.exports = ({
21-
babelQuery,
22-
devtool,
23-
entry,
24-
mode,
25-
optimization,
26-
output,
27-
performance = {},
28-
plugins,
29-
tsLoaders,
30-
}) => ({
31-
mode,
32-
entry,
27+
const mergeCustomizer = (objValue, srcValue) => {
28+
if (isArray(objValue)) {
29+
return objValue.concat(srcValue)
30+
}
31+
}
32+
33+
const baseConfig = /** @type { import('webpack').Configuration } */ {
3334
output: {
3435
path: path.resolve(__rootdir, 'build'),
3536
publicPath: '/',
36-
...output,
37-
}, // Merge with env dependent settings
38-
optimization,
37+
},
38+
3939
module: {
4040
rules: [
4141
{
42-
test: /\.jsx?$/, // Transform all .js and .jsx files required somewhere with Babel
42+
test: /\.(t|j)sx?$/, // Transform all .js and .jsx files required somewhere with Babel
4343
exclude: /node_modules/,
4444
include: [path.resolve(__rootdir, 'src'), ...esModules],
4545
use: {
4646
loader: 'babel-loader',
47-
options: babelQuery,
4847
},
4948
},
50-
{
51-
test: /\.ts(x?)$/,
52-
exclude: /node_modules/,
53-
use: tsLoaders,
54-
},
5549
{
5650
test: /\.css$/,
5751
use: [
@@ -152,6 +146,7 @@ module.exports = ({
152146
},
153147
],
154148
},
149+
155150
plugins: [
156151
// Always expose NODE_ENV to webpack, in order to use `process.env.NODE_ENV`
157152
// inside your code for any environment checks; Terser will automatically
@@ -191,9 +186,8 @@ module.exports = ({
191186
},
192187
},
193188
}),
194-
]
195-
.concat(plugins)
196-
.filter(Boolean),
189+
].filter(Boolean),
190+
197191
resolve: {
198192
modules: [path.resolve(__rootdir, 'src'), 'node_modules'],
199193
extensions: ['.js', '.jsx', '.react.js', '.ts', '.tsx'],
@@ -202,7 +196,13 @@ module.exports = ({
202196
types: path.resolve(__rootdir, 'src/types/'),
203197
},
204198
},
205-
devtool,
199+
206200
target: 'web', // Make web variables accessible to webpack, e.g. window
207-
performance,
208-
})
201+
202+
stats: 'normal',
203+
}
204+
205+
module.exports = {
206+
baseConfig,
207+
merge: (objValue, srcValue) => mergeWith(objValue, srcValue, mergeCustomizer),
208+
}

0 commit comments

Comments
 (0)