前一阵突发奇想,想写一个汇总所有骑行数据的网页。又想到最近看了下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–

参考

注:本文主要记录了创建一个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;
}

参考

全文参考《设计模式之禅》Head First Design Pattern这本也不错。

准备

JavaScript学久了,在看设计原则时,顺带温习了下OOP中的一些传统概念。

类图

  • 一>: 关联,类定义中的相互引用,通常使用类的属性表达。 比如客户类和订单类
  • -->: 依赖,最弱的关系,对象间的临时关联,用函数参数、局部变量、返回值等表达
  • 一▷: 继承
  • --▷: 实现
  • 一◇: has-a关系,表示整体与局部,但不稳定。比如公司类和雇员类
  • 一◆: contains-a关系,表示整体与局部,部分不能脱离整体而存在。

override和overload

区别 覆写 重载
单词 OverLoading Override
概念 方法名称相同,参数的类型或个数不同 方法名称相同,参数的类型或个数相同,返回值类型相同
范围 发生在一个类之中 发生在类的继承关系中
权限 一个类中所重载多个方法可以不同的权限 被子类所覆写的方法不能拥有比父类更严格的访问控制权限

接口和抽象类的不同

两者都为“面向契约编程”而存在。总体来说,接口是对能力的抽象,抽象类是对事物的抽象。从而有着下面的不同:

  • 接口被类实现,抽象类被子类继承。
  • 接口只做方法声明,抽象类中可以做方法声明,也可以做方法实现。
  • 接口里定义的变量只能是公共静态常量,抽象类中的变量可以是普通变量。
  • 抽象类里可以没有抽象方法,接口是设计的结果,抽象类是重构的结果。
  • Java中接口可继承接口,并可多继承接口,但类只能单继承。

它们还有以下特点:

  • 在实现时必须全部实现,否则仍是接口/抽象类
  • 抽象类中可以没有抽象方法

设计6原则

SOLID原则:

  • 单一职责(接口细分到单一业务)
  • 里氏替换(实现都按接口来)
  • 依赖倒置(多使用抽象概念)
  • 接口隔离原则(接口尽量细分)
  • 迪米特法则(低耦合)
  • 开闭原则(高内聚,低耦合)

总结来说,就是好好设计、合理拆分接口,一旦确定,避免更改;减少接口耦合;面向接口编程;使用中间层应对需求变更

常见设计模式

单例模式

单例模式是最简单的设计模式。即一个类只有一个实例,或一个构造函数只能初始化一次(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
// 饿汉型
public class Singleton {
private static final Singleton singleton = new Singleton();

// 限制外部访问
private Singleton() {
}

// 暴露的public方法
public static Singleton getInstance() {
return singleton;
}

// 其他方法
// ...
}

// 懒汉型
public class Singleton {
private static Singleton singleton = null;

// 限制外部访问
private Singleton() {
}

// 暴露的public方法
public static sychronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}

JavaScript中就灵活多了。最常用的字面量变量就是最简单的单例,强行使用构造函数,也可以借助闭包的特点实现。另外,JavaScript是单线程,不需要考虑线程安全的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 字面量变量
const singleton = {
// 一些属性
foo: 'bar',
// 一些方法
baz() {
console.log('Hello world!');
}
}

// 闭包
function Singleton() {
const singleton = this;
this.foo = 'bar';
this.baz = () => { console.log('Hello world!'); };
Singleton = () => singleton;
}

拓展

当放开单例限制,编程多例模式时,可以通过加入计数器来限制。但不常用,下面给出JavaScript版本例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Singleton() {
const singleton = this = [];
// 比如限制为3
const limit = 3;
// 一些初始化工作
for (let i = 0; i < limit; i++) {
// ...
[].push({
// ...
});
}
Singleton = () => singleton;
}

工厂模式

工厂模式意思是,将创建对象的过程封装起来,在OOP语言中体现在定义一个创建一类对象的接口,让实例化过程在子类中完成。浅显的来讲,就是让工厂类将具有共性、创建过程复杂的一类对象的创建过程封装起来,便于业务类使用,同时也便于日后拓展。业务类只需要交给工厂类需要的对象类名(Java)或别的标志就可以得到所需对象。

使用场景上,它是new模式的替代品,在任何需要对象的场景下都可以使用,但是只有下面这些情况下是比较合适的:

  • 需要灵活解耦的框架
  • 产品类创建过程复杂、有差异性且有共性。比如连接邮箱客户端的三种协议POP3、IMAP、HTTP的构造过程抽象成一个IConnectMail接口,再设计各自的工厂类。在日后有新的连接邮箱客户端协议时,只需新增一个工厂类即可。

Java中工厂类可以使用反射等方法创建新对象。

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
// 产品类
public abstract class abstractProduct {
// 共有方法
public void method() {}
// 抽象方法
public abstract void abstractMethod();
}

public class product1 extends abstractProduct() {
public abstractMethod() {
// 差异化方法
}
}

public class product2 extends abstractProduct() {
public abstractMethod() {
// 差异化方法
}
}

public abstract class Factory {
// 入参根据场景调整
public abstract <T extends abstractProduct> T createProduct(Class<T> c);
}

public class Factory1 extends Factory {
public <T extends Product> T createProduct(Class<T> c) {
Product p = null;
try {
p = (Product)Class.forName(c.getName()).newInstance();
} catch (Exception e) {
}
}
}

JavaScript中,工厂模式的实现更加轻量级,因为构造对象的方式更加简单,使用函数将多个产品类间的共性抽出来就行了。样例略。

抽象工厂模式

抽象模式在工厂模式的基础上又抽象了一层,产品类接口下是多个产品族抽象类,产品族下才是明确的产品类。相对应的,工厂接口下的多个工厂方法根据最细节的产品类生产对象。它的优势在:

  • 可以不公开地控制产品族间的约束
  • 更好地组织多维度(更多是2维)上多个产品间的生产

缺点也很明显,产品族的修改将会直接影响工厂接口和产品抽象类,这是开闭原则的大忌。因此,在产品维度固定,且有必要从多维度上划分产品时,才会使用抽象工厂模式。比如Windows、Linux、Mac OS上的文本编辑器与图像处理程序就是两个维度的多个产品。

样例略。

模板方法模式

模板方法模式比较好理解,就是将子类中共有的算法框架抽象到抽象类中实现,注意是框架,而不是具体的步骤。子类可以根据自己的需要,在不改变框架的基础上,重定义算法的某些步骤,得到不同的结果。下面举个例子就能更方便地看明白了。

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
const student = {
study() {
this.gotoPrimarySchool();
this.gotoMiddleSchool();
this.gotoUniversity();
}
};

const stu1 = Object.assign(Object.create(Student), {
gotoPrimarySchool() {
console.log("子弟学校 ");
}
gotoMiddleSchool() {
console.log("人大附中 ");
}
gotoUniversity() {
console.log("清华大学 ");
}
});

const stu2 = Object.assign(Object.create(Student), {
gotoPrimarySchool() {
console.log("子弟学校 ");
}
gotoMiddleSchool() {
console.log("华师一附中 ");
}
gotoUniversity() {
console.log("中科大 ");
}
});

stu1.study(); //子弟学校 人大附中 清华大学
stu2.study(); //子弟学校 华师一附中 中科大

可以看到,同样是调用学习方法studystu1stu2可以再不影响公用算法流程下,定义自己的算法步骤。使用Java实现是一样的思路:

  • 定义抽象类,声明可以差异化的基本方法,实现模板方法,在模板方法中调用可以差异化的基本方法
  • 子类根据需要,实现自己的基本方法

模板方法模式核心就在于封装不变部分,开放可变部分,共有的算法步骤也较容易维护。因此,使用在下面的场景里:

  • 子类共有相同算法流程
  • 将核心算法设计为模板方法,细节功能由子类补充

建造者模式

建造者模式和工厂模式类似,意思是,讲一个复杂对象的构建表示分离,使同样的构建过程可以有不同的表示。其中的构建强调的是不同基本方法的调用顺序安排,而不是基本方法的实现(这也是它和工厂方法的最大区别);表示是指产品子类对于基本方法的差异性实现。

对比上面模板方法模式来看,就是study的顺序对于不同人不一样,这个顺序有另外的建造类描述并实现。可以看到,建造者模式主要的使用场景是:

  • 相同的执行方法,不同的执行顺序,产生不同的结果
  • 产品类中,不同的构建顺序会有不同的结果
  • 用户希望执行次序可控

在实现时,建造类通过传入新状态或其他方式影响产品类的模板方式执行次序。在建造类和产品类之上,使用导演类可以起到封装的作用。

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
// 产品类
public class Product {
public void method() {
// 差异化业务
}
}

// 抽象建造类
public abstract class Builder {
// 设置构建的次序,以获得不同的结果
public abstract void setSequence();
// 建造
public abstract Product build();
}

// 具体建造类
public class Builder1 extends Builder {
private Product product = new Product();
public void setSequence() {
// 差异化逻辑
}
public Product buildProduct() {
return product;
}
}

// 导演类
public class Director {
private Builder builder = new Builder1();
public Product getProductA() {
builder.setSequence();
return builder.build();
}
}

代理模式

代理模式即为其他对象提供一个代理来控制对这个对象的访问,浅显易懂。利用代理模式还可以拦截原始请求,做额外的事情。应用很广泛。

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
public interface Object {
// 作为示例的一个方法
public void request();
}

public class RealObject implements Object {
public void request() {
// 业务逻辑
}
}

public class Proxy implements Object {
// 代理的对象
private Object object = null;

public Proxy() {
this.subject = new Proxy();
}

// 传递代理者
public Proxy(Object o) {
}

public void request() {
this.before();
this.object.request();
this.after();
}

// 预处理
public void before() {
}

// 善后处理
public void after() {
}
}

除了普通代理,还有强制代理即只能通过被代理对象对象访问到代理。另外,AOP(Aspect Oriented Progarmming)模式也是建立在代理模式的基础上的。ES6中的proxy和ES7中的装饰器就是AOP概念下的产物。

原型模式

原型模式即不通过new而是通过对象复制生产同类对象。非常好理解。在Java中,一个实现了cloneable接口的对象即使用了原型模式。而JavaScript更是天生使用原型模式实现对象的继承和拓展。

1
2
3
4
5
6
7
8
9
10
11
12
public class PrototypeClass implements Cloneable {
@Override
public PrototypeClass clone() {
PrototypeClass p = null;
try {
p = (PrototypeClass)super.clone();
// 其他操作
} catch(CloneNotSupportedException e) {
}
return p;
}
}

它的优势在于更加轻量级,避免了构造函数的约束。问题在于,第一会跳过构造函数(这个JavaScript没有),第二是深浅拷贝的问题,第三在Java中,clone带有final成员的类会抛出异常。

中介者模式

中介者模式主要用在多个对象那个间有比较复杂的交互场景下,用一个中介对象封装对象间的一系列交互,中介往往与各对象都有交互,从而使其耦合松散,符合迪米特法则。从类图上看,它把原先的蛛网状结构简化为了星型结构。

它的优点是减少了类间依赖,缺点是有些时候中介者会膨胀的很大。使用场景上,在有协调概念出现的场景都有它的发挥空间:

  • 机场调度中心
  • MVC框架中的Controller
  • 媒体网关,中介服务

因为应用场景广泛,这里不举样例。

命令模式

命令模式即将一个用对象组织每一个请求,从而允许使用请求完成一系列操作,同时还可以对请求排队或记录日志或撤销以及恢复。模式主要包括三个角色:

  • 接受者,完成请求内操作的角色
  • 命令,封装好的系列操作
  • 调用者,接受、执行命令的角色

这种设计模式在存在上面三种角色的场景很适用,易于封装常用的命令,且很容易拓展,当命令间共同点较多时,还可以结合模板方法模式进行改进。

例子如下:

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
public abstract class Receiver {
// 定义所有接受者必须完成的业务
public abstract void work() {
}
}

public class Receiver1 extends Receiver {
public void work() {
}

// 差异化业务
public void otherWork1() {
}
}

public class Receiver2 extends Receiver {
public void work() {
}

// 差异化业务
public void otherWork2() {
}
}

public abstract class Command {
public abstract void exec();
}

public class Command1 extends Command {
// 对特定接受者命令
private Receiver receiver;

public Command1(Receiver _receiver) {
this.receiver = _receiver;
}

public void exec() {
this.receiver.work();
}
}

public class Command2 extends Command {
// 对特定接受者命令
private Receiver receiver;

public Command2(Receiver _receiver) {
this.receiver = _receiver;
}

public void exec() {
this.receiver.work();
}
}

// 调用者
public class Invoker {
private Command command;

public void setCommand(Command _command) {
this.command = _command;
}

public void react() {
this.command.exec();
}
}

责任链模式

责任链模式重点在“链”,将有机会处理请求的所有对象组成链,沿着这条链传递请求直到有对象处理它为止。它和Express和Redux中的中间件的概念有相似之处,区别在于责任链上一般只会有一个或部分对象处理请求。它替换了场景代码中的大堆if elseswitch语句。

一个狭义的抽象处理者像下面这样。使用模板方法模式,将抽象的业务逻辑外的处理流程实现在抽象类,细节的业务逻辑放在子类里完成。

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
public abstract class Handler {
// 下一个处理者
private Handler next;
// 处理逻辑
public final Response handle(Request req) {
Response res = null;
if (this.getLevel().equals(req.getLevel())) {
// 只处理属于自己的level
res = this.exec(req);
} else {
// 如果有下一个处理者,交给它
if (this.next != null) {
res = this.next.handle(req);
} else {
// 自行处理
}
}
return res;
}

// 设置下一个处理者
public void setNext(Handler _handler) {
this.next = _handler;
}

// 处理者有自己的处理级别
protected abstract Level getLevel();

// 处理者有自己的处理逻辑
protected abstract Response exec(Request req);
}

// 子类示例
public classHandler1 extends Handler {
// 自己的处理逻辑
protected Response exec(Request req) {
return null;
}

// 自己的处理级别
protected Level getHandlerLevel() {
return null;
}
}

public class Level {
// 定义一个请求和处理等级
}

public class Request {
// 获取请求等级
public Level getRequestLevel() {
return null;
}
}

public class Response {
// 定义返回的数据
}

它的优点在解耦了请求与处理者,使系统更加灵活。问题在责任链比较长时,遍历整个责任链会带来性能问题。Express中类似的中间件则不会,因为处理后可以决定是否执行next(),跳到下一个中间件。

上面Java的实现通过next属性连接所有的处理者(类似链表),在JavaScript的工程实现上,一般在上层又有一层封装,用数组保存所有处理者,再建立之间的连接。这种动态的连接支持动态增删处理者,甚至改变他们的处理顺序。

装饰模式

装饰模式也是一种比较常见的模式,它可以动态地为一个对象增加一些额外的功能,使用装饰器类返回对象比定义子类继承要来得更加灵活。在设计上,有下面几个角色:

  • 抽象构件,即被修饰的对象抽象
  • 具体构件,被修饰的客体
  • 装饰器,一般是一个抽象类,将具体装饰器的共性抽出来,其必有一个private属性指向原始的抽象构件
  • 具体装饰器,装饰具体构件的原有方法,一般来说需要有所有与具体构件public方法同名的方法,且在方法内会使用到而非单纯替换原同名方法(类似滚雪球的过程)。

它的使用类似下面:

1
2
3
4
5
6
7
8
9
10
11
12
// 场景类
public class Scene {
public static void main(String[] args) {
Component c = new Component();
// 装饰
c = new Decorator1(c);
// 再次装饰
c = new Decorator2(c);
// 执行
c.exec();
}
}

JavaScript中的Object.create()Object.assign()和装饰模式有几分相似。

它的优势在于装饰类间不相互耦合,且装饰次序可以灵活修改,可以很好地重构、替换继承关系。劣势在于包装层次过多时,不利于调试时发现错误。装饰模式一般用于:

  • 动态增强一个类、对象的功能
  • 批量为一批对象或类改装或增加功能

总之就是使用继承总感觉小题大做的场合下(OOP语言中)。JS下扩展功能要容易很多。

策略模式

策略模式意为定义一组算法,将每个算法单独封装为可以相互替换的模块。在使用时,有三个主要角色:

  • 策略模块,被封装好的算法策略,屏蔽了高层逻辑对策略的直接访问(高内聚
  • 抽象策略,抽出策略共性的接口,如下面的
  • 具体策略,具体的算法策略,包含具体的算法

在算法间可以自由切换、最好屏蔽算法细节时常用,比如Web编程中常见的表格验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const strategy = {
notEmpty: val => val.length > 0,
name: val => val.length > 1 && val.length < 10,
age: val => +val > 0,
password: val => val.match(/\d+{6,}/g)
}

const validator = {
rules: strategy,
verify: (rule, val) => this.rules[rule](val)
}

function formValidate(formData) {
const { name, age, password, introduction } = formData;
return validator.verify('name', name) &&
validator.verify('age', age) &&
validator.verify('password', password) &&
validator.verify('notEmpty', introduction);
}

策略模式的优势在扩展性良好,同时避免了if else以及switch语句。它的问题也很明显,策略类增多时,会不好维护,同时所有策略类都需要对外暴露。当策略类数目膨胀时需要考虑到这些问题。同时,现在在哪些时机如何组合策略并没有严格定义,实际实现时,会参考建造者模式里一样,定义一个导演类,把常用的组合方式定义出来。减少策略类的暴露。

适配器模式

适配器模式即,把一个类的接口变成客户端期待的另一个接口,从而使原先不匹配的两个类能够一起工作。简单点说,就是加了一个中间层做翻译工作。这种模式下有三种角色:

  • 目标角色,即期望接口
  • 源角色,即原始接口
  • 适配器角色,即转换类

在实现上,通常使用一个同时继承两个类的类作为中转。因为对象很轻量级,JS中就更容易实现了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public interface Target {
public void request();
}

public class Target1 extends Target {
public void request() {
// 目标逻辑
}
}

public class Adaptee {
// 原有逻辑
public void foo() {
// ...
}
}

public class Adapter extends Adaptee implements Target {
public void request() {
super.foo();
}
}

它的优点在可以将两个不相关的类在一起运行,提高了类的复用程度,它通常用来救火,完成拓展。由于实际工程中,业务变更较常出现,适配器模式也很常用。

拓展

如需要多适配一的场景,此时无法多继承(Java),在适配类中引用多个类即可。

迭代器模式

迭代器模式,顾名思义是提供一种方法按顺序访问容器中的每个元素,而无需暴露容器的细节。在实现时,通常要自己实现一个迭代器。Java中通过拓展java.util.Iterator实现,JavaScript中,则通过封装数组实现。实现时,要考虑下面几个方法:

  • 判断是否到达尾部
  • 返回下一个元素
  • 删除当前元素

像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
// 迭代器
public interface Iterator {
public Object next();
public boolean hasNext();
public boolean remove();
}
// 容器
public interface Demo {
public void add(Object o);
public void remove(Object o);
public Iterator iterator();
}

现在所有高级语言基本都有这个接口或基础实现。这个模式已经很少用到。

组合模式

组合模式用在表示树状结构的数据中,使用户对单个对象和组合对象使用具有一致性。组合模式下有三种角色:

  • Component,节点抽象角色,参与组合对象的共有方法和属性
  • Leaf,叶子对象,遍历的最小单位
  • Composite,树枝节点

用JavaScript表示,就像下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const node = {
commonMethod() {}
}

const branchNode = Object.create(node);
const leafNode = Object.create(node);

branchNode = Object.create(branch, {
children: [],
add(node) {
children.push(node);
},
remove(index) {
children.splice(index,1);
},
print() {
children.forEach(child => { console.log(child); });
}
})

leafNode = Object.create(leafNode, {
// ...重写node的同名方法
})

组合模式在表示整合局部的关系时很有用,但是由于树枝节点和叶子节点的使用方式不同,在使用两种节点时,需要直接调用接口的实现类,这一点违背了依赖导致原则(面向接口编程)。使用上分为透明模式和安全模式,后者就像上面一样,在实现类上区分开树枝和叶子节点,透明模式下,所有的方法均抽象到抽象类中实现,而在叶子节点调用不合法方法时抛出异常。

综上来看,组合模式即使用用数据结构描述一颗多叉树。

观察者模式

观察者模式,也叫发布订阅模式,可以说是前端最熟悉也是最常见的一种设计模式了。小到页面事件监听,大到Vue的设计原理都能看到观察者模式的影子。它的内涵在将信息流从原来的pull变成push。从而不需要使用whilesetInterval这种很消耗资源的方式。代价是,需要硬编码到被监听者中,在状态改变时,push信息到监听者那里。通常实现时,这个过程可以抽象到很底层完成,如Vue使用Object.defineProperty,并不影响整体实现。另外,为了实现多对多的监听,往往需要在被监听者和监听者之间增加spy(或者叫probe)这样的角色进行中转和广播通知。

这时候可以定义Observable接口,当然了,它需要可以增删监听者和在发生事件后提醒监听者。

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
public interface Observable {
public void add(Observer o);
public void delete(Observer o);
public void notify(String text);
}

public interface IFoo {
public void work();
public void sleep();
}

public interface Observer {
public void update(String text);
}

public class Victim implements Observable, IFoo {
private ArrayList<Observer> oList = new ArrayList<Observer>();
public void add(Observer o) {
this.oList.add(o);
}

public void delete(Observer o){
this.oList.remove(o);
}

public void notify(String text) {
for (Observer o: oList) {
o.update(text);
}
}

public void work() {
// ...
this.notify("Working...");
}

public void sleep() {
// ...
this.notify("Sleeping...");
}
}

一个简单的JavaScript实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var EventUtil = {
// 全局事件管理
var events = {},
// 注册事件
on = function (type, handler) {
if (events[type]) {
events[type].push(handler);
} else {
events[type] = [handler];
}
},
// 触发事件
emit = function (type) {
if (!events[type]) return;
for (var i = 0, len = events[type].length; i < len; i++) {
events[type][i];
}
};
};

观察者模式的优势在于在两个对象有频繁信息交互或希望监听特定时机时很有用,当信息很多时,可以考虑增加中间层,设计消息队列处理。Java本身也提供java.util.Observerjava.util.Observable用来实现这种模式。

建立在观察者模式的基础上,有响应式编程这样新的编程范式出现,ReactiveX就是在这种范式基础上推出的多语言框架。JavaScript版本的叫做RxJS,相信看完这个简介对你会非常有帮助。

门面模式

门面模式又叫外观模式,它要求所有子系统与外部通信时必须使用统一的对象进行,提供高层的接口,尽量掩盖不必要的业务细节,使得子系统更易用。因为实现起来重点在于统一通信数据格式和封装业务细节。这种模式也非常常用。比如在通常的前后端协调时,后端回传前端请求的数据通常都是统一的格式,避免错误同时减少前端工作量。比如下面这样。同样,有时后端也会要求前端在请求时使用统一的数据格式(不常见)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const successRet = {
// 错误码
code: 0,
// 返回数据
data: {
userInfo: {
id: 0000001,
// ...
}
}
}

const errorRet = {
code: 0002,
// 错误原因
message: "请求过于频繁!"
}

门面模式可以极大地提升封装性,增加系统的高内聚,同时也减少了系统内外的耦合,提高了灵活度和安全性。劣势在于对扩展不利,所有的改动几乎都要对应到门面(Facade)类的硬编码。因此门面模式的使用场景是:

  • 为一个复杂的系统或模块提供对外接口
  • 子系统间相对独立

通常情况下,门面类只负责聚合,不参与具体的子系统逻辑。另外,在系统庞大时,很可能有不止一个门面入口。后端接口微服务化的趋势下,在系统内,拆分原来庞大的接口,同时面向前端不同设备,设计不同的服务汇合接入点正是门面模式的体现。

备忘录模式

备忘录模式即,在不破坏封装性的前提下,捕获一个对象的内部状态,在对象外保存,并在合适的时候可以将对象恢复到保存的状态。这个概念很简单,涉及到三个角色:

  • 发起人,需要记录状态的对象
  • 备忘录, 用来储存状态
  • 备忘录管理者,对备忘录进行管理,保存和恢复
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
class Memorial {
state: ''

memorize() {
return new Memento(this.state);
}
restore(memento) {
this.state = memento.state;
}
}

class Memento {
state: ''

constructor(state) {
this.state = state;
}
}

const Manager = {
memento: null
}

const m = new Memorial();
Manager.memento = Memorial.memorize();
Memorize.restore(Manager.memento);

它的优点很明显,可以保存和回复状态,支持回滚事务。但在使用时,通常是结合了别的设计模式的变种。

拓展

结合原型模式,可以直接用clone对象的方式保存状态。这么做问题是当状态对象较大时,会有时间和空间的开销,优势是可以直接将状态存储在类内部,避免了其余类的定义。

在多状态存储上,Java可以借用BeanUtil工具类(书中所说),JavaScript中就灵活很多了(还是因为轻量级的对象)。同样的多备忘录模式就不再赘述。另外,需要保证备忘录的保密性时,封装成业务类的内置类,设置权限为private即可,JS中同理。

访问者模式

访问者模式指,封装作用在数据结构上各元素的操作,它可以在不改变数据的前提下定义新的对于元素的操作。实现原理上,

  • 被访问类新增访问方法(如accept),注入访问类,同时将自己交给访问类
  • 访问类根据得到的被访问类对象,执行想要的操作
  • 场景类中通过调用访问方法访问被访问类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public abstract class Element {
// ...
public void accept(IVisitor v);
}

public class Element {
public void foo() {
}

// 注入访问类
public void accept(IVisitor v) {
visitor.visit(this);
}
}

public interface IVisitor() {
// 通过重载对不同元素定义不同的访问方式
public void visit(Element e);
public void visit(OtherElement e);
}

当然,为了保证依赖倒置原则,被访问类和访问类都可以再抽象出抽象类和接口。另外,访问者模式通常和组合模式、迭代器模式一同出现。访问者模式的优点:

  • 符合单一职责原则
  • 拓展性优秀

缺点在于,被访问者要暴露细节给访问者,通常会增加很多约定,使代码不稳定。另外重载中依赖的是具体元素违背了依赖倒置原则。

访问器模式的应用场景通常是使用迭代器模式已经不能满足的场合。比如对不同的元素有不同的遍历操作,甚至涉及到元素内部的逻辑。使用访问器模式可以封装和掩盖这种差异性。

拓展

在访问器模式和迭代器模式一同出现时,可以增加统计功能,在每次访问元素时收集统计信息。在往深处拓展,甚至可以抽象访问器为接口,从而拓展不同类型的访问器,如用来展示数据和用来统计数据的。

状态模式

这种模式就很好理解了。即将客体抽象成一个有限状态机,客体有一个初始状态,在某特定时机下会跃迁到另一状态,且状态间的跳转是有规律可循的。这种模式在编程中非常常见,自然语言分析、所有可以用马尔科夫过程描述的事物变化都可以抽象成状态模式实现。在实现时,主要要完成三方面工作:

  • 定义所有状态,根据状态的薄厚程度,用常量或类定义
  • 定义修改状态的行为,在方法内往往要根据上一时刻状态做判断,这些行为定义在状态内部
  • 在上下文中调用这些行为

在实现时,为了避免switch语句,会使用一个上下文类,托管当前状态,在状态切换时,先调用状态类中的行为,再通知上下文类更换托管的状态。两者相互注入。

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
public abstract class State {
// 注入上下文对象
protected Context c;
// 设置上下文
public void setContext(Context c) {
this.context = c;
}
// 行为
public abstract action1();
public abstract action2();
// ...
}

public class State1 {
@Override
public void action1() {
// ...
}

@Override
public void action2() {
super.context.setState(Context.STATE2);
// 切换到state2
super.context.handleAction2();
}
}

public class Context {
// 注入所有状态
public final static state STATE1 = new State1();
public final static state STATE2 = new State2()

private State CurrState;

public State getState() {
return this.CurrState;
}

// 设置当前状态
public void setState(State currState) {
this.currState = currState;
// 切换当前状态
this.CurrState.setContext(this);
}

// 行为委托
public void handleAction1() {
this.CurrState.action1();
}
public void handleAction2() {
this.CurrState.action2();
}
}

增加了上下文类Context后,避免了大量的switch语句,问题是,状态较多时,定义的类也会较多。状态模式在工作流开发中很常用。

解释器模式

解释器模式顾名思义,即定义一个解释器,去按文法解释一种语言中的句子。当然这个语言以科学运算和编程语句居多。它的应用场景比较特殊,即需要语句解析介入的场景,比如自然语言分析、或者真的编写一个语言的解释器。通常开发解释器工程量和难度都较大,且会遇到效率问题。一般应用较少。

在这个模式下。主要有下面这些角色;

  • 抽象解释器,用来派生具体的表达式解释器
  • 终结符解释器,即不需要解释的,字面意义的符号,比如1a
  • 非终结符解释器,和两边表达式相关联的符号解释器,比如+*
  • 上下文角色

享元模式

享元模式是一种重要的池技术,原理上指使用共享对象支持大量的细粒度的对象。我们可以将这些对象内的状态拆分成可共享状态和不可共享状态。对象往往可以按可共享状态拆分为细粒度较大的若干部分,放在共享对象池中,再贴上自己的不可共享状态。

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
// 享元
public abstract class SharedObject {
private String intrinsic;
// 共享的状态作为享元的key
protected final String Extrinsic;
// 共享的状态需要可以设置
public SharedObject(String E) {
this.Extrinsic = E;
}
// 业务方法
public abstract foo();
// 不可共享状态的getter/setter
public String getIntrinsic() {
return intrinsic;
}
public void setIntrinsic(String intrinsic) {
this.intrinsic = intrinsic;
}
}

// 享元工厂
public class SharedObjectFactory {
// 共享池
private static HashMap<String, SharedObject> pool = new HashMap<String, SharedObject>();
// 工厂方法
public static SharedObject getSharedObject(String Extrinsic) {
SharedObject o = null;
// 从池中寻找
if (pool.containsKey(Extrinsic)) {
o = pool.get(Extrinsic);
} else {
o = new SharedObject1(Extrinsic);
// 放到池中
pool.put(Extrinsic, o);
}
return o;
}
}

在共享对象池中,建议使用可共享状态的简单组合构成池内元素的key值(最好是基本类型),一方面减少编程负担,另一方面还可以提高工作效率。享元模式主要使用在下面场景下:

  • 系统中存在大量相似对象
  • 对象具备相近的外部状态和与环境无关的内部状态

桥梁模式

桥梁模式又叫桥接模式,是比较轻量级的一种设计模式,意为将抽象和实现解耦,使两者可以独立变化。角色上,桥梁模式分为主体和客体,在主体内注入客体的接口/抽象类,并在主体的方法内使用,提供setter或构造函数方法。这样继承自主体的类就可以根据传入setter/构造函数的客体实现类的不同得到不同的实现结果。

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
public interface Object {
// 基本方法
public void foo()
}

public class Object1 implements Object {
public void foo() {
// 自己的逻辑
}
}

public class Object2 implements Object {
public void foo() {
// 自己的逻辑
}
}

public abstract class Subject {
// 必须有一个注入的客体
private Object o;

// 必须可以通过构造函数/setter注入
public Subject(Object o) {
this.o = o;
}

// 获得客体行为
public void bar() {
this.o.foo();
}

// 获得客体
public Object getObject() {
return o;
}
}

public class Subject1 extends Subject {
// 覆写构造函数
public Subject1(Object o) {
super(o);
}
// 自身业务
@Override
public void bar() {
super.bar();
// ... 其余业务
}
}

public class Scene {
public static void main(String[] args) {
Object o = new Object1();
Subject s = new Subject1(o);
s.bar();
}
}

桥梁模式的扩展能力很强,它是对普通的继承的一种灵活的补充,避免了父类对子类的强侵入,可以将父类可能会变化的部分抽出去,通过注入的方式引入,方便子类随时更换。

设计模式的比较

创建类

和创建类相关的模式:

  • 工厂模式
  • 抽象工厂模式
  • 建造者模式
  • 单例模式
  • 原型模式

后两者容易理解。重点对比上面三个模式:

  • 工厂模式注重整体的构建过程,成产出的对象具有很强的相似性
  • 建造者模式注重建造的过程,希望在每一步最好都可定制,根据步骤的不同生产出差异化的对象,通常伴生导演类
  • 抽象工厂模式通常和产品族关系密切,尤其是一组事物有很明显的两个或多个划分方法时使用,其余等同工厂模式

结构类

结构类指调整或组合类产生更大结构来适应更高层次逻辑需求,大多通过增加中间层解决问题,有下面的相关模式:

  • 适配器模式
  • 桥梁模式
  • 组合模式
  • 装饰模式
  • 门面模式
  • 享元模式
  • 代理模式

其中简单明了的有桥梁模式、组合模式、门面模式、享元模式,下面对比下其他的几种模式:

  • 代理模式即在原对象和用户间增加了一个中间层,在不改变原接口的情况下,增加准入和限定操作
  • 装饰模式是代理模式的加强,装饰类并不起中间层的作用,不做准入判断,它单纯地在原接口上增强或削弱功能
  • 适配器模式和它们差别较大,它也起包装作用,作用于两个不同的对象,重点在伪装和转换

行为类

这一批模式重点在修饰类的行为:

  • 责任链模式
  • 命令模式
  • 解释器模式
  • 迭代器模式
  • 中介者模式
  • 备忘录模式
  • 观察者模式
  • 状态模式
  • 策略模式
  • 模板方法模式
  • 访问者模式

下面比较一些类似的模式

  • 命令模式强调把动作解耦,将其分为执行对象和执行行为,在行为类内部注入执行对象,使用执行者操作命令
  • 策略模式强调包装对等的可替换的多个算法,通常有一个上下文类,封装所有的算法

比如在表单验证时,策略模式会将所有的验证规则对等地定义出来,再由一个表单验证上下文对象包裹起来;命令模式下,首先要明确所有验证规则的接受者(Receiver),再定义所有的验证“命令”,最后由执行者(Invoker)操作命令完成工作,具体工作是在接受者那里完成的,命令只负责组织。

关于策略模式和状态模式,

  • 策略模式没有状态的概念,虽然有上下文类Context,但是切换的状态只是不同的算法而已
  • 状态模式重点关注状态,它同样有上下文类Context,但相同的行为在不同的状态下产生的结果不同。在实现上体现在,上下文类内保存了当前状态,虽然状态间都有相同方法,但实现不同。

至于观察者模式和责任链模式,

  • 观察者模式重点在观察和被观察的关系(想想事件监听),被观察者中需要注入监听者(Observable),再由监听者告知观察者(Observer),整条链是有回调的,链上传递的信息可以自由变化,即最后返回给用户数据的总是第一个被观察者
  • 责任链模式重点在事务链条化处理的过程(想想中间件),每个处理者都必须通过next属性明确指定下一个目标,整条链是责任链,链上角色相互平等,传递的信息一般不会改变结构,最终由最后一个角色返回结果

其他

首先先来比较策略模式和桥梁模式。它俩的共同点在都有一个注入依赖关系。策略模式中算法封装被注入到上下文类Context中,桥梁模式中差异化继承的部分被单独抽出再注入父类里。它们的区别主要在:

  • 策略模式着重于封装一系列不同的行为
  • 桥梁模式在不破坏封装的情况下将差异化的实现部分从抽象部分抽取出来,因此必然有抽象化角色和实现化角色

门面模式和中介者模式就比较好区分了,它们的应用场景有很大不同;

  • 门面模式用来掩盖下面复杂的子系统,提供统一的高层接口(“金玉其外”),它并不管下面的子系统间怎样耦合(“败絮其中”)
  • 中介者模式要用在同事对象间(通常至少3个)复杂交互时,化网状结构为星型结构,减少耦合

最后,代理模式、装饰模式、适配器模式(不严格)、桥梁模式、门面模式都可以总结为包装模式,它们并没有为原来的类增加新的功能,只是增加了新的包装或插件。

设计模式的组合

shell命令解释demo

主要采用命令模式、责任链模式、模板方法模式。

银行扣款demo

主要采用策略模式、工厂方法模式、门面模式

产品消费事件demo

产品创建时摄影工厂模式,保证产品和工厂的紧耦合,避免创建事件不触发的可能性

新模式

MVC

MVC其实算不上一种新模式,但是从上世纪90年代起到现在实在是太流行了。它的目的是通过C(Controller)将模型M(Model)和视图V(View)分离开。书中具体在讲MVC在Java Web开发中的实现,这里从略。

规格书模式

规格书模式多用在描述一个规范或条件的场合下,比如“性别男年龄大于20且位于北京”。通过定义抽象类以及ANDORNOT等的组合,可以得到更复杂的规格书对象。

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
70
71
72
73
74
75
76
77
78
public interface ISpec() {
public boolean isSatisfied(Object o);
public ISpec and(ISpec s);
public ISpec or(ISpec s);
public ISpec not(ISpec s);
}

public abstract Spec implements ISpec {
public abstract isSatisfied(Object o);

public ISpec and(ISpec s) {
return new AndSpec(this, spec);
}

public ISpec or(ISpec s) {
return newOrSpec(this, spec);
}

public ISpec not() {
return notSpec(this);
}
}

public class AndSpec extends Spec {
private ISpec left;
private ISpec right;

public AndSpec(ISpec left, ISpec right) {
this.left = left;
this.right = right;
}

@Override
public boolean isSatisfied(Object o) {
return left.isSatisfied(o) && right.isSatisfied(o);
}
}

public class OrSpec extends Spec {
private ISpec left;
private ISpec right;

public OrSpec(ISpec left, ISpec right) {
this.left = left;
this.right = right;
}

@Override
public boolean isSatisfied(Object o) {
return left.isSatisfied(o) || right.isSatisfied(o);
}
}

public class NotSpec extends Spec {
private ISpec spec;

public NotSpec(ISpec s) {
this.spec = s;
}

@Override
public boolean isSatisfied(Object o) {
return !this.spec.isSatisfied(o);
}
}

public class bizSpec extends Spec {
private Object obj;

public bizSpec(Object o) {
this.obj = o;
}

public boolean isSatisfied(Object o) {
// 根据业务逻辑决定真值判断
// ...
}
}

规格模式应用场景比较局限,在LINQ(Language INtegrated Query,语言集成查询)中常见,用来构造WHERE子句。

对象池模式

对象池模式和享元模式相似,都是循环使用对象,但是对象池模式是整个直接取出到池中,避免初始化和释放资源时的消耗。如连接池和线程池就是常见的例子。

雇工模式

雇工模式是常见的“接口-实现”的倒转实现。比如,小学老师教小学学生,中学老师教中学学生,大学老师教大学学生。在实现时,我们先定义学习接口,再实现所有的学生类。再实现老师时,直接调用学生接口的学习方法就完成了教学的实现。雇工模式为一组类提供通用的功能,不需要类实现这些功能,而在客体上实现功能。它和命令模式有相似之处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface IService {
public void serving();
}

public class Object implements IService {
public void serving() {
// 服务完成
}
}

public class Servant {
public void service(IService s) {
s.serving();
}
}

黑板模式

黑板模式是观察者模式的拓展,它允许消息有多个读写者,且同时进行。消息生产者通过“黑板”这个总线,将消息传递给对应的消息消费者。这可以是一种宏观的设计理念,使用数据库或者消息队列当做“黑板”都是可以的。

空对象模式

空对象通过实现一个无意义的默认类避免程序出现null值。

1
2
3
4
5
class NullAnimal implements Animal {
public void makeSound {
// 什么都不写
}
}

–END–

攻略向游记,以流水账为主,记念宿舍的毕业旅行

Day1 - Day4:成都

我们一共在成都玩耍了3天,逛的景点大多数为名人古迹,其中有杜甫草堂武侯祠,门票分别是30和60,不过景点里面也没什么好逛的。由于时间有限,都江堰这样的比较偏远的景点只能放弃。

AhhRKJ.jpg

除此以外,还有几个有名的商业步行街和老街,都有历史,可以去逛逛:

  • 锦里,小吃纪念品一条街,有一整片街区,在武侯祠旁边
  • 春熙路,商业步行街,类似于万达广场,龙抄手总店就在这里。位于天府广场东南侧
  • 宽窄巷子,类似锦里,南锣鼓巷的感觉,但路面非常干净。在人民公园附近

AhhfbR.jpg

另外值得一去的就是熊猫基地了。4A级景区,虽然门票要快60元,但是能尽情吸(熊)猫,感觉还是超棒的。不建议在节假日去,人山人海的吸猫体验很差。熊猫基地的位置离市中心较远,建议单独安排一天去。

Ahh5Ux.jpg

除此之外,我们还顺路去四川博物馆逛了下,可看的内容还挺多的。上面提到的这些地方除了熊猫基地基本都离市中心不远,我们几个住在如家人民公园店,每次出行都很方便。

除了玩,体验四川的美食也是少不了的。我们分别在第一和第二天去了玉林串串香龙抄手的总店,不用说口味都很经典。其中龙抄手点的每人78的套餐,可以尝到各种成都的小吃,比较推荐。其余的如甜水面勾魂面等没有尝试,不做评价。在小吃上,极力推荐军屯锅盔,外焦里嫩的味道,加上花椒和肉沫的味道和香气,让我欲罢不能。一个5-7元,还比较实惠。

Ahh4V1.jpg

Day4 - Day7:重庆

在重庆,我们住在三峡广场舍友的家附近,布丁比较坑,我们住了两天换到了更远一点的7天。重庆的城市景观很有特色,有着“山城”,“桥都”的美称,3D的城市景观、穿楼钻山的轻轨2号线、赛博朋克感极强的高楼建筑都是山赋予的。另外,嘉陵江和长江穿城而过,滨江的风景和长江索道吸引了许多游客。

在老街区上,有沙坪坝区的磁器口和渝中区的洪崖洞。我们第一天晚上去的磁器口,它位于嘉陵江南岸,也是一整片的街区,以美食为主,值得一提的是陈昌银麻花的总店就位于此,山寨较多需要仔细辨认。洪崖洞也位于嘉陵江南岸,但距离长江和朝天门更近,它曾经是棚户区。依山而建形成了它独特的层次景观,每一楼都有单独的步行街,顶楼和1楼还分别是两条公路。从下往上仰视和从上往下俯瞰都有非常棒的景观。不过,推荐晚上来看,亮灯后的画面很壮观。在渝中区附近,解放碑八一广场也可以顺便逛逛。

AhhTPK.jpg

长江索道也在渝中区,它在长江的北岸,门票单程20,往返30,值得一坐。曾经作为交通工具的长江索道,现在更多承担着旅游观光的作用,同样不推荐在节假日来乘坐,人会非常多,我们大概排了1个小时的队才坐上。在索道上俯瞰长江和渝中区山上层次感极强的居民楼,别有一种感觉。

AhhI56.jpg

重庆的轻轨2号线很有特点,作为重庆内开通的第一条地铁/轻轨线路,2号线最初是以观光为主的,票价曾经高达25元一人。作为在《火锅英雄》等影片中出现过的轻轨线路,在李子坝黄花园两站能感受轻轨穿楼和钻山的神奇。沿途的风景很不错。

AhhH2D.jpg

另外,重庆还有一些红色景点,如白公馆渣滓洞,在沙坪坝区歌乐山上,和红岩纪念馆在一起,不需要门票,有时间可以去看看。

吃上,重庆的美食小吃毫不逊色成都。毛血旺小面肯定是不能错过的。其中,毛血旺我们是在磁器口吃的,原来正宗的毛血旺是煮着吃的。小面的话,就不限地点了。洪崖洞还有别的很多重庆小吃,可以一试,如凉糕什么的。

还有一个不能少的是重庆火锅,作为本地人,舍友推荐南山区的枇杷园火锅,味道真得很正宗。油碟和火锅是标配,麻酱完全是北方人的吃法,涮料的主角也不是肉片,而是鸭肠、黄喉、毛肚、jun花、鸭血等下水。重庆火锅往往不只是辣,还有麻,对于无辣不欢的人来说,简直是飨宴。要提醒一点的是,重庆的特色美食比较重口,建议中间穿插一天较清淡的饮食,以免把肠胃吃坏。

Ahh78O.jpg

重庆人普遍健谈而热情,舍友的招待就让我们感到了扑面而来的欢迎和宾至如归感。总的来说,重庆很值得一去。

Day7 - Day9:武汉

武汉是我们旅程的最后一站。作为湖北人,深刻感受到湖北菜似乎没有什么值得一说的特色。倒是一些小吃,像是热干面三鲜豆皮蛋酒糯米鸡等比较有名。由于江多、湖多,这边做有着不错的手艺。如果想尝试湖北菜,我们去过的清江人家湖锦酒楼两个饭店还可以。

黄鹤楼的门票居然要80元1人,有点小贵。在主楼上可以眺望到长江大桥和江景,只可惜那天江面雾很大,桥只能看见一半。

Ahhbxe.jpg

湖北省博物馆还是很值得一去的,曾侯乙编钟表演票价20元1人,绝对超值,谁看谁知道。整个演出大约半小时,钟、罄、埙、排箫等的表演实在精彩。东湖风景也挺不错,如果天气好,推荐环湖骑行,共享单车就可以。

Day10:各奔东西

19日上午我们5人分散在武汉、汉口、武昌三站各回各家(除了能哥回北京写推荐信)。

这次旅行我主要有两点感想:

  • 我们很幸运,这10天里,老天很给面子地一场雨没下。
  • 只有本地人才能把城市里最有特点和最真实的一面展示出来

说起来是游记,但以流水账为主,谨记念第一次长途骑行和观光游

去海南的主意不是我出的,最初我和C是打算去些更远的地方骑行。那还是2016年的下半年的事情,C在学苑超市偶遇我,突然说起要不要去台湾骑行。对于长期只在北京市里转来转去的我来说,实在太有诱惑力,于是我俩一拍即合。只是那会儿我既没实习更没工作,长途骑行也只能是计划。

这一计划就是一年多,转眼就是17年的12月,时机成熟,我叫上凯哥。我们最终定下了5天的行程,从海口到三亚,中间在文昌、博鳌、兴隆和海棠湾停靠(这也是经典的环岛东线)。整个5天除了第1天,每天都有很贴近海边的路,风景还是不错的。路上遇到了蛮多骑友,他们来自不同的地方,有的甚至是从甘肃、辽宁这种地方骑过来的。除了晒伤和胳膊肘的轻伤外,这次难得的旅行还留下了下面这些经历。

下图是我在首都机场候机的间隙拍的,窗外那架海航的波音747就是我们要乘坐的那架。

AhhMDA.jpg

我们到海口时已经是晚上了,第一晚住在租车的517驿站,环境比事先设想的要差劲一些,所幸也只住这一晚。租车的人并不是很多,听老板说寒假的时候是旺季,可能是都来避寒了吧。我们的行程是5天,刚好满足一个5日套餐,学生证还可以8.8折,可以说相当划算了。

Ahh1Et.jpg

Day1: 海口 ——> 龙楼

第一天是整个行程里相对比较无趣的,并看不到海。路书里甚至有一条没法骑行的小路。最后从小路绕上213省道时,我们三个都累坏了。若没有凯哥的麦丽素,后果恐怕难以设想。

AhhnjH.jpg

在大致坡镇休息时,C瞥见了一家叫“琼海炒冰清补凉”的店,作为曾经在广东上了4年学的C,竟然也没断清楚句。拜这好奇所赐,我尝到了一道极好的甜点。

这里的清补凉以椰奶为底,炒冰为料,辅以红豆、绿豆、薏米、椰果、葡萄干、莲子、仙草冻等。入口冰爽,糖水的味道清而不腻,实在美味。要是夏天品尝,应该更是大大提升幸福感。(右边的菜汤请忽略)

AhhQHI.jpg

另外,由于我们三个的速度有所差距,为了避免走失的情况。在大致坡时,我们威逼利诱凯哥下载了行者,加入了我们“海南养生骑行群”,从而能共享队友的位置。这在后面几天派上了大用场。

海南的人们大多比较慵懒,穿着拖鞋。很少见到穿着其他鞋的。海南的摩托和电动车非常多,十字路口过街的车流相当壮观。

Day2: 龙楼 ——> 博鳌

在龙楼歇了一夜后,我们向博鳌出发。沿着213省道骑行十几公里后左拐,东郊椰林中成片椰树的画面就出现在面前。在后面的几天骑行里,我们逐渐发现椰树似乎是这边主要的木本植物。

AhhKud.jpg

现在似乎是淡季,这边的野海滩几乎没有人。灰暗的天空营造出一种凄凉的既视感。

Ahh84f.jpg

离开东郊椰林后,我们坐船过江来到文昌市内,码头边停着许多渔船,散发着浓重的腥味。现在可能是休渔期,船上没看到什么人。

AhbZX8.jpg

我们在文昌市吃过午饭,从海边延乡道绕回S213省道上,半路下起了小雨。不过不影响兴致,反倒驱散了骑车的燥热。等再次骑上县道时,离博鳌已经不远了。

Ahh3UP.jpg

这边的鸡、牛、猪的养殖方式很有特点。在路的两边,很容易发现它们没人看管,自由踱步地觅食。有的甚至直接走上公路。

AhhY8S.jpg

C的估计真的非常准,我和他准时在下午5点半到达酒店(凯哥要慢一点)。我们晚上去了一家叫“海的故事”的酒吧,它建在海边,夜晚的海浪咆哮着涌上海岸,海风也比文昌那里凶猛些。享受着海风拂面的同时,我和C进行了一番恋爱观的探讨,然而都是纸上谈兵。

AhhJC8.jpg

Day3: 博鳌 ——> 兴隆

海南的东北人很多,新建的楼盘也很多,当然同样没人住的楼盘也挺多。

Ahhauj.jpg

博鳌附近的省道修得极好,很适合骑车。不知道是不是因为亚洲论坛的缘由。

AhhNvQ.jpg

在到达万宁市稍作休整后,我们转向海边的旅游公路进发,“旅游公路”这名字名副其实,海边的风景很不错。

Ahhtgg.jpg

路况同样很棒。

AhhdDs.jpg

在桥上还能看到某小河的入海口。

Ahhwbn.jpg

AhhBEq.jpg

晚上5点多时,我们很幸运地赶上了夕阳,旅游公路上的远眺美不胜收。

AhhDU0.jpg

Ahhr5V.jpg

AhhyCT.jpg

由于拍照耽误了些时间,我们到达兴隆时已经比较晚了。吃了顿正宗的海(dong)南(bei)饺子,便结束了一天的行程。并没有泡到兴隆温泉。

Day4: 万宁 ——> 三亚海棠湾

海南的丘陵比较多,一路上很多上上下下的起伏。不过,真正意义上的爬坡出现在万宁和陵水的分界岭,是一个海拔100多米的爬坡。在下坡的路上,还看见了一群5、60岁大爷组成的车队,着实是老当益壮。

Ahhc2F.jpg

这一天的午餐在陵水进行,早听人说海南的粉很出名,四大粉中就有陵水酸粉的一个位置。这份是加了沙虫的,味道很赞。

Ahh68U.jpg

接下来又是S213上的狂奔,海棠湾位于三亚市区东北边,离蜈支洲岛比较近,有着相当不错的海景。我们入住的民宿正对着海(当然旁边的所有民宿都是这样)。

Ahhgv4.jpg

天台上的风景也是不错的。

AhbQts.jpg

灯台旁的防波堤上,海浪有耐心地拍打。

AhbMkj.jpg

Day5: 三亚海棠湾 ——> 三亚

我们出发去三亚时已经是12月31号的事情了,这段路很近,只有30多公里。还车的时候,还没到正午。

在三亚跨年是C设计好的环节,似乎在一个陌生的地方,哦不,严格意义上来说是在一个陌生的热闹的地方跨年,会很有feel。尽管对此我持保留意见,但我倒是也觉得这会是有趣的体验。

1年说过就过去了。12月31号的这个青旅里,十几个来自世界各地的朋友玩着抽牌喝酒的游戏,和我们一样,等待2018的来临。唯一的不同是他们似乎真的很期待这一刻的到来。临到跨年的前几分钟,更是热情地拉着我们一同去海边看烟花。“打冬海”,她们有点激动地说。

AhhWr9.jpg

沿着海边的街直走,我才发现,三亚的跨年比想象中热闹。街头遍是从遥远的俄罗斯赶来的毛子兄弟。海边的跨年party上,他们更是在倒计时中载歌载舞。这恐怕是我这辈子见过最密集的毛子群体了。

海水拍打的沙滩上,邀请我们的外国友人相互祝着“Happy new year”。恍惚间,一瓶劲酒被塞进手里。“Take a dip,let’s celebrate!”,一个褐发碧眼的年轻姑娘朗声笑着。盛情难却,我们一人一口,干完了最后的酒。

远处有人放起烟花,仿佛春节似的。

用一个这样的跨年作为环岛东线的结尾,挺好的。

0%