在开发脚手架之前,我们先了解下脚手架开发的流程图。
脚手架架构图
脚手架拆包策略
- 核心流程:core
- 命令:commands
- 初始化
- 发布
- 清除缓存
- 模型层:models
- Command命令
- Project项目
- Component组件
- Npm模块
- Git仓库
- 支持模块:utils
- Git操作
- 云构建
- 工具方法
- API请求
- Git API
命令执行流程
- 准备阶段
命令注册
命令执行
准备阶段
- 检查版本号
// 检查版本
function checkPkgVersion() {
log.info('cli', pkg.version);
}
- 检查node版本
// 检查node版本
checkNodeVersion() {
//第一步,获取当前Node版本号
const currentVersion = process.version;
const lastVersion = LOWEST_NODE_VERSION;
//第二步,对比最低版本号
if (!semver.gte(currentVersion, lastVersion)) {
throw new Error(colors.red(`code-robot-cli 需要安装v${lastVersion}以上版本的Node.js`));
}
}
- 检查root权限
// 检查root启动
function checkRoot() {
//使用后,检查到root账户启动,会进行降级为用户账户
const rootCheck = require('root-check');
rootCheck();
}
- 检查用户主目录
// 检查用户主目录
function checkUserHome() {
if (!userHome || !pathExists(userHome)) {
throw new Error(colors.red('当前登录用户主目录不存在!!!'));
}
}
- 检查入参
// 检查入参
function checkInputArgs() {
const minimist = require('minimist');
args = minimist(process.argv.slice(2));
checkArgs();
}
function checkArgs() {
if (args.debug) {
process.env.LOG_LEVEL = 'verbose';
} else {
process.env.LOG_LEVEL = 'info';
}
log.level = process.env.LOG_LEVEL;
}
- 检查环境变量
// 检查环境变量
function checkEnv() {
const dotenv = require('dotenv');
const dotenvPath = path.resolve(userHome, '.env');
if (pathExists(dotenvPath)) {
config = dotenv.config({
path: dotenvPath
});
}
createDefaultConfig();
log.verbose('环境变量', process.env.CLI_HOME_PATH);
}
function createDefaultConfig() {
const cliConfig = {
home: userHome
}
if (process.env.CLI_HOME) {
cliConfig['cliHome'] = path.join(userHome, process.env.CLI_HOME);
} else {
cliConfig['cliHome'] = path.join(userHome, constants.DEFAULT_CLI_HOME);
}
process.env.CLI_HOME_PATH = cliConfig.cliHome;
}
- 检查是否是最新版本
// 检查是否是最新版本,是否需要更新
async function checkGlobalUpdate() {
//1.获取当前版本号和模块名
const currentVersion = pkg.version;
const npmName = pkg.name;
//2.调用npm API,获取所有版本号
const { getNpmSemverVersion } = require('@code-robot-cli/get-cli-info');
//3.提取所有版本号,比对哪些版本号是大于当前版本号
const lastVersion = await getNpmSemverVersion(currentVersion, npmName);
if (lastVersion && semver.gt(lastVersion, currentVersion)) {
//4.获取最新的版本号,提示用户更新到该版本
log.warn(colors.yellow(`请手动更新${npmName},当前版本:${currentVersion},最新版本:${lastVersion}
更新命令:npm install -g ${npmName}`))
}
}
命令注册
注册init阶段
//命名的注册
function registerCommand() {
program
.name(Object.keys(pkg.bin)[0])
.usage('<command> [options]')
.version(pkg.version)
.option('-d, --debug', '是否开启调试模式', false)
.option('-tp, --targetPath <targetPath>', '是否指定本地调试文件路径', '');
program
.command('init [projectName]')
.option('-f, --force', '是否强制初始化项目')
.action(init); //init 单独解析一个命令 exec动态加载模块
//开启debug模式
program.on('option:debug', function () {
if (program.debug) {
process.env.LOG_LEVEL = 'verbose';
} else {
process.env.LOG_LEVEL = 'info';
}
log.level = process.env.LOG_LEVEL;
log.verbose('test');
});
//指定targetPath
program.on('option:targetPath', function () {
process.env.CLI_TARGET_PATH = program.targetPath;
});
//对未知命令的监听
program.on('command:*', function (obj) {
const availabelCommands = program.commands.map(cmd => cmd.name());
log.verbose(colors.red('未知命令:' + obj[0]));
if (availabelCommands.length > 0) {
log.verbose(colors.blue('可用命令:' + availabelCommands.join(',')));
}
})
program.parse(process.argv);
//用户没有输入命令的时候
if (program.args && program.args.length < 1) {
program.outputHelp();
console.log();
}
}
当前架构图
通过准备阶段和命令初始化init阶段,我们创建了如下一些package:
这样的架构设计已经可以满足一般脚手架需求,但是有以下两个问题:
1.cli安装速度慢:所有的package都集成在cli里,因此当命令较多时,会减慢cli的安装速度
2.灵活性差:init命令只能使用@code-robot-cli/init包,对于集团公司而言,每个团队init命令可能都各不相同,可能需要实现init命令动态化,如:
- 团队A使用@code-robot-cli/init作为初始化模板
- 团队B使用自己开发的@code-robot-cli/my-init作为初始化模板
- 团队C使用自己开发的@code-robot-cli/your-init作为初始化模板
这时对我们的架构设计就提出了挑战,要求我们能够动态加载init模块,这将增加架构的复杂度,但大大提升脚手架的可扩展性,将脚手架框架和业务逻辑解耦
脚手架架构优化
命令执行阶段
const SETTINGS = {
init: "@code-robot-cli/init",
}
const CACHE_DIR = 'dependencies/';
async function exec() {
let targetPath = process.env.CLI_TARGET_PATH;
const homePath = process.env.CLI_HOME_PATH;
let storeDir = '';
let pkg;
log.verbose('targetPath', targetPath);
log.verbose('homePath', homePath);
const cmdObj = arguments[arguments.length - 1];
const cmdName = cmdObj.name();
const packageName = SETTINGS[cmdName];
const packageVersion = 'latest';
if (!targetPath) {//是否执行本地代码
//生成缓存路径
targetPath = path.resolve(homePath, CACHE_DIR);
storeDir = path.resolve(targetPath, 'node_modules');
log.verbose(targetPath, storeDir);
//初始化Package对象
pkg = new Package({
targetPath,
storeDir,
packageName,
packageVersion
});
//判断Package是否存在
if (await pkg.exists()) {
//更新package
await pkg.update()
} else {
//安装package
await pkg.install();
}
} else {
pkg = new Package({
targetPath,
packageName,
packageVersion
});
}
//获取入口文件
const rootFile = pkg.getRootFile();
if (rootFile) {//判断入口文件是否存在
try {
//在当前进程中调用
// require(rootFile).call(null, Array.from(arguments));
//在node子进程中调用
const args = Array.from(arguments);
const cmd = args[args.length - 1];
const o = Object.create(null);
Object.keys(cmd).forEach(key=>{
if (cmd.hasOwnProperty(key) && !key.startsWith('_') && key !== 'parent') {
o[key] = cmd[key];
}
})
args[args.length - 1] = o;
const code = `require('${rootFile}').call(null, ${JSON.stringify(args)})`;
const child = spawn('node',['-e',code],{
cwd:process.cwd(),
stdio:'inherit'
});
//执行产生异常
child.on('error',e=>{
log.error(e.message);
process.exit(1);
});
//执行完毕 正常退出
child.on('exit',e=>{
log.verbose('命令执行成功:'+e);
process.exit(e);
})
} catch (e) {
log.error(e.message);
}
}
//1.targetPath -> modulePath
//2.modulePath -> Package(npm模块)
//3.Package.getRootFile(获取入口文件)
//4.Package.update/Package.install
}
脚手架项目创建功能设计
首先我们要思考下脚手架项目创建为了什么:
- 可扩展性:能够快速复用到不同团队,适应不同团队之间的差异
- 低成本:在不改动脚手架源码的情况下,能够新增模板,且新增模板的成本很低
- 高性能:控制存储空间,安装时充分利用Node多进程提升安装性能
创建项目功能架构设计图
整体过程分为三个阶段:
- 准备阶段
- 下载模块
- 安装模块
准备阶段
准备阶段的核心工作就是:
- 确保项目的安装环境
- 确认项目的基本信息
下载模块
下载模块是利用已经封装Package类快速实现相关功能
安装模块
安装模块分为标准模式和自定义模式:
- 标准模式下,将通过ejs实现模块渲染,并自动安装依赖并启动项目
- 自定义模式下,将允许用户主动去实现模块的安装过程和后续启动过程
核心代码如下:
class InitCommand extends Command {
init() {
this.projectName = this._argv[0] || '';
this.force = this._cmd.force;
log.verbose(this._argv);
log.verbose('projectName', this.projectName);
log.verbose('force', this.force);
}
async exec() {
try {
//1.准备阶段
const projectInfo = await this.prepare();
if (projectInfo) {
//2.下载模板
log.verbose('projectInfo', projectInfo);
this.projectInfo = projectInfo
await this.downloadTemplate();
//3.安装模板
await this.installTemplate();
}
} catch (e) {
log.error(e.message);
if (process.env.LOG_LEVEL === 'verbose') {
console.log(e);
}
}
}
async installTemplate() {
log.verbose('templateInfo', this.templateInfo);
if (this.templateInfo) {
if (!this.templateInfo.type) {
this.templateInfo.type = TEMPLATE_TYPE_NORMAL
}
if (this.templateInfo.type === TEMPLATE_TYPE_NORMAL) {
//标准安装
await this.installNormalTemplate();
} else if (this.templateInfo.type === TEMPLATE_TYPE_CUSTOM) {
//自定义安装
await this.installCustomTemplate();
} else {
throw new Error('无法失败项目模板类');
}
} else {
throw new Error('项目模板信息不存在');
}
}
checkCommand(cmd) {
if (WHITE_COMMAND.includes(cmd)) {
return cmd;
}
return null;
}
async execCommand(command, errMsg) {
let ret;
if (command) {
const cmdArray = command.split(' ');
const cmd = this.checkCommand(cmdArray[0]);
if (!cmd) {
throw new Error('命令不存在!命令:' + command);
}
const args = cmdArray.slice(1);
ret = await execAsync(cmd, args, {
stdio: 'inherit',
cwd: process.cwd(),
})
}
if (ret !== 0) {
throw new Error(errMsg)
}
}
async ejsRender(options) {
const dir = process.cwd();
const projectInfo = this.projectInfo;
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, projectInfo, {}, (err, result) => {
console.log(result);
if (err) {
reject1(err);
} else {
fse.writeFileSync(filePath, result);
resolve1(result);
}
})
});
})).then(() => {
resolve();
}).catch(err => {
reject(err);
});
})
})
}
async installNormalTemplate() {
//拷贝模板代码直当前目录
let spinner = spinnerStart('正在安装模板');
log.verbose('templateNpm', this.templateNpm)
try {
const templatePath = path.resolve(this.templateNpm.cachFilePath, 'template');
const targetPath = process.cwd();
fse.ensureDirSync(templatePath);//确保当前文件存不存在,不存在会创建
fse.ensureDirSync(targetPath);
fse.copySync(templatePath, targetPath);//把缓存目录下的模板拷贝到当前目录
} catch (e) {
throw e;
} finally {
spinner.stop(true);
log.success('模板安装成功');
}
const templateIgnore = this.templateInfo.ignore || [];
const ignore = ['**/node_modules/**', ...templateIgnore];
await this.ejsRender({ ignore });
//依赖安装
const { installCommand, startCommand } = this.templateInfo
await this.execCommand(installCommand, '依赖安装过程中失败');
//启动命令执行
await this.execCommand(startCommand, '启动执行命令失败');
}
async installCustomTemplate() {
//查询自定义模板的入口文件
if (await this.templateNpm.exists()) {
const rootFile = this.templateNpm.getRootFile();
if (fs.existsSync(rootFile)) {
log.notice('开始执行自定义模板');
const options = {
...this.options,
cwd:process.cwd(),
}
const code = `require('${rootFile}')(${JSON.stringify(options)})`;
log.verbose('code',code);
await execAsync('node',['-e', code], { stdio: 'inherit', cwd: process.cwd()});
log.success('自定义模板安装成功');
} else {
throw new Error('自定义模板入口文件不存在');
}
}
}
async downloadTemplate() {
//1. 通过项目模板API获取项目模板信息
//1.1 通过egg.js搭建一套后端系统
//1.2 通过npm存储项目模板
//1.3 将项目模板信息存储到mongodb数据库中
//1.4 通过egg.js获取mongodb中的数据并且通过API返回
const { projectTemplate } = this.projectInfo;
const templateInfo = this.template.find(item => item.npmName === projectTemplate);
const targetPath = path.resolve(userHome, '.code-robot-cli', 'template');
const storeDir = path.resolve(userHome, '.code-robot-cli', 'template', 'node_modules');
const { npmName, version } = templateInfo;
this.templateInfo = templateInfo;
const templateNpm = new Package({
targetPath,
storeDir,
packageName: npmName,
packageVersion: version
})
if (! await templateNpm.exists()) {
const spinner = spinnerStart('正在下载模板...');
await sleep();
try {
await templateNpm.install();
} catch (e) {
throw e;
} finally {
spinner.stop(true);
if (templateNpm.exists()) {
log.success('下载模板成功');
this.templateNpm = templateNpm;
}
}
} else {
const spinner = spinnerStart('正在更新模板...');
await sleep();
try {
await templateNpm.update();
} catch (e) {
throw e;
} finally {
spinner.stop(true);
if (templateNpm.exists()) {
log.success('更新模板成功');
this.templateNpm = templateNpm;
}
}
}
}
async prepare() {
// 判断项目模板是否存在
const template = await getProjectTemplate();
if (!template || template.length === 0) {
throw new Error('项目模板不存在');
}
this.template = template;
//1.判断当前目录是否为空
const localPath = process.cwd();
if (!this.isDirEmpty(localPath)) {
let ifContinue = false;
if (!this.force) {
//询问是否继续创建
ifContinue = (await inquirer.prompt({
type: 'confirm',
name: 'ifContinue',
default: false,
message: '当前文件夹不为空,是否继续创建项目?'
})).ifContinue;
if (!ifContinue) {
return;
}
}
//2.是否启动强制更新
if (ifContinue || this.force) {
//给用户二次确认
const { confirmDelete } = await inquirer.prompt({
type: 'confirm',
name: 'confirmDelete',
default: false,
message: '是否确认清空当前目录下的文件?',
})
if (confirmDelete) {
//清空当前目录
fse.emptyDirSync(localPath)
}
}
}
return this.getProjectInfo();
//3.选择创建项目或组件
//4.获取项目得基本信息
}
async getProjectInfo() {
function isValidName(v) {
return /^[a-zA-Z]+([-][a-zA-Z][a-zA-Z0-9]*|[_][a-zA-Z][a-zA-Z0-9]*|[a-zA-Z0-9])*$/.test(v);
}
let projectInfo = {};
let isProjectInfoValid = false;
if (isValidName(this.projectName)) {
isProjectInfoValid = true;
projectInfo.projectName = this.projectName;
}
//1.选择创建项目或组件
const { type } = await inquirer.prompt({
type: 'list',
name: 'type',
message: '请选择初始化类型',
default: TYPE_PROJECT,
choices: [{
name: '项目',
value: TYPE_PROJECT
}, {
name: '组件',
value: TYPE_COMPONENT
}]
});
log.verbose('type', type);
this.template = this.template.filter(template => {
return template.tag.includes(type);
})
const title = type === TYPE_PROJECT ? '项目' : '组件';
//2.获取项目的基本信息
const projectNamePrompt = {
type: 'input',
name: 'projectName',
message: `请输入${title}的名称`,
default: '',
validate: function (v) {
const done = this.async();
setTimeout(function () {
//1.输入的首字符必须为英文字符
//2.尾字符必须为英文或数字,不能为字符
//3.字符仅运行"-_"
//\w = a-zA-Z0-9 *表示0个或多个
if (!isValidName(v)) {
done(`请输入合法的${title}名称`);
return;
}
done(null, true);
}, 0);
},
filter: function (v) {
return v;
}
}
let projectPrompt = [];
if (!isProjectInfoValid) {
projectPrompt.push(projectNamePrompt);
}
projectPrompt.push({
input: 'input',
name: 'projectVersion',
message: `请输入${title}版本号`,
default: '1.0.0',
validate: function (v) {
const done = this.async();
setTimeout(function () {
//1.输入的首字符必须为英文字符
//2.尾字符必须为英文或数字,不能为字符
//3.字符仅运行"-_"
//\w = a-zA-Z0-9 *表示0个或多个
if (!(!!semver.valid(v))) {
done('请输入合法的版本号');
return;
}
done(null, true);
}, 0);
},
filter: function (v) {
if (!!semver.valid(v)) {
return semver.valid(v);
} else {
return v;
}
}
}, {
type: 'list',
name: 'projectTemplate',
message: `请选择${title}模板`,
choices: this.createTemplateChoices()
});
if (type === TYPE_PROJECT) {
const project = await inquirer.prompt(projectPrompt);
projectInfo = {
...projectInfo,
type,
...project
}
} else if (type === TYPE_COMPONENT) {
const descriptionPrompt = {
input: 'input',
name: 'componentDescription',
message: '请输入组件描述信息',
default: '',
validate: function (v) {
const done = this.async();
setTimeout(function () {
//1.输入的首字符必须为英文字符
//2.尾字符必须为英文或数字,不能为字符
//3.字符仅运行"-_"
//\w = a-zA-Z0-9 *表示0个或多个
if (!v) {
done('请输入组件描述信息');
return;
}
done(null, true);
}, 0);
}
}
projectPrompt.push(descriptionPrompt);
const component = await inquirer.prompt(projectPrompt);
projectInfo = {
...projectInfo,
type,
...component
}
}
//return 项目的基本信息(object)
if (projectInfo.projectName) {
projectInfo.className = require('kebab-case')(projectInfo.projectName).replace(/^-/, '');
}
if (projectInfo.projectVersion) {
projectInfo.version = projectInfo.projectVersion;
}
if (projectInfo.componentDescription) {
projectInfo.description = projectInfo.componentDescription;
}
return projectInfo;
}
isDirEmpty(localPath) {
let fileList = fs.readdirSync(localPath);
//文件过滤的逻辑
fileList = fileList.filter(file => (
!file.startsWith('.') && ['node_modules'].indexOf(file) < 0
));
return !fileList || fileList.length <= 0;
}
createTemplateChoices() {
return this.template.map(item => ({
value: item.npmName,
name: item.name
}))
}
}
function init(argv) {
// console.log('init',projectName,cmdObj.force,process.env.CLI_TARGET_PATH);
return new InitCommand(argv);
}
module.exports = init;
module.exports.InitCommand = InitCommand;
如何通过Yargs来开发脚手架?
- 脚手架分为三部分构成(vue create vuex)
- bin:主命令在package.json中配置bin属性,npm link本地安装
- command:命令
- options:参数(boolean/string/number)
- 文件顶部增加
#!/usr/bin/env node
,这行命令的用途时告诉操作系统要在环境变量当中查询到node命令,通过node命令来执行文件
- 脚手架初始化流程
- 构造函数:Yargs() (通过Yargs构造函数的调用去生成一个脚手架)
- 常用方法:
- Yargs.options (注册脚手架的属性)
- Yargs.option
- Yargs.group (将脚手架属性进行分组)
- Yargs.demandCommand (规定最少传几个command)
- Yargs.recommendCommands (在输入错误command以后可以给你推荐最接近的正确的command)
- Yargs.strict (开启以后可以报错提示)
- Yargs.fail (监听脚手架的异常)
- Yargs.alias (起别名)
- Yargs.wrapper (命令行工具的宽度)
- Yargs.epilogus (命令行工具底部的提示)
- 脚手架参数解析方法
- hideBin(process.argv)
- Yargs.parse(argv, options)
- 命令注册方法
- Yargs.command(command,describe, builder, handler)
- Yargs.command({command,describe, builder, handler})
Node.js模块路径解析流程
- Node.js项目模块路径解析是通过
require.resolve
方法来实现的 require.resolve
就是通过Module._resolveFileName
方法实现的require.resolve
实现原理:Module._resolveFileName
方法核心流程有3点:- 判断是否为内置模块
- 通过
Module._resolveLookupPaths
方法生成node_modules可能存在的路径 - 通过
Module._findPath
查询模块的真实路径
Module._findPath
核心流程有4点:- 查询缓存(将request和paths通过
\x00
(空格)合并成cacheKey) - 遍历paths,将path与request组成文件路径basePath
- 如果basePath存在则调用
fs.realPathSync
获取文件真实路径 - 将文件真实路径缓存到
Module._pathCache
(key就是前面生成的cacheKey)
- 查询缓存(将request和paths通过
fs.realPathSync
核心流程有3点:- 查询缓存(缓存的key为p,即
Module._findPath
中生成的文件路径) - 从左往右遍历路径字符串,查询到
/
时,拆分路径,判断该路径是否为软连接,如果是软连接则查询真实链接,并生成新路径p,然后继续往后遍历,这里有1个细节需要注意:- 遍历过程中生成的子路径base会缓存在knownHard和cache中,避免重复查询
- 遍历完成得到模块对应的真实路径,此时会将原路径original作为key,真实路径作为value,保存到缓存中
- 查询缓存(缓存的key为p,即
require.resolve.paths
等价于Module._resolveLoopupPaths
,该方法用于获取所有的node_modules可能存在的路径require.resolve.paths
实现原理: