likes
comments
collection
share

手把手学习Vite都在用的CAC源码,从调试到原理!

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

相识 CAC

那是一个阳光明媚的上午 , 我熟练的在工位上 cv(摸鱼). 突然老大出现在我身后. 我心想: 坏了 是不是最近摸鱼摸的太猖狂了 , 背发现了... 我难道要走 n 了... 也不知道今年寒冬过没过去... 在内心一顿脑补后. 我来到了老大的办公室 , 他跟我说 : 阿阳啊 , 咱们组前端开发效率有点低下啊 , 你想想办法开发一个脚手架, 把那种模版化的工作都想办法集成在脚手架里. 我突然放松了, 因为这种东西之前自己做过很多, 也有自己的脚手架, 不行就 clone 下来改一改, 我就开心的回到工位上开始技术选型(继续摸鱼).

又过了几天,摸鱼摸的有点无聊了. 心想, 学学习吧.就学学 vite 看看 vite 源码 看看他是咋实现的. 在看的过程中我发现 vite 就是用 cac 来构建 cli

看看 vite 是怎么用 cac 的

手把手学习Vite都在用的CAC源码,从调试到原理!

手把手学习Vite都在用的CAC源码,从调试到原理!

当时我还在疑惑 为啥 vite 不用 commander ? 这 cac 是啥意思. 咋起了个这么随意的名字. 随后我就去看了 cac 的 repo.

啥是 cac

手把手学习Vite都在用的CAC源码,从调试到原理!

看了一下 cac 的介绍, 是我格局小了. 手把手学习Vite都在用的CAC源码,从调试到原理! 再看了看他 2k 多的 star. 我的格局反而打开了. 手把手学习Vite都在用的CAC源码,从调试到原理!

学他!

cac VS commander

手把手学习Vite都在用的CAC源码,从调试到原理!

看介绍 cac 最少有四个优点

  1. 超级轻量 . 体积小 (6)
  2. 简单的 api 设计 (666666)
  3. 功能强大 支持复杂的参数 (3(一般 6))
  4. 支持类型 (666666)

手把手学习Vite都在用的CAC源码,从调试到原理!

那么让我们一起学习 cac 的使用和源码吧!

看看 vite 是怎么使用 cac 的

// 声明了一个叫 vite 的 cli
const cli = cac('vite')

cli
  // 如果 用户只敲了 vite
  .command('[root]', 'start dev server') // default command
  // 或者 vite serve
  .alias('serve') // the command is called 'serve' in Vite's API
  // 或者 vite dev
  .alias('dev') // alias to align with the script name
  // 如果命令里有 --host 如 vite --host 8888
  .option('--host [host]', `[string] specify hostname`)
  // ... 以此类推
  .option('--port <port>', `[number] specify port`)
  .option('--https', `[boolean] use TLS + HTTP/2`)
  .option('--open [path]', `[boolean | string] open browser on startup`)
  .option('--cors', `[boolean] enable CORS`)
  .option('--strictPort', `[boolean] exit if specified port is already in use`)
  .option(
    '--force',
    `[boolean] force the optimizer to ignore the cache and re-bundle`
  )
  // 如果命中上面声明的命令 就执行这里注册的回调函数
  .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
    // do somethings ...
  })

// ... n多命令

// 注册 help
cli.help()
// 注册版本
cli.version(VERSION)

// cli 启动!
cli.parse()

根据上面的例子, cac 是一个 麻雀虽小 五脏俱全的构建 cli 的库! 让我们一起分析他的源码吧!

源码分析

第一步 clone

git clone https://github.com/cacjs/cac.git

第二步 分析工程化

分析根目录时 , 我们发现他使用了 prettier 和 .editorconfig 来做代码风格统一 没使用 eslint.

我们打开项目中的 package.json

{
  "name": "cac",
  "version": "6.0.0",
  "description": "Simple yet powerful framework for building command-line apps.",
  "repository": {
    // ...
  },
  // 项目入口
  "main": "index-compat.js",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    // ...
  },
  "files": [
    // ...
  ],
  "scripts": {
    "test": "jest",
    "test:cov": "jest --coverage",
    "build:deno": "node -r sucrase/register scripts/build-deno.ts",
    "build:node": "rollup -c",
    "build": "yarn build:deno && yarn build:node",
    "toc": "markdown-toc -i README.md",
    "prepublishOnly": "npm run build && cp mod.js mod.mjs",
    "docs:api": "typedoc --out api-doc --readme none --exclude \"**/__test__/**\" --theme minimal"
  },
  "author": "egoist <0x142857@gmail.com>",
  "license": "MIT",
  "devDependencies": {
    // ...
  },
  "engines": {
    "node": ">=8"
  },
  "release": {
    "branch": "master"
  },
  "config": {
    // ...
  },
  "lint-staged": {
    // ...
  },
  "husky": {
    // ...
  }
}

我们可以发现 他的 pkg 写的还是非常不错的. 作为一个 runtime 为 node 的库甚至还兼容了 deno . 但是有一点有点瑕疵. 他没有约束他的包管理工具 ... 之后我们发现. 项目目录中有 yarn.lock . 果断执行 yarn 安装依赖

手把手学习Vite都在用的CAC源码,从调试到原理!

在安装依赖的过程中, 我们可以找一下这个项目的入口. 如果是 esm 和 cjs 还不一样. ems 需要构建打包后产物. 而 cjs 直接就是 index-compat. 我们来分析下他是如何打包的.

如何打包

执行 yarn build , 发现他会分别给 node runtime 和 deno runtime 打包

看到命令 build:node , 他是使用 rollup 作为打包工具, 我们找到 rollup.config.js

发现他会吐出三份 bundle

手把手学习Vite都在用的CAC源码,从调试到原理!

调试 cac

创建调试代码

我们在 examples 目录下创建一个debug-cac.js文件 调试 cac 源码.

require('ts-node/register')
const cli = require('../src/index').cac()

cli
  .command('dev', 'start dev server')
  .option('--port <port>', `[number] specify port`)
  .action(async (root, options) => {
    // do somethings ...
    console.log(`vite start ~`)
  })

// 注册 help
cli.help()
// 注册版本
cli.version(`6.6.6`)
// cli 启动!
cli.parse()

创建 vscode 调试文件

点击工具栏里的调试, 之后 create a launch.json . 选择 node .

手把手学习Vite都在用的CAC源码,从调试到原理!

将刚刚创建的 launch.json 内容替换成

{
  "configurations": [
    {
      "name": "Launch Program",
      "program": "${workspaceFolder}/examples/debug-cac.js",
      "request": "launch",
      "skipFiles": ["<node_internals>/**"],
      "type": "node"
    }
  ]
}

在我们的 调试代理中打上断点

手把手学习Vite都在用的CAC源码,从调试到原理!

点击开始调试 手把手学习Vite都在用的CAC源码,从调试到原理!

源码分析

创建 cac 实例

const cli = require('../src/index').cac()

初始化了 cac 在 CAC 这个类上初始化了一堆属性

手把手学习Vite都在用的CAC源码,从调试到原理!

而且让 cac 继承了 EventEmitter , 拥有发布订阅的能力.

注册

cli
  .command('dev', 'start dev server')
  .option('--port <port>', `[number] specify port`)
  .action(async (root, options) => {
    // do somethings ...
    console.log(`vite start ~`)
  })

从这一行 我们发现 . command .option 可以链式调用. 在看源码时特殊注意一下.

我们来分析一下 command 的实现

手把手学习Vite都在用的CAC源码,从调试到原理!

可以看到 new 了一个 Command 对象 , 之后收集起来. 我们看下 Command 类的实现.

手把手学习Vite都在用的CAC源码,从调试到原理!

手把手学习Vite都在用的CAC源码,从调试到原理!

通过 return command , 提供链式调用的能力. 而且可以推断出 , 在我们的 demo 中 , 链式调用的 option 是调用的 command 的 option 而不是 cac 的 option.

command 的 option 方法就是吧一个 Option 对象收集起来.

手把手学习Vite都在用的CAC源码,从调试到原理!

而且我们在 command 这个文件里也发现了 GlobalCommand 的实现. 他就是 command 的一个子类.

手把手学习Vite都在用的CAC源码,从调试到原理!

我们再回到 cac 这个文件中, 看看 cac 自身的 option 方法 version 方法等注册相关的方法

手把手学习Vite都在用的CAC源码,从调试到原理!

都是通过在初始化 cac 时创建的 GlobalCommand 保存的.

所以我们可以分析出 cac command option 的依赖关系.

消费 option 的是 command , 而消费 command 的是 cac.

parse

当我们收集好注册相关的信息后, 我们就可以开始解析解析用户输入的命令了.

我们来到 cac 的 parse 方法 , 这个方法接收两个参数 , 一个是 argv , 一个是 options. 返回解析后的 argv

parse(
    // 默认使用 process.argv
    argv = processArgs,
    {
      /** Whether to run the action for matched command */
      /** 是否需要运行 action */
      run = true,
    } = {}
  ): ParsedArgv {
    // 记录原始参数
    this.rawArgs = argv

    // 如果脚手架没命名 就用默认值 `cli`
    if (!this.name) {
      this.name = argv[1] ? getFileName(argv[1]) : 'cli'
    }

    let shouldParse = true

    // Search sub-commands
    // 处理子命令
    for (const command of this.commands) {
      // 解析当前的子命令
      const parsed = this.mri(argv.slice(2), command)
      // 用户输入的子命令名称
      const commandName = parsed.args[0]
      // 如果匹配到子命令
      if (command.isMatched(commandName)) {
        shouldParse = false
        const parsedInfo = {
          ...parsed,
          args: parsed.args.slice(1),
        }
        // 保存解析结果
        this.setParsedInfo(parsedInfo, command, commandName)
        // 触发命令
        this.emit(`command:${commandName}`, command)
      }
    }
    // 如果没有命令被匹配到
    // 检查默认命令
    if (shouldParse) {
      // Search the default command
      for (const command of this.commands) {
        if (command.name === '') {
          // 重复
          shouldParse = false
          const parsed = this.mri(argv.slice(2), command)
          this.setParsedInfo(parsed, command)
          this.emit(`command:!`, command)
        }
      }
    }

    // 如果到这里还没匹配到命令的话,就只对参数进行解析
    if (shouldParse) {
      const parsed = this.mri(argv.slice(2))
      this.setParsedInfo(parsed)
    }

    // 判断是不是 help 之类
    if (this.options.help && this.showHelpOnExit) {
      this.outputHelp()
      run = false
      this.unsetMatchedCommand()
    }

    if (
      this.options.version &&
      this.showVersionOnExit &&
      this.matchedCommandName == null
    ) {
      this.outputVersion()
      run = false
      this.unsetMatchedCommand()
    }

    const parsedArgv = { args: this.args, options: this.options }

    // 如果 run 还是 true 的话,意味着不是 -h 也不是 --version
    // 而且用户也没有通过参数阻止本次运行
    // 执行所有匹配到的命令的 action
    if (run) {
      this.runMatchedCommand()
    }

    // 如果遇到未知的命令 也会触发一个事件去通知
    // 类似 404 的路由
    if (!this.matchedCommand && this.args[0]) {
      this.emit('command:*')
    }

    // 返回解析后的 argv
    return parsedArgv
  }

总结

cac 是一个 麻雀虽小,五脏俱全的构建命令行工具库. 他的实现也是非常简单,

个人认为优点在于:

  1. 使用了很多的 oop 技巧 , 在架构层面分层分的很好.
  2. 工程化做的不错.
  3. 代码量不多, 适合初学者学习.