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

前言

原文地址:github.com/Nealyang/Pe…前端

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

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

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

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

功能
功能

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

bin

效果

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 的版本號shell

  • initialization.js

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

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

  • 功能入口
// 建立工程
 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 後沒有跟任何命令segmentfault

pm-cli init

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

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

init projectName

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

initProject
initProject
運行結束圖
運行結束圖

init

init
init

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

initProject
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
addSimplePage
detail
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

TODO

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

工具

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

commander

homePage:https://github.com/tj/commander.js

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

Inquirer

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

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

fs-extra

homePage:https://github.com/jprichardson/node-fs-extra

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

semver

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

用於對版本的一些操做

chalk

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

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

clui

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

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

download-git-repo

homePage:https://gitlab.com/flippidippi/download-git-repo

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

ora

homePage:https://github.com/sindresorhus/ora

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

shelljs

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

Node 跨端運行 shell 的組件

validate-npm-package-name

homePage:https://github.com/npm/validate-npm-package-name

用於檢查包名的合法性

blessed-contrib

homePage:https://github.com/yaronn/blessed-contrib

命令行可視化組件

原本這些工具打算單獨寫一篇文章的,可是堆 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地址

參考

技術交流

全棧前端交流羣
相關文章
相關標籤/搜索