背景
最近研究Vite如何通过CDN加载资源,找到一个插件(vite-plugin-external)挺好用,设计也很巧妙,就想着扒一下它的源码看看。没想到源码却很简单,看完后你也能写Vite插件。
关于Vite如何通过CDN加载资源,可以看看这篇文章《彻底搞懂Vite+Vue如何通过CDN加载资源》
vite-plugin-external 插件,从其名字可以看出,和 external 相关,在构建工具中配置 external,目的就是为了告知程序,构建时不要将 external 配置的依赖项打进去。它提供了运行时排除依赖项的方法,同时也支持构建打包时排除依赖项。
这些依赖项被排除,应用程序如何读取它?答案就是:通过全局引入,也就是我们常说的通过CDN的方式,去加载这些依赖资源。
在分析它的源码之前,先看看 vite-plugin-external 是如何使用的,以Vue为例,我们想要通过CDN加载Vue,可以这样配置:
javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import createExternal from 'vite-plugin-external'
export default defineConfig({
plugins: [
vue(),
createExternal({
externals: {
vue: 'Vue',
}
})
],
})
我们看看运行时,这个插件做了什么事情:
根据运行时的代码分析,猜测 vite-plugin-external 做了这几件事:
1)修改了Vue指向,从图二可以看到Vue已经指向了全局变量"Vue"
2)生成了一个.vite_external文件夹,以及vue.js文件,图三可以看到vue.js内容,只是导出全局变量"Vue"
前置知识
在分析源码之前,要理解以下概念,如果全部掌握,可以跳过。
1)resolve.alias
详见Vite官方文档
我们通过实际配置,看看alias的作用:
javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
resolve: {
alias: [
{
find: new RegExp('^vue$'),
replacement: 'https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.0-beta.7/vue.esm-browser.min.js'
}
]
},
plugins: [
vue(),
],
})
通过源文件和运行时对比,可以看到 alias 直接替换成了我们配置的CDN。
tips:alias 配置的CDN必须是ESM格式。
2)cacheDir
详见Vite官网
此目录下会存储预打包的依赖项或 vite 生成的某些缓存文件,默认就是node_modules下面的.vite,vite-plugin-external 的cacheDir 是 node_modules 下的 .vite-external 文件。
3)构建选项build
详见Vite 官网
使用过 Vite 的人,应该都配置过build,Vite 生产构建使用的是Rollup,主要了解build.rollupOptions的具体配置。
4)Vite 插件API钩子
需要简单了解Vite插件的 API 的使用, 详见Vite 官网
vite-plugin-external 使用了两个钩子,config 和 configResolved,核心逻辑在config钩子实现。
以下是Vite提供所有钩子:
通用钩子指Rollup和Vite都有
钩子 | 调用时机 | 钩子类型 |
---|---|---|
options |
服务器启动时 | 通用钩子 |
buildStart |
服务器启动时 | 通用钩子 |
resolveId |
每个传入模块请求时 | 通用钩子 |
load |
每个传入模块请求时 | 通用钩子 |
transform |
每个传入模块请求时 | 通用钩子 |
buildEnd |
服务器关闭时 | 通用钩子 |
closeBundle |
服务器关闭时 | 通用钩子 |
config | 在解析 Vite 配置前调用 | Vite 独有 |
configResolved | 在解析 Vite 配置后调用 | Vite 独有 |
configureServer | 配置开发服务器的钩子 | Vite 独有 |
configurePreviewServer | 预览服务器的钩子 | Vite 独有 |
transformIndexHtml | 转换 index.html 的专用钩子 |
Vite 独有 |
handleHotUpdate | 执行自定义 HMR 更新处理 | Vite 独有 |
5)Vite 插件基本模板
实现 Vite 插件很简单,本质就是要返回一个对象,这个对象中可以定义一些基本属性以及钩子函数。
Vite 插件的API抛出了比较多的钩子,在钩子中处理业务逻辑。
一个简单的插件模板如下,需要用到什么钩子,根据实际场景来:
javascript
export default function createPlugin(opts) {
return {
name: 'vite-plugin-external',
enforce: opts.enforce,
transform(src, id) {
if (fileRegex.test(id)) {
return {
code: compileFileToJS(src),
map: null // 如果可行将提供 source map
}
}
},
config(config, { mode, command }) {
...
},
configResolved(config) {
...
}
}
}
源码解读
1)入口函数 createExternal
vite-plugin-external 导出方法 createExternal,先看看 createExternal 这个函数的源码:
ini
export default function createPlugin(opts: Options) : Plugin {
let libNames: any[];
return {
name: 'vite-plugin-external',
enforce: opts.enforce,
async config(config: UserConfig, { mode, command }: ConfigEnv) {
// 根据条件初始化部分默认参数
const {
cacheDir,
externals,
interop
} = buildOptions(opts, mode);
libNames = !externals ? [] : Object.keys(externals);
let externalLibs = libNames;
let globals = externals;
// if development mode
// 在开发模式下,通过程序新增alias,将vue的alias指向自己构造的假文件
if (command === 'serve' || interop === 'auto') {
await addAliases(
config,
cacheDir as string,
globals,
libNames,
);
externalLibs = [];
globals = void 0;
}
if (command === 'build') {
// nodeBuiltins: booelan 是否排除 nodejs 内置模块
if (opts.nodeBuiltins) {
externalLibs = externalLibs.concat(
builtinModules.map((builtinModule) => {
return new RegExp(`^(?:node:)?${builtinModule}(?:/.+)*$`);
})
);
}
const { externalizeDeps } = opts;
// externalizeDeps: 排除不需要打包的依赖。
if (externalizeDeps) {
externalLibs = externalLibs.concat(externalizeDeps.map((dep) => {
return new RegExp(`^${dep}(?:/.+)*$`);
}));
}
}
let { build } = config;
if (!build) {
build = {};
config.build = build;
}
let { rollupOptions } = build;
if (!rollupOptions) {
rollupOptions = {};
build.rollupOptions = rollupOptions;
}
setExternals(rollupOptions, externalLibs);
setOutputGlobals(rollupOptions, globals);
},
configResolved(config: ResolvedConfig) {
// cleanup cache
if (config.command === 'serve') {
const depCache = join(config.cacheDir, 'deps', '_metadata.json');
let metadata;
try {
metadata = JSON.parse(readFileSync(depCache, 'utf-8'));
}
catch (e) {}
if (metadata && libNames && libNames.length) {
const { optimized } = metadata;
if (optimized && Object.keys(optimized).length) {
libNames.forEach((libName) => {
if (optimized[libName]) {
delete optimized[libName];
}
});
}
writeFileSync(depCache, JSON.stringify(metadata));
}
}
}
};
}
从代码可以看出,核心逻辑在 config 钩子实现,直接修改了vite的 config 配置。其中调用了buildOptions、addAliases、setExternals、setOutputGlobals四个方法。
先拆解一下这个函数的核心逻辑:
1)调用 buildOptions 方法处理用户传入的参数
2)获取用户传入参数中的 externals,以 vue 为例,存储在 libNames = ['vue']、globals={vue: 'Vue'}中
3)如果在开发模式下(command === 'serve'),调用 addAlias方法,在 config 中新增 resolve.alias 配置项,并同时根据 externals 配置生成缓存文件
4)如果在生产构建模式下(command === 'build'),默认读取配置文件中的build配置项,先调用 setExternals 修改 build.rollupOptions.external 配置项,再调用 setOutputGlobals 修改 build.rollupOptions.output 配置项
以上就是 vite-plugin-external 的核心逻辑,本质就是修改 config 。接下来,我们一步步分析。
2)buildOptions
这个方法主要就是处理一些非必要的参数,为这些非必要参数生成默认值,同时支持开发和生产环境独立配置。
非必要参数默认值处理
cwd:用于拼接 cacheDir
的路径,默认:process.cwd()
cacheDir:缓存文件夹,默认:${cwd}/node_modules/.vite_external
开发和生产环境独立配置
如果生产环境和开发环境配置不同参数(例如开发环境和生产环境配置不同的externals),可支持根据mode是development还是production取对应参数:
css
createExternal({
externals: {
react: '$linkdesign.React'
},
development: {
externals: {react: 'React'}
}
})
ini
function buildOptions(opts: Options, mode: string) : Options {
let {
cwd,
cacheDir,
externals,
// eslint-disable-next-line prefer-const
...rest
} = opts || {};
// mode: 'development' 用于开发,'production' 用于构建
const modeOptions: Options | undefined = opts[mode];
// 根据环境获取用户传进来的 opts 参数,主要处理缓存文件路径 和 外部依赖
if (modeOptions) {
Object.entries(modeOptions).forEach(([key, value]) => {
if (value) {
switch (key) {
case 'cwd': // 用于拼接 `cacheDir` 的路径,默认值:`process.cwd()`
cwd = value;
break;
case 'cacheDir': // 缓存文件夹,默认值:`${cwd}/node_modules/.vite_external`
cacheDir = value;
break;
case 'externals': // 外部依赖
externals = Object.assign({}, externals, value);
break;
}
}
});
}
// 如果用户没有传递相关参数,则cwd、cacheDir都取默认值
if (!cwd) {
cwd = process.cwd();
}
if (!cacheDir) {
cacheDir = join(cwd, 'node_modules', '.vite_external');
}
else if (!isAbsolute(cacheDir)) {
cacheDir = join(cwd, cacheDir);
}
return {
...rest,
cwd,
cacheDir,
externals
};
}
3)处理 externals 参数
以 vue 为例,externals 处理后得到 libNames 和 globals,这个数据格式分别为:
libNames:['vue']
Globals:{ vue: 'Vue' }
ini
const {
cacheDir,
externals,
interop
} = buildOptions(opts, mode);
libNames = !externals ? [] : Object.keys(externals);
let externalLibs = libNames;
let globals = externals;
4)addAlias
这个方法的本质就是修改 config 中的 resolve.alias,配置 alias 的用处在前文的【前置知识】中也介绍过。
具体逻辑可以看代码中的注释,其中核心点有2个:
- 根据上一步生成的 libNames,在 config 中新增 resolve.alias 配置
- 调用 createFakeLib 方法,生成外部依赖的文件,例如 Vue,则生成一个导出了全局变量Vue的js文件(commonjs)
我认为这个插件最大的亮点就在这里,其他逻辑都是为了修改 config 配置,都是Vite提供的功能,实现起来也很简单。
而 createFake 这个函数虽然不复杂,但设计很巧妙,它将一个全局变量,包装在一个commonjs文件中导出,从而可以实现将原先指向本地node_modules的依赖包,最后指向对应的全局变量。
不过其本质还是利用 Vite 中 resolve.alias 的特性。
在项目中,如果我们想利用 CDN ,可配置alias,其路径要是对应依赖包的CDN路径,且要求是ESM方式,ESM这种方式,我们最终也不会应用于生产环境。
vite-plugin-external的处理,alias依赖的是的全局变量,生产和开发都可以用一样的逻辑加载 CDN 资源。
typescript
async function addAliases(
config: UserConfig,
cacheDir: string,
externals: Record<string, any> | undefined,
libNames: string[]
) : Promise < void > {
// cleanup cache dir
emptyDirSync(cacheDir);
// 如果 libNames 和 externals 都不存在,则无需处理
if (libNames.length === 0 || !externals) {
return;
}
let { resolve } = config;
// 如果 config 中没有配置resolve,则初始化一个空值
if (!resolve) {
resolve = {};
config.resolve = resolve;
}
let { alias } = resolve;
// 如果 config.resolve 中没有配置alias,则初始化一个空值
if (!alias) {
alias = [];
resolve.alias = alias;
}
// #1 if alias is object type
// 如果 config.resolve.alias 不是数组,则将其转换为数组
// 统一处理成这样的格式 { find: string | RegExp, replacement: string}[]
if (!Array.isArray(alias)) {
alias = Object.entries(alias).map(([key, value]) => {
return { find: key, replacement: value };
});
resolve.alias = alias;
}
// 以上都是处理 config.resolve 的默认参数
// 接下来才是将用户传入的 externals 添加到 alias中
await Promise.all(libNames.map((libName) => {
// 获取各个外部依赖的缓存文件路径
// 例如vue,默认是 `${cwd}/node_modules/.vite_external/vue.js`
const libPath = join(cacheDir, `${libName.replace(///g, '_')}.js`);
(alias as Alias[]).push({
find: new RegExp(`^${libName}$`),
replacement: libPath
});
// 根据 externals 配置的 value 值,生成该依赖文件
// 例如 vue,用户传入的配置是{vue: 'Vue'},此时 value 是 Vue
return createFakeLib(externals[libName], libPath);
}));
}
// 生成缓存文件
function createFakeLib(globalName: string, libPath: string) : Promise < void > {
// 如果是vue,最后 cjs = module.exports = Vue
const cjs = `module.exports = ${globalName};`;
// 根据 libPath 生成文件
return outputFile(libPath, cjs, 'utf-8');
}
5)setOutputGlobals
这个方法也很简单,最终目的就是修改 build.rollupOptions.output 配置,不清楚的可以去Rollup官方了解它的作用。实现将 externals中配置的依赖包从最后构建的产物中排除。
ini
function setOutputGlobals(rollupOptions: RollupOptions, externals?: Record<string, any>) : void {
if (!externals) {
return;
}
let { output } = rollupOptions;
if (!output) {
output = {};
rollupOptions.output = output;
}
// compat Array
if (Array.isArray(output)) {
output.forEach((n) => {
rollupOutputGlobals(n, externals);
});
}
else {
rollupOutputGlobals(output, externals);
}
}
function rollupOutputGlobals(output: OutputOptions, externals: Record<string, any>) : void {
let { globals } = output;
if (!globals) {
globals = {};
output.globals = globals;
}
Object.assign(globals, externals);
}
5)总结
vite-plugin-external 这个插件的核心逻辑还是比较简单,就是修改 config 配置:
- 运行时,提供修改 resolve.alias 的方法,将 externals 中的依赖项指向全局变量
- 构建时,提供修改 build.rollupOptions 的方法,将 externals 中的依赖项从构建产物中排除
其中,运行时调用 createFake 方法,将 externals 中的依赖项指向全局变量,这里设计很巧妙。把该依赖项的全局变量,包装成一个commonjs文件导出,可以突破 alias 时只允许配置ESM文件的限制。
写在最后
所以,如果你想实现一个可以修改 config 文件的Vite 插件,基本模板如下:
javascript
export default function myPlugin(opts) {
return {
name: 'my-plugin',
config(config, { mode, command }) {
...
}
}
}
如果,还想支持其他场景,模板也类似,只是需要将钩子函数 config 换成其他钩子函数。