Hyrule - electron+react app开发实践
背景
本文也是在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_ID
和SECRET
, 并填写回调地址 - 引导用户访问
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.comfetch(`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
,sha
和name
信息 - 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了
分别是:
- getSelection: 获取光标位置
- executeEdits: 执行文本替换
- selection: 恢复光标位置
- monaco.Range: 创建一个range
逻辑步骤为:
- 获取当前用户光标位置, 记录为
startSelection
, - 从
clipboardData
中获取上传的file
- 再次获取当前光标, 记录为
endSelection
, 两个selection可以确定上传前的选区 - 根据
startSelection
和endSelection
创建一个range
- 调用
executeEdits
, 在上一步的range
中执行文本插入, 插入![](Uplaoding...)
- 再次获取当前光标, 记录为
endSelection
,此时光标在uploading...
之后, 用于后续替换 - 上传图片
- 根据
start
和end
再次创建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滚动
- 获取
scrollHeight
和scrollTop
- 使用
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应用, 还有许多地方做的不够好, 后续继续完善.