Today we have a large number of different browsers and even more versions of each. Not a long time ago features were added infrequently, but now you can see them added in almost every release. As a result, different versions of browsers have different features’ support, not to mention a different level of vendor support.
Developers want to use new features, as they often simplify their lives. Using modern development tools, you can use features before they even get an official vendor support by transpiling and using polyfills. Additionally, these tools guarantee that a website will work in all browsers, regardless of a particular feature support. Examples: Autoprefixer and postcss-preset-env for CSS, Babel for JavaScript. But you need to understand that using these tools can increase the bundle’s size.
As a result, we have a website that works in any browser, but it loads slower. Let me remind you that the loading time and fast transitions directly affects UX and popularity. What can be done with it? In fact, we don’t need to transpile and polyfill absolutely every feature — it’s enough to do this only with those that are not supported by current browsers (or relevant to the audience of your website). For example, promises are supported by every browser, excluding the oldest ones.
Browserslist
Browserslist is a convenient tool for describing target browsers just by using simple queries like the following:
last 2 years
> 1%
not dead
This is an example of .browserslistrc
file, that requires: browsers over the past two years, plus browsers with more than 1% of users, and all of these browsers must be «live». You can see specific browsers resolution on browserl.ist. Learn more about queries syntax on project page.
Already mentioned Autoprefixer, postcss-preset-env and babel-preset-env under the hood use Browserslist, and if your project has a Browserslist config, project code will be compiled for these browsers.
At this stage, we can come to the following conclusion: the newer browsers we are targeting, the less bundle size we get. At the same time, we should not forget that in the real world not every single user has the newest browser, and the website should be accessible for all users, or at least for the most of them. What can be done under these considerations?
Browser targeting variants
1. Limited targeting
By default, if there is no config in the project, Browserslist will use default
browsers. This query is an alias for > 0.5%, last 2 versions, Firefox ESR, not dead
. In general, you can stop on this query, and over the time, the browsers matching this query will start to support most of the current features.
But you can target a considerable number of browsers by following these rules: exclude legacy and unpopular ones, consider more or less relevant versions of browsers. Sounds simple, but actually it’s not. You need to carefully balance the Browserslist config to cover most of the audience.
2. Audience analysis
If your website implies only certain regions’ support, then you can try using a query like > 5% in US
, which returns suitable browsers based on the usage statistics by specified country.
Browserslist family is full of various additional tools, one of them is Browserslist-GA (there is also browserslist-adobe-analytics), which allows you to export data from analytics service about your users’ browsers statistics. After that, it becomes possible to use this data in Browserslist config and make queries based on it:
> 0.5% in my stats
For example, if you can update this data on every deploy, then your website will always be built for current browsers used by your audience.
3. Differential resource loading
In March 2019 Matthias Binens from Google proposed to add differential script loading (further DSL) to browsers:
<script type="module"
srcset="2018.mjs 2018, 2019.mjs 2019"
src="2017.mjs"></script>
<script nomodule src="legacy.js"></script>
Until now, his proposal stays only a proposal, and it is unknown whether this will be implemented by vendors or not. But the concept is understandable, and Browserslist family has tools that you can use to implement something similar, one of them is browserslist-useragent. This tool allows you to check if the browser’s User-Agent fits your config.
Browserslist-useragent
There are already several articles on this topic, here is an example of one — «Smart Bundling: How To Serve Legacy Code Only To Legacy Browsers». We will briefly go over the implementation. First, you need to to configure your build process to output two versions of the bundles for the modern and legacy browsers, for example. Here, Browserslist will help you with it ability to declare several environments in a configuration file:
[modern]
last 2 versions
last 1 year
not safari 12.1
[legacy]
defaults
Next, you need to configure the server to send the right bundle to the user’s browser:
/* … */
import { matchesUA } from 'browserslist-useragent'
/* … */
app.get('/', (request, response) => {
const userAgent = request.get('User-Agent')
const isModernBrowser = matchesUA(userAgent, {
env: 'modern',
allowHigherVersions: true
})
const page = isModernBrowser
? renderModernPage(request)
: renderLegacyPage(request)
response.send(page)
})
Thus, the website will send a lightweight bundle to users with modern browsers, resulting in a faster loading time, while saving accessibility for other users. But, as you can see, this method requires your own server with special logic.
Module/nomodule
With browsers’ support of ES-modules, there is a way to implement DSL on client side:
<script type="module" src="index.modern.js"></script>
<script nomodule src="index.legacy.js"></script>
This pattern is called module/nomodule, and it’s based on the fact that legacy browsers without ES-modules’ support will not handle scripts with the type module
, since this type is unknown to them. So browsers that support ES-modules will load scripts with the type module
and ignore scripts with the nomodule
attribute. Browsers with ES-modules’ support can be specified by the following config:
[esm]
edge >= 16
firefox >= 60
chrome >= 61
safari >= 11
opera >= 48
The biggest advantage of the module/nomodule pattern is that you don’t need to own a server — everything works completely on client side. Differential stylesheet loading cannot be done this way, but you can implement resource loading using JavaScript:
if ('noModule' in document.createElement('script')) {
// Modern browsers
} else {
// Legacy browsers
}
One of the disadvantages: this pattern has some cross-browser problems. Also, browsers supporting ES-modules already have new features with different levels of support, for example, optional chaining operator. With the addition of new features, this DSL variation will lose its relevance.
You can read more about the module/nomodule pattern in the article «Modern Script Loading». If you are interested in this DSL variant and would like to try it in your project, then you can use Webpack plugin: webpack-module-nomodule-plugin.
Browserslist-useragent-regexp
More recently, another tool was created for Browserslist: browserslist-useragent-regexp. This tool allows you to get a regular expression from config to check browser’s User-Agent. Regular expressions work in any JavaScript runtime, which makes it possible to check the browser’s User-Agent not only on server side, but also on client side. Thus, you can implement a working DSL in a browser:
// last 2 firefox versions
var modernBrowsers = /Firefox\/(73|74)\.0\.\d+/
var script = document.createElement('script')
script.src = modernBrowsers.test(navigator.userAgent)
? 'index.modern.js'
: 'index.legacy.js'
document.all[1].appendChild(script)
Another fact is that generated regexpes are faster than matchesUA function from browserslist-useragent, so it makes sense to use browserslist-useragent-regexp on server side too:
> matchesUA('Mozilla/5.0 (Windows NT 10.0; rv:54.0) Gecko/20100101 Firefox/54.0', { browsers: ['Firefox > 53']})
first time: 21.604ms
> matchesUA('Mozilla/5.0 (Windows NT 10.0; rv:54.0) Gecko/20100101 Firefox/54.0', { browsers: ['Firefox > 53']})
warm: 1.742ms
> /Firefox\/(5[4-9]|6[0-6])\.0\.\d+/.test('Mozilla/5.0 (Windows NT 10.0; rv:54.0) Gecko/20100101 Firefox/54.0')
first time: 0.328ms
> /Firefox\/(5[4-9]|6[0-6])\.0\.\d+/.test('Mozilla/5.0 (Windows NT 10.0; rv:54.0) Gecko/20100101 Firefox/54.0')
warm: 0.011ms
All in all, this looks very cool, but there should be an easy way to integrate it into the project’s building process... And in fact there is!
Browserslist Differential Script Loading
Bdsl-webpack-plugin is a Webpack plugin paired with html-webpack-plugin and using browserslist-useragent-regexp, which helps automate DSL addition to the bundle. Here is an example Webpack config for this plugin usage:
const {
BdslWebpackPlugin,
getBrowserslistQueries,
getBrowserslistEnvList
} = require('bdsl-webpack-plugin')
function createWebpackConfig(env) {
return {
name: env,
/* … */
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
cacheDirectory: true,
presets: [
['@babel/preset-env', {
/* … */
targets: getBrowserslistQueries({ env })
}]
],
plugins: [/* … */]
}
}]
},
plugins: [
new HtmlWebpackPlugin(/* … */),
new BdslWebpackPlugin({ env })
]
};
}
module.exports = getBrowserslistEnvList().map(createWebpackConfig)
This example exports several configs to output bundles for each environment from Browserslist config. As an output, we get HTML file with built-in DSL script:
<!DOCTYPE html>
<html>
<head>
<title>Example</title>
<script>function dsl(a,s,c,l,i){c=dsld.createElement('script');c.async=a[0];c.src=s;l=a.length;for(i=1;i<l;i++)c.setAttribute(a[i][0],a[i][1]);dslf.appendChild(c)}var dsld=document,dslf=dsld.createDocumentFragment(),dslu=navigator.userAgent,dsla=[[]];if(/Firefox\/(73|74)\.0\.\d+/.test(dslu))dsl(dsla[0],"/index.modern.js")
else dsl(dsla[0],"/index.legacy.js");dsld.all[1].appendChild(dslf)</script>
</head>
<body></body>
</html>
In addition to scripts loading, there is support for styles loading. It is also possible to use this plugin on server side.
But, unfortunately, there are some nuances that you should know before starting to use bdsl-webpack-plugin: since scripts and styles loading is initialized by JavaScript, they are loaded asynchronously without render being blocked, and etc. For example, in case of the scripts — this means an inability to use defer
attribute, and for the styles — the necessity to hide page content until styles are fully loaded. You can investigate how to get around these nuances, and other features of this plugin yourself, see documentation and usage examples.
Dependencies transpilation
Following the aforesaid part of the article, we’ve learned several ways of using Browserslist to reduce the size of the website’s own code, but the other part of the bundle is its dependencies. In web applications, the size of the dependencies in the final bundle can take up a significant part.
By default, the build process should avoid the transpilation of dependencies, otherwise the build will take a lot of time. Also dependencies, utilizing unsupported syntax, are usually distributed already transpiled. In practice, there are three types of packages:
- with transpiled code;
- with transpiled code and sources;
- with code with current syntax only for modern browsers.
With the first type, obviously, nothing can be done. The second — you need to configure the bundler to work only with the sources from the package. The third type — in order to make it work (even with not very relevant browsers) you still have to transpile it.
Since there is no common way to make packages with several versions of the bundle, I will describe how I suggest to approach this problem: the regular transpiled version has .js
extension, the main file is written to the main
field of package.json
file, while, on the contrary, the version of the bundle without transpilation has .babel.js
extension, and the main file is written in the raw
field. Here is a real example — Canvg package. But you can do it another way, for example, here is how it’s done in Preact package — the sources are located in the separate folder, and package.json
has a source
field.
To make Webpack work with such packages, you need to modify resolve
config section:
{
/* … */
resolve: {
mainFields: [
'raw',
'source',
'browser',
'module',
'main'
],
extensions: [
'.babel.js',
'.js',
'.jsx',
'.json'
]
}
/* … */
}
This way, we tell Webpack how to lookup files in packages that are used at build time. Then we just need to configure babel-loader:
{
/* … */
test: /\.js$/,
exclude: _ => /node_modules/.test(_) && !/(node_modules\/some-modern-package)|(\.babel\.js$)/.test(_),
loader: 'babel-loader'
/* … */
}
The logic is straightforward: we ask to ignore everything from node_modules
, except specific packages and files with specific extensions.
Results
I’ve measured DevFest Siberia 2019 a website’s bundle size and loading time before and after applying differential loading together with dependencies transpilation:
Regular network | Regular 4G | Good 3G | |
---|---|---|---|
Without DSL | |||
Average loading time | 1,511 ms | 4,240 ms | 8,696 ms |
Fastest loading time | 1,266 ms | 3,366 ms | 8,349 ms |
Encoded size | 292 kB | ||
Decoded size | 1.08 MB | ||
bdsl-webpack-plugin, 3 environments (modern, actual, legacy) | |||
Average loading time | 1,594 ms | 3,409 ms | 8,561 ms |
Fastest loading time | 1,143 ms | 3,142 ms | 6,673 ms |
Encoded size | 218 kB | ||
Decoded size | 806 kB |
The result is a decreased loading time and bundle size reduction by ≈20%, read more detailed report. You can also make measurements by yourself — you can find the required script in the bdsl-webpack-plugin repository.
Sources
- Smart Bundling: How To Serve Legacy Code Only To Legacy Browsers, Shubham Kanodia
- Modern Script Loading, Jason Miller
Top comments (1)
Great explanation, thanks 👍👍👍