Second Edition
Created by Maria D. Campbell / @letsbsocial1
This is the second edition of my React Workflows Without CRA. If you want to acquaint yourself with the first edition as well, please visit the React Workflow Presentation repository. For a detailed map of this edition, please visit the README of this edition's repository. To view the source code of this workflow, please visit the speech-to-text-app repository.
I'm Maria.
I've always been obsessed with development and construction in one way or another.
So why am I here today to discuss React workflows?
When I started learning React, I was not happy with the fact that virtually all React learning seemed to revolve around using create-react-app to create React applications.
And that meant blindly accepting its extensive boiler-plate.
Why blindly ? Because the workflow is hidden by default. In order to see what is going on, you have to eject out of CRA.
But once you eject, THERE IS NO GOING BACK!
And one thing I know for sure ...
Sometimes it's faster (and easier) to start from scratch than try to manipulate to your needs.
And that's where
(4 and beyond) come in.
So why webpack ?
If done from scratch, it can mean fewer workflow files and lines of code. If you come from task runners like Gulp (I do) or Grunt, you know what I'm talking about.
And if you are familiar with CRA, you know how extensive its boilerplate is.
if your focus is on writing JSX and JS for the browser.
with that approach is that when developers start heavily relying on tools like that, they can lose site of the bigger picture.
Which can STUNT one's growth as a developer.
I first got the bug to create my own custom React workflows when I created my first React application using CRA.
I would get errors in my React projects which I sometimes found difficult to debug. They might or might not have been CRA related.
So I decided to start creating my own React workflows using webpack.
package.json
{
"name": "speech-to-text-app",
"version": "0.0.1",
"private": true,
"description": "\"A voice controlled notes app that allows you to take notes by recording your voice\"",
"main": "index.js",
"scripts": {
"test": "jest",
"lint": "eslint .",
"clean": "rimraf dist",
"cleanSrc": "rimraf dist/src",
"start": "webpack-dev-server --mode development --config config/webpack.base.config.js --open --hot
--history-api-fallback --env.PLATFORM=local --env.VERSION=stag",
"predeploy": "webpack --mode production --config config/webpack.prod.config.js --env.PLATFORM=production
--env.VERSION=stag --progress",
"deploy": "gh-pages -d dist"
},
"jest": {
"setupFiles": [
"raf/polyfill"
],
"moduleNameMapper": {
"\\.(pdf|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js",
"\\.(css|less|scss)$": "identity-obj-proxy"
}
},
"author": "Maria D. Campbell",
"license": "ISC",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.8",
"@fortawesome/free-brands-svg-icons": "^5.5.0",
"@fortawesome/free-regular-svg-icons": "^5.5.0",
"@fortawesome/free-solid-svg-icons": "^5.5.0",
"@fortawesome/react-fontawesome": "^0.1.3",
"core-js": "^2.5.7",
"gh-pages": "^2.0.1",
"react": "^16.6.3",
"react-dom": "^16.6.3"
},
"devDependencies": {
"@babel/core": "^7.1.6",
"@babel/plugin-proposal-class-properties": "^7.1.0",
"@babel/plugin-proposal-export-namespace-from": "^7.0.0",
"@babel/plugin-proposal-throw-expressions": "^7.0.0",
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
"@babel/polyfill": "^7.0.0",
"@babel/preset-env": "^7.1.5",
"@babel/preset-react": "^7.0.0",
"@babel/register": "^7.0.0",
"autoprefixer": "^9.3.1",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0",
"babel-loader": "^8.0.4",
"clean-webpack-plugin": "^1.0.0",
"copy-webpack-plugin": "^4.6.0",
"css-loader": "^1.0.1",
"enzyme": "^3.7.0",
"eslint": "^5.9.0",
"eslint-config-airbnb": "^17.1.0",
"eslint-loader": "^2.1.1",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jsx-a11y": "^6.1.2",
"eslint-plugin-react": "^7.11.1",
"file-loader": "^2.0.0",
"html-webpack-plugin": "^3.2.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^23.6.0",
"jest-enzyme": "^7.0.1",
"mini-css-extract-plugin": "^0.4.4",
"node-sass": "^4.10.0",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"postcss": "^7.0.5",
"postcss-loader": "^3.0.0",
"prop-types": "^15.6.2",
"raf": "^3.4.1",
"react-test-renderer": "^16.6.1",
"regenerator-runtime": "^0.12.1",
"rimraf": "^2.6.2",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"uglifyjs-webpack-plugin": "^2.0.1",
"url-loader": "^1.1.2",
"webpack": "^4.3.0",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.10",
"webpack-manifest-plugin": "^2.0.4",
"webpack-merge": "^4.1.4",
"webpack-visualizer-plugin": "^0.1.11"
}
}
Please ignore the closing rootdir
tag at the bottom of the package.json
.
"jest": {
"setupFiles": [
"raf/polyfill"
],
React 16+ depends on requestAnimationFrame
, even in test
environments. First install the raf npm package, and then add this to your package.json. This was the same in my previous workflow.
"moduleNameMapper": {
"\\.(pdf|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js",
"\\.(css|less|scss)$": "identity-obj-proxy"
}
Even though all that has changed here is adding |scss
to
identity-obj-proxy
, so much else changed that resulted in the need to add this to the
moduleNameMapper
property. Again, please ignore the closing rootdir
tag.
"babel-jest": "^23.6.0",
"enzyme": "^3.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^23.6.0",
"jest-enzyme": "^7.0.1",
"raf",
"react-test-renderer": "^16.6.1",
"regenerator-runtime": "^0.12.1",
"@babel/core": "^7.1.6",
"babel-core": "^7.0.0-bridge.0",
These packages, along with
babel-jest
and
regenerator-runtime
are what is needed now in order to be able to use
Jest with
Babel 7. To learn more about using Babel with Jest, please visit
Using
Babel.
everything changed
.babelrc
{
"presets": ["env", "react", "stage-1", "stage-2", "jest"]
}
Previously, the babel config looked something like this.
.babelrc
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-proposal-throw-expressions"
]
}
Now, the babel config looks something like this.
Before Babel 7, proposal plugins were clumped together within their appropriate presets. i.e., stage-0, stage-1, stage-2, etc.
.babelrc
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
With Babel 7, presets were removed from the webpack
config and only appear in the .babelrc
file. Only presets which were actually approved appeared in the
presets array.
"@babel/preset-env"
"@babel/preset-env"
is the Babel preset which allows you to use the latest JS.
"@babel/preset-react"
"@babel/preset-react"
is the Babel preset which allows you to use React code.
naming
The naming of presets changed with Babel
7 to differentiate between the versions and maintain naming consistency throughout. Instead of simply adding
"env"
or "react"
to the preset array, you now have to add "@babel/preset-env"
or "@babel/preset-react"
, for example.
the whys
"@babel/plugin-proposal-class-properties": "^7.1.0",
Transforms ES6 class syntax into ES5
constructor
functions. But the class syntax feature
has not yet been approved, so it is still called a proposal. With
Babel 7, we must add the equivalent
Babel
plugin for each modern JS feature we want to implement in our applications. That's because there are no longer stage
presets in Babel 7.
the whys
"@babel/plugin-proposal-export-namespace-from": "^7.0.0",
ES6 modules allow us to import something from something else:
import * as name from "module-name";
the whys
"@babel/plugin-proposal-export-namespace-from"
However, there was no corresponding export!
the whys contd
"@babel/plugin-proposal-export-namespace-from"
plugin proposal: export namespace from
export * as ns from "mod";
the whys contd
"@babel/plugin-proposal-throw-expressions": "^7.0.0",
example:
function test(param = throw new Error('required!')) {
const test = param === true || throw new Error('Falsey!');
}
the whys contd
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
import()
returns a promise, so it can be used with
async functions. However, doing this
requires the
"@babel/plugin-syntax-dynamic-import"
plugin in Webpack.
config/webpack.base.config.js
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
Now .JS$ rules
looks like this. No more
presets. Only in .babelrc. In addition, I changed the name of the webpack config file. I will talk about that later.
test: /\.js$/,
The test
property tells webpack what kind of file to match to the
babel-loader
. The regex /\.js$/
refers to any extension that matches
.js
at the end of a filename.
exclude: /node_modules/,
The exclude
property means that whatever modules match the /node_modules/
regex should be excluded from bundle.js
.
use: {
loader: 'babel-loader',
},
Then it uses
that loader to transform those files before adding it to
bundle.js
.
the whys
@babel/core
This is the Babel core compiler. It makes it possible to use babel-loader
. Most babel
plugins/top level packages have a peerDependency on
@babel/core. Previously it had been babel-core. It is also needed for
Jest. To learn more about
peerDependencies, click here.
the whys
"babel-core": "^7.0.0-bridge.0"
This package is needed for Jest testing if Babel 7 is being used. This package is meant to ease the transition libraries that use "babel-core" as a peerDependency for Babel 6. To learn more, please visit the babel-bridge repo .
the whys
"@babel/polyfill": "^7.0.0"
React 16+ depends on collection types Map and Set. @babel/polyfill emulates a full ES2015+ environment and is for applications. You can use built-ins like Promise or WeakMap, static methods like Array.from or Object.assign, instance methods like Array.prototype.includes, and generator functions provided you use the regenerator plugin.
the whys
"@babel/register": "^7.0.0",
Allows you to use the require module in your applications.
package.json
"babel-eslint": "^10.0.1",
"eslint": "^5.9.0",
"eslint-loader": "^2.1.1",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-react": "^7.11.1",
.eslintrc.json
{
"parser": "babel-eslint",
"env": {
"browser": true,
"commonjs": true,
"es6": true,
"node": true,
"jest": true
},
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:import/recommended"],
"settings": {
"react": {
"pragma": "React",
"version": "16.6.3"
}
},
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 2018,
"sourceType": "module"
},
"plugins": [
"import",
"react"
],
"rules": {
"no-unused-vars": 0,
"no-console": 0,
"max-len": [1, 120, 2, {
"ignoreComments": true
}],
"prop-types": [0],
"indent": [
0
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
0
],
"semi": [
0
]
}
}
config/webpack.base.config.js
{
test: /\.js$/,
exclude: /node_modules/,
loaders: [
'babel-loader',
'eslint-loader'
]
},
For my complete write up on configuring ESLint with React, please visit The Importance Of ESlint (And React) . To view the complete webpack.base.config.js file, click here .
much changed
I still have two webpack config files, but now they reside in a config folder and are also named differently.
One file is called webpack.base.config.js
and is for webpack configs related to the development environment, some of which also are related to production. The other is
webpack.prod.config.js
and is for webpack configs related only to the production environment.
why the new configs
Setting up Webpack with these two new configs means drier code. No duplications as before. Click here to visit a repo containing my previous workflow.
what requires/variables have NOT changed (base config)
const path = require('path');
const webpack = require('webpack');
As you can see, not much!
what requires/variables have changed (base config)
const CopyWebpackPlugin = require('copy-webpack-plugin');
const merge = require('webpack-merge');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const WorkboxPlugin = require('workbox-webpack-plugin');
how module.exports has changed (base config)
module.exports = env => {
const {PLATFORM, VERSION} = env;
return merge([
{
entry: {
main: './src/index.js'
},
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
optimization: {
splitChunks: {
chunks: 'all'
}
},
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.js$/,
exclude: /node_modules/,
loaders: [
'babel-loader',
'eslint-loader'
]
},
{
test: /\.(scss|sass|css)$/,
exclude: /node_modules/,
loaders: [
PLATFORM === 'production' ? MiniCssExtractPlugin.loader : 'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
localIdentName: '[local]___[hash:base64:5]'
},
},
'postcss-loader',
'sass-loader'
]
},
{
test: /\.(jpg|png|gif|svg|pdf|ico)$/,
use: [{
loader: 'file-loader',
options: {
name: '[path][name].[hash:8].[ext]'
},
}],
},
]
},
plugins: [
new CleanWebpackPlugin(['dist']),
new HtmlWebpackPlugin({
template: 'src/index.html',
favicon: 'src/favicon.ico', /* remove */
styles: 'src/styles.css',
inject: true
}),
new MiniCssExtractPlugin({
filename: PLATFORM === 'production' ? 'styles/[name].[hash].css' : '[name].css',
}),
new CopyWebpackPlugin([{
from: 'src/static'
}]),
new WorkboxPlugin.GenerateSW({
swDest: 'sw.js'
}),
new webpack.DefinePlugin({
'process.env.VERSION': JSON.stringify(env.VERSION),
'process.env.PLATFORM': JSON.stringify(env.PLATFORM)
})
],
},
])
}
config/webpack.base.config.js
const path = require('path');
const webpack = require('webpack');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const merge = require('webpack-merge');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const WorkboxPlugin = require('workbox-webpack-plugin');
module.exports = env => {
const {PLATFORM, VERSION} = env;
return merge([
{
entry: {
main: './src/index.js'
},
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
optimization: {
splitChunks: {
chunks: 'all'
}
},
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.js$/,
exclude: /node_modules/,
loaders: [
'babel-loader',
'eslint-loader'
]
},
{
test: /\.(scss|sass|css)$/,
exclude: /node_modules/,
loaders: [
PLATFORM === 'production' ? MiniCssExtractPlugin.loader : 'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
localIdentName: '[local]___[hash:base64:5]'
},
},
'postcss-loader',
'sass-loader'
]
},
{
test: /\.(jpg|png|gif|svg|pdf|ico)$/,
use: [{
loader: 'file-loader',
options: {
name: '[path][name].[hash:8].[ext]'
},
}],
},
]
},
plugins: [
new CleanWebpackPlugin(['dist']),
new HtmlWebpackPlugin({
template: 'src/index.html',
favicon: 'src/favicon.ico', /* remove */
styles: 'src/styles.css',
inject: true
}),
new MiniCssExtractPlugin({
filename: PLATFORM === 'production' ? 'styles/[name].[hash].css' : '[name].css',
}),
new CopyWebpackPlugin([{
from: 'src/static'
}]),
new WorkboxPlugin.GenerateSW({
swDest: 'sw.js'
}),
new webpack.DefinePlugin({
'process.env.VERSION': JSON.stringify(env.VERSION),
'process.env.PLATFORM': JSON.stringify(env.PLATFORM)
})
],
},
])
}
When webpack processes your app, it recursively builds a dependency
graph
that includes every module that your app needs, and then packages all of
those modules into a small number of
bundles to be loaded by the
browser.
To get started, you only need to understand 4 basic concepts:
module.exports = {
entry: {
main: './src/index.js'
},
}
The starting point of webpack's dependency graph is called the entry
point
. The entry point tells webpack where to start its bundling
process
, and then follows the graph of dependencies to know what to bundle.
module.exports = {
entry: {
main: './src/index.js'
},
}
The entry point did change since the previous workflow, and module.exports
, and what now comes before entry did as well.
module.exports changed
module.exports = env => {
const {PLATFORM, VERSION} = env;
};
The env variable is passed to module.exports
so that I can make those webpack configurations which are required both in
development and production.
PLATFORM
refers to dev or prod
envs, and VERSION
refers to staging (stag).
entry: {
main: './src/index.js'
},
I no longer include a VENDOR_LIBS (vendor)
entry. That means I no longer define a VENDOR_LIBS variable containing the third party dependencies I use in my application. This has to do with the
new way of bundle splitting
in webpack
4 using the
SplitChunksPlugin
instead of CommonsChunkPlugin
.
webpack-merge is what makes it possible to merge my two configs together into one, thereby resulting in much drier code as well.
module.exports = env => {
const {PLATFORM, VERSION} = env;
return merge([
{
entry: {
main: './src/index.js'
},
])
}
The value of the main property indicates to webpack what code needs to be bundled. Previously I called it bundle, but now for consistency and readability sake, I changed it to main.
import React from 'react';
import ReactDOM from 'react-dom';
import 'core-js/es6/map';
import 'core-js/es6/set';
import App from './App';
import './styles.scss';
import './favicon.ico'; /* remove */
The files which need to be included in the bundled file in output have all been imported into the
main
index.js
file.
the why
import 'core-js/es6/map';
import 'core-js/es6/set';
A polyfilled environment for React 16 using core-js to support older browsers. Visit the React documentation to learn more.
And the main App Component
that holds everything else related to the app is rendered there as well.
is important for browser caching
.
is great because it allows sites to load faster once the client has grabbed the necessary files and assets from the server the first time a user visits the page.
However, it can cause big headaches when new code has been added to files. If the name of a file remains the same when new code has been added to it, the browser thinks that nothing has changed. To the browser, same name means same content.
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
Once you've bundled all your assets together, you need to tell webpack where to bundle your app. The output object
tells webpack what to do with bundled code. But this output object
differs greatly from the first edition of this
workflow.
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
The value of the first property, filename
, refers to the
name(s) of output bundle(s). Note the
ternary operator.
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
Previously, when I used the CommonsChunkPlugin
and not the SplitChunksPlugin
, the name of the
hash I used was
[chunkhash:8]
. Now that we all are using the
SplitChunksPlugin
with webpack, some of us use
[contenthash]
. For more about [contenthash]
and webpack 4, click here.
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
chunkFilename
determines the name of non-entry chunk files. In my configuration, that means either vendor chunks or (at least with this particular workflow)
dynamic imports. Again, note the ternary
operator.
const path = require('path');
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
The value of the third property,
path
, is the absolute path to the dist folder, where the bundled assets end up. It has to be required in your
webpack config
in order for you to able to use it.
const path = require('path');
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
path.resolve() resolves a sequence of paths or path segments into an absolute path. As mentioned earlier, for the output path, webpack needs to know exactly where you want your bundles and output files to be emitted.
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
I use the [name] property because of my bundle
splitting
resulting from naming different types of
chunks. Previously the reasoning was because of >
1
entry point, but now it's because of >
1
type of chunk. As you saw, they do not reside in the entry point!
With webpack 4, it has become clear to me that bundle splitting and code splitting are NOT interchangeable, as I previously thought. That's because of my introduction to dynamic imports, which IS code splitting, as opposed to multiple entry points, which is bundle splitting.
I will be updating the terminology in the first edition of this workflow. I came across a fantastic article on medium that gets into the difference between the two, and helped me tremendously in optimizing my vendor bundle(s): The 100% correct way to split your chunks with Webpack, by David Gilbertson.
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
[name] takes into consideration that there can be more than one type of main bundle file, and they may have different names.
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
A simple way to make sure the browser picks up changed files is by using output.filename
substitutions. The [hash] (or previously for me [chunkhash]) can be used to include a build-specific
hash in the filename.
output: {
filename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
chunkFilename: PLATFORM === 'production' ? 'scripts/[name].[contenthash].chunk.js' : 'scripts/[name].chunk.js',
path: path.resolve(__dirname, '../dist')
},
It's even better to use the
[contenthash]
substitution, which is the hash of the content of a file, different for each type of
asset.
Michael Ciniawsky, a webpack core contributor, put it well in webpack issue #7179:
[chunkhash] isn't very 'stable' as it can change when e.g a module of a chunk is moved for some reason ... For predictable long term (browser) caching the contents of the asset are the only reliable hashing source since browsers cache files based on Map { url => content } relations without any knowledge of webpack chunks or the like...
And for Code Splitting (Async Chunks):
Also don't forget to set the chunkFilename for Code Splitting (Async Chunks):
const output = {
filename: dev || ci ? '[name].bundle.js' : '[name].[contenthash].bundle.js',
chunkFilename: dev || ci ? '[name].chunk.js' : '[name].[contenthash].chunk.js'
}
There are 3 main types of code in a typical app or site built with webpack:
The runtime, along with the manifest data, is basically all the code webpack needs to connect your modularized app while it's running in the browser.
manifest
refers to the collection of module related data
runtime
uses to resolve and load modules after they have been bundled and shipped to the browser.
Both will be discussed in detail when we get to webpack.prod.config.js.
So why mention all of this? Because runtime
and manifest
affect browser
caching
.
Previously when using [chunkhash]
, certain hashes change even when their content did not. This was caused by the injection
of runtime
and manifest
, which changed every build.
Originally, chunks (and modules imported inside them) were connected by a parent-child relationship in the internal webpack graph. The CommonsChunkPlugin was used to avoid duplicated dependencies across them, but further optimizations were not possible. Since webpack v4, the CommonsChunkPlugin was removed in favor of optimization.splitChunks.
We can use the webpack SplitChunksPlugin
to split
(vendor) dependencies into
chunks.
Out of the box, SplitChunksPlugin
should work well for most users.
By default it only affects on-demand chunks, because changing initial chunks would affect the script tags the html file should include to run the project.
webpack will automatically split chunks based on the following conditions:
node_modules
folder.
config/webpack.prod.config.js
optimization: {
runtimeChunk: 'single',
},
After adding runtimeChunk
, a new (default name) runtime
file will be created. The value "single"
creates a single
runtime file to be shared for ALL generated chunks.
10926 ± npm run start ✹
> speech-to-text-app@0.0.1 start /Users/mariacam/Development/speech-to-text-app
> webpack-dev-server --mode development --config config/webpack.base.config.js --open --hot --history-api-fallback
--env.PLATFORM=local --env.VERSION=stag
clean-webpack-plugin: /Users/mariacam/Development/speech-to-text-app/config/dist has been removed.
ℹ 「wds」: Project is running at http://localhost:8080/
ℹ 「wds」: webpack output is served from /
ℹ 「wds」: 404s will fallback to /index.html
ℹ 「wdm」: wait until bundle finished: /
ℹ 「wdm」: Hash: cc60f0b1138d573ca57d
Version: webpack 4.3.0
Time: 7393ms
Built at: 11/23/2018 3:29:44 PM
Asset Size Chunks Chunk Names
src/favicon.2cc0ddfd.ico 1.06 KiB [emitted]
scripts/Speech.chunk.js 29.9 KiB Speech [emitted] Speech
scripts/main.chunk.js 50.2 KiB main [emitted] main
scripts/vendors~Speech.chunk.js 794 KiB vendors~Speech [emitted] vendors~Speech
scripts/vendors~main.chunk.js 1.27 MiB vendors~main [emitted] vendors~main
favicon.ico 1.06 KiB [emitted]
index.html 485 bytes [emitted]
precache-manifest.ead46283758951b05b27e4de5ca60d6c.js 655 bytes [emitted]
sw.js 907 bytes [emitted]
Entrypoint main = scripts/vendors~main.chunk.js scripts/main.chunk.js
[./node_modules/core-js/es6/map.js] 208 bytes {vendors~main} [built]
[./node_modules/core-js/es6/set.js] 208 bytes {vendors~main} [built]
[./node_modules/webpack-dev-server/client/index.js?http://localhost:8080]
(webpack)-dev-server/client?http://localhost:8080 7.78 KiB {vendors~main} [built]
[./node_modules/webpack-dev-server/client/overlay.js] (webpack)-dev-server/client/overlay.js 3.58 KiB {vendors~main}
[built]
[./node_modules/webpack-dev-server/client/socket.js] (webpack)-dev-server/client/socket.js 1.05 KiB{vendors~main}
[built]
[./node_modules/webpack/hot sync ^\.\/log$] (webpack)/hot sync nonrecursive ^\.\/log$ 170 bytes {main} [built]
[./node_modules/webpack/hot/dev-server.js] (webpack)/hot/dev-server.js 1.66 KiB {vendors~main} [built]
[./node_modules/webpack/hot/emitter.js] (webpack)/hot/emitter.js 77 bytes {vendors~main} [built]
[./node_modules/webpack/hot/log-apply-result.js] (webpack)/hot/log-apply-result.js 1.31 KiB {vendors~main} [built]
[./node_modules/webpack/hot/log.js] (webpack)/hot/log.js 1.03 KiB {vendors~main} [built]
[0] multi (webpack)-dev-server/client?http://localhost:8080 (webpack)/hot/dev-server.js ./src/index.js 52 bytes {main}
[built]
[./src/App.js] 1.04 KiB {main} [built]
[./src/favicon.ico] 70 bytes {main} [built]
[./src/index.js] 382 bytes {main} [built]
[./src/styles.scss] 1.33 KiB {main} [built]
+ 117 hidden modules
Child html-webpack-plugin for "index.html":
1 asset
Entrypoint undefined = index.html
[./node_modules/html-webpack-plugin/lib/loader.js!./src/index.html] 504 bytes {0} [built]
[./node_modules/lodash/lodash.js] 527 KiB {0} [built]
[./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 509 bytes {0} [built]
[./node_modules/webpack/buildin/module.js] (webpack)/buildin/module.js 519 bytes {0} [built]
ℹ 「wdm」: Compiled successfully.
So much has changed even in Terminal output when running your React application in
dev
mode with webpack-dev-server. Everything configured in
config/webpack.base.config.js
prints out in Terminal. So cool, right?
custom script, webpack-dev-server
10926 ± npm run start ✹
> speech-to-text-app@0.0.1 start /Users/mariacam/Development/speech-to-text-app
> webpack-dev-server --mode development --config config/webpack.base.config.js --open --hot --history-api-fallback
--env.PLATFORM=local --env.VERSION=stag
npm run start
is my custom script for running my app in dev
mode using webpack-dev-server
.
webpack-dev-server --mode development
runs the webpack-dev-server
in development. webpack-dev-server
provides a simple web server and ability
to use live reloading in development only.
custom script, webpack-dev-server
new webpack.DefinePlugin({
'process.env.VERSION': JSON.stringify(env.VERSION),
'process.env.PLATFORM': JSON.stringify(env.PLATFORM)
})
Sets
process.env.PLATFORM on DefinePlugin to --mode development
. it also enables
NamedChunksPlugin and NamedModulesPlugin.
custom script, webpack-dev-server
new webpack.DefinePlugin({
'process.env.VERSION': JSON.stringify(env.VERSION),
'process.env.PLATFORM': JSON.stringify(env.PLATFORM)
})
I define --mode development
alongside ternary
operators throughout webpack.base.config.js
using the PLATFORM variable.
custom script, webpack-dev-server
10926 ± npm run start ✹
> speech-to-text-app@0.0.1 start /Users/mariacam/Development/speech-to-text-app
> webpack-dev-server --mode development --config config/webpack.base.config.js --open --hot --history-api-fallback
--env.PLATFORM=local --env.VERSION=stag
Next we have to tell webpack where to look for files. Since we are using a custom script to do this
instead of the webpack config file, we first add the --config
flag followed by config/webpack.base.config.js to let
webpack know that webpack.base.config.js
is the config file located in a folder called config.
custom script, webpack-dev-server
10926 ± npm run start ✹
> speech-to-text-app@0.0.1 start /Users/mariacam/Development/speech-to-text-app
> webpack-dev-server --mode development --config config/webpack.base.config.js --open --hot --history-api-fallback
--env.PLATFORM=local --env.VERSION=stag
We can only use this flag if there indeed IS a config folder that holds webpack.base.config.js
.
custom script, webpack-dev-server
10926 ± npm run start ✹
> speech-to-text-app@0.0.1 start /Users/mariacam/Development/speech-to-text-app
> webpack-dev-server --mode development --config config/webpack.base.config.js --open --hot --history-api-fallback
--env.PLATFORM=local --env.VERSION=stag
The --config
flag is followed by config/webpack.base.config.js
to indicate the file to
interpret in dev mode. The
--open
flag opens the application in your default browser.
custom script, webpack-dev-server
10926 ± npm run start ✹
> speech-to-text-app@0.0.1 start /Users/mariacam/Development/speech-to-text-app
> webpack-dev-server --mode development --config config/webpack.base.config.js --open --hot --history-api-fallback
--env.PLATFORM=local --env.VERSION=stag
The --hot
flag enables HMR (Hot Module Replacement).
HMR builds on top of WDS
(webpack-dev-server). It makes it possible to swap modules live.
custom script, webpack-dev-server
The reason I use style-loader
in development, for example, is because theMiniCssExtractPlugin
does NOT support
HMR, but
style-loader
DOES. That makes it possible to update any changes in my styles
without refreshing the page.
custom script, webpack-dev-server
10926 ± npm run start ✹
> speech-to-text-app@0.0.1 start /Users/mariacam/Development/speech-to-text-app
> webpack-dev-server --mode development --config config/webpack.base.config.js --open --hot --history-api-fallback
--env.PLATFORM=local --env.VERSION=stag
The --history-api-fallback
flag makes 404s fallback to /index.html
.
custom script, webpack-dev-server
10926 ± npm run start ✹
> speech-to-text-app@0.0.1 start /Users/mariacam/Development/speech-to-text-app
> webpack-dev-server --mode development --config config/webpack.base.config.js --open --hot --history-api-fallback
--env.PLATFORM=local --env.VERSION=stag
--env.PLATFORM=local
lets webpack-dev-server know that it is running locally. Whatever is set to
PLATFORM === 'development'
in config/webpack.base.config.js
will be triggered by npm run start
.
custom script, webpack-dev-server
10926 ± npm run start ✹
> speech-to-text-app@0.0.1 start /Users/mariacam/Development/speech-to-text-app
> webpack-dev-server --mode development --config config/webpack.base.config.js --open --hot --history-api-fallback
--env.PLATFORM=local --env.VERSION=stag
--env.VERSION=stag
refers to the staging
environment. The staging environment here is in
dev mode.
dev instance info
clean-webpack-plugin: /Users/mariacam/Development/speech-to-text-app/config/dist has been removed.
This line lets us know that the clean-webpack-plugin has removed any old file versions in the dist folder before a build process.
dev instance info
ℹ 「wds」: Project is running at http://localhost:8080/
ℹ 「wds」: webpack output is served from /
The first line emitted by [wds] tells us on which localhost our app is running. the next lets us know the contentBase of the dev build. In the first edition,
I added a devServer object to my
webpack dev config, and contentBase was one of its properties. I set contentBase to
'./src/'
. If it is not explicitly set, then it defaults to '/'
.
dev instance info
ℹ 「wds」: 404s will fallback to /index.html
This line is emitted as a result of the --history-api-fallback
flag.
dev instance info
ℹ 「wdm」: wait until bundle finished: /
ℹ 「wdm」: Hash: cc60f0b1138d573ca57d
[wdm]
stands for webpack-dev-middleware
. It is not new to webpack
4, but it is new to me because of my new approach(es) to the workflow. It is a wrapper that emits files processed by webpack to a server. It is built into
[wds], but is also available as a separate package to allow more custom options.
The webpack-dev-server is a little Node.js Express server, which uses the webpack-dev-middleware to serve a webpack bundle. It also has a little runtime which is connected to the server via Sock.js. The server emits information about the compilation state to the client, which reacts to those events. You can choose between different modes, depending on your needs. - webpack wiki docs on Github
It was very difficult to pin down current (official) documentation which would provide a good explanation of the [wdm] internal to [wds]. I don't know how 'current' the explanation I found in the webpack wiki is (it's a over a year old), but it still gives a good idea of the relationship between [wds] and [wdm].
dev instance info
ℹ 「wdm」: wait until bundle finished: /
ℹ 「wdm」: Hash: cc60f0b1138d573ca57d
As the dev bundle is being built, [wdm] prints out the first message. After the build is complete, it prints out a compilation/build hash. Every time a change is made in the dev build, a new hash is emitted identifying that build.
dev instance info
Version: webpack 4.3.0124
Time: 7393ms
Built at: 11/23/2018 3:29:44 PM
assets emitted
If you check to see how assets were set up in webpack.base.config.js, viewable in slide 60, this would all make sense.
assets emitted
Also note
src/favicon.2cc0ddfd.ico
in slide 60. This is why I end up with a
src folder in
dist containing this file. I need to add it as the value of favicon in the
HtmlWebpackPlugin configuration in order to
inject favicon.ico
into index.html
.
favicon.ico
from src/ and moved it into static/. Please visit the README.md for a more detailed explanation!
other webpack base configs
optimization: {
splitChunks: {
chunks: 'all'
}
},
The 'all'
value means that chunks can be shared between async and non-async
chunks, and this can be very powerful.
other webpack base configs
new CopyWebpackPlugin([{
from: 'src/static'
}]),
I didn't technically need the copy-webpack-plugin for the app I used towards this second edition, but normally I would (it's where I would place my images for example), so I added it to the workflow here. Basically it copies any assets in the static folder over to the dist folder.
other webpack base configs
new WorkboxPlugin.GenerateSW({
swDest: 'sw.js'
}),
This is something entirely new to the workflow, but not to CRA. It is the configuration for the workbox-webpack-plugin.
other webpack base configs
new WorkboxPlugin.GenerateSW({
swDest: 'sw.js'
}),
This configuration generates an sw.js (service-worker) file in production, and lets webpack know its destination, including the name of the file. The reason there is no dest folder is because if one was added (dist) to the 'path', then a new dist within dist would be created. webpack already knows its destination.
the why : precache files
If you want your web app to work offline or there are assets you know can be cached for a long time, pre-caching is the best approach. precaching a file ensures that a file is downloaded and cached before a service worker is installed, meaning that if your service worker is installed, your files will be cached.
the workbox-webpack-plugin
provides an easy
way to precache files, but it can also
create a
precache-manifest.json
file that lists the files to precache. It provides a list of the files to be precached
along with revisioning.
self.__precacheManifest = [
{
"url": "styles/main.690dfc9a9eacd0ee4d0f.css"
},
{
"url": "styles/Speech.690dfc9a9eacd0ee4d0f.css"
},
{
"revision": "2cc0ddfdc87f52e9d8c0c7d5b6efbebe",
"url": "src/favicon.2cc0ddfd.ico"
},
{
"revision": "c00558e3eb099318bfd7",
"url": "scripts/vendors~main.1ad1cd98b3cea083930b.chunk.js"
},
{
"revision": "91502a5c5a80b43265a1",
"url": "scripts/vendors~Speech.f0e3c9ecdeb61f482646.chunk.js"
},
{
"revision": "526080fd283e0b39d519",
"url": "scripts/runtime.f6f71c9f7a8cf14b349f.chunk.js"
},
{
"revision": "1365bbd7f1fd166121d6",
"url": "scripts/main.f8d88775b8bc47d47855.chunk.js"
},
{
"revision": "07fd12356b7d18233857",
"url": "scripts/Speech.d477efae3d7e20cf3ae3.chunk.js"
},
{
"revision": "23378fffa3b2d116a81dbb2052c30463",
"url": "index.html"
},
{
"revision": "2cc0ddfdc87f52e9d8c0c7d5b6efbebe",
"url": "favicon.ico"
}
];
precache-manifest.json prints out the list of files being precached, basically all your app's assets (by default). It consists of two attribute-value pairs. The url attribute and its value, and the revisioning attribute and its value.
url attribute-value pair
The url value is the path to the asset as it appears in the index.html tag. That usually does not change unless there is a complete overhaul of an app's workflow configuration!
revisioning attribute-value pair
The revisioning value on the other hand, changes when there is a change to the asset's content. This way, old versions of a file will not continue to be cached. They are updated with the help of workbox and new caches are generated.
npm run predeploy ⏎ ✹
> speech-to-text-app@0.0.1 predeploy /Users/mariacam/Development/speech-to-text-app
> webpack --mode production --config config/webpack.prod.config.js --env.PLATFORM=production --env.VERSION=stag
--progress
clean-webpack-plugin: /Users/mariacam/Development/speech-to-text-app/config/dist has been removed.
0% compiling(node:29675) DeprecationWarning: Tapable.plugin is deprecated. Use new API on `.hooks` instead
Hash: 690dfc9a9eacd0ee4d0f
Version: webpack 4.3.0
Time: 25239ms
Built at: 11/23/2018 6:36:14 PM
Asset Size Chunks Chunk Names
styles/main.690dfc9a9eacd0ee4d0f.css 720 bytes 4 [emitted] main
src/favicon.2cc0ddfd.ico 1.06 KiB [emitted]
styles/Speech.690dfc9a9eacd0ee4d0f.css 1.39 KiB 1 [emitted] Speech
scripts/Speech.d477efae3d7e20cf3ae3.chunk.js 7.32 KiB 1 [emitted] Speech
scripts/runtime.f6f71c9f7a8cf14b349f.chunk.js 2.64 KiB 2 [emitted] runtime
scripts/vendors~main.1ad1cd98b3cea083930b.chunk.js 129 KiB 3 [emitted] vendors~main
scripts/vendors~Speech.f0e3c9ecdeb61f482646.chunk.js 26.6 KiB 0 [emitted] vendors~Speech
scripts/main.f8d88775b8bc47d47855.chunk.js 1.27 KiB 4 [emitted] main
favicon.ico 1.06 KiB [emitted]
index.html 686 bytes [emitted]
precache-manifest.25877dad8e187853834337f15891def2.js 967 bytes [emitted]
sw.js 907 bytes [emitted]
asset-manifest.json 702 bytes [emitted]
Entrypoint main = scripts/runtime.f6f71c9f7a8cf14b349f.chunk.js scripts/vendors~main.1ad1cd98b3cea083930b.chunk.js
styles/main.690dfc9a9eacd0ee4d0f.css scripts/main.f8d88775b8bc47d47855.chunk.js
[+EN/] ./src/styles.scss 39 bytes {4} [built]
[NX+1] ./src/App.scss 245 bytes {4} [built]
[Z1A6] ./src/components/speech/Speech.js + 5 modules 12.9 KiB {1} [built]
| 6 modules
[m1cd] ./src/favicon.ico 70 bytes {4} [built]
[tjUo] ./src/index.js + 1 modules 1.45 KiB {4} [built]
| ./src/index.js 382 bytes [built]
| ./src/App.js 1.04 KiB [built]
[yLpj] (webpack)/buildin/global.js 509 bytes {0} [built]
+ 89 hidden modules
Child mini-css-extract-plugin
node_modules/css-loader/index.js??ref--6-1!node_modules/postcss-loader/src/index.js!node_modules/sass-loader/lib/loader.js!src/App.scss:
Entrypoint mini-css-extract-plugin = *
[bUMP]
./node_modules/css-loader??ref--6-1!./node_modules/postcss-loader/src!./node_modules/sass-loader/lib/loader.js!./src/App.scss
2.47 KiB {0} [built]
+ 1 hidden module
Child mini-css-extract-plugin
node_modules/css-loader/index.js??ref--6-1!node_modules/postcss-loader/src/index.js!node_modules/sass-loader/lib/loader.js!src/styles.scss:
Entrypoint mini-css-extract-plugin = *
[OL5h]
./node_modules/css-loader??ref--6-1!./node_modules/postcss-loader/src!./node_modules/sass-loader/lib/loader.js!./src/styles.scss
959 bytes {0} [built]
+ 1 hidden module
Child html-webpack-plugin for "index.html":
1 asset
Entrypoint undefined = index.html
[MFLM] ./node_modules/html-webpack-plugin/lib/loader.js!./src/index.html 504 bytes {0} [built]
[YuTi] (webpack)/buildin/module.js 519 bytes {0} [built]
[yLpj] (webpack)/buildin/global.js 509 bytes {0} [built]
+ 1 hidden module
Child mini-css-extract-plugin
node_modules/css-loader/index.js??ref--6-1!node_modules/postcss-loader/src/index.js!node_modules/sass-loader/lib/loader.js!src/components/speech/Speech.scss:
Entrypoint mini-css-extract-plugin = *
2 modules
custom script, npm run predeploy
npm run predeploy ⏎ ✹
> speech-to-text-app@0.0.1 predeploy /Users/mariacam/Development/speech-to-text-app
> webpack --mode production --config config/webpack.prod.config.js --env.PLATFORM=production --env.VERSION=stag
--progress
npm run predeploy
is the custom script I run before I deploy to gh-pages. It
builds my dist folder required for deployment (or re-deployment) to gh-pages. This script is shorter than npm
run start
, and some things should also look familiar.
custom script, npm run predeploy
npm run predeploy ⏎ ✹
> speech-to-text-app@0.0.1 predeploy /Users/mariacam/Development/speech-to-text-app
> webpack --mode production --config config/webpack.prod.config.js --env.PLATFORM=production --env.VERSION=stag
--progress
Now, instead of webpack-dev-server
command, we use the webpack
commmand. In order to properly use the webpack
command, we need to have the webpack-cli
installed as a devDependency in our app.
custom script, npm run predeploy
This is a change from the first
edition, where I used TARGET_ENV=development
in my
custom npm run serve
script, declared and defined a variable TARGET_ENV
in
webpack-dev.config.js
, and new
webpack.DefinePlugin({DEV:
true})
.
custom script, npm run predeploy
I also did not install or use the webpack-cli in the first edition, which can be limiting.
custom script, npm run predeploy
webpack --mode production --config config/webpack.prod.config.js --env.PLATFORM=production --env.VERSION=stag
This line is similar to the one for webpack-dev-server
, but for production. The --progress
flag allows you to see the (%) progress of your production build.
assets emitted
Again, if you check to see how assets were set up in webpack.base.config.js, viewable in slide 60,
assets
emitted would mostly make sense. Specifically,
non node_module related .js
files/chunks
and .css files/chunks
. But there is one type of file that throws many people off and that
I wanted to discuss only in
production because of its configuration in config/webpack.prod.config.js. Can you guess which one that is?
assets emitted: bundle splitting
scripts/vendors~main.1ad1cd98b3cea083930b.chunk.js 129 KiB 3 [emitted] vendors~main
scripts/vendors~Speech.f0e3c9ecdeb61f482646.chunk.js 26.6 KiB 0 [emitted] vendors~Speech
config/webpack.prod.config.js
const webpack = require('webpack');
const merge = require('webpack-merge');
const ManifestPlugin = require('webpack-manifest-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
const Visualizer = require('webpack-visualizer-plugin');
const baseConfig = require('./webpack.base.config');
const prodConfiguration = env => {
return merge([
{
optimization: {
splitChunks: {
chunks: 'all',
maxInitialRequests: Infinity,
minSize: 0,
cacheGroups: {
vendor: {
test: / [\\/]node_modules[\\/]/,
name(module) {
// get the name. E.g. node_modules/packageName/not/this/part.js
// or node_modules/packageName
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
// npm package names are URL-safe, but some servers don't like @ symbols
return `npm.${packageName.replace('@', '')}`;
},
},
styles: {
name: 'main',
test: /\.css$/,
chunks: 'all',
enforce: true
}
},
},
runtimeChunk: 'single',
minimizer: [
new UglifyJSPlugin(),
new OptimizeCssAssetsPlugin()
],
},
plugins: [
new ManifestPlugin({
fileName: ('asset-manifest.json')
}),
new webpack.HashedModuleIdsPlugin(),
new Visualizer({ filename: './statistics.html'})
]
}
])
}
module.exports = env => {
return merge(baseConfig(env), prodConfiguration(env));
}
(vendor) node_module bundle splitting
When I first tried to implement the approach I
took in the first edition, the size of my vendor bundle was VERY BIG. It was
> 2MB
, way over the suggested
size of
<= 244KB
. Then I tried to split up the vendor entry files into individual
node_module dependencies, but that did no good.
(vendor) node_module bundle splitting
I started searching for a better way. At this point I knew that I should split the vendor bundle somehow into individual node_module chunks, but I just did not know how. I had to learn more about the new webpack in order to make that happen.
(vendor) bundle splitting
After further research into webpack and some google searches, I came across David Gilbertson's article The 100% correct way to split your chunks with Webpack. It was EXACTLY what I was looking for!
what hasn't changed
const webpack = require('webpack');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
As in the first edition, we need to require webpack, optimize-css-assets-webpack-plugin, and the uglifyjs-webpack-plugin.
what's new
const merge = require('webpack-merge');
const ManifestPlugin = require('webpack-manifest-plugin');
const Visualizer = require('webpack-visualizer-plugin');
const baseConfig = require('./webpack.base.config');
what's new
const prodConfiguration = env => {}
We pass env into prodConfiguration, but this time we do not have to differentiate
between
development and production as we did in baseConfig. By default,
mode ===
production
unless configured otherwise.
module.exports = env => {
return merge(baseConfig(env), prodConfiguration(env));
}
This is the code responsible for merging baseConfig with prodConfiguration and passing env into those configs. Since we are exporting, we can use these configs in our package.json. In the first edition we were using global process.env, and did not have to export our env. This configuration does NOT use global process.env, so we have to export it to make it available globally.
optimization
optimization: {
splitChunks: {
chunks: 'all',
maxInitialRequests: Infinity,
minSize: 0,
}
}
webpack 4 runs optimizations for you depending on the chosen mode. You can still manually configure and override them, as done here.
splitChunks
chunks: 'all',
Choosing chunks: 'all'
means that chunks can be shared between async and non-async chunks. It also tells webpack to put everything in
node_modules/
into a file called vendors~main.<[contenthash]>.chunk.js.
splitChunks
maxInitialRequests: Infinity,
By default, webpack sets maxInitialRequests
to 3
in production, but since we are splitting up our vendors~main.<[contenthash]>.chunk.js into individual modules of an indefinite
number, we override the default and set it to
Infinity
.
splitChunks
minSize: 0,
The minSize refers to the minimum
size in bytes for a chunk to be
generated. Since we want to split ALL modules no
matter what the size, we override the default and set the minSize to 0
.
cacheGroups
cacheGroups: {
vendor: {
test: / [\\/]node_modules[\\/]/,
name(module) {
// get the name. E.g. node_modules/packageName/not/this/part.js
// or node_modules/packageName
// npm package names are URL-safe, but some servers don't like @ symbols
return `npm.${packageName.replace('@', '')}`;
},
},
styles: {
name: 'main',
test: /\.css$/,
chunks: 'all',
enforce: true
}
}
cacheGroups
cacheGroups tells webpack to create chunks based on some conditions. To learn more, please visit the webpack 4 docs.
Configuring cacheGroups: vendor
The defaults assign all modules from node_modules
to a cacheGroup called vendors and all modules duplicated in at least 2
chunks to a cacheGroup called
default. So that's what default
cacheGroups is all about! To learn more, please visit the
SplitChunksPlugin docs for webpack
4.26.1, but a slightly different version (and target market) than the
purely English docs.
cacheGroups: vendor
test: / [\\/]node_modules[\\/]/,
Instead of excluding as we do with our loaders in webpack.base.config.js, we are testing for the /node_modules/
directory. In other words, our
vendor cacheGroups refers
to and is used for any module being loaded from node_modules/
.
cacheGroups: vendor
name(module) {
// get the name. E.g. node_modules/packageName/not/this/part.js
// or node_modules/packageName
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
// npm package names are URL-safe, but some servers don't like @ symbols
return `npm.${packageName.replace('@', '')}`;
},
the name function with the parameter module passed in to it, contains a return statement which will generate the name of a chunk if conditions set in the value of the packageName variable are met.
cacheGroups: vendor
name(module) {}
Providing a string or function to generate the name of a split chunk allows you to use a custom name. Here we are providing a function. To learn more, please click here.
cacheGroups: vendor
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
So why module.context
? context is an absolute string to the directory that
contains the entry files. Like:
module.exports = {
//...
context: path.resolve(__dirname, 'app')
};
cacheGroups: vendor
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
A context is created if your request contains expressions, so the exact module is not known on compile time.
cacheGroups: vendor
webpack parses the require() call and extracts some information:
Directory: ./template
Regular expression: /^.*\.ejs$/
cacheGroups: vendor
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
A context module is generated. It contains references to all modules in that directory that can be required with a request matching the regular expression.
cacheGroups: vendor
So if module.context
matches the regular expression passed to the .match()
method, it will
be returned via the following:
// npm package names are URL-safe, but some servers don't like @ symbols
return `npm.${packageName.replace('@', '')}`;
cacheGroups: vendor
// npm package names are URL-safe, but some servers don't like @ symbols
return `npm.${packageName.replace('@', '')}`;
We remove any "@"
from any
package names, because some
servers don't like the
"@"
symbol. Have you ever tried to search for info regarding a package where the name starts with "@"
? The browser rejects it ! The same principle applies here.
cacheGroups: styles
styles: {
name: 'main',
test: /\.css$/,
chunks: 'all',
enforce: true
}
cacheGroups: styles
name: 'main',
I wanted to name the beginning of my css main, so I added the name property for the styles cacheGroup. Otherwise, it would end up being a number id followed by [hash].[ext]. And I did not want that!
cacheGroups: styles
test: /\.css$/,
The test property checks for
chunks that end in .css
.
cacheGroups: styles
chunks: 'all',
chunks: 'all'
ensures that
all css is extracted
into one file.
I
dynamically imported my Speech.js into my top level App.js. Not
only did I
split the Speech.js code away from
main[contenthash].chunk.js
, but I also split away
Speech.js's Speech.scss
(compiled to Speech.css) from main.css.
cacheGroups: styles
I ended up with 2 style chunks/files.
cacheGroups: styles
enforce: true
The enforce
property set to
true ensures that aside from the Speech.css file,
all other styles would reside in one
main.css file.
optimization: runtimeChunk
runtimeChunk: 'single',
creates a "single"
runtime file to be shared
with all generated chunks. I had an equivalent configuration in the first
edition, and wanted to keep it that way!
optimization: minimizer
minimizer: [
new UglifyJSPlugin(),
new OptimizeCssAssetsPlugin()
],
optimization.minimize
property by default tells webpack to minimize the js bundle using the
UglifyjsWebpackPlugin. I am using two minimizers, one for js, and the other for css, so I use the optimization.minimizer
property, an array.
optimization: minimizer
Adding new UglifyJSPlugin() is probably a bit redundant (now that I know better), but I had to add it in the first edition, so it was a matter of habit!
plugins
plugins: [
new ManifestPlugin({
fileName: ('asset-manifest.json')
}),
new webpack.HashedModuleIdsPlugin(),
new Visualizer({ filename: './statistics.html'}),
new BundleAnalyzerPlugin()
]
plugins
new ManifestPlugin({
fileName: ('asset-manifest.json')
}),
the webpack-manifest-plugin
creates
the asset-manifest.json
that generates the manifest data mentioned in slide 91,
slide 92, and slide 93.
asset-manifest.json
An asset-manifest.json file is generated in the root ('/') directory with a mapping of all source file names to their corresponding output file. Like:
runtime.js": "scripts/runtime.f6f71c9f7a8cf14b349f.chunk.js",
"vendors~main.js": "scripts/vendors~main.1ad1cd98b3cea083930b.chunk.js",
Please visit the asset-manifest.json source code.
asset-manifest.json
As mentioned earlier, the runtime along with the manifest data residing in asset-manifest.json, is basically all the code webpack needs to connect your modularized app while it's running in the browser.
asset-manifest.json
It contains the loading and resolving logic needed to connect your modules as they interact. This includes connecting modules that have already been loaded into the browser as well as logic to lazy-load the ones that haven't.
The build output in the first
edition in Terminal viewable in here is in part possible
thanks to the HashedModuleIdsPlugin
. Note the 4
character hashes within brackets. Visit
slide
51 to
slide 55 of the first edition to learn more.
> speech-to-text-app@0.0.1 deploy /Users/mariacam/Development/speech-to-text-app
> gh-pages -d dist
Published
gh-pages -d dist
is the value of my custom script
npm run deploy
. It is possible because of the gh-pages npm dependency.
The current build results in a separate
file called asset-manifest.json containing the manifest
data
. In my
previous workflow, a file called
runtime.js, but no
asset-manifest.json. Here there is a separation of concerns as both an
asset-manifest.json and a runtime.js is created.
Because my webpack configuration is using the HtmlWebpackPlugin
, there is no need to worry about adding the script tag for the
manifest. The plugin adds a reference to index.html
.
plugins: [
new HtmlWebpackPlugin({
template: 'src/index.html',
favicon: 'src/favicon.ico', /* remove */
styles: 'src/styles.css',
inject: true
}),
]
My HtmlWebpackPlugin
configuration in my config/webpack-base.config.js
.
template
is the webpack require path
to the template.
favicon
gives the given favicon path
to the output
html.
favicon.ico
from src/ and moved it into static/. Please visit the README.md for a more detailed explanation!
styles
gives the given styles path
to the output
html.
inject
default is set to true
. I always like to make sure that my JS assets are placed at the
bottom of the body
element, so I use
true
. You can check out other
options in the HtmlWebpackPlugin
docs.
Separating runtime
code from vendor
code is the first step towards making sure that the vendor hash
does
not change with each new
build unless a new 3rd party plugin is added.
Using [contenthash]
instead of [chunkhash]
or [hash]
is another step towards making sure that the vendor
hash does
not change with each new build unless a new 3rd party plugin is added.
[gQNZ] ./src/style/style.css 307 bytes {1} [built]
[lVK7] ./src/index.js 846 bytes {1} [built]
[olkN] ./src/store.js 354 bytes {1} [built]
[pnOm] ./src/App.js 3.01 kB {1} [built]
[tuRH] ./src/reducers/todo.js 874 bytes {1} [built]
[z7yQ] ./src/logo.svg 3.52 kB {1} [built]
+ 2 hidden modules
However, this bundle splitting
is not enough. Each time a
new build takes place, each module.id
is incremented based on
resolving order by default. When the resolving order is changed, the module.id
changes.
So how can we prevent vendor's [contenthash]
from changing? There are two webpack plugins that can get us there. One is the NamedModulesPlugin
, and the other is the HashedModuleIdsPlugin
.
new webpack.NamedModulesPlugin(),
Uses the path to the module
instead of a numerical
identifier.
It's useful in development
because the output is more readable, but it also takes a bit longer to run.
Update: This now can be removed in config/webpack.base.config.js
as it is a default in dev mode.
new webpack.HashedModuleIdsPlugin(),
Recommended for production
builds. It causes hashes
to be based on the relative path of the module,
generating
a 4 character string
as the module
id.
Let's say our webpack.prod.config.js doesn't include the HashedModuleIdsPlugin and we have made a change to our js code. we might have added another React component, a helper function, etc. We would expect the hashes of our main js bundle and our asset-manifest.json to change on production build, but not our vendor bundle. However, all three change. Why?
Because each webpack module.id
changes whenever the resolving order is changed. The resolving order changes whenever the
module.id
has changed. The vendor
bundle hash
changes because its
resolving order has changed and therefore its module.id
as well.
Vendor remains constant
Let's say we add the HashedModuleIdsPlugin to our webpack production config and then ran another build. Our hash should stay consistent between builds. If we were to make a change to our js code again, the vendor hash should still remain the same because no changes were made to the bundle. Check it for yourself!
webpack loaders
transform your files into modules as they are added to your dependency graph
. Loaders
have 2 main purposes:
test
property)
bundle.js
. (use
property)
Replaced the ExtractTextPlugin
in webpack
4.
The webpack >= v4.0.0 'support' for extract-text-webpack-plugin was moved to mini-css-extract-plugin as a more lightweight approach for webpack >= v4.0.0 and ETWP entered maintenance mode... - Michael Ciniawsky, member, webpack-contrib
Extracts CSS into separate files. It creates a CSS file per JS file which contains CSS. Great for React and CSS Modules!
Supports on-demand-loading
of CSS and Source
Maps.
Because it does not support HMR, I have to use style-loader
in dev mode. Otherwise, I would have to do a manual page refresh to view my style changes. If you are acquainted with the
first edition of the workflow, you might remember that
style-loader
does support HMR.
{
test: /\.(scss|sass|css)$/,
exclude: /node_modules/,
loaders: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
localIdentName: '[local]___[hash:base64:5]'
},
},
'postcss-loader',
'sass-loader'
]
},
test: /\.(scss|sass|css)$/,
This test property includes .scss
, .sass
, and .css
extensions, because I use .scss
in my React applications. That .scss
then has to be compiled into .css
.
This is a much more efficient way of implementing the test property.
exclude: /node_modules/,
How you call the MiniCssExtractLoader.
This workflow supports CSS Modules. In order to make that happen, we have to add the following options to our css-loader:
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
localIdentName: '[local]___[hash:base64:5]'
},
the what
Some people want to keep their external stylesheets but want to localize their styles. That's where CSS Modules come in. To view my presentation entitled The Evolution in Design and Development: A Melting Pot, in which I devote a section on CSS Modules, please click here.
Note: as of 2.0, CRA supports CSS Modules.
When you add support for CSS Modules, whatever styles you add to a particular React component's scss/css file will only affect the styling of that particular component. If you have a component called Speech, a js file called Speech.js, and stylesheet called Speech.scss, those styles will only affect Speech.js. That's because you are importing them specifically into Speech.js. You'll get a better understanding if you visit this project's repository and study the code.
Let's use the same example for SCSS in React using CSS Modules, as we do in this project. We would import Speech.scss into Speech.js, and those styles would only affect Speech.js. But maybe we want to have styles global to the whole project that we don't import anywhere. As a matter of fact, we don't even change the syntax of the classNames. We would treat this global styling the same as we would in any application using SCSS. Please visit the Speech To Text App repository to see for yourself.
Supports hot module replacement
(HMR)
for an extracted css file. shepherdwind
, the creator of css-hot-loader, came up with the idea because style-loader
, which also can achieve css hot reload, needs to inject a style tag into index.html. And this can happen before JS scripts are loaded, resuslting in a page with no styles.
Does not play nice with the MiniCssExtractPlugin, so I have removed it from the workflow for now. Too bad. It is fantastic.
HMR
exchanges,
adds,
or removes
modules while an app is
running,
without a full
reload
. It can speed up development
because:
[contenthash]
returns a hash specific to content. It is available for the extract-text-webpack-plugin
only, and is the most specific hash option
available in webpack. And it's great to use with css files for browser
caching
. The hash only changes when the css
changes.
Apparently, the webpack team has suggested using [contenthash]
over
[hash]
for better long term caching with the MiniCssExtractPlugin. I'm willing to give it a try, as I already use webpack's new
[contenthash]
for JS bundles and it works beautifully.
Loaders
only execute a transform on a per-file
basis. Plugins
are most commonly used to perform actions
and custom functionality on "compilations"
or "chunks"
of your bundled modules.
const htmlWebpackPlugin = require('html-webpack-plugin');
plugins: [
]
In order to use a plugin, you have to require
it.
plugins: [
new HtmlWebpackPlugin({
template: 'src/index.html',
favicon: 'src/favicon.ico', /* remove */
styles: 'src/styles.css',
inject: true
}),
]
and then add
it to the plugins array
. And since you can use the same plugin many times in the same config for different purposes, you need to create an instance of it by calling it with new
.
And CRA takes the scripts
approach. A lot like creating scripts for Gulp or Grunt tasks.
"scripts": {
"test": "jest",
"lint": "eslint .",
"clean": "rimraf dist",
"cleanSrc": "rimraf dist/src",
"start": "webpack-dev-server --mode development --config config/webpack.base.config.js --open --hot
--history-api-fallback --env.PLATFORM=local --env.VERSION=stag",
"predeploy": "webpack --mode production --config config/webpack.prod.config.js --env.PLATFORM=production
--env.VERSION=stag --progress",
"deploy": "gh-pages -d dist"
},
You can create custom
, aka local
,
npm scripts
in your package.json
file. We went over most of these scripts. The rest were the same in the first edition of this workflow.
script: {
"clean ": "rimraf dist"
}
Is executed in Terminal by running npm run clean
. rimraf
is an npm plugin which refers to the rm
-rf
comand. It cleans everything within the specified directory and all its subdirectories. It's much faster than the clean-webpack-plugin
, which does the same thing.
Same as in the first edition.
script: {
"cleanSrc": "rimraf dist/src",
}
This script also has not changed since the first edition. I use it to get rid of the src folder which gets injected into dist on production build because of the presence of favicon.ico. I am successful in getting rid of it both in master and gh-pages in the first edition, but then I deploy differently there.
script: {
"cleanSrc": "rimraf dist/src",
}
In future apps, I probably will revert to the old way of deployment. It's much more complex, but provides a clean solution. It's not a big deal, but it bugs me that I have a folder which serves no purpose!
In this workflow, I only use POSTCSS for autoprefixer. For that, I only need to add the postcss-loader
in webpack.base.config.js
and require autoprefixer in postcss.config.js.
When I first started working on my React workflow, it was some time back. I even left it for a bit and then returned to continue. Much has changed since then.
CRA has piled on more features and documentation! Whether you end up using it or not, it is worth following them on Github, visiting their issues threads, and becoming acquainted with their documentation. They even suggest alternatives!
Customising @reactjs create-react-app has been super useful; I've written a blog post for @WeAreAmido about it https://t.co/JNEPSQJmIT
— Callum Mellor-Reed (@callummr) September 19, 2017
The React team has been sensitive to developers' needs. For the longest time, they were discussing how to better serve developers in development and production. According to threads I came across on Github, many developers were deploying development instead of production code without realizing it. So React responded by adding a little icon to React DevTools that would signal to developers whether their application was deployed with development or production code.
If a React application is deployed with development code, the icon appears red. If it is deployed with production code, it appears black. In addition, a new warning appears in the browser console telling you if your application is running with minified development code rather than production.
webpack has drastically improved their documentation, making it easier to master. If you haven't already checked it out, you should. They're doing a really great job!