# 手写脚手架工具

# 什么是cli

命令行界面(英语:command-line interface,[缩写]:CLI)是在图形用户界面得到普及之前使用最为广泛的[用户界面],它通常不支持[鼠标],用户通过键盘输入指令,计算机接收到指令后,予以执行。

# 所需依赖包

  • commander 参数解析 如:-V、--help
  • axios 接口调用
  • inquirer 交互式命令行工具
  • download-git-repo 下载并提取git存储库
  • metalsmith 读取所有文件,实现模板渲染
  • consolidate 统一模板引擎
  • ncp 异步递归文件和目录复制
  • ora loading加载器
  • ejs 模版编译
  • util 工具方法

# 目录结构

├── bin 
│ └── www // 全局命令执行的根文件 
├── package.json 
├── src 
│ ├── constants.js // 存放常量
│ ├── create.js // create命令逻辑 
│ ├── main.js // 入口文件 

# 实现功能

vue-cli create projectName

# 创建工程

创建项目文件夹

mkdir vue-cli

初始化项目并添加配置

  • 初始化package.json
npm init -y
  • 编写入口文件,在bin文件夹下创建www.js文件,写入以下代码
#! /usr/bin/env node

require('../src/main');
  • 配置bin命令,指向www文件
"bin": {
  "vue-cli": "./bin/www"
}
  • 链接包到全局
npm link

链接成功后可在命令行执行vue-cli命令测试

# 添加参数解析

  • 安装模块
npm install --save-dev commander
  • 编写main.js文件
const program = require('commander');

// process.argv为用户在命令行传入的参数
program.version('0.0.1').parse(process.argv);

执行vue-cli -V出现0.0.1即为成功

  • 动态获取版本号package.json文件中获取
// constants.js
const { version } = require('../package.json');

module.exports = {
  version,
};

// main.js
const { version } = require('./constants');
program.version(version).parse(process.argv);

# 添加指令命令

根据我们想要实现的功能配置执行的动作。

// 配置3个指令命令
const mapActions = {
  // 创建项目
  create: {
    // 别名
    alias: 'c',
    // 描述
    description: 'create a project',
    // 示例
    examples: [
      'td-cli create <project-name>',
    ],
  },
  config: {
    alias: 'conf',
    description: 'config project variable',
    examples: [
      'td-cli config set <k><v>',
      'td-cli config get <k>',
    ],
  },
  '*': {
    alias: '',
    description: 'command not found',
    examples: [],
  },
};

// 循环添加命令
Reflect.ownKeys(mapActions).forEach(action => {
  program
    .command(action)  // 配置命令的名字
    .alias(mapActions[action].alias)  // 命令的别名
    .description(mapActions[action].description)  // 命令对应的描述
    .action(_ => {
      if (action === '*') {
        // 访问不到对应的命令 就打印找不到命令
        console.log(mapActions[action].description);
      } else {
        console.log(action);
      }
    });
});

// 监听help事件
program.on('--help', _ => {
  console.log('\nExample:');
  // 循环打印出示例
  Reflect.ownKeys(mapActions).forEach(actions => {
    mapActions[actions].examples.forEach(example => {
      console.log(example);
    });
  });
});

执行vue-cli --help可打印出配置的命令信息

# 实现create命令

create命令的主要作用是拉取git仓库中的对应模版到本地

// main.js
.action(_ => {
	if (action === '*') {
  	console.log(mapActions[action].description);
  } else {
    // console.log(action);
    require(path.resolve(__dirname, action))(...process.argv.slice(3))
  }
});

// 创建create.js
module.exports = async (projectName) => {
  console.log(projectName);
};

执行vue-cli create projectName可以打印出projectName

# 拉取项目

我们需要获取仓库的所有模版信息(以github为例)。通过使用axios获取获取相关信息

npm install --save-dev axios

拉去github上的仓库模版

// create.js
const axios = require('axios');

// 获取仓库列表
const fetchRopeList = async () => {
  // 获取当前组织中的所有仓库信息,这个仓库中存放的都是项目模版
  const { data } = await axios.get('https://api.github.com/orgs/td-cli/repos');
  return data;
}

module.exports = async () => {
  let repos = await fetchRepoList();
  // 获取模版名称
  repos = repos.map((item) => item.name);
  console.log(repos);
};

# 添加loading和命令行交互工具

npm install --save-dev inquirer ora
// create.js
const org = require('ora');
const Inquirer = require('inquirer');

module.exports = async () => {
  // 初始化loading
  const spinner = ora('fetching template .....');
  spinner.start(); // 开始loading
  let repos = await fetchRepoList();
  spinner.succeed(); // 结束loading
  // 获取模版名称
  repos = repos.map((item) => item.name);
  // 选择模版
  const {
    repo,
  } = await Inquirer.prompt({
    // 获取选择后的结果
    name: 'repo',
    // 什么方式显示在命令行
    type: 'list',
    // 提示信息
    message: 'please choise a template to create project',
    // 选择的数据/数据源
    choices: repos,
  });
  // 获取到用户选择模版名
  console.log(repo);
};

# 获取模版版本信息

封装loading

// create.js
/**
 * 封装loading效果
 * @param {*} fn 方法
 * @param {*} message 提示语
 */
const waitFnloading = (fn, message) => async (...args) => {
  const spinner = org(message);
  spinner.start();
  const result = await fn(...args);
  spinner.stop();
  return result;
}

获取版本信息

// create.js
// 获取版本列表
const fetchTagList = async repo => {
  const { data } = await axios.get(`https://api.github.com/repos/td-cli/${repo}/tags`);
  return data;
}

...
let tags = await waitFnloading(fetchTagList, 'fetching tags...')(repo);
tags = tags.map(item => item.name);
const { tag } = await Inquirer.prompt({
	name: 'tag',
  type: 'list',
  message: 'please choise a tag to create project',
  // 数据源
  choices: tags,
})
...

# 下载项目

获取项目临时存放目录

// constants.js
// Windows与Mac的用户目录不一致需要兼容
const downloadDirectory = `${process.env[process.platform === 'darwin' ? 'HOME' : 'USERPROFILE']}/.template`;

module.exports = {
  version,
  downloadDirectory
};
npm install --save-dev download-git-reop util
// create.js
const { promisify } = require('util');

// 由于`downloadGitRepo`不是`promise`方法,所以需要用`promisify`包装一下
let downloadGitRepo = require('download-git-repo');
downloadGitRepo = promisify(downloadGitRepo);

// 下载模版,返回模版存放目录
const download = async (repo, tag) => {
  let api = `td-cli/${repo}`;
  if (tag) {
    api += `#${tag}`;
  }
  const dest = `${downloadDirectory}/${repo}`;
  await downloadGitRepo(api, dest);
  return dest;
}

...
const dest = await waitFnloading(download, 'download template...')(repo, tag);
...

需要将项目拷贝到当前执行命令的目录下

npm install ncp --save-dev
// create.js
let ncp = require('ncp');
ncp = promisify(ncp);

...
// 拷贝文件
await ncp(dest, path.join(path.resolve(), projectName))
...

这样就创建了一个简单的模版项目

# 复杂模版下载

通常用户可以定制下载模版的内容,例如package.json文件,用户可以根据提示设置项目名、描述等。项目模版中增加了ask.js文件

module.exports = [
	{
		type: 'confirm',
    name: 'private',
    message: 'ths resgistery is private?',
  },
  {
    type: 'input',
    name: 'author',
    message: 'author?',
  },
  {
    type: 'input',
    name: 'description',
    message: 'description?',
  },
  {
    type: 'input',
    name: 'license',
    message: 'license?',
  },
]

根据对应的询问生成最终的package.js

安装需要的模块

npm i metalsmith ejs consolidate --save-dev
// create.js
let { render } = require('consolidate').ejs;
render = promisify(render);
const Metalsmith = require('metalsmith');

...
if (!fs.existsSync(path.join(dest, 'ask.js'))) {
  // 简单模版 -> 拷贝文件
  await ncp(dest, path.resolve(projectName))
} else {
  await new Promise((resolve, reject) => {
    Metalsmith(__dirname)
      .source(dest)
      .destination(path.resolve(projectName))
      .use(async (files, metal, done) => {
        // 读取ask.js文件
        const args = require(path.join(dest, 'ask.js'));
        const obj = await Inquirer.prompt(args);

        // 下传用户选择信息
        const meta = metal.metadata();
        Object.assign(meta, obj);
        
        // 删除ask.js文件
        delete files['ask.js'];

        done();
      })
      .use(async (files, metal, done) => {
        // 获取用户填写的信息渲染模版
        const obj = metal.metadata();
        Reflect.ownKeys(files).forEach(async file => {
          // 只处理js和json文件
          if (file.includes('.js') || file.includes('.json')) {
            // 获取文件内容
            let content = files[file].contents.toString();
            // 判断是否存在模版变量
            if (content.includes('<%')) {
              content = await render(content, obj);
              // 渲染
              files[file].contents = Buffer.from(content);
            }
          }
        });
        done();
      })
      .build(error => {
        if (error) {
          reject();
        } else {
          resolve();
        }
      });
  })
}
...

# 优化

判断当前目录是否存在相同的文件夹

...
// 判断当前目录下是否存在相同文件夹
if (fs.existsSync(path.resolve(projectName))) {
  // throw new Error('存在相同目录');
  const { flge } = await Inquirer.prompt({
    name: 'flge',
    type: 'confirm',
    message: 'Project directory exists, do you want to override it?'
  })
  if (!flge) return false;
}
...

# 发布工具

nrm use npm  // 准备发布包
npm addUser  // 填写账号密码
npm publish  // 已经发布成功

源码地址:https://github.com/sunpu007/vue-cli (opens new window)

上次更新时间: 11/7/2021, 2:23:15 PM

添加微信

获取阿里云更多优惠

阿里云最新活动