一个脚手架工具可以自动化的帮你完成一些项目基本结构的搭建,从而减轻新项目的初始成本,类似 vue-cli
、create-react-app
等,在我们的工作过程中总会碰到新项目需要沿用老项目的架构、UI风格等类似的情况,这个时候或许我们就需要自己搞一套属于我们自己的脚手架工具了,接下来便简单解析一个脚手架从0到1的过程。文章最后会附上本文涉及代码仓库地址及 npm
地址。
这里由于前端 Node
环境都是现成的,又碰巧的 Node
可以做 IO
操作,所以我们的技术方案便选用 Node
来实现,当然还有很多其他技术也都可以选用,类似 Python
等。
package | version |
---|---|
Node.js | >=v12.x |
npm | latest |
这里其实有好多类库存在更新的版本,但由于历史问题(我这个脚手架创建时间较早,仓库还不能完美支持新语法,也懒得搞)这里还是选择了较老的版本,同学们学习的时候可以使用新版本来搞。
package | version | description |
---|---|---|
commander | ^9.2.0 | 用来处理命令行携带的参数 |
chalk | ^4.1.2 | 用于在命令行终端中输出彩色的文字 |
download-git-repo | ^3.0.2 | 用于从 git 仓库拉取 |
fs-extra | ^10.1.0 | 用于从本地路径复制文件至目标路径 |
handlebars | ^4.7.7 | 模板引擎,作用不必多少,大家应该都有所了解 |
inquirer | ^8.2.3 | 用于用户与命令行交互 |
log-symbols | ^4.1.0 | 提供带颜色的符号 |
ora | ^5.4.1 | 实现命令行环境的loading 效果, 和显示各种状态的图标等 |
update-notifier | ^5.1.0 | 更新通知程序,工具包有更新的话提示 |
开始编码之前先整理一下我们的思路,首先我们想要的功能是从一个已有的模板项目目录拷贝派生出新的项目仓库,那么我们首先需要有一个模板配置的模块
创建 /command/add.js
并写入新增模板的逻辑,此处我们将模板配置保存至本地的一个 templates.json
配置文件中
'use strict'
const inquirer = require("inquirer");
const config = require('../templates');
const {checkTemplateNameExit, templateSave, templateList, formatterAnswers} = require("../utils/templateUtils");
const {InvalidArgumentError} = require("commander");
const {listShowTemplates} = require("./list");
const log = require("../utils/log");
const templateObject = {
name: '', // 配置项名称
type: 'git', // 模板工程来源
path: '', // 模板地址
}
/**
* 新增模板
* @param template {{path: string, name: string, type: 'git'|'local'}}
* @param options {{path: string, name: string, type: 'git'|'local'}}
*/
const addTemplate = (template = templateObject, options = templateObject) => {
const checkedObject = {
name: options.name || template.name,
type: options.type || template.type,
path: options.path || template.path,
branch: 'master',
default: true,
};
checkTemplateNameExit(checkedObject.name, 'check', true)
const buildQuestion = (value, key) => {
return `${value && 'Make sure' || 'Input'} the ${key} for the template`
}
const questionList = [
{
name: 'name',
default: checkedObject.name,
type: 'input',
message: buildQuestion(checkedObject.name, 'name'),
validate: (input) => checkTemplateNameExit(input, 'inquirer', true),
},
{
type: 'list',
choices: ['git', 'local'],
name: 'type',
default: checkedObject.type,
message: buildQuestion(checkedObject.type, 'type'),
},
{
type: 'editor',
name: 'path',
default: checkedObject.path,
message: buildQuestion(checkedObject.path, 'path'),
validate: (input) => {
if (!input.trim()) throw new InvalidArgumentError('Template path is required!');
return true;
}
},
{
name: 'branch',
default: 'master',
message: buildQuestion(checkedObject.branch, 'branch'),
validate: (input) => {
if (!input.trim()) throw new InvalidArgumentError('Template branch is required!');
return true;
},
when: (answer) => answer.type === 'git'
},
{
type: 'confirm',
name: 'default',
default: true,
message: 'Do you want to set it as the default option'
}
];
inquirer
.prompt(questionList)
.then(answers => {
formatterAnswers(answers);
if (answers.default) {
templateList().forEach(item => item.default = false);
}
config.tpl[answers.name] = {...answers, path: answers.path.replace(/[\u0000-\u0019]/g, '')};
templateSave(config).then(() => {
log.log('\n');
log.success('New template added!\n');
log.info('The last template list is: \n')
listShowTemplates();
log.log('\n')
process.exit()
}).catch(() => {
process.exit()
})
})
.catch((error) => {
if (error.isTtyError) {
log.error(`Prompt couldn't be rendered in the current environment`)
} else {
log.error(error)
}
})
}
module.exports = {
addTemplate,
}
新增 /command/list.js
文件,读取并输出模板配置列表
'use strict'
const {showLogo, templateList} = require("../utils/templateUtils");
const log = require("../utils/log");
const listShowTemplates = (withLogo) => {
withLogo && showLogo();
log.table(templateList())
process.exit()
}
module.exports = {
listShowTemplates,
}
新增 /command/delete.js
文件,并写入移除模板的逻辑
'use strict'
const config = require('../templates')
const inquirer = require("inquirer");
const {checkTemplateNameExit, templateSave, templateNameList, formatterAnswers} = require("../utils/templateUtils");
const {listShowTemplates} = require("./list");
const log = require("../utils/log");
const removeTemplate = (name = '') => {
inquirer
.prompt([
{
type: 'input',
name: 'name',
default: name,
message: `${name ? 'Is' : 'Input'} the template name`,
validate: (input) => checkTemplateNameExit(input, 'inquirer', false, 'exit')
},
{
type: 'confirm',
name: 'confirm',
message: 'Confirm to remove this template',
default: true
},
])
.then((answers) => {
if (answers.confirm) {
formatterAnswers(answers);
if (templateNameList().length > 1) {
delete config.tpl[answers.name];
templateSave(config).then(() => {
log.log('\n');
log.success('Template removed!\n')
log.info('The last template list is: \n')
listShowTemplates();
log.log('\n');
process.exit();
}).catch(() => {
process.exit();
})
} else {
log.error('At least one template needs to be retained!\n');
process.exit();
}
} else {
log.warning('The operation was cancelled!\n')
process.exit()
}
})
.catch((error) => {
if (error.isTtyError) {
log.error(`Prompt couldn't be rendered in the current environment`)
} else {
log.error(error)
}
})
}
module.exports = {
removeTemplate,
}
关于模板的功能我们已经添加过了, 接下来便开始实现我们的核心功能,通过接收用户输入的项目配置参数来通过模板工程创建我们想要的新项目。
添加 /command/init.js
文件,并写入以下内容
'use strict'
const config = require('../templates')
const inquirer = require("inquirer");
const os = require("os");
const fsExtra = require('fs-extra');
const {InvalidArgumentError} = require("commander");
const download = require("download-git-repo");
const handlebars = require("handlebars");
const ora = require("ora");
const {getTemplateDefault, templateNameList, showLogo, formatterAnswers} = require("../utils/templateUtils");
const log = require("../utils/log");
const createSuccess = (spinner, projectConfig) => {
spinner.succeed();
// 模板文件列表
const fileName = [
`${projectConfig.name}/package.json`,
`${projectConfig.name}/index.html`
];
const removeFiles = [
`${projectConfig.name}/.git`
]
const meta = {
name: projectConfig.name,
version: projectConfig.version,
description: projectConfig.description,
author: projectConfig.author
}
fileName.forEach(item => {
if (fsExtra.pathExistsSync(item)) {
const content = fsExtra.readFileSync(item).toString();
const result = handlebars.compile(content)(meta);
fsExtra.outputFileSync(item, result);
}
});
// 删除多余文件
removeFiles.forEach(item => {
fsExtra.removeSync(item);
})
showLogo();
log.success(`Successfully created project ${projectConfig.name}.`)
log.success('Get started with the following commands:\n')
log.bash(`cd ${projectConfig.name} && npm install`)
}
const createProjectFromGit = (projectConfig) => {
const spinner = ora('Download from template...');
spinner.start();
download(`direct:${projectConfig.template.path}#${projectConfig.template.branch}`, projectConfig.name, {clone: true}, function (err) {
if (err) {
spinner.fail();
log.error(err)
} else {
createSuccess(spinner, projectConfig)
}
process.exit();
})
}
const createProjectFromLocal = (projectConfig) => {
const spinner = ora('Creating from template...');
spinner.start();
fsExtra.copy(projectConfig.template.path.trim(), `./${projectConfig.name}`)
.then(() => {
createSuccess(spinner, projectConfig)
process.exit();
})
.catch(err => {
spinner.fail()
log.error(err)
process.exit();
})
}
const initProjectFromTemplate = (projectName = 'my-app', templateName = '') => {
inquirer.prompt([
{
type: 'input',
name: 'projectName',
default: projectName,
message: 'Is sure the project name',
validate: (input) => {
if (!input) throw new InvalidArgumentError('project name is required!');
if (fsExtra.pathExistsSync(input)) throw new InvalidArgumentError('directory already exists!');
return true
}
},
{
name: 'version',
default: '1.0.0',
message: 'input the project version'
},
{
name: 'description',
default: projectName,
message: 'input the project description'
},
{
name: 'author',
default: os.userInfo().username,
message: 'input the project author'
},
{
type: 'confirm',
name: 'defaultTemplate',
message: 'use the default template',
when: !templateName
},
{
type: 'list',
name: 'templateName',
default: templateNameList()[0],
choices: templateNameList(),
when: (answers) => {
if (!templateName) return !answers.defaultTemplate;
else return !templateNameList().some(item => item === templateName);
},
message: 'choose the project template',
},
]).then(answers => {
formatterAnswers(answers);
const {projectName, version, description, author} = answers;
let template = answers.templateName || templateName;
if (answers.defaultTemplate) template = getTemplateDefault().name;
const projectConfig = {name: projectName, version, description, author, template: config.tpl[template]}
switch (projectConfig.template.type) {
case 'git':
createProjectFromGit(projectConfig);
break;
case 'local':
createProjectFromLocal(projectConfig);
break;
default:
log.warning('Type not supported yet!');
process.exit();
}
}).catch(err => {
log.error(err)
process.exit()
})
}
module.exports = {
initProjectFromTemplate
}
最后再配置下我们的入口文件,新增 /bin/sim.js
并注册我们的命令项
#!/usr/bin/env node --harmony
'use strict'
const { program } = require('commander');
const pkg = require('../package.json')
const updateNotifier = require('update-notifier')
const {addTemplate, removeTemplate, listShowTemplates, initProjectFromTemplate} = require("../command");
const {checkTemplateType, showLogo} = require("../utils/templateUtils");
updateNotifier({ pkg }).notify({ isGlobal: true })
program
.version(pkg.version, '-v, --version')
.option('-a, --add [name...]', 'Create a new project template')
.option('-r, --remove [name...]', 'Remove a template')
.option('-l, --list', 'List all of project templates')
.option('-i, --init [name...]', 'Build a new project from template')
.usage(' [options]' )
.description('Build a new project based on the project template')
.action((options) => {
if (options.add) addTemplate({name: options.add[0], type: options.add[1], path: options.add[2]});
if (options.remove) removeTemplate(options.remove[0]);
if (options.list) listShowTemplates(true);
if (options.init) initProjectFromTemplate(options.init[0], options.init[1]);
})
program
.command('add')
.argument('[name]', 'the template name', '')
.argument('[type]', 'the template type of git or local', checkTemplateType, '')
.argument('[path]', 'the template path for gitUrl or localPath', '')
.option('-n, --name ' , 'the template name', '')
.option('-t, --type ' , 'the template type of git or local', checkTemplateType, '')
.option('-p, --path ' , 'the template path for git or local', '')
.description('Create a new project template')
.action((name, type, path, options) => {addTemplate({name, type, path}, options)})
program
.command('remove')
.argument('[name]', 'the template name', '')
.option('-n, --name ' , 'the template name', '')
.description('Remove a template')
.action((name, options) => {removeTemplate(name || options.name)})
program
.command('list')
.description('List all of project templates')
.action(() => listShowTemplates(true))
program
.command('init')
.description('Build a new project from a template')
.argument('[projectName]', 'the project name', '')
.argument('[templateName]', 'the template name', '')
.option('-p, --projectName ' , 'the project name', '')
.option('-t, --templateName ' , 'the template name', '')
.action((projectName, templateName, options) => {
initProjectFromTemplate(options.projectName || projectName, options.templateName || templateName)
})
program.parse(process.argv);
if (!program.args.length && !Object.keys(program.opts()).length) {
showLogo()
program.help()
}
接下来便把我们的工程通过 npm publish
命令发布到 npm
服务器然后就可以分享给小伙伴们使用了
附上项目参考资料,以供小伙伴们参考