一个页面级别的控制器就像胶水,通过将模块化的功能黏在一起来构造一个鲜活的应用。我们将实现配置与一个明确的生命周期,通过松耦合的架构组合一个单页面应用的多个部分。
介绍
作为一个模块化的工具包,很多Dojo的文档都是在讲解单独的组件如何使用。但是当你需要组合它们来创建一个应用的时候,你需要一个框架来将它们灵活的组织起来。
问题
最佳实践建议保持关注点分离,维护组成应用的模块。所以,如何管理各个组件的加载与初始化,如何将它们与数据结合起来,用户界面处理是否灵活与模块化?
解决方案
一个页面级别的控制器是一个对象,它有管理页面或应用的职责。它假定控制应用的生命周期与各部分的加载。它按正确的顺序初始化与连接那些部件,并能掌控大局。
讨论
Dojo并没有建议我们该如何将它所提供的组件组合成一个应用。它有所有的瓶瓶罐罐,就是没有蓝图。作为一个工具包,就是这么设计的。你可以在你的静态页面使用一些Dojo组件来点缀,或者使用它来构建一个纯GUI的应用,使用哪种设计模式与实现方式取决与你的选择。对于这份教程,我们取个折中,构建这个实现有些关键需求:
- 利用Dojo的包系统来帮助模块加载,通过build脚本来优化
- 模块化维护——避免把一些应用的特殊内容编写进组件中去
- 保持关注点的分离——UI应该与数据分离
开始
我们的需求是构建一个应用允许用户搜索Flickr上的照片,按缩略图展示结果,点击每个缩略图查看对应的大图。这种主—祥(我取的)模式,在很多应用中都有。在本教程中我们专注于组合——如何把分离的部分组合在一起——所以我们先大致预览一个它们各个部分。
存储
应用的数据层由dojox/data/FlickrStore来处理。这是一个开箱即用的组件,实现了dojo/data的读取API,发送请求到Flickr的API服务器。
我们使用标准的fetch方法来传递查询,将转化成到Flickr服务器的JSONP请求,响应并触发onComplete回调。其他组件应该多少知道一点Flickr的东西。任何特殊的需求都应该限制在store的实例中实现,通过提供的配置——也就是我们应用提供的。
UI布局
我们使用在布局教程中有讲过的基于BorderContainer的布局。每个搜索结果将会有它自己的tab在TabContainer中,占居中心的区域。
表单
用户在顶部的输入框中输入搜索关键词。他们可以点击搜索按钮,或按下Enter键来提交搜索。Wiring up event handlers and their actions is the domain of our application controller in this example.
我们可以为我们的应用创建一个自定义的挂件来提供高层的接口,但是这么简单的需求不一定要这么做。
结果列表
我们应用的renderItem方法渲染结果并创建一个新的tab面板。
我们通过事件委托技术来注册一个点击事件监听器,那样在选中列表项的时候就会展示其响应的大图。这里,我们也可以创建一个自定义的挂件来渲染这些项目,但是在应用层面流程与职责没有太大改变。
幻灯片
我们把大图放在一个幻灯片样式的弹出框里面。我们可以实例化一个dojox/image/LightboxNano挂件来显示图片。
加载遮罩
我们一对简单的startLoading与endLoading方法来增减加载遮罩。加载遮罩是应用级别页面关心的事情,所以遮罩的显隐放在应用的控制器中来管理。
第1步 布局
在这个应用中,我们使用声明式的UI创建方法。应用的主布局在页面的标记中陈述,使用适当的data-dojo-type与data-dojo-props属性来配置我们的挂件。
关键词输入字段是一个纯HTML的文本输入框,搜索的提交按钮是一个纯HTML的按钮。Dijit的BorderContainer管理顶部与中心区域的位置与尺寸,让搜索栏固定,搜索结果高度自适应。
滚动操作由分开的tab面板来处理——我们使用了dijit/layout/ContentPane。
1 <script> 2 require([ 3 "dijit/layout/BorderContainer", 4 "dijit/layout/TabContainer", 5 "dijit/layout/ContentPane", 6 "dojo/domReady!" 7 ]); 8 </script>
我们为初始化布局所需要的模块如下:
1 <body class="claro"> 2 <div id="appLayout" class="demoLayout" data-dojo-type="dijit/layout/BorderContainer" data-dojo-props="design:'headline'"> 3 <div class="centerPanel" id="tabs" data-dojo-type="dijit/layoutTabContainer" data-dojo-props="region:'center',tabPosition:'bottom'"> 4 <div data-dojo-type="dijit/layout/ContentPane" data-dojo-props="title:'About'"> 5 <h2>Flickr keyword photo search</h2> 6 <p>Each search creates a new tab with the results as thumbnail</p> 7 <p>Click on any thumbnail to view the larger image</p> 8 </div> 9 </div> 10 <div class="edgePanel" data-dojo-type="dijit/layout/ContentPane" data-dojo-props="region:'top'"> 11 <div class="searchInputColumn"> 12 <div class="searchInputColumInner"> 13 <input id="searchTerms" placeholer="search terms"/> 14 </div> 15 </div> 16 <div class="searchButtonColumn"> 17 <button id="searchBtn">Search</button> 18 </div> 19 </div> 20 </div> 21 </body>
一切都很好,每样东西都在它们应在的位置,但是没有功能,我们需要把功能放在什么地方。当然就是应用控制器中啦。
第2步 应用控制器
我们为应用控制器创建一个新的模块。
1 define([ 2 "dojo/_base/config", 3 "dojox/data/FlickrRestStore", 4 "dojox/image/LightboxNano" 5 ], function(config, FlickrRestStore, LightboxNano) { 6 var store = null, 7 flickrQuery = config.flickrRequest || {}, 8 9 startup = function() { 10 // 创建数据存储 11 store = new FlickrRestStore(); 12 initUi(); 13 }, 14 15 initUi = function() { 16 lightbox = new LightboxNano({}); 17 }, 18 19 doSearch = function() { 20 21 }, 22 23 renderItem = function(item, refNode, posn) { 24 25 }; 26 27 return { 28 init: function() { 29 startup(); 30 } 31 }; 32 });
demo/app模块获得查询详情,它将最终通过Flickr存储从Dojo的配置对象。在模块之外保持很多种细节也许改变在从测试,开发到产品之间。dojoConfig声明如下:
1 dojoConfig = { 2 async: true, 3 isDebug: true, 4 parseOnLoad: true, 5 packages: [{ 6 name: "demo", 7 location: "/documentation/tutoials/1.10/recipes/app_controller/" 8 }], 9 flickrRequest: { 10 apikey: "YOURAPIKEYHERE", 11 sort:[{ 12 attribute: "datetaken", 13 descending: true 14 }] 15 } 16 };
关于dojo/_base/config更多信息,查看教程与参考指南。
demo/app模块是我们将要保存数据存储引用的对方,与查询信息一起,我们在每个Flickr请求中使用。
我们定义一个init方法作为主入口。视觉交互在initUi方法中完成,也就是所有的挂件与DOM依赖的步骤都放在这里。
主要交互动作就是通过doSearch方法发送搜索关键字。
第3步 搜索钩子
控制器有创建请求的能力。它通过调用doSearch方法将事件与搜索栏关联起来,他组合请求对象并调用存储的fetch方法。
当搜索成功后,我们没有在这里直接处理结果,而是通过renderItem方法来处理每个结果,帮组我们实现关注分离。
1 doSearch= function() { 2 // summary: 3 // initiate a search for the given keywords 4 var terms = dom.byId("searchTerms").value; 5 if(!terms.match(/\w+/)) { 6 return; 7 } 8 var listNode = createTab(terms); 9 var results = store.fetch({ 10 query: lang.delegate(flickrQuery, { 11 text: terms 12 }), 13 count: 10, 14 onItem: function(item) { 15 // first assign and record an id 16 // render the items into the <ul> node 17 var node = renderItem(item, listNode); 18 }, 19 onComplete: endLoading 20 }); 21 },
第4步 搜索结果
要处理从store返回的结果,我需要创建renderItem方法。注意,流程没有变,标记没有变,如何获取数据与如何渲染数据依然是分离的。
为了有助于渲染我们为应用控制器添加一些属性——元素内容模版,和一些Flickr返回的用于查找url的对象路径。
1 var itemTemplate = '<img src="${thumbnail}">${title}'; 2 var itemClass = "item"; 3 var itemsById = {}; 4 5 var largeImageProperty = "media.l"; // path to the large image url in the store item 6 var thumbnailImageProperty = "media.t"; // path to the thumb url in the store item
如此以来renderItem就可以工作了:
第5步 查看大图
第6步 加载遮罩
第7步 交错加载
第8步 进一步改进
最终代码
demo/app的代码看起来如下:
1 define([ 2 "dojo/dom", 3 "dojo/dom-style", 4 "dojo/dom-class", 5 "dojo/dom-construct", 6 "dojo/dom-geometry", 7 "dojo/string", 8 "dojo/on", 9 "dojo/aspect", 10 "dojo/keys", 11 "dojo/_base/config", 12 "dojo/_base/lang", 13 "dojo/_base/fx", 14 "dijit/registry", 15 "dojo/parser", 16 "dijit/layout/ContentPane", 17 "dojox/data/FlickrRestStore", 18 "dojox/image/LightboxNano", 19 "demo/module" 20 ], function(dom, domStyle, domClass, domConstruct, domGeometry, string, on, aspect, keys, config, lang, baseFx, registry, parser, ContentPane, FlickrRestStore, LightboxNano) { 21 var store = null, 22 preloadDelay = 500, 23 flickrQuery = config.flickrRequest || {}, 24 25 itemTemplate = '<img src="${thumbnail}">${title}', 26 itemClass = 'item', 27 itemsById = {}, 28 29 largeImageProperty = "media.l", 30 thumbnailImageProperty = "media.t", 31 32 startup = function() { 33 store = new FlickrRestStore(); 34 initUi(); 35 aspect.before(store, "fetch", function() { 36 startLoading(registry.byId("tabs").domNode); 37 }); 38 }, 39 40 endLoading = function() { 41 baseFx.fadeOut({ 42 node: dom.byId("loadingOverlay"), 43 onEnd: function(node) { 44 domStyle.set(node, "display", "none"); 45 } 46 }).play(); 47 }, 48 49 startLoading = function(targetNode) { 50 var overlayNode = dom.byId("loadingOverlay"); 51 if("none" == domStyle.get(overlayNode, "display")) { 52 var coords = domGeometry.getMarginBox(targetNode || document.body); 53 domGeometry.setMarginBox(overlayNode, coords); 54 55 domStyle.set(dom.byId("loadingOverlay"), { 56 display: "block", 57 opacity: 1 58 }); 59 } 60 }, 61 62 initUi = function() { 63 lightbox = new LightboxNano({}); 64 65 on(dom.byId("searchTerms"), "keydown", function(event) { 66 if(event.keyCode == keys.ENTER) { 67 event.preventDefault(); 68 doSearch(); 69 } 70 }); 71 72 on(dom.byId("searchBtn"), "click", doSearch); 73 74 endLoading(); 75 }, 76 77 78 doSearch = function() { 79 var terms = dom.byId("searchTerms").value; 80 if(!terms.match(/\w+/)) { 81 return; 82 } 83 var listNode = createTab(terms); 84 var results = store.fetch({ 85 query: lang.delegate(flickrQuery, { 86 text: terms 87 }), 88 count: 10, 89 onItem: function(item) { 90 itemsById[item.id] = item; 91 var node = renderItem(item); 92 node.id = listNode.id + '_' + item.id; 93 listNode.appendChild(node); 94 }, 95 onComplete: endLoading 96 }); 97 }, 98 99 showImage = function(url, originNode) { 100 lightbox.show({ 101 href: url, 102 origin: originNode 103 }); 104 }, 105 106 createTab = function(term, items) { 107 var contr = registry.byId("tabs"); 108 var listNode = domConstruct.create("ul", { 109 "class": "demoImageList", 110 "id": "panel" + contr.getChildren().length 111 }); 112 113 var panel = new ContentPane({ 114 title: term, 115 content: listNode, 116 closable: true 117 }); 118 contr.addChild(panel); 119 contr.selectChild(panel); 120 121 var hdl = on(listNode, "click", onListClick); 122 return listNode; 123 }, 124 125 showItemById = function(id, originNode) { 126 var item = itemsById[id]; 127 if(item) { 128 showImage(lang.getObject(largeImageProperty, false, item), originNode); 129 } 130 }, 131 132 onListClick = function(event) { 133 var node = event.target, 134 containerNode = registry.byId("tabs").containerNode; 135 136 for(var node = event.target; (node && node !== containerNode); node = node.parentNode) { 137 if(domClass.contains(node, itemClass)) { 138 showItemById(node.id.substring(node.id.indexOf("_") + 1), node); 139 break; 140 } 141 } 142 }, 143 144 renderItem = function(item, refNode, posn) { 145 itemsById[item.id] = item; 146 var props = lang.delegate(item, { 147 thumbnail: lang.getObject(thumbnailImageProperty, false, item) 148 }); 149 150 return domConstruct.create("li", { 151 "class": itemClass, 152 innerHTML: string.substitute(itemTemplate, props) 153 }, refNode, posn); 154 }; 155 156 return { 157 init: function() { 158 startLoading(); 159 startup(); 160 } 161 }; 162 });
总结
在我们构建这个应用的时候按着这种方式做了很多决策。在任何时候答案都是不一样的,不同的需求或预设。如:
- 我们当然可以更整洁地创建自定义挂件来封装结果列表
- 控制器可以派生自一个类
- 我们可以使用通用的数据存储,甚至是较新的dojo/store API
- 我们可以是用自拥有对象来呈现用户界面——控制器与“整体挂件”打交道。