手撸一个自己的前端脚手架

很多小伙伴一直很纠结什么是脚手架?其实核心功能就是创建项目初始文件,那问题又来了,市面上的脚手架不够用吗,为什么还要自己写?

只要提到脚手架你就会想到,vue-clicreate-react-appdva-cli ... 他们的特点不用多说那就是专一! 但是在公司开发中你会发现有以下一系列的问题!

  • 业务类型多
  • 多次造轮子,项目升级等问题
  • 公司代码规范,无法统一

在自己开发cli前,那肯定先要看些优秀的cli是如何实现的!虽然不是第一个吃螃蟹的人,那也要想想怎么吃更好_#

1.必备模块

我们先从大家众所周知的vue-cli入手,先来看看他都是用了哪些npm包来实现的

  • commander :参数解析 --help其实就借助了他~
  • inquirer :交互式命令行工具,有他就可以实现命令行的选择功能
  • download-git-repo :在git中下载模板
  • chalk :粉笔帮我们在控制台中画出各种各样的颜色
  • metalsmith :读取所有文件,实现模板渲染
  • consolidate :统一模板引擎

先幻想一下要实现的功能:

根据模板初始化项目 quick-cli create project-name
初始化配置文件 quick-cli config set repo repo-name

2.工程创建

废话不多说我们开始创建项目,编写自己的脚手架~~~

npm init -y # 初始化package.json
npm install eslint husky --save-dev # eslint是负责代码校验工作,husky提供了git钩子功能
npx eslint --init # 初始化eslint配置文件

2.1 创建文件夹

├── bin
│   └── www  // 全局命令执行的根文件
├── package.json
├── src
│   ├── main.js // 入口文件
│   └── utils   // 存放工具方法
│── .huskyrc    // git hook
│── .eslintrc.json // 代码规范校验

2.2 eslint配置

配置package.json校验src文件夹下的代码

"scripts": {
    "lint":"eslint src"
}

2.3 配置husky

使用git提交前,校验代码是否符合规范

{
  "hooks": {
    "pre-commit": "npm run lint"
  }
}

2.4 链接全局包

设置在命令下执行quick-cli时,调用bin目录下的www文件

"bin": {
    "quick-cli": "./bin/www"
}

www文件中使用main作为入口文件,并且以node环境执行此文件

#! /usr/bin/env node
require('../src/main.js');

链接包到全局下使用

npm link

我们已经可以成功的在命令行中使用quick-cli命令,并且可以执行main.js文件!

3.解析命令行参数

commander:The complete solution for node.js command-line interfaces

先吹一波commander,commander可以自动生成help,解析选项参数!

像这样 vue-cli --help!
像这样 vue-cli create <project-namne>

3.1 使用commander

npm install commander

main.js就是我们的入口文件

const program = require('commander');

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

执行quick-cli --help 是不是已经有一个提示了!

这个版本号应该使用的是当前cli项目的版本号,我们需要动态获取,并且为了方便我们将常量全部放到util下的constants文件夹中

const { name, version } = require('../../package.json');

module.exports = {
  name,
  version,
};

这样我们就可以动态获取版本号

const program = require('commander');

const { version } = require('./utils/constants');

program.version(version)
  .parse(process.argv);

3.2 配置指令命令

根据我们想要实现的功能配置执行动作,遍历产生对应的命令

const actionsMap = {
  create: { // 创建模板
    description: 'create project',
    alias: 'cr',
    examples: [
      'quick-cli create <template-name>',
    ],
  },
  config: { // 配置配置文件
    description: 'config info',
    alias: 'c',
    examples: [
      'quick-cli config get <k>',
      'quick-cli config set <k> <v>',
    ],
  },
  '*': {
    description: 'command not found',
  },
};
// 循环创建命令
Object.keys(actionsMap).forEach((action) => {
  program
    .command(action) // 命令的名称
    .alias(actionsMap[action].alias) // 命令的别名
    .description(actionsMap[action].description) // 命令的描述
    .action(() => { // 动作
      console.log(action);
    });
});

program.version(version)
  .parse(process.argv);

3.3 编写help命令

监听help命令打印帮助信息

program.on('--help', () => {
  console.log('Examples');
  Object.keys(actionsMap).forEach((action) => {
    (actionsMap[action].examples || []).forEach((example) => {
      console.log(`${example}`);
    });
  });
});

到现在我们已经把命令行配置的很棒啦,接下来就开始实现对应的功能!

4.create命令

create命令的主要作用就是去git仓库中拉取模板并下载对应的版本到本地,如果有模板则根据用户填写的信息渲染好模板,生成到当前运行命令的目录下~

action(() => { // 动作
  if (action === '*') { // 如果动作没匹配到说明输入有误
    console.log(acitonMap[action].description);
  } else { // 引用对应的动作文件 将参数传入
    require(path.resolve(__dirname, action))(...process.argv.slice(3));
  }
}

根据不同的动作,动态引入对应模块的文件

创建create.js

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

执行quick-cli create project,可以打印出 project

4.1 拉取项目

我们需要获取仓库中的所有模板信息,我的模板全部放在了git上,这里就以git为例,我通过axios去获取相关的信息~~~

npm i axios

这里借助下github的 api

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

module.exports = async (projectName) => {
  let repos = await fetchRepoList();
  repos = repos.map((item) => item.name);
  console.log(repos);
};

发现在安装的时候体验很不好,没有任何提示,而且最终的结果我希望是可以供用户选择的!

4.2 inquirer & ora

我们来解决上面提到的问题

npm i inquirer ora 
module.exports = async (projectName) => {
  const spinner = ora('fetching repo list');
  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 choice repo template to create project',
    choices: repos, // 选择模式
  });
  console.log(repo);
};

我们看到的命令行中选择的功能基本都是基于inquirer实现的,可以实现不同的询问方式。

4.3 获取版本信息

和获取模板一样,我们可以故技重施

const fetchTagList = async (repo) => {
  const { data } = await axios.get(`https://api.github.com/repos/quick-cli/${repo}/tags`);
  return data;
};
// 获取版本信息
spinner = ora('fetching repo tags');
spinner.start();
let tags = await fetchTagList(repo);
spinner.succeed(); // 结束loading

// 选择版本
tags = tags.map((item) => item.name);
const { tag } = await Inquirer.prompt({
  name: 'tag',
  type: 'list',
  message: 'please choice repo template to create project',
  choices: tags,
});

我们发现每次都需要去开启loading、关闭loading,重复的代码当然不能放过啦!我们来简单的封装下:

const wrapFetchAddLoding = (fn, message) => async (...args) => {
  const spinner = ora(message);
  spinner.start(); // 开始loading
  const r = await fn(...args);
  spinner.succeed(); // 结束loading
  return r;
};
// 这回用起来舒心多了~~~
let repos = await wrapFetchAddLoding(fetchRepoList, 'fetching repo list')();
let tags = await wrapFetchAddLoding(fetchTagList, 'fetching tag list')(repo);

4.4 下载项目

我们已经成功获取到了项目模板名称和对应的版本,那我们就可以直接下载啦!

npm i download-git-repo

很遗憾的是这个方法不是promise方法,没关系我们自己包装一下:

const { promisify } = require('util');
const downLoadGit = require('download-git-repo');
downLoadGit = promisify(downLoadGit);

node中已经帮你提供了一个现成的方法,将异步的api可以快速转化成promise的形式~

下载前先找个临时目录来存放下载的文件,来~我们继续配置常量:

const downloadDirectory = `${process.env[process.platform === 'darwin' ? 'HOME' : 'USERPROFILE']}/.template`;

这里我们将文件下载到当前用户下的.template文件中,由于系统的不同目录获取方式不一样,process.platform 在windows下获取的是 win32 ,我这里是mac 所以获取的值是 darwin,再根据对应的环境变量获取到用户目录

const download = async (repo, tag) => {
  let api = `quick-cli/${repo}`; // 下载项目
  if (tag) {
    api += `#${tag}`;
  }
  const dest = `${downloadDirectory}/${repo}`; // 将模板下载到对应的目录中
  await downLoadGit(api, dest);
  return dest; // 返回下载目录
};


// 下载项目
const target = await wrapFetchAddLoding(download, 'download template')(repo, tag);

如果对于简单的项目可以直接把下载好的项目拷贝到当前执行命令的目录下即可。

安装ncp可以实现文件的拷贝功能:

npm i ncp

像这样:

let ncp = require('ncp'); 
ncp = promisify(ncp);
// 将下载的文件拷贝到当前执行命令的目录下
await ncp(target, path.join(path.resolve(), projectName));

当然这里可以做的更严谨一些,判断一下当前目录下是否有重名文件等...,还有很多细节也需要考虑像多次创建项目是否要利用已经下载好的模板,大家可以自由的发挥~

4.5 模板编译

刚才说的是简单文件,那当然直接拷贝就好了,但是有的时候用户可以定制下载模板中的内容,拿package.json文件为例,用户可以根据提示给项目命名、设置描述等。

这里我在项目模板中增加了ask.js

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

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

下载的模板中使用了ejs模板

{
  "name": "vue-template",
  "version": "0.1.2",
  "private": "<%=private%>",
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build"
  },
  "dependencies": {
    "vue": "^2.6.10"
  },
  "autor":"<%=author%>",
  "description": "<%=description%>",
  "devDependencies": {
    "@vue/cli-service": "^3.11.0",
    "vue-template-compiler": "^2.6.10"
  },
  "license": "<%=license%>"
}

写到这里,大家应该想到了!核心原理就是将下载的模板文件,依次遍历根据用户填写的信息渲染模板,将渲染好的结果拷贝到执行命令的目录下

安装需要用到的模块

npm i metalsmith ejs consolidate
const MetalSmith = require('metalsmith'); // 遍历文件夹
let { render } = require('consolidate').ejs;
render = promisify(render); // 包装渲染方法

// 没有ask文件说明不需要编译
if (!fs.existsSync(path.join(target, 'ask.js'))) {
  await ncp(target, path.join(path.resolve(), projectName));
} else {
  await new Promise((resovle, reject) => {
    MetalSmith(__dirname)
      .source(target) // 遍历下载的目录
      .destination(path.join(path.resolve(), projectName)) // 输出渲染后的结果
      .use(async (files, metal, done) => {
        // 弹框询问用户
        const result = await Inquirer.prompt(require(path.join(target, 'ask.js')));
        const data = metal.metadata();
        Object.assign(data, result); // 将询问的结果放到metadata中保证在下一个中间件中可以获取到
        delete files['ask.js'];
        done();
      })
      .use((files, metal, done) => {
        Reflect.ownKeys(files).forEach(async (file) => {
          let content = files[file].contents.toString(); // 获取文件中的内容
          if (file.includes('.js') || file.includes('.json')) { // 如果是js或者json才有可能是模板
            if (content.includes('<%')) { // 文件中用<% 我才需要编译
              content = await render(content, metal.metadata()); // 用数据渲染模板
              files[file].contents = Buffer.from(content); // 渲染好的结果替换即可
            }
          }
        });
        done();
      })
      .build((err) => { // 执行中间件
        if (!err) {
          resovle();
        } else {
          reject();
        }
      });
  });
}

这里的逻辑就是像上面描述的那样,实现了模板替换,到此安装项目的功能就完成了!我们发现这里所有用到的地址路径都写死了,但是我们希望这是一个更通用的脚手架,可以让用户自己随意配置拉取地址~

5.config命令

新建config.js 的主要作用其实就是配置文件的读写操作,当然如果配置文件不存在,需要提供默认的值,我们先来编写常量:

constants.js的配置

const configFile = `${process.env[process.platform === 'darwin' ? 'HOME' : 'USERPROFILE']}/.quickrc`; // 配置文件的存储位置
const defaultConfig = {
  repo: 'quick-cli', // 默认拉取的仓库名
};

编写config.js

const fs = require('fs');
const { defaultConfig, configFile } = require('./util/constants');
module.exports = (action, k, v) => {
  if (action === 'get') {
    console.log('获取');
  } else if (action === 'set') {
    console.log('设置');
  }
  // ...
};

一般rc类型的配置文件都是ini格式也就是:

repo=quick-cli
register=github

下载 ini 模块解析配置文件

npm i ini

这里的代码很简单,无非就是文件操作了:

const fs = require('fs');
const { encode, decode } = require('ini');
const { defaultConfig, configFile } = require('./util/constants');

const fs = require('fs');
const { encode, decode } = require('ini');
const { defaultConfig, configFile } = require('./util/constants');

module.exports = (action, k, v) => {
  const flag = fs.existsSync(configFile);
  const obj = {};
  if (flag) { // 配置文件存在
    const content = fs.readFileSync(configFile, 'utf8');
    const c = decode(content); // 将文件解析成对象
    Object.assign(obj, c);
  }
  if (action === 'get') {
    console.log(obj[k] || defaultConfig[k]);
  } else if (action === 'set') {
    obj[k] = v;
    fs.writeFileSync(configFile, encode(obj)); // 将内容转化ini格式写入到字符串中
    console.log(`${k}=${v}`);
  } else if (action === 'getVal') { 
    return obj[k];
  }
};

getVal这个方法是为了在执行create命令时可以获取到配置变量

const config = require('./config');
const repoUrl = config('getVal', 'repo');

这样我们可以将create方法中所有的quick-cli全部用获取到的值替换掉啦!

到此基本核心的方法已经ok!剩下的大家可以自行扩展啦!

6.项目发布

终于走到最后一步,我们将项目推送到npm上,流程不再赘述啦!

nrm use npm
npm publish # 已经发布成功~~

可以通过npm install quick-cli -g 进行安装!


希望您能通过文章有所收获!
如果您觉得文章不错,欢迎关注微信公众号,持续获取更多内容!


默认标题_方形二维码_2019.08.28.png
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容