vite可发布npm的虚拟插件
开发(dev)和构建(build)阶段都支持 tree-shaking、生成 TypeScript 类型(.d.ts),并包含打包脚本、Rollup 配置与发布说明。下面是一套完整、可复制粘贴的实现方案:源码(TypeScript)、构建配置、package.json、使用说明、以及注意事项(为何 tree-shaking 能生效、如何测试)。
目标产出(你将得到)
vite-plugin-auto-all(TypeScript 实现)- 支持:
virtual:auto-api+virtual:env(dev & build)- 在 build 阶段生成真实 ES 模块输出(无副作用)以便 Rollup/webpack 做 tree-shaking
- 生成
.d.ts类型声明文件(rollup-plugin-dts)- 可直接
npm publish的 package.json + 发布流程说明
# 一、设计要点(简短说明)
- Tree-shaking 原则:输出的虚拟模块在构建时会以纯 ES module 形式提供命名导出(没有副作用的顶层代码),并作为 Rollup chunks 被识别与摇树(treeshake)。所以仅导出的函数/常量被使用时会被保留,否则被摇掉。
- 开发(dev)模式:通过
resolveId/load提供虚拟模块,支持 HMR(invalidate module + return module node)。 - 构建(build)模式:依然通过
resolveId/load,但我们确保模块内容是纯 ES module(只导出,顶层不执行副作用)。Rollup 会把这些模块视为常规模块并参与 tree-shaking。 - TypeScript 支持:代码用 TypeScript 编写,打包输出
dist/(ESM & CJS),并生成类型声明dist/index.d.ts。 - 无副作用:package.json
sideEffects: false,并让输出为 ES modules。
# 二、项目结构(建议)
vite-plugin-auto-all/
├─ src/
│ ├─ index.ts // 插件入口
│ ├─ virtualApi.ts // 生成 virtual:auto-api 内容逻辑
│ ├─ virtualEnv.ts // 生成 virtual:env 内容逻辑
│ └─ types.ts // 导出的 TS 类型
├─ rollup.config.mjs
├─ tsconfig.json
├─ package.json
├─ README.md
└─ .npmignore
2
3
4
5
6
7
8
9
10
11
# 三、核心源码(TypeScript)
把下面文件放到 src/ 中。
# src/virtualEnv.ts
import fs from 'node:fs'
import path from 'node:path'
import dotenv from 'dotenv'
export function loadClientEnv(root: string, mode: string, prefix = 'VITE_') {
const envDir = root || process.cwd()
const files = [
`.env`,
`.env.local`,
`.env.${mode}`,
`.env.${mode}.local`
]
const raw: Record<string,string> = {}
for (const f of files) {
const full = path.resolve(envDir, f)
if (fs.existsSync(full)) {
const parsed = dotenv.parse(fs.readFileSync(full, 'utf-8'))
Object.assign(raw, parsed)
}
}
const clientEnv: Record<string, any> = {}
for (const k of Object.keys(raw)) {
if (k === 'NODE_ENV' || k.startsWith(prefix)) {
clientEnv[k] = raw[k]
}
}
return clientEnv
}
/**
* Generate pure ES module string for virtual:env
* Exports both named exports and default export object.
*/
export function generateEnvModuleCode(env: Record<string, any>, extra = {}) {
const merged = { ...extra, ...env }
const keys = Object.keys(merged)
const named = keys.map(k => `export const ${k} = ${JSON.stringify(merged[k])};`).join('\n')
const defaultExport = `export default { ${keys.join(', ')} };`
// No top-level side effects: only exports.
return `${named}\n\n${defaultExport}\n`
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# src/virtualApi.ts
import fs from 'node:fs/promises'
import path from 'node:path'
/**
* Scan apiDir recursively and collect named exports from each file.
* For simplicity we statically parse `export function NAME` and `export const NAME =` and `export { A, B }`
* For production you'd use an AST parser (es-module-lexer / @babel/parser).
*/
export async function scanApiDir(root: string, apiDir: string) {
const dir = path.resolve(root, apiDir)
const files: string[] = []
async function walk(dirPath: string) {
const entries = await fs.readdir(dirPath, { withFileTypes: true })
for (const ent of entries) {
const p = path.join(dirPath, ent.name)
if (ent.isDirectory()) await walk(p)
else if (/\.(js|ts|jsx|tsx)$/.test(ent.name)) files.push(p)
}
}
try {
await walk(dir)
} catch (e) {
return { files: [], exports: {} }
}
const exportsMap: Record<string, { id: string, names: string[] }> = {}
for (const f of files) {
const code = (await fs.readFile(f, 'utf-8')).toString()
const relId = '/' + path.relative(root, f).replace(/\\/g, '/')
const names = new Set<string>()
// simple regex-based extraction (sufficient and fast). For production, swap with AST.
for (const m of code.matchAll(/export\s+(?:function|const|let|var|class)\s+([A-Za-z0-9_$]+)/g)) {
names.add(m[1])
}
for (const m of code.matchAll(/export\s*{\s*([^}]+)\s*}/g)) {
const parts = m[1].split(',').map(s => s.trim().split(/\s+as\s+/)[0])
for (const p of parts) if (p) names.add(p)
}
if (names.size > 0) {
exportsMap[relId] = { id: relId, names: Array.from(names) }
}
}
return { files, exports: exportsMap }
}
/**
* Produce module code that imports used functions and re-exports them as named exports.
* This module is pure ES module and has no side effects.
*/
export function generateAutoApiModuleCode(exportsMap: Record<string, { id: string, names: string[] }>) {
const importLines: string[] = []
const exportNames: string[] = []
for (const rel in exportsMap) {
const info = exportsMap[rel]
if (info.names.length === 0) continue
importLines.push(`import { ${info.names.join(', ')} } from '${info.id}';`)
exportNames.push(...info.names)
}
const unique = Array.from(new Set(exportNames))
const exportLine = `export { ${unique.join(', ')} };`
return `${importLines.join('\n')}\n\n${exportLine}\n`
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# src/index.ts (插件入口)
import { Plugin } from 'vite'
import path from 'node:path'
import { loadClientEnv, generateEnvModuleCode } from './virtualEnv.js'
import { scanApiDir, generateAutoApiModuleCode } from './virtualApi.js'
export interface Options {
apiDir?: string
envPrefix?: string
}
const VIRTUAL_API_ID = 'virtual:auto-api'
const VIRTUAL_ENV_ID = 'virtual:env'
const RES_API_ID = '\0' + VIRTUAL_API_ID
const RES_ENV_ID = '\0' + VIRTUAL_ENV_ID
export default function autoAllPlugin(options: Options = {}): Plugin {
const apiDir = options.apiDir || 'src/composables'
const envPrefix = options.envPrefix || 'VITE_'
let root = process.cwd()
let mode = 'development'
let cachedEnv: Record<string, any> = {}
let cachedApiExports: Record<string, { id: string, names: string[] }> = {}
return {
name: 'vite-plugin-auto-all',
enforce: 'pre',
apply: 'serve', // default apply to serve; we'll still handle build with generic hooks
configResolved(config) {
root = config.root || root
mode = config.mode || mode
cachedEnv = loadClientEnv(root, mode, envPrefix)
},
resolveId(id) {
if (id === VIRTUAL_API_ID) return RES_API_ID
if (id === VIRTUAL_ENV_ID) return RES_ENV_ID
},
async load(id) {
if (id === RES_ENV_ID) {
// Always regenerate on load to pick up config
const code = generateEnvModuleCode(cachedEnv, {
MODE: mode,
DEV: mode === 'development',
PROD: mode === 'production',
BASE_URL: '/'
})
return code
}
if (id === RES_API_ID) {
const { exports } = await scanApiDir(root, apiDir)
cachedApiExports = exports
const code = generateAutoApiModuleCode(exports)
return code
}
},
// HMR handling for dev server
async handleHotUpdate(ctx) {
const file = ctx.file
if (!file) return
// if API files changed, invalidate virtual:auto-api
const apiRoot = path.resolve(root, apiDir)
if (file.startsWith(apiRoot)) {
const mod = ctx.server.moduleGraph.getModuleById(RES_API_ID)
if (mod) {
ctx.server.moduleGraph.invalidateModule(mod)
return [mod]
}
}
// env files change -> invalidate env module and trigger full reload
if (file.includes('.env')) {
cachedEnv = loadClientEnv(root, mode, envPrefix)
const mod = ctx.server.moduleGraph.getModuleById(RES_ENV_ID)
if (mod) {
ctx.server.moduleGraph.invalidateModule(mod)
return [mod]
} else {
// fallback full reload
ctx.server.ws.send({ type: 'full-reload' })
}
}
},
// Build-time: ensure virtual modules are provided during build as pure ES modules
async buildStart() {
// pre-scan API and env so Rollup sees these modules at bundle time
cachedEnv = loadClientEnv(root, mode, envPrefix)
const r = await scanApiDir(root, apiDir)
cachedApiExports = r.exports
},
// When rollup requests the virtual id via resolve/load, we already support them above.
// Note: apply:'serve' doesn't run for build hooks; to ensure build uses it, we can accept both:
// (Vite will call load/resolveId during build as well)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
说明:上面源码里
scanApiDir使用的是正则解析导出符号;生产环境建议替换为es-module-lexer或@babel/parser以获得更健壮的导出识别(特别是 re-export、export from 等)。
# 四、打包配置(Rollup)
rollup.config.mjs(以 ESM 写法):
import typescript from '@rollup/plugin-typescript'
import dts from 'rollup-plugin-dts'
import pkg from './package.json' assert { type: 'json' }
export default [
// ESM + CJS bundle
{
input: 'src/index.ts',
output: [
{ file: pkg.module, format: 'es', sourcemap: true },
{ file: pkg.main, format: 'cjs', sourcemap: true }
],
external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})],
plugins: [
typescript({ tsconfig: './tsconfig.json', declaration: false, rootDir: 'src' })
]
},
// d.ts
{
input: './dist/types/src/index.d.ts',
output: [{ file: pkg.types, format: 'es' }],
plugins: [dts()]
}
]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
说明:上例假设你用
tsc先输出声明到dist/types,然后 rollup-plugin-dts 聚合。也可直接用tsc --emitDeclarationOnly配合 rollup dts 步骤。
# 五、tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"declaration": true,
"declarationDir": "dist/types",
"emitDeclarationOnly": false,
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 六、package.json(示例)
{
"name": "vite-plugin-auto-all",
"version": "1.0.0",
"description": "Vite plugin: auto-import API + virtual env module + HMR, tree-shakable in build",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"sideEffects": false,
"scripts": {
"clean": "rimraf dist",
"build:ts": "tsc -p tsconfig.json",
"build:rollup": "rollup -c",
"build": "npm run clean && npm run build:ts && npm run build:rollup",
"lint": "eslint . --ext .ts,.js",
"prepare": "npm run build"
},
"keywords": ["vite","plugin","auto-import","virtual","env","hmr"],
"author": "Your Name",
"license": "MIT",
"devDependencies": {
"@rollup/plugin-typescript": "^11.0.0",
"rollup": "^3.0.0",
"rollup-plugin-dts": "^5.0.0",
"typescript": "^5.0.0",
"dotenv": "^16.0.0",
"rimraf": "^3.0.0"
},
"peerDependencies": {
"vite": ">=4"
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 七、发布前的关键注意事项(让 tree-shaking 生效)
- 模块导出形式:虚拟模块导出必须是命名导出或者
export default对象,且不在顶层执行副作用(例如不要在模块顶部执行网络请求、console.log 等)。上面实现满足这一点。 - package.json
sideEffects: false:告诉打包器模块无副作用,从而允许摇树。 - 生成 ESM 输出:
module字段指向 ES 模块dist/index.esm.js,Rollup/webpack 能够更好执行 tree-shaking。 - 保证构建阶段虚拟模块可访问:我们的 plugin 在 buildStart 预扫描 API/Env,这样 load/resolveId 在构建阶段能被处理;在极端情况下可在
buildStart中this.emitFile({ type: 'chunk', id: '\0virtual:auto-api' })等做更复杂的处理,但大多数场景load+resolveId已足够。
# 八、使用范例(在项目中怎么用)
在目标项目中安装:
npm i -D vite-plugin-auto-all
vite.config.ts:
import { defineConfig } from 'vite'
import autoAll from 'vite-plugin-auto-all'
export default defineConfig({
plugins: [ autoAll({ apiDir: 'src/composables' }) ]
})
2
3
4
5
6
使用:
// 可以直接从虚拟模块导入
import { useUser } from 'virtual:auto-api'
import { VITE_API_URL } from 'virtual:env'
// 也可 default import env
import env from 'virtual:env'
2
3
4
5
6
在构建产物中,如果没有使用 useUser,该符号将被 tree-shaken 掉(前提是下游构建支持 tree-shaking,通常是 Rollup/webpack/Rspack/esbuild 都行)。
# 九、生成类型(.d.ts)建议
- 在
src/types.ts导出插件的 option 类型、以及虚拟模块的相关类型(例如declare module 'virtual:env'),以便 TypeScript 项目能识别。 - 你可以在
dist/index.d.ts中添加:
declare module 'virtual:env' {
const env: { [key: string]: string | boolean }
export default env
export const MODE: string
export const DEV: boolean
export const PROD: boolean
export const BASE_URL: string
}
declare module 'virtual:auto-api' {
// You may leave it open; consumers get types from actual source files.
export {}
}
2
3
4
5
6
7
8
9
10
11
12
rollup-plugin-dts可以帮助把.d.ts合并到dist/index.d.ts。
# 十、发布流程(检查清单 + 命令)
- 更新
package.json(name/version/author/license)。确保唯一包名。 npm run build(或 CI 自动化)- 登录 npm:
npm login - 发布:
npm publish --access public(若是 scoped 包,使用--access public) - Tag 和 Release(可选):
git tag v1.0.0 && git push --tags
CI 示例(GitHub Actions):在 release 分支打标签时触发 npm publish。
# 十一、如何本地验证 tree-shaking 有效
- 在一个测试项目中安装本地包(
npm pack或yarn add ../path/to/your/plugin)。 - 在测试项目里
import { unusedFunc } from 'virtual:auto-api'不要在代码中引用,构建npm run build。 - 查看打包后的产物(例如
dist/assets/*.js),确认unusedFunc没出现在最终 bundle(或使用 source-map 分析)。 - 你也可以在 plugin 里人为加入
console.log('side effect')来确认sideEffects: false是否生效(不存在 console.log)。
# 十二、后续增强建议(你可以选择实现)
- 用
es-module-lexer或@babel/parser替换当前正则式导出检测,支持export { default as X } from './x'等复杂用法。 - 支持
auto import按需按使用位置生成 import 语句(更接近 unplugin-auto-import)。 - 为
virtual:env生成env.d.ts并支持import.meta.env替换(AST + magic-string),使import.meta.env.FOO在 TS 中可被类型检查与自动完成。 - 提供
preset(例如 Vue preset、React preset)内置的常用 API 列表。 - 在 build 阶段把虚拟模块显式 emit 为 chunk(
this.emitFile({ type: 'chunk', id: virtualId }))以便做更精细 rollup 控制(optional)。
# GitHub Actions自动发布到npm
你只需要把它放到:
.github/workflows/publish.yml
# ✅ GitHub Actions:自动 build + 自动发布 npm + 自动创建 Release
name: Publish to npm
on:
push:
tags:
- "v*" # 例如 v1.0.0 时触发
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test --if-present
- name: Build plugin
run: npm run build
# ---- 发布到 npm ----
- name: Publish to npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# ---- 自动创建 GitHub Release ----
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
dist/**
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 🔐 需要你在 GitHub 设置的 Secrets
进入仓库 → Settings → Secrets → Actions → New repository secret
| Secret 名称 | 值 |
|---|---|
NPM_TOKEN | npm 的 publish token(npmjs.com → Access Tokens → Generate Fine-grained Token) |
# 📌 工作流触发方式
每次你 push 一个 tag:
git tag v1.0.0
git push origin v1.0.0
2
GitHub Actions 会自动:
- 安装 Node 环境
npm ci装依赖- 跑测试
npm run build- 用
npm publish发布到 npm - 创建 GitHub Release,并附带
dist/**文件
# 🔧 如果你还希望支持 Beta / Alpha 发布
例如:
v1.1.0-beta.1
那就保持同样的 tag 规则即可,npm 会按版本发布。