Code Dive: babel-loader

The babel-loader library is a webpack loader that is using babel to transpile future generations of Javascript to versions clients are actually using. Here we'll be diving into version 8.0.5 of the library to see how it functions.

The structure of every webpack loader starts with exporting a function that accepts three parameters. The first parameter, which webpack refers to as source, is a string that could be the source code, image, css and more. The second parameter is the sourceMap, which contains information for debugging usage. The third parameter is file metadata.

let babel;
try {
  babel = require('@babel/core');
} catch (err) {
  if (err.code === 'MODULE_NOT_FOUND') {
    err.message +=
      "\n babel-loader@8 requires Babel 7.x (the package '@babel/core'). " +
      "If you'd like to use Babel 6.x ('babel-core'), you should install 'babel-loader@7'.";
  }
  throw err;
}

// Since we've got the reverse bridge package at @babel/core@6.x, give
// people useful feedback if they try to use it alongside babel-loader.
if (/^6\./.test(babel.version)) {
  throw new Error(
    "\n babel-loader@8 will not work with the '@babel/core@6' bridge package. " +
      "If you want to use Babel 6.x, install 'babel-loader@7'.",
  );
}

In the first few lines, babel-loader throws an error if the version of babel is incompatible. This is a runtime check in case consumers of the library missed the peer dependency warnings during npm install.

const pkg = require('../package.json');
const cache = require('./cache');
const transform = require('./transform');
const injectCaller = require('./injectCaller');

const path = require('path');
const loaderUtils = require('loader-utils');

Next is importing the various files and helper libraries. loader-utils is a library provided by webpack to help in common operations such as getting options that were passed to the loader.

module.exports = makeLoader();
module.exports.custom = makeLoader;

function makeLoader(callback) {
  const overrides = callback ? callback(babel) : undefined;

  return function (source, inputSourceMap) {
    // Make the loader async
    const callback = this.async();

    loader.call(this, source, inputSourceMap, overrides).then(
      (args) => callback(null, ...args),
      (err) => callback(err),
    );
  };
}

Line 39-50 is a makeLoader function that allows other people to make a custom loader by passing a function which is then passed the instance of babel that babel-loader imported. The function should, according to its documentation, return an object which changes options, babel config, or result.

async function loader(source, inputSourceMap, overrides) {
  const filename = this.resourcePath;

At line 52, we reach the main function loader. Its first few lines process the object that the custom loader returns, either via the "customize" option or via the custom export. At the end of this section, we have a normalized "loaderOptions".

const programmaticOptions = Object.assign({}, loaderOptions, {
  filename,
  inputSourceMap: inputSourceMap || undefined,

  // Set the default sourcemap behavior based on Webpack's mapping flag,
  // but allow users to override if they want.
  sourceMaps:
    loaderOptions.sourceMaps === undefined
      ? this.sourceMap
      : loaderOptions.sourceMaps,

  // Ensure that Webpack will get a full absolute path in the sourcemap
  // so that it can properly map the module back to its internal cached
  // modules.
  sourceFileName: filename,
});

These few lines set the options which will be passed into babel's "loadPartialConfig" function.

const config = babel.loadPartialConfig(injectCaller(programmaticOptions));
  if (config) {
    let options = config.options;
    if (overrides && overrides.config) {
      options = await overrides.config.call(this, config, {
        source,
        map: inputSourceMap,
        customOptions,
      });
    }

"injectCaller" is a helper function that first checks to see if the "caller" option is available in the version of babel that was imported, and if it does, injects the "caller" option. The next few lines allow the override to change the options object.

const {
  cacheDirectory = null,
  cacheIdentifier = JSON.stringify({
    options,
    '@babel/core': transform.version,
    '@babel/loader': pkg.version,
  }),
  cacheCompression = true,
  metadataSubscribers = [],
} = loaderOptions;

let result;
if (cacheDirectory) {
  result = await cache({
    source,
    options,
    transform,
    cacheDirectory,
    cacheIdentifier,
    cacheCompression,
  });
} else {
  result = await transform(source, options);
}

Line 187-199 does the actual transformation work. "transform" is a function provided by babel, which turns your Javascript into the older, browser-readable version. Here, the transform result is obtained from the cache if available.

// TODO: Babel should really provide the full list of config files that
// were used so that this can also handle files loaded with 'extends'.
if (typeof config.babelrc === 'string') {
  this.addDependency(config.babelrc);
}

Here babel-loader is checking if babelrc is a path, and tells webpack via "this.addDependency" that this is a file that will invalidate the result if its content changes. However, as the comment indicates, babel-loader misses paths because the babel config is loaded by babel and not babel-loader.

if (result) {
      if (overrides && overrides.result) {
        result = await overrides.result.call(this, result, {
          source,
          map: inputSourceMap,
          customOptions,
          config,
          options,
        });
      }

      const { code, map, metadata } = result;

      metadataSubscribers.forEach(subscriber => {
        subscribe(subscriber, metadata, this);
      });

      return [code, map];
    }
  }

  // If the file was ignored, pass through the original content.
  return [source, inputSourceMap];

Lines 207 to 226 do extra handling, by allowing an override to change the result and calling a hidden functionality "metadataSubscribers" which takes in the file metadata and calling when bound to the webpack context. Lastly, babel-loader returns the generated code and the sourcemap.

If we strip babel-loader of its custom options, caching, error handling and warnings, we end up with the following 64 line implementation:

const babel = require('@babel/core');
const loaderUtils = require('loader-utils');

module.exports = function (source, sourceMap, meta) {
  let loaderOptions = loaderUtils.getOptions(this) || {};

  // Standardize on 'sourceMaps' as the key passed through to Webpack, so that
  // users may safely use either one alongside our default use of
  // 'this.sourceMap' below without getting error about conflicting aliases.
  if (
    Object.prototype.hasOwnProperty.call(loaderOptions, 'sourceMap') &&
    !Object.prototype.hasOwnProperty.call(loaderOptions, 'sourceMaps')
  ) {
    loaderOptions = Object.assign({}, loaderOptions, {
      sourceMaps: loaderOptions.sourceMap,
    });
    delete loaderOptions.sourceMap;
  }

  const filename = this.resourcePath;
  const babelOptions = Object.assign({}, loaderOptions, {
    filename,
    inputSourceMap: sourceMap || undefined,
    // Set the default sourcemap behavior based on Webpack's mapping flag,
    // but allow users to override if they want.
    sourceMaps:
      loaderOptions.sourceMaps === undefined
        ? this.sourceMap
        : loaderOptions.sourceMaps,
    // Ensure that Webpack will get a full absolute path in the sourcemap
    // so that it can properly map the module back to its internal cached
    // modules.
    sourceFileName: filename,
    caller: {
      name: 'babel-loader',
      supportsStaticESM: true,
      supportsDynamicImport: true,
    },
  });

  const callback = this.async();
  const config = babel.loadPartialConfig(babelOptions);
  if (!config) {
    // If the file was ignored, pass through the original source.
    callback(null, source, sourceMap, meta);
    return;
  }

  const options = config.options;
  if (options.sourceMaps === 'inline') {
    // Babel has this weird behavior where if you set "inline", we
    // inline the sourcemap, and set 'result.map = null'. This results
    // in bad behavior from Babel since the maps get put into the code,
    // which Webpack does not expect, and because the map we return to
    // Webpack is null, which is also bad. To avoid that, we override the
    // behavior here so "inline" just behaves like 'true'.
    options.sourceMaps = true;
  }

  babel.transform(source, options, function (err, result) {
    if (err) {
      callback(err);
    } else {
      callback(null, result.code, result.map, result.metadata);
    }
  });
};

As it turns out, babel-loader is surprisingly simple once you peel back the layers and dissect the pieces. Hopefully, this helps you in writing your custom webpack loader in the future!