返回首页

是否写过 Loader?简单描述一下编写 loader 的思路?

问题解析

Loader 是 Webpack 的核心概念之一,用于对模块的源代码进行转换。理解如何编写 loader 是深入掌握 Webpack 的重要一步。

核心概念

Loader 的特点

特性 说明
链式调用 多个 loader 可以串联使用
单一职责 每个 loader 只做一件事
Node.js 环境 运行在 Node.js 中,可调用任意 Node API
无状态 不保留状态,确保可复用
异步优先 尽可能使用异步方式

Loader 的两种模式

模式 说明 使用场景
Normal 默认模式,处理 UTF-8 字符串 文本文件处理
Raw 处理二进制数据(Buffer) 图片、字体等文件

详细解答

最简单的 Loader 示例

// my-loader.js
module.exports = function(source) {
  // source: 模块源代码字符串

  // 对 source 进行处理
  const result = source.replace(/console\.log\(.*\);?/g, '');

  // 返回处理后的代码
  return result;
};

异步 Loader

// async-loader.js
module.exports = function(source) {
  // 声明异步操作
  const callback = this.async();

  // 模拟异步操作
  setTimeout(() => {
    const result = source.toUpperCase();

    // 第一个参数:错误对象(无错误则为 null)
    // 第二个参数:处理后的源代码
    // 第三个参数:source map(可选)
    // 第四个参数:附加数据(可选)
    callback(null, result);
  }, 100);
};

处理二进制文件的 Loader

// raw-loader.js
module.exports = function(source) {
  // 当 raw = true 时,source 是 Buffer
  // 可以对图片、字体等二进制文件进行处理

  const size = source.length;
  const base64 = source.toString('base64');

  return `module.exports = "data:image/png;base64,${base64}";`;
};

// 声明处理原始数据
module.exports.raw = true;

使用工具库编写 Loader

// 使用 loader-utils 和 schema-utils
const { getOptions } = require('loader-utils');
const { validate } = require('schema-utils');

// 定义选项的 JSON Schema
const schema = {
  type: 'object',
  properties: {
    text: {
      type: 'string'
    },
    transform: {
      type: 'string',
      enum: ['uppercase', 'lowercase', 'capitalize']
    }
  },
  additionalProperties: false
};

module.exports = function(source) {
  // 获取 loader 选项
  const options = getOptions(this) || {};

  // 验证选项
  validate(schema, options, {
    name: 'My Loader',
    baseDataPath: 'options'
  });

  const { text = '', transform = 'uppercase' } = options;

  let result = source;

  // 根据选项转换
  switch (transform) {
    case 'uppercase':
      result = source.toUpperCase();
      break;
    case 'lowercase':
      result = source.toLowerCase();
      break;
    case 'capitalize':
      result = source.replace(/\b\w/g, c => c.toUpperCase());
      break;
  }

  // 添加自定义文本
  result = `${text}\n${result}`;

  return result;
};

深入理解

Loader 的 Pitch 阶段

// pitch-loader.js
module.exports = function(source) {
  console.log('Normal 阶段');
  return source;
};

// Pitch 阶段:在 loader 链中从左到右执行
module.exports.pitch = function(remainingRequest, precedingRequest, data) {
  console.log('Pitch 阶段');
  console.log('Remaining request:', remainingRequest);
  console.log('Preceding request:', precedingRequest);

  // 可以在这里提前返回,中断 loader 链
  // return 'module.exports = "pitched";';
};

Loader 上下文(this)

module.exports = function(source) {
  // this 指向 loader 上下文

  // 获取 loader 选项
  const options = this.getOptions();

  // 声明异步
  const callback = this.async();

  // 添加依赖文件(用于 watch 模式)
  this.addDependency('/path/to/dependency');

  // 发出警告
  this.emitWarning(new Error('Warning message'));

  // 发出错误
  this.emitError(new Error('Error message'));

  // 解析路径
  const resolvedPath = this.resolve('/context', './request');

  // 加载模块
  this.loadModule('./module', (err, source, sourceMap, module) => {
    // ...
  });

  // 获取资源文件信息
  const resourcePath = this.resourcePath;
  const resourceQuery = this.resourceQuery;

  return source;
};

完整的 Loader 示例

// replace-loader.js
const { getOptions } = require('loader-utils');
const { validate } = require('schema-utils');

const schema = {
  type: 'object',
  properties: {
    search: {
      type: 'string'
    },
    replace: {
      type: 'string'
    },
    flags: {
      type: 'string'
    }
  },
  required: ['search', 'replace']
};

module.exports = function(source) {
  const options = getOptions(this) || {};

  validate(schema, options, {
    name: 'Replace Loader'
  });

  const { search, replace, flags = 'g' } = options;

  // 支持异步
  const callback = this.async();

  try {
    const regex = new RegExp(search, flags);
    const result = source.replace(regex, replace);

    // 生成 source map(如果需要)
    if (this.sourceMap) {
      // 使用 magic-string 等库生成 source map
      callback(null, result, map);
    } else {
      callback(null, result);
    }
  } catch (error) {
    callback(error);
  }
};

最佳实践

编写 Loader 的原则

// 1. 单一职责原则
// 好的做法:一个 loader 只做一件事
// markdown-loader.js
const marked = require('marked');

module.exports = function(source) {
  const html = marked.parse(source);
  return `module.exports = ${JSON.stringify(html)};`;
};

// 2. 链式组合
// 使用多个 loader 组合完成复杂任务
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.md$/,
        use: [
          'html-loader',      // 处理 HTML
          './markdown-loader' // 转换 Markdown
        ]
      }
    ]
  }
};

// 3. 无状态设计
// 不要在 loader 中保留状态
// 好的做法
module.exports = function(source) {
  // 每次调用都是独立的
  return source.replace(/foo/g, 'bar');
};

// 不好的做法
let count = 0;  // 不要在 loader 外部定义状态
module.exports = function(source) {
  count++;
  return source;
};

加载本地 Loader

// 方法 1:使用绝对路径
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: path.resolve(__dirname, './loaders/my-loader.js')
      }
    ]
  }
};

// 方法 2:使用 resolveLoader
module.exports = {
  resolveLoader: {
    // 添加本地 loader 目录
    modules: ['node_modules', path.resolve(__dirname, 'loaders')]
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: 'my-loader'  // 直接写 loader 名
      }
    ]
  }
};

// 方法 3:使用 npm link(开发时)
// cd my-loader
// npm link
// cd my-project
// npm link my-loader

实用的 Loader 示例

Markdown Loader

// markdown-loader.js
const marked = require('marked');
const hljs = require('highlight.js');

module.exports = function(source) {
  const callback = this.async();

  // 配置 marked
  marked.setOptions({
    highlight: (code, lang) => {
      if (lang && hljs.getLanguage(lang)) {
        return hljs.highlight(code, { language: lang }).value;
      }
      return hljs.highlightAuto(code).value;
    }
  });

  const html = marked.parse(source);

  // 返回 HTML 字符串
  callback(null, `module.exports = ${JSON.stringify(html)};`);
};

YAML Loader

// yaml-loader.js
const yaml = require('js-yaml');

module.exports = function(source) {
  try {
    const data = yaml.load(source);
    return `module.exports = ${JSON.stringify(data)};`;
  } catch (error) {
    this.emitError(error);
    return 'module.exports = {};';
  }
};

SVG Sprite Loader

// svg-sprite-loader.js
const { parse } = require('svg-parser');

module.exports = function(source) {
  const callback = this.async();
  const options = this.getOptions() || {};

  try {
    const parsed = parse(source);
    const svgContent = JSON.stringify(source);

    const code = `
      var svg = ${svgContent};
      if (typeof document !== 'undefined') {
        var div = document.createElement('div');
        div.innerHTML = svg;
        var svgEl = div.querySelector('svg');
        svgEl.style.position = 'absolute';
        svgEl.style.width = 0;
        svgEl.style.height = 0;
        document.body.appendChild(div);
      }
      module.exports = svg;
    `;

    callback(null, code);
  } catch (error) {
    callback(error);
  }
};

面试要点

  1. Loader 的特点

    • 链式调用,单一职责
    • 运行在 Node.js 环境
    • 默认处理 UTF-8,raw=true 处理二进制
    • 无状态,尽可能异步化
  2. 编写 Loader 的基本结构

    module.exports = function(source) {
      // 处理 source
      return result;
    };
    
  3. 异步 Loader

    const callback = this.async();
    callback(null, result, sourceMap);
    
  4. 使用工具库

    • loader-utils:获取选项、解析请求等
    • schema-utils:验证选项配置
  5. 加载本地 Loader

    • 绝对路径引用
    • resolveLoader.modules 配置
    • npm link 链接本地包
  6. Loader 的 Pitch 阶段

    • 在 loader 链中从左到右执行
    • 可以中断 loader 链
  7. 注意事项

    • 保持无状态
    • 尽可能异步
    • 使用 this.async() 处理异步操作
    • 使用 this.emitError() 报告错误