前言
目前前端发展蒸蒸日上,工程化也越来越成熟。在这期间出现了很多优秀的框架和工具。与此同时伴随着与框架搭配使用的脚手架也呼之欲出。前端脚手架工具发展的日益强大,比如vue-cli
,create-react-app
等等是在vue
,react
开发搭建项目常用的脚手架。小编在看了vue-cli3
,vue-cli2
的脚手架实现之后,心血来潮自己实现一个简易版的脚手架,下边我们一起来学习一下脚手架的实现流程。
小编福利推荐,更多精彩内容请点击链接,点击这里
实现思路
我认为vue-cli3
和vue-cli2
的实现区别有如下几点
- 就是
vue-cli3
不在从git
仓库下载模板,而是自己生成代码和创建文件和文件夹。 -
vue-cli3
把webpack
的配置内置了,不在暴露出来,提供用户自定义的配置文件来自定义自己的配置;而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.json
的bin
项来指定自己定义的命令对应的可执行文件的位置,我们在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
命令,你会发现会输出我们打印的字符串。
到这里我们已经成功在系统里添加了自己定义的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
,可以看到在控制台输出版本信息
接下来为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
可以在控制台查看到我们输入的test
和cmd
对象。可以在cmd中查找到存在force属性。
如果执行lbs init
,输出如下
如果执行lbs init test
,输出如下
这里我们主要是获取这两个数据,如果你的命令还有其它的复杂功能,还可以扩展其它参数和选项。
这里只是command
的一种使用方式,当我们为command
添加第二个描述参数,就意味着使用独立的可执行文件作为子命令,比如你的命令是init
那么你就需要创建一个lbs-init
脚本文件,这个文件负责执行你指定的命令,按照lbs-${command}
的方式创建脚本,我们创建lbs-init.js
文件
把命令修改如下,为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
实现命令行和用户的交互(用户输入,选择,问答)
这里我们需要安装chalk
,inquirer
插件
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
,可以看到成功获取到projectName
和templateName
接下来我们还需要判断用户输入的项目名称在当前目录是不是存在,在存在的情况下
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 -f
和lbs init -f
命令,查看my-app是否删除,
执行lbs init
,根据一步步提示,输入已经存在的目录名称作为项目名称;选择模板,检查是否my-app文件夹被删除,如下
下载,解压模板
下载模板,需要我们根据选择的模板名称拼接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
命令,在linux
和mac
执行的是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
测试一下
那么到这里一个简易版的脚手架已经完成!
有什么疑问可以关注公众号私信哦~