我的第一个PWA开发记录

注:本文主要记录了创建一个PWA应用的过程,方便日后再次开发类似应用时参考。
github链接:shenlvmeng/Distance: 一次PWA的尝试

准备

安装http-server方便本地测试。

本地开辟文件夹,加入.editorconfig.gitignore(根据样例适当修改)

1
2
3
4
5
6
7
8
9
10
11
# http://editorconfig.org

root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

创建index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
<title>Away from home</title>
<link rel="stylesheet" type="text/css" href="./main.css">
</head>
<body>
<h1>My first PWA Application</h1>
<p>See console for more!</p>
</body>
</html>

在目标文件夹路径下执行http-server,bingo!第一步完成

清单文件

创建清单文件manifest.json,描述应用添加到主屏幕需要的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"name": "Distance from Home",
"short_name": "Distance",
"display": "standalone",
"start_url": "/",
"theme_color": "#8888ff",
"background_color": "#aaaaff",
"icons": [
{
"src": "compass.png",
"sizes": "144x144",
"type": "image/png"
}
]
}

并在index.html中引入:

1
<link rel="manifest" href="manifest.json">

添加Service Worker

Service Worker这个东西可以实现页面的缓存和离线访问,让应用逼近app的体验。

可以在HTML里插入<script>标签引入,这里单独定义一个app.js。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app.js

// Registering ServiceWorker
if ( 'serviceWorker' in navigator ) {
navigator.serviceWorker.register( 'sw.js' ).then(function(registration) {

// Registration was successful
console.log( 'ServiceWorker registration successful. Scope: ' + registration.scope )
}).catch(function(err) {

// Registration failed with error
console.log( 'ServiceWorker registration failed' + err);
});
}

index.html中引用。

下面实现service worker。主要是三个时机:

  • 脚本安装时,写入缓存
  • 脚本获取数据时,先查找缓存
  • 缓存更新版本时,删除原先版本的缓存
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
// 脚本安装时
self.addEventListener('install', event => {
event.waitUntil(
caches.open(cacheStorageKey)
.then(cache => cache.addAll(cacheList))
// 保证在页面更新过程中,新的Service Worker脚本能立即激活生效
.then(() => self.skipWaiting())
)
});

// 通过脚本fetch数据
self.addEventListener('fetch', event => {
event.respondWith(
// 先在cache中找
caches.match(event.request).then(res => {
// cache中没有再使用fetch
return res || fetch(event.request.url);
})
)
});

// 更新静态资源
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(cacheNames.map(name => {
if (name !== cacheStorageKey) {
return caches.delete(name);
}
}));
})
);
});

其中,有下面一些名词:

  • self: Service Worker作用域, 也是全局变量
  • caches: 缓存接口
  • waitUntilExtendableEvent.waitUntil()方法——这会确保Service Worker 不会在waitUntil()里面的代码执行完毕之前安装完成。
  • skipWait,表示强制当前处在waiting状态的脚本进入activate状态

在浏览器中打开localhost:8080/即可,注意PWA必须运行在HTTPS的环境下

加入百度地图支持

参考官方API接入支持

添加文件index.js,写入如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const url4PC = 'https://api.map.baidu.com/api?v=2.0&ak=你的秘钥&callback=init';
const init = window.init;
let map = null;

// 为了能成功调用到callback
window.init = () => {
map = new BMap.Map('map');
map.centerAndZoom(new BMap.Point(121.491, 31.233), 11);
map.enableScrollWheelZoom(true);
window.init = init;
}

function loadMap() {
const script = document.createElement('script');
script.src = url4PC;
document.body.appendChild(script);
}

window.onload = loadMap;

当然需要添加一点CSS,这里从略。

Service Worker支持跨域

接下来问题来了,打开网页测试,页面刷新时Service Worker会抛出跨域相关的错误。

Ahb0hR.png

再正常不过了,未经配置的情况下,fetch是不允许跨域的。注意sw.js中的一段:

1
2
3
4
caches.match(event.request).then(res => {
// cache中没有再使用fetch
return res || fetch(event.request.url);
})

这里我们不仅有跨域请求,还使用着JSONP的方式。后续地图数据和地图图片的展示请求也比较复杂。再经过多次加入respondWith()响应块失败后,干脆跳过这部分跨域的处理。不过还是留下了一个函数,方便后面拓展。

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
function isCORSRequest(url) {
return url.search(host) === -1;
}

// 并存该函数为日后拓展所用
function handleFetchRequest(req) {
const request = isCORSRequest(req.url) ?
new Request(req.url, { mode: 'cors' })
: req;
fetch(req);
}

// 通过脚本fetch数据
self.addEventListener('fetch', event => {
if (isCORSRequest(event.request.url)) {
return;
}
event.respondWith(
// 先在cache中找
caches.match(event.request).then(res => {
// cache中没有再使用fetch
return res || handleFetchRequest(event.request);
})
);
});

目标点管理

核心功能之一是,可以新增计算距离的目标点。在我的设计里,有两种用户友好的添加方式:

  • 找不到地点时,可以搜索出地点,通过搜索结果设置
  • 明确地图上位置时,直接通过鼠标操作设置

前面的利用AutoComplete类,完成自动补全和搜索功能。值得注意的一个坑是,在初次设置好map对象后,每次的搜索结果都会以第一次初始化地点为中心搜索结果。解决方案是,每次<input>框聚焦时,重新构造一个AutoComplete类。

1
2
3
4
<div class="search">
<input class="searchbox" type="text" id="position" size="20" placeholder="输入地点或在地图上双击点选" />
<div id="searchBtn" class="searchBtn"></div>
</div>
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
36
37
38
39
40
41
42
43
44
45
46
function initSearchLocation() {
// 工厂方法,每次聚焦时新创建一个AutoComplete对象
function setAutoComplete() {
const lastVal = doc.getElementById('position').value.trim();
// 建立一个自动完成的对象
const searchBox = new BMap.Autocomplete({
input: "position",
location: map
});
doc.getElementById('position').value = lastVal;

// 鼠标点击下拉列表后的事件
searchBox.addEventListener("onconfirm", event => {
const value = event.item.value;
const searchWord = `${value.province}${value.city}${value.district}${value.street}${value.business}`;
setPlace(searchWord);
});
}

function setPlace(searchWord){
map.clearOverlays();
// 搜索
const cleverSearch = new BMap.LocalSearch(map, {
onSearchComplete: () => {
pauseUpdate();
if (!cleverSearch.getResults().getPoi(0)) {
alert('未找到指定地点,换个关键词试试?');
return;
}
// 获取第一个智能搜索的结果
const res = cleverSearch.getResults().getPoi(0).point;
const marker = new BMap.Marker(res);
map.centerAndZoom(res, 18);
map.addOverlay(marker);
addToBeacons(marker);
}
});
cleverSearch.search(searchWord);
}

// 不使用自动补全,直接回车或点击图标
doc.getElementById("position").addEventListener("focus", event => {
setAutoComplete();
});
// 省略了其他的绑定事件
}

下一步是支持标记点(BeaconNodes)的管理,离线持久化采用localStorage,技术上倒没有什么难度。用户交互上,考虑用右键点击(for PC)和长按操作(PC Mobile)添加,点击标记删除。

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 从页面新增目标点
function addToBeacons(point) {
let tag = win.prompt("给Beacon点起个名字吧");
if (tag === null) {
return;
}
tag = tag.trim().substr(0, 20) || `未知地点#${seq}`;
// 第一个点默认活跃
beaconNodes.push({
id: seq,
point,
tag,
isActive: !beaconNodes.length
});
addBeacon(seq, point, tag, beaconNodes.length === 1);
paintDropdown();
localStorage.setItem(STORAGE_KEY, JSON.stringify(beaconNodes));
}

function removeFromBeacon(id) {
if (!beaconNodes.length) {
return;
}
beaconNodes.splice(beaconNodes.findIndex(node => node.id == id), 1);
paintDropdown();
localStorage.setItem(STORAGE_KEY, JSON.stringify(beaconNodes));
}

// 在地图上新增目标点,方便复用
function addBeacon(id, point, tag, isActive) {
const icon = new BMap.Icon(`imgs/location-${2-isActive}.png`, new BMap.Size(50, 50));
const mark = new BMap.Marker(point, { icon, enableMassClear: false });
const label = new BMap.Label(tag,{ offset: new BMap.Size(32, 2) });
label.setStyle({
border: "#dedede",
padding: "3px 5px",
"font-size": "14px",
"font-weight": "bold"
});
mark.setLabel(label);
mark.getLabel().setTitle(tag);
mark.addEventListener('click', event => {
if (win.confirm(`确认要删除Beacon:${mark.getLabel().getTitle()}`)) {
map.removeOverlay(mark);
removeFromBeacon(id);
}
});
map.addOverlay(mark);
seq = id + 1;
}

// ...
function initMap() {
// 根据storage填充地图
// 注意这里必须使用Point类
beaconNodes.forEach(node => {
addBeacon(node.id, new BMap.Point(node.point.lng, node.point.lat), node.tag, node.isActive);
});
// ...

// 避免和其他事件冲突,使用暂无占用的右键点击
map.addEventListener("rightclick", event => {
addToBeacons(event.point);
});
// 支持移动设备,长按
map.addEventListener("longpress", event => {
addToBeacons(event.point);
})
}

在管理上,新增了下拉菜单管理当前所有节点,这里需要将离线的BeaconNodes映射成DOM结构,考虑到文件已略大,且这一部分比较繁琐,和业务关系不大,故单独抽出一个文件。

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
36
37
38
39
40
41
42
43
44
45
46
47
// dom.js
/*
* 无状态,与业务弱相关
* 只渲染下拉菜单
*/

function renderDropdown(obj, dom, isInit) {
if (!obj) {
return;
}
dom.innerText = "";
// render过程
const fragment = document.createDocumentFragment();
obj.map(node => {
const div = document.createElement('DIV');
div.className = `node_wrapper${node.isActive ? " active" : ""}`;
const innerHTML = `<span>#${node.id}</span><span class="node-tag">${node.tag}</span>`;
div.innerHTML = innerHTML;
const operateBtn = document.createElement('DIV');
operateBtn.className = "operateBtn";
if (node.isActive) {
operateBtn.className += " active"
operateBtn.innerText = "Deactive!"
} else {
operateBtn.innerText = "Active!"
}
div.appendChild(operateBtn);
// 方便统一事件绑定
div.dataset.nid = node.id;
return div;
}).forEach(div => {
fragment.appendChild(div);
});
dom.appendChild(fragment);

// 多次调用只监听一次
if (isInit) {
// 下拉事件
dom.parentNode.children[0].addEventListener("click", event => {
if (dom.parentNode.className.search("hide") !== -1) {
dom.parentNode.className = "dropdown dropdown_container";
} else {
dom.parentNode.className += " hide";
}
});
}
}

HTML和CSS部分就不再赘述了。

在下拉菜单下,可以点击条目跳转到相应位置,以及点击按钮切换当前活动的BeaconNode。这部分实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function initDropdown() {
paintDropdown(true);
// 事件监听
const dropdown = doc.getElementById("dropdown");
dropdown.addEventListener("click", event => {
const nid = event.target.parentNode.dataset.nid || event.target.dataset.nid;
const node = beaconNodes.find(node => node.id == nid);
if (event.target.tagName.toLowerCase() === "div") {
if (node) {
const prev = beaconNodes.find(node => node.isActive);
if (prev) {
prev.isActive = false;
beaconMarkers[prev.id].setIcon(new BMap.Icon("imgs/location-2.png", new BMap.Size(40, 40)));
}
node.isActive = true;
paintDropdown();
beaconMarkers[nid].setIcon(new BMap.Icon("imgs/location-1.png", new BMap.Size(40, 40)));
}
} else {
node && map.panTo(new BMap.Point(node.point.lng, node.point.lat));
}
});
}

当然了,标记点的增删改查还剩下改没有实现,目前来看,和“改”相关的业务是能拖动标记点,方便用户随时更改标记点位置。在addBeacon()函数中新增相关代码。

1
2
3
4
5
6
mark.enableDragging();
// ...
mark.addEventListener('dragend', event => {
editToBeacons(id, { point: event.point });
paintDistance(lastPoint);
});

editToBeacons()函数设计如下:

1
2
3
4
5
6
function editToBeacons(id, newProps) {
const index = beaconNodes.findIndex(node => node.id == id);
Object.assign(beaconNodes[index], newProps);
localStorage.setItem(STORAGE_KEY, JSON.stringify(beaconNodes));
// 不是所有的属性更改都有重绘,交给调用者处理重绘
}

计算距离

计算距离包括距离和方向两部分,既然能拿到两点的经纬度信息,这两个值肯定可以计算出来。计算距离上利用百度地图的API,计算角度上,通过Math.atan2换算得到。

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
36
37
38
39
40
41
42
43
44
function paintDistance(currentPoint) {
let info;
let target = beaconNodes.find(node => node.isActive);
if (!target || !currentPoint) {
info = "N/A , 未知方向";
} else {
let distance = map.getDistance(currentPoint, target.point) || "N/A";
if (distance > 10000) {
distance = `${(distance / 1000).toFixed(2)}km`;
} else if (distance > 100) {
distance = `${distance.toFixed(0)}m`;
} else {
distance = `就在附近...`
}
// 工具函数位于utils.js
const deg = getDirection(target.point, currentPoint);
info = `${distance} , ${deg}点钟方向`
}
// ...
}

function getDirection(p1, p2) {
const pi = Math.PI;
const [x2, y2, x1, y1] = [p1.lng, p1.lat, p2.lng, p2.lat];
let rad = Math.atan2(y2 - y1, x2 - x1);
let absRad = Math.abs(rad);
let clockPointer;
if (absRad < pi / 12) {
clockPointer = 3;
} else if (absRad < pi / 4) {
clockPointer = rad > 0 ? 2 : 4;
} else if (absRad < pi * 5 / 12) {
clockPointer = rad > 0 ? 1 : 5;
} else if (absRad < pi * 7 / 12) {
clockPointer = rad > 0 ? 12 : 6;
} else if (absRad < pi * 3 / 4) {
clockPointer = rad > 0 ? 11 : 7;
} else if (absRad < pi * 11 / 12) {
clockPointer = rad > 0 ? 10 : 8;
} else {
clockPointer = 9;
}
return clockPointer;
}

参考