So many keywords in this titleā¦ The ecosystem of web development today appears complicated, and you may feel overwhelmed. So let me take you in a step-by-step tutorial to learn about each of those words and the related technologies. This article is mainly about how to use webpack to bundle your code into static assets.
TL;DR: here is the Github repo with the result. You can clone it and use it as a starter kit for single page applications with Webpack, Babel, React, TypeScript, HMR and code-splitting.
Make sure you have node and npm installed. Check with
node -v
andnpm --version
. You should have at least version 9 for the first and 6 for the second.
Get started with Webpack
Use default values
Create a folder, run npm init
inside to initialize a package.json
file. Then install webpack and webpack command line interface in this folder and add it to your dev dependencies with npm install -D webpack webpack-cli
. You should now have this lines in your package.json
file:
"devDependencies": {
"webpack": "^4.8.3",
"webpack-cli": "^2.1.4"
}
And many folders in your node_modules
folder.
To use a globally installed npm package xxx (installed with
npm install -g xxx
), runnpm xxx
. To use a locally installed npm package, runnpx xxx
.
Create an index.js
file in /src
containing console.log('Hello world!');
. Then run your first webpack command npx webpack src/index.js --output dist/index.js
. Congratulations, you have just used webpack! The terminal should display something like that:
Hash: bc5be164b8f35dda104c
Version: webpack 4.8.3
Time: 14ms
Built at: 2018-05-25 14:27:14
Asset Size Chunks Chunk Names
index.js 564 bytes 0 [emitted] main
Entrypoint main = index.js
[0] ./src/index.js 21 bytes {0} [built]
You can see the files that you have bundled and the output.
The bundle time and other data may be useful for debugging. You can ask for more informations, like the dependencies list by passing the
--verbose
flag.
Because you didnāt provide any configuration, it uses production defaults values. The JavaScript result in dist/index.js
may seem hard to read. Pass the mode
flag with a none
value to have an output easier to read: npx webpack src/index.js --output dist/index.js --mode none
.
Import other files with harmony modules imports
In /src
, create a myModule.js
file with console.log('My module');
and import it in index.js
with import './myModule';
.
Run the bundle command and you will see both files in the list.
index.js 3.04 KiB 0 [emitted] main
Entrypoint main = index.js
[0] ./src/index.js 51 bytes {0} [built]
[1] ./src/myModule.js 26 bytes {0} [built]
You can also open /dist/index.js
to check that both console.log are in the bundle.
Load the code into an HTML file
Your code is not super useful without an html shell. In /src
, create an index.html
file with:
<html>
<head></head>
<body>
<script src="../dist/index.js"></script>
</body>
</html>
If you open the file in your browser and check the console, you can read the following messages.
index.js:89 My module
index.js:82 Hello world!
Configure webpack
The more complicated things your build, the more flags you need. At some point, this becomes unmanageable. You need a write a file to configurate the call. To configure webpack, you add a webpack.config.js
file at the root of your folder. This file is a node.js file so you can do anything you would be able to do in any node file. You can also write it as a json file ; but the node file it more powerful.
Remake your base configuration
To replicate this command npx webpack src/index.js --output dist/index.js --mode none
in a config file, you need to write this code:
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist/'),
filename: 'index.js'
},
mode: 'none'
};
Then run npx webpack
and webpack uses your config file with the same result as precedently.
Wrap it with an npm script
This is unrelated to webpack, but npm offers a neat way to launch command. You can add commands in the "scripts"
object of your npm package:
"scripts": {
"build": "npx webpack"
},
You can now run npm run build
to launch your build command. You can also compose the scripts for a more advanced use.
Use webpack to enable interactive coding
Watch mode
When you code, you donāt want to reload your page each time you made a change to your files. Run your webpack command with the --watch
flag: npx webpack --watch src/index.js --output dist/index.js --mode none
. The output looks like that:
Webpack is watching the filesā¦
Hash: fac002cbaae2a99f656c
Version: webpack 4.8.3
Time: 17ms
Built at: 2018-05-25 15:32:18
Asset Size Chunks Chunk Names
index.js 3.04 KiB 0 [emitted] main
Entrypoint main = index.js
[0] ./src/index.js 51 bytes {0} [built]
[1] ./src/myModule.js 26 bytes {0} [built]
The process stays active. If you go to you index.js
file and change Hello world!
to Hello Earth!
, this is added to your prompt:
Hash: 1b86e8df6f9e24f4ab30
Version: webpack 4.8.3
Time: 1ms
Built at: 2018-05-25 15:33:51
Asset Size Chunks Chunk Names
index.js 3.04 KiB 0 [emitted] main
Entrypoint main = index.js
[0] ./src/index.js 47 bytes {0} [built]
[1] ./src/myModule.js 26 bytes {0} [built]
You bundle automatically rebuilt.
Notice that the build time went down from 17ms to 1ms. 17ms seems short, but with thousands of files, it can go up to a few seconds. Webpack has an in-memory caching mechanism, so when you change just one file, it rebuilds only that file.
Watch is a good tool, but you will user a more powerful one: webpack dev server.
Getting started with webpack dev server and auto-reload
Now that your code bundles automatically, you want that new bundle version to be live pushed in the browser. The first thing you need to do is adding webpack dev server to your project with npm install -D webpack-dev-server
. Now start your server with npx webpack-dev-server
. Your prompt starts with:
ā¹ ļ½¢wdsļ½£: Project is running at http://localhost:8080/
ā¹ ļ½¢wdsļ½£: webpack output is served from /
So head to http://localhost:8080/
to see the listing of your code. You can click on src
to display your index.html
file. However, you want it to display the content of /src
by default. So go to the config file and in the exports, add:
devServer: {
contentBase: path.resolve(__dirname, 'src')
}
Now if you kill and restart the server, you have this additional line ā¹ ļ½¢wdsļ½£: Content not from webpack is served from /Volumes/Projects/webpack-tutorial-example/src
in the prompt. So when you go to http://localhost:8080/
, you will see your index.html page.
If you delete the content of dist, you see that there is nothing in the console anymore. Letās fix that.
Webpack dev server serves the content from an in-memory version of the generated bundle and exposes it here http://localhost:8080/webpack-dev-server
. So, you need to make it serve in /dist
. Update your config file with a public path:
devServer: {
contentBase: path.resolve(__dirname, 'src'),
publicPath: '/dist/'
}
Change your HTML to call the right script <script src="dist/index.js"></script>
. Now kill the server, restart with npx webpack-dev-server
, go to http://localhost:8080/
and check the console. Both messages should be displayed. Now if you change index to log console.log('Hello me!');
, you can see that your entire page reloads automatically.
Restart the dev server on configurations changes
Nodemon is a commonly used watcher for node. You can use it to restart your server when the config file changes. It can be nice when you are playing with the configuration. Start with npm install -D nodemon
and edit your package.json
file.
"scripts": {
"build": "npx webpack",
"server": "webpack-dev-server",
"server-autoreload":
"nodemon --watch webpack.config.js -x webpack-dev-server"
},
When you type npm run server-autoreload
, your dev server restarts each time you update your config file. There is a small overhead, so when you finished your configuration, you may use directly npm run server
.
Watch static assets
If you modify the index.html
file, nothing happens automatically. You have to reload the page manually because this file is currently a static asset and not part of a bundle.
To enable watching for static assets, add it to your configuration file:
devServer: {
contentBase: path.resolve(__dirname, 'src'),
publicPath: '/dist/',
watchContentbase: true
}
Kill and restart and you can now live update the HTML too!
All the configuration possibilities are documented on the webpack site
Introducing the HRM, hot module reload
Hot reload is awesome for development. It is faster than reloading your page, however the key point is not the speed but the persistent state. Your application keeps its state when you update the code!
In your config file, add the webpack import const webpack = require('webpack');
and then update your devServer configuration.
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist/'),
filename: 'index.js',
publicPath: '/dist/'
},
mode: 'development',
devServer: {
contentBase: path.resolve(__dirname, 'src'),
publicPath: '/dist/',
watchContentBase: false,
hotOnly: true
},
plugins: [
new webpack.NamedModulesPlugin(),
new webpack.HotModuleReplacementPlugin()
]
}
output.publicPath
need to be set to /dist/
so the HRM can find the files.Disable the watchContentBase
so an update to the files in src
wonāt trigger a reload, then set the triggers to hotOnly
and add a couple of plugins. webpack.HotModuleReplacementPlugin()
will perform the actual hot reload and webpack.NamedModulesPlugin()
is there, so your plugins have a name and not only an id. The naming is useful for debugging.
You can now try to make changes in your index.js file. There is no full-page reload anymore. However, you have this warning:
Ignored an update to unaccepted module ./src/index.js -> 0
[HMR] The following modules couldn't be hot updated: (They would need a full reload!)
[HMR] - ./src/index.js
This warning appears because you have not setup any behavior when the module reloads.
Add a behavior in the js file on hot reload
In your js file, simply add:
if (module.hot) {
module.hot.accept();
}
Accept the reload when it comes. Now, if you change the message to console.log('Hello you!');
you can see in your console that the logged message changed without a global page reload.
In production, HMR does not do anything. So any code inside you if block runs only in dev mode. You can use that block to reinitialize some state or to inject any code you need on refresh.
Configure environment
Development code and Production code wonāt look the same. Production code need to be minified, uglified and striped from development tools like the HMR. Right now, when you run npm run build
(which calls npx webpack
) your bundle is 28.2 Ko. This bundle is too heavy for a couple of console.log
.
index.js 28.2 KiB main [emitted] main
The easiest way is to use an environment variable. You can pass environment variables to webpack by adding an env
flag; under the hood, webpack uses yargs to pass the arguments. If you pass the flag webpack --env.foo=bar --env.baz=qux
you are able to get the values in module.exports
.
module.exports = env => {
console.log(env.foo); // 'bar'
console.log(env.baz); // 'qux'
}
Wrap your config file according to this pattern and pass the env.NODE_ENV
flag. You can use any key, but most people use NODE_ENV. The npm scripts looks like that:
"scripts": {
"build": "webpack --env.NODE_ENV=production",
"server": "webpack-dev-server --env.NODE_ENV=development",
"server-autoreload": "nodemon --watch webpack.config.js -x webpack-dev-server -- --env.NODE_ENV=development"
},
In your configuration file, you add the isDevelopment
boolean. Then set the mode conditionally mode: isDevelopment ? 'development' : 'production',
. Finally, initialize the plugin before the default configuration. The new build from npm run build
is 0.65 Ko. Better than the 28.2Ko you had before! index.js 652 bytes 0 [emitted] main
const path = require('path');
const webpack = require('webpack');
module.exports = env => {
console.log('NODE_ENV:', env.NODE_ENV);
const isDevelopment = env.NODE_ENV === 'development';
const plugins = [];
if (isDevelopment) {
plugins.push(new webpack.NamedModulesPlugin());
plugins.push(new webpack.HotModuleReplacementPlugin());
}
return {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist/'),
filename: 'index.js',
publicPath: '/dist/'
},
mode: isDevelopment ? 'development' : 'production',
devServer: {
contentBase: path.resolve(__dirname, 'src'),
publicPath: '/dist/',
watchContentBase: false,
hotOnly: true
},
plugins
};
};
The if (module.hot)
In your index.js
file, you have that code :
if (module.hot) {
module.hot.accept();
}
This code is development only, and you donāt want to see it in production. Donāt worry; it wonāt be there. Because if(module.hot)
is transpiled to if(false)
in your production bundle and you minifier treats it as dead code and eliminates it.
Cool feature : overlay debug
Add overlay: true,
to your devServer configuration.
devServer: {
overlay: true,
...
}
Then add a typo in your code like removing the last single quote in the console log, save and look what happened. Your browser should display this warning in an overlay:
Failed to compile.
./src/index.js
Module parse failed: Unterminated string constant (3:12)
You may need an appropriate loader to handle this file type.
| import './myModule';
|
| console.log('Hello world!);-
Cool! When you made an error that breaks your code, webpack tells you live what error you made and where. This is super nice for debugging when you are coding an UI.
Expose your local dev server to the world
Right now, your server is only available on your local machine. You may want to expose the server to other machines on your local network. Like co-workers machines, mobile devices or computer running another OS to test your application. Simply add host: '0.0.0.0',
to your devServer configuration
devServer: {
host: '0.0.0.0',
...
},
Then find your local IPV4 Adress. It should look like that: 192.168.1.32
; and you can visit http://192.168.1.32:8080/
from any device connected to the same network. Usually a smart phone in wifi.
Clean dist before you build or develop
Install the clean webpack plugin with npm i -D clean-webpack-plugin
and add it to your configuration file
...
const CleanWebpackPlugin = require('clean-webpack-plugin');
module.exports = env => {
...
const plugins = [new CleanWebpackPlugin(['dist'])];
...
Pass an array of folders to remove before any build or server initialization.
This helps you to clean tokenized builds.
Get rid of the extensions for the imports
Instead of import App from './App.js';
, you want to write import App from './App';
. To do that, simply add the extensions in the resolver. In webpack.config.js
:
...
mode: isDevelopment ? 'development' : 'production',
resolve: {
extensions: ['.js', '.json', '.ts', '.jsx', '.tsx']
},
devServer: {
...
Using sourcemaps
The code generated for development or production can become barely debuggable. When you have much code bundled, it can be problematic to find the bugs. So, add the sourcemap in your webpack.config.js
:
...
{
entry: './src/index.js',
devtool: 'cheap-module-eval-source-map',
output: {
...
Generate a runtime error in index.js
by adding console.log(hello);
. The console displays a reference error. When you click on the index.js link, you see the actual source code you wrote and not the transpiled code.
You can use
eval-source-map
to get a longer build with more information; or ahidden-source-map
for production to avoid sending it to clients. The webpack documentation gives more details.
Use babel loader to transpile form ESNext to es5
Install babel
Modern JavaScript is awesome. The language has evolved for the better in the last few years, but many users still use old browsers that donāt support those more modern features. Use Babel to transpile the modern JavaScript you write into the old JavaScript that is the most widely supported. You can check their Github repo. At first, install babel with:
npm i -D babel-loader @babel/core @babel/preset-env
About preset env and compatibility
Babel is a set of plugins to transpile code. @babel/preset-env is a powerful preset that allows you to choose precisely the compilation target for your production code. It uses the syntax of browserslist.
If you want to support ie9+, firefox 10+, chrome 5+ and iOS7 for example, here is what you will have to write :
ie >= 9, Firefox >= 10, Chrome >= 5, iOS >= 7
You can also have a simple cover 99%
and babel adjusts its settings to make sure your transpiled code works for 99% of the users in the world.
There is no good general rule. If you work for a public service, target
ie >= 6
. If your work for startups, targetlast 2 versions
(for each browser). The more browsers you support, the bigger your bundle is. However, you or your client may have specific requirements. Think about mobile users.
You can check on the browserl.ist site your configuration. The basic is default
. Try > 0.1%
for example, it shows you the list of everything you support with this setting.
Add some ES6
Nothing fancy, but letās add some es6 to your index.js
file:
const myName = 'Fabien';
const greetings = `Hello, Iām ${myName}`;
console.log(greetings);
const
and template litterals cannot be interpreted by older browser. So you need to transpile it.
Add the module and the preset
In your webpack.config.js file, add the following module:
module.exports = env => {
...
return {
...
module: {
rules: [
{
test: /\.js$/,
exclude: /(node_modules)/,
use: {
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
{
modules: false,
debug: true,
target: {
browsers: ['cover 99%']
}
}
]
]
}
}
}
]
},
...
};
};
test: /\.js$/
: your module will transform all the.js
files.exclude: /(node_modules)/
: the js files in the node_modules folder wonāt be transpiled. This is super important for your development environment. If you forget to exclude that folder, babel compilation time can be way too high; and you could transform things that should stay the way they are.use: ...
: what rules you are going to use.modules: false
: this is important to avoid the module import to commonjs. You want to keep the harmony import for code optimization.browsers: ['cover 99%']
: the preset to adjust the transpilation coverage 99% of the users.debug: true
: displays all the transforms used to respect the coverage.
Now, when you npm run build
, the following code is generated in dist/index.js
:
var o="Hello, Iām ".concat("Fabien");console.log(o)
const
became var
and the template literal now uses concat
. ie9 can now read the modern code you wrote!
The
options
object can be configurated in a.babelrc.js
file.
Go for a whitelist instead of a blacklist for the transpiled files
exclude: /(node_modules)/
will exclude the node modules. However, you may want to be more restrictive and go for a whitelist. Better use include: path.resolve(__dirname, 'src'),
.
This is to avoid processing files that may exist in other folders like
/public
,/assets
,tests
ā¦ or any folder you may add later.
Use a .babelrc file
To simplify your webpack.config.js
file, remove rules.use.options
and put it in the .babelrc.js
file:
module.exports = {
presets: [
[
'@babel/preset-env',
{
modules: false,
debug: true,
target: {
browsers: ['cover 99%']
}
}
]
]
};
Disable the transform in development
You probably use a modern browser to develop. So you donāt need to transpile to much of your code. Letās split the modules for dev and production.
const path = require('path');
const webpack = require('webpack');
module.exports = env => {
const isDevelopment = env.NODE_ENV === 'development';
const plugins = [];
if (isDevelopment) {
plugins.push(new webpack.NamedModulesPlugin());
plugins.push(new webpack.HotModuleReplacementPlugin());
}
let module = {};
if (!isDevelopment)
module = {
rules: [
{
test: /\.js$/,
exclude: /(node_modules)/,
use: {
loader: 'babel-loader'
}
}
]
};
return {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist/'),
filename: 'index.js',
publicPath: '/dist/'
},
mode: isDevelopment ? 'development' : 'production',
devServer: {
contentBase: path.resolve(__dirname, 'src'),
hotOnly: true,
overlay: true,
publicPath: '/dist/',
watchContentBase: false
},
module,
plugins
};
};
The development code is not transpiled anymore and is processed way faster.
Use aliases for absolute imports
When you import a module in your code, you can use relatives path imports like ../../Components/MyComponent
or absolute imports like /src/Components/MyComponent
. Both relative and absolute import used that way can cause trouble when refactoring. However, you can use aliases to help you:
...
resolve: {
alias: {
Components: path.resolve(__dirname, 'src/Components/'),
},
...
It may save yourself many troubles.
Add React to your app
Configure the loader
You write React application with JSX. JSX is not valid JavaScript. It has to be transpiled. So letās add the loader for react with npm i -D @babel/preset-react
. and add it in your .babelrs.js
file:
module.exports = {
presets: [
[
'@babel/preset-env',
...
],
'@babel/preset-react'
]
};
Our js file is now processed first by the react loader and then by the babel loader.
Webpack uses loader in a reverse order.
Initialize React
Add React to your packages with npm i -S react react-dom
, and initialize a simple React application in index.js
:
import React from 'react';
import ReactDom from 'react-dom';
class App extends React.Component {
render() {
return <div>Hello world!!</div>;
}
}
let root = document.getElementById('root');
if (!root) {
root = document.createElement('div');
root.id = 'root';
document.body.appendChild(root);
}
ReactDom.render(<App />, root);
if (module.hot) {
module.hot.accept();
}
The react application works. You can now start coding. Notice that when you update your code, the HRM live update the content.
React HRM
Live reload is not good enough. Letās add a simple state and a counter to the application:
render() {
return (
<div>
Counter : {(this.state || {}).counter || 0}
<br />
<button
onClick={() =>
this.setState({ counter: ((this.state || {}).counter || 0) + 1 })
}
>
add
</button>
</div>
);
}
When you update your code, the live reload erases the state. You want to use React HMR to avoid that. npm i -D react-hot-loader
.
In the .babelrc.js
, add
...
],
plugins: ['react-hot-loader/babel']
};
Put the application code in an App.js
file:
import React from 'react';
import { hot } from 'react-hot-loader';
class App extends React.Component {
render() {
return (
<div>
Counter: {(this.state || {}).counter || 0}
<br />
<button
onClick={() =>
this.setState({ counter: ((this.state || {}).counter || 0) + 1 })
}
>
add
</button>
</div>
);
}
}
export default hot(module)(App);
The index.js file renders the App.
import React from 'react';
import ReactDom from 'react-dom';
import { hot } from 'react-hot-loader';
import App from './App.js';
let root = document.getElementById('root');
if (!root) {
root = document.createElement('div');
root.id = 'root';
document.body.appendChild(root);
}
ReactDom.render(<App />, root);
Now you have a hot module reload; a faster live reload that keeps the state.
Use TypeScript
TypeScript is fantastic, and you should use it. Wel, you donāt have to, but if you want, letās see how you can do this.
Use TypeScript in webpack.config.js
Start by installing some packages with npm i -D typescript ts-node @types/node @types/webpack
.
The
@types
packages gives you autocompletion for node and webpack.
Then change the extension of your config file to webpack.config.ts
. Thatās it; you can use TypeScript.
Donāt forget to update to
.ts
in yourserver-autoreload
script inpackage.json
.
Use TypeScript in your React files
Start by installing the loader with npm i -D @babel/preset-typescript
and to add it in the presets:
presets: [
[
'@babel/preset-env',
{
modules: false,
//debug: true,
target: {
browsers: ['cover 99%']
}
}
],
'@babel/preset-react',
'@babel/typescript'
],
Let me introduce two extensions. .tsx
for all files that contains JSX and .ts
for all the other JavaScript files.
Right now, both your
index.js
andApp.js
file contain JSX, so they both need the.tsx
extension.
You need to change the test in your module rules. test: /\.js$/
must become test: /\.(tsx?)$/i
that will return all the ts and tsx file. And to change the entry to entry: './src/index.tsx'
. You can now use TypeScript in your React files.
You should install the types for React and react-dom with
npm i -D @types/react @types/react-dom
. I love the autocomplete.
Install another plugin commonly used in react npm i -D @babel/plugin-proposal-class-properties
and add it in the plugins list plugins: ['@babel/plugin-proposal-class-properties', 'react-hot-loader/babel']
. It allows you to use things like state = {...};
in the react classes.
TypeScript and React
Learning TypeScript is out of the scope of this tutorial, but here is what the App.tsx
file should look like when typed.
import React from 'react';
import { hot } from 'react-hot-loader';
export interface AppPropsInterface {}
export interface AppStateInterface {
counter: number;
}
class App extends React.Component<AppPropsInterface, AppStateInterface> {
state = {
counter: 0
};
private incrementCounter = (
event: React.MouseEvent<HTMLButtonElement>
): void => {
this.setState({ counter: this.state.counter + 1 });
};
public render(): JSX.Element {
return (
<div>
Counter: {this.state.counter}
<br />
<button onClick={this.incrementCounter}>add</button>
</div>
);
}
}
export default hot(module)(App);
A bit more about typescript configuration
You can create a tsconfig.json
at the root of your project to specify the behavior of the TypeScript compiler. Since babel 7 supports TypeScript, the idea is to use TypeScript only for the tooling. Here are my options:
{
"compilerOptions": {
"noEmit": true /* Most important thing: will not compile anything */,
"jsx": "preserve" /* Do not compile your JSX */,
"allowSyntheticDefaultImports": true /* To import without "* as" */,
"alwaysStrict": true,
"target": "ESNEXT",
"strictNullChecks": true /* You have to add "| null" in your type when needed */,
"strictFunctionTypes": true /* Enable strict checking of function types. */,
"noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
"noUnusedLocals": true /* Report errors on unused locals. */,
"noUnusedParameters": true /* Report errors on unused parameters. */,
"noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
"moduleResolution":
"node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
"baseUrl":
"./src" /* Base directory to resolve non-absolute module names. */
}
}
Dynamic import
A compelling feature of webpack is code splitting. You can setup dynamic import for components and they are not included in the base bundle but loaded only when required. At first, install the plugin with npm i -D @babel/plugin-syntax-dynamic-import
and add it to the plugin list:
plugins: [
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-proposal-class-properties',
'react-hot-loader/babel'
]
Create a /src/SubView.tsx
file with a functional component:
import React from 'react';
export interface PropsInterface {
counter: number;
}
const SubView: React.SFC<PropsInterface> = ({ counter }) => (
<div>This is a subview created when the counter was {counter}</div>
);
export default SubView;
And then, update App.tsx
as follow:
export interface AppStateInterface {
counter: number;
subView: JSX.Element | null;
}
class App extends React.Component<AppPropsInterface, AppStateInterface> {
state = {
counter: 0,
subView: null
};
private incrementCounter = (): void => {
this.setState({ counter: this.state.counter + 1 });
};
private addSubView = (): void => {
import('./SubView')
.then(module => {
const Component = module.default as React.SFC;
this.setState({
subView: <Component counter={this.state.counter} />
});
})
.catch(error => console.error(error));
};
public render(): JSX.Element {
return (
<div>
Counter: {this.state.counter}
<br />
<button onClick={this.incrementCounter}>add</button>
<br />
<button onClick={this.addSubView}>add sub view</button>
{this.state.subView}
</div>
);
}
}
Full reload and check your network panel. When you click the āadd subviewā button, there is a 0.index.js
XHR call. The code is your little module. It was not included in the main bundle and need to be called.
Splitting allows you to reduce the final bundle size significantly.
Your linter: TSLint
Because of TypeScript, you want to use TSLint instead of ESLint. I highly encourage the AirBnB rules. You may not agree with everything, but they are so commonly used that nobody feels lost when using them. Install with npm i -D tslint
.
Make sure to have the right extensions installed for your EDI.
Include the HTML file in your build
You need to copy the file from /src
and paste it to /dist
. This is done using the Copy Webpack Plugin. Install with npm i -D copy-webpack-plugin
. This is used only in production, so update the webpack.config.ts
file as follow:
...
if (isDevelopment) {
plugins.push(new webpack.NamedModulesPlugin());
plugins.push(new webpack.HotModuleReplacementPlugin());
} else {
plugins.push(
new CopyWebpackPlugin(
[
{
from: 'src/index.html',
to: path.resolve(__dirname, 'dist')
}
],
{}
)
);
}
...
Now when you npm run build
, the TML is copied in the /dist
folder.
If you have various assets (picture, pdfā¦), this is how you include them in your build.
Milestone reached!
You can deploy the content of your dist folder in any FTP, you have a functioning page!
Optimize the production build
Minify the HTML
Minifiy the HTML is a super small win, but 1 bit is 1 bit! First, install the minifier with npm i -D html-minifier
. Then add the transform in your plugin:
...
new CopyWebpackPlugin(
[
{
from: 'src/index.html',
to: path.resolve(__dirname, 'dist'),
transform(htmlAsBuffer) {
return Buffer.from(
HTMLMinifier.minify(htmlAsBuffer.toString('utf8'), {
collapseWhitespace: true,
collapseBooleanAttributes: true,
collapseInlineTagWhitespace: true
})
);
}
}
],
{}
)
...
You can find all the minification options in the github repo of html-minifier.
When you
npm run build
, your HTML is now minified.
Uglify the JavaScript
You can try babel minify, but I use Uglify for now. Install the plugin with npm i -D uglifyjs-webpack-plugin
. Then add it to your plugins list for the dev build:
...
if (isDevelopment) {
plugins.push(new webpack.NamedModulesPlugin());
plugins.push(new webpack.HotModuleReplacementPlugin());
} else {
plugins.push(
new UglifyJsPlugin({
parallel: true,
sourceMap: true,
cache: true
})
);
plugins.push(
...
When you npm run build
, your JS is now uglified.
Donāt forget to add
sourceMap: true
to keep generating the sourcemaps files.parallel
andcache
speeds up the build.
Make sourcemaps external
Webpack inlines your sourcemaps in their parent files. Which is practical for your development environment. However, it adds much weight to the bundles. You declared devtool: 'cheap-module-eval-source-map',
to ask for that inlining. Letās use the source map dev tool plugin to gain a more fine-grained control of the source maps generation.
plugins.push(
new webpack.SourceMapDevToolPlugin({
filename: 'sourcemaps/[name].js.map',
lineToLine: true
})
);
You also need to remove the 'react-hot-loader/babel'
from your babel settings and the devtools: 'cheap-module-eval-source-map'
from the exported plugin to allow sourcemaps generation.
Split the configurations
Our production and development settings are now different enough to justify a split. Make two files webpackConfig/development.js
and webpackConfig/production.js
.
Our main webpack.config.ts
will just import and export the production and development settings:
const path = require('path');
const productionConfig = require('./webpackConfig/production');
const developmentConfig = require('./webpackConfig/development');
module.exports = env => {
if (env.NODE_ENV === 'production')
return productionConfig(env, path.resolve(__dirname));
if (env.NODE_ENV === 'development')
return developmentConfig(env, path.resolve(__dirname));
};
Note that you pass the path from the main file to avoid having to look up for it in the config files.
The development config will look like that:
const webpack = require('webpack');
function buildDevelopementConfig(env, dirname) {
console.log('Start build for NODE_ENV: ', env.NODE_ENV);
return {
entry: dirname + '/src/index.tsx',
devtool: 'cheap-module-eval-source-map',
output: {
path: dirname + '/dist',
filename: 'index.js',
publicPath: '/',
sourceMapFilename: 'bundle.map'
},
mode: 'development',
resolve: {
extensions: ['.js', '.json', '.ts', '.jsx', '.tsx']
},
devServer: {
host: '0.0.0.0',
contentBase: dirname + '/src',
hotOnly: true,
overlay: true,
publicPath: '/',
watchContentBase: false
},
module: {
rules: [
{
test: /\.(tsx?)$/i,
include: dirname + '/src',
use: {
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
{
modules: false,
debug: true,
target: {
browsers: ['cover 99%']
}
}
],
'@babel/preset-react',
'@babel/typescript'
],
plugins: [
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-proposal-class-properties',
'react-hot-loader/babel'
]
}
}
}
]
},
plugins: [
new webpack.NamedModulesPlugin(),
new webpack.HotModuleReplacementPlugin()
]
};
}
module.exports = buildDevelopementConfig;
And the production configuration will look like this:
const webpack = require('webpack');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const HTMLMinifier = require('html-minifier');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
function buildProductionConfig(env, dirname) {
console.log('Start build for NODE_ENV: ', env.NODE_ENV);
return {
entry: dirname + '/src/index.tsx',
output: {
path: dirname + '/dist',
filename: 'index.js',
publicPath: '/',
sourceMapFilename: 'bundle.map'
},
mode: 'production',
resolve: {
extensions: ['.js', '.json', '.ts', '.jsx', '.tsx']
},
module: {
rules: [
{
test: /\.(tsx?)$/i,
include: dirname + '/src',
use: {
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
{
modules: false,
debug: true,
target: {
browsers: ['cover 99%']
}
}
],
'@babel/preset-react',
'@babel/typescript'
],
plugins: [
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-proposal-class-properties'
]
}
}
}
]
},
plugins: [
new CleanWebpackPlugin(['dist']),
new UglifyJsPlugin({
parallel: true,
sourceMap: true,
cache: true
}),
new webpack.SourceMapDevToolPlugin({
filename: 'sourcemaps/[name].js.map',
lineToLine: true
}),
new CopyWebpackPlugin(
[
{
from: dirname + '/src/index.html',
to: dirname + '/dist',
transform(htmlAsBuffer) {
return Buffer.from(
HTMLMinifier.minify(htmlAsBuffer.toString('utf8'), {
collapseWhitespace: true,
collapseBooleanAttributes: true,
collapseInlineTagWhitespace: true
})
);
}
}
],
{}
)
],
performance: {
hints: 'warning'
}
};
}
module.exports = buildProductionConfig;
Analyze my bundles
It is always interesting to visualize the impact of your imports on your production bundles. Letās install Webpack Bundle Analyzer with npm i -D webpack-bundle-analyzer
. Then add it to your production configuration with:
...
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
...
plugins: [
new BundleAnalyzerPlugin({ analyzerMode: 'static' }),
...
Now when you npm run build
a page opens and display the detailed weight of your bundles and the related imports.
Mission complete!
You have now a complete development environment to start building a single page web application with React and TypeScript. You can also deploy your build directly on an FTP server to expose it to the world!