經過學習慕課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
實現原理: