从零实现一个脚手架

698 阅读4分钟

为什么要实现脚手架

在项目开发过程中,我们常常有不同的项目,但是基础模板却是相同的,如果用官方提供的脚手架,里面有很多的配置是没有为我们配置的,再从零配置浪费时间,为了提升开发效率,所以就可以自己写一套常用的模板,编写一个脚wenj手架,在运行我们自己的脚手架命令后,直接生成常用的配置好的模板

如何实现

首先我们先梳理一下,会用到的模块包

1. commander           用来配置可执行命令
2. inquirer            用来实现命令行交互
3. ejs                 用来渲染模版
4. ora                 用来实现loading
5. download-git-repo   用来下载模版文件
6. chalk               用来实现打印不同颜色的字体
7. figlet              打印logo 

创建可执行脚本,配置可执行命令,实现命令行交互然后下载模版,编译ejs模版,根据用户选择动态生成内容

实现

首先创建初始化一个包,npm init -y

添加bin字段,value值是我们执行的入口文件,bin/index.js, name默认就是我们使用脚手架的名称

// 默认以name 可以配置别名
// "bin": "./bin/index",
  "bin": {
    "cli": "./bin/index",
    "mycli": "./bin/index"
  },

创建一个bin文件夹,然后 新建index.js文件,将这个包在开发模式下链接到全局下面,

先链接到本地全局下面,因为发布了这个包之后可以下载下来使用,开发先链接到本地 npm link npm link --force npm unlink cli(npm link 后删除)

#! /usr/bin/env node

const program = require('commander');

const figlet = require('figlet');

const createCommand = require('../lib/command')

// 配置命令
program.version(`cli@${require('../package.json').version}`)
program.usage('<command> [option]  ✅例如: cli create name -f', )
 
// chalk 配置颜色
program.on('--help', () => {
    console.log("")

    // 使用 figlet 绘制 Logo
    console.log('\r\n' + figlet.textSync('cli-hello', {
        font: 'Ghost',
        horizontalLayout: 'default',
        verticalLayout: 'default',
        width: 100,
        whitespaceBreak: true
    }));  

    console.log('🌞运行cli <command> --help 查看具体参数')
    console.log('✅例如: cli create --help')
    console.log("")
})

// 自定义--help中的选项
program.option('-u --user', 'คิดถึง')

// 自定义创建命令
createCommand()

// 解析用户传入的命令以及参数
program.parse(process.argv);

将创建命令的文件抽离,新建一个lib文件夹,创建command.js文件

const program = require('commander');
const { createAction, createPage } = require('./action')
const createCommand = () => {
// 创建项目
program.command('create <value>')
       .description('创建一个新项目')
       .option('-f, --fource', '是否覆盖创建')
       .option('-t, --type   <value>', '选择项目的类型 React/Vue')
       .action((value, options) => {
           createAction(value, options)
        })

// 添加页面
program.command('addPage <name> [page]')
       .description('创建一个新组件')
       .action((name, path) => {
         createPage(name, path)
        })
}

module.exports = createCommand

为了方便接收到命令后进行交互,我们创建一个action.js文件,将其所有的动作抽离

const path = require('path')
const inquirer = require('inquirer')
const { loadingWrap } = require('../utils/loading')
// fs-extra 相当于fs模块
const fs = require('fs-extra');
const Creator = require('./Creator')
const { ejsCompile } = require('../utils/ejsCompile')
const { writeFile, mkdirSync } = require('../utils/fileUtils')

// create
const createAction = async (projectName, cmd) => {

    // 获取当前执行的目录路径
    const cwd = process.cwd(); 

    // 获取到要创建的地址
    const targetDir = path.join(cwd, projectName);

    // 判断该目录存不存在
    if (fs.existsSync(targetDir)) {
        if (cmd.force) { 
            // 如果是强制创建, 删除目录后面直接创建
            await fs.remove(targetDir)
        } else { 
            // 提示用户是否确认覆盖
            const {chose} = await inquirer.prompt([ // 配置询问方式
                {
                    name: 'chose',
                    type: 'list', // 类型
                    message: '文件夹已存在是否覆盖?',
                    choices: [
                        {name: '覆盖', value: 'overwrite'},
                        {name: '取消', value: false}
                    ]
                }
            ]);

            // 覆盖就是删除再创建
            if (chose === 'overwrite') {
                await loadingWrap(fs.remove, '文件覆盖中', targetDir)
            } else {
                return;
            }
        }
    }

    // 创建目录
    const creator = new Creator(projectName, targetDir)
    
    // 开始创建
    creator.createProject()

} 


// createPage
const createPage = async (name, address) => {
    const templatePath = await path.resolve(__dirname, '../template/component.react.ejs')

    // 需要一个ejs模版进行渲染
    const result = await ejsCompile(templatePath, {name, lowerName: name.toLowerCase()});
    
     // 判断文件不存在,那么就创建文件
    mkdirSync(address);
    const targetPath = path.resolve(address, `${name}.tsx`);
    // 写入文件
    writeFile(targetPath, result);

}

module.exports = {
    createAction,
    createPage,
}

创建一个Creator.js文件,用来执行创建文件操作

const fs = require('fs-extra')
const inquirer = require('inquirer')
const { fetchRepoList, fetchTagList } = require('./request')
const util = require('util');  // promisify 将回调函数的形式包裹promise
const terminal = require('../utils/terminal')
const open = require('open');

const { loadingWrap } = require('../utils/loading')

// 需要将这个方法转换为promise
const downloadGit = require('download-git-repo');
const { args } = require('commander');

class Creator {
    constructor(projectName,targetDir ) {
        this.name = projectName
        this.targetDir = targetDir
        this.downloadGitRepo = util.promisify(downloadGit)
    }

    async fetchRepo() {

        // 失败重新拉取
        let repos = await loadingWrap(fetchRepoList, '模版文件正在拉取中');
        
        if (!repos) return false;

        repos = repos.map(item => item.name)

        let { repo } = await inquirer.prompt([{
            name : 'repo',
            type: 'list',
            choices: repos,
            message: '请选择项目'
        }])

        return repo

    }

    async fetchTag(args) {

        if (!args) return;

        let tags = await loadingWrap(fetchTagList, '正在拉取版本号', args)

        if (!tags) return false;

        tags = tags.map(item => item.name) 

        let { tag } = await inquirer.prompt({
            name: 'tag',
            type: 'list',
            choices: tags,
            message: "请选择版本"
        })

        return tag
    }

    async download(repo, tag) {
        if (!repo) return false;
        // 拼接下载路径
        let requestUrl = `zhu-cli/${repo}${tag ? '#' + tag : '' }`
        // 把资源下载到某个路径(后续可以增加缓存,下载到系统目录,渲染模版然后再写入)
        // console.log(process.cwd(), this.targetDir, this.name)
        // 当前目录下创建文件夹,下载到这个目录下
        await fs.mkdirs(this.targetDir)
        await loadingWrap(this.downloadGitRepo, '项目生成中', requestUrl, this.targetDir)

        return true
    }

    async runNpm(isSuccess) {

        if (!isSuccess) return;

         const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
         // 下载依赖
         await terminal.spawn(npm, ['install'], { cwd: `${this.targetDir}` });
        
         open('http://localhost:8080/')

         // 运行项目
         await terminal.spawn(npm, ['run', 'serve'], { cwd: `${this.targetDir}` });

        
    }


    async createProject() { // 真实创建


        // 获取仓库模版
        const repo = await this.fetchRepo()

        // 获取模版版本
        const tag = await this.fetchTag(repo)

        // 下载
        const isSuccess = await this.download(repo, tag)

        // 执行npm install 打开浏览器
        await this.runNpm(isSuccess)
        
    }

}

module.exports = Creator

创建一个request.js文件,用来请求我们github上面的模版地址,这里我们请求了所有的模版以及他们的版本,如果只有一个模版,只需要改一下地址,直接下载即可

可以用download-git-repo 传入地址直接下载

const axios = require('axios')
axios.interceptors.response.use(res => res.data)

async function fetchRepoList() {
    return axios.get('https://api.github.com/orgs/my-cli/repos')
}

async function fetchTagList(repo) {
    return axios.get(`https://api.github.com/repos/my-cli/${repo}/tags`)
}

module.exports = {
    fetchRepoList,
    fetchTagList
}

在终端交互的时候,下载模版添加loading状态

新建utils

// loading.js
const ora = require('ora')
const chalk = require('chalk');


async function sleep(n) {
    return new Promise((resolve, reject) => {
        setTimeout(resolve, n)
    })
}

let counter = 0;  // 用来记录重试的错误
async function loadingWrap(fn, message, ...args) {
    const spinner = ora(`${chalk.green(message)}`)
    spinner.start()
    try {
      const value = await fn(...args)
      spinner.succeed('成功');
      console.log('')
      return value
    } catch(e) {
        spinner.fail('执行失败,重试中...⌛️')
           // 自动重试5次
        if (counter === 4) {
            console.log(chalk.red('网络错误或未知错误,请重试'))
            counter = 0
            return false;
        } else {
            counter = counter + 1;
            await sleep(1000)
            return loadingWrap(fn, message, ...args)
        }
    }
}


module.exports = {
    loadingWrap
}

添加创建文件夹写入文件的工具 fileUtiles.js

const fs = require('fs');
const path = require('path');
const inquirer = require('inquirer');


const writeFile = async (path, content) => {

    if (fs.existsSync(path)) {
    // 提示用户是否确认覆盖
    const {chose} = await inquirer.prompt([ // 配置询问方式
          {
              name: 'chose',
              type: 'list', // 类型
              message: '此文件已存在是否覆盖?',
              choices: [
                  {name: '覆盖', value: 'overwrite'},
                  {name: '取消', value: false}
              ]
          }
     ]); 

      if (!chose) {
          return;
        } else if (chose === 'overwrite'){
          return fs.promises.writeFile(path, content);
        }
    } else {
      return fs.promises.writeFile(path, content);

    }
  }
  
  const mkdirSync = (dirname) => {
    if (fs.existsSync(dirname)) {
      return true
    } else {
      // 不存在,判断父亲文件夹是否存在?
      if (mkdirSync(path.dirname(dirname))) {
        // 存在父亲文件,就直接新建该文件
        fs.mkdirSync(dirname)
        return true
      }
    }
  }

  module.exports = {
    writeFile,
    mkdirSync
  }


添加开启终端子进程的工具,用来实现自动下载依赖,自动运行项目

// terminal.js
// 开启子进程
const { spawn, exec } = require('child_process');  
const spawnCommand = (...args) => {
    return new Promise((resole, reject) => {
      const childProcess = spawn(...args);
      childProcess.stdout.pipe(process.stdout);
      childProcess.stderr.pipe(process.stderr);
      childProcess.on('close', () => {
        resole();
      })
    })
  }

module.exports = {
    spawn: spawnCommand
}

添加ejsCompile.js 编译ejs模版,用来实现可以快速创建一个页面

const ejs = require('ejs')

const ejsCompile = (templatepath, data={}, options = {}) => {

    return new Promise((resolve, reject) => {
        ejs.renderFile(templatepath, {data}, options, (err, str) => {
            if (err) {
                reject(err);
                return;
            }
            resolve(str)
        })
    })
    
}


module.exports = {
    ejsCompile
}

在使用终端创建一个页面的时候,要首先创建好ejs模版,到时候,直接将命令行的参数传入到模版里面,渲染出来,然后写入到文件中就可以了

新建一个template文件夹,创建一个react的模版 component.react.ejs

import React from 'react'


const <%= data.lowerName %> :React.FC<Iprops> = (props) => {

    return (
        <div>
            <%= data.name %>
        </div>
    )
}

interface Iprops {

}


export default  <%= data.lowerName %>
✅这个时候我们的大概脚手架基本功能就已经完成啦,
  后面想要新增功能只要在command里添加就好了,然后添加ejs模版渲染

搁置了好久的脚手架终于搞完了,奖励自己一瓶茶π

效果

image.png