从0开始实现简易版脚手架

585 阅读7分钟

1 简介

1.1 什么是脚手架

针对脚手架,前端并没有一个很明确的定义。当我们百度搜索脚手架时,会看到这么一句话: 脚手架是为了保证各施工过程顺利进行而搭设的工作平台。

那么是否可以理解为:在日常开发积累的一些模板,一些组件,搭建的一个相对成熟的项目,方便复用,提高效率。

简单的来说:可复用,可提高开发效率的一个工具。(例如我们常用的vue-cli)

1.2、为什么去了解脚手架

  • 提高工作效率
  • 学习,提高个人竞争力

1.3、市面上,已经有成熟的模板,为什么还要手写呢?

市面上的 vue-cli、create-react-app、angular-cli,已经可以很好的满足我们的日常需求了,为什么还要,花时间去学习,或者开发一个新的脚手架呢?

  • 业务日渐增多,有了项目自己配置,vue-cli 等项目无法满足
  • 可集成多个业务模块(多个项目),根据业务需求,选择不同的模板
  • 利于团队内部多人协作,即使后端人员也可以很容易的上手,启动项目
  • 根据动态交互生成项目结构和配置文件,具备更灵活的定制的能力
  • 更好的了解学习

2 开发流程

那么一个最简单脚手架的最基本流程是什么呢?

  • 通过命令行交互询问用户问题
  • 根据用户回答的结果生成文件

那么就定个小目标,来完成一个简单的脚手架工具!

2.1 新建项目

2.1.1 创建文件夹

mkdir my-cli

cd my-cli

npm init // 初始化项目

2.1.2 创建入口文件

├─ bin

│ └─ index.js # 启动文件

└─ package.json

#! /usr/bin/env node 
console.log('bin: index.js')

2.1.3 npm link 链接到全局

npm link的功能: 在本地开发npm模块的时候,我们可以使用npm link命令,将npm 模块链接到对应的运行项目中去,方便地对模块进行调试和测试

修改package.json

"bin": {     
    "nb-cli""./bin/index.js" // bin 在 package.json 中提供一个字段,该字段是命令名称到本地文件名的映射   
},

执行代码

npm link

执行命令

nb-cli

打印出: bin: index.js

至此:我们的小目标,距离完成更近了一步!

2.2 依赖工具

作用
chalk-pipe使用更简单的样式字符串创建粉笔样式方案
chalk正确处理终端字符串样式
Commander.js完整的 node.js 命令行解决方案
Inquirer.js一组通用的交互式命令行用户界面。
slash系统路径符处理
minimist解析参数选项
dotenv将环境变量从 .env文件加载到process.env中
dotenv-expand扩展计算机上已经存在的环境变量
hash-sum非常快的唯一哈希生成器
deepmerge深度合并两个或多个对象的可枚举属性。
yaml-front-matter解析yaml或json
resolve

package.json

"dependencies": { 
    "axios": "^0.26.0",
    "chalk": "^4.0.0",
    "execa": "^5.1.0",
    "chalk-pipe": "^5.1.1", 
    "Commander": "npm:commander@^9.0.0", 
    "deepmerge": "^4.2.2",
    "dotenv": "^16.0.0",
    "dotenv-expand": "^8.0.1",
    "fs-extra": "^10.0.1",
    "hash-sum": "^2.0.0",
    "Inquirer": "npm:inquirer@^8.2.0",
    "minimist": "^1.2.5",
    "resolve": "^1.22.0",
    "slash": "^4.0.0",
    "yaml-front-matter": "^4.1.1"
}

2.3 创建命令

2.3.1 注册命令

  • 利用 commander 注册多个命令
  • 每个命令 也可以自定义多个选项(根据具体需求, 这里只定义最简单的 create )
#! /usr/bin/env node


const program = require('commander')


program.command('create <app-name>')
.description('create a new project by your select')
.option('-f --force', 'overwrite your project if it exist')
.action((name, options)=> {
  console.log(name, options)
})


program.parse(process.argv); // 解析命令

执行命令

nb-cli create app

打印: app {}
nb-cli create app -f

打印: app { force: true }

由此,我们可以看到, create 命令已经生效,并打出了相应的参数。当然这仅仅只是开始,我们要针对,用户输入的命令进行逻辑处理!

2.3.2 完善创建逻辑

创建 lib 文件夹并在文件夹下创建 create.js;

├─ lib

│ └─ create.js # 创建文件

create.js 的职责:

  • 根据用户选择进行相关处理(是否覆盖等)
  • 询问用户,并给出具体选择(模板选择,版本选择等等)
  • 根据用户的相关选择,进行模板下载等

2.3.3 通过例子了解 inquirer

我们需要借助 通过 inquirer 来询问用户相关问题。

先通过一个例子来了解一下 inquirer。

const inquirer = require('inquirer')

// 问题 类型 confirm input list expand
// 包括 default 默认值 validate 校验 filter 过滤 when 条件判断
const questions = [
  {
    type: 'confirm', 
    name: 'human',
    message: 'Are you human?',
    default: true,
  },
  {
    type: 'input',
    name: 'phone',
    message: "What's your phone number?",
    validate(value) {
      const pass = value.match(
        /^([01]{1})\d{10}$/i
      );
      if (pass) {
        return true;
      }


      return 'Please enter a valid phone number';
    },
  },
  {
    type: 'list',
    name: 'gender',
    message: 'What is your gender?',
    choices: ['Men', 'Women', 'Unknown'],
    filter(val) {
      return val.toLowerCase();
    },
  },
  {
    type: 'input',
    name: 'age',
    message: 'What is your age?',
    validate(value) {
      const valid = !isNaN(parseFloat(value));
      return valid || 'Please enter a number';
    }
  },
  {
    type: 'expand',
    name: 'sport',
    message: 'What is your favorite sport?',
    choices: [
      {
        key: 'f',
        name: 'Football',
        value: 'Football',
      },
      {
        key: 'b',
        name: 'Basketball',
        value: 'Basketball',
      },
      {
        key: 'o',
        name: 'Other',
        value: 'Other',
      },
    ],
  },
  {
    type: 'input',
    name: 'address',
    message: 'Where do you live?',
    default: 'HeFei',
  },
  {
    type: 'list',
    name: 'region',
    message: 'Which area do you live in',
    choices: ['shushan', 'luyang', 'yaohai', 'baohe'],
    when(answers) {
      return answers.address == 'HeFei';
    },
  },
];


module.exports = async function (name, options) {
  console.log(name, options);
  inquirer.prompt(questions).then((answers) => {
    console.log(answers)
  } )
}

上述例子可以让你更好的了解 inquirer 的 用法。 包含了 输入框,选中框,校验,when 等使用方式。也可通过官网了解更多细节!

2.3.4 询问用户是否覆盖已存在目录

接下来来完善询问逻辑,以及删除逻辑。通过判断本地是否已经存在文件夹,来进行 覆盖等操作!

const inquirer = require('inquirer');
const fs = require('fs-extra'); // fs模块的扩展
const path = require('path')

const questions = [
  {
    name: 'action',
    type: 'list',
    message: 'Overwrite current directory?',
    choices: [
      {
        name: 'Overwrite',
        value: 'overwrite'
      },{
        name: 'Cancel',
        value: 'Cancel'
      }
    ]
  }
]




module.exports = async function (name, options) {
  const cwd = process.cwd(); // 当前目录
  const target = path.join(cwd, name);
  if (fs.existsSync(target)) { // 如果目录存在
    if (options.force) { // 强制覆盖
      console.log(`\r\nRemoving...`)
      await fs.remove(target);
      console.log(`\r\nRemove success...`)
    } else {
      let { action } = await inquirer.prompt(questions)
      if ( action == 'overwrite') {
        console.log(`\r\nRemoving...`)
        await fs.remove(target);
        console.log(`\r\nRemove success...`)
      } else {
        return
      }
    }
  }
}

通过创建一个空文件夹 app,来验证是否达到预期!

mkdir app

nb-cli create app -f

Removing...

Remove success...

由此可见,删除逻辑已经满足!

2.3.5 安装模板

本次脚手架,主要通过拉取远程仓库模板,来实现。因此,我们需要通过获取远程 模板,版本。进行模板安装!

在 lib 文件夹并在文件夹下创建 http.js;

├─ lib

│ └─ http.js # 创建文件

const axios = require('axios')

axios.interceptors.response.use(res => {
  return res.data;
})

/**
 * 获取模板列表
 * @returns Promise
 */
async function getRepoList() {
  return axios.get('xxxxxxxxxxxxx')
}

/**
 * 获取分支信息
 * @param {stringkey
 * @returns Promise
 */
async function  getTagList(key) {
  return axios.get(`xxxxxxxxxx${key}`)
}

module.exports = {
  getRepoList,
  getTagList
}

正如我们期望的一样,我们可以通过 获取远程模板,分支,提供给用户,用于选择! 目前 由于是内部部署的gitlab,暂时无法通过接口请求的方式去获取远程的仓库,分支等,因此本地写好固定的模板,分支。作为用户的可选择配置。来完成模板的下载 因此通过模拟数据,来实现当前效果

2.3.6 模拟数据

在 lib 文件夹并在文件夹下创建 util.js;

├─ lib

│ └─ util.js # 创建文件

function normalizeData(arr = []) {
    const target = [];
    arr.forEach(item => {
        target.push({
            name: item,
            value: item
        })
    });
    return target;
}

// 内部模板用 xxx 代替,这里可以用自己的或者公用的
function getRepoList() {
    return ['xxx', 'xxx', 'xxx']
}

function getTagList() {
    return ['test', 'pre', 'master']
}

module.exports = {
    getRepoList,
    getTagList,
    normalizeData
}

2.3.7 模板拉取

那么问题来了?我们该如何从远程仓库拉取代码呢?

此时,我们可以回想一下,平时拉取代码的方式。 git clone xxxx。 因此我们可以借助 execa 来执行代码。

在 lib 文件夹并在文件夹下创建 generator.js;

├─ lib

│ └─ generator.js # 创建文件

const inquirer = require('inquirer')
const {getRepoList, getTagList, normalizeData} = require('./util')
const execa = require('execa');
const path = require('path');

class Generator {
    constructor (name, currentDir) {
        // 项目名称
        this.name = name;
        // 创建位置
        this.currentDir = currentDir;
    }

    // 创建
    async create(){
      // 由于内部gitlab 无权限拉取 因此先将 模板,分支写死 作为示例
      const template = await this.getTemplate()
      const branch = await this.getBranch()
      this.clone(template, branch)
    }

    // 安装模板
    clone(template, branch) {
        const cwd = this.currentDir;
        const child = execa('git', ['clone'`xxxxxx/${template}.git`], {
            stdio: ['inherit''pipe''inherit']
        })

        child.stdout.on('data'buffer => {
            console.log("写数据")
            process.stdout.write(buffer)
        })

        child.on('close'code => {
            console.log(code)
            if (code !== 0) {
                return
            }

            // resolve()
        })
    }

    async getTemplate() {
        const { template } = await inquirer.prompt({
            name'template',
            type'list',
            choicesnormalizeData(getRepoList()),
            message'Place choose a templates to create project'
        })
        return template
    }

    async getBranch() {
        const { branch } = await inquirer.prompt({
            name'branch',
            type'list',
            choicesnormalizeData(getTagList()),
            message'Place choose a branch to create project'
        })
        return branch
    }
}

module.exports = Generator

本地执行 命令

nb-cli create app

此时我们会发现, app 文件夹并没有按照预期,生成。因为此时我们相当于 只执行了 git clone...

2.3.8 缺少项目名称文件夹

新增 createDir 方法

async createDir(name) {
       await execa('mkdir', [name], {
           stdio: ['inherit''pipe''inherit']
       })
    }

更新 clone 函数, 通过提前创建 文件夹,然后 git clone 到当前目录

    async clone(template, branch) {
        await this.createDir(this.name);
        return new Promise((resolve, reject) => {
            const child = execa('git', ['clone', '-b', branch, `xxx/${template}.git`, this.currentDir], {
                stdio: ['inherit', 'pipe', 'inherit']
            })

            child.stdout.on('data', buffer => {
                console.log("写数据")
                process.stdout.write(buffer)
            })

            child.on('close', code => {
                console.log(code)
                if (code !== 0) {
                    reject()
                    return
                }
                resolve()
            })
        })
    }

2.3.9 主动的npm install

当项目(模板)下载完毕时,此时我不想手动的安装依赖,是否可以主动替用户 进行npm install (前端项目)。

新增 install 函数

    async install() {
       await execa('npm', ['--registry','https://registry.npm.taobao.org','install'], {
           cwdthis.currentDir,
           stdio: ['inherit''pipe''inherit']
       })
    }

这里用了直接使用淘宝镜像,主要是为了更快的安装依赖。当然,我们可以继续扩展,如(根据用户的选择 来使用 npm yarn等)

更新 create

async create() {
        // 由于内部gitlab 无权限拉取 因此先将 模板,分支写死 作为示例
        const template = await this.getTemplate()
        const branch = await this.getBranch()
        console.log("正在下载模板")
        await this.clone(template, branch)
        console.log("模板下载完成,进行依赖安装")
        await this.install();
        console.log(`cd ${this.name}`)
        console.log('npm run dev')
    }

执行命令看看效果:

image.png image.png image.png

2.3.10 打印文件多样化

我们在日常使用 vue-cli时,会发现 控制台打印 用颜色的 区分,那么是如何实现的呢?

我们可以借助 chalk 来实现该功能。下面对我们的工具来进行简单优化

console.log(`模板下载完成,进行${chalk.yellow('依赖')}安装`)

image.png

3. 总结

到此,我们结合内部的一个简易的脚手架已经完成。当然,这是一个最简单的版本

提供

  • create 命令
  • 支持删除
  • 选择模板,分支
  • npm install

我们也可以在此基础上,进行功能完善,如:

  • 支持更多的命令
  • 模板,分支,从远程获取
  • 用户可选的 包管理器等
  • 以及像 vue-cli 一样,支持 plugin 功能等

那么后续可以,借鉴vue-cli 完成一个更加完善的脚手架工具

4. 完整代码

/bin/index.js

#! /usr/bin/env node
const program = require('commander')

program.command('create <app-name>')
.description('create a new project by your select')
.option('-f --force', 'overwrite your project if it exist')
.action((name, options)=> {
  require('../lib/create.js')(name, options)
})
program.parse(process.argv); // 解析命令

/lib/create.js

const inquirer = require('inquirer');
const fs = require('fs-extra'); // fs模块的扩展
const path = require('path')
const Generator = require('./generator')
const execa = require('execa');

const questions = [
    {
        name: 'action',
        type: 'list',
        message: '是否覆盖?',
        choices: [
            {
                name: '覆盖',
                value: 'overwrite'
            }, {
                name: '取消',
                value: 'Cancel'
            }
        ]
    }
]



module.exports = async function (name, options) {
    const cwd = process.cwd(); // 当前目录
    console.log(options)
    const currentDir = path.join(cwd, name);
    if (fs.existsSync(currentDir)) { // 如果目录存在
        if (options.force) { // 强制覆盖
            console.log(`\r\nRemoving...`)
            await fs.remove(currentDir);
            console.log(`\r\nRemove success...`)
        } else {
            let { action } = await inquirer.prompt(questions)
            console.log(action);
            if (action == 'overwrite') {
                console.log(`\r\nRemoving...`)
                await fs.remove(currentDir);
                console.log(`\r\nRemove success...`)
            } else {
                return
            }
        }
    }
    const generator = new Generator(name, currentDir);
    generator.create()
}

/lib/util

function normalizeData(arr = []) {
    const target = [];
    arr.forEach(item => {
        target.push({
            name: item,
            value: item
        })
    });
    return target;
}

function getRepoList() {
    return ['xxx', 'xxx', 'xxx']
}

function getTagList() {
    return ['test', 'pre', 'master']
}

module.exports = {
    getRepoList,
    getTagList,
    normalizeData
}

/lib/http.js 暂时无用

const axios = require('axios')

axios.interceptors.response.use(res => {
  return res.data;
})

/**
 * 获取模板列表
 * @returns Promise
 */
async function getRepoList() {
  return axios.get('xxxxxxxxxxxxx')
}

/**
 * 获取分支信息
 * @param {string} key
 * @returns Promise
 */
async function  getTagList(key) {
  return axios.get(`xxxxxxxxxx${key}`)
}

module.exports = {
  getRepoList,
  getTagList
}

/lib/generator.js

const inquirer = require('inquirer')
const { getRepoList, getTagList, normalizeData } = require('./util')
const execa = require('execa');
const path = require('path');
const chalk = require('chalk');
const fs = require('fs-extra'); // fs模块的扩展

class Generator {
    constructor(name, currentDir) {
        // 项目名称
        this.name = name;
        // 创建位置
        this.currentDir = currentDir;
    }

    // 创建
    async create() {
        // 由于内部gitlab 无权限拉取 因此先将 模板,分支写死 作为示例
        const template = await this.getTemplate()
        const branch = await this.getBranch()
        console.log(`正在下载模板${chalk.red(template)}`)
        await this.clone(template, branch)
        console.log(`模板下载完成,进行${chalk.yellow('依赖')}安装`)
        await this.install();
        console.log(`cd ${chalk.cyan(this.name)}`)
        console.log('npm run dev')
    }

    // 依赖安装
    async install() {
        await execa('npm', ['--registry','https://registry.npm.taobao.org','install'], {
            cwd: this.currentDir,
            stdio: ['inherit', 'pipe', 'inherit']
        })
    }

    // 创建文件夹
    async createDir(name) {
        await execa('mkdir', [name], {
            stdio: ['inherit', 'pipe', 'inherit']
        })
    }

    // cd目录
    async toPath(path) {
        await execa.command(`cd ${path}`)
    }

    // 安装模板
    async clone(template, branch) {
        await this.createDir(this.name);
        return new Promise((resolve, reject) => {
            const child = execa('git', ['clone', '-b', branch, `xxx/${template}.git`, this.currentDir], {
                stdio: ['inherit', 'pipe', 'inherit']
            })
            child.stdout.on('data', buffer => {
                console.log("写数据")
                process.stdout.write(buffer)
            })

            child.on('close', code => {
                if (code !== 0) {
                    reject()
                    return
                }
                resolve()
            })
        })
    }

    async getTemplate() {
        const { template } = await inquirer.prompt({
            name: 'template',
            type: 'list',
            choices: normalizeData(getRepoList()),
            message: 'Place choose a templates to create project'
        })
        return template
    }

    async getBranch() {
        const { branch } = await inquirer.prompt({
            name: 'branch',
            type: 'list',
            choices: normalizeData(getTagList()),
            message: 'Place choose a branch to create project'
        })
        return branch
    }
}

module.exports = Generator