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
相关推荐
aggression3 小时前
代码敲击乐:让你了解前端的动静结合和移动端的适配性
前端
yinuo3 小时前
深入理解与实战 Git Submodule
前端
骑自行车的码农3 小时前
React 事件收集函数
前端·react.js
一个处女座的程序猿O(∩_∩)O3 小时前
Vue CLI 插件开发完全指南:从原理到实战
前端·javascript·vue.js
小蜜蜂dry3 小时前
JavaScript 原型
前端·javascript
用户90443816324603 小时前
前端也能玩 AI?用 brain.js 在浏览器里训个 "前后端分类大师",后端同事看了都沉默!
前端
祈祷苍天赐我java之术3 小时前
什么是Nginx?:掌握高性能 Web 服务器核心技术
服务器·前端·nginx
Achieve前端实验室4 小时前
【每日一面】async/await 的原理
前端·javascript·面试
姜至4 小时前
el-calendar实现自定义展示效果
前端·vue.js