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

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

「本文正在参与技术专题征文Node.js进阶之路,点击查看详情

前言

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

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

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

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

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

  • 重复性劳动,繁琐且浪费时间
  • 容易忽略项目中的配置设置
  • copy 过来的模板会存有重复代码

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

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

  • vue-cli 的痛击 : 小包最近使用 vue-cli 创建项目时总是爆出莫名其妙的错误,解决方案一直未能查找到,每次只能卸载重装一遍然后才能正常使用
  • 多个 cli 的繁琐: 最近小包也开始学习 react ,小包一直幻想如果两者使用一个脚手架就好了
  • 架构成长: 架构这两个字太吸引人了,脚手架的搭建在小包心里一直是架构的必备技能。
  • nodejs 成长: 利用 nodejs 实现一个脚手架也是对自己 nodejs 水平的一大锻炼。

学习本文,你能收获:

  • 🌟 掌握开发脚手架的全流程
  • 🌟 学会命令行开发常用的多种第三方模块
  • 🌟 拥有一个属于自己的脚手架

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

zcxiaobao-cli-demo.gif

脚手架实现分析

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

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

  • vue --version 可以查看 vue 版本
  • vue --help 可以查看帮助文档
  • vue create xxx 可以创建一个项目
  • ...

vue 创建项目

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

Step1: 运行创建命令

vue create demo
复制代码

Step2: 交互式用户选择

vue-create1

vue-create2

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

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

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

  • 通过命令行与用户交互
  • 根据用户的选择生成对应的文件

流程分析

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

  1. 首先我们要初始化一个项目
    • 创建项目 zc-cli,配置项目所需的信息
    • npm link 项目至全局,这样本地可以临时调用指令
  2. 项目开发
    • 基础指令配置: 例如 --help --version
    • 复杂指令配置: create 指令
    • 实现命令行交互功能: 基于 inquirer 实现命令行交互
    • 拉取项目模板
    • 根据用户的选择动态生成项目

使用的三方库

我们在开发 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 参数可以强制覆盖原有指令)

npm-link

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

commander —— 命令行指令配置

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

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

// 安装依赖
npm install commander
复制代码
// enter
const program = require("commander");

// 解析用户执行时输入的参数
// process.argv 是 nodejs 提供的属性
// npm run server --port 3000
// 后面的 --port 3000 就是用户输入的参数
program.parse(process.argv);
复制代码

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

commander-help

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

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

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

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

commander-help-alter

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

chalk —— 命令行美化工具

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

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

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

//enter

const 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!"
  )
);
复制代码

chalk-demo

怎么样,够花里胡哨吧。但安装 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);
});
复制代码

inquirer-demo

ora —— 命令行 loading 效果

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

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

const ora = require("ora");
// 定义一个loading
const spinner = ora("Loading unicorns");
// 启动loading
spinner.start();
setTimeout(() => {
  spinner.color = "yellow";
  spinner.text = "Loading rainbows";
}, 1000);

// loading 成功
spinner.succeed();
// loading 失败
spinner.fail();
复制代码

ora

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,
    })
);
复制代码

figlet

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 命令是否添加成功。

zc-cli-create

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

$ zc-cli create
error: missing required argument 'project-name'

$ zc-cli create xxx
xxx {}

$ zc-cli create xxx --force
xxx { 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,人性化啊,我们来模仿一下。

vue--help

给我们 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 测试一下:

zc-cli-help

给 --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

zc-cli-chalk

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

创建项目

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
// 当前函数中可能存在很多异步操作,因此我们将其包装为 async
module.exports = async function (projectName, options) {
  console.log(projectName, options);
};
复制代码

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

$ zc-cli create xxx --force
xxx { force: true }
复制代码

存在同名目录

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

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

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

  1. 通过 process.cwd 获取当前工作目录,然后拼接项目名得到项目目录
  2. 检查是否存在同名目录
  3. 存在同名目录
    • 用户创建项目时使用了 --force 参数,直接删除同名目录
    • 未使用 --force 参数,给用户提供交互选择框,由用户决定
  4. 不存在同名目录,继续创建项目
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 文件夹,测试一下是否实现对重名目录的处理:

repeat-directory

项目创建 Creator 类

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

// Creator.js

class Creator {
  // 项目名称及项目路径
  constructor(name, target) {
    this.name = name;
    this.target = target;
  }
  // 创建项目部分
  create() {
    console.log(this.name, this.target);
  }
}

module.exports = Creator;

// create.js
const 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;
}
复制代码

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

getRepo

获取版本信息

获取到模板信息后,我们就可以根据模板名调用第二个 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;
}
复制代码

测试版本是否拉取成功。

getTag

添加 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 方法添加到模板获取和版本获取部分。

loading

失败重新拉取

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

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

/**
 * 睡觉函数
 * @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);
  }
}
复制代码

loadinf-fail

模板下载

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

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) // 项目创建位置
  );
}
复制代码

downloadRepo

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

downloadRepoXXX

美化项目

添加 logo

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

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

zc-figlet-logo

模板使用提示

当模板下载成功后,我们像 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");
  }
复制代码

zc-cli-succes-tem

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

zc-cli-vue3

发布项目

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

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

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

Step2: 然后将本地登录 npm

npmlogin

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

npmpublish

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

总结与展望

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

  • 将模板渲染成项目: zc 脚手架其实本质上是创建了一个模板,我们在后续开发中,应该需要实现基于模板及用户的选择渲染成真实的项目
  • 模板的缓存功能: 同一个模板是无需下载多次的,因此我们应该添加模板的缓存功能
  • 项目名称等部分的校验
  • config 命令的实现
  • 集成 react cli 功能

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

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

源码仓库

github 地址: zcxiaobao-cli

npm 仓库地址: zcxiaobao-cli

如果感觉有帮助的话,别忘了给小包点个 ⭐

后语

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

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

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

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

分类:
前端
分类:
前端
收藏成功!
已添加到「」, 点击更改