Versioned asset paths with RequireJS

logo

While we were using RequireJS in a project we stumbled across a few problems. One of those problems was getting our assets loaded through a versioned path. Each iteration we upgrade our asset version so the cache is busted after a release, to do this we simply set a parameter in config.yml in symfony.

//app/config/config.yml
parameters:
    asset_version: 1

The desired result would be an url like this http://hostname/v1/assets/js/vendor/bootstrap/dist/js/bootstrap.min.js. We use grunt to prepare and copy all third party libraries so we don't clutter the web folder. A small entry in your nginx vhost configuration file is needed in order to convert v1.

# Asset versioning
location ~ ^/v([0-9]+)/(.*)$ {
    try_files $uri $uri/ /$2;
}

This will point to the real location where our assets can be found: <project>/web/assets/js/vendor/bootstrap/... So far so good, our nginx can understand the asset version and we have a parameter in symfony that can be modified when we need to bust the cache.

RequireJS and Twig

We have a separate twig template to setup requirejs, this allows us to load a specific module on a page without repeating ourself. More on that later on. This has several changes to it that differ from a basic setup as given by example in the requirejs docs.

<script src="js/lib/require.js"></script>
<script>
    //Load common code that includes config, then load the app
    //logic for this page. Do the requirejs calls here instead of
    //a separate file so after a build there are only 2 HTTP
    //requests instead of three.
    requirejs(['./js/common'], function (common) {
        //js/common sets the baseUrl to be js/ so
        //can just ask for 'app/main2' here instead
        //of 'js/app/main2'
        requirejs(['app/main2']);
    });
</script>

After loading the initial require.js file, the main configuration is loaded that resides in ./js/common. When this is loaded all following require statements are given, this will ensure that the configuration is loaded before any other module calls require on a library/module that it needs.

When working on larger projects, a setup like this might not be desirable. You might include a search.html.twig template that does not extend from the main layout file but also requires some javascript to be loaded. This will result in multiple require calls spread out over your page. When using versioned assets or you want to inject configuration into requirejs, you don't want to duplicate code by including the _requirejs.html.twig multiple times.

The main problem with using multiple require calls is that RequireJS loads files asynchronously. As stated in the docs: http://requirejs.org/docs/api.html#data-main RequireJS does not guaranty that your configuration file (common.js in this case) will be loaded before any other require calls are executed.

This caused random javascript errors in our project, we decided to work around the problem by NOT using requireJS to load the configuration. Instead we defined it in a config.js file which is then loaded into the page using a separate <script> tag. We can inject any variable into the global scope before loading the configuration file so we can set the base path there.

This is our requirejs template in twig that will load all files in the following order:

  • require.min.js
  • set variables
  • load config.js (this uses the variables set in the previous step)
  • load all the remaining modules you require.

// _requirejs.twig.html
<script src="{{ asset('assets/js/vendor/requirejs/require.min.js') }}"></script>
<script>
    var version = '{{ asset_version }}';
    var basePath = '{{ app.request.basePath }}';

</script>
<script src="{{ asset('assets/js/dist/config.js') }}"></script>
<script>
    {% if module is iterable %}
    require(['app/default', '{{ module|join("', '")|raw }}']);
    {% elseif module is defined %}
    require(['app/default', '{{ module }}']);
    {% else %}
    require(['app/default']);
    {% endif %}
</script>

The config.js file will look like this:

// config.js
(function () {
    require.config({
    baseUrl: window.basePath + '/v' + window.version + '/assets/js/dist',
        map: {
            ...
        },
})();

If you are not using r.js optimizer you now have versioned assets in project.

A full example can be seen below along with part of our config.js file in the next section.



{% block js_extra_top %} {{ include('common/_requirejs.html.twig', { module: ['sylius-form-collection'] }) }} {% endblock js_extra_top %} // some code here .. <script> require(['app/other'], function (OtherClass) { var other = new OtherClass(...); }); </script> // more code here ..

R.js optimizer

We use the optimizer to prepare our entire RequireJS setup. When injecting variables into the config.js you wil get the following error while compiling:

Running "requirejs:development" (requirejs) task
{ [Error: Error: The config in mainConfigFile /<path>/var/tmp/assets/js/src/config.js cannot be used because it cannot be evaluated correctly while running in the optimizer. Try only using a config that is also valid JSON, or do not use mainConfigFile and instead copy the config values needed into a build file or command line arguments given to the optimizer.
Source error from parsing: /<path>/var/tmp/assets/js/src/config.js: ReferenceError: window is not defined
    at /<path>/node_modules/requirejs/bin/r.js:28837:27
]
  originalError: 
   [Error: The config in mainConfigFile /<path>/var/tmp/assets/js/src/config.js cannot be used because it cannot be evaluated correctly while running in the optimizer. Try only using a config that is also valid JSON, or do not use mainConfigFile and instead copy the config values needed into a build file or command line arguments given to the optimizer.
   Source error from parsing: /<path>/var/tmp/assets/js/src/config.js: ReferenceError: window is not defined] }

There is a simple, although dirty, workaround for this. The optimizer only reads the first require.config tag, so all you have to do is append a second config tag below and everything will compile normally.

// config.js
(function () {
    // first config
    require.config({
        map: {
            ...
        },
    })

    // second config
    require.config({
        baseUrl: window.basePath + '/v' + window.version + '/assets/js/dist',
    });
})();

Now you are all setup to use versioned assets in your project with RequireJS.

Our optimizer config

As a bonus here is our grunt r.js optimizer file, it has some pathing setup because all the source files, including third party libraries are stored outside the webroot.


module.exports = { options: { optimizeCss: false, useStrict: false, keepBuildDir: false, preserveLicenseComments: false, findNestedDependencies: true, //skipSemiColonInsertion: false, normalizeDirDefines: 'all', skipModuleInsertion: true, optimizeAllPluginResources: true, skipDirOptimize: false, logLevel: 0, mainConfigFile: '<%= pkg.config.tmp %>/js/src/config.js', appDir: '<%= pkg.config.tmp %>/js/src', dir: '<%= pkg.config.tmp %>/js/build', baseUrl: '../src', removeCombined: true, paths: { app: "app", admin: "admin", common: "common", config: "config", jquery: "../vendor/jquery/dist/jquery.min" } }, production: { options: { optimize: 'uglify2', generateSourceMaps: false, uglify2: { output: { beautify: false }, compress: { sequences: false, unused: false, drop_debugger: true, drop_console: true, global_defs: { DEBUG: false } }, warnings: true, mangle: false } } }, development: { options: { optimize: 'uglify2', generateSourceMaps: true, uglify2: { output: { beautify: false }, compress: { sequences: false, unused: false, drop_debugger: false, drop_console: false, global_defs: { DEBUG: true } }, warnings: true, mangle: false } } } }