Skip to content

脚手架开发-Englighten CLI ​

基本概念 ​

深入理解shell、bash 和 CLI ​

什么是 Shell? Shell 是一个计算机程序,它作为用户与操作系统之间的接口。简单来说,shell 就是一个接收命令并执行相应操作的程序。你可以把它想象成一个“翻译器”,把用户的命令翻译成操作系统能理解的语言。

Shell 的种类

  1. bash(Bourne Again SHell)
  2. sh(Bourne SHell)
  3. csh(C SHell)
  4. zsh(Z SHell)
  5. fish(Friendly Interactive SHell)

不同操作系统上的 Shell

  1. Windows 在 Windows 系统中,传统的命令行工具是 cmd.exe(简称 cmd)。
  • cmd:Windows 的默认命令行工具,提供了基本的命令行功能。
    • 例如:dir(列出目录内容)、cd(切换目录)、del(删除文件)等。
  1. Linux 在 Linux 系统中,最常见的命令行工具是 bash。
  • bash:Linux 的默认 shell,提供了丰富的命令行功能。
    • 例如:ls(列出目录内容)、cd(切换目录)、rm(删除文件)等。
  1. macOS macOS 早期使用的是 bash,但从 macOS Catalina(10.15)开始,默认 shell 改成了 zsh。

命令行界面(CLI)CLI(Command Line Interface)是指命令行界面,即用户通过输入命令来进行操作的界面。CLI 是 shell 的一种表现形式,允许用户通过文本输入命令来与操作系统交互。

总结

  • Shell是操作系统提供的接口程序,用于接收用户输入的命令,交给操作系统内核执行并接受响应结果,常见的有 bash、zsh 等。
  • Bash是Shell的一个实现,用于执行用户输入的命令
  • CLI 是通过文本输入命令来与操作系统交互的界面。

脚手架的执行原理 ​

脚手架(Scaffold)是一种自动化工具,用于快速生成项目的基本结构和文件。它可以帮助开发者快速搭建一个项目的基础框架,而不需要手动创建每一个文件和目录。 脚手架通常提供一个命令行工具(CLI),用户可以通过命令行来启动脚手架。

当我们执行npm install @vue/cli -g的时候发生了什么?

  1. 安装过程:
    • 执行 npm install @vue/cli -g 会全局安装 Vue CLI
    • 全局安装的包会被放置在一个全局的 node_modules 目录中,通常是 %AppData%\npm\node_modules 或者 %LocalAppData%\npm\node_modules。
  2. 生成可执行文件:
    • Vue CLI 会在安装完成后生成几个可执行文件:
      • vue.cmd(Windows)
      • vue(Unix-like 系统,如 macOS 和 Linux)
      • vue.ps1(PowerShell 脚本)
    • 通常位于 %AppData%\npm 或 %LocalAppData%\npm

当我们执行vue create my-project的时候发生了什么?

shell
vue create my-project --force -r https://registry.npmjs.org

上述从左到右依次为:主命令 command command参数 option配置

  1. 查找可执行文件:
    • 当我们执行vue的时候,在macos中相当于执行了which vue,windows中相当于执行了where vue,用于找到在Node.js的bin目录下的vue(vue.cmd),这个vue是一个软连接
    • 这里软连接指向的是@vue/cli/bin/vue.js
  2. 执行命令:
    • vue.cmd 文件是一个批处理文件,它会调用全局安装的 Vue CLI,也就是@vue/cli/bin/vue.js。
    • Vue CLI 会解析命令行参数并执行相应的操作。
    • 为什么系统会识别vue这个命令呢?
      • 因为在package.json中配置了bin字段,这个字段会告诉npm如何执行这个包。
      json
      {
        "name": "@vue/cli",
        "version": "x.y.z",
        "bin": {
          "vue": "bin/vue.js"
        }
      }
    • 为什么vue.js会被执行呢?
      • 因为vue.js的源码第一行是#!/usr/bin/env node,这个是Node.js的shebang,表示这个文件是一个Node.js脚本,然后Node.js会自动执行这个脚本。

Node其实就是一个客户端 macOS中,which node我们可以发现他是一个软连接,windows中我们也可以查看node是一个exe文件,我们平时执行node test.js,其实是把test.js中的代码当成一个字符串,传给了node.exe,然后node进行解析,把它当成一个可执行文件进行解析,最后返回结果,我们也可以输入node -e "console.log('hello world')",这样也可以执行

脚手架基础 ​

脚手架开发流程详解 ​

  1. 初始化项目
shell
npm init -y
  1. 新建bin目录,并在其中创建一个名为index.js的文件,内容如下:
js
#!/usr/bin/env node

console.log('welcome my cli-  nv----')
  1. 配置package.json
json
"bin": {
    "cli-nv": "bin/index.js"
}
  1. 发布
shell
npm login
npm publish
  • 注意事项

    • 注意脚手架package.json中的name,name不能重复,否则会报错
    • 注意npm源,如果npm源不是官方的,可能会导致发布失败
  • 本地调试方法

    • 在你的npm包的上层目录npm i -g xxx,这样软链会指向你的本地
    • 使用npm link命令,这样软链会指向你的本地
    • 分包之后的本地调试
      • 假如你的A包依赖B包,首先B包中npm init -y,然后新建lib/index.js,然后在package.json中添加"main": "lib/index.js",然后npm link,然后A包中npm link B,这样A包中就可以使用B包中的内容了

脚手架本地link标准流程 链接本地脚手架:

shell
cd you-cli-dir
npm link

链接本地库文件:

shell
cd your-lib-dir
npm link 
cd your-cli-dir
npm link your-lib

取消链接本地库文件:

shell
cd your-lib-dir
npm unlink 
cd your-cli-dir
npm unlink your-lib

理解npm link:

  • npm link your-lib:将当前项目中node_modules目录下的your-lib链接到node全局的node_modules目录下的库文件。
  • npm link:将当前项目链接到node全局的node_modules中作为一个库文件,并解析bin配置创建可执行文件

命令注册以及参数解析 ​

可以借助node中的process.argv获取命令行参数,然后进行解析。argv是一个数组,第一个元素是node可执行文件路径,第二个元素是js文件路径,第三个元素开始才是命令行参数。 如果手动去分析后续参数,需要考虑很多组合情况,来区分command和args,我们这里只给出一个简单示例:

js
#!/usr/bin/env node

const lib = require('liuhuakawaii-cli-lib')

const argv = require('process').argv
console.log(argv, 'argv');

const command = argv[2]
const options = argv.slice(3)
let [option, param] = options
option = option.replace('--', '')

console.log(command, 'command');
console.log(options, 'options');
console.log(param, 'param');

if (lib[command]) {
  lib[command](option, param)
} else {
  console.log('command not found')
}

yargs ​

基本设置

  • hideBin: 用于移除 Node.js 特定的命令行参数
  • arg: 处理后的命令行参数

全局配置

  • .usage(): 设置使用说明
  • .demandCommand(): 要求至少一个命令
  • .strict(): 严格模式,不允许未知选项
  • .recommendCommands(): 在输入错误时推荐相似命令
  • .fail(): 自定义错误处理
  • .alias(): 为选项设置别名
  • .wrap(): 设置帮助信息的宽度
  • .epilogue(): 设置帮助信息的结尾文本

全局选项

  • .options(): 定义多个选项
  • .option(): 定义单个选项
  • .group(): 将选项分组

代码示例

js
#!/usr/bin/env node

const yargs = require('yargs')
const { hideBin } = require('yargs/helpers')

const arg = hideBin(process.argv)
const cli =

  yargs(arg)
    .usage('Usage: liuhuakawaii-cli <command> [options]')
    .demandCommand(1, 'A command is required. Pass --help to see all available commands and options.')
    .strict()
    .recommendCommands()
    .fail((msg, err, yargs) => {
      console.log(msg)
    })
    .alias('h', 'help')
    .alias('v', 'version')
    .wrap(yargs.terminalWidth())
    .epilogue('For more information, visit https://github.com/liuhuakawaii/liuhuakawaii-cli')
    .options({
      debug: {
        type: 'boolean',
        describe: 'Enable debug mode',
        default: false,
        alias: 'd',
      },
    })
    .option('registry', {
      type: "string",
      describe: "Specify npm registry",
      default: "https://registry.npmjs.org",
      alias: "r"
    })
    .group(['debug', 'registry'], 'Dev Options:')
    .command('init [name]', 'Initialize a new project', (yargs) => {
      yargs.option('name', {
        type: 'string',
        describe: 'Project name',
        default: 'my-project',
        alias: 'n'
      })
    }, (argv) => {
      console.log(argv);

    })
    .command({
      command: 'list',
      describe: 'List all packages',
      alias: ['ls', 'l'],
      handler: () => {
        console.log('List all packages');
      }
    })
    .argv

Commander.js ​

基本设置

  • new commander.Command(): 创建 Commander 实例
  • .name(): 设置程序名称
  • .usage(): 设置使用说明
  • .version(): 设置版本信息
  • .option(): 定义全局选项
    • 短选项,长选项
    • 描述
    • 默认值(可选)

命令定义

  • .command(): 定义命令
  • .description(): 设置命令描述
  • .option(): 定义命令特定选项
  • .action(): 定义命令执行的回调函数
  • .addCommand(): 将子命令添加到主程序

独立的可执行命令

  • executableFile: 指定可执行文件
  • isDefault: 设置为默认命令
  • .alias(): 设置命令别名

参数处理

  • .arguments(): 定义命令参数
  • .description(): 设置描述,可以为参数单独设置描述
  • .action(): 定义参数处理函数
  • .parse(): 解析命令行参数

代码示例

js
#!/usr/bin/env node

const commander = require('commander')
const pkg = require('../package.json')

//获取commander的单例
// const { program } = commander

//手动实例化
const program = new commander.Command()

program
  .name(Object.keys(pkg.bin)[0])
  .usage('<command> [options]')
  .version(pkg.version)
  .option('-d, --debug', 'output extra debugging', false)
  .option('-s, --small', 'small pizza size')
  .option('-p, --pizza-type <type>', 'flavour of pizza')
  .option('-e, --env <env>', 'environment</env>')

//command 注册命令
const clone = program.command('clone <source> [destination]') //<source>是必填参数,[destination]是可选参数
clone
  .description('clone a repository')
  .option('-f, --force', '是否强制克隆')
  .action((source, destination) => {
    console.log('clone from %s to %s', source, destination || 'current directory')
  })

//addCommand 注册子命令
const service = new commander.Command('service')
service
  .command('start <service-name>')
  .description('start service')
  .action((serviceName) => {
    console.log('start %s service', serviceName)
  })

service
  .command('stop <service-name>')
  .description('stop service')
  .action((serviceName) => {
    console.log('stop %s service', serviceName)
  })
program.addCommand(service)

program
  .command('install [name]', 'install package', {
    executableFile: 'xxx-cli',   //多个脚手架可以串行调用
    isDefault: true,  //默认执行
  })
  .alias('i')

program
  .arguments('<cmd> [options]')
  .description('test command', {
    cmd: 'command to run',
    options: 'options for command'
  })
  .action((cmd, options) => {
    console.log('cmd: %s, options: %O', cmd, options)
  })
program.parse(process.argv)

命令行UI显示 ​

chalk ​

基本语法 ​

chalk 提供了一系列预定义的颜色和样式函数,你可以通过调用这些函数来改变文本的颜色和样式。

js
const chalk = require('chalk')
console.log(chalk.red('Hello') + ' World' + chalk.blue('!')) //单独调用
console.log(chalk.red('Hello', 'World', '!'))  //拼接字符串
console.log(chalk.red.bgYellow.bold('Hello world!')) //链式调用
console.log(chalk.hex('#FF00FF').underline(' rainbow'))
console.log(chalk.rgb(123, 45, 67).inverse('inverse'))
console.log(chalk.keyword('orange')('Yay for orange colored text!')) //通过关键字获取颜色
console.log(chalk.red('hello',chalk.underline('Underlined reddish color'))) //迭代调用
源码阅读 ​

自定义import

js
import ansiStyles from '#ansi-styles'
import supportColors from '#supports-color'

//package.json 配置 指向内置库
"imports":{
    "#ansi-styles":"./source/vendor/ansi-styles/index.js",
    "#supports-color":{
        "node":"./source/vendor/supports-color/index.js",//node
        "default":"./source/vendor/supports-color/browser.js" //浏览器
    },
}

批量生成方法和构造者模式应用 根据ansiStyles配置批量生成构造者方法

js
for (const [styleName, style] of Object.entries(ansiStyles)) {
  styles[styleName] = {
    get() {
      const builder = createBuilder(this, createStyler(style.open, style.close, this[STYLER]), this[IS_EMPTY])
      Obeject.defineProperty(this, styleName, { value: builder })
      return builder
    }
  }
}

使用工厂模式生成实例

js
function createChalk(options){
  return chalkFactory(options)
}

const chalk = createChalk()

export default chalk

链式调用 简单实现

js
class Test {
  one() {
    console.log(1);
    return this
  }
  two() {
    console.log(2);
    return this
  }
}

function createBuilder() {
  return new Test()
}

const builder = createBuilder()
builder.one().two()  // 1 2

ora ​

基本使用 ​
js
import ora from 'ora';

// 模拟一个异步任务
function simulateAsyncTask() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.5) {
        resolve('任务成功完成');
      } else {
        reject(new Error('任务失败'));
      }
    }, 2000);
  });
}

// 创建一个加载器实例
const spinner = ora('正在加载...').start();

// 执行异步任务
simulateAsyncTask()
  .then((message) => {
    spinner.succeed(message);
  })
  .catch((error) => {
    spinner.fail(error.message);
  })
  .finally(() => {
    // 无论成功还是失败,最终都会执行的代码
    console.log('任务结束');
  });

// 你可以使用其他 API 来定制加载效果
spinner.color = 'yellow'; // 设置加载器的颜色
spinner.text = '正在处理数据...'; // 更新加载器的文本

// 如果你需要暂停和恢复加载器
setTimeout(() => {
  spinner.stopAndPersist({
    symbol: '🚀', // 自定义符号
    text: '任务完成,准备发射!'
  });
}, 4000);
源码阅读 ​

class私有属性

js
class Test {
  #name = 'liuhuakawaii'
  #getName() {
    return this.#name
  }
  getName() {
    return this.#getName()
  }

}
const test = new Test()
console.log(test.getName()) // liuhuakawaii
console.log(test.#getName()) // 报错
console.log(test.#name) // 报错

命令行交互 ​

inquirer ​

基本使用 ​

创建提示inquirer.prompt 方法用于创建提示。它接受一个数组,数组中的每个对象定义一个提示。

提示类型

  • input:用于获取用户输入的文本。
  • password:用于获取用户输入的密码,输入内容会被隐藏。
  • list:用于让用户从一组选项中选择一个。
  • checkbox:用于让用户从一组选项中选择多个。
  • confirm:用于获取用户的确认(是/否)。
js
const inquirer = require('inquirer').default;

//findLastIndex 是 ECMAScript 2022 中引入的一个新方法,如果你的 Node.js 版本不支持这个方法,就会出现这个错误。
Array.prototype.findLastIndex = function (predicate) {
  for (let i = this.length - 1; i >= 0; i--) {
    if (predicate(this[i], i, this)) {
      return i;
    }
  }
  return -1;
};

inquirer
  .prompt([
    {
      type: 'input',
      name: 'name',
      message: 'What is your name?',
      default: 'none',
      validate: function (value) {
        var pass = value.match(
          /^[a-zA-Z0-9_]+$/
        );
        if (pass) {
          return true;
        }
        return 'Please enter a valid name';
      }
    },
    {
      type: 'password',
      name: 'password',
      message: 'Enter your password',
      validate: function (value) {
        if (value.length < 8) {
          return 'Password should be at least 8 characters';
        }
        return true;
      }
    },
    {
      type: 'checkbox',
      name: 'provider',
      message: 'Select a provider',
      choices: [
        { name: 'Google', value: 'google' },
        { name: 'Facebook', value: 'facebook' },
        { name: 'Github', value: 'github' },
        { name: 'Twitter', value: 'twitter' },
        { name: 'WeChat', value: 'wechat' },
        { name: 'QQ', value: 'qq' },
        { name: 'Weibo', value: 'weibo' },
      ]
    },
    {
      type: 'confirm',
      name: 'confirm',
      message: 'Do you want to create a new account?',
      default: true
    },
  ]).then(answers => {
    console.log(answers);
  })

多package项目管理 ​

npm workspaces ​

创建项目

sh
npm init --wworkspaces a
npm init -w a

项目创建后,会在顶级目录下创建node_modules并存储所有的package的依赖。

sh
node_modules
|—— @liuhuakawaiitestgroup
|   |—— test-a        -->../../a
|   |__ test-cli      -->../../cli

安装依赖或者更新依赖

为某个特定的workspace安装依赖

sh
npm install chalk --workspace test-a
npm install @liuhuakawaiitestgroup/test-cli -w cli

当修改了package的内容后(如:package.json中的name)

sh
npm install --workspace 
npm install --ws

发布项目

sh
npm publish --ws

脚手架开发技术沉淀 ​

esm不能使用filename和dirname ESM无法像CJS那样使用__filename和__dirname,因为ESM是运行在浏览器的,浏览器没有这个概念。所以需要通过下面的方式获取

js
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

优先加载本地脚手架版本 使用import-local优先加载本地版本

js
import importLocal from 'import-local';

if (importLocal(__filename)) {
    log.info('use local version');
}else {
    entry(process.argv.slice(2))
}

脚手架hook注册

js
import { program } from 'commander';

program
.hook('preAction', () => {
    preAction();
})

监听option特定选项

js
program.on('option:debug', () => {
    if (program.opts().debug) {
        log.verbos('debug', 'launch debug mode');
    }
})

未注册命令监听

js
program.on('command:*', (unknownCommand) => {
    log.error('未知命令', unknownCommand[0]);
})

ESM不支持json文件

js
import pkg from '../package.json' assert { type: 'json' };

镜像源问题 ​

如果使用的是淘宝镜像源(npmmirror.com)。要发布包到 NPM,我们需要使用 NPM 官方源。

sh
# 1. 首先切换到 npm 官方源
npm config set registry https://registry.npmjs.org/

# 2. 验证当前源
npm config get registry
# 应该显示 https://registry.npmjs.org/

# 3. 现在重新登录
npm login

# 4. 这时应该会提示输入用户名、密码和邮箱,而不是打开浏览器

完成发布后,如果你想切回淘宝镜像(因为安装包会更快),可以:

sh
npm config set registry https://registry.npmmirror.com/

你可以使用 nrm 来管理 npm 源,这样切换起来会更方便:

bash
# 安装 nrm
npm install -g nrm

# 查看可用的源
nrm ls

# 切换到 npm 官方源
nrm use npm

# 切换到淘宝源
nrm use taobao