从零开发前端脚手架——下载GitLab模板模式

1,943 阅读6分钟

引言

当一个团队需要同时开发多个具有相同框架和配置的项目时,为了节省搭建项目的时间,通常会将基础框架配置抽取成一个模板。当需要开设新项目时,只需要拉取该模板并配置少量的定制化参数,就可以快速进入业务开发阶段,从而大大提高了工作效率。因此,本文针对此场景设计了一个脚手架。在大多数公司中,内部使用gitlab作为协同开发工具,因此本文基于gitlab提供了相应的教程。如果使用github或gitee等其他工具,需要查阅相关资料并相应调整拉取项目的步骤。(文章最后有代码仓库链接)

graph TD
在本地使用脚手架命令创建项目 --> 选取需要拉取的远程模板 --> 将模板下载到本地 --> 配置定制化参数 --> 在本地根据模板以及参数创建对应项目 --> 开箱即用

Gitlab准备 - 如何获取模板列表

步骤1:获取 GitLab 的访问令牌(Access Token)

首先,你需要在 GitLab 上创建一个访问令牌,以便在 API 请求中进行身份验证。可以按照以下步骤创建访问令牌:

  1. 登录到 GitLab 帐户并导航到用户设置页,也可以进入你需要获取的群组下的设置页,这里用群组做演示。
  2. 在左侧导航栏中选择 "Access Tokens" 选项。 image.png
  3. 提供一个名称,选择所需的权限,并点击 "Create Access Token" 按钮。 image.png
  4. 复制生成的访问令牌,这将是后续 API 请求中的身份验证凭证。

步骤2: 了解 Gitlab 相关 api

功能接口功能描述
获取项目信息GET /projects/:id通过该接口可以获取指定项目的详细信息,包括项目的名称、描述、创建者、创建时间、最近更新时间、访问级别、仓库 URL、分支信息、标签信息等。
获取项目列表GET /projects通过该接口可以获取当前用户或指定用户下的项目列表,可以设置不同的参数来筛选和排序项目,例如项目的可见性、访问级别、所属组织、创建者、名称等。
获取项目文件内容GET /projects/:id/repository/files/:file_path通过该接口可以获取指定项目中某个文件的内容,可以设置文件路径、分支名称、文件格式等参数。
获取项目目录内容GET /projects/:id/repository/tree通过该接口可以获取指定项目中某个目录下的文件和子目录的信息,可以设置目录路径、分支名称、递归深度等参数。
获取项目提交列表GET /projects/:id/repository/commits通过该接口可以获取指定项目的提交列表,包括提交 ID、提交者信息、提交时间、提交信息、变更文件等。
获取项目分支列表GET /projects/:id/repository/branches通过该接口可以获取指定项目的分支列表,包括分支名称、最近提交信息、保护状态等。
获取项目标签列表GET /projects/:id/repository/tags通过该接口可以获取指定项目的标签列表,包括标签名称、标签信息、提交信息等。
获取项目合并请求列表GET /projects/:id/merge_requests通过该接口可以获取指定项目的合并请求列表,包括合并请求的标题、描述、状态、提交信息等。

步骤3:发送 API 请求获取仓库信息

使用 JavaScript 中的 HTTP 请求库,例如 Axios、Fetch 等,发送 GET 请求到 GitLab API 获取仓库信息。以下是一个使用 Axios 库发送请求的示例代码

// 导入 Axios 库
const axios = require('axios');

// 设置 GitLab API 的基本 URL 和访问令牌
const gitlabBaseUrl = 'https://gitlab.example.com/api/v4'; // 替换为你的 GitLab 实例的 URL
const accessToken = 'YOUR_ACCESS_TOKEN'; // 替换为你的访问令牌

// 设置请求的项目 ID 和项目路径
const projectId = '1'; // 替换为你的项目 ID
const projectPath = 'your-project-path'; // 替换为你的项目路径

// 构造 API 请求 URL
const apiUrl = `${gitlabBaseUrl}/projects/${projectId}`;

// 发送 GET 请求获取项目信息
axios.get(apiUrl, {
  headers: {
    'Authorization': `Bearer ${accessToken}` // 使用访问令牌进行身份验证
  },
  params: {
    'path': projectPath // 设置项目路径作为查询参数
  }
})
.then(response => {
  // 处理 API 响应
  console.log('项目信息:', response.data);
})
.catch(error => {
  // 处理错误
  console.error('获取项目信息失败:', error);
});

npm工具库准备

库名称详细功能仓库链接npm 链接
axiosAxios 是一个基于 Promise 的 HTTP 客户端,用于浏览器和 Node.js 环境中进行 HTTP 请求。它支持拦截器、取消请求、自动转换响应数据等丰富的功能,适用于前端和后端的 HTTP 通信。GitHub 仓库npm
commander"commander" 是一个强大的 Node.js 命令行解析器,它可以帮助开发者轻松地创建命令行工具。它提供了丰富的功能,包括定义命令、选项、参数,处理用户输入,生成帮助信息等,使得创建复杂的命令行工具变得简单和高效。GitHub 仓库npm
consolidate"consolidate" 是一个模板引擎的统一接口库,它可以在多个模板引擎之间进行切换,使得在应用中使用不同的模板引擎变得更加灵活和方便。"consolidate" 支持众多流行的模板引擎,如 EJS、Handlebars、Pug 等,并提供了一致的 API 来渲染模板和处理数据。GitHub 仓库npm
download-git-repo"download-git-repo" 是一个用于从 Git 仓库下载代码库的 Node.js 模块。它提供了简单的 API,用于从远程 Git 仓库下载代码并将其保存到本地文件系统,支持多种协议和认证方式,并且具有下载进度、错误处理等功能。GitHub 仓库npm
ejs"ejs" 是一个简单高效的 JavaScript 模板引擎,用于在 Node.js 和浏览器中生成动态 HTML 页面。它支持嵌套模板、动态数据绑定、条件和循环语句等,使得在应用中生成 HTML 变得更加简单和灵活。GitHub 仓库npm
inquirer"inquirer" 是一个功能强大的 Node.js 命令行交互工具,用于与用户进行交互式的命令行界面。它支持多种问题类型,如输入框、选择器、确认框等,还可以自定义问题和答案的样式、验证用户输入等,使得在命令行中获取用户输入变得更加方便和灵活。GitHub 仓库npm
metalsmith"metalsmith" 是一个简单灵活的静态网站生成器,使用 Node.js 编写,支持自定义插件和模板引擎,可以用于构建各种类型的静态网站,如博客、文档、API 文档等。GitHub 仓库npm
ncp"ncp" 是一个简单而强大的 Node.js 文件复制工具,可以用于在文件系统中复制文件和目录,支持递归复制、忽略文件和目录、设置权限等功能,用于在 Node.js 应用中进行文件复制操作非常方便。GitHub 仓库npm
ora"ora" 是一个简单易用的 Node.js 终端加载动画库,用于在命令行界面中展示加载状态。它支持多种样式和颜色,可以自定义加载文本、加载速度等,非常适合在 Node.js 命令行工具中显示加载状态。GitHub 仓库npm

项目实战流程

1. 初始化项目和命令

1.1 新建项目文件夹,并初始化项目

mkdir hi-cli
cd hi-cli
npm init -y #初始化项目,得到package.json

1.2 按照目录格式新建对应文件

├── bin
│   └── www             // 全局命令执行的根文件(无后缀名)
├── package.json
└── src
    ├── const
    │   ├── index.js    // 对外暴露的公共常量
    │   └── private.js  // 私有常量
    ├── lib
    │   └── create.js   // 入口文件
    └── main.js

1.3 链接全局包

设置在命令下执行hi-cli时调用bin目录下的www文件, 编辑package.json 添加

"bin": {
    "hi-cli": "./bin/www"
 },

结果

{
  "name": "hi-cli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  + "bin": {
  +  "hi-cli": "./bin/www"
  + },
  "scripts": {}
  ...
}

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

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

链接包到全局下使用,运行

npm link

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

为了测试可以在main.js里面添加测试脚本

console.log("Welcome to hi-cli");

image.png

2. 解析命令行参数

一般我们使用脚手架命令,会添加参数,比如

hi-cli create my-project

或者使用

hi-cli --help

来查看该命令有什么帮助选项。这些都能使用commander来实现

2.1 使用commander

npm install commander

编辑main.js

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

这时候运行hi-cli --help会看到自动生成的help信息

运行hi-cli -V会看到我们配置的版本号0.0.1

image.png

保持版本和我们脚手架package.json文件中的一致,我们提取package.json文件中的版本及项目名字。 编辑const/index.js文件

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

module.exports = {
  cliName: name,
  version,
};

接着在main.js中引入该参数

const program = require("commander");
const { version } = require("./const");

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

这时候运行命令就会得到

image.png

2.2 初始化命令,获取用户终端参数,比如项目文件名

根据我们想要实现的功能配置执行动作,遍历产生对应的命令,同时添加监听help命令,打印帮助信息。编辑文件main.js

const program = require("commander");
const path = require("path");
const { version, cliName } = require("./const");

// 定义命令
const actionsMap = {
  create: {
    description: "create project",
    alias: "cr",
    examples: [`${cliName} create <template-name>`],
  },
  "*": {
    alias: "",
    description: `command not found, please use \n ${cliName} --help`,
  },
};

Object.keys(actionsMap).forEach((action) => {
  program
    .command(action)
    .alias(actionsMap[action].alias)
    .description(actionsMap[action].description)
    .action(() => {
      if (action === "*") {
        console.log(actionsMap[action].description);
      } else {
        const argv = process.argv.slice(3);
        // 当使用create命令,获取用户输入后,当作参数调用 lib/create.js
        // 这时候我们关注create.js文件即可
        require(path.resolve(__dirname, `lib/${action}`))(...argv);
      }
    });
});

// 添加监听help命令,打印帮助信息
program.on("--help", () => {
  console.log("\n Examples");
  Object.keys(actionsMap).forEach((action) => {
    (actionsMap[action].examples || []).forEach((example) => {
      console.log(`  ${example}`);
    });
  });
});

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

这时候就会得到

image.png

2.3 create命令逻辑编写

当我们拿到项目名称时候,就调用create.js, 创建Creator实例,调用create方法。 create方法主要步骤如下:

graph TD
从拿到create命令的projectName参数开始 --> 1.判断当前目录下是否有重名文件 --> 2.获取当前组织下的所有仓库列表 --> 3.用户选择需要下载的模板仓库 --> 4.决定是依据标签还是分支来获取具体版本代码 --> 5.根据选择结果获取资源列表 --> 6.选定资源模板后进行下载,会判断是否下载过该模板 --> 7.模板下载完成后,获取用户定制化参数,渲染模板,并在当前目录生成新项目 --> 完成

编辑文件create.js

// 各种工具库,详细见工具库介绍章节
const axios = require("axios"); // 接口请求封装
const path = require("path");
const chalk = require("chalk"); // 终端输出字符美化
const ora = require("ora"); // 加载
const fs = require("fs");
const Inquirer = require("inquirer"); // 交互式命令行。
const { promisify } = require("util");
const MetalSmith = require("metalsmith"); // 遍历文件夹 找需不需要渲染
let { render } = require("consolidate").ejs;
render = promisify(render);

const { token, url, clonePre, downloadDirectory } = require("../const");

let downLoadGit = require("download-git-repo");
downLoadGit = promisify(downLoadGit);
let ncp = require("ncp");
ncp = promisify(ncp);

class Creator {
  // 从拿到create命令的projectName参数开始
  constructor(projectName) {
    this.projectName = projectName;
  }

  async create() {
    // 1.判断当前目录下是否有重名文件
    const currentPath = path.resolve(this.projectName);

    const continued = await this.checkFileExistAndIfOverwrite(currentPath);
    if (!continued) {
      console.log(chalk.cyan("Nothing happened, bye"));
      return;
    }

    // 2.获取当前组织下的所有仓库列表
    const repos = await this.wrapFetchAddLoding(
      this.fetchRepoList,
      "fetching repo list"
    )();

    // 3.用户选择需要下载的模板仓库
    const templates = repos
      .map((item) => ({
        name: item.name,
        value: `${item.name}_${item.id}`,
      }))
      .filter((item) => item.name.includes("template"));

    const { template } = await Inquirer.prompt({
      name: "template",
      type: "list",
      message: "please choose template to create project",
      choices: templates, // 选择模式
    });

    let [templateName, templateId] = template.split("_");

    // 4.决定是依据标签还是分支来获取具体版本代码
    const { type } = await Inquirer.prompt({
      name: "type",
      type: "list",
      message: "Which resource do you want, branches or tags",
      choices: [
        {
          name: "branches",
          value: "branches",
        },
        {
          name: "tags",
          value: "tags",
        },
      ], // 选择模式
    });

    // 5.根据选择结果获取资源列表,
    let resources = await this.wrapFetchAddLoding(
      this.fetchRepoResourceList,
      `fetching ${type} list`
    )(templateId, type);

    // 可能是tag列表,或者是分支列表
    resources = resources.map((item) => item.name);

    const { resource } = await Inquirer.prompt({
      name: "resource",
      type: "list",
      message: "please choose template to create project",
      choices: resources, // 选择模式
    });

    // 6.选定资源模板后进行下载,会判断是否下载过该模板
    const result = await this.download(templateName, resource);

    console.log("downloaded in\n", result);

    // 7.模板下载完成后,获取用户定制化参数,渲染模板,并在当前目录生成新项目
    try {
      await this.renderTemplate(result, currentPath);
      console.log(
        chalk.green(
          `Your project has been created successfully in ${currentPath}`
        )
      );
    } catch (e) {
      console.log(chalk.red("error", e));
    }
  }
  // 完成。

  // 以下是封装的函数
  // 获取仓库列表
  async fetchRepoList() {
    const { data } = await axios.get(url, {
      headers: {
        "PRIVATE-TOKEN": token,
      },
    });
    return data;
  }

  // type: tags | branches
  async fetchRepoResourceList(id, type) {
    const { data } = await axios.get(`${url}/${id}/repository/${type}/`, {
      headers: {
        "PRIVATE-TOKEN": token,
      },
    });
    return data;
  }

  // 下载项目
  async copyToMyProject(currentPath) {
    await ncp(target, currentPath);
  }

  // 判断当前文件夹下是否有重名文件,如果有则咨询是否覆盖,覆盖则删掉原有的目录
  async checkFileExistAndIfOverwrite(dest) {
    if (fs.existsSync(dest)) {
      const { action } = await Inquirer.prompt([
        {
          name: "action",
          type: "list",
          message: "File exists, do you want to overwrite or cancel?",
          choices: [
            {
              name: "overwrite",
              value: "overwrite",
            },
            {
              name: "cancel",
              value: false,
            },
          ],
        },
      ]);
      if (action === "overwrite") {
        console.log(chalk.yellow("Removing the file..."));
        fs.rmSync(dest, { recursive: true });
        return true;
      } else {
        return false;
      }
    }
    return true;
  }

  // 用克隆的方式从gitlab下载项目
  async download(template, resource) {
    let api = `${clonePre}/${template}${resource ? "#" + resource : ""}`;
    const dest = `${downloadDirectory}/${template}#${resource}`; // 将模板下载到对应的目录中

    // 判断是否已经下载过该模板
    const continued = await this.checkFileExistAndIfOverwrite(dest);

    // 如果没有删除,则停止后续下载操作。
    if (!continued) return dest;

    await this.wrapFetchAddLoding(downLoadGit, "Downloading the template")(
      api,
      dest,
      {
        clone: true,
      }
    );

    return dest; // 返回下载目录
  }

  // 7.模板下载完成后,获取用户定制化参数,渲染模板,并在当前目录生成新项目
  async renderTemplate(result, currentPath) {
    // 7.1.下载完成后判断模板是否有ask.json文件,是的话就是带有模板需要渲染的目录
    const askFileName = "ask.json";
    const askPath = path.join(result, askFileName);

    // 如果不是带模板的项目,直接拷贝
    if (!fs.existsSync(askPath)) {
      // 将下载的文件拷贝到当前执行命令的目录下
      await this.wrapFetchAddLoding(copyToMyProject, currentPath);
    } else {
      // 7.2.是的话则根据json文件获取用户定制化参数
      await new Promise((resovle, reject) => {
        MetalSmith(__dirname) // 如果你传入路径 他默认会遍历当前路径下的src文件夹
          .source(result)
          .destination(currentPath)
          .use(async (files, metal, done) => {
            // 根据ask.json询问用户
            const args = require(askPath);
            const result = await Inquirer.prompt(args);
            const data = metal.metadata();
            Object.assign(data, result);
            // 删掉复制项目的ask.json文件
            delete files[askFileName];
            done();
          })
          .use((files, metal, done) => {
            const data = metal.metadata();
            Reflect.ownKeys(files).forEach(async (file) => {
              // 根据项目需求过滤你需要渲染的模板
              if (
                file.includes(".js") ||
                file.includes(".json") ||
                file.includes(".env") ||
                file.includes(".md")
              ) {
                let content = files[file].contents.toString(); // 文件的内容
                if (content.includes("<%")) {
                  content = await render(content, data);
                  files[file].contents = Buffer.from(content); // 渲染
                }
              }
            });
            // 根据用户的输入 下载模板
            done();
          })
          .build((err) => {
            if (err) {
              reject(err);
            } else {
              resovle();
            }
          });
      });
    }
  }

  // 对于promise函数,在开始时候开启loading提示,更用户友好
  wrapFetchAddLoding(fn, message) {
    return async (...args) => {
      const spinner = ora(message);
      spinner.start(); // 开始loading
      let r;
      try {
        r = await fn(...args);
        spinner.succeed(); // 结束loading
      } catch (e) {
        spinner.fail(); // 结束loading
      }
      return r;
    };
  }
}

module.exports = async (projectName) => {
  const creator = new Creator(projectName);
  creator.create();
};

以上用到了很多常量,我们需要编辑const/index.js

const { token, url, clonePre } = require("./private");

// 存放用户的所需要的常量
const { version, name } = require("../../package.json");

// 存储模板的位置, for macOS
const downloadDirectory = `${
  process.env[process.platform === "darwin" ? "HOME" : "USERPROFILE"]
}/.template`;

module.exports = {
  version,
  downloadDirectory,
  cliName: name,
  // private
  token,
  url,
  clonePre,
};

以及根据团队gitlab信息编辑const/private.js

// for templates group
// 你创建的 GitLab 的访问令牌(Access Token)
const token = "";

// 假设你的公司gitlab地址为, https://gitlab.company.vip
// 那么url就为 https://gitlab.company.vip/api/v4/projects
const url = "";

// 同时你的group为templates,
// 那么clonePre就为 gitlab.company.vip:templates
const clonePre = "";
module.exports = { token, url, clonePre };

配置完这些,就完成了cli项目的代码,那么你就可以在公司的npm私有库发布你的cli脚手架,要保证用户有模板仓库的访问权限,这部分就没有教程了。

模板代码和ask.json

模板代码,举例如果要配置package.json

{
  "name": "my-project",
  "version": "0.1.0",
  "private": true,
  "autor": "<%=author%>",
  "description": "<%=description%>",
  "license": "<%=license%>",
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "core-js": "^3.8.3",
    "vue": "^2.6.14"
  },
  "devDependencies": {
    "@babel/core": "^7.12.16",
    "@babel/eslint-parser": "^7.12.16",
    "@vue/cli-plugin-babel": "~5.0.0",
    "@vue/cli-plugin-eslint": "~5.0.0",
    "@vue/cli-service": "~5.0.0",
    "eslint": "^7.32.0",
    "eslint-plugin-vue": "^8.0.3",
    "vue-template-compiler": "^2.6.14"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "@babel/eslint-parser"
    },
    "rules": {}
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

ask.json如下

[
  {
    "type": "input",
    "name": "author",
    "message": "author?"
  },
  {
    "type": "input",
    "name": "description",
    "message": "description?"
  },
  {
    "type": "input",
    "name": "license",
    "message": "license?"
  }
]

注意到我们在模板仓库里,需要配置的地方用到编写方式 <%=${paramName}%>,在ask.json里就是name属性值。 根据自己的需要,在相应的文件里添加需要的自定义配置。同时注意修改渲染部分脚本,增加需要扫描的文件类型。

// 根据项目需求过滤你需要渲染的模板
  if (
    file.includes(".js") ||
    file.includes(".json") ||
    file.includes(".env") ||
    file.includes(".md")
  ) {
    let content = files[file].contents.toString(); // 文件的内容
    if (content.includes("<%")) {
      content = await render(content, data);
      files[file].contents = Buffer.from(content); // 渲染
    }
  }

优化代码介绍

1.文件夹是否存在判断

使用fs库,来判断是否当前目录下存在相同文件

// 判断当前文件夹下是否有重名文件,如果有则咨询是否覆盖,覆盖则删掉原有的目录
  async checkFileExistAndIfOverwrite(dest) {
    if (fs.existsSync(dest)) {
      const { action } = await Inquirer.prompt([
        {
          name: "action",
          type: "list",
          message: "File exists, do you want to overwrite or cancel?",
          choices: [
            {
              name: "overwrite",
              value: "overwrite",
            },
            {
              name: "cancel",
              value: false,
            },
          ],
        },
      ]);
      if (action === "overwrite") {
        console.log(chalk.yellow("Removing the file..."));
        fs.rmSync(dest, { recursive: true });
        return true;
      } else {
        return false;
      }
    }
    return true;
  }

2.对于需要花费较长时间处理的操作,添加加载中提示

使用ora库,创建spinner

// 对于promise函数,在开始时候开启loading提示,更用户友好
  wrapFetchAddLoding(fn, message) {
    return async (...args) => {
      const spinner = ora(message);
      spinner.start(); // 开始loading
      let r;
      try {
        r = await fn(...args);
        spinner.succeed(); // 结束loading
      } catch (e) {
        spinner.fail(); // 结束loading
      }
      return r;
    };
  }

3.使用chalk库优化提示文字

可以自行定义

参考代码链接

hi-cli 脚手架代码

github.com/ShirleyZmj/…

template 模板示例代码

github.com/ShirleyZmj/…