本文为稀土掘金技术社区首发签约文章,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
。
结束语
到这里,关于构建工具插件机制通用套路的梳理就结束了。相信通过本文的介绍,大家对上面提到的几个构建工具的插件使用也有了一些新的体会了吧。
这些套路,说白了也就那么一回事。有了这些套路,就算是再出现新的构建工具,其插件机制也很快会被我们熟悉,哈哈。