前端模块化开发demo之攻击地图

很早以前写过一篇用RequireJS包装AjaxChart,当时用Highcharts做图表,在其上封装了一层ajax,最后只是简单套用了一下requireJS。由于当时自己才接触模块化,理解层面还太浅,后来经过其他项目的磨练以及实习获得的见识,想重新结合一个示例来写点前端模块化的开发方式。

项目背景

最近在做一个安全运维监控的项目,其中有一条是根据设备获取到的攻击数据,在地图上做可视化。对比了HighchartsECharts

  • ECharts对国内地图的支持更多
  • ECharts在模块化和扩展方面做的比Highcharts更好

所以最后我选择了基于ECharts去封装。类似的网络攻击的监控地图可看国外的Norse Attack Map,也算是同类的参照。

需求整理

数据要求

  • 提供的数据只有IP到IP的攻击,包括攻击时间、攻击类型等,需要自行根据IP定位到相应的经纬度。

展现要求

  • 地图提供世界、中国、省份,这三种维度(只针对中国)
  • 要在地图上表现出攻击的来源与目标之间的动画
  • 需要强调出攻击受灾地区,可一眼看出哪里是重灾区
  • 可以循环表现攻击,也可实时刷新攻击数据

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- index.html 主页面
- assets
- css
- normalize.css 浏览器初始化样式
- common.css 从bootstrap里扒了一些基础样式
- img/
- js
- app
- mainMap.js index页面的主执行js
- lib
- echarts/ 用了源码包
- zrender/ 同样源码包,具体看echarts官方说明
- geo 一些地理数据的定义
- china/
- world/
- mods
- attackMap/ 对echarts map的封装
- util.js 等等其他帮助或插件模块的封装
- xxxx.js
- config.js

requireJS的config配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
requirejs.config({
baseUrl: 'js/lib',
paths: {
jquery: 'http://cdn.staticfile.org/jquery/1.7.2/jquery.min',
underscore: 'http://cdn.staticfile.org/underscore.js/1.7.0/underscore-min'
},
packages: [
{
name: 'echarts',
location: 'echarts/src',
main: 'echarts'
},
{
name: 'zrender',
location: 'zrender/src',
main: 'zrender'
}
]
});

map封装过程

初步封装 mods/attackMap/main.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
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
define(function(require){

var U = require('underscore');
var EC = require('echarts/echarts');
var ecMap = require('echarts/chart/map');
var ecMapParams = require('echarts/util/mapData/params').params;

var EVENT = require('echarts/config').EVENT;
var MAP_TYPE_WORLD = 'world';
var MAP_TYPE_CHINA = 'china';


var AttackMap = function(config){
this.config = U.extend({
view: MAP_TYPE_WORLD
}, config);

this.el = document.getElementById(this.config.id);
// 初始化echarts
this._init();
};

// 不带下划线的为对外暴露的方法
AttackMap.prototype = {
_init: function(){
// _chart对象私有
this._chart = EC.init(this.el);
// default view
var mapOption = U.extend({}, require('mods/attackMap/mapOption'));
// 合并option
U.extend(mapOption.series[0], this._getViewOption(this.config.view));
// render
this._chart.setOption(mapOption);

// 交互
this._bindEvents();
},

_bindEvents: function(){
var that = this;
this._chart.on(EVENT.CLICK, function(e, chart){
// 仅对中国钻取
if(e.data.name === '中国' || e.data.name === 'China'){
that.setView(MAP_TYPE_CHINA);
}
// and中国省份钻取
else if(e.data.name in ecMapParams){
that.setView(e.data.name);
}
});
},

// view涉及到的series里需要设置的属性
_getViewOption: function(viewType){
if(viewType === MAP_TYPE_WORLD){
return {
mapType: MAP_TYPE_WORLD,
nameMap: require('geo/world/countryName')
}
}
else if(viewType === MAP_TYPE_CHINA){
return {
mapType: MAP_TYPE_CHINA
};
}
else if(viewType in ecMapParams){
return {
mapType: viewType
};
}
return {};
},

_setOtherOption: function(viewType){
if(viewType === MAP_TYPE_WORLD){
this._chart.chart.map.series[0].itemStyle.normal.label.show = false;
this._chart.chart.map.series[0].markLine.effect.period = 15;
}
else if(viewType === MAP_TYPE_CHINA){
this._chart.chart.map.series[0].itemStyle.normal.label.show = false;
this._chart.chart.map.series[0].markLine.effect.period = 8;
}
else{
this._chart.chart.map.series[0].itemStyle.normal.label.show = true;
this._chart.chart.map.series[0].markLine.effect.period = 4;
}
},

// 设置地图视图
setView: function(viewType){
// 上一次的view
(typeof this._lastView === 'undefined') && (this._lastView = this.config.view);
// 防止重复set
if(viewType === this._lastView){
return false;
}
this._lastView = viewType;

// 历史开过的view(string逗号分隔)
(typeof this._historyViews === 'undefined') && (this._historyViews = this.config.view);
// 用来判断是否加载过
if(this._historyViews.indexOf(viewType) === -1){
this._historyViews += (',' + viewType);
// loading
this._chart.showLoading();
// 假loading
var that = this;
setTimeout(function(){
that._chart.hideLoading();
}, 350);
}

// 要先reset再draw
this.reset();
var viewOption = this._getViewOption(viewType);
this._chart.setSeries([viewOption]);
// 多级的option没法merge原来的,所以得手动设置
this._setOtherOption(viewType);
},

// 攻击线
setAttacks: function(data, isLoop){
// 是否循环显示markline(暂未用到)
isLoop = isLoop || true;
// 留个data备份(暂未用到)
this._mData = data;

// TODO: 要对IP聚合
// 国内最小定位到市级,国外只能定位到国家
// 而markline只能通过 name-name 来标识
// 聚合后相同 name-name 的攻击累计次数视为强度

var lineData = U.map(data, function(v){
return [
{name: v['srcName'], geoCoord: [v['srcLocX'], v['srcLocY']]},
{name: v['destName'], geoCoord: [v['destLocX'], v['destLocY']]}
]
});

var pointData = U.map(data, function(v){
return {
name: v['destName'],
geoCoord: [v['destLocX'], v['destLocY']]
}
});

// ECharts内部的核心变量
var _map = this._chart.chart.map;
// 防止addMarkLine抛异常 seriesIndex 0
// _map.buildMark(0);

try{
this._chart.addMarkLine(0, {data: lineData});
}catch(e){
// console.error(e);
}

try{
this._chart.addMarkPoint(0, {data: pointData});
}catch(e){
// console.error(e);
}
},

// 通用方法
refresh: function(){
this._chart.refresh();
},
reset: function(){
this._chart.restore();
}
};

return AttackMap;
});

这里我用echarts中的MarkLine作为攻击线,MarkPoint作为受害地点,AttackMap封装了对echarts的操作过程,对外只暴露setViewsetAttacks两个方法,以实现地图维度的缩放以及攻击线的表现。其中echarts map的通用配置项都拎到了mods/attactMap/mapOption.js中,这里AttackMap只手工操作部分option,比如根据地图的维度修改MarkLine动画的速率。

应用层 js/app/mainMap.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
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
require([
'jquery',
'mods/attackMap/main',
'mods/attackMap/mock'

], function($, AttackMap, Mock){

var View = {
// 作为一个视图模版来初始化
init: function(){
// 此View片段的root元素
// this.$el = $('body');

// 初始化成员
this.aMap = new AttackMap({
id: 'mapChart',
view: 'world'
});

// 绑定事件
this._bindEvents();
},

_bindEvents: function(){
var that = this;
// 视图切换
this._bindMapViewEvents();

// 其他binding
$(window).on('resize', function(){
that.aMap.resize();
});
},

// 视图切换事件
_bindMapViewEvents: function(){
var that = this;

// NOTE: 会有动态生成的元素
$('.J_changeView').live('click', function(){
that.aMap.setView($(this).attr('data-type'));
});
},

// 攻击数据展现
_renderAttacks: function(data){
// render map
this.aMap.setAttacks(data);

// render table
var $tbody = $('#attacksTable').find('tbody');
// var $frags = [];
$.each(data, function(i, v){
var $tr = $('<tr><td>'+v['srcIp']+'</td><td>'+v['srcName']+'</td><td>'+v['destIp']+'</td><td>'+v['destName']+'</td><td>'+v['type']+'</td><td>'+v['time']+'</td></tr>');
$tbody.append($tr);
});
},

// 获取攻击数据
getAttacks: function(){
var that = this;
// ajax TODO

// 本地mock数据
that.attacksData = Mock.data;
that._renderAttacks(that.attacksData);
}
};

// execution
View.init();

// lazy load
setTimeout(function(){
View.getAttacks();
}, 16);

});

至此,在应用层页面上,可以通过点击.J_changeView按钮来切换地图的维度(世界/中国/省份),攻击数据的展现暂时没有ajax调用,只是简单用了mock数据来做,大体效果是一样的。

最终demo

自定义事件封装

在上面的demo链接中看到,不仅应用层页面的按钮可以切换地图维度,直接点击地图里的”中国”区域也能切换地图,同时又能通知到应用层页面的按钮改变状态。因此应用层页面是需要关心AttackMap的状态(事件)的,同样将鼠标放在攻击线上出现的攻击详情,也是通过监听AttackMap的事件实现的。

1、在 mods/attackMap/main.js 中定义事件类型

1
2
3
4
5
6
// 对外事件
AttackMap.EVENTS = {
VIEW_CHANGED: 'viewChanged',
LINE_HOVERED: 'marklineHovered',
LINE_BLURED: 'marklineBlured'
};

2、在AttackMap中实现事件触发器

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
AttackMap.prototype = {
on: function(type, fn){
(typeof this._handlers === 'undefined') && (this._handlers = {});
(typeof this._handlers[type] === 'undefined') && (this._handlers[type] = []);
this._handlers[type].push(fn);
},
fire: function(type, data, event){
if(typeof this._handlers === 'undefined' ||
typeof this._handlers[type] === 'undefined'){
return false;
}

var that = this;
var eventObj = {
type: type,
data: data
};
// 原生event对象
(typeof event !== 'undefined') && (eventObj.event = event);

U.each(this._handlers[type], function(fn){
fn(eventObj, that);
});
}
};

3、在AttackMap内部适当的方法中fire自定义事件

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
AttackMap.prototype = {
_bindEvents: function(){
var that = this;
// 省略...

this._chart.on(EVENT.HOVER, function(e, chart){
// 是markline
if(e.name.indexOf('>') !== -1){
// 阻止此时的tooltip
that._chart.chart.map.component.tooltip.hideTip();

// 由外部去渲染
that.fire(
AttackMap.EVENTS.LINE_HOVERED,
{ name: e.name },
e.event
);
}
// 不是markline,告诉外部
else{
// 效率有点低 每次hover都会触发
that.fire(AttackMap.EVENTS.LINE_BLURED);
}
});
},
setView: function(viewType){
// 省略...

// 对外fire事件
this.fire(
AttackMap.EVENTS.VIEW_CHANGED,
{ viewType: viewType }
);
}
};

当触发AttackMap.EVENTS.LINE_HOVERED事件时,由于应用层页面要绘制攻击详情的浮层,需要知道鼠标位置信息,所以这里fire时将原生的event对象也传了进去。(注意fire方法的实现中,传给回调函数的eventObj对象中,有事件类型type,自定义data,以及原生event对象)

4、在应用层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
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
// 别名
var MAP_EVENTS = AttackMap.EVENTS;

var View = {
// 视图切换事件
_bindMapViewEvents: function(){
var that = this;

// AttackMap监听
this.aMap.on(MAP_EVENTS.VIEW_CHANGED, function(e){
var type = e.data.viewType;
// 清空当前
$current = $('.view-nav.active');
$current.removeClass('active');

// 目标
var $target = $('.view-nav[data-type="' + type + '"]');
if($target.length == 0){
// 另起一个
var $copy = $current.clone();
$copy.addClass('active').attr('data-type', type).text(type);
$('#dynamicNav').empty().append($copy);
}
else{
$target.addClass('active');
}
});

// 省略...
},

// 攻击线(地图markline)事件
_bindMapLineEvents: function(){
var that = this;

this.aMap.on(MAP_EVENTS.LINE_HOVERED, function(e){
// 前提:srcName-destName 必须能唯一区分
// 国外IP目前只能定位到国家
var temps = (e.data.name).split(' > ');
var source = temps[0];
var dest = temps[1];

var attacks = that.attacksData;
// 遍历data
for(var i=0; i<attacks.length; i++){
if(attacks[i]['srcName'] === source && attacks[i]['destName'] === dest){
that._drawMapLineDetail(attacks[i], e.event.pageX, e.event.pageY);
break;
}
}
});

this.aMap.on(MAP_EVENTS.LINE_BLURED, function(e){
that._hideMapLineDetail();
});
},

// 画攻击线详情
_drawMapLineDetail: function(){
// 细节省略...
},
_hideMapLineDetail: function(){
// 细节省略...
}
};

再看一遍demo

点缀的动画效果

时钟模块

比较简单,源码在 js/lib/mods/clock.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
define(['jquery'], function($){
var Clock = function(config){
this.$el = $('#' + this.config.id);
this._init();
};

Clock.prototype = {
_init: function(){
// 细节省略...
this.start();
},
_update: function(){
// 细节省略...
},
start: function(){
// 先初始化时间
this._update();

var that = this;
this.timer = setInterval(function(){
that._update();
}, 1000);
},
stop: function(){
clearInterval(this.timer);
this.timer = null;
}
};

return Clock;
});

move动画封装

原理是采用的css中transform动画,我们原本的做法会是先定义两个css class,一个添加transform的各种css规则,另一个class添加与前一项相反(或清除动画)的css规则,然后通过js操控DOM元素,在两个class之间切换。但我觉得这种做法太挫了,可以把相同效果的transform封装起来(避免写大同小异的css class),于是我封装了一个只做move移动的动画util方法。

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
79
80
81
82
83
define(['jquery', 'underscore'], function($, U){
return {
/* 移动动画
@param el {HTMLElement}
@param x1 {number}
@param y1 {number}
@param x2 {number}
@param y2 {number}
@param config {Object}
@param duration {number}
@param ease {string}
@param isShowEl {boolean} 动画结束后是否继续显示元素
@param isClear {boolean} 动画结束后是否清除动画属性
@param beforeAnim {Function}
@param afterAnim {Function}
*/
moveAnim: function(el, x1, y1, x2, y2, config) {
if(!el){
return;
}
if(!el.tagName && el.length){
// jquery节点
el = el[0];
}

var style = el.style;
config = U.extend({
duration: 400,
ease: 'ease',
isShowEl: true,
isClear: false
}, config);

style.display = 'block';
style.transform = 'translate3d(' + x1 + 'px, ' + y1 + 'px, 0px)';
style.transitionDuration = '0ms';
style.webkitTransform = 'translate3d(' + x1 + 'px, ' + y1 + 'px, 0px)';
style.webkitTransitionDuration = '0ms';

// before animation
config.beforeAnim && config.beforeAnim();

setTimeout(function() {
style.transform = 'translate3d(' + x2 + 'px, ' + y2 + 'px, 0px)';
style.transitionDuration = config.duration + 'ms';
style.transitionTimingFunction = config.ease;
style.webkitTransform = 'translate3d(' + x2 + 'px, ' + y2 + 'px, 0px)';
style.webkitTransitionDuration = config.duration + 'ms';
style.webkitTransitionTimingFunction = config.ease;

// 下面不会有第二次setTimeout
if(config.isShowEl && !config.isClear){
// after animation
config.afterAnim && config.afterAnim();
}
}, 0);

// 动画结束后不显示元素
if(!config.isShowEl){
style.display = 'none';
}
// 清空动画属性(下次show时显示在最初的位置)
if(!config.isShowEl || config.isClear){
var that = this;
setTimeout(function() {
that._clearTransform(el);
// after animation
config.afterAnim && config.afterAnim();
}, config.duration + 10);
}
},

_clearTransform: function(el){
var style = el.style;
style.transform = null;
style.transitionDuration = null;
style.transitionTimingFunction = null;
style.webkitTransform = null;
style.webkitTransitionDuration = null;
style.webkitTransitionTimingFunction = null;
}
}
});

基于move动画的滚动表格

在demo中可以看到屏幕下方的攻击数据的表格一直在滚动播放,现在已经很少人还在用<marquee>这种东西了,好比已经淘汰的用<table>做页面布局。我这里基于上面的动画util方法,实现了一个滚动播放的table组件。

实现思路是,先要对table元素做预处理,将thead拷贝一份,因为表格滚动时thead是不动的(相当于sticky)。代码结构类似上面的Clock类,主动画逻辑包在setInterval中。每次动画循环到来时,取出tbody的第一个tr元素的高度h,然后将table整体向上move这段高度h,move结束后将第一个tr追加到tbody的队尾。具体实现代码见 js/lib/mods/animTable.js

还有什么欠缺的

最初的展现需求都已实现了,在这过程中封装了AttackMap,并自己实现了自定义事件,完全将echarts对外透明了。同时还产出了几个非主要的js小组件,过程看似拉的很长,但都是一步步自然而然会产生的想法。这里还遗留着一个问题,如何将html模板、样式和js模块捆绑起来,即只需reuqire一下模块,模块相应的css会一并载入。

1
2
3
4
5
6
7
8
9
10
11
<!-- 不需要 <link rel="stylesheet" href="moduleA.css"> -->
<div>
<!-- 引入组件的html模板 -->
%{ require moduleA }%
</div>

<script>
require(['mods/moduleA'], function(A){
// something...
});
</script>

我想达到的效果就像上面,应用层页面不需要引组件模块的css,只要inclue一份html模板,require一下对应的js模块。有知道具体做法的吗,我想进一步交流。

demo

感想

  • 在繁忙的项目中抽出时间做些整理和总结,是件重要但不紧急的事情。

  • 和以前写的文章一对比,明显感觉到自己这半年多的成长。