从 0 实现一个前端项目提效脚手架

563 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 21 天,点击查看活动详情

原文来自我的个人博客

1 功能演示

1.1 脚手架安装

npm i @coderyjw/cli -g

输入 jw-cli -h 查询帮助

image.png

1.2 init 命令演示:下载项目模板

1. 输入 jw-cli init -h 查询帮助

image.png

2. 输入 jw-cli init初始化项目,选择类型:项目

image.png

3. 输入项目名称: react-app

image.png

4. 选择项目模板:react模板

image.png

5. 下载成功

image.png

1.3 install 命令演示:源码下载

1. 输入 jw-cli install,选择 Gitee

image.png

2. 输入 token

image.png

3. 输入关键词: vue-admin-template

image.png

4. 输入开发语言(可选择不输入)

image.png

5. 选择项目:vue-admin-template

image.png

6. 选择版本: 4.3.1

image.png

image.png

image.png

7. 下载成功,启动项目

image.png

1. 脚手架整体框架搭建

1.1 脚手架入口文件开发

1. 使用 Lerna 创建项目

Lerna 是一个用于管理包含多个包的 JavaScript 项目的管理工具。

npm i lerna -g
mkdir jw-cli
cd jw-cli
lerna init

此时我们的目录结构应该是这样的:

image.png

2. 通过 Lerna 创建 cli package

lerna create cli

3. 创建 packages/cli/bin/cli.js,通常来说脚手架文件都会放在 bin 目录下

4. 将 packages/cli/lib/cli.js 修改为 packages/cli/lib/index.js。这个就是我们未来要下载和安装的入口文件

5. 到 npm 上创建对应的组织

image.png

image.png 6. 修改 packages/cli/package.json

image.png

注意上面填入的 package name(@coderyjw/cli) 要填入你自己在 npm 上创建的组织,

7. 让入口文件生效

cd packages/cli
npm link

8. 修改 bin/cli.js 与 lib/index.js 测试是否生效

bin/cli.js

#!/usr/bin/env node

import entry from "../lib/index.js";

entry(process.argv.slice(2));

lib/index.js

export default function (args) {
  console.log("this is entry", args);
}

执行 jw-cli,控制台打印如下内容说明入口文件已生效

image.png

至此我们脚手架的入口就已经开发好了。

1.2 脚手架注册 + 命令注册

1. 在 packages/cli 目录下安装 commander 插件

lerna add commander packages/cli

commander 是一个很好用的 node.js 命令行解决方案,这里就不详细讲了。

2. 创建 packages/cli/lib/createCLI.js 文件用于封装脚手架注册逻辑

import fse from "fs-extra";
import path from "path";
import { dirname } from "dirname-filename-esm";

const __dirname = dirname(import.meta);
const pkgPath = path.resolve(__dirname, "../package.json");
const pkg = fse.readJsonSync(pkgPath);

export default function createCLI(program) {

  // 命令注册
  program
    .name(Object.keys(pkg.bin)[0])
    .usage("<command> [options]")
    .version(pkg.version, "-v, --version", "输出当前版本号")
    .option("-d, --debug", "是否开启调试模式", false)
    .helpOption("-h, --help", "命令显示帮助")
    .addHelpCommand("help [command]", "命令显示帮助");

  // 监听未知的命令
  program.on("command:*", function (obj) {
    console.error("未知的命令:" + obj[0]);
  });

  // 监听 debug 选项
  program.on("option:debug", function () {
    if (program.opts().debug) {
      console.log("开启调试模式");
    }
  });
}

3. 在 packages/cli/lib/index.js 中引入 createCLI

import { program } from "commander";
import createCLI from "./createCLI.js";

export default function (args) {
  createCLI(program);
  program.parse(process.argv);
}

这里需要引入两个包:

  1. fs-extra:它可以看做是 node 中 fs 模块的升级版,比 fs 更加的好用
  2. dirname-filename-esm:因为在 esm 中,是没有 cjs 中的 __dirname 变量的,但是我们可以通以下下方法获得 __dirname
import { dirname } from "dirname-filename-esm";
const __dirname = dirname(import.meta);

所以我们要先安装以下这两个包

lerna add dirname-filename-esm  packages/cli
lerna add fs-extra  packages/cli

4. 最后测试能出现下面的效果则说明我们已经成功注册了命令

image.png

1.3 log 日志功能封装

在上面的代码中,我们使用的是 cosole.log 进行日志打印,实际上我们有更好的选择,那就是 npmlog,虽然它的功能已经非常完善了,但是我们最好对它再进行一层封装。

1. 创建 utils 文件夹作为我们的一个工具包,后续我们所有的工具类都放在这个包下

lerna create utils

此时的目录结构

image.png

2. 打开 packages/utils/packages.json 修改

image.png

3. 将 packages/utils/utils.js 改为 packages/utils/index.js

4. 安装 npmlog 依赖

leran add npmlog pacakges/utils

5. 创建 packages/utils/log.js 文件并做如下配置

import log from "npmlog";
import isDebug from "./isDebug.js";

if (isDebug()) {
  log.level = "verbose";
} else {
  log.level = "info";
}

log.heading = "jw-cli";

log.addLevel("success", 2000, { fg: "green", bold: true });

export default log;

6. 创建 packages/utils/isDebug.js

/**
 * 判断是否是 debug 模式
 * @returns boolean
 */
export default function isDebug() {
  return process.argv.includes("--debug") || process.argv.includes("-d");
}

7. 在 packages/utils/index.js 导入并导出这两个模块

import isDebug from "./isDebug.js";
import log from "./log.js";

export { log, isDebug };

8. 在 cli 包中引入 utils 包

lerna add @coderyjw/utils packages/cli

9. 修改 packages/cli/bin/createCLI.js,引入 log 模块,并将 console 改为 log

import { log } from "@coderyjw/utils";
...
export default function createCLI(program) {
  ...
  // 监听未知的命令
  program.on("command:*", function (obj) {
    log.error("未知的命令:" + obj[0]);
  });

  // 监听 debug 选项
  program.on("option:debug", function () {
    if (program.opts().debug) {
      log.verbose("debug", "开启调试模式");
    }
  });

10. 测试成功

image.png

1.4 Commander 类封装

与 log 一样,虽然 commander 已经很好用了,但是我们还是希望能对其进行封装,这样对我们后续的命令注册也会非常的方便。对于 Commander 类的封装,这里提供给大家一种思路。

1. 创建 command 一个新的 package

lerna create command

2. 修改 packages/command/package.json

image.png

3. 将 packages/command/command.js 改为 packages/command/index.js,修改 index.js

class Command {
  constructor(instance) {
    if (!instance) {
      throw new Error("command instance must not be null!");
    }

    this.program = instance;

    const cmd = this.program.command(this.command);
    cmd.description(this.description);
    cmd.hook("preAction", () => {
      this.preAction();
    });
    cmd.hook("postAction", () => {
      this.postAction();
    });
    if (this.options?.length > 0) {
      this.options.forEach((option) => {
        cmd.option(...option);
      });
    }
    cmd.action((...params) => {
      this.action(params);
    });
  }

  get command() {
    throw new Error("command must be implements");
  }

  get description() {
    throw new Error("description must be implements");
  }

  get options() {
    return [];
  }

  action() {
    throw new Error("action must be implements");
  }

  preAction() {
    // empty
  }

  postAction() {
    // empty
  }
}

export default Command;

在上面的代码中,我们封装了一个 Command 类最后将其导出,在 Command 类的构造函数中,我们主要做了以下几件事:

  1. 通过 program.command 注册命令
  2. 添加 description 命令描述信息
  3. 添加了命令生命周期 preAction 和 postAction 两个 hook,这两个函数分别会在 action 的前后触发
  4. 调用 command.action() 触发我们命令的逻辑

之后的所有命令只要继承该类即可

1.5 init 命令封装

接下来我们终于可以开始我们的第一个命令的注册了。

1. 创建 init 包

lerna create init

2. 修改 packages/init/package.json

image.png

3. 将 packages/init/init.js 改为 packages/init/index.js,修改 index.js

import Command from "@coderyjw/command";
import { log } from '@coderyjw/utils'

class InitCommand extends Command {
  get command() {
    return "init [name]";
  }

  get description() {
    return "项目初始化";
  }

  get options() {
    return [
      ["-f, --force", "是否强制更新", false],
      ["-t, --type <type>", "项目类型(project/page)"],
      ["-tp, --template <template>", "模版名称"],
    ];
  }

  async action([name, opts]) {
    log.verbose("init", name, opts);
  }

  preAction() {}

  postAction() {}
}

function Init(instance) {
  return new InitCommand(instance);
}

export default Init;

在上面的代码中我们封装的 InitCommand 继承自 Command 类,最后导出了一个创建 InitCommand 类的函数,这样,我们只要到 packages.cli/lib/index.js 脚手架入口文件中调用这个函数即可

4. 修改 packages.cli/lib/index.js

import { program } from "commander";
import createCLI from "./createCLI.js";
import createInitCommand from "@coderyjw/init";

export default function (args) {
  createCLI(program);

  createInitCommand(program)
  
  // 解析配置
  program.parse(process.argv);
}

5. 当然我们还要导入一下所需的依赖

lerna add @coderyjw/command packages/init
lerna add @coderyjw/utils packages/init
lerna add @coderyjw/init packages/cli

6. 测试成功

image.png

1.6 node 最低版本检查功能

到现在我们脚手架的基本逻辑其实已经差不多了,我们可以再回来优化一下启动逻辑,比如检查 Node 版本,这个启动的逻辑可以放在 commander 的钩子函数 preAction 中来实现

1. 修改 packages/cli/bin/createCLI.js:

import semver from "semver";
import chalk from "chalk";
...
...
const LOWEST_NODE_VERSION = "14.0.0";

const checkNodeVersion = () => {
  log.verbose("node version", process.version);
  // 当前 node 版本低于 LOWEST_NODE_VERSION 时 抛出错误
  if (!semver.gte(process.version, LOWEST_NODE_VERSION)) {
    throw new Error(
      chalk.red(`jw-cli 需要安装 ${LOWEST_NODE_VERSION} 以上版本的 Node.js`)
    );
  }
};

const preAction = () => {
  // 检查 Node 版本
  checkNodeVersion();
};
export default function createCLI(program) {
  // 命令注册
  program
    .name(Object.keys(pkg.bin)[0])
    .usage("<command> [options]")
    .version(pkg.version, "-v, --version", "输出当前版本号")
    .option("-d, --debug", "是否开启调试模式", false)
    .helpOption("-h, --help", "命令显示帮助")
    .addHelpCommand("help [command]", "命令显示帮助")
    .hook("preAction", preAction);
...
...
  • 在上面的代码中:
    1. 我们首先定义了一个 preAction 函数,后续如果还有什么启动逻辑需要做的都可以放在这里,
    2. 然后封装了一个 checkNodeVersion,在这个方法中我们用到了两个第三方包
      1. 一个是 semver: 这个包是专门用来比较两个版本的
      2. 另一个是 chalk:这个包是专门用来修改终端字符串样式的

2. 接下来我们安装下这两个包

lerna add chalk packages/cli 
lerna add semver packages/cli 

3. 分别修改 LOWEST_NODE_VERSION 的值为 17.0.0 和 14.0.0 进行测试,测试成功

image.png

image.png

1.7 添加异常监听

到现在为止,我们已经基本把脚手架的框架维护好了,最后再做一些收尾工作,比如异常监听。

Node.js 程序运行在单进程上,应用开发时一个难免遇到的问题就是异常处理,

关于异常监听,我们可以这样优化,判断如果当前是调试模式时再打印出函数栈,如果不是就仅打印出 message 即可

1. 修改 packages/utils/lib/log.js,封装 printErrorLog 方法,之后如果我们主动捕捉到的异常时都可以用这个方法打印日志

export function printErrorLog(e, type) {
  if (isDebug()) {
    log.error(type, e);
  } else {
    log.error(type, e.message);
  }
}

2. 修改在 packages/utils/lib/index.js , 导出 printErrorLog

import isDebug from "./isDebug.js";
import log, { printErrorLog } from "./log.js";

export { log, isDebug, printErrorLog };

3. 另外除了主动捕捉到的异常,还有一些可能漏掉的异常,可能导致程序退出,我们创建 packages/cli/lib/exception.js:

import { printErrorLog } from "@coderyjw/utils";

process.on("uncaughtException", (e) => printErrorLog(e, "error"));

process.on("unhandledRejection", (e) => printErrorLog(e, "promise"));

4. 在 packages/cli/index.js 中引入 exception.js

import "./exception.js";

5. 我们以上一小节的报错为例进行测试

image.png

可以看到加上 -d 的有报错的函数调用栈,而没加的就只打印出了报错 message

2. 项目创建脚手架开发

  • 我们为什么要开发创建项目的脚手架?为什么不直接使用 vue-cli 或者 create-react-app?
    1. 因为项目在迭代过程中会添加很多本土化的元素,如:H5 兼容、接口请求、埋点上报、组件封装、通用方法等,甚至会对整块业务逻辑进行复用,比如登录;
    2. 而且每次在创建项目的时候都要重新填写这些代码是非常耗时的,而且无法对每个团队成员进行复用,可以利用脚手架来完成项目模板的沉淀和标准化建设。

2.1 项目模板开发

1. 新建一个 jw-cli-template 项目,并按如下目录结构创建文件夹

image.png

2. 修改分别修改各个模板的 package.json

react 模板

{
  "name": "@coderyjw/template-react18",
  "version": "1.0.0",
  "description": "jw-cli react18 template",
  "author": "yejw6",
  "license": "ISC",
  "publishConfig": {
    "access": "public"
  }
}

vue 模板

{
  "name": "@coderyjw/template-vue3",
  "version": "1.0.0",
  "description": "jw-cli vue3 template",
  "author": "yejw6",
  "license": "ISC",
  "publishConfig": {
    "access": "public"
  }
}

vue-element-admin 模板

{
  "name": "@coderyjw/template-vue-element-admin",
  "version": "1.0.0",
  "description": "jw-cli vue-element-admin template",
  "main": "index.js",
  "author": "yejw6",
  "license": "ISC",
  "publishConfig": {
    "access": "public"
  }
}

3. 这三个模板的 template 就是存放我们最终代码的位置,关于模板大家可以按照自己的沉淀配置,这里我分享一下我 react模板的配置

4. 通过 npm 依次发布模板

npm publish 

5. 可以在 npm 仓库中看到我们的三个模板

image.png

2.2 整体逻辑搭建

有了模板之后我们就可以开始写代码去下载模板了。

  • 我们的这个脚手架最主要的逻辑应该包含以下三条:
    1. 我们应该有一个命令行来让我们选择生成哪份模板,甚至还可以选择生成一个页面模块(包含页面、状态、路由、网络请求等等)
    2. 选择完之后最好是能缓存到本地硬盘上,这样下次再去生成时就不要再去下载了
    3. 最后从缓存中下载到本地目录,提示命令运行项目

1. 创建 packages/init/lib/createTemplate.js

import { log } from "@coderyjw/utils";

export default async function createTemplate(name, opts) {
  log.verbose("createTemplate", "选择项目模板,生成项目信息");

  return null
}

2. 创建 packages/init/lib/downloadTemplate.js

import { log } from "@coderyjw/utils";

export default async function downloadTemplate(selectedTemplate) {
  log.verbose("downloadTemplate", "下载项目模板值缓存目录");
  log.verbose("template", selectedTemplate);
}

3. 创建 packages/init/lib/installTemplate.js

import { log } from "@coderyjw/utils";

export default async function installTemplate(name, opts) {
  log.verbose("installTemplate", "安装项目模板至目录");
}

上面创建的三份文件分别对应我们上面说的三个逻辑,目前还是空的需要我们去完善代码。

**4. 修改 packages/init/lib/index.js **

import createTemplate from "./createTemplate.js";
import downloadTemplate from "./downloadTemplate.js";
import installTemplate from "./installTemplate.js";
...
...

  async action([name, opts]) {
    // 1. 选择项目模板,生成项目信息
    const selectedTemplate = await createTemplate(name, opts);
    // 2. 下载项目模板值缓存目录
    await downloadTemplate(selectedTemplate);
    // 3. 安装项目模板至目录
    await installTemplate(selectedTemplate, opts);
  }

至此我们整体的代码已经创建好了,接下来就是去实现各个逻辑了

2.3 选择项目模板,生成项目信息

要想在命令行进行选择模板,就要用到 inquirer 了,inquirer 拥有一组通用的交互式命令行用户界面,支持常见的 input 输入、单选、多选、是/否等常见提问类型

1. 创建 packages/utils/inquirer.js,分别对其以上能力进行封装

import inquirer from "inquirer";

function make({
  choices,
  defaultValue,
  message = "请选择",
  type = "list",
  require = true,
  mask = "*",
  validate,
  pageSize,
  loop,
}) {
  const options = {
    name: "name",
    default: defaultValue,
    message,
    type,
    require,
    mask,
    validate,
    pageSize,
    loop,
  };

  if (type === "list") {
    options.choices = choices;
  }

  return inquirer.prompt(options).then((answer) => answer.name);
}

export function makeList(params) {
  return make({ ...params });
}

export function makeInput(params) {
  return make({ type: "input", ...params });
}

export function makePassword(params) {
  return make({ type: "password", ...params });
}

2. 在 package/utils/lib/index.js 中导出

import isDebug from "./isDebug.js";
import log, { printErrorLog } from "./log.js";
import { makeList, makeInput, makePassword } from "./inquirer.js";

export { log, isDebug, printErrorLog, makeList, makeInput, makePassword };

3. 安装 inquirer

lerna add inquirer packages/utils

4. 修改 package/init/lib/createTemplate.js 文件

import { log, makeList, makeInput } from "@coderyjw/utils";
const ADD_TYPE_PROJECT = "project";
const ADD_TYPE_PAGE = "page";

const ADD_TYPE = [
  { name: "项目", value: ADD_TYPE_PROJECT },
  { name: "页面", value: ADD_TYPE_PAGE },
];

const GLOBAL_ADD_TEMPLATE = [
  {
    name: "vue3 项目模板",
    value: "template-vue3",
    npmName: "@coderyjw/template-vue3",
    version: "1.0.1",
  },
  {
    name: "react18 项目模板",
    value: "template-react18",
    npmName: "@coderyjw/template-react18",
    version: "1.0.0",
  },
  {
    name: "vue-element-admin 项目模板",
    value: "template-vue-element-admin",
    npmName: "@coderyjw/template-vue-element-admin",
    version: "1.0.0",
  },
];

// 获取创建类型
function getAddType() {
  return makeList({
    choices: ADD_TYPE,
    message: "请选择初始化类型",
    defaultValue: ADD_TYPE_PROJECT,
  });
}

// 获取项目名称
function getAddName() {
  return makeInput({
    message: "请输入项目的名称",
    defaultValue: "",
    validate(name) {
      if (name.length > 0) return true;
      return "项目名称不能为空";
    },
  });
}

// 选择项目模版
function getAddTemplate(ADD_TEMPLATE) {
  return makeList({
    choices: ADD_TEMPLATE,
    message: "请选择项目模版",
  });
}

export default async function createTemplate(name, opts) {
  log.verbose("createTemplate", "选择项目模板,生成项目信息");

  const ADD_TEMPLATE = GLOBAL_ADD_TEMPLATE;

  // 项目类型,项目名称,项目模版
  let addType, addName, addTemplate;
  addType = await getAddType();
  log.verbose("addType", addType);

  if (addType === ADD_TYPE_PROJECT) {
    addName = await getAddName();
    log.verbose("addName", addName);
    addTemplate = await getAddTemplate(ADD_TEMPLATE);
    log.verbose("addTemplate", addTemplate);

    const selectedTemplate = ADD_TEMPLATE.find((_) => _.value === addTemplate);
    if (!selectedTemplate) throw new Error(`项目模版 ${template} 不存在!`);
    log.verbose("selectedTemplate", selectedTemplate);

    return {
      type: addType,
      name: addName,
      template: selectedTemplate,
    };
  } else {
    throw new Error(`抱歉,创建的项目类型 ${addType} 暂不支持!`);
  }
}

在上面的代码中我们分别封装 getAddName getAddType getAddTemplate 三个方法,用于获取要创建的类型、名称和模板

5. 测试

image.png

image.png

image.png

image.png

不出意外的话就会生成我们选中模板的信息了。

6. 修改 package/init/lib/createTemplate.js,优化代码,使之能通过一条命令直接生成模板

+  const { type, template } = opts;
   // 项目类型,项目名称,项目模版
   let addType, addName, addTemplate;
-  addType = await getAddType()
+  if (type) {
+    addType = type;
+  } else {
+    addType = await getAddType();
+  }
...
-    addName = await getAddName();
+    if (name) {
+      addName = name;
+    } else {
+      addName = await getAddName();
+    }
-    addTemplate = await getAddTemplate(ADD_TEMPLATE);
+    if (template) {
+      addTemplate = template;
+    } else {
+      addTemplate = await getAddTemplate(ADD_TEMPLATE);
+    }

7. 测试成功

image.png

上面的代码还会有一个小问题,就是生成的模板信息中 version 一直是 1.0.0 固定,我们知道我们的版本是会更新的,如果每次更新都要重新改一下脚手架的代码是会十分麻烦的,那么应该如何解决呢?我这里的提供一种方法是每次都去下载最新的版本,或者你也可以做一个功能下载指定版本的模板。

npm 给我们提供了一个 api 让我们可以获取到一个包的所有信息,我们只需要通过请求 https://registry.npmjs.org/ + 包名 即可

image.png

8. npm API 接入,创建 packages/utils/npm.js

import urlJoin from "url-join";
import axios from "axios";
import { log } from "./log.js";

function getNpmInfo(npmName) {
  // npm API 地址 https://registry.npmjs.org/
  // 如果 npm 过慢,可以使用淘宝镜像 https://registry.npmmirror.com/
  const registry = "https://registry.npmmirror.com/";
  const url = urlJoin(registry, npmName);
  return axios.get(url).then((res) => {
    try {
      return res.data;
    } catch (e) {
      Promise.reject(e);
    }
  });
}

export default async function getLatestVersion(npmNmae) {
  const result = await getNpmInfo(npmNmae);
  if (result?.["dist-tags"]?.["latest"]) {
    return result["dist-tags"]["latest"];
  }
  log.error("没有 latest 版本号");
  return Promise.reject("没有 latest 版本号");
}

9. 修改 packages/utils/index.js 导出 getLatestVersion 方法

import isDebug from "./isDebug.js";
import log, { printErrorLog } from "./log.js";
import { makeList, makeInput, makePassword } from "./inquirer.js";
import getLatestVersion from "./npm.js";
export {
  log,
  isDebug,
  printErrorLog,
  makeList,
  makeInput,
  makePassword,
  getLatestVersion,
};

10. 最后不要忘了安装 axios 和 url-join 两个包

lerna add axios packages/utils
lerna add url-join packages/utils

11. 在 packages/init/lib/createTemplate.js 中引入 getLatestVersion,获取最新版本

// 获取最新的版本
const latestVersion = await getLatestVersion(selectedTemplate.npmName);
log.verbose("latestVersion", latestVersion);
selectedTemplate.version = latestVersion;

12. 最后因为我们之后要下载模板,最好将缓存目录获取的逻辑在这一步给实现了传过去

import path from "path";
import { homedir } from "os";

const TEMP_HOME = ".jw-cli";
...
// 安装缓存目录
function makeTargetPath() {
  return path.resolve(`${homedir()}/${TEMP_HOME}`, "addTemplate");
}
...
const targetPath = makeTargetPath();
log.verbose("targetPath", targetPath);

return {
  type: addType,
  name: addName,
  template: selectedTemplate,
  targetPath,
};

2.4 下载项目模板至缓存目录

接下来我们就来到了第二步逻辑:下载项目模板至缓存目录

1. 修改 packages/init/downloadTemplate.js

import path from "node:path";
import { pathExistsSync } from "path-exists";
import fse from "fs-extra";
import ora from "ora";
import { execa } from "execa";
import { printErrorLog, log } from "@yejiwei/utils";

function getCacheDir(targetPath) {
  return path.resolve(targetPath, "node_modules");
}

function makeCacheDir(targetPath) {
  const cacheDir = getCacheDir(targetPath);
  if (!pathExistsSync(cacheDir)) {
    fse.mkdirpSync(cacheDir);
  }
}

async function downloadAddTemplate(targetPath, selectedTemplate) {
  const { npmName, version } = selectedTemplate;
  const installCommand = "npm";
  const installArgs = ["install", `${npmName}@${version}`];
  const cwd = targetPath;
  const subprocess = execa(installCommand, installArgs, { cwd });
  await subprocess;
}

export default async function downloadTemplate(selectedTemplate) {
  const { targetPath, template } = selectedTemplate;
  makeCacheDir(targetPath);
  const spinner = ora("正在下载模版....").start();

  try {
    await downloadAddTemplate(targetPath, template);
    spinner.stop();
    log.success("模版下载成功");
  } catch (e) {
    spinner.stop();
    printErrorLog(e);
  }
}
  • 在上面的代码中:

    1. 我们主要通过封装了 downloadAddTemplate 来下载模板
    2. 引入 execa模块 来执行 npm install 包名的方式将模板下载至缓存目录中
    3. 另外还引入了 ora 模块做了一个下载中的动画

    image.png

2. 引入这些依赖

lerna add path-exists packages/init
lerna add execa packages/init
lerna add ora packages/init

3. 输入命令测试

image.png

image.png

可以看到命令行显示模板下载成功,并且模板代码已经成功下载至我们的路径 C:\Users\yjw\.jw-cli\addTemplate\node_modules\@coderyjw\template-react18 下面

2.5 安装项目模板至目录

接下来我们终于来到第三步逻辑了:安装项目模板至目录

1. 修改 packages/init/lib/installTemplate.js

import fse from "fs-extra";
import path from "path";
import ora from "ora";
import { log } from "@coderyjw/utils";

function getCacheFilePath(targetPath, template) {
  return path.resolve(targetPath, "node_modules", template.npmName, "template");
}

function copyFile(targetPath, template, installDir) {
  const originFilePath = getCacheFilePath(targetPath, template);
  log.verbose("originFilePath", originFilePath);
  const fileList = fse.readdirSync(originFilePath);
  log.verbose("fileList", fileList);
  const spinner = ora("正在拷贝文件...").start();
  fileList.map((file) => {
    fse.copySync(`${originFilePath}/${file}`, `${installDir}/${file}`);
  });
  spinner.stop();
  log.success("模版拷贝成功");
}

export default async function installTemplate(selectedTemplate, opts) {
  log.verbose("installTemplate", "安装项目模板至目录");
  const { force = false } = opts;
  const { targetPath, template, name } = selectedTemplate;
  const rootDir = process.cwd();
  fse.ensureDirSync(targetPath);
  const installDir = path.resolve(`${rootDir}/${name}`);
  fse.ensureDirSync(installDir);

  copyFile(targetPath, template, installDir);
  log.info(`输入以下命令运行项目`)
  log.info(`cd ${name}`)
  log.info(`npm install`)
  log.info(`npm start`)
}

在上面的代码中我们主要封装了 copyFile 方法用来从缓存目录中拷贝文件到当前目录下,但是有一个小问题就是,如果当前目录下我们填入 name 的文件夹已存在时就会有点问题,这里我们可以通过添加一个 option 让用户选择是否强制更新

image.png

2. 代码逻辑优化

+   import { pathExistsSync } from "path-exists";
...
+   const { force = false } = opts;
...
-  fse.ensureDirSync(installDir);
+  if (pathExistsSync(installDir)) {
+    if (!force) {
+      log.error(`当前目录已存在 ${installDir} 文件夹`);
+      return;
+    } else {
+      fse.removeSync(installDir);
+      fse.ensureDirSync(installDir);
+    }
+  } else {
+    fse.ensureDirSync(installDir);
+  }

3. 输入 jw-cli init react-app -t project -tp template-react18 -d 命令测试

image.png 模板成功安装

再次输入命令显示

image.png

输入 jw-cli init react-app -t project -tp template-react18 -d -f 命令强制更新

image.png

至此,我们的项目创建脚手架的开发就已经完成了。

3. 源码下载脚手架开发

在上一章中,我们完成了项目创建脚手架的开发,代码全部放置 init文件夹下,从本章开始我们将进行源码下载器的开发。

  • 为什么要实现源码下载器?

    1. 因为 npm 只能下载 npm registry 中的包,而对于未上传 npm 的包则无法下载
    2. 对于 github 和 gitee 的项目或包如果手动下载效率低下,则可以通过脚手架开发前端项目下载器提升效率
  • 那么,实现一个源码下载器需要怎么样的流程呢?

  1. 首先我们需要掌握 github API 和 gitee API的使用方法
  2. 通过 github 和 gitee 实现根据仓库名称搜索项目的能力(github 能力更强,甚至可以根据源码进行搜索)
  3. 存储 github 或 gitee 的 token ,具备 github API 或 gitee API的调用条件
  4. 通过搜索获取仓库信息,选取指定版本(实现翻页功能)
  5. 将指定版本的源码拉取到本地
  6. 在本地安装依赖,安装可执行文件,启动项目

3.1 整理逻辑搭建

1. 创建 install 文件夹作为源码下载器的包

lerna create install

2. 修改 packages/install/package.json

{
  "name": "@coderyjw/install",
  "version": "0.0.0",
  "description": "jw-cli脚手架 install 命令",
  "author": "叶继伟 <yejw6@asiainfo.com>",
  "homepage": "",
  "license": "ISC",
  "main": "lib/index.js",
  "directories": {
    "lib": "lib",
    "test": "__tests__"
  },
  "files": [
    "lib"
  ],
  "type": "module",
  "scripts": {
    "test": "node ./__tests__/install.test.js"
  }
}

3. 修改 packages/install/lib/install.js 为 packages/install/lib/index.js

import Command from "@coderyjw/command";


class InstallCommand extends Command {
  get command() {
    return "install";
  }

  get description() {
    return "项目下载、安装依赖、启动项目";
  }

  get options() {
    return [["-c, --clear", "清空缓存", false]];
  }

  async action() {}
}

function Install(instance) {
  return new InstallCommand(instance);
}

export default Install;

4. 在 packages/cli/lib/index.js 中引入 install 包

import { program } from "commander";
import createCLI from "./createCLI.js";
import createInitCommand from "@coderyjw/init";
import createInstall from "@coderyjw/install";
import "./exception.js";

export default function (args) {
  createCLI(program);

  createInitCommand(program);

  createInstall(program)

  // 解析配置
  program.parse(process.argv);
}

5. 安装依赖

lerna add @coderyjw/command packages/install
lerna add @coderyjw/install packages/cli

6. 运行 jw-cli,可以看到此时我们的脚手架已经有了 install 命令

image.png

3.2 平台选择 + token 缓存

1. 创建 packages/utils/git/GitServer.js 文件

import { homedir } from "os";
import path from "path";
import { pathExistsSync } from "path-exists";
import fse from "fs-extra";
import { makePassword } from "../lib/inquirer.js";
import { log } from "../lib/index.js";

const TEMP_HOME = ".jw-cli";
const TEMP_GITHUB_TOEN = ".github-token";
const TEMP_GITEE_TOEN = ".gitee-token";

function createTokenPath(platForm) {
  if (platForm === "github") {
    return path.resolve(homedir(), TEMP_HOME, TEMP_GITHUB_TOEN);
  } else {
    return path.resolve(homedir(), TEMP_HOME, TEMP_GITEE_TOEN);
  }
}

export default class GitServer {
  constructor() {}

  async init(platForm) {
    // 判断 token 是否录入
    const tokenPath = createTokenPath(platForm);
    if (pathExistsSync(tokenPath)) {
      this.token = fse.readFileSync(tokenPath).toString();
    } else {
      this.token = await this.getToken();
      fse.writeFileSync(tokenPath, this.token);
    }
    log.verbose("token", this.token);
  }

  async getToken() {
    return await makePassword({ message: "请输入 token 信息" });
  }
}

2. 创建 packages/utils/git/Gitee.js 文件

import GitServer from "./GitServer.js";
import { log } from "../lib/index.js";

const BASE_URL = "https://gitee.com/api/v5";

export default class Gitee extends GitServer {
  constructor() {
    super();
  }
}

3. 创建 packages/utils/git/Github.js 文件

import GitServer from "./GitServer.js";
import { log } from "../lib/index.js";

const BASE_URL = "https://api.github.com";

export default class GitHub extends GitServer {
  constructor() {
    super();
  }
}

4. 创建 packages/utils/git/GitUtils.js 文件

import { makeList, log } from "../lib/index.js";
import Github from "./Github.js";
import Gitee from "./Gitee.js";

export async function chooseGitPlatForm() {
  let platForm = await makeList({
    message: "请选择 Git 平台",
    choices: [
      {
        name: "Github",
        value: "github",
      },
      { name: "Gitee", value: "gitee" },
    ],
  });
  log.verbose("platForm", platForm);

  return platForm;
}

export async function initGitServer(platForm) {
  let gitAPI;
  if (platForm === "github") {
    gitAPI = new Github();
  } else if (platForm === "gitee") {
    gitAPI = new Gitee();
  }
  await gitAPI.init(platForm);
  return gitAPI;
}

5. 在 packages/utils/index.js 中导入并导出

+  import Github from "../git/Github.js";
+  import Gitee from "../git/Gitee.js";
+  import { chooseGitPlatForm, initGitServer } from "../git/GitUtils.js";
export {
  log,
  isDebug,
  printErrorLog,
  makeList,
  makeInput,
  makePassword,
  getLatestVersion,
+  Github,
+  Gitee,
+  chooseGitPlatForm,
+  initGitServer
};

在上面的代码中我们分别封装了三个类 GitSever、Github、Gitee,后两者继承自前者,我们之后会使用这两个类来接入 gitee 和 github 的 api 来完成源码的查询与下载,但是,我们当前的目标还是先进行平台(github/gitee)的选择和 token 的缓存,因为调用这两个平台的 api 都是需要提供它们的 token 的,所以我们最好在第一次输入之后缓存一下,之后就不用再输入了。

6. 修改 packages/install/lib/index.js

import { chooseGitPlatForm, initGitServer } from "@coderyjw/utils";
...

  async action() {
    await this.generateGitAPI();
  }

  async generateGitAPI() {
    this.platForm = await chooseGitPlatForm();

    this.gitAPI = await initGitServer(this.platForm);
  }
...

7. 安装依赖

lerna add path-exists packages/utils
lerna add fs-extra packages/utils
lerna add @coderyjw/utils packages/install

8. 测试

image.png

image.png

我们到 C:\Users\yjw\.jw-cli 目录下可以看到两个平台的 token 数据都已经缓存到了本地

image.png

  1. 关于 Github token 如何获取 token 可以查看 文档

  2. Gitee 创建 token

3.3 仓库搜索 + 翻页功能开发

1. Github / Gitee Search API 接入,分别修改 packages/utils/git/Github.js 和 packages/utils/git/Gitee.js

Github.js

import axios from "axois";
import GitServer from "./GitServer.js";
import { log } from "../lib/index.js";

const BASE_URL = "https://api.github.com";

export default class GitHub extends GitServer {
  constructor() {
    super();
    this.service = axios.create({
      baseURL: BASE_URL,
      timeout: 50000,
    });

    this.service.interceptors.request.use(
      (config) => {
        config.headers["Authorization"] = `Bearer ${this.token}`;
        config.headers["Accept"] = "application/vnd.github+json";
        return config;
      },
      (error) => {
        return Promise.reject(error);
      }
    );

    this.service.interceptors.response.use(
      (response) => {
        return response.data;
      },
      (err) => {
        return Promise.reject(err);
      }
    );
  }

  get(url, params, headers) {
    return this.service({
      url,
      params,
      method: "GET",
      headers,
    });
  }

  post(url, data, headers) {
    return this.service({
      url,
      data,
      params: {
        access_token: this.token,
      },
      method: "post",
      headers,
    });
  }

  searchRepositories(params) {
    return this.get("search/repositories", params);
  }

  searchCode(params) {
    return this.get("search/code", params);
  }
}

Gitee.js

import axios from "axois";
import GitServer from "./GitServer.js";
import { log } from "../lib/index.js";

const BASE_URL = "https://gitee.com/api/v5";

export default class Gitee extends GitServer {
  constructor() {
    super();
    this.service = axios.create({
      baseURL: BASE_URL,
      timeout: 5000,
    });

    this.service.interceptors.response.use(
      (response) => {
        return response.data;
      },
      (err) => {
        return Promise.reject(err);
      }
    );
  }

  get(url, params, headers) {
    return this.service({
      url,
      params: {
        ...params,
        access_token: this.token,
      },
      method: "GET",
      headers,
    });
  }

  post(url, data, headers) {
    return this.service({
      url,
      data,
      params: {
        access_token: this.token,
      },
      method: "post",
      headers,
    });
  }

  searchRepositories(params) {
    return this.get("search/repositories", params);
  }
}

2. 安装 axios

lerna add axios packages/utils

3. 修改 packages/install/lib/index.js

import Command from "@coderyjw/command";
import {
  chooseGitPlatForm,
  initGitServer,
  makeList,
  makeInput,
} from "@coderyjw/utils";
const PREV_PAGE = "prev_page";
const NEXT_PAGE = "next_page";
const SEARCH_MODE_REPO = "search_mode_repo";
const SEARCH_MODE_CODE = "search_mode_code";
...
  async action() {
    await this.generateGitAPI();

    await this.searchGitAPI();
    log.verbose("selectedProject", this.selectedProject);
  }
  ...
  async searchGitAPI() {
    log.verbose("this.platForm", this.platForm);
    if (this.platForm === "github") {
      this.mode = await makeList({
        message: "请选择搜索模式",
        choices: [
          { name: "仓库名称", value: SEARCH_MODE_REPO },
          { name: "源码", value: SEARCH_MODE_CODE },
        ],
      });
    }

    // 1. 收集搜索关键词和开发语言
    this.q = await makeInput({
      message: "请输入搜索关键词",
      validate(value) {
        if (value) {
          return true;
        } else {
          return "请输入搜索关键词";
        }
      },
    });

    this.language = await makeInput({
      message: "请输入开发语言",
    });

    this.keywords =
      this.q + (this.language ? `+language:${this.language}` : "");

    log.verbose("search keywords", this.keywords, this.platForm);

    this.page = 1;
    this.perPage = 10;

    await this.doSearch();
  }

  async doSearch() {
    // 2. 根据平台生成搜索参数
    let params;
    let count = 0;
    let list;
    let searchResult;

    if (this.platForm === "github") {
      params = {
        q: this.keywords,
        order: "desc",
        per_page: this.perPage,
        page: this.page,
      };

      log.verbose("search project params", params);
      log.verbose("mode", this.mode);
      if (this.mode === SEARCH_MODE_REPO) {
        searchResult = await this.gitAPI.searchRepositories(params);
        list = searchResult.items.map((item) => ({
          name: `${item.full_name}(${item.description})`,
          value: item.full_name,
        }));
      } else if (this.mode === SEARCH_MODE_CODE) {
        searchResult = await this.gitAPI.searchCode(params);
        list = searchResult.map((item) => ({
          name:
            item.repository.full_name +
            (item.repository.description &&
              `(${item.repository.description})`),
          value: item.repository.full_name,
        }));
      }

      count = searchResult.total_count; // 整体数据量
    } else if (this.platForm === "gitee") {
      params = {
        q: this.q,
        order: "desc",
        per_page: this.perPage,
        page: this.page,
      };
      if (this.language) {
        params.language = this.language; // 注意输入格式: JavaScript
      }
      log.verbose("search params", params);
      searchResult = await this.gitAPI.searchRepositories(params);

      count = 99999;

      list = searchResult.map((item) => ({
        name: `${item.full_name}(${item.description})`,
        value: item.full_name,
      }));
    }
    // 判断当前页面,已经是否达到最大页数
    if (
      (this.platForm === "github" && this.page * this.perPage < count) ||
      (this.platForm === "gitee" && list?.length > 0)
    ) {
      list.push({
        name: "下一页",
        value: NEXT_PAGE,
      });
    }

    if (this.page > 1) {
      list.unshift({
        name: "上一页",
        value: PREV_PAGE,
      });
    }

    if (count > 0) {
      const selectedProject = await makeList({
        message:
          this.platForm === "github"
            ? `请选择要下载的项目(共 ${count} 条数据)`
            : "请选择要下载的项目",
        choices: list,
      });

      if (selectedProject === NEXT_PAGE) {
        await this.nextPage();
      } else if (selectedProject === PREV_PAGE) {
        await this.prevPage();
      } else {
        // 选中项目 去查询 tag
        this.selectedProject = selectedProject;
      }
    }
  }

  async nextPage() {
    this.page++;
    await this.doSearch();
  }

  async prevPage() {
    this.page--;
    await this.doSearch();
  }
  • 解释上面的代码:
    1. 我们首先调用了封装的 searchGitAPI 方法,这个方法是专门查询源码仓库的
    2. 在 searchGitAPI 我们先是进行了平台的判断,因为 github 提供的 api 是可以通过源码查询仓库的
    3. 之后我们进行了一些数据的收集,包括搜索的关键字和开发语言,这些都是调用 github/gitee api 所需要的参数
    4. 然后调用了另一个封装的 doSearch 方法,封装这个方法的目的是为了翻页,因为在翻页的时候还需要再次做查询的操作,代码会重复。
    5. 在 doSearch 方法中我们对不同平台(github/gitee)不同搜索模式(通过仓库名称搜索、通过源码搜索)进行了区分,它们会调用不同的 api
    6. 再拿到搜索到的数据之后,在命令行中以列表的形式显示出来让用户选择
    7. 最后分别封装了 nextPage 和 prePage 进行翻页的操作

4. 我们通过 jw-cli install -d 命令进行测试

image.png

image.png

image.png

image.png

image.png

image.png

image.png

3.4 tag 选择 + 翻页功能开发

tag 的选择和翻页的功能和上一小节是相同的逻辑,这里就不再解释,直接看代码吧

1. 修改 packages/utils/Gitee.js,接入查询 giee tag 的 api

getTags(fullName) {
    return this.get(`/repos/${fullName}/tags`);
}

2. 修改 packages/utils/Github.js,接入查询 github tag 的 api

getTags(fullName, params) {
    return this.get(`/repos/${fullName}/tags`, params);
}

3. 修改 packages/install/lib/index.js

...
  async action() {
    await this.generateGitAPI();

    await this.searchGitAPI();
    log.verbose("selectedProject", this.selectedProject);
    log.verbose("selectedTag", this.selectedTag);
  }
  ...
  async searchGitAPI() {
      ...
      ...
      await this.selectTags();
  }
...
  async selectTags() {
    let tagsList;
    this.tagPage = 1;
    this.tagPerPage = 30;
    tagsList = await this.doSelectTags();
  }

  async doSelectTags() {
    let tagsListChoices = [];
    let tagsList
    if (this.platForm === "github") {
      const params = {
        page: this.tagPage,
        per_page: this.tagPerPage,
      };
      log.verbose("search tags params", this.selectedProject, params);
       tagsList = await this.gitAPI.getTags(this.selectedProject, params);
      tagsListChoices = tagsList.map((item) => ({
        name: item.name,
        value: item.name,
      }));
    } else {
      log.verbose("search tags params", this.selectedProject);
       tagsList = await this.gitAPI.getTags(this.selectedProject);
      tagsListChoices = tagsList.map((item) => ({
        name: item.name,
        value: item.name,
      }));
    }
    if (tagsList.length === 0) return;

    if (tagsList.length > 0) {
      tagsListChoices.push({
        name: "下一页",
        value: NEXT_PAGE,
      });
    }
    if (this.tagPage > 1) {
      tagsListChoices.unshift({
        name: "上一页",
        value: PREV_PAGE,
      });
    }
    const selectedTag = await makeList({
      message: "请选择tag",
      choices: tagsListChoices,
    });

    if (selectedTag === NEXT_PAGE) {
      await this.nextTags();
    } else if (selectedTag === PREV_PAGE) {
      await this.prevTags();
    } else {
      this.selectedTag = selectedTag;
    }
  }

  async prevTags() {
    this.tagPage--;
    await this.doSelectTags();
  }

  async nextTags() {
    this.tagPage++;
    await this.doSelectTags();
  }

4. 测试

image.png

3.5 下载指定分支源码

选择完仓库名和分支后就可以去下载仓库了,这个功能我们可以通过 execa 模块执行 git clone 命令实现

  1. 修改 packages/install/lib/index.js

  async action() {
    await this.generateGitAPI();

    await this.searchGitAPI();

    log.verbose("selectedProject", this.selectedProject);
    log.verbose("selectedTag", this.selectedTag);

+    await this.downloadRepo();
  }
  
  ...
+  async downloadRepo() {
+    const spinner = ora(
+      `正在下载:${this.selectedProject}` +
+        (this.selectedTag ? `(${this.selectedTag})` : "")
+    ).start();
+    try {
+      await this.gitAPI.cloneRepo(this.selectedProject, this.selectedTag);
+      spinner.stop();
+      log.success(
+        `下载模板成功:${this.selectedProject}` +
+          (this.selectedTag ? `(${this.selectedTag})` : "")
+      );
+    } catch (err) {
+      spinner.stop();
+      printErrorLog(err);
+    }
+  }
  1. 修改 packages/utlis/git/GitServer.js
+  cloneRepo(fullName, tag) {
+    if (tag) {
+      return execa("git", ["clone", this.getRepoUrl(fullName), "-b", tag]);
+    }
+    return execa("git", ["clone", this.getRepoUrl(fullName)]);
+  }
  1. 修改 packages/utlis/git/Github.js
+  getRepoUrl(fullName) {
+    return `https://github.com/${fullName}.git`;
+  }
  1. 修改 packages/utlis/git/Gitee.js
+  getRepoUrl(fullName) {
+    return `https://gitee.com/${fullName}.git`;
+  }
  1. 安装依赖
lerna add ora packages/install

3.6 自动安装依赖 + 启动项目

  1. 修改 packages/install/lib/index.js
  async downloadRepo() {
    const spinner = ora(
      `正在下载:${this.selectedProject}` +
        (this.selectedTag ? `(${this.selectedTag})` : "")
    ).start();
    try {
      await this.gitAPI.cloneRepo(this.selectedProject, this.selectedTag);
      spinner.stop();
      log.success(
        `下载模板成功:${this.selectedProject}` +
          (this.selectedTag ? `(${this.selectedTag})` : "")
      );
+      await this.installDependencies();
+      await this.runRepo();
    } catch (err) {
      spinner.stop();
      printErrorLog(err);
    }
  }
  
+  async installDependencies() {
+    const spinner = ora(
+      `正在安装依赖:${this.selectedProject}` +
+        (this.selectedTag ? `(${this.selectedTag})` : "")
+    ).start();
+    try {
+      const ret = await this.gitAPI.installDependencies(
+        process.cwd(),
+        this.selectedProject,
+        this.selectedTag
+      );
+      spinner.stop();
+      if (ret) {
+        log.success(
+          `依赖安装安装成功:${this.selectedProject}` +
+            (this.selectedTag ? `(${this.selectedTag})` : "")
+        );
+      } else {
+        log.error("依赖安装失败");
+      }
+    } catch (err) {
+      spinner.stop();
+      printErrorLog(err);
+    }
+  }

  async runRepo() {
    await this.gitAPI.runRepo(process.cwd(), this.selectedProject);
  }
  1. 修改 packages/utils/git/GitServer.js

function getProjectPath(cwd, fullName) {
  const projectName = fullName.split("/")[1]; // vuejs/vue => vue
  const projectPath = path.resolve(cwd, projectName);
  return projectPath;
}

function getPackageJson(cwd, fullName) {
  const projectPath = getProjectPath(cwd, fullName);
  const pkgPath = path.resolve(projectPath, "package.json");
  if (pathExistsSync(pkgPath)) {
    return fse.readJsonSync(pkgPath);
  } else {
    return null;
  }
}

...
export default class GitServer {
...
  installDependencies(cwd, fullName, tag) {
    const projectPath = getProjectPath(cwd, fullName);
    if (pathExistsSync(projectPath)) {
      return execa("npm", ["install", "--registry=https://registry.npmmirror.com"], { cwd: projectPath });
    }

    return null;
  }

  runRepo(cwd, fullName) {
    const projectPath = getProjectPath(cwd, fullName);
    const pkg = getPackageJson(cwd, fullName);
    if (pkg) {
      const { scripts, bin } = pkg;

      if (bin) {
        execa("npm", ["run", "-g", name, "--registry=https://registry.npmmirror.com"], {
          cwd: projectPath,
          stdout: "inherit",
        });
      }

      if (scripts && scripts.dev) {
        return execa("npm", ["run", "dev"], {
          cwd: projectPath,
          stdout: "inherit",
        });
      } else if (scripts && scripts.serve) {
        return execa("npm", ["run", "serve"], {
          cwd: projectPath,
          stdout: "inherit",
        });
      } else if (scripts && scripts.start) {
        return execa("npm", ["run", "start"], {
          cwd: projectPath,
          stdout: "inherit",
        });
      } else {
        log.warn("未找到启动命令");
      }
    } else {
    }
  }

那么至此我们的源码下载脚手架就已经开发完成了,最后我们用一张图总结所有流程

image.png

image.png