我们知道,Vite 开发时,用的是 esbuild 进行构建,而在生产环境,则是使用 Rollup 进行打包。
为什么生产环境仍需要打包?为什么不用 esbuild 打包?
Vite 官方文档已经做出解析:尽管原生 ESM 现在得到了广泛支持,但由于嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 仍然效率低下(即使使用 HTTP/2)。为了在生产环境中获得最佳的加载性能,最好还是将代码进行 tree-shaking、懒加载和 chunk 分割(以获得更好的缓存)
虽然 esbuild
快得惊人,并且已经是一个在构建库方面比较出色的工具,但一些针对构建应用的重要功能仍然还在持续开发中 —— 特别是代码分割和 CSS 处理方面。就目前来说,Rollup 在应用打包方面更加成熟和灵活。尽管如此,当未来这些功能稳定后,我们也不排除使用 esbuild
作为生产构建器的可能。
由于生产环境的打包,使用的是 Rollup,Vite 需要保证,同一套 Vite 配置文件和源码,在开发环境和生产环境下的表现是一致的。
想要达到这个效果,只能是 Vite 在开发环境模拟 Rollup 的行为 ,在生产环境打包时,将这部分替换成 Rollup 打包
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-M0smqISH-1655305310353)(https://img-1252756644.cos.ap-nanjing.myqcloud.com/img/image-20220614195140001.png)]
Vite 兼容了什么
要讲 Vite 如何进行兼容之前,首先要搞清楚,兼容了什么?
我们用一个例子来类比一下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-41SFWflV-1655305310354)(https://img-1252756644.cos.ap-nanjing.myqcloud.com/img/image-20220614201308858.png)]
我们可以得到一下信息:
- 洗烘一体机可以替代洗衣机,它们能做到一样的效果
- 洗烘一体机,可以使用洗衣机的生态
这时候我们可以说,洗烘一体机,兼容洗衣机的生态,洗烘一体机能完全替代洗衣机
兼容关系,是不同层级的东西进行兼容。
替代关系,是同一层级的东西进行替代
那回到 vite,我们根据 Rollup 和 Vite 的关系,可以推出:
- Vite 不是兼容 rollup,说兼容 Rollup 其实是不严谨的
- Vite 是部分兼容 Rollup 的插件生态
- Vite 可以做到部分替代 Rollup
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K7OdihRy-1655305310355)(https://img-1252756644.cos.ap-nanjing.myqcloud.com/img/image-20220614203433307.png)]
这里强调一下,是部分兼容、部分替代,不是完全的,因为 Vite 的部分实现是与 Rollup 不同的
如何兼容 Rollup 的插件生态
想要兼容 Rollup 生态,就必须要实现 Rollup 的插件机制
Rollup 插件是什么?
Rollup 插件是一个对象,对象具有一个或多个属性、build
构建钩子和 output generation
输出生成钩子。
插件应该作为一个包分发,它导出一个可以传入特定选项对象的函数,并返回一个对象。
下面是一个简单的例子:
// rollup-plugin-my-example.js
export default function myExample () {
return {
name: 'my-example',
resolveId ( source ) {
if (source === 'virtual-module') {
return source; // 这表明 Rollup 不应该检查文件系统来找到这个模块的 id
}
return null; // 其他模块照常处理
},
load ( id ) {
if (id === 'virtual-module') {
return 'export default "This is virtual!"'; // 返回 "virtual-module" 的代码
}
return null; // 其他模块照常处理
}
};
}
// rollup.config.js
import myExample from './rollup-plugin-my-example.js';
export default ({
input: 'virtual-module',
plugins: [myExample()], // 使用插件
output: [{
file: 'bundle.js',
format: 'es'
}]
});
// bundle.js
import text from "virtual-module"
console.log(text) // 输出:This is virtual!
当 import text from "virtual-module"
时,相当于引入了这段代码:export default "This is virtual!"
宏观层面的兼容架构
Vite 需要兼容 Rollup 插件生态,就需要 Vite 能够像 Rollup 一样,能够解析插件对象,并对插件的钩子进行正确的执行和处理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RKpoxfsZ-1655305310356)(https://img-1252756644.cos.ap-nanjing.myqcloud.com/img/image-20220614205848096.png)]
这需要 Vite 在其内部,实现一个模拟的 Rollup 插件机制,实现跟 Rollup 一样的对外的插件行为,才能兼容 Rollup 的插件生态
Vite 里面包含的一个模拟 rollup,由于只模拟插件部分,因此在 Vite 源码中,它被称为 PluginContainer
(插件容器)
微观层面的实现
实现 Rollup 的插件行为,实际上是实现相同的插件钩子行为。
插件钩子是在构建的不同阶段调用的函数。钩子可以影响构建的运行方式、提供有关构建的信息或在构建完成后修改构建。
钩子行为,主要包括以下内容:
- 实现 Rollup 插件钩子的调度
- 提供 Rollup 钩子的 Context 上下文对象
- 对钩子的返回值进行相应处理
- 实现钩子的类型
什么是钩子的调度?
按照一定的规则,在构建对应的阶段,执行对应的钩子。
例如:当 Rollup 开始运行时,会先调用 options
钩子,然后是 buildStart
下图为 Rollup 的 build
构建钩子(output generation
输出生成钩子不在下图)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xnJZGDLF-1655305310357)(https://img-1252756644.cos.ap-nanjing.myqcloud.com/img/image-20220614212338842.png)]
什么是钩子的 Context 上下文对象?
在 Rollup 的钩子函数中,可以调用 this.xxx
来使用一些 Rollup 提供的实用工具函数,Context 提供属性/方法可以参考 Rollup 官方文档
而这个 this
就是钩子的 Context 上下文对象。
Vite 需要在运行时,实现一套相同的 Context 上下文对象,才能保证插件能够正确地执行 Context 上下文对象的属性/方法。
什么是对钩子的返回值做相应的处理?
部分钩子的返回值,是会影响到 Rollup 的行为。
例如:
export default function myExample () {
return {
name: 'my-example',
options(options) {
// 修改 options
return options
}
};
}
options
钩子的返回值,会覆盖当前 Rollup 当前的运行配置,从而影响到 Rollup 的行为。
Vite 同样需要实现这个行为 —— 根据返回值做相应的处理。每个钩子的返回值(如果有),对应的处理是不同的,都需要实现
什么是钩子类型?
钩子分为 4 种类型:
async
:钩子函数可以是 async 异步的,返回 Promisefirst
:如果多个插件都实现了这个钩子,那么这些钩子会依次运行,直到一个钩子返回的不是 null 或 undefined的值为止。sequential
:如果有几个插件实现了这个钩子,串行执行这些钩子parallel
:如果多个插件都实现了这个钩子,并行执行这些钩子
例如: options
钩子,是 async
和 sequential
类型,options
钩子可以是异步的,且是串行执行的,因为配置会按顺序依次被覆盖修改,如果是并行执行 options
,那么最终的配置就会不可控
Vite 同样需要实现这些钩子类型
插件容器
前面小节已经说过,插件容器,是一个小的 Rollup,实现了 Rollup 的插件机制。
插件容器实现的功能如下:
- 提供 Rollup 钩子的 Context 上下文对象
- 对钩子的返回值进行相应处理
- 实现钩子的类型
注意:插件容器的实现,不包含调度。调度是 Vite 在其运行过程中,使用插件容器的方法实现的
插件容器的简化实现如下:
const container = {
// 钩子类型:异步、串行
options: await (async () => {
let options = rollupOptions
for (const plugin of plugins) {
if (!plugin.options) continue
// 实现钩子类型:await 实现和异步和串行,下一个 options 钩子,需要等待当前钩子执行完成
// 实现对返回值进行处理:options 钩子返回值,覆盖当前 options
options = (await plugin.options.call(minimalContext, options)) || options
}
return options;
})(),
// 钩子类型:异步、并行
async buildStart() {
// 实现并行的钩子类型:用 Promise.all 执行
await Promise.all(
plugins.map((plugin) => {
if (plugin.buildStart) {
return plugin.buildStart.call(
new Context(plugin) as any,
container.options as NormalizedInputOptions
)
}
})
)
},
// 钩子类型:异步、first 优先
async resolveId(rawId, importer) {
// 上下文对象,后文介绍
const ctx = new Context()
let id: string | null = null
const partial: Partial<PartialResolvedId> = {}
for (const plugin of plugins) {
const result = await plugin.resolveId.call(
ctx as any,
rawId,
importer,
{ ssr }
)
// 如果有函数返回值 result,就直接 return,不执行后面钩子了
if (!result) continue;
return result;
}
}
// 钩子类型:异步、优先
async load(id, options) {
const ctx = new Context()
for (const plugin of plugins) {
const result = await plugin.load.call(ctx as any, id, { ssr })
if (result != null) {
return result
}
}
return null
},
// 钩子类型:异步、串行
async transform(code, id, options) {
// transform 钩子的上下文对象,不太一样,因为多了一些需要处理的工具函数。不需要深究
const ctx = new TransformContext(id, code, map as SourceMap)
for (const plugin of plugins) {
let result: TransformResult | string | undefined
try {
result = await plugin.transform.call(ctx, code, id)
} catch (e) {
ctx.error(e)
}
if (!result) continue;
code = result;
}
return {
code,
map: ctx._getCombinedSourcemap()
}
},
// ...省略 buildEnd 和 closeBundle
}
上面代码,已经是实现了下面的两个内容:
- 对钩子的返回值进行相应处理
- 实现钩子的类型
Context 上下文对象,提供了很多实用工具函数:
class Context implements PluginContext {
parse(code: string, opts: any = {}) {
// 省略实现
}
async resolve(
id: string,
importer?: string,
options?: { skipSelf?: boolean }
) {
// 省略实现
}
// ...省略
}
我们大概知道有这么个东西就行了,不需要知道具体的实现工具函数是怎么实现的。感兴趣的可以查看 Rollup 文档
插件的调度是如何实现的?
插件容器要怎么使用?
这两个问题,其实是同一个问题,当需要调度时,就要使用插件容器了。
例如:当 Server 启动时,会调用 listen
函数进行端口监听,这时候就会调用 container
的 buildStart
函数,执行插件的 buildStart
钩子
httpServer.listen = (async (port: number, ...args: any[]) => {
if (!isOptimized) {
try {
await container.buildStart({})
// 其他逻辑
} catch (e) {
httpServer.emit('error', e)
return
}
}
return listen(port, ...args)
})
这就是在构建对应的阶段,执行对应的钩子。
而在哪些阶段,分别调用了什么钩子,本篇文章则不过多介绍了
总结
至此,Vite 兼容 Rollup 的方式已经讲完了~
我们先介绍了兼容的概念, Vite 兼容的是 Rollup 插件生态,而不是 Rollup 这个工具。从而得出,Vite 需要实现 Rollup 插件生态的结论
然后围绕 Rollup 插件生态,我们介绍了什么是 Rollup 插件钩子,并从宏观和微观,分别介绍了兼容的架构(PluginContainer)和需要实现的细节:
- 实现 Rollup 插件钩子的调度
- 提供 Rollup 钩子的 Context 上下文对象
- 对钩子的返回值进行相应处理
- 实现钩子的类型
最后用简单的代码,实现了一个 PluginContainer,并介绍了,如何实现插件钩子的调度。
学完本篇内容,大概也就知道了 Rollup 钩子的相关生态了,如果我们需要实现一套插件生态,也可以对 Rollup 进行模仿。另外也学会了,如何用一个工具,去兼容另外一套工具的生态 —— 实现其对外的 API 能力
最后
如果这篇文章对您有所帮助,请帮忙点个赞?,您的鼓励是我创作路上的最大的动力。