How do I use Webpack to create independent SPA bundles that may or may not be loaded on the fly as my user navigates my SPA?
I have a contacts module, and a tasks mo
I've worked through a bit of this and wanted to post my work here for the benefit of others.
The premise is a web app consisting of a single page, with certain framework utilities loaded initially, with all subsequent sections of the app loaded on demand as the user navigates and changes the url hash.
The proof of concept app.js framework/entry point looks like this
app.js
var framework = require('./framework/frameworkLoader');
window.onhashchange = hashChanged;
hashChanged(); //handle initial hash
function hashChanged() {
var mod = window.location.hash.split('/')[0].replace('#', '');
if (!mod) return;
framework.loadModule(mod, moduleLoaded, invalidModule);
function moduleLoaded(moduleClass, moduleHtml){
//empty to remove handlers previously added
$('#mainContent').empty();
$('#mainContent').html(moduleHtml);
var inst = new moduleClass();
inst.initialize();
}
function invalidModule(){
alert('Yo - invalid module');
}
};
Obviously the point of interest is framework.loadModule(mod, moduleLoaded, invalidModule);. As Tobias said, there must be separate, stand-alone AMD-style require statements (I believe there's a CommonJS alternative, but I haven't explored that) for EACH possibility. Obviously nobody would want to write out each possibility for a large application, so my presumption is that some sort of simple node task would exist as part of the build process to navigate the app's structure, and auto-generate all of these require statements for each module for you. In this case the assumption is that each folder in modules contains a module, the main code and html for which are in eponymously-named files. For example, for contacts the module definition would be in modules/contacts/contacts.js and the html in modules/contacts/contacts.htm.
I just manually wrote out this file since having Node navigate folders and file structures, and output new files is trivially easy.
frameworkLoader.js
//************** in real life this file would be auto-generated*******************
function loadModule(modName, cb, onErr){
if (modName == 'contacts') require(['../modules/contacts/contacts', 'html!../modules/contacts/contacts.htm'], cb);
else if (modName == 'tasks') require(['../modules/tasks/tasks', 'html!../modules/tasks/tasks.htm'], cb);
else onErr();
}
module.exports = {
loadModule: loadModule
};
With the rest of the files:
webpack.config.js
var path = require('path');
var webpack = require('webpack');
module.exports = {
entry: {
app: './app'
},
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name]-bundle.js',
publicPath: '/build/',
}
};
And the main html file
default.htm
Hello there!
Sub heading
Module content below
The next step is adding ad hoc dependencies to these modules. Unfortunately adding a require(['dep1', 'dep2'], function(){ doesn't quite work as I would have hoped; that eagerly chases down all dependencies in the list, and bundles them all with the module in question, rather than loading them on demand. This means that if both the contacts and tasks modules require the same dependency (as they're about to) both modules with have that entire dependency bundled in, causing it to be loaded and reloaded as the user browses to contacts and then tasks.
The solution is the bundle loader npm install bundle-loader --save. This allows us to do require('bundle!../../libs/alt') which returns a function that when called fetches our dependency. The function takes as argument a callback which accepts our newly loaded dependency. Obviously loading N dependencies like this will require unpleasant code to wrangle the N callbacks, so I'll build in Promise support in just a moment. But first to update the module structure to support dependency specification.
contacts.js
function ContactsModule(){
this.initialize = function(alt, makeFinalStore){
//use module
};
}
module.exports = {
module: ContactsModule,
deps: [require('bundle!../../libs/alt'), require('bundle!alt/utils/makeFinalStore')]
};
tasks.js
function TasksModule(){
this.initialize = function(alt){
//use module
};
}
module.exports = {
module: TasksModule,
deps: [require('bundle!../../libs/alt')]
};
Now each module returns an object literal with the module itself, and the dependencies it needs. Obviously it would have been nice to just write out a list of strings, but we need the require('bundle! calls right there so Webpack can see what we need.
Now to build in Promise support to our main app.js
app.js
var framework = require('./framework/frameworkLoader');
window.onhashchange = hashChanged;
hashChanged(); //handle initial hash
function hashChanged() {
var mod = window.location.hash.split('/')[0].replace('#', '');
if (!mod) return;
framework.loadModule(mod, moduleLoaded, invalidModule);
function moduleLoaded(modulePacket, moduleHtml){
var ModuleClass = modulePacket.module,
moduleDeps = modulePacket.deps;
//empty to remove handlers previous module may have added
$('#mainContent').empty();
$('#mainContent').html(moduleHtml);
Promise.all(moduleDeps.map(projectBundleToPromise)).then(function(deps){
var inst = new ModuleClass();
inst.initialize.apply(inst, deps);
});
function projectBundleToPromise(bundle){
return new Promise(function(resolve){ bundle(resolve); });
}
}
function invalidModule(){
alert('Yo - invalid module');
}
};
This causes separate individual bundle files to be created for contacts, tasks, alt, and makeFinalStore. Loading tasks first shows the bundle with the tasks module, and the bundle with alt loading in the network tab; loading contacts after that shows the contacts bundle loading along with the with the makeFinalStore bundle. Loading contacts first shows contacts, alt, and makeFinalStore bundles loading; loading tasks after that shows only tasks bundle loading.
Lastly, I wanted to extend the contacts module so that it would support its own ad hoc dynamic loading. In real life a contacts module might load on the fly the contact's billing information, contact information, subscription information, and so on. Obviously this proof of concept will be more simple, bordering on silly.
Under the contacts folder I created a contactDynamic folder, with the following files
contentA.js
contentA.htm
contentB.js
contentB.htm
contentC.js
contentC.htm
contentA.js
module.exports = {
selector: '.aSel',
onClick: function(){ alert('Hello from A') }
};
contentA.htm
Content A
Click me for a message
contentB.js
module.exports = {
selector: '.bSel',
onClick: function(){ alert('Hello from B') }
};
contentB.htm
Content B
Click me for a message
contentC.js
module.exports = {
selector: '.cSel',
onClick: function(){ alert('Hello from C') }
};
contentC.htm
Content C
Click me for a message
The updated code for contacts.js is below. Some things to note. We're building dynamic contexts ahead of time so we can exclude files appropriately. If we don't, then our dynamic require with bundle! will fail when it gets to the html files; our context limits files to *.js. We also create a context for .htm files—note that we're using both the bundle! and html! loaders together. Also note that order matters - bundle!html! works but html!bundle! causes these bundles to not get built, and I'm hoping someone can comment as to why. But as is, separate bundles are created for each individual .js, and .htm file, and are loaded on demand only when needed. And of course I'm wrapping the bundle! calls in Promises as before.
Also, I understand that the ContextReplacementPlugin can be used instead of these contexts, and I'm hoping someone can show me how: is an instance of ContextReplacementPlugin passed into a dynamic require?
contacts.js
function ContactsModule(){
this.initialize = function(alt, makeFinalStore){
$('#contacts-content-loader').on('click', '.loader', function(){
loadDynamicContactContent($(this).data('name'));
});
};
}
function loadDynamicContactContent(name){
var reqJs = require.context('bundle!./contactDynamic', false, /.js$/);
var reqHtml = require.context('bundle!html!./contactDynamic', false, /.htm$/);
var deps = [reqJs('./' + name + '.js'), reqHtml('./' + name + '.htm')];
Promise.all(deps.map(projectBundleToPromise)).then(function(deps){
var code = deps[0],
html = deps[1];
$('#dynamicPane').empty().html(html);
$('#dynamicPane').off().on('click', code.selector, function(){
code.onClick();
});
});
}
function projectBundleToPromise(bundle){
return new Promise(function(resolve){ bundle(resolve); });
}
module.exports = {
module: ContactsModule,
deps: [require('bundle!../../libs/alt'), require('bundle!alt/utils/makeFinalStore')]
};
contacts.htm
CONTACTS MODULE
Nothing loaded yet
Lastly, the final default.htm
default.htm
Hello there!
Sub heading
Load contacts
Load tasks
Module content below