Hyrule - electron app 開發實踐

背景

Hyrulehtml

本文也是在Hyrule下完成vue

技術棧以及主要依賴

electron提供跨平臺PC端運行環境,使用react+antd構建UI界面node

monaco-editor提供編輯器功能,使用remark轉換markdownreact

electron如何運行web

electron做用就是提供多端運行環境,實際開發體驗跟通常Web開發無二linux

萬事開頭難,初次接觸的確不知道如何入手,github上也有相應的模板webpack

無論模板如何,核心仍是如何在electron中加載htmlgit

electron分爲主進程(main)和渲染進程(renderer),主進程能夠跟操做系統打交道,渲染進程能夠說跟頁面打交道(webapp),所以只須要在主進程建立一個window來跑頁面便可。github

若是隻是開發普通頁面,那隻要加載html便可,若是使用webpack開發,則開發時候須要在electron中訪問dev-server提供的頁面web

const win = new BrowserWindow({
    // 建立一個window, 用於加載html
    title: app.getName(),
    minHeight: 750,
    minWidth: 1090,
    webPreferences,
    show: false, // 避免app啓動時候顯示出白屏
    backgroundColor: '#2e2c29'
  })
  if (isDev) {
    win.loadURL('http://localhost:8989/') // 開發環境訪問dev-server提供的頁面
    // 配置react-dev-tool
    const {
      default: installExtension,
      REACT_DEVELOPER_TOOLS
    } = require('electron-devtools-installer')
    installExtension(REACT_DEVELOPER_TOOLS)
      .then(name => console.log(`Added Extension: ${name}`))
      .catch(err => console.log('An error occurred: ', err))
    // win.webContents.openDevTools()
  } else {
    // 生產環境直接加載index.html
    win.loadFile(`${__dirname}/../../../renderer/index.html`)
  }
複製代碼

至此, 就能夠在electron中運行開發的webapp, 剩下的工做便跟平常開發同樣typescript

項目啓動

如上面所說, 在啓動開發環境時候, 須要兩個進程

  • devServer: 使用webpack來啓動webapp開發環境
  • electron: 直接使用node來執行main.js, 啓動electron

但因爲使用typescript來開發, 在web端能夠由webpack來完成, 那麼在electron中, 則多了一步來編譯

所以整個開發環境啓動有三步

  • dev:web 啓動dev-server
  • dev:main 編譯main.ts到./dist/main.js
  • dev:electron 執行main.js, 啓動electron(藉助nodemon來自動重啓)

目前還未特地去尋找一鍵啓動方法, 所以啓動步驟稍微多

{
  "scripts": {
    "dev:web": "node ./build/devServer.js",
    "build:web": "webpack --progress --hide-modules --colors --config=build/prod.conf.js",
    "dev:main": "yarn build:main --watch",
    "build:main": "tsc -p tsconfig.electron.json",
    "dev:electron": "nodemon --watch ./dist/main --exec electron ./dist/electron/src/main/main.js",
    "build:package": "electron-builder --config ./electronrc.js -mwl",
    "build": "yarn build:web && yarn build:main && yarn build:package"
  }
}
複製代碼

項目開發

接下來, 只須要重點開發webapp便可, electron端能夠做爲輔助, 提供一些系統級別調用功能

下面講講開發過程當中遇到的問題以及解決方法

github 認證

因爲app是基於github來完成, 所以全部功能都須要對接github api

github大部分api都是對外開放, 當須要訪問私有倉庫或者進行敏感操做時候才須要token

可是不使用token的話, api有調用次數限制

獲取token有兩種方式

  • 直接讓用戶輸入access token
  • 經過github app形式來交換token

用戶自行輸入token

第一種方式顯然是最簡單的, 只須要提供一個form表單讓用戶輸入access token

經過oauth2.0受權獲取token

oauth2.0受權步驟大概以下:

  • 在github申請github app, 並獲取CLIENT_IDSECRET, 並填寫回調地址
  • 引導用戶訪問https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}
  • 用戶受權後github會帶上code並跳轉到回調地址
  • 拿到code後請求https://github.com/login/oauth/access_token獲取用戶access_token
  • 拿到access_token就能夠調用github api

因爲須要提供回調地址, 而Hyrule並不須要任何服務器, 所以在回調這一步須要作些處理

  • 回調地址填寫localhost, 用戶受權後會跳轉回咱們開發的web頁面, 控制權又回到咱們手上

  • 在electron中能夠監聽跳轉, 所以在監聽到跳轉時候阻止默認事件, 並獲取url上的code, 接下來獲取access_token便可

    authWindow.webContents.on('will-redirect', handleOauth)
    authWindow.webContents.on('will-navigate', handleOauth)
    
    function handleOauth(event, url) {
      const reg = /code=([\d\w]+)/
      if (!reg.test(url)) {
        return
      }
      event.preventDefault()
      const code = url.match(reg)[1]
      const authUrl = 'https://github.com/login/oauth/access_token'
      fetch(authUrl, {
        method: 'POST',
        body: qs.stringify({
          code,
          client_id: GITHUB_APP.CLIENT_ID,
          client_secret: GITHUB_APP.SECRET
        }),
        headers: {
          Accept: 'application/json',
          'Content-Type': 'application/x-www-form-urlencoded',
          Referer: 'https://github.com/',
          'User-Agent':
            'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36'
        }
      })
        .then(res => res.json())
        .then(r => {
          if (code) {
            const { access_token } = r
            setToken(access_token)
            // Close the browser if code found or error
            getWin().webContents.send('set-access-token', access_token)
            authWindow.webContents.session.clearStorageData()
            authWindow.destroy()
          }
        })
    }
    
    複製代碼

api service開發

作api service開發只是爲了更快速調動github api

npm上也有@octokit/rest, 已經封裝好了全部github api, 文檔也足夠齊全, 但因爲笨app用到接口很少, 所以我選擇了自行封裝

列舉下所用接口

  • 獲取當前用戶
  • 獲取用戶全部repo, 包括private
  • 獲取/建立/編輯/刪除issues
  • 獲取repo的tree數據
  • 獲取文件blob數據 (獲取content接口有大小限制, 獲取blob沒有)
  • 建立和刪除file

剛開始直接使用fetch來請求api, 後面發現fetch並不能獲取上傳進度, 後續改回了xhr

service 二次封裝

api service提供最基礎的api調用, 須要再進一步封裝以知足功能需求

圖牀部分service

列舉下圖牀所須要service

  • 獲取repo下某sha的tree data(其實就是獲取repo的目錄結構, 默認第一層爲master)
  • 上傳圖片和刪除圖片

看似所須要接口很少, 但實際開發起來仍是花了很多時間, 不過更可能是在優化流程上

如何加載github圖片

github倉庫分爲了public和private, 而public倉庫的文件能夠直接經過https://raw.githubusercontent.com/user/repo/${branch-or-sha}/${path-to-file}訪問. 而private則須要經過token方式訪問

  • git-blobs: 能夠獲取任何文件, 返回base64
  • contents: 能夠獲取1mb之內的文件, 返回base64
  • 經過https://access_token@github.com/user/repo/path/to/file 因爲此形式有安全隱患, 所以沒法直接用在<img />上, 可是能夠經過curl形式使用
  • 帶上Authorization訪問raw.githubusercontent.com
    fetch(
      `https://raw.githubusercontent.com/${owner}/${repo}/master/${name}`,
      {
        headers: {
          Authorization: `token ${_token}`
        }
      }
    )
    複製代碼

對於public的倉庫, 直接經過img標籤便可, 對於private, 則須要多一步處理.

經過github api獲取圖片base64後拼接上MIME賦值給img.src便可, 若是以爲base64太長, 能夠進一步轉成blob-url, 而且加上緩存, 則對於同一張圖片只須要加載一次便可.

// base64轉blob
async function b64toblob(b64Data, contentType='application/octet-stream') {
  const url = `data:${contentType};base64,${b64Data}`;
  const response = await fetch(url);
  const blob = await response.blob();
  return blob;
}
複製代碼

按理說上面的方法已經很好地解決private圖片加載, 但因爲使用了react-image圖片組件, 會自動根據圖片加載狀況添加對應加載狀態, 若是使用上述方法, 會致使圖片先顯示error而後才轉成正常圖片.

想要private圖片也能直接經過src形式加載, 須要一個"後臺"幫咱們加載圖片, 而後返回對應的http response, 而剛好electron上能夠自定義協議, 並進行攔截, 那麼咱們能夠定義一個github:協議, 全部該url都由electron攔截並處理

這裏我選擇了streamprotocol

總體流程大概以下:

  • electron註冊自定義協議github://
  • 構造圖片src: github://${repo}/${sha}/${name}
  • electron攔截請求, 解析獲得repo, shaname信息
  • electron發起github api, 獲得圖片的base64
  • 將base64轉成buffer, 並構形成Readable後返回
// 註冊協議
function registerStreamProtocol() {
  protocol.registerStreamProtocol('github', (req, callback) => {
    const { url } = req
    getImageByApi(url, getToken(), callback)
  })
}

function getImageByApi(
  url: string,
  _token: string,
  callback: (
    stream?: (NodeJS.ReadableStream) | (Electron.StreamProtocolResponse)
  ) => void
) {
  // 解析url
  const [, src] = url.split('//')
  if (!src) return
  const [owner, repo, sha, name] = src.split('/')
  const [, ext] = name.split('.')
  // 獲取圖片數據
  fetch(`https://api.github.com/repos/${owner}/${repo}/git/blobs/${sha}`, {
    headers: {
      Authorization: `token ${_token}`,
      'content-type': 'application/json'
    }
  }).then(async res => {
    const data = (await res.json()) as any
    // 轉成Buffer
    const buf = Buffer.from(data.content, 'base64')
    // 構造Readable
    const read = new Readable()
    read.push(buf)
    read.push(null)
    res.headers
    callback({
      statusCode: res.status,
      data: read,
      // 將對應頭部也帶上
      headers: {
        'Content-Length': data.size,
        'Content-Type': `image/${ext}`,
        'Cache-Control:': 'public',
        'Accept-Ranges': 'bytes',
        Status: res.headers.get('Status'),
        Date: res.headers.get('date'),
        Etag: res.headers.get('etag'),
        'Last-Modified': res.headers.get('Last-Modified')
      }
    })
  })
}
複製代碼

除了使用github api, 也能夠直接經過raw獲取, 相似一個請求轉發

按道理這樣返回該請求的相應是最直接的方法, 可是該方法是在太慢了, 對node不夠精通, 暫時想不到緣由

function getImageByRaw(
  url: string,
  _token: string,
  callback: (
    stream?: (NodeJS.ReadableStream) | (Electron.StreamProtocolResponse)
  ) => void
) {
  const [, src] = url.split('//')
  // /repos/:owner/:repo/git/blobs/:sha
  const [owner, repo, , name] = src.split('/')
  // 直接fetch raw文件, 而且帶上authorization便可
  fetch(
    `https://raw.githubusercontent.com/${owner}/${repo}/master/${name}`,
    {
      headers: {
        Authorization: `token ${_token}`
      }
    }
  ).then(res => {
    // 直接返回reabable
    // 可是太慢了, 不知道爲什麼
    callback({
      headers: res.headers.raw(),
      data: res.body,
      statusCode: res.status
    })
  })
}
複製代碼

cache緩存

在圖片管理中目錄結構, 其實就是對應git上的一棵tree, 而要達到同步效果, 必須從github中拉取對應的tree data

但其實只須要在該tree第一次加載時候去github拉取數據, 一旦數據拉取到本地, 後續目錄讀取就能夠脫離github

  • 第一次訪問根目錄
  • 拉取master目錄結構
  • 進入目錄A
  • 根據目錄A的sha拉取其目錄結構
  • 返回根目錄
  • 直接讀取緩存中目錄結構

可見全部目錄只須要拉取一次數據便可, 後續操做只須要在本地cache中完成

那麼能夠構造一個簡單的緩存數據結構

class Cache<T> {
  _cache: {
    [k: string]: T
  } = {}
  set(key: string, data: T) {
    this._cache[key] = data
  }
  get(key: string) {
    const ret = this._cache[key]
    return ret
  }
  has(key: string) {
    return key in this._cache
  }
  clear() {
    this._cache = {}
  }
}

export type ImgType = {
  name: string
  url?: string
  sha: string
}

export type DirType = {
  [k: string]: string
}

export type DataJsonType = {
  images: ImgType[]
  dir: DirType
  sha: string
}

class ImageCache extends Cache<DataJsonType> {
  addImg(path: string, img: ImgType) {
    this.get(path).images.push(img)
  }
  delImg(path: string, img: ImgType) {
    const cac = this.get(path)
    cac.images = cac.images.filter(each => each.sha !== img.sha)
  }
}
複製代碼

只要緩存中沒有對應的key, 則從github上面拉取數據, 若是存在則直接在該緩存中操做, 每次增長或刪除圖片, 只須要更新其sha便可.

舉例:

class ImageKit {
  uploadImage(
    path: string,
    img: UploadImageType,
  ) {
    const { filename } = img
    const d = await uploadImg()
    // 獲取緩存中數據
    cache.addImg(path, {
      name: filename,
      sha: d.sha
    })
  }
}
複製代碼

對於issues也是一樣方法來緩存, 只不過數據結構有點變化, 這裏就不敘述.

異步隊列

github api有提供批量操做tree的接口, 可是並無想象中那麼容易使用, 反而有點複雜

在這裏便沒有考慮經過操做tree形式完成批量上傳, 而是將批量上傳拆分紅一個個任務逐個上傳, 也就說在交互上批量, 實際上仍是單一.

這裏用了lite-queue來管理異步隊列(這個庫也是後來才拆出來的), 使用方法很簡單

const queue = new Queue()
const d = await queue.exec(() => {
  return Promise.resolve(1000)
})
console.log(d) // 1000
複製代碼

其實就是根據調用順序, 保證上一個promise執行完後才執行下一個, 而且提供正確的回調和相似Promise.all操做

monaco編輯器加載

這裏選擇monaco-editor做爲編輯器, 對於使用vscode的開發者來講這樣更容易上手

如何初始化, 官方文檔有詳細說明, 下面附上初始化配置

this.editor = monaco.editor.create(
  document.getElementById('monaco-editor'),
  {
    value: content,
    language: 'markdown',
    automaticLayout: true,
    minimap: {
      enabled: false
    },
    wordWrap: 'wordWrapColumn',
    lineNumbers: 'off',
    roundedSelection: false,
    theme: 'vs-dark'
  }
)
複製代碼

添加快捷鍵監聽

監聽CtrlOrCmd + S完成文章保存

monaco-editor有提供相關api, 這裏直接上代碼

const KM = monaco.KeyMod
const KC = monaco.KeyCode
this.editor.addCommand(KM.CtrlCmd | KC.KEY_S, this.props.onSave)
複製代碼

粘貼圖片直接上傳

寫文章不免不了貼圖片, 而貼圖片意味着須要有一個圖牀, 結合hyrule, 能夠藉助github作圖牀, 而後在文章中引入, 步驟分別爲:

  • 上傳圖片
  • 複製markdown url
  • 粘貼在文章中

而最理想的操做是直接拖動到編輯器或者ctrl + v粘貼圖片, 在github issues中咱們也能夠直接粘貼圖片並完成圖片上傳, 這裏就能夠模仿github的交互

  • 用戶上傳圖片
  • 肯定當前光標所在位置
  • 插入(Uploading...)提示
  • 圖片上傳完後替換掉上一部的(Uploading...)
  • 完成圖片插入

瀏覽器有提供監聽paste的接口, 而肯定光標位置以及文本替換就要藉助monaco-editor的api了

分別是:

邏輯步驟爲:

  • 獲取當前用戶光標位置, 記錄爲startSelection,
  • clipboardData中獲取上傳的file
  • 再次獲取當前光標, 記錄爲endSelection, 兩個selection能夠肯定上傳前的選區
  • 根據startSelectionendSelection建立一個range
  • 調用executeEdits, 在上一步的range中執行文本插入, 插入![](Uplaoding...)
  • 再次獲取當前光標, 記錄爲endSelection,此時光標在uploading...以後, 用於後續替換
  • 上傳圖片
  • 根據startend再次建立range
  • 調用executeEdits插入圖片![](imgUrl)
  • 獲取光標後當即調用setPosition, 能夠將光標恢復到圖片文字後
  • 完成圖片上傳

代碼以下:

window.addEventListener('paste', this.onPaste, true)
function onPaste(e: ClipboardEvent) {
    const { editor } = this
    if (editor.hasTextFocus()) {
      const startSelection = editor.getSelection()
      let { files } = e.clipboardData
      // 以startSelection爲頭, 建立range
      const createRange = (end: monaco.Selection) => new monaco.Range(
        startSelection.startLineNumber,
        startSelection.startColumn,
        end.endLineNumber,
        end.endColumn
      )
      // 使用setTimeout, 能夠確保光標恢復在選區以後
      setTimeout(async () => {
        let endSelection = editor.getSelection()
        let range = createRange(endSelection)
        // generate fileName
        const fileName = `${Date.now()}.${file.type.split('/').pop()}`
        // copy img url to editor
        editor.executeEdits('', [{ range, text: `![](Uploading...)`}])
        // get new range
        range = createRange(editor.getSelection())
        const { url } = uploadImage(file)
        // copy img url to editor
        editor.executeEdits('', [{ range, text: `![](${url})`}])
        editor.setPosition(editor.getPosition())
      })
    }
  }
複製代碼

markdown預覽以及滾動同步

要作markdown編輯器, 少不了即時預覽功能, 而即時預覽又少不了滾動同步

該功能剛開始也花了很多時間去思考如何實現

第一次實現方案是根據編輯器滾動的百分比, 來設置預覽區的百分比, 但其實這樣並不合適, 舉例子就是插入一張圖, 只佔據編輯器一行, 而渲染區能夠佔據很大的空間

其實網上也有很多實現方法, 我這裏也講講個人實現方法, 用起來仍是蠻好的..

滾動同步原理

滾動同步最主要的是渲染當前編輯器中的內容, 而編輯器隱藏的, 是咱們不須要渲染的, 換一個角度想, 若是咱們把編輯器所隱藏的部分渲染出來, 那它的高度就是渲染區的scrollTop, 因此只須要獲取編輯器隱藏掉的內容, 而後將其渲染到一個隱藏dom中, 計算高度, 將次高度設爲渲染區的scrollTop, 就能夠完成滾動同步

代碼實現

獲取monaco-editor隱藏的行數

因爲沒有找到對應api直接獲取隱藏的行數, 所以用最原始的辦法

  • 監聽editor滾動
  • 獲取scrollHeightscrollTop
  • 使用scrollTop/LINE_HEIGHT粗略獲取隱藏掉的行數
this.editor.onDidScrollChange(this.onScroll)
const onScroll = debounce(e => {
  if (!this._isMounted) return
  const { scrollHeight, scrollTop } = e
  let v = 0
  if (scrollHeight) {
    v = scrollTop / LINE_HEIGHT
  }
  this.props.onScroll(Math.round(v))
}, 0)
複製代碼
渲染並計算隱藏區域的高度
let dom = null
// 獲取編輯器dom
function getDom(): HTMLDivElement {
  if (dom) return dom
  return document.getElementById('markdown-preview') as HTMLDivElement
}

let _div: HTMLDivElement = null
// content爲全部markdown內容
// lineNumber爲上一部獲取的行數
function calcHeight(content: string, lineNumber) {
  // 根據空格分行
  const split = content.split(/[\n]/)
  // 截取前lineNumber行
  const hide = split.slice(0, lineNumber).join('\n')
  // 建立一個div, 並插入到body
  if(!_div) {
    _div = document.createElement('div')
    _div.classList.add('markdown-preview')
    _div.classList.add('hidden')
    document.body.append(_div)
  }
  // 將其寬度設成跟渲染區同樣寬度, 方便高度計算
  _div.setAttribute('style', `width: ${getDom().clientWidth}`)
  // 渲染內容
  _div.innerHTML = parseMd(hide)
  // 獲取div的高度
  // 此處-40是修正渲染區的paddingTop
  return _div.clientHeight - 40
}
複製代碼
設置渲染區scrollTop

獲取隱藏區的高度後便可設置對應的scrollTop

getDom().scrollTo({
  top
})
複製代碼

此時滾動已經有了較好的同步, 雖然算不上完美, 但我以爲仍是一個不錯的解決方案.

項目打包

使用了electron-builder盡心打包, 只需添加electronrc.js配置文件便可

module.exports = {
  productName: 'App name', // App 名稱
  appId: 'com.App.name', // 程序的惟一標識符
  directories: {
    output: 'package'
  },
  files: ['dist/**/*'], // 構建好的dist目錄
  // copyright: 'Copyright © 2019 zWing',
  asar: true, // 是否加密
  artifactName: '${productName}-${version}.${ext}',
  // compression: 'maximum', // 壓縮程度
  dmg: { // MacOS dmg形式安裝完後的界面
    contents: [
      {
        x: 410,
        y: 150,
        type: 'link',
        path: '/Applications'
      },
      {
        x: 130,
        y: 150,
        type: 'file'
      }
    ]
  },
  mac: {
    icon: 'build/icons/icon.png'
  },
  win: {
    icon: 'build/icons/icon.png',
    target: 'nsis',
    legalTrademarks: 'Eyas Personal'
  },
  nsis: { // windows的安裝包配置
    allowToChangeInstallationDirectory: true,
    oneClick: false,
    menuCategory: true,
    allowElevation: false
  },
  linux: {
    icon: 'build/icons'
  },
  electronDownload: {
    mirror: 'http://npm.taobao.org/mirrors/electron/'
  }
}

複製代碼

最後執行electron-builder --config ./electronrc.js -mwl進行打包便可, -mwl指的是打包三種平臺

更詳細的打包配置仍是去官方文檔查看, 這一部分沒有過多深刻了解

結語

第一次開發electron應用, 還有許多地方作的不夠好, 後續繼續完善.

相關文章
相關標籤/搜索