从零一步步实现一个前端脚手架

前言

目前前端发展蒸蒸日上,工程化也越来越成熟。在这期间出现了很多优秀的框架和工具。与此同时伴随着与框架搭配使用的脚手架也呼之欲出。前端脚手架工具发展的日益强大,比如vue-clicreate-react-app等等是在vuereact开发搭建项目常用的脚手架。小编在看了vue-cli3vue-cli2的脚手架实现之后,心血来潮自己实现一个简易版的脚手架,下边我们一起来学习一下脚手架的实现流程。

小编福利推荐,更多精彩内容请点击链接,点击这里

实现思路

我认为vue-cli3vue-cli2的实现区别有如下几点

  • 就是vue-cli3不在从git仓库下载模板,而是自己生成代码和创建文件和文件夹。
  • vue-cli3webpack的配置内置了,不在暴露出来,提供用户自定义的配置文件来自定义自己的配置;而vue-cli2则是把配置完全暴露出来,可以任意修改。

本文我们这里是基于vue-cli2的实现思路来一步步实现一个简单的react版本脚手架。下边是小编整体的实现过程

1、添加自己脚手架的命令(lbs)
2、使用commander工具为自己的lbs命令添加解析参数,解析参数,添加自定义命令;附上官方文档 commander文档
3、使用inquirer实现命令行和用户的交互(用户输入,选择);附上官方文档 inquirer文档
4、根据用户输入的项目名称,模板来下载,解压模板
5、修改模板里边的文件(package.json,index.html等)
6、为项目安装依赖,结束

开始撸代码

本文实现一个 lbs init [projectName] --force命令

projectName: 输入的项目名称
--force: 定义的选项(当前目录存在输入的[projectName]文件夹时候,是否强制覆盖)

添加脚手架命令(lbs)

创建项目这一步省略
利用package.jsonbin项来指定自己定义的命令对应的可执行文件的位置,我们在package.json,添加如下代码

"bin":{
  "lbs": "./bin/lbs.js"
},

然后创建bin/lbs.js文件,添加测试代码:

#!/usr/bin/env node

console.log("hello lbs-cli")

第一行是必须添加的,是指定这里用node解析这个脚本。默认找/usr/bin目录下,如果找不到去系统环境变量查找。

然后我们在任意目录下打开cmd窗口,输入lbs命令,你会发现找不到命令。其实我们还需要做一步操作,就是把本地项目全局安装一下,在当前项目下执行npm install . -g,然后在cmd下执行lbs命令,你会发现会输出我们打印的字符串。

QQ截图20200918130913.png

到这里我们已经成功在系统里添加了自己定义的lbs命令,那么我们怎么为lbs添加init,create,--version等等参数呢?

使用commander丰富我们的lbs命令

不熟悉commander的使用请看commander文档

我们首先要安装一下插件,然后初步尝试一下为我们的lbs命令添加版本查看的选项

const { program } = require("commander")
const pkg = require("./../package.json")

program.version(pkg.version,'-v --version')

program.parse(process.argv)

此时我们在任意命令行执行lbs -v或者lbs --version,可以看到在控制台输出版本信息

QQ截图20200918133907.png

接下来为lbs命令添加一个命令:

// projectName 是一个可选参数

program.command('init [projectName]')
.description("初始化项目")   
// 添加一个选项     
.option('-f --force','如果存在输入的项目目录,强制删除项目目录') 
.action((projectName,cmd)=>{ 
     // projectName 是我们输入的参数,
     console.log(projectName) 
     // cmd是Command对象
     console.log(cmd.force)  
})

这里我们添加了一个init命令,支持一个可选参数和一个-f的可选选项
这时候我们执行一下

lbs init test -f

可以在控制台查看到我们输入的testcmd对象。可以在cmd中查找到存在force属性。

QQ截图20200918135820.png

如果执行lbs init,输出如下

QQ截图20200918135905.png

如果执行lbs init test,输出如下

QQ截图20200918135917.png

这里我们主要是获取这两个数据,如果你的命令还有其它的复杂功能,还可以扩展其它参数和选项。

这里只是command的一种使用方式,当我们为command添加第二个描述参数,就意味着使用独立的可执行文件作为子命令,比如你的命令是init那么你就需要创建一个lbs-init脚本文件,这个文件负责执行你指定的命令,按照lbs-${command}的方式创建脚本,我们创建lbs-init.js文件

QQ截图20200918141559.png

把命令修改如下,为command方法添加第二个参数

// projectName 是一个可选参数
program.command('init [projectName]','init project')  
.description("初始化项目")        
// 添加一个选项
.option('-f --force','如果存在输入的项目目录,强制删除项目目录') 
        .action((projectName,cmd)=>{ 
            console.log(projectName) // projectName 是我们输入的参数,
            console.log(cmd.force)  // cmd是Command对象
        })

执行lbs init,你会发现什么也没输出。因为这里不会执行到action方法,会去执行我们创建的lbs-init.js这个空文件。所以什么也不会输出。这时候lbs.js只需要定义init命令就可以了。只需要这一行就足够了program.command('init [projectName]','init project')

然后在lbs-init.js添加解析代码

const { program } = require("commander")

let projectName;
let force;

program.arguments('[projectName]') // 指定解析的参数
        .description("初始化项目")  
        .option('-f --force','如果存在输入的项目目录,强制删除项目目录') 
        .action((name,cmd)=>{ 
            projectName = name;
            force = cmd.force;
        });
program.parse(process.argv);

console.log(projectName,force)

重新执行lbs init test -f发现数据都能获取。到这里我们已经可以为我们的lbs init命令自定义参数和选项了,那么当用户只执行lbs init命令,这时候我们就获取不到项目名称,我们怎么办呢?请往下看

使用inquirer实现命令行和用户的交互(用户输入,选择,问答)

这里我们需要安装chalkinquirer插件
chalk:主要是自定义颜色控制台输出

创建一个logger.js工具类,主要是输出控制台信息

const chalk = require('chalk');

exports.warn = function(message){
    console.log(chalk.yellow(message));
}

exports.error = function(message){
    console.log(chalk.red(message))
}

exports.info = function(message){
    console.log(chalk.white(message))
}

exports.infoGreen = function(message){
    console.log(chalk.green(message))
}

exports.exit = function(error){
    if(error && error instanceof Error){
        console.log(chalk.red(error.message))
    }
    process.exit(-1);
}

这个库是我们可以和用户交互的工具;第一个问题是输入项目名称,第二个问题是让用户选择一个模板,这里的模板需要在github上准备好,我这里只准备了一个lb-react-apps-template,这个模板是基于react-apps-template这个项目重新建了一个git仓库。这个模板的具体实现可以可以看之前`webpack的系列文章:react+webpack4搭建前端项目,后边两个模板是是不存在的

// 设置用户交互的问题
const questions = [
    {
        type: 'input',
        name:'projectName',
        message: chalk.yellow("输入你的项目名字:")
    },
    {
        type:'list',
        name:'template',
        message: chalk.yellow("请选择创建项目模板:"),
        choices:[
            {name:"lb-react-apps-template",value:"lb-react-apps-template"},
            {name:"template2",value:"tempalte2"},
            {name:"template3",value:"tempalte3"}
        ]
    }
];

// 如果用户命令参数带projectName,只需要询问用户选择模板
if(projectName){
    questions.splice(0,1);
}

// 执行用户交互命令
inquirer.prompt(questions).then(result=>{
    if(result.projectName) {
        projectName = result.projectName;
    }
    const templateName = result.template;
    // 获取projectName templateName
    console.log("项目名称:" + projectName)
    console.log("模板名称:" + templateName)
    if(!templateName || !projectName){
        // 退出
        logger.exit();
    }
    // 往下走
    checkProjectExits(projectName,templateName); // 检查目录是否存在
}).catch(error=>{
    logger.exit(error);
})

这里的checkProjectExits下边会实现,可以先忽略。这时候我们执行lbs init,可以看到成功获取到projectNametemplateName

QQ截图20200918173924.png

接下来我们还需要判断用户输入的项目名称在当前目录是不是存在,在存在的情况下
1、如果用户执行的命令包含--force,那么直接把存在的目录删除,
2、如果命令不包含 --force,那么需要询问用户是否需要覆盖。如果用户需要覆盖,那就直接删除存在的文件夹,不过用户不允许,那就直接退出

添加checkProjectExits检查目录存在的方法,代码如下

function checkProjectExits(projectName,templateName){
    const currentPath = process.cwd();
    const filePath = path.join(currentPath,`${projectName}`); // 获取项目的真实路径
    if(force){ // 强制删除
        if(fs.existsSync(filePath)){
            // 删除文件夹
            spinner.logWithSpinner(`删除${projectName}...`)
            deletePath(filePath)
            spinner.stopSpinner(false);
        }
        startDownloadTemplate(projectName, templateName) // 开始下载模板
        return;
    }
    if(fs.existsSync(filePath)){ // 判断文件是否存在 询问是否继续
        inquirer.prompt( {
            type: 'confirm',
            name: 'out',
            message: `${projectName}文件夹已存在,是否覆盖?`
        }).then(data=>{
            if(!data.out){ // 用户不同意
                exit();
            }else{
                // 删除文件夹
                spinner.logWithSpinner(`删除${projectName}...`)
                deletePath(filePath)
                spinner.stopSpinner(false);
                startDownloadTemplate(projectName, templateName) // 开始下载模板
            }
        }).catch(error=>{
            exit(error);
        })
    }else{
        startDownloadTemplate(projectName, templateName) // 开始下载模板
    }
}

function startDownloadTemplate(projectName,templateName){
    console.log(projectName,templateName)
}

我们这里用到了一个spinner的工具类,新建lib/spinner.js,主要是一个转菊花的动画提示,代码如下

const ora = require('ora')
const chalk = require('chalk')

const spinner = ora()
let lastMsg = null

exports.logWithSpinner = (symbol, msg) => {
  if (!msg) {
    msg = symbol
    symbol = chalk.green('✔')
  }
  if (lastMsg) {
    spinner.stopAndPersist({
      symbol: lastMsg.symbol,
      text: lastMsg.text
    })
  }
  spinner.text = ' ' + msg
  lastMsg = {
    symbol: symbol + ' ',
    text: msg
  }
  spinner.start()
}

exports.stopSpinner = (persist) => {
  if (!spinner.isSpinning) {
    return
  }

  if (lastMsg && persist !== false) {
    spinner.stopAndPersist({
      symbol: lastMsg.symbol,
      text: lastMsg.text
    })
  } else {
    spinner.stop()
  }
  lastMsg = null
}

我们新建lib/io.js,实现deletePath删除目录方法,如下

function deletePath (filePath){
    if(fs.existsSync(filePath)){
        const files = fs.readdirSync(filePath);
        for(let index=0; index<files.length; index++){
            const fileNmae = files[index];
            const currentPath = path.join(filePath,fileNmae);
            if(fs.statSync(currentPath).isDirectory()){
                deletePath(currentPath)
            }else{
                fs.unlinkSync(currentPath);
            }
        }
        fs.rmdirSync(filePath);
    }
}

可以创建my-app文件夹,这时候可以测试一下lbs init my-app -flbs init -f命令,查看my-app是否删除,

执行lbs init,根据一步步提示,输入已经存在的目录名称作为项目名称;选择模板,检查是否my-app文件夹被删除,如下

QQ截图20200918190201.png
下载,解压模板

下载模板,需要我们根据选择的模板名称拼接github仓库相对应的zip压缩包的url,然后执行node的下载代码,(注意这里是把下载的zip压缩包下载到系统的临时目录)下载成功后把zip压缩包解压到用户输入项目名称的目录,解压成功后删除已下载的压缩包。这一个流程就结束了

这其中下载利用request插件,解压用到了decompress插件,这两个插件需要提前安装一下,这两个插件有不熟悉使用的小伙伴可以提前熟悉一下相关使用

重写上边的startDownloadTemplate方法

function startDownloadTemplate(projectName,templateName){
    // 开始下载模板
    downloadTemplate(templateName, projectName , (error)=>{
        if(error){
            logger.exit(error);
            return;
        }
        // 替换解压后的模板package.json, index.html关键内容
        replaceFileContent(projectName,templateName)
    })
}

function replaceFileContent(projectName,templateName){
    console.log(projectName,templateName);
}

新建lib/download.js,实现downloadTemplate下载模板的方法,代码如下

const request = require("request")
const fs = require("fs")
const path = require("path")
const currentPath = process.cwd();
const spinner = require("./spinner")
const os = require("os")
const { deletePath , unzipFile } = require("./io")

exports.downloadTemplate = function (templateName,projectName,callBack){

    // 根据templateName拼接github对应的压缩包url
    const url = `https://github.com/liuboshuo/${templateName}/archive/master.zip`;

    // 压缩包下载的目录,这里是在系统临时文件目录创建一个目录
    const tempProjectPath = fs.mkdtempSync(path.join(os.tmpdir(), `${projectName}-`));

    // 压缩包保存的路径
    const file = path.join(tempProjectPath,`${templateName}.zip`);

    // 判断压缩包在系统中是否存在
    if(fs.existsSync(file)){
        fs.unlinkSync(file); // 删除本地系统已存在的压缩包
    }
    
    spinner.logWithSpinner("下载模板中...")
    let stream = fs.createWriteStream(file);
    request(url,).pipe(stream).on("close",function(err){  
        spinner.stopSpinner(false)

        if(err){
            callBack(err);
            return;
        }

        // 获取解压的目录
        const destPath = path.join(currentPath,`${projectName}`);

        // 解压已下载的模板压缩包
        unzipFile(file,destPath,(error)=>{
            // 删除创建的临时文件夹
            deletePath(tempProjectPath);
            callBack(error);
        });
    })
}

lib/io.js添加解压zip压缩包的方法,代码如下

const decompress = require("decompress");
exports.unzipFile = function(file,destPath,callBack){
    decompress(file,destPath,{
        map: file => {
            // 这里可以修改文件的解压位置, 
            // 例如压缩包中文件的路径是 ${destPath}/lb-react-apps-template/src/index.js   =》  ${destPath}/src/index.js
            const outPath = file.path.substr(file.path.indexOf('/') + 1)
            file.path = outPath
            return file
        }}
    ).then(files => {
        callBack()
    }).catch(error=>{
        callBack(error)
    })
}

这里可以执行以下lbs init my-app测试一下

修改项目中的模板文件(package.json,index.html等)

重写replaceFileContent方法,这一步是把模板中的一些文件的内容修改以下,比如package.json的name,index.html的title值

function replaceFileContent(projectName,templateName){
    const currentPath = process.cwd();
    try{
        // 读取项目的package.json
        const pkgPath = path.join(currentPath,`${projectName}/package.json`);
        // 读取内容
        const pkg = require(pkgPath);
        // 修改package.json的name属性为项目名称
        pkg.name = projectName;
        fs.writeFileSync(pkgPath,JSON.stringify(pkg,null,2));

        const indexPath = path.join(currentPath, `${projectName}/index.html`);
        let html = fs.readFileSync(indexPath).toString();
        // 修改模板title为项目名称
        html = html.replace(/<title>(.*)<\/title>/g,`<title>${projectName}</title>`)
        fs.writeFileSync(indexPath,html);
    }catch(error){
        exit(error)
    }
    // 安装依赖
    install(projectName)
}

function install(projectName){
    console.log(projectName)
}
安装依赖

重写install方法,这里利用child_process包创建一个node的子进程来执行npm install任务。注意这里要执行的命令npm在不同系统有区别,在window下执行的是npm.cmd命令,在linuxmac执行的是npm命令
有不熟悉child_process使用的小伙伴可以深入学习一下,这是nodejs自带的一个包,非常有用,这里贴一下文档地址 child_process官方文档,这里利用spawn方法执行系统命令,还可以使用execFileSync方法来执行文件等等

const currentPath = process.cwd();
    const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
    // 创建一个子进程执行npm install 任务
    const nodeJob = child_process.spawn(npm , ['install'], {
        stdio: 'inherit', // 指定父子进程通信方式
        cwd: path.join(currentPath,projectName)
    });
    // 监听任务结束,提示用户创建成功,接下来的操作
    nodeJob.on("close",()=>{
        logger.info(`创建成功! ${projectName} 项目位于 ${path.join(currentPath,projectName)}`)
        logger.info('')
        logger.info('你可以执行以下命令运行开发环境')
        logger.infoGreen(` cd ${projectName}       `);
        logger.infoGreen(` npm run dev             `);
    })

执行lbs init测试一下

1600428938(1).jpg

那么到这里一个简易版的脚手架已经完成!

有什么疑问可以关注公众号私信哦~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容