Glittering's blog Glittering's blog
Home
  • 学习手册

    • 《JavaScript教程》
    • 《ES6 教程》
    • 《TypeScript 从零实现 axios》
    • 《Git》
    • 《Vite》
    • 《Vue3》
    • TypeScript
    • CSS
  • 技术文档
  • 算法
  • 工作总结
  • 实用技巧
  • collect
About
  • Classification
  • Label
GitHub (opens new window)

Glitz Ma

前端开发工程师
Home
  • 学习手册

    • 《JavaScript教程》
    • 《ES6 教程》
    • 《TypeScript 从零实现 axios》
    • 《Git》
    • 《Vite》
    • 《Vue3》
    • TypeScript
    • CSS
  • 技术文档
  • 算法
  • 工作总结
  • 实用技巧
  • collect
About
  • Classification
  • Label
GitHub (opens new window)
  • vite从入门到精通
  • vite插件
  • vite自动导入api插件
  • Vite 开发服务器核心原理讲解
  • Vite HMR源码级讲解
  • Vite虚拟模块插件
  • 模拟 Vite 内置env
  • vite一体化虚拟模块插件
  • vite可发布npm的虚拟插件
    • 一、设计要点(简短说明)
    • 二、项目结构(建议)
    • 三、核心源码(TypeScript)
      • src/virtualEnv.ts
      • src/virtualApi.ts
      • src/index.ts (插件入口)
    • 四、打包配置(Rollup)
    • 五、tsconfig.json
    • 六、package.json(示例)
    • 七、发布前的关键注意事项(让 tree-shaking 生效)
    • 八、使用范例(在项目中怎么用)
    • 九、生成类型(.d.ts)建议
    • 十、发布流程(检查清单 + 命令)
    • 十一、如何本地验证 tree-shaking 有效
    • 十二、后续增强建议(你可以选择实现)
    • GitHub Actions自动发布到npm
      • ✅ GitHub Actions:自动 build + 自动发布 npm + 自动创建 Release
      • 🔐 需要你在 GitHub 设置的 Secrets
      • 📌 工作流触发方式
      • 🔧 如果你还希望支持 Beta / Alpha 发布
  • 《Vite》学习笔记
mamingjuan
2024-08-18
目录

vite可发布npm的虚拟插件

开发(dev)和构建(build)阶段都支持 tree-shaking、生成 TypeScript 类型(.d.ts),并包含打包脚本、Rollup 配置与发布说明。下面是一套完整、可复制粘贴的实现方案:源码(TypeScript)、构建配置、package.json、使用说明、以及注意事项(为何 tree-shaking 能生效、如何测试)。

目标产出(你将得到)

  1. vite-plugin-auto-all(TypeScript 实现)
  2. 支持:virtual:auto-api + virtual:env(dev & build)
  3. 在 build 阶段生成真实 ES 模块输出(无副作用)以便 Rollup/webpack 做 tree-shaking
  4. 生成 .d.ts 类型声明文件(rollup-plugin-dts)
  5. 可直接 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
1
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`
}
1
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`
}
1
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)
  }
}
1
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()]
  }
]
1
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"]
}
1
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"
  }
}
1
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 生效)

  1. 模块导出形式:虚拟模块导出必须是命名导出或者 export default 对象,且不在顶层执行副作用(例如不要在模块顶部执行网络请求、console.log 等)。上面实现满足这一点。
  2. package.json sideEffects: false:告诉打包器模块无副作用,从而允许摇树。
  3. 生成 ESM 输出:module 字段指向 ES 模块 dist/index.esm.js,Rollup/webpack 能够更好执行 tree-shaking。
  4. 保证构建阶段虚拟模块可访问:我们的 plugin 在 buildStart 预扫描 API/Env,这样 load/resolveId 在构建阶段能被处理;在极端情况下可在 buildStart 中 this.emitFile({ type: 'chunk', id: '\0virtual:auto-api' }) 等做更复杂的处理,但大多数场景 load + resolveId 已足够。

# 八、使用范例(在项目中怎么用)

在目标项目中安装:

npm i -D vite-plugin-auto-all
1

vite.config.ts:

import { defineConfig } from 'vite'
import autoAll from 'vite-plugin-auto-all'

export default defineConfig({
  plugins: [ autoAll({ apiDir: 'src/composables' }) ]
})
1
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'
1
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 {}
}
1
2
3
4
5
6
7
8
9
10
11
12
  • rollup-plugin-dts 可以帮助把 .d.ts 合并到 dist/index.d.ts。

# 十、发布流程(检查清单 + 命令)

  1. 更新 package.json(name/version/author/license)。确保唯一包名。
  2. npm run build(或 CI 自动化)
  3. 登录 npm:npm login
  4. 发布:npm publish --access public(若是 scoped 包,使用 --access public)
  5. Tag 和 Release(可选):git tag v1.0.0 && git push --tags

CI 示例(GitHub Actions):在 release 分支打标签时触发 npm publish。


# 十一、如何本地验证 tree-shaking 有效

  1. 在一个测试项目中安装本地包(npm pack 或 yarn add ../path/to/your/plugin)。
  2. 在测试项目里 import { unusedFunc } from 'virtual:auto-api' 不要在代码中引用,构建 npm run build。
  3. 查看打包后的产物(例如 dist/assets/*.js),确认 unusedFunc 没出现在最终 bundle(或使用 source-map 分析)。
  4. 你也可以在 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
1

# ✅ 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/**
1
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
1
2

GitHub Actions 会自动:

  1. 安装 Node 环境
  2. npm ci 装依赖
  3. 跑测试
  4. npm run build
  5. 用 npm publish 发布到 npm
  6. 创建 GitHub Release,并附带 dist/** 文件

# 🔧 如果你还希望支持 Beta / Alpha 发布

例如:

v1.1.0-beta.1
1

那就保持同样的 tag 规则即可,npm 会按版本发布。


上次更新: 2025/12/09, 09:52:56
vite一体化虚拟模块插件

← vite一体化虚拟模块插件

Copyright © 2015-2025 Glitz Ma
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式