彻底的把脚手架这玩意整明白(自定义搭建脚手架、yeoman、plop)

1,922 阅读7分钟

一、什么是前端工程化?

前端工程化就是通过各种工具和技术,提升前端效率、降低成本的过程。这句话有两个含义:

  1. 前端工程化的内容:各种工具和技术

  2. 前端工程化的作用:通过使用工具,提升开发效率,降低成本 WechatIMG12864.jpeg

二、为啥需要自建脚手架工具

有同学可能就有疑问了,现在不是有现成的脚手架工具吗?为啥还要来玩这种骚操作,用来装逼吗?

src=http___img.soogif.com_K9qzWZXM27BvWG53ltEedTkwCkYBdIdj.JPEG&refer=http___img.soogif.webp

我想诚实的跟你说,不排除有装逼的成分在,但也确实能解决实际问题,提升效率,譬如:公司搭建好了一个项目雏形,集成了公司特定的业务交互、逻辑,想把它作为前端项目的模板。如果你想到的是ctrl+c ctrl+v那就low了哦,通过脚手架来处理不仅方便快捷,还不容易出错。咋样?是不是找到卷的意义了!

我们看上图脚手架工具分专用脚手架和通用脚手架,文章都会一一详细介绍!

三、搭建自定义脚手架

专用脚手架主要是为了满足特殊需求,比如vue、react的脚手架,他们有些独特的配置需求,通用的脚手架无法满足需求,所以他们建立了自己的脚手架。下面且听我慢慢讲解关于自定义脚手架的知识

3.1 原理

脚手架的原理其实很简单,主要是利用node以及node搭建的工具来实现,无非就是两步:

  1. 收集用户自定义需求
  2. 根据用户的需求将模板项目写入目标的目录

3.2 回顾一下我们用vue-cli是怎么用的

vue-cli官网

# 安装
npm install -g @vue/cli
# or
yarn global add @vue/cli

# 创建项目
vue create hello-world

cli-new-project.png cli-select-features.png

以上步骤就是我们使用vue-cli的大致流程了,我们也大致会按照这个流程来,但不会跟vue-cli完全一样!

3.3 开始搭建自定义脚手架

目标:实现 vtmp-cli

3.3.1 项目初始化
mkdir my-vtmp-cli
cd my-vtmp-cli
npm init   # 生成 package.json 文件
3.3.2 新建程序入口 bin/index.js
#! /usr/bin/env node
console.log("hello world")
3.3.3 package.json设置bin入口
{ 
    "name": "my-vtmp-cli", 
    "version": "1.0.0", 
    "description": "", 
    "main": "cli.js", 
    "bin": {
        // 手动添加入口文件为 index.js 
        "vtmp-cli": "bin/index.js"
    }, 
    "scripts": { 
        "test": "echo \"Error: no test specified\" && exit 1" 
    }, 
    "author": "", 
    "license": "ISC" 
}
3.3.4 项目目录结构
my-vtmp-cli 
├─ bin
    └─index.js 
└─ package.json
3.3.5 npm link 链接到全局
npm link

执行完成

截屏2022-04-22 上午11.33.40.png

测试命令

截屏2022-04-22 上午11.37.50.png

到这里最初的脚手架雏形就搭好了!!!

3.4 搭建执行命令

commander中文文档

bin/index.js

#! /usr/bin/env node
const program = require("commander");
const pkg = require('../package.json')

program
  .version(`${pkg.version}`)
  .command("create <app-name>")
  .option("-f, --force", "overwrite target directory if it exist") // 是否强制创建,当文件夹已经存在
  .description("create a new project")
  .action((name, options) => {
    console.log(name, options)
  });

program.parse(process.argv);

注意:每次更改代码后,都需要重新link

截屏2022-04-22 下午12.04.02.png

命令执行完在action回调内打印命令参数

截屏2022-04-22 下午2.43.52.png

3.5 询问用户需求

接着上一步搭建好的command,在执行回调内调用inquirer用户询问

# bin/index.js
...
program
  .version(`${pkg.version}`)
  .command("create <app-name>")
  .option("-f, --force", "overwrite target directory if it exist") // 是否强制创建,当文件夹已经存在
  .description("create a new project")
  .action((name, options) => {
    console.log(name, options)
    // 在 create.js 中执行创建任务
    createtor(name, options);
  });
...

输出目标目录的前期校验

// 创建文件 lib/create.js
const path = require('path')
const fs = require("fs-extra");
const chalk = require("chalk");
const Generator = require('./build')
// 询问用户输入
const inquirer = require('inquirer')
// 暴露出去的方法 name options 是command传过来的参数
module.exports = async (name, options) => {
  const cwd = process.cwd();
  // 输出文件的目标目录
  const destDir = path.join(cwd, name);
  // 判断目标目标是否已经存在
  if (fs.existsSync(destDir)) {
    // 是否强制创建,force 代表是否强制执行
    if (options.force) {
      await fs.remove(destDir);
    } else {
      // 询问用户是否确定要覆盖
      let { action } = await inquirer.prompt([
        {
          name: "action",
          type: "list",
          message: "Target directory already exists Pick an action:",
          choices: [
            {
              name: "Overwrite",
              value: "overwrite",
            },
            {
              name: "Cancel",
              value: false,
            },
          ],
        },
      ]);
      
      if (!action) {
        return;
      } else if (action === "overwrite") {
        // 移除已存在的目录
        console.log(chalk.hex("#ff8800").bold(`\r\nRemoving...`));
        await fs.remove(destDir);
        console.log(chalk.hex("#ff8800").bold(`\r\ninquirer start...`));
      }
    }
  }
  // 以上解决了目标目录是否存在的问题,如果存在就移除
  // 接下来真正的进入用户询问阶段
  // 将项目名、输出目标目录往下传
  const createtor = new Generator(name, destDir);
  createtor.create()
};

将模板读取并写入用户的目录

// 创建文件 lib/build.js
const ejs = require("ejs");
const path = require("path");
const fs = require("fs-extra"); // 文件操作相关
const chalk = require("chalk"); // 日志美化
const inquirer = require("inquirer");
const ora = require("ora"); // loading样式
const spawn = require("cross-spawn"); // 命令行执行工具

class Generator {
  constructor(name, destDir) {
    this.name = name;
    this.destDir = destDir;
  }
  // 核心代码
  _copyFile(tmplDir, destDir, res, rootPath, callback) {
    fs.readdir(tmplDir, (err, files) => {
      if (err) throw err;

      let count = 0;
      // 计数法来判断是否遍历完成
      const checkEnd = function () {
        ++count == files.length && callback();
      };

      files.forEach((file) => {
        const filePath = path.join(tmplDir, file);
        // 当前路径的状态
        const stat = fs.statSync(filePath);
        // 文件类型
        if (stat.isFile()) {
          // 相对路径
          const relativePath = path.relative(rootPath, filePath);
          // 输出的绝对路径
          const outputAbsolutePath = path.join(destDir, relativePath);
          // 这里有个注意的点,如果是当前文件需要接受用户传递的参数,就用`ejs`模板引擎来处理
          if (
            relativePath === "package.json" ||
            relativePath === "index.html"
          ) {
            ejs
              .renderFile(filePath, res)
              .then((response) => {
                const outputDir = path.dirname(outputAbsolutePath);
                if (!fs.existsSync(outputDir)) {
                  // 判断文件夹是否存在,不存在就创建
                  fs.ensureDirSync(outputDir);
                  // console.log(chalk.cyan(`made directories, starting with ${made}`));
                }
                // 文件输出
                fs.writeFileSync(outputAbsolutePath, response);
                // console.log(chalk.hex("#67c23a").bold(`create ---> ${outputAbsolutePath}`));
              })
              .catch((err) => {
                console.log(chalk.red(err));
              });
          } else {
            // 直接执行 读取 写入 操作
            fs.readFile(filePath, "utf-8", (err, resp) => {
              if (err) throw err;
              const outputDir = path.dirname(outputAbsolutePath);
              if (!fs.existsSync(outputDir)) {
                // 判断文件夹是否存在,不存在就创建
                fs.ensureDirSync(outputDir);
                // console.log(chalk.cyan(`made directories, starting with ${made}`));
              }
              fs.writeFileSync(outputAbsolutePath, resp);
              // console.log(chalk.hex("#67c23a").bold(`create ---> ${outputAbsolutePath}`));
            });
          }
          // 执行计数方法
          checkEnd();
        } else {
          // 文件夹类型递归
          this._copyFile(filePath, destDir, res, rootPath, checkEnd);
        }
      });
    });
  }
  async create() {
    const name = this.name;
    const destDir = this.destDir;
    // 创建目标目录
    await fs.ensureDirSync(destDir);
    // inquirer 询问
    inquirer
      .prompt([
        {
          type: "input",
          name: "namespace",
          message: "Please input your project namespace, such as @tencent:",
          default: "",
        },
        {
          type: "input",
          name: "description",
          message: "Please input project description:",
          default: "vue3 project template",
        },
        {
          type: "input",
          name: "author",
          message: "Author's Name",
          default: "",
        },
        {
          type: "input",
          name: "email",
          message: "Author's Email",
          default: "",
        },
        {
          type: "input",
          name: "license",
          message: "License",
          default: "MIT",
        },
      ])
      .then((res) => {
        // inquirer 执行完之后的回调
        const params = {
          fullName: res.namespace ? `${res.namespace}/${name}` : name,
          name,
          ...res,
        };
        // 模板所在的目录
        const tmplDir = path.join(__dirname, "../templates");
        const rootPath = tmplDir;
        console.log(chalk.hex("#ff8800").bold(`\r\ncreate file start...`));
        // 开始复制文件
        const timeStart = new Date().getTime();
        const spinner = ora("Downloading...");
        const installing = ora("Installing...");
        spinner.start()
        // 模板下载
        this._copyFile(tmplDir, destDir, params, rootPath, async() => {
          // 模板下载回调
          spinner.succeed(chalk.hex('#67c23a').bold(`template download finished!  耗时${new Date().getTime() - timeStart}ms`));
          // 开始安装依赖
          const istamp = new Date();
          installing.start()
          // 执行依赖安装
          const result = spawn("npm", ["install"], { cwd: destDir });
          // 打印安装依赖的日志
          result.stdout.on("data", (buffer) => {
            process.stdout.write(chalk.hex("#67c23a").bold(buffer));
          });
          // 监听依赖安装状态
          result.on('close', (code) => {
            if (code !== 0) {
              console.log(chalk.red('Error occurred while installing dependencies!'));
              process.exit(1);
            } else {
              console.log(chalk.hex("#67c23a").bold(`\r\nInstall finished  耗时${new Date() - istamp}ms`));
            }
            installing.stop()
            console.log(chalk.hex("#ff8800").bold(`\r\ncd ${name}`));
            console.log(chalk.hex("#ff8800").bold("\rnpm run dev"));
          })
        });
      });
  }
}
module.exports = Generator;

项目目录结构

截屏2022-04-22 下午3.18.17.png

搭建完成后执行的日志输出

截屏2022-04-22 下午3.22.52.png

四、脚手架相关工具库

名称简介
commander命令行自定义指令
inquirer命令行询问用户问题,记录回答结果
chalk控制台输出内容样式美化
ora控制台 loading 样式
figlet控制台打印 logo
easy-table控制台输出表格
download-git-repo下载远程模版
fs-extra系统fs模块的扩展,提供了更多便利的 API,并继承了fs模块的 API
cross-spawn支持跨平台调用系统上的命令

五、利用通用脚手架实现自己的脚手架 Yeoman

Yeoman是一个通用脚手架系统,允许创建任何类型的应用程序.它允许快速开始新项目并简化现有项目的维护.Yeoman与语言无关.它可以使用任何语言(Web,Java,Python,C#等)生成项目.Yeoman本身不做任何决定.每个决定都是由生成器决定的,生成器基本上是Yeoman环境中的插件.有很多公开可用的生成器,它很容易创建新的生成器以匹配任何工作流程.Yeoman始终是满足您脚手架需求的正确选择 截屏2022-04-14 下午6.26.30.png

5.1 准备工作

  1. 安装nodejs
  2. yoYeoman的命令行工具包,需要进行全局安装:
npm install -g yo
yo --version // 查看版本 
  1. 安装Yeoman生成器generator
npm install -g generator-generator

5.2 初始化generator项目目录

文件夹名必须为generator-name,name是你创建的generator的名字.以generator-app-vuets为例.

注:文件夹名称必须以generator- 为前缀,否则执行 yo xxx 初始化项目时会无法找到你的项目模块 具体操作

yo generator
# 之后跟着工具的询问填写自己的信息就OK

执行示例

截屏2022-04-22 下午4.22.37.png 初始化完成输出的项目结构

截屏2022-04-22 下午4.11.17.png

5.3 index.js文件编写

yeoman 提供了一个基本生成器,你可以扩展它以实现自己的行为.这个基础生成器将帮你减轻大部分工作量. 在生成器的 index.js 文件中,以下是扩展基本生成器的方法:

var Generator = require("yeoman-generator");
module.exports = class extends Generator {}

yeoman 生命周期函数执行顺序如下:

  • initializing - 初始化函数
  • prompting - 接收用户输入阶段
  • configuring - 保存配置信息和文件
  • default - 执行自定义函数
  • writing - 生成项目目录结构阶段
  • conflicts - 统一处理冲突,如要生成的文件已经存在是否覆盖等处理
  • install - 安装依赖阶段
  • end - 生成器结束阶段 我们常用的就是 initializing,prompting,default,writing,install 这四种生命周期函数. 重点完成三个方法的定制,分别是:prompting,writing,install

prompting方法: 主要是来完成和用户交互的,交互的用户输入信息都放在prompts数组中:

  • name: 用户输入项的标识,在获取用户输入值的时候会用到
  • message: 是给用户的提示信息
  • type: 非必填,默认是text,即让用户输入文本;confirm是选择输入“Yes/No"
  • default: 非必填,用户输入的默认值 writing方法: 需要重点关注四个方法:
  • this.templatePath:返回template目录下文件的地址
  • this.destinationPath:指定加工完成后文件的存放地址,一般是项目目录
  • this.fs.copy:把文件从一个目录复制到另一个目录,一般是从template目录复制到你所指定的项目目录,用于固定文件和可选文件(根据用户选择)
  • this.fs.copyTpl:和上面的函数作用一样,不过会事先经过模板引擎的处理,一般用来根据用户输入处理加工文件

install方法:

install() { 
    //this.installDependencies(); // 安装npm依赖和bower依赖
    //this.bowerInstall(); // 只安装bower依赖
    this.npmInstall(); // 只安装npm组件
}

5.4 脚手架调试

执行npm link

npm link 

调试执行 yoyo + generator包名

截屏2022-04-22 下午4.49.00.png

截屏2022-04-22 下午4.52.23.png

5.5 发布npm

npm publish

当你遇到困难时不要灰心,按照这个自检清单逐一排查:
1.检查是否登录了npm (npm whoami)
2.你是否有多个账号记混了,退出重新登录
3.你注册号时所填的邮箱是否被验证了,如果没有刷新页面点击顶部通知栏重发验证邮件
4.检查你的包名是否已被占用
5.检查你的版本号是否是用过的版本号
6.检查你的npm源是否是npm官方源(npm config list)

六、一个小而美的脚手架工具 plop

Plop是一个小而美的脚手架工具,它主要用于创建项目中特定类型的文件,Plop主要集成在项目中使用,帮助我们快速生成一定规范的初始模板文件。比如我们去书写Vue的组件,如果每次都是手动去写组件的初始内容,会比较繁琐,有可能会出错,且每个人写的规范可能都不一样。这时候我们就可以用Plop来创建规范的Vue组件的初始内容,方便快捷,易于管理且规范。
plop: https://www.npmjs.com/package/plop

6.1 安装

yarn add plop --dev

安装完成后在项目根目录下创建 plopfile.js 文件

image.png

const promptDirectory = require('inquirer-directory')
const pageGenerator = require('./template/page/prompt')
const apisGenerator = require('./template/apis/prompt')
module.exports = function (plop) {
    plop.setPrompt('directory', promptDirectory)
    plop.setGenerator('page', pageGenerator)
    plop.setGenerator('apis', apisGenerator)
}

6.2 创建模板文件

位置 /template/page/

index.hbs

<template>
    <section>
       
    </section>
</template>

<script>
export default {
    name: '{{ name }}',
    data() {
        return {}
    },
    methods: {
        
    }
}
</script>

prompt.js

const path = require('path')

const notEmpty = (name) => {
    return (v) => {
        if (!v || v.trim === '') {
            return `${name} is required`
        } else {
            return true
        }
    }
}

module.exports = {
    description: 'generate vue template',
    prompts: [
        {
            type: 'directory',
            name: 'from',
            message: 'Please select the file storage address',
            basePath: path.join(__dirname, '../../src/views')
        },
        {
            type: 'input',
            name: 'fileName',
            message: 'file name',
            // 表单校验
            validate: notEmpty('fileName')
        },
        {
            type: 'input',
            name: 'name',
            message: 'name',
            validate: notEmpty('name')
        },
        // 当存在多选的情况可以使用checkbox类型
        //{
        //    type: 'checkbox',
        //    name: 'types',
        //    message: 'api types',
        //    choices: () => {
        //        return ['create', 'update', 'get', 'delete', 'check', 'fetchList', 'fetchPage'].map((type) => ({
        //            name: type,
        //            value: type,
        //            checked: true
        //        }))
        //    }
        //}
    ],
    actions: (data) => {
        const { fileName, name, from } = data
        const filePath = path.join('src/views', from, fileName + '.vue')
        const actions = [
            {
                type: 'add',
                path: filePath,
                templateFile: 'template/page/index.hbs',
                data: { name }
            }
        ]
        return actions
    }
}

6.3 示例

module.exports = plop => {
  // setGenerator可以设置一个生成器,每个生成器都可用于生成特定的文件
  // 接收两个参数,生成器的名称和配置选项
  plop.setGenerator('component', {
    // 生成器的描述
    description: 'create a component',
    // 发起命令行询问
    prompts: [{
      // 类型
      type: 'input',
      // 接收变量的参数
      name: 'name',
      // 询问提示信息
      message: 'component name',
      // 默认值
      default: 'MyComponent'
    }],
    // 完成命令行后执行的操作,每个对象都是动作对象
    actions: [{
      // 动作类型
      type: 'add',
      // 生成文件的输出路径
      path: 'src/components/{{name}}/{{name}}.vue',
      // template 模板的文件路径,目录下的文件遵循hbs的语法规则
      templateFile: 'plop-templates/component.vue.hbs'
    }]
  })
}

6.4 setGenerator

plop对象下有一个setGenerator方法,这个方法用于创建一个生成器,主要就是帮助我们去生成特定的文件,setGenerator接收两个参数nameoption

  • name:是该生成器的名称,也是执行该生成器的命令
  • option:是一个对象,是生成器的配置选项 执行生成器
    下面该示例就是执行我们创建的生成器component
    在这里插入图片描述

option、description

生成器的描述信息

6.5 prompts

将来生成器工作时发起的询问,它是一个数组,每个对象都是一次询问

prompts: [{
  // 类型
  type: 'input',
  // 用于接收用户输入参数的变量名,将来也会使用到hbs模板中
  name: 'name',
  // 询问提示信息
  message: 'component name',
  // 默认值
  default: 'MyComponent'
}],

在这里插入图片描述

6.6 actions

这是命令行询问结束后执行的操作,它同样是一个对象数组,每个对象都是一个动作对象。

actions: [{ 
    // 动作类型 
    type: 'add', 
    // 生成文件的输出路径 
    path: 'src/components/{{name}}/{{name}}.vue', 
    // template 一般放置再根目录的plop-templates目录下,下面的文件遵循hbs的语法规则 
    templateFile: 'plop-templates/component.vue.hbs' 
}]

七、源码

自定义脚手架 vtmp-cli

Yeoman generator-vue3-ltc