设计自己的脚手架

前言

​ 在工程中,不仅是软件工程,在建筑行业,我们也经常能看到脚手架的概念。脚手架(又称为CLI,全称command-line interface),我理解是一种快速构建项目的工具,它主要提供了项目的基础结构和一些常用的配置,避免了从头开始搭建项目的繁琐工作。通过使用脚手架,开发者可以更加高效地创建和启动项目,并且保持项目结构的一致性,同时还能提供一些常用的功能和工具,例如自动化构建、代码生成、测试等。脚手架可以根据特定的需求和技术栈来定制。平时的学习和工作中我们经常会用到各种各样的脚手架,例如vue-cliCreate React Appcreate-vite等等,使用这些工具可以大大提高我们创建项目的速度。

​ 那当我们遇到重复的构建工作的时候是不是也可以考虑自己搭建一个脚手架呢。正好最近在使用vitepress搭建自己的小册网站记录自己的学习过程。由于前端涉及到的工具很多,需要很多个小册,那么我是不是可以通过搭建自己的脚手架来完成这样重复的小册构建工作呢?于是就有了这篇文章

​ 学习本文,你能收获:

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

前置知识

​ 在看了常见的脚手架工具的源码之后,发现大部分脚手架的构建都离不开以下工具,先对这些工具做个介绍,以便有初步的了解。这些库不一定都会用到,可以按需选择。

  • commander:最常用的命令行工具,可以通过内置的api很方便的读取命令行的命令。
    • minimist: 命令行参数解析工具,比commander要简单
  • inquirer: 交互式命令工具,给用户提供一个提问流方式,通过promise的方式返回用户选择。可以实现定制化功能
  • prompts: 交互式命令工具,inquirer的轻量级版本
  • chalk: 颜色插件,用来修改命令行输出样式,通过颜色区分info、error日志。对重要信息用不同颜色进行区分
  • ora: 用于显示加载中的效果,类似于前端页面的loading效果,像下载模版这种耗时的操作,有了loading效果,可以提示用户正在进行中,请耐心等待。在我们平时使用命令行的时候经常看到这种效果
  • fs-extra: node fs文件系统模块的增强版,可以方便我们操作本地文件。对所有的异步操作都提供了promise支持
  • cross-spawn: 是一个用于跨平台执行命令的 Node.js 模块。它解决了在不同操作系统上执行命令时可能会遇到的一些兼容性问题。
  • figlet: 可以将 text 文本转化成生成基于 ASCII 的艺术字
  • execa: 执行终端命令
  • handlebars: 可以方便执行模版替换,用用户的输入替换掉内置模版

如何搭建一个脚手架

​ 我们以vue-cli为例,看下脚手架的一般功能

  1. 提供不同的指令,执行不同的事情

    例如 --version --help --create等等

  2. 交互式用户选择

    我们的脚手架可能会有多种选择,我们需要向用户提供不同的选择。

    image-20230714162425036
  3. 用户选择完毕后,根据用户选择生成用户需求的项目文件

通过以上分析,我们可以看出脚手架的基本范式:通过命令行与用户交互的选择来生成对应的文件。

image-20230714162938722

搭建自己的脚手架

知道了大概流程之后我们就可以开始搭建自己的脚手架了

项目使用 typescript + node搭建,主要目录结构如下

zy-cli
├─ .gitignore
├─ README.md
├─ build // 打包后文件夹
├─ project-template // 初始化项目模版
├─ bin
|  ├─ bin.js // 生产环境执行文件入口
|  ├─ bin-dev.js // 本底调试执行文件入口,主要是能够动态编译ts
├─ package.json // 配置文件,具体见下
├─ src
│  └─ const // 常量包
│     ├─ index.ts
│  ├─ commands // 命令文件夹
│  │  ├─ create.ts // create命令
│  │  ├─ config.ts // config命令
│  │  ├─ package.ts // package命令
│  │  └─ utils // 公共函数
│  ├─ index.ts // 入口文件
│  └─ helpers // 公共第三方包
│     ├─ index.ts
│     ├─ logger.ts // 控制台颜色输出
│     └─ spinner.ts // 控制台loading
│  └─ utils // 工具类
│     ├─ index.ts
├─ tsconfig.json // TypeScript配置文件
└─ tslint.json // tslint配置文件
初始化项目
安装依赖

1、npm init 初始化package.json

2、npm i typescript ts-node eslint rimraf -D 安装开发依赖

3、npm i typescript chalk commander execa fs-extra globby handlebars inquirer ora pacote figlet 安装生产依赖

packagejson 配置

scripts配置clear、build、publish、lint命令

完成的package.json配置文件如下

{
  "name": "xzy-cli",
  "version": "1.0.0",
  "description": "xzy-cli",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "clear": "rimraf build",
    "build": "npm run clear && tsc",
    "publish": "npm run build && npm publish",
    "lint": "tslint ./src/**/*.ts --fix",
    "lini-fix": "tslint ./src/**/*.ts --fix"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/xzy0625/xzy-cli.git"
  },
  "bin": {
    "xzy": "./bin/bin.js",
    "xzy-dev": "./bin/bin-dev.js"
  },
  "keywords": [
    "cli",
    "node",
    "vitepress",
    "typescript"
  ],
  "author": "csuxxzy",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/xzy0625/xzy-cli/issues"
  },
  "homepage": "https://github.com/xzy0625/xzy-cli#readme",
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^6.0.0",
    "@typescript-eslint/parser": "^6.0.0",
    "eslint": "^8.45.0",
    "rimraf": "^5.0.1",
    "ts-node": "^10.9.1",
    "typescript": "^5.1.6"
  },
  "dependencies": {
    "chalk": "^4.1.2",
    "commander": "^11.0.0",
    "execa": "^7.1.1",
    "figlet": "^1.6.0",
    "fs-extra": "^11.1.1",
    "globby": "^11.0.4",
    "handlebars": "^4.7.7",
    "inquirer": "^9.2.8",
    "ora": "^6.3.1",
    "pacote": "^15.2.0"
  }
}

需关注bin字段和files字段。

  • bin字段: bin字段用于指定可执行文件的路径,是在全局或局部安装模块时可以直接运行的命令。bin字段的值可以是一个字符串或一个对象。如果bin字段的值是一个字符串,那么它表示一个可执行文件的路径。例如:
{
  "name": "my-package",
  "version": "1.0.0",
  "bin": "./dist/my-command.js"
}

​ 在安装该模块后,可以直接在命令行中运行my-command来执行./dist/my-command.js指定的文件。

​ 如果bin字段的值是一个对象,那么它的键是命令的名字,值是对应的可执行文件的路径。例如:

{
  "name": "my-package",
  "version": "1.0.0",
  "bin": {
    "my-command": "./dist/my-command.js",
    "another-command": "./dist/another-command.js"
  }
}

​ 在安装该模块后,可以直接在命令行中运行my-commandanother-command来分别执行./dist/my-command.js和./dist/another-command.js`指定的文件。

  • files字段: 即npm的白名单,也就是说发包后需要包括哪些文件,不配置的话默认发布全部文件,这样很容易暴漏我们的源码,也会导致npm的包体积过大。所以这里我们配置了"files": [ "build", "bin.js" ],发布到npm的时候只会包含build目录和bin.js文件
lint 和typescript配置
  • typescript配置

    根目录下执行tsc --init,更改配置如下

    {
      "compilerOptions": {
        "target": "es2017",
        "module": "commonjs",
        "moduleResolution": "node",
        "emitDecoratorMetadata": true,
        "experimentalDecorators": true,
        "allowSyntheticDefaultImports": true,
        "alwaysStrict": true,
        "sourceMap": false,
        "noEmit": false,
        "noEmitHelpers": false,
        "importHelpers": false,
        "strictNullChecks": false,
        "allowUnreachableCode": true,
        "lib": ["es6"],
        "typeRoots": ["./node_modules/@types"],
        "outDir": "./build", // 重定向输出目录
        "rootDir": "./src" // 仅用来控制输出的目录结构
      },
      "exclude": [ // 不参与打包的目录
        "node_modules",
        "build",
      ],
      "esModuleInterop": true,
      "allowSyntheticDefaultImports": true,
      "compileOnSave": false,
      "buildOnSave": false
    }
    
    
  • eslint

    根目录下执行 eslint --init --format json,完整配置如下

    {
        "env": {
            "browser": true,
            "es2021": true
        },
        "extends": [
            "eslint:recommended",
            "plugin:@typescript-eslint/recommended"
        ],
        "parser": "@typescript-eslint/parser",
        "parserOptions": {
            "ecmaVersion": "latest",
            "sourceType": "module"
        },
        "plugins": [
            "@typescript-eslint"
        ],
        "rules": {
            "no-console": "warn"
        }
    }
    
    加入bin字段调试

    package.json中加入如下bin字段

      "bin": {
        "xzy": "./bin.js",
        "xzy-dev": "./bin-dev.js"
      }
    

    新建bin目录,加入bin.jsbin-dev.js(如有lint报错,在.eslintingnore文件中加入bin目录就好)

    mkdir bin && cd ./bin && touch bin.js bin-dev.js
    

    bin-dev.js

    #!/usr/bin/env node   
    require('ts-node/register')
    require('../src')
    

    bin.js

    #!/usr/bin/env node   
    require('../build')
    

    该字段是定义命令名(也就是你脚手架的名字)和关联的执行文件,行首加入一行 #!/usr/bin/env node 指定当前脚本由node.js进行解析

    由于我们使用的是typescript进行开发,如果不用ts动态编译功能。所以在bin-dev中使用了ts-node。这样不用每次改代码都build一次。后续发包之后使用bin.js,指向打包后的build目录

npm link调试

根目录下执行npm link,将我们的包link到全局的node中,这样就可以使用包中的bin命令了。 image-20230717114319819

核心代码实现
获取所有命令

通过文件即功能的方式统一获取commands目录下的所有命令,同时注册到commander中去

// 获取所有命令
  const commandsPath = await getPathList("./commands/*.*s");

  // 注册命令
  commandsPath.forEach((commandPath) => {
    const commandObj = require(`./${commandPath}`);
    const { command, description, optionList, action } = commandObj.default;
    const curp = program
      .command(command)
      .description(description)
      .action(action);

    optionList &&
      optionList.map((option: [string]) => {
        curp.option(...option);
      });
  });
create实现
  1. 检查目录是否存在

    // 检查是否已经存在相同名字工程
    export const checkProjectExist = async (targetDir) => {
      if (fs.existsSync(targetDir)) {
        const answer = await inquirer.prompt({
          type: "list",
          name: "checkExist",
          message: `\n仓库路径${targetDir}已存在同名文件,请选择是否需要覆盖原路径(删除原文件后新建)`,
          choices: ["是", "否"],
        });
        if (answer.checkExist === "是") {
          logger.warn(`已删除${targetDir}...`);
          fs.removeSync(targetDir);
          return false;
        } else {
          logger.info("您已取消创建");
          return true;
        }
      }
      return false;
    };
    
  2. 获取用户输入

    // 获取用户输入
    export const getQuestions = async (projectName): Promise<IQuestion> => {
      return await inquirer.prompt([
        {
          type: "input",
          name: "name",
          message: `package name: (${projectName})`,
          default: projectName,
        },
        {
          type: "input",
          name: "description",
          message: "description",
        },
        {
          type: "input",
          name: "author",
          message: "author",
        },
        {
          type: "input",
          name: "git",
          message: "git仓库",
        },
      ]);
    };
    
  3. 复制项目

    export const cloneProject = async (
      targetDir: string,
      projectName: string,
      template: ICmdArgs["template"],
      projectInfo: IQuestion
    ) => {
      spinner.start(`开始创建目标文件 ${chalk.cyan(targetDir)}`);
      // 复制'project-template'到目标路径下创建工程
      await fs.copy(
        path.join(__dirname, "..", "..", `project_template/${template}`),
        targetDir
      );
    
      // handlebars模版引擎解析用户输入的信息存在package.json
      const packagePath = `${targetDir}/package.json`;
      const configPath = `${targetDir}/docs/.vitepress/config.js`;
    
      // 读取文件内容
      const packageContent = fs.readFileSync(packagePath, "utf-8");
      const configContent = fs.readFileSync(configPath, "utf-8");
    
      // 覆盖模版内容
      const packageResult = handlebars.compile(packageContent)(projectInfo);
      const configResult = handlebars.compile(configContent)(projectInfo);
    
      // 写入新内容
      fs.writeFileSync(packagePath, packageResult);
      fs.writeFileSync(configPath, configResult);
    
      logger.info("开始安装项目所需依赖");
    
      try {
        // 新建工程装包
        execa.commandSync("yarn", {
          stdio: "inherit",
          cwd: targetDir,
        });
      } catch (error) {
        // 报错就用npm试下
        execa.commandSync("npm install", {
          stdio: "inherit",
          cwd: targetDir,
        });
      }
    
      if (projectInfo.git) {
        logger.info("开始关联项目到git");
        // 关联git
        gitCmds(projectInfo.git).forEach((cmd) =>
          execa.commandSync(cmd, {
            stdio: "inherit",
            cwd: targetDir,
          })
        );
      }
    
      spinner.succeed(
        `目标文件创建完成 ${chalk.yellow(projectName)}\n👉 输入以下命令开始创作吧!:`
      );
      logger.info(`$ cd ${projectName}\n$ yarn dev\n`);
    };
    
美化项目
添加logo

当使用--help时在最后添加logo展示

console.log(
  "\r\n" +
  figlet.textSync("xzy-cli", {
    font: "3D-ASCII",
    horizontalLayout: "default",
    verticalLayout: "default",
    width: 80,
    whitespaceBreak: true,
  })
);
image-20230717171158556
发包

到此,脚手架基本的搭建与开发就完成了,发布到npm

  • 1、npm run lint 校验代码,毕竟都发包了,避免出现问题
  • 2、npm run build typescript打包
  • 3、npm publish 发布到npm 发包完成后,安装检查

具体可以参考我的另一片文章:npm发布包详细流程+常见错误

源码仓库

github 地址: @csuxzy/xzy-cli

npm 仓库地址: @csuxzy/xzy-cli

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值