前端工程化必备-实现一个自己的脚手架

@TOC

前言

​ 在工程中,不仅是软件工程,在建筑行业,我们也经常能看到脚手架的概念。脚手架(又称为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. 交互式用户选择

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

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

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

搭建自己的脚手架

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

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

md 复制代码
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配置文件如下

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字段的值是一个字符串,那么它表示一个可执行文件的路径。例如:
json 复制代码
{
  "name": "my-package",
  "version": "1.0.0",
  "bin": "./dist/my-command.js"
}

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

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

json 复制代码
{
  "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,更改配置如下

    json 复制代码
    {
      "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,完整配置如下

    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字段

    json 复制代码
      "bin": {
        "xzy": "./bin.js",
        "xzy-dev": "./bin-dev.js"
      }

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

    shell 复制代码
    mkdir bin && cd ./bin && touch bin.js bin-dev.js

    bin-dev.js

    javascript 复制代码
    #!/usr/bin/env node   
    require('ts-node/register')
    require('../src')

    bin.js

    javascript 复制代码
    #!/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命令了。

核心代码实现

获取所有命令

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

typescript 复制代码
// 获取所有命令
  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. 检查目录是否存在

    typescript 复制代码
    // 检查是否已经存在相同名字工程
    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. 获取用户输入

    typescript 复制代码
    // 获取用户输入
    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. 复制项目

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

美化项目

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

js 复制代码
console.log(
  "\r\n" +
  figlet.textSync("xzy-cli", {
    font: "3D-ASCII",
    horizontalLayout: "default",
    verticalLayout: "default",
    width: 80,
    whitespaceBreak: true,
  })
);

发包

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

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

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

源码仓库

github 地址: @csuxzy/xzy-cli

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

相关推荐
梦帮科技5 小时前
AI 音乐产品的发布工程:验证门、数据发布、回滚与生产运维纪律
数据结构·数据库·架构·node.js·音视频·动态规划·推荐算法
西安栈上月明软件科技5 小时前
从 Linux 0.01 到 AI 开源:星图邻的开源实践
人工智能·自然语言处理·架构·开源·fastapi
泯泷5 小时前
那段文字是谁删的?Yjs 14 正式版之前,一套删除归属方案的实现与边界
前端·javascript·算法
野槐8 小时前
前端项目优化(有了我,会发生...)
前端
Lynne3098 小时前
工业网络,到底该集中还是自治?
网络·架构·数据
aa小小8 小时前
【无标题】
前端
计算机魔术师9 小时前
还在单线程跟 AI 聊天?Claude Code 并行多会话让你一个人干三个人的活
前端
志尊宝9 小时前
Vue3 零基础每日笔记(038):亲手封装 useDebounceFn 与 useThrottleFn——高频事件的流量阀
前端·javascript·vue·html·vue3
JavaGuide9 小时前
对标 MinIO!全新一代分布式文件系统正式发布
前端·后端
风骏时光牛马9 小时前
AI驱动自动化业务工作流搭建实践
前端