Hyrule - electron+react app开发实践

背景

Hyrule

本文也是在Hyrule下完成

技术栈以及主要依赖

electron提供跨平台PC端运行环境,使用react+antd构建UI界面

monaco-editor提供编辑器功能,使用remark转换markdown

electron如何运行web

electron作用就是提供多端运行环境,实际开发体验跟一般Web开发无二

万事开头难,初次接触的确不知道如何入手,github上也有相应的模板

不管模板如何,核心还是如何在electron中加载html

electron分为主进程(main)和渲染进程(renderer),主进程可以跟操作系统打交道,渲染进程可以说跟页面打交道(webapp),因此只需要在主进程创建一个window来跑页面即可。

如果只是开发普通页面,那只要加载html即可,如果使用webpack开发,则开发时候需要在electron中访问dev-server提供的页面

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, 剩下的工作便跟日常开发一样

项目启动

如上面所说, 在启动开发环境时候, 需要两个进程

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