likes
comments
collection
share

精读了 500 行的 create-vite,你也可以开发自己的 cli 工具

作者站长头像
站长
· 阅读数 11

vite 源码库下载、依赖安装

如果使用 Windows Git-Bash 安装也许你会遇到以下问题:

node-pre-gyp info it worked if it ends with ok
│ node-pre-gyp info using node-pre-gyp@1.0.10
│ node-pre-gyp info using node@16.13.0 | win32 | x64
│ node-pre-gyp info check checked for "C:\xxx\vite\node_modules\.pnpm\bcrypt@5.1.0\node_modules\bcrypt\lib\binding\napi-v3\bcrypt_lib.node" (not found)
│ node-pre-gyp http GET https://github.com/kelektiv/node.bcrypt.js/releases/download/v5.1.0/bcrypt_lib-v5.1.0-napi-v3-win32-x64-unknown.tar.gz
│ node-pre-gyp ERR! install request to https://github.com/kelektiv/node.bcrypt.js/releases/download/v5.1.0/bcrypt_lib-v5.1.0-napi-v3-win32-x64-unknown.tar.gz failed, reason: read ECONNRESET 
│ node-pre-gyp WARN Pre-built binaries not installable for bcrypt@5.1.0 and node@16.13.0 (node-v93 ABI, unknown) (falling back to source compile with node-gyp) 
│ node-pre-gyp WARN Hit error request to https://github.com/kelektiv/node.bcrypt.js/releases/download/v5.1.0/bcrypt_lib-v5.1.0-napi-v3-win32-x64-unknown.tar.gz failed, reason: read ECONNRESET 
│ node-pre-gyp ERR! build error 
│ node-pre-gyp ERR! stack Error: Failed to execute 'node-gyp.cmd clean' (Error: spawn node-gyp.cmd ENOENT)
│ node-pre-gyp ERR! stack     at ChildProcess.<anonymous> (C:\DevCode\Github源码\vite\node_modules\.pnpm\@mapbox+node-pre-gyp@1.0.10\node_modules\@mapbox\node-pre-gyp\lib\util\compile.js:83:23)
│ node-pre-gyp ERR! stack     at ChildProcess.emit (node:events:390:28)
│ node-pre-gyp ERR! stack     at Process.ChildProcess._handle.onexit (node:internal/child_process:288:12)
│ node-pre-gyp ERR! stack     at onErrorNT (node:internal/child_process:477:16)
│ node-pre-gyp ERR! stack     at processTicksAndRejections (node:internal/process/task_queues:83:21)
│ node-pre-gyp ERR! System Windows_NT 10.0.19045
│ node-pre-gyp ERR! command

这是 bcrypt 依赖平台兼容性导致的,具体原因请看 Github,我的解决办法也很简单,换 Powershell 开启管理员模式安装,work it !

精读了 500 行的 create-vite,你也可以开发自己的 cli 工具

查看 packages/create-vite 源码库 README

根据 README 文档,我们可以看到 cva (即 create-vite)的使用方法如下:

# with NPM
npm create vite@latest

# with Yarn
yarn create vite

# with PNPM
pnpm create vite

指定模板、和生成的目标文件夹

npm create vite@latest my-vue-app --template vue

# npm 7+, extra double-dash is needed:
npm create vite@latest my-vue-app -- --template vue

# yarn
yarn create vite my-vue-app --template vue

# pnpm
pnpm create vite my-vue-app --template vue

查看 package.json

{
  "bin": {
    "create-vite": "index.js",
    "cva": "index.js"
  },
  // others...
  "engines": {
    "node": "^14.18.0 || >=16.0.0"
  },
  // others...
  "devDependencies": {
    "@types/minimist": "^1.2.2",
    "@types/prompts": "^2.4.4",
    "cross-spawn": "^7.0.3", // 跨平台的 node.js spawn/spawnSync 方案库
    "kolorist": "^1.8.0", // 轻量的终端着色方案库
    "minimist": "^1.2.8", // 轻量、强大的终端参数解析库
    "prompts": "^2.4.2" // 轻量、强大、开发友好的终端交互库,多用于 cli 构建
  },
}

查看目录,可以很容易得知,入口文件是 index.ts,运行时方便调试 typescript 的源码可以使用 esno;打开 vscodeJavaScript Debug Terminal 面板,提前打好断点,输入以下命令

# 需先进入到 vite/packages/create-vite 文件目录
npx esno src/index.ts

# 或这样,esno 的别名 "tsx"
npx tsx src/index.ts

调试示例

我们在 index.ts 文件 init 方法是核心方法里,打下断点,输入上一步的命令,就会发现执行后会在对应位置停住

精读了 500 行的 create-vite,你也可以开发自己的 cli 工具

纵览主文件大纲

精读了 500 行的 create-vite,你也可以开发自己的 cli 工具

前面是变量,后面是那部分从命名上看,都是各种处理文件的方法、以及 cva 的交互主入口 init 方法;

接下来逐一深入源码查看一番

  • init 方法主入口
// 为了方便理解,适当有删减
async function init () {
  // 从 terminal 输入得到生成的目标文件夹
  const argTargetDir = formatTargetDir(argv._[0])
  // 从 terminal 输入得到需要生成的模板类型
  // 支持 vanilla/vue/react/preact/lit/svelte 极其他变体
  const argTemplate = argv.template || argv.t

  /**
   * projectName 是你需要生成的目标文件夹名
   * overwrite 判断是否覆盖已存在的同名生成目标件文件夹
   * framework 选择生成的库模板,如 vanila/vue/react 等
   * variant 选择需要生成的其他变体变体,如 vue-ts/react-ts 等
   */
  let result: prompts.Answers<'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'>

  try {
    result = await prompts(
      [
        // prompts 的流程步骤省略,具体看源码
        // 核心就是通过与用户交互得到 'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant' 这几个值
      ],
      {
        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
  const root = path.join(cwd, targetDir)

  if (overwrite) {
    // 选择了覆盖同名生成目标文件夹,则清空删除原来文件目录
    emptyDir(root)
  } else if (!fs.existsSync(root)) {
    // 反之创建目录
    fs.mkdirSync(root, { recursive: true })
  }

  // 用户交互选择生成的模板
  let template: string = variant || framework?.name || argTemplate

  // node.js 包管理工具信息(即npm/yarn/pnpm)
  // 可以从 process.env.npm_config_user_agent 获取
  const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
  const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
  const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')

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

  const write = (file: string, content?: string) => {
    // 文件写入操作
    const targetPath = path.join(root, renameFiles[file] ?? file)
    if (content) {
      fs.writeFileSync(targetPath, content)
    } else {
      copy(path.join(templateDir, file), targetPath)
    }
  }

  const files = fs.readdirSync(templateDir)
  for (const file of files.filter((f) => f !== 'package.json')) {
    // 从模板目录,逐个遍历文件,然后写入目标目录
    write(file)
  }
  // more...
}

主流程就是如上注释解析那样,下面深入 cva d的文件操作系列方法

  • formatTargetDir
// 对目标目录名进行初步格式化
function formatTargetDir(targetDir: string | undefined) {
  // 去掉首尾空格、末尾的 '/' 字符
  return targetDir?.trim().replace(/\/+$/g, '')
}
  • copy
function copy(src: string, dest: string) {
  const stat = fs.statSync(src)
  if (stat.isDirectory()) {
    // 文件夹使用 copyDir
    copyDir(src, dest)
  } else {
    // 文件使用 fs.copyFileSync
    fs.copyFileSync(src, dest)
  }
}
  • copyDir
// 从下面源码看到,copyDir 操作也是去遍历目录,进行文件的 copy
function copyDir(srcDir: string, destDir: string) {
  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)
  }
}
  • emptyDir
function emptyDir(dir: string) {
  if (!fs.existsSync(dir)) {
    return
  }

  for (const file of fs.readdirSync(dir)) {
    // 跳过 .git 文件夹
    if (file === '.git') {
      continue
    }
    // 清空删除文件夹
    fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
  }
}
  • isEmpty
function isEmpty(path: string) {
  // 判断文件夹是否为空,.git 目录特殊处理
  const files = fs.readdirSync(path)
  return files.length === 0 || (files.length === 1 && files[0] === '.git')
}
  • editFile
// 这个方法是修改文件内容,重新回写到文件内;用于 --template=react-swc 的特殊处理
// 实际上 --template=react-swc 从 react-ts 模板变体而来,只是修改某些 package.json 依赖
function editFile(file: string, callback: (content: string) => string) {
  const content = fs.readFileSync(file, 'utf-8')
  fs.writeFileSync(file, callback(content), 'utf-8')
}
  • pkgFromUserAgent
// 这个方法用来解析获取,当前使用的包管理工具和版本号,如 NPM6/NPM7,Yarn1/Yarn2 等
// 实际上就是解析上文提到的 process.env.npm_config_user_agent 环境变量
function pkgFromUserAgent(userAgent: string | undefined) {
  if (!userAgent) return undefined
  const pkgSpec = userAgent.split(' ')[0]
  const pkgSpecArr = pkgSpec.split('/')
  
  return {
    name: pkgSpecArr[0],
    version: pkgSpecArr[1],
  }
}

精读了 500 行的 create-vite,你也可以开发自己的 cli 工具

  • isValidPackageName
// 判断生成目标文件名是否有效
function isValidPackageName(projectName: string) {
  return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(
    projectName,
  )
}

精读了 500 行的 create-vite,你也可以开发自己的 cli 工具

  • toValidPackageName
// 对目标目录名进行有效化转换
function toValidPackageName(projectName: string) {
  return projectName
    .trim()
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/^[._]/, '')
    .replace(/[^a-z\d\-~]+/g, '-')
}

调试 unit-test

如果想调试单元测试,可以打开 JavaScript Debug Terminal,输入以下命令:打开 JavaScript Debug Terminal,输入以下命令:

./node_modules/.bin/vitest run packages/create-vite/

总结

通过对 cva 源码的调试阅读,发现原来自己实现一个简易的 cli 工具也不是很难,借助社区强大的轻量工具(如:prompts,kolorist...),剩下就是对 node.js 文件系统的各种操作了,这些需要扎实的 node.js-fs 基本功,文件操作由自己实现可以轻量可控制,但需要多注意跨平台的兼容性就是了!