手撕create-vite源码,搭建属于自己的模板cli

589 阅读5分钟

前言:最近需要按照模板生成相应脚手架。单纯复制粘贴不仅low也不容易维护,像npm包create-vite通过命令行一键生成脚手架的形式就很优雅了,参考了create-vite的源码后,发现create-vite源码相对于其他命令行库有以下优点:1.使用typescript编写具体逻辑,可读性高,最后通过bundler打包为js代码运行,这是我认为编写现代cli最合适的编码模式。2.代码简单很短(不超过600行),几乎不需要怎么改代码就能拿过来自己用。

通过学习本文,你可以收获:1.如何编写一个现代化cli npm库。2.如何把create-vite变成自己专属的应用生成cli。

create-vite库的package.json详解

{
  "name": "create-vite",
  "version": "5.2.3",
  "type": "module",
  "license": "MIT",
  "author": "Evan You",
  "bin": {
    "create-vite": "index.js",
    "cva": "index.js"
  },
  "files": [
    "index.js",
    "template-*",
    "dist"
  ],
  "engines": {
    "node": "^18.0.0 || >=20.0.0"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/vitejs/vite.git",
    "directory": "packages/create-vite"
  },
  "bugs": {
    "url": "https://github.com/vitejs/vite/issues"
  },
  "homepage": "https://github.com/vitejs/vite/tree/main/packages/create-vite#readme",
  "funding": "https://github.com/vitejs/vite?sponsor=1",
  "devDependencies": {
    "@types/minimist": "^1.2.5",
    "@types/prompts": "^2.4.9",
    "cross-spawn": "^7.0.3",
    "kolorist": "^1.8.0",
    "minimist": "^1.2.8",
    "prompts": "^2.4.2",
    "unbuild": "^2.0.0"
  },
  "scripts": {
    "dev": "unbuild --stub",
    "build": "unbuild",
    "typecheck": "tsc --noEmit"
  }
}

一个cli库的package.json有两个字段需要注意,一个是bin字段,bin字段表明了一个库所有的可执行命令。另一个是files字段,表明这个包作为依赖下载后会暴露出来的文件。

bin字段的作用

用create-vite库举例,他的bin字段有两个key,create-vite和cva。当你执行npm install -g create-vite后,如果你是unix-like OS,系统会创建相应的symlink。如/usr/local/bin/create-vite和/usr/local/bin/cva。如果你是windows系统,则创建symlink时会创建cmd文件,如C:\Users{Username}\AppData\Roaming\npm\create-vite.cmd和C:\Users{Username}\AppData\Roaming\npm\cva.cmd

image-20240506235628640.png

创建symlink完毕后,就可以在任意路径的终端执行相应命令了.

如下图所示,当我输入cva时,实际上也就命中了create-vite的pacakge.json中bin.cva对应的symlink。

image-20240507000404752.png

如果安装在局部,则二进制文件cmd会放在node_modules/.bin目录下

image-20240507000901081.png

我们可以通过npx cva来在终端,运行局部安装的create-vite npm包暴露的cva命令

image-20240507001054611.png

我们再来看bin字段中对应的文件

"bin": {
    "create-vite": "index.js",
    "cva": "index.js"
  }

create-vite和cva都指向package.json同一级别目录下的index.js

内容如下

#!/usr/bin/env node

import './dist/index.mjs'

实际上每个bin字段中的js文件,都会以#!/usr/bin/env node作为开头,这个头部叫做shebang,告诉系统使用环境变量中node对应的编译器去执行shebang后面的代码。

用js实现一个最简单的cli库

如下图所示,创建一个文件夹名为test-cli,使用npm init命令创建相应的package.json,创建bin文件夹,新建index.js文件 image-20240507214114693.png

用下方提供的package.json和index.js覆盖刚刚创建的文件内容

package.json

{
  "name": "test-cli",
  "version": "1.0.0",
  "description": "A simple CLI package",
  "keywords": [
    "cli"
  ],
  "bin": {
    "hello": "./bin/index.js"
  },
  "author": "yzk",
  "license": "ISC"
}

index.js

#! /usr/bin/env node 
console.log("Hello cli");

当前test-cli目录下打开终端执行npm link,这个命令是将我们的test-cli库安装到全局环境,也可以使用npm install -g代替。在包安装过程中会根据bin字段的内容创建相应symlink。

执行npm list -g 可以看到test-cli库,在执行hello就会命中index.js在控制台输出相应命令。

image-20240507215106548.png

create-vite目录结构

vitejs使用monorepo的方式组织代码,create-vite正是这个monorepo中的一个package,我们单独看这个package的目录结构。其中index.js是bin字段中命令指向的文件,template-开头的是各个模板文件夹。其他就是ts配置了

image-20240507220156438.png

由于create-vite是一个纯命令行运行的库,实际上我们只需要关注bin字段中每个key对应的文件是怎么来的就可以。

前面看过index.js

内容是引入了打包后的mjs文件。实际上通过bundler,我们可以使用ts,可以使用现代es语法,不用管引入的依赖是commonJS模块还是ES模块。开发体验是最爽的。

create-vite选用的bundler是unbuild,是基于rollupv3的一个bundler,配置如下

image-20240507221125939.png

unbuild的各项配置都很接近vite。

入口文件src/index.ts 是bundler打包的入口,代码的实际逻辑都在这。大致就是读取用户输出文字和选择的模板,然后根据输出的内容去更改对应模板的某些字段,然后在用户指定的文件夹中生成应用。

使用的库如下:

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import spawn from 'cross-spawn'
import minimist from 'minimist'
import prompts from 'prompts'
import {
  blue,
  cyan,
  green,
  lightBlue,
  lightGreen,
  lightRed,
  magenta,
  red,
  reset,
  yellow,
} from 'kolorist'

使用了node:fs库,用于读写文件清空当前文件夹等文件操作,node:path库操作路径,minimist库用于把命令行中输入的参数转变成一个对象里的key value键值对。prompts库则提供了命令行中显示的一条条提示以及解析用户针对每条提示做出的回答,结合kolorist库提供的颜色就能提供彩色的命令行提示了。

image-20240507222739434.png

模板数组

type Framework = {
  name: string
  display: string
  color: ColorFunc
  variants: FrameworkVariant[]
}
type FrameworkVariant = {
  name: string
  display: string
  color: ColorFunc
  customCommand?: string
}

const FRAMEWORKS: Framework[] = [
  {
    name: 'vanilla',
    display: 'Vanilla',
    color: yellow,
    variants: [
      {
        name: 'vanilla-ts',
        display: 'TypeScript',
        color: blue,
      },
      {
        name: 'vanilla',
        display: 'JavaScript',
        color: yellow,
      },
    ],
  },
  {
    name: 'vue',
    display: 'Vue',
    color: green,
    variants: [
      {
        name: 'vue-ts',
        display: 'TypeScript',
        color: blue,
      },
      {
        name: 'vue',
        display: 'JavaScript',
        color: yellow,
      },
      {
        name: 'custom-create-vue',
        display: 'Customize with create-vue ↗',
        color: green,
        customCommand: 'npm create vue@latest TARGET_DIR',
      },
      {
        name: 'custom-nuxt',
        display: 'Nuxt ↗',
        color: lightGreen,
        customCommand: 'npm exec nuxi init TARGET_DIR',
      },
    ],
  }]

使用prompts库编写命令行的提示信息并解析用户输入/选择:

let result: prompts.Answers<
    'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'
  >

  prompts.override({
    overwrite: argv.overwrite,
  })

  try {
    result = await prompts(
      [
        {
          type: argTargetDir ? null : 'text',
          name: 'projectName',
          message: reset('Project name:'),
          initial: defaultTargetDir,
          onState: (state) => {
            targetDir = formatTargetDir(state.value) || defaultTargetDir
          },
        },
        {
          type: () =>
            !fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'select',
          name: 'overwrite',
          message: () =>
            (targetDir === '.'
              ? 'Current directory'
              : `Target directory "${targetDir}"`) +
            ` is not empty. Please choose how to proceed:`,
          initial: 0,
          choices: [
            {
              title: 'Remove existing files and continue',
              value: 'yes',
            },
            {
              title: 'Cancel operation',
              value: 'no',
            },
            {
              title: 'Ignore files and continue',
              value: 'ignore',
            },
          ],
        },
        {
          type: (_, { overwrite }: { overwrite?: string }) => {
            if (overwrite === 'no') {
              throw new Error(red('✖') + ' Operation cancelled')
            }
            return null
          },
          name: 'overwriteChecker',
        },
        {
          type: () => (isValidPackageName(getProjectName()) ? null : 'text'),
          name: 'packageName',
          message: reset('Package name:'),
          initial: () => toValidPackageName(getProjectName()),
          validate: (dir) =>
            isValidPackageName(dir) || 'Invalid package.json name',
        },
        {
          type:
            argTemplate && TEMPLATES.includes(argTemplate) ? null : 'select',
          name: 'framework',
          message:
            typeof argTemplate === 'string' && !TEMPLATES.includes(argTemplate)
              ? reset(
                  `"${argTemplate}" isn't a valid template. Please choose from below: `,
                )
              : reset('Select a framework:'),
          initial: 0,
          choices: FRAMEWORKS.map((framework) => {
            const frameworkColor = framework.color
            return {
              title: frameworkColor(framework.display || framework.name),
              value: framework,
            }
          }),
        },
        {
          type: (framework: Framework) =>
            framework && framework.variants ? 'select' : null,
          name: 'variant',
          message: reset('Select a variant:'),
          choices: (framework: Framework) =>
            framework.variants.map((variant) => {
              const variantColor = variant.color
              return {
                title: variantColor(variant.display || variant.name),
                value: variant.name,
              }
            }),
        },
      ],
      {
        onCancel: () => {
          throw new Error(red('✖') + ' Operation cancelled')
        },
      },
    )
  } catch (cancelled: any) {
    console.log(cancelled.message)
    return
  }

  // user choice associated with prompts
  const { framework, overwrite, packageName, variant } = result

修改具体文件内容是利用正则表达式进行替换实现的。

function setupReactSwc(root: string, isTs: boolean) {
  editFile(path.resolve(root, 'package.json'), (content) => {
    return content.replace(
      /"@vitejs\/plugin-react": ".+?"/,
      `"@vitejs/plugin-react-swc": "^3.5.0"`,
    )
  })
  editFile(
    path.resolve(root, `vite.config.${isTs ? 'ts' : 'js'}`),
    (content) => {
      return content.replace('@vitejs/plugin-react', '@vitejs/plugin-react-swc')
    },
  )
}

function editFile(file: string, callback: (content: string) => string) {
  const content = fs.readFileSync(file, 'utf-8')
  fs.writeFileSync(file, callback(content), 'utf-8')
}

create-vite源码还是非常简单的,而且如果要需要实现命令行生成模板的需求,按照Framework的类型去增加相应模板就能实现了。几乎不需要改动。

参考资料: 1.docs.npmjs.com/cli/v10/con… 2.www.knowledgehut.com/blog/web-de…