我的第一个Electron应用

前一阵突发奇想,想写一个汇总所有骑行数据的网页。又想到最近看了下Electron,干脆写一个能够生产这样网页的工具,造福自己,造福他人。

Ahbsc6.png

Electron是啥

Electron)一套由Github开发的开源库,基于ChromiumNode.js,支持通过HTML、JavaScript、CSS来构建跨平台(Mac、Windows、Linux)应用。Electron起初是为文本编辑器Atom而开发的一套开发框架,名为Atom Shell。现如今已经为众多应用所用。

从开发者角度看,Electron分为mainrenderer两部分。前者运行在主进程中,以创建web页面的方式显示一个GUI;后者是渲染进程,每个Electron的web页面运行在其中。通常的浏览器内,网页通常运行在一个沙盒的环境不能够进行原生操作。 在Electron中,可以在渲染进程使用Node.js的API和Electron提供的众多API(GUI相关除外),和操作系统进行一些低级别的交互。主进程和渲染进程通过ipcMainipcRenderer相互沟通;也可以通过remote的方式发起,后者要更简洁些。

在项目结构上,官网并没有限制,electron-webpackproject-structure可以参考,安全性上,可以参考官网的介绍。要想获得对Electron概念的快速认识,可以看看关于Electron快速入门,再去知乎Electron精华话题看看,或者看看awesome list也是极好的。

调研准备

地图考虑还是使用百度地图API(因为上个小项目用的就是百度地图,好上手),根据demo演示来看,根据数据点画个折线是没什么问题的。地图的part没问题了。

下面就是数据的part。去确认了下我骑行常用的APP行者,网页和APP都有导出功能。导出格式为.gpx的gps数据文件。OK,数据的来源也有了。

至于怎么把这些点连线搁在地图上,就是我要干的活了。

功能设计

但是事情没那么简单,既然选择Electron来练手,光做个展示的网页出来就很没意思了。这也不是Electron的用武之地。于是能够想到的就是,做一个可以生成上面那个网页的工具,一方面减轻我的负担,让我在日后维护时省心省力;另一方面也能造福他人嘛。

现在整理一下,我拿在手里的是一堆.gpx的文件,产出是可以画图的网页。稍微分解一下:

  • 网页是需要独立存在,不需要用户配置的,这些gps数据必须单独存储,可以使用前端友好的JSON文件。这个转译过程需要在Electron应用中完成
  • 网页需要能够配置生成,不需要用户手写,因此在应用里需要填充HTML模板,生产HTML文件。
  • 页面并不复杂,不需要使用Vue、React甚至webpack的加持,作为我的第一个Electron应用,把握整体感受要紧

开写

相关环境

安装Electron过程中,报错node版本过低。只能重新安转新版本的node,windows下只有nvm-windows这个选择。安装完成后,之前全局安装的npm包只能重头再安一遍。先安装nrm再说。

注意:安装nvm-windows前,强烈建议卸载已有的Node.js

boilerplate

boilerplate即骨架。现在前端的环境里,一门成熟的开源库是一定有一堆boilerplate的,毕竟程序猿懒人多。Electron也不能免俗。可以从awesome list中挑一个看上眼的。如果项目比较大,可以直接用electron-vue这种省心省力,一键式配置,开发打包工具一应齐全。这里我从官网提到的quick start,除了一些样例代码,啥都没有,正合我意。

(我曾经尝试使用electron-webpack-quick-start,想着顺便打包了electron-builder,还有webpack、热加载,岂不美哉。不过根据这里所说,是没有index.html暴露给你的,这几乎就意味着必须要用Vue、React这样的解决方案,但是electron-webpack这个库并没有包括,需要自己add-on,但是按照文档所说的操作后,并不能识别.vue文件,而且也没有vue-devtool。这是坑之一

转译

转译过程是在renderer.js中完成的。实际上,项目里大多数业务逻辑也是在渲染进程中完成的。核心在把gpx文件里的信息解析出来,除了<desc></desc>中的meta信息之外,其余的点结构大致像下面这样;

1
2
3
4
5
6
<trkpt lat="40.106419" lon="116.369812">
<ele>40.6</ele>
<time>2017-03-04T16:52:36Z</time></trkpt>
<trkpt lat="40.106531" lon="116.369916">
<ele>59.8</ele>
<time>2017-03-04T16:52:39Z</time></trkpt>

幸运的是,npm上早就有gpx的parser。gpx-parse的功能足够满足我们需要了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var gpxParse = require("gpx-parse");
//from file
gpxParse.parseGpxFromFile("/path/to/gpxFile", function(error, data) {
//do stuff
});
//or from string
gpxParse.parseGpx("<gpx></gpx>", function(error, data) {
//do stuff
});
// or an external file via HTTP(S)
gpxParse.parseRemoteGpxFile("http://host.tld/my.gpx", function(error, data) {
//do stuff
});

顺带写几个input框(包括<input type="file">),测试一下,没啥问题(排除掉中间处理yarn和npm冲突问题的话)。观察一下,返回值是一个GpxResult类型,里面有metadataroutestracks等字段,只有tracks中记录着点的信息。考虑到tracks和里面segments字段是数组的情况,要进行一下flatten的处理。最后,整个转译过程大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function serialize(file, index) {
gpxParse.parseGpxFromFile(file.path, function(error, data) {
if (error || !data.tracks) {
alert('文件内容错误')
return
}
const track = data.tracks;
// 扁平化处理
const flattenTrack = track.reduce((acc, cur) => (cur.segments.reduce((acc, cur) => acc.concat(cur), []).concat(acc)), [])
const points = flattenTrack.map(({lat, lon}) => ({lat, lng: lon}))
try {
const jsonData = JSON.stringify(points)
const pathStr = path.join(OUTPUT_PATH, `${index}.json`)
remote.require('fs').writeFile(pathStr, jsonData,'utf8', err => {
if (err) throw err
})
} catch (e) {
console.warn(e)
alert('文件序列化失败')
}
});
}

写一个示例网页

既然最后的网页是生成出来的,就有第一个能够成功工作起来的网页作为模子,好抽离范本出来。先搭一个最简单的HTML架子,插入百度地图的script标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
body, html, #map {width: 100%; height: 100%; overflow: hidden; margin:0;}
</style>
<title>我的骑行轨迹</title>
</head>
<body>
<div id="map"></div>
<script src="https://api.map.baidu.com/api?v=2.0&ak=你的秘钥"></script>
</body>
</html>

下面我们把工作稍微分析一下:

  • 从本地读取JSON文件,意味着自己实现一个ajax,考虑兼容性(毕竟没了babel和webpack),使用XMLHttpRequest
  • 读取当然得是异步的,JSON文件很有可能很多,需要依次进行
  • 地图配置和画图就很简单了,参考API就行了

第一个工作不难:

1
2
3
4
5
6
7
8
9
10
function getJSON(path, callback) {
var request = new XMLHttpRequest();
request.open('GET', path, true);
request.onreadystatechange = function() {
if (request.readyState === 4 && request.status === 200) {
callback(request.response);
}
}
request.send();
}

第二个工作也不难,在递归函数的外面设置控制递归的变量就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var pool = Array.apply(null, Array(length)).map(function(v, i){return i+'.json';});
function paint() {
if (!pool.length) return;
getJSON(pool.shift(), function(res) {
if (res) paint();
try {
var pois = JSON.parse(res).map(function(point) {
return new BMap.Point(point.lng, point.lat);
});
var polyline = new BMap.Polyline(pois);
map.addOverlay(polyline);
} catch(e) {
console.warn(e);
}
})
}

OK,大功告成(排除其余逻辑bug之后),赶紧接上renderer.js那边转译好的JSON文件看看骑行轨迹吧!

你以为事情会这么简单么?

当然不。

坐标换算

图是出来了,但是路线有偏差,发现明显有所平移。这是怎么回事,搜索过后才发现,百度所采用的坐标并不是gps数据中的真实大地坐标,而是在火星坐标基础上再次加密的百度坐标(更多)。官网示例上也给出了gps坐标转成百度坐标的API。

得,那就在转译成JSON数据前多map一段呗。仔细一看,Convertor的介绍里赫然写着“一次最多10个点”,居然还限流(其实不只是限流的问题,递归的写法也要变化)。一条路线至少上千个点呢,算了先试试看速度吧。

两条路线用了30s才显示出来,果然很慢……

只能自己实现转译过程了,网上倒是有一些例子,都差不多。尝试了一下,发现有点效果,但是路线还是有偏移。试了半个多小时后,总算找到了一个完美的JS转译代码,感谢原作者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function delta(lat, lon) {
// Krasovsky 1940
//
// a = 6378245.0, 1/f = 298.3
// b = a * (1 - f)
// ee = (a^2 - b^2) / a^2;
const a = 6378245.0; // a: 卫星椭球坐标投影到平面地图坐标系的投影因子。
const ee = 0.00669342162296594323; // ee: 椭球的偏心率。
let dLat = transformLat(lon - 105.0, lat - 35.0);
let dLon = transformLon(lon - 105.0, lat - 35.0);
const radLat = lat / 180.0 * PI;
let magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * PI);
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * PI);
return {lat: dLat, lon: dLon};
}
// ...
function transformLat (x, y) {
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * PI) + 320 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0;
return ret;
}
function transformLon (x, y) {
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0;
return ret;
}
// ...

这转译过程,要不是有先行者,我怕是要倒在这里了。

HTML模板

示例HTML已经工作起来了,现在就是抽出模子的过程。网页并不复杂,可以用简单的HTML template解决问题。John Resig的方案如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Simple JavaScript Templating
// John Resig - https://johnresig.com/ - MIT Licensed
(function(){
var cache = {};
this.tmpl = function tmpl(str, data){
// Figure out if we're getting a template, or if we need to
// load the template - and be sure to cache the result.
var fn = !/\W/.test(str) ?
cache[str] = cache[str] ||
tmpl(document.getElementById(str).innerHTML) :
// Generate a reusable function that will serve as a template
// generator (and which will be cached).
new Function("obj",
"var p=[],print=function(){p.push.apply(p,arguments);};" +
// Introduce the data as local variables using with(){}
"with(obj){p.push('" +
// Convert the template into pure JavaScript
str
.replace(/[\r\t\n]/g, " ")
.split("<%").join("\t")
.replace(/((^|%>)[^\t]*)'/g, "$1\r")
.replace(/\t=(.*?)%>/g, "',$1,'")
.split("\t").join("');")
.split("%>").join("p.push('")
.split("\r").join("\\'")
+ "');}return p.join('');");
// Provide some basic currying to the user
return data ? fn( data ) : fn;
};
})();

看上去很眼熟,以前的项目似乎见到过。

把之前的示例HTML放在index.html<script type="text/template"></script>中,在渲染进程里加上代码看看?

1
console.log(tmpl('template'));

嗯……报错了。提示“Missing ')' after argument list”。加断点调试发现是标签里的"打断了new Function的语句。尝试了多种方法无效后,索性使用encodeURIComponent想处理掉麻烦的特殊符号,但是这么做之后就无法匹配<%=%>了。

于是最后选择underscore的template方法。再试试……

没问题了。之后把允许用户填写的部分抽出来,就可以把index.html的生成放在转译代码身旁了。

1
2
3
4
5
6
7
const template = document.getElementById('template').innerHTML
// ...
remote.require('fs').writeFile(path.join(OUTPUT_PATH, 'index.html'), tmpl(template)(data).replace(/&lt;/g, '<'),'utf8', err => {
if (err) throw err
else alert('生成完毕!\n将output文件夹下所有文件上传到服务器即可查看效果!')
})
})

再次运行,测试生成的网页能否工作?答案当然是可以。

苦力活

技术上的问题解决了,现在从用户填写信息到最后生成能用的展示页面也没有问题了。初版下面的问题就是美化了。

  • CSS修饰样式
  • 将模板HTML文件压缩(包括JS和CSS),因为反正用户不会修改内容,不需要考虑可读性
  • 一些保护性编程和边缘情况兜底

最后测试结果如下:

AhbD91.png

生成效果如上。

发布

初版开发已经完成了,只剩发布出来给别人用了,考虑到官网文档讲得实在不清不楚,不如用一个好工具帮我们完成。

这里使用electron-builder。跟着介绍里一步步完善package.json和项目结构。加上依赖后,执行yarn dist生成可分发的文件。

嗯……果然失败了。原因很简单,网络错误,Electron镜像文件下载失败。还好淘宝有electron镜像。通过设置ELECTRON_MIRROR环境变量,可以大大加快速度。

1
export ELECTRON_MIRROR=https://npm.taobao.org/mirrors/electron/

然后,再次执行yarn dist,在从Github下载其他相关文件的时候,仍然会网络错误。于是我机智的从网页上下载下来,直接放在了目标目录下。再次执行任务,居然不能识别出来。好吧……

故事的最后,打包还是完成了。不过由于生成文件的目录写成了相对目录,生成的文件得通过搜索才能找到,考虑后面生成在桌面。

–END–

参考