一、为什么要做源码分析?
为什么要分析 Lerna 源码?
- Lerna 是脚手架,对我们开发脚手架有借鉴价值
- Lerna 项目中蕴含大量的最佳实践,值得深入研究和学习
目标
- 了解 Lerna 源码结构和执行流程分析
- 深入了解
import-local
源码
收获
- 了解明星项目的架构设计
- 获得脚手架执行流程的一种实现思路
- 脚手架调试本地源码的另外一种方法
- Node.js 加载
node_modules
模块的流程 - 各种文件操作算法和最佳实践
二、Lerna 源码分析
2.1 源码分析前准备
在阅读源码前需做如下准备
- 安装依赖
- 找到入口文件
- 能够进行本地调试
入口文件(core/lerna/package.json)
如上所示,lerna 命令就是在此处生成的,对应的 JS 文件是 **cli.js**
(core/lerna/cli.js)。
配置调试工具
配置 **launch.json**
文件
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "lerna debug",
// 如果使用 nvm 来管理 Node 版本,可以通过 runtimeVersion 来指定调试的 Node 版本
// yarns 解释器不支持 Node 低版本,故选择高版本 v14.17.6
"runtimeVersion": "14.17.6",
"program": "${workspaceFolder}/core/lerna/cli.js",
"args": [
// 参数,等同于node cli.js ls
"ls"
]
}
]
}
针对变量区域无法监测到的变量,可以手动将变量添加至监视区进行相关监视,如下图所示。
文件**cli.js**
(core/lerna/cli.js)
2.2 lerna 初始化过程分析
配置调试脚本(launch.json
)的时候,设置的 args
参数是 ls
,相当于运行命令 node cli.js ls
,接下来便顺着该命令梳理下 lerna 的整个执行流程。
2.2.1 core/lerna/cli.js
源码解析
文件**cli.js**
(core/lerna/cli.js)
如上所示:
importLocal
功能: 待补充require(".")(process.argv.slice(2))
本质是函数调用。require(".")
等同于require("./index.js")
,该文件返回的是main
函数,而main
函数调用的参数是process.argv.slice(2)
。
2.2.2 core/lerna/index.js
源码解析
require(".")
相当于 require("./index.js")
,该文件返回的是 main
函数,如下所示。
文件**index.js**
(core/lerna/index.js)
// 通过require加载文件(会进入这些文件,并从上到下执行相关代码逻辑!!!)
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");
const pkg = require("./package.json");
module.exports = main;
function main(argv) {
const context = {
lernaVersion: pkg.version,
};
return cli()
.command(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);
}
- 依次加载文件:在文件的入口处通过
require
加载了一系列文件。require
在加载文件时会进入该文件,并从上到下依次逐行执行。在加载文件时,如果又遇到require
,会再次重复上述逻辑,直至完成全部文件的加载及执行(这就是 NodeJS 执行的逻辑和依赖加载的顺序); **yargs**
对象生成:调用cli()
会生成一个 yargs 对象,然后调用该对象的command
方法完成一系列命令的注册,最后再调用parse
方法解析参数。parse
传入了两个参数argv
和context
,parse
方法会将这两个参数进行合并后注入到项目中,并作为脚手架的默认参数;- 命令(command)格式是对象:针对命令参数处理,lerna 在初始化的时候将所有支持的 command 输出成对象(object)(注:有句话叫万物皆对象,这个思想比较重要!!!),如下图所示。在命令对象(object)当中有个
handler
方法,该方法是处理命令被调用时要执行的逻辑;
文件最后输出 main
函数到最外层,并将 main
函数执行起来。这就是 lerna 在启动过程中要做的事情。
注:require
用于读取并执行 JS 文件,并返回该模块的 exports 对象。Node 使用 CommonJS 模块规范, CommonJS 规范加载模块是同步的,只有加载完成,才能执行后续操作。
2.2.3 core/cli/index.js
源码解析
文件index.js(core/cli/index.js)
"use strict";
const dedent = require("dedent");
const log = require("npmlog");
const yargs = require("yargs/yargs");
const { globalOptions } = require("@lerna/global-options");
module.exports = lernaCLI;
/**
* A factory that returns a yargs() instance configured with everything except commands.
* Chain .parse() from this method to invoke.
*
* @param {Array = []} argv
* @param {String = process.cwd()} cwd
*/
function lernaCLI(argv, cwd) {
// 进行yargs初始化(yargs的标准用法),argv 是默认参数,cwd 是当前路径
const cli = yargs(argv, cwd);
return globalOptions(cli)
.usage("Usage: $0 <command> [options]")
.demandCommand(1, "A command is required. Pass --help to see all available commands and options.")
.recommendCommands()
.strict()
.fail((msg, err) => {
// certain yargs validations throw strings :P
const actual = err || new Error(msg);
// ValidationErrors are already logged, as are package errors
if (actual.name !== "ValidationError" && !actual.pkg) {
// the recommendCommands() message is too terse
if (/Did you mean/.test(actual.message)) {
log.error("lerna", `Unknown command "${cli.parsed.argv._[0]}"`);
}
log.error("lerna", actual.message);
}
// exit non-zero so the CLI can be usefully chained
cli.exit(actual.exitCode > 0 ? actual.exitCode : 1, actual);
})
.alias("h", "help")
.alias("v", "version")
.wrap(cli.terminalWidth()).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
`);
}
上述代码完成了一个脚手架的初始化过程和全局 options
的定义,其中:
$0
:表示从参数argv
中取变量$0
进行展示;
- yargs 的各个方法调用含义可参照 笔记:yargs 用法;
- 本地依赖引用:在文件入口处,针对 lerna 本地依赖包的引用(如
global-options
),package.json
文件的dependencies
字段采用的是file
,这有别于传统依赖包的引用。此处采用本地调试的方法来引用本地依赖; - 建造者(builder)设计模式:
globalOptions
返回的是一个yargs
对象,并基于这个对象进行了一堆设置,然后将这个yargs
对象返回回去。这里采用了设计模式中的建造者设计方法,即持续对一个对象不断地调用它的方法,并返回这个对象自身(利用this)。
引用本地依赖
查看 package.json
文件,发现 global-options
包是一个本地模块,lerna 便采用了本地调试时使用的一个方法,即通过 file
字段来加载本地模块,如下所示:
这里面有个非常重要的知识点,即本地文件如何进行调试。
**npm link**
调试:使用npm link
调试存在一个很大问题,就是必须要手动运行npm link
,一旦发布上线以后,还要做npm unlink
。使用npm link
会将依赖包拷贝到全局的node_modules
目录下,当开发的项目有很多本地依赖包时,就需要将所有的依赖包全部npm link
到全局的的node_modules
目录下,这不仅占磁盘空间,而且会使得整个 link 关系变得特别复制和混乱。**file**
调试:在进行 lerna 源码分析过程中,发现整个源码中全部采用file
字段来引用 npm 依赖包,这种方式明显是优于npm link
这种调试方式的。但采用这个方式,如果将包发布上线后,又该如何找到这些文件呢?这个其实借助于lerna publish
这条命令,将file
字段的本地链接解析成线上链接。查看publish
目录下的源码,发现 lerna 是通过resolveLocalDependencyLinks
这个函数来处理本地文件的dependency
链接,通过调用这个方法可以将本地链接解析成线上链接。
采用 file
调试这种方式,是非常有利于我们进行项目管理,可以避免使用 npm link
带来的各种问题。通过file
字段添加本地模块的依赖后,还需运行 npm install
来安装下依赖(本质是创建一个软链接)。
2.2.4 core/global-options/index.js
源码解析
文件index.js(core/global-options/index.js)global-options
主要做了以下事情:
- 定义全局的
options
,即 lerna 全局可支持的options
; - 定义全局分组:该分组包含全局的
options
和help
、version
。通过分组在查找options
会很清晰地知道哪些options
是全局的,哪些options
是当前命令支持的,避免了混在一起无法进行有效地区分; - 定义了一个隐藏的
option
,即ci
,
global-options
最后将 yargs 对象返回回去。
2.2.5 commands/list
源码解析
文件**command.js**
(commands/list/command.js)
其中 command.js
导出的是一个对象,导出的对象属性是按照 yargs
模块注册命令的格式来的,各个属性格式含义如下:
exports.command
: string (or array of strings) that executes this command when given on the command line, first string may contain positional args.exports.aliases
: array of strings (or a single string) representing aliases(别名) ofexports.command
, positional args defined in an alias are ignored.exports.describe
: string used as the description for the command in help text, usefalse
for a hidden command.exports.builder
: object declaring(声明) the options the command accepts, or a function accepting and returning a yargs instance(注:builder 是在我们执行脚手架之前要做的准备工作,一般是定义额外的option).exports.handler
: a function which will be passed the parsed argv(注:handler 是实际输入指令的时候最终调用的那个方法).exports.deprecated
: a boolean (or string) to show deprecation notice.
文件**index.js**
(commands/list/index.js)
const { Command } = require("@lerna/command");
const listable = require("@lerna/listable");
const { output } = require("@lerna/output");
const { getFilteredPackages } = require("@lerna/filter-options");
module.exports = factory;
// 采用了工厂模式
function factory(argv) {
return new ListCommand(argv);
}
class ListCommand extends Command {
get requiresGit() {
return false;
}
initialize() {
// 基于 Promise.resolve() 来实现链式调用
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);
}
this.logger.success(
"found",
"%d %s",
this.result.count,
this.result.count === 1 ? "package" : "packages"
);
}
}
module.exports.ListCommand = ListCommand;
在 index.js
文件中,有两种方式可以借鉴参考:
- 工厂模式的调用
- 链式调用
2.2.6 core/command
逻辑分析
文件**index.js**
(core/command/index.js)
微任务在不停地调用then,相当于不停地往微任务中添加微任务
let runner = new Promise((resolve, reject) => {
// run everything inside a Promise chain
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) => {
warnIfHanging();
resolve(result);
},
(err) => {
if (err.pkg) {
// Cleanly log specific package error details
logPackageError(err, this.options.stream);
} else if (err.name !== "ValidationError") {
// npmlog does some funny stuff to the stack by default,
// so pass it directly to avoid duplication.
log.error("", cleanStack(err, this.constructor.name));
}
// ValidationError does not trigger a log dump, nor do external package errors
if (err.name !== "ValidationError" && !err.pkg) {
writeLogFile(this.project.rootPath);
}
warnIfHanging();
// error code is handled by cli.fail()
reject(err);
}
);
});
```javascript const cloneDeep = require(“clone-deep”); const dedent = require(“dedent”); const execa = require(“execa”); const log = require(“npmlog”); const os = require(“os”);
const { PackageGraph } = require(“@lerna/package-graph”); const { Project } = require(“@lerna/project”); const { writeLogFile } = require(“@lerna/write-log-file”); const { ValidationError } = require(“@lerna/validation-error”);
const { cleanStack } = require(“./lib/clean-stack”); const { defaultOptions } = require(“./lib/default-options”); const { logPackageError } = require(“./lib/log-package-error”); const { warnIfHanging } = require(“./lib/warn-if-hanging”);
const DEFAULT_CONCURRENCY = os.cpus().length;
class Command { constructor(_argv) { log.pause(); log.heading = “lerna”;
// 进行深拷贝
const argv = cloneDeep(_argv);
log.silly("argv", argv);
// "FooCommand" => "foo"
this.name = this.constructor.name.replace(/Command$/, "").toLowerCase();
// composed commands are called from other commands, like publish -> version
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}`);
}
// launch the command
let runner = new Promise((resolve, reject) => {
// run everything inside a Promise chain
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) => {
warnIfHanging();
resolve(result);
},
(err) => {
if (err.pkg) {
// Cleanly log specific package error details
logPackageError(err, this.options.stream);
} else if (err.name !== "ValidationError") {
// npmlog does some funny stuff to the stack by default,
// so pass it directly to avoid duplication.
log.error("", cleanStack(err, this.constructor.name));
}
// ValidationError does not trigger a log dump, nor do external package errors
if (err.name !== "ValidationError" && !err.pkg) {
writeLogFile(this.project.rootPath);
}
warnIfHanging();
// error code is handled by cli.fail()
reject(err);
}
);
});
// passed via yargs context in tests, never actual CLI
/* istanbul ignore else */
if (argv.onResolved || argv.onRejected) {
runner = runner.then(argv.onResolved, argv.onRejected);
// when nested, never resolve inner with outer callbacks
delete argv.onResolved; // eslint-disable-line no-param-reassign
delete argv.onRejected; // eslint-disable-line no-param-reassign
}
// "hide" irrelevant argv keys from options
for (const key of ["cwd", "$0"]) {
Object.defineProperty(argv, key, { enumerable: false });
}
Object.defineProperty(this, "argv", {
value: Object.freeze(argv),
});
Object.defineProperty(this, "runner", {
value: runner,
});
}
// proxy “Promise” methods to “private” instance then(onResolved, onRejected) { return this.runner.then(onResolved, onRejected); }
/ istanbul ignore next / catch(onRejected) { return this.runner.catch(onRejected); }
get requiresGit() { return true; }
// Override this to inherit config from another command.
// For example changed
inherits config from publish
.
get otherCommandConfigs() {
return [];
}
configureEnvironment() { // eslint-disable-next-line global-require const ci = require(“is-ci”); let loglevel; let progress;
/* istanbul ignore next */
if (ci || !process.stderr.isTTY) {
log.disableColor();
progress = false;
} else if (!process.stdout.isTTY) {
// stdout is being piped, don't log non-errors or progress bars
progress = false;
loglevel = "error";
} else if (process.stderr.isTTY) {
log.enableColor();
log.enableUnicode();
}
Object.defineProperty(this, "envDefaults", {
value: {
ci,
progress,
loglevel,
},
});
}
configureOptions() { // Command config object normalized to “command” namespace const commandConfig = this.project.config.command || {};
// The current command always overrides otherCommandConfigs
const overrides = [this.name, ...this.otherCommandConfigs].map((key) => commandConfig[key]);
this.options = defaultOptions(
// CLI flags, which if defined overrule subsequent values
this.argv,
// Namespaced command options from `lerna.json`
...overrides,
// Global options from `lerna.json`
this.project.config,
// Environmental defaults prepared in previous step
this.envDefaults
);
}
configureProperties() { const { concurrency, sort, maxBuffer } = this.options;
this.concurrency = Math.max(1, +concurrency || DEFAULT_CONCURRENCY);
this.toposort = sort === undefined || sort;
/** @type {import("@lerna/child-process").ExecOpts} */
this.execOpts = {
cwd: this.project.rootPath,
maxBuffer,
};
}
configureLogging() { const { loglevel } = this.options;
if (loglevel) {
log.level = loglevel;
}
// handle log.success()
log.addLevel("success", 3001, { fg: "green", bold: true });
// create logger that subclasses use
Object.defineProperty(this, "logger", {
value: log.newGroup(this.name),
});
// emit all buffered logs at configured level and higher
log.resume();
}
enableProgressBar() { / istanbul ignore next / if (this.options.progress !== false) { log.enableProgress(); } }
gitInitialized() { const opts = { cwd: this.project.rootPath, // don’t throw, just want boolean reject: false, // only return code, no stdio needed stdio: “ignore”, };
return execa.sync("git", ["rev-parse"], opts).exitCode === 0;
}
runValidations() { if ((this.options.since !== undefined || this.requiresGit) && !this.gitInitialized()) { throw new ValidationError(“ENOGIT”, “The git binary was not found, or this is not a git repository.”); }
if (!this.project.manifest) {
throw new ValidationError("ENOPKG", "`package.json` does not exist, have you run `lerna init`?");
}
if (!this.project.version) {
throw new ValidationError("ENOLERNA", "`lerna.json` does not exist, have you run `lerna init`?");
}
if (this.options.independent && !this.project.isIndependent()) {
throw new ValidationError(
"EVERSIONMODE",
dedent`
You ran lerna with --independent or -i, but the repository is not set to independent mode.
To use independent mode you need to set lerna.json's "version" property to "independent".
Then you won't need to pass the --independent or -i flags.
`
);
}
}
runPreparations() { if (!this.composed && this.project.isIndependent()) { // composed commands have already logged the independent status log.info(“versioning”, “independent”); }
if (!this.composed && this.options.ci) {
log.info("ci", "enabled");
}
let chain = Promise.resolve();
chain = chain.then(() => this.project.getPackages());
chain = chain.then((packages) => {
this.packageGraph = new PackageGraph(packages);
});
return chain;
}
runCommand() { return Promise.resolve() .then(() => this.initialize()) .then((proceed) => { if (proceed !== false) { return this.execute(); } // early exits set their own exitCode (if non-zero) }); }
initialize() { throw new ValidationError(this.name, “initialize() needs to be implemented.”); }
execute() { throw new ValidationError(this.name, “execute() needs to be implemented.”); } }
module.exports.Command = Command; ```