Understanding Webpack Loaders

Published on
11 mins read
––– views

Introduction

This section primarily introduces the basic principles of these plugins and implements some common Loaders.

What is a Loader

In Webpack, Loaders are tools used to transform the source code of modules. Webpack treats everything as a module, and these modules can be various types of files, such as JavaScript, CSS, images, etc. Loaders handle these files, converting them into modules that Webpack can process.

Common Loaders include:

LoaderFunction
babel-loaderTranslates modern JavaScript to older versions
style-loaderInjects styles into the DOM
css-loaderResolves and imports CSS files in JavaScript/TypeScript
file-loaderCopies files to the output directory
url-loaderSimilar to file-loader, but can convert small files to Data URLs
sass-loaderLoads and compiles SASS/SCSS files into CSS
less-loaderLoads and compiles LESS files into CSS
ts-loaderTranslates TypeScript to JavaScript
postcss-loaderProcesses CSS with PostCSS plugins
eslint-loaderRuns ESLint on JavaScript/TypeScript files
vue-loaderLoads and compiles Vue.js components
raw-loaderLoads file content as a string
image-webpack-loaderOptimizes and compresses image files

The full list can be found in the Webpack official documentation.

Differences between Loaders and Plugins

FunctionWorking MethodExample
LoaderTransforms module files into valid JavaScript that can be added to the dependency graphApplied along the file loading chain, processing module files one by one in the specified orderBabel Loader is used to convert ECMAScript 2015+ code into backward-compatible JavaScript.
PluginExecutes a wider range of tasks such as bundle optimization, resource management, injection of environment variables, etc.Interacts with different stages of the Webpack build process through hooks, allowing custom operations during the build processHtmlWebpackPlugin is used to generate HTML files and automatically inject bundled script files into the HTML.

In short, Loaders handle the transformation of module files, while Plugins execute various custom tasks during the build process. Loaders are file-level processors, whereas Plugins focus on the entire build process. In the configuration file, we configure a series of Loaders to handle specific types of files, while Plugins are typically instances added to the configuration through the plugins array.

Input and Output of Loaders

By default, resource files are converted to UTF-8 strings and then passed to the loader. By setting raw to true, the loader can receive the raw Buffer.

// loader.js
module.exports = function (content) {
  // Process the input source, here simply adding a comment line at the beginning
  const processedSource = `// This is a custom loader\n${source}`

  // Return the processed result
  return processedSource
}

module.exports.raw = true

The output content of the loader must be of type String or Buffer.

Synchronous and Asynchronous Loaders

Synchronous Loaders

Synchronous loaders are the most common type of loaders. They process each module in sequence according to Webpack's default handling process. Each synchronous loader processes the code of a module and then passes the result to the next loader. These loaders are easy to write and configure.

Asynchronous Loaders

Asynchronous loaders offer more flexibility, allowing for asynchronous operations while processing a module, such as fetching data from the network and then continuing to process the module once the asynchronous operation is complete.

To create an asynchronous loader, you need to use the this.async() method. This method returns a callback function that you must call manually when the asynchronous operation is complete to pass the processed result to the next loader.

Here is a simple example of an asynchronous loader that fetches module content via a network request:

// async-loader.js
module.exports = function (source) {
  // Get the loader context, the this object
  const callback = this.async()

  // Simulate an asynchronous operation (e.g., fetching data from the network)
  setTimeout(() => {
    const simulatedData = { message: 'This is simulated asynchronous data' }
    const transformedSource = source.replace(
      '/_ async-data-placeholder _/',
      JSON.stringify(simulatedData)
    )

    // Call the callback function to pass the processed source code to the next loader
    callback(null, transformedSource)
  }, 1000) // Simulate an asynchronous operation that takes 1 second
}

Working Principles and Execution Order of Loaders

In the execution process of Webpack Loaders, there are two important phases: the pitch phase and the normal phase.

Suppose we have the following three Loaders: Loader1, Loader2, Loader3, with the following configuration:

// webpack.config.js
module: {
  rules: [
    {
    test: /\.js$/,
    use: ['loader3', 'loader2', 'loader1'],
    },
  ],
}

The corresponding Webpack execution phases are shown in the figure below.

Pitch Phase:

  • If the pitch method exists:

    • First, execute the pitch method of loader3.
    • Then, execute the pitch method of loader2.
    • Finally, execute the pitch method of loader1. Normal Phase:
  • If the normal method exists:

    • First, execute the normal method of loader1.
    • Then, execute the normal method of loader2.
    • Finally, execute the normal method of loader3.

Function of the enforce attribute:

The enforce: "pre" attribute sets loader1 as a pre-processing Loader, meaning that the pitch method and normal method of loader1 will be executed before the normal Loaders. This is used to perform some pre-processing operations, such as static code analysis or code style checks, before the modules are officially loaded.

Function of the Pitch Phase:

If the pitch method of a Loader returns a non-undefined, non-null, or non-empty string result, Webpack will stop the Pitch phase and start the Normal phase from that Loader.

Configuring Loaders

In the Webpack configuration file, the module.rules configuration item is used to define Loader rules. Each rule is an object containing two main properties: test and use.

  • test: A regular expression used to match the file types to be processed by the Loader.
  • use: Specifies which Loaders to apply, which can be a string or an array, executed in right-to-left order.
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/, // Matches files ending in .js
        use: ['babel-loader'], // Uses babel-loader for processing
        options: {
          presets: ['@babel/preset-env'],
        },
      },
      {
        test: /\.css$/, // Matches files ending in .css
        use: ['style-loader', 'css-loader'], // Uses css-loader first, then style-loader
      },
    ],
  },
}

Three ways to import loaders

  1. Directly use the Loader name in the configuration file:

In the Webpack configuration file, you can directly use the name of the Loader, and Webpack will automatically find and use these Loaders.

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['babel-loader'],
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
}
  1. Use the full module path:

You can also use the full module path of the Loader to ensure that the specified version of the Loader is used in the project.

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [require.resolve('babel-loader')],
      },
      {
        test: /\.css$/,
        use: [require.resolve('style-loader'), require.resolve('css-loader')],
      },
    ],
  },
}
  1. Import the Loader via require:

In the configuration file, you can also use require to import the Loader and pass it to the use array.

// webpack.config.js
const babelLoader = require.resolve('babel-loader')
const styleLoader = require.resolve('style-loader')
const cssLoader = require.resolve('css-loader')

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [babelLoader],
      },
      {
        test: /\.css$/,
        use: [styleLoader, cssLoader],
      },
    ],
  },
}

Implementing Common Loaders

First, refer to the official guide on writing a Loader.

The simplest loader

The simplest loader returns the source code unchanged, for example:

module.exports = function (source) {
  return source
}

babel-loader

Primarily uses @babel/parser to parse the source code, then traverses the AST (abstract syntax tree) with @babel/traverse, and finally regenerates the code with @babel/generator.

First, install the dependencies:

npm install @babel/parser @babel/traverse @babel/generator

Then, create a simple Babel Loader file, as shown below:

const { transform } = require('@babel/core')

function loader(source) {
  // Get the options configured for the Loader
  const options = this.getOptions()

  console.log('babel-loader: ', options)

  // Use Babel to transform the code
  const transformedCode = transform(source, {
    ...options,
    sourceMap: true,
  }).code

  return transformedCode
}

module.exports = loader

Then, use this handwritten Babel Loader in the Webpack configuration:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['./babel-loader'],
      },
    ],
  },
}

less-loader

First, you need to install the less package:

 npm install less

The main code is as follows:

const less = require('less')

module.exports = function (source) {
  // Use the less package to parse the LESS code
  less.render(source, (error, result) => {
    if (error) {
      this.emitError(error) // Pass the error to Webpack for handling
      return
    }

    // Return the converted CSS code
    this.callback(null, result.css, result.map)
  })
}

css-loader

The css-loader mainly resolves @import and url statements in CSS files, handles css-modules, and returns the result as a JS module.

module.exports = function (source) {
  console.log('css-loader source: ', source)

  const classRegex = /(?<=\.)(._?)(?={)/g // Regular expression to get all class names in the string
  const classKeyMap = Object.fromEntries(
    source.match(classRegex).map((str) => [str.trim(), str.trim()])
  ) // Extract the original CSS class names from the string
  return `/\*\***CSS_SOURCE**${source}_//\*_**CSS_CLASSKEYMAP**${JSON.stringify(classKeyMap)}_/`
}

style-loader

After being processed by the css-loader, we have obtained the complete CSS style code. The style-loader's function is to insert the result into the DOM tree as a style tag. The main source code is as follows:

module.exports = function (source) {
  console.log('style-loader', source)

  const cssSource = source.match(/(?<=**CSS_SOURCE**)((.|\s)_?)(?=\*\/)/g) // Get the CSS resource string
  const classKeyMap = source.match(/(?<=**CSS_CLASSKEYMAP**)((.|\s)_?)(?=\*\/)/g) // Get the CSS class name Map

  console.log('classKeyMap', classKeyMap)

  const script = `
    var style = document.createElement('style');
    style.innerHTML = ${JSON.stringify(cssSource)};
    document.head.appendChild(style);

    // Export classKeyMap if available
    ${classKeyMap !== null ? `module.exports = ${classKeyMap}` : ''}

`

  return script
}

file-loader && url-loader

file-loader and url-loader are two commonly used loaders in Webpack for handling and processing resource files. However, they have some differences.

file-loader is mainly used to handle file resources by outputting the resource files to a specified directory and returning a URL address for the resource file. You can specify the output directory of the resource file with the outputPath parameter and the URL address prefix of the resource file with the publicPath parameter. It copies the resource file to the specified directory and returns a string path for use in the code.

url-loader, compared to file-loader, can convert smaller files directly into base64-encoded strings to reduce network requests without loading the file locally. You can control the file size to be converted into base64 format with the limit parameter and set a fallback loader to use when the file conversion fails with the fallback parameter.

First, let's look at the implementation of file-loader:

const loaderUtils = require('loader-utils')

function fileLoader(source) {
  // Use the interpolateName function from loader-utils to generate a filename based on the content
  const filename = loaderUtils.interpolateName(this, '[name].[hash].[ext]', {
    content: source,
  })

  // this.emitFile(filename, source): Used to output the file to the output directory. It takes the filename and file content as parameters. This ensures the file is included in the output and can be referenced by other parts of the build process.
  this.emitFile(filename, source)

  return `module.exports="${filename}"`
}

// Set the raw property of the loader to true, indicating that the loader processes binary data. Here, it means that the file content is read and output as a Buffer.
fileLoader.raw = true

module.exports = fileLoader

Now, let's look at the implementation of url-loader:

function urlLoader(source) {
  console.warn('url-loader: ', source.size)

  // Get the options (configuration) for the loader
  const options = this.getOptions() || { limit: 20480 }

  // If the file size is less than the specified threshold, convert it to a Data URL
  if (options.limit && source.length < options.limit) {
    const base64 = Buffer.from(source, 'binary').toString('base64')
    return `module.exports="data:${
      this.resourceMimeType || 'application/octet-stream'
    };base64,${base64}"`
  }

  // Otherwise, use file-loader to handle it
  return require('../file-loader').call(this, source)
}

urlLoader.raw = true
module.exports = urlLoader

Corresponding Webpack configuration:

{
test: /\.(png|jpg)$/,
use: [
    {
      loader: "./loaders/url-loader",
      options: {
        limit: 20480, // Convert images smaller than 20kb to base64
      },
    },
  ],
}

The final result is as follows:

The source code can be found here.

Some Business Practices

  1. Removing the selection in antd
// rm-selection-loader.js
// Remove ::selection in style files because ::selection is difficult to cancel
module.exports = function runtime(params) {
 return params.replace(/::selection \{[^}]+\}/g, "");
};

// config.js
config.module
.rule("less-in-node_modules")
.use("custom")
.before("css-loader")
.loader(path.resolve(\_\_dirname, "./rm-selection-loader.js"));
Built with
Copyright © 2024
York's Blog - York's coding journey