This article is the fourth in a series of deep dives into JavaScript. You can view previous articles by visiting the Github repository associated with this project.
This series does not comprehensively cover every JavaScript feature. Instead, features are covered as they crop up in solutions to various problems. Also, every post is based on tutorials and open source libraries produced by other developers, so like you, I too am also learning new things with each article.
In the last article we added the functionality for our framework to create and render DOM elements, created an example application and then successfully tested it. Today we will cover the steps necessary to run our app in a browser.
The first step
At the moment, if we installed a server like http-server and spun it up in the folder housing our example application, this error shows up in the browser console Uncaught ReferenceError: require is not defined
. This is because the require
function only exists in the node environment. It provides a way of accessing code that exists in separate files. The easiest (and most painful) way to replicate this behaviour in the browser would be to use <script>
tags.
Before the advent of ES Modules, developers used (and still do) either the CommonJS or AMD formats to tackle this problem. And this is where build tools such as Webpack or Parcel come in. Conceptually, their work is straightforward. They gather all the files needed to run an application, work out the dependencies of those files and then create one big JavaScript file which can run in a web browser. The complexity comes in the how of that process and various other cool tricks such as hot reloading (creating a new bundle each time you save changes to a file) and tree shaking (eliminating unused code).
The first step in creating the bundler will be creating a command line tool so we can use npm scripts to initiate everything. Our framework aprender
already has a package.json
file so we begin by adding the following command.
{
"name": "aprender",
"version": "1.0.0",
"description": "",
"main": "",
"scripts": {
"test": "node tests",
"demo": "maleta demo/index.html --entry index.js"
}
}
At this point it is worth exploring what happens when we type npm run demo
in the terminal. Before running the commend, we first create a symlink
between aprender
and our build tool, which will be called maleta
. The symlink is created by:
- Creating a folder called
maleta
on the same folder level asaprender
- In the terminal, navigate to
maleta
and typenpm link
- Navigate to
aprender
and typenpm link maleta
When npm run demo
is executed, npm grabs the scripts
object in aprender's package.json
file and runs whatever command is assigned to the property demo
. The first part of the demo command is referencing maleta
, our module bundler. npm will process maleta
's package.json
file and look for an object called bin
. It looks like this:
"bin": {
"maleta": "bin/cli.js"
}
The bin
folder in any npm package contains executable files. The value of maleta
is the path to the file cli.js
, which contains the following code:
#!/usr/bin/env node
const program = require('commander');
const version = require('../package.json').version;
const bundler = require('../src/bundler');
program.version(version);
program
.command('serve <filename>')
.description('serves the files')
.option(
'--entry <file>',
'set the name of the entry JS file'
)
.action(bundle);
program
.command('help [command]')
.description('display help information for a command')
.action(function(command) {
let cmd = program.commands.find(c => c.name() === command) || program;
cmd.help();
});
const args = process.argv;
// Make serve the default command except for --help
if (args[2] === '--help' || args[2] === '-h') args[2] = 'help';
if (!args[2] || !program.commands.some(c => c.name() === args[2])) args.splice(2, 0, 'serve');
program.parse(process.argv);
function bundle (entryJsFile, command) {
bundler(entryJsFile, {
entryJsFile: command.entry
});
}
This file is executed by your operating system's shell. npm does this by using node's child_process
method. The shebang #!/usr/bin/env node
at the top of the file tells your operating system which interpreter or application to use when executing the file (if you are using Windows, this will be slightly different). When the node process is launched, any arguments specified are passed to the process.argv
property. The first two arguments refer to the absolute pathname of the executable that started the process and the path to the JavaScript file being executed. Every argument from index two onwards is used by whatever code is being executed.
Maleta's CLI tool is built using commander. Commander exposes an object with a number of methods. We can use the version
method to return the bundler version by typing maleta -V
or maleta --version
. After that we use the command
method to begin creating our commands. command
takes one argument written in the following syntax command <requiredArg> [optionalArg]
. Our CLI tool has two commands - one to serve the app and another to print help text. The string specified via description
is displayed when a user runs the help command. The action
method is used to specifiy the callback function which runs when the command is executed. It receives the argument(s) passed in via the <>
or []
brackets and the commander object, which will have the names of any specified options among its properties.
Taking inspiration from Parcel, we make serve
the default argument if no argument has been passed and then use commander's parse
method to add the arguments to the commander object. Finally, bundle
calls the imported bundler
function with the entry file.
The bundler at work
Maleta borrows much of its structure from Minipack, a similar project written by Ronen Amiel that explains how bundlers work. The only differences are that Maleta bundles both ES and CommonJS modules, has a CLI tool and spins up a server to run the app. At the core of our bundler's work is the dependancy graph. This lists all the files used in an application along with any dependencies. Before building that graph, we will use the entry file to create a rootAsset
object with the following structure:
const rootAsset = {
outDir: '', // the path of the directory where the bundle will created
content: '', // the code in the file
entryJsFilePath: '', // the path of the entry JavaScript file
rootDir: '', // the path of the directory where the entry file lives
dependencyGraph: '', // the dependencies of the entry file
ast: '' // an abstract syntax tree created from the code in the file
}
Bundlers should be able to handle JavaScript or HTML files as the entry file but for simplicity, Maleta will only accept HTML files as the starting point. The function which creates the rootAsset
object is:
function createRootAssetFromEntryFile(file, config) {
rootAsset.content = fs.readFileSync(file, 'utf-8');
rootAsset.rootDir = getRootDir(file);
rootAsset.outDir = path.resolve('dist');
if (config.entryJsFile) {
rootAsset.ast = htmlParser(rootAsset.content);
rootAsset.entryJsFilePath = path.resolve(rootAsset.rootDir, config.entryJsFile);
} else {
extractEntryJSFilePathFromEntryFile(rootAsset);
}
rootAsset.dependencyGraph = createDependencyGraph(rootAsset.entryJsFilePath);
return rootAsset;
}
It receives the arguments passed into the bundler
function by the CLI tool. The only interesting activities occur in the htmlParser
, extractEntryJSFilePathFromEntryFile
and createDependencyGraph
functions. fs
and path
are node modules which are documented here and getRootDir
does what its name states. Note: Reading the file synchronously with fs.readFileSync
is not very performant as it a blocking call but we are not too worried about that at this moment.
When we call htmlParser
it receives the following content from our demo app:
<html>
<head>
<title>Hello, World</title>
</head>
<body>
<div id="app"></div>
<script src="./index.js"></script>
</body>
</html>
htmlParser
refers to the module posthtml-parser
, a tool for parsing and turning HTML into an abstract syntax tree (AST). Our npm command demo: maleta demo/index.html --entry index.js
helps us easily find the path to related entry JavaScript file. However, if the --entry
option is missing, we call extractEntryJSFilePathFromEntryFile
.
function extractEntryJSFilePathFromEntryFile(rootAsset) {
const parsedHTML = htmlParser(rootAsset.content);
rootAsset.ast = parsedHTML;
parsedHTML.walk = walk;
parsedHTML.walk(node => {
if (node.tag === 'script') {
if (node.attrs.src.endsWith('/index.js')) {
rootAsset.entryJsFilePath = path.resolve(rootAsset.rootDir, node.attrs.src)
}
}
return node;
});
if (!rootAsset.entryJsFilePath) throw Error('No JavaScript entry file has been provided or specified. Either specify an entry file or make sure the entry file is named \'index.js\'');
}
The only difference here is posthml
's walk
method which we have attached to the AST. We use it to traverse the tree and ensure the HTML file has a link to a JavaScript file called index.js
.
Building the dependency graph
Our graph will be an array of objects listing every module in the application. Each object will have:
- an
id
- the code from the module
- the original filename
- an array of the relative file paths of that module's dependencies
- an object with the ids of those same dependencies.
The first thing createDependencyGraph
does is create the main asset from the entry JavaScript file using this function:
function createJSAsset(filename) {
const content = fs.readFileSync(filename, 'utf-8');
const ast = babylon.parse(content, { sourceType: 'module' });
const relativeFilePathsOfDependenciesArray = [];
traverse(ast, {
ImportDeclaration({ node }) {
relativeFilePathsOfDependenciesArray.push(node.source.value)
},
CallExpression({ node }) {
const { callee, arguments: args } = node;
if (
callee.name === 'require' &&
args.length === 1 &&
args[0].type === 'StringLiteral'
) {
relativeFilePathsOfDependenciesArray.push(args[0].value)
}
}
})
const id = moduleID++;
const { code } = transformFromAstSync(ast, null, {
presets: ['@babel/env'],
cwd: __dirname
});
return {
id,
code,
filename,
relativeFilePathsOfDependenciesArray,
mapping: {}
}
}
babylon
is the same JavaScript parser used by babel. Its parse
method runs the given code as a JS program and in the second argument you pass an options object which tells it whether it is dealing with a module or script. Its output is an AST according to the babel AST format. We use it with the babel plugin traverse
(babel-traverse) to find all the dependency references. ImportDeclaration
finds all the ES module imports whilst CallExpression
searches for every function call expression, from which we can check if its being done with the require
keyword.
The next task is to parse the JavaScript code in the file. transformFromAstSync
is a method from the babel/core
module and it turns our AST into the final code which will run in the browser. It also creates a source map. In the config object it is important to set the working directory to maleta
otherwise any file paths will be resolved to whichever directory is running maleta, which in our case is aprender
.
Once the main asset has been created from the entry JavaScript file, it is assigned to the assetQueue
array for processing. This array is a queue which will eventually contain assets representing every JavaScript file in the application. The relationship between each asset and its dependencies is stored in an object called mapping
. Every property on this object is the file name of each dependency along with its id.
Creating the bundle
function createBundle(entryFile, config) {
let modules = '';
let bundle;
const rootAsset = createRootAssetFromEntryFile(entryFile, config);
const bundlePath = path.resolve(rootAsset.outDir, 'index.js');
const bundleHtml = htmlRender(rootAsset.ast);
const bundleHtmlPath = path.resolve(rootAsset.outDir, 'index.html');
// ...
}
createBundle
is the function used by our CLI to kickstart the bundling process. createRootAssetFromEntryFile
performs all the steps listed above and returns a rootAsset
object. From that, we create the file paths for the output files. We also use htmlRender
(which is actually posthtml-render
) to turn the AST we grabbed from the entry HTML file into a new HTML tree. The next step is to iterate over the dependency graph and create the bundled code like so:
function createBundle(entryFile, config) {
// ...
rootAsset.dependencyGraph.forEach(mod => {
modules += `${mod.id}: [
function (require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.mapping)},
],`;
});
bundle = `
(function(modules) {
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(name) {
return require(mapping[name]);
}
const module = { exports: {} };
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({${modules}})
`;
// ...
}
The bundle explained
The bundle is an immediately invoked function expression (IIFE), a JavaScript function that runs immediately as soon as it is defined. We assign it to the bundle
variable and then pass in the modules
object as the argument. Each module is an array with a function that executes code for that module as its first element and the module/dependency relationship as its second element.
The first thing the IIFE does is create a require
function which takes an id
as its only argument. In this function, we destructure the array and access the function and mapping object of each module. The modules will have require()
calls to relative file paths and some might make calls to the same file paths even though they are referring to different dependencies. We handle that by creating a dedicated local require
function which turns file paths into module ids.
For example, in our demo application the require(0)
call at the end of the IIFE results in the following:
function require(id) {
const [fn, mapping] = modules[id];
/* the value of fn */
function (require, module, exports) {
"use strict";
var aprender = require('../src/aprender');
var button = aprender.createElement('button', {
children: ['Click Me!']
});
var component = aprender.createElement('div', {
attrs: {
id: 'root-component'
},
children: ['Hello, world!', button]
});
var app = aprender.render(component);
aprender.mount(app, document.getElementById('app'));
}
/* the value of mapping */
{"../src/aprender": 1}
}
require('../src/aprender');
is really localRequire('../src/aprender')
. Internally, localRequire
makes this recursive call require(mapping['../src/aprender']
. mapping['../src/aprender']
returns the value 1
, which is the id
of the entry JavaScript file's only dependency. require(1)
returns:
function require(id) {
const [fn, mapping] = modules[id];
/* the value of fn */
function (require, module, exports) {
"use strict";
var createElement = require('./createElement');
var render = require('./render');
var mount = require('./mount');
module.exports = {
createElement: createElement,
render: render,
mount: mount
};
}
/* the value of mapping */
{"./createElement":2,"./render":3,"./mount":4}
}
Each time the code in our dependencies makes a require
call, it will be destructured in this way. The rest of the code in the bundler IIFE is:
function localRequire(name) {
return require(mapping[name]);
}
const module = { exports: {} };
fn(localRequire, module, module.exports);
return module.exports;
localRequire
wraps the recursive call we explained above and fn(localRequire, module, module.exports)
executes the function we destructured at the beginning of the require
function. All the exports from the dependencies of module in question will be stored in the module
object. In our demo application, createElement
, render
and mount
all export functions and an object with all these exports is value of the aprender
module.
Serving the bundle
Once the bundle is ready, we create an output directory, create the index.js
and index.html
files for the demo application and then serve them using http
and serve-static
.
function createBundle(entryFile, config) {
//...
// create the output directory if it does not exist
if (!fs.existsSync(rootAsset.outDir)) {
fs.mkdirSync(rootAsset.outDir);
}
// create output html and js files
fs.writeFileSync(bundlePath, bundle);
fs.writeFileSync(bundleHtmlPath, bundleHtml);
// create server and serve files
const serve = serveStatic(rootAsset.outDir);
const server = http.createServer( function onRequest(req, res) {
serve(req, res, finalhandler(req, res));
});
server.listen(3000);
console.log(`${chalk.bold('Now serving the application on')} ${chalk.red('http://localhost:3000')}`);
}
Summary
The bundler we created is by no means perfect and no doubt contains many holes and candidates for improvement. However, it is functional and that is the most important thing. We have reached a stage in our project where we can view our application in a browser. In the next article, we will return to our UI framework and add the functionality which allow us to create more complicated demo application.
Top comments (0)