在上一篇文章《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-loader和url-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-loader在file-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.time和console.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 操作实现精准代码注入
- ✅ 配置化过滤,精准控制埋点范围
🚀 实践建议
- 功能单一:每个 loader 只专注一个转换任务,保持可维护性
- 链式组合:善用 loader 链实现复杂处理流程
- 错误处理:完善的错误提示能显著提升开发体验
- 缓存优化 :对耗时操作合理使用缓存提升构建性能 Loader 作为 Webpack 的核心扩展机制,掌握了它就意味着能够自如地处理任何类型的资源文件。本文的自定义函数计时 loader 案例展示了 loader 的强大灵活性,从"使用者"到"创造者"的转变 ,希望大家能在实际项目中灵活运用这些知识,创造更多实用的自定义 loader。在接下来的文章中,我们将深入探讨
Webpack 的插件系统Plugin