从零开始搭建一款前端脚手架

6,876 阅读3分钟

从零开始搭建脚手架

简介

  1. 脚手架是一个集成项目初始化、调试、构建、测试、部署等等流程,能够让使用者专注于 code 的工具。用白话说就是,一个建筑已经搭好架子,我们只需要不断加入砖头就行。

  2. 提高效率,在统一的基础上,提供更多提高效率的工具,这个效率不只是开发效率,是从开发到上线的全流程的效率,比如一些业务组件的封装,提高上线效率的发布系统,各种 utils 等。

  3. 一个完整的脚手架一般包含三个方面的内容:

    • 脚手架命令脚本:我们所需要安装到全局的脚手架,通过它可以方便的开始一个项目的开发
    • scripts 包:一般我们会将打包、编译、测试以及读取自定义配置文件等等操作(例如 webpack 相关配置操作,本地服务器相关内容等等),单独做成 npm 包。让使用者不必关心这些操作,专心 code。
    • 模板文件:显而易见,就是我们初始化项目的时候,所拉取的项目内容。

项目初始化

  1. 使用 npm init 初始化项目

  2. 使用 git init 将项目添加到 git 管理

  3. 创建远程 git 仓库并与之关联

  4. 使用 eslint 做代码检查:

    • 安装: npm install eslint @babel/eslint-parser -D

    • 配置 .eslintrc.json

      
       {
         "env": {
           "node": true,
           "commonjs": true
         },
         "parser": "@babel/eslint-parser",
         "parserOptions": {
           "ecmaVersion": 2015,
           "requireConfigFile": false
         },
         "extends": ["eslint:recommended", "prettier"],
         "rules": {
           "semi": ["error", "always"],
           "quotes": "off",
           "no-console": "off",
           "no-redeclare": "warn"
         }
       }
      
      
    • 配置 .eslintignore

      #井号是注释  根据自己的项目需要进行忽略
      # 如果 .eslintrc 开启了 env  nodejs 那么 默认 node_modules是自动忽略的
      node_modules
      /node_modules/**
      
  5. package.json文件中,加入 bin 字段

    // package.json
    "bin": {
       "pri": "./bin/pri.js"
    }ss
    
    • pri 是 cli 的名称,类似 npm 的 npm 或 npx 命令
    • ./bin/pri.js 是指运行 pri 命令是执行的是 bin 目录下的 pri.js 文件
  6. 使用 prettier 进行代码格式化

    • 安装: npm install prettier eslint-config-prettier -D

    • 配置 .prettierrc

      {
       "singleQuote": true,
       "semi": true,
       "tabWidth": 2
      }
      
  7. 在根目录下 创建 bin 文件夹,添加 pri.js

    #!/usr/bin/env node
    const package = require("../package.json");
    const { version } = package;
    console.log(version);
    
    • 打开终端,执行 npm link, 然后运行 pri 打印版本号 1.0.0 即表示项目初始化成功

      project-init.png

初认识 commander

  1. commander:TJ 大神开发的工具,能够更好地组织和处理命令行的输入。

    • 中文文档
    • 是完整的 node.js 命令行解决方案。
    • 安装: npm install commander
  2. 通过 pri 命令输出 pri-cli 的 version:

    • 修改 pri.js

      #!/usr/bin/env node
      const { Command } = require("commander");
      
      const package = require("../package.json");
      
      // 获取package.version
      const { version } = package;
      const program = new Command();
      
      // 定义当前版本
      program.version(
        `pri: ${version}`,
        "-v, --version",
        "output the current pri version"
      );
      
      // 解析运行参数(必须且要放在最后一行)
      program.parse(process.argv);
      
    • 运行 pri -vpri --version, 将在控制台打印 pri: 1.0.0

      comander-version.png

  3. 自动化帮助信息:帮助信息是 Commander 基于你的程序自动生成的,默认的帮助选项是-h,--help。运行 pri -hpri --help, 将在控制台看到下面的帮助信息。

    comander-help.png

创建项目

注册 create 命令

  1. 我们需要实现一个能通过命令行创建项目的 create 命令

  2. pri.js中添加下面的代码:

    // 定义create命令
    program
      .command("create <app-name>")
      .description("Create a new pri project.")
      .alias("c")
      .action((name) => {
        console.log(`project -> `, name);
      });
    
  3. 在控制台执行 pri create my-app 可以看到下面的结果

    pri-create.png

  4. 控制台出现上面的结果表示 create 命令注册成功了,但实际中 create 命令后可能不止有一个参数,同时我们还希望控制台的输出能更加美观,这需要用到下面两个库。

    • chalk: 修改控制台中字符串的样式(字体样式加粗等/字体颜色/背景颜色)
    • minimist: 轻量级的命令行解析工具
  5. 安装 chalkminimist: npm install chalk@4.1.0 minimist。注意 chalk v5 是 es 模块的。

  6. 在 pri.js 的 create action 命令中添加下面的代码:

      .alias('c')
      .action((name) => {
        if (minimist(process.argv.slice(3))._.length > 1) {
          const info = `Info: You provided more than one argument. The first one will be used as the app's name, the rest are ignored. `;
          log(chalk.yellow(info));
        }
      });
    
    
  7. 在控制台输入 pri create my-app test,可以看到下面的输出结果

    create-log.png

创建项目的根目录

  1. 创建项目的根目录有以下几种情况

    • 项目名是否为 .,为.则获取当前目录名作为项目名,并将当前目录作为项目根目录。
    • 项目名是否符合 npm 包的命名规则,不符则提示错误,并且退出项目的创建
    • 是否通过 pri create app-name -f 或者 pri create app-name --force指定了强制创建项目,如果指定了 -f 或者 --force则先删除目标目录下的内容然后再创造项目。
    • 目标目录是否为当前目录,如果是则先询问是否在当前目录创建项目,是则创建,否则退出创建
    • 目标目录是否是已存在的非当前目录,如果是则询问是否覆盖、合并或取消,然后根据选择创建目录。
    • 如果目标目录不存在,则创建目标目录并生成文件。
  2. 根据上面的描述可以知道需要三个 库来帮助我们创建目录

  3. 修改 bin/pri.js 中 create 命令部分的代码为:

    program
      .command("create <app-name>")
      .description("Create a new pri project.")
      .option("-f, --force", "Overwrite target directory if it exists")
      .alias("c")
      .action((name, options) => {
        if (minimist(process.argv.slice(3))._.length > 1) {
          const info = `Info: You provided more than one argument. The first one will be used as the app's name, the rest are ignored. `;
          log(chalk.yellow(info));
        }
    
        create(name, options);
      });
    
  4. 增加 lib/error.js

    const { logErrors } = require("./logger");
    
    function catchErrorHof(fn) {
      return (...args) => {
        try {
          return fn(...args);
        } catch (error) {
          logErrors([error.message]);
        }
      };
    }
    
    function asyncCatchErrorHof(fn) {
      return async (...args) => {
        try {
          return await fn(...args);
        } catch (error) {
          logErrors([error.message]);
        }
      };
    }
    
    module.exports = { asyncCatchErrorHof, catchErrorHof };
    
  5. 增加 lib/create.js

    "use strict";
    const chalk = require("chalk");
    const readline = require("readline");
    
    const dim = {
      error: "❌",
      warn: "⚠️",
    };
    
    /**
     * @param {string[]|undefined} infos
     * @param {string} dim
     */
    function logInfos(infos, dim) {
      if (!infos) return;
    
      infos.forEach((msg) => {
        const str = dim ? chalk.cyan.dim(dim, msg) : chalk.cyan(msg);
        console.log(str);
      });
    }
    
    /**
     *  @param {string[]|undefined} warnings
     *  @param {string} dim
     */
    function logWarnings(warnings, dim) {
      if (!warnings) return;
    
      warnings.forEach((msg) => {
        const str = dim ? chalk.yellow.dim(dim, msg) : chalk.yellow(msg);
        console.warn(str);
      });
    }
    
    /**
     * @param {string[]|undefined} errors
     * @param {string} dim
     */
    function logErrors(errors, dim) {
      if (!errors) return;
    
      errors.forEach((msg) => {
        const str = dim ? chalk.red.dim(dim, msg) : chalk.red(msg);
        console.error(str);
      });
    }
    
    /**
     * @param {string|undefined} msg
     */
    function clearConsole(msg) {
      const blank = "\n".repeat(process.stdout.rows);
      console.log(blank);
      readline.cursorTo(process.stdout, 0, 0);
      readline.clearScreenDown(process.stdout);
      if (msg) {
        logInfos([msg]);
      }
    }
    
    module.exports = {
      clearConsole,
      dim,
      logInfos,
      logErrors,
      logWarnings,
    };
    
  6. 增加 lib/logger.js

    "use strict";
    const fs = require("fs-extra");
    const path = require("path");
    const chalk = require("chalk");
    const { prompt } = require("inquirer");
    const validateNpmPackageName = require("validate-npm-package-name");
    const { asyncCatchErrorHof } = require("./error");
    const {
      clearConsole,
      dim,
      logInfos,
      logWarnings,
      logErrors,
    } = require("./logger");
    
    /**
     * @description 创建pri 项目
     * @param {string} projectName
     * @param {{[p:string]:string}} options
     */
    async function create(projectName, options) {
      const cwd = options.cwd || process.cwd(); // process.cwd(): 返回是当前执行node命令时候的文件夹地址
      const inCurrent = projectName === "."; // 如果项目名称为 '.' 表示要在当前目录下直接创建项目
      const name = inCurrent ? path.relative("../", projectName) : projectName;
      const targetDir = path.resolve(cwd, projectName); // 获取创建项目的地址
      const result = validateNpmPackageName(name); // 检查项目名称是否符合npm 包的命名规范
    
      // 不符合npm 包的命名规范
      if (!result.validForNewPackages) {
        return handleInvalidName(result, name);
      }
    
      // 当要存在和要创建的项目相同的文件夹时
      if (fs.existsSync(targetDir)) {
        const isCreate = await createInExistTargetDir(targetDir, {
          ...options,
          inCurrent,
        });
    
        if (!isCreate) return;
      }
    
      console.log(`Creating project: ${name}`);
    }
    
    function handleInvalidName(result, name) {
      logErrors([`Invalid project name ${name}`]);
      logErrors(result.errors, dim.error);
      logWarnings(result.warnings, dim.warn);
      process.exit(1);
    }
    
    /**
     * @description 处理文件夹存在的情况
     * @param {string} targetDir
     * @param {{[p:string]:string}} options
     * @returns {Promise<boolean>} 如果是false表示不在已存在的目录中创建项目
     */
    async function createInExistTargetDir(targetDir, options) {
      const { force, inCurrent } = options;
      // 强制创建
      if (force && !inCurrent) {
        await fs.remove(targetDir); //清除当前文件和文件夹
        return true;
      }
    
      // 不是强制创建
      // 在当前文件夹下创建
      clearConsole();
      if (inCurrent) {
        return createInCurrentDir();
      }
    
      // 不是在当前文件夹下创建
      return createInSubDir(targetDir);
    }
    
    /**
     * @returns {Promise<boolean>}
     */
    async function createInCurrentDir() {
      const { ok } = await prompt([
        {
          name: "ok",
          type: "confirm",
          message: chalk.cyan("Create project in current directory?"),
        },
      ]);
    
      return ok;
    }
    
    const actionEnum = {
      Overwrite: 2,
      Merge: 1,
      Cancel: 0,
    };
    
    async function createInSubDir(targetDir) {
      const { action } = await prompt([
        {
          name: "action",
          type: "list",
          message: chalk.cyan(
            `Target directory ${targetDir} exists. Choose an action`
          ),
          choices: [
            { name: "Overwrite", value: 2 },
            { name: "Merge", value: 1 },
            { name: "Cancel", value: 0 },
          ],
        },
      ]);
    
      if (!action || action === actionEnum.Cancel) return false;
    
      if (action === actionEnum.Overwrite) {
        logInfos([`Removing ${targetDir}`]);
        await fs.remove(targetDir);
      }
    
      return true;
    }
    
    module.exports = {
      create: asyncCatchErrorHof(create),
    };
    
  7. 简单的进行测试,在终端执行 pri create <app-name> 可以看到下面的效果 create-test

参考

  1. 从 0 搭建一个自己的前端脚手架
  2. 如何搭建一个属于自己的脚手架
  3. 教你从零开始搭建一款前端脚手架工具