手把手带你撸一个极简的前端脚手架

1,336 阅读9分钟

本文源码已经收录到github,欢迎各位小哥哥小姐姐们star,持续更新中。

前言

在前两篇文章10分钟带你升级webpack5【建议细读】从0到1手把手带你捋一套webpack+vue项目模板中,带小伙伴们一起如何手动的从0到1搭建一个前端项目模板,但在我们实际的项目开发过程中,单纯的靠拷贝搭建好的项目模板生成新的项目,显然是比较低效的;如何规避这种低效的拷贝操作呢?答案是脚手架/CLI。那接下来我们就将上篇文章中我们手动搭建好的项目模板的基础上,通过脚手架/CLI的形式一键快捷生成我们想要的项目。

为什么需要脚手架/CLI

CLI,全称是 command-line interface,也就是命令行界面。

脚手架的好处有哪些呢?

  • 规避通过人工低效的拷贝项目模板的操作
  • 规范项目开发目录结构
  • 统一团队统一开发风格,便于跨团队合作,以及后期维护,降低新人上手成本
  • 提供一键前端项目的创建、配置、本地开发、插件扩展等功能,让开发者更多时间专注于业务

脚手架/CLI的工作流程

  • 一个完整的脚手架通常包含有这些:项目的创建项目模块的新增删除项目打包项目统一测试项目发布等;
  • 下边通过这种图简单的阐述了脚手架的工作流程:各自不同的端 -> 执行不同的脚手架命令 -> 从远端拉取不同的项目模板 -> 根据拉取的不同模板进行项目开发 -> 开发完自测后提交代码到远端仓库

前端脚手架工作流程.png

脚手架/CLI需要用到的依赖分析

包名功能描述
commander处理控制台命令
chalk美化我们在命令行中输出内容的样式
fs-extra文件操作
inquirer控制台询问
handlebars模板引擎渲染
log-symbols打印日志提醒
ora命令行loading动效
download-git-repo远程下载模板

如何编写一个极简的脚手架/CLI

我们先来给脚手架取一个名字吧,caoyp-cli

完成步骤拆解:

  1. 创建项目(create
  2. 自定义脚手架启动命令(commander
  3. 询问用户获取创建的信息(inquirer
  4. 从github下载用户需要的模板(download-git-repo
  5. 发布项目(npm

1. 创建项目

创建项目之前,我们先简单的定义一下我们的项目结构

caoyp-cli           
├─ bin                
│  └─ cli.js  # 启动文件 
├─ lib                
│  └─ create.js  # 创建项目文件 
│  └─ generator.js  # 下载项目文件
├─ .gitignore 
├─ README.md  
└─ package.json       

github上创建项目

git clone  https://github.com/Paulinho89/caoyp-cli
cd caoyp-cli

在项目更目录下执行:

npm init -y

在项目的根目录会自动帮助我们生成一个package.json文件

{
  "name": "caoyp-cli",
  "version": "1.0.0",
  "description": "前端单页脚手架",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "caoyp-cli",
    "caoyp",
    "脚手架"
  ],
  "author": "caoyp",
  "license": "ISC"
}

敲黑板了,这里是关键!!!

images.jpeg

接下来我们要在package.json中添加一个bin字段,在安装时npm 会将文件符号链接到 prefix/bin 以进行全局安装或./node_modules/.bin/本地安装。这样,就可以全局使用了。例如,下面的将caoyp-cli作为命令名称,执行文件是根目录的index.js

{
  "name": "caoyp-cli",
  "version": "1.0.0",
  "description": "前端单页脚手架",
  "main": "index.js",
  "bin": {
    "caoyp": "./bin/cli.js"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "caoyp-cli",
    "caoyp",
    "脚手架"
  ],
  "author": "caoyp",
  "license": "ISC"
}

简单的编辑一下./bin/cli.js

#! /usr/bin/env node

console.log('caoyp-cli working ~~')

#! /usr/bin/env node这段说明一下,不是注释,这段代码是告诉你的脚本工具(bash/zsh), 下面的内容是要在node环境下运行的代码,不能省略!!!

为了方便调试,使用npm link链接到全局

➜  caoyp-cli git:(main) npm link
npm WARN my-node-cli@1.0.0 No description
npm WARN my-node-cli@1.0.0 No repository field.

audited 51 packages in 1.7s
found 0 vulnerabilities

/usr/local/bin/caoyp-cli -> /usr/local/lib/node_modules/caoyp-cli/cli.js
/usr/local/lib/node_modules/caoyp-cli -> /Users/caoyp/study/caoyp-cli

在命令行中输入caoyp,测试一下

➜  caoyp-cli git:(main) caoyp

caoyp-cli working ~~

完成测试,打印出我们想要的结果。

2. 自定义脚手架启动命令

先来分析一下我们要怎么做?

  1. 我们要借助commander来实现
  2. 参考vue-cli,初始化一些常用的命令createhelp等等
  3. 如果项目已经存在,需要给用户提示是否需要强制覆盖

2.1 安装依赖

npm i commander --save

2.2 创建命令

./bin/cli.js中编辑

#! /usr/bin/env node

const program = require('commander')

program
  // 定义命令和参数
  .command('create <app-name>')
  .description('create a new project')
  // -f or --force 为强制创建,如果创建的目录存在则直接覆盖
  .option('-f, --force', 'overwrite target directory if it exist')
  .action((name, options) => {
    // 打印执行结果
    console.log('name:', name, 'options:', options)
  })
  
program
   // 配置版本号信息
  .version(`v${require('../package.json').version}`)
  .usage('<command> [option]')
  
// 解析用户执行命令传入参数
program.parse(process.argv);

在命令行中输入caoyp,测试一下

➜  caoyp-cli git:(main) caoyp
Usage: caoyp <command> [option]

Options:
  -V, --version                output the version number
  -h, --help                   display help for command

Commands:
  create [options] <app-name>  create a new project
  help [command]               display help for command

此时,我们发现Commands中已经有了create [options] <app-name>命令,那接下来我们再输入caoyp create试试,看看会打印什么

➜  caoyp-cli git:(main) caoyp create
error: missing required argument 'app-name'

➜  caoyp-cli git:(main) caoyp create my-project
执行结果 >>> name: my-project options: {}

➜  caoyp-cli git:(main) caoyp create my-project -f
执行结果 >>> name: my-project options: { force: true }

➜  caoyp-cli git:(main) caoyp create my-project --force
执行结果 >>> name: my-project options: { force: true }

此时,我们可以在命令行中拿到我们的输入信息了。

2.3 执行命令

创建一个lib文件夹,并且创建一个create.js

module.exports = async function(name, options) {
    console.log('-----create.js', name, options);
}

./bin/cli.js中引入create.js

const program = require('commander');

program.command('create <app-name>')
    .description('create a new project')
    // -f or --force 为强制创建,如果创建的目录存在则直接覆盖
    .option('-f, --force', 'overwrite target directory if it exist')
    .action((name, options) => {
        // 在 create.js 中执行创建任务
        require('../lib/create.js')(name, options)
    })

再次执行caoyp create my-project,结果打印显示

➜  caoyp-cli git:(main) caoyp create my-project
-----create.js
my-project {}

按照开头描述的,我们这个时候需要思考一下,我们在执行create的时候,项目目录到底是否存在;

  • 如果存在,友情提示创建项目已经存在;如果存在相同的项目目录,且加上-f,代表强制执行,此时删除老的目录,直接创建新的目录
  • 如果不存在,直接创建新的目录

我们需要借助另外一个插件fs-extra来辅助我们判断文件是否存在,同时安装一下log-symbols,方便我们在日志打印的时候,更加友好的给用户提示

安装一下fs-extralog-symbols

npm i fs-extra log-symbols --save

我们按照上边的思路再继续完善一下create.js

const path = require('path');
const fs = require('fs-extra');
const symbols = require('log-symbols');

module.exports = async function(name, options) {
    const cwd = process.cwd();
    // 需要创建的目录地址
    const targetAir = path.join(cwd, name);
    // 判断目录是否已经存在
    if (!fs.existsSync(targetAir)) {
        // TODO:创建新的目录
    } else {
        console.log(symbols.error, chalk.red('项目已存在'));
        // 是否要强制创建
        if (options.force) {
            await fs.remove(targetAir);
            // TODO:询问用户是否确定要覆盖
        }
    }
}

标注TODO的逻辑,我们会在下边再详细讲解。

2.4 完善帮助命令

细心的小伙伴们在我们使用vue-cli的时候,--help会发现有很多帮助命令可以引导我们如何✅的姿势去使用脚手架。

➜  ~ vue --help
Usage: vue <command> [options]

Options:
  -V, --version  output the version number
  -h, --help     output usage information

Commands:
  init           generate a new project from a template
  list           list available official templates
  build          prototype a new project
  create         (for v3 warning only)
  help [cmd]     display help for [cmd]

所以,这里我们也模仿一下vue-cli,完善一下我们的帮助命令,先再安装一下chalk,用于美化一下命令行;figlet帮助我们打印一个属于自己的logo。

const program = require('commander');
const figlet = require('figlet');
const chalk = require('chalk');

program
  .on('--help', () => {
    console.log('\r\n' + figlet.textSync('caoyp', {
      font: 'Ghost',
      horizontalLayout: 'default',
      verticalLayout: 'default',
      width: 80,
      whitespaceBreak: true
    }));
    console.log(`\r\nRun ${chalk.cyan(`caoyp <command> --help`)} for detailed usage of given command\r\n`)
  })

执行caoyp --help,看看会打印出什么

➜  caoyp-cli git:(main) caoyp --help
Usage: caoyp <command> [option]

Options:
  -V, --version                output the version number
  -h, --help                   display help for command

Commands:
  create [options] <app-name>  create a new project
  help [command]               display help for command

             ('-.                                _ (`-.  
            ( OO ).-.                           ( (OO  ) 
   .-----.  / . --. / .-'),-----.   ,--.   ,--._.`     \ 
  '  .--./  | \-.  \ ( OO'  .-.  '   \  `.'  /(__...--'' 
  |  |('-..-'-'  |  |/   |  | |  | .-')     /  |  /  | | 
 /_) |OO  )\| |_.'  |\_) |  |\|  |(OO  \   /   |  |_.' | 
 ||  |`-'|  |  .-.  |  \ |  | |  | |   /  /\_  |  .___.' 
(_'  '--'\  |  | |  |   `'  '-'  ' `-./  /.__) |  |      
   `-----'  `--' `--'     `-----'    `--'      `--'      

Run caoyp <command> --help for detailed usage of given command

3. 询问用户获取创建的信息

还是老规矩,动手之前我们先来分析一下询问用户信息该如何去做?

  1. 完善上一步TODO:询问用户已经存在的目录是否要覆盖下载最新的
const path = require('path');
const fs = require('fs-extra');
const symbols = require('log-symbols');

module.exports = async function(name, options) {
    const cwd = process.cwd();
    // 需要创建的目录地址
    const targetAir = path.join(cwd, name);
    // 判断目录是否已经存在
    if (!fs.existsSync(targetAir)) {
        // 创建新的目录
        fn(name, targetAir);
    } else {
        console.log(symbols.error, chalk.red('项目已存在'));
        // 是否要强制创建
        if (options.force) {
            await fs.remove(targetAir);
            // 创建新的目录
            fn(name, targetAir);
        }
    }
}
  1. 自定义模板信息
const fn = (name, targetAir) => {
    // 自定义模板项目信息
    inquirer.prompt([
        {
            name: 'version',
            message: '请输入项目版本',
            default: '1.0.0'
        },
        {
            name: 'description',
            message: '请输入项目描述信息',
            default: '这是一个自定义脚手架生成的项目'
        },
        {
            name: 'author',
            message: '请输入作者名称',
            default: ''
        }
    ]).then(res => {
        // TODO 下载github模板
    })
}

此时,我们再次输入caoyp create my-project,会发现有对应的模板信息需要我们填写,创建一个空的my-project目录;如果输入caoyp create my-project -f,回车后会发现上次创建的my-project目录被移除掉,重新让我们输入对应的模板信息,重新创建新的模板项目;

4. 从github下载用户需要的模板

还是老规矩,动手之前我们先来分析一下该如何从github下载用户需要的模板?

  1. github上我们提前已经上传好默认的下载 模板

image.png

  1. 借助download-git-repo辅助下载模板

先安装一下依赖download-git-repo用于下载github中的模板;handlebars用户动态的渲染模板提示信息;ora下载的时候给一个loading效果。

npm i download-git-repo handlebars ora --save

./lib目录下创建generator.js文件

const ora = require('ora');
const symbols = require('log-symbols');
const inquirer = require('inquirer');
const fs = require('fs-extra');
const downloadGitRepo = require('download-git-repo');
const chalk = require('chalk');
const handlebar = require('handlebars');

class Generator {
    constructor(name, targetDir, res) {
        this.name = name;
        this.targetDir = targetDir;
        this.res = res;
    }

    async download() {
        // 1)拼接下载地址
        const requestUrl = `github.com:Paulinho89/webpack5-single-template#master`;
        const spinner = ora(`正在下载模板,源地址:${requestUrl}`);
        spinner.start();
        // 2)调用下载方法
        await downloadGitRepo(
            // 直连下载,默认下载master
            requestUrl,
            this.targetDir,
            (error) => {
                if (error) {
                    spinner.fail();
                   // 下载失败给出友情提示 console.log(symbols.error, chalk.red(error));
                } else {
                    spinner.succeed();
                    const fileName =  `${this.name}/package.json`;
                    // 下载成功后获取命令行输入的提示信息
                    const meta = {
                        name: this.name,
                        version: this.res.version,
                        description: this.res.description,
                        author: this.res.author
                    }
                    // {{name}}/package.json路径存在时
                    if (fs.existsSync(fileName)) {
                    // 读取提示信息
                    const content = fs.readFileSync(fileName).toString();
                    const resultContent = handlebar.compile(content)(meta); //写入提示信息到模板项目的package.json文中 
                    fs.writeFileSync(fileName, resultContent);
                    }
                    console.log(symbols.success, chalk.green('项目初始化成功'))
                    console.log(symbols.info, `\r\n  cd ${chalk.cyan(this.name)}`)
                    console.log(symbols.info, `\r\n  npm install`)
                    console.log(symbols.info, 'npm run start\r\n')
                }
            }
        )
    }
}

module.exports = Generator;

关于下载downloadGitRepo()方法使用时会经常报出路径错误导致下载模板失败--git clone status 128

解决方案:

错误的写法:

  • 直接去github模板链接当做第一个参数直接传递
  • 使用{clone: true}
downloadGitRepo('https://github.com/Paulinho89/webpack5-single-template', name, {clone: true}, (err) => {
    console.log(err ? 'Fail' : 'Success')
})

正确的写法:

  • 使用github.com:用户名/模板名称#分支名称
  • 去掉{clone: true}
downloadGitRepo('github.com:Paulinho89/webpack5-single-template#master', name, (err) => {
    console.log(err ? 'Fail' : 'Success')
})
  1. 完善上一步TODO:下载成功之后,将每次输入自定义的模板信息填充到模板项目的package.json中去

模板项目的package.json中的versiondescriptionauthor这几个字段我们可以写成动态渲染的模式。

{
  "name": "webpack-single-template",
  "version": "{{version}}",
  "description": "{{description}}",
  "main": "index.js",
  "scripts": {
    "start": "webpack serve --config build/webpack.config.dev.js",
    "build:test": "rimraf dist && webpack --config build/webpack.config.test.js",
    "build:prod": "rimraf dist && webpack --config build/webpack.config.prod.js",
    "lint": "eslint --fix --ext .vue,.js src build",
    "lint-staged": "lint-staged",
    "build:stats": "webpack --config build/webpack.config.prod.js --json > stats.json",
    "analyz": "cross-env NODE_ENV=production ANALYZE=true npm_config_report=true npm run prod",
    "dll": "webpack --config build/webpack.config.dll"
  },
  "pre-commit": [
    "lint-staged"
  ],
  "lint-staged": {
    "*.{js, vue}": [
      "eslint --fix",
      "git add"
    ]
  },
  "author": "{{author}}",
  "license": "ISC"
}
const Generator = require('./generator');

const fn = (name, targetAir) => {
    // 自定义模板项目信息
    inquirer.prompt([
        {
            name: 'version',
            message: '请输入项目版本',
            default: '1.0.0'
        },
        {
            name: 'description',
            message: '请输入项目描述信息',
            default: '这是一个自定义脚手架生成的项目'
        },
        {
            name: 'author',
            message: '请输入作者名称',
            default: ''
        }
    ]).then(res => {
       // 创建项目
        const generator = new Generator(name, targetAir, res);
        generator.download();
    })
}

当下载模板完成之后会将用户输入的答案渲染到 package.json 中。

5. 发布项目

经过上边的逻辑梳理后,我们搭建的简易脚手架已经完成了,我们在实际使用的时候都是需要发布到npm仓库里的。

发布流程:

  1. 注册一个npm账号

如果是新注册的账号,需要先登录填写的邮箱进行验证,否则直接发布的话发布失败。

  1. 完善package.json中的配置,确定发布的版本号
{
  "name": "caoyp-cli",
  "version": "1.0.0",
  "description": "前端单页脚手架",
  "main": "index.js",
  "bin": {
    "caoyp": "./bin/cli.js"
  },
  "directories": {
    "lib": "lib"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/Paulinho89/caoyp-cli.git"
  },
  "author": "caoyp",
  "keywords": [
    "caoyp-cli",
    "caoyp",
    "脚手架"
  ],
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/Paulinho89/caoyp-cli/issues"
  },
  "homepage": "https://github.com/Paulinho89/caoyp-cli#readme",
  "dependencies": {
    "axios": "^0.21.1",
    "chalk": "^4.1.1",
    "commander": "^7.2.0",
    "download-git-repo": "^3.0.2",
    "figlet": "^1.5.0",
    "fs-extra": "^10.0.0",
    "handlebars": "^4.7.7",
    "inquirer": "^8.1.1",
    "log-symbols": "^3.0.0",
    "ora": "^5.4.1"
  }
}
  1. 使用npm publish发布
npm publish

发布成功之后,可以进入到npm个人仓库里,看到自己发布的包。

image.png

按照我们README.md中定义好的安装命令把脚手架install到我们的本地,然后创建,重启,一切正常;OK,这时我们的脚手架从项目搭建到发布整个流程就算结束了。

写到最后

  • 目前,这个脚手架还是偏于简单,有些步骤还需要进一步完善,像我们在向远端仓库拉取模板的时候,通常我们在实际的开发过程中会有多个版本的模板,且每个模板都会有不同的tag;每次拉取可以通过选择不同的版本模板、不同的tag、添加更多的模板、删除模板等。接下来,我也会将这些功能持续完善到脚手架中。

如果您觉得这篇文章对您有一点点帮助,欢迎您看完后给我点赞评论关注支持一下,您的支持是我写作的动力,谢谢~~😁

images.jpeg