如何开发一个 webpack loader

loader 是什么

Webpack Loader 是用于 Webpack 构建工具的一种插件机制,用于处理模块。在 Webpack 构建过程中,Loader 用于将不同类型的文件转换成可以被添加到依赖图中的模块,并且可以应用于这些模块的转换和处理。

webpack 编译流程:

如何开发一个 webpack loader

原始代码 -> 翻译结果

A->B,一对一映射,实际上就是使用 Loader 函数来处理映射逻辑,可以类比为翻译,将各种形式的资源文件翻译成 JS。

如何开发一个 webpack loader

loader 的主要作用

Webpack Loader 的主要作用有以下几个方面:

  1. 文件转换: Loader 负责将不同类型的文件(如 JavaScript、CSS、图片等)转换成 Webpack 可以处理的模块。
  2. 预处理: Loader 可以执行一些预处理步骤,例如在加载 JavaScript 文件之前使用 Babel 转译,或在加载样式文件之前使用 Less 或 Sass 进行处理。
  3. 代码分割: Loader 可以根据需要将代码进行拆分,实现按需加载,从而减小页面初始加载时的体积。
  4. 资源处理: Loader 可以处理和加载各种资源,例如图片、字体等,并将它们作为模块添加到构建流程中。
  5. 动态引入: Loader 可以通过动态引入的方式,在运行时动态加载模块。

Loader 是一组按照顺序执行的函数,每个函数都有对模块内容的访问权,可以对模块进行转换、处理和增强。它们以管道的方式链接在一起,每个 Loader 将处理前一个 Loader 的输出,并将处理结果传递给下一个 Loader。 Loader 可以是同步的,也可以是异步的,这使得它们非常灵活。

一个简单的 Loader 通常是一个 Node.js 模块,它导出一个函数。这个函数接收源文件的内容作为输入,返回转换后的内容作为输出。在 Webpack 配置中,通过 module.rules 来定义 Loader 的使用规则。

常用 loader:

loader 名称 作用
file-loader 和 raw-loader 用于加载文本文件的内容,例如 JSON 文件。
babel-loader 用于将 ECMAScript 2015+ 代码转换为向后兼容的 JavaScript 版本,以便在现代浏览器中执行。
style-loader 和 css-loader 用于处理样式表文件,将 CSS 代码插入到页面中。
sass-loader 和 less-loader 用于处理 Sass 和 Less 样式文件,将其转换为 CSS。
file-loader 和 url-loader 用于处理文件,例如图片、字体等,使它们成为模块的一部分。
html-loader 用于处理 HTML 文件,使其成为模块,并解决其中的资源引用问题。
eslint-loader 用于在构建过程中进行代码检查,基于 ESLint 规则检测 JavaScript 代码。
svg-url-loader 用于处理 SVG 文件,将其转换为DataURL。

下文会以这些 loader 中的实现为例子,来介绍 loader 开发。

如何开发一个 loader

Loader Context 接口是什么

使用 loader 来解决问题,首先需要熟悉 Loader 提供了哪些可以操作的 API。

Webpack 官网对 Loader Context 已经有比较详细的说明,这里简单介绍几个比较常用的接口:

序号 接口 讲解
1 fs Compilation 对象的 inputFileSystem 属性,我们可以通过这个对象获取更多资源文件的内容;
2 resource 当前文件路径;
3 resourceQuery 文件请求参数,例如 import “./a?foo=bar” 的 resourceQuery 值为 ?foo=bar;
4 callback 可用于返回多个结果;
5 getOptions 用于获取当前 Loader 的配置对象;
6 async 用于声明这是一个异步 Loader,开发者需要通过 async 接口返回的 callback 函数传递处理结果;
7 emitWarning 添加警告;
8 emitError 添加错误信息,注意这不会中断 Webpack 运行;
9 emitFile 用于直接写出一个产物文件,例如 file-loader 依赖该接口写出 Chunk 之外的产物;
10 addDependency 将 dep 文件添加为编译依赖,当 dep 文件内容发生变化时,会触发当前文件的重新构建;

翻译阶段:处理映射逻辑

在 loader 中添加额外依赖

在 less-loader 中包含这样一段代码:

try {
  result = await (options.implementation || less).render(data, lessOptions);
} catch (error) {
  // ...
}

const { css, imports } = result;

imports.forEach((item) => {
  // ...
  this.addDependency(path.normalize(item));
});

Loader Context 的 addDependency 接口用于添加额外的文件依赖,当这些依赖发生变化时,也会触发重新构建。上例中,path.normalize(item) 改变时,会触发重新构建。

依赖注册流程大致如下:

代码中首先调用 less 库编译文件内容,之后遍历所有 @import 语句(result.imports 数组),调用 addDependency 接口将 import 到的文件都注册为依赖,此后这些资源文件发生变化时都会触发重新编译。

为什么 less-loader 需要这么处理?

为了确保依赖变化时触发重新构建。less 工具本身已经会递归所有 Less 文件树,一次性将所有 .less 文件打包在一起,例如在 a.less 中:

@import (less) './b.less' 

a、b 文件会被 less 打包在一起。a.less 对 b.less 的依赖,对 Webpack 来说是无感知的,如果不用 addDependency 显式声明依赖,后续 b.less 文件的变化不会触发 a.less 重新构建。

因此,addDependency 接口适用于那些 Webpack 无法理解隐式文件依赖的场景

除上例 less-loader,babel-loader 也是一个特别经典的案例。在 babel-loader 内部会添加对 Babel 配置文件如 .babelrc 的依赖,当 .babelrc 内容发生变化时,也会触发 babel-loader 重新运行。

此外,Loader Context 还提供了下面几个与依赖处理相关的接口:

接口 接口作用
addContextDependency(directory: String) 添加文件目录依赖,目录下内容变更时会触发文件变更
addMissingDependency(file: String) 用于添加文件依赖,效果与 addDependency 类似
clearDependencies() 清除所有文件依赖

处理二进制资源

当期望以二进制方式读入资源文件时,例如在 file-loader、image-loader 等场景中,可以按如下代码示例操作

export default function loader(source) {/* ... */}

export const raw = true; // 添加这一句即可

之后,loader 函数中获取到的第一个参数 source 将会是 Buffer 对象形式的二进制内容。

输出阶段:如何控制输出结果

取消 loader 缓存

Webpack 默认会缓存 Loader 的执行结果直到资源或资源依赖发生变化,因为Loader 中执行的各种资源内容转译操作通常都是 CPU 密集型 —— 这放在 JavaScript 单线程架构下可能导致性能问题;又或者异步 Loader 会挂起后续的加载器队列直到异步 Loader 触发回调,稍微不注意就可能导致整个加载器链条的执行时间过长。

开发者需要对此有个基本的理解,必要时可以通过 this.cachable 显式声明不作缓存。

在 loader 中返回多个结果

如果下游 loader 或 webpack 本身不止需要处理结果,还需要更多信息,可以用 callback 接口来返回更多信息。

例如在 webpack-contrib/eslint-loader 中:

export default function loader(content, map) {
  // ...
  linter.printOutput(linter.lint(content));
  this.callback(null, content, map);
}

通过 this.callback(null, content, map) 语句,同时返回转译后的内容sourcemap 内容。

callback 的完整签名如下:

this.callback(
  // 异常信息,Loader 正常运行时传递 null 值即可
  err: Error | null,
  // 转译结果
  content: string | Buffer,
  // 源码的 sourcemap 信息
  sourceMap?: SourceMap,
  // 任意需要在 Loader 间传递的值
  // 经常用来传递 AST 对象,避免重复解析
  data?: any
);

在 loader 中返回异步结果

涉及到异步或 CPU 密集操作时,Loader 中还可以以异步形式返回处理结果,

例如 webpack-contrib/less-loader 的核心逻辑:

import less from "less";

async function lessLoader(source) {
  // 1. 获取异步回调函数
  const callback = this.async();
  // ...

  let result;

  try {
    // 2. 调用less 将模块内容转译为 css
    result = await (options.implementation || less).render(data, lessOptions);
  } catch (error) {
    // ...
  }

  const { css, imports } = result;

  // ...

  // 3. 转译结束,返回结果
  callback(null, css, map);
}

export default lessLoader;

在 less-loader 中,包含三个重要逻辑:

  1. 调用 this.async 获取异步回调函数,此时 Webpack 会将该 Loader 标记为异步加载器,会 挂起 当前执行队列直到 callback 被触发;
  2. 调用 less 库将 less 资源转译为标准 css;
  3. 调用异步回调 callback 返回处理结果。

this.async 返回的异步回调函数签名与上一节介绍的 this.callback 相同:

this.callback(
  // 异常信息,Loader 正常运行时传递 null 值即可
  err: Error | null,
  // 转译结果
  content: string | Buffer,
  // 源码的 sourcemap 信息
  sourceMap?: SourceMap,
  // 任意需要在 Loader 间传递的值
  // 经常用来传递 AST 对象,避免重复解析
  data?: any
);

在 loader 中直接写出文件

如果需要在 loader 中直接写出文件,可以使用Loader Context 的 emitFile 接口。

例如在 file-loader 中:

export default function loader(content) {
  const options = getOptions(this);

  validate(schema, options, {
    name: 'File Loader',
    baseDataPath: 'options',
  });
  // ...

  if (typeof options.emitFile === 'undefined' || options.emitFile) {
    // ...
    this.emitFile(outputPath, content, null, assetInfo);
  }

  const esModule =
    typeof options.esModule !== 'undefined' ? options.esModule : true;

  return `${esModule ? 'export default' : 'module.exports ='} ${publicPath};`;
}

export const raw = true;

借助 emitFile 接口,我们能够在 Webpack 构建主流程之外提交更多产物,这有时候是必要的。

emitFile(name: string, content: Buffer|string, sourceMap: {...})

日志处理

在 loadr 中正确处理日志

Webpack 内置了一套 infrastructureLogging 接口,专门用于处理 Webpack 内部及各种第三方组件的日志需求,infrastructureLogging 提供了根据日志分级筛选展示功能,从而将日志的写逻辑与输出逻辑解耦。

提示:作为对比,假如我们使用 console.log 等硬编码方式输出日志信息,用户无法过滤这部分输出,可能会造成较大打扰,体感很不好。

因此,在编写 Loader 时也应该尽可能使用 Webpack 内置的这套 Logging 规则,方法很简单,只需使用 Loader Context 的 getLogger 接口,如:

export default function loader(source) {
  const logger = this.getLogger("xxx-loader");
  // 使用适当的 logging 接口
  // 支持:verbose/log/info/warn/error
  logger.info("information");

  return source;
}

getLogger 返回的 logger 对象支持 verbose/log/info/warn/error 五种级别的日志,最终用户可以通过 infrastructureLogging.level 配置项筛选不同日志内容,例如:

module.exports = {
  // ...
  infrastructureLogging: {
    level: 'warn',
  },
  // ...
};

在 loader 中上报异常

Webpack Loader 中有多种上报异常信息的方式:

  1. 使用 logger.error,仅输出错误日志,不会打断编译流程。
  2. 使用 this.emitError 接口,同样不会打断编译流程。

与 logger.error 相比,emitError 不受 infragstrustureLogging 规则控制,必然会强干扰到最终用户;其次,emitError 会抛出异常的 Loader 文件、代码行、对应模块,更容易帮助定位问题。

  1. 使用 this.callback 接口提交错误信息,但注意导致当前模块编译失败,效果与直接使用 throw 相同,用法:
export default function loader(source) {
  this.callback(new Error("发生了一些异常"));

  return source;
}

总的来说,这些方式各自有适用场景:

  1. 一般应尽量使用 logger.error,减少对用户的打扰;
  2. 对于需要明确警示用户的错误,优先使用 this.emitError;
  3. 对于已经严重到不能继续往下编译的错误,使用 callback 。

测试

在 Loader 中编写单元测试收益非常高,一方面对开发者来说,不用重复手动测试各种特性;一方面对于最终用户来说,带有一定测试覆盖率的项目通常意味着更高、更稳定的质量。常规的 Webpack Loader 单元测试流程大致如下:

  1. 创建在 Webpack 实例,并运行 Loader;
  2. 获取 Loader 执行结果,比对、分析判断是否符合预期;
  3. 判断执行过程中是否出错。

运行 loader

在 node 环境下运行调用 Webpack 接口

在 node 环境下运行调用 Webpack 接口,用代码而非命令行执行编译,很多框架都会采用这种方式,例如 vue-loader、stylus-loader、babel-loader 等,优点是运行效果最接近最终用户,缺点是运行效率相对较低(可以忽略)。

posthtml/posthtml-loader 为例,它会在启动测试之前创建并运行 Webpack 实例:

// posthtml-loader/test/helpers/compiler.js 文件
module.exports = function (fixture, config, options) {
  config = { /*...*/ }

  options = Object.assign({ output: false }, options)

  // 创建 Webpack 实例
  const compiler = webpack(config)

  // 以 MemoryFS 方式输出构建结果,避免写磁盘
  if (!options.output) compiler.outputFileSystem = new MemoryFS()

  // 执行,并以 promise 方式返回结果
  return new Promise((resolve, reject) => compiler.run((err, stats) => {
    if (err) reject(err)
    // 异步返回执行结果
    resolve(stats)
  }))
}

提示:上面的示例中用到 compiler.outputFileSystem = new MemoryFS() 语句将 Webpack 设定成输出到内存,能避免写盘操作,提升编译速度。

编写一系列 mock 方法模拟环境

编写一系列 mock 方法,搭建起一个模拟的 Webpack 运行环境,例如 emaphp/underscore-template-loader ,优点是运行速度更快,缺点是开发工作量大通用性低,了解即可。

校验 Loader 执行结果

上例运行结束之后会以 resolve(stats) 方式返回执行结果,stats 对象中几乎包含了编译过程所有信息,包括:耗时、产物、模块、chunks、errors、warnings 等等,我们可以从 stats 对象中读取编译最终输出的产物,例如 style-loader:

// style-loader/src/test/helpers/readAsset.js 文件
function readAsset(compiler, stats, assets) => {
  const usedFs = compiler.outputFileSystem
  const outputPath = stats.compilation.outputOptions.path
  const queryStringIdx = targetFile.indexOf('?')

  if (queryStringIdx >= 0) {
    // 解析出输出文件路径
    asset = asset.substr(0, queryStringIdx)
  }

  // 读文件内容
  return usedFs.readFileSync(path.join(outputPath, targetFile)).toString()
}

解释一下,这段代码首先计算 asset 输出的文件路径,之后调用 outputFileSystem 的 readFile 方法读取文件内容。

接下来,有两种分析内容的方法:

  1. 调用 Jest 的 expect(xxx).toMatchSnapshot() 断言,判断当前运行结果是否与之前的运行结果一致,从而确保多次修改的结果一致性,很多框架都大量用了这种方法;
  2. 解读资源内容,判断是否符合预期,例如 less-loader 的单元测试中会对同一份代码跑两次 less 编译,一次由 Webpack 执行,一次直接调用 less 库,之后分析两次运行结果是否相同。

对此有兴趣的同学,可以看看 less-loader 的 test 目录。

如何判断执行过程是否触发异常?

最后,还需要判断编译过程是否出现异常,同样可以从 stats 对象解析:

export default getErrors = (stats) => {
  const errors = stats.compilation.errors.sort()
  return errors.map(
    e => e.toString()
  )
}

大多数情况下都希望编译没有错误,此时只要判断结果数组是否为空即可。某些情况下可能需要判断是否抛出特定异常,此时可以 expect(xxx).toMatchSnapshot() 断言,用快照对比更新前后的结果。

进阶:loader 链式调用模型详解

如何开发一个 loader 已经介绍完毕,现在我们来了解 一下在 Loader 代码之外,还有什么会影响 Loader 的执行。

举个例子,为了读取 less 文件,我们通常需要同时配置多个加载器:

module.exports = {
  module: {
    rules: [
      {
        test: /.less$/i,
        use: ["style-loader", "css-loader", "less-loader"],
      },
    ],
  },
};

Webpack 启动后会以一种所谓“链式调用”的方式按 use 数组顺序从后到前调用 Loader:

  1. 首先调用 less-loader 将 Less 代码转译为 CSS 代码;
  2. 将 less-loader 结果传入 css-loader,进一步将 CSS 内容包装成类似 module.exports = “${css}” 的 JavaScript 代码片段;
  3. 将 css-loader 结果传入 style-loader,在运行时调用 injectStyle 等函数,将内容注入到页面的 标签。

链式调用这种设计有两个好处:

  1. 保持单个 Loader 的单一职责,一定程度上降低代码的复杂度;
  2. 细粒度的功能能够被组装成复杂而灵活的处理链条,提升单个 Loader 的可复用性。

不过,这只是链式调用的一部分,这里面有两个问题:

  1. Loader 链条启动之后,所有 Loader 都执行完毕才会结束,没有中断的机会 —— 除非显式抛出异常;
  2. 某些场景下并不需要关心资源的具体内容,但 Loader 需要在 source 内容被读取出来之后才会执行。

为了解决这两个问题,Webpack 在 Loader 基础上叠加了 pitch 的概念。

什么是 pitch

Webpack 允许在 Loader 函数上挂载名为 pitch 的函数,运行时 pitch 会比 Loader 本身更早执行,例如:

const loader = function (source){
  console.log('后执行')
  return source;
}

loader.pitch = function(requestString) {
  console.log('先执行')
}

module.exports = loader

Pitch 函数的完整签名:

function pitch(
  remainingRequest: string, previousRequest: string, data = {}
): void {
}

包含三个参数:

  • remainingRequest : 当前 loader 之后的资源请求字符串;
  • previousRequest : 在执行当前 loader 之前经历过的 loader 列表;
  • data : 与 Loader 函数的 data 相同,用于传递需要在 Loader 传播的信息。

这些参数不复杂,但与 requestString 紧密相关,我们看个例子加深了解:

module.exports = {
  module: {
    rules: [
      {
        test: /.less$/i,
        use: [
          "style-loader", "css-loader", "less-loader"
        ],
      },
    ],
  },
};

css-loader.pitch 中拿到的参数依次为:

// css-loader 之后的 loader 列表及资源路径
remainingRequest = less-loader!./xxx.less
// css-loader 之前的 loader 列表
previousRequest = style-loader
// 默认值
data = {}

pitch 函数调度逻辑

Pitch 翻译成中文是_抛、球场、力度、事物最高点_等,它背后折射的是一整套 Loader 被执行的生命周期概念。

实现上,Loader 链条执行过程分三个阶段:pitch、解析资源、执行,设计上与 DOM 的事件模型非常相似,对应关系如下图:

如何开发一个 webpack loader

pitch 阶段按配置顺序从左到右逐个执行 loader.pitch 函数(如果有的话),开发者可以在 pitch 返回任意值中断后续的链路的执行

为什么要设计 pitch 这一特性

回顾一下前面提到过的 less 加载链条:

loader 作用
less-loader 将 less 规格的内容转换为标准 css;
css-loader 将 css 内容包裹为 JavaScript 模块;
style-loader 将 JavaScript 模块的导出结果以 link 、style 标签等方式挂载到 html 中,让 css 代码能够正确运行在浏览器上。

实际上, style-loader 只是负责让 CSS 在浏览器环境下跑起来,并不需要关心具体内容,很适合用 pitch 来处理,核心代码:

// ...
// Loader 本身不作任何处理
const loaderApi = () => {};

// pitch 中根据参数拼接模块代码
loaderApi.pitch = function loader(remainingRequest) {
  //...

  switch (injectType) {
    case 'linkTag': {
      return `${
        esModule
        ? `...`
        // 引入 runtime 模块
        : `var api = require(${loaderUtils.stringifyRequest(
          this,
          `!${path.join(__dirname, 'runtime/injectStylesIntoLinkTag.js')}`
        )});
            // 引入 css 模块
            var content = require(${loaderUtils.stringifyRequest(
              this,
              `!!${remainingRequest}`
            )});

            content = content.__esModule ? content.default : content;`
      } // ...`;
    }

    case 'lazyStyleTag':
    case 'lazySingletonStyleTag': {
      //...
    }

    case 'styleTag':
    case 'singletonStyleTag':
    default: {
      // ...
    }
  }
};

export default loaderApi;

关键点:

  • loaderApi 为空函数,不做任何处理;
  • loaderApi.pitch 中拼接结果,导出的代码包含:
    • 引入运行时模块 runtime/injectStylesIntoLinkTag.js;
    • 复用 remainingRequest 参数,重新引入 css 文件。

运行后,关键结果大致如:

var api = require('xxx/style-loader/lib/runtime/injectStylesIntoLinkTag.js')
var content = require('!!css-loader!less-loader!./xxx.less');

注意了,到这里 style-loader 的 pitch 函数返回这一段内容,后续的 Loader 就不会继续执行,当前调用链条中断了

之后,Webpack 继续解析、构建 style-loader 返回的结果,遇到 inline loader 语句:

var content = require('!!css-loader!less-loader!./xxx.less');

所以从 Webpack 的角度看,对同一个文件实际调用了两次 loader 链,第一次在 style-loader 的 pitch 中断,第二次根据 inline loader 的内容跳过了 style-loader。

实践

下文将通过几个具体 Loader 的实现,来实践上文讲述的开发方式。

eslint-loader

const childProcess = require('child_process')
const exec = (command, cb) => {
    childProcess.exec(command, (error, stdout) => {
        cb && cb(error, stdout)
    })
}
const schema = {
    type: 'object',
    properties: {
        fix: 'boolean'
    }
}

module.exports = function (content) {
    const resourcePath = this.resourcePath
    // 调用 this.async 获取异步回调函数,此时 Webpack 会将该 Loader 标记为异步加载器,
    // 会 挂起 当前执行队列直到 callback 被触发;
    const callback = this.async()
    const command = `npx eslint ${resourcePath}`
    exec(command, (error, stdout) => {
        if (error) {
            console.log(stdout)
        }
        // 调用异步回调 callback 返回处理结果。
        callback(null, content)
    })
}

file-loader

const loaderUtils = require('loader-utils')
module.exports = function (content, map = null, meta = {}) {
    // 是否被url-loader处理过,处理过的话返回base74,url-loader在下面小结具体实现
    const { url,base64 } = meta
    if (url) {
        return `module.exports = "${base64}"`
    } else {
        // 根据当前的上下文,生成一个文件路径,基于dist打包目录,这里生成的文件地址就是:dist/assets/img.jpg
        const interpolateName = loaderUtils.interpolateName(
            this,
            'assets/[name].[contenthash].[ext][query]',
            { content }
        }
        // webpack特有方法,生成一个文件
        this.emitFile(interpolateName, content);
        return `module.exports = "${interpolateName}"`
    }
}
// 添加标记,表示这是一个raw loader
module.exports.raw = true

在 Webpack 中,module.exports.raw 是一个标志,用于指示一个 loader 是否返回原始的二进制数据而不是 UTF-8 编码的字符串。

url-loader

const { getExtByPath } = require('../utils')
const schema = {
    type: 'object',
    properties: {
        limit: {
            type: 'number'
        }
    }
}
module.exports = function (content) {
    // 默认值500K
    const options = this.getOptions(schema) || { limit: 1000 * 500 }
    const { limit } = options
    // 超过阈值则返回原内容
    const size = Buffer.byteLength(content)
    if (size > limit) {
        return content
    }
    // 读取buffer
    const buffer = Buffer.from(content)
    const ext = getExtByPath(this)
    // 将buffer转为base64字符串
    const base64 = 'data: image/' + ext + ';base64,' + buffer.toString('base64');
    // 这里返回了第四个参数——meta,表示这张图片已经被url-loader处理过,上层的file-loader应该使用base64变量
    this.callback(null, content, null, { url: true, base64 })
}

module.exports.raw = true

参考资料

loader API:webpack-v3.jsx.app/api/loaders…

webpack5核心原理与应用实践:掘金小册

webpack loader实战——手撕8个常用loader – 掘金

原文链接:https://juejin.cn/post/7329724409430065161 作者:Tiffany

(0)
上一篇 2024年1月31日 上午10:10
下一篇 2024年1月31日 下午4:00

相关推荐

发表回复

登录后才能评论