1.0.0 • Published 1 year ago

lqsimooc-cli-dev v1.0.0

Weekly downloads
-
License
ISC
Repository
-
Last release
1 year ago

脚手架开发全流程记录

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阶段

主要就是通过命令行交互收集项目信息,生成项目配置,为下一步模板的下载和安装做铺垫

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替换模板内容

  1. 先用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);
    }
  2. 再用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)
        • 自定义模板
          • 执行下载下来的模板的主文件(传入已经获取到项目信息等)
1.0.0

1 year ago