前言
工作中,我们可能经常碰到下面这些问题:
- 公司需要开发一个新项目,项目的权限,菜单,登录等功能在别的项目已经存在。
- 项目需要配置例如eslint等代码规范,需要和原先的一些项目保持一致(可能所有的项目都需要保持一致)。
- 公司的所有项目需要遵循一套风格,比如皮肤,字体规范,按钮形状等等...
这个时候,我们可能会复制菜单等逻辑的代码,复制eslint规则的json,复制样式的css或者font文件等等。。。
但是显然这并不是一个优雅的解决方案,因此,我们需要去开发一个脚手架,这个脚手架自动生成项目,项目中有已经存在的基础功能(权限,菜单等),有统一的一套技术栈(比如vue3+vite),有统一的代码规范,甚至有统一的单元测试等等...
所以,话不多说,走起~~~
搭建脚手架
初始化脚手架名称
首先,给你的脚手架定一个响亮的名称(我的是ywill-cli),需要注意的是,不要和npm源上的包名重复,否则在你想发布到npm的时候会有重名问题(定好名字后可以去npm上搜一下是否存在了这个包)。
现在,根据你定的包名,创建一个和你包名同名的文件夹。然后初始化package.json:
npm init -y
复制代码
创建入口
你已经拥有了一个属于你的包的文件夹了(虽然空空如也...),现在该去创建一个入口了。
入口就是在最后例如使用 ywill-cli 命令的时候,执行的入口文件。
在你的包文件夹下,建立一个bin目录,里面增加一个main文件(注意,纯main文件,没有.js这种后缀)。main中代码如下:
#! /usr/bin/env node
console.log('我是入口文件....')
复制代码
这个时候,在命令行运行node ./bin/main
发现控制台就执行了main这个文件,并打印出来了结果:

#! /usr/bin/env
是base脚本需要在第一行指定脚本的解释语言,我们使用的语言是node。
当然,你可能更希望运行例如ywill-cli
而不是node ./bin/mian
,所以我们在package.json中增加以下代码:
"bin": {
"ywill-cli": "bin/enter"
}
复制代码
接着,为了暂时能够升级ywill-cli
为全局命令,我们运行npm link
。
就这么简单,好了,这个时候在控制台使用ywill-cli
就可以打印出来了。
配置脚手架的选项(options)
首先,什么是options?看看vue脚手架我们就一目了然了
没错,红框框中的就是选项。
我们现在也为我们的脚手架增加选项。
增加版本
我们需要用到一个插件commander
,安装一下:
npm install commander@9
复制代码
新版本好像不支持commonjs语法,具体我也没仔细看,保险起见,还是安装9.x.x的把
commander
是用来实现脚手架命令配置的插件,大家可以自己去commander
中文文档查看。
为main中增加如下代码:
const program = require('commander');
// 获取当前版本号
const version = require('../package.json').version;
program
// 配置脚手架名称
.name('ywill-cli')
// 配置命令格式
.usage(`<command> [option]`)
// 配置版本号
.version(version);
program.parse(process.argv);
复制代码
代码非常简单,注释也写清楚了。这个时候,我们运行ywill-cli --help
看一下:
很棒,已经有--version
的提示了。我们执行一下命令:ywill-cli --version
会发现可以输出版本号了。
增加提示
我们再看看vue的提示:
为了增加提示(其实是为了美化效果),我们需要引入插件chalk
:
npm install chalk@4
复制代码
chalk
是用来美化字体的插件,也就是改变字体, 背景颜色等等,大家可以自己去chalk地址查看。
现在,我们继续为main增加下面代码:
const chalk = require('chalk');
// 给提示增加
program.on('--help', () => {
console.log();
console.log(
`Run ${chalk.cyan(
'ywill-cli <command> --help'
)} for detailed usage of given command.
`)
});
复制代码
继续运行ywill-cli --help
看一下:
成功,那么配置脚手架的选项(options)我们就实现了,so easy!!!
配置脚手架命令(command)
脚手架的核心是命令,例如vue create xxx
,所以,我们也需要实现自己的脚手架命令(毕竟开发脚手架就是为了这个)。
我们现在明确这个脚手架的需求是:使用这个脚手架,可以选择拉取vue2或者vue3的模版代码(当然,以后你们的脚手架可能是拉取公司的基础框架代码或者某些模块)。
添加命令模块
现在,我们需要有一个模块(比如create
模块),来完成创建指令。
所以,我们新建一个文件夹lib
,并增加一个文件create.js
,代码如下:
module.exports = function(projectName, options) {
console.log(projectName, options);
}
复制代码
这个模块很简单,就是导出一个函数,这个函数接收两个参数,然后将接收的参数打印出来。
现在,我们继续改造main
文件,在main
文件增加以下代码:
// 获取create模块
const createModel = require('../lib/create')
program
.command('create <project-name>')
.description('create a new project')
.option('-f, --force', 'overwrite target directory if it exists')
.action((projectName, options) => {
// 引入create模块,并传入参数
createModel(projectName, options);
})
复制代码
解释一下:
program.command
是定义一个命令,命令的格式是ywill-cli create xxx
description
就是这个命令的描述,不多介绍了option
是命令后面可以携带的参数以及参数的相关描述action
后面是一个回调,回调的第一个参数就是上面的xxx
,第二个参数就是--
后面的
OK,现在我们运行命令ywill-cli create test --force
,发现果然打印了如下:
编写create模块
现在,我们就可以对create模块的逻辑进行编写了。
创建Creator类
由于create模块可能有很多功能,比如校验目录重复,获取版本信息,拉取远程代码或模块等等功能。所以我们先创建一个类,于是,create.js代码变为:
class Creator {
constructor(projectName, options) {
this.projectName = projectName;
this.options = options;
}
// 创建
async create() {
...
}
}
module.exports = async function (projectName, options) {
const creator = new Creator(projectName, options);
await creator.create();
}
复制代码
处理目录重复
假设我们可以通过ywill-cli create xxx
来创建一个基础项目了,但是我们所在的目录本身可能存在xxx
目录,所以,我们需要做一个目录是否存在的校验,校验如下:
-
如果使用了
--force
参数,那么直接删除原先的目录,然后直接创建 -
如果没有使用
--force
参数,那么询问用户,是否覆盖,选择覆盖则执行上面逻辑,不覆盖则终止创建在第二步的时候,询问用户需要和用户进行交互,所以我们需要使用
inquirer
插件,还需要使用fs-extra
模块来判断目录是否存在。
inquirer
插件是一个和命令行交互的工具插件,vue-cli的交互就是使用这个插件完成的,大家可以自己去inquirer
文档自行查看。
安装一下:
npm install inquirer@8 fs-extra
复制代码
现在,我们为create.js增加这个逻辑:
const path = require('path');
const fs = require('fs-extra');
const chalk= require('chalk');
const Inquirer = require('inquirer');
const cwd = process.cwd();
class Creator {
// ...
// 创建
async create() {
const isOverwrite = await this.handleDirectory();
if(!isOverwrite) return;
console.log('todo....');
}
// 处理是否有相同目录
async handleDirectory() {
const targetDirectory = path.join(cwd, this.projectName);
// 如果目录中存在了需要创建的目录
if (fs.existsSync(targetDirectory)) {
if (this.options.force) {
await fs.remove(targetDirectory);
} else {
let { isOverwrite } = await new Inquirer.prompt([
{
name: 'isOverwrite',
type: 'list',
message: '是否强制覆盖已存在的同名目录?',
choices: [
{
name: '覆盖',
value: true
},
{
name: '不覆盖',
value: false
}
]
}
]);
if (isOverwrite) {
await fs.remove(targetDirectory);
} else {
console.log(chalk.red.bold('不覆盖文件夹,创建终止'));
return false;
}
}
};
return true;
}
}
复制代码
逻辑非常简单,就不一一解释了,上面都已经注释了。
这个时候,我们再次运行ywill-cli create test
(先在目录下创建一个test文件夹):
果然,我们如约的和命令行交互了。
增加调取模版API
接下来,我们需要从远程去获取需要拉取的模版列表,然后选择一个需要拉取的模版,然后拉取到本地。
这里准备好了需要拉取的模版列表的API: https://api.github.com/users/shenyWill/repos
里面的topics
包含了template
的就是可以拉取的模版。
那么,我们先封装一下API(也可以不封装,直接使用axios也行),下载axios:
npm install axios
复制代码
接下来,创建一个api目录(在lib里面或者不在都可以,我放在了lib里面,注意自己的引用路径即可),所有与api操作相关的都在里面,这个不一一讲解了,毕竟不是重点,直接贴代码:
// api/request.js
const axios = require('axios');
class HttpRequest {
constructor(baseUrl, options = {}) {
this.baseUrl = baseUrl;
this.commonOptions = options;
}
getInsideConfig() {
const configs = {
baseUrl: this.baseUrl,
...this.commonOptions
};
return configs;
}
interceptors(instance, options) {
// todo...
}
request(options) {
const instance = axios.create({});
options = Object.assign(this.getInsideConfig(), options);
this.interceptors(instance, options);
return instance(options);
}
};
module.exports = HttpRequest;
复制代码
// api/api.request.js
const HttpRequest = require('./request');
module.exports = new HttpRequest('');
复制代码
// api/interface/index.js
const axios = require('../api.request');
const getRepoList = params => {
return axios.request({
url: 'https://api.github.com/users/shenyWill/repos',
params,
method: 'get'
})
}
module.exports = {
getRepoList
}
复制代码
上面无非就是很简单的封装了一下api,就不介绍了。
获取模版列表
那么,现在就需要去调用上面的getRepoList
获取模版列表,然后让用户选择,需要拉取哪个模版。
由于我们拉取远程数据需要时间,所以,为了优化体验感,我们增加一个loading
的效果,这需要用到ora
库。
ora
就是增加命令行loading效果的库,大家可以自行去ora
文档查看。
安装一下:
npm install ora@5
复制代码
话不多说,我们为create.js
增加这个逻辑:
const ora = require('ora');
const api = require('./api/interface/index');
// ...
async create() {
// ...
await this.getCollectRepo();
}
// ...
// 获取可拉取的仓库列表
async getCollectRepo() {
const loading = ora('正在获取模版信息...');
loading.start();
const {data: list} = await api.getRepoList({per_page: 100});
loading.succeed();
const collectTemplateNameList = list.filter(item => item.topics.includes('template')).map(item => item.name);
let { choiceTemplateName } = await new Inquirer.prompt([
{
name: 'choiceTemplateName',
type: 'list',
message: '请选择模版',
choices: collectTemplateNameList
}
]);
console.log('选择了模版:' + choiceTemplateName);
}
复制代码
这个时候,运行一下试试,是不是可以拉取到模版了,如下图:
下载对应模版
接下来,就需要根据用户选择的模版来定向拉取对应的模版到本地了,拉取的模版地址已经准备好了shenyWill/xxx
(xxx为对应的模版名称),那么我们需要使用download-git-repo
插件来把git上面的项目拉取到本地,由于这个插件不支持promise
,所以又需要使用node自带的util
工具来支持。下载一下插件:
npm install download-git-repo
复制代码
在create.js
中加入如下代码:
const util = require('util');
const downloadGitRepo = require('download-git-repo');
// ...
class Creator {
// ...
// 获取可拉取的仓库列表
async getCollectRepo() {
// ...
this.downloadTemplate(choiceTemplateName);
}
// 下载仓库
async downloadTemplate(choiceTemplateName) {
this.downloadGitRepo = util.promisify(downloadGitRepo);
const templateUrl = `shenyWill/${choiceTemplateName}`;
const loading = ora('正在拉取模版...');
loading.start();
await this.downloadGitRepo(templateUrl, path.join(cwd, this.projectName));
loading.succeed();
}
}
复制代码
再次运行,应该可以拉取代码到本地了:
同时,在本地目录应该可以查看到相关代码了。
模版提示
最后,我们增加一个优化的功能,在拉取成功后,告诉用户该怎么操作,并且增加艺术字体。
增加艺术字体需要使用figlet
插件,大家可以自行查看figlet
文档。
下载一下:
npm install figlet
复制代码
然后,我们在create.js
中增加如下代码:
const figlet = require('figlet');
class Creator {
// ...
// 下载仓库
async downloadTemplate(choiceTemplateName) {
// ...
this.showTemplateHelp();
}
// 模版使用提示
showTemplateHelp() {
console.log(`\r\nSuccessfully created project ${chalk.cyan(this.projectName)}`);
console.log(`\r\n cd ${chalk.cyan(this.projectName)}\r\n`);
console.log(" npm install");
console.log(" npm run dev\r\n");
console.log(`
\r\n
${chalk.green.bold(
figlet.textSync("SUCCESS", {
font: "isometric4",
horizontalLayout: "default",
verticalLayout: "default",
width: 80,
whitespaceBreak: true,
})
)}
`)
}
}
复制代码
在尝试运行一次命令,会发现成功:
发布脚手架
一个最简单的脚手架,就这样创建完成了,接下来就是发布到npm(或者你公司内部的源,比如字节的bnpm)上了,发布就和发布插件没有区别。
- 去npm上注册一个自己的账号
- 本地登录npm,也就是
npm login
- 在本地增加一个
.npmignore
文件,写上需要忽略的文件,比如.vscode
等 - 执行
npm publish
即可(每次更新的时候,修改你的package.json
中的version
,再次npm publish
即可)。 - 接着,就可以在npm中找到我们的包了,比如
ywill-cli
最后
到此为止,整个最基础的脚手架就搭建完成了,附上比较核心的main
和create.js
代码:
// main
#! /usr/bin/env node
const program = require('commander');
const chalk = require('chalk');
// 获取当前版本号
const version = require('../package.json').version;
// 获取create模块
const createModel = require('../lib/create')
program
// 配置脚手架名称
.name('ywill-cli')
// 配置命令格式
.usage(`<command> [option]`)
// 配置版本号
.version(version);
// 给提示增加
program.on('--help', () => {
console.log();
console.log(
`Run ${chalk.cyan(
'ywill-cli <command> --help'
)} for detailed usage of given command.
`)
});
program
.command('create <project-name>')
.description('create a new project')
.option('-f, --force', 'overwrite target directory if it exists')
.action((projectName, options) => {
// 引入create模块,并传入参数
createModel(projectName, options);
})
program.parse(process.argv);
复制代码
// create.js
const path = require('path');
const fs = require('fs-extra');
const chalk= require('chalk');
const Inquirer = require('inquirer');
const ora = require('ora');
const util = require('util');
const downloadGitRepo = require('download-git-repo');
const figlet = require('figlet');
const api = require('./api/interface/index');
const cwd = process.cwd();
class Creator {
constructor(projectName, options) {
this.projectName = projectName;
this.options = options;
}
// 创建
async create() {
const isOverwrite = await this.handleDirectory();
if(!isOverwrite) return;
await this.getCollectRepo();
}
// 处理是否有相同目录
async handleDirectory() {
const targetDirectory = path.join(cwd, this.projectName);
// 如果目录中存在了需要创建的目录
if (fs.existsSync(targetDirectory)) {
if (this.options.force) {
await fs.remove(targetDirectory);
} else {
let { isOverwrite } = await new Inquirer.prompt([
{
name: 'isOverwrite',
type: 'list',
message: '是否强制覆盖已存在的同名目录?',
choices: [
{
name: '覆盖',
value: true
},
{
name: '不覆盖',
value: false
}
]
}
]);
if (isOverwrite) {
await fs.remove(targetDirectory);
} else {
console.log(chalk.red.bold('不覆盖文件夹,创建终止'));
return false;
}
}
};
return true;
}
// 获取可拉取的仓库列表
async getCollectRepo() {
const loading = ora('正在获取模版信息...');
loading.start();
const {data: list} = await api.getRepoList({per_page: 100});
loading.succeed();
const collectTemplateNameList = list.filter(item => item.topics.includes('template')).map(item => item.name);
let { choiceTemplateName } = await new Inquirer.prompt([
{
name: 'choiceTemplateName',
type: 'list',
message: '请选择模版',
choices: collectTemplateNameList
}
]);
this.downloadTemplate(choiceTemplateName);
}
// 下载仓库
async downloadTemplate(choiceTemplateName) {
this.downloadGitRepo = util.promisify(downloadGitRepo);
const templateUrl = `shenyWill/${choiceTemplateName}`;
const loading = ora('正在拉取模版...');
loading.start();
await this.downloadGitRepo(templateUrl, path.join(cwd, this.projectName));
loading.succeed();
this.showTemplateHelp();
}
// 模版使用提示
showTemplateHelp() {
console.log(`\r\nSuccessfully created project ${chalk.cyan(this.projectName)}`);
console.log(`\r\n cd ${chalk.cyan(this.projectName)}\r\n`);
console.log(" npm install");
console.log(" npm run dev\r\n");
console.log(`
\r\n
${chalk.green.bold(
figlet.textSync("SUCCESS", {
font: "isometric4",
horizontalLayout: "default",
verticalLayout: "default",
width: 80,
whitespaceBreak: true,
})
)}
`)
}
}
module.exports = async function (projectName, options) {
const creator = new Creator(projectName, options);
await creator.create();
}
复制代码
当然,脚手架的内容远远不止于此,大家一起努力,加油加油!!!