一、为什么要做源码分析?

为什么要分析 Lerna 源码?

  • Lerna 是脚手架,对我们开发脚手架有借鉴价值
  • Lerna 项目中蕴含大量的最佳实践,值得深入研究和学习

目标

  • 了解 Lerna 源码结构和执行流程分析
  • 深入了解 import-local 源码

收获

  • 了解明星项目的架构设计
  • 获得脚手架执行流程的一种实现思路
  • 脚手架调试本地源码的另外一种方法
  • Node.js 加载 node_modules 模块的流程
  • 各种文件操作算法和最佳实践

二、Lerna 源码分析

2.1 源码分析前准备

在阅读源码前需做如下准备

  • 安装依赖
  • 找到入口文件
  • 能够进行本地调试

入口文件(core/lerna/package.json)

image.png
如上所示,lerna 命令就是在此处生成的,对应的 JS 文件是 **cli.js**(core/lerna/cli.js)

配置调试工具

image.png

配置 **launch.json** 文件

  1. {
  2. "version": "0.2.0",
  3. "configurations": [
  4. {
  5. "type": "node",
  6. "request": "launch",
  7. "name": "lerna debug",
  8. // 如果使用 nvm 来管理 Node 版本,可以通过 runtimeVersion 来指定调试的 Node 版本
  9. // yarns 解释器不支持 Node 低版本,故选择高版本 v14.17.6
  10. "runtimeVersion": "14.17.6",
  11. "program": "${workspaceFolder}/core/lerna/cli.js",
  12. "args": [
  13. // 参数,等同于node cli.js ls
  14. "ls"
  15. ]
  16. }
  17. ]
  18. }

针对变量区域无法监测到的变量,可以手动将变量添加至监视区进行相关监视,如下图所示。
文件**cli.js**(core/lerna/cli.js)
image.png

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)
image.png
如上所示:

  • 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)

  1. // 通过require加载文件(会进入这些文件,并从上到下执行相关代码逻辑!!!)
  2. const cli = require("@lerna/cli");
  3. const addCmd = require("@lerna/add/command");
  4. const bootstrapCmd = require("@lerna/bootstrap/command");
  5. const changedCmd = require("@lerna/changed/command");
  6. const cleanCmd = require("@lerna/clean/command");
  7. const createCmd = require("@lerna/create/command");
  8. const diffCmd = require("@lerna/diff/command");
  9. const execCmd = require("@lerna/exec/command");
  10. const importCmd = require("@lerna/import/command");
  11. const infoCmd = require("@lerna/info/command");
  12. const initCmd = require("@lerna/init/command");
  13. const linkCmd = require("@lerna/link/command");
  14. const listCmd = require("@lerna/list/command");
  15. const publishCmd = require("@lerna/publish/command");
  16. const runCmd = require("@lerna/run/command");
  17. const versionCmd = require("@lerna/version/command");
  18. const pkg = require("./package.json");
  19. module.exports = main;
  20. function main(argv) {
  21. const context = {
  22. lernaVersion: pkg.version,
  23. };
  24. return cli()
  25. .command(addCmd)
  26. .command(bootstrapCmd)
  27. .command(changedCmd)
  28. .command(cleanCmd)
  29. .command(createCmd)
  30. .command(diffCmd)
  31. .command(execCmd)
  32. .command(importCmd)
  33. .command(infoCmd)
  34. .command(initCmd)
  35. .command(linkCmd)
  36. .command(listCmd)
  37. .command(publishCmd)
  38. .command(runCmd)
  39. .command(versionCmd)
  40. .parse(argv, context);
  41. }
  • 依次加载文件:在文件的入口处通过 require 加载了一系列文件。require 在加载文件时会进入该文件,并从上到下依次逐行执行。在加载文件时,如果又遇到 require ,会再次重复上述逻辑,直至完成全部文件的加载及执行(这就是 NodeJS 执行的逻辑依赖加载的顺序);
  • **yargs** 对象生成:调用 cli() 会生成一个 yargs 对象,然后调用该对象的 command 方法完成一系列命令的注册,最后再调用 parse 方法解析参数。parse 传入了两个参数 argvcontextparse 方法会将这两个参数进行合并后注入到项目中,并作为脚手架的默认参数;
  • 命令(command)格式是对象:针对命令参数处理,lerna 在初始化的时候将所有支持的 command 输出成对象(object)(注:有句话叫万物皆对象,这个思想比较重要!!!),如下图所示。在命令对象(object)当中有个 handler 方法,该方法是处理命令被调用时要执行的逻辑;

image.png
文件最后输出 main 函数到最外层,并将 main 函数执行起来。这就是 lerna 在启动过程中要做的事情。

注:
require 用于读取并执行 JS 文件,并返回该模块的 exports 对象。Node 使用 CommonJS 模块规范, CommonJS 规范加载模块是同步的,只有加载完成,才能执行后续操作。

2.2.3 core/cli/index.js 源码解析

文件index.js(core/cli/index.js)

  1. "use strict";
  2. const dedent = require("dedent");
  3. const log = require("npmlog");
  4. const yargs = require("yargs/yargs");
  5. const { globalOptions } = require("@lerna/global-options");
  6. module.exports = lernaCLI;
  7. /**
  8. * A factory that returns a yargs() instance configured with everything except commands.
  9. * Chain .parse() from this method to invoke.
  10. *
  11. * @param {Array = []} argv
  12. * @param {String = process.cwd()} cwd
  13. */
  14. function lernaCLI(argv, cwd) {
  15. // 进行yargs初始化(yargs的标准用法),argv 是默认参数,cwd 是当前路径
  16. const cli = yargs(argv, cwd);
  17. return globalOptions(cli)
  18. .usage("Usage: $0 <command> [options]")
  19. .demandCommand(1, "A command is required. Pass --help to see all available commands and options.")
  20. .recommendCommands()
  21. .strict()
  22. .fail((msg, err) => {
  23. // certain yargs validations throw strings :P
  24. const actual = err || new Error(msg);
  25. // ValidationErrors are already logged, as are package errors
  26. if (actual.name !== "ValidationError" && !actual.pkg) {
  27. // the recommendCommands() message is too terse
  28. if (/Did you mean/.test(actual.message)) {
  29. log.error("lerna", `Unknown command "${cli.parsed.argv._[0]}"`);
  30. }
  31. log.error("lerna", actual.message);
  32. }
  33. // exit non-zero so the CLI can be usefully chained
  34. cli.exit(actual.exitCode > 0 ? actual.exitCode : 1, actual);
  35. })
  36. .alias("h", "help")
  37. .alias("v", "version")
  38. .wrap(cli.terminalWidth()).epilogue(dedent`
  39. When a command fails, all logs are written to lerna-debug.log in the current working directory.
  40. For more information, find our manual at https://github.com/lerna/lerna
  41. `);
  42. }

上述代码完成了一个脚手架的初始化过程和全局 options 的定义,其中:

  • $0 :表示从参数 argv 中取变量 $0 进行展示;

image.png

  • yargs 的各个方法调用含义可参照 笔记:yargs 用法
  • 本地依赖引用:在文件入口处,针对 lerna 本地依赖包的引用(如global-options),package.json 文件的 dependencies 字段采用的是 file,这有别于传统依赖包的引用。此处采用本地调试的方法来引用本地依赖;
  • 建造者(builder)设计模式:globalOptions 返回的是一个 yargs 对象,并基于这个对象进行了一堆设置,然后将这个 yargs 对象返回回去。这里采用了设计模式中的建造者设计方法,即持续对一个对象不断地调用它的方法,并返回这个对象自身(利用this)。

引用本地依赖

查看 package.json 文件,发现 global-options 包是一个本地模块,lerna 便采用了本地调试时使用的一个方法,即通过 file 字段来加载本地模块,如下所示:
image.png
这里面有个非常重要的知识点,即本地文件如何进行调试

  • **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 链接,通过调用这个方法可以将本地链接解析成线上链接

image.png

采用 file 调试这种方式,是非常有利于我们进行项目管理,可以避免使用 npm link 带来的各种问题。通过file 字段添加本地模块的依赖后,还需运行 npm install 来安装下依赖(本质是创建一个软链接)。

2.2.4 core/global-options/index.js 源码解析

文件index.js(core/global-options/index.js)
image.png
global-options 主要做了以下事情:

  • 定义全局的 options,即 lerna 全局可支持的options
  • 定义全局分组:该分组包含全局的 optionshelpversion。通过分组在查找 options 会很清晰地知道哪些 options 是全局的,哪些 options 是当前命令支持的,避免了混在一起无法进行有效地区分;
  • 定义了一个隐藏的option,即ci

global-options 最后将 yargs 对象返回回去。

2.2.5 commands/list 源码解析

文件**command.js**(commands/list/command.js)
image.png
其中 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(别名) of exports.command, positional args defined in an alias are ignored.
  • exports.describe: string used as the description for the command in help text, use false 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)

  1. const { Command } = require("@lerna/command");
  2. const listable = require("@lerna/listable");
  3. const { output } = require("@lerna/output");
  4. const { getFilteredPackages } = require("@lerna/filter-options");
  5. module.exports = factory;
  6. // 采用了工厂模式
  7. function factory(argv) {
  8. return new ListCommand(argv);
  9. }
  10. class ListCommand extends Command {
  11. get requiresGit() {
  12. return false;
  13. }
  14. initialize() {
  15. // 基于 Promise.resolve() 来实现链式调用
  16. let chain = Promise.resolve();
  17. chain = chain.then(() => getFilteredPackages(this.packageGraph, this.execOpts, this.options));
  18. chain = chain.then((filteredPackages) => {
  19. this.result = listable.format(filteredPackages, this.options);
  20. });
  21. return chain;
  22. }
  23. execute() {
  24. // piping to `wc -l` should not yield 1 when no packages matched
  25. if (this.result.text.length) {
  26. output(this.result.text);
  27. }
  28. this.logger.success(
  29. "found",
  30. "%d %s",
  31. this.result.count,
  32. this.result.count === 1 ? "package" : "packages"
  33. );
  34. }
  35. }
  36. module.exports.ListCommand = ListCommand;

index.js 文件中,有两种方式可以借鉴参考:

  • 工厂模式的调用
  • 链式调用

2.2.6 core/command 逻辑分析

文件**index.js**(core/command/index.js)

  • 微任务在不停地调用then,相当于不停地往微任务中添加微任务

    1. let runner = new Promise((resolve, reject) => {
    2. // run everything inside a Promise chain
    3. let chain = Promise.resolve();
    4. chain = chain.then(() => {
    5. this.project = new Project(argv.cwd);
    6. });
    7. chain = chain.then(() => this.configureEnvironment());
    8. chain = chain.then(() => this.configureOptions());
    9. chain = chain.then(() => this.configureProperties());
    10. chain = chain.then(() => this.configureLogging());
    11. chain = chain.then(() => this.runValidations());
    12. chain = chain.then(() => this.runPreparations());
    13. chain = chain.then(() => this.runCommand());
    14. chain.then(
    15. (result) => {
    16. warnIfHanging();
    17. resolve(result);
    18. },
    19. (err) => {
    20. if (err.pkg) {
    21. // Cleanly log specific package error details
    22. logPackageError(err, this.options.stream);
    23. } else if (err.name !== "ValidationError") {
    24. // npmlog does some funny stuff to the stack by default,
    25. // so pass it directly to avoid duplication.
    26. log.error("", cleanStack(err, this.constructor.name));
    27. }
    28. // ValidationError does not trigger a log dump, nor do external package errors
    29. if (err.name !== "ValidationError" && !err.pkg) {
    30. writeLogFile(this.project.rootPath);
    31. }
    32. warnIfHanging();
    33. // error code is handled by cli.fail()
    34. reject(err);
    35. }
    36. );
    37. });

    ```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”;

  1. // 进行深拷贝
  2. const argv = cloneDeep(_argv);
  3. log.silly("argv", argv);
  4. // "FooCommand" => "foo"
  5. this.name = this.constructor.name.replace(/Command$/, "").toLowerCase();
  6. // composed commands are called from other commands, like publish -> version
  7. this.composed = typeof argv.composed === "string" && argv.composed !== this.name;
  8. if (!this.composed) {
  9. // composed commands have already logged the lerna version
  10. log.notice("cli", `v${argv.lernaVersion}`);
  11. }
  12. // launch the command
  13. let runner = new Promise((resolve, reject) => {
  14. // run everything inside a Promise chain
  15. let chain = Promise.resolve();
  16. chain = chain.then(() => {
  17. this.project = new Project(argv.cwd);
  18. });
  19. chain = chain.then(() => this.configureEnvironment());
  20. chain = chain.then(() => this.configureOptions());
  21. chain = chain.then(() => this.configureProperties());
  22. chain = chain.then(() => this.configureLogging());
  23. chain = chain.then(() => this.runValidations());
  24. chain = chain.then(() => this.runPreparations());
  25. chain = chain.then(() => this.runCommand());
  26. chain.then(
  27. (result) => {
  28. warnIfHanging();
  29. resolve(result);
  30. },
  31. (err) => {
  32. if (err.pkg) {
  33. // Cleanly log specific package error details
  34. logPackageError(err, this.options.stream);
  35. } else if (err.name !== "ValidationError") {
  36. // npmlog does some funny stuff to the stack by default,
  37. // so pass it directly to avoid duplication.
  38. log.error("", cleanStack(err, this.constructor.name));
  39. }
  40. // ValidationError does not trigger a log dump, nor do external package errors
  41. if (err.name !== "ValidationError" && !err.pkg) {
  42. writeLogFile(this.project.rootPath);
  43. }
  44. warnIfHanging();
  45. // error code is handled by cli.fail()
  46. reject(err);
  47. }
  48. );
  49. });
  50. // passed via yargs context in tests, never actual CLI
  51. /* istanbul ignore else */
  52. if (argv.onResolved || argv.onRejected) {
  53. runner = runner.then(argv.onResolved, argv.onRejected);
  54. // when nested, never resolve inner with outer callbacks
  55. delete argv.onResolved; // eslint-disable-line no-param-reassign
  56. delete argv.onRejected; // eslint-disable-line no-param-reassign
  57. }
  58. // "hide" irrelevant argv keys from options
  59. for (const key of ["cwd", "$0"]) {
  60. Object.defineProperty(argv, key, { enumerable: false });
  61. }
  62. Object.defineProperty(this, "argv", {
  63. value: Object.freeze(argv),
  64. });
  65. Object.defineProperty(this, "runner", {
  66. value: runner,
  67. });

}

// 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;

  1. /* istanbul ignore next */
  2. if (ci || !process.stderr.isTTY) {
  3. log.disableColor();
  4. progress = false;
  5. } else if (!process.stdout.isTTY) {
  6. // stdout is being piped, don't log non-errors or progress bars
  7. progress = false;
  8. loglevel = "error";
  9. } else if (process.stderr.isTTY) {
  10. log.enableColor();
  11. log.enableUnicode();
  12. }
  13. Object.defineProperty(this, "envDefaults", {
  14. value: {
  15. ci,
  16. progress,
  17. loglevel,
  18. },
  19. });

}

configureOptions() { // Command config object normalized to “command” namespace const commandConfig = this.project.config.command || {};

  1. // The current command always overrides otherCommandConfigs
  2. const overrides = [this.name, ...this.otherCommandConfigs].map((key) => commandConfig[key]);
  3. this.options = defaultOptions(
  4. // CLI flags, which if defined overrule subsequent values
  5. this.argv,
  6. // Namespaced command options from `lerna.json`
  7. ...overrides,
  8. // Global options from `lerna.json`
  9. this.project.config,
  10. // Environmental defaults prepared in previous step
  11. this.envDefaults
  12. );

}

configureProperties() { const { concurrency, sort, maxBuffer } = this.options;

  1. this.concurrency = Math.max(1, +concurrency || DEFAULT_CONCURRENCY);
  2. this.toposort = sort === undefined || sort;
  3. /** @type {import("@lerna/child-process").ExecOpts} */
  4. this.execOpts = {
  5. cwd: this.project.rootPath,
  6. maxBuffer,
  7. };

}

configureLogging() { const { loglevel } = this.options;

  1. if (loglevel) {
  2. log.level = loglevel;
  3. }
  4. // handle log.success()
  5. log.addLevel("success", 3001, { fg: "green", bold: true });
  6. // create logger that subclasses use
  7. Object.defineProperty(this, "logger", {
  8. value: log.newGroup(this.name),
  9. });
  10. // emit all buffered logs at configured level and higher
  11. 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”, };

  1. 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.”); }

  1. if (!this.project.manifest) {
  2. throw new ValidationError("ENOPKG", "`package.json` does not exist, have you run `lerna init`?");
  3. }
  4. if (!this.project.version) {
  5. throw new ValidationError("ENOLERNA", "`lerna.json` does not exist, have you run `lerna init`?");
  6. }
  7. if (this.options.independent && !this.project.isIndependent()) {
  8. throw new ValidationError(
  9. "EVERSIONMODE",
  10. dedent`
  11. You ran lerna with --independent or -i, but the repository is not set to independent mode.
  12. To use independent mode you need to set lerna.json's "version" property to "independent".
  13. Then you won't need to pass the --independent or -i flags.
  14. `
  15. );
  16. }

}

runPreparations() { if (!this.composed && this.project.isIndependent()) { // composed commands have already logged the independent status log.info(“versioning”, “independent”); }

  1. if (!this.composed && this.options.ci) {
  2. log.info("ci", "enabled");
  3. }
  4. let chain = Promise.resolve();
  5. chain = chain.then(() => this.project.getPackages());
  6. chain = chain.then((packages) => {
  7. this.packageGraph = new PackageGraph(packages);
  8. });
  9. 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; ```

2.2.7 importLocal 逻辑分析