前言
脚手架已经成为了前端日常工作中必不可少的开发利器,通过它不仅能够减少机械的重复工作,而且还能有效的组织和管理企业的项目模板.
企业内部涵盖的前端项目类型不一,比如针对不同平台有pc
端、h5
、小程序
等.不同框架有react
和vue
技术栈.业务类型又可以将项目区分为后台管理系统
、门户网站
以及数据大屏
等.
这么多不同类型的项目,它们采用的技术栈组合也会随着相应的场景搭配.后台管理系统
通常会采用vue
+Element UI
+ vuex
+ ts
搭建项目.手机端项目除了基础的设置,它还需要额外配置不同屏幕下的适配.react native
项目搭建之初需要配置好全局通用的方法,比如路由跳转、loading
加载、请求方法等.
如果每次开启一个新项目,都需要把项目搭建的环节重复一遍,这不符合程序员的行事风格.
脚手架的出现能够有效的帮助前端开发者管理企业内部各种类型项目模板,比如开发者只需要在命令行输入以下命令:
cli create my-app
cli
是我们开发的脚手架工具的名称,create
是创建新项目的命令,my-app
是给新项目起的名称.
命令行接受了上述命令,立刻列出了企业内所有的项目模板(如下):
* vue2
* vue3
* react-mobile
* CRM
开发者只需要按键盘的上下键选择项目模板,再敲击enter
键选中就完成了操作.脚手架接下来自动完成以下任务:
- 根据开发者选择的模板名称,找到对应的
githup
仓库地址,将其下载到本地 - 如果出现网络波动下载失败,断连重试
5
次 - 项目下载成功后,进入根目录打开
package.json
文件,将项目名称name
属性修改为my-app
package.json
文件修改完后,开始安装依赖,最后运行启动命令启动项目
整个过程开发者只需要简单输入几条命令,新项目从下载、安装到启动全部自动完成.
脚手架除了能帮助我们有效的管理和创建新项目,另外还可以增添其他额外的功能,比如一键命令,脚手架自动帮助我们新建页面或组件.
最终实现效果如下(源代码在文章结尾):
实现
Hello world
新建一个项目文件夹mycli
,打开文件夹执行命令npm init
新建项目.
在package.json
中新增字段"bin": "./bin/index"
(代码如下).
// package.json文件
{
"name": "mycli",
"version": "1.0.0",
"description": "",
"main": "index.js",
"bin": "./bin/index",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
项目根目录下新建文件bin/index
,输入以下代码.
#! /usr/bin/env node
指定运行环境为node
,下面只写一句测试代码hello world
.
#! /usr/bin/env node
console.log("hello world");
到此为止就可以直接测试上述代码了.进入项目根目录执行npm link
将脚手架链接到全局,再执行mycli
命令就能输出"hello world"
(如下图所示).
commander
上述"hello world"
的小案例可以看出,在命令行中一旦输入mycli
命令,bin/index
内编写的逻辑代码就会被执行.
为了实现脚手架的功能,首先介绍一个第三方类库commander
,它被常用来编写各类命令行工具.
commander
定义命令(代码如下),program.command
定义了create <app-name>
命令,其中create
是定义的关键字,<app-name>
是用户传入的参数.
#! /usr/bin/env node
const { program } = require('commander');
program.version('1.0.0');
//创建新项目的命令
program.command("create <app-name>")
.description("创建一个新项目")
.action((appName)=>{
console.log(appName);
})
program.parse(process.argv);
调用方法如下图所示.mycli create my-app
成功调用了定义的命令,最后打印输出appName
为my-app
.
program.version
用于设置版本号,通过mycli -V
可查询.
mycli -h
显示帮助信息,上述命令定义的description
会在帮助信息显示出来.
命令行工具除了直接输入命令,还可以附加参数.比如下面使用option
定义-t
或者--template
接受用户传入的参数.
#! /usr/bin/env node
const { program } = require('commander');
program.version('1.0.0');
//创建新项目
program.command("create <app-name>")
.description("创建一个新项目")
.option('-t, --template <template-name>','选择一个模板下载')
.action((appName,options)=>{
console.log(appName,options.template);
})
program.parse(process.argv);
调用方式如下图所示.通过options
可以拿到用户传递的参数.
inquirer
inquirer
是命令行与用户展开交互的第三方工具库,通过它提供的Api
可以轻松在命令行中输出列表和问题.
观察下面代码.命令行依次向用户提出问题,第一个问题询问用户是否爱吃榴莲,type
类型为confirm
,用户只需要返回yes
和no
.
第二个问题是询问用户喜欢吃什么水果,需要用户输入答案.(运行效果图如下)
#! /usr/bin/env node
const inquirer = require('inquirer');
inquirer.prompt([
{
type:"confirm",
name:"firut",
message:"你喜欢吃榴莲吗?"
},
{
type:"input",
name:"food",
message:"告诉我你喜欢吃什么?"
}
])
.then((answers) => {
console.log(answers);
})
执行结果answers
会将用户输入的答案根据name
属性排布输出.
inquirer
在开发脚手架的过程中,使用最多的场景是输出列表(代码和运行效果图如下).
当type
类型为list
时,命令行窗口输出一串列表提供给用户选择.用户可以敲击键盘上下键选择不同答案,选定后按下enter
键确认答案.
#! /usr/bin/env node
const inquirer = require('inquirer');
inquirer.prompt([
{
type:"list",
message:"以下哪位人物的武功最高?",
name:"master",
choices:["孙悟空","大鹏金翅","牛魔王","黄袍怪","黄眉大王"]
}
])
.then((answers) => {
console.log(answers); // { master:"大鹏金翅" }
})
实际场景中,inquirer
通常会与commander
搭配使用.
创建新项目
回到正题,我们的初步目标是为了让脚手架自动下载新项目并安装依赖和启动应用,首先得创建一个配置文件repo.js
,用来存储各类模板的详细信息.(代码如下)
url
是项目的githup
地址,bootstrap
是启动命令,install
是安装命令.
以后有新的项目模板,只需要在配置文件的后面加一项即可.
// repo.js 文件
//模板下载地址
exports.config = {
"vue3":{
url:"kaygod/vue3-demo",
bootstrap:"npm run serve"
},
"vue":{
url:"kaygod/vue_demo",
bootstrap:"yarn serve",
install:"yarn install"
}
}
脚手架入口文件/bin/index
编写代码如下.文件定义了一条创建项目的命令,但具体处理该条命令的逻辑代码都封装到了/bin/actions/create.js
文件.
这样做更容易维护代码的结构,以后入口文件定义一条新命令,都可以在/bin/actions
新建js
文件处理该条命令的逻辑.
#! /usr/bin/env node
const { program } = require('commander');
program.version('1.0.0');
//创建新项目
program.command("create <app-name>")
.description("创建一个新项目")
.action((appName)=>{
require("./actions/create")(appName);
})
program.parse(process.argv);
create.js
文件代码如下,创建整个项目的流程都可以在createProject
函数中体现.
createProject
接受项目名称appName
.调用inquirer.prompt
第一次向用户弹出输入框,要求用户填写项目描述信息.第二次向用户弹出模板列表,要求用户选择模板下载.process.cwd()
是nodejs
提供的api
,执行后返回用户运行命令行窗口所在的路径.将此路径与appName
拼接得到创建的新项目本地路径.download
函数下载项目到本地- 项目下载成功后,
updatePackage
函数修改package.json
信息 start
函数先给新项目安装依赖,再执行启动命令
// /bin/actions/create.js文件
const inquirer = require('inquirer');
const { download } = require("../download");
const { updatePackage } = require("../updatePackage");
const { start } = require("../startProject");
const path = require("path");
const { config } = require("../repo");
async function createProject(appName){
const prompList = [
{
type: 'input',
name: 'description',
message: '请输入项目描述信息:',
},
{
type:"list",
message:"请选择一个模板下载:",
name:"template_name",
choices:Object.keys(config) // 从配置文件repo.js中动态获取所有模板的名称
}
];
const { template_name ,description } = await inquirer.prompt(prompList);
const project_dir = path.join(process.cwd(),appName); //新建项目的路径
try {
await download(template_name,project_dir); // 下载项目到本地
await updatePackage(project_dir,{name:appName,description,template:template_name}); //修改package.json
start(project_dir,template_name);// 启动项目
} catch (error) {
console.log(error);
}
}
module.exports = createProject;
运行效果图如下:
download
download
函数除了下载项目到本地,它还需要处理下载失败重试下载的情况(代码如下).
download
函数实现下载这一部分功能主要依靠download-git-repo
第三方库,它提供了api
可以方便拉取githup
上的仓库源码.
download-git-repo
调用形式如:dl(`${url}`,project_dir,async function(err) {})
,它的第一个参数是远程仓库的地址(配置文件repo
已经配置好了),第二个参数是下载到本地的路径,第三个参数是下载完的回调函数.
如果下载失败,回调函数的err
不为空,需要启动下载重试.
download-git-repo
还提供了很多其他的下载方式,比如使用git clone
、私有仓库等,具体细节可查阅官方文档.
const dl = require('download-git-repo');
const { startLoading,endLoading } = require("./loading");
const { config } = require("./repo");
const fse = require("fs-extra");
let count = 0; //计算下载次数
exports.download = (template_name,project_dir) => {
return new Promise(async (resolve,reject) => {
const { url } = config[template_name]; // 模板的下载地址
// 如果目录非空删除目录内容。如果目录不存在,就创建一个
await fse.emptyDir(project_dir);
(function execuate(){
count++;
if(count >= 5){
count = 0;
reject();
return;
}
startLoading(); //加载中
dl(`${url}`,project_dir,async function(err) {
endLoading(); // 关闭加载中
if (err) {
console.log(err);
//出现下载错误,延时3秒重新下载3次
console.log("\n下载失败,3s后下载重试...\n");
await sleep();
execuate();
}else{
resolve(null);
count = 0;
}
})
})();
});
};
/**
* 睡眠
*/
const sleep = (time = 3000) => {
return new Promise((resolve)=>{
setTimeout(()=>{
resolve(null);
},time)
})
}
下载的过程往往会因为网络原因变得枯燥漫长,我们需要在界面上显示loading
的图案(如下图);
loading
图案借助ora
库可以轻松实现,封装成函数导出给外部调用.
// /bin/loading.js 文件
const ora = require('ora');
const loading = ora('Loading');
exports.startLoading = (text = '加载中...') => {
loading.text = text;
loading.color = 'green';
loading.start();
};
exports.endLoading = () => {
loading.stop();
};
updatePackage
项目成功下载到本地后,我们需要将新项目的package.json
修改成如下的形式.
{
"name": "my-app",
"version": "0.1.0",
... //省略
"description": "vue3项目",
"template": "vue3"
}
name
和description
替换成用户在脚手架中输入的值,而template
存下当前项目对应的项目模板名称(后面使用脚手架创建页面时可以用到此参数).
updatePackage
函数依靠fs-extra
提供api
,先将文件内容读取到内存进行修改,再写入原文件中.
const fse = require('fs-extra');
const path = require("path");
//更改package.json文件
exports.updatePackage = async (dirpath, data) => {
const filename = path.join(dirpath,'package.json');
try {
await fse.ensureFile(filename);
let packageJson = await fse.readFile(filename);
packageJson = JSON.parse(packageJson.toString());
packageJson = { ...packageJson, ...data };
packageJson = JSON.stringify(packageJson, null, '\t');
await fse.writeFile(filename, packageJson);
} catch (err) {
console.error("\npackage.json文件操作失败!\n");
throw err;
}
};
start
pacakge.json
修改完毕,脚手架需要给项目安装依赖并启动应用(代码如下).
start
函数主要做两件事:安装依赖和启动项目.安装依赖通常需要运行命令npm i
,而启动项目也需要运行命令.vue
项目使用npm run serve
启动,react
项目使用npm run start
启动.
不管是安装依赖还是启动项目,它们都需要执行npm
相关命令.nodejs
的核心模块child_process
可以使脚手架直接运行命令.
不同项目的启动命令可能不同,有的使用npm run serve
,有的使用yarn start
,这些都可以在配置文件repo.js
中写好,导出给start
函数调用.
const exec = require('child_process').exec;
const { config } = require("./repo");
/**
* 安装依赖并启动项目
*/
exports.start = async (path,template_name) => {
await installLib(path,template_name);
console.log('项目依赖安装完毕...');
await startProject(path,template_name);
console.log('项目启动成功...');
};
const installLib = (path,template_name) => {
const install_command = config[template_name].install || "npm i"; //安装依赖的命令
return new Promise((resolve, reject) => {
const workerProcess = exec( // 安装依赖
install_command,
{
cwd: path,
},
(err) => {
if (err) {
console.log(err);
reject(err);
} else {
resolve(null);
}
}
);
workerProcess.stdout.on('data', function (data) {
console.log(data);
});
workerProcess.stderr.on('data', function (data) {
console.log(data);
});
});
};
const startProject = (path,template_name) => {
const bootstrap_command = config[template_name].bootstrap || "npm run serve"; //启动项目的命令
return new Promise((resolve, reject) => {
const workerProcess = exec( // 启动项目
bootstrap_command,
{
cwd: path,
},
(err) => {
if (err) {
console.log(err);
reject(err);
} else {
resolve(null);
}
}
);
workerProcess.stdout.on('data', function (data) {
console.log(data);
});
workerProcess.stderr.on('data', function (data) {
console.log(data);
});
});
};
创建页面
脚手架除了做基础的创建新项目的工作,还可以根据实际需求做出更多的拓展.
入口文件/bin/index
新定义一条命令newpage
(代码如下),用来给项目新建页面.
#! /usr/bin/env node
const { program } = require('commander');
program.version('1.0.0');
//创建新项目
program.command("create <app-name>")
.description("创建一个新项目")
.option('-t, --template <template-name>','选择一个模板下载')
.action((appName,options)=>{
require("./actions/create")(appName,options);
})
//创建新页面
program.command("newpage <page-name>")
.description("创建一个新页面")
.action((pageName)=>{
require("./actions/newpage")(pageName);
})
program.parse(process.argv);
效果图如下:
newpage
函数代码如下,以vue3
项目模板为例,执行命令后在项目/src/views
文件夹下新增页面.
newPage
函数首先会读取项目的package.json
文件中的template
字段,这个字段是前面创建项目后脚手架给package.json
添加的模板名称.
通过template
字段,我们就可以知道当前项目属于哪一种模板类型.然后使用策略模式针对不同的模板编写创建新页面的逻辑.
例如下面代码定义了一个vue3Handler
函数,它能够为使用vue3
模板下载的项目新建页面.
const Mustache = require('mustache'); // 模板引擎
const path = require("path");
const fse = require("fs-extra");
/**
* 创建新页面
*/
async function newPage(page_name){ //创建的页面名称
try {
const packageJson = await fse.readFile("./package.json");
const { template } = JSON.parse(packageJson.toString());// 获取模板名称
const fn = eval(`${template}Handler`);
fn && fn(page_name,template);
} catch (error) {
console.log("\n请在项目根路径下执行此命令!\n");
throw error;
}
}
/**
* 创建vue3模板的新页面
*/
const vue3Handler = async (page_name,template)=>{
let template_content = await fse.readFile(path.join(__dirname,`../template/${template}/index`));
template_content = template_content.toString();
const result = Mustache.render(template_content,{
page_name
});
//开始创建文件
await fse.writeFile(path.join("./src/views",`${page_name}.vue`), result);
console.log("\n页面创建成功!\n");
}
module.exports = newPage;
vue3Handler
函数首先会读取/template/vue3/index
下放置的vue3
模板文件(代码如下).
将模板代码转化成字符串赋值给template_content
变量,再使用模板引擎Mustache
将模板中name
属性对应的页面名称修改成用户敲击newpage
命令时输入的值.
修改完成后,再将内存中的新文件内容输出到/src/views
文件下生成.
<template>
<div class="container"></div>
</template>
<script lang='ts'>
import {
reactive,
toRefs,
onBeforeMount,
onMounted,
defineComponent,
} from 'vue'
interface DataProps {}
export default defineComponent({
name: '{{page_name}}',
setup() {
return {
}
},
})
</script>
<style scoped lang="less">
.container{}
</style>
受此启发,脚手架工具还可以定义更多的逻辑去完成更多的需求.比如newpage
命令不光光只是在views
文件夹下新建一个页面,它还可以自动将新建的页面配置插入到路由和vuex
中去,真正实现一键命令就可以在浏览器上看到结果.
最后将工具发布到npm
,就可以分享给其他成员使用了.