Electron-vue開發實戰5——開發插件系統之CLI部分

前言

原文發表在個人博客,歡迎關注~html

祝你們2019年豬年新年快樂!本文較長,須要必定耐心看完哦~前端

前段時間,我用electron-vue開發了一款跨平臺(目前支持主流三大桌面操做系統)的免費開源的圖牀上傳應用——PicGo,在開發過程當中踩了很多的坑,不只來自應用的業務邏輯自己,也來自electron自己。在開發這個應用過程當中,我學了很多的東西。由於我也是從0開始學習electron,因此不少經歷應該也能給初學、想學electron開發的同窗們一些啓發和指示。故而寫一份Electron的開發實戰經歷,用最貼近實際工程項目開發的角度來闡述。但願能幫助到你們。vue

預計將會從幾篇系列文章或方面來展開:node

  1. electron-vue入門
  2. Main進程和Renderer進程的簡單開發
  3. 引入基於Lodash的JSON database——lowdb
  4. 跨平臺的一些兼容措施
  5. 經過CI發佈以及更新的方式
  6. 開發插件系統——CLI部分
  7. 開發插件系統——GUI部分
  8. 想到再寫...

說明

PicGo是採用electron-vue開發的,因此若是你會vue,那麼跟着一塊兒來學習將會比較快。若是你的技術棧是其餘的諸如reactangular,那麼純按照本教程雖然在render端(能夠理解爲頁面)的構建可能學習到的東西很少,不過在main端(electron的主進程)應該仍是能學習到相應的知識的。react

若是以前的文章沒閱讀的朋友能夠先從以前的文章跟着看。webpack

說在前面,其實這篇文章寫起來真的很難。如何構建一個插件系統,我花了半年的時間。要在一篇或者兩篇文章裏把這個東西說好是真的不容易。因此可能行文上會有一些瑕疵,後續會不斷打磨。git

插件系統——容器

相信不少人平時更多的是給其餘框架諸如VueReact或者Webpack等寫插件。咱們能夠把提供插件系統的框架稱爲「容器」,經過容器暴露出來的API,插件能夠掛載到容器上,或者接入容器的生命週期來實現一些更定製化的功能。github

好比Webpack本質上是一個流程系統,它經過Tapable暴露了不少生命週期的鉤子,插件能夠經過接入這些生命週期鉤子實現流水線做業——好比babel系列的插件把ES6代碼轉義成ES5SASSLESSStylus系列的插件把預處理的CSS代碼編譯成瀏覽器可識別的正常CSS代碼等等。web

咱們要實現一個插件系統,本質上也是實現這麼一個容器。這個容器以及對應的插件須要具有以下基本特徵:vue-cli

  • 容器在沒有 第三方插件 接入的狀況下也能 實現基本功能
  • 插件具備獨立性
  • 插件可配置可管理

第一點應該很容易理解。若是一個插件系統由於沒有第三方插件的存在就沒法運行,那麼這個插件系統有什麼用呢?不過有別於第三方插件,不少插件系統有本身內置的插件,好比vue-cliWebpack的一系列內置插件。這個時候插件系統自己的一些功能就會由內置的插件去實現。

第二點,插件的獨立性是指插件自己運行時不會 主動 影響其餘插件的運做。固然某個插件能夠依賴於其餘插件的運行結果。

第三點,插件若是不能配置不能管理,那麼從安裝插件階段就會遇到問題。因此容器須要有設計良好的入口給予插件註冊。

接下來的部分,我將結合PicGo-CorePicGo來詳細說明CLI插件系統與GUI插件系統如何構建與實現。

CLI插件系統

概述

其實CLI插件系統能夠認爲是無GUI的插件系統,也就是運行在命令行或者不帶有可視化界面的插件系統。爲何咱們開發Electron的插件系統,須要扯到CLI插件系統呢?這裏須要簡單回顧一下Electron的結構:

Electron結構

能夠看到除了Renderer的界面渲染,大部分的功能是由Main進程提供的。對於PicGo而言,它的底層應該是一個上傳流程系統,以下:

PicGo-Core

  1. Input(輸入):接受來自外部的輸入,默認是經過路徑或者完整的圖片base64信息
  2. Transformer(轉換器):把輸入轉換成能夠被上傳器上傳的對象(包含圖片尺寸、base6四、圖片名等信息)
  3. Uploader(上傳器):未來自轉換器的輸出上傳到指定的地方,默認的上傳器將會是SM.MS
  4. Output(輸出):輸出上傳的結果,一般能夠在輸出的imgUrl裏拿到結果

因此理論上它的底層應該在Node.js端就能實現。而Electron的Renderer進程只是實現了GUI界面,去調用底層Node.js端實現的流程系統提供的API而已。相似於咱們平時在開發網頁時候的先後端分離,只不過如今這個後端是基於Node.js實現的插件系統。基於這個思路,我開始着手PicGo-Core的實現。

生命週期

一般來講一個插件系統都有本身的一個生命週期,好比VuebeforeCreatecreatedmounted等等,WebpackbeforeRunrunafterCompile等等。這個也是一個插件系統的靈魂所在,經過接入系統的生命週期,賦予了插件更多的自由度。

所以咱們能夠先來實現一個生命週期類。代碼能夠參考Lifecycle.ts

生命週期流程能夠參考上面的流程圖。

class Lifecycle {
  // 整個生命週期的入口
  async start (input: any[]): Promise<void> {
    try {
      await this.beforeTransform(input)
      await this.doTransform(input)
      await this.beforeUpload(input)
      await this.doUpload(input)
      await this.afterUpload(input)
    } catch (e) {
      console.log(e)
    }
  }

  // 獲取原始輸入,轉換前
  private async beforeTransform (input) {
    // ...
  }

  // 將輸入轉換成Uploader可上傳的格式
  private async doTransform (input) {
    // ...
  }
  
  // Uploader上傳前
  private async beforeUpload (input) {
    // ...
  }
  
  // Uploader上傳
  private async doUpload (input) {
    // ...
  }
  
  // Uploader上傳完成後
  private async afterUpload (input) {
    // ...
  }
}
複製代碼

在實際使用中,咱們能夠經過:

const lifeCycle = new LifeCycle()
lifeCycle.start([...])
複製代碼

來運行整個上傳流程的生命週期。不過到這裏咱們尚未看到任何跟插件相關的東西。這是爲了實現咱們說的第一個條件: 容器在沒有 第三方插件 接入的狀況下也能 實現基本功能

廣播事件

不少時候咱們須要將一些事件以某種方式傳遞出去。就像發佈訂閱模型同樣,由容器發佈,由插件訂閱。這個時候咱們能夠直接讓Lifecycle這個類繼承Node.js自帶的EventEmmit

class Lifecycle extends EventEmitter {
  constructor () {
    super()
  }
  // ...
}
複製代碼

那麼Lifecycle也就擁有了EventEmitteremiton方法了。對於容器來講,咱們只須要emit事件出去便可。

好比在PicGo-Core裏,上傳的整個流程都會往外廣播事件,通知插件當前進行到什麼階段,而且將當前的輸入或者輸出在廣播的時候發送出去。

private async beforeTransform (input) {
 // ...
 this.emit('beforeTransform', input) // 廣播事件
}
複製代碼

插件能夠自由選擇監聽想要監聽的事件。好比插件想要知道上傳結束後的結果(僞代碼):

plugin.on('finished', (output) => {
  console.log(output) // 獲取output
})
複製代碼

在開發PicGo-Core的時候,有一些頗有用的事件。在這裏我也想分享出來,雖然不是全部插件系統都會有這樣的事件,可是結合本身和項目的實際須要,他們有的時候頗有用。

進度事件

平時咱們上傳或者下載文件的時候,都會注意一個東西:進度條。一樣,在PicGo-Core裏也暴露了一個事件,叫作uploadProgress,用於告訴用戶當前的上傳進度。不過在PicGo-Core,上傳進度是從beforeTransform就開始算了,爲了方便計算,劃分了5個固定的值。

private async beforeTransform (input) {
  this.emit('uploadProgress', 0) // 轉換前,進度0
}
private async doTransform (input) {
  this.emit('uploadProgress', 30) // 開始轉換,進度30
}
private async beforeUpload (input) {
  this.emit('uploadProgress', 60) // 開始上傳,進度60
}
private async afterUpload (input) {
  this.emit('uploadProgress', 100) // 上傳完畢,進度100
}
複製代碼

若是上傳失敗的話就返回-1

async start (input: any[]): Promise<void> {
 try {
   await this.beforeTransform(input)
   await this.doTransform(input)
   await this.beforeUpload(input)
   await this.doUpload(input)
   await this.afterUpload(input)
 } catch (e) {
   console.log(e)
   this.emit('uploadProgress', -1)
 }
}
複製代碼

經過監聽這個事件,PicGo就能作出以下的上傳進度條:

progress-bar

系統通知

若是上傳出了問題,或者有些信息須要經過系統級別的通知告訴用戶的話,能夠發佈notification事件。經過監聽這個事件能夠調用系統通知來發布。插件也能夠發佈這個事件,讓PicGo監聽。如上圖上傳成功後右上角的通知。

接入生命週期

上部分講到了生命週期中的事件廣播,能夠發現事件廣播是隻管發無論結果的。也就是PicGo-Core只管發佈這個事件,至於有沒有插件監聽,監聽後作了什麼都不用關心。(怎麼有點像UDP同樣)。可是實際上不少時候咱們須要接入生命週期作一些事情的。

就拿上傳流程來講,我要是想要上傳前壓縮圖片,那麼監聽beforeUpload事件是作不到的。由於在beforeUpload事件裏就算你把圖片已經壓縮了,恐怕上傳的流程早就走完了,emit事件出去後生命週期照舊運行。

所以咱們須要在容器的生命週期裏實現一個功能,可以讓插件接入它的生命週期,在執行完當前生命週期的插件的動做後,才把結果送往下一個生命週期。能夠發現,這裏有一個「等待」插件執行的動做。所以PicGo-Core使用最簡易而直觀的async函數配合await來實現「等待」。

咱們先不用考慮插件是如何註冊的,後文會說到。咱們先來實現怎麼讓插件接入生命週期。

下面以生命週期beforeUpload爲例:

private async beforeUpload (input) {
  this.ctx.emit('uploadProgress', 60)
  this.ctx.emit('beforeUpload', input)
  // ...
  await this.handlePlugins(beforeUploadPlugins.getList(), input) // 執行並「等待」插件執行結束
}
複製代碼

能夠看到咱們經過await等待生命週期方法handlePlugins(下文會說明如何實現)的執行結束。而咱們運行的插件列表是經過beforeUploadPlugins.getList()(下文會說明如何實現)獲取的,說明這些是隻針對beforeUpload這個生命週期的插件。而後將輸入input傳入handlePlugins讓插件們調用便可。

如今咱們實現一下handlePlugins

private async handlePlugins (plugins: Plugin[], input: any[]) {
  await Promise.all(plugins.map(async (plugin: Plugin) => {
    await plugin.handle(input)
  }))
}
複製代碼

咱們經過Promise.all以及await來「等待」全部插件執行。這裏須要注意的是,每一個PicGo插件須要實現一個handle方法來供PicGo-Core調用。能夠看到,這裏實現咱們說的第二個特徵: 插件具備獨立性

從這裏也能看到咱們經過asyncawait構建了一個可以「等待」插件執行結束的環境。這樣就解決了光是經過廣播事件沒法接入插件系統的生命週期的問題。

不,等等,這裏還有一個問題。beforeUploadPlugins.getList()是哪來的?上面只是一個示例代碼。實際上PicGo-Core根據上傳流程裏的不一樣生命週期預留了五種不一樣的插件:

  • beforeTransformPlugins
  • transformer
  • beforeUploadPlugins
  • uploader
  • afterUploadPlugins

分別在上傳的5個週期裏調用。雖然這5種插件調用的時機不同,可是它們的實現是一樣的:有一樣的註冊機制、一樣的方法用於獲取插件列表、獲取插件信息等等。因此咱們接下去來實現一個生命週期的插件類。

生命週期插件類

這個是插件系統裏很關鍵的一環,這個類的實現了插件應該以什麼方式註冊到咱們的插件系統裏,以及插件系統如何獲取他們。這塊的代碼能夠參考 LifecyclePlugins.ts

如下是實現:

class LifecyclePlugins {

  // list就是插件列表。以對象形式呈現。
  list: {
    [propName: string]: Plugin
  }
  constructor () {
    this.list = {} // 初始化插件列表爲{}
  }

  // 插件註冊的入口
  register (id: string, plugin: Plugin): void {
    // 若是插件沒有提供id,則不予註冊
    if (!id) throw new TypeError('id is required!')
    // 若是插件沒有handle的方法,則不予註冊
    if (typeof plugin.handle !== 'function') throw new TypeError('plugin.handle must be a function!')
    // 若是插件的id重複了,則不予註冊
    if (this.list[id]) throw new TypeError(`${this.name} duplicate id: ${id}!`)
    this.list[id] = plugin
  }

  // 經過插件ID獲取插件
  get (id: string): Plugin {
    return this.list[id]
  }

  // 獲取插件列表
  getList (): Plugin[] {
    return Object.keys(this.list).map((item: string) => this.list[item])
  }

  // 獲取插件ID列表
  getIdList (): string[] {
    return Object.keys(this.list)
  }
}

export default LifecyclePlugins
複製代碼

對於插件而言最重要的是register方法,它是插件註冊的入口。經過register註冊後,會在Lifecycle內部的listid:plugin形式裏寫入這個插件。注意到,PicGo-Core要求每一個插件須要實現一個handle的方法,用於以後在生命週期裏調用。

這裏用僞代碼說明一下插件要如何註冊:

beforeTransformPlugins.register('test', {
  handle (ctx) {
    console.log(ctx)
  }
})
複製代碼

這裏咱們就註冊了一個id叫作test的插件,它是一個beforeTransform階段的插件,它的做用就是打印傳入的信息。

而後在不一樣的生命週期裏,調用LifeCyclePlugins.getList()的方法就能獲取這個生命週期對應的插件的列表了。

抽離出核心類

若是僅僅是實現一個可以在Node.js項目裏運行的插件系統,上面兩個部分基本就夠了:

  1. Lifecyle類負責整個生命週期
  2. LifecylePlugins類負責插件的註冊與調用

不過一個良好的CLI插件系統還須要至少以下的部分(至少我以爲):

  1. 能夠經過命令行調用
  2. 可以讀取配置文件進行額外配置
  3. 命令行一鍵安裝插件
  4. 命令行完成插件配置
  5. 友好的log信息提示

此處能夠參考vue-cli3這個工具。

所以咱們至少還須要以下的部分:

  1. 命令行操做相關的類
  2. 配置文件操做相關
  3. 插件安裝、卸載、更新等相關操做的類
  4. 插件加載相關的類
  5. 日誌信息輸出相關的類

這上面的幾個部分都跟生命週期類自己沒有特別強的耦合關係,因此能夠沒必要將它們都放到生命週期類裏實現。

相對的,咱們抽離出一個Core做爲核心,將上述這些類包含到這個核心類中,核心類負責命令行命令的註冊、插件的加載、優化日誌信息以及調用生命週期等等。

最後再將這個核心類暴露出去,供使用者或者開發者使用。這個就是PicGo-Core的核心 PicGo.ts 的實現。

PicGo自己的實現並不複雜,基本上只是調用上述幾個類實例的方法。

不過注意到這裏有一個以前一直沒有提到的東西。PicGo-Core除了核心PicGo以外的幾個子類裏,基本上在constructor構建函數階段都會傳入一個叫作ctx的參數。這個參數是什麼?這個參數是PicGo這個類自身的this。經過傳入this,PicGo-Core的子類也能使用PicGo核心類暴露出來的方法了。

好比Logger類實現了美觀的命令行日誌輸出:

logger

那麼在其餘子類裏想要調用Logger的方法也很容易:

ctx.log.success('Hello world!')
複製代碼

其中ctx就是咱們上面說的,PicGo自身的this指針。

咱們接下去介紹的每一個類具體的實現。

日誌輸出相關類

先從這個類開始提及是由於這個類是最簡單並且侵入性最小的一個類。有它沒它都行,可是有它天然是錦上添花。

PicGo實現美化日誌輸出的庫是chalk,它的做用就是用來輸出花花綠綠的命令行文字:

用起來也很簡單:

const log = chalk.green('Success')
console.log(log) // 綠色字體的Success
複製代碼

咱們打算實現4種輸出類型,success、warn、info和error:

logger

因而建立以下的類:

import chalk from 'chalk'
import PicGo from '../core/PicGo'

class Logger {
  level: {
    [propName: string]: string
  }
  ctx: PicGo
  constructor (ctx: PicGo) { // 將PicGo的this傳入構造函數,使得Logger也能使用PicGo核心類暴露的方法
    this.level = {
      success: 'green',
      info: 'blue',
      warn: 'yellow',
      error: 'red'
    }
    this.ctx = ctx
  }

  // 實際輸出函數
  protected handleLog (type: string, msg: string | Error): string | Error | undefined {
    if (!this.ctx.config.silent) { // 若是不是靜默模式,靜默模式不輸出log
      let log = chalk[this.level[type]](`[PicGo ${type.toUpperCase()}]: `)
      log += msg
      console.log(log)
      return msg
    } else {
      return
    }
  }

  // 對應四種不一樣類型
  success (msg: string | Error): string | Error | undefined {
    return this.handleLog('success', msg)
  }

  info (msg: string | Error): string | Error | undefined {
    return this.handleLog('info', msg)
  }

  error (msg: string | Error): string | Error | undefined {
    return this.handleLog('error', msg)
  }

  warn (msg: string | Error): string | Error | undefined {
    return this.handleLog('warn', msg)
  }
}

export default Logger
複製代碼

以後再將Logger這個類掛載到PicGo核心類上:

import Logger from '../lib/Logger'
class PicGo {
  log: Logger
  constructor () {
    // ...
    this.log = new Logger(this) // 把this傳入Logger,也就是Logger裏的ctx
  }
  // ...
}
複製代碼

這樣其餘掛載到PicGo核心類上的類就能使用ctx.log來調用log裏的方法了。

配置文件相關

不少時候咱們的所寫的系統也好、插件也好,或多或少須要一些配置以後才能更好地使用。好比vue-cli3vue.config.js,好比hexo_config.yml等等。而PicGo也不例外。默認狀況下它能夠直接使用,可是若是想要作些其餘操做,天然就須要配置了。因此配置文件是插件系統很重要的一個組成部分。

以前我在Electron版的PicGo上使用了lowdb做爲JSON配置文件的讀寫庫,體驗不錯。爲了向前兼容PicGo的配置,寫PicGo-Core的時候我依然採用了這個庫。關於lowdb的一些具體用法,我在以前的一篇文章裏有說起,有興趣的能夠看看——傳送門

因爲lowdb作的是相似MySQL同樣的持久化配置,它須要磁盤上一個具體的JSON文件做爲載體,因此沒法經過建立一個配置對象去初始化配置。所以一切都從這個配置文件展開:

PicGo-Core採用一個默認的配置文件:homedir()/.picgo/config.json,若是在實例化PicGo沒提供配置文件路徑那麼就會使用這個文件。若是使用者提供了具體的配置文件,那麼就會使用所提供的配置文件。

下面來實現一下PicGo初始化的過程:

import fs from 'fs-extra'
class PicGo extends EventEmitter {
  configPath: string
  private lifecycle: Lifecycle
  // ...

  constructor (configPath: string = '') {
    super()
    this.configPath = configPath // 傳入configPath
    this.init()
  }

  init () {
    if (this.configPath === '') { // 若是不提供配置文件路徑,就使用默認配置
      this.configPath = homedir() + '/.picgo/config.json'
    }
    if (path.extname(this.configPath).toUpperCase() !== '.JSON') { // 若是配置文件的格式不是JSON就返回錯誤日誌
      this.configPath = ''
      return this.log.error('The configuration file only supports JSON format.')
    }
    const exist = fs.pathExistsSync(this.configPath)
    if (!exist) { // 若是不存在就建立
      fs.ensureFileSync(`${this.configPath}`)
    }
    // ...
  }
  // ...
}
複製代碼

那麼在實例化PicGo的時候就是以下這樣:

const PicGo = require('picgo')
const picgo = new PicGo() // 不提供配置文件就用默認配置文件

// 或者

const picgo = new PicGo('./xxx.json') // 提供配置文件就用所提供的配置文件
複製代碼

有了配置文件以後,咱們只須要實現三個基本操做:

  1. 初始化配置
  2. 讀取配置
  3. 寫入配置(寫入配置包括建立、更新、刪除等)

初始化配置

通常來講咱們的系統都會有一些默認的配置,PicGo也不例外。咱們能夠選擇把默認配置寫到代碼裏,也能夠選擇把默認配置寫到代碼裏。由於PicGo的配置文件有持久化的需求,因此把一些關鍵的默認配置寫入配置文件是合理的。

初始化配置的時候會用到lowdb的一些知識,這裏就不展開了:

import lowdb from 'lowdb'
import FileSync from 'lowdb/adapters/FileSync'

const initConfig = (configPath: string): lowdb.LowdbSync<any> => {
  const adapter = new FileSync(configPath, { // lowdb的adapter,用於讀取配置文件
    deserialize: (data: string): Function => {
      return (new Function(`return ${data}`))()
    }
  })
  const db = lowdb(adapter) // 暴露出來的db對象

  if (!db.has('picBed').value()) { // 若是沒有picBed配置
    db.set('picBed', { // 就生成一個默認圖牀爲SM.MS的配置
      current: 'smms'
    }).write()
  }
  if (!db.has('picgoPlugins').value()) { // 同理
    db.set('picgoPlugins', {}).write()
  }

  return db // 將db暴露出去讓外部使用
}
複製代碼

那麼在PicGo初始化階段就能夠將configPath傳入,來實現配置的初始化,以及獲取配置。

init () {
  // ...
  let db = initConfig(this.configPath)
  this.config = db.read().value() // 將配置文件內容存入this.config
}
複製代碼

讀取配置

一旦初始化配置以後,要獲取配置就很容易了:

import { get } from 'lodash'
getConfig (name: string = ''): any {
  if (name) { // 若是提供了配置項的名字
    return get(this.config, name) // 返回具體配置項結果
  } else {
    return this.config // 不然就返回完整配置
  }
}
複製代碼

這裏用到了lodashget方法,主要是爲了方便獲取以下狀況:

好比配置內容長這樣:

{
  "a": {
    "b": true
  }
}
複製代碼

往常咱們要獲取a.b須要:

let b = this.config.a.b
複製代碼

萬一遇到a不存在的時候,那麼上面那句話就會報錯了。由於a不存在,那麼a.b就是undefined.b天然會報錯了。而用lodashget方法則能夠避免這個問題,而且能夠很方便的獲取:

let b = get(this.config, 'a.b')
複製代碼

若是a不存在,那麼獲取到的結果b也不會報錯,而是undefined

寫入配置

有了上面的鋪墊,寫入內容也很簡單。經過lowdb提供的接口,寫入配置以下:

const saveConfig = (configPath: string, config: any): void => {
  const db = initConfig(configPath)
  Object.keys(config).forEach((name: string) => {
    db.read().set(name, config[name]).write()
  })
}
複製代碼

咱們能夠用:

saveConfig(this.configPath, { a: { b: true } })
複製代碼

或者:

saveConfig(this.configPath, { 'a.b': true })
複製代碼

上面兩種寫法都會生成以下配置:

{
  "a": {
    "b": true
  }
}
複製代碼

能夠看到明顯後者更簡潔點。這多虧了lowdb裏由lodash提供的set方法。

至此咱們已經將配置文件相關的操做實現完了。其實能夠把這堆操做封裝成一個類的,PicGo-Core在一開始實現的時候以爲東西很少不復雜,因此只是抽成了一個小工具來調用的。固然這個不是關鍵,關鍵在於實現了配置文件的相關操做後,你的系統和這個系統的插件都能所以受益。系統能夠把跟配置文件相關的操做的API暴露給插件使用。接下去咱們一步步來完善這個插件系統。

插件操做類

暫時沒想好這個類要取的名字是啥,代碼裏我寫的是pluginHandler,那麼就叫它插件操做類吧。這個類主要目的就三個:

  1. 經過npm安裝插件 —— install
  2. 經過npm卸載插件 —— uninstall
  3. 經過npm更新插件 —— update

npm來分發插件,這是大多數Node.js插件系統會選擇的解決方案。畢竟在沒有本身的插件商店(好比VSCode)的基礎上,npm就是一個自然的「插件商店」。固然發佈到npm之上好處還有不少,好比能夠十分方便地來對插件進行安裝、更新和卸載,好比對Node.js用戶來講是0成本的上手。這也是pluginHandler這個類要作的事。

pluginHandler相關的實現思路來自feflow,特此感謝。

平時咱們安裝一個npm模塊的時候,很簡單:

npm install xxxx --save
複製代碼

不過咱們是在當前項目目錄的上來安裝的。PicGo因爲引入了配置文件,因此咱們能夠直接在配置文件所在的目錄裏進行插件的安裝,這樣若是你要卸載PicGo,只要把。可是每次都讓用戶打開PicGo的配置文件所在的路徑去安裝插件未免太累了。這樣也不優雅。

相對的,若是咱們全局安裝了picgo以後,在文件系統任何一個角落裏只須要經過picgo install xxx就能安裝一個picgo的插件,而不須要定位到PicGo的配置文件所在的文件夾,這樣用戶體驗會好很多。這裏你們能夠類比vue-cli3安裝插件的步驟。

爲了實現這個效果,咱們須要經過代碼的方式去調用npm這個命令。那麼Node.js要如何經過代碼去實現命令行調用呢?

這裏咱們可使用cross-spawn來實現跨平臺的、經過代碼來調用命令行的目的。

spawn這個方法Node.js原生也有(在child_process裏),不過cross-spawn解決了一些跨平臺的問題。使用上是同樣的。

const spawn = require('cross-spawn')
spawn('npm', ['install', '@vue/cli', '-g'])
複製代碼

能夠看到,它的參數是以數組的形式傳入的。

而咱們要實現的插件操做,除了主要命令installupdateuninstall不同以外,其餘的參數都是同樣的。因此咱們抽離出一個execCommand的方法來實現它們背後的公共邏輯:

execCommand (cmd: string, modules: string[], where: string, proxy: string = ''): Promise<Result> {
  return new Promise((resolve: any, reject: any): void => {
    // spawn的命令行參數是以數組形式傳入
    // 此處將命令和要安裝的插件以數組的形式拼接起來
    // 此處的cmd指的是執行的命令,好比install\uninstall\update
    let args = [cmd].concat(modules).concat('--color=always').concat('--save')
    const npm = spawn('npm', args, { cwd: where }) // 執行npm,並經過 cwd指定執行的路徑——配置文件所在文件夾

    let output = ''
    npm.stdout.on('data', (data: string) => {
      output += data // 獲取輸出日誌
    }).pipe(process.stdout)

    npm.stderr.on('data', (data: string) => {
      output += data // 獲取報錯日誌
    }).pipe(process.stderr)

    npm.on('close', (code: number) => {
      if (!code) {
        resolve({ code: 0, data: output }) // 若是沒有報錯就輸出正常日誌
      } else {
        reject({ code: code, data: output }) // 若是報錯就輸出報錯日誌
      }
    })
  })
}
複製代碼

關鍵的部分基本都已經在代碼裏給出了註釋。固然這裏仍是有一些須要注意的地方。注意這句話:

const npm = spawn('npm', args, { cwd: where }) // 執行npm,並經過 cwd指定執行的路徑——配置文件所在文件夾
複製代碼

裏面的{cwd: where},這個where是會從外部傳進來的值,表示這個npm命令會在哪一個目錄下執行。這個也是咱們要作這個插件操做類最關鍵的地方——不用讓用戶主動打開配置文件所在目錄去安裝插件,在系統任何地方均可以輕鬆安裝PicGo的插件。

接下去咱們實現一下install方法,這樣另外兩個就能夠類推了。

async install (plugins: string[], proxy: string): Promise<void> {
  plugins = plugins.map((item: string) => 'picgo-plugin-' + item)
   const result = await this.execCommand('install', plugins, this.ctx.baseDir, proxy)
   if (!result.code) {
     this.ctx.log.success('插件安裝成功')
     this.ctx.emit('installSuccess', {
       title: '插件安裝成功',
       body: plugins
     })
   } else {
     const err = `插件安裝失敗,失敗碼爲${result.code},錯誤日誌爲${result.data}`
     this.ctx.log.error(err)
     this.ctx.emit('failed', {
       title: '插件安裝失敗',
       body: err
    })
  }
}
複製代碼

別看代碼不少,關鍵就一句const result = await this.execCommand('install', plugins, this.ctx.baseDir, proxy),剩下的都是日誌輸出而已。好了,插件也安裝完了,如何加載呢?

插件加載類

上面說了,咱們會將插件安裝在配置文件所在目錄裏。值得注意的是,因爲npm的特色,若是目錄裏有個叫作package.json的文件,那麼安裝插件、更新插件等操做會同時修改package.json文件。所以咱們能夠經過讀取package.json文件來得知當前目錄下有什麼PicGo的插件。這也是Hexo的插件加載機制裏的很重要的一環。

pluginLoader相關的實現思路來自hexo,特此感謝。

關於插件的命名,PicGo這裏有個約束(這也是不少插件系統選擇的方式),必須以picgo-plugin-開頭。這樣才能方便插件加載類識別它們。

這裏有一個小坑。若是咱們配置文件所在的目錄裏沒有package.json的話,那麼執行安裝插件的命令會有報錯信息。可是咱們不想讓用戶看到這個報錯,因而在初始化插件加載類的時候,須要判斷一下這個文件存不存在,若是不存在那麼咱們就要建立一個:

class PluginLoader {
  ctx: PicGo
  list: string[]
  constructor (ctx: PicGo) {
    this.ctx = ctx
    this.list = [] // 插件列表
    this.init()
  }

  init (): void {
    const packagePath = path.join(this.ctx.baseDir, 'package.json')
    if (!fs.existsSync(packagePath)) { // 若是不存在
      const pkg = {
        name: 'picgo-plugins',
        description: 'picgo-plugins',
        repository: 'https://github.com/Molunerfinn/PicGo-Core',
        license: 'MIT'
      }
      fs.writeFileSync(packagePath, JSON.stringify(pkg), 'utf8') // 建立這個文件
    }
  }
  // ...
}
複製代碼

接下來咱們要實現最關鍵的load方法了。咱們須要以下步驟:

  1. 先經過package.json來找到全部合法的插件
  2. 經過require來加載插件
  3. 經過維護picgoPlugins配置來判斷插件是否被禁用
  4. 經過執行未被禁用的插件暴露的register方法來實現插件註冊
import PicGo from '../core/PicGo'
import fs from 'fs-extra'
import path from 'path'
import resolve from 'resolve'

load (): void | boolean {
  const packagePath = path.join(this.ctx.baseDir, 'package.json')
  const pluginDir = path.join(this.ctx.baseDir, 'node_modules/')
    // Thanks to hexo -> https://github.com/hexojs/hexo/blob/master/lib/hexo/load_plugins.js
  if (!fs.existsSync(pluginDir)) { // 若是插件文件夾不存在,返回false
    return false
  }
  const json = fs.readJSONSync(packagePath) // 讀取package.json
  const deps = Object.keys(json.dependencies || {})
  const devDeps = Object.keys(json.devDependencies || {})
  // 1.獲取插件列表
  const modules = deps.concat(devDeps).filter((name: string) => {
    if (!/^picgo-plugin-|^@[^/]+\/picgo-plugin-/.test(name)) return false
    const path = this.resolvePlugin(this.ctx, name) // 獲取插件路徑
    return fs.existsSync(path)
  })
  for (let i in modules) {
    this.list.push(modules[i]) // 把插件push進插件列表
    if (this.ctx.config.picgoPlugins[modules[i]] || this.ctx.config.picgoPlugins[modules[i]] === undefined) { // 3.判斷插件是否被禁用,若是是undefined則爲新安裝的插件,默認不由用
      try {
        this.getPlugin(modules[i]).register() // 4.調用插件的`register`方法進行註冊
        const plugin = `picgoPlugins[${modules[i]}]`
        this.ctx.saveConfig( // 將插件設爲啓用-->讓新安裝的插件的值從undefined變成true
          {
            [plugin]: true
          }
        )
      } catch (e) {
        this.ctx.log.error(e)
        this.ctx.emit('notification', {
          title: `Plugin ${modules[i]} Load Error`,
          body: e
        })
      }
    }
  }
}
resolvePlugin (ctx: PicGo, name: string): string { // 獲取插件路徑
  try {
    return resolve.sync(name, { basedir: ctx.baseDir })
  } catch (err) {
    return path.join(ctx.baseDir, 'node_modules', name)
  }
}
getPlugin (name: string): any { // 經過插件名獲取插件
  const pluginDir = path.join(this.ctx.baseDir, 'node_modules/')
  return require(pluginDir + name)(this.ctx) // 2.經過require獲取插件並傳入ctx
}
複製代碼

load這個方法是整個插件系統加載的最關鍵的部分。光看上面的步驟和代碼可能沒辦法很好理解。咱們下面用一個具體的插件例子來講明。

假設我寫了一個picgo-plugin-xxx的插件。個人代碼以下:

// 插件系統會傳入picgo的ctx,方便插件調用picgo暴露出來的api
// 因此咱們須要有一個ctx的參數用於接收來自picgo的api
module.exports = ctx => {

  // 插件系統會調用這個方法來進行插件的註冊
  const register = () => {
    ctx.helper.beforeTransformPlugins.register('xxx', {
      handle (ctx) { // 調用插件的 handle 方法時也會傳入 ctx 方便調用api
        console.log(ctx.output)
      }
    })
  }

  return {
    register
  }
}
複製代碼

咱們從前文已經大概知道插件運行流程:

  1. 首先運行生命週期
  2. 當運行到某個生命週期,好比這裏的beforeTransform,那麼這個階段就去獲取beforeTransformPlugins這些插件
  3. beforeTransformPlugins這些插件由ctx.helper.beforeTransformPlugins.register方法註冊,並能夠經過ctx.helper.beforeTransformPlugins.getList()獲取
  4. 拿到插件以後將調用每一個beforeTransformPluginshandle方法,並傳入ctx供插件使用

注意上面的第三步,ctx.helper.beforeTransformPlugins.register這個方法是在何時被調用的?答案就是在本小節介紹的插件的加載階段,pluginLoader調用了每一個插件的register方法,那麼在插件的register方法裏,咱們寫了:

ctx.helper.beforeTransformPlugins.register('xxx', {
  handle (ctx) { // 調用插件的 handle 方法時也會傳入 ctx 方便調用api
    console.log(ctx.output)
  }
})
複製代碼

也就是在這個時候,ctx.helper.beforeTransformPlugins.register這個方法被調用。

因而乎,在生命週期開始以前,整個插件以及每一個生命週期的插件已經預先被註冊了。因此在生命週期開始運做的時候,只須要經過getList()就能夠獲取註冊過的插件,從而執行整個流程了。

也所以,我之前在跑Hexo生成博客的時候曾經遇到的問題就獲得解釋了。我之前安裝過一些Hexo的插件,可是不知道爲何老是沒法生效。後來發現是安裝的時候沒有使用--save,致使它們沒被寫入package.json的依賴字段。而Hexo加載插件的第一步就是從package.json裏獲取合法的插件列表,若是插件不在package.json裏,哪怕在node_modules裏有,也不會生效了。

有了插件,接下去咱們講講如何在命令行調用和配置了。

命令行操做類

PicGo的命令行操做類主要依賴於兩個庫:commander.jsInquirer.js。這兩個也是作Node.js命令行應用很經常使用的庫了。前者負責命令行解析、執行相關命令。後者負責提供與用戶交互的命令行界面。

好比你能夠輸入:

picgo use uploader
複製代碼

這個時候由commander.js去解析這句命令,告訴咱們這個時候調用的是use這個命令,參數是uploader,那麼就進入Inquirer.js提供的交互式界面了:

Inquirer.js

若是你用過諸如vue-cli3或者create-react-app等相似的命令行工具必定相似的狀況很熟悉。

首先咱們寫一個命令行操做類,用於暴露api給其餘部分註冊命令,此處源碼能夠參考Commander.ts

import PicGo from '../core/PicGo'
import program from 'commander'
import inquirer from 'inquirer'
import { Plugin } from '../utils/interfaces'
const pkg = require('../../package.json')

class Commander {
  list: {
    [propName: string]: Plugin
  }
  program: typeof program
  inquirer: typeof inquirer
  private ctx: PicGo

  constructor (ctx: PicGo) {
    this.list = {}
    this.program = program
    this.inquirer = inquirer
    this.ctx = ctx
  }
  // ...
}

export default Commander
複製代碼

而後咱們在PicGo-Core的核心類裏將其實例化:

import Commander from '../lib/Commander'
class PicGo extends EventEmitter {
  // ...
  cmd: Commander

  constructor (configPath: string = '') {
    super()
    this.cmd = new Commander(this)
    // ...
  }
  // ...
複製代碼

這樣其餘部分就可使用ctx.cmd.program來調用commander.js以及使用ctx.cmd.inquirer來調用Inquirer.js了。

這兩個庫的使用,網絡上有不少教程了。此處簡單舉個例子,咱們從PicGo最基本的功能——命令行上傳圖片開始提及。

命令的註冊

爲了與以前的插件結構統一,咱們把命令註冊也寫到handle函數裏。

import PicGo from '../../core/PicGo'
import path from 'path'
import fs from 'fs-extra'

export default {
  handle: (ctx: PicGo): void => {
    const cmd = ctx.cmd
    cmd.program // 此處是一個commander.js實例
      .command('upload') // 註冊命令 upload
      .description('upload, go go go') // 命令的描述
      .arguments('[input...]') // 命令的參數
      .alias('u') // 命令的別名 u
      .action(async (input: string[]) => { // 命令執行的函數
        const inputList = input // 獲取輸入的input
            .map((item: string) => path.resolve(item))
            .filter((item: string) => {
              const exist = fs.existsSync(item) // 判斷輸入的地址存不存在
              if (!exist) {
                ctx.log.warn(`${item} is not existed.`) // 若是不存在就返回警告信息
              }
              return exist
            })
        await ctx.upload(inputList) // 上傳圖片(調用生命週期的start函數)
      })
  }
}
複製代碼

這樣咱們若是經過某種方式把命令註冊進去:

import PicGo from '../../core/PicGo'
import upload from './upload'
// ...

export default (ctx: PicGo): void => {
  ctx.cmd.register('upload', upload) // 此處的註冊邏輯跟lifecyclePlugins一致。
  // ...
}
複製代碼

當代碼寫到這裏,可能你們以爲已經大功告成了。實際上還差了最後一步,咱們缺乏一個入口來接納咱們輸入的命令。就好比如今咱們寫完了命令,也寫完了命令的註冊,而後咱們要怎麼在命令行裏使用呢?

命令行的使用

這個時候要簡單說下package.json裏的兩個字段binmain。其中main字段指向的文件,是你const xxx = require('xxx')的時候拿到的東西。而bin字段指向的文件,就是你在全局安裝了以後,能夠在命令行裏直接輸入的命令。

舉個例子,PicGo-Core的bin字段以下:

// ...
"bin": {
  "picgo": "./bin/picgo"
},
複製代碼

那麼用戶若是全局安裝了picgo,就能夠經過picgo這個命令來使用picgo了。相似安裝@vue/cli以後,可使用vue這個命令同樣。

那麼咱們來看看./bin/picgo作了啥。源碼在這裏

#!/usr/bin/env node
const path = require('path')
const minimist = require('minimist')
let argv = minimist(process.argv.slice(2)) // 解析命令行
let configPath = argv.c || argv.config || '' // 查看是否提供了configPath
if (configPath !== true && configPath !== '') {
  configPath = path.resolve(configPath)
} else {
  configPath = ''
}
const PicGo = require('../dist/index')
const picgo = new PicGo(configPath) // 實例化picgo
picgo.registerCommands() // 註冊命令

try {
  picgo.cmd.program.parse(process.argv) // 調用commander.js解析命令
} catch (e) {
  picgo.log.error(e)
  if (process.argv.includes('--debug')) {
    Promise.reject(e)
  }
}
複製代碼

關鍵部分就在picgo.cmd.program.parse(process.argv)這句話,這句話調用了commander.js來解析process.argv,也就是命令行裏命令以及參數。

那麼咱們在開發階段就能夠用./bin/picgo upload這樣來調用命令,而在生產環境下,也就是用戶全局安裝後,就能夠經過picgo upload這樣來調用命令了。

配置項的處理

前文提到了,配置項是插件系統裏很重要的一個組成部分。不一樣插件系統的配置項處理不太同樣。好比Hexo提供了_config.yml供用戶配置,vue-cli3提供了vue.config.js供用戶配置。PicGo也提供了config.json供用戶配置,不過在此基礎上,我想提供一個更方便的方式來讓用戶直接在命令行裏完成配置,而不須要專門打開這個配置文件。

好比咱們能夠經過命令行來選擇當前上傳的圖牀是什麼:

$ picgo use
? Use an uploader (Use arrow keys)
  smms
❯ tcyun
  weibo
  github
  qiniu
  imgur
  aliyun
(Move up and down to reveal more choices)
複製代碼

這種在命令行裏的交互,須要以前提到的Inquirer.js來輔助咱們達到這個效果。

它的用法也很簡單,傳入一個prompts(能夠理解爲一個問題數組),而後它會將問題的結果再以對象的形式返回出來,咱們一般將這個結果記爲answer

而PicGo爲了簡化這個過程,只須要插件提供一個config方法,這個方法只需返回一個合法的prompts問題數組,而後PicGo會自動調用Inquirer.js去執行它,並自動將結果寫入配置文件裏。

舉個例子,PicGo內置的Imgur圖牀的config代碼以下:

const config = (ctx: PicGo): PluginConfig[] => {
  let userConfig = ctx.getConfig('picBed.imgur')
  if (!userConfig) {
    userConfig = {}
  }
  const config = [
    {
      name: 'clientId',
      type: 'input',
      default: userConfig.clientId || '',
      required: true
    },
    {
      name: 'proxy',
      type: 'input',
      default: userConfig.proxy || '',
      required: false
    }
  ]
  return config // 這個config就是一個合法的prompts數組
}
export default {
  // ...
  config
}
複製代碼

而後咱們用代碼實現可以在命令行裏調用它,源碼傳送門

如下代碼有所精簡

import PicGo from '../../core/PicGo'
import { PluginConfig } from '../../utils/interfaces'

// 處理uploader的config數組,而後寫入配置文件
const handleConfig = async (ctx: PicGo, prompts: PluginConfig, name: string): Promise<void> => {
  const answer = await ctx.cmd.inquirer.prompt(prompts)
  let configName = `picBed.${name}`
  ctx.saveConfig({
    [configName]: answer
  })
}

export default {
  handle: (ctx: PicGo): void => {
    const cmd: typeof ctx.cmd = ctx.cmd
    cmd.program
      .command('set') // 註冊一個set命令
      .alias('config') // 別名 config
      .description('configure config of picgo')
      .action(async () => {
        try {
          let prompts = [ // prompts問題數組
            {
              type: 'list',
              name: 'uploader',
              choices: ctx.helper.uploader.getIdList(), // 獲取Uploader列表
              message: `Choose a(n) uploader`,
              default: ctx.config.picBed.uploader || ctx.config.picBed.current
            }
          ]
          let answer = await ctx.cmd.inquirer.prompt(prompts) // 等待inquirer處理用戶的輸入
          const item = ctx.helper.uploader.get(answer.uploader) // 獲取用戶選擇的uploader
          if (item.config) { // 若是uploader提供了config方法
            await handleConfig(ctx, item.config(ctx), answer.uploader) //處理該config方法暴露出的prompts數組
          }
          ctx.log.success('Configure config successfully!')
        } catch (e) {
          ctx.log.error(e)
          if (process.argv.includes('--debug')) {
            Promise.reject(e)
          }
        }
      })
  }
}
複製代碼

上面是針對Uploader的config方法進行的配置處理,對於其餘插件也是同理的,就再也不贅述。這樣咱們就實現了可以經過命令行快速對配置文件進行配置,用戶體驗又是++。

插件系統發佈

講了那麼多,咱們都是在本地書寫的插件系統,如何發佈讓別人可以安裝使用呢?關於往npm發佈模塊有不少相關文章,好比參考這篇文章。我在這裏想講的是如何發佈一個既能在命令行使用,又能夠經過好比const picgo = require('picgo')在Node.js項目裏使用API調用的庫。

CLI與API調用並存

其實這個上面的部分裏也提到了。咱們在發佈一個npm庫的時候一般是在package.json裏的main字段指定這個庫的入口文件。那麼這樣使用者就能夠經過好比const picgo = require('picgo')在Node.js項目裏使用。

若是咱們想要讓這個庫安裝以後可以註冊一個命令,那麼咱們能夠在bin字段裏指定這個命令已經對應的入口文件。好比:

// ...
"bin": {
  "picgo": "./bin/picgo"
},
複製代碼

這樣咱們在全局安裝以後就會在系統裏註冊一個叫作picgo的命令了。

固然這個時候binmain的入口文件一般是不同的。bin的入口文件須要作好解析命令行的功能。因此一般咱們會使用一些命令行解析的庫例如minimist或者commander.js等等來解析命令行裏的參數。

小結

至此,一個CLI插件系統的關鍵部分咱們就基本實現了。那麼咱們在Electron項目裏,能夠在main進程裏使用咱們所寫的插件系統,並經過這個插件暴露的API來打造應用的插件系統了。下一篇文章會詳細講述如何把CLI插件系統整合進Electron,實現GUI插件系統,並加入一些額外的機制,使得在GUI上的插件系統更加靈活而強大。

本文不少都是我在開發PicGo的時候碰到的問題、踩的坑。也許文中簡單的幾句話背後就是我無數次的查閱和調試。但願這篇文章可以給你的electron-vue開發帶來一些啓發。文中相關的代碼,你均可以在PicGoPicGo-Core的項目倉庫裏找到,歡迎star~若是本文可以給你帶來幫助,那麼將是我最開心的地方。若是喜歡,歡迎關注個人博客以及本系列文章的後續進展。

注:文中的圖片除未特意說明以外均屬於我我的做品,須要轉載請私信

參考文獻

感謝這些高質量的文章:

  1. 用Node.js開發一個Command Line Interface (CLI)
  2. Node.js編寫CLI的實踐
  3. Node.js模塊機制
  4. 前端插件系統設計與實現
  5. Hexo插件機制分析
  6. 如何實現一個簡單的插件擴展
  7. 使用NPM發佈與維護TypeScript模塊
  8. typescript npm 包例子
  9. 經過travis-ci發佈npm包
  10. Dynamic load module in plugin from local project node_modules folder
  11. 跟着老司機玩轉Node命令行
  12. 以及沒來得及記錄的那些好文章,感謝大家!
相關文章
相關標籤/搜索