@meng-xi/vite-plugin 不仅提供了 6 个开箱即用的构建插件,更是一套完整的 Vite 插件开发框架。本文将深入剖析其核心功能模块的实现原理、设计亮点、使用方法及与同类方案的对比优势,帮助开发者全面理解并高效使用这个工具库。


目录


一、引言

在现代前端工程化体系中,Vite 已成为主流构建工具。然而,实际项目中经常遇到构建进度不可见、静态资源复制繁琐、路由配置手动维护、版本号管理混乱、白屏体验差等痛点。市面上的 Vite 插件往往只解决单一问题,且缺乏统一的开发规范。

@meng-xi/vite-plugin 的设计理念是:提供一套完整的解决方案,而非零散的工具集合。它包含两层能力:

  1. 6 个内置插件 — 覆盖构建进度、文件复制、路由生成、版本管理、图标注入、全局 Loading 等高频场景
  2. 插件开发框架 — 导出 BasePluginValidatorLoggercreatePluginFactory 等核心组件,让开发者能以统一规范快速构建自定义插件

二、功能概述

2.1 库的定位与架构

@meng-xi/vite-plugin
│
├── 插件开发框架(基础设施层)
│   ├── BasePlugin        抽象基类:生命周期、配置合并、错误处理
│   ├── Validator         配置验证器:链式 API、批量错误收集
│   ├── Logger            日志管理器:单例 + 代理、统一前缀
│   ├── createPluginFactory  工厂函数:类 → Vite Plugin 转换
│   └── common            通用工具:fs 操作、格式化、深度合并
│
└── 内置插件(应用层)
    ├── buildProgress     终端构建进度条
    ├── copyFile          智能文件复制(增量)
    ├── generateRouter    uni-app 路由自动生成
    ├── generateVersion   多格式版本号生成
    ├── injectIco         HTML 图标注入
    └── injectLoading     全局 Loading 状态管理

模块导出设计:通过 package.jsonexports 字段提供 5 个入口点,支持按需导入:

{
  "exports": {
    ".":           { "import": "./dist/index.mjs",  "require": "./dist/index.cjs",  "types": "./dist/index.d.ts" },
    "./common":    { "import": "./dist/common/index.mjs", ... },
    "./factory":   { "import": "./dist/factory/index.mjs", ... },
    "./logger":    { "import": "./dist/logger/index.mjs", ... },
    "./plugins":   { "import": "./dist/plugins/index.mjs", ... }
  }
}

每个入口同时提供 ESM(.mjs)、CJS(.cjs)和类型声明(.d.ts),兼容所有构建工具和 Node.js 版本。

2.2 核心功能模块一览

模块 功能 关键特性
BasePlugin 插件基类 三层配置合并、钩子自动组合、安全执行、生命周期管理
Validator 配置验证 链式 API、批量错误收集、自定义规则、默认值设置
Logger 日志管理 单例模式、插件级代理、统一前缀、ANSI 颜色
createPluginFactory 工厂函数 三重泛型、选项标准化器、pluginInstance 暴露
common 通用工具 增量文件复制、并发控制、深度合并、格式化工具
buildProgress 构建进度 三种格式、进度只进不退、TTY 感知、自定义主题
copyFile 文件复制 增量判断、并发复制、递归支持、详细统计
generateRouter 路由生成 pages.json 解析、子包支持、文件监听、用户修改保留
generateVersion 版本号 6 种格式、文件/全局变量双输出、自定义模板
injectIco 图标注入 HtmlTagDescriptor API、字符串简写、图标文件复制
injectLoading Loading 管理 双阶段注入、请求拦截、防闪烁、指针事件控制、安全验证

2.3 与同类库的对比优势

对比维度 @meng-xi/vite-plugin vite-plugin-progress vite-plugin-copy vite-plugin-html
功能覆盖 6 个插件 + 开发框架 仅进度条 仅文件复制 仅 HTML 注入
插件开发框架 完整(BasePlugin + Validator + Logger)
错误处理 三级策略(throw/log/ignore) 无统一策略 无统一策略 无统一策略
配置验证 内置 Validator,批量错误收集
日志管理 单例 + 代理,统一前缀 各自为政 各自为政 各自为政
类型安全 全链路 TypeScript 部分 部分 部分
增量复制 mtimeMs + size 双判断 部分支持
Loading 防闪烁 delayShow + minDisplayTime + debounceHide
Loading 指针控制 enablePointerEvents / disablePointerEvents
路由用户修改保留 preserveRouteChanges
按需导入 5 个子路径入口 单入口 单入口 单入口

核心优势总结

  1. 框架级一致性 — 所有内置插件共享相同的生命周期、错误处理、日志和验证机制,而非各自为政
  2. 可扩展性 — 开发者可基于 BasePlugin 构建自定义插件,享受与内置插件完全相同的基础设施
  3. 防御性编程safeExecute + errorStrategy 确保单个插件的异常不会意外中断整个构建流程
  4. 生产级细节 — 增量复制、防闪烁、TTY 感知、SSR 安全检测、XSS 防护等,都是实际生产环境中的刚需

三、插件开发框架

3.1 BasePlugin — 插件基类

BasePlugin 是整个库的核心,所有内置插件和自定义插件都继承自它。它提供了完整的生命周期管理、配置合并、错误处理和钩子自动组合机制。

3.1.1 生命周期流程
┌──────────────────────────────────────────────────────────────┐
│                    BasePlugin 生命周期                         │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ① constructor                                               │
│     ├── mergeOptions()         三层配置深度合并               │
│     │   deepMerge(baseDefaults, pluginDefaults, userOptions)  │
│     ├── initLogger()           创建插件日志代理               │
│     │   Logger.create() → createPluginLogger()               │
│     ├── new Validator()        初始化配置验证器               │
│     └── validateOptions()      执行配置验证(safeExecuteSync)│
│                                                              │
│  ② toPlugin() → Vite Plugin 对象                             │
│     ├── addPluginHooks()       子类注册业务钩子               │
│     ├── 自动组合 configResolved                              │
│     │   基类.onConfigResolved() → 子类钩子                   │
│     └── 自动组合 closeBundle                                 │
│         子类钩子 → 基类.destroy()                            │
│                                                              │
│  ③ destroy()                                                 │
│     ├── 子类重写:清理 watcher、定时器等                      │
│     └── 基类:Logger.unregister() 注销日志                   │
│                                                              │
└──────────────────────────────────────────────────────────────┘
3.1.2 三层配置合并
protected mergeOptions(options: T): Required<T> {
  const baseDefaults: BasePluginOptions = {
    enabled: true,
    verbose: true,
    errorStrategy: 'throw'
  }
  const pluginDefaults = this.getDefaultOptions()
  return deepMerge(baseDefaults, pluginDefaults, options) as Required<T>
}

合并优先级:baseDefaults < pluginDefaults < userOptionsdeepMerge 的关键行为是 undefined 不覆盖已有值(保护默认值),null 会覆盖(允许显式置空),嵌套对象递归合并,数组直接覆盖。

3.1.3 钩子自动组合

toPlugin() 方法最精巧的设计是自动组合 configResolvedcloseBundle 钩子:

public toPlugin(): Plugin {
  const plugin: Plugin = {
    name: this.getPluginName(),
    enforce: this.getEnforce()
  }
  this.addPluginHooks(plugin)

  // 自动组合 configResolved:基类先存储 viteConfig,子类才能使用
  const subclassConfigResolved = plugin.configResolved
  plugin.configResolved = (config) => {
    if (this.options.enabled) {
      this.onConfigResolved(config)
      if (typeof subclassConfigResolved === 'function') {
        subclassConfigResolved(config)
      }
    }
  }

  // 自动组合 closeBundle:子类先清理资源,基类再注销日志
  const instance = this
  const subclassCloseBundle = plugin.closeBundle
  plugin.closeBundle = function() {
    if (typeof subclassCloseBundle === 'function') {
      subclassCloseBundle.call(this)
    }
    instance.destroy()
  }

  return plugin
}

设计意图

  • configResolved — 基类先存储 viteConfig,子类钩子才能通过 this.viteConfig 访问
  • closeBundle — 子类先清理资源(如关闭 watcher、停止定时器),基类再注销日志配置

子类无需手动注册这两个钩子,只需重写 onConfigResolved()destroy() 方法即可。

3.1.4 错误处理策略
interface BasePluginOptions {
	errorStrategy?: 'throw' | 'log' | 'ignore'
}
操作执行
  │
  ├── 成功 → 返回结果
  │
  └── 异常 → handleError()
              │
              ├── 'throw'(默认)
              │   logger.error() → throw error → 中断构建
              │
              └── 'log' | 'ignore'
                  logger.error() → return undefined → 继续执行

所有内置插件的异步操作都通过 safeExecute 包裹:

plugin.writeBundle = async () => {
	await this.safeExecute(() => this.copyFiles(), '复制文件')
}

这确保了即使复制文件失败,也不会导致整个构建进程崩溃(当 errorStrategy'log' 时)。

3.2 Validator — 配置验证器

Validator 提供流畅的链式 API,是所有插件配置验证的基础:

this.validator
	.field('sourceDir')
	.required()
	.string()
	.custom(val => val.trim() !== '', 'sourceDir 不能为空字符串')
	.field('overwrite')
	.boolean()
	.default(true)
	.field('incremental')
	.boolean()
	.default(true)
	.validate()

核心设计:验证错误不立即抛出,而是收集到 errors 数组中,最终在 validate() 时一次性抛出所有错误。这让用户能一次看到所有配置问题,而非逐个修复。

支持的验证方法

方法 说明
field(name) 指定验证字段
required() 标记为必填
string() / boolean() / number() / array() / object() 类型验证
default(value) 设置默认值(仅当值为 undefined/null 时生效)
custom(fn, msg) 自定义验证规则
validate() 执行验证,失败时抛出包含所有错误的异常

Validator 实例可独立使用injectIco 插件中为嵌套的 copyOptions 创建了独立的 Validator 实例进行验证,体现了 Validator 的复用能力。

3.3 Logger — 日志管理器

Logger 采用 单例 + 代理 双层架构:

Logger(全局单例)
  │
  ├── pluginConfigs: Map<string, boolean>     ← 各插件日志开关
  │
  ├── 静态方法
  │   ├── Logger.create({ name, enabled })    ← 注册插件 + 返回单例
  │   ├── Logger.unregister(name)             ← 注销插件日志配置
  │   └── Logger.destroy()                    ← 销毁单例(测试用)
  │
  └── createPluginLogger(name) → PluginLogger ← 创建插件级代理
      ├── info(message, data?)
      ├── success(message, data?)
      ├── warn(message, data?)
      └── error(message, data?)

日志输出格式

ℹ️ [@meng-xi/vite-plugin:build-progress] 转换模块 67%
✅ [@meng-xi/vite-plugin:copy-file] 复制文件成功:从 src/assets 到 dist/assets
⚠️ [@meng-xi/vite-plugin:generate-router] pages.json 中没有有效的页面配置
❌ [@meng-xi/vite-plugin:inject-ico] 图标文件不存在: /assets/favicon.ico

设计要点

  • 统一前缀 [@meng-xi/vite-plugin:插件名],多插件并行时快速定位来源
  • 每个插件通过 verbose 选项独立控制日志开关
  • 插件销毁时自动 Logger.unregister(),防止内存泄漏
  • 支持 ANSI 颜色码,终端输出直观区分日志级别
  • data 参数支持附加数据输出,用于展示详细统计信息

3.4 createPluginFactory — 工厂函数

function createPluginFactory<T extends BasePluginOptions, P extends BasePlugin<T>, R = T>(PluginClass: new (options: T, loggerConfig?: LoggerOptions) => P, normalizer?: OptionsNormalizer<T, R>): PluginFactory<T, R>

三重泛型设计

  • T — 插件配置类型(如 InjectIcoOptions
  • P — 插件实例类型(如 InjectIcoPlugin
  • R — 原始配置类型(如 string | InjectIcoOptions,支持简写)

OptionsNormalizer 的妙用 — 支持字符串简写:

// injectIco 支持字符串简写:injectIco('/assets')
export const injectIco = createPluginFactory<InjectIcoOptions, InjectIcoPlugin, string | InjectIcoOptions>(InjectIcoPlugin, options => (typeof options === 'string' ? { base: options } : options || {}))

pluginInstance 暴露机制

return (options?: R) => {
	const normalizedOptions = (normalizer ? normalizer(options) : options) as T
	const plugin = new PluginClass(normalizedOptions)
	const vitePlugin = plugin.toPlugin() as PluginWithInstance<T>
	vitePlugin.pluginInstance = plugin // 挂载原始实例
	return vitePlugin
}

返回的 Vite Plugin 对象上附加了 pluginInstance 属性,允许外部访问插件内部状态(如 optionslogger 等),实现了封装与可观测性的平衡。

3.5 common — 通用工具模块

common 模块是插件开发框架的基础工具层,提供 4 个子模块:

子模块 功能 关键导出
fs 文件系统操作 checkSourceExistscopySourceToTargetshouldUpdateFilerunWithConcurrencyreadFileContentwriteFileContentfileExistsreadFileSync(已废弃)
format 格式化工具 generateRandomHashgetDateFormatParamsparseTemplatestripJsonCommentsformatDatepadNumbertoCamelCasetoPascalCase
object 对象工具 deepMerge
validation 验证工具 Validator

核心工具详解

增量文件判断shouldUpdateFile 通过比较源文件与目标文件的 mtimeMs(修改时间戳)和 size(文件大小)判断是否需要更新:

async function shouldUpdateFile(sourceFile: string, targetFile: string): Promise<boolean> {
	try {
		const [sourceStats, targetStats] = await Promise.all([fs.promises.stat(sourceFile), fs.promises.stat(targetFile)])
		return sourceStats.mtimeMs > targetStats.mtimeMs || sourceStats.size !== targetStats.size
	} catch {
		return true // 目标文件不存在,需要复制
	}
}

文件存在检查fileExists 用于判断文件是否存在,在增量复制中替代 try-catch 模式:

async function fileExists(filePath: string): Promise<boolean> {
	try {
		await fs.promises.access(filePath, fs.constants.F_OK)
		return true
	} catch {
		return false
	}
}

并发控制runWithConcurrency 使用 Worker Pool 模式,N 个 Worker 共享递增索引并行消费任务队列,结果按原始顺序存储:

async function runWithConcurrency<T, R>(items: T[], handler: (item: T) => Promise<R>, concurrency: number): Promise<R[]> {
	const results: R[] = []
	let index = 0
	async function runNext(): Promise<void> {
		while (index < items.length) {
			const currentIndex = index++
			results[currentIndex] = await handler(items[currentIndex])
		}
	}
	const workers = Array(Math.min(concurrency, items.length))
		.fill(null)
		.map(() => runNext())
	await Promise.all(workers)
	return results
}

日期格式化formatDategetDateFormatParams 提供灵活的日期格式化能力:

const params = getDateFormatParams(new Date())
// { YYYY: '2026', MM: '05', DD: '23', HH: '15', mm: '30', ss: '00', SSS: '123', timestamp: '1748001000000' }

formatDate(new Date(), '{YYYY}-{MM}-{DD}') // '2026-05-23'

四、内置插件详解

4.1 buildProgress — 构建进度可视化

在 Vite 构建过程中实时显示终端进度条,支持三种格式和自定义主题。

实现原理

进度计算 — 基于构建生命周期阶段和模块转换比例计算进度:

阶段 进度范围 说明
config 5% 配置解析完成
resolve 10% 模块依赖解析
transform 15% - 85% 按模块转换比例线性增长
bundle +10% 打包阶段(仅生产构建)
write +5% 写入文件阶段
done 100% 构建完成

进度只进不退 — 使用 lastPercentage 记录历史最高进度,Math.max(calculated, lastPercentage) 确保进度不会回退。

TTY 感知 — 通过 process.stdout.isTTY 检测终端类型,非 TTY 环境(如 CI/CD)自动降级为日志输出。

Spinner 动画 — 以 80ms 间隔定时刷新进度显示,实现流畅的旋转动画效果。构建完成时自动停止并恢复光标显示。

模块排除node_modules.virtual 模块自动排除,不计入进度统计。

配置选项
buildProgress({
	width: 30, // 进度条宽度(字符数)
	format: 'bar', // 'bar' | 'spinner' | 'minimal'
	completeChar: '█', // 已完成填充字符
	incompleteChar: '░', // 未完成填充字符
	clearOnComplete: true, // 构建完成后是否清除进度条
	showModuleName: true, // 是否显示当前处理模块名
	theme: {
		// 自定义颜色主题
		completeColor: t => `\x1b[32m${t}\x1b[39m`,
		incompleteColor: t => `\x1b[90m${t}\x1b[39m`,
		percentageColor: t => `\x1b[1m${t}\x1b[22m`,
		phaseColor: t => `\x1b[36m${t}\x1b[39m`,
		moduleColor: t => `\x1b[90m${t}\x1b[39m`
	}
})
适用场景
  • 开发/生产构建的进度可视化
  • CI/CD 流水线中的构建监控(自动降级为日志)
  • 大型项目的构建时间评估

4.2 copyFile — 智能文件复制

在 Vite 构建完成后将指定目录的文件复制到目标位置,支持增量复制避免不必要的 IO 开销。

实现原理

增量判断逻辑 — 通过比较源文件与目标文件的 mtimeMs(修改时间戳)和 size(文件大小)判断是否需要更新:

async function shouldUpdateFile(sourceFile: string, targetFile: string): Promise<boolean> {
	try {
		const [sourceStats, targetStats] = await Promise.all([fs.promises.stat(sourceFile), fs.promises.stat(targetFile)])
		return sourceStats.mtimeMs > targetStats.mtimeMs || sourceStats.size !== targetStats.size
	} catch {
		return true // 目标文件不存在,需要复制
	}
}

并发复制 — 使用 Worker Pool 模式(runWithConcurrency),N 个 Worker 共享递增索引并行消费任务队列,结果按原始顺序存储。默认并发限制为 10。

复制流程

  1. 一次性获取所有文件条目(含类型信息,避免重复 stat 调用)
  2. 预先并行创建所有目标目录(Promise.all + ensureTargetDir
  3. 并发复制文件(runWithConcurrency,默认 10 并发)
  4. 统计并返回复制结果(copiedFiles / skippedFiles / copiedDirs / executionTime)

安全执行 — 复制操作通过 safeExecute 包裹,确保异常不会导致构建崩溃:

protected addPluginHooks(plugin: Plugin): void {
  plugin.writeBundle = async () => {
    await this.safeExecute(() => this.copyFiles(), '复制文件')
  }
}

enforce 设置 — 插件设置 enforce: 'post',确保在其他插件处理完构建产物后再执行文件复制。

配置选项
copyFile({
	sourceDir: 'src/assets', // 源目录路径(必填)
	targetDir: 'dist/assets', // 目标目录路径(必填)
	overwrite: true, // 是否覆盖现有文件
	recursive: true, // 是否递归复制子目录
	incremental: true // 是否启用增量复制
})
适用场景
  • 静态资源(图片、字体等)从源目录复制到构建输出目录
  • 多环境配置文件的按需复制
  • 大型项目中避免全量复制带来的性能开销

4.3 generateRouter — 路由配置自动生成

读取 uni-app 项目的 pages.json,自动生成路由配置文件,支持文件监听和用户修改保留。

实现原理

解析流程

pages.json → stripJsonComments → JSON.parse → 解析主包页面 → 解析子包页面
                                                            │
                                                            └── 解析 tabBar 页面

路由名称策略 — 支持 4 种命名方式:

策略 示例路径 生成名称
path /pages/index pages_index
camelCase /pages/user-list pagesUserList
pascalCase /pages/user-list PagesUserList
custom 自定义函数 customNameGenerator 决定

元信息映射 — 通过 metaMapping 配置将 pages.json 中的字段映射到路由 meta:

metaMapping: {
  navigationBarTitleText: 'title',    // style.navigationBarTitleText → meta.title
  requireAuth: 'requireAuth'           // style.requireAuth → meta.requireAuth
}

用户修改保留 — 这是 generateRouter 最独特的设计亮点。当 preserveRouteChangestrue 时,重新生成路由配置会保留用户对已有路由的手动修改:

private mergeRoutes(newRoutes: RouteConfig[], existingRoutesMap: Map<string, RouteConfig>): RouteConfig[] {
  return newRoutes.map(newRoute => {
    const existingRoute = existingRoutesMap.get(newRoute.path)
    if (!existingRoute) return newRoute

    const mergedMeta: RouteMeta = {}
    if (newRoute.meta) Object.assign(mergedMeta, newRoute.meta)
    if (existingRoute.meta) Object.assign(mergedMeta, existingRoute.meta)

    return {
      ...existingRoute,
      path: newRoute.path,
      meta: Object.keys(mergedMeta).length > 0 ? mergedMeta : undefined
    }
  })
}

合并策略

  • path 始终使用新生成的(由 pages.json 决定,是路由标识符)
  • name 等其他字段保留用户修改(...existingRoute 优先)
  • meta 先用新生成的作为基础,再用用户现有的覆盖(用户修改优先)

文件监听 — 开发模式下自动监听 pages.json 变化并重新生成路由配置,监听器在 destroy() 时自动关闭。

输出格式 — 生成的路由配置文件支持 TypeScript 和 JavaScript 两种格式,TypeScript 格式自动包含类型定义(RouteMetaRouteConfig 接口)。

配置选项
generateRouter({
	pagesJsonPath: 'src/pages.json', // pages.json 文件路径
	outputPath: 'src/router.config.ts', // 输出文件路径
	outputFormat: 'ts', // 'ts' | 'js'
	nameStrategy: 'camelCase', // 'path' | 'camelCase' | 'pascalCase' | 'custom'
	includeSubPackages: true, // 是否包含子包页面
	watch: true, // 开发模式下是否监听文件变化
	exportTypes: true, // 是否导出类型定义
	preserveRouteChanges: true, // 是否保留用户修改
	metaMapping: {
		// 元信息字段映射
		navigationBarTitleText: 'title',
		requireAuth: 'requireAuth'
	}
})
适用场景
  • uni-app 项目的路由配置自动化管理
  • 多人协作中避免手动维护路由配置导致的冲突
  • 需要在路由 meta 中添加自定义字段(如权限控制)的场景

4.4 generateVersion — 多格式版本号生成

在 Vite 构建过程中自动生成版本号,支持多种格式和输出方式。

实现原理

6 种版本号格式

格式 示例输出 说明
timestamp 20260203153000 时间戳格式
date 2026.02.03 日期格式
datetime 2026.02.03.153000 日期时间格式
semver 1.0.0 语义化版本(基于 semverBase
hash a1b2c3d4 随机哈希(可配置长度 1-32)
custom 自定义 基于模板解析,支持占位符

自定义模板 — 支持丰富的占位符:

generateVersion({
	format: 'custom',
	customFormat: '{YYYY}.{MM}.{DD}-{hash}', // 输出: 2026.02.03-a1b2c3d4
	hashLength: 8
})

可用占位符:{YYYY}{YY}{MM}{DD}{HH}{mm}{ss}{SSS}{timestamp}{hash}{major}{minor}{patch}

双输出模式

outputType 行为
file 写入版本文件(默认 version.json)到构建输出目录
define 通过 Vite 的 define 注入全局变量(默认 __APP_VERSION__
both 同时执行文件写入和全局变量注入

版本信息对象 — 文件输出模式生成的 version.json 包含丰富的构建信息:

{
	"version": "20260203153000",
	"buildTime": "2026-02-03T15:30:00.000Z",
	"timestamp": 1738567800000,
	"format": "timestamp"
}

可通过 extra 字段追加自定义信息(如环境、作者等)。

前缀/后缀 — 支持 prefixsuffix 配置,如 prefix: 'v' 可生成 v1.0.0

配置选项
generateVersion({
	format: 'timestamp', // 版本号格式
	semverBase: '1.0.0', // semver 格式的基础版本
	outputType: 'file', // 'file' | 'define' | 'both'
	outputFile: 'version.json', // 输出文件名
	defineName: '__APP_VERSION__', // 全局变量名
	hashLength: 8, // hash 格式的长度
	prefix: '', // 版本号前缀
	suffix: '', // 版本号后缀
	extra: {
		// 额外信息
		environment: 'production'
	}
})
适用场景
  • 构建产物版本追溯
  • 前端版本检测与热更新提示
  • 自动化部署流程中的版本标记

4.5 injectIco — HTML 图标注入

在构建过程中将图标链接注入到 HTML 文件的 <head> 标签中,并可选复制图标文件到目标目录。

实现原理

双模式注入 — 根据配置自动选择注入方式:

  1. 自定义 link 标签模式 — 当配置了 link 字段时,使用字符串替换方式将完整的 <link> 标签注入到 </head>
  2. HtmlTagDescriptor 模式 — 当配置了 icons 数组时,使用 Vite 原生 HtmlTagDescriptor API 注入图标标签
plugin.transformIndexHtml = {
	order: 'pre',
	handler: (html: string) => {
		// 如果使用自定义 link 标签,使用字符串替换方式
		if (this.options.link) {
			return this.injectCustomLinkTag(html)
		}
		// 否则使用 Vite 原生 HtmlTagDescriptor API
		const tags = this.getIconTagDescriptors()
		if (tags.length > 0) {
			return { html, tags }
		}
		return html
	}
}

字符串简写 — 通过 OptionsNormalizer 支持 injectIco('/assets') 的简写形式,自动转换为 { base: '/assets' }

export const injectIco = createPluginFactory<InjectIcoOptions, InjectIcoPlugin, string | InjectIcoOptions>(InjectIcoPlugin, options => (typeof options === 'string' ? { base: options } : options || {}))

图标文件复制 — 可选配置 copyOptions,在 writeBundle 阶段将图标文件复制到目标目录,复用 common 模块的 copySourceToTarget 实现增量复制。

配置选项
injectIco({
	base: '/', // 图标基础路径
	url: '/favicon.ico', // 图标 URL
	link: '<link rel="icon" ...>', // 自定义 link 标签
	icons: [
		// 图标数组(使用 HtmlTagDescriptor API)
		{ rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' },
		{ rel: 'icon', href: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' }
	],
	copyOptions: {
		// 图标文件复制配置
		sourceDir: 'src/assets/icons',
		targetDir: 'dist/assets/icons'
	}
})

// 字符串简写
injectIco('/assets')
适用场景
  • 多尺寸 favicon 注入
  • PWA 图标配置
  • 不同环境使用不同图标的场景

4.6 injectLoading — 全局 Loading 状态管理

将全局 Loading 状态管理代码注入到 HTML 中,提供创建、显示、隐藏和销毁 loading 的方法,支持自动拦截 fetch/XHR 请求实现 loading 的自动管理。

实现原理

双阶段注入策略 — 根据 defaultVisible 配置决定注入方式:

defaultVisible = true(白屏 Loading)
┌─────────────────────────────────────────────┐
│ <html>                                      │
│   <head>                                    │
│     <!-- 阶段一:CSS + HTML 静态注入 -->     │
│     <style data-loading-style               │
│            data-loading-id="...">...</style> │
│     <div id="__loading-root__">...</div>    │
│     → HTML 解析即显示,零 JS 依赖           │
│   </head>                                   │
│   <body>                                    │
│     <!-- 阶段二:JS 管理器注入 -->           │
│     <script> Loading Manager IIFE </script> │
│     → 运行时 API、请求拦截、事件监听        │
│   </body>                                   │
│ </html>                                     │
└─────────────────────────────────────────────┘

defaultVisible = false(按需 Loading)
┌─────────────────────────────────────────────┐
│ <html>                                      │
│   <head>                                    │
│     <!-- 无静态注入 -->                      │
│   </head>                                   │
│   <body>                                    │
│     <!-- 完整注入:CSS + HTML + JS -->       │
│     <script>                                │
│       动态创建 <style> 和 <div>             │
│       + Loading Manager IIFE                │
│     </script>                               │
│   </body>                                   │
└─────────────────────────────────────────────┘

白屏 Loading 的关键:当 defaultVisible = true 时,CSS 和 HTML 以静态标签形式直接注入到 <head> 中,浏览器解析到 <head> 时 loading 即可见,无需等待 JS 执行,实现真正的白屏 Loading 效果。

资源缓存 — CSS 和 HTML 只生成一次并缓存(_cachedAssets),供 head 和 body 注入共享,避免重复计算。

注入顺序transformIndexHtml 设置 order: 'post',确保在其他 HTML 转换插件之后执行,避免被覆盖。

注入降级策略 — 依次尝试 </body></html> → 追加到末尾,确保在各种 HTML 结构下都能成功注入。

请求拦截 — 自动拦截 fetch/XHR 请求,实现请求开始时自动显示 Loading,请求结束时自动隐藏:

// fetch 拦截
if (autoBind === 'fetch' || autoBind === 'all') {
  _originalFetch = window.fetch;
  window.fetch = function(input, init) {
    var url = typeof input === 'string' ? input : (input instanceof URL ? input.href : ...);
    var method = (init && init.method) || (input && input.method) || 'GET';
    manager._requestStart(url, method);
    return _originalFetch.apply(this, arguments).then(
      function(response) { manager._requestEnd(url, method); return response; },
      function(error) { manager._requestEnd(url, method); throw error; }
    );
  };
}

// XHR 拦截 — 使用 addEventListener('loadend') 监听请求结束
XMLHttpRequest.prototype.open = function(method, url) {
  this.__loadingUrl = url || '';
  this.__loadingMethod = method || 'GET';
  return _originalXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function() {
  var self = this;
  manager._requestStart(self.__loadingUrl, self.__loadingMethod);
  self.addEventListener('loadend', function() {
    manager._requestEnd(self.__loadingUrl, self.__loadingMethod);
  });
  return _originalXHRSend.apply(this, arguments);
};

并发请求管理 — 内部维护 pendingCount 计数器,首个请求开始时显示 Loading,最后一个请求结束时隐藏,正确处理并发场景。

请求过滤 — 通过 requestFilter 配置排除特定请求:

requestFilter: {
  excludeUrls: [/\/api\/health/],           // 排除特定 URL(正则)
  includeUrls: [/\/api\/data/],             // 仅包含特定 URL
  excludeMethods: ['OPTIONS'],              // 排除特定 HTTP 方法
  excludeUrlPrefixes: ['/static/']          // 排除特定 URL 前缀
}

防闪烁机制 — 三重防护避免 Loading 频繁显示/隐藏导致的视觉闪烁:

                    请求开始
                        │
                ┌────────┴────────┐
                │ delayShow 启用?  │
                └────────┬────────┘
                   是 │     │ 否
                ┌─────┘     └──→ 立即显示
                │ 延迟 duration ms
                │
                ├── 延迟期间请求结束 → 取消显示
                └── 延迟到期 → 显示 Loading
                                  │
                        ┌─────────┴─────────┐
                        │ minDisplayTime 启用?│
                        └─────────┬─────────┘
                           是 │     │ 否
                        ┌─────┘     └──→ 请求结束即隐藏
                        │ 保证至少显示 duration ms
                        │
                        ├── 最小显示时间内请求结束 → 延迟隐藏
                        └── 最小显示时间到期 → 准备隐藏
                                                      │
                                        ┌─────────────┴─────────────┐
                                        │ debounceHide 启用?         │
                                        └─────────────┬─────────────┘
                                            是 │     │ 否
                                        ┌─────┘     └──→ 立即隐藏
                                        │ 防抖 duration ms
                                        │
                                        ├── 防抖期间新请求 → 取消隐藏
                                        └── 防抖到期 → 隐藏 Loading

元素就绪重试_doShow 方法在 loading DOM 元素未就绪时会自动重试(最多 20 次,每次间隔 50ms),确保在动态注入场景下也能正常显示:

function _doShow(text) {
	if (_destroyed) return
	_findEl()
	if (!_loadingEl) {
		if (++_showRetryCount > _maxShowRetries) return
		_retryTimer = setTimeout(function () {
			_retryTimer = null
			_doShow(text)
		}, 50)
		return
	}
	// ... 正常显示逻辑
}

请求拦截恢复destroy() 方法会恢复原始的 fetchXMLHttpRequest 方法,避免内存泄漏和副作用残留:

function _restoreInterceptors() {
	if (_originalFetch && typeof window !== 'undefined' && window.fetch) {
		window.fetch = _originalFetch
		_originalFetch = null
	}
	if (_originalXHROpen && typeof window !== 'undefined' && window.XMLHttpRequest) {
		XMLHttpRequest.prototype.open = _originalXHROpen
		XMLHttpRequest.prototype.send = _originalXHRSend
		_originalXHROpen = null
		_originalXHRSend = null
	}
}

SSR 安全检测 — 生成的 JS 代码开头包含 SSR 环境检测:

if (typeof window === 'undefined' || typeof document === 'undefined') return

CSS 性能优化 — 遮罩层样式包含 CSS 性能提示:

.__loading-overlay__ {
	contain: content; /* CSS Containment,优化渲染性能 */
	will-change: opacity; /* 提示浏览器优化 opacity 动画 */
}
.__loading-overlay__.__loading-hidden__ .__loading-spinner__,
.__loading-overlay__.__loading-hidden__ .__loading-dot__,
.__loading-overlay__.__loading-hidden__ .__loading-spinner__::after {
	animation-play-state: paused; /* 隐藏时暂停动画,减少 CPU 开销 */
}

安全验证 — 0.0.9 版本新增多项安全验证:

验证器 防护目标
validateCustomTemplate 阻止 customTemplate 中包含 <script> 标签(XSS 防护)
validateCallbacks 阻止回调字符串中包含 <script> 标签(XSS 防护)
validateGlobalName 阻止使用 __proto__constructor 等危险属性(原型污染)
validateDefaultText 空字符串时发出警告
validateAutoHideOn defaultVisible 为 false 时 autoHideOn 无效,发出警告

生命周期回调 — 支持在 Loading 显示/隐藏/销毁时执行自定义逻辑,回调以函数体字符串形式提供(因为需要注入到客户端代码中):

callbacks: {
  onBeforeShow: 'console.log("about to show")',  // 返回 false 可阻止显示
  onShow: 'console.log("shown")',
  onBeforeHide: 'console.log("about to hide")',  // 返回 false 可阻止隐藏
  onHide: 'console.log("hidden")',
  onDestroy: 'console.log("destroyed")'
}

4 种 Spinner 类型

类型 说明
spinner 旋转圆环(默认)
dots 跳动圆点
pulse 脉冲效果
bar 进度条动画

autoHideOn 自动隐藏 — 当 defaultVisible = true 时,支持三种自动隐藏时机:

行为
DOMContentLoaded DOM 加载完成后自动隐藏(默认)
load 页面完全加载后自动隐藏
manual 手动调用 hide() 隐藏
配置选项
injectLoading({
	position: 'center', // 'center' | 'top' | 'bottom'
	defaultText: '加载中...', // 默认文本
	spinnerType: 'spinner', // 'spinner' | 'dots' | 'pulse' | 'bar'
	defaultVisible: false, // 是否默认可见(白屏 Loading)
	autoHideOn: 'DOMContentLoaded', // 自动隐藏时机
	autoBind: 'none', // 'fetch' | 'xhr' | 'all' | 'none'
	globalName: '__LOADING_MANAGER__', // 全局变量名
	customTemplate: '<div class="my-loader"><span data-loading-text></span></div>', // 自定义 HTML 模板
	style: {
		overlayColor: 'rgba(255, 255, 255, 0.7)',
		spinnerColor: '#4361ee',
		spinnerSize: '40px',
		textColor: '#333',
		textSize: '14px',
		customClass: '', // 自定义 CSS 类名
		customStyle: '', // 自定义内联样式字符串
		zIndex: 9999,
		pointerEvents: true, // 是否阻止底层交互(默认 true = 阻止)
		backdropBlur: false, // 是否启用背景模糊
		backdropBlurAmount: 4 // 模糊程度
	},
	transition: {
		enabled: true,
		duration: 200,
		easing: 'ease-out'
	},
	minDisplayTime: { enabled: true, duration: 300 },
	delayShow: { enabled: true, duration: 200 },
	debounceHide: { enabled: false, duration: 100 },
	requestFilter: {
		excludeUrls: [/\/api\/health/],
		excludeMethods: ['OPTIONS'],
		excludeUrlPrefixes: ['/static/']
	},
	callbacks: {
		onBeforeShow: 'return true',
		onShow: '',
		onBeforeHide: 'return true',
		onHide: '',
		onDestroy: ''
	}
})
运行时 API

注入后通过全局变量(默认 window.__LOADING_MANAGER__)提供以下方法:

方法 说明
show(text?) 显示 Loading(受 delayShow 控制)
hide() 隐藏 Loading(受 minDisplayTime 和 debounceHide 控制)
forceHide() 强制隐藏 Loading(忽略所有延迟和防抖)
toggle(text?) 切换 Loading 的显示/隐藏状态
enablePointerEvents() 启用遮罩层指针事件(阻止底层交互)
disablePointerEvents() 禁用遮罩层指针事件(允许交互穿透)
togglePointerEvents() 切换遮罩层指针事件状态
updateText(text) 更新 Loading 文本
isVisible() 获取当前可见状态
isPointerEventsEnabled() 获取当前遮罩层指针事件是否启用
getPendingCount() 获取当前进行中的请求数
destroy() 销毁管理器(移除 DOM、恢复拦截器、执行回调)
适用场景
  • 白屏 Loading:defaultVisible: true + autoHideOn: 'DOMContentLoaded'
  • 请求自动 Loading:autoBind: 'all' + requestFilter 排除不需要的请求
  • 手动控制 Loading:autoBind: 'none',通过全局 API 手动调用
  • 防闪烁场景:启用 delayShow + minDisplayTime + debounceHide
  • 运行时切换交互:enablePointerEvents() / disablePointerEvents() 动态控制遮罩层交互

五、使用指南

5.1 安装与快速上手

# 安装
npm install @meng-xi/vite-plugin
# 或
pnpm add @meng-xi/vite-plugin
// vite.config.ts
import { defineConfig } from 'vite'
import { buildProgress, copyFile, generateRouter, generateVersion, injectIco, injectLoading } from '@meng-xi/vite-plugin'

export default defineConfig({
	plugins: [
		buildProgress(),
		copyFile({
			sourceDir: 'src/assets',
			targetDir: 'dist/assets'
		}),
		generateRouter(),
		generateVersion({
			format: 'datetime',
			outputType: 'both'
		}),
		injectIco('/assets'),
		injectLoading({
			defaultVisible: true,
			autoHideOn: 'DOMContentLoaded',
			autoBind: 'all'
		})
	]
})

5.2 按需导入

// 仅导入插件
import { buildProgress, copyFile } from '@meng-xi/vite-plugin/plugins'

// 仅导入开发框架
import { BasePlugin, createPluginFactory } from '@meng-xi/vite-plugin/factory'

// 仅导入日志模块
import { Logger } from '@meng-xi/vite-plugin/logger'

// 仅导入通用工具
import { deepMerge, copySourceToTarget } from '@meng-xi/vite-plugin/common'

5.3 自定义插件开发

基于 BasePlugin 开发自定义插件只需 4 步:

import { BasePlugin, createPluginFactory } from '@meng-xi/vite-plugin/factory'
import type { Plugin } from 'vite'

// 1. 定义配置类型
interface MyPluginOptions {
	message: string
	count?: number
}

// 2. 继承 BasePlugin
class MyPlugin extends BasePlugin<MyPluginOptions> {
	protected getPluginName(): string {
		return 'my-plugin'
	}

	protected getDefaultOptions(): Partial<MyPluginOptions> {
		return { count: 10 }
	}

	protected validateOptions(): void {
		this.validator.field('message').required().string().field('count').number().validate()
	}

	// 3. 注册 Vite 钩子
	protected addPluginHooks(plugin: Plugin): void {
		plugin.buildStart = () => {
			this.logger.info(this.options.message)
		}
	}

	// 4. 可选:清理资源
	protected destroy(): void {
		super.destroy()
		// 清理 watcher、定时器等
	}
}

// 导出工厂函数
export const myPlugin = createPluginFactory(MyPlugin)

5.4 运行时交互:pluginInstance

const plugin = injectLoading({
	defaultVisible: true,
	autoBind: 'all'
})

// 访问插件内部状态
console.log(plugin.pluginInstance.options) // 完整配置
console.log(plugin.pluginInstance.logger) // 日志代理

六、常见应用场景

场景一:uni-app 项目工程化

export default defineConfig({
	plugins: [
		buildProgress({ format: 'bar' }),
		generateRouter({
			pagesJsonPath: 'src/pages.json',
			preserveRouteChanges: true,
			metaMapping: {
				navigationBarTitleText: 'title',
				requireAuth: 'requireAuth'
			}
		}),
		generateVersion({
			format: 'datetime',
			outputType: 'both',
			defineName: '__APP_VERSION__'
		}),
		injectLoading({
			defaultVisible: true,
			autoHideOn: 'DOMContentLoaded',
			autoBind: 'all',
			requestFilter: {
				excludeUrlPrefixes: ['/static/']
			}
		})
	]
})

场景二:Web 应用白屏优化

export default defineConfig({
	plugins: [
		buildProgress(),
		injectLoading({
			defaultVisible: true, // 白屏 Loading
			autoHideOn: 'load', // 页面完全加载后隐藏
			spinnerType: 'pulse',
			style: {
				overlayColor: 'rgba(255, 255, 255, 0.9)',
				backdropBlur: true,
				backdropBlurAmount: 8,
				pointerEvents: true // 阻止白屏期间的交互
			},
			delayShow: { enabled: false }, // 白屏模式无需延迟
			minDisplayTime: { enabled: true, duration: 500 }
		}),
		injectIco({
			base: '/',
			icons: [
				{ rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' },
				{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png', sizes: '180x180' }
			]
		})
	]
})

场景三:CI/CD 构建监控

export default defineConfig({
	plugins: [
		buildProgress({
			format: 'minimal',
			clearOnComplete: false, // 保留最终进度信息
			showModuleName: false
		}),
		generateVersion({
			format: 'custom',
			customFormat: '{YYYY}.{MM}.{DD}.{hash}',
			hashLength: 6,
			outputType: 'both',
			extra: {
				environment: process.env.NODE_ENV,
				commitHash: process.env.CI_COMMIT_SHA
			}
		}),
		copyFile({
			sourceDir: 'public',
			targetDir: 'dist',
			incremental: true,
			errorStrategy: 'log' // CI 中不中断构建
		})
	]
})

场景四:运行时动态控制交互

// vite.config.ts
export default defineConfig({
	plugins: [
		injectLoading({
			autoBind: 'all',
			style: {
				pointerEvents: true // 默认阻止交互
			},
			debounceHide: { enabled: true, duration: 100 }
		})
	]
})

// 应用代码中
const loading = window.__LOADING_MANAGER__

// 显示 loading 并阻止交互
loading.show('提交中...')

// 特定场景允许交互穿透(如仅展示加载状态,不阻止操作)
loading.disablePointerEvents()

// 恢复交互阻止
loading.enablePointerEvents()

// 切换交互状态
loading.togglePointerEvents()

// 检查交互是否被阻止
if (loading.isPointerEventsEnabled()) {
	console.log('交互已被阻止')
}

七、注意事项与最佳实践

通用注意事项

  1. peerDependencies 要求 — 本库要求 Vite 版本 ^5.0.0 || ^6.0.0 || ^7.0.0,请确保项目 Vite 版本兼容
  2. errorStrategy 选择 — 生产环境建议使用默认的 'throw' 策略以及时发现问题;CI/CD 等容错场景可使用 'log'
  3. verbose 控制 — 生产构建可设置 verbose: false 减少日志输出

buildProgress 注意事项

  1. 非 TTY 环境降级 — CI/CD 中进度条自动降级为日志输出,无需额外配置
  2. 模块排除node_modules.virtual 模块自动排除,不计入进度统计

copyFile 注意事项

  1. 增量复制前提 — 增量复制依赖文件的 mtimeMssize,某些构建工具可能会重置文件时间戳,导致增量判断失效
  2. 并发控制 — 默认并发限制为 10,大量小文件场景可适当提高

generateRouter 注意事项

  1. pages.json 格式 — 支持 JSON 注释(自动调用 stripJsonComments),但需确保 JSON 结构合法
  2. preserveRouteChanges — 开启后,删除 pages.json 中的页面不会自动删除对应路由配置中的用户修改字段,需手动清理
  3. customNameGenerator — 使用 nameStrategy: 'custom' 时必须提供 customNameGenerator 函数

generateVersion 注意事项

  1. define 模式 — 使用 outputType: 'define' 时,代码中通过 __APP_VERSION__ 访问版本号,TypeScript 项目需自行声明类型
  2. custom 格式 — 使用 format: 'custom' 时必须提供 customFormat 模板

injectIco 注意事项

  1. 注入顺序transformIndexHtml 设置为 order: 'pre',确保在其他 HTML 转换之前执行
  2. link 与 icons 互斥 — 配置了 link 字段时会使用字符串替换方式,不会处理 icons 数组

injectLoading 注意事项

  1. 白屏 Loading 条件defaultVisible: true 时要求 HTML 模板包含 <head></head> 标签,否则白屏 Loading 无法生效
  2. 请求拦截副作用autoBind 会修改全局 fetchXMLHttpRequest,务必在不需要时调用 destroy() 恢复
  3. 回调函数格式callbacks 中的回调以函数体字符串形式提供,不是函数引用,因为需要注入到客户端代码中
  4. SSR 兼容 — 生成的代码包含 SSR 环境检测,在服务端渲染时自动跳过
  5. XHR 拦截方式 — XHR 使用 addEventListener('loadend') 监听请求结束,确保只触发一次 _requestEnd
  6. pointerEvents 默认值 — 默认为 true(阻止交互),如需允许交互穿透需显式设为 false
  7. customTemplate 安全 — 自定义模板不允许包含 <script> 标签,回调逻辑应通过 callbacks 配置
  8. globalName 安全 — 全局变量名必须是合法的 JavaScript 标识符,且不能是 __proto__constructorprototype 等内置属性
  9. autoHideOn 生效条件autoHideOn 仅在 defaultVisibletrue 时生效,defaultVisiblefalse 时该配置将被忽略(会有警告提示)

八、总结

@meng-xi/vite-plugin 的核心价值在于它不仅是一组工具插件,更是一套完整的插件开发体系:

框架层面

  • BasePlugin 提供了标准化的生命周期管理、三层配置合并、安全执行和钩子自动组合
  • Validator 的批量错误收集机制让配置问题一目了然
  • Logger 的单例 + 代理架构确保多插件日志输出的一致性和可控性
  • createPluginFactory 的三重泛型设计和 OptionsNormalizer 让插件 API 既类型安全又灵活易用

插件层面

  • buildProgress 的进度只进不退和 TTY 感知体现了对终端体验的细致考虑
  • copyFile 的增量判断(mtimeMs + size)和并发复制(Worker Pool)兼顾了正确性和性能
  • generateRouter 的用户修改保留(preserveRouteChanges)解决了代码生成的经典痛点
  • generateVersion 的 6 种格式和双输出模式覆盖了版本管理的各种需求
  • injectIco 的字符串简写和双模式注入降低了使用门槛
  • injectLoading 的双阶段注入、请求拦截恢复、防闪烁三重机制、指针事件运行时控制、XSS 防护和 CSS 性能优化体现了生产级的工程素养

这套设计使得 @meng-xi/vite-plugin 既能开箱即用解决常见问题,又能作为基础设施支撑自定义插件的开发,实现了工具库与开发框架的统一。


本文基于 @meng-xi/vite-plugin@0.0.9 版本撰写,如有更新请以最新文档为准。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐