从零打造一个 Vite 脚手架工具:比你想象的简单多了

前言

大家好,我是奈德丽。

周末闲着没事,突然想研究一下 create-vite 是怎么实现的。打开源码一看,我人都傻了------核心逻辑就几百行代码!这让我突然来了兴致:既然这么简单,为什么不自己撸一个呢?

于是我花了一个周末的时间,从零开始实现了一个简化版的脚手架工具 custom-create-vite。虽然功能没有官方的全面,但该有的都有,而且还加了一些自己觉得好用的特性,比如eslint、tailwind等。

今天就来和大家分享一下整个实现过程,以及我踩过的那些坑,如果你也想有一个自己的cli工具,不妨花几分钟看一看。

为什么要造这个轮子?

可能有人会问:官方的 create-vite 不是挺好用的吗?干嘛要自己写?

说实话,我主要是出于这几个原因:

1. 学习目的

学习当然是为了解决自己的疑惑,以前通过npm create vite,不知道实际上是怎么运行的,之前一直以为我执行了这个命令之后,vite cli是根据用户选择实时动态生成内容,简单点说,就是当你在Vue、React等众多框架中选择Vue时,cli是动态生成了vue模板,其实不是这样,它是提前创建了很多个template,比如js和ts就对应了不同模板,而React和Vue也是,他俩组合就有4个模板。

2. 团队定制需求

我们团队有一些固定的开发规范,比如统一用 Antfu 的 ESLint 配置、Tailwind CSS v4 等等。每次创建新项目都要手动配置这些东西,贼烦。如果能在脚手架里预置这些选项,那就爽歪歪了。

3. 插件化扩展

我想实现一个插件系统,让用户在创建项目的时候就能选择要不要装 ESLint、Tailwind CSS、UnoCSS 这些工具。这样新项目开箱即用,不用再手动配置。

基于这些想法,我开始动手了。

技术选型

既然是 2025 年了,那必须得用现代化的技术栈:

  • prompts:交互式命令行工具,API 简单,体积小
  • picocolors:给终端输出加点颜色,提升用户体验

没错,整个项目只依赖这两个包:

json 复制代码
{
  "dependencies": {
    "prompts": "^2.4.2",
    "picocolors": "^1.1.1"
  }
}

项目结构设计

在动手写代码之前,我先规划了一下项目主要结构:

bash 复制代码
mini-vite/
├── index.js                    # 主入口文件
├── plugins/                    # 插件系统
│   ├── index.js               # 插件管理器
│   ├── eslint.js              # ESLint 插件
│   ├── tailwind.js            # Tailwind CSS 插件
│   └── unocss.js              # UnoCSS 插件
├── template-vue/              # Vue JavaScript 模板
├── template-vue-ts/           # Vue TypeScript 模板
├── template-react/            # React JavaScript 模板
├── template-react-ts/         # React TypeScript 模板
└── package.json

结构很清晰:

  • 主逻辑放在 index.js
  • 插件系统独立成 plugins 目录,方便扩展
  • 每个模板都是独立的目录,包含完整的项目文件

除了主要目录,我还搭建了工作流,以及github 原生dependabot,如果有小伙伴感兴趣的话,后期可以写一篇文章专门讲一讲如何配置。

核心流程实现

下面讲讲具体的实现细节。整个流程可以分为 7 个步骤:

第一步:配置命令行入口

首先在 package.json 里声明 bin 字段:

json 复制代码
{
  "name": "mini-vite",
  "type": "module",
  "bin": {
    "mini-vite": "index.js",
    "create-mini-vite": "index.js"
  }
}

注意两点:

  1. type 必须设置为 module,这样才能用 ES Module 语法
  2. 我配置了两个命令名,用户可以用 mini-vitecreate-mini-vite,怎么顺手怎么来

然后在 index.js 开头加上 shebang:

js 复制代码
#!/usr/bin/env node

这一行告诉系统用 Node.js 来执行这个文件。没有这一行,直接运行会报错。

第二步:询问项目名称

js 复制代码
import prompts from 'prompts'
import colors from 'picocolors'

const { cyan, green, yellow, red, reset } = colors

async function init() {
  console.log(`\n${cyan('Mini Vite')} - A minimal scaffolding tool\n`)

  let targetDir = 'mini-vite-project'

  const result = await prompts({
    type: 'text',
    name: 'projectName',
    message: reset('Project name:'),
    initial: targetDir,
    onState: (state) => {
      targetDir = state.value?.trim() || targetDir
    }
  })

  if (!result.projectName) {
    console.log('\n' + red('✖') + ' Operation cancelled')
    return
  }
}

init().catch((e) => {
  console.error(red('Error:'), e)
  process.exit(1)
})

prompts 询问用户输入项目名称,默认是 mini-vite-project。如果用户直接按回车或者 Ctrl+C 取消,就退出程序。

第三步:检查目录是否存在

js 复制代码
import fs from 'fs'
import path from 'path'

const root = path.join(process.cwd(), targetDir)

// 判断目录是否为空
function isEmpty(path) {
  const files = fs.readdirSync(path)
  return files.length === 0 || (files.length === 1 && files[0] === '.git')
}

// 清空目录
function emptyDir(dir) {
  if (!fs.existsSync(dir)) return
  for (const file of fs.readdirSync(dir)) {
    if (file === '.git') continue
    fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
  }
}

if (fs.existsSync(root) && !isEmpty(root)) {
  const response = await prompts({
    type: 'select',
    name: 'overwrite',
    message: `Target directory "${targetDir}" is not empty. Please choose:`,
    choices: [
      { title: 'Cancel', value: 'no' },
      { title: 'Remove existing files', value: 'yes' }
    ]
  })

  if (response.overwrite === 'no') {
    console.log('\n' + red('✖') + ' Operation cancelled')
    return
  } else if (response.overwrite === 'yes') {
    console.log(`\nRemoving existing files in ${targetDir}...`)
    emptyDir(root)
  }
}

这里我踩了个坑!一开始我用 fs.rmdirSync(),结果发现如果目录非空就会报错。后来改成 fs.rmSync() 配合 recursive: true,才能递归删除整个目录。

第四步:验证包名

js 复制代码
function isValidPackageName(projectName) {
  return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(projectName)
}

function toValidPackageName(projectName) {
  return projectName
    .trim()
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/^[._]/, '')
    .replace(/[^a-z\d\-~]+/g, '-')
}

let packageName = path.basename(root)

if (!isValidPackageName(packageName)) {
  const response = await prompts({
    type: 'text',
    name: 'packageName',
    message: reset('Package name:'),
    initial: toValidPackageName(packageName),
    validate: (name) => isValidPackageName(name) || 'Invalid package name'
  })
  packageName = response.packageName
}

npm 包名有一定的规范,不能有大写字母、空格、特殊字符等。所以需要验证一下,如果不合法就提示用户重新输入。

第五步:选择框架和变体

js 复制代码
const FRAMEWORKS = [
  {
    name: 'vue',
    display: 'Vue',
    color: green,
    variants: [
      { name: 'vue-ts', display: 'TypeScript', color: blue },
      { name: 'vue', display: 'JavaScript', color: yellow }
    ]
  },
  {
    name: 'react',
    display: 'React',
    color: cyan,
    variants: [
      { name: 'react-ts', display: 'TypeScript', color: blue },
      { name: 'react', display: 'JavaScript', color: yellow }
    ]
  }
]

// 选择框架
const frameworkResponse = await prompts({
  type: 'select',
  name: 'framework',
  message: reset('Select a framework:'),
  choices: FRAMEWORKS.map(framework => ({
    title: framework.color(framework.display),
    value: framework
  }))
})

const framework = frameworkResponse.framework

if (!framework) {
  console.log('\n' + red('✖') + ' Operation cancelled')
  return
}

// 选择变体(TypeScript 或 JavaScript)
const variantResponse = await prompts({
  type: 'select',
  name: 'variant',
  message: reset('Select a variant:'),
  choices: framework.variants.map(variant => ({
    title: variant.color(variant.display),
    value: variant.name
  }))
})

const template = variantResponse.variant

这里我用了两级选择:先选框架(Vue 或 React),再选变体(TypeScript 或 JavaScript)。这样交互更清晰,用户体验更好。

每个选项都用不同的颜色标识,视觉效果更友好。

第六步:选择增强功能(插件系统)

这是我觉得最有意思的部分!一开始我vite cli 的swc也是动态插入的,但是并没有这种插件模块,我想了一下还是通过单独的模块来实现一个插件系统,因为后续会扩展更多的插件,这样可扩展性也更高点

js 复制代码
// 5. 选择增强功能(可选)
  const featuresResponse = await prompts([
    {
      type: 'multiselect',
      name: 'features',
      message: reset('Select additional features:'),
      //把多选一的插件排除掉
      choices: getPluginChoices().filter(choice => !['tailwind', 'unocss'].includes(choice.value)),
      hint: '- Space to select. Return to submit',
      instructions: false
    }
  ])

  const selectedFeatures = featuresResponse.features || []

  // 6. 选择 CSS 框架(单选,可选)
  const cssFrameworkResponse = await prompts({
    type: 'select',
    name: 'cssFramework',
    message: reset('Select a CSS framework (optional):'),
    choices: [
      { title: 'None', value: null },
      { title: 'Tailwind CSS v4', value: 'tailwind', description: 'Utility-first CSS framework' },
      { title: 'UnoCSS', value: 'unocss', description: 'Instant on-demand Atomic CSS' }
    ]
  })

  if (cssFrameworkResponse.cssFramework) {
    selectedFeatures.push(cssFrameworkResponse.cssFramework)
  }

我把插件分成了两类:

  1. 通用功能:ESLint(多选)等不能同时选择的扩展
  2. CSS 框架:Tailwind CSS 或 UnoCSS(单选)

为什么要分开呢?因为 CSS 框架是互斥的,不能同时装 Tailwind 和 UnoCSS,会冲突。所以必须用单选。

第七步:复制模板并应用插件

js 复制代码
import { fileURLToPath } from 'url'
import { applyPlugins } from './plugins/index.js'

const currentDir = path.dirname(fileURLToPath(import.meta.url))
const templateDir = path.resolve(currentDir, `template-${template}`)

// 复制文件的工具函数
function copy(src, dest) {
  const stat = fs.statSync(src)
  if (stat.isDirectory()) {
    copyDir(src, dest)
  } else {
    fs.copyFileSync(src, dest)
  }
}

function copyDir(srcDir, destDir) {
  fs.mkdirSync(destDir, { recursive: true })
  for (const file of fs.readdirSync(srcDir)) {
    const srcFile = path.resolve(srcDir, file)
    const destFile = path.resolve(destDir, file)
    copy(srcFile, destFile)
  }
}

// 创建目标目录
fs.mkdirSync(root, { recursive: true })

// 复制模板文件
const files = fs.readdirSync(templateDir)
for (const file of files.filter(f => f !== 'package.json')) {
  const srcFile = path.join(templateDir, file)
  const destFile = path.join(root, file)
  copy(srcFile, destFile)
}

// 读取并修改 package.json
const pkgPath = path.join(templateDir, 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
pkg.name = packageName

// 应用选中的插件
applyPlugins(selectedFeatures, root, template, pkg)

// 写入 package.json
fs.writeFileSync(
  path.join(root, 'package.json'),
  JSON.stringify(pkg, null, 2) + '\n'
)

这里有个小细节:因为用了 ES Module,没有 __dirname 变量,所以要用 import.meta.url 配合 fileURLToPath 来获取当前文件路径。

复制文件的逻辑很简单:递归遍历模板目录,把每个文件都复制到目标目录。

最关键的是 applyPlugins 这一步,它会根据用户选择的插件,动态修改项目配置。

插件系统的设计

插件系统是整个项目的核心,我花了不少时间来设计它的架构。

插件的标准结构

每个插件都是一个独立的 JS 文件,导出一个对象:

js 复制代码
// plugins/eslint.js
export const eslintPlugin = {
  name: 'eslint',
  title: 'ESLint + Prettier (Antfu Config)',
  description: 'Code quality and formatting',
  
  setup(root, template, pkg) {
    // 1. 添加依赖
    pkg.devDependencies = pkg.devDependencies || {}
    pkg.devDependencies['eslint'] = '^9.39.1'
    pkg.devDependencies['@antfu/eslint-config'] = '^6.2.0'
  
    // 2. 创建配置文件
    const configContent = `import antfu from '@antfu/eslint-config'

export default antfu()`
  
    fs.writeFileSync(
      path.join(root, 'eslint.config.js'),
      configContent
    )
  
    console.log(`${green('✔')} ESLint configured`)
  }
}

每个插件包含:

  • name:插件标识
  • title:显示给用户看的名称
  • description:简短描述
  • setup :安装函数,接收 root(项目目录)、template(模板名称)、pkg(package.json 对象)三个参数

插件管理器

js 复制代码
// plugins/index.js
import { eslintPlugin } from './eslint.js'
import { tailwindPlugin } from './tailwind.js'
import { unocssPlugin } from './unocss.js'

export const plugins = {
  eslint: eslintPlugin,
  tailwind: tailwindPlugin,
  unocss: unocssPlugin
}

// 插件执行顺序
const pluginOrder = {
  eslint: 1,
  tailwind: 2,
  unocss: 2
}

export function applyPlugins(selectedPlugins, root, template, pkg) {
  // 按优先级排序
  const sortedPlugins = [...selectedPlugins].sort((a, b) => {
    const orderA = pluginOrder[a] || 999
    const orderB = pluginOrder[b] || 999
    return orderA - orderB
  })
  
  for (const pluginName of sortedPlugins) {
    const plugin = plugins[pluginName]
  
    if (!plugin) {
      console.error(`${red('✖')} Plugin "${pluginName}" not found`)
      continue
    }
  
    try {
      plugin.setup(root, template, pkg)
    } catch (error) {
      console.error(`${red('✖')} Failed to configure ${plugin.title}`)
      console.error(`   ${error.message}`)
    }
  }
}

插件管理器负责:

  1. 按优先级排序插件(ESLint 要先执行,CSS 框架后执行)
  2. 依次调用每个插件的 setup 方法
  3. 错误处理,防止某个插件失败导致整个流程崩溃

Tailwind CSS v4 插件

这里我又踩了个坑!

Tailwind CSS v4 的配置方式和 v3 完全不同。v3 需要创建 tailwind.config.jspostcss.config.js,但 v4 简化了很多:

js 复制代码
// plugins/tailwind.js
export const tailwindPlugin = {
  name: 'tailwind',
  title: 'Tailwind CSS v4',
  description: 'Utility-first CSS framework',
  
  setup(root, template, pkg) {
    // 1. 添加依赖
    pkg.devDependencies['tailwindcss'] = '^4.1.0'
    pkg.devDependencies['@tailwindcss/vite'] = '^4.1.0'
  
    // 2. 创建 CSS 文件
    const cssPath = path.join(root, 'src/index.css')
    fs.writeFileSync(cssPath, '@import "tailwindcss";\n')
  
    // 3. 修改 vite.config.js
    const viteConfigPath = path.join(root, 'vite.config.js')
    let viteConfig = fs.readFileSync(viteConfigPath, 'utf-8')
  
    // 在 import 区域添加
    viteConfig = viteConfig.replace(
      /(import .+ from .+\n)/,
      `$1import tailwindcss from '@tailwindcss/vite'\n`
    )
  
    // 在 plugins 数组添加
    viteConfig = viteConfig.replace(
      /plugins:\s*\[/,
      'plugins: [tailwindcss(), '
    )
  
    fs.writeFileSync(viteConfigPath, viteConfig)
  
    console.log(`${green('✔')} Tailwind CSS v4 configured`)
  }
}

这里采用的最新版的v4 tailwindcss,v4 的配置步骤:

  1. 安装 tailwindcss@tailwindcss/vite
  2. 在 CSS 文件里写 @import "tailwindcss";
  3. vite.config.js 引入 Vite 插件

不需要 创建 tailwind.config.jspostcss.config.js

UnoCSS 插件

js 复制代码
// plugins/unocss.js
export const unocssPlugin = {
  name: 'unocss',
  title: 'UnoCSS',
  description: 'Instant on-demand Atomic CSS',
  
  setup(root, template, pkg) {
    // 1. 添加依赖
    pkg.devDependencies['unocss'] = '^0.64.6'
  
    // 2. 创建配置文件
    const configContent = `import { defineConfig } from 'unocss'

export default defineConfig({
  // 你的配置
})`
  
    fs.writeFileSync(
      path.join(root, 'uno.config.ts'),
      configContent
    )
  
    // 3. 修改 vite.config.js
    const viteConfigPath = path.join(root, 'vite.config.js')
    let viteConfig = fs.readFileSync(viteConfigPath, 'utf-8')
  
    viteConfig = viteConfig.replace(
      /(import .+ from .+\n)/,
      `$1import UnoCSS from 'unocss/vite'\n`
    )
  
    viteConfig = viteConfig.replace(
      /plugins:\s*\[/,
      'plugins: [UnoCSS(), '
    )
  
    fs.writeFileSync(viteConfigPath, viteConfig)
  
    // 4. 修改 main 入口文件
    const isVue = template.startsWith('vue')
    const mainFile = isVue ? 'main.js' : 'main.jsx'
    const mainPath = path.join(root, 'src', mainFile)
    let mainContent = fs.readFileSync(mainPath, 'utf-8')
  
    mainContent = `import 'virtual:uno.css'\n${mainContent}`
  
    fs.writeFileSync(mainPath, mainContent)
  
    console.log(`${green('✔')} UnoCSS configured`)
  }
}

UnoCSS 的配置相对复杂一点:

  1. 创建 uno.config.ts
  2. 修改 vite.config.js 引入插件
  3. 在入口文件(main.jsmain.jsx)引入 virtual:uno.css

模板设计

因为平常主要用Vue和React比较多,我为 Vue 和 React 各准备了 JS 和 TS 两个版本,一共 4 个模板。

Vue 模板

arduino 复制代码
template-vue/
├── index.html
├── vite.config.js
├── package.json
├── public/
│   └── vite.svg
└── src/
    ├── main.js
    ├── App.vue
    ├── style.css
    ├── components/
    │   └── HelloWorld.vue
    └── assets/
        └── vue.svg

package.json

json 复制代码
{
  "name": "vite-project",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.5.13"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.2.1",
    "vite": "^6.0.5"
  }
}

vite.config.js

js 复制代码
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()]
})

React 模板

arduino 复制代码
template-react/
├── index.html
├── vite.config.js
├── package.json
├── public/
│   └── vite.svg
└── src/
    ├── main.jsx
    ├── App.jsx
    ├── index.css
    ├── components/
    │   └── Counter.jsx
    └── assets/
        └── react.svg

package.json

json 复制代码
{
  "name": "vite-project",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.3.4",
    "@types/react": "^18.3.18",
    "@types/react-dom": "^18.3.5",
    "vite": "^6.0.5"
  }
}

TypeScript 版本就是在上面的基础上:

  1. 添加 tsconfig.jsontsconfig.node.json
  2. 文件后缀改成 .ts.tsx
  3. package.json 里加上 typescript 依赖

使用方式

开发阶段,可以用node index.js或则是使用 npm link 把命令链接到全局:

bash 复制代码
cd mini-vite
npm link

然后就可以在任意目录使用了:

bash 复制代码
mini-vite my-app

或者:

bash 复制代码
create-mini-vite my-app

执行后会看到交互式界面:

vbnet 复制代码
Mini Vite - A minimal scaffolding tool

? Project name: › my-app
? Select a framework: › 
  Vue
  React
? Select a variant: › 
  TypeScript
  JavaScript
? Select additional features: › 
◯ ESLint + Prettier (Antfu Config)
? Select a CSS framework (optional): › 
  None
  Tailwind CSS v4
  UnoCSS

✔ Scaffolding project in /path/to/my-app...

✔ ESLint configured
✔ Tailwind CSS v4 configured

✔ Done!

Now run:

  cd my-app
  npm install
  npm run dev

踩过的坑

1. ES Module 的 __dirname 问题

在 ES Module 里没有 __dirname__filename,要这样获取:

js 复制代码
import { fileURLToPath } from 'url'
import path from 'path'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

我一开始直接用 __dirname,结果报错说未定义。查了文档才知道要用 import.meta.url

2. Tailwind CSS v4 的配置变化

v4 不再需要 tailwind.config.jspostcss.config.js,直接在 CSS 里引入就行:

css 复制代码
@import "tailwindcss";

然后在 vite.config.js 引入 Vite 插件:

js 复制代码
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [tailwindcss()]
})

我一开始还按 v3 的方式创建配置文件,浪费了好长时间。

3. 正则表达式修改 vite.config.js

用正则表达式修改配置文件的时候要特别小心:

js 复制代码
// 不好的写法(假设格式固定)
content.replace('plugins: [', 'plugins: [tailwindcss(), ')

// 好的写法(考虑各种空格情况)
content.replace(/plugins:\s*\[/, 'plugins: [tailwindcss(), ')

因为不同人写代码的格式可能不一样,有人写 plugins:[,有人写 plugins: [。用正则的 \s* 可以匹配任意空格。

和官方工具的对比

特性 create-vite mini-vite
支持的框架 8+ 2(Vue、React)
模板数量 18+ 4
依赖数量 3 2
插件系统 ✓(ESLint、Tailwind、UnoCSS)
代码量 ~500 行 ~300 行
团队定制 困难 容易
学习难度 中等 简单

官方工具功能更全面,适合生产环境。我这个更轻量、更灵活,适合学习和团队定制。

写在最后

后续我会补充更多plugin,支持h5端不同机型适配、router和跨页面应用存储等通用功能 大家也可以尝试写一个自己的cli工具,无论是团队开发还是个人独立开发,我觉得都挺有用的,虽然现在ai很牛,有时候让它创建项目,也会出错,还得不断调试才能做好(吐槽一下)

emm 懦夫的味道...


参考资料

相关推荐
沐怡旸2 小时前
【穿越Effective C++】条款16:成对使用new和delete时要采用相同形式——内存管理的精确匹配原则
c++·面试
liangshanbo12152 小时前
CSS 数学函数完全指南:从基础计算到高级动画
前端·css
码上成长3 小时前
GraphQL:让前端自己决定要什么数据
前端·后端·graphql
冴羽3 小时前
为什么在 JavaScript 中 NaN !== NaN?背后藏着 40 年的技术故事
前端·javascript·node.js
久爱@勿忘3 小时前
vue下载项目内静态文件
前端·javascript·vue.js
前端炒粉3 小时前
21.搜索二维矩阵 II
前端·javascript·算法·矩阵
合作小小程序员小小店4 小时前
web网页开发,在线%台球俱乐部管理%系统,基于Idea,html,css,jQuery,jsp,java,ssm,mysql。
java·前端·jdk·intellij-idea·jquery·web
不爱吃糖的程序媛4 小时前
Electron 应用中的系统检测方案对比
前端·javascript·electron
泷羽Sec-静安4 小时前
Less-9 GET-Blind-Time based-Single Quotes
服务器·前端·数据库·sql·web安全·less