讓NodeJS在你的專案中發光發熱

MARKORMARK發表於2019-06-17

近些年來藉著NodeJS的春風,前端經歷了一波大洗牌式得的發展。使得前端開發在效率,質量上有了質的飛躍。可以說NodeJS已經是前端不可欠缺的技能了。但是是事實上大部分的前端對於本地安裝的NodeJS的使用可能僅限於node -vnpm了?。其實NodeJS作為真正意義上的服務端語言,在我們開發的時候可以運用NodeJS強大的模組和眾多的npm包來為我們自己服務。

寫在前面

注意:這篇文章基本上不會去將一些非常基礎的東西,希望大家自備ES6+語法, NodeJS基礎, 簡單的Linux操作等知識。還有這篇文章側重點不會放在技術的實現細節,主要是提供一些思路和方向。更加深層次的使用,還是要各位朋友自己去挖掘。而且這篇文章會有點長?

快速建立模組

這個部分我之前在加快Vue專案的開發速度中提到過,不過那個版本寫的比較簡單(糙),而且基本全都都是通過Node寫的。說白了就是用NodeJS去代替我們生成需要複製貼上的程式碼。

在大型的專案中,尤其是中後臺專案在開發一個新的業務模組的時候可能離不開大量的複製貼上,像中後臺專案中可能很多模組都是標準的CURD模組,包括了列表,新增,詳情,編輯這些頁面。那麼這就意味著有大量的重複程式碼存在,每次複製貼上完之後還有修修改改刪刪等一大堆麻煩事兒,最重要的是複製貼上很容易忘記哪一部分就忘記改了,導致專案做的很糙而且也會浪費不少時間。那我們的需求就是把人工複製貼上的那部分交給Node去做,力求時間和質量得到雙重的保障。

之前在Vue專案中寫過這個模組,那接下來的demo我們以一個Vue專案來做示例,專案地址

前期準備

  • 檔案結構劃分:檢視檔案,路由檔案,Controler檔案該怎麼放一定要劃分清楚。這個就像是地基,可以根據自己專案的業務去劃分,我們的專案目錄是下面這樣劃分

      vue-base-template
      │   config                            // webpack配置config/q其他一些config檔案
      │   scripts                           // 幫助指令碼檔案 ===> 這裡存放我們的專案指令碼檔案
      │   │   template                      // 模組檔案
      │   │   build-module.js               // build構建指令碼
      │   │   
      └───src                               // 業務邏輯程式碼
      │   │   api                           // http api 層
      │   └── router                        // 路由檔案
      │   │     │  modules                  // 業務路由資料夾  ==> 業務模組路由生成地址
      │   │           │ module.js           // 制定模組
      │   │   store                         // vuex
      │   └── views                         // 檢視檔案
      │   │     │  directory                // 抽象模組目錄
      │   │     │      │  module            // 具體模組資料夾
      │   │     │      │    │ index.vue     // 檢視檔案
      │   │   global.js                     // 全域性模組處理
      │   │   main.js                       // 入口檔案

    業務模組我基本上是通過抽象模組+具體模組的方式去劃分:

    • 抽象模組:在這裡指的是沒有具體的功能頁面只是一系列業務模組的彙總,相當於一個包含各個具體模組的目錄。
    • 具體模組:指的是有具體功能頁面的模組,包含著各個具體的頁面。

這個劃分方式很靈活,主要是根據自己的需求來。

  • 定製模板檔案:主要是用於生成檔案的模板 比如.vue這種檔案
  • 技術準備:

    理論上來講我們需要用到以下一些npm模組, 起碼要知道這個都是幹什麼的

  • 建立流程

    流程很簡單我畫的也不好,湊合看吧。抽空我會重新畫的?

    ![流程圖](./images/process.png '流程圖')

開始擼

自從有了這個想法之後, 這個指令碼到現在已經是我第三遍擼了。從第一次的單一簡單,到現在的功能完善。我最大的感觸就是這個東西開發起來好像沒有什麼盡頭,每一次都能找到不一樣的需求點,每次都能找到可以優化的部分。針對自己的專案,指令碼可以很簡單也可以很複雜。很魔性, 對我來說肯定還有第四次第五次的重構。

為了後期的維護,我把所有的指令碼相關NodeJS程式碼放到了根目錄下的scripts資料夾中

scripts                             // 幫助指令碼檔案 ===> 這裡存放我們的專案指令碼檔案
└───template                        // template管理資料夾
│   │   index.js                    // 模組檔案處理中心
│   │   api.template.js             // api模組檔案
│   │   route.template.js           // route模組檔案
│   │   template.vue                // view模組檔案
│   build-module.js                 // 建立指令碼入口檔案
│   │   
|   .env.local                      // 本地配置檔案
│   │                               
│   util.js                         // 工具檔案

下面我們一個部分一個部分的來講這些檔案的作用(<font color="red">大量程式碼預警</font>)

  • build-module.js: 入口檔案, 與使用者進行互動的指令碼。 通過問答的方式得到我們需要的三個核心變數目錄(抽象模組), 模組(具體模組), 註釋。如果是第一次執行這個指令碼, 那麼還會有配置相關的問題, 第一次設定完之後接下來的使用將不會再詢問,若想修改可自己修改.env.local檔案。這裡我不詳細描述了, 大部分解釋都寫在註釋裡面了。

第一部分 配置檔案已經輸入項處理部分

const inquirer = require('inquirer')
const path = require('path')
const { Log, FileUtil, LOCAL , ROOTPATH} = require('./util')
const { buildVueFile, buildRouteFile, buildApiFile, RouteHelper } = require('./template')
const EventEmitter = require('events');
// file options
const questions = [
  {
    type: 'input',
    name: 'folder',
    message: "請輸入所屬目錄名稱(英文,如果檢測不到已輸入目錄將會預設新建,跳過此步驟將在Views資料夾下建立新模組):"
  },
  {
    type: 'input',
    name: 'module',
    message: "請輸入模組名稱(英文)",
    // 格式驗證
    validate: str => ( str !== '' && /^[A-Za-z0-9_-]+$/.test(str))
  },
  {
    type: 'input',
    name: 'comment',
    message: "請輸入模組描述(註釋):"
  },
]
// local configs 
const configQuestion = [
  {
    type: 'input',
    name: 'AUTHOR',
    message: "請輸入作者(推薦使用拼音或者英文)",
    // 格式驗證
    validate: str => ( str !== '' && /^[\u4E00-\u9FA5A-Za-z]+$/.test(str)),
    when: () => !Boolean(process.env.AUTHOR)
  },
  {
    type: 'input',
    name: 'Email',
    message: "請輸入聯絡方式(郵箱/電話/釘釘)"
  }
]
// Add config questions if local condfig does not exit
if (!LOCAL.hasEnvFile()) {
  questions.unshift(...configQuestion)
}
// 獲取已經完成的答案
inquirer.prompt(questions).then(answers => {
  // 1: 日誌列印
  Log.logger(answers.folder == '' ? '即將為您' : `即將為您在${answers.folder}資料夾下` + `建立${answers.module}模組`)
  // 2: 配置檔案的相關設定
  if (!LOCAL.hasEnvFile()) {
    LOCAL.buildEnvFile({
      AUTHOR: answers.AUTHOR,
      Email: answers.Email
    })
  }
  // 3: 進入檔案和目錄建立流程
  const {
    folder, // 目錄
    module, // 模組
    comment // 註釋
  } = answers
  buildDirAndFiles(folder, module, comment)
})
// 事件處理中心
class RouteEmitter extends EventEmitter {}
// 註冊事件處理中心
const routeEmitter = new RouteEmitter() 
routeEmitter.on('success', value => {
  // 建立成功後正確退出程式
  if (value) {
    process.exit(0)
  }
})

第二部分 實際操作部分

// module-method map
// create module methods
const generates = new Map([
  // views部分
  // 2019年6月12日17:39:29 完成
  ['view', (folder, module, isNewDir , comment) => {
    // 目錄和檔案的生成路徑
    const folderPath = path.join(ROOTPATH.viewsPath,folder,module)
    const vuePath = path.join(folderPath, '/index.vue')
    // vue檔案生成
    FileUtil.createDirAndFile(vuePath, buildVueFile(module, comment), folderPath)
  }],
  // router is not need new folder
  ['router', (folder, module, isNewDir, comment) => {
    /**
     * @des 路由檔案和其他的檔案生成都不一樣, 如果是新的目錄那麼生成新的檔案。
     * 但是如果module所在的folder 已經存在了那麼就對路由檔案進行注入。
     * @reason 因為我們當前專案的目錄分層結構是按照大模組來劃分, 即src下一個資料夾對應一個router/modules中的一個資料夾
     * 這樣做使得我們的目錄結構和模組劃分都更加的清晰。
     */
    if (isNewDir) {
      // 如果folder不存在 那麼直接使用module命名 folder不存在的情況是直接在src根目錄下建立模組
      const routerPath = path.join(ROOTPATH.routerPath, `/${folder || module}.js`)
      FileUtil.createDirAndFile(routerPath, buildRouteFile(folder, module, comment))
    } else {
      // 新建路由helper 進行路由注入
      const route = new RouteHelper(folder, module, routeEmitter)
      route.injectRoute()
    }
  }],
  ['api', (folder, module, isNewDir, comment) => {
    // inner module will not add new folder
    // 如果當前的模組已經存在的話那麼就在當前模組的資料夾下生成對應的模組js
    const targetFile = isNewDir ? `/index.js` : `/${module}.js`
    // 存在上級目錄就使用上級目錄  不存在上級目錄的話就是使用當前模組的名稱進行建立
    const filePath = path.join(ROOTPATH.apiPath, folder || module)
    const apiPath = path.join(filePath, targetFile)
    FileUtil.createDirAndFile(apiPath, buildApiFile(comment), filePath)
  }]
])
/**
 * 通過我們詢問的答案來建立檔案/資料夾
 * @param {*} folder 目錄名稱
 * @param {*} module 模組名稱
 * @param {*} comment 註釋
 */
function buildDirAndFiles (folder, module, comment) {
  let _tempFloder = folder || module // 臨時資料夾 如果當前的檔案是
  let isNewDir
  // 如果沒有這個目錄那麼就新建這個目錄
  if (!FileUtil.isPathInDir(_tempFloder, ROOTPATH.viewsPath)) {
    rootDirPath = path.join(ROOTPATH.viewsPath, _tempFloder)
    // create dir for path
    FileUtil.createDir(rootDirPath)
    Log.success(`已建立${folder ? '目錄' : "模組"}${_tempFloder}`)
    isNewDir = true
  } else {
    isNewDir = false
  }
  // 迴圈操作進行
  let _arrays = [...generates]
  _arrays.forEach((el, i) => {
    if (i < _arrays.length) {
      el[1](folder, module, isNewDir, comment)
    } else {
      Log.success("模組建立成功!")
      process.exit(1)
    }
  })
}

注: 這裡我用了一個generates這個Map去管理了所有的操作,因為上一個版本是這麼寫我懶得換了,你也可以用一個二維陣列或者是物件去管理, 也省的寫條件選擇了。

  • template: 管理著生成檔案使用的模板檔案(vue檔案,路由檔案, api檔案),我們只看其中的route.template.js,其他的部分可以參考專案
/*
 * @Author: _author_
 * @Email: _email_
 * @Date: _date_
 * @Description: _comment_
 */
export default [
  {
    path: "/_mainPath",
    component: () => import("@/views/frame/Frame"),
    redirect: "/_filePath",
    name: "_mainPath",
    icon: "",
    noDropdown: false,
    children: [
      {
        path: "/_filePath",
        component: () => import("@/views/_filePath/index"),
        name: "_module",
        meta: {
          keepAlive: false
        }
      }
    ]
  }
]

template中最重要的要屬index.js了, 這個檔案主要是包含了模板檔案的讀取和重新生成出我們需要的模板字串, 以及生成我們需要的特定路由程式碼template/index.js,檔案模板的生成主要是通過讀取各個模板檔案並轉化成字串,並把的指定的字串用我們期望的變數去替換, 然後返回新的字串給生成檔案使用。

const fs = require('fs')
const path = require('path')
const os = require('os')
const readline = require('readline')
const {Log, DateUtil, StringUtil , LOCAL, ROOTPATH} = require('../util')
/**
 * 替換作者/時間/日期等等通用註釋
 * @param {*string} content 內容
 * @param {*string} comment 註釋
 * @todo 這個方法還有很大的優化空間
 */
const _replaceCommonContent = (content, comment) => {
  if (content === '') return ''
  // 註釋對應列表 comments =  [ [檔案中埋下的錨點, 將替換錨點的目標值] ]
  const comments = [
    ['_author_', LOCAL.config.AUTHOR],
    ['_email_', LOCAL.config.Email],
    ['_comment_', comment],
    ['_date_', DateUtil.getCurrentDate()]
  ]
  comments.forEach(item => {
    content = content.replace(item[0], item[1])
  })
  return content
}
/**
 * 生成Vue template檔案
 * @param {*} moduleName 模組名稱
 * @returns {*string}
 */
module.exports.buildVueFile = (moduleName, comment) => {
  const VueTemplate = fs.readFileSync(path.resolve(__dirname, './template.vue'))
  const builtTemplate = StringUtil.replaceAll(VueTemplate.toString(), "_module_", moduleName)
  return _replaceCommonContent(builtTemplate, comment)
}
/**
 * @author: etongfu
 * @description: 生成路由檔案
 * @param {string} folder 資料夾名稱 
 * @param {string} moduleName 模組名稱
 * @returns  {*string}
 */
module.exports.buildRouteFile = (folder,moduleName, comment) => {
  const RouteTemplate = fs.readFileSync(path.resolve(__dirname, './route.template.js')).toString()
  // 因為路由比較特殊。路由模組需要指定的路徑。所以在這裡重新生成路由檔案所需要的引數。
  const _mainPath = folder || moduleName
  const _filePath = folder == '' ? `${moduleName}` : `${folder}/${moduleName}`
  // 進行替換
  let builtTemplate = StringUtil.replaceAll(RouteTemplate, "_mainPath", _mainPath) // 替換模組主名稱
  builtTemplate = StringUtil.replaceAll(builtTemplate, "_filePath", _filePath) // 替換具體路由路由名稱
  builtTemplate = StringUtil.replaceAll(builtTemplate, "_module", moduleName) // 替換模組中的name
  return _replaceCommonContent(builtTemplate, comment)
}

/**
 * @author: etongfu
 * @description: 生成API檔案
 * @param {string}  comment 註釋
 * @returns:  {*}
 */
module.exports.buildApiFile = comment => {
  const ApiTemplate = fs.readFileSync(path.resolve(__dirname, './api.template.js')).toString()
  return _replaceCommonContent(ApiTemplate, comment)
}

在這個資料夾中,需要額外注意的是RouteHelper這個class,這個是對已存在的路由檔案進行新模組的路由注入操作,主要通過了stream(流),readline(逐行讀取)來實現的。

接下來是乾貨部分 ==> 首先通過引數找到我們的目標路由檔案,然後通過generateRouter()來拼接生成我們需要注入的路由。通過injectRoute方法開始注入路由,在injectRoute中我們首先來生成一個名字為_root臨時路徑的檔案並根據這個路徑建立一個writeStream, 然後根據舊的路由檔案地址root建立一個readStream並通過readline讀寫介面去讀取原來的路由檔案,用一個陣列收集舊的路由每一行的資料。讀取完畢之後開始遍歷temp這個陣列並找到第一個children然後把generateRouter()方法返回的陣列插入到這個位置。最後使用拼接完成的temp遍歷逐行寫入writeStream中。最後把原來的root檔案刪除,把_root重新命名為root。一個路由注入的流程就完了。大體的流程就是這樣, 關於程式碼細節不懂得朋友們可以私信我?。


/**
 * @author: etongfu
 * @description: 路由注入器
 * @param {string}  dirName
 * @param {string}  moduleName
 * @param {event}  event
 * @returns:  {*}
 */
module.exports.RouteHelper = class {
  constructor (dirName, moduleName, event) {
    // the dir path for router file
    this.dirName = dirName
    // the path for router file
    this.moduleName = moduleName
    // 事件中心
    this.event = event
    // route absolute path
    this.modulePath = path.join(ROOTPATH.routerPath, `${dirName}.js`)
  }
  /**
   * Generate a router for module
   * The vue file path is @/name/name/index
   * The default full url is http:xxxxx/name/name
   * @param {*} routeName url default is router name
   * @param {*string} filePath vue file path default is ${this.dirName}/${this.moduleName}/index
   * @returns {*Array} A string array for write line
   */
  generateRouter (routeName = this.moduleName, filePath = `${this.dirName}/${this.moduleName}/index`) {
    let temp = [
      `      // @Author: ${LOCAL.config.AUTHOR}`,
      `      // @Date: ${DateUtil.getCurrentDate()}`,
      `      {`,
      `        path: "/${this.dirName}/${routeName}",`,
      `        component: () => import("@/views/${filePath}"),`,
      `        name: "${routeName}"`,
      `      },`
    ]
    return temp
  }
  /**
   * add router to file
   */
  injectRoute () {
    try {
      const root = this.modulePath
      const _root = path.join(ROOTPATH.routerPath, `_${this.dirName}.js`)
      // temp file content
      let temp = []
      // file read or write
      let readStream = fs.createReadStream(root)
      // temp file
      let writeStream = fs.createWriteStream(_root)
      let readInterface = readline.createInterface(
        {
          input: readStream
        // output: writeStream
        }
      )
      // collect old data in file
      readInterface.on('line', (line) => {
        temp.push(line)
      })
      // After read file and we begin write new router to this file
      readInterface.on('close', async () => {
        let _index
        temp.forEach((line, index) => {
          if (line.indexOf('children') !== -1) {
            _index = index + 1
          }
        })
        temp = temp.slice(0, _index).concat(this.generateRouter(), temp.slice(_index))
        // write file
        temp.forEach((el, index) => {
          writeStream.write(el + os.EOL)
        })
        writeStream.end('\n')
        // 流檔案讀寫完畢
        writeStream.on('finish', () => {
          fs.unlinkSync(root)
          fs.renameSync(_root, root)
          Log.success(`路由/${this.dirName}/${this.moduleName}注入成功`)
          //emit 成功事件
          this.event.emit('success', true)
        })
      })
    } catch (error) {
      Log.error('路由注入失敗')
      Log.error(error)
    }
  }
}

關於路由注入這一塊我自己這麼設計其實並不是很滿意,有更好的方法還請大佬告知一下。

  • .env.local: 配置檔案, 這個是第一次使用指令碼的時候生成的。沒啥特別的,就是記錄本地配置項。
AUTHOR = etongfu
Email = 13583254085@163.com
  • util.js: 各種工具方法,包含了date, file, fs, string, Log, ROOTPATH等等工具方法, 篇幅有限我就貼出來部分程式碼, 大家可以在專案中檢視全部程式碼
const chalk = require('chalk')
const path = require('path')
const dotenv = require('dotenv')
const fs = require('fs')
// 本地配置相關
module.exports.LOCAL = class  {
  /**
   * env path
   */
  static get envPath () {
    return path.resolve(__dirname, './.env.local')
  }
  /**
   * 配置檔案
   */
  static get config () {
    // ENV 檔案查詢優先查詢./env.local
    const ENV = fs.readFileSync(path.resolve(__dirname, './.env.local')) || fs.readFileSync(path.resolve(__dirname, '../.env.development.local'))
    // 轉為config
    const envConfig = dotenv.parse(ENV)
    return envConfig
  }
  /**
   * 建立.env配置檔案檔案
   * @param {*} config 
   * @description 建立的env檔案會儲存在scripts資料夾中
   */
  static buildEnvFile (config = {AUTHOR: ''}) {
    if (!fs.existsSync(this.envPath)) {
      // create a open file
      fs.openSync(this.envPath, 'w')
    }
    let content = ''
    // 判斷配置檔案是否合法
    if (Object.keys(config).length > 0) {
      // 拼接內容
      for (const key in config) {
        let temp = `${key} = ${config[key]}\n`
        content += temp
      }
    }
    // write content to file
    fs.writeFileSync(this.envPath, content, 'utf8')
    Log.success(`local env file ${this.envPath} create success`)
  }
  /**
   * 檢測env.loacl檔案是否存在
   */
  static hasEnvFile () {
    return fs.existsSync(path.resolve(__dirname, './.env.local')) || fs.existsSync(path.resolve(__dirname, '../.env.development.local'))
  }
}

// 日誌幫助檔案
class Log {
  // TODO
}
module.exports.Log = Log

// 字串Util
module.exports.StringUtil = class {
    // TODO
}
// 檔案操作Util
module.exports.FileUtil = class {
  // TODO
  /**
   * If module is Empty then create dir and file
   * @param {*} filePath .vue/.js 檔案路徑
   * @param {*} content 內容
   * @param {*} dirPath 資料夾目錄
   */
  static createDirAndFile (filePath, content, dirPath = '') {
    try {
      // create dic if file not exit
      if (dirPath !== '' && ! fs.existsSync(dirPath)) {
        // mkdir new dolder
        fs.mkdirSync(dirPath)
        Log.success(`created ${dirPath}`)
      }
      if (!fs.existsSync(filePath)) {
        // create a open file
        fs.openSync(filePath, 'w')
        Log.success(`created ${filePath}`)
      }
      // write content to file
      fs.writeFileSync(filePath, content, 'utf8')
    } catch (error) {
      Log.error(error)
    }
  }
}
// 日期操作Util
module.exports.DateUtil = class {
  // TODO
}

Util檔案中需要注意的部分可能是.env檔案的生成和讀取這一部分和FileUtilcreateDirAndFile, 這個是我們用來生成資料夾和檔案的方法,全部使用node檔案系統完成。熟悉了API之後不會有難度。

Util檔案中有一個ROOTPATH要注意一下指的是我們的路由,views, api的根目錄配置, 這個配置的話我建議不要寫死, 因為如果你的專案有多入口或者是子專案,這些可能都會變。你也可以選擇其他的方式進行配置。

// root path
const reslove = (file = '.') => path.resolve(__dirname, '../src', file)
const ROOTPATH = Object.freeze({
  srcPath: reslove(),
  routerPath: reslove('router/modules'),
  apiPath: reslove('api'),
  viewsPath: reslove('views')
})
module.exports.ROOTPATH = ROOTPATH
  • 預覽

這樣的話我們就能愉快的通過命令列快速的建立模組了, 效果如下

![建立成功](./images/build.png '建立成功')

執行

![執行](./images/run.png '執行')

  • 總結

雖然這些事兒複製貼上也能完成,但是通過機器完成可靠度和可信度都會提升不少。我們的前端團隊目前已經全面使用指令碼來建立新模組,並且指令碼在不斷升級中。親測在一個大專案中這樣一個指令碼為團隊節約的時間是非常可觀的, 建議大家有時間也可以寫一寫這種指令碼為團隊或者自己節約下寶貴的時間?

完成機械任務

在開發過程中,有很多工作都是機械且無趣的。不拿這些東西開刀簡直對不起他們

SSH釋出

注: 如果團隊部署了CI/CD,這個部分可以直接忽略。

經過我的觀察,很多前端程式設計師並不懂Linux操作, 有的時候釋出測試還需要去找同事幫忙,如果另一個同事Linux功底也不是很好的話,那就可能浪費兩個人的很大一塊兒時間。今天通過寫一個指令碼讓我們的所有同事都能獨立的釋出測試。這個檔案同樣放在專案的scripts資料夾下

前期準備

開始擼

因為是釋出到不同的伺服器, 因為development/stage/production應該都是不同的伺服器,所以我們需要一個配置檔案來管理伺服器。
deploy.config.js

module.exports = Object.freeze({
  // development
  development: {
    SERVER_PATH: "xxx.xxx.xxx.xx", // ssh地址
    SSH_USER: "root", // ssh 使用者名稱
    SSH_KEY: "xxx", // ssh 密碼 / private key檔案地址
    PATH: '/usr/local' // 操作開始資料夾 可以直接指向配置好的地址
  },
  // stage
  stage: {
    SERVER_PATH: "",
    SSH_USER: "",
    SSH_KEY: "",
    PATH: ''
  },
  // production
  production: {
    SERVER_PATH: "",
    SSH_USER: "",
    SSH_KEY: "",
    PATH: ''
  }
})

配置檔案配置好了下面開始寫指令碼, 我們先確定下來流程

  1. 通過inquirer問問題,這是個示例程式碼, 問題就比較簡單了, 在真正使用中包括了釋出平臺等等不同的釋出目標。
  2. 檢查配置檔案, 因為配置檔案準確是必須的
  3. 壓縮dist檔案,通過zip-local去操作。很簡單
  4. 通過node-ssh連線上伺服器
  5. 執行刪除和備份(備份還沒寫)伺服器上老的檔案。
  6. 呼叫SSHputFile方法開始把本地檔案上傳到伺服器。
  7. 對伺服器執行unzip命令。

8: 釋出完成?

下面是乾貨程式碼

第一部分 實際操作部分, 連結SSH, 壓縮檔案等等

const fs = require('fs')
const path = require('path')
const ora = require('ora')
const zipper = require('zip-local')
const shell = require('shelljs')
const chalk = require('chalk')
const CONFIG = require('../config/release.confg')
let config
const inquirer = require('inquirer')
const node_ssh = require('node-ssh')
let SSH = new node_ssh()
// loggs
const errorLog = error => console.log(chalk.red(`*********${error}*********`))
const defaultLog = log => console.log(chalk.blue(`*********${log}*********`))
const successLog = log => console.log(chalk.green(`*********${log}*********`))
// 資料夾位置
const distDir = path.resolve(__dirname, '../dist')
const distZipPath = path.resolve(__dirname, '../dist.zip')
// ********* TODO 打包程式碼 暫時不用 需要和打包接通之後進行測試 *********
const compileDist = async () => {
  // 進入本地資料夾
  shell.cd(path.resolve(__dirname, '../'))
  shell.exec(`npm run build`)
  successLog('編譯完成')
}
// ********* 壓縮dist 資料夾 *********
const zipDist =  async () => {
  try {
    if(fs.existsSync(distZipPath)) {
      defaultLog('dist.zip已經存在, 即將刪除壓縮包')
      fs.unlinkSync(distZipPath)
    } else {
      defaultLog('即將開始壓縮zip檔案')
    }
    await zipper.sync.zip(distDir).compress().save(distZipPath);
    successLog('資料夾壓縮成功')
  } catch (error) {
    errorLog(error)
    errorLog('壓縮dist資料夾失敗')
  }
}
// ********* 連線ssh *********
const connectSSh = async () =>{
  defaultLog(`嘗試連線服務: ${config.SERVER_PATH}`)
  let spinner = ora('正在連線')
  spinner.start()
  try {
    await SSH.connect({
      host: config.SERVER_PATH,
      username: config.SSH_USER,
      password: config.SSH_KEY
    })
    spinner.stop()
    successLog('SSH 連線成功')
  } catch (error) {
    errorLog(err)
    errorLog('SSH 連線失敗');
  }
}
// ********* 執行清空線上資料夾指令 *********
const runCommond = async (commond) => {
  const result = await SSH.exec(commond,[], {cwd: config.PATH})
  defaultLog(result)
}
const commonds = [`ls`, `rm -rf *`]
// ********* 執行清空線上資料夾指令 *********
const runBeforeCommand = async () =>{
  for (let i = 0; i < commonds.length; i++) {
    await runCommond(commonds[i])
  }
}
// ********* 通過ssh 上傳檔案到伺服器 *********
const uploadZipBySSH = async () => {
  // 連線ssh
  await connectSSh()
  // 執行前置命令列
  await runBeforeCommand()
  // 上傳檔案
  let spinner = ora('準備上傳檔案').start()
  try {
    await SSH.putFile(distZipPath, config.PATH + '/dist.zip')
    successLog('完成上傳')
    spinner.text = "完成上傳, 開始解壓"
    await runCommond('unzip ./dist.zip')
  } catch (error) {
    errorLog(error)
    errorLog('上傳失敗')
  }
  spinner.stop()
}

第二部分 命令列互動和配置校驗

// ********* 釋出程式 *********
/**
 * 通過配置檔案檢查必要部分
 * @param {*dev/prod} env 
 * @param {*} config 
 */
const checkByConfig = (env, config = {}) => {
  const errors = new Map([
    ['SERVER_PATH',  () => {
      // 預留其他校驗
      return config.SERVER_PATH == '' ? false : true
    }],
    ['SSH_USER',  () => {
      // 預留其他校驗
      return config.SSH_USER == '' ? false : true
    }],
    ['SSH_KEY',  () => {
      // 預留其他校驗
      return config.SSH_KEY == '' ? false : true
    }]
  ])
  if (Object.keys(config).length === 0) {
    errorLog('配置檔案為空, 請檢查配置檔案')
    process.exit(0)
  } else {
    Object.keys(config).forEach((key) => {
      let result = errors.get(key) ? errors.get(key)() : true
      if (!result) {
        errorLog(`配置檔案中配置項${key}設定異常,請檢查配置檔案`)
        process.exit(0)
      }
    })
  }
  
}
// ********* 釋出程式 *********
const runTask = async () => {
  // await compileDist()
  await zipDist()
  await uploadZipBySSH()
  successLog('釋出完成!')
  SSH.dispose()
  // exit process
  process.exit(1)
}
// ********* 執行互動 *********
inquirer.prompt([
  {
    type: 'list',
    message: '請選擇釋出環境',
    name: 'env',
    choices: [
      {
        name: '測試環境',
        value: 'development'
      },
      {
        name: 'stage正式環境',
        value: 'production'
      },
      {
        name: '正式環境',
        value: 'production'
      }
    ]
  }
]).then(answers => {
  config = CONFIG[answers.env]
  // 檢查配置檔案
  checkByConfig(answers.env, config)
  runTask()
})

效果預覽

![釋出](./images/deploy.png '釋出')

至此大家就可以愉快的釋出程式碼了, 無痛釋出。親測一次耗時不會超過30s

打包後鉤子

寫累了, 改天抽空接著寫?

總結

這些指令碼寫的時候可能需要一點時間, 但是一旦完成之後就會為團隊在效率和質量上有大幅度的提升,讓開發人員更見專注與業務和技術。同時時間成本的節約也是不可忽視的,這是我在團隊試驗之後得出的結論。 以前開發一個模組前期的複製貼上準備等等可能需要半個小時還要多點, 現在一個模組前期準備加上一個列表頁靜態開發10分鐘搞定。寫了釋出指令碼之後直接就讓每一個同事能夠獨立釋出測試環境(正式許可權不是每個人都有),並且耗時極短。這些都是實在的體現在日常開發中了。另外Node環境都安裝了,不用白不用(白嫖???), 各位大佬也可以自己發散思維,能讓程式碼搬的磚就不要自己搬

示例程式碼

原文地址 如果覺得有用得話給個⭐吧

相關文章