实现扩展 external 能力的 Vite 插件
前言
大家对构建工具中的 external 的属性一定不会陌生吧。在优化构建产物体积需求中可能会引入 CDN 来取代一些基础的模块或工具包,如 React、Vue、lodash 等。
最近没什么事情,闲余时间研究了下 Vite 和 Rollup 的内部实现,借此机会来探究下构建工具是如何处理 external 这一类外部链接的,并对 external 的能力做一些扩展。
如何解析 External
因为 external 的能力主要体现在构建流程,那么我们就从构建的时机开始看起吧。
从 Vite 传递给 Rollup 的参数上也可以看出来:
const userExternal = options.rollupOptions?.external
let external = userExternal
const rollupOptions: RollupOptions = {
context: 'globalThis',
preserveEntrySignatures: ssr
? 'allow-extension'
: libOptions
? 'strict'
: false,
...options.rollupOptions,
input,
plugins,
external,
onwarn(warning, warn) {
onRollupWarning(warning, warn, config)
}
}
const { rollup } = await import('rollup')
const bundle = await rollup(rollupOptions)
Vite 的构建流程是依赖于 Rollup 的能力,也就是说 external 的属性主要是在 Rollup 构建上体现出来的。
那么 Rollup 是如何处理 external 的路径信息呢?
Rollup 在构建准备阶段我们可以看到有如下初始化流程:
function normalizeInputOptions(config) {
const options = {
// ...
external: getIdMatcher(config.external)
};
return { options, unsetOptions };
}
在 getIdMatcher 函数中对 config.external 进行初始化
const getIdMatcher = (option) => {
if (option === true) {
return () => true;
}
if (typeof option === 'function') {
return (id, ...args) => (!id.startsWith('\0') && option(id, ...args)) || false;
}
if (option) {
const ids = new Set();
const matchers = [];
for (const value of ensureArray(option)) {
if (value instanceof RegExp) {
matchers.push(value);
}
else {
ids.add(value);
}
}
return (id, ..._args) => ids.has(id) || matchers.some(matcher => matcher.test(id));
}
return () => false;
};
上述函数提供的能力简单地说就是当用户在 vite.config.* 配置模块中配置了 build.rollupOptions.external 的话,那么 rollup 会收集配置的路径或正则来为后续判断解析的路径是否为外链。
options.external = (id, ..._args) => ids.has(id) || matchers.some(matcher => matcher.test(id));
那么,这时大家一定会很好奇检测外链是在什么时机下发生的呢? 带着这个疑问我们继续往下看吧。
Rollup 检测时机
Rollup 在初始化配置信息之后会通过配置入口(默认 index.html )来生成 模块依赖图
async generateModuleGraph() {
(
{ entryModules: this.entryModules, implicitEntryModules: this.implicitEntryModules } =
await this.moduleLoader.addEntryModules(normalizeEntryModules(this.options.input), true)
);
}
async build() {
timeStart('generate module graph', 2);
await this.generateModuleGraph();
// ...
}
大家对于模块依赖图一定不会陌生吧,说简单点就是确定模块与模块之间的依赖关系。我们就不一一照着源码来介绍生成模块依赖图的流程,那样多无聊呀!
我们可以静下心来好好想想,如果让我们来构建项目的模块依赖图,我们应该怎么做呢?
首先,我们肯定需要从配置的 入口(默认 index.html ) 开始分析起。我们可以分析 index.html 模块依赖哪些模块吧,可能 依赖 这个词有些同学不是很懂,那么就用更通俗的话来说 "【我们可以分析 index.html 模块 import 、 script、link 了哪些模块吧】"。分析完 index.html 模块后我们不就又可以分析它依赖的模块嘛,在分析依赖模块之前我们应该要先找一下依赖模块在哪,毕竟依赖模块通常也是本地文件资源嘛。找到依赖模块的位置后很轻易的就能联想到肯定是要读取依赖模块信息吧,那么熟悉 node 的同学就直接 fs.readFile 开干! 那我们读取完了嘞,这还要想嘛,不就也像 index.html 模块一样进行解析然后分析依赖情况嘛。细心的同学就可以发现其实就是一个递归检索依赖模块的过程,好像也没什么难度嘛。
真棒! Rollup 还真就是这么处理的,只不过处理流程的时候会引入插件来协助分析。
那么有同学可能就会说:好像讲离题了唉,这个文章不是要分析 external,怎么讲到了依赖构建的流程呢!
别急嘛,心急吃不了热豆腐。上述的内容是为了让大家对于 Rollup 生成模块依赖图流程有一个大体的认识,后面的内容会有所关联的。
那么我们就继续吧。前面有提到 「找到依赖模块的位置」,Rollup 在处理依赖模块路径的时候会判断当前模块路径是否是 external,也就是说是否路径是外链。
this.resolveId =
async (source, importer, customOptions, isEntry, skip = null) => {
return this.getResolvedIdWithDefaults(
this.getNormalizedResolvedIdWithoutDefaults(
this.options.external(source, importer, false)
? false
: await resolveId(source, importer, this.options.preserveSymlinks, this.pluginDriver, this.resolveId, skip, customOptions, typeof isEntry === 'boolean' ? isEntry : !importer),
importer,
source
)
);
};
源码上可以了解到当路径被判定为外部链接的情况下是不会执行 await resolveId(...),也就意味着对于外部链接 Rollup 不会对其做处理。
对 external 的扩展目标与思路
好嘞,前文先介绍到这里,接下来先确定下对 external 扩展的目标吧。
-
对
ESM和UMD产物链接做支持且自动引入CDN链接。- 目标解释:
伴随着高版本浏览器支持
ESM规范。各大包开发者也意识到了ESM的重要性,也提供了ESM规范的产物且同时也有类似 skypack 的CDN厂商对ESM产物的链接做支持。因此插件对于ESM外链的支持也是势在必行。
- 目标解释:
伴随着高版本浏览器支持
-
支持
Vite2.0及其以上版本。- 目标解释:
可能大家有疑惑为什么不支持
2.0之前的版本呢,原因在于2.0其实是Vite的第一个稳定版本。其次大家可以在 NPM Version 上看出在Vite 2.0之前的版本基本没什么受众,耗费时间去兼容意义并不是很大。同时在 awesome-vite 库中申请优秀插件的时候官方有这样一条要求:The plugin/tool is working with Vite 2.x and onward即对于Vite 2.x版本及以上的支持。
- 目标解释:
可能大家有疑惑为什么不支持
好嘞,现在我们已经确认好目标。那么接下来我们就要分析下需要如何进行实现。
对 ESM 和 UMD 产物链接做支持且自动引入 CDN 链接 目标的实现
-
ESM规范产物链接的支持:由于高版本浏览器支持对
ESM链接的支持,那么我们只需要将原先的包模块名称换成CDN链接就可以了。就好比将import react from 'react'替换为import react from 'https://cdn.skypack.dev/react'。按照我们先前说的,当我们引入react模块,那么在构建阶段Rollup就会去找react模块究竟在哪里,也就是说寻找react模块的具体路径。那么我们只需要告诉Rollup,react的具体路径为https://cdn.skypack.dev/react不就好了嘛。的确就是这么处理的,不过这里需要提一嘴的是Rollup在获取路径后还会通过external配置项来确定下获取的链接是否是外链,如果不是外链的话会进一步进行解析,继续解析按照我们先前说的就是去加载资源,我们引入CDN,不就是想让包体积变小嘛,如果让Rollup加载资源,那打包体积不久没优化嘛,我们目标就想要替换react为https://cdn.skypack.dev/react。因此我们需要告诉Rollup,https://cdn.skypack.dev/react是一个外链,你不要继续解析了。 -
UMD规范产物链接的支持:早期
JQuery盛行时,我们通常会使用CDN的方式来引入JQuery的UMD规范产物,然后CDN引入的产物执行后会在全局(浏览器环境在window下)注入特定的属性,开发者就可以通过这个特定属性来获取JQuery的能力。大家可能会有这样地想法,浏览器环境中我们只需要帮忙获取一下JQuery在window下注入的属性然后将模块导入语句修改一下就好了嘛,即将import jQuery from 'jquery'替换成const jQuery = window['jQuery']。的确,这就是现在最通俗的解决方案,不过需要对不同的导入方式做特定的处理。但作为Vite的插件,我们可以使用更为简单的方式来进行实现,即借助 虚拟模块 来进行实现。可能有部分同学对于虚拟模块的定义有点陌生,其实顾名思义就是虚假的模块,是一个不存在磁盘空间上的文件。那么我们可以在虚拟模块中将定义的内容导出就好了,即import jQuery from 'jquery'加载的内容为// virtual: jquery const jQuery = window['jQuery']; export default jQuery;那么
Rollup加载jquery内容其实就转换为加载我们上述导出的jQuery了。 -
自动引入
CDN链接:按照以往流程的话,最后一步我们肯定会手动创建
script或link标签后插入到index.html模块的head标签内。由于这个流程过于简单但有可能存在开发人员不用CDN后但没将CDN引入链接移除,那就存在一定的开销了。那么我们这次一并把自动注入能力做掉吧! 其实这个流程就是对于html模块做操作,按照正常想法实现的话通常是先找到一个能解析html模块的工具,将html文本转换为ast结构的对象,然后递归分析ast,针对head节点做插入处理。这里 也实现了相类似的能力,感兴趣的同学可以看看。不过通过借助
Vite内置 html 插件 的能力,我们可以用更简单的方式来做到。感兴趣的同学可以点击了解一下具体插件内部的实现吧,这里我就简单的说明一下作用吧。html 插件 会调用transformIndexHtml钩子,钩子的返回值若携带特定的标识符会将信息进行拼接后注入到html模块特定的地方,使用方式可见 官方文章。那么我们只需要在transformIndexHtml钩子中返回特定的信息,Vite的内置 html 插件 就会帮我们处理。
支持 Vite 2.0及其以上版本 目标的实现:
在上文 「ESM规范产物链接的支持」 中有提到我们需要告诉 Rollup 对于 ESM 规范的 CDN 链接不需要继续做解析,因此需要在 Rollup 配置上新增 external 属性。由于 ESM 外链支持异步化(配置外链的流程很大程度会接入平台),而对于在 2.0 ~ 2.2 版本之间 Vite 并不支持异步配置。只能在 vite.config.* 模块中手动添加 external。
结束语
好嘞,到此实现思路就已经聊完了。相信大部分的同学已经可以写出 external 这一类的插件了,感觉还是蛮有意思的。我已经将实现的思路代码化了,感兴趣的同学可以将 项目 clone 下来。在此我也很期待大家能给 仓库 带来好的 想法 和 贡献,当然能留下您的 Star 再好不过了,感谢大家的阅读!
转载自:https://juejin.cn/post/7169534783783960613