当前位置: 首页 > article >正文

从零构建自己的脚手架

从零构建自己的脚手架

简介

什么是CLI

CLI 全称是 Command Line Interface,是一类通过命令行交互的终端工具。日常工作中常用的脚手架有 vue-cli、create-react-app、angular-cli 等,都是通过简单的初始化命令,完成内容的快速构建。

为什么需要CLI

CLI 可以帮助我们更高效地操作计算机系统,我们可以将有规律可循的、重复的、繁琐的、模板化的工作,集成到CLI工具中。

  • GUI:更侧重易用性,用户通过点击图形界面,完成相关配置

  • CLI:更侧重操作效率,通过命令组合自动化操作、批量操作等

脚手架的简单雏形

脚手架就是在启动的时候询问一些简单的问题,并且通过用户回答的结果去渲染对应的模板文件,例如我们在使用 vue-cli创建一个 vue 项目时的时候 👇

step1:运行创建命令

vue create hello-world

step2:询问用户问题

image

image

step3:生成符合用户需求的项目文件

image

参考上面的流程我们可以自己来 搭建一个简单的脚手架雏形。

1. 在命令行启动 cli

目标: 实现在命令行执行 my-node-cli 来启动我们的脚手架

1.1 新建项目目录 my-node-cli

mkdir my-node-cli 
cd my-node-cli 
npm init

1.2 新建程序入口文件 cli.js

$ touch cli.js # 新建 cli.js 文件

在 package.json 文件中指定入口文件为 cli.js 👇

{
  "name": "my-node-cli",
  "version": "1.0.0",
  "description": "",
  "main": "cli.js",
  "bin": "cli.js", // 手动添加入口文件为 cli.js
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

此时项目目录结构:

my-node-cli      
├─ cli.js        
└─ package.json  

打开 cli.js 进行编辑

文件头部必须有 #!/usr/bin/env node,这是头部声明代码,用来告诉系统使用 NodeJS 执行脚本;如不声明,默认按shell去解析执行。

#! /usr/bin/env node

console.log('my-node-cli working~')

1.3 npm link 链接到全局

npm link

执行完成 ✅

image

我们就可以来测试了,在命令行中输入 my-node-cli 执行一下

image

这里我们就看到命令行中打印了:【my-node-cli working~】,此时最简单的一个demo就完成了👏

2. 询问用户信息

实现与询问用户信息的功能需要引入 inquirer.js 👉 文档看这里

npm install inquirer@8.2.5 --dev // 版本要低于9,否则报语法错误,详见【遇到的问题】

接着我们在 cli.js 来设置我们的问题

#! /usr/bin/env node

// #! 用于指定脚本的解释程序,Node CLI 应用入口文件必须要有这样的文件头

const inquirer = require('inquirer');

inquirer.prompt([
  {
    type: 'input', //type: input, number, confirm, list, checkbox ... 
    name: 'name', // key 名
    message: 'Your name', // 提示信息
    default: 'my-node-cli' // 默认值
  }
]).then(answers => {
  console.log(answers); // 打印互动的输入结果
})

在命令行输入 my-node-cli 看一下执行结果

image

这里我们就拿到了用户输入的项目名称 { name: ‘hello-ranran’ }, 👌

3. 生成对应的文件

3.1 新建模版文件夹

mkdir templates # 创建模版文件夹 

3.2 新建 index.html 和 common.css 两个简单的示例文件

// index.html 
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>
        <!-- ejs 语法 -->
        <%= name %>
    </title>
  </head>
  <body>
    <h1><%= name %></h1>
  </body>
</html>

/* common.css */
body {
    margin: 20px auto;
    background-color: azure;
}

此时的代码结构:

my-node-cli           
├─ templates          
│  ├─ common.css      
│  └─ index.html      
├─ cli.js             
├─ package-lock.json  
└─ package.json       

3.3 接着完善文件生成逻辑

这里借助 ejs 模版引擎将用户输入的数据渲染到模版文件上

npm install ejs --save

完善后到 cli.js 👇

#! /usr/bin/env node

// #! 用于指定脚本的解释程序,Node CLI 应用入口文件必须要有这样的文件头

const inquirer = require('inquirer');
const path = require('path');
const fs = require('fs');
const ejs = require('ejs');

inquirer
  .prompt([
    {
      type: 'input', //type: input, number, confirm, list, checkbox ...
      name: 'name', // key 名
      message: 'Your name', // 提示信息
      default: 'my-node-cli' // 默认值
    }
  ])
  .then((answers) => {
    const destUrl = path.join(__dirname, 'templates'); // 模版文件目录
    const cwdUrl = process.cwd(); // 生成文件目录,process.cwd() 对应控制台所在目录
    // 从模版目录中读取文件
    fs.readdir(destUrl, (err, files) => {
      if (err) throw err;
      // 使用 ejs 渲染对应的模版文件
      files.forEach((file) => {
        // renderFile(模版文件地址,传入渲染数据)
        ejs.renderFile(path.join(destUrl, file), answers).then((data) => {
          // 生成 ejs 处理后的模版文件
          fs.writeFileSync(path.join(cwdUrl, file), data);
        });
      });
    });
  });

同样,在控制台执行一下 my-node-cli ,此时 index.html、common.css 已经成功创建 ✔

image

我们打印一下当前的目录结构 👇

my-node-cli           
├─ templates          
│  ├─ common.css      
│  └─ index.html      
├─ cli.js             
├─ common.css .................... 生成对应的 common.css 文件        
├─ index.html .................... 生成对应的 index.html 文件        
├─ package-lock.json  
└─ package.json    

打开生成的 index.html 文件看一下

image

用户输入的 { name: ‘my-app’ } 已经添加到了生成的文件中了 ✌️

热门脚手架工具库

实际生产中搭建一个脚手架或者阅读其他脚手架源码的时候需要了解下面这些工具库 👇

名称简介
commander命令行自定义指令
inquirer命令行询问用户问题,记录回答结果
chalk控制台输出内容样式美化
ora控制台 loading 样式
figlet控制台打印 logo
cli-table3控制台输出表格
download-git-repo下载远程模版
fs-extra系统fs模块的扩展,提供了更多便利的 API,并继承了fs模块的 API
cross-spawn支持跨平台调用系统上的命令

重点介绍下面这些,其他工具可以查看说明文档

1. commander 自定义命令行指令(在线文档)

安装依赖

npm install commander

完善cli.js代码

#! /usr/bin/env node

const program = require('commander');

program
  .version('0.1.0')
  .command('create <name>')
  .description('create a new project')
  .action((name) => {
    // 打印命令行输入的值
    console.log('project name is ' + name);
  });

program.parse();

npm link 链接到全局

  • 执行 npm link 将应用 my-node-cli 链接到全局

  • 完成之后,在命令行中执行 my-node-cli

看下命令行的输出内容

image

这个时候就有了 my-node-cli命令使用的说明信息

image

image

2. chalk 命令行美化工具(在线文档)

安装依赖

npm install chalk@4.1.2

完善cli.js代码

#! /usr/bin/env node

const program = require('commander');
const chalk = require('chalk');

program
  .version('0.1.0')
  .command('create <name>')
  .description('create a new project')
  .action((name) => {
    // 打印命令行输入的值

    // 文本样式
    console.log('project name is ' + chalk.bold(name));

    // 颜色
    console.log('project name is ' + chalk.cyan(name));
    console.log('project name is ' + chalk.green(name));

    // 背景色
    console.log('project name is ' + chalk.bgRed(name));

    // 使用RGB颜色输出
    console.log('project name is ' + chalk.rgb(4, 156, 219).underline(name));
    console.log('project name is ' + chalk.hex('#049CDB').bold(name));
    console.log('project name is ' + chalk.bgHex('#049CDB').bold(name));
  });

program.parse();

npm link 链接到全局

  • 执行 npm link 将应用 my-node-cli 链接到全局

  • 完成之后,在命令行中执行 my-node-cli create my-app

看下命令行的输出内容

image

具体的样式对照表如下

image

3. inquirer 命令行交互工具(在线文档)

inquirer 在脚手架工具中的使用频率是非常高的,在上文脚手架的简单雏形中,我们已经使用到了,这里就不过多介绍了。

4. ora 命令行 loading 动效(在线文档)

安装依赖

npm install ora@5.x

完善cli.js代码

#! /usr/bin/env node
const ora = require('ora');
// 自定义文本信息
const message = 'Loading...';
// 初始化
const spinner = ora(message);
// 开始加载动画
spinner.start();
setTimeout(() => {
  // 修改动画样式

  // Type: string
  // Default: 'cyan'
  // Values: 'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white' | 'gray'
  spinner.color = 'red';
  spinner.text = 'Loading rainbows';

  setTimeout(() => {
    // 加载状态修改
    spinner.stop(); // 停止
    spinner.succeed('Loading succeed'); // 成功 ✔
    // spinner.fail(text?);  失败 ✖
    // spinner.warn(text?);  提示 ⚠
    // spinner.info(text?);  信息 ℹ
  }, 2000);
}, 2000);

npm link 链接到全局

  • 执行 npm link 将应用 my-node-cli 链接到全局

  • 完成之后,在命令行中执行 my-node-cli

看下命令行的输出内容

请至钉钉文档查看附件《未命名_副本.mov》

5. cross-spawn 跨平台shell工具(在线文档

安装依赖

npm install cross-spawn

完善cli.js代码

#! /usr/bin/env node

const spawn = require('cross-spawn');
const chalk = require('chalk');

// 定义需要按照的依赖
const dependencies = ['vue', 'vuex', 'vue-router'];

// 执行安装
const child = spawn('npm', ['install', '-D'].concat(dependencies), {
  stdio: 'inherit'
});

// 监听执行结果
child.on('close', function (code) {
  // 执行失败
  if (code !== 0) {
    console.log(chalk.red('Error occurred while installing dependencies!'));
    process.exit(1);
  }
  // 执行成功
  else {
    console.log(chalk.cyan('Install finished'));
  }
});

看下命令行的输出内容

image

成功安装 👍

搭建自己的脚手架

需要实现的功能

  1. 通过 ranran-cli create 命令启动项目

  2. 如果重名则询问用户是否进行覆盖

  3. 远程拉取模板文件

搭建步骤拆解

  1. 创建项目

  2. 创建脚手架启动命令(使用 commander)

  3. 如果重名则询问用户是否进行覆盖

  4. 下载远程模板(使用 download-git-repo)

代码实现

目录结构

image

package.json

{
  "name": "ranran-cli",
  "version": "1.0.0",
  "description": "simple vue cli",
  "main": "index.js",
  "bin": {
    "ranran-cli": "./bin/cli.js"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "files": [
    "bin",
    "lib"
  ],
  "author": "ranran",
  "keywords": [
    "ranran-cli",
    "脚手架"
  ],
  "license": "ISC",
  "dependencies": {
    "chalk": "^4.1.2",
    "commander": "^10.0.1",
    "cross-spawn": "^7.0.3",
    "download-git-repo": "^3.0.2",
    "ejs": "^3.1.9",
    "figlet": "^1.6.0",
    "fs-extra": "^11.1.1",
    "inquirer": "^8.2.5",
    "ora": "^5.4.1"
  }
}

脚手架启动文件****cli.js

#! /usr/bin/env node

const program = require('commander');
const figlet = require('figlet');

program
  .command('create <app-name>')
  .description('create a new project')
  .option('-f, --force', 'overwrite target directory if it exist') // 是否强制创建,当文件夹已经存在
  .action((name, options) => {
    // 在 create.js 中执行创建任务
    require('../lib/create.js')(name, options);
  });

program.on('--help', () => {
  console.log(
    '\r\n' +
      figlet.textSync('ranran', {
        font: 'Ghost',
        horizontalLayout: 'default',
        verticalLayout: 'default',
        width: 80,
        whitespaceBreak: true
      })
  );
});

// 解析用户执行命令传入参数
program.parse(process.argv);

创建 lib 文件夹并在文件夹下创建 create.js

const path = require('path');

// fs-extra 是对 fs 模块的扩展,支持 promise 语法
const fs = require('fs-extra');
const inquirer = require('inquirer');
const Generator = require('./utils');

module.exports = async function (name, options) {
  // 当前命令行选择的目录
  const cwd = process.cwd();
  // 需要创建的目录地址
  const targetAir = path.join(cwd, name);

  // 目录是否已经存在?
  if (fs.existsSync(targetAir)) {
    // 是否为强制创建?
    if (options.force) {
      await fs.remove(targetAir);
    } else {
      // 询问用户是否确定要覆盖
      let { action } = await inquirer.prompt([
        {
          name: 'action',
          type: 'list',
          message: 'Target directory already exists Pick an action:',
          choices: [
            {
              name: 'Overwrite',
              value: 'overwrite'
            },
            {
              name: 'Cancel',
              value: false
            }
          ]
        }
      ]);

      if (!action) {
        return;
      } else if (action === 'overwrite') {
        // 移除已存在的目录
        console.log(`\r\nRemoving...`);
        await fs.remove(targetAir);
      }
    }
  }

  // 创建项目
  const generator = new Generator(name);

  // 开始创建项目
  generator.create();
};

创建 lib 文件夹并在文件夹下创建 utils.js

const downloadGitRepo = require('download-git-repo');
const ora = require('ora');
const chalk = require('chalk');

const bsInitOriginUrl = '@git/xx.xxx.x.x:xx/xx.git';
const bsInitUrl = 'gitlab:xx.xxx.x.x:xx/xx#dev';

// 项目模板远程下载
const downloadTemplate = async (ProjectName, api) => {
  return new Promise((resolve, reject) => {
    downloadGitRepo(api, ProjectName, { clone: true }, (err) => {
      if (err) {
        reject(err);
      } else {
        resolve();
      }
    });
  });
};

class Generator {
  constructor(name) {
    // 目录名称
    this.name = name;
  }

  async download() {
    let loading = ora().start(`Start cloning template... ${chalk.yellow(bsInitOriginUrl)}`);
    await downloadTemplate(this.name, bsInitUrl);
    setTimeout(() => {
      loading.succeed(`\r\nSuccessfully created project ${chalk.cyan(this.name)}`);
      console.log(`\r\n  cd ${chalk.cyan(this.name)}`);
      console.log('  npm run dev\r\n');
    }, 2000);
  }

  // 下载模板到模板目录
  async create() {
    // 下载模板到模板目录
    await this.download();
  }
}

module.exports = Generator;

遇到的问题

使用my-node-cli报错

image

原因:发现安装的是看了一下【inquirer】的版本号是9以上的

解决方法:降【inquirer】的版本到8.2.5

npm i inquirer@8.2.5

npm link只需要执行一次

代码更改后,不需要重新执行npm link

如果是修改了执行命令的别名,则需要重新执行npm link

image

删除软链接

更改入口文件后重新进行npm link报错,已修改入口文件地址。

image

使用npm unlink、npm link --force均无效。

image

image

解决方法:找到npm软链的目录,删除相应的文件,有两处都需要删干净。

image

重新 npm link后生效。

image

参考文档

https://juejin.cn/post/6966119324478079007#heading-38

https://juejin.cn/post/7178666619135066170#heading-21


http://www.kler.cn/news/18554.html

相关文章:

  • 【多线程初阶三】简单了解wait和notify方法~
  • [Go语言实战]并发模式runner
  • iOS输入限制表情输入、最大字符、正则过滤
  • Geoffrey Hinton对于AI的警示 20230507
  • SQL 招聘网站岗位数据分析
  • 数据挖掘笔记
  • Spring-AOP
  • 文心一言 VS chatgpt (6)-- 算法导论2.3 1~2题
  • macOS的CAOpenGLLayer中如何启用OpenGL3.2 core profile
  • Oracle监控账号创建【Prometheus】
  • webstorm 创建harthat项目
  • AI 工具合辑盘点(七)持续更新 之 AI 音乐制作工具
  • 【运动规划算法项目实战】如何利用AABB作碰撞检测(附ROS C++代码)
  • SQL学习日记
  • 从文字到语义:文本分词和词性标注的原理与实现
  • Gradio的web界面演示与交互机器学习模型,安装和使用《1》
  • 拐点已至!被比亚迪赶超,大众中国打响「翻身战」
  • 单元测试 - 集成H2 Dao测测试
  • 【Redis7】Redis7 持久化(重点:RDB与AOF重写机制)
  • 名称空间(namespaces)与作用域
  • [LeetCode周赛复盘] 第 344 场周赛20230507
  • 从不同视角来看待API数据接口
  • Unity用脚本获取物体和组件(下)
  • MySQL基础(三)基本的SELECT语句
  • eSIM证书要求-证书验证-EID
  • 第1章 Nginx简介
  • 187页9万字企业大数据治理与云平台实施方案(word)
  • sentinel 随笔 0-责任链
  • 俩小伙一晚上写了个 AI 应用,月入两万??(文末附开发教程)
  • Scrum敏捷开发工具-单团队敏捷开发管理