经过学习慕课Web前端架构课程的笔记记录。前端
脚手架本质是一个操做系统的客户端,它经过命令行执行,就好比:vue
vue create vue-test-app
复制代码
上面这条命令由3个部分组成:node
脚手架的执行原理以下:react
@vue/cli
后会添加的命令为vue
?首先咱们经过which vue
找到vue
的目录/usr/local/bin/vue
,而后进入到/usr/local/bin
目录下,查看下面全部内容,其中有一条这样的连接vue -> ../lib/node_modules/@vue/cli/bin/vue.js
,vue
软链接到全局安装目录,去执行vue.js
,这种绑定关系是在那肯定的呢?咱们进入到../lib/node_modules/@vue/cli/
,查看package.json
,咱们看到"bin": { "vue": "bin/vue.js" },
,这个bin
当中配置的就是咱们在操做系统中安装完成后软链接的名称以及指向的实际文件git
@vue/cli
时发生了什么?npm install -g @vue/cli
复制代码
首先npm把咱们当前的包下载到node_modules
目录里面,若是是全局安装的node
,它可能存在/uer/lib/
目录下,当把这个包彻底下载完毕以后,它会去解析package.json
里面的bin
,若是说bin
下面有配置,它就会在咱们node
安装目录下的bin
目录里面建立一个软链接.github
vue
命令时发生了什么?为何vue
指向了js
文件,咱们却能够直接经过vue
命令去执行它?第一个问题:执行vue
的时候,咱们操做系统会经过which vue
去找到bin
目录下的文件执行,也就是说去环境变量中找vue
是否被注册,注册了就执行.mongodb
第二个问题:由于在文件上方加入了!/usr/bin/env node
环境变量,这个环境变量可让咱们经过固定的命名去对它进行执行vuex
扩展一下,下面两种写法的区别:vue-cli
#!/usr/bin/env node
#!/usr/bin/node
复制代码
第一种是在环境变量中查找node
数据库
第二种是直接执行/usr/bin/
目录下的node
开发脚手架的核心目标是: 提高前端研发效能
vue create
vue add
vue invoke
复制代码
--version
、--help
-V
、-h
等等...
连接本地脚手架:
cd your-cli-dir
npm link
复制代码
连接本地库文件:
cd your-cli-dir
npm link
cd your-cli-dir
npm link your-lib
复制代码
取消连接本地库文件:
cd your-cli-dir
npm unlink
cd your-cli-dir
# link存在
npm unlink your-lib
# link不存在
rm -rf node_modules
npm install -S your-lib
复制代码
理解npm link
:
npm link your-lib
: 将当前项目中的node_modules
下指定的库文件连接到node
全局node_modules
下的库文件npm link
: 将当前项目连接到node
全局node_modules
中做为一个库文件,并解析bin
配置建立可执行文件理解npm unlink
:
npm unlink
: 将当前项目从node
全局node_modules
中移除npm unlink your-lib
: 将当前项目中的库文件依赖移除分析可痛点,那么就会有解决办法,那就是经过Lerna来管理多Package。
Lerna是一个优化基于git+npm的多package项目的管理工具,使用Lerna管理的大型项目有:babel,vue-cli,craete-react-app等等。
file:your-local-module-path
,在lerna public
的时候自动将该路径替换在开发脚手架以前,咱们先了解下脚手架开发的流程图。
// 检查版本
function checkPkgVersion() {
log.info('cli', pkg.version);
}
复制代码
// 检查node版本
checkNodeVersion() {
//第一步,获取当前Node版本号
const currentVersion = process.version;
const lastVersion = LOWEST_NODE_VERSION;
//第二步,对比最低版本号
if (!semver.gte(currentVersion, lastVersion)) {
throw new Error(colors.red(`roy-cli-dev 须要安装v${lastVersion}以上版本的Node.js`));
}
}
复制代码
// 检查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('@roy-cli-dev/get-npm-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命令只能使用@roy-cli-dev/init包,对于集团公司而言,每一个bu的init命令可能都各不相同,可能须要实现init命令动态化,如:
这时对咱们的架构设计就提出了挑战,要求咱们可以动态加载init模块,这将增长架构的复杂度,但大大提高脚手架的可扩展性,将脚手架框架和业务逻辑解耦
const SETTINGS = {
init: "@roy-cli-dev/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
}
复制代码
首先咱们要思考下脚手架项目建立为了什么:
总体过程分为三个阶段:
准备阶段的核心工做就是:
下载模块是利用已经封装Package类快速实现相关功能
安装模块分为标准模式和自定义模式:
核心代码以下:
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, '.roy-cli-dev', 'template');
const storeDir = path.resolve(userHome, '.roy-cli-dev', '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;
复制代码
至此咱们完成了脚手架开发以及经过脚手架建立项目。
脚手架分为三部分构成(vue create vuex)
#!/usr/bin/env node
,这行命令的用途时告诉操做系统要在环境变量当中查询到node命令,经过node命令来执行文件脚手架初始化流程
脚手架参数解析方法
命令注册方法
require.resolve
方法来实现的require.resolve
就是经过Module._resolveFileName
方法实现的require.resolve
实现原理:
Module._resolveFileName
方法核心流程有3点:
Module._resolveLookupPaths
方法生成node_modules可能存在的路径Module._findPath
查询模块的真实路径Module._findPath
核心流程有4点:
\x00
(空格)合并成cacheKey)fs.realPathSync
获取文件真实路径Module._pathCache
(key就是前面生成的cacheKey)fs.realPathSync
核心流程有3点:
Module._findPath
中生成的文件路径)/
时,拆分路径,判断该路径是否为软链接,若是是软链接则查询真实连接,并生成新路径p,而后继续日后遍历,这里有1个细节须要注意:
require.resolve.paths
等价于Module._resolveLoopupPaths
,该方法用于获取全部的node_modules可能存在的路径require.resolve.paths
实现原理: