脚手架开发之 Lerna 源码分析
为什么要做源码分析
- 自我成长,提升编码能力和技术深度的需要
- 为我所用,应用到实际开发,实际产生效益
- 学习借鉴,站在巨人肩膀上,登高望远
为什么要分析 Lerna 源码
2w + star
的明星项目Lerna
是脚手架,对我们开发脚手架有借鉴价值Lerna
项目中蕴含大量的最佳实践,值得深入研究和学习
学习目标
Lerna
源码结构和执行流程分析import-local
源码深度精读
学习收获
- 如何将源码分析的收获写进简历
- 学习明星项目的架构设计
- 获得脚手架执行流程的一种实现思路
- 脚手架调试本地源码的另一种方法
node.js
加载node_modules
模块的流程- 各种文件操作算法和最佳实践
知识点: 本地库作为依赖的方法
file:路径
lerna
上线时会自动替换成线上的地址
"dependencies": { "@lerna/global-options": "file:../global-options", }
yargs 使用
安装
npm i yargs -S
最简单的 yargs
脚手架
// \bin\index.js
// 引入 yargs 构造函数
const yargs = require('yargs/yargs')
const { hideBin } = require('yargs/helpers')
// 解析参数
const arg = hideBin(process.argv)
// 调用 yargs 构造函数 传入一个参数进行解析 然后调用 argv 完成初始化过程
yargs(arg)
.strict() // 开启严格模式 命令错误时 会出现 Unknown argument: xxx 的提示
.argv // 可以解析参数
现在就可以在命令行运行了。
test-cli --help
test-cli --version
test-cli --h
输出如下
usage
打印在命令行最前面
yargs(arg)
.usage("Usage:test-cli [command] <options>") // 打印在命令行最前面
.strict()
.argv
demandCommand
设置最少需要输入的 command
的数量
yargs(arg)
.demandCommand(1, "A command is required. Pass --help to see all available commands and options.")
.argv
当你不输入 command
的时候,就会报错
alias
别名
yargs(arg)
.alias("h", "help")
.alias("v", "version")
.argv
这样输入 h
和输入 help
的结果是一样的,v
和 version
的结果是一样的
wrap
cli
的宽度
yargs(arg)
.wrap(100)
.argv
可以看到 cli
在命令行中的宽度发生了变化
yargs.terminalWidth()
这个方法会返回命令行界面的宽度,这样cli就会全屏展示了
const cli = yargs(arg)
cli
.wrap(cli.terminalWidth())
.argv
epilogue
结尾的内容
cli
.epilogue("this is footer")
.argv
可以看到 cli
的最后输出了 this is footer
可以使用 dedent
这个库去去除缩进,使代码格式保持一致
cli
.epilogue(dedent(`
When a command fails, all logs are written to lerna-debug.log in the current working directory.
For more information, find our manual at https://github.com/lerna/lerna
`))
.argv
options
增加一个全局的选项,对所有的 command
都有效
cli
.options({
debug: {
type: 'boolean',
describe: "bootstrap debug moe",
alias: "d"
}
})
.argv
option
options
可以定义多个选项,而 option
只可以定义一个,作用是一样的。
可以添加 hidden:true
,来隐藏 option
,供内部人员开发时使用。
.option("registry", {
type: 'string',
describe: "define global registry",
alias: "r",
// hidden:true
})
group
给 option
分组, options
是默认的组
cli
.group(['debug'], 'Deb Options:')
.group(['registry'], 'Publish Options:')
.argv
command
定义一个 command
,接收四个参数
- 第一个:
command
的格式,name [port]
,name
是命令的名称,port
表示一个自定义的option
- 第二个:对
command
的描述 - 第三个:
builder
函数,在执行命令之前做的一些事情 - 第四个:
handler
函数,执行command
的行为
注意:定义脚手架的时候,任何地方的别名都不可以出现重复,不然会覆盖。
cli
.command(
"init [name]",
"do init a project",
(yargs) => {
yargs.option("name", {
type: "string",
describe: 'name of a project',
alias: "n"
})
},
(argv) => {
console.log('🚀🚀~ argv:', argv);
}
)
.argv
所有内容和别名都会出现在 argv
这个参数中。
另外,command
也支持对象的写法
cli
.command({
command: "list",
aliases: ["ls", "la", "ll"],
describe: "List local packages",
builder: (yargs) => { },
handler: (yargs) => { }
})
.argv
recommendCommands
当你输入一个错误的 command
的时候,会自动的帮助你去寻找一个最接近的 command
来提示你
cli
.recommendCommands()
.argv
当我们输入 test-cli lis
,输出 Did you mean list?
fail
当 command
不存在时的错误处理
当一个 command
不存在时,默认会输出 --help
的内容 ,如果我们不想看到,那么就可以在 fail
这个方法里进行定制
cli
.fail((err, msg) => {
console.log(err);
})
.argv
这样就只有错误信息,而不会输出出其他东西了
parse
会把定义的内容注入到当前的项目中
// 定义一个内容
const context = {
testVersion: pkg.version,
};
// 不用在这里解析参数了
const cli = yargs()
cli
.parse(argv, context)
我们再次打印出 args
, 发现之前定义的 testVersion
已经出现在 args
中了
Lerna 源码结构
D:\lerna-main
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── commands
├── CONTRIBUTING.md
├── core
├── child-process
├── cli
├── command
├── conventional-commits
├── filter-options
├── global-options
├── lerna
├── otplease
├── package
├── package-graph
├── project
├── prompt
└── validation-error
├── doc
├── FAQ.md
├── helpers
├── integration
├── jest.config.js
├── jest.integration.js
├── lerna.json
├── LICENSE
├── node_modules
├── package-lock.json
├── package.json
├── README.md
├── scripts
├── setup-integration-timeout.js
├── setup-unit-test-timeout.js
├── utils
└── __fixtures__
入口文件
可以在根目录的 package.json
文件中发现脚手架的入口
"bin": {
"lerna": "core/lerna/cli.js"
},
Lerna 初始化分析
根据入口文件,发现 Lerna
初始化的时候执行了 main
方法。
// core\lerna\cli.js
// 引入 import-local 这个库
const importLocal = require("import-local");
// import-local 的逻辑后面单独分析
if (importLocal(__filename)) {
require("npmlog").info("cli", "using local version of lerna");
} else {
// 引入当前目录下的index.js模块 这个模块返回了一个 main 方法 并把 process.argv.slice(2) 作为参数执行
require(".")(process.argv.slice(2)); // 相当于 main(process.argv.slice(2))
}
index.js
简略代码,这个模块只输出了一个 main
方法
// core\lerna\index.js
module.exports = main;
function main(argv) {
}
index.js
完整代码
// core\lerna\index.js
"use strict";
//
const cli = require("@lerna/cli");
// 引入若干指令
const addCmd = require("@lerna/add/command");
const bootstrapCmd = require("@lerna/bootstrap/command");
const changedCmd = require("@lerna/changed/command");
const cleanCmd = require("@lerna/clean/command");
const createCmd = require("@lerna/create/command");
const diffCmd = require("@lerna/diff/command");
const execCmd = require("@lerna/exec/command");
const importCmd = require("@lerna/import/command");
const infoCmd = require("@lerna/info/command");
const initCmd = require("@lerna/init/command");
const linkCmd = require("@lerna/link/command");
const listCmd = require("@lerna/list/command");
const publishCmd = require("@lerna/publish/command");
const runCmd = require("@lerna/run/command");
const versionCmd = require("@lerna/version/command");
// 引入 package.json 模块
const pkg = require("./package.json");
// 输出 main 方法
module.exports = main;
// main 方法
function main(argv) {
// 定义一个对象,里面保存一个 lernaVersion 属性,值是 package.json 中的 version 属性的值
const context = {
lernaVersion: pkg.version,
};
return cli()
.command(addCmd) // 添加 addCmd 命令
.command(bootstrapCmd) // 添加命令
.command(changedCmd) // 添加命令
.command(cleanCmd) // 添加命令
.command(createCmd) // 添加命令
.command(diffCmd) // 添加命令
.command(execCmd) // 添加命令
.command(importCmd) // 添加命令
.command(infoCmd) // 添加命令
.command(initCmd) // 添加命令
.command(linkCmd) // 添加命令
.command(listCmd) // 添加命令
.command(publishCmd) // 添加命令
.command(runCmd) // 添加命令
.command(versionCmd) // 添加命令
.parse(argv, context);// 合并参数 , 将 argv 和自定义的 context 中的属性合并到 argv 中
}
main
方法都做了那些事呢,首先是执行了 cli
这个方法。cli
这个模块输出的是 lernaCLI
方法。
// core\cli\index.js
module.exports = lernaCLI;
function lernaCLI(argv, cwd) {
}
接下里看看 lernaCLI
干了什么
// core\cli\index.js
// lernaCLI 方法
function lernaCLI(argv, cwd) {
// 对 yargs 进行初始化
const cli = yargs(argv, cwd);
// globalOptions 也是一个方法 把 yargs 作为参数传入 返回的还是这个 yargs 对象
// 然后基于 globalOptions() 的结果 又做了一些设置
// 运用的是构造者模式,对一个对象调用方法,然后返回这个对象本身
return globalOptions(cli)
.usage("Usage: $0 <command> [options]") // 配置 cli 的开始内容 $0 表示再 argv 中寻找 $0 这个值进行替换
.demandCommand(1, "A command is required. Pass --help to see all available commands and options.") // 配置输入的最小命令
.recommendCommands() // 配置 command 的与错误最相近的 command 提示
.strict() // 开启严格模式 命令不存在时 会报错
.fail((msg, err) => { // 命令不存在时的错误定制
})
.alias("h", "help") // 别名
.alias("v", "version") // 别名
.wrap(cli.terminalWidth()) // 配置cli的宽度和命令行一样
.epilogue(dedent`
`); // 配置 cli 结尾的内容
}
接下来看一下 globalOptions
这个东西都干了什么
// core\global-options\index.js
function globalOptions(yargs) {
// 定义了一堆的 option
const opts = {
loglevel: {
defaultDescription: "info",
describe: "What level of logs to report.",
type: "string",
},
concurrency: {
defaultDescription: os.cpus().length,
describe: "How many processes to use when lerna parallelizes tasks.",
type: "number",
requiresArg: true,
},
"reject-cycles": {
describe: "Fail if a cycle is detected among dependencies.",
type: "boolean",
},
"no-progress": {
describe: "Disable progress bars. (Always off in CI)",
type: "boolean",
},
progress: {
// proxy for --no-progress
hidden: true,
type: "boolean",
},
"no-sort": {
describe: "Do not sort packages topologically (dependencies before dependents).",
type: "boolean",
},
sort: {
// proxy for --no-sort
hidden: true,
type: "boolean",
},
"max-buffer": {
describe: "Set max-buffer (in bytes) for subcommand execution",
type: "number",
requiresArg: true,
},
};
// 拿到这些 option 的名称
const globalKeys = Object.keys(opts).concat(["help", "version"]);
return yargs
.options(opts) // 给 yargs 添加 全局options
.group(globalKeys, "Global Options:") // 对 options 进行分组
.option("ci", { // 添加了一个隐藏的 option
hidden: true,
type: "boolean",
});
}
Command 执行过程
前面提到 main
方法当中添加了很多 command
,再来看看 Command
执行过程是什么样的。
以 listCmd
为例
// commands\list\command.js
const { filterOptions } = require("@lerna/filter-options");
const listable = require("@lerna/listable");
exports.command = "list"; // 配置命令的名称
exports.aliases = ["ls", "la", "ll"]; // 配置命令的别名
exports.describe = "List local packages"; // 配置命令的描述
exports.builder = (yargs) => { // 配置命令在执行之前做的事情
listable.options(yargs);
return filterOptions(yargs);
};
exports.handler = function handler(argv) { // 配置命令在执行过程做的事情
return require(".")(argv); // 调用当前目录下 index.js 导出的 factory 方法
};
继续看看 handler
所执行的 factory
方法。
// commands\list\index.js
module.exports = factory;
function factory(argv) {
return new ListCommand(argv); // 实例化一个 ListCommand
}
// ListCommand 的结构
class ListCommand extends Command {
get requiresGit() {
}
initialize() {
}
execute() {
}
}
module.exports.ListCommand = ListCommand;
可以看到 ListCommand
是通过继承来了,继续看看父类的内容
// core\command\index.js
class Command {
constructor(_argv) {
// 深拷贝 argv
const argv = cloneDeep(_argv);
// 添加 name 属性 FooCommand => foo
this.name = this.constructor.name.replace(/Command$/, "").toLowerCase();
// 添加 composed 属性, 是否使用复合指令
this.composed = typeof argv.composed === "string" && argv.composed !== this.name;
// 如果不是复合指令
if (!this.composed) {
// composed commands have already logged the lerna version
log.notice("cli", `v${argv.lernaVersion}`);
}
// 最终的执行过程
let runner = new Promise((resolve, reject) => {
// 定义一个微任务 chain.then 会被加入到微任务队列
let chain = Promise.resolve();
// 会行程队列,一个接一个执行
chain = chain.then(() => {
this.project = new Project(argv.cwd);
});
chain = chain.then(() => this.configureEnvironment());
chain = chain.then(() => this.configureOptions());
chain = chain.then(() => this.configureProperties());
chain = chain.then(() => this.configureLogging());
chain = chain.then(() => this.runValidations());
chain = chain.then(() => this.runPreparations());
// 核心内容
chain = chain.then(() => this.runCommand());
chain.then(
(result) => {
},
(err) => {
}
);
});
// 向 argv 中定义 cwd 和 $0 两个参数
for (const key of ["cwd", "$0"]) {
Object.defineProperty(argv, key, { enumerable: false });
}
// 对 argv 属性做一些处理
Object.defineProperty(this, "argv", {
value: Object.freeze(argv),
});
// 对 runner 属性做一些处理
Object.defineProperty(this, "runner", {
value: runner,
});
}
// 核心内容
runCommand() {
return Promise.resolve()
.then(() => this.initialize()) // 调用 initialize 方法
.then((proceed) => {
if (proceed !== false) {
return this.execute(); // 调用 execute方法
}
// early exits set their own exitCode (if non-zero)
});
}
// initialize 和 execute 强制用户实现,否则会报错
initialize() {
throw new ValidationError(this.name, "initialize() needs to be implemented.");
}
execute() {
throw new ValidationError(this.name, "execute() needs to be implemented.");
}
}
initialize
和 execute
强制用户实现,否则会报错。
那么现在返回来再看看这两个方法的实现,不用关心 lerna
的源码,主要是看一下执行过程。
// commands\list\index.js
module.exports = factory;
function factory(argv) {
return new ListCommand(argv);
}
class ListCommand extends Command {
get requiresGit() {
return false;
}
initialize() {
// 也是通过 chain 微任务队列的方式
let chain = Promise.resolve();
chain = chain.then(() => getFilteredPackages(this.packageGraph, this.execOpts, this.options));
chain = chain.then((filteredPackages) => {
this.result = listable.format(filteredPackages, this.options);
});
return chain;
}
execute() {
// piping to `wc -l` should not yield 1 when no packages matched
if (this.result.text.length) {
output(this.result.text);
}
// 打印log 执行完毕
this.logger.success(
"found",
"%d %s",
this.result.count,
this.result.count === 1 ? "package" : "packages"
);
}
}
module.exports.ListCommand = ListCommand;