在一般的Web GUI 中,每个应用都分散在一个页面中,会随着页面的跳转而反映在浏览器的地址栏上;稍微复杂的基于Web 系统中,都采用划分Frame 元素或打开浏览器新窗口的方式来组织页面,从浏览器的地址看起来,虽然只有一个地址,但是子Frame 的页面还是会整张页面地刷新。AJAX 改变了以往一张页面一次请求的模式,可以允许在同一张页面发起各种的请求,这样我们对于页面的组织形式有了新的途径。在单页面GUI 模型中,主页面是可以独立加载、更新和替换的一些可视元素的组合。通过这种方式,可以不必在每次用户操作后重新加载整个页面。在任何时候,都只显示与应用程序当前阶段相关的可视元素和内容。其他所有内容均被隐藏;但只要应用程序流程中需要用到它,它就会显示出来。
单页面GUI 与传统页面组织的本质区别在于,单页面只允许一个document 对象存在,一切UI 组件的根节点就是这个document 对象,程序所有 UI 的渲染任务均在这个页面内完成,即使是表单提交的任务也不需要作页面转向(Redirect )。图一是单页面GUI 的对象组织示意图:
Ajax 技术的出现给页面带来了一些变化,其中最直观的莫过于站点的页面上出现越来越多的“loading …” ,“正在加载中……”等提示信息,忽如一夜春风来,loading 加载处处开的意思。“loading …” 或者“正在加载中……”表示浏览器正在与服务器之间进行交互,交互完成之后,将对页面进行局部刷新,这种交互模式虽然简单却极大的提高了Web 应用的用户体验,使单页面GUI 的设计成为可能。
单页面GUI 为我们带来了什么?
单页面GUI 的定义是相对于多页面概念(Multi-page )的,两者的对比略举如下:
单页面GUI 的应用情况
曾经有一个实际案例是,把74 张JSP 页面转化为单个页面,利用Java 下常用的Ajax 框架DWR 向负责后台的通讯,结果是450 行的HTML 和200 行的CSS 。有许多的Ajax 程序采用单页面的方案,典型的有Google 下的一系列在线应用,比如GMail 、Reader 、Maps 等, 雅虎的Oddpost 和微软的Kahuna 、Start 等。以上的应用都是大规模的Web 应用,如果在实际的项目开发中,单页面GUI 很难说一定比多页面的设计好,因为在传统开发模式大背景下,多数开发者希望利用IDE 或类似WinForms/Swing 的GUI “ 画出”控件这样强大的支持来解决表示层的方案,以提供工作效率;另一方面,大量JavaScript 投入项目产生,开发者会因JavaScript 的困惑而对项目总体把握度而大打折扣,因此开发人员对于哪些逻辑可以在客户端执行应该有一个清醒的认识。单页面实施起来可以说难度更高,但从后期的维护工作量和用户体验来讲,效果会更好。
动态资源下载
单页面GUI 意味着页面内所有功能会重新地被规划安排。通常,这个规划过程会被开发人员理解为所必需的模块化,因为通过其可以方便地进行调试和编码。当网页内的各种资源,包括HTML 、图片、脚本程序等的资源数量或体积特别大的时候,我们必须采取一定策略来优化资源的加载,例如HTML 内容过长我们可以分页,图片体积过大可以采用更高压缩比的格式或缩小尺寸等的手段优化,从而在同一时刻内,使得浏览器保持在一个合理的资源调控,和带来较好的用户体验。另一个方法减少初始下载是,对页面的内容部分进行延迟加载。
脚本程序属于网页资源中特殊的一种。传统多页面的设计下,每个页面需要的脚本文件会按照功能上划分而有所不同。这就需要使用者在页面上手动加入标签装载指定的脚本资源。随着脚本数量的增多,如果都要为这些文件去管理脚本的引用标签,组织页面,将会是一件痛苦的事情,尤其使用者在不清楚类库之间的依赖关系、加载先后顺序的情况下,更容易出现错误。最直截了当的解决办法是将常用的类库资源打包到单一的框架文件中,作为一个完整的加载资源出现。Ext 也是采用这种策略(ext-all.js )。实际上,如果采用单页面GUI 的方案(One Page One Application ,简称OPOA ),——即所有的任务均在同一页面内完成的方案。相对而言,这种方案即使不过多关心如何按需加载的,也是情有可原的。当然从页面运行效率而言,完整加载的方式是有害的,因为同一个页面上基本不会用到所有的组件,简单说,用户有80% 的功能不会用到“那部分”的函数,浏览器却加载了,造成了不必要的资源、带宽(Bandwidth )占用。
当今JavaScript 发展在加快,体积也随着加大,在功能与体积相矛盾的情况下,按需加载是行之有效的策略方式。按需加载又称动态加载、On-Demand 加载,意思都是相近的。目前按需加载常采用的主要有三种方式:
1. 即时同步加载式:
此加载方式是利用XHR (XMLHttpRequest )对象,设置Open() 方法的第三个参数为true ,设置通过同步方式下载脚本。若采用了同步(synchronous )通讯的设置,浏览器在内容未下载完毕之前,此时的readyState 状态属性是2 、3 之间,是一直处于等待的状态,渲染其他网页元素的任务亦伴随停止,包括渲染DOM 元素、加载图片、停止响应用户事件等的任务。因此,在加载所需JavaScript 文件刚好是比较慢的网络环境,时间一长浏览器就会变得好像僵硬(Freeze )的状态,甚至最大化、最小化浏览器的操作用户都难以控制。虽然即时同步加载方式的算法实现起来不太困难,但主要的弊端是在非内网下获取资源时极容易导致浏览器的阻塞,尤其在网络速度较慢的情况下。使用此方式的库有早期的Dojo 、JSVM 等。
2. 异步加载式:
异步加载式同样是使用XHR 对象进行资源的加载,但通讯方式改为异步。其特征是使用了eval 函数执行脚本。使用此方式加载需要指定函数所处于的作用域链(Scope Chain ),因为我们知道eval 函数执行时是根据“就近原则”的,即声明的成员在当前函数作用域下是有效可被访问的;若需要应用到全局范围,这需要更技巧性的脚本编程,可参见modules.js 库 的做法。另外,由于采用了异步加载的方式,处理库内部之间的依赖关系(dependency )会变得比较复杂,典型的库有新版Dojo 1.x 中的包加载机制。
3. 异步加载式之动态标签:
有时候,仅在用户呼叫出某个功能的这个时候才加载应用程序的相应内容。这样就把若干的功能组合成为一个大的模块。我们不是把单个功能分散都做异步下载,因为这样的划分颗粒度过于细小了,而是把若干的功能组合在一起,用户一触发UI 的事件就动态下载所属模块,已经下载过的就不再重复。利用DOM 动态载入外部JavaScript 文件也是一种解决之道,这样的做法会更适合单页面GUI 的设计。
具体地说,我们首先把每一个模块都划分一个单独的脚本、CSS 资源,比如博客模块、论坛模块、商城模块等等……规划好之后,用EXT 制成一个全局的导航布局,把对应的模块功能都放在布局上。这里的安排并不是把所有的模块都给放上,而只是列出对应的菜单、对应的按钮……好了,有了这些菜单、按钮,我们的设计目的是,只要有用户按下这些控件的时候,浏览器就会下载那块功能对应的资源;如果是重复的就不用二次下载(脚本应能识别用户操作哪些是重复的)。
按需下载包文件
前面 谈到的动态资源下载方式有三种,与前两点比较,第三点是重点,也是本应用实例所使用原理。之前我们从最基础的内容说起,现在我们就在loadContent() 的原理基础上再进一步扩展,形成moduleLoader 类:
/** * 前端模块异步加载器。 * 依赖ext的createCallback、createDelegate函数 * @class moduleLoader * @extends Object * @constructor * @param {Object} config 配置属性对象 */ moduleLoader = function(config){ // 复制属性到当前实例 Ext.apply(this, config); /** * @property {Object} action 该模块的处理函数,类型为hash */ /** * @property {Array} script 要异步下载的脚本列表 */ /** * @property {Array} style 要异步下载的样式列表 */ /** * @propety {Boolean} loaded True表示当前资源已加载到浏览器渲染。此项是为了不会重复下载资源时的判读根据。只读的。Read-Only */ this.loaded = false; // 鉴于下面使用的createCallback()方法没有指定scope的地方,所以在这里先绑定 var moduleDetect = this.moduleDetect.createDelegate(this); for (var i in this.action) { var oldFn = this.action[i]; //原有的函数 // 函数作为值送入createCallback方法,返回的类型是Function。作用是创建回调函数。 this[i] = moduleDetect.createCallback(oldFn); } } // 实例方法 moduleLoader.prototype = { /** * @private * @param {Fucntion} onSuccessHandler “按需加载”完成后的回调函数 * @param {Object} Scope 作用域 */ moduleDetect: function(onSuccessHandler, scope){ if(this.loaded === false) { moduleLoader.load({ script : this.script ,style : this.style ,onSuccess : onSuccessHandler }); this.loaded = true; } else { // 如果已经下载过直接执行。 onSuccessHandler.call(scope); } } }
moduleLoader 还需要依赖一个静态方法load() ,负责加载脚本、样式。此方法是静态的因此也可以独立的使用。
/** * 局部加载JS或CSS文件 * @static method * 静态用法: * ModuleLoader.prototype.load({ script : ['/ajaxee/test.js','/ajaxee/test2.js'], style : ['/ajaxee/test.css'] })
* @cfg {String} path The URL to request * @cfg {Function} onSuccess * @cfg {Object} scope */ moduleLoader.load= function(path){ var dom; if(path.script){ if(!path.script.pop)path.script = [path.script]; for (var i = 0, j = path.script.length; i < j; i++) { dom = document.createElement("script"); dom.src = path.script[i]; document.getElementsByTagName("head")[0].appendChild(dom); } // 兼容IE、非IE浏览器的判断 dom[Ext.isIE ? "onreadystatechange" : "onload"] = function(){ if (this.readyState && this.readyState == "loading") return; dom = null; if(path.onSuccess)path.onSuccess.call(path.scope, path.script); } } if(path.style){ if(!path.style.pop)path.style = [path.style]; for (var i = 0, j = path.style.length; i < j; i++) { dom = document.createElement("link"); dom.type = "text/css"; dom.rel = "stylesheet" dom.href = path.style[i]; document.getElementsByTagName("head")[0].appendChild(dom); } } }
熟悉了上面相关的类之后,下面我们以某个OA 项目中的地址簿为例子,建立该模块的功能管理者AppMgr 。我们只要实例化一次(头一次加载成功后就不再加载了),保存到全局变量中,也就是“单例”的形式创建对象。实际上这也是对该模块下各个功能先作一个分配。有了这种前期的思路后,我们写好的这个AppMgr 实例便是封装每个模块的公共属性和方法(如例子OA.Client.AddressBook.AppMgr 中的action ),而且还要定义模块所需的资源文件,说明按需加载的JS/CSS 文件是哪些。
OA.Client.AddressBook.AppMgr = new moduleLoader({ script: [ 'Client/AddressBook/panel.js', 'Client/AddressBook/windows.js' ], style: [ 'Style/AddressBook/default/AddressBook.css' ], // 各种UI行为,开发者自己定义…… action : { openFrontPage: function(){ App.mainTabPanel.addTab(new OA.Client.AddressBook.frontPage(), true); }, openMainGrid : function(){ App.mainTabPanel.addTab(new OA.client.Portal.masterGrid()); }, openCommentWindow: function(){ (new Ext.Window({ title: "测试对话框", iconCls: 'AppIcon_Comment_16x16', resizable: false, autoDestroy: true, closeAction: 'close', width: 610, height: 400, items: new OA.Client.Comment.Browser() })).show(); }, openConfigWindow: function(){ var Index = new OA.Client.Admin.frontPage(); App.mainTabPanel.addTab(Index, true); Index.show(); } } });
有了这个单例后,我们就可以在UI 上面分配该模块的各种方法。大多数Web 系统都会包含功能菜单和显示页面,功能菜单可以是UI 左面的一棵树,也可以是一个可以切换的踏板标签页,而显示页面无非就是一块显示得区域,点击相应的功能菜单,切换不同的内容。现在我们可以结合到一个按钮上、结合到菜单上、结合到树上……总之可以制定事件的地方就可以分配触发该功能的函数。下面我们就以一个树的根节点为示范例子,说明如何分配UI 模块:
// 创建树的根节点 var rootNode = new Ext.tree.TreeNode(); rootNode.appendChild(new Ext.tree.TreeNode({ iconCls :'oa-tree-myDesktop-Admin', //图标样式 // 登记点击该节点时的事件 listeners: { 'click': OA.Client.AddressBook.AppMgr['openFrontPage'] // 该值的类型是Function函数。因此可以将功能分配给当前click事件的处理函数 }, text: '考勤事务' })); rootNode.appendChild(new Ext.tree.TreeNode({ iconCls :'oa-tree-myDesktop-Admin', //图标样式 // 登记点击该节点时的事件 listeners: { 'click': OA.Client.Admin.AppMgr.openFrontPage }, text: '考勤事务' })); // …………更多的树节点
小结
我们还本文中尝试在Ext实现“单一页面”的程序设计。通过内嵌页面iframe和传统的页面跳转方法,虽然可以实现数据的定位或功能的切换,但是遗憾的是在全面引入AJAX方案后这样的方式不够强大和灵活。本文同时也向大家介绍如何在单页面的基础上提供非跳转或iframe的GUI设计,以提供更合理的用户体验及彻底的按需加载方案。
此处披露的内容是《ExtJS 3详解与实践》 的补充内容,完整的资料敬请参阅《ExtJS 3 详解与实践》 一书的全面介绍。