从零阅读 cac 源码初涉命令行工具背后的实现原理 | 催学社 @ 源码共读 @cac

918 阅读9分钟

cac

前言

参与源码共读,旨在从源码中学习别人的开发思想和了解工具背后的工作原理,由于是第一次尝试阅读,所以想着从零开始,了解一个包的方方面面,为了更好的深入了解底层原理,所以在阅读的同时带着催学社的相关问题,边调试边记录下来了笔记,如果有不对或者可以优化的地方,欢迎各位提出哈,我也会坚持学习,输出学习成果,共同进步!

1、目录分析(附带查阅过程带上的一些注释)

cac
├─ examples  // 用例
│  ├─ basic-usage.js
│  ├─ command-examples.js
│  ├─ command-options.js
│  ├─ dot-nested-options.js
│  ├─ help.js
│  ├─ ignore-default-value.js
│  ├─ negated-option.js
│  ├─ sub-command.js
│  └─ variadic-arguments.js
├─ scripts  // 使用 deno 构建
│  └─ build-deno.ts
├─ src
│  ├─ __test__  // 单测
│  │  ├─ __snapshots__  // 快照测试,比对前后跑单测生成的快照是否一致,不一致则代表代码有所改动
│  │  │  └─ index.test.ts.snap
│  │  └─ index.test.ts
│  ├─ CAC.ts
│  ├─ Command.ts
│  ├─ deno.ts
│  ├─ index.ts
│  ├─ node.ts
│  ├─ Option.ts
│  └─ utils.ts
├─ circle.yml  // CircleCI 的配置文件
├─ index-compat.js  // 处理 commonjs 向后兼容
├─ jest.config.js  // 测试框架 jest 的配置文件
├─ LICENSE  // 开源许可证说明
├─ mod.js  // 可以理解成轻量级的 requirejs,是一套的前端模块加载解决方案。与传统的模块加载相比,modJS 会根据产品实际使用场景,自动选择一种相应的方案,使最终的实现非常轻量简洁。
├─ mod.ts
├─ mod_test.ts
├─ package-lock.json  // 记录当前 npm 包项目的依赖树,包括其具体来源和版本号
├─ package.json  // 描述及管理整个项目
├─ README.md  // 自述文件
├─ rollup.config.js  // rollup 构建配置文件
├─ tsconfig.json  // ts 配置文件
└─ yarn.lock  // 同上 pkg-lock,包管理器换成 yarn

2、了解 .editorconfig 作用

  • 描述:EitorConfig 的默认配置文件,它是用来协同团队开发人员之间的代码的风格及样式规范化的一个工具
  • 作用:用于统一不同 IDE 编码风格配置的一种配置文件

3、.gitattributes 作用

  • 描述:允许给 git 动作所操作的文件或者路径指定属性

  • 作用:

    • 兼容 win 和 mac/linux 下 eol,即行尾不一致问题,前者为 CRLF,后者为 LF,其导致的问题有以下两个
    • 规范代码,确保文件行尾序列一致性,否则在某些 Eslint 规则下会显示报错
    • 避免 git diff 出现莫名的差异,即你修改了某个文件后又撤销,但是因为保存了文件的原因,因系统差异,行尾序列已经发生改变,导致 git 显示文件存在差异

4、持续集成是如何实现的

  • 描述:cac 用的是 CircleCI,它是一个持续集成/持续部署的服务

  • 配置(对应 cac circle.yml):

    • version - 版本号

    • jobs - 一系列的步骤单元

    • build - job 名称

    • docker - 指定运行环境

    • image - 指定 docker 镜像

      • branches - 查阅 docs 已弃用,原用于配置 job 在哪些分支运行
    • ignore - 忽略分支

      • steps - job 细化下的步骤
    • checkout - 把项目源码检出至 job 的工作区 working-directory

    • restore_cache - 恢复缓存

      • key - 基于 key 值恢复数据
    • run - 步骤类型,代表要执行的命令

      • name - 在 CircleCI UI 显示的步骤标题
      • command - 通过 shell 运行的命令
    • save_cache - 缓存数据

      • key - 基于 key 值缓存数据
      • paths - 缓存至该路径下

5、分析单元测试环境是如何搭建的

  • ts-jest:

    • 描述:一个支持 source map 的 jest 转换器
    • 作用:可以直接用 jest 去测试用 ts 编写的项目,支持 ts 所有功能包括类型检查
  • 搭建基于 ts 的 jest 测试环境(不使用 ts-jest 下)

    • 使用 babel-jest 支持并进行相应的项目配置
    • 如果要支持 esm 之类的还需要额外的配置
    • 相较于 ts-jest 可以检查类型错误,用 babel 支持的方式只能对其进行编译,但是没办法检查类型,只能额外起一个 terminal 运行 tsc 辅助检查
  • jest.config.js

    • 描述:测试框架 jest 的配置文件

    • 配置:

      • testEnvironment - 测试运行的环境
      • transform - 配置规则转换,如 jest 原本支持 cjs,但是目前前端项目,一般都是使用 esm 规范进行模块化开发,所以可以借助其对命中规则的文件交由如 ts-jest 处理
      • testRegex - 匹配需要测试的文件
      • testPathIgnorePatterns - 忽略不需测试的文件路径
      • moduleFileExtensions - 模块使用的文件扩展名,若不指定模块的文件扩展名,则 jest 将从数组中从左到右进行查找

6、分析 package.json

  • 配置(选部分重点了解):

    • main - 应用程序的入口点
    • module - 指向入口为基于 esm 模块规范而使用 es5 语法书写的模块,可以启用 tree shaking 机制结合如 uglifyjs-webpack-plugin 对最终产物 assets 进行优化
    • types - ts 的入口文件
    • exports - 提供为不同环境(node/browser)或 js 风格(cjs/esm)公开包模块,同时限制对其内部部分的访问,可以理解为配置外部引用该包的规则,即条件引用,内部配置的为该包真实和全部的导出,优先级会高于 main、file、module、browser 等字段
    • files - 指定发布 pkg 时包含哪些文件
    • scripts - 定义脚本命令
    • release - 发布的信息,如分支
    • engines - 指定项目的 node 版本
    • config - 配置内容,可以设置包脚本中使用的配置参数
    • husky - 通过配置 hooks 可以在 git 操作,如 commit 前做一些提交规范检查等的操作
  • 发布(必要的字段):

    • name - 包名
    • version - 版本号

7、分析 README.md

  • 结构:

    • logo
    • 官方主页
    • 介绍
    • 安装
    • 快速开始
    • 功能列表
    • 截图
    • todoList
    • 不足之处
    • FAQ
    • Change Log(更新日志)
  • 生成

    • github 新建仓库时可以选择
    • 借助 gh-md-toc

8、分析构建

  • rollup

    • rollup.config.js

      • @rollup/plugin-node-resolve - 用于加载第三方包时定位模块,如引入时候只写包名,实际会定位到默认如 index.js
    • rollup-plugin-esbuild - es6 编译与压缩器

    • rollup-plugin-dts - 自动生成构建后的类型文件

9、分析 tsconfig.json

  • 配置

    • compilerOptions - 编译选项

      • target - 指定 ecma 目标版本
      • declaration - 生成相应的 *.d.ts
      • declarationDir - 指定生成声明文件存放目录
      • esModuleInterop - 支持在 cjs 模式下使用 esm 的默认导入
      • pretty - 代码美化
      • moduleResolution - 模块解析策略,ts 默认用 node 的解析策略,即相对的方式导入
      • lib - 指定项目运行时使用的库
      • allowSyntheticDefaultImports - // 允许从没有设置默认导出的模块中默认导入
      • stripInternal - 不输出 JSDoc 注解
      • noImplicitAny - 不允许隐式 any 类型
      • noImplicitReturns - 检查函数没有隐式的返回值
      • noImplicitThis - 不允许隐式的 this
      • noUnusedLocals - 是否检查检查未使用的局部变量
      • noUnusedParameters - 是否检查未使用的参数
      • noFallthroughCasesInSwitch - 是否检查 switch 语句包含正确的 break
      • strictNullChecks - 是否严格的检查空值
      • strictFunctionTypes - 是否严格检查函数的类型
      • strictPropertyInitialization - 是否严格检查属性是否初始化
      • alwaysStrict - 编译后的文件是否开启严格模式
      • module - 指定要使用模块化的规范
      • outDir - 指定编译后文件的存放位置
    • include - 指定哪些文件需要被编译

    • exclude - 指定哪些文件不需要被编译

10、Option

  • 描述:负责处理 cli 实例或者 command 实例的配置项
  • 作用:解析存储用户在命令行中输入的配置项,然后在 cli 或 command 实例内部进行使用,如设置其 name,type 之类,然后在 action 中可以从参数中获取到然后做相应的回调处理

11、Command

  • 描述:声明 cli 命令,可以给命令设置独立的配置项和其匹配后的执行动作
  • 作用:匹配用户在命令行中输入的命令名称,然后执行我们给该命令预置的操作

12、action

  • 描述:解析命令行输入后找到能匹配内部预设的命令后所执行的操作
  • 作用:匹配到内部命令后,找到之前设置的与该命令挂钩的回调操作然后执行

13、实现连续调用的 api 形式

  • 描述:通过 cli 或 command 调用内部方法后,可以通过链式调用继续进行其他操作

  • 实现:在其内部方法执行完后,返回当前 cli 或 command 实例,如下 option 方法,执行完内部相应操作后,把 this 返回出去,供后续链式调用使用

      option(rawName: string, description: string, config?: OptionConfig) {
        const option = new Option(rawName, description, config)
        this.options.push(option)
        return this
      }
    

14、Brackets 的使用方式

  • 描述:配置内部接收参数的形式

  • 作用:决定命令/配置等是否必须赋值,赋值的类型是什么

  • 区别:Brackets 分为 [] 和 <> 两种

    • [] - 可选,接收 string | number | true
    • <> - 必须,接收 string | number

15、Negated Options

  • 描述:设置默认的 option,no- 后面为配置名,当命令行输入中有对应的配置项及值,则经过 mri 解析后的 parsed 有值,将 parsed 中对应 key 的值赋给 option,如果外部有传入配置项而没有赋值,或者连配置项都没传,那么 mri 内部会给 options 中的 negated 设置默认值 true

  • 作用:默认配置,外部有传入,则赋值,没有则设置默认值

  • 实现:

    • 解析 Option 中的 rawName 是否包含 no-
    • 把 no- 替换成空,真正配置项中不包含这个 rawName,而是取 no- 后面的为配置名,然后把内部的 negated 置为 true
    • 在 CAC 的 mri 中,通过 getMriOptions 处理 alias 和 boolean 类型的配置,然后借助 mri 第三方包根据之前处理得到的 mriOptions 和命令行输入进行解析,然后根据解析后的 parsed 对匹配到的 key 进行赋值

16、分析下面用例的执行流程

  • 用例:(通过 jest runner debugger 一遍了解流程)

    test("test case", () => {
      const cli = cac();
    
      cli
        .command("build""desc")
        .option("--env <env>""Set envs")
        .example("--env.API_SECRET xxx")
        .action((options) => {
          console.log(options);
        });
    
      cli.help();
    
      cli.parse();
    });
    
  • 流程:

    • 初始化 CAC 实例

    • 初始化 Command 实例,将 globalCommand 挂载到 subCommand 上并存到 CAC 的 commands 后返回 subCommand 实例供后续链式调用

    • 给 subCommand 初始化 Option 实例并存到 options

    • subCommand 保存 example 到 examples

    • subCommand 保存 action 到 commandAction

    • CAC 实例设置帮助信息的 Option

    • 调用 parse 解析

      • 设置 CAC name,初始化 CAC 实例时候有传,则赋值,没有则取命令行参数通过 getFilename 函数值默认值

      • 通过 mri 解析,获取 commandName,然后通过 subCommand 的 isMatched 函数匹配

      • 中间经过各种匹配 commandName 的流程,包括默认的 command 等

        • 如 match help,则会执行相应的动作,如 outputHelp
        • 如 match 到 build,在 runMatchedCommand 时候会组装 actionArgs 然后执行前面配置的 action 函数
      • 最后将解析后的 parsedArgv 返回出去

学习 demo

cac 源码共读