从零发布一个 Vite+Vue3 脚手架

从零发布一个 Vite+Vue3 脚手架:动态生成项目与多平台发布

背景:从「重复 CV 工程师」到「自动化工具开发者」

在团队开发中,每次新建项目都要经历「复制老项目 → 疯狂删文件 → 重写配置」的循环。

为了解决这个问题,我给自己写了一个支持 TypeScript/JavaScript 双版本插件按需加载的脚手架工具,并成功发布到 npm 和 GitHub Packages。

本文将分享完整实现过程。

一、成果预览:一行命令搞定项目初始化

核心特性

  1. 零配置启动

    shell 复制代码
    pnpm create vvt [项目名称]
  2. 语言按需选择

  3. 插件化架构

    1. postcss-pxtorem:在设计图转页面的时候比较好用
    2. tailwindcss:减轻想类名、编写大量 css的苦恼,but 协同开发维护起来也有点难顶
    3. vite-svg-loader:如果项目中有较多的 svg 文件,推荐使用
  4. 按照使用说明启动项目,结果如下:

二、环境准备

  • node.js(>=v20)
  • npm
  • pnpm
  • Vite 脚手架
  • npm账号/github账号

三、初始化项目

1. 准备基础模板

我是使用的vite脚手架创建了一个基础框架,然后添加了一些基础配置,作为自己的脚手架基础。项目结构概览如下,具体配置可以参考我的搭建一个自己的开箱即用的 VUE 脚手架为 VUE 脚手架添加开发常用的插件/配置 这两篇文章。

调整为能发布成脚手架的结构,整体结构如下:

shell 复制代码
├── bin
│   └── cli.js         # 命令行入口
├── templates          # 项目模板
│   ├── typescript     # TS 版本模板,里面的代码就是上面准备的基础模板
│   └── javascript     # JS 版本模板,根据 TS 进行部分删减
├── gitignore          # node_modules、.npmrc文件一定要放进来!!
├── .npmrc             # 填写 github packages 注册表 token
├── LICENSE            # 许可文件
├── package.json       # 会进行一些发布配置
├── publish-github.js  # 自动化发布脚本
└── README.md          # 脚手架使用说明文件

2. 创建bin/cli.js文件

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

console.log('hello vvt')
...主内容

#!开头标识这个文件被当做执行文件,可以当做脚本运行。后面的/usr/bin/env node表示文件用node执行,基于用户安装根目录下的环境变量中查找node

3. 修改 package.json

json 复制代码
{
  "name": "create-vvt", // 脚手架的名称,若要发布到 npm,必须唯一。创建命令也是这个
  "version": "0.1.0", // 每次发布需要更新版本号
  "type": "module",
  "description": "一个基于 Vite + Vue3 + TypeScript/JavaScript 的项目模板脚手架",
  "bin": {
    "vvt": "bin/cli.js" // 用于配置指令,指令由key、value组成,key是指令名,value为运行指令时运行的文件
  }
  ...
}

4. 本地测试

使用npm link将这个项目链接到全局,进行测试。关于链接方法的具体操作可以在vite+ts发布 npm 包过程记录这里找到。测试成功之后就进行核心代码部分了。

四、关键实现细节

  • 用户输入校验:项目名非空检测
  • 模板选择:TS/JS 双版本支持
  • 插件按需加载:Tailwind CSS 等动态配置

1. 核心依赖

shell 复制代码
pnpm add chalk commander inquirer ora
# 用于构建用户友好的交互界面
工具 作用 官方文档
commander 命令行参数解析 文档链接
inquirer 交互式问答 文档链接
chalk 终端输出美化 文档链接
ora 加载动画 文档链接

2. 动态生成项目模板

graph TD A[用户输入] --> B{语言选择} B -->|TypeScript| C[加载 TS 模板] B -->|JavaScript| D[加载 JS 模板] C/D --> E[插件按需装配] E --> F[写入 package.json] F --> G[项目生成完成]
  1. 定义可选的插件和语言选项

    js 复制代码
    // 定义可选插件
    const PLUGINS = [
      {
        name: 'postcss-pxtorem',// 用在命令行显示
        value: 'pxtorem',
        description: '将 px 单位转换为 rem 单位',// 用在命令行显示
        devDependencies: {
          'postcss-pxtorem': '^6.1.0'
        }
      }
      ...其他插件
    ];
    
    // 语言选项
    const LANGUAGES = {
      typescript: {
        name: 'TypeScript',
        value: 'typescript',
        description: '使用 TypeScript 语法(类型安全的 JavaScript 超集)',
        templateDir: 'templates/typescript' // 对应模板所在文件夹路径
      },
      javascript: {
        name: 'JavaScript',
        value: 'javascript',
        description: '使用 JavaScript 语法(更简洁,无类型检查)',
        templateDir: 'templates/javascript'
      }
    };
  2. 基于commander执行自定义命令指令,准备创建项目

    js 复制代码
    // 初始化命令行工具
    import { Command } from 'commander';
    const program = new Command();
    
    program
      .name('create-vite-vue3-ts')
      .description('基于 Vite + Vue3 的项目模板生成工具')
      .version('0.1.0')
      .argument('[project-name]', '项目名称')
      .action(async (projectName) => {
        try {
          await createProject(projectName); // 使用action绑定主逻辑函数
        } catch (error) {
          console.error(chalk.red('错误:') + error.message);
          process.exit(1);
        }
      });
    program.parse(process.argv);
  3. 使用inquirer收集用户输入,如项目名称、描述、作者等

    js 复制代码
    // 创建项目主逻辑函数createProject
    // 1. 获取项目名称
    const { name, description, author } = await inquirer.prompt([
      {
        type: 'input',
        name: 'name',
        message: '请输入项目名称:',
        default: projectName || 'vite-vue3-project',
        // 对输入添加校验逻辑,避免空值
        validate: (input) => {
          if (!input.trim()) {
            return '项目名称不能为空';
          }
          return true;
        }
      },
      {
        type: 'input',
        name: 'description',
        message: '请输入项目描述:',
        default: '基于 Vite + Vue3 的项目模板'
      },
      {
        type: 'input',
        name: 'author',
        message: '请输入作者名称:',
        default: 'egg'
      }
    ]);
    
    // 2. 选择语言
    const { language } = await inquirer.prompt([
      {
        type: 'list',
        name: 'language',
        message: '请选择开发语言:',
        choices: Object.values(LANGUAGES).map((lang) => ({
          name: `${lang.name} (${lang.description})`,
          value: lang.value
        })),
        default: 'typescript'
      }
    ]);
  4. 检查用户计划创建的文件是否存在,存在的话需要覆盖

    js 复制代码
    const targetDir = path.join(process.cwd(), name); // name是用户填写的项目名
    
    if (fs.existsSync(targetDir)) {
        const { overwrite } = await inquirer.prompt([
          {
            type: 'confirm',
            name: 'overwrite',
            message: `目标目录 ${chalk.cyan(name)} 已存在。是否要覆盖?`,
            default: true
          }
        ]);
    
        if (!overwrite) {
          throw new Error('操作取消');
        }
    
        const spinner = ora('正在清理目录...').start();
    
        await fs.promises.rm(targetDir, { recursive: true, force: true });
        spinner.succeed(chalk.green('目录清理完成'));
      }
  5. 指导用户选择安装插件

    js 复制代码
    // 逐个选择插件
    const selectedPlugins = [];
    for (const plugin of PLUGINS) {
      const { install } = await inquirer.prompt([
        {
          type: 'confirm',
          name: 'install',
          message: `是否安装 ${plugin.name}(${plugin.description})?`,
          default: false
        }
      ]);
      if (install) {
        selectedPlugins.push(plugin.value);
      }
    }
  6. 创建项目

    js 复制代码
    // 1. 创建项目Loading初始化
    const spinner = ora(chalk.bgYellow('正在创建项目...')).start();
    // 2. 根据选择的语言选择对应模板进行复制,也可以把模板文件放到 github上,复制操作换成从仓库下载,这样模板更新不用更新脚手架,用户也能直接使用。
    const templateDir = path.resolve(
      __dirname,
      '..',
      LANGUAGES[language].templateDir
    );
    fs.mkdirSync(targetDir, { recursive: true });
    await copyTemplate(templateDir, targetDir);
    // 3. 更新配置文件
    spinner.text = '正在更新配置文件...';
    // 这个方法是针对选择的插件对模板文件内容进行修改
    await updateProjectFiles(targetDir, selectedPlugins, {
      name,
      description,
      author,
      language
    });
    // 这个方法是针对选择的插件修改下载依赖配置
    await updatePackageJson(targetDir, selectedPlugins, {
      name,
      description,
      author
    });
    // 4. loading结束
    spinner.succeed(chalk.green('项目创建成功!'));
    
    // 5. 输出使用说明
    console.log('\n使用说明:');
    console.log(chalk.cyan(`  cd ${name}`));
    console.log(chalk.cyan('  pnpm install'));
    console.log(chalk.cyan('  pnpm dev\n'));
  7. updateProjectFilesupdatePackageJson部分代码示例

    js 复制代码
    /** updateProjectFiles 根据用户选择处理tailwindcss插件
     ** 主要需要修改导入路径,和类型导入的处理
     ** 其他插件处理方式类似 
    */
    
    // 根据选择的语言决定入口文件的扩展名
    const mainExtension = projectInfo.language === 'typescript' ? '.ts' : '.js';
    const mainPath = path.join(root, `src/main${mainExtension}`);
    
    // 处理 CSS 配置文件
    const cssConfigExtension =
      projectInfo.language === 'typescript' ? '.ts' : '.js';
    const cssConfigPath = path.join(
      root,
      `viteConfig/css/index${cssConfigExtension}`
    );
    
    // 根据选择的插件修改配置
    if (fs.existsSync(cssConfigPath)) {
      let cssConfig = fs.readFileSync(cssConfigPath, 'utf-8');
    
      // 处理 tailwindcss 插件
      if (!selectedPlugins.includes('tailwind')) {
        // 如果没有安装,则删掉导入代码
        cssConfig = cssConfig.replace(/import tailwindcss.*;\n/, '');
        cssConfig = cssConfig.replace(/\s*tailwindcss\(\),?\n?/, '');
        mainContent = mainContent.replace(/import '\.\/tailwind.css';\n/, '');
    		// 删掉tailwind.css配置文件
        const tailwindPath = path.join(root, 'src/tailwind.css');
        if (fs.existsSync(tailwindPath)) {
          fs.unlinkSync(tailwindPath);
        }
    		// 删掉tailwind.config.js/ts配置文件
        const tailwindConfigExtension =
          projectInfo.language === 'typescript' ? '.ts' : '.js';
        const tailwindConfigPath = path.join(
          root,
          `tailwind.config${tailwindConfigExtension}`
        );
        if (fs.existsSync(tailwindConfigPath)) {
          fs.unlinkSync(tailwindConfigPath);
        }
      }
    	// 重写 vite css配置文件 和main文件
      fs.writeFileSync(cssConfigPath, cssConfig);
      fs.writeFileSync(mainPath, mainContent);
    }
    js 复制代码
    /** updatePackageJson 根据用户选择处理安装依赖配置 */
    const pkgPath = path.join(root, 'package.json');
    const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
    
    // 获取选中插件的依赖
    const devDependencies = {};
    for (const plugin of PLUGINS.filter((p) =>
      selectedPlugins.includes(p.value)
    )) {
      Object.assign(devDependencies, plugin.devDependencies);
    }
    
    // 更新 package.json
    pkg.devDependencies = {
      ...pkg.devDependencies,
      ...devDependencies
    };
    
    // 移除未选中插件的依赖
    PLUGINS.forEach((plugin) => {
      if (!selectedPlugins.includes(plugin.value)) {
        Object.keys(plugin.devDependencies).forEach((dep) => {
          delete pkg.dependencies[dep];
          delete pkg.devDependencies[dep];
        });
      }
    });
    
    fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));

五、双平台发布:npm 与 GitHub Packages

为了能更便捷的在双平台进行发布,在项目里面做了特殊配置。

1. 发布到npm

发布到npm配置可以参考我这篇文章:vite+ts发布 npm 包过程记录

2. 发布到github pacakges

下面是使用 npm 注册表发布到github pacakges的简单说明(👉官方教程地址):

  1. 创建自己的personal access token (classic)

    • 在 GitHub 任意页面的右上角,单击个人资料照片,然后单击 "设置/Settings"。

    • 在左侧边栏中,单击" 开发人员设置/Developer settings"。

    • 请在左侧边栏的" Personal access tokens"下,单击"Tokens(classic)" 。

    • 单击"生成新令牌/Generate new token"。

    • 选择"Generate new token(classic)"。

    • 在"备注"字段中,为令牌提供一个描述性名称。

    • 要为令牌提供到期时间,请选择"到期时间",然后选择默认选项或单击"自定义"以输入日期 。

    • 选择要授予此令牌的作用域。发布 packages 包必须要要选择write:packagesread:packagesdelete:packages

    • 单击"生成令牌"。

    • 要将新令牌复制到剪贴板,保存好。

    • 如果不再需要 personal access token,请删除。

  2. 使用 personal access token (classic)GitHub Packages 验证

    在脚手架项目根目录创建.npmrc文件,然后添加到.gitignore中。内容如下:

    shell 复制代码
    //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
    @star-devil:registry=https://npm.pkg.github.com

    ${GITHUB_TOKEN}就是上个步骤生成的personal access token,通过在命令行使用export语句传入,更安全。star-devil是我 github的昵称

  3. 创建publish-github.js文件:为了更方便的同时在 npm 和 github 上发布包

js 复制代码
import fs from 'fs';
import { execSync } from 'child_process';

// 检查环境变量
if (!process.env.GITHUB_TOKEN) {
  console.error('错误: GITHUB_TOKEN 环境变量未设置');
  console.error('请使用以下命令设置: export GITHUB_TOKEN=your_token');
  process.exit(1);
}

try {
  // 读取原始 package.json
  const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));

  // 备份原始 package.json
  fs.writeFileSync('./package.json.backup', JSON.stringify(pkg, null, 2));

  // 修改为 GitHub Packages 所需的格式
  pkg.name = '@star-devil/create-vvt';
  pkg.publishConfig = {
    registry: 'https://npm.pkg.github.com',
    access: 'public'
  };

  // 写入修改后的 package.json
  fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2));

  console.log('Modified package.json for GitHub Packages');

  // 执行发布
  execSync('npm publish', { stdio: 'inherit' });
  console.log('Published to GitHub Packages successfully!');
} catch (error) {
  console.error('Error:', error);
} finally {
  // 恢复原始 package.json
  if (fs.existsSync('./package.json.backup')) {
    fs.copyFileSync('./package.json.backup', './package.json');
    fs.unlinkSync('./package.json.backup');
    console.log('Restored original package.json');
  }
}
  1. package.json中增加发布脚本命令
json 复制代码
{
  "scripts": {
    "npm:publish": "npm publish", // 发布到 npm 使用这个命令
    "github:publish": "node publish-github.js" // 发布到 github 使用这个命令
	},
  "repository": {
    "type": "git",
    "url": "git+https://github.com/<github-name>/<package-name>.git"
  },
  "homepage": "https://github.com/<github-name>/<package-name>",
  "license": "MIT",
  "publishConfig": {
    "access": "public",
    "registry": "https://registry.npmjs.org"
  },
}

六、完整源码与使用指南

当然,还有很多功能可以扩充和修改,后面会继续深入研究。完整代码可以查看我的 github 仓库~谢谢~

相关推荐
夏之小星星12 分钟前
el-tree结合checkbox实现数据回显
前端·javascript·vue.js
琉璃℡初雪1 小时前
vue2/3 中使用 @vue-office/docx 在网页中预览(docx、excel、pdf)文件
vue.js·pdf·excel
Bl_a_ck1 小时前
【React】Craco 简介
开发语言·前端·react.js·typescript·前端框架
寧笙(Lycode)3 小时前
React系列——HOC高阶组件的封装与使用
前端·react.js·前端框架
拖孩3 小时前
【Nova UI】十五、打造组件库之滚动条组件(上):滚动条组件的起步与进阶
前端·javascript·css·vue.js·ui组件库
苹果电脑的鑫鑫3 小时前
element中表格文字剧中可以使用的属性
javascript·vue.js·elementui
Hejjon3 小时前
Vue2 elementUI 二次封装命令式表单弹框组件
前端·vue.js
Wannaer4 小时前
从 Vue3 回望 Vue2:响应式的内核革命
前端·javascript·vue.js
Bl_a_ck5 小时前
--openssl-legacy-provider is not allowed in NODE_OPTIONS 报错的处理方式
开发语言·前端·web安全·网络安全·前端框架·ssl
赵大仁5 小时前
React vs Vue:点击外部事件处理的对比与实现
javascript·vue.js·react.js