從零手寫pm-cli腳手架,統一阿里拍賣源碼架構

前言

原文地址: https://github.com/Nealyang/PersonalBlog/issues/72

腳手架實際上是大多數前端都不陌生的東西,基於前面寫過的兩篇文章:前端

大概呢,就是介紹下,目前個人幾個項目頁面的代碼組織形式。node

用了幾個項目後,發現也挺順手,遂想着要不搞個 cli 工具,統一下源碼的目錄結構吧。react

這樣不只能夠減小一個機械的工做同時也可以統一源碼架構。同窗間維護項目的陌生感也會有所下降。的確是有一部分提效的不是。雖然咱們大多數頁面都走的大黃蜂搭建🥺。。。git

功能

cli 工具其實就一些基本的命令運行、 CV 大法,沒有什麼技術深度。

bin

效果

bin

工程目錄

工程目錄

代碼實現

  • bin/index.js
#!/usr/bin/env node

'use strict';

const currentNodeVersion = process.versions.node;
const semver = currentNodeVersion.split('.');
const major = semver[0];

if (major < 10) {
  console.error(
    'You are running Node ' +
      currentNodeVersion +
      '.\n' +
      'pmCli requires Node 10 or higher. \n' +
      'Please update your version of Node.'
  );
  process.exit(1);
}

require('../packages/initialization')();

這裏是入口文件,比較簡單,就是配置個入口,順便校驗 node 的版本號github

  • initialization.js

這個文件主要是配置一些命令,其實也比較簡單,你們從 commander裏面查看本身須要的配置,而後配置出來就能夠了shell

就是根據本身需求去配置這裏就不贅述了,除了以上,就如下兩點實現:npm

  • 功能入口
// 建立工程
  program
    .usage("[command]")
    .command("init")
    .option("-f,--force", "overwrite current directory")
    .description("initialize your project")
    .action(initProject);

  // 新增頁面
  program
    .command("add-page <page-name>")
    .description("add new page")
    .action(addPage);

  // 新增模塊
  program
    .command("add-mod [mod-name]")
    .description("add new mod")
    .action(addMod);

  // 添加/修改 .pmConfig.json
  program
    .command("modify-config")
    .description("modify/add config file (.pmCli.config)")
    .action(modifyCon);

  program.parse(process.argv);
  • 兜底

所謂兜底就是輸入 pm-cli 後沒有跟任何命令json

pm-cli init

在說 init 以前呢,這裏有個技術背景。就是咱們的 rax 工程,基於 def 平臺初始化出來的,因此說自帶一個腳手架。可是咱們在源碼開發中呢,會對其進行一些改動。爲了不認知重複呢,init 我分爲兩個功能:segmentfault

  • init projectName 從 0 建立一個def init rax projectName 項目
  • 在 raxProject 裏面 init 會基於當前架構補充咱們所統一的源碼架構

流程

init projectName

這裏咱們在一個空目錄中進行演示

initProject

運行結束圖

init

init

至於這裏的一些問題的交互就不介紹了,就是inquirer配置的一些問題而已。沒有太大的參考價值 。windows

initProject

入口

入口方法較爲簡單,其實就是區分當前運行 pm-cli init究竟是基於已有項目初始化,仍是新建一個 rax 項目 ,判斷依據也很是簡單,就是判斷當前目錄下是否有 package.json

雖然這麼判斷感受是草率了點,可是,你細品也確實如此!對於有 package.json 的當前目錄,我還會去校驗別的不是。

若是當前目錄存在 package.json,那麼我認爲你是一個項目,想在此項目中,初始化拍賣源碼架構的配置。因此我會去判斷當前項目是否已經初始化過了。

fs.existsSync(path.resolve(CURR_DIR, `./${PM_CLI_CONFIG_FILE_NAME}`))

也就是這個PM_CLI_CONFIG_FILE_NAME的內容。那麼則給出提示。畢竟不須要重複初始化嘛。若是你想強行再初始化一次,也能夠!

pm-cli init -f

準備工做坐在前期,最終運行的功能都在 run 方法裏面。

校驗名稱合法性

這裏還有個功能函數很是的通用,也就提早拿出來講了吧。

const dirList = fs.readdirSync(CURR_DIR);

checkNameValidate(projectName, dirList);
/**
 * 校驗名稱合法性
 * @param {string} name 傳入的名稱 modName/pageName
 * @param {Array}} validateNameList 非法名數組
 */
const checkNameValidate = (name, validateNameList = []) => {
  const validationResult = validatePageName(name);
  if (!validationResult.validForNewPackages) {
    console.error(
      chalk.red(
        `Cannot create a mod or page named ${chalk.green(
          `"${name}"`
        )} because of npm naming restrictions:\n`
      )
    );
    [
      ...(validationResult.errors || []),
      ...(validationResult.warnings || []),
    ].forEach((error) => {
      console.error(chalk.red(`  * ${error}`));
    });
    console.error(chalk.red("\nPlease choose a different project name."));
    process.exit(1);
  }
  const dependencies = [
    "rax",
    "rax-view",
    "rax-text",
    "rax-app",
    "rax-document",
    "rax-picture",
  ].sort();
  validateNameList = validateNameList.concat(dependencies);

  if (validateNameList.includes(name)) {
    console.error(
      chalk.red(
        `Cannot create a project named ${chalk.green(
          `"${name}"`
        )} because a page with the same name exists.\n`
      ) +
        chalk.cyan(
          validateNameList.map((depName) => `  ${depName}`).join("\n")
        ) +
        chalk.red("\n\nPlease choose a different name.")
    );
    process.exit(1);
  }
};

其實就是校驗名稱合法性以及排除重名。這個工具函數能夠直接 CV。

如上的流程圖,咱們已經走到run 方法了,剩下的就是裏面的一些判斷。

const packageObj = fs.readJSONSync(path.resolve(CURR_DIR, "./package.json"));
  // 判斷是 rax 項目
  if (
    !packageObj.dependencies ||
    !packageObj.dependencies.rax ||
    !packageObj.name
  ) {
    handleError("必須在 rax 1.0 項目中初始化");
  }
  // 判斷 rax 版本
  let raxVersion = packageObj.dependencies.rax.match(/\d+/) || [];
  if (raxVersion[0] != 1) {
    handleError("必須在 rax 1.0 項目中初始化");
  }

  if (!isMpaApp(CURR_DIR)) {
    handleError(`不支持非 ${chalk.cyan('MPA')} 應用使用 pmCli`);
  }

由於這些判斷也不是很是的具備參考價值,這裏就簡單跳過了,而後在重點介紹下一些公共方法的編寫。

addTsConfig

/**
 * 判斷目標項目是否爲 ts,並建立配置文件
 */
function addTsconfig() {
  let distExist, srcExist;
  let disPath = path.resolve("./tsconfig.json");
  let srcPath = path.resolve(__dirname, "../../ts.json");

  try {
    distExist = fs.existsSync(disPath);
  } catch (error) {
    handleError("路徑解析發生錯誤 code:0024,請聯繫@一凨");
  }
  if (distExist) return;
  try {
    srcExist = fs.existsSync(srcPath);
  } catch (error) {
    handleError("路徑解析發生錯誤 code:1233,請聯繫@一凨");
  }
  if (srcExist) {
    // 本地存在
    console.log(
      chalk.red(`編碼語言請採用 ${chalk.underline.red("Typescript")}`)
    );
    spinner.start("正在爲您建立配置文件:tsconfig.json");
    fs.copy(srcPath, disPath)
      .then(() => {
        console.log();
        spinner.succeed("已爲您建立 tsconfig.json 配置文件");
      })
      .catch((err) => {
        handleError("tsconfig 建立失敗,請聯繫@一凨");
      });
  } else {
    handleError("路徑解析發生錯誤 code:2144,請聯繫@一凨");
  }
}

上面的代碼你們都能讀的懂,粘貼這一段代碼的目的就是,但願你們寫cli 的時候,必定要多考慮邊界狀況,存在性判斷,以及一些異常兜底。避免沒必要要的 bug 產生

rewriteAppJson

/**
 * 重寫項目中的 app.json
 * @param {string} distAppJson app.json 路徑
 */
function rewriteAppJson(distAppPath) {
  try {
    let distAppJson = fs.readJSONSync(distAppPath);
    if (
      distAppJson.routes &&
      Array.isArray(distAppJson.routes) &&
      distAppJson.routes.length === 1
    ) {
      distAppJson.routes[0] = Object.assign({}, distAppJson.routes[0], {
        title: "阿里拍賣",
        spmB: "B碼",
        spmA: "A碼",
      });

      fs.writeJSONSync(path.resolve(CURR_DIR, "./src/app.json"), distAppJson, {
        spaces: 2,
      });
    }
  } catch (error) {
    handleError(`重寫 ${chalk.cyan("app.json")}出錯了,${error}`);
  }
}

別的重寫方法就不粘貼了,由於也是比較枯燥且重複的。下面說一下公共方法和用處吧

下載模板

const templateProjectPath = path.resolve(__dirname, `../temps/project`);
// 下載模板
await downloadTempFromRep(projectTempRepo, templateProjectPath);
/**
 *從遠程倉庫下載模板
 * @param {string} repo 遠程倉庫地址
 * @param {string} path 路徑
 */
const downloadTempFromRep = async (repo, srcPath) => {
  if (fs.pathExistsSync(srcPath)) fs.removeSync(`${srcPath}`);

  await seriesAsync([`git clone ${repo} ${srcPath}`]).catch((err) => {
    if (err) handleError(`下載模板出錯:errorCode:${err},請聯繫@一凨`);
  });
  if(fs.existsSync(path.resolve(srcPath,'./.git'))){
    spinner.succeed(chalk.cyan('模板目錄下 .git 移除'));
    fs.remove(path.resolve(srcPath,'./.git'));
  }
};

下載模板這裏我直接用的 shell 腳本,由於這裏涉及到不少權限的問題。

shell

// execute a single shell command where "cmd" is a string
exports.exec = function (cmd, cb) {
  // this would be way easier on a shell/bash script :P
  var child_process = require("child_process");
  var parts = cmd.split(/\s+/g);
  var p = child_process.spawn(parts[0], parts.slice(1), { stdio: "inherit" });
  p.on("exit", function (code) {
    var err = null;
    if (code) {
      err = new Error(
        'command "' + cmd + '" exited with wrong status code "' + code + '"'
      );
      err.code = code;
      err.cmd = cmd;
    }
    if (cb) cb(err);
  });
};

// execute multiple commands in series
// this could be replaced by any flow control lib
exports.seriesAsync = (cmds) => {
  return new Promise((res, rej) => {
    var execNext = function () {
      let cmd = cmds.shift();
      console.log(chalk.blue("run command: ") + chalk.magenta(cmd));
      shell.exec(cmd, function (err) {
        if (err) {
          rej(err);
        } else {
          if (cmds.length) execNext();
          else res(null);
        }
      });
    };
    execNext();
  });
};

copyFiles

/**
 * 拷貝頁面s
 * @param {array} filesArr 文件數組,二維數組
 * @param {function} errorCb 失敗回調函數
 * @param {成功回調函數} successCb 成功回調函數
 */
const copyFiles = (filesArr, errorCb, successCb) => {
  try {
    filesArr.map((filePathArr) => {
      if (filePathArr.length !== 2) throw "配置文件讀寫錯誤!";
      fs.copySync(filePathArr[0], filePathArr[1]);
      spinner.succeed(chalk.cyan(`${path.basename(filePathArr[1])} 初始化完成`));
    });
  } catch (error) {
    console.log(error);

    errorCb(error);
  }
};

在將遠程代碼拷貝到源碼目錄 temps/下,進行一波修改後,仍是須要 copy 到項目目錄中的,因此這裏封裝了一個方法。

配置文件

配置文件是我爲了標識出當前項目,是否爲 pmCli 初始化所得。由於在addPage 的時候,page 中的一些頁面會使用到外部的組件,好比 loadingPage

配置文件

如上,initProject:true|false用來標識當前倉庫。

[pageName] 用來表示有哪些頁面是用 pmCli 新建的。屬性 type:'simpleSource'|'withContext'|'customStateManage'則用來告訴後續 add-mod 到底添加哪一種類型的模塊。

同時呢,對內容進行了加密,由於配置頁面,是放在用戶的項目下的

配置文件

加密

const crypto = require('crypto');
function aesEncrypt(data) {
    const cipher = crypto.createCipher('aes192', 'PmCli');
    var crypted = cipher.update(data, 'utf8', 'hex');
    crypted += cipher.final('hex');
    return crypted;
}

function aesDecrypt(encrypted) {
    const decipher = crypto.createDecipher('aes192', 'PmCli');
    var decrypted = decipher.update(encrypted, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    return decrypted;
}
module.exports = {
    aesEncrypt,
    aesDecrypt
}

基本上如上,初始化項目的功能就介紹完了,後面的功能都是換湯不換藥的這些操做。我們蜻蜓點水,提個要點。

pm-cli add-page

addSimplePage

detail

生成的目錄

流程圖

流程圖

上面的功能,其實就是跟 initProject裏面的代碼類似,就是一些「業務」狀況的判斷不一樣而已。

pm-cli add-mod

自定義狀態管理模塊

簡單源碼模塊

新增的模塊

其實模塊的新增也沒有特別的技術點。先選擇頁面列表,而後讀取.pmCli.config中的頁面的類型。根據類型去新增頁面

function run(modName) {
  // 新增模塊,須要定位當前位置
  modifiedCurrPathAndValidatePro(CURR_DIR);
  // 選擇可以新增模塊的頁面
  pageList = Object.keys(pmCliConfigFileContent).filter((val) => {
    return val !== "initProject";
  });
  if (pageList.length === 0) {
    handleError();
  }

  inquirer.prompt(getQuestions(pageList)).then((answer) => {
    const { pageName } = answer;
    // modName 重名判斷
    try {
      checkNameValidate(
        modName,
        fs.readdirSync(
          path.resolve(CURR_DIR, `./src/pages/${pageName}/components`)
        )
      );
    } catch (error) {
      console.log("讀取當前頁面模塊列表失敗", error);
    }

    let modType = pmCliConfigFileContent[pageName].type;
    inquirer.prompt(getInsureQuestions(modType)).then(async (ans) => {
      if (!ans.insure) {
        modType = ans.type;
      }
      const distPath = path.resolve(
        CURR_DIR,
        `./src/pages/${pageName}/components`
      );
      const tempPath = path.resolve(__dirname, "../temps/mod");
      // 下載模板
      await downloadTempFromRep(modTempRepo, tempPath);
      try {
        if (fs.existsSync(distPath)) {
          console.log(chalk.cyanBright(`開始進行模塊初始化`));
          let copyFileArr = [
            [
              path.resolve(tempPath, `./${modType}`),
              path.resolve(distPath, `./${modName}`),
            ],
          ];
          if(modType === 'customStateManage'){
            copyFileArr = [
              [
                path.resolve(tempPath,`./${modType}/mod-com`),
                path.resolve(distPath,`./${modName}`)
              ],
              [
                path.resolve(tempPath,`./${modType}/mod-com.d.ts`),
                path.resolve(distPath,`../types/${modName}.d.ts`)
              ],
              [
                path.resolve(tempPath,`./${modType}/mod-com.reducer.ts`),
                path.resolve(distPath,`../reducers/${modName}.reducer.ts`)
              ],
            ]
          }
          copyFiles(copyFileArr, (err) => {
            handleError(`拷貝配置文件失敗`, err);
          });
          if (!ans.insure) {
            console.log();
            console.log(
              chalk.underline.red(
                ` 請確認頁面:${pageName},在 .pmCli.config 中的類型`
              )
            );
            console.log();
          }
          modAddEndConsole(modName,modType);
        } else {
          handleError("本地文件目錄有問題");
        }
      } catch (error) {
        handleError("讀取文件目錄出錯,請聯繫@一凨");
      }
    });
  });
}

矯正 CURR_DIR

在添加模塊的時候,我還作了我的性化處理。防止好心人覺得要到 cd 到指定 pages 下才能 addMod,因此我支持只要你在 srcpages 或者項目根目錄下,均可以執行 add-mod

/**
 * 糾正當前路徑到項目路徑下,主要是爲了防止用戶在當前頁面新建模塊
 */
const modifiedCurrPathAndValidatePro = (proPath) => {
  const configFilePath = path.resolve(CURR_DIR, `./${PM_CLI_CONFIG_FILE_NAME}`);
  try {
    if (fs.existsSync(configFilePath)) {
      pmCliConfigFileContent = JSON.parse(
        aesDecrypt(fs.readFileSync(configFilePath, "utf-8"))
      );
      if (!isTrue(pmCliConfigFileContent.initProject)) {
        handleError(`配置文件:${PM_CLI_CONFIG_FILE_NAME}被篡改,請聯繫@一凨`);
      }
    } else if (
      path.basename(CURR_DIR) === "pages" ||
      path.basename(CURR_DIR) === "src"
    ) {
      CURR_DIR = path.resolve(CURR_DIR, "../");
      modifiedCurrPathAndValidatePro(CURR_DIR);
    } else {
      handleError(`當前項目並不是${chalk.cyan("pm-cli")}初始化,不可以使用該命令`);
    }
  } catch (error) {
    handleError("讀取項目配置文件失敗", error);
  }
};

pm-cli modify-config

由於以前介紹過源碼的頁面架構,同時我也應用到了項目開發中。開發 pmCli 的時候,又新增了新增了配置文件,存在本地仍是加密的。那麼豈不是我以前的項目須要新增頁面還不能用這個 pmCli

因此,就新增了這個功能:

modify-config:

  • 當前項目是否存在 pmCli,沒有則新建,有,則修改

注意點(總結)

  • cli 其實就是個簡單的 node 小應用。fs-extra + shell就能玩起來,很是簡單
  • 邊界狀況以及各類人性化的交互須要考慮周到
  • 異常處理和異常反饋須要給足
  • 無聊且重複的工做。固然,你能夠發揮你的想象

THE LAST TIME

THE LAST TIME

TODO

  • 集成發佈端腳手架(React)
  • 支持參數透傳
  • vscode 插件,面板化操做

工具

所謂工欲善其事必先利其器,在 cli 避免不了使用很是多的工具,這裏我主要是使用一些開源包以及從 CRA 裏面 copy 過來的方法。

commander

homePage: https://github.com/tj/command...

node.js 命令行接口的完整解決方案

Inquirer

homePage: https://github.com/SBoudrias/...

交互式命令行用戶界面的組件

fs-extra

homePage: https://github.com/jprichards...

fs 模塊自帶文件模塊的外部擴展模塊

semver

homePage: https://github.com/npm/node-s...

用於對版本的一些操做

chalk

homePage: https://github.com/chalk/chalk

在命令行中給文本添加顏色的組件

clui

spinners、sparklines、progress bars圖樣顯示組件

homPage:https://github.com/nathanpeck...

download-git-repo

homePage: https://gitlab.com/flippidipp...

Node 下載並提取一個git倉庫(GitHub,GitLab,Bitbucket)

ora

homePage: https://github.com/sindresorh...

命令行加載效果,同上一個相似

shelljs

homePage: https://github.com/shelljs/sh...

Node 跨端運行 shell 的組件

validate-npm-package-name

homePage: https://github.com/npm/valida...

用於檢查包名的合法性

blessed-contrib

homePage: https://github.com/yaronn/ble...

命令行可視化組件

原本這些工具打算單獨寫一篇文章的,可是堆 list 的文章的確不是頗有用。容易忘主要是,因此這裏就帶過了。功能和效果,你們自行查看和測試吧。而後 CRA 中的比較不錯的方法,我也在文章末尾列出來了。關於 CRA 的源碼閱讀,也能夠查看我以往的文章:github/Nealyang

CRA 中不錯的方法/包

  • commander:概述一下,Node命令接口,也就是能夠用它代管Node命令。npm地址
  • envinfo:能夠打印當前操做系統的環境和指定包的信息。 npm地址
  • fs-extra:外部依賴,Node自帶文件模塊的外部擴展模塊 npm地址
  • semver:外部依賴,用於比較Node版本 npm地址
  • checkAppName():用於檢測文件名是否合法,
  • isSafeToCreateProjectIn():用於檢測文件夾是否安全
  • shouldUseYarn():用於檢測yarn在本機是否已經安裝
  • checkThatNpmCanReadCwd():用於檢測npm是否在正確的目錄下執行
  • checkNpmVersion():用於檢測npm在本機是否已經安裝了
  • validate-npm-package-name:外部依賴,檢查包名是否合法。npm地址
  • printValidationResults():函數引用,這個函數就是我說的特別簡單的類型,裏面就是把接收到的錯誤信息循環打印出來,沒什麼好說的。
  • execSync:引用自child_process.execSync,用於執行須要執行的子進程
  • cross-spawnNode跨平臺解決方案,解決在windows下各類問題。用來執行node進程。npm地址
  • dns:用來檢測是否可以請求到指定的地址。npm地址

參考

技術交流

全棧前端交流羣④羣

相關文章
相關標籤/搜索