从0搭建一个自己的前端脚手架

5,981 阅读9分钟

下大雪了~

前言

上篇文章只是简单的说了下前端脚手架的代码,和实现思路,最近又增加了部分的功能,这次就把上篇文章加上这次的进行一个统一的描述,最近看其他人的掘金文章,发现只有大白话才是最好的,能让人迅速理解的的语言,那么这次,就用大白话来进行写一个前端脚手架。

开始

什么是前端脚手架

1.是什么

  • 脚手架是为了保证各施工过程顺利进行而搭设的工作平台。(百度对脚手架的定义)
  • 能够快速帮我生成新项目的目录模板
  • 能够提升我的开发效率和开发的舒适性

2.有什么好处

  • 是统一各业务线的技术栈、制定规范,这样出现问题可以统一解决,升级和迭代都可以同步的进行
  • 提高效率,在统一的基础上,提供更多提高效率的工具,这个效率不只是开发效率,是从开发到上线的全流程的效率,比如一些业务组件的封装,提高上线效率的发布系统,各种utils等

开始撸代码

本文代码在https://github.com/amazingliyuzhao/cli-demo 的test分支

1.项目目录

先看macaw-hellow.js

console.log('hello, cli')

然后我去命令行里面执行一下

node ./bin/macaw-hellow.js

可以看到打印语句

到现在我们已经写出执行命令了。然后我们来增加一点功能,首先我们看到其他的脚手架比如vue-cli 创建文件的时候都是,vue create projectName,基本都是自定义命令然后+项目名

但是我们发现他们前面并没有+node命令,但是仍然会执行语句。这就是因为使用tj大神写的commander.js工具来实现的 关于commander.js的文档可以直接看github的官方文档,而且带有中文版本,这里用到哪一个我就说哪一个,想要深入了解的可以自己看一下。

2. commander.js

获取项目名称

第一步,引入

const program = require('commander')

改造一下我们的hellow文件

program.usage('<project-name>')
        .parse(process.argv) // 加入这个能获取到项目名称
// 根据输入,获取项目名称
let projectName = program.rawArgs[2] // 获取项目名称
console.log(projectName)

执行hellow文件

node ./bin/macaw-hello.js testDemo

可以看到打印结果

可以看到,现在已经拿到了我们自定义的项目名字,接下来我们就可以继续搞事情了

拿到名字下一步干什么,当然是要根据名字来创建我们的项目。

项目名称容错

但是先考虑下容错,如果没有名字怎么办

help() 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项

if (!projectName) {  // project-name 必填  如果没有输入名称执行helphelp
  program.help()// 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
  return
}

创建文件夹

创建文件夹的逻辑有很多,我们可以先理顺了再写

创建文件的逻辑

  1. 如果创建项目名和根目录一致

    直接在根目录下生成文件

  2. 如果创建项目名和根目录不一致

    直接在根目录下创建项目目录并生成件

  3. 如果根目录下已经存在和项目名相同的文件

    询问用户是否覆盖

看下处理代码

#!/usr/bin/env node

const program = require('commander')
const path = require('path')
const fs = require('fs')
const glob = require('glob') // npm i glob -D
const download = require('../lib/download') //下载配置
const inquirer = require('inquirer') // 按需引入
const logSymbols = require("log-symbols");
const chalk = require('chalk')
const remove = require('../lib/remove') // 删除文件js
const generator = require('../lib/generator')// 模版插入
const CFonts = require('cfonts');

program.usage('<project-name>')
            .parse(process.argv) // 加入这个能获取到项目名称

// 根据输入,获取项目名称
// console.log(program)
let projectName = program.rawArgs[2] // 获取项目名称

if (!projectName) {  // project-name 必填  如果没有输入名称执行helphelp
  program.help()// 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
  return
}

// 当前目录为空,如果当前目录的名称和project-name一样,则直接在当前目录下创建工程,否则,在当前目录下创建以project-name作为名称的目录作为工程的根目录
// 当前目录不为空,如果目录中不存在与project-name同名的目录,则创建以project-name作为名称的目录作为工程的根目录,否则提示项目已经存在,结束命令执行。
// process.cwd() 是当前执行node命令时候的文件夹地址
//__dirname 是被执行的js 文件的地址 ——文件所在目录
const list = glob.sync('*')  // 遍历当前目录,数组类型
let next = undefined;
let rootName = path.basename(process.cwd());
if (list.length) {  // 如果当前目录不为空
  if (list.some(n => {
    const fileName = path.resolve(process.cwd(), n);
    const isDir = fs.statSync(fileName).isDirectory();
    return projectName === n && isDir // 找到创建文件名和当前目录文件存在一致的文件
  })) { // 如果文件已经存在
    next = inquirer.prompt([
      {
        name:"isRemovePro",
        message:`项目${projectName}已经存在,是否覆盖文件`,
        type: 'confirm',
        default: true
      }
    ]).then(answer=>{
        if(answer.isRemovePro){
          remove(path.resolve(process.cwd(), projectName))
          rootName = projectName;
          return Promise.resolve(projectName);
        }else{
          console.log("停止创建")
          next = undefined
          // return;
        }
      })
  }
} else if (rootName === projectName) {  // 如果文件名和根目录文件名一致
  rootName = '.';
  next = inquirer.prompt([
    {
      name: 'buildInCurrent',
      message: '当前目录为空,且目录名称和项目名称相同,是否直接在当前目录下创建新项目?',
      type: 'confirm',
      default: true
    }
  ]).then(answer => {
    console.log(answer.buildInCurrent)
    return Promise.resolve(answer.buildInCurrent ? '.' : projectName)
  })
} else {
  rootName = projectName;
  next = Promise.resolve(projectName) // 返回resole函数,并传递projectName
}

next && go()

function go () {
  // 预留,处理子命令
}

上面我们提到了询问用户,那我们怎么在命令行和用户进行交互呢

3. inquirer.js —— 一个用户与命令行交互的工具

通过介绍我们就能知道这个函数库的作用,就是能在命令行和用户进行一个交互,先看下场景

  • type:表示提问的类型,包括:input, confirm, list, rawlist, expand, checkbox, password, editor;
  • message:问题的描述;
  • default:默认值;
  • choices:列表选项,在某些type下可用,并且包含一个分隔符(separator);
  • validate:对用户的回答进行校验;
  • filter:对用户的回答进行过滤处理,返回处理后的值;
  • transformer:对用户回答的显示效果进行处理(如:修改回答的字体或背景颜色),但不会影响最终的答案的内容;
  • when:根据前面问题的回答,判断当前问题是否需要被回答;
  • pageSize:修改某些type类型下的渲染行数;
  • prefix:修改message默认前缀;
  • suffix:修改message默认后缀。

大体使用框架为

const inquirer = require('inquirer');//引入

const promptList = [];//用户的交互

inquirer.prompt(promptList).then(answers => {
    console.log("最后输出")
    console.log(answers); // 返回的结果
})

我们一条一条来看

  1. 首先是type中的input

const promptList = [{
  type: 'input',
  message: '设置一个用户名:',
  name: 'name',
  default: "test_user" // 默认值
},{
  type: 'input',
  message: '请输入手机号:',
  name: 'phone',
  validate: function(val) {
      if(!val.match(/\d{11}/g)) { // 校验位数
          return "请输入11位数字";
      }
      return true
  }
}];

效果为

  1. type中-->confirm
const promptList = [{
  type: "confirm",
  message: "第一个条件是否成立",
  name: "firstChange",
  prefix: "第一个条件的前缀"
},{
  type: "confirm",
  message: "第二个条件是否成立(依托于第一个条件)",
  name: "secondChange",
  suffix: "第二个条件的后缀",
  when: function(answers) { // 当firstChange为true的时候才会提问当前问题
      return answers.firstChange
  }
}];

效果为

3. type-->list

const promptList = [{
  type: 'list',
  message: '请选择一种水果:',
  name: 'fruit',
  choices: [
      "🍎  Apple",
      "🍐  Pear",
      "🍌  Banana"
  ],
  filter: function (val) { // 使用filter将回答变为小写
      return val.toLowerCase();
  }
}];

效果为

  1. type-->list
const promptList = [{
  type: "expand",
  message: "请选择一种水果:",
  name: "fruit",
  choices: [
      {
          key: "a",
          name: "Apple",
          value: "apple"
      },
      {
          key: "O",
          name: "Orange",
          value: "orange"
      },
      {
          key: "p",
          name: "Pear",
          value: "pear"
      }
  ]
}];

这个要解释一下,key就是提示,而且key只能为一个简单字母,H默认为help选项,选择H会展示所有的选择项

效果为

5. type中-->checkbox

const promptList = [{
  type: "checkbox",
  message: "类型:",
  name: "color",
  choices: [
    new inquirer.Separator("--- 品牌 ---"), // 添加分隔符
      {
        name: "奥迪",
      },
      {
        name: "奔驰",
      },
      {
        name: "红旗",
      },
      new inquirer.Separator("--- 颜色 ---"), // 添加分隔符
      {
          name: "blur",
          checked: true // 默认选中
      },
      {
        name: "red",
      },
      {
          name: "green"
      }
  ]
}];

  1. type中-->password 密文输入
const promptList = [{
  type: "password", // 密码为密文输入
  message: "请输入密码:",
  name: "pwd"
}];

  1. type中-->editor 密文输入
const promptList = [{
  type: "editor",
  message: "请输入备注:",
  name: "editor"
}];

回车开始写备注,esc+:wq 保存并退出编辑

至此inquire常用的几个类型我们就说完了,继续看我们的项目吧

回到之前流程,我们已经走到了创建文件 我们在处理完创建文件的逻辑之后执行next和go函数,

 next = Promise.resolve(projectName) // 返回resole函数,并传递projectName

在go函数里面创建我们的目录

function go () {
    next.then(projectRoot => { //
        if (projectRoot !== '.') {
          fs.mkdirSync(projectRoot)//创建目录文件
        }
    }
}

生成文件

我们先看一个细节,我执行命令是amaz-test + 项目名,这里的amaz就是我的自定义命令,看下我们的 package.json文件的配置

{
  "name": "liyuzhaocli-2",
  "version": "1.0.0",
  "description": "amz脚手架1.0",
  "bin": { 
    "macaw": "./bin/macaw.js",
    "amaz": "./bin/macaw-init.js",
    "amaz-test": "./bin/macaw-test.js"
  },
  "main": "./bin/macaw-hellow.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "cli"
  ],
  "author": "amaizngli",
  "license": "ISC",
  "dependencies": {
    "cfonts": "^2.4.5",
    "commander": "^4.0.0",
    "download-git-repo": "^3.0.2",
    "glob": "^7.1.5",
    "handlebars": "^4.5.1",
    "inquirer": "^7.0.0",
    "metalsmith": "^2.3.0",
    "ora": "^4.0.2"
  }
}

可以看到bin下面就是我们的自定义命令了

如果想模拟发布npm后的效果,可以在本地直接npm link 链接到全局这样就可以在我们的电脑上使用我们的命令了。

但是现在我们只是创建了一个空的目录,还需要往里面填充我们的模版,那么就需要我们的另一个工具了

4. download-git-repo —— 一个下载远程git文件的工具

首先我们在lib目录下新建一个download.js的文件

先放官网地址 www.npmjs.com/package/dow…

先看下代码

const download = require('download-git-repo')
const path = require("path")
const ora = require('ora')

module.exports = function (target) {
  target = path.join(target || '.', '.download-temp');
  return new Promise(function (res, rej) {
    // 这里可以根据具体的模板地址设置下载的url,注意,如果是git,url后面的branch不能忽略
    // 格式是名字/地址 后面不加 .git 但是带着 #分支
    // let url = 'amazingliyuzhao/CliTestGit#test'
    let url = 'amazingliyuzhao/cli-template#test'
    const spinner = ora(`正在下载项目模板,源地址:${url}`)
    spinner.start();

    download(url, target, { clone: false }, function (err) { // clone false 设置成false 具体设置看官网设置
      if (err) {
        spinner.fail()
        rej(err)
      }
      else {
        // 下载的模板存放在一个临时路径中,下载完成后,可以向下通知这个临时路径,以便后续处理
        spinner.succeed()
        res(target)
      }
    })
  })
}

其中的ora是一个代码美化库

注意一个大坑,就是url地址的格式,我们正常的git地址是 github.com/amaz/cli-de…

当用这个工具的时候要使用amaz/cli-demo#test。#后面是对应的分支名字。没有默认,master也必须写

我们可以把提前写好的模版放到git上,然后创建好文件之后,直接从git拉取,当需要更新模版的时候,只需要更新远端的git仓库,每次拉取,都是拉取的最新的代码。

最后再在我们的macaw-init 文件中引入

const download = require('../lib/download') //下载配置

然后改写下go函数

function go () {
      next.then(projectRoot => { 
        if (projectRoot !== '.') {
          fs.mkdirSync(projectRoot) //创建文件
        }
        return download(projectRoot).then(target => {//下载模版
          return {
            projectRoot,
            downloadTemp: target
          }
        })
      })
  }

下载的远端模版

好了问题又来了,我们下载完模版,所有信息都是在模版里面写死的,版本信息,项目名称,版本号,都需要我们自己去项目中修改,这看起来并不是我们预期的可定制化

所以我们的期望是通过用户输入自定义我们的信息,提到用户交互我们首先想到上面说的inquire.js工具,但是我们怎么把从用户那里获取到的内容填充到我们的模版呢?

这时候我们又要用到一个新的工具

5. metalsmith —— 一个下载远程git文件的工具

惯例先放官网 metalsmith.io/ 但是说实话,官网不太能看懂。

推荐一个网站 www.kutu66.com//GitHub/art…

在lib下面新建一个generator文件,

安装

npm i handlebars metalsmith -D
// // npm i handlebars metalsmith -D
const rm = require('rimraf').sync //以包的形式包装rm -rf命令,用来删除文件和文件夹的,不管文件夹是否为空,都可删除
const Metalsmith = require('metalsmith') // 插值
const Handlebars = require('handlebars') // 模版
const remove = require("../lib/remove") // 删除
const fs = require("fs")
const path = require("path")

module.exports = function (context) {
  let metadata = context.metadata; // 用户自定义信息
  let src = context.downloadTemp; // 暂时存放文件目录
  let dest = './' + context.projectRoot; //项目的根目录

  if (!src) {
    return Promise.reject(new Error(`无效的source:${src}`))
  }

  return new Promise((resolve, reject) => {
    const metalsmith = Metalsmith(process.cwd())
      .metadata(metadata) // 将用户输入信息放入
      .clean(false)
      .source(src)
      .destination(dest);
    
    metalsmith.use((files, metalsmith, done) => {
      const meta = metalsmith.metadata()
      Object.keys(files).forEach(fileName => {
        if(fileName.split(".").pop() != "png"){
          const t = files[fileName].contents.toString()
          files[fileName].contents = new Buffer.from(Handlebars.compile(t)(meta),'UTF-8')
        }
      })
      done()
    }).build(err => {
      remove(src);
      err ? reject(err) : resolve(context);
    })
  })
}

具体使用方法,可以看网站(太久没看,我也快忘了~,之后补充详细)

基本来说,这是一个实现插值的文件,用这个工具的同时还需要一个模版库,这里我选的是Handlebars,具体语法可以自己搜一下,和ejs以及其他的模版js文件一样,只是语法可能不太相同。

思想就是,遍历模版下的所有文件,然后把文件内容转换成字符串,找到模版字符串的内容就进行处理,但是这里也有一个坑,就是这个工具好像不识别图片,所以我在上面代码对图片进行了过滤处理。

然后在macaw-init.js里面引入一下我们的代码

const generator = require('../lib/generator')// 模版插入

改写下我们的go函数

function go () {
  next.then(projectRoot => { //
    if (projectRoot !== '.') {
      fs.mkdirSync(projectRoot)
    }
    CFonts.say('amazing', {
      font: 'block',              // define the font face
      align: 'left',              // define text alignment
      colors: ['#f80'],         // define all colors
      background: 'transparent',  // define the background color, you can also use `backgroundColor` here as key
      letterSpacing: 1,           // define letter spacing
      lineHeight: 1,              // define the line height
      space: true,                // define if the output text should have empty lines on top and on the bottom
      maxLength: '0',             // define how many character can be on one line
    });
    return download(projectRoot).then(target => {
      return {
        projectRoot,
        downloadTemp: target
      }
    })
  })
  .then(context => {
    // console.log(context)
    return inquirer.prompt([
      {
        name: 'projectName',
    	  message: '项目的名称',
        default: context.name
      }, {
        name: 'projectVersion',
        message: '项目的版本号',
        default: '1.0.0'
      }, {
        name: 'projectDescription',
        message: '项目的简介',
        default: `A project named ${context.projectRoot}`
      },{
        name: 'isElement',
        message: '是否使用element',
        default: "No",
      },{
        name: 'isEslint',
        message: '是否使用isEslint',
        default: "No",
      }
    ]).then(answers => { // 可选选项回调函数
      let v = answers.isElement.toUpperCase();
      answers.isElement = v === "YES" || v === "Y";
      let iseslint = answers.isEslint.toUpperCase();
      answers.isEslint = iseslint === "YES" || iseslint === "Y";
      return {
        ...context,
        metadata: {
          ...answers
        }
      }
    })
  }).then(context => {
    console.log("生成文件")
    console.log(context)
     //删除临时文件夹,将文件移动到目标目录下
     return generator(context); // 插值处理
  }).then(context => {
    // 成功用绿色显示,给出积极的反馈
    console.log(logSymbols.success, chalk.green('创建成功:)'))
    console.log(chalk.green('cd ' + context.projectRoot + '\nnpm install\nnpm start'))
  }).catch(err => {
    console.error(err)
     // 失败了用红色,增强提示
     console.log(err);
     console.error(logSymbols.error, chalk.red(`创建失败:${err.message}`))
  })
}

可以看到我们用到inquire.js进行的用户交互,然后处理用户回答后,进行插值处理

对了上的彩色字体也是用了一个工具Cfonts

官网 www.npmjs.com/package/cfo…

还有各种花里胡哨的效果等你探索~

  CFonts.say('amazing', {
      font: 'block',              // define the font face
      align: 'left',              // define text alignment
      colors: ['#f80'],         // define all colors
      background: 'transparent',  // define the background color, you can also use `backgroundColor` here as key
      letterSpacing: 1,           // define letter spacing
      lineHeight: 1,              // define the line height
      space: true,                // define if the output text should have empty lines on top and on the bottom
      maxLength: '0',             // define how many character can be on one line
    });

现在我们已经从远端拉取了模版,并且实现了插值,那模版中我们怎么进行处理呢?

6. 在模版中进行插值

先看一个处理代码

{
  "name": "{{projectName}}",
  "version": "{{projectVersion}}",
  "description": "{{projectDescription}}",
  "author": "lyz",
  "private": true,
  "scripts": {
    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
    "start": "npm run dev",
    "lint": "eslint --ext .js,.vue src",
    "build": "node build/build.js"
  },
  "dependencies": {
    {{#if isElement}}
    "element-ui": "^2.13.0",
    {{/if}}
    "vue": "^2.5.2",
    "vue-router": "^3.0.1",
    "node-sass": "^4.13.0"
  },
  "devDependencies": {
    "autoprefixer": "^7.1.2",
    "babel-core": "^6.22.1",
    "babel-eslint": "^8.2.1",
    "babel-helper-vue-jsx-merge-props": "^2.0.3",
    "babel-loader": "^7.1.1",
    "babel-plugin-syntax-jsx": "^6.18.0",
    "babel-plugin-transform-runtime": "^6.22.0",
    "babel-plugin-transform-vue-jsx": "^3.5.0",
    "babel-preset-env": "^1.3.2",
    "babel-preset-stage-2": "^6.22.0",
    "chalk": "^2.0.1",
    "copy-webpack-plugin": "^4.0.1",
    "css-loader": "^0.28.0",
    "eslint": "^4.15.0",
    "eslint-config-standard": "^10.2.1",
    "eslint-friendly-formatter": "^3.0.0",
    "eslint-loader": "^1.7.1",
    "eslint-plugin-import": "^2.7.0",
    "eslint-plugin-node": "^5.2.0",
    "eslint-plugin-promise": "^3.4.0",
    "eslint-plugin-standard": "^3.0.1",
    "eslint-plugin-vue": "^4.0.0",
    "extract-text-webpack-plugin": "^3.0.0",
    "file-loader": "^1.1.4",
    "friendly-errors-webpack-plugin": "^1.6.1",
    "html-webpack-plugin": "^2.30.1",
    "node-notifier": "^5.1.2",
    "optimize-css-assets-webpack-plugin": "^3.2.0",
    "ora": "^1.2.0",
    "portfinder": "^1.0.13",
    "postcss-import": "^11.0.0",
    "postcss-loader": "^2.0.8",
    "postcss-url": "^7.2.1",
    "rimraf": "^2.6.0",
    "sass-loader": "^7.3.1",
    "semver": "^5.3.0",
    "shelljs": "^0.7.6",
    "uglifyjs-webpack-plugin": "^1.1.1",
    "url-loader": "^0.5.8",
    "vue-loader": "^13.3.0",
    "vue-style-loader": "^3.0.1",
    "vue-template-compiler": "^2.5.2",
    "webpack": "^3.6.0",
    "webpack-bundle-analyzer": "^2.9.0",
    "webpack-dev-server": "^2.9.1",
    "webpack-merge": "^4.1.0"
  },
  "engines": {
    "node": ">= 6.0.0",
    "npm": ">= 3.0.0"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not ie <= 8"
  ]
}

可以看到我们的模版语法{{}} 以及下面{{#if isElement}}{{/if}}都是handlebus语法,当metalsmith工具遍历文件碰到这些语法后会自动解析。并将用户的输入处理后插入到我们的模版中,这里我选择的是否配置element和是否配置eslint都是通过在packagejson里面选择是否加载对应的依赖包进行配置的

7. 更多的扩展

其实到目前,我们已经能写一出一个微定制化的模版了,但是我们的插值操作都是在代码里面进行的,但是如果设计到文件级别的增删我们要如何配置呢,目前的一个思路就是通过.gitignore文件,将不需要的文件添加或者从里面删除,仍然是使用我们的插值工具完成。这个模块之后再进行补充,

要是涉及到整个框架的改变,比如一个vue一个react,推荐在下载模版的时候加入用户询问,然后在我们的远端维护多个git仓库,根据用户的选择下载不同仓库的模版。

结语

这篇文章从写到写完,可能用了一周多,每次重新打开思路可能会有一些疏漏,阅读时候发现什么错误,希望在评论区指正。