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应用, 还有许多地方作的不够好, 后续继续完善.

相关文章
相关标签/搜索