本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
前言
之前在老项目中尝试接入 Vite 的时候,小编遇到了一个源文件 url 解析的问题。为了解决这个问题,小编自己动手写了一个插件。整个过程可以说是非常顺利,写的插件也达到了预期的目的。不过就在接入改造进入尾声的时候,小编心血来潮的为这个插件添加了 enforce: 'pre' 配置项,结果整个项目就报错运行不起来了。
这个问题的出现,引起了小编很大的兴趣。经过研究,小编找到了问题出现的原因,并在研究过程中,结合之前 Webpack、Rollup、Esbuild 等构建工具插件的使用经验,发现了这些构建工具插件机制的一些通用套路,收获直接拉满。
在这里,小编将对上面所述的问题、问题出现的原因以及构建工具插件机制的通用套路做一个详细梳理,希望能同样给到大家一些不一样的收获。
本文的目录结构如下:
构建工具插件机制的通用套路
首先,小编直接开门见山,给出自己总结的关于构建工具插件机制的通用套路, 总共有 4 个:
-
插件的工作机制都是基于发布/订阅模式实现的。
尽管各个构建工具在插件机制的实现方式上各不相同,但原理都是类似的。
整个过程分为两部分:先根据实际需要,订阅构建工具某个工作阶段对应的
hook,提供一个callback;然后等到构建工具工作到这个阶段时,触发对应的hook收集的callback。 -
多个插件可订阅同一个
hook,这使得callback会存在多个。不同的构建工具,对callback执行顺序的处理策略会有略微不同。默认情况下,所有的构建工具都是按照插件订阅
hook的先后顺序按序执行callback的。相比
Webpack,Rollup和Vite分别提供了额外配置,来调整callback的处理顺序:-
Rollup,配置为order = pre / null / post,可提供hook level的顺序调整; -
Vite,配置为enforce = pre / normal / post,可提供plugin level的顺序调整;
这一块儿,小编会在后续的内容中详细说明。
-
-
每个
hook,根据其收集的callback内部是否可以出现异步代码,分为sync hook和async hook。sync,是hook最基本的特征,即按序处理的callback,必须等上一个callback的所有同步代码执行完毕,才会开始处理下一个callback。callback中的异步代码不会影响下一个callback的处理。如果想要等上一个
callback的异步代码执行完毕之后,再开始处理下一个callback,那就需要借助async类型的hook了。基本上所有的构建工具,都提供了
async hook,使得我们可以在callback中实现异步逻辑。 -
每个
hook,根据其收集的多个callback之间的依赖关系,又可以细分为bail(first)、waterfall(sequential)、parallel。bail, 是webpack里面的叫法, 在rollup中的叫法是first。如果某一个callback有返回不是undefined的值,那么后面的所有callback都不处理。waterfall, 是webpack里面的叫法, 在rollup中的叫法是sequential。上一个callback的返回值会作为下一个callback的入参。parallel, 在Webpack和Rollup中叫法一样,只能在async hook中使用。callback可并行执行,即不用等上一个callback的异步代码执行,就可以开始处理下一个callback。
可以这么说,只要知道了这 4 个通用套路, 那么不管是 bundle 类型还是 unbunlde 类型的构建工具,其插件机制都可以很快被我们所掌握, 唯一需要花点时间的就是了解一下各个构建工具具体提供的 hook 种类以及插件的写法了。
不信的话,我们就拿 Webpack、Rollup、Vite、Esbuild 这四个构建工具来试一下吧。
有一点小编要提前声明一下,本文不会对各个构建工具关于发布/订阅模式的具体实现展开说明。不知道内部实现,完全不影响我们熟练使用插件。
Webpack 的插件机制
谈到构建工具,Webpack 一定是绕不开的,所以小编就先和大家聊聊 Webpack 的插件机制。对比其他几个构建工具,Webpack 的插件机制可以说是最复杂,当然功能也是最强大的。
其复杂性和强大性主要体现在两个方面:
-
数量庞大、种类繁多的
hooks; -
hook收集的callback,既有sync、async两种类型,又存在bail、waterfall、parallel等多种依赖关系;
Webpack 各种类型的 hooks以及触发的时机,可以通过一张图来给大家展示一下:
其中,红色的是 Compiler 类型的 hook,在打包构建的开始阶段和结束阶段触发;绿色的是 normalModuleFactory 类型的 hook, 在构建模块时为源文件创建 module 对象时调用;紫色的是 JavascriptParser 类型的 hook, 在构建模块依赖图时解析源文件对应的 AST 时调用;蓝色的是 Compilation 类型的 hook,在使用 loader 处理源文件内容、解析源文件依赖关系、将模块依赖图分离为 chunks 时使用。
不同类型的 hook,订阅的时机不同。
订阅 Compiler 类型的 hook,需要在 compiler 实例构建完成,依次执行插件实例的 apply 方法。
举个 🌰,
class MyPlugin {
// 插件实例的 apply 方法执行时,入参为 compiler 实例
apply(compiler) {
// 订阅 Compiler 类型的 initialize hook,在 compiler 完成初始化以后触发 callback
compiler.hooks.initialize.tap('MyPlugin', () => {
...
});
}
}
NormalModuleFactory 类型的 hook,订阅起来要麻烦一些,过程如下:
-
先订阅
Compiler的normalModuleFactory hook; -
等
compilationParams中的normalModuleFactory对象构建以后,触发normalModuleFactory hook的callback,将normalModuleFactory对象作为入参传入; -
在
callback中通过入参normalModuleFactory订阅;
ContextModuleFactory类型的hook订阅过程和NormalModuleFactory的一样。
举个 🌰,
class MyPlugin {
apply(compiler) {
// 先订阅 Compiler 类型的 normalModuleFactory hook
compiler.hooks.normalModuleFactory.tap('MyPlugin', (normalModuleFactory) => {
// 当 compilationParams 中的 normalModuleFactory 对象构建完成以后,触发 callback 执行
// 在 callback 中,通过入参 normalModuleFactory 订阅 NormalModuleFactory 类型的 hook
normalModuleFactory.hooks.resolve.tapAsync('MyPlugin', (data, callback) => {
// 当开始解析源文件的 url 时,触发 callback
...;
callback();
});
});
}
}
Compilation 类型的 hook, 和 NormalModuleFactory 类型 hook 的订阅过程类似,如下:
-
先订阅
Compiler的compilation hook; -
等
compilation对象构建以后,触发compilation hook的callback,将compilation对象作为入参传入; -
在
callback中通过入参compilation订阅;
举个 🌰,
class MyPlugin {
apply(compiler) {
// 先订阅 Compiler 类型的 compilation hook
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
// 当 compilation 对象构建完成以后,触发 callback 执行
// 在 callback 中,通过入参 compilation 订阅 Compilation 类型的 hook
compilation.hooks.optimizeChunks.tap('MyPlugin', (chunks) => {
// 当开始对 initial chunks 和 asyncs chunk 做自定义 chunk 分离时,触发 callback
...;
callback();
});
});
}
}
JavaScriptParser 类型的 hook,订阅起来最为复杂:
-
先订阅
Compiler的normalModuleFactory hook; -
等
compilationParams中的normalModuleFactory对象构建以后,触发normalModuleFactory hook的callback,将normalModuleFactory对象作为入参传入; -
在
callback中通过入参normalModuleFactory订阅NormalModuleFactory类型的parser hook; -
等源文件的
url完成解析,构建好parser实例以后,触发parser hook的callback,将parser对象作为入参传入; -
在
callback中通过入参parser订阅JavascriptParser类型的hook;
举个 🌰,
class MyPlugin {
apply(compiler) {
// 先订阅 Compiler 类型的 normalModuleFactory hook
compiler.hooks.normalModuleFactory.tap('MyPlugin', (normalModuleFactory) => {
// 当 compilationParams 中的 normalModuleFactory 对象构建完成以后,触发 callback 执行
// 在 callback 中,通过入参 normalModuleFactory 订阅 NormalModuleFactory 类型的 parser hook
normalModuleFactory.hooks.parser.tap('MyPlugin', (parser) => {
// 源文件的 url 解析完成,构建好 parser 实例以后,触发 callback 执行
// 在 callback 中,通过入参 parser 可以订阅 JavascriptParser 类型的 hook
parser.hooks.import.tap('MyPlugin', (statement, source) => {
// source == 'lodash'
});
});
});
}
}
这里要吐槽一下,
Webpack的插件写起来真是太复杂了 !!!
知道了各种 hooks 的订阅时机和触发时机,我们就可以在实际开发过程中,根据自身需要因地制宜的编写各种插件,非常灵活。
聊完了 hooks 的种类以及订阅方式,我们再来看看 hook 对应的 callback 的处理逻辑。
首先,根据 callback 中是否可以存在异步代码,webpack 将 hook 又分为 sync 和 async 两类。这两者的唯一区别就是如果 callback 中如果存在异步代码,是否可以等异步代码执行完毕以后才开始处理下一个 callback。sync hook 不可以, async hook 可以。
这两种类型的 hook, 订阅的时候都有各自的 api。sync hook 使用 tap 订阅, async hook 使用 tapAsync、tapPromise 订阅。
有一点需要特别说明,async hook, 根据是否需要等 callback 中的异步代码执行完毕以后才开始处理下一个 callback,又分为 AsyncSeriesHook 和 AsyncParallelHook 两类。AsyncSeriesHook 需要, AsyncParallelHook 不需要。
不过需要注意哦,虽然 AsyncParallelHook 在使用时,不需要等待上一个 callback 的异步代码处理完毕就可以开始处理下一个 callback,但是必须要等所有的 callback 的异步代码全部处理完毕,打包构建才可以进入下一个阶段,类似 Promise.all。
sync hook没有parallel一说, 同步代码肯定无法并行。
举个 🌰:
class MyPlugin {
apply(compiler) {
// compilation hook 是 sync 类型的 hook,使用 tap 订阅
compiler.hooks.compilation.tap('MyPlugin', compilation => {
// 异步代码不会影响下一个 callback 的处理
...
});
// afterCompile hook 是 AsyncSeriesHook 类型的 hook,可以通过 tapAsync 订阅
compiler.hooks.afterCompile.tapAsync('MyPlugin', (compilation, callback) => {
...
setTimeout(() => {
// 只有主动调用 callback,才能开始处理 hook 收集的下一个回调
// 如果没有调用,打包构建过程会被阻塞
callback()
}, 1000);
});
// make hook 是 AsyncParallelHook 类型的 hook,可以通过 tapAsync 订阅
compiler.hooks.make.tapAsync('MyPlugin', (compilation) => {
// hook 的回调中如果存在异步代码,下一个 callback 不需要等待上一个 callback 的异步代码执行完毕
...
});
// AsyncSeriesHook 类型的 hook 也可以用过 tapPromise 订阅,不过 callback 必须返回一个 promise 对象
compiler.hooks.afterCompiler.tapPromise('MyPlugin', (compilation) => {
return new Promise((resolve, reject) => {
...
});
});
// 该种订阅方式和上面的相同
compiler.hooks.afterCompiler.tapPromise('MyPlugin', async (compilation) => {
...
});
// AsyncParallelHook 类型的 hook 也可以通过 tapPromise 方式订阅
compiler.hooks.make.tapPromise('MyPlugin', async (compilation) => {
...
});
}
}
另外,不管是 sync hook 还是 async hook,对应的 callback 之间还存在另外两种关系 - bail 和 waterfall。
bail,熔断,如果某一个 callback 有返回非 undefined 的值,那么后面的所有 callback 都不处理。 sync 对应 SyncBailHook, async 对应 AsyncSeriesBailHook。
举个 🌰,
class MyPlugin {
apply(compiler) {
compiler.hooks.normalModuleFactory.tap('MyPlugin', (normalModuleFactory) => {
normalModuleFactory.hooks.parser.tap('MyPlugin', (parser) => {
// statement 是 SyncBailHook 类型的 hook,只要能获取明确的 statement,hook 后续的 callback 就不需要触发了
parser.hooks.statement.tap('MyPlugin', (statement) => {
return statement; // 如果 statement 是一个非 undefined 的值,那么剩余的 callback 不会触发
// 反之, 如果没有 return 一个非 undefined 的值,剩余的 callback 依次触发
});
});
// resolve 是一个 AsyncSeriesBailHook 类型的 hook,只有得到明确的 resovleData,hook 后续的 callback 就不触发
normalModuleFactory.hooks.resolve.tapAsync('MyPlugin', (data, callback) {
...
setTimeout(() => {
callbck(data); // 如果 data 是一个非 undefined 的值,那么 hook 后续的回调不会触发
// 反之,如果 data 是一个 undefined 的值,hook 后续的回调会按序触发
});
});
});
}
}
waterfall,瀑布,上一个 callback 的返回值会作为下一个 callback 的入参。 sync 对应 SyncWaterfallHook, async 对应 AsyncSeriesWaterfallHook。
举个 🌰:
class MyPlugin {
apply(compiler) {
compiler.hooks.normalModuleFactory.tap('MyPlugin', (normalModuleFactory) => {
// module 是一个 SyncWaterfallHook
normalModuleFactory.hooks.module.tap('MyPlugin', (module, createData, resolveData) => {
const tempModule = module;
return tempModule; // 返回的 tempModule 会作为下一个 callback 的 module 入参
});
});
compiler.hooks.contextModuleFactory.tap('MyPlugin', (contextlModuleFactory) => {
// afterResolve 是一个 AsyncSeriesWaterfallHook
normalModuleFactory.hooks.afterResolve.tapAsync('MyPlugin', (data, callback) => {
callback(data); // 返回的 data 会作为 hook 下一个回调的 data 入参
});
});
}
}
总的来说, Webpack 常用的 hooks,就是 sync、async、bail、waterfall、series、parallel 这几个关键字的自由组合:
-
sync+series=SyncHook(sync本身就有series的意思),hook所有的callback按序执行,上一个callback中的异步代码不影响下一个callback, 也不影响打包构建的下一个阶段。典型的有
Compiler类型的normalModuleFactory、compilation,Compilation类型的optimize、afterOptimizeChunks等。 -
sync+series+bail=SyncBailHook, 在SyncHook的基础上,如果上一个callback返回非undefined的值,后续callback不做处理;典型的有
Compilation类型的optimizeDependencies、optimizeModules、optimizeChunks等。 -
sync+series+waterfall=SyncWaterfallHook,在SyncHook的基础上,上一个callback的返回值会作为下一个callback的入参;典型的有
NormalModuleFactory类型的module,Compilation类型的assetPath等。 -
async+series=AsyncSeriesHook,hook所有的callback按序执行,如果callback中有异步代码,则必须等上一个callback的异步代码结束,才开始执行下一个callback;典型的有
Compiler类型的run、beforeCompile、afterCompiler等。 -
async+series+bail=AsyncSeriesBailHook,在AsyncSeriesHook的基础上,如果上一个callback返回非undefined的值,后续callback不做处理;典型的有
NormalModuleFactory类型的beforeResolve、resolve、afterResolve等。 -
async+series+waterfall=AsyncSeriesWaterfallHook,在AsyncSeriesHook的基础上,上一个callback的返回值会作为下一个callback的入参;典型的有
ContextModuleFactory类型的beforeResolve、afterResolve等。 -
async+parallel=AsyncParallelHook,hook所有的callback按序执行,如果callback中有异步代码,下一个callback中不需要等上一个callback的异步代码结束就可以开始,但必须等所有callback的异步代码结束才可以进入打包构建的下一个阶段。典型的有
Compiler类型的make。
在我们自定义插件时,一定要特别关注 bail、waterfall 类型的 hooks。这两类 hooks,前面 callback 的返回值会影响到后续 callback 的执行,如果返回的值不合理,可能会导致打包构建无法继续。这一点要切记噢。
Rollup 的 plugin 机制
聊完 Webpack 的插件机制,我们再来聊聊 Rollup 的插件机制。
和 Webpack 一样,作为 bundle 类型构建工具,Rollup 在打包构建过程中, 同样需要以指定入口为起点,构建模块依赖图,将模块依赖图分离为 initial chunk、async chunks、customer chunks。在构建模块依赖图时,也需要经历 url 解析为绝对路径、读取源文件内容、通过 AST 解析源文件的依赖关系。
同样的,Rollup 也提供了插件机制,使得我们有了介入上述各个阶段的机会,做一些自定义操作。不过相比 Webpack,Rollup 的插件机制相对要简单很多。
举个 🌰:
// 写一个插件
const myPlugin = {
name: 'xxx',
resolveId: async (id) => {
...
},
load: (id) => {
...
}
}
// 使用一个插件
// config.js
{
...
plugins: [myPlugin]
}
比起 Webpack 来, Rollup 插件的定义和使用是不是简单了很多呢。
使用 Rollup 完成打包构建非常简单,举个 🌰:
rollup.rollup(inputOptions).then(async bundle => {
await bundle.write(outputOptions); // 打包结果写到 output 指定位置
// await bundle.generate(outputOptions); // 打包结果写到内存中
});
Rollup 将整个打包构建过程分为两个阶段:
-
build阶段 - 构建模块依赖图,API为Rollup.rollup(inputOptions) -
output generation阶段 - 分离chunks并生成最后的bundle内容,API为bundle.write(outputOptions)或者bundle.generate(outputOptions)。
对应的 hooks 也分为这两类。
这里,我们贴两张 Rollup 官网的两张图。
上图是 build 阶段构建模块依赖图过程中涉及的所有 hooks, 其中:
-
options, 初始化input options以后,依次触发options hook的callback,对input options做自定义更新; -
buildStart,Rollup在完成build阶段的初始化工作以后, 依次触发buildStart hook的callback; -
resolveId、resolveDynamicImport,将模块的静态依赖、动态依赖解析为绝对路径; -
load, 根据模块的绝对路径加载源文件内容; -
transform, 对源文件内容转换为浏览器可以识别的内容; -
moduleParsed, 模块的依赖关系通过AST解析完毕,依次触发moduleParsed hook的callback; -
buildEnd, 模块依赖图构建完成,依次触发buildEnd hook的callback;
其中,核心的几个 hook 是 resolveId、load、transform、moduleParsed。
上图是 output generation 阶段将模块依赖图分离为 chunks 并生成最后的 bundle 涉及的所有 hooks:
-
outputOptions, 初始化output options以后,依次触发outputOptions hook的callback,对output options做自定义更新; -
renderStart,Rollup完成output generation阶段的初始化工作以后,依次触发renderStart hook的callback; -
renderDynamicImport/resolveFieUrl/resolveImportMeta, 在将模块依赖图分离为chunks的过程中,依次触发hook的callback,对import(...)、import.meta.url、import.meta做自定义操作; -
banner/footer/intro/outro, 等 chunks 分离完成以后, 依次触发hook的callback,收集要给每一个chunk添加到注释、代码; -
renderChunk, 根据每个chunk收集的modules、banner、footer、intro、outro, 为每一个chunk构建内容; -
augmentChunkHash, 为每一个chunk生成chunk hash; -
generateBundle, 所有的chunk的内容都完成构建以后,依次触发generateBundle hook的callback; -
writeBundle,将生成的bundles输出到指定位置以后,依次触发writeBundle hook的callback; -
closeBundle,output generation阶段结束,依次触发closeBundle hook的callback;
仔细观察,我们会发现 Rollup 提供的 hooks, 基本上都能在 Webpack 中找到匹配项,而且也分为 sync 和 async 两类,并且 hook 收集的 callback 也存在 first(bail)、sequential(waterfall)、parallel 依赖关系, 理解起来和 Webpack 完全一样。
举个 🌰:
-
Rollup的resolveId hook实际对应Webpack的normalModuleFactory.hooks.resolve,具备asnyc+series+first(bail)特征; -
Rollup的load对应Webpack读取源文件的过程(Webpack没有相关hook, 😅),具备asnyc+series+first(bail)特征; -
Rollup的transform对应Webpack使用loader处理源文件的过程,具备asnyc+series+sequential(waterfall)特征; -
Rollup的buildStart对应Webpack的compiler.make, 具备async+series+parallel的特征; -
...
不过也有稍许不同, 主要体现在这 2 个方面:
-
async hooks,只基于promise实现,不像Webpack中还可以通过tapAsync+callback实现; -
hook对应的callback的执行顺序可以通过order:pre/normal/post实现hook level的调整;
举个 🌰:
{
name: 'myPlugin',
resolveId: {
order: 'post', // resolveId hook 会稍后订阅,对应的 callback 也会稍后触发
handler: async (id) => {
...
}
},
load: {
order: 'pre', // load hook 会提前订阅,对应的 callback 也会提前触发
handler: (id) => {
...
}
}
}
和 Webpack 一样,在使用自定义插件的时候,一定要注意 first、sequential 这两类 hook, 防止出现返回值不合适导致打包构建无法进行的情况。
Esbuild 的 plugin 机制
和 Webpack、Rollup 一样, Esbuild 也是 bundle 类型的构建工具,工作的时候同样要经历构建模块依赖和分离模块依赖图并生成 bundles 的过程。
相比 Webpack 和 Rollup, Esbuild 的插件机制要更加简单。
举个 🌰:
let myPlugin = {
name: 'resolve',
setup: (build) => {
build.onResolve({ filter: /.*/ }, (args) => {
...
});
...
build.onLoad({ filter: /.txt$/ }, async (args) => {
...
});
}
}
esbuild.build({
entryPoints: 'xxx',
outdir: 'xxx',
plugins: [myPlugin],
...
});
Esbuild 一共提供了 4 个 hooks 供外部调用,分别是:
-
onStart,build开始时会触发; -
onResolve,解析源文件url时调用,可自定义url如何解析; -
onLoad, 加载源文件内容时调用,可自定义源文件如何加载; -
onEnd,build结束时会触发;
有了前面 Webpack、Rollup 的铺垫,我们可以顺利做出如下推测:
-
onStart hook,具备async+series+parallel特征; -
onResolve hook, 具备async+series+first(bail)特征; -
onLoad hook, 具备async+series+first(bail)特征; -
onEnd hook, 具备async+series+parallel特征;
而实际也确实如此,小伙伴们可以自行尝试哦。
Vite 的 plugin 机制
Vite 的插件机制,和 Rollup 基本相同,有略微差别也仅仅是提供的 hooks 不同。
举个 🌰:
{
name: 'myPlugin',
config: async (config) => {
...
},
resolveId: async (id) => {
...
}
}
由于 Vite 在开发模式下采取 unbundle 机制,生产模式下使用 bundle 模式,所以它的 hooks 也要分成两类,即 dev hooks 和 prod hooks。
2.x 版本,Vite 生产模式下基于 Rollup 实现打包构建,3.x 版本,则基于 Esbuild 实现打包构建,使用的 prod hooks 和 Rollup(Esbuild) 完全一致,这里小编就不再详细说明了。本文,我们重点关注 dev hooks。
由于 Vite 的插件机制和 Rollup 一脉相承,所以 dev hooks 也分为 sync 和 async 两类,并且 hook 收集的 callback 也同样存在 first(bail)、sequential(waterfall)、parallel 依赖关系。
根据 dev 模式下 Vite 的整个工作过程,其关键 dev hooks 按照触发顺序分为:
-
dev server启动阶段-
config hook, 在Vite读取vite.config.ts文件获取config配置以后触发,给开发人员再次需改 config 的机会,具备async+series+sequential特征; -
configResolved hook,Vite的config配置项初始化完成以后触发,具备async+series+parallel的特征; -
configureServer hook, dev server 创建以后触发,用于给dev server添加自定义middleware, 具备async+series+sequential特征; -
buildStart hook, 等同于Rollup的buildStart hook,unbundle开始工作, 先完成预构建,然后启动dev server;
-
-
dev server工作阶段-
transformIndexHtml hook,解析入口文件index.html时触发,可用于转换html文件内容, 具备async+series+sequential特征; -
resolveId, 等同于Rollup的resolveId, 解析请求文件的绝对路径时触发,具备asnyc+series+first(bail)特征; -
load, 等同于Rollup的load, 加载请求文件时触发,具备asnyc+series+first(bail)特征; -
transform, 等同于 Rollup 的transform,转换请求文件内容时触发,具备async+series+sequential特征;
-
-
dev server关闭阶段buildEnd、closeBundle,dev server 关闭时触发,具备async+series+parallel特征;
和 Rollup 一样, Vite 在定义插件的时候,也可以通过 enforce: pre / normal / post 属性来调整插件订阅 hook 的顺序。 不过,Vite 是 plugin level 的顺序调整,即如果某个插件设置了 enforce: pre,那么这个插件里面所有 hook 的 callback 的执行顺序都会提前,比起 Rollup 中 hook level 的顺序调整,有点不够灵活。
举个 🌰:
{
name: 'myPlugin',
enforce: 'pre', // myPlugin 会提前订阅 hook,对应的 callback 会提前触发
config: async (config) => {
...
},
resolveId: async (id) => {
...
}
}
和 Webpack、Rollup 一样,在使用自定义插件的时候,一定要注意 first、sequential 这两类 hook, 防止出现返回值不合适导致打包构建无法进行的情况。
前言描述问题解析
了解了 Webpack、Rollup、Vite、Esbuild 这几个构建工具插件机制的通用套路以后,我们再回过头来,看看前言中提到的设置 enforce: pre 导致项目运行报错的问题。
小编先带大家了解一下上面提到的问题。
之前的老项目中,模块之间存在这样的 3 种依赖关系:
// App.tsx
import { useState } from 'react'; // 第三方依赖
import Layout from './components/layout'; // 相对路径
import Header from 'components/header'; // 特殊依赖
...
其中,第一种和第二种是我们最常见的第三方依赖、相对路径依赖。第三种比较特殊,这种写法是基于 tsconfig.json 中有 baseUrl 配置才可以正常使用的, 如下:
// tsconfig.json
{
"compilerOptions": {
...
baseUrl: 'src'
}
}
在项目中配置好 vite.config.ts 文件以后,直接启动 vite server,控制台会出现如下报错信息:
The following dependencies are imported but could not be resolved:
components/header (imported by /xx/xx/xx/App.tsx)
Are they installed?
之所以会出现这样的异常信息,是因为 Vite 在预构建的时候,将 components/header 当成了第三方依赖。当发现在 node_modules 文件夹中没有找到对应的源文件时,就抛出异常,给出第三方依赖是否安装的提示。
针对这种情况,我们可以定义一个自定义插件,在这个插件中设置 resolveId hook,来处理上面的特殊依赖。
代码如下:
const path = require('path');
const fs = require('fs');
function urlResolve(options?: any) {
return {
name: 'custome-plugin-url-resolve',
resolveId: async (id: string) => {
if (
id.startsWith('.') ||
id.startsWith('/') ||
id.startsWith('http://') ||
id.startsWith('https://')
)
// 相对路径或者绝对路径,直接返回
return id;
const pkg = require('../package.json');
const firstPath = id.split('/')[0];
// 判断 id 是否是三方库
if (pkg.dependencies[firstPath]) return id;
// 判断 id 是否是别名
if (options && options.alias) {
for (let item of options.alias) {
if (item.find === firstPath) return id;
}
}
const tspkg = require('../tsconfig.json');
if (tspkg.compilerOptions && tspkg.compilerOptions.baseUrl) {
let url = path.resolve('./', tspkg.compilerOptions.baseUrl) + '/' + id;
if (path.extname(url)) {
if (fs.existsSync(url)) {
return url;
}
} else {
if (options && options.extname) {
for (let item of options.extname) {
if (fs.existsSync(url + item)) {
return url + item;
}
if (fs.existsSync(url + '/index' + item)) {
return url + '/index' + item;
}
}
}
}
}
return id;
},
};
}
export default urlResolve;
用法如下:
{
...,
plugins: [
urlResolve({
alias: [
{ find: '@', replacement: path.resolve('./', 'src') }
],
extname: ['.ts', '.js', '.tsx', '.jsx', '.png'],
})
]
}
经测试,这个插件可以很好的解决上面的问题,😄。
不过,当我们给这个插件设置 enforce: pre 以后,整个应用再运行时就报错了。
报错如下:
简单分析一下。这个报错的原因是 - 请求的入口文件是 ts 格式,没有转换成浏览器可识别的 js 格式,导致出现语法错误。
这只是表层原因,更深层次原因的其实是 enforce: pre 配置项导致 resolveId hook 解析请求文件的绝对路径出现了问题。
首先,小编先给大家罗列一下 dev 模式下涉及的插件订阅 hook 的顺序:
-
路径别名插件
-
vite:pre-alias plugin,订阅resolveId hook; -
alias plugin,订阅buildStart hook和resolvedId hook;
-
-
enforce:pre类型的三方plugin; -
Vite内部插件-
vite:modulepreload-polyfill plugin,订阅resolvedId hook和load hook; -
vite:resolve plugin,订阅resolvedId hook和load hook; -
vite:optimized-deps plugin- 订阅load hook; -
vite:html-inline-proxy plugin- 订阅resolveId hook和load hook; -
vite:css plugin, 订阅buildStart hook、transform hook; -
vite:esbuild plugin, 订阅buildEnd hook、transform hook; -
vite:json plugin, 订阅transform hook; -
vite:wasm plugin, 订阅resolveId hook、load hook; -
vite:worker plugin, 订阅buildStart hook、load hook、transform hook、renderChunk hook; -
vite:asset plugin, 订阅buildStart hook、load hook、renderChunk hook、generateBundle hook; -
...
-
-
enforce:normal类型的三方plugin -
Vite内部插件-
vite:define plugin,订阅transform hook; -
vite:css-post plugin, 订阅buildStart hook、load hook、renderChunk hook、generateBundle hook; -
vite:worker-import-meta-url plugin, 订阅transform hook; -
...
-
-
enforce:post类型的三方plugin -
Vite内部插件-
vite:client-inject plugin, 订阅transfrom hook; -
vite:import-analysis plugin, 订阅transfrom hook; -
...
-
在这些插件中,vite:resolve 用于将源文件依赖中的相对路径解析为绝对路径, 如果是绝对路径或者第三方依赖,则返回 undefined。
正常情况下,当浏览器发起一个资源请求时,dev server 会做如下处理:
-
执行
vite:resolve的resolveId hook收集的callback,将请求路径解析为绝对路径; -
执行
load hook收集的callback,根据绝对路径加载源文件内容; -
执行
transform hook收集的callback,对源文件内容做转换; -
将转换以后的内容返回给浏览器;
我们定义的 custome-plugin-url-resolve 插件, 开始时属于 enforce:normal 类型的第三方插件,在 vite:resolve 之后订阅 resolveId hook, 对应的 callback 执行顺序靠后。
由于 vite:resolve 无法处理项目中的特殊依赖,返回了 undefined,使得 customer-plugin-url-resolve 继续处理特殊依赖,返回需要的绝对路径,达到了预期的目的。
当我们给 custome-plugin-url-resolve 设置了 enforce:pre 属性后,会先与 vite:resolve 订阅 resolveId hook,导致对应的 callback 也优先处理。
在 custome-plugin-url-resolve 中,我们的逻辑是如果 url 是相对路径,直接返回入参 url。由于 resolveId hook 是 first 类型,当我们返回非 undefined 的值以后,后续的 callback 就不处理了,导致 dev server 无法得到请求文件的绝对路径,也就无法读取源文件内容、对源文件内容做转换了,所以最后返回的就是 ts 格式的内容。
这里也印证了我们上面提到的在使用自定义插件的时候,一定要注意 first、sequential 这两类 hook。
结束语
到这里,关于构建工具插件机制通用套路的梳理就结束了。相信通过本文的介绍,大家对上面提到的几个构建工具的插件使用也有了一些新的体会了吧。
这些套路,说白了也就那么一回事。有了这些套路,就算是再出现新的构建工具,其插件机制也很快会被我们熟悉,哈哈。