单页应用客户端关注的是构建桌面应用,而不是传统网站。
shell是架构的必需组件。先开发包含功能容器的页面布局,然后让Shell来渲染它们。
3.1 深刻理解Shell
Shell是单页应用的主控制器。只是架构的一部分,是我们从很多商业项目中提炼出来的。
首先编写Shell模块是有好处的,因为它是架构的中枢。它是功能模块和业务逻辑以及通用浏览器接口(像URI或cookie)之间的协调者。当用户点击了后退按钮、登录或做了其他事情而改变了应用状态,这些状态可以使用书签来标记,Shell会协调这些改变。
Shell负责以下事情:
- 渲染和管理功能容器
- 管理应用状态
- 协调功能模块
3.2 创建文件和名字空间
每个JavaScript名字空间都会有一个对应的JavaScript文件,并且使用自执行匿名函数,以免污染全局名字空间。我们也会建立平行的CSS文件结构。这种约定能加快开发、提升质量和简化维护。随着项目中添加更多的模块和开发人员,它的价值就会增加。
创建文件结构:
安装jQuery和插件:http://jquery.com/download/
jquery-3.2.1.js
jQuery的uriAnchor插件提供了管理URI的锚组件的工具方法。https://github.com/mmikowski/urianchor
编写应用的HTML文件:spa.html,为什么不把脚本文件放到body容器的最后面呢?,这样静态的HTML和CSS在JavaScript加载完成前就能显示,通常会使页面渲染更快。然而,单页应用不是这样工作的。它们使用JavaScript来生成HTML,因此将脚本放置在头部之外,并不能更快地渲染页面。相反,我们把所有的外部脚本放在head区块中,以便改进组织结构和易读性。
<!doctype html>
<html>
<head>
<title>SPA Starter</title>
<!-- stylesheets -->
<link rel="stylesheet" href="css/spa.css" type="text/css" />
<link rel="stylesheet" href="css/spa.shell.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>
<!-- our javascript -->
<script src="js/spa.js" ></script>
<script src="js/spa.shell.js" ></script>
<script>
$(function () {
spa.initModule( $('#spa') );
});
</script>
</head>
<body>
<div id="spa"></div>
</body>
</html>
创建CSS根名字空间:css/spa.css
/** 重置大多数选择器,我们不信任浏览器的默认行为 */
* {
margin: 0;
padding: 0;
-webkit-box-sizing : border-box;
-moz-box-sizing : border-box;
box-sizing : border-box;
}
h1, h2, h3, h4, h5, h6, p { margin-bottom: 10px ; }
o1, ul, dl { list-style-position : inside ; }
/** 希望确保跨平台应用有一致的外观。 */
body {
font : 13px 'Trebuchet MS', Verdana, Helvetica, Arial, sans-serif;
color : # 444;
background-color: #888;
}
a {
text-decoration: none;
}
a:link, a:visited { color : inherit; }
a:hover { text-decoration : underline; }
strong {
font-weight: 800;
color : #000;
}
/** 通常使用根名字作为元素选择器,定义选择器的名字空间*/
#spa {
position : absolute;
top : 8px;
left : 8px;
bottom : 8px;
right : 8px;
min-height: 500px;
min-width: 500px;
overflow : hidden;
background-color : #fff;
border-radius : 0 8px 0 8px;
}
/** 其他模块,以spa-x-作为前缀*/
.spa-x-select {}
.spa-x-clearfloat {
height : 0 !important;
float : none !important;
visibility : hidden !important;
clear : both !important;
}
创建JavaScript根名字空间:js/spa.js 希望添加一个初始化应用的方法,确保代码通过JSLint的验证。
/*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
*/
var spa = (function ( ) {
// 初始化
var initModule = function ( $container ) {
$container.html(
'<h1 style="display:inline-block; margin:25px;">'
+ 'hello world!'
+ '</h1>'
);
};
return { initModule : initModule }; //返回spa名字空间中的对象,只导出了initModele方法
}());
安装JSLint: npm install -g jslint
jslint js/spa.js
打开spa.html
3.3 创建功能容器
Shell创建并管理着功能模块要用到的容器。
选取策略:单独在layout.html中开发功能容器的HTML和CSS。再把代码移至Shell的CSS和JavaScript文件。这种做法通常是最快速的,是开发初始布局的最高效方法,因为不用担心与大多数其他代码交互,就能进行工作。先编写HTML,然后添加样式。
编写Shell的HTML:layout.html
<!doctype html>
<html>
<head>
<title>HTML Layout</title>
<link rel="stylesheet" href="css/spa.css" type="text/css" />
<style>
.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-chat,
.spa-shell-modal {
position : absolute;
}
.spa-shell-head {
top : 0;
left : 0;
right : 0;
height : 40px;
}
.spa-shell-head-logo {
top : 4px;
left : 4px;
right : 32px;
width : 128px;
background : orange;
}
.spa-shell-head-acct {
top : 4px;
right : 0;
height : 32px;
width : 64px;
background : green;
}
.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 : 250px;
background : #eee;
}
.spa-x-closed,
.spa-shell-main-nav {
width : 0px;
}
.spa-shell-main-content {
left : 250px;
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-chat {
bottom : 40px;
right : 0;
width : 300px;
height : 15px;
background : red;
z-index : 1;
}
.spa-shell-modal {
margin-top : -200px;
margin-left : -200px;
top : 50%;
left : 50%;
width : 400px;
height : 400px;
background : #fff;
z-index : 2;
}
</style>
</head>
<body>
<div id="spa">
<div class="spa-shell-head">
<div class="spa-shell-head-logo">logo</div>
<div class="spa-shell-head-acct">acct</div>
<div class="spa-shell-head-search">search</div>
</div>
<div class="spa-shell-main">
<div class="spa-shell-main-nav">nav</div>
<div class="spa-shell-main-content">content</div>
</div>
<div class="spa-shell-foot">foot</div>
<div class="spa-shell-chat">chat</div>
<div class="spa-shell-modal">modal</div>
</div>
</body>
</html>
使用Tidy工具来验证以下HTML,确保没有错误。http://http://infohound.net/tidy 在线版
编写Shell的CSS:编写支持流式布局(布局的流动性)的CSS,除了一些最极端的尺寸,内容的宽度和高度会完全自适应填充浏览器窗口。避免使用任何边框,因为它们会更改CSS盒子的尺寸。这在快速原型开发过程中会引入不必要的麻烦。一旦对容器的展示满意了,必要是可以再回过头来添加边框。
3.4 渲染功能容器
让Shell来渲染容器,而不是使用静态的HTML和CSS。
将HTML转换为JavaScript:
在JavaScript中添加HTML模板: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
*/
spa.shell = (function () {
var configMap = {
main_html : String()
+ '<div class="spa-shell-head">'
+ '<div class="spa-shell-head-logo"></div>'
+ '<div class="spa-shell-head-acct"></div>'
+ '<div class="spa-shell-head-search"></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-chat"></div>'
+ '<div class="spa-shell-modal"></div>'
},
stateMap = { $container : null }, /*在整个模块中共享的动态信息*/
jqueryMap = { },
setJqueryMap, initModule;
// 将创建和操作页面元素的函数放在"DOM Methods"区块中
// Begin DOM Methods
setJqueryMap = function () {
//缓存jQuery集合,几乎我们编写的每个Shell和功能模块都应该有这个函数
//可以大大地减少jQuery对文档的遍历次数,能够提高性能。
var $container = stateMap.$container;
jqueryMap = { $container : $container };
};
initModule = function ( $container ) {
stateMap.$container = $container;
$container.html( configMap.main_html );
setJqueryMap();
};
return { initModule : initModule };
} ());
编写Shell的样式表:css/spa.shell.css的文件中使用spa-shell-*选择器。
.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-chat,
.spa-shell-modal {
position : absolute;
}
.spa-shell-head {
top : 0;
left : 0;
right : 0;
height : 40px;
}
.spa-shell-head-logo {
top : 4px;
left : 4px;
right : 32px;
width : 128px;
background : orange;
}
.spa-shell-head-acct {
top : 4px;
right : 0;
height : 32px;
width : 64px;
background : green;
}
.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 : 250px;
background : #eee;
}/*使用父类来影响子元素。这大概是CSS的一个最强大的功能,但几乎没被频繁的使用*/
.spa-x-closed,
.spa-shell-main-nav {
width : 0px;
}
.spa-shell-main-content {
left : 250px;
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-chat {
bottom : 40px;
right : 0;
width : 300px;
height : 15px;
background : red;
z-index : 1;
}
.spa-shell-modal {
margin-top : -200px;
margin-left : -200px;
top : 50%;
left : 50%;
width : 400px;
height : 400px;
background : #fff;
z-index : 2;
}
指示应用使用Shell:js/spa.js,以便使用Shell,
var spa = (function ( ) {
// 初始化
var initModule = function ( $container ) {
spa.shell.initModule( $container );
};
return { initModule : initModule }; //返回spa名字空间中的对象,只导出了initModele方法
}());
打开spa.html
3.5 管理功能容器
编写展开或收起聊天滑块的方法:需求如下
- 开发人员能够配置滑块运动的速度和高度。
- 创建单个方法来展开或者收起聊天滑块
- 避免出现竞争条件,即滑块可能同时在展开和收起。
- 开发人员能够传入一个可选的回调函数,会在滑块运动结束时调用。
- 创建测试代码,以便确保滑块功能正常。
修改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
*/
spa.shell = (function () {
var configMap = {
main_html : String()
+ '<div class="spa-shell-head">'
+ '<div class="spa-shell-head-logo"></div>'
+ '<div class="spa-shell-head-acct"></div>'
+ '<div class="spa-shell-head-search"></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-chat"></div>'
+ '<div class="spa-shell-modal"></div>',
chat_extend_time : 1000,
chat_retract_time : 300,
chat_extend_height : 450,
chat_retract_height : 15
},
stateMap = { $container : null }, /*在整个模块中共享的动态信息*/
jqueryMap = { },
setJqueryMap, initModule, toggleChat;
// 将创建和操作页面元素的函数放在"DOM Methods"区块中
// Begin DOM Methods
setJqueryMap = function () {
//缓存jQuery集合,几乎我们编写的每个Shell和功能模块都应该有这个函数
//可以大大地减少jQuery对文档的遍历次数,能够提高性能。
var $container = stateMap.$container;
jqueryMap = {
$container : $container,
$chat : $container.find('.spa-shell-chat')
};
};
toggleChat = function ( do_extend, callback ) {
var px_chat_ht = jqueryMap.$chat.height();
var is_open = px_chat_ht === configMap.chat_extend_height;
var is_closed = px_chat_ht === configMap.chat_retract_height;
var is_sliding = ! is_open && !is_closed;
// 避免出现竞争条件,即同时在展开和收起,如果已经在运动中,则拒绝执行
if (is_sliding) { return false; }
// Begin extend chat slider
if (do_extend) {
jqueryMap.$chat.animate (
{ height : configMap.chat_extend_height },
configMap.chat_extend_time,
function () {
if (callback) { callback( jqueryMap.$chat ); }
}
);
return true;
}
// Begin retract chat slider
jqueryMap.$chat.animate (
{ height : configMap.chat_retract_height },
configMap.chat_retract_time,
function () {
if (callback) { callback( jqueryMap.$chat ); }
}
);
return true;
};
initModule = function ( $container ) {
stateMap.$container = $container;
$container.html( configMap.main_html );
setJqueryMap();
// test toggle
setTimeout( function () { toggleChat( true ); }, 3000 );
setTimeout( function () { toggleChat( false ); }, 8000 );
};
return { initModule : initModule };
} ());
打开spa.html
给聊天滑块添加点击事件处理程序:需求如下
- 设置提示信息文字来提示用户操作,比如“Click to retract”
- 添加点击事件处理程序来调用toggleChat
- 点击事件处理程序绑定到jQuery事件上。
3.6 管理应用状态
浏览器控件 桌面控件
回退按钮 撤销
前进按钮 重做
标签 另存为
查看历史 撤销历史
选取一个策略来管理历史控件:P80
- Susan访问了我们的单页应用,点击聊天滑块来打开它。
- 她将单页应用添加为书签,然后浏览了其他网站
- 之后,她决定回到我们的应用,于是点击了她的书签。
URI的锚组件http://localhost/apa.html#!chat=open
回到顶部的链接。跳转,可书签化
锚组件的一个独特功能是,在它改变的时候,浏览器不会重新加载页面。锚组件是只给客户端使用的控件,它是保存应用状态的理想地方。
使用uriAnchor插件
onClickChat = function (event) {
if ( toggleChat( stateMap.is_chat_retracted )) {
$.uriAnchor.setAnchor( {
chat : (stateMap.is_chat_retracted ? 'open' : 'closed' )
});
}
return false;
};
点击滑块时,URI会改变
使用锚来驱动应用状态:
/*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
*/
spa.shell = (function () {
var configMap = {
anchor_schema_map : {
chat : {open : true, closed : true }
},
main_html : String()
+ '<div class="spa-shell-head">'
+ '<div class="spa-shell-head-logo"></div>'
+ '<div class="spa-shell-head-acct"></div>'
+ '<div class="spa-shell-head-search"></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-chat"></div>'
+ '<div class="spa-shell-modal"></div>',
chat_extend_time : 1000,
chat_retract_time : 300,
chat_extend_height : 450,
chat_retract_height : 15,
chat_extended_title : 'Click to retract',
chat_retract_title : 'Click to extend'
},
stateMap = {
$container : null,
anchor_map : {},
is_chat_retracted : true
}, /*在整个模块中共享的动态信息*/
jqueryMap = { },
setJqueryMap, initModule, toggleChat,
copyAnchorMap, changeAnchorPart, onHashchange,
onClickChat;
// 将创建和操作页面元素的函数放在"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;
//
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 'open':
toggleChat(true);
break;
case 'closed':
toggleChat(false);
break;
default:
toggleChat(false);
delete anchor_map_proposed.char;
$.uriAnchor.setAnchor( anchor_map_proposed, null, true );
}
}
return false;
};
// Begin DOM Methods
setJqueryMap = function () {
//缓存jQuery集合,几乎我们编写的每个Shell和功能模块都应该有这个函数
//可以大大地减少jQuery对文档的遍历次数,能够提高性能。
var $container = stateMap.$container;
jqueryMap = {
$container : $container,
$chat : $container.find('.spa-shell-chat')
};
};
toggleChat = function ( do_extend, callback ) {
var px_chat_ht = jqueryMap.$chat.height();
var is_open = px_chat_ht === configMap.chat_extend_height;
var is_closed = px_chat_ht === configMap.chat_retract_height;
var is_sliding = ! is_open && !is_closed;
// 避免出现竞争条件,即同时在展开和收起,如果已经在运动中,则拒绝执行
if (is_sliding) { return false; }
// Begin extend chat slider
if (do_extend) {
jqueryMap.$chat.animate (
{ height : configMap.chat_extend_height },
configMap.chat_extend_time,
function () {
jqueryMap.$chat.attr(
'title', configMap.chat_extended_title
);
stateMap.is_chat_retracted = false;
if (callback) { callback( jqueryMap.$chat ); }
}
);
return true;
}
// Begin retract chat slider
jqueryMap.$chat.animate (
{ height : configMap.chat_retract_height },
configMap.chat_retract_time,
function () {
jqueryMap.$chat.attr(
'title', configMap.chat_restracted_title
);
stateMap.is_chat_retracted = true;
if (callback) { callback( jqueryMap.$chat ); }
}
);
return true;
};
onClickChat = function (event) {
/*
if ( toggleChat( stateMap.is_chat_retracted )) {
$.uriAnchor.setAnchor( {
chat : (stateMap.is_chat_retracted ? 'open' : 'closed' )
});
}*/
changeAnchorPart( {
chat: ( stateMap.is_chat_retracted ? 'open' : 'closed' )
});
return false;
};
initModule = function ( $container ) {
stateMap.$container = $container;
$container.html( configMap.main_html );
setJqueryMap();
// test toggle
//setTimeout( function () { toggleChat( true ); }, 3000 );
//setTimeout( function () { toggleChat( false ); }, 8000 );
stateMap.is_chat_retracted = true;
jqueryMap.$chat
.attr('title', configMap.chat_restracted_title)
.click( onClickChat );
$.uriAnchor.configModule( {
schema_map : configMap.anchor_schema_map
});
$(window)
.bind( 'hashchange', onHashchange )
.trigger( 'hashchange' );
};
return { initModule : initModule };
} ());