一、为什么要做源码分析?
为什么要分析 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 :Pconst actual = err || new Error(msg);// ValidationErrors are already logged, as are package errorsif (actual.name !== "ValidationError" && !actual.pkg) {// the recommendCommands() message is too terseif (/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 chainedcli.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, usefalsefor 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 matchedif (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 chainlet 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 detailslogPackageError(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 errorsif (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 -> versionthis.composed = typeof argv.composed === "string" && argv.composed !== this.name;if (!this.composed) {// composed commands have already logged the lerna versionlog.notice("cli", `v${argv.lernaVersion}`);}// launch the commandlet runner = new Promise((resolve, reject) => {// run everything inside a Promise chainlet 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 detailslogPackageError(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 errorsif (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 callbacksdelete argv.onResolved; // eslint-disable-line no-param-reassigndelete argv.onRejected; // eslint-disable-line no-param-reassign}// "hide" irrelevant argv keys from optionsfor (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 barsprogress = 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 otherCommandConfigsconst overrides = [this.name, ...this.otherCommandConfigs].map((key) => commandConfig[key]);this.options = defaultOptions(// CLI flags, which if defined overrule subsequent valuesthis.argv,// Namespaced command options from `lerna.json`...overrides,// Global options from `lerna.json`this.project.config,// Environmental defaults prepared in previous stepthis.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 useObject.defineProperty(this, "logger", {value: log.newGroup(this.name),});// emit all buffered logs at configured level and higherlog.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; ```
