spike-cli
v1.0.9
Published
spike-cli
Downloads
7
Readme
从零构建自己的脚手架spike-cli
前言
日常工作中经常需要搭建项目结构。如果将搭建好的项目封装为模板,再次需要搭建时,通过简单的命令即可完成项目初始化。便可快速搭建项目的基本结构并提供项目规范和约定,极大提高团队效率。
设计脚手架
在开发之前,我们需要根据需求设计出自己的脚手架流程。笔者的需求是,要经常搭建Vue
、React项目。所以设计方案核心流程为:
- 输入
spike-cli init [projectName]
命令 - 用户输入项目名称(如输入
projectName
合法则跳过) - 用户输入项目版本号
- 用户选择项目模板
- 下载模板
- 安装模板
- 启动项目
我们根据核心流程,再增加一些前置校验:检查node版本
、检查是否root启动
、检查用户主目录
、检查是否需要更新
。
笔者的习惯是将流程梳理清楚之后,绘制成流程图。之后开发时,按照流程图上的逻辑编写代码可以保持思路清晰。脚手架流程图如下:
脚手架雏形
我们现在需要做的就是输入spike-cli init [projectName]
命令之后,启动我们的脚手架。
1.新建入口文件src/core/cli/bin/index.js
#! /usr/bin/env node
require('../lib')(process.argv.slice(2))
这段代码的作用是使用node来执行src/core/cli/lib/index.js
文件的内容。我们的脚手架代码会放在src/core/cli/lib/index.js
中,后面会详细介绍里面的内容。
2.package.json文件添加bin属性
{
"name": "spike-cli",
"version": "1.0.0",
"bin": {
"spike-cli": "src/core/cli/bin/index.js"
},
...
}
当用户npm install spike-cli -g
时,就会在/usr/local/bin
目录下生成一个spike-cli
的命令 --> /usr/local/lib/node_modules/spike-cli/src/core/cli/bin/index.js
。这样当用户直接使用spike-cli时,就可以执行我们的文件。
3.npm link
我们在开发时,为了方便调试脚手架。可以输入npm link
,也可以将package.json
中bin
属性,链接到/usr/local/bin
目录下。当不需要时,可以通过npm unlink
,取消链接即可。
架手架准备阶段
从这开始,我们编写架手架的具体逻辑。首先我们来做前置校验:检查node版本
、检查是否root启动
、检查用户主目录
、检查是否需要更新
。
1.新建src/core/cli/lib/index.js
async function core() {
try {
await prepare();
} catch (e) {
log.error(e.message);
}
}
// 准备阶段
async function prepare() {
checkRoot();
checkUserHome();
await checkGlobalUpdate();
}
// 检查node版本
function checkNodeVersion() {
const currentVersion = process.version;
if (semver.lt(currentVersion, LOWEST_NODE_VERSION)) {
log.error(colors.red(`spike-cli 需要安装 v${LOWEST_NODE_VERSION} 以上版本的 Node.js`));
process.exit(1);
}
}
// 检查是否是否root账户启动
function checkRoot() {
const rootCheck = require('root-check');
rootCheck();
}
// 检查是否用户主目录
function checkUserHome() {
if (!userHome || !pathExists(userHome)) {
throw new Error(colors.red('当前登录用户主目录不存在!'))
}
}
// 检查是否需要更新版本
async function checkGlobalUpdate() {
const currentVersion = pkg.version;
const npmName = pkg.name;
const lastVersion = await getNpmSemverVersion(currentVersion,npmName);
if(lastVersion) {
log.warn('更新提示', colors.yellow(`请手动更新 ${npmName}, 当前版本:${currentVersion}, 最新版本:${lastVersion}\n更新命令:npm install -g ${npmName}`))
}
}
module.exports = core;
上面的代码的作用就是做一些检查功能。其中和接下来会用很多第三方package,我们先来了解一下它们的作用:
| 名称 | 简介 | | :----------------------------------------------------- | -------------------------------- | | npmlog | 自定义级别和彩色log输出 | | colors | 自定义log输出颜色和样式 | | user-home | 获得用户主目录 | | root-check | root账户启动 | | semver | 项目版本相关操作 | | fs-extra | 系统fs模块的扩展 | | commander | 命令行自定义指令 | | inquire | 命令行询问用户问题,记录回答结果 |
脚手架命令注册
上面的准备工作全部通过之后,我们开始注册命令。这里我们使用commander
来帮助我们注册一个init [projectName]
命令和option:force
、option:debug
两个option
。这样我们就可以使用下面命名进行交互
spike-cli init testProject --debug --force
其中--debug
和--force
可不传。--debug
参数可开启debug
模式。--force
参数可强制初始化项目。
下面我们来看看命令注册代码:
async function core() {
try {
await prepare();
+ registerCommand();
} catch (e) {
log.error(e.message);
}
}
// 注册command命令
function registerCommand() {
program
.name(Object.keys(pkg.bin)[0])
.usage('<command> [options]')
.version(pkg.version)
.option('-d, --debug', '是否开启调试模式', false)
// 注册 init command
program
.command('init [projectName]')
.option('-f --force', '是否强制初始化项目')
.action(init)
// 监听 degub option
program.on('option:debug', function() {
if (program._optionValues.debug) {
process.env.LOG_LEVEL = 'verbose';
}
log.level = process.env.LOG_LEVEL;
log.verbose('开启debug模式')
})
// 监听未知命令
program.on('command:*', function (obj) {
const availableCommands = program.commands.map(cmd => cmd.name());
console.log(colors.red('未知的命令:' + obj[0]));
if (availableCommands.length > 0) {
console.log(colors.green('可用的命令:' + availableCommands.join(',')));
}
if (program.args && program.args.length < 1) {
program.outputHelp();
}
})
program.parse(process.argv);
}
脚手架命令执行
当我们监听到init [projectName]
命令时,我们会执行init函数。下面我们来看看init函数的代码:
function init() {
const argv = Array.from(arguments);
const cmd = argv[argv.length - 1];
const o = Object.create(null);
Object.keys(cmd).forEach(key => {
if(key === '_optionValues') {
o[key] = cmd[key];
}
})
argv[argv.length - 1] = o;
return new InitCommand(argv);
}
init
函数会接受command参数,由于command参数内容过多,只留下有用的optionValues
参数。再执行InitCommand
并传入简化过的参数。
下面我们来看看InitCommand
内部代码:
class InitCommand {
constructor(argv) {
if (!Array.isArray(argv)) {
throw new Error('参数必须为数组!');
}
if(!argv || argv.length < 1) {
throw new Error('参数不能为空!');
}
this._argv = argv;
new Promise((resolve, reject) => {
let chain = Promise.resolve();
chain = chain.then(() => this.initArgs());
chain = chain.then(() => this.exec());
chain.catch(e => log.error(e.message));
})
}
initArgs() {
this._cmd = this._argv[this._argv.length - 1];
this._argv = this._argv.slice(0, this._argv.length - 1);
this.projectName = this._argv[0] || '';
this.force = !!this._cmd._optionValues.force;
}
async exec() {
try {
// 1.交互阶段
const projectInfo = await this.interaction();
this.projectInfo = projectInfo;
if (projectInfo) {
log.verbose('projectInfo', projectInfo);
// 2.下载模板
await this.downloadTemplate();
// 3.安装模板
await this.installTemplate();
}
} catch (e) {
log.error(e.message);
}
}
}
这段代码是命令执行的核心流程。首先进行参数的校验和参数的整合,之后就进入到交互阶段
、下载模板阶段
、安装模板阶段
。
交互阶段
进入到交互阶段,我们首先要判断当前目录是否为空,如果为空是否启动强制更新,再二次确认是否清空当前目录。这里我们需要用到inquire
,让他来帮助我们解决命令行交互的问题。下面我们来看代码:
async interaction() {
// 1.判断当前目录是否为空
const localPath = process.cwd();
if (!this.isDirEmpty(localPath)) {
// 2.是否启动强制更新
let isContinue = false;
if(!this.force) {
isContinue = (await inquirer.prompt({
type: 'confirm',
name: 'isContinue',
message: '当前文件夹不为空,是否继续创建项目?',
default: false
})).isContinue;
if (!isContinue) return;
}
if (isContinue || this.force) {
// 3.二次确认是否清空当前目录
const { confirmDelete } = await inquirer.prompt({
type: 'confirm',
name: 'confirmDelete',
default: false,
message: '是否确认清空当前目录下的文件?'
})
if (confirmDelete) {
fse.emptyDirSync(localPath);
}
}
}
return this.getProjectInfo();
}
如果用户继续选择创建时,我们就要执行getProjectInfo
函数。它会询问用户项目名称、项目版本和选择的模板,并返回项目信息。
我们需要提前将模板上传到npm上,并准备好模板信息。
const template = [
{
name: 'react标准模板',
npmName: 'spike-cli-template-react',
version: '1.0.0',
installCommand: 'npm install',
startCommand: 'npm run start'
},
{
name: 'react+redux模板',
npmName: 'spike-cli-template-react-redux',
version: '1.0.0',
installCommand: 'npm install',
startCommand: 'npm run start'
},
{
name: 'vue3标准模板',
npmName: 'spike-cli-template-vue3',
version: '1.0.0',
installCommand: 'npm install',
startCommand: 'npm run serve'
}
]
module.exports = template;
下来我们来看看具体代码:
async getProjectInfo() {
function isValidName(v) {
return /^[a-zA-Z]+([-][a-zA-Z][a-zA-Z0-0]*|[_][a-zA-Z][a-zA-Z0-0]*|[a-zA-Z0-9]*)$/.test(v);
}
console.log('getProjectInfo');
let projectInfo = {};
const projectPrompts = [];
let isProjectNameValid = false;
if (isValidName(this.projectName)) {
isProjectNameValid = true;
projectInfo.projectName = this.projectName;
}
if (!isProjectNameValid) {
projectPrompts.push({
type: 'input',
name: 'projectName',
message: '请输入项目名称',
default: '',
validate: function (v) {
const done = this.async();
setTimeout(() => {
// 1.首字母必须为英文字母
// 2.尾字母必须为英文或数字,不能为字符
// 3.字符仅允许 "-_"
// 合法:a, a-b, a_b, a-b-c, a_b_c, a-b1-c1, a_b1_c1
// 不合法:1, a_, a-, a_1, a-1
if (!isValidName(v)) {
done('请输入合法的项目名称');
return;
}
done(null, true);
}, 0);
},
filter: function (v) {
return v;
}
})
}
projectPrompts.push({
type: 'input',
name: 'projectVersion',
message: '请输入项目版本号',
default: '1.0.0',
validate: function (v) {
const done = this.async();
setTimeout(() => {
if (!semver.valid(v)) {
done('请输入合法的版本号');
return;
}
done(null, true);
}, 0);
},
filter: function (v) {
if (!!semver.valid(v)) {
return semver.valid(v);
} else {
return v;
}
}
})
projectPrompts.push({
type: 'list',
name: 'projectTemplate',
message: '请选择项目模板',
choices: this.createTemplateChoices()
})
const project = await inquirer.prompt(projectPrompts);
projectInfo = {
...projectInfo,
...project
}
if (projectInfo.projectName) {
projectInfo.name = projectInfo.projectName;
projectInfo.version = projectInfo.projectVersion;
projectInfo.className = require('kebab-case')(projectInfo.projectName);
}
return projectInfo
}
下载模板
和用户进行交互之后,我们就开始下载选择的项目模板。这里借助了npmInstall
,来帮我们下载。下面我们来看看代码:
async downloadTemplate() {
const { projectTemplate } = this.projectInfo;
this.templateInfo = template.find(item => item.npmName === projectTemplate);
const spinner = spinnerStart('正在下载模板...');
await sleep();
try {
await npmInstall({
root: process.cwd(),
registry: getDefaultRegistry(),
pkgs: [{ name: this.templateInfo.npmName, version: this.templateInfo.version }]
})
} catch (e) {
throw new Error(e);
}finally {
spinner.stop(true);
this.templatePath = path.resolve(process.cwd(), 'node_modules', this.templateInfo.npmName, 'template');
if (pathExists(this.templatePath)) {
log.success('下载模板成功!');
}
}
}
这里的模板会被下载到node_modules
下,我们提前拼接好模板路径,为接下来拷贝模板至当前目录做准备。
安装模板
我们首先拷贝模板代码至当前目录。然后使用ejs将packageName和version渲染到package.json中。再执行安装命令和启动命令。
下面我们来看看代码:
async installTemplate() {
let spinner = spinnerStart('正在安装模板...');
await sleep();
try {
// 拷贝模板代码至当前目录
fse.copySync(this.templatePath, process.cwd());
} catch (e) {
throw new Error(e);
}finally {
spinner.stop(true);
log.success('模板安装成功!');
}
const options = {
ignore: ['node_modules/**', 'public/**']
}
await this.ejsRender(options);
const { installCommand, startCommand } = this.templateInfo;
// 安装命令
await this.exexCommand(installCommand, '依赖安装过程中失败!');
// 启动命令
await this.exexCommand(startCommand, '依赖安装过程中失败!')
}
async ejsRender(options) {
const dir = process.cwd();
return new Promise((resolve, reject) => {
glob('**', {
cwd: dir,
ignore: options.ignore || '',
nodir: true
}, (err, files) => {
if (err) {
reject(err);
}
Promise.all(files.map(file => {
const filePath = path.join(dir, file);
return new Promise((resolve1, reject1) => {
ejs.renderFile(filePath, this.projectInfo, {}, (err, result) => {
if (err) {
reject1(err);
} else {
fse.writeFileSync(filePath, result);
resolve1(result);
}
})
})
}))
.then(() => resolve())
.catch(err => reject(err))
})
})
}
这里代码看上去稍多,但是核心逻辑上面已经讲明了。其中只有exexCommand
函数没有细讲。它的作用是执行传入的node
命令,其核心是使用node
内置child_process
模块中的spawn方法。
下面我们来看看具体代码:
async exexCommand(command, errMsg) {
let ret;
if(command) {
const cmdArray = command.split(' ');
const cmd = this.checkCommand(cmdArray[0]);
if(!cmd) {
throw new Error('命令不存在!命令:' + cmd);
}
const args = cmdArray.slice(1);
ret = await execAsync(cmd, args, { stdio: 'inherit', cwd: process.cwd() });
}
if(ret !== 0) {
throw new Error(errMsg)
}
return ret;
}
function execAsync(command, args, options) {
return new Promise((resolve, reject) => {
const p = exec(command, args, options);
p.on('error', e => {
reject(e);
})
p.on('exit', c => {
resolve(c)
})
})
}
function exec(command, args, options) {
const win32 = process.platform === 'win32';
const cmd = win32 ? 'cmd' : command;
const cmdArgs = win32 ? ['/c'].concat(command, args) : args;
return require('child_process').spawn(cmd, cmdArgs, options || {});
}
到这为止,我们的脚手架就开发完了,之后就可以发布到npm
上了。
总结
笔者已经将spike-cli发布到npm上了。有兴趣的读者可以使用下面的命令体验一下:
npm install spike-cli -g
spike-cli init testProject
spike-cli脚手架是笔者根据自己的需求设计的,各位读者也可以根据自己的需求设计相应的环节。
希望本文可以帮助读者搭建自己的脚手架,提高开发效率。