vite-plugin-virtual-mpa-v2
v1.0.9
Published
[](https://npmjs.com/package/vite-plugin-virtual-mpa-v2)  [插件,支持虚拟文件和 EJS 模板引擎,兼容 Vite 8.0 (Rolldown) 和旧版本 (Rollup)
特性
- 兼容 Vite 8.0 (Rolldown) 和 Vite 2.x-7.x (Rollup)
- 支持 EJS 模板引擎,可使用单个模板生成多个页面
- 虚拟文件支持,开发时文件存在于内存中,构建时写入磁盘
- 自动页面扫描功能
- 完整的 HMR 支持
- HTML 压缩(可选)
- History Fallback 中间件支持
安装
# npm
npm install vite-plugin-virtual-mpa-v2 -D
# pnpm
pnpm add vite-plugin-virtual-mpa-v2 -D
# yarn
yarn add vite-plugin-virtual-mpa-v2 -D快速选择指南
根据你的项目需求,选择合适的配置方式:
| 配置方式 | 入口文件数量 | 适用场景 | 配置复杂度 | |---------|------------|---------|-----------| | 共用入口 + 占位符 | 1 个 | 页面逻辑相似,共享初始化代码 | ⭐ 简单 | | 独立入口 | 每个页面 1 个 | 页面差异大,需要独立配置 | ⭐⭐ 中等 | | 手动配置页面 | 自由定义 | 需要完全控制每个页面 | ⭐⭐⭐ 灵活 |
配置方式详解
方式一:共用入口 + 占位符替换(推荐)
适用场景:所有页面共享相同的初始化逻辑(如 Vue 应用初始化、全局组件注册、插件安装等),只有页面组件不同。
工作原理:
- 使用一个共享的入口文件(如
src/main.ts) - 入口文件中使用占位符(默认
__PAGE__) - 构建时,插件会将占位符替换为实际页面的组件路径
目录结构
project/
├── index.html # 模板文件
├── src/
│ ├── main.ts # 共享入口(所有页面共用)
│ └── pages/
│ ├── home/
│ │ └── index.vue # 首页组件
│ ├── about/
│ │ └── index.vue # 关于页面组件
│ └── contact/
│ └── index.vue # 联系页面组件
└── vite.config.ts配置示例
// vite.config.ts
import { defineConfig } from 'vite'
import { createMpaPlugin } from 'vite-plugin-virtual-mpa-v2'
export default defineConfig({
plugins: [
createMpaPlugin({
template: 'index.html', // 模板文件
verbose: true, // 打印日志
placeholder: '__PAGE__', // 占位符(默认值)
scanOptions: {
scanDirs: 'src/pages', // 扫描目录
entryFile: 'main.ts', // 入口文件名(相对于页面目录)
componentFile: 'index.vue', // 组件文件名(用于检测页面是否存在)
filename: (name) => `${name}.html` // 输出文件名规则
}
})
]
})// src/main.ts - 共享入口文件
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import Page from '__PAGE__' // 🔑 占位符,构建时会被替换为实际组件路径
import './style.css' // ✅ 相对路径会自动转换为绝对路径
// 例如,构建 home 页面时,会替换为:
// import Page from '../../../src/pages/home/index.vue'
// import './style.css' 会被转换为 import '/src/style.css'
const app = createApp(Page)
// 可以在这里添加共享逻辑
// app.use(router)
// app.use(store)
app.mount('#app')<!-- index.html - 模板文件 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= typeof pageName !== 'undefined' ? pageName : 'App' %></title>
</head>
<body>
<div id="app"></div>
<!-- 入口文件会自动注入 -->
</body>
</html>构建结果:
- 生成
home.html、about.html、contact.html - 每个页面的入口文件中,
__PAGE__被替换为对应的组件路径
方式二:独立入口
适用场景:每个页面有独立的初始化逻辑,或者不同页面使用不同的框架/库配置。
工作原理:
- 每个页面目录下都有自己的
main.ts入口文件 - 插件会自动扫描并使用每个页面的独立入口
- 如果页面目录没有入口文件,则使用根目录的共享入口(fallback)
目录结构
project/
├── index.html # 模板文件
├── src/
│ ├── main.ts # 默认入口(可选,作为 fallback)
│ └── pages/
│ ├── home/
│ │ ├── index.vue # 首页组件
│ │ └── main.ts # 首页独立入口 ✅
│ ├── about/
│ │ ├── index.vue
│ │ └── main.ts # 关于页面独立入口 ✅
│ └── contact/
│ └── index.vue # 没有 main.ts,使用根目录的 src/main.ts
└── vite.config.ts配置示例
// vite.config.ts
import { defineConfig } from 'vite'
import { createMpaPlugin } from 'vite-plugin-virtual-mpa-v2'
export default defineConfig({
plugins: [
createMpaPlugin({
template: 'index.html',
verbose: true,
scanOptions: {
scanDirs: 'src/pages',
entryFile: 'main.ts', // 每个页面目录下的入口文件名
componentFile: 'index.vue', // 用于检测页面目录是否有效
filename: (name) => `${name}.html`
}
})
]
})// src/pages/home/main.ts - home 页面独立入口
import { createApp } from 'vue'
import Home from './index.vue'
const app = createApp(Home)
// home 页面特有的配置
app.mount('#app')// src/pages/about/main.ts - about 页面独立入口
import { createApp } from 'vue'
import About from './index.vue'
import { createRouter, createWebHistory } from 'vue-router'
const app = createApp(About)
// about 页面可能需要路由
const router = createRouter(/* ... */)
app.use(router)
app.mount('#app')优先级规则:
- 如果页面目录下存在
main.ts(或配置的entryFile),使用该入口 - 否则,使用根目录的
src/main.ts(如果存在) - 如果都不存在,会报错
方式三:手动配置页面
适用场景:需要完全控制每个页面的配置,包括入口、模板、输出文件名等。适合复杂项目或特殊需求。
完整手动配置
// vite.config.ts
import { defineConfig } from 'vite'
import { createMpaPlugin, createPages } from 'vite-plugin-virtual-mpa-v2'
// 使用 createPages 辅助函数创建页面配置
const pages = createPages([
{
name: 'home',
filename: 'index.html', // 输出文件名
entry: '/src/pages/home/main.ts', // 入口文件(必须以 / 开头)
template: 'index.html', // 可选,指定页面专属模板
data: { title: '首页' } // EJS 模板数据
},
{
name: 'about',
filename: 'about.html',
entry: '/src/pages/about/main.ts',
data: { title: '关于我们' }
},
{
name: 'admin',
filename: 'admin/index.html', // 支持子目录
entry: '/src/pages/admin/main.ts',
template: 'admin.html', // 使用不同的模板
data: { title: '管理后台' }
}
])
export default defineConfig({
plugins: [
createMpaPlugin({
template: 'index.html', // 默认模板
pages, // 手动配置的页面列表
verbose: true
})
]
})混合配置(扫描 + 手动覆盖)
// vite.config.ts
import { defineConfig } from 'vite'
import { createMpaPlugin } from 'vite-plugin-virtual-mpa-v2'
export default defineConfig({
plugins: [
createMpaPlugin({
template: 'index.html',
// 自动扫描大部分页面
scanOptions: {
scanDirs: 'src/pages',
entryFile: 'main.ts',
componentFile: 'index.vue'
},
// 手动配置特殊页面(会与扫描结果合并,同名页面会覆盖)
pages: [
{
name: 'special',
entry: '/src/special-entry.ts', // 特殊入口
data: { title: '特殊页面' }
}
]
})
]
})配置选项
MpaOptions
interface MpaOptions {
// ========== 基础配置 ==========
/**
* 默认模板文件
* @default 'index.html'
*/
template?: string
/**
* 是否打印日志
* @default true
*/
verbose?: boolean
// ========== 页面配置 ==========
/**
* 手动配置页面列表
* 与 scanOptions 扫描的页面合并,同名页面会覆盖
*/
pages?: Page[]
/**
* 自动扫描配置
* 配置后会自动扫描目录下的页面
*/
scanOptions?: ScanOptions
// ========== 入口解析 ==========
/**
* 占位符,用于共用入口模式
* 支持字符串或正则表达式
* @default '__PAGE__'
*/
placeholder?: string | RegExp
/**
* 自定义页面组件路径解析
* 返回值会替换入口文件中的占位符
*/
resolvePageEntry?: (pageName: string) => string
// ========== 模板功能 ============
/**
* EJS 全局数据,会注入到所有页面模板中
* 页面专属 data 会覆盖这里的同名属性
*/
data?: Record<string, any>
/**
* 自定义 HTML 转换函数
* 可以在构建时修改 HTML 内容
*/
transformHtml?: (
html: string,
ctx: TransformHtmlContext
) => IndexHtmlTransformResult
// ========== 服务器配置 ==========
/**
* 开发服务器 history fallback 重写规则
* 用于 SPA 路由支持
*/
rewrites?: RewriteRule
/**
* 预览服务器 history fallback 重写规则
*/
previewRewrites?: RewriteRule
/**
* 开发服务器默认打开的页面
* - 设置为页面名称(如 'app1'),会自动打开 /app1.html
* - 设置为文件名(如 'app1.html'),会自动打开该页面
* - 设置为 false 禁用自动打开功能
*/
devServerOpenPage?: string | false
// ========== 监听配置 ==========
/**
* 文件变化监听配置
* 可用于热更新页面配置
*/
watchOptions?: WatchOptions
// ========== HTML 压缩 ==========
/**
* HTML 压缩配置
* - true: 使用默认压缩选项
* - 对象: 自定义压缩选项(基于 html-minifier-terser)
*/
htmlMinify?: boolean | MinifyOptions
}Page
interface Page {
/**
* 页面名称
* - 用于生成默认的 rewrite 规则
* - 不能包含 '/'
*/
name: string
/**
* 输出的 HTML 文件名
* @default `${name}.html`
* 支持子目录,如 'admin/index.html'
*/
filename?: string
/**
* 页面专属模板
* 不指定则使用 MpaOptions.template
*/
template?: string
/**
* 页面入口文件路径
* - 必须是绝对路径(以 '/' 开头)
* - 相对于项目根目录
* @example '/src/pages/home/main.ts'
*/
entry?: string
/**
* 页面专属 EJS 数据
* 会与 MpaOptions.data 合并,同名属性优先使用这里
*/
data?: Record<string, any>
/**
* 组件路径(相对于项目根目录)
* 用于占位符替换时计算相对路径
* 扫描模式下会自动设置
* @example 'src/pages/home/index.vue'
*/
componentPath?: string
}ScanOptions
interface ScanOptions {
/**
* 要扫描的目录
* 可以是单个目录或多个目录
* @example 'src/pages'
* @example ['src/pages', 'src/views']
*/
scanDirs: string | string[]
/**
* 页面入口文件名
* 相对于页面目录
* @default 'main.ts'
*/
entryFile?: string
/**
* 组件文件名
* 用于检测页面目录是否有效
* 只有包含此文件的目录才会被视为页面
* @default 'index.vue'
*/
componentFile?: string
/**
* 自定义输出文件名
* @param pageName 页面名称(目录名)
* @returns 输出的 HTML 文件名
* @default (name) => `${name}.html`
*/
filename?: (pageName: string) => string
/**
* 自定义模板路径
* 相对于页面目录
* 不指定则使用 MpaOptions.template
* @example 'template.html' // 使用页面目录下的 template.html
*/
template?: string
}WatchOptions
interface WatchOptions {
/**
* 包含的文件模式
* 支持 glob 模式
*/
include?: FilterPattern
/**
* 排除的文件模式
* 支持 glob 模式
*/
excluded?: FilterPattern
/**
* 要监听的事件类型
* @default ['add', 'unlink', 'change', 'unlinkDir', 'addDir']
*/
events?: ('add' | 'unlink' | 'change' | 'unlinkDir' | 'addDir')[]
/**
* 事件处理函数
*/
handler: WatchHandler
}
interface WatchHandler {
(ctx: {
server: ViteDevServer
file: string // 变化的文件路径
type: string // 事件类型
reloadPages: (pages: Page[]) => void // 重新加载页面配置
}): void
}高级功能
自定义组件路径解析
如果你使用非标准的目录结构,可以通过 resolvePageEntry 自定义组件路径:
// vite.config.ts
export default defineConfig({
plugins: [
createMpaPlugin({
template: 'index.html',
placeholder: /\$\{pageName\}/g, // 使用正则作为占位符
resolvePageEntry: (pageName) => {
// 自定义路径解析逻辑
return `./views/${pageName}/View.vue`
},
scanOptions: {
scanDirs: 'src/views',
componentFile: 'View.vue' // 自定义组件文件名
}
})
]
})// src/main.ts
import { createApp } from 'vue'
import Page from '${pageName}' // 会被替换为 resolvePageEntry 返回的值
createApp(Page).mount('#app')开发服务器默认打开页面
配置开发服务器启动时自动打开的页面:
import { createMpaPlugin } from 'vite-plugin-virtual-mpa-v2'
export default defineConfig({
plugins: [
createMpaPlugin({
template: 'index.html',
scanOptions: {
scanDirs: 'src/pages',
},
// 方式一:使用页面名称
devServerOpenPage: 'home', // 打开 /home.html
// 方式二:使用完整文件名
// devServerOpenPage: 'home.html', // 打开 /home.html
// 方式三:禁用自动打开
// devServerOpenPage: false
})
]
})注意:此配置仅在开发服务器启动时有效,配合 Vite 的 server.open 选项使用。
HTML 压缩
生产构建时压缩 HTML:
import { createMpaPlugin } from 'vite-plugin-virtual-mpa-v2'
export default defineConfig({
plugins: [
createMpaPlugin({
// ... 其他配置
htmlMinify: true // 使用默认配置
})
]
})自定义压缩选项:
createMpaPlugin({
htmlMinify: {
collapseWhitespace: true,
removeComments: true,
removeEmptyAttributes: true,
minifyCSS: true,
minifyJS: true
}
})自定义 HTML 转换
在构建时动态修改 HTML:
import { createMpaPlugin } from 'vite-plugin-virtual-mpa-v2'
export default defineConfig({
plugins: [
createMpaPlugin({
template: 'index.html',
transformHtml(html, ctx) {
// ctx.pageName - 当前页面名称
// ctx.filename - 输出文件名
// ctx.path - 页面路径
return {
html,
tags: [
{
tag: 'meta',
injectTo: 'head',
attrs: {
name: 'page-name',
content: ctx.pageName
}
},
{
tag: 'script',
injectTo: 'body',
children: `window.PAGE_NAME = '${ctx.pageName}'`
}
]
}
}
})
]
})EJS 模板
除了页面配置中的 data,所有以 VITE_ 开头的环境变量会自动注入到模板:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title><%= typeof title !== 'undefined' ? title : 'App' %></title>
<% if (typeof VITE_API_URL !== 'undefined') { %>
<script>
window.API_URL = '<%= VITE_API_URL %>'
</script>
<% } %>
</head>
<body>
<div id="app"></div>
</body>
</html>从 vite-plugin-virtual-mpa 迁移
主要变化
| 特性 | 旧版本 | V2 版本 |
|-----|-------|--------|
| Vite 兼容性 | Vite 2.x-7.x | Vite 2.x-8.x (支持 Rolldown) |
| 虚拟文件实现 | \0 虚拟模块前缀 | 临时文件缓存 |
| 独立入口 | 需要手动配置每个页面 | 自动扫描支持 |
| 占位符 | 固定 __PAGE__ | 可配置(字符串或正则) |
| 组件解析 | 固定规则 | 可自定义 resolvePageEntry |
迁移步骤
- 更新依赖:
npm uninstall vite-plugin-virtual-mpa
npm install vite-plugin-virtual-mpa-v2 -D- 更新导入:
// 旧版本
import { createMpaPlugin } from 'vite-plugin-virtual-mpa'
// 新版本
import { createMpaPlugin } from 'vite-plugin-virtual-mpa-v2'- 配置调整(大多数情况下无需调整):
如果使用了独立入口,现在可以更简单地配置:
// 旧版本 - 需要为每个页面指定完整入口
createMpaPlugin({
pages: [
{ name: 'pageA', entry: '/src/pages/pageA/main.ts' },
{ name: 'pageB', entry: '/src/pages/pageB/main.ts' }
]
})
// 新版本 - 自动扫描独立入口
createMpaPlugin({
scanOptions: {
scanDirs: 'src/pages',
entryFile: 'main.ts',
componentFile: 'index.vue'
}
})不兼容的变化
- 临时文件存储在
node_modules/.mpa-cache/,无需手动清理 - 虚拟模块前缀从
\0改为临时文件,提高兼容性
默认 Rewrite 规则
插件会为每个页面自动生成 history fallback 规则:
{
from: new RegExp(`^/(${pageNames.join('|')})/?$`),
to: (ctx) => `/${base}/${inputMap[ctx.match[1]]}`
}例如,配置了页面 home 和 about 后:
- 访问
/home→ 重写到/home.html - 访问
/about→ 重写到/about.html
常见问题
Q: TypeScript 报错 "Cannot find module 'PAGE'" 怎么解决?
A: 在使用共用入口 + 占位符模式时,需要在项目中添加类型声明。
方法一:使用包内置的类型声明(推荐)
在 tsconfig.json 中添加类型引用:
{
"compilerOptions": {
"types": ["vite-plugin-virtual-mpa-v2/global"]
}
}方法二:在项目中创建类型声明文件
创建 src/vite-env.d.ts(或 src/env.d.ts):
// src/vite-env.d.ts
/// <reference types="vite/client" />
// 声明默认占位符
declare module '__PAGE__' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
// 如果使用自定义占位符,也需要声明
// declare module '${your-placeholder}' {
// import type { DefineComponent } from 'vue'
// const component: DefineComponent<{}, {}, any>
// export default component
// }方法三:使用 @ts-ignore(不推荐)
// @ts-ignore
import Page from '__PAGE__'Q: 占位符替换后的路径是什么?
A: 占位符会被替换为从虚拟入口文件(位于 node_modules/.mpa-cache/pages/)到组件文件的相对路径。例如:
- 虚拟入口位置:
node_modules/.mpa-cache/pages/home.ts - 组件文件:
src/pages/home/index.vue - 替换结果:
../../src/pages/home/index.vue
注意:在使用扫描模式时,componentPath 会自动设置。手动配置时可以通过 page.componentPath 指定组件路径。
Q: 如何在独立入口模式下共享代码?
A: 可以创建一个共享的初始化函数:
// src/shared/init.ts
export function setupApp(app: App) {
// 共享配置
app.use(router)
app.use(store)
}
// src/pages/home/main.ts
import { createApp } from 'vue'
import { setupApp } from '../../shared/init'
import Home from './index.vue'
const app = createApp(Home)
setupApp(app)
app.mount('#app')Q: 页面名称有什么限制?
A: 页面名称不能包含 /,因为它用于生成 rewrite 规则。如果需要子目录,请使用 filename 选项:
{
name: 'admin-dashboard', // ✅ 正确
filename: 'admin/dashboard.html', // 支持子目录
// ...
}Q: 共用入口中的相对路径 import 会正常工作吗?
A: 会。插件会自动将入口文件中的相对路径转换为绝对路径。
问题背景
在共用入口模式下,原始入口文件(如 src/main.ts)会被复制到虚拟入口目录(node_modules/.mpa-cache/pages/)。如果入口文件中存在相对路径的 import,直接复制会导致路径解析错误。
原始文件: src/main.ts
虚拟文件: node_modules/.mpa-cache/pages/home.ts
// 如果不处理,相对路径会从虚拟文件位置解析,导致错误
import './style.css' // ❌ 会尝试从 node_modules/.mpa-cache/pages/style.css 加载解决方案
插件在生成虚拟入口时,会自动将所有相对路径转换为以 / 开头的绝对路径(相对于项目根目录)。
转换流程:
- 读取原始入口文件内容
- 使用正则表达式匹配所有相对路径的 import/export 语句
- 基于原始入口文件所在目录计算绝对路径
- 将相对路径替换为绝对路径
// src/main.ts (原始文件)
import './style.css' // 相对路径
import '../shared/utils.ts' // 相对路径
import './components/Foo.vue' // 相对路径
// ↓ 插件转换后 ↓
// node_modules/.mpa-cache/pages/home.ts (虚拟文件)
import '/src/style.css' // ✅ 绝对路径
import '/shared/utils.ts' // ✅ 绝对路径
import '/src/components/Foo.vue' // ✅ 绝对路径支持的语法
| 语法类型 | 示例 | 转换结果 |
|---------|------|---------|
| 静态 import | import './style.css' | import '/src/style.css' |
| 命名 import | import { foo } from './utils' | import { foo } from '/src/utils' |
| 默认 import | import Foo from './Foo.vue' | import Foo from '/src/Foo.vue' |
| 动态 import | import('./module.js') | import('/src/module.js') |
| export from | export * from './utils' | export * from '/src/utils' |
| 命名 export | export { foo } from './bar' | export { foo } from '/src/bar' |
独立入口模式的处理
独立入口模式同样支持相对路径转换:
// src/pages/home/main.ts (原始文件)
import './style.css' // 页面目录下的样式
import './components/Header.vue' // 页面目录下的组件
// ↓ 转换后 ↓
import '/src/pages/home/style.css'
import '/src/pages/home/components/Header.vue'实现原理
核心代码位于 src/virtual-entry.ts:
function rewriteRelativeImports(content: string, entryDir: string, root: string): string {
// 匹配三种模式的相对路径
const importRegex = /(import\s+(?:[\w*{}\s,]+\s+from\s+)?['"])(\.\.?\/[^'"]+)(['"])/g
const exportRegex = /(export\s+(?:\*|{[\w\s,]+})\s+from\s+['"])(\.\.?\/[^'"]+)(['"])/g
const dynamicImportRegex = /(import\s*\(\s*['"])(\.\.?\/[^'"]+)(['"]\s*\))/g
const replacer = (_match, prefix, relativePath, suffix) => {
// 1. 基于入口文件目录计算绝对路径
const absolutePath = path.resolve(entryDir, relativePath)
// 2. 转换为相对于项目根的绝对路径(以 / 开头)
const normalizedPath = '/' + path.relative(root, absolutePath).split(path.sep).join('/')
return `${prefix}${normalizedPath}${suffix}`
}
return content
.replace(importRegex, replacer)
.replace(exportRegex, replacer)
.replace(dynamicImportRegex, replacer)
}