关于webpack:Webpack-系列第六篇如何编写loader

57次阅读

共计 12066 个字符,预计需要花费 31 分钟才能阅读完成。

全文 5000 字,深度分析 Webpack Loader 的个性、运行机制、开发技巧,欢送点赞关注。写作不易,未经作者批准,禁止任何模式转载!!!

对于 Webpack Loader,网上曾经有很多很多的材料,很难讲出花来,然而要写 Webpack 的系列博文又没方法绕开这一点,所以我浏览了超过 20 个开源我的项目,尽量全面地总结了一些编写 Loader 时须要理解的常识和技巧。蕴含:

那么,咱们开始吧。

意识 Loader

如果要做总结的话,我认为 Loader 是一个带有副作用的内容转译器!

Webpack Loader 最外围的只能是实现内容转换器 —— 将各式各样的资源转化为规范 JavaScript 内容格局,例如:

  • css-loader 将 css 转换为 __WEBPACK_DEFAULT_EXPORT__ = ".a{xxx}" 格局
  • html-loader 将 html 转换为 __WEBPACK_DEFAULT_EXPORT__ = "<!DOCTYPE xxx" 格局
  • vue-loader 更简单一些,会将 .vue 文件转化为多个 JavaScript 函数,别离对应 template、js、css、custom block

那么为什么须要做这种转换呢?实质上是因为 Webpack 只意识合乎 JavaScript 标准的文本 (Webpack 5 之后减少了其它 parser):在构建(make) 阶段,解析模块内容时会调用 acorn 将文本转换为 AST 对象,进而剖析代码构造,剖析模块依赖;这一套逻辑对图片、json、Vue SFC 等场景就不 work 了,就须要 Loader 染指将资源转化成 Webpack 能够了解的内容状态。

Plugin 是 Webpack 另一套扩大机制,性能更强,可能在各个对象的钩子中插入特化解决逻辑,它能够笼罩 Webpack 全生命流程,能力、灵活性、复杂度都会比 Loader 强很多。

Loader 根底

代码层面,Loader 通常是一个函数,构造如下:

module.exports = function(source, sourceMap?, data?) {
  // source 为 loader 的输出,可能是文件内容,也可能是上一个 loader 处理结果
  return source;
};

Loader 函数接管三个参数,别离为:

  • source:资源输出,对于第一个执行的 loader 为资源文件的内容;后续执行的 loader 则为前一个 loader 的执行后果
  • sourceMap: 可选参数,代码的 sourcemap 构造
  • data: 可选参数,其它须要在 Loader 链中传递的信息,比方 posthtml/posthtml-loader 就会通过这个参数传递参数的 AST 对象

其中 source 是最重要的参数,大多数 Loader 要做的事件就是将 source 转译为另一种模式的 output,比方 webpack-contrib/raw-loader 的外围源码:

//... 
export default function rawLoader(source) {
  // ...

  const json = JSON.stringify(source)
    .replace(/\u2028/g, '\\u2028')
    .replace(/\u2029/g, '\\u2029');

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

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

这段代码的作用是将文本内容包裹成 JavaScript 模块,例如:

// source
I am Tecvan

// output
module.exports = "I am Tecvan"

通过模块化包装之后,这段文本内容转身变成 Webpack 能够解决的资源模块,其它 module 也就能援用、应用它了。

返回多个后果

上例通过 return 语句返回处理结果,除此之外 Loader 还能够以 callback 形式返回更多信息,供上游 Loader 或者 Webpack 自身应用,例如在 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
);

异步解决

波及到异步或 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 中,逻辑分三步:

  • 调用 this.async 获取异步回调函数,此时 Webpack 会将该 Loader 标记为异步加载器,会挂起以后执行队列直到 callback 被触发
  • 调用 less 库将 less 资源转译为规范 css
  • 调用异步回调 callback 返回处理结果

this.async 返回的异步回调函数签名与上一节介绍的 this.callback 雷同,此处不再赘述。

缓存

Loader 为开发者提供了一种便捷的扩大办法,但在 Loader 中执行的各种资源内容转译操作通常都是 CPU 密集型 —— 这放在单线程的 Node 场景下可能导致性能问题;又或者异步 Loader 会挂起后续的加载器队列直到异步 Loader 触发回调,略微不留神就可能导致整个加载器链条的执行工夫过长。

为此,默认状况下 Webpack 会缓存 Loader 的执行后果直到资源或资源依赖发生变化,开发者须要对此有个根本的了解,必要时能够通过 this.cachable 显式申明不作缓存,例如:

module.exports = function(source) {this.cacheable(false);
  // ...
  return output;
};

上下文与 Side Effect

除了作为内容转换器外,Loader 运行过程还能够通过一些上下文接口,有限度地影响 Webpack 编译过程,从而产生内容转换之外的副作用。

上下文信息可通过 this 获取,this 对象由 NormolModule.createLoaderContext 函数在调用 Loader 前创立,罕用的接口包含:

const loaderContext = {
    // 获取以后 Loader 的配置信息
    getOptions: schema => {},
    // 增加正告
    emitWarning: warning => {},
    // 增加错误信息,留神这不会中断 Webpack 运行
    emitError: error => {},
    // 解析资源文件的具体门路
    resolve(context, request, callback) {},
    // 间接提交文件,提交的文件不会通过后续的 chunk、module 解决,间接输入到 fs
    emitFile: (name, content, sourceMap, assetInfo) => {},
    // 增加额定的依赖文件
    // watch 模式下,依赖文件发生变化时会触发资源从新编译
    addDependency(dep) {},};

其中,addDependencyemitFileemitErroremitWarning 都会对后续编译流程产生副作用,例如 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));
  });

解释一下,代码中首先调用 less 编译文件内容,之后遍历所有 import 语句,也就是上例 result.imports 数组,一一调用 this.addDependency 函数将 import 到的其它资源都注册为依赖,之后这些其它资源文件发生变化时都会触发从新编译。

Loader 链式调用

应用上,能够为某种资源文件配置多个 Loader,Loader 之间依照配置的程序从前到后(pitch),再从后到前顺次执行,从而造成一套内容转译工作流,例如对于上面的配置:

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

这是一个典型的 less 解决场景,针对 .less 后缀的文件设定了:less、css、style 三个 loader 合作解决资源文件,依照定义的程序,Webpack 解析 less 文件内容后先传入 less-loader;less-loader 返回的后果再传入 css-loader 解决;css-loader 的后果再传入 style-loader;最终以 style-loader 的处理结果为准,流程简化后如:

上述示例中,三个 Loader 别离起如下作用:

  • less-loader:实现 less => css 的转换,输入 css 内容,无奈被间接利用在 Webpack 体系下
  • css-loader:将 css 内容包装成相似 module.exports = "${css}" 的内容,包装后的内容合乎 JavaScript 语法
  • style-loader:做的事件非常简单,就是将 css 模块包进 require 语句,并在运行时调用 injectStyle 等函数将内容注入到页面的 style 标签

三个 Loader 别离实现内容转化工作的一部分,造成从右到左的调用链条。链式调用这种设计有两个益处,一是放弃单个 Loader 的繁多职责,肯定水平上升高代码的复杂度;二是细粒度的性能可能被组装成简单而灵便的解决链条,晋升单个 Loader 的可复用性。

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

  • Loader 链条一旦启动之后,须要所有 Loader 都执行结束才会完结,没有中断的机会 —— 除非显式抛出异样
  • 某些场景下并不需要关怀资源的具体内容,但 Loader 须要在 source 内容被读取进去之后才会执行

为了解决这两个问题,Webpack 在 loader 根底上叠加了 pitch 的概念。

Loader Pitch

网络上对于 Loader 的文章曾经有十分十分多,但少数并没有对 pitch 这一重要个性做足够深刻的介绍,没有讲清楚为什么要设计 pitch 这个性能,pitch 有哪些常见用例等。

在这一节,我会从 what、how、why 三个维度开展聊聊 loader pitch 这一个性。

什么是 pitch

Webpack 容许在这个函数上挂载名为 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 的事件模型十分类似,pitch 对应到捕捉阶段;执行对应到冒泡阶段;而两个阶段之间 Webpack 会执行资源内容的读取、解析操作,对应 DOM 事件模型的 AT\_TARGET 阶段:

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

那么为什么要设计 pitch 这一个性呢?在剖析了 style-loader、vue-loader、to-string-loader 等开源我的项目之后,我集体总结出两个字:阻断

示例:style-loader

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

  • less-loader:将 less 规格的内容转换为规范 css
  • css-loader:将 css 内容包裹为 JavaScript 模块
  • style-loader:将 JavaScript 模块的导出后果以 linkstyle 标签等形式挂载到 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。

类似的技巧在其它仓库也有呈现,比方 vue-loader,感兴趣的同学能够查看我之前发在 ByteFE 公众号上的文章《Webpack 案例 ——vue-loader 原理剖析》,这里就不开展讲了。

进阶技巧

开发工具

Webpack 为 Loader 开发者提供了两个实用工具,在诸多开源 Loader 中呈现频率极高:

  • webpack/loader-utils:提供了一系列诸如读取配置、requestString 序列化与反序列化、计算 hash 值之类的工具函数
  • webpack/schema-utils:参数校验工具

这些工具的具体接口在相应的 readme 上曾经有明确的阐明,不赘述,这里总结一些编写 Loader 时常常用到的样例:如何获取并校验用户配置;如何拼接输入文件名。

获取并校验配置

Loader 通常都提供了一些配置项,供开发者定制运行行为,用户能够通过 Webpack 配置文件的 use.options 属性设定配置,例如:

module.exports = {
  module: {
    rules: [{
      test: /\.less$/i,
      use: [
        {
          loader: "less-loader",
          options: {cacheDirectory: false}
        },
      ],
    }],
  },
};

在 Loader 外部,须要应用 loader-utils 库的 getOptions 函数获取用户配置,用 schema-utils 库的 validate 函数校验参数合法性,例如 css-loader:

// css-loader/src/index.js
import {getOptions} from "loader-utils";
import {validate} from "schema-utils";
import schema from "./options.json";


export default async function loader(content, map, meta) {const rawOptions = getOptions(this);

  validate(schema, rawOptions, {
    name: "CSS Loader",
    baseDataPath: "options",
  });
  // ...
}

应用 schema-utils 做校验时须要提前申明配置模板,通常会解决成一个额定的 json 文件,例如上例中的 "./options.json"

拼接输入文件名

Webpack 反对以相似 [path]/[name]-[hash].js 形式设定 output.filename 即输入文件的命名,这一层规定通常不须要关注,但某些场景例如 webpack-contrib/file-loader 须要依据 asset 的文件名拼接后果。

file-loader 反对在 JS 模块中引入诸如 png、jpg、svg 等文本或二进制文件,并将文件写出到输入目录,这外面有一个问题:如果文件叫 a.jpg,通过 Webpack 解决后输入为 [hash].jpg,怎么对应上呢?此时就能够应用 loader-utils 提供的 interpolateNamefile-loader 中获取资源写出的门路及名称,源码:

import {getOptions, interpolateName} from 'loader-utils';

export default function loader(content) {
  const context = options.context || this.rootContext;
  const name = options.name || '[contenthash].[ext]';

  // 拼接最终输入的名称
  const url = interpolateName(this, name, {
    context,
    content,
    regExp: options.regExp,
  });

  let outputPath = url;
  // ...

  let publicPath = `__webpack_public_path__ + ${JSON.stringify(outputPath)}`;
  // ...

  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;

代码的外围逻辑:

  1. 依据 Loader 配置,调用 interpolateName 办法拼接指标文件的残缺门路
  2. 调用上下文 this.emitFile 接口,写出文件
  3. 返回 module.exports = ${publicPath},其它模块能够援用到该文件门路

除 file-loader 外,css-loader、eslint-loader 都有用到该接口,感兴趣的同学请自行返回查阅源码。

单元测试

在 Loader 中编写单元测试收益十分高,一方面对开发者来说不必去怎么写 demo,怎么搭建测试环境;一方面对于最终用户来说,带有肯定测试覆盖率的我的项目通常意味着更高、更稳固的品质。

浏览了超过 20 个开源我的项目后,我总结了一套 Webpack Loader 场景下罕用的单元测试流程,以 Jest · 🃏 Delightful JavaScript Testing 为例:

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

如何运行 Loader

有两种方法,一是在 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 办法,搭建起一个模仿的 Webpack 运行环境,例如 emaphp/underscore-template-loader,长处的运行速度更快,毛病是开发工作量大通用性低,理解理解即可。

比对后果

上例运行完结之后会以 resolve(stats) 形式返回执行后果,stats 对象中简直蕴含了编译过程所有信息,包含耗时、产物、模块、chunks、errors、warnings 等等,我在之前的文章 分享几个 Webpack 实用剖析工具 对此曾经做了较深刻的介绍,感兴趣的同学能够返回浏览。

在测试场景下,能够从 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 办法读取文件内容。

接下来,有两种剖析内容的办法:

  • 调用 Jest 的 expect(xxx).toMatchSnapshot() 断言判断以后运行后果是否与之前的运行后果统一,从而确保屡次批改的后果一致性,很多框架都大量用了这种办法
  • 解读资源内容,判断是否合乎预期,例如 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 的过程中,有一些小技巧可能晋升调试效率,包含:

  • 应用 ndb 工具实现断点调试
  • 应用 npm link 将 Loader 模块链接到测试项目
  • 应用 resolveLoader 配置项将 Loader 所在的目录退出到测试项目中,如:
// webpack.config.js
module.exports = {
  resolveLoader:{modules: ['node_modules','./loaders/'],
  }
}

无关紧要总结

这是 Webpack 原理剖析系列第七篇文章,说实话最开始并没有想到能写这么多,后续还会持续 focus 在这个前端工程化畛域,我的指标是能攒成一本本人的书,感兴趣的同学欢送点赞关注,如果感觉有什么中央脱漏、纳闷,欢送评论探讨。

往期文章

  • [万字总结] 一文吃透 Webpack 外围原理
  • 十分钟精进 Webpack:module.issuer 属性详解
  • 有点难的 webpack 知识点:Dependency Graph 深度解析
  • 有点难的知识点:Webpack Chunk 分包规定详解
  • [倡议珍藏] Webpack 4+ 优良学习材料合集
  • Webpack 系列第五篇:彻底了解 Webpack 运行时

正文完
 0