脚手架

45 阅读3分钟

1. 实现原理

把每个 template 单独发包 然后在通过命令来下载不同的包

  • 通过 registry.npmjs.org/@vue/cli 可以拿到 npm 包的信息
  • 比如 dist-tags.latest 可以拿到最新版本号
  • 选择一个模版后,从 npm 包下载模版,这里就直接用 npm install 下载就行

2. 用到的包介绍

2.1 fs-extra

读取文件目录的操作


const fse = require('fs-extra')

//copy文件
fse.copySync('./src', './aaa/bbb/') 

2.2 glob

通配符匹配某个目录下的文件

const { glob } = require('glob');

async function main() {
    const files = await glob('**', {
        cwd: process.cwd(),
        ignore: 'node_modules/**'
    })
    console.log(files);
}

main();

匹配当前目录下除了 node_modules 下的所有文件和目录。

2.3 inquirer

在命令行提问的一个插件 结果成对象形式

import { input, select, password } from '@inquirer/prompts'

const name = await input({ message: '请输入你的名字' })

const job = await select({
  message: '选择你的职业',
  choices: [
    {
      name: '教师',
      value: '教师',
      description: '11111'
    },
    {
      name: '医生',
      value: '医生',
      description: '22222'
    }
  ]
})

const pass = await password({ message: '请输入密码' })

console.log({
  name,
  job,
  pass
})

2.4 semver

npm 包的版本号的规范

  • 1.2.3 分别是 major 主版本号、minor 次版本号、patch 修订版本号
  • major:当你做的不兼容的 api 修改时,改这个版本号
  • minor:当你做了向下兼容的功能新增时,改这个版本号
  • patch:当你做了向下兼容的问题修复时,改这个版本号
const semver = require('semver')

if (semver.valid('1.2.3#')) {
  console.log('版本号有效')
} else {
  console.log('版本号无效')
}

if (semver.gt('2.0.0', '1.0.8')) {
  console.log('有新版本可以安装')
}

if (semver.lte(process.version, '22.0.0')) {
  console.log(`node 版本 ${process.version} 小于 22`)
}

image.png

2.5 npminstall

从 npm 仓库下载模版到本地的临时目录,这时候就可以用 npminstall 这个包来下载:

const npminstall = require('npminstall');

(async () => {
  await npminstall({
    pkgs: [
        { name: 'chalk', version: 'latest' },
    ],
    root: process.cwd() + '/aaa',    
    registry: 'https://registry.npmjs.org',
  });
})().catch(err => {
  console.error(err);
});

安装 chalk 最新版本到 aaa 目录下。

2.6 cli-spinner、ora

网络请求、文件写入等耗时逻辑需要展示一个 loading, cli-spinner 就是 cli 里的 loading

const Spinner = require('cli-spinner').Spinner

console.log('111')
console.log('222')

const spinner = new Spinner(`安装中.. %s`)
spinner.start()

setTimeout(() => {
  spinner.stop(true)
}, 3000)

3 .操作

3.1 项目结构目录



├── package.json
├── packages
│   ├── cli
│   │   ├── CHANGELOG.md
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── create
│   │   ├── CHANGELOG.md
│   │   ├── package.json
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   ├── template-react
│   │   ├── CHANGELOG.md
│   │   ├── package.json
│   │   ├── questions.json
│   │   └── template
│   ├── template-vue
│   │   ├── CHANGELOG.md
│   │   ├── package.json
│   │   ├── questions.json
│   │   └── template
│   └── utils
│       ├── CHANGELOG.md
│       ├── package.json
│       ├── src
│       │   ├── index.ts
│       │   ├── NpmPackage.ts
│       │   └── versionUtils.ts
│       └── tsconfig.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml

3.2 获取版本信息和npm的包

utils提供一些操作的npm 的方法

import fs from 'node:fs'
import fse from 'fs-extra'
// @ts-ignore
import npminstall from 'npminstall'
import { getLatestVersion, getNpmRegistry } from './versionUtils.js'
import path from 'node:path'

export interface NpmPackageOptions {
  name: string
  targetPath: string
}

class NpmPackage {
  name: string // 包名
  version: string = '' // 版本,初始为空
  targetPath: string // 目标路径
  storePath: string // 存储路径

  constructor(options: NpmPackageOptions) {
    this.targetPath = options.targetPath
    this.name = options.name
    // 设置存储路径为目标路径下的node_modules
    this.storePath = path.resolve(options.targetPath, 'node_modules')
  }

  //  准备工作:创建目录并获取最新版本号
  async prepare() {
    if (!fs.existsSync(this.targetPath)) {
      fse.mkdirpSync(this.targetPath) // 递归创建目录
    }
    const version = await getLatestVersion(this.name)
    this.version = version
  }

  async install() {
    await this.prepare()

    return npminstall({
      pkgs: [
        {
          name: this.name,
          version: this.version
        }
      ],
      registry: getNpmRegistry(), // 获取npm镜像地址
      root: this.targetPath // 安装到目标路径
    })
  }

  get npmFilePath() {
    // 格式:.store/@scope+package@version/node_modules/@scope/package
    // 正确格式:@scope+package@version
    if (!this.version) {
      throw new Error('版本号未初始化,请先调用prepare()')
    }
    const storeDir = `${this.name.replace('/', '+')}@${this.version}`

    return path.resolve(this.storePath, `.store/${storeDir}/node_modules/${this.name}`)
  }

  async exists() {
    await this.prepare()

    return fs.existsSync(this.npmFilePath)
  }

  async getPackageJSON() {
    if (await this.exists()) {
      return fse.readJsonSync(path.resolve(this.npmFilePath, 'package.json'))
    }
    return null
  }

  async getLatestVersion() {
    return getLatestVersion(this.name)
  }

  async update() {
    const latestVersion = await this.getLatestVersion()
    return npminstall({
      root: this.targetPath,
      registry: getNpmRegistry(),
      pkgs: [
        {
          name: this.name,
          version: latestVersion
        }
      ]
    })
  }
}



export default NpmPackage



function getNpmRegistry() {
  return 'https://registry.npmmirror.com'
}

async function getNpmInfo(packageName: string) {
  const register = getNpmRegistry()
  const url = urlJoin(register, packageName)
  try {
    const response = await axios.get(url)

    if (response.status === 200) {
      return response.data
    }
  } catch (e) {
    return Promise.reject(e)
  }
}

async function getLatestVersion(packageName: string) {
  const data = await getNpmInfo(packageName)
  return data['dist-tags'].latest
}

async function getVersions(packageName: string) {
  const data = await getNpmInfo(packageName)
  return Object.keys(data.versions)
}

export { getNpmRegistry, getNpmInfo, getLatestVersion, getVersions }



  • 从npm下载或更新选定的模板包 将模板文件复制到目标目录

3.2.1 获取包中的信息

image.png

3.2.2 获取npm包

npminstall可以并行下载和安装包

// 安装单个包
npminstall({
      pkgs: [
        {
          name: this.name,
          version: this.version
        }
      ],
      registry: getNpmRegistry(), // 获取npm镜像地址
      root: this.targetPath // 安装到目标路径
    })

image.png

//获取包的路径
  get npmFilePath() {
    // 格式:.store/@scope+package@version/node_modules/@scope/package
    // 正确格式:@scope+package@version
    if (!this.version) {
      throw new Error('版本号未初始化,请先调用prepare()')
    }
    const storeDir = `${this.name.replace('/', '+')}@${this.version}`

    return path.resolve(this.storePath, `.store/${storeDir}/node_modules/${this.name}`)
  }


image.png

  1. this.storePath:基础路径(通常是 项目路径/node_modules
  2. .store/npminstall 的专用存储目录
  3. ${storeDir}:格式化的包标识
  4. node_modules/${this.name}:包的实际安装路径

说明

const storeDir = `${this.name.replace('/', '+')}@${this.version}`
// @vue/cli -> @vue+cli@5.0.8
  • 文件系统不支持 / 在目录名中
  • + 是安全的替代字符(不会在合法包名中出现)
特性标准 npm/yarnnpminstall|
存储位置扁平化 node_modules.store 目录
磁盘使用可能重复占用空间通过硬链接节省空间
依赖解析提升依赖隔离依赖

3.3 生成create 命令

import { select, input, confirm } from '@inquirer/prompts'
import os from 'node:os'
import { NpmPackage } from '@starry-sky-studio/my-utils'
import path from 'node:path'
import ora, { Ora } from 'ora'
import fse from 'fs-extra'
import { glob } from 'glob'
import ejs from 'ejs'


async function create() {
 
  let spinner: Ora | null = null

  try {
    const projectTemplate = await select({
      message: '请选择项目模版',
      choices: [
        {
          name: 'react 项目',
          value: '@starry-sky-studio/template-react-ts'
        },
        {
          name: 'vue 项目',
          value: '@starry-sky-studio/template-vue-ts'
        }
      ]
    })

    let projectName = ''
    while (!projectName) {
      projectName = await input({ message: '请输入项目名' })
    }

    const targetPath = path.join(process.cwd(), projectName)

    const pkg = new NpmPackage({
      name: projectTemplate,
      targetPath: path.join(os.homedir(), '.ivy-cli-template')
    })

    if (fse.existsSync(targetPath)) {
      const empty = await confirm({ message: '该目录不为空,是否清空' })
      if (empty) {
        fse.emptyDirSync(targetPath)
      } else {
        console.log('🚫 操作已取消')
        process.exit(0)
      }
    }

    if (!(await pkg.exists())) {
      spinner = ora('下载模版中...').start()
      await pkg.install()
      spinner.succeed('模版下载完成')
    } else {
      spinner = ora('更新模版中...').start()
      await pkg.update()
      spinner.succeed('模版更新完成')
    }

    spinner = ora('创建项目中...').start()

    const templatePath = path.join(pkg.npmFilePath, 'template')
    fse.copySync(templatePath, targetPath)

    spinner.succeed('项目文件复制完成')
    
    
    // { projectName: 'name', eslint: true }
    const renderData: Record<string, any> = { projectName }
    const deleteFiles: string[] = []

    const questionConfigPath = path.join(pkg.npmFilePath, 'questions.json')

    if (fse.existsSync(questionConfigPath)) {
      const config = fse.readJSONSync(questionConfigPath)

      for (let key in config) {
        const res = await confirm({ message: `是否启用 ${key}?` })
        renderData[key] = res

        if (!res) {
          deleteFiles.push(...config[key].files)
        }
      }
    }

    spinner = ora('应用项目配置...').start()

    const files = await glob('**', {
      cwd: targetPath,
      nodir: true,
      ignore: 'node_modules/**'
    })

    for (let i = 0; i < files.length; i++) {
      const filePath = path.join(targetPath, files[i])
      const renderResult = await ejs.renderFile(filePath, renderData)
      fse.writeFileSync(filePath, renderResult)
    }

    deleteFiles.forEach((item) => {
      fse.removeSync(path.join(targetPath, item))
    })

    spinner.succeed('项目配置完成')

    console.log(`\n🎉 项目创建成功:${targetPath}`)
   
  } catch (error) {
    // 处理所有错误
    if (spinner) {
      spinner.fail('操作失败')
    }
    process.exit(1)
  }
}

create()

export default create

  1. 使用 inquirer 进行用户交互(选择模板、输入项目名)
  2. 检查目标目录是否存在,处理非空目录
  3. 使用封装的 NpmPackage 工具下载/更新项目模板
  4. 将模板复制到目标目录
  5. 使用 ejs 模板引擎渲染文件内容
  6. 根据用户选择添加/删除特定功能(如 ESLint
  7. 提供友好的进度提示和成功信息
  • 检查模板包中是否存在 questions.json 配置文件 读取这个文件内容

    {
    "eslint": {
       "files": ["eslint.config.js"]
     }
    }
    
    //renderData { projectName: 'name', eslint: true } 
    
    
  • 然后用glob来检查所有的项目文件

    const files = await glob('**', {
      cwd: targetPath,
      nodir: true,
      ignore: 'node_modules/**'
    })

    for (let i = 0; i < files.length; i++) {
      const filePath = path.join(targetPath, files[i])
      const renderResult = await ejs.renderFile(filePath, renderData)
      fse.writeFileSync(filePath, renderResult)
    }

使用ejs 将文件内容重写 <%= projectName %> 使用这个来替换


//在模版项目中的package.json文件
{
  "name": "<%= projectName %>",
  <% if (eslint) { %>
    "lint": "eslint .",
  <% } %>
  },
  "devDependencies": {
    <% if (eslint) { %>
    "eslint": "^9.13.0",
    "eslint-plugin-react-hooks": "^5.0.0",
    "eslint-plugin-react-refresh": "^0.4.14",
    <% } %>
  }
}



//渲染文件重新写后的: /Users/rwr/repo/my-cli/packages/create/name/package.json  
{
  "name": "name",
  "description": "name 项目模板",
  "scripts": {
    "lint": "eslint .",
  },
 
  "devDependencies": {
    "eslint": "^9.30.1",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.20",
  }
}

  • 在渲染过程中,如果用户没有选择启用eslint,那么就会删除eslint相关的配置文件(包括eslint.config.js

3.4 cli入口

#!/usr/bin/env node
import create from '@starry-sky-studio/my-create'
import { Command } from 'commander'
import fse from 'fs-extra'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

// 获取当前模块的路径
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const pkgJson = fse.readJSONSync(path.join(__dirname, '../package.json'))

// 检测是否通过 pnpm create 调用
const isCreateCommand =
  process.argv[1] &&
  (process.argv[1].includes('create-starry-sky-studio') ||
    process.argv[1].includes('create-starry-sky-studio'))

const program = new Command()

program
  .name('starry-sky-studio-my-cil')
  .description('Starry Sky Studio 脚手架工具')
  .version(pkgJson.version)
  .configureOutput({
    // 重写输出格式,添加更多信息
    outputError: (str, write) => write(`❌ 错误: ${str}`)
  })


// 处理 pnpm create 调用
if (isCreateCommand) {
  create().catch((err) => {
    console.error('项目创建失败:', err.message)
    process.exit(1)
  })
} else {
  // 正常命令模式
  program
    .command('create')
    .description('创建一个新项目')
    .action(async () => {
      try {
        await create()
      } catch (err: any) {
        console.error('❌ 项目创建失败:', err.message)
        process.exit(1)
      }
    })

  // 添加帮助命令
  program
    .command('help')
    .description('显示帮助信息')
    .action(() => program.help())

  // 添加退出处理
  program.hook('preAction', () => {
    setupExitHandlers()
  })

  // 解析命令行参数
  program.parse(process.argv)
}


  • 主要是一些脚手架的命令
starry-sky-studio-my-cil create   创建一个新项目
starry-sky-studio-my-cil help     显示帮助信息
starry-sky-studio-my-cil --version 显示版本信息

image.png

//package.json
 "bin": {
    "starry-sky-studio-my-cil": "./dist/index.js",
    "create-starry-sky-studio": "./dist/index.js"
  },

当用户全局安装你的 npm 包时,npm 会将这些命令链接到全局环境,使得用户可以直接在终端中运行这些命令。

🤔:

image.png

  • 主要是 Not Found - GET registry.npmjs.org/create-star… 原因 把create当成了包的名字
  • 这个可以当创建包的时候名字可以改成 create-名字 比如create-vite

# 安装
pnpm add -g @starry-sky-studio/my-cli

# 创建项目
starry-sky-studio-my-cil create

image.png

4 管理版本信息changeset

image.png

image.png

npx changeset add// 将改变的文件添加到changeset会自动更新CHANGELOG.md文件 也会让你选择升级的版本号
npx changeset publish //可以自动发包

5.总结

graph TD
    A[开始创建项目] --> B[设置中断处理器]
    B --> C[选择项目模板]
    C --> D[输入项目名称]
    D --> E[检查目标目录]
    E --> F[下载/更新模板]
    F --> G[复制模板文件]
    G --> H[配置项目选项]
    H --> I[渲染模板文件]
    I --> J[完成创建]
    J --> K[显示成功信息]
    
    Z[用户按下 Ctrl+C] --> Y[显示取消信息]
    Y --> X[退出程序]
    

代码仓库 - github.com/liv-rong/no…