Optimizing JavaScript Through Scope Hoisting

Back in the day, JavaScript was often written in one long file with code strewn about in an unmanageable mess. Modern JavaScript brought us CommonJS modules, a standard that allowed us to properly split our code into cohesive files and easily expose APIs from one module to the next. Developers cheered. Codebases rejoiced. This was already a common construct in other languages and was desperately needed in JavaScript. Later, the CommonJS standard would be superseded by native module support in EcmaScript 2015 (more often referred to as ES6).
An example of an ES6 module is as follows:
// multiply.js
export default function (num1, num2) {
return num1 * num2;
}
This is a simple module that multiplies two numbers together and returns the result. Another file, say, main.js, might import the module and use it as follows:
// main.js
import multiply from './multiply';const answer = multiply(2, 3);
console.log(answer);
When developing for the browser, module bundlers are often used to combine dozens or hundreds of these modules into a single file that can be delivered to a user’s browser. Several popular module bundlers exist, including Webpack, Rollup, Parcel, Browserify, and RequireJS. Bundlers have a full understanding of the module format. If we told a bundler to bundle our main.js file, it would be able to read the module contents and determine that the code in main.js needs the code in multiply.js in order to operate correctly. As a result, the bundler would include the code from both main.js and multiply.js into the output bundle. Not only that, but the bundler would provide the necessary glue code that would feed the multiply module’s export (the multiply
function) into the main module when the main module requests it. Both the modules and the glue code are included in the output bundle.
Traditional bundling techniques
Traditionally, bundlers wrap each module in a function and then provide some overhead code that wires them together. Bundling the example above using Webpack, arguably the most popular bundler at the moment, produces this output when using default configuration:
/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 0);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__multiply__ = __webpack_require__(1);const answer = Object(__WEBPACK_IMPORTED_MODULE_0__multiply__["a" /* default */])(2, 3);
console.log(answer);/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {"use strict";
/* harmony default export */ __webpack_exports__["a"] = (function (num1, num2) {
return num1 * num2;
});/***/ })
/******/ ]);
I’ve highlighted our original code, albeit somewhat mangled, in bold. As you can see, quite a bit of overhead code has been added. When we send code to a user’s browser, we usually minify the code, which in this case removes quite a bit of cruft:
!function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{configurable:!1,enumerable:!0,get:r})},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n(1);const o=Object(r.a)(2,3);console.log(o)},function(e,t,n){"use strict";t.a=function(e,t){return e*t}}]);
Again, I’ve marked our original (even more mangled) code in bold. This is still quite a bit of overhead. Some of that overhead is repeated for every module we have. As we’ll see, this can have a significant impact on size and performance.
Scope hoisting
Over the last few years, a bundler called Rollup gained considerable popularity, and for good reason. Rollup took a dramatically different approach to how modules are bundled. Rather than wrapping each module in a function and adding code to wire the functions together, it hoists all module code into a single function. This requires special care be taken to ensure that the original behavior is preserved while also preventing variable name conflicts, among other things. Bundling the example above using Rollup produces this output:
(function () {
'use strict';function multiply (num1, num2) {
return num1 * num2;
}const answer = multiply(2, 3);
console.log(answer);}());
Notice how content from both modules have been merged (hoisted) into the scope of a single function. There is a lack of overhead while the intended behavior is maintained. And now minified:
!function(){"use strict";const o=2*3;console.log(o)}();
In this case, the minifier was smart enough (and scope hoisting made it simple enough) to eliminate the multiply function entirely. Double-win.
Turbine: A real-world case study
For Adobe Experience Platform Launch, we have what amounts to a rule engine that is delivered to our customers’ websites, ultimately being downloaded and executed millions of times by users across the world. We call this rule engine Turbine. It is open source and you can find the code on GitHub. Our customers rely on us to optimize our code where feasible to ensure a great experience for their users.
As of October 2017, we were using Webpack to bundle Turbine. This was working fine, but hoisting looked like a promising way to reduce code weight so we decided to give Rollup a try. The file size was impacted as follows:
Using Webpack without scope hoisting:
- Original: 123.0 KB
- Minified: 32.0 KB
- Gzipped: 10.8 KB
Using Rollup with scope hoisting:
- Original: 75.1 KB
- Minified: 17.2 KB
- Gzipped: 6.4 KB
Furthermore, we benchmarked the time it took the browser to perform the initial execution of Turbine after it had been downloaded. Benchmarking with Chrome on a high-performing 2017 MacBook Pro, the average time spent over 10 runs was as follows:
- Using Webpack without scope hoisting: 9.61 milliseconds
- Using Rollup with scope hoisting: 8.43 milliseconds
Needless to say, we were very pleased with the results. With little work on our part, we were able to decrease the gzipped size of Turbine by ~41% and improve initial execution time by ~12%.
Hoisting support
Rollup may have been the first to support scope hoisting, but it wasn’t long before other bundlers followed suit. Webpack added support for scope hoisting in Webpack 3 by using ModuleConcatenationPlugin. Hoisting can be implemented in Browserify using Rollupify and is available in Parcel using an experimental flag.
While we ended up choosing Rollup for bundling Turbine (we didn’t need any features that Rollup lacked), rest assured that you do have scope hoisting options with other bundlers. Put your code on a scope hoisting diet and let me know how it goes.