快速跳转
下一篇:CAC 源码解析
前言
本系列博客主要用于记录和分享我自己学习 Node.js 库 CAC 的笔记,CAC 是一个开源工具,用于帮助用户构建基于命令行的应用程序,初次关注到 CAC 是从 vite 源码 里面看到的,vite 的 cli 就基于 CAC 实现。它基于 Typescript 和 OOP 思想是我认为值得学习的地方。
小提示
在本次源码学习中,我会重点关注设计思想和架构方面的内容,对具体的功能(如参数和选项的解析等)不会展开详细的描述。但可以保证的是,在理解了这一切之后,再去了解具体的实现,就会变得非常轻松。
除了源码本身之外,工程化所使用的技术和配置,也会有详细或者简单介绍,所以本系列一共分为上下两章,本章主要介绍工程化的内容,源码解析会放在下一章节。
目录结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
.
├── LICENSE
├── README.md
├── circle.yml #circle.yml: CircleCI 配置文件
├── examples #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
├── .editorconfig #.editorconfig: EditorConfig 配置文件
├── .gitattributes
├── .gitignore
├── .prettierrc
├── index-compat.js #index-compat.js: 处理兼容性问题
├── jest.config.js #jest.config.js: jest 配置文件
├── mod.js #mod.js: 兼容 deno
├── mod.ts #mod.js: 兼容 deno
├── mod_test.ts
├── package.json #package.json: package.json
├── rollup.config.js #rollup.config.js: rollup 打包配置
├── scripts #scripts: js 脚本目录
│ └── build-deno.ts
├── src #src: 源码目录
│ ├── CAC.ts
│ ├── Command.ts
│ ├── Option.ts
│ ├── __test__ #__test__: 测试目录
│ │ ├── __snapshots__ #__snapshots__:测试快照目录
│ │ │ └── index.test.ts.snap
│ │ └── index.test.ts
│ ├── deno.ts
│ ├── index.ts
│ ├── node.ts
│ └── utils.ts
├── tsconfig.json #tsconfig.json: ts 配置
└── yarn.lock
|
工程化
scripts 目录详解
在阅读源码之前,不妨先看一看 cac 的工程化,这个项目的工程化运用的技术算是比较完善的,所以有不少值得学习的地方,我们可以先从 script 目录入手。
scripts 目录用于存放 js 脚本,在本项目中仅有一个,用于兼容和构建 deno 工程,构建后会将文件输出在deno
目录,deno/index.ts
会被 mod.ts
指定为 deno 工程入口文件:
1
2
|
/* mod.ts */
export * from './deno/index.ts'
|
build-deno.ts
脚本主要做了两件事,读取src
目录下的源码文件,借助bable
的 API 完成对于 deno 工程的转换:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
/* scripts/build-deno.ts */
// 入口函数
async function main() {
// 读取 src 目录下除了测试代码之外的所有源码文件
const files = await globby(['**/*.ts', '!**/__test__/**'], { cwd: 'src' })
await Promise.all(
// 遍历读取
files.map(async (file) => {
if (file === 'node.ts') return
// 读取代码内容,格式化为 urf-8
const content = await fs.readFile(path.join('src', file), 'utf8')
// 借助插件,完成代码转换
const transformed = await transformAsync(content, {
// 注意,node2deno 是自定义插件
plugins: [tsSyntax, node2deno],
})
// 输出到根下的 deno 目录
await fs.outputFile(path.join('deno', file), transformed.code, 'utf8')
})
)
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
|
值得注意的是,这里使用了一个自定义 bable 插件来完成代码转换工作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
function node2deno(options: { types: typeof Types }): PluginObj {
return {
name: 'node2deno',
visitor: {
// ImportDeclaration 函数主要用于处理文件引用
ImportDeclaration(path) {
const source = path.node.source
if (source.value.startsWith('.')) {
if (source.value.endsWith('/node')) {
source.value = source.value.replace('node', 'deno')
}
source.value += '.ts'
} else if (source.value === 'events') {
source.value = `https://deno.land/std@0.114.0/node/events.ts`
} else if (source.value === 'mri') {
source.value = `https://cdn.skypack.dev/mri`
}
},
},
}
}
|
来看一看这个插件做了什么,ImportDeclaration
函数是 bable 提供用于处理 import 语句的,path.node.source.value
这个变量所获取到的是每个 import 语句的值,例如:
1
2
3
|
import { EventEmitter } from 'events' // path.node.source.value = ‘events’
import mri from 'mri' // path.node.source.value = ‘mri’
import CAC from './CAC' // path.node.source.value = ‘./CAC’
|
理解了这一点之后,就很容易明白这个函数在处理什么
- 当文件中引用了
events
的时候,改变引用路径为 https://deno.land/std@0.114.0/node/events.ts
- 当文件中引用了
mri
的时候,改变引用路径为 https://cdn.skypack.dev/mri
- 当文件中引用了其他文件的时候,后缀加上 .ts
- 当文件中引用了 node.ts 的时候,将 node 改为 deno
circle.yml 配置简介
circle.yml
用于配置 CircleCI
与 github 继承的自动化测试和构建任务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
version: 2
jobs:
build:
docker:
- image: circleci/node:12
branches:
ignore:
- gh-pages # list of branches to ignore
- /release\/.*/ # or ignore regexes
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "yarn.lock" }}
- run:
name: install dependences
command: yarn
- save_cache:
key: dependency-cache-{{ checksum "yarn.lock" }}
paths:
- ./node_modules
- run:
name: test
command: yarn test:cov
- run:
name: upload coverage
command: bash <(curl -s https://codecov.io/bash)
- run:
name: Release
command: yarn semantic-release
|
其实这个配置文件写得已经比较清晰了,可以仅从语义上判断整个构建过程。
docker
指定用户构建构建的 docker 环境
branches - ignore
配置需要忽略的分支或者目录
steps-checkout
checkout 指令用于签出代码到配置好的路径
steps-restore_cache
通过 key 按顺序恢复对应的缓存,checksum 会对给定的文件名生成哈希,用于更新缓存。
run-install dependences
安装依赖
save_cache
保存缓存到对应的 key
run-test
执行命令 jest --coverage
,–coverage 参数会在输出中生成测试覆盖率报告。
run-upload coverage
Linux 中 <
的意思将后面文件作为前面命令的输入,所以这一步在做的是从 codecov.io 获取脚本,上传测试报告
run-Release
发布版本,具体可以参考 semantic-release
这个库
rollup.config.js 打包配置简介
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
import nodeResolvePlugin from '@rollup/plugin-node-resolve'
import esbuildPlugin from 'rollup-plugin-esbuild'
import dtsPlugin from 'rollup-plugin-dts'
// 辅助函数
function createConfig({ dts, esm } = {}) {
// file 是输出结果
let file = 'dist/index.js'
// 根据参数判断输出结果的后缀
if (dts) {
file = file.replace('.js', '.d.ts')
}
if (esm) {
file = file.replace('.js', '.mjs')
}
return {
// 入口文件
input: 'src/index.ts',
// 输出文件配置
output: {
// 指定输出格式
format: dts || esm ? 'esm' : 'cjs',
file,
exports: 'named',
},
plugins: [
nodeResolvePlugin({
mainFields: dts ? ['types', 'typings'] : ['module', 'main'],
extensions: dts ? ['.d.ts', '.ts'] : ['.js', '.json', '.mjs'],
customResolveOptions: {
moduleDirectories: dts
? ['node_modules/@types', 'node_modules']
: ['node_modules'],
},
}),
!dts && require('@rollup/plugin-commonjs')(),
!dts &&
esbuildPlugin({
target: 'es2017',
}),
dts && dtsPlugin(),
].filter(Boolean),
}
}
// 输出配置
export default [
createConfig(),
createConfig({ dts: true }),
createConfig({ esm: true }),
]
|
打包配置也是非常简单,通过一个辅助函数createConfig
,输出三种不同的结果
- dts(ts 声明文件)
- cjs(cjs 格式)
- mjs(esm 格式)
plugins 中有个比较不常见的写法,借助Array.filter
,过滤出需要的插件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
plugins: [
nodeResolvePlugin({
mainFields: dts ? ['types', 'typings'] : ['module', 'main'],
extensions: dts ? ['.d.ts', '.ts'] : ['.js', '.json', '.mjs'],
customResolveOptions: {
moduleDirectories: dts
? ['node_modules/@types', 'node_modules']
: ['node_modules'],
},
}),
!dts && require('@rollup/plugin-commonjs')(),
!dts &&
esbuildPlugin({
target: 'es2017',
}),
dts && dtsPlugin(),
].filter(Boolean), // 返回为 true 的结果
|
jest 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
module.exports = {
// 用于测试的环境,这里是 node 环境,如果需要测试 web 环境,可以配置为 jsdom
testEnvironment: 'node',
// 如果需要测试的不是原生 js 代码,则需要指定解析器
transform: {
'^.+\\.tsx?$': 'ts-jest'
},
//匹配测试文件
testRegex: '(/__test__/.*|(\\.|/)(test|spec))\\.tsx?$',
//配置忽略的目录
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/types/'],
//指定文件类型
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node']
}
|
moduleFileExtensions
jest 官方建议将最常用的格式配置到最左侧,本项目几乎是纯 ts 编码的,所以将 ts 配置在第一位,是一个可以学习的最佳实践
index-compat.js
1
2
3
4
5
6
7
8
9
10
11
|
const { cac, CAC, Command } = require('./dist/index')
// 兼容性处理
module.exports = cac
Object.assign(module.exports, {
default: cac,
cac,
CAC,
Command,
})
|
注意,index-conpat.js
这个文件被作为package.json
中的 exports
字段的导出方式之一:
1
2
3
4
5
6
7
8
9
10
|
/** package.json **/
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./index-compat.js"
},
"./package.json": "./package.json",
"./": "./"
},
|
exports 字段可以根据不用的导入方式返回不同的结果,所以一下这两种写法都是可以的:
1
2
3
4
5
|
// CommonJS 写法,从 ./index-compat.js 中导出
const cac = require('cac')
// ESModule 写法,从 ./dist/index.mjs 中导出
import cac from 'cac'
|
(本篇完)
下一篇:CAC 源码解析