ES6 modules, now in a browser near you
There are some news and changes since I last visited ES6 modules. I started hearing more and more about WebPack and how it did the one thing that Rollup doesn't.... it splits the code in different bundles based on the overall size of your Javascript files.
The latest news is that modules are now supported in browsers so packaging them may or may not be as necessary any more.
ES6 modules defined #
In ES6 each module is defined in its own file. The functions or variables defined in a module are not visible outside unless you explicitly export them. This means that you can write code in your module and only export those values which should be accessed by other parts of your app.
ES6 modules are declarative in nature. To export certain variables from a module you just use the keyword export. Similarly, to consume the exported variables in a different module you use import.
Rollup #
Things haven't changed since the last time I worked with Rollup so I'll use the same example I used then.
The first part of the example is the notional image manipulations. We load the image using a promise and resolve it on load and reject with a new error. The other functions just log their results to console.
Using the export
keyword tells the parsers that we will import the function from other scripts as we'll do later in our main script.
// image-manip.js
export function loadImage(url) {
return new Promise( (resolve, reject) =>{
var image = new Image();
image.src = url;
image.onload = () => {
resolve(image);
};
image.onerror = () => {
reject(new Error('Could not load image at ' + url));
};
});
}
export function scaleToFit(width, height, image) {
console.log('Scaling image to ' + width + ' x ' + height);
return image;
}
export function watermark(text, image) {
console.log('Watermarking image with ' + text);
return image;
}
export function grayscale(image) {
console.log('Converting image to grayscale');
return image;
}
Our main script uses the import
keyword to bring the exported functions from the image-manip library into the current file. We then use it to create a function without having to redefine the functions, we just use them.
// app.js
import { loadImage, scaleToFit, watermark, grayscale } from './image-manip.js';
// Image processing pipeline
function processImage(image) {
loadImage(image)
.then((image) => {
document.body.appendChild(image);
return scaleToFit(300, 450, image);
})
.then((image) => {
return watermark('The Real Estate Company', image);
})
.then((image) => {
return grayscale(image);
})
.catch((error) => {
console.log('we had a problem in running processImage ', error);
});
}
To create a Rollup bundle we need to do the following:
- Install Rollup globally
- Install Rollup and related plugins at the project level
- Create a
rollup.config.js
configuration file - Run the command line, configure your package file to run the build through NPM or your build system
NPM installation is pretty straight forward. This will take care of steps 1 and 2 of our task list.
npm install -g rollup
npm install -D rollup \
babel-preset-es2015-rollup\
rollup-plugin-babel \
rollup-plugin-commonjs \
rollup-plugin-json \
rollup-plugin-node-resolve
The configuration file tells Rollup what files to work with and how to process them. This is step 3 in our task list.
'use strict';
import commonjs from 'rollup-plugin-commonjs';
import nodeResolve from 'rollup-plugin-node-resolve';
import json from 'rollup-plugin-json';
export default {
entry: './es6/main.js',
plugins: [
nodeResolve({
jsnext: true
}),
commonjs({
include: 'node_modules/**'
}),
json()
],
dest: './bundle.js'
};
How we run the command depends on the tooling we have set up. We can run Rollup from the command line using a command like the one below. This command assumes that there is a rollup.config.js
in the directory where we run the command.
rollup -c --output bundle-2.js # --output is equal to dest
We can also configure NPM to run our Rollup build as a script. In the example below we run the command using npm run build
which will then trigger the build sing the configuration available in the same directory.
{
...,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "rollup -c"
},
...
}
The last version is a Gulp task to handle transpilation to ES5 and run Rollup to create the bundle. This is pretty close to the configuration file but split differently to accommodate the two-step process. Soon creating bundles for browsers will become easier because we won't need the transpilation process... browsers are beginning to support modules natively.
const gulp = require('gulp');
const rollup = require('rollup')
;
gulp.task('build', function () {
return rollup.rollup({
entry: "./src/main.js",
plugins: [
nodeResolve({
jsnext: true
}),
commonjs({
include: 'node_modules/**'
}),
json()
],
})
.then(function (bundle) {
bundle.write({
format: "umd",
moduleName: "library",
dest: "./bundle.js",
sourceMap: true
});
})
});
Webpack #
Webpack is the 500 pound gorilla when it comes to compressing and bundling web content. It works on both CSS and Javascript to take over a lot of things that we can and should do in our build process.
That said it does some things that no other build system does. It works to create CSS and Javascript bundles from the same configuration (assuming that it is properly configured), it will warn you if any of your bundles are over 250Kb which prevents the bundles from slowing down page loading.
I think it's too big a tool that tries to do too much but, again, it seems that everyone is using it so you may have to use it whether you like it or not.
We will only concentrate on Javascript bundling, code splitting and shaking. I will not use Webpack for handing images and S/CSS because I don't think we should be converting it all to Javascript. Think about it, we load over 1MB of images into our pages, and a couple hundred KB of CSS and we will convert all those images to strings just so we can feed them to Javascript and then place them back into their correct locations? Thanks but no thanks... I would rather use traditional strategies built around Gulp and it's ecosystem and not split it into something completely different
Installing Webpack #
# install webpack and dependencies
npm install webpack \
babel-core \
babel-loader \
babel-preset-es2015 --save-dev
# build project structure
mkdir -p project/src
Create the following files and place them in the location indicated in the first line of each file. If it doesn't have a path, like src/to/file.js
then it goes in the project folder.
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello webpack</title>
</head>
<body>
<div id="root"></div>
<script src="dist/bundle.js"></script>
</body>
</html>
We'll place the script in the app
directory. This example is deliberately simple, It can be as complex as you need it to be.
// src/app.js
const root = document.querySelector('#root')
root.innerHTML = `<p>Hello webpack.</p>`
The final script of this build process is the Webpack configuration file. This file will transpile the app.js
file into ES5 and then convert it into bundle.js
for use in all browsers.
// webpack.config.js
const webpack = require('webpack')
const path = require('path')
const config = {
context: path.resolve(__dirname, 'src'),
entry: './app.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [{
test: /\.jsx$/,
include: path.resolve(__dirname, 'src'),
use: [{
loader: 'babel-loader',
options: {
presets: [
['es2015', { modules: false }]
]
}
}]
}]
}
}
module.exports = config
In order to run the files we need to modify the project's package.json
to add scripts that will run Webpack. There are ways to incorporate the tool into Gulp and Grunt build processes, that is left as an exercise to the reader.
{
"scripts": {
"build": "webpack"
},
}
One last thing to consider. The configuration presented above will bundle everything together and that may not always be desirable.
We can modify the Webpack configuration script to bundle all our vendor assets into separate chunks. The modified script looks like this:
var webpack = require('webpack');
var path = require('path');
module.exports = function(env) {
return {
entry: {
main: './index.js',
vendor: 'moment'
},
output: {
filename: '[name].[chunkhash].js',
path: path.resolve(__dirname, 'dist')
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor' // Specify the common bundle's name.
})
]
}
}
By providing multiple entry points we make it easier to use the CommonsChunkPlugin
to bundle together our vendor (normally third party) libraries. In the example we're bundling moment but this may also be an array of our vendor dependencies.
If you want more information, check the Webpack Guides.
Why do we need to convert everything to Javascript? #
My biggest peeve with Webpack and the reason why I've only shown how to work with Javascript is that I'm still not convinced everything should be done in Javascript.
What is the difference between bundling all CSS into Javascript so we can be sure that we're only feeding the user the CSS needed for a page and using UNCSS and Critical to inline and deliver only the CSS needed for a page or a screen in an app?
Same thing with images; Is it worth or even necessary to convert all images over a given size into Base64 strings just so we can inline them in Javascript or is it better to use responsive images (with all the associated pain in the ass they entail) and build the responsive images from a script or a build file?
Granted, everyone has an opinion on this subject. Mine is that I don't think everything in JS is the answer to all our development issues.
Modules natively in browsers #
Rollup and Webpack are popular because, until recently, there was no way to work with modules directly in web browsers. Even though ES6 is almost 2 years old we had no browser that supported modules natively.
That is changing. Native support for modules is starting to come out in browsers... it's still experimental in most of them but it's a good sign that they will come into release version of the browsers soon. The current list of supported browsers:
- Safari 10.1.
- Chrome Canary 60 – behind the Experimental Web Platform flag in
chrome:flags
- Firefox 54 – behind the
dom.moduleScripts.enabled
setting inabout:config
- Edge 15 – behind the Experimental JavaScript Features setting in
about:flags
Differences between regular scripts and module scripts when used in the browsers (taken from exploring ES6:
Scripts | Modules | |
---|---|---|
HTML element | <script> |
<script type="module"> |
Default mode | non-strict | strict |
Top-level variables are | global | local to module |
Value of this at top level |
window |
undefined |
Executed | synchronously | asynchronously |
Declarative imports (import statement) |
no | yes |
Programmatic imports (Promise-based API) | yes | yes |
File extension | .js |
.js |
There are two ways to create a module. External and internal modules, each of which can export and import multiple named functions from other modules.
Take the following utils.js
external module that explorts a single function to append text to the body of a page.
// utils.js
export function addTextToBody(text) {
const div = document.createElement('div');
div.textContent = text;
document.body.appendChild(div);
}
This internal module imports addTextToBody
from utils.js
and uses it as a local function without name spacing.
<script type="module">
import {addTextToBody} from './utils.js';
addTextToBody('Modules are pretty cool.');
</script>
The last concern when working with native module implementations is how to handle older browsers. Most modern browsers have repurposed the type
attribute of the script
element: If it's value is module the JS engine will treat the content as a module with different rules than those for normal scripts.
To target older browsers use the nomodule
attribute.
<script type="module" src="module.js"></script>
<script nomodule src="fallback.js"></script>
There is a lot more to learn about native module implementation. Some of the things that caught my attention when researching modules:
They only run once per page no matter how many times you load it.
Modules and script modules never block rendering. They always run as if the defered
attribute was set in the calling script tag. The defer tag means that the script will execute after the content is downloaded but before the DOMContentLoaded event is fired.
Modules and inline (script) modules can use async attribute meaning that they can be made to load without blocking HTML rendering but it also means that you can no longer guarantee execution order. If the order your scripts run in is important rely on the defered attribute discussed earlier.
Must use a valid Javascript Mime Type or it will not execute. In this context, a valid Javascript Mime Type is one of those listed in the HTML Standard:
- application/ecmascript
- application/javascript
- application/x-ecmascript
- application/x-javascript
- text/ecmascript
- text/javascript
- text/javascript1.0
- text/javascript1.1
- text/javascript1.2
- text/javascript1.3
- text/javascript1.4
- text/javascript1.5
- text/jscript
- text/livescript
- text/x-ecmascript
- text/x-javascript