全文 5000 字,深度剖析 Webpack Loader 的特性、运行机制、开发技巧,欢迎点赞关注。写做不易,未经做者赞成,禁止任何形式转载!!!
关于 Webpack Loader,网上已经有不少不少的资料,很难讲出花来,可是要写 Webpack 的系列博文又没办法绕开这一点,因此我阅读了超过 20 个开源项目,尽可能全面地总结了一些编写 Loader 时须要了解的知识和技巧。包含:javascript
那么,咱们开始吧。php
若是要作总结的话,我认为 Loader 是一个带有反作用的内容转译器!
Webpack Loader 最核心的只能是实现内容转换器 —— 将各式各样的资源转化为标准 JavaScript 内容格式,例如:css
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 能够理解的内容形态。html
Plugin 是 Webpack 另外一套扩展机制,功能更强,可以在各个对象的钩子中插入特化处理逻辑,它能够覆盖 Webpack 全生命流程,能力、灵活性、复杂度都会比 Loader 强不少。
代码层面,Loader 一般是一个函数,结构以下:前端
module.exports = function(source, sourceMap?, data?) { // source 为 loader 的输入,多是文件内容,也多是上一个 loader 处理结果 return source; };
Loader 函数接收三个参数,分别为:vue
source
:资源输入,对于第一个执行的 loader 为资源文件的内容;后续执行的 loader 则为前一个 loader 的执行结果sourceMap
: 可选参数,代码的 sourcemap 结构data
: 可选参数,其它须要在 Loader 链中传递的信息,好比 posthtml/posthtml-loader 就会经过这个参数传递参数的 AST 对象其中 source
是最重要的参数,大多数 Loader 要作的事情就是将 source
转译为另外一种形式的 output
,好比 webpack-contrib/raw-loader 的核心源码:java
//... 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 模块,例如:node
// source I am Tecvan // output module.exports = "I am Tecvan"
通过模块化包装以后,这段文本内容转身变成 Webpack 能够处理的资源模块,其它 module 也就能引用、使用它了。webpack
上例经过 return
语句返回处理结果,除此以外 Loader 还能够以 callback
方式返回更多信息,供下游 Loader 或者 Webpack 自己使用,例如在 webpack-contrib/eslint-loader 中:git
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 资源转译为标准 csscallback
返回处理结果this.async
返回的异步回调函数签名与上一节介绍的 this.callback
相同,此处再也不赘述。
Loader 为开发者提供了一种便捷的扩展方法,但在 Loader 中执行的各类资源内容转译操做一般都是 CPU 密集型 —— 这放在单线程的 Node 场景下可能致使性能问题;又或者异步 Loader 会挂起后续的加载器队列直到异步 Loader 触发回调,稍微不注意就可能致使整个加载器链条的执行时间过长。
为此,默认状况下 Webpack 会缓存 Loader 的执行结果直到资源或资源依赖发生变化,开发者须要对此有个基本的理解,必要时能够经过 this.cachable
显式声明不做缓存,例如:
module.exports = function(source) { this.cacheable(false); // ... return output; };
除了做为内容转换器外,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) {}, };
其中,addDependency
、emitFile
、emitError
、emitWarning
都会对后续编译流程产生反作用,例如 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 之间按照配置的顺序从前到后(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 的可复用性。
不过,这只是链式调用的一部分,这里面有两个问题:
为了解决这两个问题,Webpack 在 loader 基础上叠加了 pitch
的概念。
网络上关于 Loader 的文章已经有很是很是多,但多数并无对 pitch
这一重要特性作足够深刻的介绍,没有讲清楚为何要设计 pitch 这个功能,pitch 有哪些常见用例等。
在这一节,我会从 what、how、why 三个维度展开聊聊 loader 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 等开源项目以后,我我的总结出两个字:阻断!
先回顾一下前面提到过的 less 加载链条:
less-loader
:将 less 规格的内容转换为标准 csscss-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。
类似的技巧在其它仓库也有出现,好比 vue-loader,感兴趣的同窗能够查看我以前发在 ByteFE 公众号上的文章《Webpack 案例 ——vue-loader 原理分析》,这里就不展开讲了。
Webpack 为 Loader 开发者提供了两个实用工具,在诸多开源 Loader 中出现频率极高:
这些工具的具体接口在相应的 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
提供的 interpolateName
在 file-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;
代码的核心逻辑:
interpolateName
方法拼接目标文件的完整路径this.emitFile
接口,写出文件module.exports = ${publicPath}
,其它模块能够引用到该文件路径除 file-loader 外,css-loader、eslint-loader 都有用到该接口,感兴趣的同窗请自行前往查阅源码。
在 Loader 中编写单元测试收益很是高,一方面对开发者来讲不用去怎么写 demo,怎么搭建测试环境;一方面对于最终用户来讲,带有必定测试覆盖率的项目一般意味着更高、更稳定的质量。
阅读了超过 20 个开源项目后,我总结了一套 Webpack Loader 场景下经常使用的单元测试流程,以 Jest · 🃏 Delightful JavaScript Testing 为例:
有两种办法,一是在 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
方法读取文件内容。
接下来,有两种分析内容的方法:
expect(xxx).toMatchSnapshot()
断言判断当前运行结果是否与以前的运行结果一致,从而确保屡次修改的结果一致性,不少框架都大量用了这种方法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 的过程当中,有一些小技巧可以提高调试效率,包括:
npm link
将 Loader 模块连接到测试项目resolveLoader
配置项将 Loader 所在的目录加入到测试项目中,如:// webpack.config.js module.exports = { resolveLoader:{ modules: ['node_modules','./loaders/'], } }
这是 Webpack 原理分析系列第七篇文章,说实话最开始并无想到能写这么多,后续还会继续 focus 在这个前端工程化领域,个人目标是能攒成一本本身的书,感兴趣的同窗欢迎点赞关注,若是以为有什么地方遗漏、疑惑,欢迎评论讨论。
往期文章