Create individual SPA bundles with Webpack

后端 未结 3 1880
南旧
南旧 2020-12-23 11:39

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

相关标签:
3条回答
  • 2020-12-23 12:11

    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

    <!DOCTYPE html>
    <html xmlns="http://www.w3.org/1999/xhtml">
        <head>
            <title></title>
    
            <script type="text/javascript" src="http://code.jquery.com/jquery-1.11.3.min.js"></script>
            <script type="text/javascript" src="build/app-bundle.js"></script>
        </head>
        <body>
            <h1>Hello there!</h1>
            <h2>Sub heading</h2>
    
            <h3>Module content below</h3>
            <div id="mainContent"></div>
        </body>
    </html>
    

    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

    <h1>Content A</h1>
    
    <a class="aSel">Click me for a message</a>
    

    contentB.js

    module.exports = {
        selector: '.bSel',
        onClick: function(){ alert('Hello from B') }
    };
    

    contentB.htm

    <h1>Content B</h1>
    
    <a class="bSel">Click me for a message</a>
    

    contentC.js

    module.exports = {
        selector: '.cSel',
        onClick: function(){ alert('Hello from C') }
    };
    

    contentC.htm

    <h1>Content C</h1>
    
    <a class="cSel">Click me for a message</a>
    

    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

    <h1>CONTACTS MODULE</h1>
    
    <div id="contacts-content-loader">
        <a class="loader" data-name="contentA">Load A</a>
        <a class="loader" data-name="contentB">Load B</a>
        <a class="loader" data-name="contentC">Load C</a>
    </div>
    
    <div id="dynamicPane">
        Nothing loaded yet
    </div>
    

    Lastly, the final default.htm

    default.htm

    <!DOCTYPE html>
    <html xmlns="http://www.w3.org/1999/xhtml">
        <head>
            <title></title>
    
            <script type="text/javascript" src="http://code.jquery.com/jquery-1.11.3.min.js"></script>
            <script type="text/javascript" src="build/app-bundle.js"></script>
        </head>
        <body>
            <h1>Hello there!</h1>
            <h2>Sub heading</h2>
    
            <a href="#contacts">Load contacts</a>
            <br><br>
            <a href="#tasks">Load tasks</a>
    
            <h3>Module content below</h3>
            <div id="mainContent"></div>
        </body>
    </html>
    
    0 讨论(0)
  • 2020-12-23 12:20

    I think require.ensure might be the key here. That will allow you to set up split points for dynamic loading. You would connect it with the router of your SPA in some way. Here's the basic idea from Pete Hunt: https://github.com/petehunt/webpack-howto#9-async-loading .

    0 讨论(0)
  • 2020-12-23 12:32

    webpack creates a split point per async require statement (require.ensure or AMD require([])). So you need to write a require([]) per lazy-loaded part of your app.

    Your SPA only has a single entry point: the (client-side) router. Let's call it app.js. The pages are loaded on demand and ain't entry points.

    webpack.config.js:

    module.exports = {
        entry: {
            app: './app'
        },
        output: {
            path: path.resolve(__dirname, 'build'),
            filename: '[name]-bundle.js'
        }
    }
    

    app.js:

    var mod = window.location.hash.split('/')[0].toLowerCase();
    alert(mod);
    
    switch(mod) {
        case "contacts":
            require(["./pages/contacts"], function(page) {
                // do something with "page"
            });
            break;
        case "tasks":
            require(["./pages/tasks"], function(page) {
                // do something with "page"
            });
            break;
    }
    

    Alternative: Using a "context".

    When using a dynamic dependency i. e require("./pages/" + mod) you can't write a split point per file. For this case there is a loader that wrapps a file in a require.ensure block:

    app.js

    var mod = window.location.hash.split('/')[0].toLowerCase();
    alert(mod);
    
    require("bundle!./pages/" + mod)(function(page) {
        // do something with "page"
    });
    

    This is webpack-specific. Don't forget to npm install bundle-loader --save. Check the correct casing, it's case-sensitive.

    0 讨论(0)
提交回复
热议问题