设计和构建Model的people对象。Model向Shell和功能模块提供业务逻辑和数据。Model不依赖用户界面,它被分离出来负责逻辑和数据管理。Model自身通过使用Data模块,从Web服务器分离出来。
希望单页应用使用people对象来管理人员列表,这包括用户以及和我们聊天的人。
用户登入和登出,添加触摸控件。
5.1 理解Model
Model自身通过使用Data模块,从Web服务器分离出来。这种分离对开发人员和质量保证都有好处。
Shell会管理登入,Chat功能模块将管理聊天窗口。Avatar功能模块将管理代表人员的彩色盒子。
- 为了管理登入和登出的过程,Shell需要知道当前用户,它需要确定“当前用户是谁”的方法,在需要是更改用户。
- Chat功能模块也需要查看当前用户,以此判断他是否已授权发送或者接收消息。它需要确定正在和用户聊天的人,如果有的话。它需要查询在线人员的列表,这样就可以把他们显示在聊天滑块的左边。最后,它需要发送消息和选择用户进行聊天的方法。
- Avatar功能模块也需要查看当前用户,以此判断他是否已授权查看头像并与之交互。它也需要当前用户的身份证明,这样它就能把当前用户的头像显示为蓝色。它也需要确定正在和用户聊天的人,这样它就能把这个人的头像显示为绿色。最后,它需要设置和检索当前所有在线人员头像的详细信息的方法。
模块很多必需的业务逻辑和数据都是重叠的。
- 在每个功能模块中构建必需的逻辑和数据
- 在不同的功能模块中构建部分逻辑和数据。比如,把Chat当作people对象的拥有者,Avatar是chat对象的拥有者。然后在模块之间互相调用,以便共享信息。
- 构建中央Model,合并逻辑和数据。
第3种做法,目前为止最好的。
Model不可以假定存在document对象这种浏览器特有的方法。方便单元测试和回归测试。
Model不提供通用的工具方法。
Model不直接和服务器通信,Data模块负责
5.2 创建Model和其他文件
创建css/spa.avtr.css:
下载以下3个库到spa/js/jq:
提供了统一的触摸和鼠标输入
https://raw.githubusercontent.com/mmikowski/jquery.event.ue/master/jquery.event.ue.js
使用全局自定义事件时需要这个文件
https://raw.githubusercontent.com/mmikowski/jquery.event.gevent/master/jquery.event.gevent.js
提供了客户端数据库,它不是jQuery插件。
https://raw.githubusercontent.com/typicaljoe/taffydb/master/taffy.js
创建js/spa.avtr.js js/spa.data.js js/spa.fake.js
添加浏览器端工具js/spa.util_b.js,在Node.js中不能正常工作,而js/spa.util.js是可以的。
用来编码和解码HTML中的特殊字符,比如&和<。
5.3 设计people对象
需要识别4种类型的person:当前用户、匿名、正在和用户聊天的、其他在线的
设计people对象的API:由方法和jQuery全局自定义事件组成。
- get_user() --- 返回当前用户,如果未登入,则返回匿名person
- get_db() --- 获取所有person对象的集合
- get_by_cid(<client_id>) --- 获取client_id的person对象
- login(<user_name>) --- 以指定用户名的方法登入用户
- logout() --- 退出。
设计people的事件:Model更新数据将广播各个模块。
给people对象的API编写文档:
5.4 构建people对象
用Fake模块来向Model提供伪造数据。它是快速开发的关键所在,在完成目标之前,就一直使用伪造数据。
创建伪造的人员列表:在Model中设置isFakeData标志。spa.fake.getPeopleList
开始构建people对象:创建person对象的TaffyDB集合。spa.model.initModule()
完成people对象的构建:login() logout() get_user()
使用有jQuery的Node.js就能够运行这个测试集,无需使用浏览器,请查看附录B
5.5 在Shell中开启登入和登出的功能
设计用户登入的体验:
- 如果用户未登入,提示信息为Please Sign-in。点击显示登入对话框。
- 当用户填完对话框表单
- 移除登入对话框,显示...processing...
- 一旦登入过程完成,显示登入用户名
更新Shell的JavaScript: 熟悉jQuery全局自定义事件的发布-订阅特性。
更新Shell的样式表:
css/spa.shell.css
.spa-shell-head, .spa-shell-head-logo, .spa-shell-head-acct,
.spa-shell-head-search, .spa-shell-main, .spa-shell-main-nav,
.spa-shell-main-content, .spa-shell-foot,
.spa-shell-modal {
position : absolute;
}
.spa-shell-head {
top : 0;
left : 0;
right : 0;
height : 40px;
}
.spa-shell-head-logo {
top : 4px;
left : 8px;
right : 32px;
width : 128px;
background : orange;
}
.spa-shell-head-logo h1 {
font : 800 22px/22px Arial, Helvetica, sans-serif;
margin : 0;
}
.spa-shell-head-logo p {
font : 800 10px/10px Arial, Helvetica, sans-serif;
margin : 0;
}
.spa-shell-head-acct {
top : 4px;
right : 0;
height : 32px;
width : 210px;
line-height: 32px;
background : #888;
color : #fff;
text-align : center;
cursor : pointer;
overflow : hidden;
text-overflow : ellipsis;
}
.spa-shell-head-search {
top : 4px;
right : 64px;
height : 32px;
width : 248px;
background : blue;
}
.spa-shell-main {
top : 40px;
left : 0;
right : 0;
bottom : 40px;
}
.spa-shell-main-content,
.spa-shell-main-nav {
top : 0px;
bottom : 0px;
}
.spa-shell-main-nav {
width : 400px;
background : #eee;
z-index : 1;
}/*使用父类来影响子元素。这大概是CSS的一个最强大的功能,但几乎没被频繁的使用*/
.spa-x-closed,
.spa-shell-main-nav {
width : 0px;
}
.spa-shell-main-content {
left : 400px;
right : 0;
background : #ddd;
}/*缩进派生选择器,紧跟在父选择器的下面*/
.spa-x-closed,
.spa-shell-main-content {
left : 0px;
}
.spa-shell-foot {
height : 40px;
left : 0;
right : 0;
bottom : 0;
}
.spa-shell-modal {
margin-top : -200px;
margin-left : -200px;
top : 50%;
left : 50%;
width : 400px;
height : 400px;
background : #fff;
z-index : 2;
display : none;
}
js/spa.shell.js
/*jslint browser : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : true,
white : true
*/
/*global $, spa */
spa.shell = (function () {
'use strict';
var configMap = {
anchor_schema_map : {
chat : {opened : true, closed : true }
},
main_html : String()
+ '<div class="spa-shell-head">'
+ '<div class="spa-shell-head-logo">'
+ '<h1>SPA</h1>'
+ '<p>javascript end to end</p>'
+ '</div>'
+ '<div class="spa-shell-head-acct"></div>'
+ '</div>'
+ '<div class="spa-shell-main">'
+ '<div class="spa-shell-main-nav"></div>'
+ '<div class="spa-shell-main-content"></div>'
+ '</div>'
+ '<div class="spa-shell-foot"></div>'
+ '<div class="spa-shell-modal"></div>',
resize_interval : 200
},
stateMap = {
anchor_map : {},
resize_idto : undefined
}, /*在整个模块中共享的动态信息*/
jqueryMap = { };
var
setJqueryMap, initModule,
copyAnchorMap, changeAnchorPart,
onHashchange, setChatAnchor, onResize,
onTapAcct, onLogin, onLogout;
// 将创建和操作页面元素的函数放在"DOM Methods"区块中
copyAnchorMap = function () {
return $.extend( true, {}, stateMap.anchor_map );
};
changeAnchorPart = function (arg_map) {
var anchor_map_revise = copyAnchorMap();
var bool_return = true;
var key_name, key_name_dep;
KEYVAL:
for (key_name in arg_map) {
if (arg_map.hasOwnProperty( key_name )) {
if (key_name.indexOf('_') === 0) { continue KEYVAL; }
anchor_map_revise[key_name] = arg_map[key_name];
key_name_dep = '_' + key_name;
if (arg_map[key_name_dep]) {
anchor_map_revise[key_name_dep] = arg_map[key_name_de];
} else {
delete anchor_map_revise[key_name_dep];
delete anchor_map_revise['_s' + key_name_dep];
}
}
}
//
try {
$.uriAnchor.setAnchor( anchor_map_revise );
} catch (error) {
$.uriAnchor.setAnchor( stateMap.anchor_map, null, true );
bool_return = false;
}
return bool_return;
};
onHashchange = function (event) {
var anchor_map_previous = copyAnchorMap();
var anchor_map_proposed, _s_chat_previous;
var _s_chat_proposed, s_chat_proposed;
var is_ok = true;
//
try {
anchor_map_proposed = $.uriAnchor.makeAnchorMap();
} catch (error) {
$.uriAnchor.setAnchor( anchor_map_previous, null, true );
return false;
}
stateMap.anchor_map = anchor_map_proposed;
//
_s_chat_previous = anchor_map_previous._s_chat;
_s_chat_proposed = anchor_map_proposed._s_chat;
//
if ( ! anchor_map_previous
|| _s_chat_previous !== _s_chat_proposed ) {
s_chat_proposed = anchor_map_proposed.chat;
switch (s_chat_proposed) {
case 'opened':
is_ok = spa.chat.setSliderPosition('opened');
break;
case 'closed':
is_ok = spa.chat.setSliderPosition('closed');
break;
default:
is_ok = spa.chat.setSliderPosition('closed');
delete anchor_map_proposed.char;
$.uriAnchor.setAnchor( anchor_map_proposed, null, true );
}
}
if (!is_ok) {
if (anchor_map_previous) {
$.uriAnchor.setAnchor( anchor_map_previous, null, true );
stateMap.anchor_map = anchor_map_previous;
} else {
delete anchor_map_proposed.chat;
$.uriAnchor.setAnchor( anchor_map_proposed, null, true );
}
}
return false;
};
// Event handler
onResize = function () {
if (stateMap.resize_idto) { return true; }
spa.chat.handleResize();
stateMap.resize_idto = setTimeout(
function () {
stateMap.resize_idto = undefined;
}, configMap.resize_interval
);
return true;
};
// Begin DOM Methods
setJqueryMap = function () {
//缓存jQuery集合,几乎我们编写的每个Shell和功能模块都应该有这个函数
//可以大大地减少jQuery对文档的遍历次数,能够提高性能。
var $container = stateMap.$container;
jqueryMap = {
$container : $container,
$acct : $container.find('.spa-shell-head-acct'),
$nav : $container.find('.spa-shell-main-nav')
};
};
//点击账户元素
onTapAcct = function ( event ) {
var acct_text, user_name, user = spa.model.people.get_user();
if (user.get_is_anon() ) {
user_name = prompt( 'Please sign-in' );
spa.model.people.login( user_name );
jqueryMap.$acct.text( '... processing ...');
} else {
spa.model.people.logout();
}
return false;
};
//更新用户区
onLogin = function ( event, login_user ) {
jqueryMap.$acct.text( login_user.name );
};
//
onLogout = function ( event, logout_user ) {
jqueryMap.$acct.text( 'Please sign-in' );
};
setChatAnchor = function (position_type) {
return changeAnchorPart(
{ chat : position_type }
);
}
initModule = function ( $container ) {
stateMap.$container = $container;
$container.html( configMap.main_html );
setJqueryMap();
$.uriAnchor.configModule( {
schema_map : configMap.anchor_schema_map
});
// configure and initialize feature modules
spa.chat.configModule( {
chat_model : spa.model.chat,
set_chat_anchor : setChatAnchor,
people_model : spa.model.people
} );
spa.chat.initModule( jqueryMap.$container );
//
$.gevent.subscribe( $container, 'spa-login', onLogin );
$.gevent.subscribe( $container, 'spa-logout', onLogout );
jqueryMap.$acct
.text( 'Please sign-in' )
.bind( 'utap', onTapAcct );
//
$(window)
.bind( 'hashchange', onHashchange )
.trigger( 'hashchange' );
};
return { initModule : initModule };
} ());
js/spa.model.js
/*jslint browser : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : true,
white : true
*/
/*global TAFFY $, spa */
spa.model = (function () {
'use strict';
var configMap = {
anon_id : 'a0'
};
var stateMap = {
anon_user : null,
cid_serial : 0,
people_cid_map : {},
people_db : TAFFY(),
user : null
};
var isFakeData = true;
var personProto, makePerson, people, initModule;
var makeCid, clearPeopleDb, completeLogin, removePerson;
personProto = {
get_is_user : function () {
return this.cid === stateMap.user.cid;
},
get_is_anon : function () {
return this.cid === stateMap.anon_user.cid;
}
};
makeCid = function () {
return 'c' + String( stateMap.cid_serial++ );
};
clearPeopleDb = function () {
var user = stateMap.user;
stateMap.people_db = TAFFY();
stateMap.people_cid_map = {};
if (user) {
stateMap.people_db.insert( user );
stateMap.people_cid_map[ user.cid ] = user;
}
};
completeLogin = function ( user_list ) {
var user_map = user_list[0];
delete stateMap.people_cid_map[ user_map.cid ];
stateMap.user.cid = user_map._id;
stateMap.user.id = user_map._id;
stateMap.user.css_map = user_map.css_map;
stateMap.people_cid_map[ user_map._id ] = stateMap.user;
// When we add chat, we should join here
$.gevent.publish( 'spa-login', [stateMap.user] );
};
makePerson = function ( person_map ) {
var person,
cid = person_map.cid,
css_map = person_map.css_map,
id = person_map.id,
name = person_map.name;
if ( cid === undefined || !name ) {
throw 'client id and name required';
}
person = Object.create( personProto );
person.cid = cid;
person.name = name;
person.css_map = css_map;
if ( id ) { person.id = id; }
//加入Taffy数据库
stateMap.people_cid_map[cid] = person;
stateMap.people_db.insert( person );
return person;
};
removePerson = function( person ) {
if (!person) {
return false;
}
if ( person.id === configMap.anon_id ) {
return false;
}
stateMap.people_db( {cid : person.cid }).remove();
if (person.cid) {
delete stateMap.people_cid_map[ person.cid ];
}
return true;
};
people = (function () {
var get_by_cid, get_db, get_user, login, logout;
get_by_cid = function ( cid ) {
return stateMap.people_cid_map[ cid ];
};
get_db = function () { return stateMap.people_db; };
get_user = function () { return stateMap.user; };
login = function ( name ) {
// 模拟SocketIO
var sio = isFakeData ? spa.fake.mockSio : spa.data.getSio();
stateMap.user = makePerson( {
cid : makeCid(),
css_map : { top:25, left : 25, 'background-color':'#8f8'},
name : name
});
sio.on( 'userupdate', completeLogin );
sio.emit( 'adduser', {
cid : stateMap.user.cid,
css_map : stateMap.user.css_map,
name : stateMap.user.name
});
};
logout = function () {
var is_removed, user = stateMap.user;
//
is_removed = removePerson( user );
stateMap.user = stateMap.anon_user;
$.gevent.publish('spa-logout', [user]);
return is_removed;
};
//导出people对象的所有公开方法
return {
get_by_cid : get_by_cid,
get_db : get_db,
get_user : get_user,
login : login,
logout : logout
};
}());
initModule = function () {
var i, people_list, person_map;
//
stateMap.anon_user = makePerson( {
cid : configMap.anon_id,
id : configMap.anon_id,
name : 'anonymous'
});
stateMap.user = stateMap.anon_user;
if (isFakeData) {
//获取伪造数据
people_list = spa.fake.getPeopleList();
for (i=0; i<people_list.length; i++) {
person_map = people_list[i];
makePerson( {
cid : person_map._id,
css_map : person_map.css_map,
id : person_map.id,
name : person_map.name
});
}
}
};
return {
initModule : initModule,
people : people
};
}());
js/spa.fake.js
/*jslint browser : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : true,
white : true
*/
/*global $, spa */
spa.fake = (function () {
'use strict';
var getPeopleList, fakeIdSerial, makeFakeId, mockSio;
fakeIdSerial = 5;
//模拟服务端IO字符串
makeFakeId = function () {
return 'id_' + String( fakeIdSerial++ );
};
getPeopleList = function () {
return [
{ name : 'Berry', _id : 'id_01',
css_map : { top:20, left: 20,
'background-color' : 'rgb(128, 128, 128)'
}
},
{ name : 'Mike', _id : 'id_02',
css_map : { top:60, left: 20,
'background-color' : 'rgb(128, 255, 128)'
}
},
{ name : 'Pebbles', _id : 'id_03',
css_map : { top:100, left: 20,
'background-color' : 'rgb(128, 192, 192)'
}
},
{ name : 'Wilma', _id : 'id_04',
css_map : { top:140, left: 20,
'background-color' : 'rgb(192, 128, 128)'
}
}
];
};
//在mockSio闭包里面创建on_sio方法
mockSio = (function () {
var on_sio, emit_sio, callback_map = {};
on_sio = function ( msg_type, callback ) {
callback_map[ msg_type ] = callback;
};
emit_sio = function ( msg_type, data ) {
// adduser
if ( msg_type === 'adduser' && callback_map.userupdate ) {
setTimeout( function () {
callback_map.userupdate(
[{
_id : makeFakeId(),
name : data.name,
css_map : data.css_map
}]
);
}, 3000);
}
};
return { emit : emit_sio, on : on_sio };
}());
return {
getPeopleList : getPeopleList,
mockSio : mockSio
};
}());
js/spa.util_b.js
/*jslint browser : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : true,
white : true
*/
/*global $, spa, getComputedStyle */
spa.util_b = (function () {
'use strict';
var configMap = {
regex_encode_html : /[&"'><]/g,
regex_encode_noamp : /["'><]/g,
html_encode_map : {
'&' : '&',
'"' : '"',
"'" : ''',
'>' : '>',
'<' : '<'
}
};
var decodeHtml, encodeHtml, getEmSize;
configMap.encode_noamp_map = $.extend( {}, configMap.html_encode_map );
delete configMap.encode_noamp_map['&']; //
//把浏览器实体(如&)转换成显示字符(如&)。
decodeHtml = function (str) {
return $('<div/>').html(str || '').text();
};
//把特殊字符(如&)转换成HTML编码值(如&).
encodeHtml = function ( intput_arg_str, exclude_amp ) {
var input_str = String( input_arg_str ),
regex, lookup_map;
if (exclude_amp) {
lookup_map = configMap.encode_noamp_map;
regex = configMap.regex_encode_noamp;
} else {
lookup_map = configMap.html_encode_map;
regex = configMap.regex_encode_html;
}
return input_str.replace(regex, function (match, name) {
return lookup_map[ match ] || '';
});
};
//计算em单位的像素大小
getEmSize = function (elem) {
return Number( getComputedStyle( elem, '').fontSize.match(/\d*\.?\d*/)[0]);
};
//导出所有的公开方法
return {
decodeHtml : decodeHtml,
encodeHtml : encodeHtml,
getEmSize : getEmSize
};
}());
spa.html
<!doctype html>
<html>
<head>
<!-- ie9+ rendering support for latest standards -->
<meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>SPA Chapter 5-6</title>
<!-- third-party stylesheets -->
<!-- our stylesheets -->
<link rel="stylesheet" href="css/spa.css" type="text/css" />
<link rel="stylesheet" href="css/spa.chat.css" type="text/css" />
<link rel="stylesheet" href="css/spa.shell.css" type="text/css" />
<link rel="stylesheet" href="css/spa.avtr.css" type="text/css" />
<!-- third-party javascript -->
<script src="js/jq/jquery-3.2.1.js" ></script>
<script src="js/jq/jquery.uriAnchor.js" ></script>
<script src="js/jq/jquery.event.gevent.js" ></script>
<script src="js/jq/jquery.event.ue.js" ></script>
<script src="js/jq/taffy.js" ></script>
<!-- our javascript -->
<script src="js/spa.js" ></script>
<script src="js/spa.util.js" ></script>
<script src="js/spa.model.js" ></script>
<script src="js/spa.data.js" ></script>
<script src="js/spa.fake.js" ></script>
<script src="js/spa.util_b.js" ></script>
<script src="js/spa.shell.js" ></script>
<script src="js/spa.chat.js" ></script>
<script src="js/spa.avtr.js" ></script>
<script>
$(function () {
spa.initModule( $('#spa') );
});
</script>
</head>
<body>
<div id="spa"></div>
</body>
</html>