讓NodeJS在你的項目中發光發熱

近些年來藉着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 = [email protected]
  • 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環境都安裝了,不用白不用(白嫖😁😁😁), 各位大佬也可以自己發散思維,能讓代碼搬的磚就不要自己搬

示例代碼

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章