【架构师(第四篇)】脚手架开发之Lerna源码分析

3,468 阅读7分钟

脚手架开发之 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

输出如下

image.png

usage

打印在命令行最前面

yargs(arg)
  .usage("Usage:test-cli [command] <options>") // 打印在命令行最前面
  .strict() 
  .argv 

image.png

demandCommand

设置最少需要输入的 command 的数量

yargs(arg)
  .demandCommand(1, "A command is required. Pass --help to see all available commands and options.") 
  .argv

当你不输入 command 的时候,就会报错

image.png

alias

别名

yargs(arg)
  .alias("h", "help")
  .alias("v", "version")
  .argv

这样输入 h 和输入 help 的结果是一样的,vversion 的结果是一样的

image.png

wrap

cli 的宽度

yargs(arg)
  .wrap(100)
  .argv

可以看到 cli 在命令行中的宽度发生了变化

image.png

yargs.terminalWidth() 这个方法会返回命令行界面的宽度,这样cli就会全屏展示了

const cli = yargs(arg)
cli
  .wrap(cli.terminalWidth())
  .argv

image.png

epilogue

结尾的内容

cli
  .epilogue("this is footer")
  .argv

可以看到 cli 的最后输出了 this is footer

image.png

可以使用 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

image.png

option

options 可以定义多个选项,而 option 只可以定义一个,作用是一样的。

可以添加 hidden:true,来隐藏 option,供内部人员开发时使用。

  .option("registry", {
    type: 'string',
    describe: "define global registry",
    alias: "r"// hidden:true
  })

image.png

group

option 分组, options 是默认的组

cli
  .group(['debug'], 'Deb Options:')
  .group(['registry'], 'Publish Options:')
  .argv

image.png

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 这个参数中。

image.png

另外,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?

image.png

fail

command 不存在时的错误处理

当一个 command 不存在时,默认会输出 --help 的内容 ,如果我们不想看到,那么就可以在 fail 这个方法里进行定制

cli
  .fail((err, msg) => {
    console.log(err);
  })
  .argv    

这样就只有错误信息,而不会输出出其他东西了

image.png

parse

会把定义的内容注入到当前的项目中

// 定义一个内容
const context = {
  testVersion: pkg.version,
};
// 不用在这里解析参数了
const cli = yargs()
cli
  .parse(argv, context)

我们再次打印出 args , 发现之前定义的 testVersion 已经出现在 args 中了

image.png

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

initializeexecute 强制用户实现,否则会报错。

那么现在返回来再看看这两个方法的实现,不用关心 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;