8

【前端架构必备】手摸手带你搭建一个属于自己的脚手架

 2 years ago
source link: https://xie.infoq.cn/article/c8fc6751ef7dd938ba4b2e5e3
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

【前端架构必备】手摸手带你搭建一个属于自己的脚手架

作者:战场小包

看这篇文章之前,你肯定会疑惑,为什么你会写一个属于自己的脚手架?

脚手架相比大家都不陌生,比如我们经常使用 vue-cli ,它可以帮助我们快速的初始化一个项目,无需从零配置,极大方便我们的开发。但强大是有限的,公共的脚手架有时并不能满足我们的实际开发。

史上最贴心前端脚手架开发辅导 的作者大佬讲到几点理由,小包认为讲的很好:

公司中已经积累了部分项目逻辑,例如换肤、接口请求、项目架构、国际化等,如果此时公司新启一个项目,我们需要将原有项目的公共逻辑 ctrl + cctrl + v

但这种复制粘贴的方式是存有很多缺点的:

  • 重复性劳动,繁琐且浪费时间

  • 容易忽略项目中的配置设置

  • copy 过来的模板会存有重复代码

诸如此类,如果我们自己开发一套脚手架,自己定制自己的模板,复制粘贴的人工流程就会转换为 cli 的自动化流程。怎么样,心动吗?

但对小包来说,小包的工作经验并没有那么多,还考虑不到工作中的种种繁琐,小包有自己的想法,主要有几点:

  • vue-cli 的痛击 : 小包最近使用 vue-cli 创建项目时总是爆出莫名其妙的错误,解决方案一直未能查找到,每次只能卸载重装一遍然后才能正常使用

  • 多个 cli 的繁琐: 最近小包也开始学习 react ,小包一直幻想如果两者使用一个脚手架就好了

  • 架构成长: 架构这两个字太吸引人了,脚手架的搭建在小包心里一直是架构的必备技能。

  • nodejs 成长: 利用 nodejs 实现一个脚手架也是对自己 nodejs 水平的一大锻炼。

学习本文,你能收获:

  • 🌟 掌握开发脚手架的全流程

  • 🌟 学会命令行开发常用的多种第三方模块

  • 🌟 拥有一个属于自己的脚手架

文章最开始,我们可以先看一下 zc-cli 的功能展示。

ac750005eb9adbf570a10058adc2d27b.gif

脚手架实现分析

我们以 vue-cli 为例子,来分析一下简单脚手架需要具备的一些功能:

vue-cli 使用 vue 作为全局命令,同时提供了很多指令。

  • vue --version 可以查看 vue 版本

  • vue --help 可以查看帮助文档

  • vue create xxx 可以创建一个项目

vue 创建项目

我们以 vue 创建项目为例子,分析一下脚手架应该具备的功能

Step1: 运行创建命令

vue create demo

Step2: 交互式用户选择

08ad5f68a6a8d42c23d196d33945f6de.png
ba14407b9d3f1c9244c333692e74b72e.png

用户可以在命令行中选择自己需要的版本或者配置。

Step3: 用户选择完毕后,根据用户选择生成用户需求的项目文件

从上面的脚手架流程来看,我们可以大致总结出脚手架的功能:

  • 通过命令行与用户交互

  • 根据用户的选择生成对应的文件

基于 vue-cli 的使用经验,我们来分析一下脚手架的基本实现流程:

  1. 首先我们要初始化一个项目

  2. 创建项目 zc-cli,配置项目所需的信息

  3. npm link 项目至全局,这样本地可以临时调用指令

  4. 基础指令配置: 例如 --help --version

  5. 复杂指令配置: create 指令

  6. 实现命令行交互功能: 基于 inquirer 实现命令行交互

  7. 拉取项目模板

  8. 根据用户的选择动态生成项目

使用的三方库

我们在开发 cli 时,会用到很多第三方模块,接下来先给大家介绍一下使用的第三方模块。为了介绍起来更加清晰,我们先来创建一个 demo 项目用作演示。

初始化 demo 项目

  1. 创建 demo 文件夹,执行 npm init -y 初始化仓库,生成 package.json 文件

{  "name": "demo",  "version": "1.0.0",  "description": "",  "main": "index.js",  "scripts": {    "test": "echo \"Error: no test specified\" && exit 1"  },  "keywords": [],  "author": "",  "license": "ISC"}
  1. demo 下创建 bin 文件夹,并在里面创建 node 入口文件 enter

  2. 编辑 enter 文件,并将其配置到 package.json 中的 bin 字段

// enter#! /usr/bin/env node// 为了方便测试console.log("hello demo");
// package.json// bin 字段也支持对象模式配置"bin": "bin/enter",

为什么需要在文件头部添加 #! /usr/bin/env node

  • #! 符号的名称叫 Shebang,用于指定脚本的解释程序

  • 开发 npm 包时,需要在入口文件指定该指令,否则会抛出 No such file or directory 错误

  1. npm link 到全局

demo 文件目录下运行 npm link 将项目链接到本地环境,就可以临时实现 demo 指令全局调用。(--force 参数可以强制覆盖原有指令)

a5ab32242ff9dad4d686f3b5602876a4.png
  1. 运行 demo 命令,命令行成功打印出 hello demodemo 项目配置成功。

commander —— 命令行指令配置

第三方库 commander 来实现脚手架命令的配置。更多详细信息可以参考commander 中文文档

Step1: 安装 commander 依赖,并导入 demo 项目中

// 安装依赖npm install commmander
// enterconst program = require("commander");// 解析用户执行时输入的参数// process.argv 是 nodejs 提供的属性// npm run server --port 3000// 后面的 --port 3000 就是用户输入的参数program.parse(process.argv);

commander 自身附带了 --help 指令,导入成功后,在命令行执行 demo --help,可以打印出基本的帮助提示。

d46c7a55534204dcaab7a75745cb16cf.png

Step2: version 方法可以配置版本信息提示

Step3: name 和 usage 方法分别配置 cli 名称和 --help 第一行提示

program.name("demo").usage(`<command> [option]`).version(`1.0.0`);

再次执行 demo --help,命令行的消息提示就比较完善了。

4dc9c6ad572659956fc81135af375c54.png

更复杂的方法我们边使用边介绍。

chalk —— 命令行美化工具

chalk 可以美化我们在命令行中输出内容的样式,例如实现多种颜色,花里胡哨的命令行提示等。

Step1: 首先先安装 chalk 依赖并引入

Step2: 就可以开始输出各种花里胡哨的命令行提示

//enterconst chalk = require("chalk");console.log(`hello ${chalk.blue("world")}`);console.log(chalk.blue.bgRed.bold("Hello world!"));console.log(  chalk.green(    "I am a green line " +      chalk.blue.underline.bold("with a blue substring") +      " that becomes green again!"  ));
2a388a6a16739e8b20d7c5079afcf981.png

怎么样,够花里胡哨吧。但安装 chalk 时一定要注意安装 4.x 版本(小包使用的是 4.0.0),否则会因为版本过高,爆出错误

inquirer —— 命令行交互工具

上面我们再使用 vue create 命令时,其中有一个步骤是交互式用户选择,这个交互式功能就是由 inquirer 实现的。

inquirer 支持 Confirm 确认,List 单选,Checkbox 多选等多种交互方式。

这里我们来模拟实现 vue 的多选功能:

new Inquirer.prompt([  {    name: "vue",    // 多选交互功能    // 单选将这里修改为 list 即可    type: "checkbox",    message: "Check the features needed for your project:",    choices: [      {        name: "Babel",        checked: true,      },      {        name: "TypeScript",      },      {        name: "Progressive Web App (PWA) Support",      },      {        name: "Router",      },    ],  },]).then((data) => {  console.log(data);});
81c5b643216688ed4db49ad26d0150d0.png

ora —— 命令行 loading 效果

ora 使用非常简单,可以直接看下面的案例。更多使用: ora 文档

利用 ora 来实现一个简单的命令行 loading 效果。

const ora = require("ora");// 定义一个loadingconst spinner = ora("Loading unicorns");// 启动loadingspinner.start();setTimeout(() => {  spinner.color = "yellow";  spinner.text = "Loading rainbows";}, 1000);// loading 成功spinner.succeed();// loading 失败spinner.fail();
699ef9775e75648ac03e1345a98b31a8.gif

fs-extra —— 更友好的文件操作

fs-extra 模块是系统 fs 模块的扩展,提供了更多便利的 API,并继承了 fs 模块的 API。比 fs 使用起来更加友好。

download-git-repo —— 命令行下载工具

download-git-repo 可以从 git 中下载并提取一个 git repository

download-git-repo 仓库提供 的 download 函数接收四个参数(下面代码是 download-git-repo 源码中截取的):

/** * download-git-repo 源码 * Download `repo` to `dest` and callback `fn(err)`. * * @param {String} repo 仓库地址 * @param {String} dest 仓库下载后存放路径 * @param {Object} opts 配置参数 * @param {Function} fn 回调函数 */function download(repo, dest, opts, fn) {}

注意: download-git-repo 不支持 Promise

figlet —— 生成基于 ASCII 的艺术字

figlet 模块可以将 text 文本转化成生成基于 ASCII 的艺术字。具体效果不好解释,直接来看效果。

// enter 入口文件console.log(  "\r\n" +    figlet.textSync("demo", {      font: "Ghost",      horizontalLayout: "default",      verticalLayout: "default",      width: 80,      whitespaceBreak: true,    }));
cbc1d3c201a1919c6936d2a5d6335bbd.png

figlet 提供了多种字体,可以去官网选择你喜欢的字体。

初始化项目部分与上文类似,就不多做赘述,直接进入正文。

commander 提供了 version 方法,.version() 方法可以设置版本,其默认选项为 -V--version,设置了版本后,命令行会输出当前的版本号。

// package.json 中存取了项目的版本号 version// 直接使用该属性program.version(`zc-cli ${require("../package.json").version}`);

在命令行执行 zc-cli --version

zc-cli 1.0.0

配置首行提示

commander 还提供了 .usage.name 方法,通过这两个选项可以修改帮助提示的首行文字。利用这两个方法修改一下 --help 的首行提示。

// name 是配置脚手架名称// usage 是配置命令格式program.name("zc-cli").usage(`<command> [option]`);

执行 zc-cli --help,现在 --help 的打印就完善多了。

Usage: zcxiaobao <command> [option]Options:  -V, --version  output the version number  -h, --help     display help for command

配置 create 命令

commander 提供了 command 方法, command 方法的第一参数为命令名称,命令参数跟随在名称后面(必选参数使用 <> 表示,可选参数使用 [] 表示)

我们来配置 create 命令,该命令负责创建项目。同时在这里我们添加 --force 参数,默认覆盖当前项目。(关于存在同名目录的情况,后文有详细处理)

option 方法可以定义选项,同时可以附加选项的简介。每个选项可以定义一个短选项名称(-后面接单个字符)和一个长选项名称(--后面接一个或多个单词),使用逗号、空格或|分隔。

program  .command("create <project-name>") // 增加创建指令  .description("create a new project") // 添加描述信息  .option("-f, --force", "overwrite target directory if it exists") // 强制覆盖  .action((projectName, cmd) => {    // 处理用户输入create 指令附加的参数    console.log(projectName, cmd);  });

我们来测试一下 create 命令是否添加成功。

32655f190b022f7c73c863827f38aae5.png

通过 --help 查看到 create [options] <project-name> ,接下来来测试一下 create 的功能。

$ zc-cli createerror: missing required argument 'project-name'$ zc-cli create xxxxxx {}$ zc-cli create xxx --forcexxx { force: true }

成功获取到命令行输入的参数信息。Yes!!!

配置 config 命令

脚手架中 config 命令也是经常使用的,因此我们再添加个 config 命令,同时也熟练一下 commander 的使用。

program  .command("config [value]") // config 命令  .description("inspect and modify the config")  .option("-g, --get <key>", "get value by key")  .option("-s, --set <key> <value>", "set option[key] is value")  .option("-d, --delete <key>", "delete option by key")  .action((value, keys) => {    // value 可以取到 [value] 值,keys会获取到命令参数    console.log(value, keys);  });

优化 --help 提示

执行 vue --help,我们可以发现帮助提示部分最下面还有一句提示,并且高亮了 vue <command> --help,人性化啊,我们来模仿一下。

94bcdc0fc2059287e7f58ec57729d517.png

给我们 zc-cli 也添加上此功能:

commander 可以自动通过 on 方法来监听指令执行。

// 监听 --help 指令program.on("--help", function () {  // 前后两个空行调整格式,更舒适  console.log();  console.log(    " Run zc-cli <command> --help for detailed usage of given command."  );  console.log();});

执行 zc-cli --help 测试一下:

d10e7570b285c4746b4fd942a5ae7f9b.png

给 --help 提示上色

文章第三方模块处提到 chalk ,可以美化命令行,因此我们利用 chalkzc-cli <command> --help 高亮一下。

// 使用 cyan 颜色program.on("--help", function () {  // 前后两个空行调整格式,更舒适  console.log();  console.log(    `Run ${chalk.cyan(      "zc-cli <command> --help"    )} for detailed usage of given command.`  );  console.log();});

命令行执行 zc-cli --help ,我们就可以看到高亮的 zc-cli <command> --help

b622ca1572ebc10f67ef22c040177682.png

指令配置部分暂时就可以告一段落,休息休息,进入核心部分。🎉🎉🎉

create 模块

我们为创建功能单独建立一个模块,存放在 lib/create.js 中,同时在 zc 入口文件配置 create 指令处引入

//  zc 入口文件program  .command("create <project-name>") // 增加创建指令  .description("create a new project") // 添加描述信息  .option("-f, --force", "overwrite target directory if it exists") // 强制覆盖  .action((projectName, cmd) => {    // 引入 create 模块,并传入参数    require("../lib/create")(projectName, cmd);  });// create.js// 当前函数中可能存在很多异步操作,因此我们将其包装为 asyncmodule.exports = async function (projectName, options) {  console.log(projectName, options);};

我们来测试一下 create 模块是否可以接收到参数。

$ zc-cli create xxx --forcexxx { force: true }

存在同名目录

创建 create 命令时我们配置了 --force 参数,意为强制覆盖。那我们我们在创建一个项目目录时,就会出现三种情况:

  • 创建项目时使用 --force 参数,不管是否有同名目录,直接创建

  • 未使用 --force 参数,且当前工作目录中不存在同名目录,直接创建

  • 未使用 --force 参数,且当前工作目录中存在同名项目,需要给用户提供选择,由用户决定是取消还是覆盖

我们来梳理一下这部分的实现逻辑:

  1. 通过 process.cwd 获取当前工作目录,然后拼接项目名得到项目目录

  2. 检查是否存在同名目录

  3. 存在同名目录

  4. 用户创建项目时使用了 --force 参数,直接删除同名目录

  5. 未使用 --force 参数,给用户提供交互选择框,由用户决定

  6. 不存在同名目录,继续创建项目

const path = require("path");const fs = require("fs-extra");const Inquirer = require("inquirer");module.exports = async function (projectName, options) {  // 获取当前工作目录  const cwd = process.cwd();  // 拼接得到项目目录  const targetDirectory = path.join(cwd, projectName);  // 判断目录是否存在  if (fs.existsSync(targetDirectory)) {    // 判断是否使用 --force 参数    if (options.force) {      // 删除重名目录(remove是个异步方法)      await fs.remove(targetDirectory);    } else {      let { isOverwrite } = await new Inquirer.prompt([        // 返回值为promise        {          name: "isOverwrite", // 与返回值对应          type: "list", // list 类型          message: "Target directory exists, Please choose an action",          choices: [            { name: "Overwrite", value: true },            { name: "Cancel", value: false },          ],        },      ]);      // 选择 Cancel      if (!isOverrite) {        console.log("Cancel");        return;      } else {        // 选择 Overwirte ,先删除掉原有重名目录        console.log("\r\nRemoving");        await fs.remove(targetDirectory);      }    }  }};

我们在当前目录创建一个 aaa 文件夹,测试一下是否实现对重名目录的处理:

18f963fb05f27cdf5184720d3352d877.png

项目创建 Creator 类

为了项目更方便管理,我们将创建项目部分抽离成 Creator 类。

// Creator.jsclass Creator {  // 项目名称及项目路径  constructor(name, target) {    this.name = name;    this.target = target;  }  // 创建项目部分  create() {    console.log(this.name, this.target);  }}module.exports = Creator;// create.jsconst creator = new Creator(projectName, targetDirectory);creator.create();

命令行执行 zc-cli create aaa,成功打印出项目名与项目路径

aaa D:\workspace\forward\notes\cli\aaa

项目的模板存放在 github 中,项目使用 zhurong-cli 的模板仓库,zhurong-cli 分别提供了 vue2vue3 的仓库,并且每个仓库下提供多个版本。

github 提供了官方的 api ,我们可以通过调用官方的 api 获取到仓库及版本信息。

因此可以把创建项目整体划分成下面步骤:

  1. 通过获取仓库的 API 获取模板信息: Vue2 or Vue 3

  2. 将模板信息渲染为交互框,用户选择自己需要的模板

  3. 根据用户选择的模板,获取版本信息

  4. 将版本信息渲染成交互框,用户选择需要的版本

  5. 通过用户选取的模板及版本,下载对应模板到指定目录

  6. 将模板渲染为项目

获取模板及版本

api 请求模块

由于脚手架中要发送多种请求,因此我们单独设置 api.js 负责处理模板和版本信息的获取。

const axios = require("axios");// 拦截全局请求响应axios.interceptors.response.use((res) => {  return res.data;});/** * 获取模板 * @returns Promise 仓库信息 */async function getZhuRongRepo() {  return axios.get("https://api.github.com/orgs/zhurong-cli/repos");}/** * 获取仓库下的版本 * @param {string} repo 模板名称 * @returns Promise 版本信息 */async function getTagsByRepo(repo) {  return axios.get(`https://api.github.com/repos/zhurong-cli/${repo}/tags`);}module.exports = {  getZhuRongRepo,  getTagsByRepo,};

获取模板信息

api 部分已经封装了模板信息的获取方法,这里直接调用该方法,获取模板信息,然后使用 inquirer 渲染成命令行交互选择框。

// 获取模板信息及用户最终选择的模板async function getRepoInfo() {  // 获取组织下的仓库信息  let repoList = await getZhuRongRepo();  // 提取仓库名  const repos = repoList.map((item) => item.name);  // 选取模板信息  let { repo } = await new inquirer.prompt([    {      name: "repo",      type: "list",      message: "Please choose a template",      choices: repos,    },  ]);  return repo;}

测试一下模板选取是否成功。

f1765f3068d63822f78a769b0fab18ab.gif

获取版本信息

获取到模板信息后,我们就可以根据模板名调用第二个 api 获取到 tag (版本信息)

// 获取版本信息及用户选择的版本async getTagInfo(repo) {  let tagList = await getTagsByRepo(repo);  const tags = tagList.map((item) => item.name);  // 选取模板信息  let { tag } = await new inquirer.prompt([    {      name: "repo",      type: "list",      message: "Please choose a version",      choices: tags,    },  ]);  return tag;}

测试版本是否拉取成功。

e7c50fecaf59b38d19e270dc53a0f109.gif

添加 loading 效果

模板拉取需要消耗一定的时间,添加一个 loading 效果让用户体验更好。

我们可以借助第三方库 ora 实现 loading 方法。

/** * loading加载效果 * @param {String} message 加载信息 * @param {Function} fn 加载函数 * @param {List} args fn 函数执行的参数 * @returns 异步调用返回值 */async function loading(message, fn, ...args) {  const spinner = ora(message);  spinner.start(); // 开启加载  let executeRes = await fn(...args);  spinner.succeed();  return executeRes;}

我们将 loading 方法添加到模板获取和版本获取部分。

6b9c176d08687108cfa5e6763c8a8dbd.png

失败重新拉取

远程加载有时候有可能会遭遇网络不佳或者远程资源丢失情况,因此我们添加一个失败重拉功能,来提高用户体验。

但是我们要限制失败重拉的频率,太频繁拉取,给用户造成不好的体验。

/** * 睡觉函数 * @param {Number} n 睡眠时间 */function sleep(n) {  return new Promise((resolve, reject) => {    setTimeout(() => {      resolve();    }, n);  });}async loading(message, fn, ...args) {  const spinner = ora(message);  spinner.start(); // 开启加载  try {    let executeRes = await fn(...args);    // 加载成功    spinner.succeed();    return executeRes;  } catch (error) {    // 加载失败    spinner.fail("request fail, refetching");    await sleep(1000);    // 重新拉取    return loading(message, fn, ...args);  }}
9aeaf0ebba33cd82752d9b8fe0e57bd7.png

上文中我们已经成功获取到了模板及版本的信息,下面我们就可以去下载模板了。

download-git-repo 模块并不支持 Promise ,因此我们先借助 nodeutil 模块提供的 promisify 方法将其转化为支持 Promise 的方法。

// 把方法挂载到构造函数上constructor(name, target) {  this.name = name;  this.target = target;  // 转化为 promise 方法  this.downloadGitRepo = util.promisify(downloadGitRepo);}

定义 download 下载方法,并在 create 入口中调用:

async download(repo, tag) {  // 模板下载地址  const templateUrl = `zhurong-cli/${repo}${tag ? "#" + tag : ""}`;  // 调用 downloadGitRepo 方法将对应模板下载到指定目录  await loading(    "downloading template, please wait",    this.downloadGitRepo,    templateUrl,    path.join(process.cwd(), this.target) // 项目创建位置  );}
453b5a0ecabe5cfe9d820b53f7fa9137.gif

同时也成功地在当前目录创建 xxx 项目,xxx 项目的目录结构如下:

568847d15fee3a318e3f82159e2227c6.png

添加 logo

当调用 --help 命令时,我们在最后添加 logo 展示,经过精挑细选,最终小包选择了 3D-ASCII 字体。

我们来看一下效果,帅呆了,可以没找到上色的方法。

4077fa3f9bf7c98136c79100671f8cdf.png

模板使用提示

当模板下载成功后,我们像 vue-cli 一样添加模板使用提示,保证用户能正常启动项目。但是要注意 zc-cli 目前下载的是模板,因此我们进入模板后需要首先执行 npm install 下载依赖包才能执行。

// 核心创建逻辑 —— 创建项目部分  async create() {    // 仓库信息 —— 模板信息    let repo = await this.getRepoInfo();    // 标签信息 —— 版本信息    let tag = await this.getTagInfo(repo);    // 下载模板到模板目录    await this.download(repo, tag);    // 模板使用提示    console.log(`\r\nSuccessfully created project ${chalk.cyan(this.name)}`);    console.log(`\r\n  cd ${chalk.cyan(this.name)}`);    console.log("  npm install\r\n");    console.log("  npm run dev\r\n");  }
acb55f58171ba3c61e284cf94d7f9026.png

成功添加模板使用提示,最后我们按照模板使用步骤执行,成功创建了一个 Vue 项目。

695888a4b5813d48537848882d88a1ed.png

接下来修改 package.json ,添加个人信息,然后就可以准备发布了。

要发布一个 npm 包流程非常简单。

Step1: 首先去npm 注册一个用户

Step2: 然后将本地登录 npm

2cf8c111b1c09c2a4f047c0088192a1c.png

Step3: 执行 npm publish 命令,发布 npm 包。

Step4: 然后我们就可以在 npm 官网找到发布后的 npm 包了。(小包版: zcxiaobao-cli)

总结与展望

上面我们成功的实现了创建项目,但其实距离一个完善的脚手架还有很大距离

  • 将模板渲染成项目: zc 脚手架其实本质上是创建了一个模板,我们在后续开发中,应该需要实现基于模板及用户的选择渲染成真实的项目

  • 模板的缓存功能: 同一个模板是无需下载多次的,因此我们应该添加模板的缓存功能

  • 项目名称等部分的校验

  • config 命令的实现

  • 集成 react cli 功能

zc-cli 距离成熟好用的脚手架还有很远,但已经交代了脚手架开发的大致流程,后面的功能小包会一步一步学习,一步一步完善,最终将 zc-cli 实现成一个比较完备的脚手架。

小包下一步决定去阅读 vue-cli 源码,吸收其精华,再回来完善 zc-cli

我是 战场小包 ,一个快速成长中的小前端,希望可以和大家一起进步。

如果喜欢小包,可以在 InfoQ 关注我,同样也可以关注我的小小公众号——小包学前端

一路加油,冲向未来!!!

疫情早日结束 人间恢复太平


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK