看完这篇你也能手写Vite插件

背景

最近研究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个:

  1. 根据上一步生成的 libNames,在 config 中新增 resolve.alias 配置
  2. 调用 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 配置:

  1. 运行时,提供修改 resolve.alias 的方法,将 externals 中的依赖项指向全局变量
  2. 构建时,提供修改 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 换成其他钩子函数。

相关推荐
Mr_Xuhhh29 分钟前
重生之我在学环境变量
linux·运维·服务器·前端·chrome·算法
永乐春秋1 小时前
WEB攻防-通用漏洞&文件上传&js验证&mime&user.ini&语言特性
前端
鸽鸽程序猿1 小时前
【前端】CSS
前端·css
ggdpzhk1 小时前
VUE:基于MVVN的前端js框架
前端·javascript·vue.js
学不会•4 小时前
css数据不固定情况下,循环加不同背景颜色
前端·javascript·html
活宝小娜6 小时前
vue不刷新浏览器更新页面的方法
前端·javascript·vue.js
程序视点6 小时前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
coldriversnow6 小时前
在Vue中,vue document.onkeydown 无效
前端·javascript·vue.js
我开心就好o6 小时前
uniapp点左上角返回键, 重复来回跳转的问题 解决方案
前端·javascript·uni-app
开心工作室_kaic7 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端