lqsimooc-cli-dev v1.0.0
脚手架开发全流程记录
1. require能加载资源类型
- .js => module.exports/exports
- .json => 内部会通过JSON.parse()返回一个对象
- .node => c++插件(一般用不到)
- any => .js解析
2. 封装@lqsimooc-cli-dev/log工具库
在utils/log中新建npm项目,在cli中引入这个包 pnpm i @lqsimooc-cli-dev/log -r --filter @lqsimooc-cli-dev/core-cli
然后就能使用了
const log = require("@lqsimooc-cli-dev/log");
log()
3. 使用semver进行版本号的比对
4. 使用colors来输出彩色文字
5. 使用root-check来root用户降级
root用户的操作一般都没有权限相关问题,为了规避一些文件权限问题,所以不让使用root用户 使用1.0.0版本支持require引入
6. 使用user-home获取用户家目录
7. 使用path-exists判断路径是否存在
4.0.0版本
const pathExists = require("path-exists").sync;
const userHome = require("user-home");
pathExists(userHome)
8. 使用minimist@1.2.5检查命令参数
function checkInputArgs() {
const minimist = require("minimist");
const args = minimist(process.argv.slice(2));
console.log(args);
}
imooc-cli -wd
{ _: [], w: true, d: true }
imooc-cli --wd
{ _: [], wd: true }
9. 使用dotenv从.env文件中加载环境变量
该文件中的配置会自动挂载到process.env上
function checkEnv() {
const dotenv = require("dotenv");
const dotenvPath = path.resolve(userHome, ".env");
if (pathExists(dotenvPath)) {
config = dotenv.config({
path: dotenvPath
});
}
log.verbose("环境变量", config, process.env.DB_PWD);
}
10. 开发get-npm-info 工具库
用来获取最新的版本号
10-1。 获取npm包信息
https://registry.npmjs.org/@lqsimooc-cli-dev/core-cli
10-2. 使用url-join@4.0.1来做url的拼接
然后用axios访问获取数据,拿到最新版本信息,如果有最新的版本就提示更新
11. 让Node支持ES Module
使用webpack
const path = require('path'); module.exports = { entry: './bin/core.js', output: { path: path.join(__dirname, '/dist'), filename: 'core.js', }, mode: 'development', target: 'node', module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: [ '@babel/preset-env' ], plugins: [ [ '@babel/plugin-transform-runtime', { corejs: 3, regenerator: true, useESModules: true, helpers: true, }, ], ], }, }, }, ], }, };
nodejs原生 后缀名.mjs 里面全都都用es modules nodejs 14以前需要
node --experimental-modules xxx.mjs
12.使用commander@6.2.1来注册命令
12.1 在commands包中创建第一个命令 init
12.2 动态加载init命令
不同团队的init命令可能不同,为了增加灵活性,需要考虑动态加载init命令
12.2.1 全局指定本地调试文件路径
- 定义全局option:
program.option("-tp, --targetPath <targetPath>", "指定本地调试文件路径");
- 监听 targetPath 把值放到环境变量
program.on("option:targetPath", function () { process.env.CLI_TARGET_PATH = program.targetPath; });
- 外部init命令库读取环境变量
function init(projectName, cmdObj) { console.log("init123123", projectName, cmdObj.force, process.env.CLI_TARGET_PATH); }
12.2.2 开发exec包用来执行js文件
// 1.targetPath => modulePath
// 2.modulePath => Package(npm模块)
// 3.package.getRootFile(获取入口文件)
// 4.package.update() package.install()
12.2.3 在models中开发Package类,来统一管理操作package
12.2.3.1 使用pkg-dir来获取package的根路径
getRootFilePath() {
// 1. 获取package.json所在目录 - pkg-dir
const dir = pkgDir(this.targetPath);
if (dir) {
// 2. 读取package.json
const pkgFile = require(path.resolve(dir, "package.json"));
// 3. main/lib
if (pkgFile && pkgFile.main) {
return path.resolve(dir, pkgFile.main);
}
// 4. 路径兼容
}
return null;
}
12.2.3.2 在utils中开发utils包
isObject(o) {
return Object.prototype.toString.call(o) === "[object Object]";
}
12.2.3.3 在utils中开发format-path包
用来兼容各平台路径,
function formatPath(p) {
if (p && typeof p === "string") {
const sep = path.sep;
if (sep === "/") return p;
else { //windows
return p.replace("/\\/g", "/");
}
}
return p;
}
12.2.3.4 使用npminstall 来安装package
12.2.3.5 使用fs-extra 来进行文件相关操作
12.2.4 动态加载init 总结
- 之前init的命令直接执行init方法,现在通过执行一个中间层 exec方法
- exec中通过一个对象映射来找到对应方法的对应packageName
const SETTINGS = { init: "@lqsimooc-cli-dev/init" };
- 如果传入了targetPath
imooc-cli init -tp /Users/ashui/Desktop/架构师/01.脚手架/lqsimooc-cli-dev/commands/init/lib/index.js
那么获取rootFilePath
就会执行对应的文件 - 如果没有传入就会走默认缓存路径
- 相关路径:
targetPath:path.resolve(homePath, CACHE_DIR)
storeDir: path.resolve(targetPath, "node_modules");
- 对应逻辑:
- 如果包存在就更新包
- 如果不存在就安装包
- 执行
rootFilePath
中的文件
- 相关路径:
13 node多进程
将之前的rootFilePath的执行(require执行)
if (rootFilePath) require(rootFilePath).apply(null, arguments);
改为:子进程调用
13.1 child_process 用法
具体查看 testDemo/process/child1.js
- 异步:
- exec
- execFile
- fork
- spawn
- 同步:
- execSync
- execFileSync
- spawnSync
区别:
- exec & execFile: exec主要用来执行shell命令,execFile用来执行shell文件, 通过回调机制返回信息(适合开销较小的任务,一次性把结果返回回调中)
- spawn 没有回调,返回一个子进程,默认通过事件监听处理信息 适合耗时任务(比如:npm install)等 需要不断日志的任务 可以通过options中 stdio: "inherit" //实时在主进程打印
- fork 使用node(开启一个子进程)去执行一个文件,返回一个子进程对象 可以和子进程双向通信
const child1 = cp.fork(path.resolve(__dirname, "fork.js")); //异步 //child1.send("hello fork"); child1.send("hello fork child process", () => { //向子进程发送消息 console.log("向子进程发送消息"); }); child1.on("message", (msg) => { console.log(msg); //断开和子进程的连接 child1.disconnect(); });
13.1.1 给文件添加可执行权限
chmod +x ./test.shell
13.1.2 抹平操作系统通过cp.spawn执行node的差异
// other: cp.spawn("node", ["-e", code],{})
// windows: cp.spawn("cmd",["/c",'node','-e',code],{})
function spawn(command, args, options = {}) {
const isWin32 = process.platform === "win32";
const cmd = isWin32 ? "cmd" : command;
const cmdArgs = isWin32 ? ["/c"].concat(command, args) : args;
return cp.spawn(cmd, cmdArgs, options);
}
13.2 在models中新建command命令类
- 检查node版本 checkNodeVersion
- 格式化入参 initArgs
- 让子类实现 init
- 让子类实现 exec
week5 脚手架创建项目流程设计和开发
week5-1 主要内容
- 脚手架项目创建功能的架构设计
- 通过命令行交互获取项目基本信息
- eggjs+云MongoDB的集成
- 开发前端项目模板
- eggjs获取项目模板Api开发
- 项目模板下载功能开发
- 加餐:自己实现一个命令行交互(放弃)
week5-2 prepare阶段
主要就是通过命令行交互收集项目信息,生成项目配置,为下一步模板的下载和安装做铺垫
week5-2-1 判断当前目录是否为空
node判断目录是否为空
isCwdEmpty() {
const currentpath = process.cwd();
// console.log(currentpath);
// console.log(path.resolve("."));
let files = fs.readdirSync(currentpath);
if (!files) return true;
//过滤出 不以.开头 和 不是node_modules的文件或目录
files = files.filter((file) => !file.startsWith(".") && ["node_modules"].indexOf(file) < 0);
return files.length === 0;
}
week5-2-2 使用inquirer库做命令行交互
import inquirer from "inquirer";
inquirer
.prompt([
{
type: "input", //input, number, confirm, list, rawlist, expand, checkbox, password, editor
name: "yourName",
message: "请输入你的名字",
prefix: "ashuiweb:",
suffix: "哎哟不错",
//输入时的展现形式,不会改变结果
transformer: (name) => `我的名字叫:${name}。`,
//改变输入结果
filter: (name) => (name += "!"),
//validate: (v) => !!v
//提示信息
validate: function (v) {
const done = this.async();
if (!/^\d{1,2}\.\d{1,2}\.\d{1,2}$/.test(v)) {
done("参考格式:1.0.0");
return;
}
done(null, true);
}
},
//when 根据用户之前输入来提问
{
type: "confirm",
message: "你真的叫ashui吗",
name: "confirm",
when: (answers) => answers.yourName === "ashui"
},
{
type: "list",
name: "language",
message: "your language",
choices: [
{ value: 1, name: "php" },
{ value: 2, name: "node" },
{ value: 3, name: "js" }
]
},
//和list就是交互形式不同,基本差不多
{
type: "rawlist",
name: "hobby",
message: "your hobby",
choices: [
{ value: 1, name: "php" },
{ value: 2, name: "node" },
{ value: 3, name: "js" }
]
},
//提供简写的形式 需要用户手动输入key key不能省
{
type: "expand",
name: "color",
message: "your color",
default: "red",
choices: [
{ key: "R", name: "红", value: "red" },
{ key: "G", name: "绿", value: "green" },
{ key: "B", name: "蓝", value: "blue" }
]
},
{
type: "checkbox",
name: "ball",
message: "your ball",
default: 0,
choices: [
{ name: "足球", value: 1 },
{ name: "篮球", value: 2 },
{ name: "铅球", value: 3 }
]
},
{
type: "editor",
name: "message",
message: "your message"
}
])
.then((answers) => {
console.log(answers);
// Use user feedback for... whatever!!
})
.catch((error) => {
if (error.isTtyError) {
// Prompt couldn't be rendered in the current environment
} else {
// Something else went wrong
}
});
week5-3 eggjs项目创建
流程:
- 云mongoDb中插入数据
- eggjs中调用mongo库读取数据
- 控制器中返回数据
week5-3-1 安装 egg
npm init egg --type=simple
week5-3-2 执行npm init packageName
会下载执行 create-packageName
week5-3-3 通过egg.js创建api
week5-3-4 使用@pick-star/cli-mongodb 来快速使用momgodb
week5-3-5 在app目录下新建utils/mongo.js
week5-4 utils中新建request包用来发请求
week5-5 获取项目模板并添加选择项目模板流程
- 获取项目模板
const request = require("@lqsimooc-cli-dev/request");
module.exports = () =>
request({
url: "/project/template"
});
- 在inquire.propmt中插入
{
type: 'list',
name: 'projectTemplateInfo',
message: '请选择项目模板',
choices: this.template.map((it) => ({ value: it.name, name: it.title })),
//通过inquire中options的filter会修改最终返回结果 //最终返回
/**
{
title: 'vue-element-admin模板',
name: 'lqsimooc-cli-dev-tempate-vue-element-admin',
version: '1.0.0'
}
*/
filter: (v) => this.template.find((it) => it.name === v),
},
week5-6 获取模板信息后,下载或更新模板包
使用我们之前创建的package类型来安装或更新包
async downloadTemplate() {
//根据projectTemplateInfo下载
const { projectTemplateInfo } = this.projectInfo;
const { name, version } = projectTemplateInfo;
const targetPath = path.resolve(process.env.HOME_PATH, process.env.CLI_HOME_PATH, 'template');
const storeDir = path.resolve(targetPath, 'node_modules');
const templatePkg = new Package({
targetPath,
storeDir,
packageName: name,
packageVersion: version,
});
if (!(await templatePkg.exists())) {
log.info('模板下载中:', 'loading...');
await templatePkg.install();
} else {
log.info('更新模板中:', 'loading...');
await templatePkg.update();
}
}
week5-7 下载时间有点长?使用cli-spinner给命令行加个loading效果吧
week6 安装模板到项目
week6-1 ejs的用法
- ejs.compile返回渲染器,通过给渲染器传入数据来渲染结果
const compileHtml = ejs.compile(template, options); const compiledHtml = compileHtml(data);
- ejs.render
const renderedTHtml = ejs.render(template, data, options); console.log(renderedTHtml);
- ejs.renderFile
- promise
const renderFile = ejs.renderFile(path.resolve(__dirname, 'temp.html'), data, options); renderFile.then(console.log);
- 回调
ejs.renderFile(path.resolve(__dirname, 'temp.html'), data, options, function (err, data) { if (err) { console.log(err); return; } console.log(data); });
week6-2 ejs的标签含义
- <% '脚本' 标签,用于流程控制,无输出。
- <%= 输出数据到模板(输出是转义 HTML 标签)
- <%_ 删除其前面的空格符
- <%- 输出非转义的数据到模板
- <%# 注释标签,不执行、不输出内容
- <%% 输出字符串 '<%'
- %> 一般结束标签
- -%> 删除紧随其后的换行符
- _%> 将结束标签后面的空格符删除
week6-3 ejs其他功能
- 引入其他模板
<%# include 其他模板 _%> <#- include('header.html', {user}); #>
- 配置自定义分隔符
const renderFile = ejs.renderFile(path.resolve(__dirname, 'temp.html'), data, { delimiter: '?', //自定义分隔符 默认 '#' });
<h1>版权所有:<?= user.copyRight ?> </h1>
- 自定义加载器 使用此功能,您可以在读取模板之前对其进行预处理。
let myFileLoader = function (filePath) { return 'myFileLoader: ' + fs.readFileSync(filePath); }; ejs.fileLoader = myFileLoader;
week6-4 使用glob库来匹配文件路径
const glob = require('glob');
//异步使用
(async function () {
const jsfiles = await glob('**/*.js', { ignore: 'node_modules/**' });
console.log(jsfiles);
})();
//同步使用
const jsfiles = glob.globSync('**/*.js', { ignore: 'node_modules/**' });
console.log(jsfiles);
//读取流
const filesStream = glob.globStream('**/*.js', { ignore: 'node_modules/**' });
const jsFilesArr = [];
filesStream.on('data', (data) => {
//console.log(data);
jsFilesArr.push(data);
});
filesStream.on('end', function () {
console.log(jsFilesArr);
});
week6-5 安装模板
如果模板type是normal的就是普通安装
spinnerStart('模板安装中');
//拷贝模板代码至当前目录
const sourcePath = path.resolve(tempath, 'template');
const targetPath = process.cwd(); //当前目录
try {
fse.copySync(sourcePath, targetPath);
} catch (error) {
throw error;
} finally {
spinnerEnd();
log.success('模板安装成功');
}
week6-6 安装依赖和执行
模板数据中配置installCommand 和 startCommand,将spawn抽离出来用来执行命令
const {
projectTemplateInfo: { installCommand, startCommand },
} = this.projectInfo;
//拿到命令和命令参数
if (installCommand) {
await this.execCommand(installCommand, '依赖安装过程失败');
}
if (startCommand) {
await this.execCommand(startCommand, '启动命令失败');
}
execAsync 抽离的spawn方法
async execCommand(command, errMsg) {
const [cmd, ...args] = command.split(' ');
if (!WHITE_CMDS.includes(cmd)) throw new Error('远程模板存在非法命令');
//执行命令
const res = await execAsync(cmd, args, {
stdio: 'inherit',
cwd: process.cwd(),
});
if (res !== 0) throw new Error('errMsg');
}
week6-7 使用kebab-case库来转化驼峰到classname形式
var kebabCase = require('kebab-case');
console.log(kebabCase('webkitTransformDemo'));
// "webkit-transform-demo
console.log(kebabCase.reverse('-webkit-transform'));
// "WebkitTransform"
week6-8 重点 ejs替换模板内容
- 先用glob获取项目文件
async ejsRender(_options = {}, data = {}) { const dir = process.cwd(); const options = { ignore: ['node_modules/**'], cwd: dir, nodir: true, ..._options }; const files = await require('glob')('**', options); }
再用ejs处理对应的文件,处理好后重新写入
async ejsRender(_options = {}, data = {}) { const dir = process.cwd(); const options = { ignore: ['node_modules/**'], cwd: dir, nodir: true, ..._options }; const files = await require('glob')('**', options); await Promise.all( files.map((file) => { const filePath = path.resolve(dir, file); //后缀名白名单 if (EJS_RENDER_FILE_TYPE.includes(path.extname(filePath).substring(1))) { return ejs.renderFile(filePath, data).then((result) => { //重新写入 return fse.writeFile(filePath, result); }); } }) ); }
week6-9 处理选择模板是组件以及是自定义模板
如果创建的时候选择是组件,那么组件比项目多一个描述信息,需要用户手动去填写。
什么是自定义模板,自定义模板就是我们只管下载,至于安装逻辑交由模板中的index.js去处理
const code = `require("${rootFilePath}")(${JSON.stringify(options)})`; log.verbose('code', code, options); await execAsync('node', ['-e', code], { stdio: 'inherit', cwd: process.cwd() });
直接执行
require("/Users/ashui/.imooc-cli/template/node_modules/_lqsimooc-cli-dev-tempate-custom-vue3@1.0.3@lqsimooc-cli-dev-tempate-custom-vue3/index.js") ({"type":"project","projectName":"asd","version":"1.0.0","projectTemplateInfo":{"title":"vue3自定义模板","name":"lqsimooc-cli-dev-tempate-custom-vue3","version":"1.0.3","type":"custom","tag":["project"],"ejsIgnore":["public/**"],"path":"/Users/ashui/.imooc-cli/template/node_modules/_lqsimooc-cli-dev-tempate-custom-vue3@1.0.3@lqsimooc-cli-dev-tempate-custom-vue3"},"className":"asd","sourcePath":"/Users/ashui/.imooc-cli/template/node_modules/_lqsimooc-cli-dev-tempate-custom-vue3@1.0.3@lqsimooc-cli-dev-tempate-custom-vue3/template","targetPath":"/Users/ashui/Desktop/test/imooc-cli-test"})
week6-10 当前小结
目前为止,我们从0到1开发了一个脚手架,实现的功能如下:
imooc-cli init projectName -tp /Users/ashui/Desktop/架构师/01.脚手架/lqsimooc-cli-dev/commands/init -f -c -d
- imooc-cli 是整体命令
- init 是 imooc-cli的一个子命令
- -tp 和 -d 是immoc-cli的option 分别指代 本地调试路径和开启debug模式
- -f 和 -c 是init的option 分别指代是否强制初始化项目和是否读取上次缓存的远程模板数据。
- 如果传了tp 那么就用指定的包来执行init命令,否则就会从npm中下载@lqsimooc-cli-dev/init包进行inti命令
执行流程如下:
- prepare阶段
- 检查版本号
- root降级
- 检查用户主目录
- 检查环境变量
- 检查更新
- 注册命令
- 监听到init命令执行exec方法
- exec方法
- 获取执行init的是哪个包
- 如果传了targetPath就用这个包
- 如果没有则需要安装或更新@lqsimooc-cli-dev/init
- 子进程去执行这个包
const code = `require('${rootFilePath}').call(null, ${JSON.stringify(args)})`; //新的进程 const child = spawn('node', ['-e', code], { cwd: process.cwd(), //将子进程的输出实时输出到父进程 stdio: 'inherit', });
- 执行init包
- 初始化
- 检查node版本
- 检查参命令参数
- 准备阶段
- 判断项目模板是否存在(加入是否开启模板缓存判断的逻辑)
- 判断目录是否是空(加入是否开启强制清空目录逻辑)
- 再次提醒是否要清空目录(高危操作)
- 收集项目信息
- 项目类型:项目还是组件
- 版本信息
- 拉取对应类型的模板(前面已经在判断项目模板存在时已经做了,这里只要显示对应类型的模板即可)
- 如果是组件还要填写项目描述
- 下载模板
- 安装模板
- 普通模板
- 将下载下来的模板拷贝到当前项目目录
- 进行ejs模板渲染(替换掉需要ejs渲染的地方)
- 执行install(如果有installCommand)
- 执行start(如果有startCommand)
- 自定义模板
- 执行下载下来的模板的主文件(传入已经获取到项目信息等)
- 普通模板
- 初始化
- 获取执行init的是哪个包
1 year ago