At the beginning of 2021, we live between two worlds. One where we may need to support older browsers such as IE11 or Android 6 on mobile and one where browser vendors are keeping up very well with the latest JavaScript spec. However, if a percentage of your users are visiting your site without a “modern” browser, you hopefully still support them. Not doing so would impact anywhere from 5%-20% of your users. As a smart colleague once said: “How many stadiums or conference rooms of people is that for your web application?” Probably a non trivial amount.
Let’s look at a way to lower our JS emissions that may decrease our Ember.js app bundle by 20% or more.
ES module support
<!-- this must come first due to double fetching bugs in older browsers -->
<script nomodule src="{{rootURL}}assets/ember-lower-js.js"></script>
<script type="module" src="{{rootURL}}assets/ember-lower-js.js" data-modern-script></script>
The HTML spec specifies two types of external scripts - modern and classical. User agents that support ES modules will fetch the resources specified by type="module"
. If the browser does not, then nomodule
will be fetched. type="module"
consequentially has over 90% browser support. However, as mentioned in the intro, how many airports of people on Christmas Eve is that?
In Ember, we must take into account that ember-cli does not output ES modules. Instead it outputs AMD. In thinking about a solution, we can’t use type=”module”
and nomodule
for their original intended purpose since our app bundle is not in ES module format. However, we do care about browser support and ES modules are not supported in browsers such as Chrome 60, Firefox 59 or older Android browsers. We can use these script attributes as a proxy for serving up different app bundle sizes depending on the user’s browser compatibility with ES modules. Let’s detail our next steps for our Ember app.
Differential serving in Ember
We can use type="module"
as a proxy for our build process. As such, we will build two different versions of our JavaScript files - one with our original transpilation target (the larger file) and one with a relaxed transpilation target. Here is the example I have put together on Github.
"build:modern": "MODERN_SCRIPT=true ember build --environment=production --output-path=dist-modern",
"build": "ember build --environment=production && npm run build:modern && npm run copy-modern",
'use strict';
const MODERN_SCRIPT = process.env.MODERN_SCRIPT === 'true';
const browsers = [...];
const isCI = Boolean(process.env.CI);
const isProduction = process.env.EMBER_ENV === 'production';
if (isCI || isProduction) {
browsers.push('ie 11');
}
if (MODERN_SCRIPT) {
module.exports = {
esmodules: true
};
} else {
module.exports = {
browsers
};
}
Next, we need to “merge” the modern script with the original. We have two steps to take. 1) Copy the modern script built in dist-modern
to dist
and 2) Patch our index.html
with this new modern script source. We can setup a post processing script to do that.
However, we will run into one snag. type="module"
does not leak variables outside of it’s context whereas a plain script
tag will. In our vendor.js
, we have our AMD utility functions defined, and they necessarily need to leak out of their context so that they can be used in our app.js
. As a result, our script will only copy the app.js
code to dist
. vendor.js
must stay as is with higher transpilation settings.
const rimraf = require('rimraf');
const path = require('path');
const fs = require('fs');
const jsdom = require('jsdom');
const { JSDOM } = jsdom;
// dist and dist-modern index.html parsed as JSDOM
const modernPath = path.resolve('dist-modern', 'index.html');
const modernDOM = new JSDOM(fs.readFileSync(modernPath, { encoding: 'utf8' }));
const indexPath = path.resolve('dist-modern', 'index.html');
const indexDOM = new JSDOM(fs.readFileSync(indexPath, { encoding: 'utf8' }));
let modernScript = modernDOM.window.document.querySelector('script[src*="ember-lower-js"]');
function copyToDist(scriptSrc) {
let currentPath = path.resolve('dist-modern', scriptSrc.substring(1));
let newPath = path.resolve('dist', `${scriptSrc.slice(1,-3)}.modern.js`);
fs.renameSync(currentPath, newPath);
}
function writeModernScriptToFile(dom, modernScript) {
const script = dom.window.document.querySelector('script[data-modern-script]');
script.setAttribute('src', `${modernScript.getAttribute('src').slice(0,-3)}.modern.js`);
script.setAttribute('integrity', modernScript.getAttribute('integrity'));
}
// Move .modern script to /dist
copyToDist(modernScript.getAttribute('src'));
writeModernScriptToFile(indexDOM, modernScript);
const newHTML = indexDOM.serialize();
fs.writeFileSync(path.resolve('dist', 'index.html'), newHTML);
rimraf.sync('dist-modern');
Your dist/index.html
should look like this now.
<script nomodule src="{{rootURL}}assets/ember-lower-js.js"></script>
<script type="module" src="{{rootURL}}assets/ember-lower-js.modern.js" data-modern-script></script>
Downsides
This is a great resource to understand if browsers correctly interpret type="module"
and nomodule
- Will it double fetch. In some older browsers, your nomodule
and type="module"
will be double or even triple fetched. It is prudent to determine from your metrics if you are ok with some of your users living with this constraint. This is why we also included our nomodule
script first. If we double fetch, both scripts may be requested async but will be executed in order. AMD will not overwrite your original definitions, thus the second script is effectively a noop.
Conclusion
This was fun little exploration to lowering your JS emissions. You may see upwards of a 20% drop in your app bundle for customers using modern browsers. However, your mileage may vary, especially given the amount of extra code necessary to support async/await or whether you’re still inheriting the ember-cli blueprint that builds for IE11 in production builds. For example, I tried this approach on Discourse and only saw a 4% improvement. I hope you learned something and let me know if you have any questions or see further improvements to be had!