Skip to content
Go back

Writing Babel Plugins From Scratch

Published:  at  10:30 AM

文章目录

Introduction

Babel is a JavaScript compiler that transforms code through plugins. Understanding how to write Babel plugins enables you to create custom code transformations, tooling, and automated refactoring. This article covers the fundamental concepts and APIs, then builds a real plugin from scratch.

Understanding Babel Pipeline

Before writing plugins, understand how Babel processes code:

  1. Parse: Source code → AST (Abstract Syntax Tree)
  2. Transform: AST → Modified AST (via plugins/presets)
  3. Generate: Modified AST → Output code

Babel Plugin Structure

A Babel plugin is a function that receives babel object and returns a visitor object:

// Basic plugin structure
module.exports = function (babel) {
  // babel contains: types, template, traverse, generate, etc.
  const { types: t } = babel;

  return {
    // Visitor object - keys are AST node types
    visitor: {
      // Called when traversing Identifier nodes
      Identifier(path, state) {
        // Transform logic here
      },
    },
  };
};

Core APIs

types (t)

The types module provides AST node constructors and type checking:

const { types: t } = babel;

// Type checking
t.isIdentifier(node); // Check node type
t.isIdentifier(node, { name: "x" }); // Check with properties
t.isNumericLiteral(node); // Check for number literals

// Node construction
t.identifier("x"); // Create: x
t.numericLiteral(42); // Create: 42
t.stringLiteral("hello"); // Create: 'hello'
t.binaryExpression("+", left, right); // Create: left + right
t.callExpression(callee, args); // Create: callee(args)
t.memberExpression(obj, prop); // Create: obj.prop
t.arrowFunctionExpression(params, body); // Create: (params) => body
t.functionDeclaration(id, params, body); // Create: function id(params) {}
t.variableDeclaration("const", [declarator]); // Create: const x = y
t.objectExpression([property]); // Create: { key: value }
t.arrayExpression([elements]); // Create: [elements]
t.templateLiteral(quasis, exprs); // Create: `string ${expr}`

// Node manipulation
t.cloneNode(node); // Deep clone a node
t.removeProperties(node); // Remove special properties
t.validate(node, key, value); // Validate node property

traverse

The traverse module provides manual AST traversal:

const traverse = require("@babel/traverse").default;

// Traverse AST manually
traverse(ast, {
  Identifier(path) {
    console.log(path.node.name);
    // Traverse into children (default: true)
    // path.skip(); // Skip children
    // path.stop(); // Stop traversal
  },
  FunctionDeclaration(path) {
    // Access parent node
    const parent = path.parent;
    // Access sibling nodes
    const siblings = path.parentPath.container;
    // Get scope
    const scope = path.scope;
  },
});

template

The template module creates AST from template strings:

const template = require("@babel/template").default;

// Simple template
const buildRequire = template(`
  require(SOURCE)
`);
// Usage: buildRequire({ SOURCE: t.stringLiteral('./module') })

// Template with placeholders
const buildWrapper = template(`
  (function() {
    BODY;
  })()
`);
// Usage: buildWrapper({ BODY: statementNode })

// Template with exports
const buildExport = template(
  `
  module.exports = EXPRESSION
`,
  { plugins: ["proposal-export-default-from"] }
);

generate

The generate module converts AST back to code:

const generate = require("@babel/generate").default;

// Generate code from AST
const output = generate(
  ast,
  {
    sourceMaps: true, // Generate source maps
    comments: true, // Include comments
    compact: false, // Minify output
  },
  code
);

// output = { code: '...', map: sourceMap }

Understanding Path and State

Path Object

The path object represents the location and traversal of a node:

Identifier(path) {
  // Node properties
  path.node;           // The current AST node
  path.parent;         // Parent node
  path.parentPath;     // Parent path

  // Traversal control
  path.skip();         // Skip traversing children
  path.stop();         // Stop entire traversal

  // Node replacement
  path.replaceWith(node);           // Replace with single node
  path.replaceWithMultiple(nodes);  // Replace with multiple nodes
  path.replaceWithSourceString(code); // Replace with code string

  // Node removal
  path.remove();       // Remove this node

  // Scope info
  path.scope;          // Current scope

  // Binding info
  path.getBinding('name'); // Get variable binding
  path.bindings;       // All bindings in scope
}

State Object

The state object contains plugin options and metadata:

// Plugin receives options
module.exports = function (babel) {
  return {
    visitor: {
      Program(path, state) {
        // state.opts = { optionName: value } from plugin options
        // state.filename = current file name
        // state.file = { ast, code, opts }
      },
    },
  };
};

// Usage: { plugins: [ ['plugin-name', { optionName: value }] ] }

Plugin Example: Auto Dependency Injection

Let’s build a practical plugin that automatically injects dependencies:

// transform-deps.js - Auto inject dependencies based on usage

module.exports = function (babel) {
  const { types: t, template } = babel;

  // Template for import statement
  const importTemplate = template(`
    import IDENTIFIER from 'MODULE';
  `);

  return {
    visitor: {
      // Handle program entry - inject at top level
      Program: {
        enter(path, state) {
          // Collect all used identifiers that need injection
          const depsToInject = new Set();
          const options = state.opts || {};
          const importMap = options.imports || {};

          // Collect used identifiers throughout the file
          path.traverse({
            Identifier(idPath) {
              const name = idPath.node.name;
              // Skip if it's a declaration or local variable
              if (idPath.scope.hasBinding(name)) return;
              // Check if this identifier needs injection
              if (importMap[name]) {
                depsToInject.add(name);
              }
            },
          });

          // Get existing imports to avoid duplicates
          const existingImports = new Set();
          path.traverse({
            ImportDeclaration(importPath) {
              importPath.node.specifiers.forEach(spec => {
                if (t.isImportDefaultSpecifier(spec)) {
                  existingImports.add(spec.local.name);
                }
              });
            },
          });

          // Inject missing imports at the top
          const body = path.node.body;
          let insertIndex = 0;

          // Skip shebang if present
          if (t.isStringLiteral(body[0]) && body[0].value.startsWith("#!")) {
            insertIndex = 1;
          }

          // Insert imports in reverse order to maintain index
          const depsArray = Array.from(depsToInject).filter(
            dep => !existingImports.has(dep)
          );

          depsArray.forEach(depName => {
            const importNode = importTemplate({
              IDENTIFIER: t.identifier(depName),
              MODULE: t.stringLiteral(importMap[depName]),
            });
            body.splice(insertIndex, 0, importNode);
          });
        },
      },

      // Transform: console.log -> logger.log
      CallExpression(path, state) {
        const callee = path.node.callee;
        const options = state.opts || {};
        const transforms = options.transforms || {};

        // Transform console.log
        if (
          t.isMemberExpression(callee) &&
          t.isIdentifier(callee.object, { name: "console" }) &&
          t.isIdentifier(callee.property) &&
          transforms.console
        ) {
          // Replace console.log with logger.log
          path.node.callee = t.memberExpression(
            t.identifier("logger"),
            callee.property
          );
        }
      },
    },
  };
};

Plugin Usage Configuration

// .babelrc or babel.config.js
{
  "plugins": [
    [
      "./transform-deps.js",
      {
        "imports": {
          "React": "react",
          "lodash": "lodash",
          "axios": "axios"
        },
        "functionDeps": {
          "fetchData": [{ "name": "fetch", "source": "./utils/fetch" }]
        },
        "transforms": {
          "console": true,
          "promisify": true
        }
      }
    ]
  ]
}

Common Plugin Patterns

Pattern 1: Conditional Transformation

Identifier(path, state) {
  // Only transform in certain contexts
  if (!state.opts.enable) return;

  // Only transform in specific files
  if (state.filename?.includes('node_modules')) return;

  // Apply transformation
}

Pattern 2: Scope-Aware Transformation

Identifier(path) {
  const name = path.node.name;

  // Get binding information
  const binding = path.scope.getBinding(name);

  if (!binding) {
    // This is a free variable (not declared locally)
    // Safe to transform
  }
}

Pattern 3: Replace with Multiple Nodes

ReturnStatement(path) {
  // Replace single return with multiple statements
  path.replaceWithMultiple([
    t.expressionStatement(
      t.callExpression(
        t.identifier('console.log'),
        [t.stringLiteral('returning')]
      )
    ),
    path.node // Keep original return
  ]);
}

Pattern 4: Hoist Function Definitions

FunctionDeclaration(path) {
  // Get binding and all references
  const binding = path.scope.getBinding(path.node.id.name);

  if (binding && binding.references > 0) {
    // Move to top of parent scope
    path.scope.parent.push({
      kind: 'var',
      declare: true,
      id: path.node.id,
      init: t.functionExpression(
        null,
        path.node.params,
        path.node.body
      )
    });
    path.remove();
  }
}

Best Practices

  1. Always check node types before manipulation

    if (!t.isIdentifier(node)) return;
  2. Handle scope correctly to avoid variable conflicts

    const binding = path.scope.getBinding("varName");
    if (binding?.scope !== path.scope) return;
  3. Use template literals instead of building AST manually

    // Good
    template(`require(SOURCE)`);
    
    // Avoid
    t.callExpression(t.identifier("require"), [source]);
  4. Consider edge cases:

    // Handle missing properties
    const name = path.node.id?.name;
    
    // Handle empty arrays
    if (path.node.body.body?.length === 0) return;
  5. Test thoroughly with various input patterns

Testing Babel Plugins

// test-plugin.js
const babel = require("@babel/core");
const plugin = require("./transform-deps");

const code = `
  const x = 1;
  console.log(x);
`;

const result = babel.transformSync(code, {
  plugins: [[plugin, { transforms: { console: true } }]],
  filename: "test.js",
});

console.log(result.code);
const core = require('@babel/core');
const types = require('@babel/types');
const template = require('@babel/template').default;
const importModule = require('@babel/helper-module-imports');

const autoTrackerPlugin = (options) => {
    return {
        visitor: {
            Program: {
                enter(path, state) {
                    let loggerId;
                    path.traverse({
                        ImportDeclaration(path) {
                            const importedModuleName = path.get('source').node.value;
                            if (importedModuleName == options.name) {
                                const specifierPath = path.get('specifiers.0');
                                if (specifierPath.isImportDefaultSpecifier() ||
                                    specifierPath.isImportSpecifier() ||
                                    specifierPath.isImportNamespaceSpecifier()) {
                                    loggerId = specifierPath.node.local.name;
                                    path.stop();
                                }
                            }
                        }
                    });
                    if (!loggerId) {
                        loggerId = importModule.addDefault(path, options.name, {
                            nameHint: path.scope.generateUid(options.name)
                        });
                    }
                    state.loggerNode = template.statement(`LOGGER();`)({
                        LOGGER: loggerId
                    });
                },
            },
            "FunctionDeclaration|FunctionExpression|ArrowFunctionExpression|ClassMethod"(path, state) {
                const { node } = path;
                if (types.isBlockStatement(node.body)) {
                    node.body.body.unshift(state.loggerNode);
                } else {
                    const newNode = types.blockStatement([
                        state.loggerNode,
                        types.expressionStatement(node.body)
                    ]);
                    path.get('body').replaceWith(newNode);
                }
            }
        }
    };
};

import _logger from "logger";

function sum(a, b) {
    _logger();
    return a + b;
}

const multiply = function (a, b) {
    _logger();
    return a * b;
};

const minus = (a, b) => {
    _logger();
    return a - b;
};

class Calculator {
    // Calculator类的实现
}


Previous Post
Blind75 leetcode
Next Post
Webpack's async import