[笔记]electron 实践笔记

最近工作上需要把 web 上的内容打包成一个客户端, 以便于提供一些更好用户体验的功能. 我们的前端是目前比较标准的 webpack + react. 因此, 首先想到的是把 webpack 生成的文件丢进 electron 里运行.

看似很简单, 但实践还是踩了蛮多坑的. 当然一定程度上也是由于我个人对 electron 并不太熟悉, 各种实践也是一边摸索一边进行的…

工作原理

electron 启动时会执行一个 js 脚本创建一个 node 进程.

1
electron ./main.js

这个进程可以用于初始化 electron 应用. 并且控制窗口的打开与事件监听. 在官方文档里, 这个进程被称作 Main Process

1
2
3
4
5
6
7
8
9
10
const electron = require('electron')

const { BrowserWindow } = electron

mainWindow = new BrowserWindow({width: 1280, height: 720})

// Load code/index.html page
mainWindow.loadURL('file://index.html')

// ...

通过 loadURL 打开的窗口, 基本上就是一个内嵌 chrome 内核的浏览器, 只不过 electron 还贴心的注入了一些 nodejs 的模块, 比如 commonjs 的 require/modules, fs, path 之类的.

这个浏览器窗口的进程在官方文档里, 被称作 Renderer Process.

主进程和渲染进程可以调用的 API 各不相同, 渲染进程里提供了一个 remote 的模块可以用来执行主模块里的东西.

通过这些接口, 我们可以做到许多浏览器做不到的事情, 比如本地文件的读写, 代理, cors, 设备驱动, 等等等等…

绝对路径转换

我们的 webpack 的项目构建的包引用资源使用的是绝对路径, 如果 electron 加载的 html 方式是

1
window.loadURL(file://path/to/index.html)

那么 index.html 中绝对路径引用的资源, 比如 <img src="/a.png" /> 就会被定位到 file://a.png 上, 然而我们期望的是 `file://{project_path}/a.png

当然我们也可以修改 webpack 的 publicPath, 改为相对引用即可. 不过 electron 遇到的问题, 还是更期望可以直接通过 electron 解决.

对比这个场景里 http://file:// 加载的不同, 最大区别是 http 协议后面有一个域名来标记绝对路径的范围, 而 file 协议后面直接就跟上了路径, 在其范围内, 绝对路径是指向根部的.

所以我们只需要想办法给 file 协议也加上一个域, 再在 electron 里处理就好了.

electron 提供的 protocol 接口就可以用来注册或拦截一个协议.

我们可以注册一个新的文本协议来实现域到磁盘路径的转换

1
2
3
4
5
6
7
8
const PROTOCOL_NAME = 'local'
const BASE_URL = `${PROTOCOL_NAME}://ekoneko/`
window.loadURL(BASE_URL)
protocol.registerFileProtocol(PROTOCOL_NAME, (request, callback) => {
const url = request.url.replace(BASE_URL, '') || 'index.html'
const filePath = path.normalize(`${__dirname}/code/${url}`)
callback(filePath)
})

上面的栗子里, 我们定义了一个 local 的协议, 会将 local://ekoneko/index.html 的请求转换为磁盘路径对应的文件请求.

此外, 我们也可以直接在 file 协议的基础上做拦截, 实现相同的效果

1
2
3
4
5
6
7
8
const PROTOCOL_NAME = 'file'
const BASE_URL = `${PROTOCOL_NAME}://ekoneko/`
window.loadURL(BASE_URL)
protocol.interceptFileProtocol(PROTOCOL_NAME, (request, callback) => {
const url = request.url.replace(BASE_URL, '') || 'index.html'
const filePath = path.normalize(`${__dirname}/code/${url}`)
callback(filePath)
})

基本上很相似, 只是把协议替换成了 file, registerFileProtocol 方法替换成了 interceptFileProtocol. 从规范上我个人更倾向不破坏现有协议, 但最终通过实践我还是认为后一种实现更有优势.

原因有二:

一是不用额外处理 CORS 问题. 由于和服务端的通信还是走 htts? 协议, 对于 local:// 的环境下, 请求需要响应头提供一部分跨域头. 当然在 electron 中也可以通过 webRequest手动的拦截 http 响应并注入 headers

1
2
3
4
5
6
7
window.webContents.session.webRequest.onHeadersReceived((detail, callback) => {
detail.responseHeaders['Access-Control-Allow-Origin'] = ['local://ekoenko']
detail.responseHeaders['Access-Control-Allow-Credentials'] = ['true']
callback({
responseHeaders: detail.responseHeaders,
})
})

(BTW, electron header 的值必须是一个数组, 写成字符串不 work 也不报错, 写的时候也稍微坑了下)

另外一个区别则是目标版本的 electron, 自定义协议用不了 serviceworker.

适配相对协议

解决路径的问题, electron 的展示 UI 看起来基本上都正常了, 可是控制台里还是有 404 的错误信息.

定睛一看, 有一个第三方服务中, url 的写法是

1
//example.com/wtf

这样, 加载时就被补全了 file 协议, 成了

1
2
file://example.com/wtf
-> /dirname/example.com/wtf

我并没有找到一个很好的方法处理这个问题 (尝试想让 electron 的一个自定协议同时支持 file or http 两种方式, 并没有成功). 最终, 我选择了 service worker.

在 service worker 中, 我们可以监听页面上所有 url 访问事件. 拦截并修改返回结果. 因此, 我们只需要找到 file 协议的请求, 并且格式为 file:// + hostname(:port) + pathname, 将之转向到 http 对应位置即可.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
self.addEventListener('fetch', (ev) => {
const {request} = ev

if (request.url.slice(0, 7) === 'file://') {
const url = request.url.slice(8)
if (likeUrl(url)) {
ev.respondWith(fetch(`https://${url}`, {
method: request.method,
mode: request.mode,
cache: request.cache,
headers: request.headers,
credentials: request.credentials,
}))
}
}
})

service worker 注入

同样的, service worker 的注入也可以不去修改 webpack 的配置或项目代码.

我们可以在 mainWindow.loadURL() 之前, 先打开一个隐藏的窗口来进行 service worker 的注册.

1
2
3
4
serviceWindow = new BrowserWindow({width: 0, height: 0, show: false})
serviceWindow.loadURL('file://domain/serviceworker.html')

serviceWindow.on('page-title-updated', createMainWindow);

servicework er.html 代码如下

1
2
3
4
5
6
<html>
<script>
navigator.serviceWorker.register('/sw.js').then(() => {
document.title = 'sw loaded'
})
</script>

这里图省事用了 page-title-updated 事件来监听 service worker 注册动作的完成.

注意当主窗口关闭时, 需要 serviceWindow.destroy() 销毁掉这个隐藏窗口, 否则可能导致 electron 的进程继续存在.

这里补充一个小技巧, service worker 缓存在开发阶段很讨厌, 可以利用 electron 的 clearStorageData 清除 service worker 以及其他类型缓存. 可以定义在 close 事件上, 每次关闭窗口都可以自动清除存储缓存.

1
2
3
4
5
6
7
mainWindow.on('close', function () {
mainWindow.webContents.session.clearStorageData({
storages: ['serviceworkers'],
})

serviceWindow.destroy();
})