注:本文主要记录了创建一个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
|
if ( 'serviceWorker' in navigator ) { navigator.serviceWorker.register( 'sw.js' ).then(function(registration) {
console.log( 'ServiceWorker registration successful. Scope: ' + registration.scope ) }).catch(function(err) {
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)) .then(() => self.skipWaiting()) ) });
self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request).then(res => { 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
: 缓存接口
waitUntil
,ExtendableEvent.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;
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会抛出跨域相关的错误。
再正常不过了,未经配置的情况下,fetch是不允许跨域的。注意sw.js
中的一段:
1 2 3 4
| caches.match(event.request).then(res => { 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); }
self.addEventListener('fetch', event => { if (isCORSRequest(event.request.url)) { return; } event.respondWith( caches.match(event.request).then(res => { 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() { 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() { 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
|
function renderDropdown(obj, dom, isInit) { if (!obj) { return; } dom.innerText = ""; 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 = `就在附近...` } 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; }
|
参考