[Article Sections]
Deferring JavaScript and CSP 3 strict-dynamic
- Defer all JavaScript until after the
DOMContentLoaded
or windowonload
event.- Implement a deferral routine for the development cycle.
- Implement a deferral routine for deployment/production.
- Use CSP 3
strict-dynamic
to prevent JavaScript from being compromised by an attacker.- Bust any browser cache of a previous version of the JavaScript for returning site visitors.
DOM construction
A quick review of DOM construction mileposts is good before proceeding:
domInteractive
: The HTML is parsed and the DOM is constructed.DOMContentLoaded
: This followsdomInteractive
, and occurs after the CSS is parsed and the CSSOM is constructed.onload
: This followsdomInteractive
andDOMContentLoaded
, and occurs after all document resources (e.g., images) have finished loading.
A more complete picture of DOM and window loading events is provided here1:
Get JavaScript out of your <head>
JavaScript is render-blocking. The browser halts HTML parsing when it encounters JavaScript, instead reading and executing the JavaScript before resuming to parse the HTML. Because JavaScript typically operates on DOM elements and interacts with their CSSOM properties, it makes little sense to execute JavaScript before the DOM and CSSOM are constructed. Therefore, unless the JavaScript expedites the initial navigation of the critical rendering path (e.g., preloading fonts or other critical resources, etc.), it should be deferred until after the <head>
is executed and the <body>
is fully parsed, loaded, and ready.
The take-away: Except in special circumstances, always put JavaScript at the end of the <body>
element.
Don’t depend on the async or defer <script> attributes
The async
<script>
attribute provides immediate parallel downloading of scripts. It does not guarantee in what order they will be executed, nor a way to indicate when they should be executed, as execution of each script is immediate upon download.
The defer
<script>
attribute guarantees that scripts will be executed in the order they occur in the HTML, and it moves their execution to a point after domInteractive
but before the DOMContentLoaded
event. There is no way to prescribe a different execution point for a defer
<script>
, such as after the DOMContentLoaded
event or after the window onload
event.2
An illustration of the difference between async
and defer
is found in async vs defer attributes by Daniel Imms:
Depending on your JavaScript libraries and original routines, you may need a specific execution point for your scripts. If any script assumes document resources are available, it should be deferred until after the window onload
event. But if any script has a window onload
event, it should be loaded earlier, perhaps after DOMContentLoaded
, or else it won’t execute. A JavaScript deferral technique with granular control is desirable.
A single JavaScript resource file for all
To ensure all scripts are loaded in order and to minimize resource fetching, the best practice is to concatenate all scripts into a single file. This requires only one <script>
resource for retrieval during page rendering. (If you are performing code splitting, the main script that contains the triggers for the loading of page-specific libraries would be loaded as the main resource, with additional dependencies loaded by it as the visitor navigates the site. The routines described in this article harmonize with the requirements for code-splitting.3)
For site deployment, a minified version of each JavaScript library should be used, and any original JavaScript routines of your own should be minified with the Google Closure Compiler or a similar minifier, with all scripts combined in one file. But during development, it’s a different story: different libraries may need to be tested, and your original routines may need modification as they and the site are developed.
The scripts—minified for production or human-readable for development—should be loaded without disrupting the browser’s parsing of the rest of the HTML, and the scripts should be executed after the desired page construction event (e.g., DOMContentLoaded
or window onload
), otherwise the results of the scripts may differ between development and deployment.
A simple technique may be used both for development and deployment, with a subtle coding difference between the two. We’ll start with the deployment technique and work backwards to the development technique.
Deployment JavaScript loading: Basic form
Below is the loading script for deployment, the only script element needed. It loads a single JavaScript file, the concatenation of all minified JavaScript for the page, here named defer.min.js (any name or directory location will do). There will be two modifications to this routine discussed later, so don’t copy this code yet. Even so, the basic form presented here should be understood, here using the DOMContentLoaded
event as the trigger:
(function () { 'use strict'; function deferJS() { var script = document.createElement('script'); var fragment = document.createDocumentFragment(); // append script to fragment first: fragment.appendChild(script); // edit its attributes while attached to fragment: script.src = 'defer.min.js'; script.crossOrigin = 'anonymous'; // appending the fragment destroys the fragment while attaching the script: document.body.appendChild(fragment); } // document.currentScript cannot be used in a callback, so provide it here: function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; script.parentNode.removeChild(script); } // call deferJS when DOMContentLoaded is fired: if (window.addEventListener) { window.addEventListener('DOMContentLoaded', deferJS, false); } else if (document.onreadystatechange) { document.onreadystatechange = function () { if (document.readyState === 'interactive') { deferJS(); } }; } else { window.onload = deferJS; } // lastly, remove this script: selfDestruct(); }());
(function () { 'use strict'; function deferJS() { var script = document.createElement('script'); var fragment = document.createDocumentFragment(); // append script to fragment first: fragment.appendChild(script); // edit its attributes while attached to fragment: script.src = 'defer.min.js'; script.crossOrigin = 'anonymous'; // appending the fragment destroys the fragment while attaching the script: document.body.appendChild(fragment); } // document.currentScript cannot be used in a callback, so provide it here: function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; script.parentNode.removeChild(script); } // call deferJS when DOMContentLoaded is fired: if (window.addEventListener) { window.addEventListener('DOMContentLoaded', deferJS, false); } else if (document.onreadystatechange) { document.onreadystatechange = function () { if (document.readyState === 'interactive') { deferJS(); } }; } else { window.onload = deferJS; } // lastly, remove this script: selfDestruct(); }());
If you wanted to use the window onload
event as the trigger, the event listener would be:
if (window.addEventListener) { window.addEventListener('load', deferJS, false); } else if (document.onreadystatechange) { document.onreadystatechange = function () { if (document.readyState === 'complete') { deferJS(); } }; } else { window.onload = deferJS; }
if (window.addEventListener) { window.addEventListener('load', deferJS, false); } else if (document.onreadystatechange) { document.onreadystatechange = function () { if (document.readyState === 'complete') { deferJS(); } }; } else { window.onload = deferJS; }
...but so many JavaScript libraries make use of the window onload
event for triggering actions that it is probably safer to use the DOMContentLoaded
event as the trigger. The window onload
event code above is therefore provided for reference.
But what does the code do? The function deferJS
creates an original <script>
element and appends it to a document fragment. This is done so that individual edits to the <script>
element do not disturb the main DOM (this is a practice essential to follow when creating elements that might cause a forced reflow, something that is unlikely to obtain here, but still a best practice to follow). The function then sets the src
of the <script>
to defer.min.js, and then the function appends the fragment to the DOM. The fragment is destroyed during the append, leaving only the appended <script>
, which then automatically executes. Finally, the entire script performing this work (i.e., this script, not the appended <script>
element) self-destructs. (There’s no need to leave a script attached to the DOM once it's been executed and stored in the browser’s resources.)
This deferJS
function is invoked by an event listener selected in a conditional block at the bottom of the code. The first condition targets the DOMContentLoaded
event, the next performs the equivalent action by using a readyState
detector, and the last employs a fallback to the window onload
event if nothing else obtains.
Again, though, there are lines of code purposely left out of this routine for now. We’ll explain their utility and add them in later, so don’t copy this code yet. Next we must take a tour through site security policies for JavaScript.
Site security, CSP, and JavaScript
If you do not prevent a malicious attacker from tampering with your JavaScript or injecting rogue scripts, your site visitors are at risk. Setting a CSP (Content Security Policy) is the best line of defense. Sent as a header from the server, a good CSP limits what may be executed in your site’s code.
In CSP level 2, the script-src
directive prevents all inline scripts from executing (except those with nonces, discussed later) and allows only scripts with whitelisted SRCs to execute. For example:
script-src 'self' https://fonts.googleapis.com/ https://code.jquery.com/jquery-3.2.1.min.js;
script-src 'self' https://fonts.googleapis.com/ https://code.jquery.com/jquery-3.2.1.min.js;
This policy would allow scripts from the same domain, fonts.googleapis.com, and the specific script jquery-3.2.1.min.js at code.jquery.com to execute.
But there’s a problem here. If any of the scripts generate new scripts or execute eval()
, those descendant routines will fail to execute because they are not whitelisted. The directive unsafe-eval
is required for this, which isn’t much protection:
script-src 'self' 'unsafe-eval' https://fonts.googleapis.com/ https://code.jquery.com/jquery-3.2.1.min.js;
script-src 'self' 'unsafe-eval' https://fonts.googleapis.com/ https://code.jquery.com/jquery-3.2.1.min.js;
Also, if an attacker compromised any of the named scripts in the CSP, then they would be loaded into your site with no questions. Finally, no amount of whitelisting can account for the trivial ways that the policy can be circumvented. Google wrote extensively on this problem and detailed its findings in an exhaustive paper on the subject4, which concludes with the CSP level 3 solution.
CSP level 3: strict-dynamic to the rescue
The CSP level 3 solution to this problem is the strict-dynamic
directive. It instructs the browser to execute parent scripts and their descendant scripts and processes when you have marked those parent scripts as safe. Put another way, trust on descendant scripts and processes is communicated to the browser by trust conferred on their parents. This solves the whitelisting problem. It makes hosting JavaScript libraries on your own domain (or, as we are doing, including them in full, with security hashes, in a single script hosted on your site) paramount to security, as you must ensure the integrity of the parent script(s) and top-level scripts they may call.
Trust is communicated to the browser by use of (1) a nonce or (2) a cryptographic hash of the script (or scripts) itself. Nonces are appropriate for dynamically generated HTML. Script hashes are the only option for static HTML. We’ll cover nonces first.
Security by nonces
Nonce is short for “Number used ONCE,” and should be a pseudo-random number subjected to a SHA-256 hashing, e.g. qR+MkZw0/j/V7lHh193p7rw=
. (The hashing merely provides obfuscation to prevent predictability by an attacker.) The nonce must be generated anew every time the site is accessed, which is why nonces are perfect when serving dynamic HTML (e.g., HTML generated by JavaScript in a single-page application) but not tenable when serving static HTML (i.e., an unchanging page of HTML).
When generating the HTML dynamically, create an identical nonce for the CSP header and for the <script>
elements in the HTML. Issue the nonce in the CSP header sent by the server and write that nonce to each <script>
element in the HTML. For example, provided a random nonce qR+MkZw0/j/V7lHh193p7rw=
, then within the CSP strict-dynamic
header it would appear this way:
Header set Content-Security-Policy script-src 'strict-dynamic' 'nonce-qR+MkZw0/j/V7lHh193p7rw=';
Header set Content-Security-Policy script-src 'strict-dynamic' 'nonce-qR+MkZw0/j/V7lHh193p7rw=';
And within each SCRIPT element of your HTML:
<script nonce=qR+MkZw0/j/V7lHh193p7rw=> console.log('This site is safe!'); </script>
<script nonce=qR+MkZw0/j/V7lHh193p7rw=> console.log('This site is safe!'); </script>
Security by script hash
The alternative to nonces—and the only viable strict-dynamic
solution for sites serving static HTML—is a SHA hash of the script itself. With this method, you determine and employ either the SHA-256, SHA-384, or SHA-512 of the entire script. The hash does not appear in the HTML but is issued in the CSP header only.
The SHA-256 hash of the loader script in the Deployment JavaScript loading: Basic form section above is zZitUk0rqSW/E1TNzz0BsyzeEsOu6sZqTB1SvC7NZw8=
. This CSP issues it with a strict-dynamic
directive:
Header set Content-Security-Policy script-src 'strict-dynamic' 'sha256-zZitUk0rqSW/E1TNzz0BsyzeEsOu6sZqTB1SvC7NZw8=';
Header set Content-Security-Policy script-src 'strict-dynamic' 'sha256-zZitUk0rqSW/E1TNzz0BsyzeEsOu6sZqTB1SvC7NZw8=';
The <script>
element itself requires no matching attribute; the browser will compute the SHA of the script, compare it with the SHA in the policy, and execute the script only if the SHAs match. Any number of SHAs may be listed, each corresponding to a separate <script>
element in the HTML. However, with our method, only one <script>
element is needed, so only one SHA is required.
NB: Don't employ the SHA hash provided above on a block-copy of the script above; recalculate it provided your code formatting (e.g., if the code follows line-breaks within the <script>
tag, then the line-break(s) will need to be part of the SHA hash).
CSP for browsers without CSP level 3 support
This policy, appropriately edited, should serve older browsers without CSP 3 support5:
Header set Content-Security-Policy script-src 'strict-dynamic' 'unsafe-inline' ['unsafe-eval'] https: http: ['sha256-{random}' | 'nonce-{random}'];
Header set Content-Security-Policy script-src 'strict-dynamic' 'unsafe-inline' ['unsafe-eval'] https: http: ['sha256-{random}' | 'nonce-{random}'];
This means that when strict-dynamic
is not available:
https:
andhttp:
confer trust to those protocolsunsafe-inline
confers trust to inline scripts (<script>
elements).unsafe-eval
allows execution ofeval()
in the absence ofstrict-dynamic
.
SRI to secure the deferred script
Regardless of whether you’re using nonces or a script hash, there’s one last security problem for our deferJS
script at this stage: the SRC
script itself within the deferJS
function isn’t secure. If an attacker compromised defer.min.js, then our security measures so far would fail, as we've instructed the browser to trust all descendant processes of the <script>
. That would mean the compromised defer.min.js script would be executed, and our site would be breached. To solve this, force a Sub-Resource Integrity (SRI) check on defer.min.js.
First calculate the SHA of defer.min.js itself (not the deferJS
<script>
element). Then add that SHA as an integrity
attribute of the defer.min.js <script>
element created by the deferral routine:
(function () { 'use strict'; function deferJS() { var script = document.createElement('script'); var fragment = document.createDocumentFragment(); fragment.appendChild(script); script.src = 'defer.min.js'; script.crossOrigin = 'anonymous'; // add integrity to attributes: script.integrity = 'sha256-pL+VBoR3r4z7O6cNLVKu/K4/GfpYmSyDivzzk4qB72E='; document.body.appendChild(fragment); } function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; script.parentNode.removeChild(script); } if (window.addEventListener) { window.addEventListener('DOMContentLoaded', deferJS, false); } else if (document.onreadystatechange) { document.onreadystatechange = function () { if (document.readyState === 'interactive') { deferJS(); } }; } else { window.onload = deferJS; } selfDestruct(); }());
(function () { 'use strict'; function deferJS() { var script = document.createElement('script'); var fragment = document.createDocumentFragment(); fragment.appendChild(script); script.src = 'defer.min.js'; script.crossOrigin = 'anonymous'; // add integrity to attributes: script.integrity = 'sha256-pL+VBoR3r4z7O6cNLVKu/K4/GfpYmSyDivzzk4qB72E='; document.body.appendChild(fragment); } function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; script.parentNode.removeChild(script); } if (window.addEventListener) { window.addEventListener('DOMContentLoaded', deferJS, false); } else if (document.onreadystatechange) { document.onreadystatechange = function () { if (document.readyState === 'interactive') { deferJS(); } }; } else { window.onload = deferJS; } selfDestruct(); }());
Doing this ensures that your script is the one loaded and executed, not a replacement script by an attacker. And again, this is for the deployment version of the deferJS
script, not the development version, which we’ll cover in a bit.
But there’s one last thing to implement: cache-busting.
Cache-bust your deferred script
A visitor’s browser may cache the defer.min.js script, using the cached version on repeat visits, thus preventing your own revisions to your JavaScript from being employed. Using a time-stamp query string to thwart this is not advised6. A better solution is autoVersioning: retrieving the time stamp of the file and rewriting the file name to include the time stamp before the extension, like this:
defer.min.20170912122907.js
defer.min.20170912122907.js
A server-side routine is needed to generate this. A sample PHP routine for this task would be:
function autoVersion($url) { // Set $external_site = TRUE if the resource is not on the localhost or parent domain; // write original code for that HERE. // If we're retrieving static content from an external site: if ($external_site) { $headers = get_headers($url, 1); if ($headers && (strpos($headers[0], '200') !== FALSE)) { $time = strtotime($headers['Last-Modified']); $date = date("YmdHis", $time); } else { $date = '19990221125549'; } // Otherwise, we're retrieving it from the dynamic server or from the local server: } else { $date = date("YmdHis", filemtime($url)); } // Add date & time stamp to resource name: https://www.example.com/js/defer.min.20160221212802.js $name = explode('.', $url); $extension = array_pop($name); array_push($name, $date, $extension); $fullname = implode('.', $name); return $fullname; }
function autoVersion($url) { // Set $external_site = TRUE if the resource is not on the localhost or parent domain; // write original code for that HERE. // If we're retrieving static content from an external site: if ($external_site) { $headers = get_headers($url, 1); if ($headers && (strpos($headers[0], '200') !== FALSE)) { $time = strtotime($headers['Last-Modified']); $date = date("YmdHis", $time); } else { $date = '19990221125549'; } // Otherwise, we're retrieving it from the dynamic server or from the local server: } else { $date = date("YmdHis", filemtime($url)); } // Add date & time stamp to resource name: https://www.example.com/js/defer.min.20160221212802.js $name = explode('.', $url); $extension = array_pop($name); array_push($name, $date, $extension); $fullname = implode('.', $name); return $fullname; }
This covers what to do for your HTML, whether generated dynamically or statically. The next step is to instruct your server to use a RegEx to strip out the time stamp from qualifying files. An example rewrite rule for an Apache server configuration or .htaccess file would be:
# Strip out numeric time stamp from CSS, JS, JSON, and ICO files: RewriteEngine on RewriteRule ^((ftp|http|https):\/\/)?(.*)\.([\d]+)?\.(css|js|json|ico)$ $1$3.$5 [L] # Strip out the time stamp
# Strip out numeric time stamp from CSS, JS, JSON, and ICO files: RewriteEngine on RewriteRule ^((ftp|http|https):\/\/)?(.*)\.([\d]+)?\.(css|js|json|ico)$ $1$3.$5 [L] # Strip out the time stamp
This will allow the deferJS
script to fetch the most recent version of defer.min.js.
Loading JavaScript on the deployment/production site
Putting it all together, the deferJS
script for your deployment site would be similar to:
(function () { 'use strict'; function deferJS() { var script = document.createElement('script'); var fragment = document.createDocumentFragment(); fragment.appendChild(script); // add auto-versioning timestamp to filename: script.src = 'defer.min.20170912122907.js'; script.crossOrigin = 'anonymous'; script.integrity = 'sha256-pL+VBoR3r4z7O6cNLVKu/K4/GfpYmSyDivzzk4qB72E='; document.body.appendChild(fragment); } function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; script.parentNode.removeChild(script); } if (window.addEventListener) { window.addEventListener('DOMContentLoaded', deferJS, false); } else if (document.onreadystatechange) { document.onreadystatechange = function () { if (document.readyState === 'interactive') { deferJS(); } }; } else { window.onload = deferJS; } selfDestruct(); }());
(function () { 'use strict'; function deferJS() { var script = document.createElement('script'); var fragment = document.createDocumentFragment(); fragment.appendChild(script); // add auto-versioning timestamp to filename: script.src = 'defer.min.20170912122907.js'; script.crossOrigin = 'anonymous'; script.integrity = 'sha256-pL+VBoR3r4z7O6cNLVKu/K4/GfpYmSyDivzzk4qB72E='; document.body.appendChild(fragment); } function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; script.parentNode.removeChild(script); } if (window.addEventListener) { window.addEventListener('DOMContentLoaded', deferJS, false); } else if (document.onreadystatechange) { document.onreadystatechange = function () { if (document.readyState === 'interactive') { deferJS(); } }; } else { window.onload = deferJS; } selfDestruct(); }());
Either use a nonce attribute in the <script>
element or issue a header with a SHA of the script itself (with any initial or final line-breaks) to secure the routine and enable execution.
Loading JavaScript on the development site I: loader.js
During development, we’ll need to enable switching scripts, revising scripts, and trying out unanticipated libraries. So, we’ll use not one script but two: (1) a loader script that states & loads each JavaScript library needed for our project, (2) and a parent script that calls the loader script.
We’ll use a modification of the deferJS
<script>
above as the parent script to call loader.js. loader.js will not be exposed in the index page of your site, but rather housed along with the JavaScript libraries that it will load. The parent script will be exposed on the index page, and will be the only <script>
available for inspection in the source code received by the browser. In contrast, the scripts loaded by loader.js will be found among the sources in the browser’s Developer’s Tools.
The code of the loader.js routine is:
(function () { 'use strict'; // *** VARIABLE AND FUNCTION DECLARATIONS *** // // for holding the queue of scripts: var scriptObjects = []; // to delete all scripts with unique class name: function deleteDynamicScripts() { Array.prototype.forEach.call(document.querySelectorAll('.dynamicScript'), function (script) { script.parentNode.removeChild(script); }); } // to fill the scriptObjects array: function enqueue(object) { scriptObjects.push(object); } // to translate camelCase custom data keys into kebob-case attribute names: function camelCaseToKebobCase(str) { return str.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase(); } // to load the enqueued script to the DOM synchronously: function loadScriptsSynchronously() { // retrieve the first script attribute object in array & remove it from array: var attributeObject = scriptObjects.shift(); // create a fragment and a script element: var fragment = document.createDocumentFragment(); var script = document.createElement('script'); // append script to fragment first; edit its attributes while attached to fragment: fragment.appendChild(script); // retrieve attributes and values as key/value pairs in the object, // setting the attributes on the script element: Object.keys(attributeObject).forEach(function (key) { script[key] = attributeObject[key]; // custom data attributes must be translated from camelCase to kebob-case: if (key.substring(0, 4) === 'data') { script.setAttribute(camelCaseToKebobCase(key), attributeObject[key]); } }); // Even though 'text/javascript' is the default, state it explicitly for subsequent logic: if ((!script.hasAttribute('type')) || (script.type === '')) { script.setAttribute('type', 'text/javascript'); } // add unique class name to the script: script.classList.add('dynamicScript'); // if there are scripts remaining in the queue and the current script is executable: if ((scriptObjects.length > 0) && (script.type === 'text/javascript')) { // ...use the 'onload' event of the script to load the next enqueued script: script.onload = loadScriptsSynchronously; // ...but in case the script cannot be loaded, use the 'onerror' event... script.onerror = function (e) { // ...to log error info... console.log(script.src + ' could not load:'); console.log(e); // ...and to continue loading subsequent scripts: loadScriptsSynchronously(); }; } // append the fragment regardless of script type: document.body.appendChild(fragment); // if there are scripts remaining in the queue and the appended script was non-executable: if ((scriptObjects.length > 0) && (script.type !== 'text/javascript')) { // proceed to the next script in the queue: loadScriptsSynchronously(); } } // to delete *this* loader script if it is not deleted by a calling script in the HTML. // NB: document.currentScript cannot be used in a callback, so provide it here // (see https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript): function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; if (script.parentNode) { script.parentNode.removeChild(script); } } // *** BEGIN ROUTINE *** // // Enque scripts by specifying their attributes: enqueue({ src: 'javaScriptLibrary1.js', crossOrigin: 'anonymous' }); enqueue({ src: 'javaScriptLibrary2.js', crossOrigin: 'anonymous' }); enqueue({ src: 'javaScriptLibrary3.js', crossOrigin: 'anonymous' }); // Load all enqueued scripts synchronously: loadScriptsSynchronously(); // When the window is loaded, delete the dynamic scripts: window.onload = deleteDynamicScripts; // Finally, delete *this* script: selfDestruct(); }());
(function () { 'use strict'; // *** VARIABLE AND FUNCTION DECLARATIONS *** // // for holding the queue of scripts: var scriptObjects = []; // to delete all scripts with unique class name: function deleteDynamicScripts() { Array.prototype.forEach.call(document.querySelectorAll('.dynamicScript'), function (script) { script.parentNode.removeChild(script); }); } // to fill the scriptObjects array: function enqueue(object) { scriptObjects.push(object); } // to translate camelCase custom data keys into kebob-case attribute names: function camelCaseToKebobCase(str) { return str.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase(); } // to load the enqueued script to the DOM synchronously: function loadScriptsSynchronously() { // retrieve the first script attribute object in array & remove it from array: var attributeObject = scriptObjects.shift(); // create a fragment and a script element: var fragment = document.createDocumentFragment(); var script = document.createElement('script'); // append script to fragment first; edit its attributes while attached to fragment: fragment.appendChild(script); // retrieve attributes and values as key/value pairs in the object, // setting the attributes on the script element: Object.keys(attributeObject).forEach(function (key) { script[key] = attributeObject[key]; // custom data attributes must be translated from camelCase to kebob-case: if (key.substring(0, 4) === 'data') { script.setAttribute(camelCaseToKebobCase(key), attributeObject[key]); } }); // Even though 'text/javascript' is the default, state it explicitly for subsequent logic: if ((!script.hasAttribute('type')) || (script.type === '')) { script.setAttribute('type', 'text/javascript'); } // add unique class name to the script: script.classList.add('dynamicScript'); // if there are scripts remaining in the queue and the current script is executable: if ((scriptObjects.length > 0) && (script.type === 'text/javascript')) { // ...use the 'onload' event of the script to load the next enqueued script: script.onload = loadScriptsSynchronously; // ...but in case the script cannot be loaded, use the 'onerror' event... script.onerror = function (e) { // ...to log error info... console.log(script.src + ' could not load:'); console.log(e); // ...and to continue loading subsequent scripts: loadScriptsSynchronously(); }; } // append the fragment regardless of script type: document.body.appendChild(fragment); // if there are scripts remaining in the queue and the appended script was non-executable: if ((scriptObjects.length > 0) && (script.type !== 'text/javascript')) { // proceed to the next script in the queue: loadScriptsSynchronously(); } } // to delete *this* loader script if it is not deleted by a calling script in the HTML. // NB: document.currentScript cannot be used in a callback, so provide it here // (see https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript): function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; if (script.parentNode) { script.parentNode.removeChild(script); } } // *** BEGIN ROUTINE *** // // Enque scripts by specifying their attributes: enqueue({ src: 'javaScriptLibrary1.js', crossOrigin: 'anonymous' }); enqueue({ src: 'javaScriptLibrary2.js', crossOrigin: 'anonymous' }); enqueue({ src: 'javaScriptLibrary3.js', crossOrigin: 'anonymous' }); // Load all enqueued scripts synchronously: loadScriptsSynchronously(); // When the window is loaded, delete the dynamic scripts: window.onload = deleteDynamicScripts; // Finally, delete *this* script: selfDestruct(); }());
loader.js here loads JavaScript libraries using a loadScriptsSynchronously
function. Called repeatedly, it appends <script>
elements to the DOM using expandable, anonymous objects that provide the attributes for <script>
elements (including their src
attributes) in the form of key-value pairs. It works otherwise in the same manner as the deferJS
<script>
that calls it from the HTML, but with a twist. The important difference between the two routines is that loader.js is responsible for loading multiple JavaScript libraries, not a single library, which is the job of the (slighlty modified, as we will see) deferJS
<script>
that calls it. loader.js must ensure that the JavaScript libraries it loads are loaded synchronously. The reason for this may be crucial, depending on which libraries you are employing and how your own code (stored in your own library file) is constructed.
To ensure the JavaScript libraries are loaded and executed synchronously during development (during which time you may be [1] experimenting with different libraries, [2] adding and subtracting to them, and [3] altering their ordering as you develop the site code), loader.js works this way: The <script>
elements are defined in JavaScript objects that specify their attributes (including the src
attribute) in key-value pairs. The objects are enqueued to a scriptObjects
array (see the enqueue
function invocations at the bottom), and then a loadScriptsSynchronously
function is called. It strips the first <script>
object from the scriptObjects
array and decodes it, creating the corresponding attributes on a new <script>
element. (Special provisions are made to ensure that custom data attributes are decoded correctly, as may be encountered when loading JSX scripts to be converted with the Babel Standalone library, etc.) Importantly and crucially, an onload
attribute is added to the <script>
element to call loadScriptsSynchronously
again if there are additional <script>
objects remaining in the queue. This ensures that each <script>
is loaded fully before the next one is appended to the DOM. This in turn prevents unpredictable asynchronous loading of libraries whose load-order needs to be preserved. If, however, a <script>
is not of the default type 'text/javascript'
, there will be no onload
or onerror
event generated by that element. So, when those scripts are appended to the DOM, the loadScriptsSynchronously
routine is called immediately to proceed with the remainder of the queue. Finally, the script itself is destroyed in a final act of DOM housekeeping.
Notice that there are no CSP attributes on the created <script>
elements, nor any autoVersioning: we don't need those things here. Script security via strict-dynamic
is applied in the main HTML on the parent script, so trust is automatically conferred to the loader.js script. Again: Trust will be conferred for the parent script and its descendant processes via strict-dynamic
, using either a nonce or a SHA hash of the parent script itself.
Loading JavaScript on the development site II: The parent script that calls loader.js
Now we can cover the parent script that calls loader.js. It is a minor modification of the defer.min.js
routine covered above:
(function () { 'use strict'; function deferJS() { var script = document.createElement('script'); var fragment = document.createDocumentFragment(); fragment.appendChild(script); // here, we specify the loader.js script, not the defer.js script: script.src = 'loader.js'; script.crossOrigin = 'anonymous'; document.body.appendChild(fragment); } function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; if (script.parentNode) { script.parentNode.removeChild(script); } } if (window.addEventListener) { window.addEventListener('DOMContentLoaded', deferJS, false); } else if (document.onreadystatechange) { document.onreadystatechange = function () { if (document.readyState === 'interactive') { deferJS(); } }; } else { window.onload = deferJS; } selfDestruct(); }());
(function () { 'use strict'; function deferJS() { var script = document.createElement('script'); var fragment = document.createDocumentFragment(); fragment.appendChild(script); // here, we specify the loader.js script, not the defer.js script: script.src = 'loader.js'; script.crossOrigin = 'anonymous'; document.body.appendChild(fragment); } function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; if (script.parentNode) { script.parentNode.removeChild(script); } } if (window.addEventListener) { window.addEventListener('DOMContentLoaded', deferJS, false); } else if (document.onreadystatechange) { document.onreadystatechange = function () { if (document.readyState === 'interactive') { deferJS(); } }; } else { window.onload = deferJS; } selfDestruct(); }());
Again, this is the same as the first form of the deferJS
<script>
, provided in Deployment JavaScript loading: Basic form above. The only difference is that it calls loader.js, not defer.min.js.
For site development, this version of the deferJS
<script>
should be employed in the HTML. Specific JavaScript libraries should be loaded with the loader.js, and it will handle all new scripts and changes to old scripts. The strict-dynamic
CSP applied to the deferJS
<script>
in the HTML allows anything in loader.js to execute, so we may load arbitrary JavaScript libraries with it; each internal loading will be allowed because it is a descendant routine of the deferJS
<script>
.
Using loader.js To Employ Externally Hosted JS Libraries with SRIs
If we wished to load externally hosted scripts and include Sub-Resource Integrity (SRI) verification on them, a simple modification of loader.js as follows would suffice:
(function () { 'use strict'; var scriptObjects = []; function deleteDynamicScripts() { Array.prototype.forEach.call(document.querySelectorAll('.dynamicScript'), function (script) { script.parentNode.removeChild(script); }); } function enqueue(object) { scriptObjects.push(object); } function camelCaseToKebobCase(str) { return str.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase(); } function loadScriptsSynchronously() { var attributeObject = scriptObjects.shift(); var fragment = document.createDocumentFragment(); var script = document.createElement('script'); fragment.appendChild(script); Object.keys(attributeObject).forEach(function (key) { script[key] = attributeObject[key]; if (key.substring(0, 4) === 'data') { script.setAttribute(camelCaseToKebobCase(key), attributeObject[key]); } }); if ((!script.hasAttribute('type')) || (script.type === '')) { script.setAttribute('type', 'text/javascript'); } script.classList.add('dynamicScript'); if ((scriptObjects.length > 0) && (script.type === 'text/javascript')) { script.onload = loadScriptsSynchronously; script.onerror = function (e) { console.log(script.src + ' could not load:'); console.log(e); loadScriptsSynchronously(); }; } document.body.appendChild(fragment); if ((scriptObjects.length > 0) && (script.type !== 'text/javascript')) { loadScriptsSynchronously(); } } function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; if (script.parentNode) { script.parentNode.removeChild(script); } } // Add integrity attributes: enqueue({ src: 'https://code.jquery.com/jquery-3.2.1.slim.min.js', integrity: 'sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN', crossOrigin: 'anonymous' }); enqueue({ src: 'https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js', integrity: 'sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q', crossOrigin: 'anonymous' }); enqueue({ src: 'https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js', integrity: 'sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl', crossOrigin: 'anonymous' }); loadScriptsSynchronously(); window.onload = deleteDynamicScripts; selfDestruct(); }());
(function () { 'use strict'; var scriptObjects = []; function deleteDynamicScripts() { Array.prototype.forEach.call(document.querySelectorAll('.dynamicScript'), function (script) { script.parentNode.removeChild(script); }); } function enqueue(object) { scriptObjects.push(object); } function camelCaseToKebobCase(str) { return str.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase(); } function loadScriptsSynchronously() { var attributeObject = scriptObjects.shift(); var fragment = document.createDocumentFragment(); var script = document.createElement('script'); fragment.appendChild(script); Object.keys(attributeObject).forEach(function (key) { script[key] = attributeObject[key]; if (key.substring(0, 4) === 'data') { script.setAttribute(camelCaseToKebobCase(key), attributeObject[key]); } }); if ((!script.hasAttribute('type')) || (script.type === '')) { script.setAttribute('type', 'text/javascript'); } script.classList.add('dynamicScript'); if ((scriptObjects.length > 0) && (script.type === 'text/javascript')) { script.onload = loadScriptsSynchronously; script.onerror = function (e) { console.log(script.src + ' could not load:'); console.log(e); loadScriptsSynchronously(); }; } document.body.appendChild(fragment); if ((scriptObjects.length > 0) && (script.type !== 'text/javascript')) { loadScriptsSynchronously(); } } function selfDestruct() { var script = document.currentScript || document.scripts[document.scripts.length - 1]; if (script.parentNode) { script.parentNode.removeChild(script); } } // Add integrity attributes: enqueue({ src: 'https://code.jquery.com/jquery-3.2.1.slim.min.js', integrity: 'sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN', crossOrigin: 'anonymous' }); enqueue({ src: 'https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js', integrity: 'sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q', crossOrigin: 'anonymous' }); enqueue({ src: 'https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js', integrity: 'sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl', crossOrigin: 'anonymous' }); loadScriptsSynchronously(); window.onload = deleteDynamicScripts; selfDestruct(); }());
Here, we simply added the SHA hashes of the scripts as integrity
attributes in the objects we feed into each enqueue
function invocation.
Conclusion
We've created 3 routines:
- A
deferJS
routine for the deployment/production site. - A
loaderJS
routine for the development site. - A loader.js utility routine for the devlopment site.
Coupled with a solid CSP 3 strict-dynamic
header and appropriate nonces or hashes—and along with an SRI and a cache-busting autoVersion for the defer.min.js file—your JavaScript will be loaded with granular control and with security.
19 September 2017
Last Updated: 29 June 2019
Notes
1 See this diagram from W3 for an exhaustive listing of browser rendering events.
2 The MDN article on the SCRIPT element explains that defer
prevents the DOMContentLoaded
event from firing.
3 For more information on code-splitting, see Reduce JavaScript Payloads with Code Splitting.
5 See Google’s article Content Security Policy: Strict CSP for a complete discussion.
6 See What Is Fingerprinting and Why Should I Care? and also Revving Filenames: Don’t Use Querystring