Webpack系列-Loader

在上一篇文章《Webpack系列-Output出口》中,我们详细讲解了Webpack的输出配置。今天,我们将深入探讨Webpack的另一个核心概念------loader。loader就像是Webpack的"翻译官",负责将各种类型的文件转换为Webpack能够处理的模块。

什么是loader

loader是Webpack的核心功能之一,它让Webpack能够处理非JavaScript文件(如CSS、图片、字体等),将这些文件转换为有效的模块,从而可以被添加到依赖图中。

loader的核心特性:

  • 链式调用:loader可以链式调用,每个loader处理后的结果会传递给下一个loader
  • 同步/异步:loader可以是同步的,也可以是异步的
  • 功能单一:每个loader应该只负责一个转换功能
  • 模块化:loader返回的必须是标准的JavaScript模块

常用loader配置

处理样式文件

css文件处理

js 复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /.css$/,
        // 执行顺序:从下往上依次执行
        use: [
          'style-loader', // 将CSS注入到DOM中
          'css-loader'    // 解析CSS文件
        ]
      }
    ]
  }
};

scss文件处理

js 复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /.css$/,
        // 执行顺序:从下往上依次执行
        use: [
          'style-loader', // 将CSS注入到DOM中
          'css-loader',    // 解析CSS文件
          'sass-loader' // 将scss文件转换成css 
        ]
      }
    ]
  }
};

less文件处理

js 复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /.css$/,
        // 执行顺序:从下往上依次执行
        use: [
          'style-loader', // 将CSS注入到DOM中
          'css-loader',    // 解析CSS文件
          'less-loader' // 将less文件转换成css 
        ]
      }
    ]
  }
};

处理图片/字体资源

Webpack5之前使用file-loaderurl-loader处理图片/字体资源

file-loader

js 复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|jpeg|gif|svg)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: 'images/[name].[hash:8].[ext]',
              outputPath: 'assets/'
            }
          }
        ]
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: 'fonts/[name].[hash:8].[ext]'
            }
          }
        ]
      }
    ]
  }
};

url-loader

url-loaderfile-loader的基础上添加文件大小的判断的不同处理方式。比较推荐的方式。

js 复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|jpeg|gif|svg)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192, // 8KB以下的文件转换为base64
              fallback: 'file-loader', // 超过限制使用file-loader
              name: 'images/[name].[hash:8].[ext]'
            }
          }
        ]
      }
    ]
  }
};

综合整体配置

js 复制代码
// webpack.config.js
module.exports = {
  module: {
    rules: [
      // JavaScript/JSX
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: 'babel-loader'
      },
      // TypeScript/TSX
      {
        test: /\.(ts|tsx)$/,
        exclude: /node_modules/,
        use: 'ts-loader'
      },
      // CSS/SCSS
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.scss$/,
        use: ['style-loader', 'css-loader', 'sass-loader']
      },
      // 图片
      {
        test: /\.(png|jpg|jpeg|gif|svg)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
              name: 'images/[name].[hash:8].[ext]'
            }
          }
        ]
      },
      // 字体
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: 'fonts/[name].[hash:8].[ext]'
            }
          }
        ]
      }
    ]
  }
};

Loader底层原理

loader本质上就是一个导出函数的JS模块而已,它接收源文件内容作为参数,并返回处理后的内容。

js 复制代码
// 简单loader的例子
module.exports = function(source) {
  // source是源文件内容
  const result = source.replace(/console.log(.*);/g, ''); // 移除console.log
  return result;
};

loader的执行机制

loader执行时,主要分为两个阶段:

  • pitch阶段 从左往右执行(与配置的顺序一致)
  • normal阶段 从右往左执行,也就是实际处理阶段

Pitch阶段

每个loader都可以导出一个pitch的方法,它会在Loader的normal阶段之前执行。主要作用:

  • 提前拦截处理流程
  • 传递额外信息给到后续loader
  • 实现一些预加载逻辑处理

关键特性: pitch的熔断机制

如果某个 Loader 的pitch方法返回了一个非undefined的值,整个流程会立即终止 ,并从当前 Loader 的normal阶段开始 "往回走"。

假设Webpack配置了3个loader顺序为:

js 复制代码
use: ['loader1','loader2','loader3']

正常的执行流程为:

若loader2.pitch方法返回非undefined的值:执行流程则为:

loader的执行顺序也可以通过enforce属性进行改变:

  • enforce: 'pre' 前置Loader,优先执行
  • enforce: 'post' 后置Loader,最后执行

自定义loader

在实际项目中,经常需要对特定函数添加console.timeconsole.timeEnd来检查函数执行时间,来判断函数是否消耗性能。手动添加不仅繁琐,还容易遗漏,这时候可以通过自定义 Loader 自动实现。

代码实现

js 复制代码
/**
 * 函数计时埋点 Webpack Loader
 * 功能:自动在指定函数中注入计时逻辑,用于性能监控和函数执行时间统计
 * 支持:ES6+、JSX、TypeScript 语法,可通过配置过滤需要监控的函数
 */

// 导入依赖模块
const parser = require("@babel/parser"); // Babel 解析器,用于将代码解析为 AST
const traverse = require("@babel/traverse").default; // AST 遍历工具
const generator = require("@babel/generator").default; // AST 生成代码工具
const t = require("@babel/types"); // Babel 类型工具,用于创建和判断 AST 节点
const crypto = require("crypto"); // 加密模块,用于生成内容哈希

// 全局 AST 缓存:使用文件内容的 MD5 哈希作为键,存储处理后的代码
// 作用:避免对相同内容的文件重复处理,提升构建性能
const astCache = new Map();

/**
 * Loader 主函数
 * Webpack Loader 本质是一个函数,接收源代码作为输入,返回处理后的代码
 * @param {string} source - 输入的源代码
 * @returns {void} 通过 callback 返回处理结果
 */
module.exports = function (source) {
  // 获取异步回调函数(Webpack 支持异步 Loader)
  const callback = this.async();
  // 获取 Loader 的配置选项(通过 webpack.config.js 传入)
  const options = this.getOptions() || {};

  // 1. 检查缓存:通过内容哈希判断是否已处理过该代码
  const contentHash = crypto.createHash("md5").update(source).digest("hex");
  if (astCache.has(contentHash)) {
    // 缓存命中,直接返回缓存结果
    return callback(null, astCache.get(contentHash));
  }

  // 2. 避免重复注入:检查代码中是否已包含埋点标记
  if (source.includes("__TIME_TRACKER_MARKER__")) {
    astCache.set(contentHash, source); // 缓存未修改的内容
    return callback(null, source);
  }

  try {
    // 3. 解析源代码为 AST(抽象语法树)
    // 配置支持的语法:ES 模块、JSX、TypeScript 及各种 ES6+ 特性
    const ast = parser.parse(source, {
      sourceType: "module", // 按 ES 模块解析
      plugins: [
        "jsx", // 支持 JSX 语法(React)
        "typescript", // 支持 TypeScript
        "asyncGenerators", // 支持异步生成器
        "classProperties", // 支持类属性
        "dynamicImport", // 支持动态导入
      ],
    });

    // 4. 遍历 AST 并注入计时逻辑
    // 处理三种函数类型:函数声明、函数表达式、箭头函数
    traverse(ast, {
      FunctionDeclaration(path) {
        // 函数声明(如 function foo() {})
        if (shouldTrackFunction(path, options)) {
          injectTimeTracking(path, options);
        }
      },
      FunctionExpression(path) {
        // 函数表达式(如 const foo = function() {})
        if (shouldTrackFunction(path, options)) {
          injectTimeTracking(path, options);
        }
      },
      ArrowFunctionExpression(path) {
        // 箭头函数(如 const foo = () => {})
        if (shouldTrackFunction(path, options)) {
          injectTimeTracking(path, options);
        }
      },
    });

    // 5. 将处理后的 AST 转换回代码
    // 保留注释,确保生成的代码可读性
    const { code } = generator(ast, { comments: true }, source);
    astCache.set(contentHash, code); // 缓存处理结果
    callback(null, code); // 返回处理后的代码
  } catch (err) {
    // 错误处理:将错误传递给 Webpack
    callback(err);
  }
};

/**
 * 判断函数是否需要被埋点(基于配置的过滤规则)
 * @param {babel.NodePath} path - 函数节点的路径对象(包含节点信息及操作方法)
 * @param {Object} options - Loader 配置选项,包含 include 和 exclude 规则
 * @returns {boolean} 是否需要为该函数注入计时逻辑
 */
function shouldTrackFunction(path, options) {
  const { include = [], exclude = [] } = options;
  // 获取函数名称(用于匹配过滤规则)
  const funcName = getFunctionName(path);

  // 匿名函数默认不处理,除非配置中包含 "*"(强制包含所有函数)
  if (!funcName && !include.includes("*")) return false;

  // 排除规则优先:如果函数名匹配排除列表,则不处理
  if (matchRule(funcName, exclude)) return false;

  // 包含规则:如果匹配包含列表则处理,默认处理所有函数(当 include 为空时)
  return include.length === 0 || matchRule(funcName, include);
}

/**
 * 检查函数名是否匹配规则(支持字符串通配符和正则表达式)
 * @param {string} funcName - 函数名称
 * @param {Array<string|RegExp>} rules - 规则列表,元素可以是字符串(支持*通配符)或正则表达式
 * @returns {boolean} 是否匹配任何规则
 */
function matchRule(funcName, rules) {
  return rules.some((rule) => {
    if (typeof rule === "string") {
      // 处理字符串规则,支持通配符*(如"fetch*"匹配所有以fetch开头的函数)
      // 将通配符转换为正则表达式(* -> .*)
      const reg = new RegExp(`^${rule.replace(/\*/g, ".*")}$`);
      return reg.test(funcName);
    }
    if (rule instanceof RegExp) {
      // 处理正则表达式规则
      return rule.test(funcName);
    }
    return false;
  });
}

/**
 * 向函数节点注入计时逻辑
 * @param {babel.NodePath} path - 函数节点的路径对象
 * @param {Object} options - Loader 配置选项,支持自定义计时函数
 */
function injectTimeTracking(path, options) {
  const node = path.node; // 获取函数节点
  const body = node.body; // 获取函数体
  const isAsyncFunction = node.async; // 判断是否为 async 函数
  // 获取或生成函数名(匿名函数生成唯一标识)
  const funcName =
    getFunctionName(path) ||
    `anonymous_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
  // 生成唯一的计时标签(用于区分不同函数的计时)
  const timeLabel = `__TIME_TRACKER_${funcName}__`;

  // 为函数添加标记注释,避免重复注入
  t.addComment(
    body,
    "leading", // 注释位置:函数体最前面
    "__TIME_TRACKER_MARKER__", // 标记内容
    true // 创建块注释(/* ... */)
  );

  // 获取配置的计时函数(默认使用 console.time 和 console.timeEnd)
  const { startFn = "console.time", endFn = "console.timeEnd" } = options;

  // 生成计时开始语句(如 console.time('__TIME_TRACKER_foo__'))
  const timeStart = t.expressionStatement(
    t.callExpression(parseFunctionPath(startFn), [t.stringLiteral(timeLabel)])
  );

  // 生成计时结束语句(如 console.timeEnd('__TIME_TRACKER_foo__'))
  const timeEnd = t.expressionStatement(
    t.callExpression(parseFunctionPath(endFn), [t.stringLiteral(timeLabel)])
  );
  // 提取计时结束的表达式部分(用于包裹返回值)
  const timeEndExpr = timeEnd.expression;

  // 处理函数体:区分块级函数体({} 包裹)和表达式体(箭头函数简写)
  if (t.isBlockStatement(body)) {
    // 块级函数体:在函数体开头插入计时开始语句
    body.body.unshift(timeStart);
    // 处理函数体中的 return 语句,确保计时结束逻辑被执行
    wrapReturnStatements(body.body, timeEnd, timeEndExpr, isAsyncFunction);
  } else {
    // 表达式体(如 () => expr):转换为块级函数体并注入计时逻辑
    const originalExpr = body; // 原表达式
    node.body = t.blockStatement([
      timeStart, // 计时开始
      // 包裹原表达式并返回,确保计时结束
      t.returnStatement(
        wrapWithTimeEnd(originalExpr, timeEndExpr, isAsyncFunction)
      ),
    ]);
  }
}

/**
 * 解析函数路径字符串为 AST 节点
 * 用于处理自定义计时函数(如 "window.tracker.start" 转换为对应的 MemberExpression)
 * @param {string} path - 函数路径字符串(如 "console.time"、"tracker.end")
 * @returns {babel.Node} 对应的 AST 节点
 */
function parseFunctionPath(path) {
  const parts = path.split("."); // 分割路径(如 ["console", "time"])
  // 从左到右构建成员表达式(如 console.time -> MemberExpression(Identifier('console'), Identifier('time'))
  return parts.reduce((acc, part, index) => {
    if (index === 0) {
      // 第一个部分为标识符(如 "console" -> Identifier('console'))
      return t.identifier(part);
    }
    // 后续部分构建成员表达式(如 acc.time -> MemberExpression(acc, Identifier('time'))
    return t.memberExpression(acc, t.identifier(part));
  }, null);
}

/**
 * 获取函数名称(优先显式名称,其次从上下文推断)
 * @param {babel.NodePath} path - 函数节点的路径对象
 * @returns {string|undefined} 函数名称(匿名函数返回 undefined)
 */
function getFunctionName(path) {
  const node = path.node;

  // 1. 函数声明(如 function foo() {})直接取 id 的名称
  if (t.isFunctionDeclaration(node) && node.id) {
    return node.id.name;
  }

  // 2. 变量声明中的函数表达式(如 const foo = function() {})
  if (path.parentPath.isVariableDeclarator() && path.parentPath.node.id) {
    return t.isIdentifier(path.parentPath.node.id)
      ? path.parentPath.node.id.name
      : undefined;
  }

  // 3. 对象属性中的函数(如 { foo: function() {} })
  if (path.parentPath.isObjectProperty() && path.parentPath.node.key) {
    return t.isIdentifier(path.parentPath.node.key)
      ? path.parentPath.node.key.name
      : undefined;
  }

  // 4. 类方法(如 class A { foo() {} })
  if (path.parentPath.isClassMethod() && path.parentPath.node.key) {
    return t.isIdentifier(path.parentPath.node.key)
      ? path.parentPath.node.key.name
      : undefined;
  }

  // 匿名函数(如 () => {}、function() {})
  return undefined;
}

/**
 * 处理块级函数体中的 return 语句,确保计时结束逻辑被执行
 * @param {babel.Node[]} body - 函数体中的语句数组
 * @param {babel.Node} timeEnd - 计时结束语句(ExpressionStatement)
 * @param {babel.Node} timeEndExpr - 计时结束表达式(CallExpression)
 * @param {boolean} isAsyncFunction - 是否为异步函数
 */
function wrapReturnStatements(body, timeEnd, timeEndExpr, isAsyncFunction) {
  body.forEach((stmt, index) => {
    if (t.isReturnStatement(stmt)) {
      // 对每个 return 语句进行包裹,确保返回前执行计时结束
      body[index] = t.returnStatement(
        wrapWithTimeEnd(stmt.argument, timeEndExpr, isAsyncFunction)
      );
    }
  });

  // 如果函数没有 return 语句,在函数体末尾添加计时结束
  if (!body.some((stmt) => t.isReturnStatement(stmt))) {
    body.push(timeEnd);
  }
}

/**
 * 用计时结束逻辑包裹表达式(处理同步/异步返回值场景)
 * @param {babel.Node} expr - 原表达式(return 后面的内容)
 * @param {babel.Node} timeEndExpr - 计时结束表达式
 * @param {boolean} isAsyncFunction - 是否为异步函数
 * @returns {babel.Node} 包裹后的表达式
 */
function wrapWithTimeEnd(expr, timeEndExpr, isAsyncFunction) {
  if (isAsyncFunction) {
    // 1. 处理 async 函数:使用 Promise.finally 确保计时结束
    // async 函数返回的是 Promise,通过 finally 无论成功失败都执行计时结束
    return t.callExpression(
      t.memberExpression(
        // 将返回值包装为 Promise(处理 return undefined 的情况)
        t.callExpression(
          t.memberExpression(t.identifier("Promise"), t.identifier("resolve")),
          [expr || t.identifier("undefined")]
        ),
        t.identifier("finally") // 调用 finally 方法
      ),
      [t.arrowFunctionExpression([], timeEndExpr)] // finally 的回调函数
    );
  }

  // 2. 处理 new Promise(...) 调用:识别 Promise 实例并添加 finally
  if (isNewPromiseCall(expr)) {
    return t.callExpression(
      t.memberExpression(expr, t.identifier("finally")), // 调用 Promise.finally
      [t.arrowFunctionExpression([], timeEndExpr)]
    );
  }

  // 3. 处理已知异步调用(如 fetch、axios.get 等)
  if (t.isCallExpression(expr)) {
    const callee = expr.callee;
    // 判断是否为已知的异步调用(fetch 或 axios 风格的 HTTP 方法)
    const isKnownAsyncCall =
      t.isIdentifier(callee, { name: "fetch" }) || // fetch()
      (t.isMemberExpression(callee) &&
        ["get", "post", "put", "delete"].includes(callee.property.name)); // axios.get() 等
    if (isKnownAsyncCall) {
      return t.callExpression(
        t.memberExpression(expr, t.identifier("finally")),
        [t.arrowFunctionExpression([], timeEndExpr)]
      );
    }
  }

  // 4. 同步场景:使用序列表达式,先执行计时结束再返回原表达式
  return t.sequenceExpression([
    timeEndExpr, // 执行计时结束
    expr || t.identifier("undefined"), // 返回原表达式结果(处理 return; 的情况)
  ]);
}

/**
 * 判断表达式是否为 new Promise(...) 调用
 * @param {babel.Node} expr - 待判断的表达式
 * @returns {boolean} 是否为 new Promise 调用
 */
function isNewPromiseCall(expr) {
  // 条件1:是 new 表达式(如 new XXX(...))
  if (!t.isNewExpression(expr)) return false;
  // 条件2:new 的目标是 Promise 构造函数
  if (!t.isIdentifier(expr.callee, { name: "Promise" })) return false;
  // 条件3:Promise 构造函数至少有一个参数(通常是 executor 函数)
  return expr.arguments.length >= 1;
}

测试验证

1. 配置webpack

js 复制代码
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { type } = require("os");
const lib = require("vuepress-theme-meteorlxy");
module.exports = {
  mode: "development",
  entry: ["./src/index.js"],
  devtool: "inline-source-map",
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
    library: {
      name: "SelfLibrary",
      type: "umd",
      export: "default",
    },
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: [
          {
            loader: path.resolve(__dirname, "./loaders/track-loader.js"),
            options: {
              include: ["joinString", "test", "fetchImage"],
              exclude: [],
            },
          },
        ],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./index.html",
      title: "Webpack Test",
    }),
  ],
};

2. 测试代码

js 复制代码
// src/index.js
console.log(`Hello Webpack!`);
import { join } from "lodash";
function joinString(...sars) {
  return join(sars, " ");
}
function test() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(joinString("Hello", "Webpack"));
    }, 1000);
  });
}
function fetchImage() {
  return new Promise((resolve) => {
    fetch("https://picsum.photos/200/300").then((res) => {
      resolve(res);
    });
  });
}
export default { joinString, test, fetchImage };

3. 测试效果

小结

通过本文的学习,我们掌握了 Webpack loader 的核心知识和实践技巧:

核心要点

  • Loader 本质:接收源文件内容并返回处理结果的函数模块
  • 执行机制:链式调用 + pitch 阶段拦截
  • 配置实践:熟悉常用 loader 的配置方式

自定义Loader实践

本文实现了一个函数计时埋点loader,主要功能:

  • ✅ 自动为指定函数添加性能计时逻辑
  • ✅ 支持同步/异步函数,完美处理 Promise
  • ✅ 基于 AST 操作实现精准代码注入
  • ✅ 配置化过滤,精准控制埋点范围

🚀 实践建议

  1. 功能单一:每个 loader 只专注一个转换任务,保持可维护性
  2. 链式组合:善用 loader 链实现复杂处理流程
  3. 错误处理:完善的错误提示能显著提升开发体验
  4. 缓存优化 :对耗时操作合理使用缓存提升构建性能 Loader 作为 Webpack 的核心扩展机制,掌握了它就意味着能够自如地处理任何类型的资源文件。本文的自定义函数计时 loader 案例展示了 loader 的强大灵活性,从"使用者"到"创造者"的转变 ,希望大家能在实际项目中灵活运用这些知识,创造更多实用的自定义 loader。在接下来的文章中,我们将深入探讨 Webpack 的插件系统Plugin
相关推荐
恋猫de小郭8 分钟前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅7 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了8 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅8 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅8 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅9 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊9 小时前
jwt介绍
前端