Ext-js-应用开发蓝图-全-
Ext.js 应用开发蓝图(全)
原文:
zh.annas-archive.org/md5/26c6d01cf0554b99bbad67a6ec01f508译者:飞龙
前言
在过去,JavaScript 被用来为 Web 提供一些微小的增强,以装饰页面并添加有趣的效果或验证用户输入。随着浏览器实现了更快的 JavaScript 引擎、新特性和开发者与工程师提出的新技术和想法,JavaScript 逐渐成为提供最终用户期望的功能的近于普遍使用的工具。有了 Ext JS,您拥有一个工具集,可以构建高级 JavaScript 应用程序,但权力越大,责任越大。
应用程序架构师是项目的指导之手。他们帮助确保满足客户期望、实现时间表和控制在成本范围内。在处理这些要求的同时,架构师必须为技术成功的项目提供基础,该项目由高质量、易于理解的代码组成。
在 Ext JS 应用程序开发蓝图 中,我们将探讨设计和构建 Ext JS 应用程序的理论和实践。在接下来的 11 章中,您将学习 Ext JS 如何为开发者提供专业设计软件项目的关键,以及如何将所学知识应用于各种实际场景。
本书涵盖内容
第一章, 简介,阐述了软件架构的重要性以及本书如何帮助您学习它。
第二章, MVC 和 MVVM,介绍了软件开发中的两个经典模式以及它们在 Ext JS 中的实现方式。
第三章, 应用程序结构,展示了如何将 Ext JS 应用程序的不同部分组织成一种简化开发过程的形式。
第四章, Sencha Cmd,探讨了 Sencha 的命令行工具如何作为开发者强大的伴侣,使产品更精简并按时交付。
第五章, 实践 – 内容管理系统应用程序,展示了如何设计和构建一个简单的 CMS 应用程序,以及如何开始应用您迄今为止学到的想法。
第六章, 实践 – 监控仪表盘,涉及使用图表和网格创建日志查看仪表盘,这些图表和网格能够适应用户输入。
第七章, 实践 – 邮件客户端,展示了 Ext JS 5 的响应式特性,同时继续演示 MVVM 模式的先进用法。
第八章, 实践 – 问卷调查组件,使用 Sencha Cmd 通过设计和构建向导组件来演示代码复用。
第九章, 购物应用程序,将前几章的所有技术应用于以平板电脑为中心的购物应用程序。
第十章,调试和性能,教你高级技术来诊断开发和生产中的各种问题。
第十一章,应用测试,为你提供了构建自动化测试的技术,这些测试可以满足应用架构师对健壮应用的需求。
你需要这本书什么
本书中的代码示例是为 Ext JS 5.0.1 及其等价版本的 Sencha Cmd 开发的。实际章节的服务器端 API 使用 Node.js 编写。更多信息,请参阅www.sencha.com/products/sencha-cmd/download和nodejs.org/download/。
这本书是为谁写的
如果你已经在使用 Ext JS,并希望作为团队领导扩展你的知识,或者只是想了解如何设计和结构化你的应用,这本书就是为你准备的。我们不会停下来解释 Ext JS 的每一个小细节,我们期望你已经很好地掌握了框架的基础。我们将鼓励解决问题,而不是按数字编码,所以积极的态度和学习的愿望是必不可少的!
惯例
在本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“这对于经验丰富的 Ext JS 开发者来说应该是熟悉的:我们使用Ext.define来创建一个名为MyApp.SessionManager的类。”
代码块如下设置:
Ext.define('MyApp.SessionManager', {
login: function(username, password) {
User.login(username, password, {
success: Ext.bind(this.loginSuccess, this)
});
},
loginSuccess: function() {
this.isLoggedIn = true;
}
});
任何命令行输入或输出都如下所示:
[INF] [echoproperties] app.output.js=app.js
[INF] [echoproperties] app.output.js.compress=false
[INF] [echoproperties] app.output.js.enable=true
[INF] [echoproperties] app.output.js.optimize=false
新术语和重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中会这样显示:“再次点击记录允许你分析结果。”
注意
警告或重要注意事项以如下框中显示。
小贴士
小贴士和技巧看起来像这样。
读者反馈
我们的读者反馈总是受欢迎的。告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出你真正能从中获得最大价值的标题。
要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及本书的标题。
如果你在一个主题上有专业知识,并且你对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在你已经是 Packt 图书的骄傲所有者,我们有一些事情可以帮助你从你的购买中获得最大价值。
下载示例代码
您可以从您在www.packtpub.com的账户下载示例代码文件,适用于您购买的所有 Packt 出版书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在勘误部分显示。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过<copyright@packtpub.com>与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您在这本书的任何方面遇到问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。
第一章. 引言
学习如何理解一行代码,构建第一个“Hello World”脚本,并在它按预期工作时的兴奋感,这些都是吸引我们进入编程世界的小步骤。这本书是关于我们在这个世界中可以使用 Sencha 的 Ext JS 5 构建的项目,以及如何确保你是在一个坚实的基础之上构建。
在本章中,我们将从理论和实践的角度探讨为什么强大的应用程序架构很重要。我们将介绍本书的其余部分,并回答以下问题:
-
应用程序架构是什么?
-
它为什么很重要?
-
Ext JS 如何帮助进行应用程序设计?
-
这本书将如何帮助您提升软件架构?
让我们先来谈谈我们所说的软件架构是什么,以及为什么它对于一个成功的项目来说如此重要。
谦逊的起点
作为程序员,我们中的许多人可能都是从编写对我们有帮助的脚本或代码开始的,这些脚本或代码帮助我们的爱好。那种兴奋感,那种你可以创建实用和有用的东西的洞察力,这就是激情的起点。正是这些匆忙拼凑起来的最初几行代码,许多人都建立了自己的职业生涯。
在那些早期日子里,我们发现自己编写了数百行的代码,但并没有考虑到如果我们六个月后回来会是什么样子。它是否容易理解?在不破坏其他东西的情况下能否添加一个功能?此外,如果我们想与他人或互联网上分享它怎么办?试图找出错误的原因将迅速变成一场噩梦。
事实上,互联网上充斥着这样的代码。这为什么会成为问题?假设你被卷入为一个朋友构建一个简单的购物车。你足够了解使其工作,但也足够危险。购物车负责处理商品的支付,一个安全漏洞可能导致有人损失钱财。突然之间,你为朋友做的善事变成了让他们丢脸和损失金钱的事情。
幸运的是,就我而言,作为一个编码的新手,我缺乏开发专业知识并没有带来太大的影响。我创建了一个 PHP 脚本。这个脚本为网站生成相册。它最初是一系列照片,后来扩展到包括缩略图生成、分页和行政/上传功能。这是一个传统上糟糕的 PHP 示例,其中 HTML 与逻辑代码混合,以及意大利面般的循环来尝试使它按我的意愿工作。
随着时间的推移,解决方案会自然产生;我们开始将我们的工作分解成对应用程序有意义的更小的部分。随着代码库的增长,将逐渐变得清晰,有合理的工作方式会使生活变得更轻松。在相册示例中,我可以开始提取非常简单的方面(例如标题、页脚和分页链接),以便专注于核心功能。
当然,并不是每个人都以这种方式开始。将爱好发展成为职业只是编码社区发展技能集的一条途径。大学或在线课程、书籍和论坛都有助于我们的学习过程。重要的是要记住,无论是程序员还是架构师,都不是生来就知道一切的,承认自己的知识有差距是正常的,甚至是必要的。从资深顾问到业余黑客,我们将尽力填补这些差距,随着你构建更大、更复杂的应用程序。
成长
与其编写仅供爱好或副项目的代码,我们现在编写的是帮助运营企业的应用程序。有人正在为这种开发时间付费,有人的生活依赖于它。作为专业的软件开发者,我们需要采取专业的心态和技能集。与其立即坐下来编码一个想法,我们需要仔细考虑我们的应用程序是如何构建的。我们需要确保它在现实世界的用户接触到它时能够按预期工作;这将提供一个强大的平台,供未来的开发者在此基础上构建。
作为软件开发者,我们可能要负责成千上万行代码——在许多情况下更多——并且可能在一个由各种其他个人组成的团队中工作。在这个规模上,设计不良的应用程序可能会变得难以控制、不可靠且难以测试。
它难以控制,因为没有强大的设计,功能是按需附加的,建立在已经摇摇欲坠的基础上;不可靠,因为这种摇摇欲坠的基础有裂缝,布满了虫子,难以测试,因为系统的各个部分如此交织在一起,以至于每个组件都没有一个明确的职责和能力集合。
这就是将一个想法付诸实践的危险;你可能会创建出像花园里杂草一样失控的代码。设计是关键,因为它允许你成为园丁(仔细照料系统成长的每一个方面)。我们希望为我们的团队成员提供一个清晰、积极的起点,并在此基础上构建。
我们希望在应用程序中创建的结构应该在我们想要创建的结构中,应该给我们清晰的划分,一个组件与另一个组件之间。系统的每个部分都应该对自己负责,而无需更多(在一个更大的机器中的一个小部分)。我们的代码层(从数据到展示以及所有中间的连接)也应该清晰明确,因为没有人想看到模板直接与后端服务对话。
这是一个典型的重叠关注示例。在这个演示中,代码永远不应该担心获取数据,而应该关注如何展示。通过强大的结构和从一开始就实施的应用程序设计,可以轻松避免许多此类问题。
巨人的肩膀
在我们之前,许多人已经走上了可扩展性的道路。值得记住的是,有许多正式的指标可以帮助我们确定我们的代码是否过于复杂。“循环复杂度”就是一个例子,这是一种评估细节(如代码凝聚力和分支深度)的技术,并且可以与自动系统结合,当复杂度超过阈值时发出警告。
然而,在现实中,这样的指标是应对复杂性的方法,而不是避免复杂性的计划。通过考虑我们如何结构化代码以取得成功,我们可以最大限度地减少对这种自动检查的需求。
第一步必须是面向对象编程(OOP)。通过鼓励将一部分功能封装在类中,我们立即在我们的应用程序中引入了一种分离感。正如将我们的多行脚本拆分成单独的块是有意义的一样,这里我们可以通过创建负责单一功能的类来正式化这种方法。
在相册中,分页是一个很好的例子,说明了关注点可能重叠。我的原始实现只是有很多循环和条件语句与 HTML 混合在一起。相反,我们可以有一个类来处理分页的逻辑(例如,是否可以点击“下一页”链接),另一个类则负责根据这些数据生成 HTML。
在 Ext JS 4 中,Sencha 引入了一种新的类系统,它允许你更好地创建封装应用程序功能以及强大应用程序框架的对象。Ext JS 5 在此基础上进行了扩展,并添加了一些额外功能,使 Ext JS 应用程序架构对每个人(从个人开发者到大型团队)都理想化。它提供的结构化方法允许你在保持其基础稳固的同时扩展代码库。
应用程序架构是什么?
大多数人对传统建筑师的角色有一个大致的了解,即创建精确的图表传递给建筑工人和其他工匠,他们将承担建设工作。更重要的是,建筑师需要考虑使用的材料、建筑的美学,并监督施工过程。
在软件的世界里,架构师的角色类似:设计和监督。我们需要投资于架构师的角色,并确保我们将其打造为一个端到端的过程,以确保我们对最终产品的质量负责。设计和监督需要全面了解产品需求、涉及的技术和可用资源。软件程序(我们的“存在理由”)的架构是多方面的,从决定项目如何构建的前期文档的质量,到用户乐于使用的最终产品的精致程度。
需求分析
软件项目在没有客户、客户或其他利益相关者的需求列表的情况下不应开始。这通常以详细规范文档的形式出现。需求分析涉及建筑师理解需要解决的问题,并将他们所学应用到过程的下一阶段。
这里有一个简短的例子:
用户应该能够登录、登出、注册和请求新密码。登录应跨浏览器会话持续。
建筑师阅读了这些内容,开发者(依次)得到了以下代码来处理用户登录:
Ext.define('MyApp.SessionManager', {
login: function(username, password) {
User.login(username, password, {
success: Ext.bind(this.loginSuccess, this)
});
},
loginSuccess: function() {
this.isLoggedIn = true;
}
});
这对于经验丰富的 Ext JS 开发者来说应该是熟悉的:我们使用Ext.define创建一个名为MyApp.SessionManager的类。登录方法包含对User类中登录方法的调用,并在成功时触发回调。使用Ext.bind,我们设置成功回调的作用域为SessionManager类,当它被调用时,我们将isLoggedIn值设置为true。进一步的代码将重定向认证用户到应用的其余部分。
问题在于建筑师没有正确处理需求。他们遗漏了第二部分(这部分要求登录状态应在会话间持续)。在这个代码示例中,我们存储登录状态在内存中。为了支持这些需求,我们应该使用 cookie、localStorage或某种其他存储机制,以便在浏览器会话之间使登录可用。这段代码会是什么样子呢?让我们稍微调整一下这个类,更改一个方法,并添加几个更多的方法:
loginSuccess: function(userDetails) {
this.setUser(userDetails);
},
isUserLoggedIn: function() {
return window.localStorage.getItem('user') === null;
},
setUser: function(userDetails) {
window.localStorage.setItem('user', Ext.encode(userDetails));
},
getUser: function() {
return Ext.decode(window.localStorage.getItem('user'));
}
替换后的loginSuccess方法将调用setUser,它使用localStorage在浏览器会话之间持久化用户详情。我们还提供了一个额外的isUserLoggedIn方法来检查localStorage中是否有任何用户详情,以及一个getUser方法来获取这些详情。注意Ext.encode和Ext.decode的使用,它们将 JavaScript 对象转换为可以插入到localStorage中的字符串。
这是这个功能更现实的实现,需要在更高层次上更好地指定。如果不将这个需求从客户的规定翻译到开发者的指令,我们将遗漏应用功能集的一个重要部分。
数据设计
就像应用架构的许多方面一样,这将是建筑师、开发者、数据库管理员和技术团队其他成员之间的协作努力。数据设计是关于需要存储的数据、存储位置以及如何在 Ext JS 的存储和模型中反映这些数据的讨论结果。让我们看看一个应用的理论需求:
连续三次登录失败后,用户账户将被锁定 30 分钟。
这意味着我们需要在User模型或其他持久化方法(例如服务器端数据库)中记录失败的登录尝试次数和最后一次登录尝试。没有这些信息,我们就无法创建正确的客户端逻辑和 UI 来支持这一需求。
代码设计
也许,架构师的大部分工作来自于构建应用程序的代码库。根据团队的大小,架构师可能或可能不直接编写代码,但他们将在更高层次(几乎肯定是在类级别,在重要情况下在方法级别)对应用程序有深入了解。在许多情况下,UML 或其他绘图工具将被用来提供记录和共享设计的方式,如下面的图表所示:

一个类似于 UML 的图表,展示了理论音乐应用程序中的模型
从这个模型中产生的Album类将如下所示:
// model/Artist.js
Ext.define('MyApp.model.Artist.', {
extend: 'Ext.data.Model',
fields: [
{ name: 'name', type: 'string' }
]
});
// model/Album.js
Ext.define('MyApp.model.Album', {
extend: 'Ext.data.Model',
fields: [
{ name: 'name', type: 'string' },
{ name: 'artist', reference: 'Artist' }
],
getRunningTime: function() {
return this.tracks().sum('lengthInMs');
}
});
// model/Track.js
Ext.define('MyApp.model.Track.', {
extend: 'Ext.data.Model',
fields: [
{ name: 'title', type: 'string' },
{ name: 'lengthInMs', type: 'integer' },
{ name: 'album', reference: 'Album' }
]
});
我们使用reference配置定义字段,以在艺术家和专辑、专辑和曲目之间设置多对一关系。在之前类图中展示的getRunningTime方法是一个架构师可能不会涉足的领域(他们可以将此方法的实现细节留给开发者)。
这是本书将要涵盖的架构的关键方面。
技术评估
在这本书中,我们将讨论 Ext JS 5,因此我们的技术选择应该是相当直接的!然而,对于架构师来说,评估他们技术栈的所有相关部分以确保其支持产品需求是非常重要的。以下是我们当前情况下仍然相关的一些技术问题:
-
我们是否需要适应各种形态(如移动和平板)?
-
我们是否需要支持 Internet Explorer 8?
-
我们是否需要与 SOAP 服务通信?
所有这些都有潜在的后果,架构师在规划项目时必须评估。
代码标准和实践
就像建筑架构师必须确保他们创建的设计遵守建筑规范和安全法规,并确保他们使用的材料将创造出令人愉悦的最终效果一样,软件架构师也必须采取措施来保证最终产品的质量。
命名约定、格式指南、部署应用程序的过程——所有这些都有助于建立一个专业的流程,使开发者和项目更容易成功。架构师是指导之手,为顾客带来超出他们期望的东西。
文档
记录开发过程有很多原因。例如:
-
团队成员之间的透明度
-
在开发过程中提供参考点
-
为项目后审查提供比较
对于大型团队来说,拥有单一的设计文档参考点有助于提高效率,并帮助新团队成员快速熟悉情况。建筑师将所有想法都保留在头脑中是一个非常糟糕的想法,而让这些知识得到共享、讨论和记录则是一个非常好的主意。此类文档可能以多种形式出现:
-
类图
-
UI 线框
-
用户故事
-
编码标准
-
代码文档
代码文档通常会在构建过程中自动生成,但建筑师将负责强制要求这些代码得到文档化,并实施一个构建步骤来生成它。其他形式将是手动过程的一部分,可能涉及客户、开发团队和建筑师。
注意
这些定义是值得讨论的!维基百科的软件架构页面内容丰富,提供了多个观点,请参阅en.wikipedia.org/wiki/Software_architecture。
在接下来的几页中,我们将探讨软件和软件开发人员如何发现自己拥有一个难以管理的代码库,是什么让它难以管理,为什么这是一个问题。我们还将探讨使软件产品架构良好的属性。
Ext JS
在 Ext JS 4.0 版本之前,Sencha 并没有试图对我们的应用程序施加任何结构。组件分散在多个文件中,但除此之外,我们没有关于它们应该如何通信或如何组合成应用程序的任何指导。Ext JS 始于 2007 年 4 月 15 日的 1.0 版本,作为一个用户界面小部件库——按钮、树、网格和功能(如布局),以帮助它们保持一致,但仅此而已。
当时,这似乎并不重要。用 JavaScript 编写的单页应用程序仍然感觉像是未来的技术,尽管它已经开始出现,但它在 2014 年并不像现在这样普通。通过添加 Ajax 驱动的滑块小部件来增强现有网页已经足够了。
随着时间的推移,随着许多小部件和网站动态部分都需要交互,正式的交互机制的需求变得明显。人们使用 Ext JS 构建需要高可用性的企业级应用程序,因此需要严格的测试制度。面条代码已经不再适用。
那个世界
让我们回顾一下 Ext 3.4 时代的一个经典示例代码:
// View a working version at https://fiddle.sencha.com/#fiddle/90s
// Basic JSON sample data
var sampleData = { data: [
{ "firstName": "Jack", "surname": "Slocum" },
{ "firstName": "Shea", "surname": "Frederick" },
{ "firstName": "Colin", "surname": "Ramsay" },
{ "firstName": "Steve", "middle": "Cutter", "surname": "Blades" },
{ "firstName": "Nigel", "surname": "White" },
] };
// Create a store to hold our JSON data
var userStore = new Ext.data.JsonStore({
data: sampleData,
root: 'data',
fields: ['firstName', 'middle', 'surname']
});
// Grid panel using the store, setting the columns to match the incoming data
var grid = new Ext.grid.GridPanel({
store: userStore,
colModel: new Ext.grid.ColumnModel({
defaults: {
width: 120,
sortable: true
},
columns: [
{ header: 'First Name', dataIndex: 'firstName' },
{ header: 'Middle', dataIndex: 'middle' },
{ header: 'Surname', dataIndex: 'surname' }
]
}),
viewConfig: {
forceFit: true
},
width: 600,
height: 300,
frame: true
});
// Event handler to do something when the user clicks a row
grid.on('rowclick', function(g, idx) {
Ext.Msg.alert('Alert', 'You clicked on the row at index ' + idx);
});
// Render the grid to the viewport
grid.render(document.body);
这段代码对于读者来说应该是熟悉的,因为它生成一个填充的网格:

当在网格上点击一行时,会出现一个警报框,显示目标行的索引。这很好。这是一个相当典型的例子,你在 Ext JS 3.4 时期的文档中可能会看到。
那么,问题是什么?问题是这段代码做了一切。它设置了我们的数据存储,从远程或本地源绘制 JSON。它创建了网格本身及其许多配置选项。它设置了事件处理代码以弹出警告框。在所有这些完成后,它最终将网格渲染到屏幕上。
在这样一段简单的代码中,问题并不明显。尽管如此,当添加更多功能时,例如分页或弹出包含关于记录的更多信息详情窗口,并将所有这些与一个更大的应用程序结合,包括服务器端交互、多个屏幕等,那么事情开始变得复杂。这正是 Sencha 试图通过在 Ext JS 4 中引入定义的架构实践来解决的问题。而不是把所有东西都放在一起,混合所有这些不同的关注点,想法是它们可以被拆分成更逻辑、简洁和明确的对象。
最先进的技术
模型-视图-控制器(MVC)模式是新波 Web 开发者的选择。Ruby on Rails 已经普及了 MVC,许多其他主要框架也提出了自己的观点。
小贴士
虽然 MVC 模式近年来在 Web 开发中经常被使用,但在软件开发的其他地方也被使用,实际上它最初是为桌面 GUI 编码开发的。关于 MVC 作为一个一般概念的了解超出了我们的范围,但在网上有大量的信息和示例。
Ext JS 4.0 于 2011 年 4 月 26 日发布,距离 v1.0 版本已有 4 年多,许多开发者已经习惯了在他们的服务器端应用中使用 MVC 模式。框架(如 Backbone.js)已经逐渐开始为开发者提供在客户端代码中使用这种架构模式的工具。Ext JS 4 需要做到同样的事情,并且它做到了。
大部分都非常酷
在版本 4 之前,Ext JS 有存储库和记录(MVC 中的“模型”)。它有组件或小部件(MVC 中的“视图”)。架构问题有两个方面;模型和视图如何相互通信?我们如何避免让我们的视图逻辑代码膨胀?
一个解决方案是引入控制器概念。这允许我们创建一个粘合层来创建存储库,将它们连接到组件上,并使用事件来管理应用程序所有这些部分之间的通信。
在 Ext JS 的第五版中,通过 ViewControllers 和 ViewModels 引入了一些额外的功能,完全回答了我们的架构问题。ViewControllers 允许我们将视图中的逻辑代码分离到一个独立的类中,确保视图和 ViewController 都能专注于它们自己的责任集。
支持角色
除了在 Ext JS 框架中构建类以帮助我们构建复杂的应用程序之外,Sencha 还提供了一系列辅助工具,这些工具有助于开发过程。特别是,Sencha Cmd 是一个强大的命令行工具,它引入了一些不可或缺的功能。
从一开始,Sencha Cmd 就可以通过生成具有最佳实践布局的应用程序目录结构来帮助你,这个布局是专门为帮助新的 Ext JS 5 跨设备支持而创建的。你可以从命令行添加新的模型、控制器等。
在你开发的过程中,Sencha Cmd 可以通过编译主题文件、切割和切片图像以及运行本地开发 Web 服务器来运行你的代码,从而帮助你。此外,在测试和部署期间,它可以将你的代码打包成优化的包,以便你的用户在浏览器中下载最少的代码。
Sencha Cmd 是成长中的产品的绝佳例子。它代表了你开发基础设施的关键部分,也是 Ext JS 框架本身的绝佳补充。
准备工作
我们已经了解了我们想要解决的问题以及我们将如何学习去解决这些问题。因此,现在让我们来看看一些在开始设计之前你可能需要考虑的应用的非架构方面。虽然我们之前讨论的每一件事都谈到了塑造应用发展的总体方法,但拼图的其他部分往往同样重要。
规范
你在不知道你要设计什么的情况下如何设计一个应用程序?这正是规范所提供的。一份或一系列文档,详细描述了构成你的软件的功能应该如何表现。在软件行业中,我们开始编写代码之前不收集用户和客户的需求是一个持续的错误。就像在没有正确架构的情况下构建应用程序是不负责任的一样,在没有尽可能确定你为付费客户创建的是正确的东西的情况下构建应用程序也是不负责任的。规范可以避免哪些问题?以下是一些例子:
-
赶不上截止日期
-
超出预算
-
开发者压力
-
客户不满
这看起来像是我们项目期间不希望出现的一些相当通用的列表。
对于本书的目的来说,更重要的是,规范允许你拥有创建应用程序设计所需的所有信息。这并不一定意味着我们的设计将是正确的,但没有规范,你可以保证设计将是错误的。
一个好的匹配
创建规范和设计的一部分是理解客户需求;理解代表他们业务的“问题域”。除非你至少对航运有点了解,例如他们如何根据货物重量和运输距离计算运费,否则很难为航运公司编写计算机软件。
关于 Ext JS,我们可能正在与一个外部 SOAP API 合作,该 API 提供这些运费。嗯,Ext JS 支持使用 SOAP(需要 Sencha Complete 许可证),但如果它不支持,这可能会影响我们的设计——我们不得不编写更多代码来与 SOAP API 通信——因此我们的时间表。
我们正在编写一个内容管理系统,但由于与第三方达成的协议,它需要与客户的品牌非常紧密地结合。Ext JS 主题系统是否允许我们融入项目所需的广泛定制?
当财务总监透露他在办公室外的工作都是在 iPad mini 上完成的时,我们开始为一位新客户开发销售门户。我们现在必须回溯以融入触摸屏支持。如果我们能在适当的时候咨询所有利益相关者,我们就能节省几周的工作。
这些例子有些牵强,但有助于强调应用程序设计不仅关乎软件,而且是咨询相关人员、评估资源、理解问题域和创建软件架构的融合。这些需求不是孤立存在的。
我们是如何工作的
考虑将新的开发人员或测试人员加入您的团队。他们需要多快才能开始使用您代码的最新版本?他们如何追踪导致他们正在工作的 bug 的代码更改,然后找到负责该 bug 的开发人员?自动测试应该以何种特定方式创建,新代码应该如何编写?
这些都是创建成功应用程序时非常重要的问题,因为它们减少了创建和改进现有代码库之间的障碍,并保持最终代码的质量。以微软和 Fog Creek 著称的乔尔·斯波尔斯基(Joel Spolsky)在 2000 年写了一篇至今仍非常相关的博客文章。题为《乔尔测试:12 步走向更好的代码》(The Joel Test: 12 Steps to Better Code),它基于乔尔与各种编码团队和项目(从微软的 Excel 到 Fog Creek 自己的 Trello)的丰富经验,对开发公司提出了十二个问题。整篇文章值得一读,但我们将以 Ext JS 开发为背景,重新审视它。
在安全的手中
乔尔的第一问,“你使用源代码控制吗?”
当新开发者加入时,第一步是确定你的代码存储在哪里?我们已经远远超过了共享网络驱动器和手动复制团队中其他开发者的代码,所以你应该使用某种形式的源代码控制。这肯定是一个“有比没有好”的情况,所以我们不会具体讨论替代方案,但在我个人的情况下,我使用 Git,它是作为 Linux 内核的源代码控制系统创建的,现在在软件开发中非常流行。
你的团队中的每个成员现在都可以从彼此那里获取代码,回滚错误,跟踪更改,并找到错误的起源。然而,源代码库不仅仅是一个巨大的洞,其中每个人的文件都存在。有些是针对每个开发者的独特文件(例如,IDE 的配置文件),还有一些是由构建过程或其他脚本自动生成的。使用 Git,我们会使用 .gitignore 文件来排除几个仅创建你的仓库和提交信息的项:
# sample gitignore for extjs5
# The build directory can be recreated by developers using Sencha Cmd – it should be excluded from the repo
build/
# Changes every time a build is run
bootstrap.js
bootstrap.css
# Temporary files created when compiling .scss files
.sass-cache/
# Some team members may use Sencha architect – exclude so they keep their custom settings
.architect
# It's possible to create reusable packages using Sencha Cmd but depending on your preference you might want to exclude this directory. Packages are discussed in chapter 3.
packages/
如果你的 Ext JS 应用程序与任何服务器端代码共享目录,.gitignore 文件中将会包含比这更多的内容。大多数大型项目都会有一个高度定制的 .gitignore 文件。此外,并非所有的源代码控制系统都具备类似的功能,而在项目开始时设置它将使你的应用程序历史保持整洁有序。
如果你构建了它,他们就会来
乔尔的第二个问题,“你能一步完成构建吗?”
我们之前提到,需要让开发者和测试者能够轻松地从原始源代码构建应用程序的最终版本。这可能是一个压缩你的 CSS 和 JavaScript 的过程,以便用户可以更快地下载。关键是,你希望对你的工作与用户使用的相同最终构建版本进行测试,以避免错误悄悄通过。
在 Ext JS 5 中,我们可以使用 Sencha Cmd 来创建开发、测试和生产版本,甚至可以用来创建适用于触摸设备的应用程序打包版本。这样做可以提供一个统一的机制,让整个团队可以通过单个命令从相同的构建版本中工作,这让乔尔非常高兴。这也与他第三个问题“你每天都会进行构建吗?”相关。使用我们迄今为止描述的工具,自动构建系统可以从源代码控制中获取最新和最好的代码,使用 Sencha Cmd 进行构建,并将其部署到测试或 beta 服务器进行评估。
管理你的时间
乔尔的第四个问题,“你有一个最新的日程安排吗?”以及他的第五个问题,“你有一个规范吗?”
当然,这两者都不是特定于 Ext JS 应用程序的,但它们与本书的主题紧密相关。我们已经讨论了拥有一个规范来指导你的应用程序设计,但与之相伴的是最新的日程安排。通过正确设计你的应用程序,你可以将其划分为可以由你自己、你的团队或你的管理层安排的各个部分。
一个控制器及其关联的视图和所需模型可能需要一个月的时间才能完成,所以一个设计有三个控制器的应用程序可能需要三个月。然而,乔尔的第六个问题比这更具体,他要求一个最新的进度表。这意味着检查你的工作以确保你符合进度,并相应地进行调整。这也意味着从拖延的进度中学习,并意识到你的设计可能在某些方面有缺陷。一个具有大量交互的复杂视图显然会比一个简单的视图花费更多的时间,所以它并不像我们的“每个控制器一个月”的建议那样简单。
你可以购买时尚,但你不能购买风格
乔尔的其他问题更加普遍,所以我们跳过它们,来谈谈设置你的开发过程的一个重要方面:风格。这里不是指你的开发者穿什么,而是他们编写代码的方式。这里有两个需要考虑的因素,一个是与 JavaScript 相关的,另一个是特定于 Ext JS 的。
Twitter Bootstrap 的开发者们在 2012 年通过选择不在 JavaScript 行尾使用分号,并结合一些稍微晦涩的语法,引起了人们的反感。请参阅github.com/twbs/bootstrap/issues/3057。
事实上,在大多数情况下,由于 JavaScript 的自动分号插入,是否使用分号并不太重要。请参阅ecma262-5.com/ELS5_Section_7.htm#Section_7.9。
更重要的是,无论你做什么,都要保持一致性,并确保所有参与你应用程序开发的人都这样做。不这样做将会对你的应用程序的可维护性产生严重后果(想象一下拥有两种或三种不同风格的注释、字符串引号和条件语句的文件)。
Ext JS 本身有一些约定,如果你遵守它们,将会使你的生活变得更加容易。当一个控制器需要存储时,你可以这样做:
Ext.define('MyApp.controller.MyController', {
extend: 'Ext.app.Controller',
requires: [
'MyApp.store.Albums'
],
getAlbumsStore: function() {
return this.getStore('Albums');
}
});
这相当于:
Ext.define('MyApp.controller.MyController', {
extend: 'Ext.app.Controller',
stores: ['Albums']
});
由于 Store 类被定义为 'MyApp.store.Albums',我们可以用 'Albums' 快捷方式来引用它。同样,控制器应该总是将 "controller" 作为类名中间的部分,"model" 用于模型,"view" 用于视图。
这部分内容在 Ext JS 5 核心概念指南的命名约定部分有所涉及。没有明确提到的是,这些快捷方式在 Ext JS 中无处不在,以及它们如何使你的代码更加清晰。
摘要
尽管这本书讨论了很多关于设计应用程序的整体框架,但开发一个成功的产品有许多不同的标准。我们必须关注我们已提到的细节问题(例如分号风格或命名约定),这不仅是为了为你的代码提供一个坚实的基础,也是为了确保所有与之合作的人都感到投入并且意见一致。你不仅是在描绘一个结构良好的应用程序的图景,而且是在构建一个由数百个活动部件组成的复杂机器。所有这些部件都需要良好的润滑,以便你的项目能够成功。
在下一章中,我们将开始讨论应用程序结构的理论,并讨论那些将帮助我们塑造代码库的设计模式。我们将在这个背景下讨论 Ext JS,并展示它如何提供一套强大的功能,这些功能建立在这些模式之上,并使我们能够开始为健壮的应用程序搭建架构。
第二章。MVC 和 MVVM
软件开发的问题是我们总是在寻找做事情的正确方式。每个软件公司都会有一套自己的指南,指示他们的开发者应该如何操作。这就是软件工作的方式:我们构建一套反映我们最佳思考如何开发的想法,软件社区从这些想法中学习并在此基础上构建。它们被正式化为工作模式,这些模式在整个开发社区中共享。在本章中,我们将更多地讨论这个概念,特别是:
-
MVC 模式
-
MVVM 模式
-
Ext JS 使用两种方式
-
Ext JS 从 MVC 到 MVVM 的演变
-
当前版本 Ext JS 中设计模式的好处
关于设计模式的讨论通常非常枯燥。在本章中,我们将使用一些实际例子来说明它们为什么如此重要以及它们如何帮助你启动架构工作。
总是日记
在开始时,情况一团糟。好吧,也许并不完全是这样,但在现代软件开发中,我们有很多设计和架构模式可以利用,帮助我们塑造应用程序并确保我们不是在重新发明轮子。这些模式中的每一个都是数十年的工作的结果,这些工作不断被审查并付诸实践,我们都希望最优雅和最有用的工作能够浮出水面。在这个过程中,我们看到了笨拙的模式被更优雅的模式所取代。希望我们的混乱变得稍微不那么复杂了。
我们构建图形界面方式的一个关键发展是模型-视图-控制器(MVC),它在 20 世纪 70 年代的近传奇的施乐帕克研究中心(Xerox PARC)由挪威计算机科学家 Trygve Reenskaug 发明。它首先在 Smalltalk 编程语言中被公开采用,这是一种由包括艾伦·凯(Alan Kay)在内的计算机科学家团队开发的编程语言。它汇集了许多想法,这些想法影响了我们今天使用的几乎所有面向对象的语言。这是一件大事,是由一些非常厉害的人创造的。
弗吉尼亚大学计算机科学助理教授康奈利·巴恩斯(Connelly Barnes)给我们提供了一个很好的看待 MVC 的方法:
"模型是数据,视图是屏幕上的窗口,控制器是两者之间的粘合剂。"
它首先在描述软件结构时使用职责术语,例如,视图负责展示。在第一章,简介中,我们讨论了它在创建强大应用架构中的重要性。
对于我们回顾创新(如 Smalltalk 和 MVC)并理解它们为何如此重要来说,可能很困难。我们可以花费许多页面来回顾之前的内容以及为什么 MVC 的出现被描述为一个开创性的洞察。然而,真正重要的是,它是一种新的观察基于图形用户界面的软件组织方式,这是一种在接下来的三十年里经得起时间考验的计算科学新范式:

Martin Fowler 的裸骨 MVC
Ext JS 使用的 MVC 实现(Ruby on Rails 带来的)与 Smalltalk 中的原始实现之间存在几个差异。自其诞生以来,它一直在不断优化和调整,以适应其使用的各种环境。
将 MVC 引入网络
Smalltalk 的 MVC 实现是针对传统的桌面 GUI 系统而创建的。它所代表的职责分离对于基于网络的软件来说非常有意义;模型是业务和持久层的表示,控制器是服务器端的粘合剂,视图是为客户端浏览器渲染的 HTML。
然而,在传统的 MVC 中,视图通过响应模型发出的事件来观察模型的变化,以反映其当前状态。在标准的 HTTP 请求/响应情况下,这是不可行的。
Model 2 是 MVC 的一个衍生版本,它在 Java Struts 框架中实现,该框架为解决这个问题提供了一个潜在的解决方案。而不是视图和模型直接通信,控制器成为变化的汇集点。它响应视图的变化,并将它们传递给模型,反之亦然,如下面的图所示:

网络上的 MVC/Model 2
这就是 Ruby on Rails 实现 MVC 的方式,进而激发了众多类似 MVC 框架的诞生(如 ASP.NET MVC)。
这与网络技术(如经典 ASP、PHP 和 Cold Fusion)形成对比,在这些技术中,创建一个结合逻辑、渲染和数据库访问的页面是标准做法。这可以描述为(尽管很少这样做)是 MVC 实现逻辑后继者的 Model 1。Model 1 方法会导致我们在第一章开头描述的问题,因此 MVC 的普及,尤其是 Ruby on Rails 采用的简化方法,开始为构建良好的应用程序提供一个坚实的基础。
网络上的 MVC 可能遵循以下请求流程:
-
浏览器发起请求,并将其传递给控制器。
-
控制器消费请求参数。
-
它根据这些参数检索一个模型(通常是数据库中的)。
-
最后,它根据模型渲染视图,并将其传递回浏览器。
当然,随着 Ajax、WebSockets 和完全客户端 MVC 框架的出现,这是一个非常简化的例子。它确实有助于展示 MVC 如何轻松地适应 Web,并且实际上非常适合 Web。
Ext JS 和 MVC
我们已经探讨了 MVC 的起源以及它是如何被应用于传统的服务器端 Web 应用的。当我们使用 Ext JS 这样的 JavaScript 重量级应用时,它是如何工作的呢?
整个 MVC 概念完全移动到了浏览器中。就我们而言,服务器可以使用它想要的任何技术。它通常会只是提供和消耗浏览器之间的数据。我们回到了一种更类似于 Smalltalk 版本的 MVC 实现(屏幕上看到的不同的 UI 元素是视图),每个都可以有自己的控制器。
再次强调,这是关于分解责任。我们不是让一个控制器负责整个页面,而是可以有搜索控制器、列表控制器和详细控制器(代表构成我们应用逻辑单元的任何东西)。这是从服务器端 MVC 到客户端 MVC 的步骤如何帮助我们应用架构的关键细节。
我们已经知道 Ext JS 组件是我们的视图,Ext JS 模型命名得很好,可以很好地融入其中。我们剩下了一个重要的问题:控制器实际上应该做什么?可能更容易先移除我们知道它们不应该做的事情,然后看看剩下的是什么。我们知道模型处理数据,但它们也负责围绕这些数据的计算和逻辑。例如,计算和规则属于模型,但不属于控制器。
小贴士
这是一个概括。在许多情况下,你会有其他类来完成这种逻辑工作,以便进一步分解你的应用。重要的是要记住,你不想在控制器中包含领域逻辑!
我们还知道视图处理展示。你可以在控制器中构建一个 HTML 字符串,并将其传递给浏览器进行渲染,但这将涉及控制器承担视图的责任。
我们还剩下什么呢?实际上,并不多。你的控制器只需要负责你的视图和模型。仅此而已。它们查看用户提出的请求,获取一个模型,并使用它来将视图渲染到浏览器中。
事实上,如果你的控制器做了更多的事情,这应该被视为一个坏信号。控制器应该是乐队的指挥,而不是创作音乐的人。
Ext JS MVC 的示例
下面的截图显示了我们的 Ext JS v4 MVC 测试应用:

我们在这里生成了一个股票 Ext JS v4 应用程序,它遵循 MVC 结构,然后我们根据我们的需求对其进行了修改。在这个小应用程序中,左侧有一个音乐专辑的网格。当你点击网格上的按钮时,它会生成在网格中提到的艺术家的摘要,当你双击一行时,它将专辑信息放入右侧面板。这是一个玩具应用程序,但它有助于演示 MVC 的工作原理。稍后,我们将将其与用 Ext JS v5 编写的类似应用程序进行比较。让我们看看代码:
// view/List.js
Ext.define('MvcEx1v4.view.List', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.app-list',
store: 'Albums',
forceFit: true,
frame: true,
requires: ['Ext.Msg'],
columns: [
{ text: 'Name', dataIndex: 'name' },
{ text: 'Artist', dataIndex: 'artist' }
],
initComponent: function() {
this.bbar = [
'->',
{ xtype: 'button', text: 'Show Artist Summary', handler: this.onShowSummary, scope: this },
'->'
];
this.callParent(arguments);
},
onShowSummary: function() {
var summary = this.getStore().collect('name').join(', ');
Ext.Msg.alert('Artists', summary);
}
});
这里是我们的 MvcEx1v4.view.List 类在 view/List.js 中。它是一个相当直接的网格,使用名为 'Albums' 的存储和一个位于底部工具栏上的按钮来生成艺术家摘要。请注意,生成此摘要的事件处理程序包含在视图中:
// view/Detail.js
Ext.define('MvcEx1v4.view.Detail', {
extend: 'Ext.Container',
alias: 'widget.app-detail',
html: 'Double-click an Album to select'
});
我们的第二个视图是 MvcEx1v4.view.Detail 在 view/Detail.js 中。这只是一个带有一些占位符 HTML 的简单容器。最后,我们有包含我们的视图的应用程序视口:
// view/Viewport.js
Ext.define('MvcEx1v4.view.Viewport', {
extend: 'Ext.container.Viewport',
requires:['MvcEx1v4.view.List'],
layout: 'hbox',
defaults: {
width: 250,
margin: 20
},
items: [{ xtype: 'app-list' }, { xtype: 'app-detail' }]
});
再次,这里有一些惊喜。请注意,我们使用在各自的“别名”配置选项中定义的值来引用我们的观点:app-detail 和 app-list。我们已经处理了 MVC 中的“V”,那么让我们继续到“M”,看看我们的数据来自哪里,如下面的代码所示:
// model/Album.js
Ext.define('MvcEx1v4.model.Album', {
extend: 'Ext.data.Model',
fields: [
{ name: 'name', type: 'string' },
{ name: 'artist', type: 'string' }
]
});
// store/Albums.js
Ext.define('MvcEx1v4.store.Albums', {
extend: 'Ext.data.JsonStore',
model: 'MvcEx1v4.model.Album',
data: [
{ name: 'In Rainbows', artist: 'Radiohead' },
{ name: 'Swim', artist: 'Caribou' }
]
});
为了便于阅读,我已经将模型和消费它的存储的代码合并在一起。这个应用程序的数据是通过数据配置选项内联添加的(只是为了避免在服务器端 Ajax 调用中乱搞)。让我们看看 MVC 的最后一个方面,即控制器:
// controller/Album.js
Ext.define('MvcEx1v4.controller.Album', {
extend: 'Ext.app.Controller',
refs: [{
ref: 'detail',
selector: 'app-detail'
}],
init: function() {
this.control({
'.app-list': {
itemdblclick: this.onAlbumDblClick
}
});
},
onAlbumDblClick: function(list, record) {
var html = Ext.String.format('{0} by {1}', record.get('name'), record.get('artist'));
this.getDetail().getEl().setHTML(html);
}
});
这里开始与 Ext JS v3 应用程序中通常看到的直接视图到数据实现有所不同。我们引入了一个新类,引入了一个新的架构结构。但目的是什么?
答案是通信。正如我们所知,控制器是粘合“M”和“V”的胶水。在我们的简单示例中,它为我们提供了一个机制,让列表视图(别名为 app-list)在没有意识到对方的情况下与详细视图进行通信。control 功能用于确定当列表视图(别名为 app-list)触发 itemdblclick 事件时应该做什么。
我们提供了 onAlbumDblClick 方法来响应这个事件。在这里,我们想要与我们的详细视图(别名为 app-detail)进行通信。我们之前使用了 refs 配置选项来帮助实现这一点。让我们分解一下:
refs: [{
// We give our ref the name "detail". This autogenerates
// a method on the controller called "getDetail" which
// will enable us to access the view defined by the selector.
ref: 'detail',
// The selector is passed to Ext.ComponentQuery.query,
// so any valid component query would work here. We're
// just directly referencing the app-detail alias we
// set up in the view's configuration
selector: 'app-detail'
}]
简而言之,refs 功能为我们提供了一个简短的方式来访问视图。在 onAlbumDblClick 处理程序中,我们使用了 refs 提供的自动生成的 this.getDetail() 方法。这给了我们一个对视图的引用。然后我们可以根据列表视图提供的事件数据设置其视图元素的 HTML。
它如何帮助你的应用程序
让我们回顾一下。在我们涉及任何 MVC 东西之前,我们在 Ext JS 3 中比现在更好吗?
-
我们通过视图和模型实现了展示和数据之间的清晰分离
-
我们有一种使用控制器来协调我们应用程序不同部分的方式
-
我们通过使用多个与相关视图关联的控制器,将我们的应用程序分割成逻辑单元
不仅这有助于从一开始就保持不同功能部分非常分离,从而实现良好的设计,而且还为我们提供了一个良好的维护平台,因为它强制实施了一种非常具体的工作方式。
MVC 和选择幻觉
考虑到我们刚刚讨论的所有内容,你可能会认为 MVC 是开发的圣杯。它经过测试,适应性强,并且得到 Ext JS 的支持。事实上,在某些情况下,进一步增强 MVC 是有用的。
为了使用 Ext JS 特定的示例,让我们看看当你开始编写更复杂的应用程序时会发生什么。你的控制器可以响应视图触发的事件,协调不同视图之间的交互,甚至存储其他控制器。那么,这意味着你将在控制器、视图或两者的组合中放置事件处理器吗?
这是一个关键问题,可以通过从一开始就非常严格地管理你的开发过程来简单地回答。MVC 提供了“选择幻觉”;在这里,它提供了大量设置应用程序的方法,但只有少数几种方法会导致健康的应用程序。
当你有一个中央数据源,但不同的视图消费它时怎么办?你可能希望为每个视图以略微不同的形式呈现这些数据。视图本身是否负责塑造这些数据?
Ext JS 5 实现了一个称为 模型-视图-视图模型(MVVM)的模式,试图解决这些问题。
引入 MVVM
MVVM 可以被视为 MVC 的一种增强。引入视图模型的概念,它认识到并非每个与数据集相关的视图都会以相同的方式使用这些数据。它通过添加一个称为视图模型的中介层来解决这一问题,这个中介层位于视图和模型之间。它还把关注点的分离放在首位;为什么处理我们数据的模型要关心与处理展示的视图有关的事情呢?

MVVM 的典型表示
Ext JS 如何使用 MVVM?
在 Ext JS 5 中,MVVM 被全心全意地接纳。Sencha Cmd 生成的示例应用程序结构将提供与 View 类并行的 ViewModel 类。这通过新的配置选项与 View 类紧密集成,这使得它在解决大型 MVC 应用程序中常见的常见问题时成为一等公民,正如我们之前讨论的那样。
此外,创建了一个ViewController类来封装你通常会在视图或标准控制器中放置的逻辑。它消除了关于在哪里放置关注视图内部事件的事件处理器的问题,而不是将事件传递到应用程序其他部分的事件处理器。
启动我们的 MVVM
我们首先使用 Sencha Cmd 生成一个 Ext JS 5 应用程序模板,并将其作为构建我们示例专辑列表应用程序的基础。默认的 Ext JS 5 模板使用以下 MVVM 实现:

我们的示例应用程序移植到 Ext JS 5 的 MVVM 架构
你最直接会注意到的是,我们已经失去了控制器目录,视图目录中发生了很多事情。让我们分解一下:
// model/Album.js
Ext.define('MvvmEx1v5.model.Album', {
extend: 'Ext.data.Model',
fields: [
{ name: 'name', type: 'string' },
{ name: 'artist', type: 'string' }
]
});
相比之前的例子,专辑模型是相同的,但请注意,我们已经将应用程序名称更改为MvvmEx1v5。存储器只有非常细微的不同:
// store/Albums.js
Ext.define('MvvmEx1v5.store.Albums', {
extend: 'Ext.data.JsonStore',
model: 'MvvmEx1v5.model.Album',
data: [
{ name: 'In Rainbows', artist: 'Radiohead' },
{ name: 'Swim', artist: 'Caribou' }
]
});
我们添加了alias配置选项,这样我们就可以稍后使用专辑简称来引用存储器。现在,让我们看看视图目录:
// view/album/Album.js
Ext.define('MvvmEx1v5.view.album.Album', {
extend: 'Ext.container.Container',
xtype: 'app-album',
requires: ['Ext.grid.Panel'],
controller: 'album',
layout: 'hbox',
defaults: {
width: 250,
margin: 20
},
items: [
{
xtype: 'grid',
reference: 'list',
viewModel: 'album',
bind: '{albums}',
forceFit: true,
frame: true,
margin: '20 10 20 20',
columns: [
{ text: 'Album', dataIndex: 'name' },
{ text: 'Artist', dataIndex: 'artist' }
],
bbar: [
'->',
{ xtype: 'button', text: 'Show Artist Summary', handler: 'onShowSummary' },
'->'
],
listeners: {
rowdblclick: 'onAlbumDblClick'
}
},
{ xtype: 'container', margin: '20 10', reference: 'detail', width: 150, html: 'Double-click an Album to select' }
]
});
我们将之前的app-list和app-detail视图合并为单个app-albums视图,而且之前我们在视图中构建专辑摘要的逻辑,我们现在只定义事件处理器和逻辑放在其他地方。这个视图现在是 100%的展示,并将所有复杂的事情推迟到其他类。
注意,我们有一个controller配置选项,它定义了用于此视图类的视图控制器。我们的网格组件也有几个有趣的配置选项:
-
参考: 我们稍后会使用这个来从视图控制器获取这个组件。 -
viewModel: 这是此组件将使用的视图模型别名。 -
bind: 这定义了视图如何与视图模型通信。我们使用最简单的绑定(网格的默认bindProperty是 store),所以这里我们基本上只是将存储器的config设置为'albums'。
现在,让我们继续我们的专辑视图模型:
// view/album/AlbumModel.js
Ext.define('MvvmEx1v5.view.album.AlbumModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.album',
requires: [
'MvcEx1.store.Albums'
'Ext.Msg'
],
stores: {
albums: {
type: 'albums'
}
},
buildSummary: function() {
return this.getStore('albums').collect('name').join(', ');
}
});
此外,这里是我们现在包含这种逻辑的地方之一。一个视图模型从模型(或存储器)获取数据,并以适合其匹配视图的方式呈现。在这种情况下,我们从'albums'存储器(如我们之前提到的专辑别名通过type配置选项引用)获取数据。它提供了一个buildSummary方法,将存储的数据转换为可用于 UI 的字符串,如下所示:
// view/album/AlbumController.js
Ext.define('MvvmEx1v5.view.album.AlbumController', {
extend: 'Ext.app.ViewController',
alias: 'controller.album',
onShowSummary: function() {
var summary = this.lookupReference('list').getViewModel().buildSummary();
Ext.Msg.alert('Artists', summary);
},
onAlbumDblClick: function(list, record) {
var html = Ext.String.format('{0} by {1}', record.get('name'), record.get('artist'));
this.lookupReference('detail').getEl().setHtml(html);
}
});
最后,我们有我们的视图控制器,这是任何管理我们视图的逻辑应该去的地方。在视图控制器中定义的事件处理器将自动对匹配的视图可用。
我们的情况更好吗?
是的,我们因为更组织化而过得更好。我们知道所有应用程序的位信息在哪里,尽管我们比一个简单的单类 Ext JS 应用程序有更多的文件,但我们始终知道在哪里查找更改视图配置,在哪里找到我们的专辑逻辑,或者在哪里塑造我们从存储中提取的信息。
关于这个例子,一个重要的观点是我们放弃了第一个例子中的总体控制器,转而采用视图控制器。在这里,这很有意义;我们希望这个视图控制器只关注专辑,而不是应用程序的其他部分。然而,一个高级控制器仍然是 Ext JS MVVM 架构的有效部分,可以在需要以比视图控制器更高层次协调应用程序的情况下重新引入。
关于存储的简短插曲
在整个这一章中,我们谈了很多关于模型的内容,但从未具体讨论过存储,尽管我们在示例应用程序中使用了它们。为什么不是“SVC”或“SVVM”?
在 Ext JS 中,存储是一个特定的类,它提供了特定的功能,并且紧密绑定到您的应用程序中。然而,在简单的 MVC 或 MVVM 实现中,“存储”可能只是模型数组,而不是一个单独的架构特性。因此,存储实际上只是收集模型的一种方式,而 Ext JS 恰好是我们可以进行许多额外操作的地方(例如排序、过滤和分批)。
交互通信
我们已经展示了如何创建一个简单的应用程序,该应用程序使用几个移动部件来创建一个逻辑单元。多亏了 MVVM 模式,我们有一个允许这个单元的各个部分进行通信的方法,而无需明确绑定到其他部分的实现细节。
当我们扩展我们的应用程序时,我们可能会有几个这样的逻辑单元,也许除了专辑部分外,还有一个艺术家部分。现在,这些部分必须依次相互通信。这代表了软件架构中的一个主要问题:如何允许专辑和艺术家之间进行通信,而不使任何组件受到另一个组件详细信息的污染。这是一个与应用程序的大小和复杂性成正比的问题。
主要事件
解决这个问题的方法之一是允许应用程序部分触发自定义事件,每个事件都包含一个可以被任何可能对它们感兴趣的应用程序部分消费的有效负载。
事实上,我们在 Web 开发中经常看到这种情况。事件处理器是 JavaScript 编程的一个基本部分,因为我们把函数绑定到用户界面元素(如按钮)或浏览器事件(如window.onload)抛出的事件。我们已经在示例代码中提到了这一点;我们的视图触发了一个rowdblclick事件,该事件由我们的视图控制器处理。
在复杂的应用程序中,开发者通常会实现一个名为事件总线(event bus)的功能,这是一种将应用程序组件触发的事件传输到各个订阅者的方式。自从 Ext JS 4.2 版本以来,事件域(event domains)允许开发者将类似的功能集成到他们的代码库中。
事件域
事件域允许控制器对应用程序中来自各种不同来源的事件做出反应。默认来源包括:
-
组件(Components):这些是从组件中触发的事件。这本质上就是
Ext.app.Controller.control()通过处理扩展Ext.Component的类的事件并将它们绑定到事件监听器所提供的功能。 -
全局(Global):这些是从单个全局来源触发的事件,用于绑定任意应用程序范围内的事件。
-
控制器(Controller):这些是从其他控制器中触发的事件。
-
存储(Store):这些是从存储中触发的事件。
-
直接(Direct):这些是从扩展
Ext.direct.Provider的类中触发的事件。这仅在您需要应用程序中的Ext.direct.Manager时使用。
一些事件域允许您通过选择器(通常是与源关联的 ID)过滤接收到的事件,但在组件(Component)的情况下,您可以使用完整的 Ext.Component 查询。这允许您对如何订阅事件有更细粒度的控制。
事件域示例
让我们回到我们之前创建的 MVVM 相册示例。我们的视图有处理程序和监听器配置,这些配置将视图事件绑定到我们放在视图控制器中的事件处理程序。然而,事件域允许我们解除这种绑定,并将所有控制权交给视图组件。在我们的前一个示例中,view/album/Album.js,我们可以移除网格上的监听器配置和按钮上的处理程序,然后向 view/album/AlbumController.js 添加以下代码:
init: function() {
this.listen({
component: {
'app-album grid': {
'rowdblclick': 'onAlbumDblClick'
},
'app-album button': {
'click': 'onShowSummary'
}
}
});
},
这稍微有点冗长,所以看看这里到底发生了什么。我们向 this.listen 传递一个对象,它包含一个组件属性;这表示我们正在配置组件事件域。在这里,我们使用两个选择器,一个用于网格本身,一个用于摘要按钮,在这些定义中我们指定要绑定的事件和事件处理程序。
这使我们能够从视图中移除任何巧妙的功能,并将其全部放入视图控制器中。视图只处理展示,而视图控制器处理逻辑。
使用自定义事件
我们已经展示了如何使用事件域进一步分离我们的代码关注点,但现在,您将看到它们如何帮助在更高层次上编排交互。为此,让我们看看一个理论情况,即我们的应用程序已经发展到包含多个视图和视图控制器:
// view/search/SearchController.js
Ext.define('EventDomain1.view.search.SearchController', {
extend: 'Ext.app.ViewController',
alias: 'controller.search',
init: function() {
this.listen({
component: {
'app-search button': {
'click': 'onSearchSubmit'
}
}
});
},
onSearchSubmit: function() {
var val = this.lookupReference('searchfield').getValue();
this.fireEvent('search', val);
}
});
我们创建了一个新的SearchController,它是新Search视图的视图控制器。我们使用this.listen来监听组件事件域上的事件,并使用选择器'app-search button'(我们新Search视图中的一个按钮)进行过滤。当按钮被点击时,我们触发一个名为onSearchSubmit的事件处理方法。
我们提取用户输入的搜索词,然后触发第二个事件,将搜索词作为事件数据传递。我们触发的事件称为'search',它不是绑定到按钮或其他 UI 组件,而是可以被应用程序的其他部分作为纯应用程序事件订阅。让我们看看它如何被消费:
// partial /view/album/AlbumController.js
init: function() {
this.listen({
controller: {
'*': {
'search': 'onSearch'
}
}
});
}
这是我们之前见过的AlbumController片段,增加了一些额外的功能。使用this.listen,我们使用'*'选择器允许事件域上的所有控制器。然后,我们指定我们想要使用onSearch处理方法处理搜索事件。现在这一切都应该感觉非常熟悉了!处理方法可能像以下代码一样简单:
onSearch: function(searchTerm) {
var list = this.lookupReference('list');
list.getViewModel().search('searchTerm');
}
假设我们在视图模型中创建了一个search方法。仅仅通过一小段代码,我们就允许应用程序的两个不同部分通过关于我们应用程序的信息而不是它们自己的信息进行通信。这是保持代码中的搜索部分对专辑部分一无所知的关键,并且使得它们之间的界限非常清晰。这通过关注点的分离提供了更简单的测试,更好的可维护性,以及更容易理解应用程序的结构。
摘要
MVC 和 MVVM 是我们开始新项目前必须牢固掌握的关键架构概念。鉴于它们在 Ext JS 中如此根深蒂固,对它们背后的理念以及为什么实现这些模式将有助于我们构建代码库的方式有一个良好的理解就更加重要了。在下一章中,我们将继续探讨更多关于如何构建 Ext JS 应用程序的实际例子,结合 MVVM 概念以及其他一些理念,为构建一个强大的平台奠定基础。
第三章:应用程序结构
我们之前讨论了在没有架构感的情况下有机地扩展应用程序可能会导致难以维护的混乱代码。强加结构的一个好处是它自动提供了可预测性(一种文件系统,我们立即知道特定代码片段应该放在哪里)。
同样适用于组成您应用程序的文件。当然,我们可以将所有文件放在网站根目录下,将 CSS、JavaScript、配置和 HTML 文件混合在一个长的字母顺序列表中,但我们会失去许多保持应用程序组织的机会。在本章中,我们将探讨:
-
代码结构思路
-
典型 Ext JS 应用程序的布局
-
单例、混入和继承的使用
-
为什么全局状态是一个坏东西
结构化您的应用程序就像保持您的房子整洁一样。您将知道在哪里找到您的车钥匙,并且您将准备好应对意外的客人。
结构化思路
在大型应用程序中,代码结构的一种方式涉及命名空间(通过命名标识符划分代码的实践)。一个命名空间可以包含所有与 Ajax 相关的代码,而另一个命名空间可以包含与数学相关的类。编程语言(如 C#和 Java)甚至将命名空间作为一等语言结构来帮助代码组织。
根据命名空间将代码与目录分离成为这一点的合理扩展:

从左到右:Java 的 Platform API、Ext JS 5 和.NET Framework
命名空间标识符由一个或多个名称标记组成,例如"Java"或"Ext"、"Ajax"或"Math",通常由一个符号分隔,通常是句号。顶级名称将是整个包的总体标识符(例如"Ext"),随着名称的增加和您深入代码库,它将变得更加具体。
Ext JS 源代码大量使用这种实践来划分 UI 组件、实用类以及框架的其他部分,因此让我们看看一个真实示例。GridPanel组件可能是框架中最复杂的组件之一;它包含大量贡献于功能(如列、单元格编辑、选择和分组)的类。这些类共同创建了一个功能强大的 UI 小部件。以下是一些组成GridPanel的文件:

Ext JS 网格组件的目录结构
grid目录反映了Ext.grid命名空间。同样,子目录是子命名空间,最深层的命名空间是Ext.grid.filters.filter。
主要的 Panel 和 View 类:分别是 Ext.grid.Grid 和 Ext.grid.View,它们位于主目录中。然后,其他功能组件,例如 Column 类和各种列子类,被进一步分组到它们自己的子目录中。我们还可以看到一个 plugins 目录,其中包含多个针对网格的特定插件。
注意
Ext JS 实际上已经有一个 Ext.plugins 命名空间。它包含支持插件基础设施的类以及足够通用的插件,可以应用于整个框架。在不确定插件在代码库中最佳位置的情况下,我们可能会错误地将它放在 Ext.plugins 中。相反,Ext JS 遵循最佳实践,在 Ext.grid 之下创建一个新的、更具体的命名空间。
回到 Ext JS 框架的根目录,我们可以看到在顶层只有几个文件。一般来说,这些将是负责协调框架其他部分(如 EventManager 或 StoreManager)的类,或者是在整个框架中被广泛重用的类(如 Action 或 Component)。任何更具体的功能都应该以适当具体的方式进行命名空间化。
作为一条经验法则,你可以从 Ext JS 框架的组织中获得灵感,尽管它是一个框架而不是一个完整的应用程序。它缺少我们很快将要讨论的一些结构方面。
了解你的应用程序
当使用 Sencha Cmd 生成 Ext JS 应用程序时,我们最终得到一个代码库,它遵循类名和目录结构中的命名空间概念,如下所示:

使用 Sencha Cmd 的 "generate app" 功能创建的结构
我们应该熟悉所有这些内容,因为当我们讨论 Ext JS 中的 MVVM 时,这些内容已经被涵盖。话虽如此,其中一些部分值得进一步检查,看看它们是否被充分利用。
/overrides
这是一个很有用的工具,可以帮助我们进入一个积极且可预测的模式。有些情况下,你可能需要在全局级别覆盖 Ext JS 功能。也许,你想要更改低级类(如 Ext.data.proxy.Proxy)的实现,以提供你应用程序的定制批量行为。有时,你甚至可能会在 Ext JS 本身中发现一个错误,并使用覆盖来热修复,直到下一个点发布。overrides 目录提供了一个合理的放置这些更改的地方(只需镜像你正在覆盖的代码的目录结构和命名空间)。这也为我们提供了一个有用的规则,即子类放在 /app 目录,覆盖放在 /overrides 目录。
/.sencha
这包含了由 Sencha Cmd 使用的配置信息和构建文件。一般来说,我会建议你在完全了解 Sencha Cmd 之前尽量避免在这里过多地修改,因为如果你尝试升级到 Sencha Cmd 的新版本,可能会遇到一些讨厌的冲突。幸运的是,第四章,Sencha Cmd,全部都是关于 Sencha Cmd 的,我们将深入探讨这个文件夹。
bootstrap.js, bootstrap.json, 和 bootstrap.css
Ext JS 类系统通过 requires 功能提供了强大的依赖管理,这使我们能够创建只包含使用中代码的构建。引导文件包含有关依赖系统提供的运行应用程序所需的最小 CSS 和 JavaScript 的信息。正如我们将在 第四章,Sencha Cmd 中看到的那样,你甚至可以根据需求创建自定义的引导文件。
/packages
与 Ruby 有 RubyGems 和 Node.js 有 npm 类似,Sencha Cmd 有包的概念(一个可以从本地或远程源拖入应用程序的包)。
这允许你重用和发布功能包(包括 CSS、图像和其他资源)的集合,以减少代码的复制粘贴,并与 Sencha 社区分享你的工作。这个目录在你配置用于应用程序的包之前是空的。
/resources 和 SASS
SASS 是一种通过促进重用并引入强大的功能(如混合和函数)到样式表中来帮助创建复杂 CSS 的技术。Ext JS 使用 SASS 作为其主题文件,并鼓励你也这样做。我们将在 第四章,Sencha Cmd 中探讨这一点。
index.html
我们知道 index.html 是我们应用程序的根 HTML 页面。它可以按需进行定制(尽管,你很少需要这样做)。这里有一个需要注意的地方,它已经在文件中的注释里写明了:
<!-- The line below must be kept intact for Sencha Cmd to build your application -->
<script id="microloader" type="text/javascript" src="img/bootstrap.js"></script>
我们知道 bootstrap.js 是指什么(加载我们的应用程序并开始根据当前构建满足其依赖),所以请注意注释,并让这个脚本标签保持原样!
/build 和 build.xml
/build 目录包含构建工件(在运行构建过程时创建的文件)。如果你运行生产构建,那么你会在 /build 目录内得到一个名为 production 的目录,并且你应该在部署时只使用这些文件。build.xml 文件允许你在想要向构建过程添加一些额外功能时,避免修改 /.sencha 目录中的某些文件。如果你想在构建之前、期间或之后做些什么,这就是你要去的地方。当我们查看 第四章,Sencha Cmd 时,我们还会回到构建过程。
app.js
这是您应用程序的主要 JavaScript 入口点。此文件中的注释建议避免编辑它,以便允许 Sencha Cmd 在未来升级它。位于 /app/Application.js 的 Application.js 文件可以放心编辑,而不会引起冲突,并使您能够完成可能需要做的绝大多数事情。
app.json
这包含与 Sencha Cmd 和启动您的应用程序相关的配置选项。我们将在 第四章 中更详细地介绍,Sencha Cmd。
当我们提到这本书的主题作为 JavaScript 应用程序时,我们需要记住它只是一个由 HTML、CSS 和 JavaScript 组成的网站。然而,当处理需要针对不同环境的大型应用程序时,使用辅助开发过程工具来增强这种简单性非常有用。起初,可能看起来默认的应用程序模板包含了很多冗余,但它们是支持帮助你打造优质产品的工具的关键。
培养你的代码
当您构建应用程序时,将会有一个点,您会创建一个新的类,但它逻辑上不适合 Sencha Cmd 为您创建的目录结构。让我们看看一些例子。
我是一名伐木工 - 让我们去登录
许多应用程序都有一个集中的 SessionManager 来处理当前登录用户,执行身份验证操作,并为会话凭证设置持久存储。一个应用程序中只有一个 SessionManager。一个简化的版本可能看起来像这样:
/**
* @class CultivateCode.SessionManager
* @extends extendsClass
* Description
*/
Ext.define('CultivateCode.SessionManager', {
singleton: true,
isLoggedIn: false,
login: function(username, password) {
// login impl
},
logout: function() {
// logout impl
},
isLoggedIn() {
return isLoggedIn;
}
});
我们创建一个单例类。这个类不需要使用 new 关键字来实例化。根据其类名 CultivateCode.SessionManager,它是一个顶级类,因此它位于顶级目录中。在一个更复杂的应用程序中,可能还有一个专门的 Session 类和一些其他辅助代码,因此我们会创建以下结构:

我们会话命名空间的目录结构
关于用户界面元素呢?在 Ext JS 社区中有一个非正式的实践可以帮助这里。我们想要创建一个扩展,显示当前选中单元格的坐标(类似于 Excel 中的单元格引用)。在这种情况下,我们会创建一个 ux 目录——用户体验或用户扩展——然后遵循 Ext JS 框架的命名约定:
Ext.define('CultivateCode.ux.grid.plugins.CoordViewer', {
extend: 'Ext.plugin.Abstract',
alias: 'plugin.coordviewer',
mixins: {
observable: 'Ext.util.Observable'
},
init: function(grid) {
this.mon(grid.view, 'cellclick', this.onCellClick, this);
},
onCellClick: function(view, cell, colIdx, record, row, rowIdx, e) {
var coords = Ext.String.format('Cell is at {0}, {1}', colIdx, rowIdx)
Ext.Msg.alert('Coordinates', coords);
}
});
它看起来有点像这样,当您点击网格单元格时触发:

此外,相应的目录结构直接来自命名空间:

你可能已经看到了一个模式的出现。
小贴士
我们之前提到过,组织一个应用程序通常关于设置事情以成功落位。这种积极的模式是一个好兆头,表明你正在做正确的事情。
我们有一个可预测的系统,应该能够使我们创建新类而无需过多考虑它们将在我们的应用程序中的位置。让我们再看看一个数学辅助类的例子(一个稍微不那么明显的例子)。
再次,我们可以看看 Ext JS 框架本身以获得灵感。有一个 Ext.util 命名空间,包含超过 20 个通用类,这些类根本无处可放。所以,在这种情况下,让我们创建 CultivateCode.util.Mathematics,它包含我们用于数值工作的专用方法:
Ext.define('CultivateCode.util.Mathematics', {
singleton: true,
square: function(num) {
return Math.pow(num, 2);
},
circumference: function(radius) {
return 2 * Math.PI * radius;
}
});
这里有一个需要注意的地方,而且非常重要。有一个真正的危险,那就是在考虑你的代码命名空间及其在应用程序中的位置时,很多内容最终都放在了 utils 命名空间下,从而违背了整个目的。在将代码放入 utils 桶之前,花时间仔细检查是否有更适合的位置。
这尤其适用于你考虑在 utils 命名空间中向单个类添加大量代码的情况。再次看看 Ext JS,有许多专门的命名空间(例如 Ext.state 或 Ext.draw)。如果你正在处理一个包含大量数学的应用程序,你可能更倾向于以下命名空间和目录结构:
Ext.define('CultivateCode.math.Combinatorics', {
// implementation here!
});
Ext.define('CultivateCode.math.Geometry', {
// implementation here!
});
以下截图显示了数学命名空间的目录结构:

这又是一个没有明确正确答案的情况。它将随着经验而来,并且完全取决于你正在构建的应用程序。随着时间的推移,构建这些高级应用程序的构建块将变得习以为常。
金钱买不到类
现在我们正在学习我们的课程属于哪里,我们需要确保我们实际上正在使用正确的类类型。以下是实例化 Ext JS 类的标准方式:
var geometry = Ext.create('MyApp.math.Geometry');
然而,考虑一下你的代码。想想在 Ext JS 中手动调用 Ext.create 是多么罕见。那么,类实例又是如何创建的呢?
单例
单例只是一个在应用程序生命周期中只有一个实例的类。在 Ext JS 框架中有很多单例类。虽然单例在软件架构中通常是一个有争议的点,但在 Ext JS 中它们通常被很好地使用。
可能你更喜欢将数学函数(我们之前讨论过)实现为单例。例如,以下命令可能可行:
var area = CultivateCode.math.areaOfCircle(radius);
然而,大多数开发者会实现一个圆形类:
var circle = Ext.create('CultivateCode.math.Circle', { radius: radius });
var area = circle.getArea();
这将圆形相关的功能分区到圆形类中。它还使我们能够将圆形变量传递给其他函数和类进行额外处理。
另一方面,看看Ext.Msg。这里的方法都会被触发并遗忘,永远不会有什么进一步的操作。Ext.Ajax也是同样的情况。因此,我们再次发现自己面临一个没有明确答案的问题。这完全取决于上下文。
小贴士
这将会发生很多次,但这是好事!这本书不会教你一串事实和数字;它会教你独立思考。阅读他人的代码并从经验中学习。这不是按数字编码!
你可能会发现自己需要使用单例(singleton)的强大功能的地方是在创建一个全局管理类(例如内置的StoreManager或我们之前的SessionManager示例)。关于单例的一个反对意见是,它们往往会滥用以存储大量的全局状态,并破坏我们在代码中设置的职责分离,如下所示:
Ext.define('CultivateCode.ux.grid.GridManager', {
singleton: true,
currentGrid: null,
grids: [],
add: function(grid) {
this.grids.push(grid);
},
setCurrentGrid: function(grid) {
this.focusedGrid = grid;
}
});
没有人想在代码库中看到这类东西。它将行为和状态提升到应用的高层次。理论上,代码库的任何部分都可能调用这个管理器并产生意外的结果。相反,我们会这样做:
Ext.define('CultivateCode.view.main.Main', {
extend: 'CultivateCode.ux.GridContainer',
currentGrid: null,
grids: [],
add: function(grid) {
this.grids.push(grid);
},
setCurrentGrid: function(grid) {
this.currentGrid = grid;
}
});
我们仍然有相同的行为(一种收集网格的方式),但现在,它被限制在更符合上下文的部分。此外,我们正在使用 MVVM 系统。我们避免全局状态,并以更正确的方式组织我们的代码。这是一场全面的胜利。
作为一般规则,如果你可以避免使用单例,就请这样做。否则,要非常仔细地考虑,确保它是你应用程序的正确选择,并且标准类更适合你的需求。在先前的示例中,我们可以选择走捷径并使用管理单例,但这将是一个糟糕的选择,会损害我们代码的结构。
混入
我们习惯于在 Ext JS 中从子类继承的概念——一个网格扩展了一个面板以承担其所有功能。混入提供了类似的机会来复用功能,通过添加一小部分行为来增强现有的类。在《代码大全第二版》中,Steve McConnell,Microsoft Press US,第 6.3 节,McConnell 说:
"将封装视为一个“拥有”关系。一辆车“拥有”一个引擎,一个人“拥有”一个名字,等等。”
"将继承视为一个“是”关系。一辆车“是”一个车辆,一个人“是”一个哺乳动物,等等。”
Ext.Panel“是”一个Ext.Component,但它也“拥有”一个可固定的特性,该特性通过Ext.panel.Pinnable混入提供了一个固定工具。
在你的代码中,你应该查看混入(mixins)以提供功能,尤其是在这个功能可以被复用的场合。在下一个示例中,我们将创建一个名为shakeable的 UI 混入,它提供了一个具有摇动方法的 UI 组件,通过从一侧摇到另一侧来吸引用户的注意力:
Ext.define('CultivateCode.util.Shakeable', {
mixinId: 'shakeable',
shake: function() {
var el = this.el,
box = el.getBox(),
left = box.x - (box.width / 3),
right = box.x + (box.width / 3),
end = box.x;
el.animate({
duration: 400,
keyframes: {
33: {
x: left
},
66: {
x: right
},
100: {
x: end
}
}
});
}
});
我们使用animate方法(它本身实际上是混合在Ext.Element中的)来设置一些动画关键帧,首先移动组件的元素向左,然后向右,最后回到原始位置。下面是一个实现它的类:
Ext.define('CultivateCode.ux.button.ShakingButton', {
extend: 'Ext.Button',
mixins: ['CultivateCode.util.Shakeable'],
xtype: 'shakingbutton'
});
也像这样使用:
var btn = Ext.create('CultivateCode.ux.button.ShakingButton', {
text: 'Shake It!'
});
btn.on('click', function(btn) {
btn.shake();
});
按钮已经采用了混合提供的新的shake方法。现在,如果我们想让一个类具有shakeable特性,我们可以在必要时重用这个混合。
此外,混合可以简单地用来将类的功能提取到逻辑块中,而不是有一个包含数千行代码的单个文件。Ext.Component就是这样一个例子。事实上,其大部分核心功能都存在于混合在Ext.Component中的类中。
这在导航代码库时也很有用。共同构建一个特性的方法可以被分组并放在一个整洁的小包中。让我们看看一个使用混合重构现有类的实际例子。以下是原始类的框架:
Ext.define('CultivateCode.ux.form.MetaPanel', {
extend: 'Ext.form.Panel',
initialize: function() {
this.callParent(arguments);
this.addPersistenceEvents();
},
loadRecord: function(model) {
this.buildItemsFromRecord(model);
this.callParent(arguments);
},
buildItemsFromRecord: function(model) {
// Implementation
},
buildFieldsetsFromRecord: function(model){
// Implementation
},
buildItemForField: function(field){
// Implementation
},
isStateAvailable: function(){
// Implementation
},
addPersistenceEvents: function(){
// Implementation
},
persistFieldOnChange: function(){
// Implementation
},
restorePersistedForm: function(){
// Implementation
},
clearPersistence: function(){
// Implementation
}
});
这个MetaPanel做了两件事,这是正常FormPanel所不具备的:
-
它从
Ext.data.Model读取Ext.data.Fields,并自动根据这些字段生成表单布局。如果字段具有相同的组配置值,它还可以生成字段集。 -
当表单的值发生变化时,它会将它们持久化到
localStorage,这样用户就可以离开并稍后继续完成表单。这对于长表单很有用。
实际上,实现这些功能可能需要比之前代码框架中显示的更多方法。由于两个额外功能定义得非常明确,重构此代码以更好地描述我们的意图是很容易的:
Ext.define('CultivateCode.ux.form.MetaPanel', {
extend: 'Ext.form.Panel',
mixins: [
// Contains methods:
// - buildItemsFromRecord
// - buildFieldsetsFromRecord
// - buildItemForField
'CultivateCode.ux.form.Builder',
// - isStateAvailable
// - addPersistenceEvents
// - persistFieldOnChange
// - restorePersistedForm
// - clearPersistence
'CultivateCode.ux.form.Persistence'
],
initialize: function() {
this.callParent(arguments);
this.addPersistenceEvents();
},
loadRecord: function(model) {
this.buildItemsFromRecord(model);
this.callParent(arguments);
}
});
我们有一个更短的文件,并且在这个类中包含的行为描述得更加简洁。而不是七个或更多可能跨越几百行代码的方法体,我们只有两行混合代码,并且相关的方法被提取到一个命名良好的混合类中。
污染的解决方案
从本质上讲,我们努力确保新加入项目的成员不会对所见到的内容感到惊讶。一切都应该有明确的标签,决策应该有逻辑支撑,代码应该放在对功能有意义的地点。我们简要地讨论了如何将命名空间(如utils)变成不适合立即使用的代码的“桶”。还有一些其他情况,我们会发现自己为那些无人知晓如何使用的函数创建了一个垃圾场。
对局部问题的全局解决方案
我们的英雄,一个充满热情且才华横溢的程序员,在编写他们最新的应用程序时意识到一件事。
我可能需要这个函数很多次;可能在我的大多数 UI 组件中都需要。
担忧之下,他们考虑了实现它的最佳方式,以及它在现有代码库中的最佳位置。
我需要在应用程序的任何地方调用它。此外,我的应用程序类已经在应用程序的任何地方可用;我会在那里挂载它。MyApp.myFunc(),我们来了!
因此,我们的英雄开始踏上疯狂的道路。他们会问MyApp.isUserLoggedIn()吗?他们会想MyApp.isProduction()或MyApp.isStaging()吗?此外,为了方便访问配置,我们有MyApp.validNames和MyApp.apiUrl数组。
看看我的全局状态吧,你伟大的存在,绝望吧!
这是一种夸张的戏剧手法,以便传达观点。使用你的应用程序单例作为简单的万能工具非常容易,就像这里展示的那样:
Ext.define('CultivateCode.Application', {
extend: 'Ext.app.Application',
name: 'CultivateCode',
searchCfg: {
mode: 'beginsWith',
dir: 'asc'
},
isLoggedIn: false,
isSecure: false,
launch: function () {
this.setupAjaxOverrides();
this.performCookieCheck();
Ext.apply(Ext.util.Format, {
defaultDateFormat: 'd F Y'
});
},
setMasked: function(mask) {
// Implementation
},
setupAjaxOverrides: function() {
// Implementation
},
onAjaxError: function(connection, resp, opt) {
// Implementation
}
performCookieCheck: function() {
// Implementation
}
});
这里应该做什么才是正确的呢?嗯,searchCfg需要移动到它被使用的地方,可能是一个搜索模型或视图模型,也许是在负责搜索的 UI 组件上。
Ajax 重写和错误处理可以移动到/overrides文件夹,并放置在它们正确的命名空间中,这使得它们更容易被发现。
确保用户在浏览器上启用了 cookie 的 cookie 检查可能保留在应用程序类中,仅仅因为在这个应用程序中,cookie 可能是一个要求。
像之前的讨论中提到的,像isLoggedIn这样的东西最好由SessionManager来处理。仍然是一个单例,但这是一个更易于发现和逻辑上更合适的功能位置的单例。
在另一个地方,我们可以从 Ext JS 框架那里得到启示:setMasked。而不是在应用程序上作为方法,Ext JS 将其作为每个Ext.Container上的方法提供,这意味着你可以直接在面板和网格上调用它。这意味着将组件遮罩的代码将不再跳到全局应用程序范围并希望它指向正确的容器。相反,你可以确信你正在影响你感兴趣的组件,而不会影响其他任何东西,而且这一切都不需要污染你的全局应用程序类。
概述——管好自己的事
在这一章中,我们强调了几个要点。
当涉及到构建你的应用程序时,让自己生活得更容易。遵循最小惊讶原则,不要把所有的类堆在一个命名空间中。
使用如 mixins 这样的架构设备来保持你的代码在可管理的块中。当你查看一个类时,你不想看到数千行蜿蜒的代码,而希望看到一个简洁的逻辑单元。
将 Ext JS 框架作为结构上的灵感来源。它可能不是 100%,但它确实向我们展示了非常重要的一点,这应该指导你应用程序架构的各个方面:有一个系统总是比没有系统要好,并且你应该始终保持一致性。
在下一章中,我们将探讨 Sencha Cmd,这是一个与 Ext JS 携手并进的工具,帮助我们生成、开发和部署我们的应用程序。
第四章. Sencha Cmd
在第三章中,我们描述了 Ext JS 应用程序的本质,即 HTML、JavaScript 和 CSS 的集合。本质上就是一个网页。然而,仅仅从这个角度来看不仅过于简化了问题,而且也意味着你的应用程序无法达到其全部潜力;你将忽略诸如依赖管理、代码优化以及许多其他高质量应用程序应该包含的问题。
小贴士
记住,应用程序架构,就像建筑架构一样,不仅仅是将整体的部分拼凑在一起。它关乎砖石和建筑材料,以及构建最终产品的方法。
Sencha Cmd 可以是构建你的项目的重要部分。它为你提供了一个强大的基础,流畅的工作流程,以及精致的最后产品。在本章中,我们将探讨:
-
Sencha Cmd 实际上是什么
-
为什么 Sencha Cmd 在开发过程中很重要
-
它帮助你构建初始应用程序模板,然后协助添加新功能块的方式
-
它如何提供有用的工具来支持持续的开发过程
-
部署过程——为各种平台生成优化的构建,并为更具体的需求定制构建过程
在本章结束时,你将了解为什么使用 Sencha Cmd 构建的应用程序开发部署会显著更容易。你将对 Sencha Cmd 的各个部分以及如何配置和增强它以适应你的需求有一个深入的理解。
什么是 Sencha Cmd?
简而言之,Sencha Cmd 是一个可执行文件,它提供了一系列进一步命令来帮助你进行 Sencha 应用程序开发。在底层,它包含了一系列第三方实用工具和脚本,它们组合起来提供这种功能。以下是它安装中包含的一些内容:
-
PhantomJS:这是一个用于在不使用浏览器界面的情况下操作网页的工具
-
VCDIFF:这是一个用于计算一组文件之间差异的工具
-
Closure Compiler:这是一个用于优化和压缩 JavaScript 代码的工具
-
Jetty:这是一个提供简单 HTTP 服务器的工具
所有这些都被 Sencha 的一些自定义胶水连接在一起,由 Apache Ant 支持——这是一个在 Java 世界中常用的构建工具。
结果是一个复杂但功能强大的可定制工具。使用 Ant 几乎可以调整和扩展 Sencha Cmd 的所有功能,而 Sencha 的添加提供了一个直接的命令行界面来访问这些功能。
对于应用架构师来说,Sencha Cmd 为你的开发者提供了一个集中的工作流程和一个可重复的构建过程。它可以加快开发时间,并为你的团队工具集添加客户或业务特定的部署任务提供了一种方式。
为什么它很重要?
虽然 Ext JS 是在假设其用户很可能会使用 Sencha Cmd 的情况下开发的,但并没有硬性规定它必须使用。
注意
我们在这里不会介绍 Sencha Cmd 的安装。最新的安装程序可在 Sencha 网站上找到(www.sencha.com/),应该是一个简单的流程。
在接下来的几页中,我们将创建一个不使用 Sencha Cmd 的小型应用程序,并检查我们在路上会遇到的一些难题。
创造的行为
其中一个难题立即出现。使用 Sencha Cmd,创建一个新应用程序就像以下命令一样简单:
sencha -sdk ~/<path-to-ext-sdk> generate app MyApp ./my-app
几秒钟内,Sencha Cmd 就会创建一个名为 my-app 的新目录,包含以下内容:
-
指定的 Ext JS SDK
-
包含模型、控制器、存储和视图目录的应用程序目录
-
app.js、app.json和Application.js文件 -
index.html文件 -
Bootstrap 文件
-
一个包含配置文件的 sass 目录
-
.sencha目录和build.xml
现在,其中大部分是 Sencha Cmd 支持基础设施的一部分,我们可以将其丢弃。我们将不得不手动创建前面详细说明的大多数项目。让我们开始吧。
让我们创建应用程序目录并将 Ext JS SDK 复制到这里:

纯粹的应用程序目录
接下来是 index.html 页面。我们可以创建一个标准的 HTML5 页面,并需要连接 Ext JS 的 JavaScript 和 HTML。在由 Sencha Cmd 生成的应用程序中,我们会有一组 Bootstrap 文件来帮助我们。它们遍历您应用程序的依赖关系树并相应地自动加载文件。没有 Sencha Cmd,我们必须手动包含这些文件。因此,我们最终得到以下代码:
<!DOCTYPE HTML>
<html>
<head>
<title>NoCMD</title>
<link rel="stylesheet" type="text/css" href="ext/build/packages/ext-theme-neptune/build/resources/ext-theme-neptune-all.css">
<script type="text/javascript" src="img/ext-all.js"></script>
<script type="text/javascript" src="img/ext-theme-neptune.js"></script>
<script type="text/javascript" src="img/Application.js"></script>
</head>
<body></body>
</html>
我们现在可以开始构建我们的应用程序,从一个显示消息框的简单“Hello World”开始:
// Application.js
Ext.application({
name: 'NoCMD',
launch: function() {
Ext.Msg.alert('Welcome', 'To our Command-free application!');
}
});
下一步是将它转换成一个 MVVM 应用程序。一旦我们通过添加 app、app/model、app/store 和 app/view 来构建目录结构,我们就可以添加我们的第一个视图。记住,到目前为止我们所做的一切都将是通过 Sencha Cmd 的单个调用创建的。
这是我们的视图类代码;视图模型首先:
Ext.define('NoCMD.view.main.MainModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.main',
data: {
introText: 'Welcome to the Command-free MVVM application!',
buttonText: 'Click Me!'
}
});
这完全是标准的,紧随其后的是视图本身:
Ext.define('NoCMD.view.main.Main', {
extend: 'Ext.Panel',
requires: ['NoCMD.view.main.MainModel', 'NoCMD.view.main.MainController'],
viewModel: 'main',
controller: 'main',
items: [
{ xtype: 'component', bind: { html: '{introText}' } },
{ xtype: 'button', bind: { text: '{buttonText}', handler: 'onClickButton' } }
]
});
注意,与我们的前几个应用程序相比,我们必须显式地要求视图模型和视图控制器。我们稍后会回到这个问题。最后,这是视图控制器:
Ext.define('NoCMD.view.main.MainController', {
extend: 'Ext.app.ViewController',
requires: [
'Ext.MessageBox'
],
alias: 'controller.main',
onClickButton: function () {
Ext.Msg.alert('Confirm', 'Are you sure?');
}
});
让我们回到 requires 选项。当使用 Sencha Cmd 时,viewModel 和控制器配置选项被解析为自动依赖项。当我们运行元命令(或调用元命令的其他命令,如构建)时,它会编译您的代码并生成 Bootstrap 文件,告诉 Ext JS 如何加载您的应用程序。
没有 Sencha Cmd,我们没有自动依赖项,因此必须使用requires选项显式指定。这又避免了我们本可以避免的几行代码。
不提及其他所有与视图相关的文件都可以只用一个命令生成的事实:
sencha generate view main.Main
我们已经看到使用 Sencha Cmd 设置应用程序和添加新功能是如何简化的。让我们简要看看流程的另一端:部署。
我们希望确保我们的用户拥有尽可能瘦的生产构建,以最小化下载时间。使用 Sencha Cmd,我们可以使用build命令创建一组满足我们要求的部署文件。Sencha Cmd 将解析我们的 JavaScript 文件和元数据,并创建一个仅包含我们在应用程序中实际使用的类的最小化 JS 文件。它是通过检查每个类的要求并构建一个可以组合成单个下载的依赖关系树来做到这一点的。
没有 Sencha Cmd,我们将何去何从?以下是我们在每个生产构建中需要采取的步骤:
-
列出我们应用程序使用的所有文件(包括 Ext JS 框架本身中的文件)。
-
将它们组合在一起,然后最小化。用这个新文件替换
index.html中引用的 JavaScript 文件。
记住,这仅适用于 JavaScript!Sencha Cmd 可以为 CSS 执行类似的过程,并将应用程序的 Sass 文件编译成一个单独的下载。
注意
Ext JS 使用Ext.Loader,这是一个确保所需的类被加载的类,如果没有,则使用 Ajax 请求相关文件并解析它们——这一切都是即时的。这也意味着文件可以从代码的任何需要它的地方加载——没有单一的参考点。
事实上,Ext JS 应用程序的本质使得进行这种类型的生产优化变成一个漫长的过程,并且容易出错。在本章的剩余部分,我们将展示如何使您的构建和整个工作流程变得更加快速、易于重复,并最终产生更高品质的最终产品。
设置您的应用程序
Sencha Cmd 支持一个称为工作区的先进概念。在复杂的项目中,可能需要多个页面或部分,本质上是在更大的应用程序中的应用程序。工作区允许您共享常用代码(例如会话管理、自定义 UI 组件和辅助类跨这些各种子应用程序)。它还避免了在子应用程序中重复框架代码(即 Ext JS 源代码)的需要。
注意
Sencha Cmd 文档在docs.sencha.com/cmd/5.x/workspaces.html有关于工作区的详细文档。
可以使用以下命令生成工作区:
sencha generate workspace ./my-workspace
这只是为工作区添加了一些配置文件。当您为该工作区生成应用程序时,额外的魔力就会出现:
sencha -sdk ~/<path-to-sdk>/ext generate app MyApp ./my-workspace/my-app
这里的关键区别是 SDK 将位于工作区根目录而不是应用程序根目录。因此,所有子应用程序都将使用相同的 SDK。
或者,你可以使用 Sencha Cmd 生成一个应用程序,这我们已经提到过:
sencha -sdk ~/<path-to-sdk>/ext generate app MyApp ./my-app
这将基于我们之前多次使用过的标准应用程序模板。
虽然工作区是组织代码和促进代码重用的有用方法,但我们将在本书的剩余部分专注于单个应用程序。我们讨论的所有想法都可以在应用程序级别实现,而不会受到工作区的干扰。
生成游戏
从这里,我们可以快速构建我们应用程序的骨架。关键的 MVVM 类包括控制器、模型和视图(及其关联的视图控制器和视图模型)。Sencha Cmd 可以帮助我们快速创建所有这些类。
注意
使用命令行工具生成代码通常被称为“脚手架”,并由 Ruby On Rails 流行起来。有关更多内容,请参阅en.wikipedia.org/wiki/Scaffold_(programming)。
对于控制器,很简单:
sencha generate controller MyController
前一个命令会产生以下结果:
// app/controller/MyController.js
Ext.define('MyApp.controller.MyController', {
extend: 'Ext.app.Controller'
});
然后,模型生成器调用如下所示:
sencha generate model MyModel fullName:string,age:int
MyModel 是模型名称的直接表示。下一个参数允许在模型中生成字段(以逗号分隔的 name:type 字段对列表提供)。在这种情况下,我们创建了两个字段:fullName 类型为字符串和 age 类型为整数。这给我们以下代码:
// app/model/MyModel.js
Ext.define('MyApp.model.MyModel', {
extend: 'Ext.data.Model',
fields: [
{ name: 'name', type: 'string' },
{ name: 'age', type: 'int' }
]
});
最后,以下是一个视图生成器:
sencha generate view my.MyView
这将为我们创建几个文件,如下所示:
// app/view/my/MyView.js
Ext.define("MyApp.view.my.MyView",{
"extend": "Ext.panel.Panel",
"controller": "my-myview",
"viewModel": {
"type": "my-myview"
},
"html": "Hello, World!!"
});
// app/view/my/MyViewController.js
Ext.define(MyApp.view.my.MyViewController', {
extend: 'Ext.app.ViewController',
alias: 'controller.my-myview'
});
// app/view/my/MyViewController.js
Ext.define('MyApp.view.my.MyViewModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.my-myview',
data: {
name: 'MyApp'
}
});
使用此命令,由于我们正在创建多个文件,我们有创建目录和相应命名空间来包含它们的机遇。在这种情况下,我们在 app/view/my 创建了 my 目录以存放这三个文件。
注意
在这里请注意你模型、视图和控制器名称的大小写。Sencha Cmd 并不会对大小写做特殊处理以保持预期的命名约定。因此,你输入的任何内容都将直接传递到类中。
我们还可以使用 generate 运行两个额外的命令:theme 和 package,但我们将更详细地介绍这些内容,在第九章,购物应用程序中,当我们使用自定义主题构建打包组件时。
以风格开发
现在我们已经使用 Sencha Cmd 驱动的应用程序启动并运行,我们可以开始探讨 Sencha Cmd 可以如何简化我们的持续开发过程。这些包括从生成应用程序元数据到编译主题文件。
这里提供 – 一个网站
一个标准的 HTML 网页可以直接从你的电脑上运行,无需 Web 服务器。浏览器直接从你的本地驱动器读取文件。随着基于 JavaScript 的 Web 应用的日益流行,浏览器已经引入了各种安全限制,以防止恶意网站读取你的本地文件系统。
这导致了 Ext JS 的问题,因为在开发时,Ext.Loader会从你的应用目录动态加载和解析你的应用所依赖的 JavaScript 文件。
解决这个问题的最好方法是将你的代码通过 Web 服务器运行,因为浏览器通常期望这样做。幸运的是,为了避免在开发机器上配置完整的 Web 服务器,Sencha Cmd 为你提供了一个简单的方法来在当前目录中启动一个轻量级服务器。只需运行以下命令:
sencha web start
你应该得到类似以下的输出:

确实,在你的网络浏览器中访问http://localhost:1841,你的应用将准备就绪并等待:

注意,我们可以覆盖服务器使用的端口。为了在端口1999上启动它,我们将发出以下命令:
sencha web -port 1999 start
Bootstrap 过程
Sencha Cmd 的 JavaScript 编译器不仅仅是合并和压缩。它理解你的代码,并将处理某些部分以简化依赖管理。
例如,一个通配符requires选项,如Ext.grid.*将被展开,以便包括Ext.grid下的所有文件和类。某些配置选项,如控制器或 ViewModel,将被转换为它们的完整类引用。这就是 Bootstrap 过程——将依赖信息转换为实际要加载的文件列表的方式。
这种元数据技巧需要付出一点代价。在某些情况下,你需要刷新 Bootstrap 数据,以便 Ext JS 能够成功加载你的应用。一种方法是通过运行以下命令:
sencha app refresh
这将快速重建启动你的应用所需的文件。然而,我们可以更进一步,通过运行一个在需要时刷新应用的开发 Web 服务器来一石二鸟。这就像运行以下命令一样简单:
sencha app watch
通过这种方式,我们拥有了与 Web Start 相同的功能,并结合了一个监视元数据更改和 Sass 文件变更的过程。当它检测到变化时,它会自动重建 Bootstrap 数据和 CSS。
关心环境
Sencha Cmd 支持环境的概念,允许根据工作流程的阶段有不同的行为。我们已经提到,Sencha Cmd 利用 Ant 构建系统的力量来允许自定义过程。不同的环境只是定义了 Ant 消费并传递到构建过程中的变量,以启用、禁用或修改构建的一部分。
您可以通过运行以下命令来查看默认的构建变量:
sencha ant .props
这会产生一些代码,其中包括以下代码以及成千上万个其他变量:
[INF] [echoproperties] app.output.js=app.js
[INF] [echoproperties] app.output.js.compress=false
[INF] [echoproperties] app.output.js.enable=true
[INF] [echoproperties] app.output.js.optimize=false
这里变量app.output.js的值为app.js。
注意
注意,构建变量和配置变量是两件不同的事情。配置变量由 Sencha Cmd 整体使用,而不仅仅是构建过程。我们只将讨论配置变量,因为它们最常用,并且提供了最多的“物有所值”。我们只是没有空间涵盖每个变量。
现在,我们将更仔细地查看环境。它们与app build子命令一起使用,为我们提供了大量自定义生产代码的权力。
最终产品
在创建生产构建时,我们希望使我们的代码尽可能精简,删除日志、调试,并确保压缩 JavaScript 和 CSS,并使用所有可用的优化。让我们看看产品环境的覆盖设置:
// Defined in .sencha/app/production.defaults.properties
build.options.logger=no
build.options.debug=false
build.compression.yui=1
build.optimize=true
enable.cache.manifest=true
enable.resource.compression=true
build.embedded.microloader.compressor=-closure
让我们逐一查看每个选项,忽略第一行的注释:
-
禁用 Ext JS 框架日志。
-
告诉编译器删除标记为调试代码的部分。
-
使用 YUI Compressor 压缩 JavaScript 代码。
-
启用自定义优化(例如删除
requires选项),当依赖树已知时不再需要。这可能会导致代码库略微减小。 -
生成一个 HTML 缓存清单文件。这指示浏览器缓存应用程序的
index.html文件以减少网络活动。 -
压缩 CSS 文件和其他资源。
-
压缩启动我们应用程序的"microloader" JavaScript。
除了大量的其他配置选项之外,我们还有一个机制来定制我们的最终构建以满足我们的需求。为了跟踪生产问题,您可能希望启用将日志记录到浏览器控制台,以便您可以切换此选项。
您甚至可以创建一个自定义环境,跳过构建的部分以加快过程。查看sencha ant .props创建的输出,以了解您可以为团队的需求定制过程的地方是值得的。
在构建之前
回到第三章,应用程序结构,我们提到了 Sencha Cmd 作为应用程序模板的一部分生成的build.xml文件。现在,我们将更仔细地查看,看看我们如何使用此文件来挂钩构建过程并利用它来实现我们的目的。
我们已经提到 Sencha Cmd 使用 Ant,这是一个基于 XML 的构建系统,作为其核心。Ant 的一个关键概念是“目标”,这是一个术语,描述了一组执行构建过程一部分的任务,如 Ant 手册中所述:
"目标是在构建过程中协同完成任务以达到期望状态的容器。"
在我们的案例中,Sencha Cmd 附带了一套预存在的目标,我们可以使用它们来挂钩到构建过程的各个部分。build.xml文件包含这些目标的占位符以及一些关于它们做什么的注释。我们将挂钩到其中之一,并实现一个任务,如果某些条件未满足,将停止构建过程。
当我们讨论架构师的角色时,我们推测可能需要强制开发团队遵守编码标准。我们可以使用自动化工具来确保代码库中使用最佳实践。在这里,我们将使用 JSHint:一个 JavaScript 代码质量工具。
Ant 被广泛使用,因此有许多社区创建的附加功能。对于 JSHint,开发者 Phil Mander 创建了一个任务,使其在 Ant 目标中使用。有关更多信息,请参阅github.com/philmander/ant-jshint。
首先,我们需要下载包含新任务的 Java JAR 文件,地址为git.io/VSZvRQ。
我只是简单地将其放置在应用程序根目录中,与build.xml文件一起,但如果你有许多额外的任务,创建一个新的目录绝对是值得的。
现在,我们可以配置我们的build.xml文件以使用此新任务,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<project name="MyApp" default=".help">
<import file="${basedir}/.sencha/app/build-impl.xml"/>
<!-- Expose the new task using the ant-jshint jar file -->
<taskdef name="jshint" classname="com.philmander.jshint.JsHintAntTask"
classpath="${basedir}/ant-jshint-0.3.6-SNAPSHOT-deps.jar" />
<!-- Hook into the before-init target -->
<target name="-before-init">
<!-- JSHint is now fully exposed via XML -->
<jshint dir="${basedir}/app" includes="**/*.js" globals="Ext:true" options="strict=false">
<!-- Output a report to a file called jshint.out -->
<report type="plain" destFile="${basedir}/jshint.out" />
</jshint>
</target>
</project>
连接这个新任务有几个步骤:
-
添加一个
taskdef元素,让 Ant 了解ant-jshint任务 -
添加名为
–before-build的目标元素 -
添加一个配置为 GitHub 上文档的
jshint元素
关于我们配置 JSHint 任务的方式,唯一真正特别的事情是要注意需要在globals设置中添加 Ext。由于ant-jshint尚未被告知 Ext JS 框架的位置,我们告诉它假设一个名为 Ext 的全局变量在别处定义。
现在,我们可以再次运行sencha app build,JSHint 将解析我们的代码,并对照其规则集进行检查。如果我们的代码未通过,整个构建将失败,并在我们应用程序的根目录中创建一个名为jshint.out的文件,将显示任何 JSHint 错误的详细信息及其发生的行。
只需十分钟的工作,我们就已经创建了一种低摩擦的方法,以确保有缺陷或低质量的代码不太可能达到生产环境。
这只是预构建检查的一个例子。你也可以:
-
运行测试套件
-
运行代码复杂度检查
通过阻止在检查未通过时创建构建,你正在强制执行一种标准的工作方式,并迫使开发者检查所有有助于创建高质量产品的细节。
代码完成
一旦我们的质量检查通过,我们希望查看部署应用程序。同样,我们有机会将一些实践规范化,以确保你的团队能够一次又一次地执行相同的操作。
让我们制定一个典型的流程,代表将应用程序部署到生产环境:
-
质量检查,如测试和编码标准。
-
压缩和其他优化。
-
提高应用程序版本号。
-
推送到生产服务器。
第一步是由我们的before-init步骤处理的。压缩和优化由 Sencha Cmd 的内置任务处理。我们剩下三个任务需要在推送到生产之前完成,所以我们将依次查看它们,但首先,让我们有一个简短的休息,并谈谈 Ant。
Ant 的应用程序
实际上,接下来的几页是 Ant 的教程,而不是 Sencha Cmd 或应用程序架构。网上和印刷形式中有很多关于 Ant 的资源,那么为什么还要重复旧的内容呢?
记住,这本书不是事实和数字的列表或逐行输入的代码列表。它旨在从自上而下的角度思考应用程序,并了解你如何帮助你的团队为客户构建一个强大的产品。
一个建筑师不仅仅是为了画房子的图片。他们是为了确保建造了一座美丽的房子,房主离开时感到高兴。
版本号
返回 Ant。你想要用构建或版本号标记应用程序的多个原因之一是让利益相关者知道他们正在审查的版本,以查看它是否包含他们预期的错误修复。
应用程序版本化是一个两步过程:
-
生成一个新的构建号。
-
将其插入 JavaScript 文件以在 UI 上显示。
Ant 提供了一个任务,使第一步变得相当简单:
<propertyfile file="app.properties">
<entry key="build.number" type="int" operation="+" value="1"/>
</propertyfile>
我们使用propertyfile任务指定一个名为app.properties的文件将包含一个名为build.number的条目。每次我们运行任务时,它都会触发一个操作,将thisentry增加一,如下所示:
<property file="app.properties"/>
<replace file="${build.classes.file}" token="{VERSION}" value="${build.number}"/>
接下来,我们读取app.properties文件,了解如何使用属性任务,该任务使它包含的属性可用于后续任务。
最后,我们在生成的 JS 文件中对{VERSION}令牌进行搜索和替换,并将其替换为build.number属性。让我们看看完整的build.xml:
<?xml version="1.0" encoding="utf-8"?>
<project name="MyApp" default=".help">
<import file="${basedir}/.sencha/app/build-impl.xml"/>
<target name="-after-page">
<propertyfile file="app.properties">
<entry key="build.number" type="int" operation="+" value="1"/>
</propertyfile>
<property file="app.properties"/>
<replace file="${build.classes.file}" token="{VERSION}" value="${build.number}"/>
</target>
</project>
注意,我们正在使用after-page目标作为钩子。这个钩子在 Sencha Cmd 组装完所有应用程序的依赖项并创建一个包含它们的单个文件之后触发。这是我们进行搜索和替换的文件,这意味着我们的原始源文件保持完好。你可以有一个像这样的 JavaScript 文件:
// app/Application.js
Ext.define('MyApp.Application', {
extend: 'Ext.app.Application',
name: MyApp',
version: '{VERSION}'
});
此外,{VERSION}令牌将被替换,使你能够在整个应用程序中使用版本号,也许在页脚或关于屏幕上。
从发布到生产
我们的代码整洁有序,我们知道我们正在发布哪个版本。下一步是将它推送到生产服务器。我们将使用 SFTP 将文件传输到远程服务器:
<target name="-after-build">
<input
message="Please enter SFTP username:"
addproperty="scp.user" />
<input
message="Please enter SFTP password:"
addproperty="scp.password" />
<scp remoteTodir="${scp.user}@sftp.mysite.com:/path/to/myapp/dir" password="${scp.password}">
<fileset dir="build/production"/>
</scp>
</target>
我们使用afterbuild目标,这意味着构建的所有其他方面都已完成,最终的生产文件也已构建。由于硬编码安全凭证是一个非常糟糕的主意,我们使用输入任务从命令行请求用户输入。结果输入被分配给addproperty中指定的属性。
scp任务的remoteToDir属性应根据您的需求进行定制,但scp.username和scp.password的值将填充之前用户输入的内容。在fileset任务中,我们指定将整个build/production目录推送到远程服务器。
我们已经展示了如何利用 Ant 的力量钩入 Sencha Cmd 构建过程的关键方面,将容易出错的手动任务转换为可以轻松与开发团队共享的自动化任务。
其余最佳选择
我们只是触及了 Sencha Cmd 能做什么的表面。我们从一个高度感兴趣的架构师的角度来看 Sencha Cmd,但架构师需要对其开发者可用的完整工具栈有强烈的认识。让我们快速浏览一下 Ext JS 开发者可以使用的一些功能,以简化他们在应用程序细节上的工作。
包
包是在项目之间重用代码的一种方式。Sencha Cmd 和 Ext JS 将包视为 Sencha 生态系统中的具体概念,因此开发者应该意识到它们可供使用。我们之前讨论的工作空间概念有助于包的开发,但它们可以在应用级别进行消费,为应用提供一个整洁的 CSS、JavaScript 和其他资源的捆绑包,以便从本地或远程源使用。
注意
在docs.sencha.com/cmd/5.x/cmd_packages/cmd_creating_packages.html的 Sencha 文档提供了创建包的说明。
在企业层面,包是提供可重用逻辑和用户界面元素的关键方法,这些元素可以在团队之间共享。代码重用需要在架构层面考虑,以避免重复造轮子,因此当考虑大局时,包可以是一个重要的工具。
主题
在 Ext JS 中处理 CSS 和图像有两种方法:首先是有完整主题选项,您创建一个包含图像、Sass 文件和 JavaScript 定制的包,为您的应用程序构建一个完全定制的视觉和感觉。在许多情况下,只需要对标准 Ext JS 组件进行一些微调,以及为您的 UI 元素添加一些额外样式就足够了。在这种情况下,Sencha Cmd 提供sencha compass compile将 Sass 文件转换为包含在您的应用程序中的 CSS。别忘了sencha app watch也会自动处理这一步骤。
编译
命令的构建过程依赖于一个名为 compile 的子命令,该子命令负责解析构成您应用程序的文件。这个子命令可以独立调用,并且可以用来创建一个应用程序依赖项列表,该列表可以被 Sencha Cmd 以外的工具进一步处理。
在更大的 JavaScript 生态系统内,开发者可能更熟悉的构建工具(如 Grunt 和 Gulp)的数量正在增长。通过使用 Sencha Cmd 的一小部分功能,您的团队可以在使用 Ext JS 框架的同时继续利用这些工具。
摘要
标准和流程是软件架构师的关键职责,而 Sencha Cmd 是帮助履行这些职责的不可或缺的工具。我们看到了它如何触及应用程序生命周期的各个部分,从快速使用模板启动到将开发代码库的各个部分连接起来,再到创建一个最终优化的产品。
不仅如此,Sencha Cmd 提供的各种钩子提供了灵活性。它与您和您的团队合作,帮助简化开发过程并节省本可以浪费在手动任务上的时间。
在接下来的几章中,我们将探讨如何将我们迄今为止关于 Ext JS 应用程序架构所学的所有内容拼凑起来,并使用一些实际例子进一步展示如何为客户构建出色的产品。
第五章:实践 - 一个 CMS 应用
在前面的章节中,我们探讨了应用架构的理论方面以及我们将使用的工具来支持我们。我们已经回顾了设计模式,以及我们应用的结构化方法。现在,是时候了解这些如何组合在一起以创建一个结构良好的应用了。
在本章中,我们将创建一个内容管理系统(CMS)的基本用户界面。虽然许多企业将使用现成的 CMS(如 Joomla!或 Drupal),但企业间需求的巨大差异意味着定制内容管理系统是一个相当常见的项目。
这也是一个具有欺骗性的复杂提议。由于业务特定的需求范围很广,基本的 CMS 可以迅速变成一个复杂的应用,具有针对特定问题的模块和界面元素。这使得确保基础得到妥善处理变得尤为重要,例如代码结构和命名约定。
反映这一点,我们只会创建一个基本的入门级 CMS。我们将:
-
设计数据结构和相应的 Ext JS 模型和存储实现
-
构建完整的类结构并映射它们之间的交互
-
为一些更复杂的交互绘制伪代码
-
将我们的设计扩展到完整的实现
这也是接下来几章的一般模式。在本章中,我们的应用将包含:
-
网站结构的分层树形视图
-
搜索功能
-
显示页面详情的表单面板
-
创建、更新、读取和删除功能
到本章结束时,我们将使用 Ext JS 5 的 MVVM 架构将我们的知识应用于一个基本的现实世界应用,以使我们的代码结构整洁且易于理解。
内容管理系统
我们将生产一个基本但可用的 CMS 实现,它使用我们已经接触过的某些 Ext JS 架构概念,例如视图模型和视图控制器、事件监听器和数据绑定。以下是期望的最终产品:

架构 CMS:我们的第一个示例应用
在我们达到这个点之前,我们需要审查应用的需求,设计其各种应用层,并提出一个设计。只有这样,我们才能开始谈论代码层面。让我们从检查客户的请求开始。
需求
我们从客户那里收到两个项目概要文件:一个线框图和一组需要满足的标准。线框图是我们应用的大致布局,如下所示:

“架构 CMS”的线框图
立刻,我们知道这是一个非常简单的单页应用,具有树形视图、编辑面板和搜索栏。标准如下:
-
提供 REST 后端 API
-
显示所有页面的树形视图
-
搜索功能,将在树形视图中突出显示匹配的页面
-
所需的 HTML 编辑器
-
必须可定制的 URL
-
可以发布或取消发布的页面
-
用户必须能够创建、查看、更新和删除页面
让我们以 Ext JS 的角度来评估这些准则。首先,我们知道 REST 支持通过Ext.data.proxy.Rest在 Ext JS 中可用,我们可以通过我们的模型和存储来使用它。然而,请注意,第二点需要一个表示层次结构的树形视图。虽然 Ext JS 提供了一个TreePanel组件,并且有一个专门的TreeStore来处理层次数据,但通过 REST API 加载这种嵌套数据可能存在一些复杂性。
小贴士
在这一点上,我们可以与我们的团队协商,甚至编写一些原型代码来调查这个 REST 问题,然后再全面开始开发。在前进之前解决任何不确定性是很重要的。
在澄清了这一点之后,我们现在来看一下搜索的要求。我们知道Ext.data.NodeInterface,这个类为TreePanel中的节点提供动力,有一个findChildBy方法,允许我们从根节点遍历树,并在找到所需内容时执行任意操作。
客户指定需要一个 HTML 编辑器,这是可以接受的,因为 Ext JS 自带了一个功能齐全的 WYSIWYG HTML 编辑器,位于Ext.form.field.HtmlEditor。对于 URL 定制,我们不需要做任何特别的事情,只需意识到客户要求在编辑时包含此字段;同样,发布/取消发布标志也是如此。
最后,我们知道了客户希望在页面上执行的操作,这决定了我们的应用程序如何与现有的后端交互。鉴于我们可用的 REST API,通过Ext.data.Model的实现,支持所需的创建、读取、更新和删除(CRUD)操作应该是微不足道的。
接受挑战
在我们将客户的准则与我们可用的工具进行了审查之后,我们可以自信地接受摆在我们面前的挑战。如果不进行充分的客户需求尽职调查,我们可能会在没有 100%确定能否完成的情况下开始一个项目,这可能会危及项目的成功,并浪费宝贵的时间和金钱。
从底部开始
客户表示我们有一个 REST API 可用。实际上,他们还有其他需要在上面构建的 Ext JS 应用程序,因此我们很幸运。数据以 JSON 格式返回,可以很容易地被Ext.data消费。客户提供了关于 API 如何操作的文档:
API Endpoint: http://localhost:3000
GET /pages
Accepts: n/a
Returns: [{ id: 1, text: 'label', children: [] }, { id: 2, text: '', children: [] }]
PUT /page
Accepts: {"published":true,"stub":"our-work","body":"Our Work.","id":"5e30c0a3-729a-4719-a17f-7e2286576bda"}
Returns: {"success":true}
POST /page
Accepts: {"label":"New Page","text":"New Page","leaf":true,"id":"Unsaved-1","parentId":"5e30c0a3-729a-4719-a17f-7e2286576bda","published":true,"stub":"new-page","body":"A New Page."}
Returns: [{"clientId":"Unsaved-1","id":"2ae28c61-cc6e-4a98-83ee-f527f4b19f1e","text":"New Page","body":"A New Page.","published":true,"stub":"new-page","leaf":true}]
DELETE /page
Accepts: {"id":2}
Returns: {"success":true}
在这一点上,你的开发者将会跳一小段舞,因为不仅你有文档,而且 API 非常直接,只支持少数操作。
我们的数据实现部分现在变得简单了。我们知道我们想要实现一个树形视图。从/pages返回的数据已经以具有子数组和 ID 及文本属性的正确格式进行了格式化。我们只需要一个模型来表示一个页面,所以它在伪 UML 中看起来可能像这样:
ArchitectureCms.model.Page: extends Ext.data.TreeModel
- id
- stub
- published
- body
- [] children
然后,我们将有一个非常简单的存储来收集这些模型:
ArchitectureCms.store.Pages: extends Ext.data.TreeStore
店中没有挂载任何自定义逻辑,所以这实际上就是我们的完整定义,尽管我们知道我们的实现将配置为使用我们的ArchitectureCms.model.Page。
数据层是构建一切的基础。尽管我们为这个层的设计在这个应用中非常简单,但写下来还是有价值的,以防我们遇到任何明显的问题。现在我们可以看看这些数据类将如何与用户界面和应用程序其余部分的粘合类交互。
逐步提升
控制器是绑定应用程序的粘合剂;通常再次查看我们的线框图并分解代表视图类并需要控制器来协调其动作的方面是有用的。基本线框图可以在以下屏幕截图中看到:

线框图分解:黄色、绿色和蓝色都被识别为独立的视图
在这个应用程序中,由于我们有一个非常直接的布局和组件之间的交互集,我们可以使用一个非常简单的架构。
虽然在应用程序的早期阶段创建一个强大的起始结构很重要,但你应该始终努力构建一个设计清晰且不包含只是为了以防万一而添加的类的结构。
小贴士
你不会需要它(YAGNI)是某些软件开发者中流行的术语,他们认为少即是多——不要基于对你可能需要的某些遥远未来的假设来编写代码。相反,随着每一次添加迭代你的架构,并像在项目开始时一样对这些添加给予足够的关注。
在 Ext JS MVVM 架构中,顶级控制器用于调解其他控制器之间的交互。在这里,我们选择只创建一个控制器(一个名为Main的视图控制器),它将协调其命名空间内所有视图的动作。
让我们看看这些新类如何协同工作,以及它们如何与我们的数据层相关联:

上述图表清楚地显示了数据如何通过我们的应用程序流动(从与客户端 API 交互的数据层到控制器,然后通过视图模型下降到视图)。现在我们可以通过命名它们并指定它们的方法和属性来具体化这些类:
ArchitectCms.view.main.MainController: extends Ext.app.ViewController
- onAddClick
- onDeleteClick
- onSaveClick
- onPageSelect
ArchitectCms.view.main.PageModel: extends Ext.app.ViewModel
- pages
- currentPage
- isUnsavedPage
- searchTerm
ArchitectCms.view.main.Main: extends Ext.panel.Panel
ArchitectCms.view.main.Detail: extends Ext.form.Panel
ArchitectCms.view.main.Tree: extends Ext.tree.Panel
- searchFor
让我们稍微分解一下,并讨论我们为什么以这种方式设计应用程序的原因。
细节决定成败
很明显,控制器是大多数有趣事情发生的地方,但重要的是要记住保持你的控制器精简。如果你发现自己有很多方法,那么这是一个很好的迹象,表明你需要另一个控制器——寻找可能分离开的逻辑位置。
在我们的案例中,应用的未来迭代可能需要为树和详细面板分别设置视图控制器,以及一个总控制器以实现两者之间的通信。然而,目前我们并不需要这个。在我们的 MainController 类中,只有四个方法将处理来自视图的操作。
小贴士
控制器的作用是支持其他一切。首先关注你的数据,然后是视图,并使用控制器将它们连接起来。因此,确定你的视图将要触发哪些事件,控制器基本上就会自己写出来——它所做的只是处理这些事件并将艰苦的工作传递到其他地方。
这是一个很好的机会,暂时将实现细节放在一边,思考如果这些类被设计成让我们的生活更轻松,它们会是什么样子。
例如,名为 PageModel 的视图模型有一个名为 isUnsavedPage 的方法,它允许你确保用户在保存新页面之前不会导航离开,从而确保他们不会丢失任何数据。
在一开始就采用这种设计方式,使我们能够思考所有那些构成良好用户体验的出色功能,而无需担心实现这些功能的代码。当然,每种情况都是不同的。我们需要确保我们不会让我们的想象力失控,开始梦想那些不必要的功能!
关于 Tree 类上的 searchFor 方法,有一些简短的讨论。在控制器中,我们汇集了我们应用的一些部分,并将实际工作交给它们,而不是控制器本身。这正是我们在这里所做的事情。你可以用与在 Ext.tree.Panel 基础上使用 expandPath 方法相同的方式思考这个方法——这是一个作用于树接口而不与其他任何东西交互的方法。它的逻辑位置是作为树 UI 的增强。
野性的地方在哪里
我们有自己的设计,所以让我们稍微靠近一点,检查一下应用中可能需要更多细节的部分。例如,我们有一个名为 onAddClick 的控制器方法,它将处理添加新记录的过程,但实际会涉及哪些内容,以及其中是否隐藏着任何痛点?当这个处理程序完成其工作时,需要发生以下情况:
-
向用户请求新页面的名称
-
创建一个新的空白记录,具有默认值和页面名称
-
将页面作为当前页面的子节点添加
-
在详细面板中加载记录
-
在树中显示新记录
单个控制器动作包含的内容很多。让我们看看我们如何编写它,以确定我们是否试图做太多。我们将编写一些伪代码(假代码)来深入细节:
newPageName = promptUser 'Please enter a page name'
newPageModel = new Page {
text = newPagename
}
pageTree.addAndSelect newPageModel
这里没有使用 JavaScript,也没有使用 Ext JS 类。我们只是在编写我们希望在没有任何语言或框架限制的情况下能够编写的代码。鉴于这一点,这段代码看起来不错——它清楚地表明了正在发生什么,我们也没有做太多。
有一点需要注意,Ext.panel.Tree没有原生的addAndSelect方法。我们需要编写这个方法,但如果它使我们的控制器代码更简洁、更短,那么这很好。
锐利且复杂
在软件开发中有一个普遍的真理,那就是代码的阅读难度大于编写难度。在没有理解背后的推理的情况下理解他人的代码可能会很困难。话虽如此,代码的难度在于它有点复杂、有点令人恐惧——这种代码没有通过注释、变量命名或方法命名来明确其意图,而代码则考虑到了未来的维护者。
在编写伪代码时,我们试图确保我们的代码背后的概念在真正开始工作之前就已经得到了充分的阐述,并且任何困难都在我们真正开始工作之前得到了解决。
在复杂的情况下,伪代码可能不足以解决问题。我们将不得不编写一些真正的代码,形式为尖峰。在《更好的 Smalltalk 指南:有序集合》中,SIGS谈到了这一点:
*"有时我称之为“尖峰”,因为我们正在整个设计中钻一个尖峰。[…]
在创建尖峰时,我们正在克服任何假设,并在一个小型原型(我们能够构建的最小代码片段或应用程序)上测试我们的设计决策,以证明我们的想法。
通过消除进一步的未知因素,这巩固了我们的设计。我们可以确信 UI 组件将支持我们所需的功能,因为我们已经在实际示例中对其进行了测试。如果它是架构代码尖峰,我们可以看到我们的设计元素是否以一种“感觉正确”的方式组合在一起,如果它在使用的框架中工作,以及选择的设计模式。
我们可以对之前描述的addAndSelect方法进行尖峰测试,但我们知道Ext.tree.Panel已经有一个add方法,并且底层的selectionModel将允许我们标记一个节点为选中状态。因此,现在我们已经通过伪代码缓解了我们的担忧,就没有必要继续编写真正的代码,直到我们实现了真正的功能。作为在时间和金钱限制下工作的开发者,我们需要务实,只要我们确信已经完成了尽职调查。
真正的事情
我们已经设计了数据层和 UI 层,以及连接这两个层的粘合剂,并解决了可能引起麻烦的客户需求剩余部分。我们现在可以开始在我们的首选文本编辑器中敲击键盘,展示如何在 Ext JS 中实现设计。
数据绑定简短中断
随着 Ext JS 中视图模型的引入,数据绑定的概念也得到了突出。简而言之,数据绑定将一个值绑定到另一个值。当第一个值更改时,第二个值会自动更新。双向数据绑定意味着当任一值更改时,另一个值会相应地更新。
总体而言,Ext JS 通过视图模型实现了这个想法。一个 UI 组件可能将其标题绑定到一个值上,当这个值被应用程序的另一个部分更新时,标题会自动更改。这消除了开发者设置更改事件的必要性,并确保数据在整个应用程序中保持一致性。
在这个示例应用程序以及我们所有的实践章节中,我们将大量使用数据绑定。在许多情况下,一点点的绑定配置可以消除大量的样板事件连接,因此我们将充分利用这一点。
创建结构
使用我们之前章节的知识,我们将使用 Sencha Cmd 创建一个应用程序骨架,并将其作为我们工作的基础。现在我们已经熟悉这个过程:
sencha generate app -ext ArchitectureCms ./architecture-cms
通过一个简单的命令,我们就可以使用模板启动并运行。让我们启动一个网络服务器并查看代码中的变化:
cd architecture-cms
sencha app watch
现在,我们可以启动一个网络浏览器并导航到 http://localhost:1841 来查看模板的实际效果。我们不希望有任何生成的示例代码。因此,我们可以使用以下命令将其删除:
rm app/view/main/*
现在,我们已经有一个干净的目录结构,我们可以在此基础上构建我们的内容管理系统。
数据驱动设计
与我们首先查看数据层来设计应用程序的方式相同,我们将首先编写 Ext JS 模型和存储代码。以下是模型,我们将逐步构建它来解释代码背后的思考过程:
Ext.define('ArchitectureCms.model.Page', {
extend: 'Ext.data.TreeModel',
fields: [
{ name: 'body' },
{ name: 'stub' },
{ name: 'text' },
{ name: 'published' }
]
});
让我们回顾一下这个类的设计。我们定义了与之前相同的字段,除了子字段,这是一个特殊情况,因为我们正在使用 Ext.data.TreeModel。
当然,这不足以驱动一个真实的 Ext JS 模型。这就是设计现在与实现不同的地方。让我们将模型连接到客户的 API:
Ext.define('ArchitectureCms.model.Page', {
extend: 'Ext.data.TreeModel',
fields: [
{ name: 'body' },
{ name: 'stub' },
{ name: 'text' },
{ name: 'published' }
]
});
哇!设计基本上是语言无关的,但实现现在展示了非常 Ext JS 特定的配置选项。配置模型有两种方式。一种是通过其代理进行配置,另一种是通过其模式进行配置。代理配置运行良好,但在大型应用程序中,模式可以在模型之间共享,并提供一个中心位置来配置基本 API URL 和特定模型的获取路径。
由于这个原因,我们将从使用模式开始,尽管在这个应用程序中我们只处理一个模型。让我们看看各种配置选项:
-
namespace:这是模型类名中表示命名空间的段。这意味着 Ext JS 可以移除完整类名中的命名空间部分,只留下模型,然后它可以使用它来自动构建 URL。在这种情况下,我们将命名空间设置为ArchitectureCms.model,这使得 Ext JS 能够推断出模型名称只是Page。我们稍后会用到这个。 -
urlPrefix:这通常是主机名或与特定资源路径结合使用的 API 端点。 -
proxy.type:这是代理的类型,在处理服务器时很可能是ajax或rest。我们知道我们的客户有一个 REST API,所以它被设置为rest。 -
proxy.url:它使用所有前面的选项来构建一个 URL。花括号中的段将按顺序替换,以构建一个完整的 URL 到正在消耗的资源。{prefix}是上面的urlPrefix,{entityName:uncapitalize}是从类名中解析出的模型名称,没有命名空间,且为小写。
哇!到目前为止,我们已经深入探讨了 Ext JS 的配置选项。这一章,以及整本书,本应关于架构。所以从现在开始,有些情况下我们会跳过这类细节,假设你已经使用过 Ext JS 并且理解这些配置选项。
我们正在尝试设计这个应用程序;我们不是在教 JavaScript 或 Ext JS。尽管如此,我们会看看 Ext JS 框架的各个方面,这些方面有助于成功应用,但我们不会重复 Sencha 文档的内容。考虑到这一点,让我们对我们的模型添加更多内容,并讨论它是如何帮助我们满足客户需求的:
Ext.define('ArchitectureCms.model.Page', {
extend: 'Ext.data.TreeModel',
clientIdProperty: 'clientId',
identifier: {
type: 'sequential',
prefix: 'Unsaved-'
},
schema: {
namespace: 'ArchitectureCms.model',
urlPrefix: 'http://localhost:3000',
proxy: {
type: 'rest',
url: '{prefix}/{entityName:uncapitalize}'
}
},
fields: [
{ name: 'body' },
{ name: 'stub' },
{ name: 'text' },
{ name: 'published' }
]
});
这是 Page 类的最终迭代版本,现在已配置了标识符。我们知道我们需要区分已保存和未保存的模型,也知道如果提供,服务器将返回 clientId,所以在这里我们明确指出,ID 将包含 Unsaved- 字符串,直到服务器提供一个自动递增的标识符来替换它。
模型存储
这个应用程序的存储相当简单:
Ext.define('ArchitectureCms.store.Pages', {
extend: 'Ext.data.TreeStore',
model: 'ArchitectureCms.model.Page',
alias: 'store.pages',
root: {} // set empty root as using bind doesn't do this
});
这里的一切都是不言自明的,尽管有一个注意事项。在当前版本的 Ext JS(5.0.1)中,我们需要设置一个空的根节点,以便你可以使用数据绑定将此存储绑定到 UI 组件。如果不这样做,将会抛出错误,所以这是一个简单的解决方案。
有景观的房间
我们提到,设计你的应用程序(从数据层开始,然后移动到视图)是一个好主意,这样更容易理解控制器将必须处理的交互。当从设计到代码时,同样适用,因此我们将为这个应用程序编写用户界面,然后稍后通过控制器将其连接到数据。
首先,我们需要一个视口。在 CMS 中我们只有一个页面,所以视口是所有单个子视图(如树形图和详细面板)的容器。这个应用程序相当专注,因此我们将所有的视图和关联的类放在ArchitectureCms.view.main.*命名空间下。下面是ArchictureCms.view.main.Main视口的代码:
// app/view/main/Main.js
Ext.define('ArchitectureCms.view.main.Main', {
extend: 'Ext.panel.Panel',
requires: [
'ArchitectureCms.view.main.Detail',
'ArchitectureCms.view.main.Tree'
],
session: true,
controller: 'main',
viewModel: 'page',
title: 'Architect CMS',
bind: { title: 'Architect CMS - Currently Editing "{currentPage.text}"' },
layout: 'border',
items: [
{ xtype: 'page-detail', region: 'center', reference: 'detail' },
{ xtype: 'page-tree', region: 'west', width: 300, reference: 'tree', split: true }
]
});
这部分主要是直截了当的(我们扩展Ext.Panel而不是Ext.Container以提供标题栏的支持)。接下来,我们需要在视口中使用视图类。
session选项设置为true。我们稍后会详细讨论这个问题。
视图控制器和视图模型通过它们的别名指定;我们稍后会创建这些类。Sencha Cmd 知道这些是“自动依赖”,因此我们将自动加载它们,而无需在requires数组中包含它们。
我们创建了一个默认标题,即Architect CMS,但在下一行,我们首次使用了bind选项。让我们分析一下这里发生了什么。我们已为这个类指定了一个视图模型,并且总是必须绑定到视图模型中的某个值。不仅如此,bind选项仅在视图模型值变化时触发,这就是为什么我们需要通过标题配置指定一个默认值。对于绑定配置,我们指定我们想要绑定的值(在这种情况下是标题),然后提供一个绑定表达式。这里它只是一个字符串。花括号内的部分决定了要绑定到视图模型上的值。稍后,我们将查看currentPage.text并看到它是如何设置的,但现在的关键是意识到当这个值变化时,它会被纳入标题的值。我们会看到类似这样的:

注意,这将在不设置任何事件处理器的情况下发生。这是一点点魔法,可以减少我们必须编写的样板代码。
接下来,我们指定一个边框布局,然后将树形图和详细面板填充到项目数组中,通过它们的xtype引用。多亏了我们对requires选项的配置,Ext JS 已经知道这些类,因此我们可以使用别名作为简写。
除了绑定配置和一些自动加载的魔法之外,这里没有特别之处。从应用设计的关键来看,是引入了与视图模型和视图控制器关联的绑定概念。希望我们已经展示了如何几乎不添加额外代码的情况下引入这些想法。
树面板和搜索
现在我们已经得到了我们的视口容器,我们可以介绍视图本身。首先,我们将查看显示页面层次结构的树的代码:
// app/view/main/Tree.js
Ext.define('ArchitectureCms.view.main.Tree', {
extend: 'Ext.tree.Panel',
xtype: 'page-tree',
rootVisible: false,
tbar: [
{ xtype: 'textfield', emptyText: 'Search...', width: '100%', bind: { value: '{searchTerm}'}}
],
bind: { store: '{pages}', searchFor: '{searchTerm}' },
config: {
searchFor: null
},
applySearchFor: Ext.emptyFn
});
更多绑定表达式!一个重要的事情要意识到的是,在高级组件上声明的视图模型,在这种情况下是我们的ArchitectureCms.view.main.Main视口,将级联下来并可供子组件使用。这意味着我们的树中的绑定表达式将引用分配给主视口的Page视图模型。我们通过在树中使用绑定来满足哪些客户需求?
我们希望能够在树中搜索一个页面并高亮显示。为此,当我们输入textfield时,值必须传递给树。传统的方法是监听textfield上的变化事件或keypress,然后在树上触发search方法。而不是手动这样做,我们可以通过视图模型的数据绑定来实现相同的效果:

数据通过视图模型在 UI 组件之间流动
视图模型上的searchTerm值可以在树的searchFor配置和textfield的值之间来回流动。然而,在这种情况下,它只有一个方向(从textfield到树)。
此外,我们告诉树绑定到视图模型上的页面值;我们知道我们将在某个地方需要页面列表。
这个谜题中缺失的部分是实际在树上执行搜索的部分。多亏了 Ext JS 配置系统,任何指定的config选项也会在类实例上创建一个applyConfigName方法,并且每次config选项更改时都会调用它。这意味着通过在树上创建applySearchFor,每当searchFor通过其绑定更新时,我们就可以运行一段代码来处理新值。
注意,我们在最后一个代码片段(Ext.emptyFn部分)中放置了一个函数占位符。以下是我们要在这里使用的实际代码:
applySearchFor: function(text) {
var root = this.getRootNode();
var match = root.findChildBy(function(child) {
var txt = child.get('text');
if(txt.match(new RegExp(text, 'i'))) {
this.expandNode(child, true, function() {
var node = this.getView().getNode(child);
Ext.get(node).highlight();
}, this);
}
}, this, true);
}
简而言之,使用正则表达式对搜索词与每个树节点文本进行不区分大小写的匹配。如果找到匹配项,则展开树到这一点并调用其highlight方法以产生视觉提示。
页面详情
树用于浏览 CMS 中的树结构,因此我们现在需要一种查看每个页面细节的方法。详情面板是一个包含多个表单字段的面板:
Ext.define('ArchitectureCms.view.main.Detail', {
extend: 'Ext.form.Panel',
xtype: 'page-detail',
defaultType: 'textfield',
bodyPadding: 10,
hidden: true,
bind: {
hidden: '{!currentPage}'
},
items: [
{ xtype: 'container', cls: 'ct-alert', html: 'This record is unsaved!', bind: { hidden: '{!isUnsavedPage}' } },
{ fieldLabel: 'Id', bind: '{currentPage.id}', xtype: 'displayfield'},
{ fieldLabel: 'Published', bind: '{currentPage.published}', xtype: 'checkboxfield' },
{ fieldLabel: 'Label', bind: '{currentPage.text}' },
{ fieldLabel: 'URL Stub', bind: '{currentPage.stub}' },
{ fieldLabel: 'Body', bind: { value: '{currentPage.body}' }, xtype: 'htmleditor' }
],
bbar: [
{ text: 'Save', itemId: 'save' },
{ text: 'Add Child Page', itemId: 'addChild' },
{ text: 'Delete', itemId: 'delete' }
]
});
每个表单字段都有一个绑定表达式,它将字段值绑定到视图模型中currentPage对象的值。当用户更改字段时,视图模型将自动更新。请注意,我们不必明确指定要绑定的属性,因为表单字段已将defaultBindProperty设置为value。
整个表单面板的隐藏值绑定到currentPage,因此如果此值未设置,则面板将被隐藏。这允许您在未选择页面时隐藏表单。我们还在面板的第一项中有一个警告消息,当视图模型的isUnsavedPage值更改为false时,该消息将被隐藏。
我们在 UI 配置之外只编写了一小部分代码,但随着视图模型的添加,我们已经有了一个包含搜索功能并与详细面板关联的填充树面板。接下来,我们将查看视图模型代码本身。
魔法的页面视图模型
此视图模型使用一个简单的公式向视图提供计算值:
// app/view/main/PageModel.js
Ext.define('ArchitectureCms.view.main.PageModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.page',
requires: ['Architecture.store.Pages'],
stores: {
pages: {
type: 'pages',
session: true
}
},
formulas: {
isUnsavedPage: function(get) {
return get('page.id').toString().indexOf('Unsaved-') > -1;
}
}
});
考虑到这个类所启用的功能,代码非常少。存储定义相当直观,只需使用ArchitectureCms.store.Pages别名来指定视图模型有一个由这个存储支持的页面值。
公式定义有点更有趣。这是一种声明基于视图模型中的其他值返回值的声明方式。在这种情况下,正如我们在模型中指定的新创建的记录将使用Unsaved-前缀一样,我们可以查找这个前缀来确定记录是否已保存到服务器。因此,isUnsavedPage根据记录的 ID 是否包含此前缀返回true或false。
这里唯一缺少的是currentPage值。我们可以在视图模型上设置任意值。因此,这个值在其他控制器中被设置。在我们讨论这个之前,让我们回到讨论 Ext JS 5 中的新概念:Ext.data.Session。
此数据现在在会话中
Ext.data.Session是一种在应用程序中集中数据的方式,确保存储与同一组数据工作,从而避免重复加载数据。它还允许更轻松地进行批量更新和删除。
在我们的应用程序中,我们在顶级视图中设置session,将其设置为true,这告诉 Ext JS 自动创建一个会话并将其提供给任何请求它的其他代码。这是构建会话的最简单方法,尽管如果需要,我们还可以进行很多自定义。
我们在这个应用程序中使用会话的原因是允许我们将树和详细面板使用的数据链接起来。这也有助于数据绑定;我们可以在树和详细面板中使用完全相同的模型实例,这意味着在详细面板中进行的更新将通过视图模型流入树中的正确页面实例。当我们稍后查看我们的视图控制器时,我们将更多地使用会话,并了解它如何帮助管理您的数据。
控制所有内容的粘合剂
在前面的章节中,我们探讨了控制器如何使用事件域来挂钩应用程序中其他地方发生的任何有趣事件。在这里,我们使用相同的方法,我们之前讨论过,让控制器连接到一系列事件处理程序来处理用户界面中的用户操作:
// app/view/main/MainController.js
Ext.define('ArchitectureCms.view.main.MainController', {
extend: 'Ext.app.ViewController',
alias: 'controller.main',
requires: ['ArchitectureCms.model.Page'],
init: function() {
this.listen({
component: {
'treepanel': {
'select': 'onPageSelect'
},
'page-detail #save': {
click: 'onSaveClick'
},
'page-detail #addChild': {
click: 'onAddClick'
},
'page-detail #delete': {
click: 'onDeleteClick'
}
}
});
},
onPageSelect: function(tree, model) {
this.getViewModel().setLinks({
currentPage: {
type: 'Page',
id: model.getId()
}
});
},
onAddClick: function() {
var me = this;
Ext.Msg.prompt('Add Page', 'Page Label', function (action, value) {
if (action === 'ok') {
var session = me.getSession(),
selectedPage = viewModel.get('currentPage'),
tree = me.lookupReference('tree');
var newPage = session.createRecord('Page', {
label: value,
text: value,
leaf: true
});
selectedPage.insertChild(0, newPage);
tree.setSelection(newPage);
tree.expandNode(selectedPage);
}
});
},
onDeleteClick: function() {
var me = this;
Ext.Msg.confirm('Warning', 'Are you sure you'd like to delete this record?', function(btn) {
if(btn === 'yes') {
me.getViewModel().get('currentPage').erase();
me.getViewModel().set('currentPage', null);
Ext.toast('Page deleted');
}
}, this)
},
onSaveClick: function() {
this.getViewModel().get('currentPage').save();
Ext.toast('Page saved');
}
});
这个视图控制器处理以下四个事件:
-
由
onPageSelect方法处理的树上的select事件 -
详细面板保存按钮的
click事件由onSaveClick处理 -
详细面板添加子按钮的
click事件由onAddChildClick处理 -
详细面板删除按钮的
click事件由onDeleteClick处理
其中一些将是自解释的,一些与数据绑定相关,一些与会话相关。让我们分解重要的部分。
选择页面
当树触发 select 事件时,视图控制器的 onPageSelect 方法会接收到所选树节点的模型。我们之前提到过,我们可以在视图模型上设置任意值,特别是 currentPage 值,所以我们在这里这样做,但有一个转折。
而不是仅仅设置数据,我们通过使用链接配置来给 Ext JS 一个提示,我们想要设置一个模型实例。通过提供模型类的名称及其 ID,Ext JS 将会使用当前 Ext.data.Session 中已存在的匹配实例,如果有的话,或者它会自动从服务器加载它。这是一个方便的快捷方式,可以减少对后端 API 的请求数量,也是如何使用会话的另一个例子。
添加页面
视图控制器监听一个具有项目 ID #addChild 的按钮的事件。当它触发时,我们会询问用户新页面的名称,接下来的步骤是实际创建一个页面记录。我们不是使用 Ext.create,而是在当前的 Ext.data.Session 上调用 createRecord,这允许你继续让 Ext JS 知道我们正在管理的记录。这也允许你维护对已保存和未保存记录的全局理解。这在需要批量更新记录的应用程序中会更有用。
在创建模型实例之后,我们遵循本章前面编写的伪代码,但将其与实际的 Ext JS 方法绑定,并在树 UI 中选择它之前将其添加到树数据结构中。
删除页面
这相当直接(处理 #delete 按钮的 click 事件,然后从视图模型中获取 currentPage)。我们还从视图模型中移除了多余的页面,这样详细面板会自动清除自己,而不是留下一个可编辑的死记录。我们使用 Ext.toast 向用户显示通知。
保存页面
这甚至更简单(处理点击#save 按钮,从视图模型中获取currentPage,然后调用其save方法)。这里没有发生什么特别的事情。唯一需要注意的是,如果这是一个新记录,服务器将响应一个新的 ID 并替换 Ext JS 自动分配的那个。多亏了视图模型上对isUnsavedPage的绑定,这将导致“未保存”消息从详细面板中消失。
摘要
通过我们的首次实际应用,我们将前几章中讨论的理论思想应用于创建一个有用的代码库。从分析客户真正需要看到的内容,到我们能否满足他们的需求进行草图设计,再到通过视图模型及其支持的基础设施应用 MVVM 模式,我们首先构建了应用的大脑部分,而不是代码部分。
在下一章中,我们将创建一个更复杂、更贴近现实世界的应用,但这次我们不必如此详细地讨论数据绑定的基础知识。我们将运用我们不断增长的知识来构建一个更复杂的应用程序,一个日志分析器,它可以被系统管理员用来监控他们的基础设施。这需要我们更多地思考如何设计构成我们第二个应用程序的各个部分。
第六章:实践 - 监控仪表板
现在我们已经开始将所学应用到实际应用中,我们将逐步增加我们构建的项目复杂性。通过设计和创建一个模仿实际客户可能付费的代码库,我们不是编写孤立存在的抽象示例代码。我们正在构建一些可以展示作为软件架构师你可能会面临的一些设计决策的东西。
在本章中,我们将构建一个监控仪表板,可以用来查看应用程序服务器的指标。开发人员和系统管理员将使用此类应用程序来可视化其服务器的性能,并在任何给定时间监控负载。
这样的应用可以用于内部监控,或者它可以作为软件即服务(SaaS)部署,可以被其他用户转售。无论哪种方式,它都将作为视图模型强大功能的绝佳展示;我们将扩展我们已知的内容,并以更高级的方式使用它来塑造我们从服务器接收到的数据。在本章中,我们将涵盖以下内容:
-
设计用户界面
-
从
Ext.data.Model到支持我们 UI 所需的视图模型的数据层设计 -
使用多个视图控制器
-
构建跨视图可重用的组件
-
添加路由以允许用户将应用程序的每一屏添加到书签
-
使用视图模型过滤器来聚焦我们对底层数据的视图
到本章结束时,我们将完善我们已经开始介绍的概念,并引入一些新功能(例如路由)。这些在为应用程序设计用户体验时非常重要。
我们将像设计一个内部程序一样来处理这个问题,这个程序监控我们理论上的软件开发店中的另一个应用程序。虽然这意味着我们不受外部客户需求约束,但遵循我们已学到的所有设计指南仍然很重要。这可能是一个内部应用,但它仍然需要健壮,满足利益相关者的期望,并在未来得到维护。
应用程序设计
这个应用将有几个屏幕来查看被监控应用程序的各种属性。我们将有一个仪表板屏幕,显示正在监控的重要指标概览。然后,对于这些指标中的每一个,我们将有单独的屏幕,允许用户深入挖掘和筛选数据。由于这些屏幕将是同一主题的变体,我们只构建几个来展示概念,但这个应用我们将构建的框架意味着添加更多将变得非常简单。以下截图显示了仪表板选项卡:

网络选项卡如下:

作为用户,我们希望在监控应用程序中看到什么?我们希望一目了然的信息,但同时又能够轻松获取详细信息。我们主要关注的是 Web 请求和数据库查询的响应时间,因此我们希望这两个指标都可用。让我们考虑提供这些信息的用户界面及其可能的外观:

在仪表板选项卡上,我们使用图表来传达趋势信息——关于短期和长期的趋势——这为我们提供了数据的快速查看。前两个图表实时更新以显示平均响应时间,而底部的图表显示历史趋势以供比较。关于这些数据的详细信息如何?
此外,我们在屏幕左侧添加了标签页,允许您在日志类型之间切换。在这个应用程序中,我们除了初始的仪表板视图外,还有SQL和Web,如下面的截图所示:

在屏幕的主要部分,我们有一组控件来过滤数据。下面的图表和网格将根据过滤结果进行更新。这使用户能够查看特定日期范围内的数据。它还使用户能够选择要深入查看的详细类别。
有哪些类别?未来可能会扩展,但以下是当前Web标签页的列表:
-
位置
-
浏览器
-
设备类型
我们在我们的日期范围内显示 Web 请求的信息,以提供更多关于谁访问我们应用程序的洞察。我们是否有很多来自日本的访客?他们是否遇到了无法接受的响应时间?也许我们需要在亚洲某处添加服务器以满足他们的需求。我们是否看到了很多平板用户?我们是否需要改进我们的响应式设计以更好地应对平板屏幕大小?
在 SQL 方面,我们有:
-
查询类型(选择、插入、更新等)
-
最慢的查询
-
查询来源
第一项是基本信息;您可以看到应用程序是读多写少还是写多读少,这将告知您的技术堆栈如何随时间变化。其他两项相辅相成,显示了最慢的查询以及应用程序中哪些页面发出最多的查询。
这些指标有助于提高应用程序的透明度。在某些情况下,它们可能不足以诊断微妙的问题,但它们在显示应用程序和用户行为趋势方面将非常有价值。
需求
我们已经为我们的应用程序确定了理想的用户界面,但这是如何转化为技术要求的?
-
我们希望折线图显示趋势
-
我们希望这些图表能够在接收到新数据时更新
-
我们希望能够选择日期范围,并相应地更新图表和网格
-
我们希望能够选择数据类别,并相应地更新网格
让我们在 Ext JS 的背景下看看这些:
-
Ext JS 图表具有区域、线、散点系列等,因此我们可以以允许您可视化趋势的方式绘制数据。
-
Ext.data.Store的load方法可以接受一个addRecords参数,当设置为true时,将导致新加载的记录被追加到存储中,而不是覆盖现有数据。这将使我们能够向图表提供更新数据。 -
Ext JS 提供了一个日期字段组件,可以链接到视图模型以根据日期范围筛选数据。
-
网格有一个重新配置的方法,允许你在必要时动态地更改网格的列。
看起来不错!
消息已接收并理解
我们已经为这个项目制定了我们自己的标准,但在开始编码之前,我们仍然在明确地列出我们需要的东西,并确保我们的技术框架将支持我们的需求。为什么不直接开始工作,把手指放在键盘上呢?
虽然这不是一个有明确付费客户的工作,但这仍然是需要问责的事情。作为一个专业的开发者,仅仅说“完成时完成”是不够的,因为这种态度不会得到支付你薪水的人的认可。我们必须尽可能详细地规划我们的项目,以便从时间和质量的角度对我们工作的交付有信心。
无论我们为谁构建软件,我们都必须始终努力创造一些稳健的东西,一些能够满足或超出期望的东西。
数据结构
在本章中,我们将做出一个假设,即我们团队有一个友好的后端开发者,他能够以我们需要的格式提供数据。让我们具体说明我们将为驱动我们应用程序的数据提出的要求。
另一个假设是我们在这里寻找趋势和统计数据,因此我们将基本上将日志聚合为更适合用户消费的东西。
实时图表
我们计划在仪表板上有两个“实时”图表(一个用于显示传入的 SQL 查询,另一个用于显示网络请求)。为了使这可行,我们需要一个每秒或大约一秒可以轮询的 URL。这将为我们提供关于过去一秒钟活动数据。类似于这样:
GET /logStream
Accepts: n/a
Returns: [
{
"type":"web",
"subType":"request",
"time":"2014-11-04T12:10:14.465Z",
"ms":10,
"count":5
},
{
"type":"sql",
"subType":"query",
"time":"2014-11-04T12:10:14.466Z",
"ms":17,
"count":34
}
]
对 /logs/all/live 的 GET 请求给我们一个对象数组,每个日志类型一个。如前所述,我们只限制在 SQL 和网络。ms 属性是过去一秒钟发生的操作的平均响应时间。count 属性是发生的操作数量。我们设计这个 API 时考虑到了一点灵活性,所以它可以扩展,例如,将 URL 中的 "all" 替换为 "sql" 以过滤单个日志类型。
历史日志
在我们的应用程序的仪表板和子页面上,我们还需要显示历史数据的图表。在仪表板上,它将是过去 30 天的数据,但在子页面上,可能是一个任意的时间范围。这是我们的 API:
GET /logEntry
Accepts: filter=[{"property":"propertyName","operator":"=","value":"value"}, …]
Returns: [{
"type":"sql",
"subType":"query",
"time":"2014-11-04T12:10:14.466Z",
"ms":17,
"count":34
}, ...]
我们将依赖于Ext.data.Store: remoteFilter功能。当此设置为true时,Ext JS 将延迟过滤到服务器,并将过滤条件作为 JSON 数组传递。我们可以设置任意数量的过滤器,因此为了在日期范围内获取 SQL 数据,我们将传递类似以下内容:
[
{ property: 'type', operator: '=', value: 'sql' },
{ property: 'time', operator: '<=', value: '2014-01-01' },
{ property: 'time', operator: '>=', value: '2014-02-01' }
]
我们那位友好的服务器端开发者会将这些过滤器组合成返回正确响应的东西。
日志统计
除了关于 Web 和 SQL 操作的一般聚合信息外,我们还想在我们的标签页上显示更详细的网格。同样,这些信息将可以根据日期范围以及我们想要查看的信息类别进行筛选:
GET /statistic
Accepts:
filter=[
{ property: 'type', operator: '=', value: 'web' },
{ property: 'category', operator: '=', value: 'location' },
{ property: 'time', operator: '<=', value: '2014-01-01' },
{ property: 'time', operator: '>=', value: '2014-02-01' }
]
Returns: [{"category":"location","label":"Other","percentage":19.9}, ...]
我们再次使用remoteFilter功能,这意味着 Ext JS 将直接将 JSON 过滤器以及之前的type和time参数传递到服务器。这次,我们将添加一个category参数来指定我们想要检索的信息子集——例如,对于 Web 日志是位置,对于 SQL 是查询源。
作为回应,我们得到一个数组,其中包含所选类别中的所有项目以及它们在指定时间段内分配的百分比。
模型行为
我们已经有了我们的 API。这如何转化为我们需要的 JavaScript 数据模型?嗯,我们只需要两个——看看我们刚刚记录的 API 响应——/logs返回一种类型,/statistics返回另一种类型。它们看起来可能像这样:
Instrumatics.model.LogEntry: extends Instrumatics.model.BaseModel
- type
- subType
- time
- ms
- count
这BaseModel究竟是怎么回事?为了在模型之间共享模式配置,我们可以使用一个基模型,其他所有模型都从这个基模型继承。它看起来像这样:
Instrumatics.model.BaseModel: extends Ext.data.Model
- schema
现在,统计模型的如下所示:
Instrumatics.model.Statistic: extends Instrumatics.model.BaseModel
- category
- label
- percentage
percentage字段表示由这个统计数字表示的操作比例。例如,如果category是location且label是Japan,那么百分比可能类似于5 percent(我们 5%的请求来自日本)。这足够灵活,可以用于我们想要查看的所有数据类别。
最后,我们还需要一个用于实时日志流:
Instrumatics.model.LogStream: extends Instrumatics.model.LogEntry
日志流具有与LogEntry模型相同的字段,但我们将其作为一个单独的类,因此其类名可以影响模式配置。我们将在稍后详细介绍。
注意
对于这个理论上的 API,我们很幸运;在这里,我们可以塑造我们的需求。在现实世界中,事情可能不会那么简单,但有一个友好的后端开发者将始终使我们的前端开发者生活变得更加容易。
我们的 API 对我们的数据层产生了强烈的影响。虽然保持简单是很好的——正如我们在这里所做的那样——但重要的是不要将简单误认为是天真或缺乏灵活性。在这种情况下,我们的 UI 组件将愉快地与我们的数据层一起工作,而我们的数据层反过来又与我们的 API 一起工作,无需将任何单个组件强行与其他组件一起工作。
从高处看
我们为我们的应用程序提供了燃料;数据将为我们将要构建的编码引擎带来生命。我们现在需要建立构成这个引擎的控制器和将为我们提供用户界面以控制和可视化的视图。考虑以下截图:

对于标记为1的外部区域,我们有一个主要视图,为其他视图提供容器。这将有一个相应的视图控制器,用于管理主子组件的任何跨应用程序关注点。
对于标记为2的内部部分,我们有仪表板视图,四个图表的容器。其视图控制器将管理顶部两个图表的实时更新。
每个子页面都会添加一个额外的视图(见3),例如,一个Web视图和相关视图控制器。它将展示并控制历史日志图表、统计网格以及用户输入到过滤日期字段和按钮,如下所示:

这里展示了我们所有课程之间的交互方式:

我们已经了解了应用程序类的大致情况。让我们深入挖掘,依次查看每个类的详细信息:
Instrumatics.view.main.Main: extends Ext.tab.Panel
- items[]
- dashboard: extends Ext.panel.Panel
- web: extends Instrumatics.view.web.Web
- sql: extends Instrumatics.view.sql.Sql
主要视图是一个标签面板,包含所有子页面:
Instrumatics.view.main.MainController: extends
Ext.app.ViewController
- onTabChange
- onNavigate
正如我们提到的,主要控制器处理与应用程序整体相关的事务。它负责在onTabChange时在仪表板和子页面之间切换,并在 URL 更改时决定采取什么行动onNavigate。四个Ext.chart.CartesianChart实例用于显示仪表板上所需的各类折线图,如下所示:
Instrumatics.view.dashboard.Dashboard: extends Ext.panel.Panel
- items[]
- live-sql-requests: extends Ext.chart.CartesianChart
- live-web-requests: extends Ext.chart.CartesianChart
- historical-sql-requests: extends Ext.chart.CartesianChart
- historical-web-requests: extends Ext.chart.CartesianChart
我们需要一些代码来设置我们的实时更新图表,所以我们在这initializeChartRefresh中这样做:
Instrumatics.view.dashboard.DashboardController: extends
Ext.app.ViewController
- initializeChartRefresh
Ext.app.ViewModel如下所示:
Instrumatics.view.dashboard.DashboardModel: extends Ext.app.ViewModel
- store.webLogs
- store.sqlLogs
- store.historicalWebLogs
- store.historicalSqlLogs
仪表板的视图模型概述了四个独立的数据源,每个图表一个:
Instrumatics.view.web.Web: extends Ext.panel.Panel
- filters: extends Ext.Container
- historical-web-requests: extends Ext.chart.CartesianChart
- statistics-grid: extends Ext.grid.Panel
相关的视图控制器如下所示:
Instrumatics.view.web.WebController: extends Ext.app.ViewController
啊!这是一个相当稀疏的视图控制器,实际上它做了很多工作。让我们看看视图模型,事情可能会变得稍微清晰一些:
Instrumatics.view.web.WebModel: extends Ext.app.ViewModel
- stores
- logData
- logStatistics
- categories
- data
- currentCategory
- currentStartDate
- currentEndDate
计划从几个存储中提取历史日志数据和关于此日志数据的统计信息。我们还将有一个存储用户可以使用来过滤网格视图的类别。
注意
有争议的是,类别可以存储在用户界面的一部分,该界面完全独立地过滤数据,在完全分开的存储中。然而,将视图的数据存储在一个地方——视图模型中——并且不通过添加另一个不必要的存储类来使事情复杂化也是有意义的。
当我们考虑应用程序的当前状态时,这个视图模型的关键部分出现了。对于这个子页面,它将存储在currentCategory、currentStartDate和currentEndDate变量中。
由于我们将在视图模型中保持所有状态,我们可以将 UI 控件绑定到设置此状态,并反过来将这些值绑定到一个store过滤器。这意味着使用 UI 更改值将自动更改过滤器值,而无需在控制器中添加任何粘合代码。
这个实现需要深刻理解视图模型的力量,以及对应用程序设计的深思熟虑。我们将在编写这一部分的代码时详细说明这一点。
应用程序的最后一部分是 SQL 子页面。这基本上与 Web 子页面相同,但显示不同的信息集,所以我们不会详细说明其设计。
灵活性和实用主义
我们尚未详细讨论的是设计如何随时间变化。我们正在阐述我们认为我们的应用程序应该是什么样子,但直到我们编写它,我们不会知道确切实现的全部细节。
对正在进行的工作进行持续的再评估非常重要,以确保所写内容的品质保持高标准。我们之前已经记录了我们的设计,但在一个关键的地方,我们也意识到子页面之间将非常相似。
根据这些相似性在代码中的具体实现,可能存在重构和重用的空间,这在设计文档绘制时并不明显。然而,如果实现确实足够相似,每个子页面之间只有细微的差异,那么我们需要考虑将此代码提取到一个可重用的类中。
代码重复——甚至更糟,复制和粘贴代码——是导致代码库混乱的非常好的方式。在你需要更改某些内容、调整行为、添加功能或修复错误的情况下,你必须在几个地方做同样的事情,这会增加更改的开销,并增加更多错误渗入代码的机会。
注意
复制和粘贴是代码库的祸害。通过盲目地复制代码,你的开发者没有进行任何批判性分析。他们正在不必要地增加代码库的大小,并且很可能会引入错误。
在本章的后面部分,我们将开始构建这个应用程序的代码。我们还将密切关注任何可能重复之前内容的迹象。在这些情况下,我们会花些时间看看是否可以封装和重用某些功能。
Ext JS 提供了多种方法来结构化代码——例如继承和混入——利用这些方法将导致一个易于维护和扩展的应用程序。
这会伤害吗?
我们的设计已经从上到下完成,现在是对其进行批判性审视的时候了。设计中是否有任何未知方面,或者是否有潜在的痛点?实时更新的图表现在有点像黑盒。虽然我们知道图表支持动画(通过animate 配置选项),但我们想确保图表的轴可以随着新数据的到来而更新。进行一个非常简单的测试以确保它能工作是值得的。为此,我们将稍微回到过去。
我们将直接链接到所需的文件,并使用Ext.onReady来运行我们的代码,而不是使用 Sencha Cmd 和整个 Bootstrap 过程。以下是空模板:
<!DOCTYPE HTML>
<html manifest="">
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="UTF-8">
<title>Chart Test</title>
<script type="text/javascript" src="img/ext-all-debug.js"></script>
<script type="text/javascript" src="img/sencha-charts-debug.js"></script>
<link rel="stylesheet" type="text/css" href="ext/packages/ext-theme-neptune/build/resources/ext-theme-neptune-all-debug.css">
</head>
<body>
<div id="chart"></div>
<script type="text/javascript">
Ext.onReady(function() {
// Code goes here.
});
</script>
</body>
</html>
我们创建了一个 HTML 页面,其中链接到 Ext JS 和 Sencha Charts 的 JS 文件以及一个 Ext JS 主题的 CSS。在 HTML 的主体中,我们将创建一个 ID 为"chart"的 div 来渲染,然后是一个Ext.onReady块,我们将在这里放置大部分代码。首先,让我们在这里设置一个存储:
var store = Ext.create('Ext.data.Store', {
fields: [
{ name: 'value' },
{ name: 'time', type: 'date' }
]
});
当前时间“from”和图表轴的“to”时间变量在此处显示:
var now = new Date();
fromDate = Ext.Date.subtract(now, Ext.Date.MINUTE, 1),
toDate = Ext.Date.add(now, Ext.Date.MINUTE, 5);
然后是图表本身:
var chart = Ext.create('Ext.chart.Chart', {
renderTo: 'chart',
width: 500, height: 300,
animate: true, store: store,
axes: [
{ type: 'numeric', position: 'left', fields: 'value' },
{
type: 'time', fields: 'time', dateFormat: 'H:i:s',
fromDate: fromDate.setSeconds(0),
toDate: toDate.setSeconds(0)
}
],
series: [{ type: 'line', xField: 'time', yField: 'value' }]
});
现在,让我们向存储中添加一些数据:
setInterval(function() {
store.add({
time: (new Date()).toISOString(),
value: Ext.Number.randomInt(1, 30)
});
}, 1000);
每秒钟,我们向存储中添加一条包含当前时间和随机值的记录。运行此代码后,我们得到一个每秒更新的折线图,非常棒!然而,虽然这已经很接近我们需要的了,但有一个问题。当线条接近图表的右侧时,它就消失在画布之外了。我们需要在更新数据时以某种方式更新图表的底部轴。
图表有一个redraw事件,我们可以用它来达到这个目的。我们将尝试将底部轴上的from日期和to日期向前移动15秒。由于我们的存储通过setInterval调用进行更新,redraw事件每秒会被触发一次,因此每当redraw事件被触发第 15 次时,我们就会更新轴。这就是代码中的样子:
var redrawCounter = 0;
chart.on('redraw', function() {
redrawCounter++;
if(redrawCounter > 15) {
redrawCounter = 0;
var timeAxis = this.getAxes()[1],
oldFrom = new Date(timeAxis.getFromDate()),
oldTo = new Date(timeAxis.getToDate()),
newFrom = Ext.Date.add(oldFrom, Ext.Date.SECOND, 15),
newTo = Ext.Date.add(oldTo, Ext.Date.SECOND, 15);
timeAxis.setFromDate(newFrom);
timeAxis.setToDate(newTo);
}
});
我们使用一个名为redrawCounter的变量来跟踪自上次轴调整以来redraw事件触发了多少次。其余的代码应该相当直接。获取底部并设置其日期为 15 秒之后。
这一切都很顺利,尽管在需要挂钩到redraw事件的过程中遇到了一些意外的障碍。现在我们已经确信这个问题可以解决,我们可以继续构建应用程序的其余部分。
奋进向前
就像我们所有的示例应用程序一样,我们将使用 Sencha Cmd 并按照以下方式构建一个应用程序模板:
sencha generate app -ext Instrumatics ./instrumatics
移除生成器创建的所有冗余文件和示例文件,并使用watch命令启动一个网络服务器。然后我们可以继续创建我们应用程序的第一个真实代码:数据层。
数据先行
所有我们的模型都将继承自一个基础模型,该模型将用于指定以下命令:
// app/model/BaseModel.js
Ext.define('Instrumatics.model.BaseModel', {
extend: 'Ext.data.Model',
schema: {
namespace: 'Instrumatics.model',
urlPrefix: 'http://localhost:3000',
proxy: {
type: 'ajax',
url: '{prefix}/{entityName:uncapitalize}'
}
},
});
注意
我们假设我们有一个运行在本地的 3000 端口的 API 服务器,并提供了这个信息作为 URL 前缀。
我们在上一个章节中使用了 schema 配置,但现在我们即将在多个模型中使用它,它真正地发挥了作用。在从 BaseModel 继承的每个模型中,模型名称将被插入到代理 URL 中以替换 entityName 标记。这避免了在多个模型之间重复 URL 配置。现在我们可以根据我们的设计创建 LogEntry 模型:
// app/model/LogEntry.js
Ext.define('Instrumatics.model.LogEntry', {
extend: 'Instrumatics.model.BaseModel',
fields: [
{ name: 'value' },
{ name: 'subType' },
{ name: 'type' },
{ name: 'time', type: 'date' }
]
});
通过 schema,这将导致以下 URL:
http://localhost:3000/logEntry
除了这个之外,我们只是在 LogStream 类上实现了我们在设计中指定的字段:
// app/model/LogStream.js
Ext.define('Instrumatics.model.LogStream', {
extend: 'Instrumatics.model.LogEntry',
});
这很简单。LogStream 类从 LogEntry 类继承了所有字段,但多亏了在 BaseClass 中使用 schema 配置,LogStream 将有一个这样的 URL:
http://localhost:3000/logStream
最后,这是 Statistics 模型:
// app/model/Statistic.js
Ext.define('Instrumatics.model.Statistic', {
extend: 'Instrumatics.model.BaseModel',
fields: [
{ name: 'category' },
{ name: 'label' },
{ name: 'percentage', type: 'number' }
]
});
这里只有设计中的三个字段,它给我们提供了一个生成此 URL 的模型:
http://localhost:3000/statistic
在我们的数据层中,没有任何特别复杂的地方,因为我们已经在设计阶段深思熟虑了实现方式。在坐下来编写代码之前,我们对 Ext JS 提供的功能有所了解,这使得我们能够通过在基类中配置代理来减少代码重复。
我们应该始终寻找地方,无论是通过重构还是通过初始设计,来减少重复的代码。在这个例子中,我们可以在 LogEntry、LogStream 和 Statistic 的每个实例上设置相同的配置,但如果我们想要更改配置的某些部分,比如 API 的主机名,我们就需要在多个位置进行更改。通过以我们这样的方式集中化,我们将有更少的代码需要维护,并且更容易与之合作。
在早期的模型类中,有一点需要注意,那就是我们定义字段的方式。在 Ext JS 中有两种选择,一种是将字段定义作为一个对象字面量传递,就像我们在代码中所做的那样,另一种是将包含字段名称的字符串传递。以 Statistic 类为例,字段配置将如下所示:
'category',
'label',
{ name: 'percentage', type: 'number' }
为什么选择其中一种而不是另一种?答案是保持一致性。这是一个非常适合作为团队编程风格指南的例子,所以我们不是让一个类使用一种方法声明,而另一个类使用另一种方法,而是采用统一的方法。对于打开你的模型文件并发现字段定义组织良好的新开发者来说,不会有任何警报和惊喜。
对于字段定义,还有一种第三种方法;不去管它们。Ext JS 允许你省略字段定义,并且会根据模型消耗的数据动态创建它们。
我们都支持减少维护的代码量,但有时,明确一些更好。如果我们需要在字段中使用其他配置选项,例如转换,那么我们无论如何都需要手动定义该字段,等等,然后我们会在模型中留下一些显式的字段,一些在运行时创建。
这是个人的偏好问题,但为了我们的目的,我们将始终定义完整的字段定义。这是连贯且自我说明的;当我们查看模型文件时,我们总是知道它正在消耗的字段。
存储数据
本项目中的存储库尽可能简单:
// app/store/LogEntries.js
Ext.define('Instrumatics.store.LogEntries', {
extend: 'Ext.data.Store',
alias: 'store.logentries',
model: 'Instrumatics.model.LogEntry',
autoLoad: true,
remoteFilter: true
});
// app/store/LogStream.js
Ext.define('Instrumatics.store.LogStream', {
extend: 'Ext.data.Store',
alias: 'store.logstream',
model: 'Instrumatics.model.LogStream',
autoLoad: true,
remoteFilter: true
});
// app/model/Statistics.js
Ext.define('Instrumatics.store.Statistics', {
extend: 'Ext.data.Store',
alias: 'store.statistics',
model: 'Instrumatics.model.Statistic'
});
这些类相当是模板化的(定义模型、定义别名,然后就是这些)。有人可能会提出,这些存储库实际上可以在单个视图模型中定义,从而减少我们的代码库中的文件数量。然而,在这个应用程序中,我们将在多个视图模型中重用存储库,因此将它们的配置保留在集中位置是有意义的。
让我们继续到我们应用程序的 UI 层。
针对控制器视图
在这个应用程序中,我们有一个“主”视图,它充当应用程序的视口,以及一个相关的控制器,它处理用户与该视口的交互并处理路由。让我们首先看看 UI 部分:view:
// app/view/main/Main.js
Ext.define('Instrumatics.view.main.Main', {
extend: 'Ext.tab.Panel',
requires: [
'Instrumatics.view.dashboard.Dashboard',
'Instrumatics.view.web.Web',
'Instrumatics.view.web.Sql',
],
xtype: 'app-main',
controller: 'main-main',
header: {
title: {
text: 'Instrumatics', padding: 20
}
},
tabPosition: 'left',
tabRotation: 0,
items: [
{ xtype: 'dashboard', title: 'Dashboard', reference: 'dash' },
{ xtype: 'web-logs', title: 'Web', reference: 'web' },
{ xtype: 'sql-logs', title: 'SQL', reference: 'sql' }
]
});
这段代码中有几个有趣的部分。我们使用header配置选项来精细控制面板的标题,允许我们在标题中添加一些格式化。
我们随后将面板标签的默认配置更改为将它们放置在屏幕的左侧(而不是屏幕顶部)。由于左侧的标签默认从上到下显示,我们也将tabRotation进行调整,使它们从左到右阅读。默认选中的标签自动是项目数组中的第一个组件,因此我们可以避免设置任何配置来指定这一点。
然后,我们设置将包含在这个标签面板中的项目,即仪表板、Web 日志子页面和 SQL 日志子页面,正如我们的设计所规定。这个配置中唯一有趣的部分是为每个组件添加reference,其效用将在我们查看视图控制器时变得清晰。
主要视图控制器
这个视图控制器中的代码并不多,但它所启用的功能非常重要。让我们看看:
// app/view/main/MainController.js
Ext.define('Instrumatics.view.main.MainController', {
extend: 'Ext.app.ViewController',
alias: 'controller.main-main',
routes: {
':controller': 'onNavigate'
},
listen: {
component: {
'tabpanel': {
tabchange: 'onTabChange'
}
}
},
onTabChange: function(tab, newCmp, oldCmp) {
this.redirectTo(newCmp.getReference());
},
onNavigate: function(controller) {
var view = this.getView();
view.setActiveTab(view.lookupReference(controller));
}
});
当然,代码不多,但内容很多。这是处理路由的应用程序部分,所以让我们花点时间讨论一下路由实际上是什么。
根到顶
在 Ext JS 指南中有一份相当全面的关于路由的描述,但无论如何,我们在这里简要介绍一下。路由允许您通过 URL 的哈希(URL 中#符号之后的所有内容)在页面 URL 中保持应用程序的状态。例如:
http://localhost/#banana
这表明我们正在一个关于香蕉的页面上。同样,看看以下示例:
http://localhost/#cars/56
这表明我们正在一个关于车号56的页面上。使用哈希的优点在于,它可以不重新加载页面就用 JavaScript 进行操作。此外,对哈希符号所做的任何更改都将被浏览器的历史记录记住。这意味着我们可以在我们应用程序中导航,并使用后退按钮来追踪我们的步骤。这也意味着,如果我们可以在应用程序重新加载时将特定页面设置为书签,那么它将导航到哈希符号中指定的状态。
回到正事
我们如何在Instrumatics应用程序中实现路由?第一步是实际定义一个路由如下:
routes: { ':viewReference: 'onNavigate' }
第一部分是我们试图匹配的哈希。除了:viewReference之外,没有指定任何内容,所以在这种情况下,哈希符号中的所有内容都会被捕获并传递给一个名为onNavigate的方法。:viewReference标记的名称是任意的,在这个例子中,它不影响其他任何东西,但在更复杂的路由中,用描述性的方式命名它是很有用的。
我们在这里试图实现什么?当哈希符号改变时,我们想要检测它并将用户重定向到正确的页面。路由定义执行检测部分,所以现在让我们看看我们如何将用户移动到正确的页面:
onNavigate: function(viewReference) {
var view = this.getView();
view.setActiveTab(view.lookupReference(viewReference));
}
路由定义意味着所有匹配的路由都将被onNavigate消耗。在这个方法中,我们可以假设传入的标记是对视图控制器视图上组件的有效引用,所以我们只需使用这个引用查找组件并将其设置为“主”视图上的活动标签页。
我们似乎遗漏了某些东西,那就是,哈希是如何首先被设置的?
路由到无地
在视图控制器的监听配置中,我们使用onTabChange方法处理“主”标签面板的tabchange事件。这从用户更改到的标签中获取引用配置并将其传递给视图控制器的redirectTo方法:
onTabChange: function(tab, newCmp, oldCmp) {
this.redirectTo(newCmp.getReference());
}
redirectTo方法只是简单地更改 URL 中的哈希,在这种情况下,到新组件的引用。这是一个简单的方法,它为我们提供了一种强大的方式来改进用户体验。
仪表盘
我们已经建立了应用程序基础设施,所以现在是时候构建将建立在基础设施之上的组件了。首先是仪表盘,这需要我们仔细考虑如何实现我们应用程序的其余部分。
仪表盘由两个实时图表和两个历史图表组成。实时图表之间非常相似,历史图表也是如此(它们之间只有一些微小的格式化和绑定配置)。然而,为了构建图表,你还必须构建图表上的轴和绘制的系列,这导致了一个相当冗长的配置对象。以下是仪表板中的历史日志图表将看起来像这样:
{
xtype: 'cartesian',
title: 'Last 30 Days',
margin: 10, flex: 1
bind: '{historicalWebLogs}'
axes: [{
type: 'numeric',
position: 'left',
fields: ['value'],
title: {
text: 'Avg. Response \nTime (ms)',
fontSize: 15
},
grid: true,
minimum: 0,
maximum: 20
}, {
type: 'time',
fields: 'time',
dateFormat: 'd M'
}],
series: {
type: 'line',
xField: 'time',
yField: 'value',
style: { 'stroke': 'red' }
}
}
极好!我们配置了一个带有数值左侧轴和时间基准底部轴的折线系列图表。这正是仪表板所需要的,那么问题在哪里?
复制是问题所在。这个配置对象的大部分内容都会被复制,一份用于 Web 日志,一份用于 SQL 日志。我们之前在本章中提到,我们希望在可能的情况下与复制作斗争,因此在这种情况下,我们将创建一个新的类,我们可以在需要从日志中绘制历史请求图表的地方重用它。
下面是这个类:
// app/ux/chart/HistoricalRequestChart.js
Ext.define('Instrumatics.ux.chart.HistoricalRequestChart', {
extend: 'Ext.chart.CartesianChart',
xtype: 'historical-request-chart',
frame: true,
axes: [{
type: 'numeric',
position: 'left',
fields: ['value'],
title: {
text: 'Avg. Response \nTime (ms)',
fontSize: 15
},
grid: true,
minimum: 0,
maximum: 20
}, {
type: 'time',
position: 'bottom',
fields: ['time'],
dateFormat: 'd M'
style: {
axisLine: false
}
}],
series: {
type: 'line',
xField: 'time',
yField: 'value',
style: { 'stroke': 'red' }
}
});
它结合了图表的基本定义,如坐标轴和系列,以及我们知道将在整个应用程序中重用的东西,如左侧轴上的标题。
注意,我们将这个类放在了我们示例应用程序中迄今为止所见到的任何地方之外,但我们讨论过的地方是第三章“应用结构”。ux命名空间和相应的目录是 Ext JS 社区中可重用类的相当标准的存放位置,因此我们将遵循这一惯例。
我们将创建另一个可重用的类,这次是为实时请求图表:
// app/ux/chart/LiveRequestChart.js
Ext.define('Instrumatics.ux.chart.LiveRequestChart', {
extend: 'Ext.chart.CartesianChart',
xtype: 'live-request-chart',
redrawCounter: 0,
frame: true,
axes: [{
type: 'numeric',
position: 'left',
fields: ['value'],
title: {
text: 'Avg. Response \nTime (ms)',
fontSize: 15
},
grid: true,
minimum: 0,
maximum: 20
}, {
type: 'time',
position: 'bottom',
step: [Ext.Date.SECOND, 1],
fields: ['time'],
dateFormat: 'H:i:s',
fromDate: new Date(new Date().setMinutes( new Date().getMinutes() - 1)).setSeconds(0),
toDate: new Date(new Date().setMinutes( new Date().getMinutes() + 5)).setSeconds(0)
}],
series: {
type: 'line',
xField: 'time',
yField: 'value',
style: {
'stroke-width': 2
}
},
constructor: function() {
this.callParent(arguments);
this.on('redraw', this.onRedraw, this);
},
onRedraw: function() {
this.redrawCounter++;
if(this.redrawCounter > 15) {
this.redrawCounter = 0;
var timeAxis = this.getAxes()[1],
oldFrom = new Date(timeAxis.getFromDate()),
oldTo = new Date(timeAxis.getToDate()),
newFrom = Ext.Date.add(oldFrom, Ext.Date.SECOND, 15),
newTo = Ext.Date.add(oldTo, Ext.Date.SECOND, 15);
timeAxis.setFromDate(newFrom);
timeAxis.setToDate(newTo);
}
}
});
你会注意到这段代码与本章前面代码调查中的代码有很多相似之处。只是我们将它封装成了一个可重用的类。
准备好这些新类后,创建仪表板只需将我们已编写的内容拼接在一起,如下面的代码所示:
// app/view/dashboard/Dashboard.js
Ext.define("Instrumatics.view.dashboard.Dashboard", {
extend: "Ext.panel.Panel",
xtype: 'app-dashboard',
title: 'hello',
requires: [
'Instrumatics.ux.chart.LiveRequestChart',
'Instrumatics.ux.chart.HistoricalRequestChart'
],
viewModel: {
type: 'dashboard-dashboard'
},
controller: 'dashboard-dashboard',
layout: {
type: 'vbox',
align: 'stretch'
},
items: [
{
xtype: 'container',
flex: 1,
layout: {
type: 'hbox',
align: 'stretch'
},
items: [
{
xtype: 'live-request-chart',
title: 'Live Web Requests', bind: '{webLogs}',
series: { style: { 'stroke': 'red' } },
margin: '10 5 0 10', flex: 1
},
{
xtype: 'live-request-chart',
title: 'Live SQL Requests', bind: '{sqlLogs}',
series: { style: { 'stroke': 'green' } },
margin: '10 10 0 5', flex: 1
}
]
},
{
xtype: 'container',
flex: 1,
layout: {
type: 'hbox',
align: 'stretch'
},
items: [
{
xtype: 'historical-request-chart',
title: 'Last 30 Days', bind: '{historicalWebLogs}',
series: { style: { 'stroke': 'red' } },
margin: '10 5 10 10', flex: 1
},
{
xtype: 'historical-request-chart',
title: 'Last 30 Days', bind: '{historicalSqlLogs}',
series: { style: { 'stroke': 'green' } },
margin: '10 10 10 5', flex: 1
}
]
}
]
});
而不是复制构成图表的配置,我们只需组合容器——这些容器使用vbox和hbox布局——并设置标题、格式和绑定。
这太好了。我们不是在仪表板视图中复制代码和有很多不必要的配置,而是将这段代码移动到了一个更合理的位置,促进了重用,并使代码库更加整洁。
持续评估
到目前为止,让我们看看我们构建的内容与我们的设计相比。它与我们的实现相匹配,但并没有提到这些可重用的类。对此有两种看法:
-
我们在设计上没有做得足够深入,错过了明确说明我们需要构建的内容的机会
-
我们在发现自己重复编写了已经编写过的内容后,看到了重构代码的机会
这两种观点都有其可取之处;然而,在第五章“实际应用 – CMS 应用”中,我们讨论了 YAGNI——你不会需要它——这规定,如果所讨论的组件永远不会被重用,那么为其规划重用几乎是没有意义的。
在这种情况下,实现过程揭示了我们会有些重复,所以我们进行了重构。虽然在设计阶段仔细考虑很重要,但重新评估我们的决策和代码应该是一个持续的过程,因此,认识到即使有在设计阶段应该注意到的感觉,也没有什么是固定不变的。只要我们理解为什么需要改变,并将其作为学习经验,我们就可以随时改变事物。
仪表板视图模型
仪表板中的四个图表各自有自己的数据源,这些源在各自的绑定配置中指定。这些源的定义在仪表板视图模型中:
// app/view/dashboard/DashboardModel.js
Ext.define('Instrumatics.view.dashboard.DashboardModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.dashboard-dashboard',
stores: {
webLogs: {
type: 'logstream',
filters: [{
property: 'type',
value: 'web'
}]
},
sqlLogs: {
type: 'logstream',
filters: [{
property: 'type',
value: 'sql'
}]
},
historicalWebLogs: {
type: 'logentries',
filters: [{
property: 'type',
value: 'web'
}]
},
historicalSqlLogs: {
type: 'logentries',
filters: [{
property: 'type',
value: 'sql'
}]
}
}
});
我们正在设置仪表板的数据源。如果你回顾一下相关的存储定义,你会看到匹配的别名,以及remoteFilter选项设置为true。这使我们能够在视图模型中设置存储定义的过滤器,并将其作为 JSON 传递到服务器。
这为设置存储以从服务器端检索过滤数据提供了一个非常简单的方法(只需传递一个过滤器数组,让后端处理即可)。
我们几乎已经用除了视图控制器之外的所有部件拼凑了仪表板。现在让我们来看看这个视图控制器。
仪表板视图控制器
仪表板上没有交互性,所以视图控制器没有什么可做的。我们将用它来控制图表的实时刷新功能:
// app/view/dashboard/DashboardController.js
Ext.define('Instrumatics.view.dashboard.DashboardController', {
extend: 'Ext.app.ViewController',
alias: 'controller.dashboard-dashboard',
init: function() {
var data = this.getViewModel().getData(),
me = this;
setInterval(function() {
data.webLogs.load({ addRecords: true });
data.sqlLogs.load({ addRecords: true });
}, 1000);
}
});
这很简单;每秒钟,获取驱动实时图表的存储,并调用其加载方法,将addRecords选项设置为true。这将导致新记录被追加到存储中,而不是覆盖旧记录。
虽然这里没有多少代码,但有几个讨论点。我们本可以完全避免在仪表板上使用视图控制器,并将这种刷新行为直接嵌入到LiveRequestChart类中,也许通过配置选项设置刷新率。
通过在控制器中这样做,我们有机会集中设置刷新率的位置。但这并不是一个巨大的胜利。确实有理由将此移动到 UI 类中。这是另一个没有正确或错误做法的情况;有多种选择,选择一个并继续比被提供的选项所瘫痪要好。
我们已经完成了应用程序的第一页,现在让我们继续到子页面,首先是网络日志页面。
网络日志子页面
我们已经构建了这部分屏幕的一部分。我们可以重用之前创建的Instrumatics.ux.chart.HistoricalRequestChart来显示指定日期范围的趋势。由于这些数据来自存储,我们可以简单地过滤存储,而无需在组件本身上做任何事情。考虑到这一点,Web视图看起来是这样的:
// app/view/web/Web.js
Ext.define('Instrumatics.view.web.Web',{
extend: 'Ext.panel.Panel',
xtype: 'web-logs',
viewModel: {
type: 'web-web'
},
layout: {
type: 'vbox',
align: 'stretch'
},
items: [
{
header: {
title: 'Web Requests',
items: [
{ xtype: 'datefield', fieldLabel: 'From', labelAlign: 'right', bind: '{currentStartDate}' },
{ xtype: 'datefield', fieldLabel: 'To', labelAlign: 'right', bind: '{currentEndDate}', labelWidth: 30 },
{ xtype: 'button', text: 'Refresh', margin: '0 0 0 10' }
]
},
margin: 10, xtype: 'historical-request-chart', bind: '{logData}', flex: 1
},
{
xtype: 'grid',
margin: 10,
hideHeaders: true,
viewConfig: {
trackOver: false
},
disableSelection: true,
header: {
title: 'Breakdown',
items: [
{
xtype: 'combo',
labelAlign: 'right', labelWidth: 60,
fieldLabel: 'Category',
bind: {
store: '{categories}',
value: '{currentCategory}'
},
queryMode: 'local',
editable: false,
forceSelection: true,
displayField: 'text',
valueField: 'value'
}
]
},
bind: '{logStatistics}',
flex: 1,
width: '100%',
columns: [
{ name: 'Label', dataIndex: 'label', flex: 1 },
{ name: 'Percentage', dataIndex: 'percentage', flex: 1 }
]
}
]
});
这里有相当多的代码,但这里发生的除了组件配置之外没有其他事情。让我们一步一步地分解它。
首先,是面板本身的配置,我们在其中设置了 vbox 布局。
然后我们添加了第一个项目,一个我们之前构建的 HistoricalRequestChart 的实例,并在其标题中添加了 To 和 From 日期字段。这些字段的值绑定到视图模型中的值(currentStartDate 和 currentEndDate),而图表本身绑定到视图模型中的 logData。
最后,我们有统计网格的配置。它的存储绑定到 logStatistics,在其标题中,我们添加了一个组合框,其值绑定到视图模型中的 currentCategory,其存储绑定到类别,以提供组合框选项。组合框允许用户选择他们想要查看的统计类别。
向上前进到视图模型!
对 Web 的一种看法
我们的 Web 视图模型遵循我们的设计,并使用一些实现细节来完善它:
// app/view/web/WebModel.js
Ext.define('Instrumatics.view.web.WebModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.web-web',
stores: {
logData: {
type: 'logentries',
filters: [
{ property: 'startDate', value: '{currentStartDate}' },
{ property: 'endDate', value: '{currentEndDate}' }
]
},
logStatistics: {
type: 'statistics',
filters: [
{ property: 'category', value: '{currentCategory}' },
{ property: 'startDate', value: '{currentStartDate}' },
{ property: 'endDate', value: '{currentEndDate}' }
]
},
categories: {
fields: ['text', 'value'],
data: [{
text: 'Browser', value: 'browser'
},{
text: 'Location', value: 'location'
},{
text: 'Device Type', value: 'device'
}]
}
},
data: {
currentCategory: '',
currentStartDate: null,
currentEndDate: null
}
});
注意过滤器上的绑定。这使我们能够将过滤器与视图模型上的一个值链接起来,反过来又链接到视图中的表单控件上的值。这意味着一旦用户更新了一个表单控件,它将自动更新存储上的过滤器,重新加载存储,并重新绘制图表和网格。
我们还有一个 categories 存储,它只是用来保存类别组合框数据的一种方式,没有更多。
控制 Web
嗯,这里有一个问题,这个视图没有对应的视图控制器!在 Ext JS 的早期版本中,我们会监听日期字段和组合框上的更改事件,然后获取更改的值并使用它重新加载存储。
然而,在视图模型的新世界中,我们不需要这样做。当一个日期字段更新时,它会自动更新相应的视图模型值,反过来又更新存储的过滤器。
我们已经设置了存储和表单组件,所以通过在表单中添加一些魔法般的绑定配置,Ext JS 将为我们提供所有这些功能。这意味着我们需要编写的自定义代码更少,这只能是一件好事!
越来越多的子页面
我们知道我们应用程序中的另一个子页面,SQL 日志页面,将非常类似于 Web 日志页面。这应该会让我们对代码重复产生疑问。我们可以将网格和图表标题从 Web 视图中提取出来,并将它们转换为可重用的组件,但这是正确的事情吗?这些组件不会被重用!它们只在应用程序的一个位置有它们的位置,用于驱动子页面。与 HistoricalRequestChart 不同,它在仪表板和子页面中都被使用,这些组件只需要完成一项工作。
让我们考虑另一种方法:继承。我们可以创建一个新的组件,它包含来自Web视图的图表、表单字段和网格,并添加一些配置选项,以提供我们需要的定制化。这可能看起来像以下命令:
// app/view/SubPage.js
Ext.define('Instrumatics.view.SubPage',{
extend: 'Ext.Container',
requires: [
'Instrumatics.ux.chart.HistoricalRequestChart'
],
xtype: 'subpage',
config: {
layout: {
type: 'vbox',
align: 'stretch'
},
chartCfg: {
header: {
items: [
{ xtype: 'datefield', fieldLabel: 'From', labelAlign: 'right', bind: '{currentStartDate}' },
{ xtype: 'datefield', fieldLabel: 'To', labelAlign: 'right', bind: '{currentEndDate}', labelWidth: 30 },
{ xtype: 'button', text: 'Refresh', margin: '0 0 0 10' }
]
},
margin: 10, xtype: 'historical-request-chart', bind: '{logData}', flex: 1
},
gridCfg: {
xtype: 'grid',
margin: 10,
hideHeaders: true,
viewConfig: {
trackOver: false
},
disableSelection: true,
header: {
title: 'Breakdown',
items: [
{
xtype: 'combo',
labelAlign: 'right', labelWidth: 60,
fieldLabel: 'Category',
bind: {
store: '{categories}',
value: '{currentCategory}'
},
queryMode: 'local',
editable: false,
forceSelection: true,
displayField: 'text',
valueField: 'value'
}
]
},
bind: '{logStatistics}',
flex: 1,
width: '100%',
columns: [
{ name: 'Label', dataIndex: 'label', flex: 1 },
{ name: 'Percentage', dataIndex: 'percentage', flex: 1 }
]
}
},
initComponent: function(arguments) {
this.callParent(arguments);
this.add(this.getChartCfg());
this.add(this.getGridCfg());
}
});
我们已经将整个配置从Web视图拉取到这个名为SubPage的基类中,并将关键组件分离出来,即图表和网格。我们还通过将这些组件包裹在配置部分来利用 Ext JS 类系统的力量,这不仅为gridCfg和chartCfg生成了获取和设置方法,还为我们提供了覆盖这些配置对象部分内容的快捷方式。让我们通过展示由我们新的SubPage类驱动的SQL视图来演示:
// app/view/sql/Sql.js
Ext.define("Instrumatics.view.sql.Sql",{
extend: "Instrumatics.view.SubPage",
xtype: 'sql-logs',
viewModel: {
type: "sql-sql"
},
chartCfg: {
title: 'SQL Requests'
}
});
嗯,就是这样!我们已经实现了与Web视图相同的功能,但重用了大量的代码。请注意,chartCfg被暴露出来供我们配置,这意味着我们只需要设置viewModel和图表的标题配置,一切就绪。
结果表明,我们也可以对视图模型做同样的事情:
// app/view/SubPageModel.js
Ext.define('Instrumatics.view.SubPageModel', {
extend: 'Ext.app.ViewModel',
stores: {
logData: {
type: 'logentries',
autoLoad: true,
remoteFilter: true,
filters: [
{ property: 'startDate', value: '{currentStartDate}' },
{ property: 'endDate', value: '{currentEndDate}' }
]
},
logStatistics: {
type: 'statistics',
autoLoad: true,
remoteFilter: true,
filters: [
{ property: 'category', value: '{currentCategory}' },
{ property: 'startDate', value: '{currentStartDate}' },
{ property: 'endDate', value: '{currentEndDate}' }
]
}
},
data: {
currentStartDate: null,
currentEndDate: null
}
});
以下是一个 SQL 视图模型:
Ext.define('Instrumatics.view.sql.SqlModel', {
extend: 'Instrumatics.view.SubPageModel',
alias: 'viewmodel.sql-sql',
stores: {
categories: {
fields: ['text', 'value'],
data: [{
text: 'Query Source', value: 'source'
},{
text: 'Query Type', value: 'type'
}]
}
},
data: {
currentCategory: 'source'
}
});
现在,Web和SQL视图以逻辑方式共享代码,而没有用不属于那里的类污染我们的 UX 命名空间。在未来,我们可以轻松地添加更多类似这样的视图,但到目前为止,我们已经找到了一种避免代码重复并保持应用程序结构良好的绝佳方法。
摘要
在本章中,我们构建了第二个实际应用,并讨论了很多关于设计如何随着应用程序的发展而变化的内容。虽然我们带着最好的意图和手头拥有的知识来构建初始应用程序架构,但随着应用程序的发展,保持灵活性以生产成功的产品是很重要的。我们还介绍了路由、更多关于数据绑定,以及展示了代码可以以各种方式重用。
在下一章中,我们将构建一个大多数读者都熟悉的应用程序:电子邮件客户端。它将是一个响应式应用程序,适用于桌面和平板电脑,我们还将再次将我们已经讨论过的想法进一步发展。
第七章:实践 - 邮件客户端
我们过去两个应用程序是相当直接的例子,但它们对于说明如何为未来的更广泛产品创建一个强大的基础是有用的。在本章中,我们将构建一个功能齐全的 Web 邮件客户端,将为桌面浏览器和较小设备(如平板电脑)的用户提供定制体验。
每个人都知道传统的电子邮件界面,但我们将尝试同时展示如何使用 Ext JS 技术快速构建此类应用程序。以下是本章我们将要执行的操作分解:
-
确立应用程序的基本需求
-
为每个形态因子提出理想的用户界面
-
分析在小屏幕上可能出现的問題
-
设计视图和控制器的结构以呈现和协调所有内容
-
评估我们应用程序的设计
在此过程中,我们将加强我们对路由和视图模型的知识,并不断重新评估我们的工作以确保代码的质量。
我们还将花更多的时间来完成这个应用程序的设计,涉足 Ext JS 主题化的领域。虽然我们不会构建一个完整的替代主题,但我们会触及一些应该使用主题化系统来提高您应用程序可维护性的地方。
形态因子
此应用程序将适应各种设备,从桌面浏览器到平板电脑和手机。这些设备的尺寸通常被称为“形态因子”,Ext JS 提供了几种机制,允许您根据所使用的设备的形态因子来定制用户体验。
在本章中,我们将重点关注responsiveConfig,这是在您的视图中包含Ext.mixin.Responsive类时可用的一项选项。在标准桌面应用程序中,我们可能在视口中并排放置两个组件,因为桌面屏幕的宽度通常大于高度。在手机上,用户通常会处于纵向模式,因此这不再成立;屏幕的高度大于宽度。在这种情况下,我们可以使用responsiveConfig来覆盖原始的并排配置,并使用不同的布局、项目和组件宽度——实际上可以是原始视口配置的任何方面——并改变应用程序在更高屏幕上的外观和行为。
这一项特性为我们提供了一种异常强大的方式,以提供针对特定形态因子的定制体验。在本章中,我们将看到一些实现responsiveConfig的实际示例。
应用程序设计
我们期望从电子邮件客户端中获得哪些功能?至少:
-
登录:这有助于获取对您自己的账户的访问权限
-
收件箱:这是我们的电子邮件列表
-
已发送:这是我们过去发送的电子邮件列表
-
存档:这是我们已处理的电子邮件列表
-
撰写器:这有助于撰写电子邮件
-
搜索:这有助于找到归档电子邮件
应用程序的最后版本看起来大致如下:

左侧的线程视图和右侧所选线程的消息
我们需要做什么才能达到这个阶段?让我们绘制一个设计草图:

登录页面相当标准。我们希望验证用户输入,检查电子邮件地址,并确保密码不为空,但这里并没有什么特别之处:

这是应用程序的主要界面。我们将实现线程化电子邮件;我们在左侧有一个线程列表,显示最近消息的摘录作为线程的描述。每个线程的最后一封消息的日期显示在每个线程的左侧。在右侧,我们显示所选的消息线程,最新的消息在最后。每条消息在其左侧显示接收日期。线程有一个回复按钮和一个下拉菜单,允许对线程进行标记,稍后会有更多介绍。
在屏幕顶部,我们有一个标志(从左到右),联系人部分的图标,一个搜索栏,一个创建新电子邮件的按钮,最后是一个下拉菜单,用于按标签筛选线程。
搜索栏会导致匹配的消息线程出现在下方(与收件箱视图相同的样式)。
我们不是为归档电子邮件、已发送电子邮件等设置单独的屏幕,我们假设收件箱线程只是未标记的线程。当它们被标记——无论是自动标记为“已发送”还是其他任意的标签,如“主页”或“工作”——它们就会从收件箱视图中移除,如下面的截图所示。这个概念在许多电子邮件客户端中都有,例如 Gmail。

在编写新电子邮件时,它位于右侧位置。由于此时没有选择任何线程,因此空间清晰,可以由包含组合框以选择电子邮件收件人、电子邮件本身的简单 HTML 编辑器和发送按钮的面板占用。
回复电子邮件的方式类似;作曲面板出现在消息下方,允许用户撰写回复。
需要注意的另一些事项是,用户将开始时有一系列默认标签:
-
草稿
-
归档
-
垃圾邮件
-
工作
-
主页
当为线程选择标签时,用户可以通过在组合框中输入轻松添加另一个标签。
我需要表示钦佩
作为产品的架构师,我们始终需要考虑如何使我们的产品成为典范,如何确保用户体验良好,如何超越利益相关者的期望。第一步是详细规划我们想要构建的产品各个方面的细节。
技术上讲
我们明确提出的这些需求如何转化为底层技术?
-
我们希望显示一个带有“记住我”功能的登录表单,因此我们需要某种持久存储
-
我们希望以自定义格式显示线程数据
-
我们希望以自定义格式显示完整的消息线程
-
我们需要一个基本的 HTML 编辑器用于消息正文
-
我们需要一个自动完成框用于收件人
-
我们需要以与消息线程相同的格式显示搜索结果
-
我们需要一个自动完成框用于在各个位置使用标签
让我们将这些翻译成 Ext JS 功能:
-
我们可以使用 cookie 或本地存储在系统之间(使用原生浏览器方法或 Ext JS 驱动的会话类)保存登录信息
-
我们可以使用 Ext JS DataView 创建线程数据的模板视图
-
我们可以使用 Ext JS DataView 创建消息数据的模板视图
-
Ext JS 提供了一个 HTML 编辑器小部件
-
Ext JS 组合框可以由一个检索远程联系人数据的存储提供支持
-
我们可以使用不同的存储来重用消息线程 DataView
-
在需要添加新标签的情况下,我们可以使用 Ext JS 组合框的
editable:true选项
在这里有几个灰色区域,我们稍后会进行审查,但看起来 Ext JS 可以提供我们构建此应用程序所需的所有功能。
对情况的响应
在此项目中还有一个要求:适用于较小屏幕设备的响应式设计。好吧,事实证明,我们提出的 UI 在平板电脑上看起来已经相当不错,只有一个缺点。你必须以横向模式握持设备。这是一个在桌面上你不必真正考虑的问题,但在移动设备上变得至关重要。
从架构角度来看,我们需要了解应用程序在不同屏幕尺寸下的布局差异,以便决定如何组装应用程序。在设计到目前为止,有两个面板并排;在竖屏手机或平板电脑上根本不会有足够的屏幕空间来允许这样做。
相反,我们将根据用户操作隐藏或显示“左侧”和“右侧”面板。如果他们点击消息线程或新消息按钮,线程将被隐藏,并显示正确的右侧面板。
在竖屏模式下,唯一的另一个问题是应用程序标题栏;其中包含太多组件,无法适应较小屏幕的宽度。相反,我们将显示一个菜单按钮,在竖屏模式下切换时隐藏一些控件并显示其他控件。这为我们提供了一个仅在用户需要时才显示的二级菜单。以下是这些想法的一些原型:

当点击左侧截图中的其中一条消息时,列表被右侧截图中的消息线程所取代。
在基于 HTML 的响应式网站上,可以使用 CSS 媒体查询以适合任何屏幕和方向的样式来设计页面。虽然我们显然仍然可以使用 CSS 与 Ext JS 一起进行定制,但我们的需求更为复杂;Ext JS 能提供任何额外的功能来协助吗?
responsive插件允许开发者根据与当前设备屏幕相关的规则集定制任何组件配置。以下是在一个理论上的响应式应用中的几个示例场景:
-
如果屏幕宽度小于 500 像素,默认折叠侧边栏面板
-
如果屏幕处于横幅模式,显示新的数据列
-
如果设备是 iOS 平板,应用一个不同的 CSS 类
在网络邮件应用中,我们已经提到过显示和隐藏标题项。这可以这样做:
{
xtype: 'button', text: 'My Button',
plugins: ['responsive'],
responsiveConfig: {
'portrait': { hidden: true }
}
}
有一个内置的规则称为“portrait”,它允许你指定一个配置对象,该对象仅在规则生效时应用。作为架构师,我们必须仔细考虑如何最好地利用这个特性,而不会导致大量混乱的配置。
在应用的桌面版本中,我们有两个并排的窗格。为此,我们可以使用hbox布局。然而,对于纵向模式,我们希望能够在一个窗格和另一个窗格之间切换。这似乎是卡片布局的一个很好的应用。我们还得考虑如何触发窗格的切换,并确保这段代码只在纵向模式下运行。
重要的收获是,响应式插件——类似于视图模型——允许你避免编写大量响应应用环境状态的粘合代码,而是让我们在配置时声明我们的意图。之后,Ext JS 会处理大部分剩余的工作。
这又是另一个例子,通过深入理解现有技术,分析需求可以导致更简单的架构和更清晰的代码库。
必须输入
在前面的章节中,我们已经详细介绍了我们将要与之通信的 API。不可否认的是,它是任何应用设计的关键部分;事实上,许多项目能否成功或失败取决于它们集成的 API 的质量,但我们已经在过去两章中讨论过这个问题,我们还有很多内容要覆盖。从现在开始,我们将一般假设我们正在与一个设计良好的 RESTful API 一起工作,它与 Ext JS 配合良好。这将给我们一些空间来集中精力考虑一些新的想法。
小贴士
这并不是说在设计应用时可以跳过 API。你很少会与一个完美的后端一起工作,所以请持续分析服务器是否提供了你需要的端点。
我们将继续查看应用程序的其余部分,但与之前的章节不同;一旦从服务器拉取了数据,我们将尝试考虑它将如何通过我们的应用程序。我们还将更详细地查看设计的两个其他方面:路由和事件。与之前的章节相比,为什么会有这样的方向变化?
随着我们的应用程序变得更加复杂,我们必须不断思考如何保持这种复杂性在可控范围内。这三个功能:视图模型、事件和路由,都允许一种“发射后不管”的态度,配置一些基本设置,在源处触发一个动作,这段代码就完成了。在应用程序的某个地方将订阅这个动作——无论是视图模型绑定、路由更改还是事件的触发——并相应地消费它。
我们将首先识别我们的视图和控制器,就像我们过去做的那样,并查看这将如何影响路由、视图模型和事件,它们将推动我们应用程序的功能。
一览众山小
让我们分解构成应用程序主屏幕的主要视图:

1:标题视图,2:线程视图,3:消息视图,4:编辑器视图;主视图包含视图 1-4
登录视图是最简单的;一个自包含的视图、视图控制器和视图模型来绑定登录表单的值。它在上面的原型图中没有显示,因为它在那时是屏幕上唯一的视图,几乎是独立的。
这有一个注意事项。对于第一次,我们将使用一个总控制器来处理视图之间的交互。在前一章中,这被留给了“主要”视图控制器,因为“主要”视图是包含我们应用程序所有部分的容器。在这里,登录视图和应用程序的其余部分实际上是相互独立的,因此有“第三方”帮助他们一起工作是有意义的。
我们将把这个顶级控制器称为我们的“根”控制器。它不是一个视图控制器,而是一个完全自包含的类,负责显示登录视图并对成功的登录做出反应。为了正式化这一点:
Postcard.controller.RootController: extends Ext.app.Controller
- onLaunch -> check for valid login
- onLoginSuccess -> show main view
登录视图控制器负责处理登录尝试,并在这样做之后,将触发适当的动作。连同其视图和视图模型,它看起来像这样:
Postcard.view.login.Login: extends Ext.window.Window
- items[]
- e-mail: extends Ext.form.Text
- password: extends Ext.form.Text
- rememberMe: extends Ext.form.Checkbox
- submit: extends Ext.Button
Postcard.view.login.LoginModel: extends Ext.app.ViewModel
- e-mail
- password
- rememberMe
Postcard.view.login.LoginController: extends Ext.app.ViewController
- onLoginClick
假设onLoginClick方法成功,我们将继续到应用程序的主屏幕。
主要无害
如前几章所述,主要视图是包含应用程序中其他视图的视口,例如应用程序标题和线程列表。根据我们的设计,视图应该看起来像这样:
Postcard.view.main.Main: extends Ext.Panel
- items[]
- app-header: extends Ext.Container
- threads: extends Ext.DataView
- container: extends Ext.Container
- items[]
- messages: Ext.Container
- composer: Ext.form.Panel
这里有一些需要注意的事项,组成我们应用程序的主要视图在这里被提及:头部、线程、消息和,作曲家。我们也在设计上做了一些前瞻性的思考,即作曲家和消息视图被包含在一个单独的容器中。
这将使我们能够更轻松地与 Ext JS 布局系统一起工作,将线程视图和这个匿名容器以hbox排列。视图模型看起来如下:
Postcard.view.main.MainModel: extends Ext.app.ViewModel
- currentTag
- searchTerm
这只是为了方便在主视图中的视图之间共享一些需要共享的状态。视图控制器看起来如下:
Postcard.view.main.MainController: extends Ext.app.ViewController
- onLogout
- onHome
- onShowThread
- onNewThread
- onNewMessage
第一个方法(onLogout)将处理退出按钮的点击。主视图控制器上的下一个四个方法将由路由触发,并将负责设置应用程序状态的变化。
记住,主视图及其相关类实际上并没有自己的功能;它们负责协调所有包含在其中的其他应用程序部分。
全速前进
主视口的第一个子视图是头部视图,其中包含一些在任何地方都可以使用的组件,如下所示:
Postcard.view.header.Header: Ext.Toolbar
- items[]
- homebutton: extends Ext.Button
- searchfield: extends Ext.form.TextField
- tagfilter: extends Ext.form.ComboBox
- newmessagebutton: extends Ext.Button
- menubutton: extends Ext.Button
实际上这里发生了很多令人惊讶的事情。我们还要考虑到这是我们的纵向功能之一的目标,因此我们的实现中将会使用响应式插件,如下面的代码所示:
Postcard.view.header.HeaderController: extends Ext.app.ViewController
- onHomeClick
- onNewMessageClick
这些方法是事件监听器,进而触发进一步的功能。你可能想知道为什么我们没有处理菜单打开和关闭或从组合框中选择项的处理程序。考虑数据绑定。如果我们把菜单按钮和组合框的状态绑定到视图模型,其他组件可以绑定到视图模型中的值,并且会在我们不需要编写任何粘合代码的情况下接收更新。为此,头部视图模型看起来如下:
Postcard.view.header.HeaderModel: extends Ext.app.ViewModel
- tags
仅仅是一个用于填充标签筛选组合框的存储。我们将在实现头部时进一步讨论这种数据绑定的用法。
线程化我们的方式
线程只是“一组电子邮件消息”的时髦说法。我们将使用Ext.DataView来实现这一点:
Postcard.view.threads.Threads: extends Ext.DataView
- stripHtml
我们将在本应用中支持 HTML 电子邮件,但为了防止线程视图看起来杂乱,我们将在将其展示给用户之前移除这些 HTML。除此之外,它是对 DataView 的正常实现。
Postbox.view.threads.ThreadsModel: extends Ext.app.ViewModel
- threads
视图模型包含以下内容的线程存储:
Postcard.view.threads.ThreadsController: extends
Ext.app.ViewController
- onThreadClick
这里只有一个方法,它是由线程 DataView 上的itemclick事件触发的。它将负责将用户重定向到该线程的消息列表。
发送给我一条消息
消息视图负责显示构成线程的消息。因此,它主要基于 DataView。但这比这要复杂一些,因为 DataView 不继承自Ext.Panel;它不能有自己的子项或停靠工具栏。
在这种情况下,我们需要在消息列表底部添加一些工具,以便更改线程标签并发送回复。因此,我们将 DataView 包裹在一个面板中:
Postcard.view.messages.Messages: extends Ext.Panel
- items[]
- panel: extends Ext.Panel
- items[]
- messagelist: extends Ext.DataView
- bbar[]
- tagpicker: extends Ext.form.ComboBox
- reply: extends Ext.Button
在视图模型中,我们需要两个存储库:一个用于线程中的消息,另一个用于可供选择的标签。
Postcode.view.messages.MessagesModel: extends Ext.app.ViewModel
- messages
- threads
视图控制器有几个事件处理器来管理用户与消息视图的交互:
Postcard.view.messages.MessagesController: extends Ext.app.ViewController
- onReplyClick
- onNewThread
- onShwThread
- onTagChange
现在这个应用程序只剩下一个缺失的部分——我们如何编写新消息?
保持冷静
编写视图负责撰写新消息和回复。为此,它需要几个 UI 组件:
Postcard.view.composer.Composer: extends Ext.form.Panel
- items[]
- recipients: extends Ext.form.ComboBox
- subject: extends Ext.form.TextField
- message: extends Ext.form.HtmlEditor
如果编写视图正在回复现有的线程,则不会使用收件人和主题。它只会在创建新线程时使用:
Postcard.view.Composer.ComposerModel: extends Ext.app.ViewModel
- items[]
- contacts
- newMessage
我们有一个存储联系人信息的存储库来为收件人字段供电,以及一个对象来存储用户输入的表单值:
Postcard.view.composer.ComposerController: extends
Ext.app.ViewController
- onSendClick
视图控制器将负责将消息保存到服务器,然后服务器会将它发送到指定的收件人。
在这个应用程序中,我们并没有一个地址簿;相反,任何之前使用的电子邮件地址都只是保存下来,并在未来的消息中可供选择。
设计概述
这次我们跳过了很多数据层设计,因为它本质上是“样板”式的,我们已经在之前的章节中讨论过这类事情。那么为什么还要为视图及其关联的视图控制器和视图模型进行类设计过程呢?我们已经在之前的章节中这样做过了。
显然,每个应用程序都是不同的。以这种方式分解有助于我们在实际编写代码之前完善将要编写的代码。这很重要,因为我们将避免过度思考实现的细节,并更好地理解拼图中更大组件的形状。
下一步是重新审视路由、事件和数据流,看看这些大型组件将如何协同工作。
应用程序状态
首先是路由。我们在第六章实践 - 监控仪表板中提到,路由是一种将应用程序的部分状态保存在 URL 中的方式。另一种看待这个问题的方式是,随着应用程序通过其各种状态的变化,你正在走过用户在与其界面交互时看到的屏幕。
通过确定我们应用程序的各种高级状态,我们可以更好地可视化用户流程并确定我们可以在代码中使用的路由。
主屏幕/初始加载
这是在登录后立即显示的,代表了应用程序主屏幕在用户与之交互之前的默认状态。它看起来如下:
Route: /#home
视图将具有以下状态:
view.threads.Main.activeItem = 'threads'
如果我们在纵向视图,我们将使用卡片布局。
注意
“卡片”是构建 Ext JS 布局之一,它允许您轻松地切换一个组件为另一个组件。它也是 Ext.TabPanel 组件的基础。
这意味着应用程序的初始状态需要将线程视图作为活动项:
view.main.Main.rightPane.hidden = true
在正常设备上的初始状态下,用户既没有选择消息也没有选择编写新消息。因此,在右侧面板中没有显示任何内容。
新线程
处理请求新线程的路径如下所示:
Route: thread/new
当用户按下新消息按钮时,他们会看到编辑器视图。整体状态变化如下:
view.main.Main.activeItem = 'rightPane'
view.composer.Composer.hidden = false
view.messages.Messages.hidden = true
记住,更改路由并不意味着状态会重置到初始状态然后改变;我们需要重置所有可能由其他路由显示的东西。在这种情况下,如果用户之前选择了一个线程,那么消息视图就会显示,我们需要在创建新消息时将其隐藏。
显示线程
处理特定消息线程请求的路径如下:
Route: thread/:id/messages
当用户选择线程时,会触发此操作:
view.main.Main.activeItem = 'rightPane'
view.messages.Messages.hidden = false
view.composer.Composer.hidden = true
view.main.MainModel.currentThreadId = :id
几乎与新的消息相反,其中显示消息而隐藏了编辑器。需要选择线程的组件,在这种情况下,是消息视图,以便它可以加载所需的消息。
新消息/回复
处理请求新消息线程的路径如下:
Route: thread/:id/messages/new
这是我们的应用程序中的最后一个路由,用于在用户选择了一个线程然后点击“回复”按钮时使用。
view.main.Main.activeItem = 'rightPane'
view.messages.Messages.hidden = false
view.composer.Composer.hidden = false
view.main.MainModel.currentThreadId = :id
与“显示线程”路由类似,除了显示编辑器和消息视图。
路由概述
检查用户可以通过您的应用程序采取的路径可以是非常有价值的方式来确保您的设计中没有遗漏任何内容。这也使网站能够了解应用程序在路径上的各个点的状态,并允许您将这些状态转换为恢复这些状态的路径。
绑定协议
应用程序的一些状态存储在 URL 中,但其他瞬态状态存储在视图模型中。我们将通过查看数据如何通过此应用程序的示例来更好地理解数据绑定可以多么强大。
在纵向模式下,我们的应用程序有一个菜单按钮,可以切换其他各种组件的可见性。这个伪代码可能是这样的:
if menu_button_is_pressed
this.find('searchfield').show()
this.find('newmessagebutton').hide()
this.find('logo').hide()
else
this.find('searchfield').hide()
this.find('newmessagebutton').show()
this.find('logo').show()
end
这样的代码并不复杂,但很长且容易出错,编写起来很麻烦。相反,我们可以使用数据绑定来避免这种类型的代码,并在配置期间设置行为,类似于以下内容:
[
{
xtype: 'button', reference: 'menubutton',
enableToggle: true
},
{
xtype: 'searchfield',
bind: {
hidden: '{menubutton.pressed}'
}
}
]
在这里有几个要点需要理解:首先,一个按钮会“发布”其pressed值的当前状态。每当pressed值发生变化,无论是通过编程方式还是因为用户点击了按钮,这个值都会被推送到该按钮的视图模型中。其次,如果一个组件设置了其reference,那么就可以在视图模型中访问其发布的值。
结合这两者以及搜索字段的绑定配置,搜索字段的hidden值绑定到菜单按钮的pressed值。如果pressed为true,则搜索字段将被隐藏。
虽然我们在前面的章节中已经详细介绍了数据绑定和视图模型,但这是我们第一次探讨这种特定方法。只要在组件层次结构中的某个地方有可用的视图模型,就无需在视图模型本身上指定任何配置,这样就可以正常工作。
这是工具库中另一个简化我们代码的武器。使用这种声明式方法,我们指定我们希望发生什么,但不必说明它是如何发生的,我们可以避免编写像之前的伪代码那样的方法,并使用 Ext JS 提供的标准化方法。
困难的部分是充分理解数据绑定和视图模型的概念。通过提前考虑组件之间的依赖关系并通过视图模型传递数据,我们可以用很少的代码创建强大的交互。
一个充满事件的应用程序
关于路由和数据绑定的一个有趣观察是,它们都是围绕事件构建的。当路由发生变化时,会触发一个事件,控制器监听它,并相应地设置应用程序的状态。当一个属性被数据绑定时,Ext JS 会发布其更改,其他属性会监听这些更改。
我们也熟悉事件,如click、select、show等,这些事件是由各种 Ext JS 组件触发的。看起来,由于事件在 Ext JS 应用中被广泛使用,我们最好也利用它们!
我们可以使用 Ext JS 中每个Observable类的fireEvent方法。这允许你在我们应用的几乎任何地方触发一个自定义事件。在 Ext JS 的早期版本中,你需要使用addEvent方法事先定义事件,但现在不再是这样了。然而,这有什么用呢?它提供了真正的实际优势吗?让我们通过一些糟糕的代码来展示:
// Theoretical "messages" view controller
message.save({
success: function(response) {
var viewport = Ext.ComponentQuery.query('viewport')[0];
// Refresh the list of records after adding this one.
viewport.down('list').getStore().reload();
viewport.showMessage(response.message);
this.lookupReference('editor').hide();
},
scope: this
});
我们保存一个消息记录。然后,在回调中,重新加载列表存储,在视口中显示消息,并隐藏编辑器组件。
这有三件事情,其中只有一件——隐藏编辑器——可能放在了正确的位置。其他的事情应该由它们自己的视图控制器来处理。这段代码会更好:
message.save({
success: function(response) {
this.fireEvent('messagesaved', response.message.id);
},
scope: this
});
现在,应用程序中的任何代码都可以监听messagesaved事件并相应地执行。这个例子带来的关键好处是,这个消息视图控制器不必了解任何其他视图、控制器,甚至不必了解应用程序的其余部分。
这使得消息视图和视图控制器对整个系统中的任何变化都更加有抵抗力,并且更容易测试。在理论上,它可以从应用程序中提取出来并单独测试。
事件与您
让我们回到我们的网络邮件应用程序。除非我们打算使用它们,否则在代码库中添加事件或任何其他内容都没有意义。我们可以在很多地方使用自定义事件,但数据绑定和路由将使这些地方变得不必要。
有一个地方自定义事件将非常有用:在创建回复时。作曲控制器负责这个任务,但是当回复保存后,我们还需要刷新消息视图,以便我们可以看到回复。这是一个利用自定义事件的完美地方。我们很快就会看到它是如何实现的。
编码 – 已经很久了
我们花了很多时间检查这个提议的应用程序,并思考我们可以用来优雅地创建它的技术。现在,是时候开始构建它了。
我们提到,这个应用程序的数据层非常直接,有很多样板代码,并且基于我们在前几章中获得的知识,没有什么是出乎意料的。让我们直接进入:
// app/model/BaseModel.js
Ext.define('Postcard.model.BaseModel', {
extend: 'Ext.data.Model',
schema: {
namespace: 'Postcard.model',
urlPrefix: 'http://localhost:3000',
proxy: {
type: 'rest',
url: '{prefix}/{entityName:uncapitalize}'
}
},
});
// app/model/Contact.js
Ext.define('Postcard.model.Contact', {
extend: 'Postcard.model.BaseModel',
fields: [
{ name: 'e-mail' }
]
});
// app/model/Message.js
Ext.define('Postcard.model.Message', {
extend: 'Postcard.model.BaseModel',
fields: [
{ name: 'id' },
{ name: 'people' },
{ name: 'subject' },
{ name: 'body' },
{ name: 'date', type: 'date' },
{ name: 'tag' }
]
});
// app/model/Tag.js
Ext.define('Postcard.model.Tag', {
extend: 'Postcard.model.BaseModel',
fields: [
{ name: 'name' }
]
});
// app/model/Thread.js
Ext.define('Postcard.model.Thread', {
extend: 'Postcard.model.BaseModel',
fields: [
{ name: 'id' },
{ name: 'people' },
{ name: 'subject' },
{ name: 'lastMessageOn', type: 'date' },
{ name: 'lastMessageSnippet' }
]
});
四个模型:Contact、Tag、Message和Thread,都扩展了包含我们的数据模式的BaseModel类。请注意,BaseModel类指定了一个 REST 代理,因此我们知道我们可以在模型上期望什么样的加载和保存行为。这是完全标准的,并且与我们之前的示例应用程序非常熟悉。存储相应地简单:
// app/store/Contacts.js
Ext.define('Postcard.store.Contacts', {
extend: 'Ext.data.Store',
model: 'Postcard.model.Contact',
alias: 'store.contacts',
autoLoad: true
});
// app/store/Tags.js
Ext.define('Postcard.store.Tags', {
extend: 'Ext.data.Store',
model: 'Postcard.model.Tag',
alias: 'store.tags',
autoLoad: true
});
// app/store/Messages.js
Ext.define('Postcard.store.Messages', {
extend: 'Ext.data.Store',
model: 'Postcard.model.Message',
alias: 'store.messages'
});
// app/store/Threads.js
Ext.define('Postcard.store.Threads', {
extend: 'Ext.data.Store',
autoLoad: true,
model: 'Postcard.model.Thread',
alias: 'store.threads'
});
每个模型类都有一个存储;除了消息之外,所有内容都将自动加载,因为我们需要在应用程序中使用它们,并且它们不需要传递任何参数。
它在控制之下
数据基础已经到位,让我们看看在这个应用程序中我们将首次使用的一个功能:Controller。这次不是视图控制器,而是我们在设计中提到的整体应用程序控制器:
// app/controller/Root.js
Ext.define('Postcard.controller.Root', {
extend: 'Ext.app.Controller',
routes: {
'home': 'onHome',
'': 'checkLogin'
},
onLaunch: function() {
this.checkLogin();
},
checkLogin: function() {
if(!window.localStorage.getItem('loggedin')) {
this.loginWindow = Ext.create('Postcard.view.login.Login');
} else {
Ext.create('Postcard.view.main.Main');
}
},
onHome: function() {
if(this.loginWindow) {
this.loginWindow.destroy();
}
this.checkLogin();
}
});
在之前的例子中,app/Application.js负责创建代表应用程序主要视图的视口。在这种情况下,根控制器承担这个角色。我们覆盖其onLaunch方法以检测用户是否已登录,无论他们处于哪个路由。它还指定了应用程序的默认 URL(只是一个空字符串),并再次检查有效的登录。
当检测到有效登录时,显示主要视图,否则显示登录视图。这是一个创建原始登录系统的超级简单机制。
登录视图
登录视图是一个位于屏幕中央的窗口,其中包含多个字段。它们的值绑定到视图模型上的登录对象,如下面的代码所示:
Ext.define('Postcard.view.login.Login',{
extend: 'Ext.window.Window',
xtype: 'login-window',
title: 'Login to Postcard',
closable: false,
autoShow: true,
controller: 'login',
viewModel: 'login',
items: [{
xtype: 'textfield',
name: 'e-mail',
bind: '{login.e-mail}',
fieldLabel: 'E-mail',
allowBlank: false
}, {
xtype: 'textfield',
bind: '{login.password}',
inputType: 'password',
fieldLabel: 'Password',
allowBlank: false
}, {
xtype: 'checkbox',
bind: '{login.rememberMe}',
fieldLabel: 'Remember Me?'
}],
buttons: [{ text: 'Login' }]
});
注意controller和ViewModel配置选项以及绑定值的前缀,它链接到视图模型上的登录对象。说到这个:
Ext.define('Postcard.view.login.LoginModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.login',
data: {
login: {}
}
});
这里没有发生任何事情,只是定义了这个登录对象。让我们继续到视图控制器:
Ext.define('Postcard.view.login.LoginController', {
extend: 'Ext.app.ViewController',
alias: 'controller.login',
listen: {
component: {
'button': {
click: function() {
window.localStorage.setItem('loggedin', true);
this.redirectTo('home');
}
}
}
}
});
视图控制器所做的只是监听登录表单按钮上的click事件,然后模拟一个成功的登录。为了简单起见,这个应用程序不对用户详情进行任何验证,所以我们只是立即触发重定向到主页路由。
我们之前看到,根控制器处理主页令牌,这会移除登录视图并创建主视图。让我们继续前进,现在就看看那个视图。
主力
回顾我们的设计,主视图是我们应用程序中其余 UI 的容器。它看起来像这样:
// app/view/main/Main.js
Ext.define('Postcard.view.main.Main', {
extend: 'Ext.Panel',
xtype: 'app-main',
plugins: ['viewport', 'responsive'],
controller: 'main',
viewModel: 'main',
session: true,
responsiveConfig: {
'tall': {
layout: {
type: 'card'
}
},
'wide': {
layout: {
type: 'hbox',
align: 'stretch'
}
}
},
dockedItems: [
{ xtype: 'app-header' },
{
dock: 'bottom', xtype: 'button', cls: 'logout',
overCls: '', focusCls: '', text: 'Logout'
}
],
items: [
{ xtype: 'threads', flex: 1 },
{
xtype: 'container',
flex: 1,
defaults: { hidden: true },
items: [
{ xtype: 'messages' },
{ xtype: 'composer' }
]
}
],
isCard: function() {
return this.getLayout().type === 'card';
}
});
这里有很多事情在进行,但只有几个新概念。注意,我们向这个类添加了几个插件:viewport和responsive。因为我们没有让我们的应用程序自动创建一个作为viewport的视图,所以添加viewport插件就会做到这一点。responsive插件允许你使用我们之前在章节中讨论过的responsiveConfig选项。
在这个例子中,对于高屏,即高度大于宽度的屏幕,例如竖屏,我们使用卡片布局。对于宽屏,即宽度大于高度的屏幕,我们使用hbox布局,因为这里有更多的水平空间。这种简单的声明性设置响应视图的方式使我们能够仅通过几行配置就对应用程序进行非常显著的变化。
我们向这个视图添加了一个实用方法来帮助我们操作响应设置;isCard视图将使我们能够整洁地确定这个视图是否正在使用card布局或hbox布局。
提示
语法糖是一种更易于阅读或更好地表达其意图的写作方式。isCard方法就是这样一个例子,虽然不是必需的,但它可以使调用代码更短且更容易理解。
此配置的其余部分应该非常熟悉:两个dockedItems,一个是应用程序标题视图,另一个提供注销按钮,以及应用程序在项目数组中的其他三个视图。
主 ViewModel
初看,这段代码看起来很标准,但当你回顾主视图本身的代码时,你会注意到currentTag或searchTerm将不会被用到。那么,为什么定义它们如果它们不会被使用呢?参考以下代码中的ViewModel:
// app/view/main/MainModel.js
Ext.define('Postcard.view.main.MainModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.main',
data: {
currentTag: 'Inbox',
searchTerm: null
}
});
在 Ext JS 中,我们有父视图模型和子视图模型的概念。配置在主视图上的主视图模型将可供主视图的所有子组件使用。这意味着子视图可以获取主视图的数据,也可以将其信息传递回主视图。这是在两个子组件之间传递数据的一种极好的方式。
主视图控制器
在以下代码中参考ViewController:
// app/view/main/MainController.js
Ext.define('Postcard.view.main.MainController', {
extend: 'Ext.app.ViewController',
alias: 'controller.main',
routes: {
'thread/new': 'showRightPane',
'thread/:id/messages': 'showRightPane',
'thread/:id/messages/new': 'showRightPane'
},
listen: {
component: {
'button[cls="logout"]': {
click: function() {
window.localStorage.removeItem('loggedin');
window.location = '/';
}
}
}
},
showRightPane: function(id) {
if(this.getView().isCard()) {
this.getView().setActiveItem(1);
}
}
});
这里最有趣的事情是路由处理程序;我们给showRightPane处理程序提供了几个路由。回顾一下我们对我们应用程序中用户流程和路由的考察,许多路由需要我们确保右侧面板是可见的。这仅适用于响应式纵向视图,所以我们只有在纵向视图的卡片布局可用时才更改活动面板。
有趣的部分是,我们有一些只完成我们预期部分的路由处理程序。传递 ID 并显示子视图的部分在哪里?不用担心,我们很快就会重新访问这个问题。
家庭的头部
参考以下Header.js文件:
// app/view/header/Header.js
Ext.define('Postcard.view.header.Header', {
extend: 'Ext.Toolbar',
requires: ['Postcard.view.header.HomeButton'],
xtype: 'app-header',
height: 60,
controller: 'header',
viewModel: 'header',
session: true,
items: [
{
xtype: 'home-button', cls: 'title', html: 'Postcard',
bind: { hidden: '{menuButton.pressed}' }
},
{
xtype: 'tbspacer',
bind: { hidden: '{menuButton.pressed}' } },
{
xtype: 'textfield', flex: 1,
cls: 'search-box', emptyText: 'Search',
bind: '{searchTerm}',
plugins: ['responsive'],
responsiveConfig: {
'tall': {
hidden: true,
bind: { hidden: '{!menuButton.pressed}' }
},
'wide': { hidden: false }
}
},
{
xtype: 'tbfill',
bind: { hidden: '{menuButton.pressed}' }
},
{
xtype: 'combobox', flex: 1, editable: false,
displayField: 'name', idField: 'name',
queryMode: 'local', forceSelection: true,
bind: {
store: '{tags}', value: '{currentTag}'
},
plugins: ['responsive'],
responsiveConfig: {
'tall': {
hidden: true,
bind: { hidden: '{!menuButton.pressed}' }
},
'wide': { hidden: false }
}
},
{
xtype: 'button', cls: 'new-message',
text: 'New Message',
bind: {
hidden: '{menuButton.pressed}'
}
},
{
text: 'Menu', reference: 'menuButton',
width: 30, enableToggle: true,
plugins: ['responsive'],
responsiveConfig: {
'tall': { hidden: false },
'wide': { hidden: true }
}
}
]
});
哇!这实际上是一个标题栏的很多代码!回顾一下我们最初对这个视图的类设计,我们确实说过“发生了很多令人惊讶的事情”,所以我们并没有错。
在绑定协议这一节中,我们讨论了该类中发生的事情的一个简化示例。菜单按钮上的reference选项用于允许其他标题组件绑定到菜单的按下值;查看前面的代码,你会看到这种方法在各种地方被用来在菜单按钮切换时显示或隐藏组件。
我们不仅再次使用响应式插件来设置标题组件的初始隐藏状态,而且还使用它来确保隐藏配置仅在视口高度时绑定。这避免了当菜单按钮甚至未被使用时其他组件初始可见性的问题。这种条件绑定开辟了一些令人兴奋的可能性。
值得注意的还有两件事:我们提到主视图模型中有些值看起来没有被使用。好吧,现在它们就在这里,与标签过滤器组合和搜索文本字段的值绑定。当这些值发生变化时,它们将被传递到主视图模型,并可供其他组件使用。
值得注意的是最后一项:一个神秘的主页按钮组件。这个代码看起来是这样的:
Ext.define('Postcard.view.header.HomeButton', {
extend: 'Ext.Container',
xtype: 'home-button',
afterRender: function() {
this.callParent(arguments);
this.getEl().on('click', function() {
this.fireEvent('click');
}, this);
}
});
我们将其用作一个假按钮,扩展简单容器以触发click事件。这允许你获得一个轻量级、无样式且可点击的组件,用作主页按钮。
标题视图模型
在以下代码中参考ViewModel:
Ext.define('Postcard.view.header.HeaderModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.header',
stores: {
tags: {
type: 'tags',
session: true
}
}
});
这个ViewModel类提供了填充标签过滤器组合的标签。我们使用会话来确保在整个应用程序中使用相同的标签实例。
标题视图控制器
在以下代码中参考ViewController:
// app/view/header/HeaderController.js
Ext.define('Postcard.view.header.HeaderController', {
extend: 'Ext.app.ViewController',
alias: 'controller.header',
listen: {
component: {
'button[cls="new-message"]': {
click: function() {
this.redirectTo('thread/new');
}
},
'home-button': {
click: function() {
this.redirectTo('home');
}
}
},
controller: {
'*': {
tagadded: function() {
this.getViewModel().get('tags').reload();
}
}
}
}
});
有两个组件事件监听器,一个在新的消息按钮上,一个在主页按钮上。两者都重定向到其他控制器将消费的路由。
此外,还有一个控制器监听器,等待 tagadded 事件,并在 ViewModel 上刷新标签存储。这很好,因为我们不必担心这个事件来自哪里或哪个组件发出的;我们只需独立消费它并执行我们感兴趣的操作。
反过来也适用,这意味着 tagadded 事件的发出者不需要弄清楚如何刷新标签筛选组合;相反,它只需声明添加了一个标签,然后放心。
解开线程
线程是一系列电子邮件消息的集合,线程视图构成了我们应用程序的左侧面板。它看起来像这样:
// app/view/threads/Threads.js
Ext.define('Postcard.view.threads.Threads', {
extend: 'Ext.DataView',
xtype: 'threads',
cls: 'thread-view',
viewModel: 'threads',
controller: 'threads',
border: true,
deferEmptyText: false,
emptyText: 'No messages',
autoScroll: true,
itemSelector: '.thread',
bind: '{threads}'
tpl: new Ext.XTemplate('<tpl for=".">',
'<div class="thread">',
'<div class="date">{lastMessageOn:date("H:m")}</div>',
'<div class="details">',
'<div class="header">{people} - {subject}</div>',
'<div class="body">{[this.stripHtml(values.lastMessageSnippet)]}</div>',
'</div>',
'</div>',
'</tpl>', {
stripHtml: function(html) {
var div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
}
})
});
从我们的设计工作中我们知道我们会使用 DataView 来实现这个类,其实现结果相当直接。我们在视图模型本身(也称为 threads)上也绑定了其 store。
回顾设计,你会发现我们预计会有一个方法来从消息体中删除 HTML,现在它就在这里;一点小技巧,使用一个临时的 DOM 元素让浏览器为我们完成工作。
线程视图模型
参考以下代码中的 ViewModel:
// app/view/threads/ThreadsModel.js
Ext.define('Postbox.view.threads.ThreadsModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.threads',
stores: {
threads: {
type: 'threads',
remoteFilter: true,
filters: [
{
property: 'tag',
value: '{currentTag}'
},
{
property: 'searchTerm',
value: '%{searchTerm}%'
}
]
}
}
});
这实际上是整个应用程序中最复杂的视图模型,其中大部分复杂性应该从第六章 实用 - 监控仪表板中熟悉。过滤器数组,连同 remoteFilter 设置,将负责通过服务器发送包含筛选定义的 JSON 对象。在这种情况下,我们看到我们正在消费来自主视图模型作为标签选择组合和头部搜索字段广播的值。
我们之前讨论过这个问题,但再次强调一下。数据从头部视图流向主视图模型,然后进入线程视图。这是在不让这些部分相互了解的情况下,在应用程序的部分之间进行通信的一种极其简单的方式。
线程视图控制器
参考以下代码中的 ViewController:
// app/view/threads/ThreadController.js
Ext.define('Postcard.view.threads.ThreadsController', {
extend: 'Ext.app.ViewController',
alias: 'controller.threads',
listen: {
component: {
'threads': {
itemclick: function(dataview, record) {
this.redirectTo('thread/' + record.getId() + '/' + 'messages');
}
}
},
controller: {
'*': {
threadschanged: function() {
this.getViewModel().get('threads').reload();
}
}
}
}
});
更多的事件监听器,等等。我们知道它们会派上用场,但它们无处不在!在线程视图控制器中,我们监听 DataView 的 itemclick 事件,并简单地重定向应用程序,以便另一个控制器的路由来处理它。发射并忘记。
相反,我们监听 threadschanged 事件,当添加线程时发出。这允许你刷新视图模型中 DataView 的存储,以查看添加的线程的效果。我们不知道或关心 threadschanged 来自哪里。
我是最好的消息
参考以下代码中的 Messages.js 文件:
// app/view/messages/Messages.js
Ext.define('Postcard.view.messages.Messages', {
extend: 'Ext.Panel',
xtype: 'messages',
controller: 'messages',
viewModel: 'messages',
autoScroll: true,
session: true,
bbar: [
{
xtype: 'combobox', displayField: 'name',
idField: 'name',
reference: 'tagPicker',
queryMode: 'local', value: 'Inbox',
bind: { store: '{tags}' }
},
{
text: 'Set Tag',
itemId: 'setTag'
},
'->',
{
text: 'Reply',
itemId: 'reply',
reference: 'replyButton'
}
],
items: [{
xtype: 'dataview',
bind: '{messages}',
flex: 1,
cls: 'message-view',
tpl: new Ext.XTemplate('<tpl for=".">',
'<div class="message">',
'<div class="date">{date:date("H:m")}</div>',
'<div class="details">',
'<tpl if="xindex == 1">',
'<div class="header">{people} - {subject}</div>',
'</tpl>',
'<div class="body">{body}</div>',
'</div>',
'</div>',
'</tpl>'),
itemSelector: '.message'
});
在理想情况下,代码应该作为设计过程的最终成果从你的团队流出。任何困难的类或方法都应该是一个代码激增的一部分。你的数据层应该是在你的后端 API 之上设计的。用户界面已经在线框图中描述,用户故事提供了路由,等等。
这就是我们现在看到的情况。作为经验丰富的 Ext JS 开发者,我们知道如何配置组合框和存储。这本书并不是为了帮助做这件事。我们将继续关注设计和那些使你的代码简单化的决策。
看看之前的类。消息视图的设计,其 DataView 嵌套在面板内,允许你使用bbar;我们在写代码之前就知道这一点。这是良好设计的核心。对于理解他们所使用技术的开发者来说,实现变得容易且可预测,因为所有的事情都已经在事先考虑过了。
消息视图模型
参考以下代码中的ViewModel:
// app/view/messages/MessagesModel.js
Ext.define('Postcode.view.messages.MessagesModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.messages',
stores: {
messages: {
type: 'messages'
},
tags: {
type: 'tags',
session: true
}
}
});
看吧!有了前置设计,你可以将文档交给一个开发者,让他们创建消息视图模型。由于类的形状已经决定,所以出错的可能性很小。
消息视图控制器
话虽如此,有时代码会很长,所以真正了解发生了什么非常有帮助,如下所示:
// app/view/messages/MessagesController.js
Ext.define('Postcard.view.messages.MessagesController', {
extend: 'Ext.app.ViewController',
alias: 'controller.messages',
listen: {
component: {
'#reply': {
click: 'onReplyClick'
},
'#setTag': {
click: 'onTagChange'
}
}
},
routes: {
'thread/:id/messages': 'onShowThread',
'thread/new': 'onNewThread'
},
onShowThread:function(id) {
this.getViewModel().get('messages').load({
params: {
parentId: id
},
callback: function(records) {
this.getView().show();
},
scope: this
});
},
onNewThread: function() {
this.getView().hide();
},
onReplyClick: function() {
this.redirectTo(window.location.hash + '/new');
},
onTagChange: function() {
var tagPicker = this.lookupReference('tagPicker'),
newTag = tagPicker.getValue(),
viewModel = this.getViewModel(),
threadParent = viewModel.get('messages').getAt(0);
threadParent.set('tag', newTag);
threadParent.save({
callback: function() {
this.getViewModel().get('tags').reload();
this.fireEvent('tagadded');
this.fireEvent('threadschanged');
},
scope: this
});
}
});
如同往常一样,我们有我们的事件监听器。让我们看看组件监听器;首先是一个处理回复按钮的click事件的监听器,因为它很简单。它只是重定向到一个将负责设置应用程序以回复线程的路由。
接下来,是处理点击“设置标签”按钮的onTagChange方法。这将从标签组合框中获取选定的值并将其设置为线程中第一条消息的标签。然后,它将那条消息保存到服务器。
注意这个保存请求的回调(它触发了我们之前见过的两个事件)。一个(threadschanged)通知应用程序线程以某种方式发生了变化;在这种情况下,是一个线程的标签发生了变化,因此可能需要刷新线程列表。另一个(tagadded)表示可能有一个新的标签,任何感兴趣的类都应该相应地刷新它们的标签数据。
接下来的两个处理程序是用于路由的,但这里有一些需要注意的地方。这些路由已经被主视图控制器处理过了!这是一个强大的功能;我们可以在多个位置处理路由,这样对这条路由感兴趣的类就可以做它们自己的事情。这避免了我们不得不在主视图控制器中做所有的工作,例如,消息视图控制器可以负责加载消息,而不是在主视图控制器中这样做。
将使用路由的方式与使用事件的方式进行比较。它们非常相似;我们可以重定向到一个路由,触发这个重定向然后忘记它,或者,在应用程序中控制器将处理这个路由的地方。使用路由,你可以获得在 URL 中保持状态的额外好处,从而实现书签支持。使用事件,你可以在事件参数中发送复杂的数据。两者都有其优势。
组合完成
现在,我们来到了允许你实际发送电子邮件的视图,这是本应用中一个相当重要的部分!
// app/view/composer/Composer.js
Ext.define('Postcard.view.composer.Composer', {
extend: 'Ext.form.Panel',
xtype: 'composer',
cls: 'composer',
viewModel: 'composer',
controller: 'composer',
session: true,
items: [
{ xtype: 'hiddenfield', bind: '{newMessage.parentId}' },
{
fieldLabel: 'To', xtype: 'combo', width: '100%',
valueField: 'e-mail',
displayField: 'e-mail',
queryMode: 'local',
bind: {
hidden: '{newMessage.parentId}',
store: '{contacts}',
value: '{newMessage.people}'
}
},
{
xtype: 'textfield', fieldLabel: 'Subject',
cls: 'subject', emptyText: 'Subject',
bind: {
value: '{newMessage.subject}',
hidden: '{newMessage.parentId}'
},
width: '100%'
},
{
xtype: 'htmleditor',
bind: { value: '{newMessage.body}' }
}
],
bbar: [
'->',
{ xtype: 'button', text: 'Send' }
]
});
另一个直接的组件定义,表单字段的值绑定到视图模型中的newMessage对象,以供以后使用。这里还有一个视图模型技巧,即如果这个newMessage对象有一个parentId值,我们知道我们正在回复一个现有的线程。这意味着我们可以隐藏主题和收件人表单字段,因此我们将parentId绑定到它们的隐藏值,使这一步自动如下:
// app/view/composer/ComposerModel.js
Ext.define('Postcard.view.Composer.ComposerModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.composer',
stores: {
contacts: {
type: 'contacts'
}
},
data: {
newMessage: {}
}
});
我们有一个与绑定到收件人组合框的视图相对应的联系人存储,然后是对之前讨论过的newMessage对象的空定义。
// app/view/composer/ComposerController.js
Ext.define('Postcard.view.composer.ComposerController', {
extend: 'Ext.app.ViewController',
alias: 'controller.composer',
listen: {
component: {
'button': {
click: 'onSendClick'
}
}
},
routes: {
'thread/:id/messages': 'hideComposer',
'thread/:id/messages/new': 'showComposer',
'thread/new': 'showComposer'
},
hideComposer: function() {
this.getView().hide();
},
showComposer: function(parentId) {
this.getViewModel().set('newMessage.parentId', parentId);
this.getView().show();
},
onSendClick: function() {
var session = this.getSession(),
data = this.getViewModel().get('newMessage');
session.createRecord('Postcard.model.Message', {
people: data.people,
subject: data.subject,
body: data.body,
parentId: data.parentId
});
var batch = session.getSaveBatch().start();
batch.on('complete', this.onSaveComplete, this);
},
onSaveComplete: function(batch, operation) {
var record = operation.getRecords()[0],
id = record.getId(),
parentId = record.get('parentId');
this.redirectTo('thread/' + (parentId || id) + '/messages');
}
});
在组件监听器中,我们使用onSendClick方法处理发送按钮的click事件。这将在当前会话中创建一个新的记录并将其保存到服务器。在callback方法中,我们将应用程序调度到显示线程消息的路由,但请注意,如果这是一个全新的线程,我们将使用新消息的 ID,如果它是一个回复,我们将使用新消息的parentID。
在处理路由方面,有一个(hideComposer)在查看线程中的消息时隐藏编辑器,因为在这个时候没有必要让它可见。然后,还有一个第二个(showComposer)在newMessage上设置parentId并显示编辑器。对于新线程,路由没有捕获 ID,所以parentId参数将是未定义的,newMessage.parentId将被设置为这样的值。这使编辑器视图本身中的收件人和主题的自动显示和隐藏成为可能。在设计应用程序时,我们将其称为currentThreadId,但现在我们可以看到将其纳入newMessage对象并在保存新记录时将其传递到服务器是有意义的。
决定主题
我们已经涵盖了除外观之外的所有应用部分。回想一下本章前面展示的应用程序截图。实际上,看看登录屏幕,看看它与标准的 Ext JS 应用程序有何不同:

我们已经改变了诸如窗口框架的字体和颜色等关键事项,但请查看执行此操作的代码:
// sass/etc/all.scss
$body-font-family: 'Roboto', sans-serif;
$window-base-color: #fff;
$window-header-color: #000;
$window-padding: 20px;
$window-header-font-family: $body-font-family;
$toolbar-footer-background-color: #fff;
$form-label-font-family: $body-font-family;
Ext JS 主题提供了一系列由它们前面的美元符号指定的变量。通过定义我们自己的,例如$body-font-family,并覆盖现有的变量,我们可以轻松地调整应用的外观以适应不同的需求。
不仅于此,从维护的角度来看,设置几个变量远比编写一大堆 CSS 规则来覆盖主题样式表要优越得多。我们可以避免像 CSS 优先级和找到正确的选择器这样的问题,并继续努力让我们的应用脱颖而出。然而,如果我们需要的话,我们可以降级使用 SASS,这是 Ext JS 用于主题的类似 CSS 的编译器。看看线程视图的样式:
// sass/src/view/threads/Threads.scss
.thread-view {
font-family: $body-font-family;
margin: $gutters;
.x-view-item-focused {
outline: 0 !important;
.header {
color: rgb(255, 20, 108);
}
}
.header {
font-size: 125%;
}
.body {
font-size: 105%;
color: #666;
padding: 10px 0;
line-height: 160%;
}
.date {
color: $subdued-grey;
font-size: 150%;
padding: 0 15px;
font-weight: bold
}
}
.thread {
display: flex;
padding: 50px;
&:hover {
cursor: pointer;
}
.details {
border-bottom: 1px solid $subdued-grey;
}
&:last-child .details {
border-bottom: 0 !important;
}
.date {
width: 80px;
text-align: right;
}
.details {
flex: 1;
}
}
线程视图是一个 DataView,这意味着其模板可以包含任何自定义 HTML。在这里编写新的 SASS 规则是有意义的,但 Ext JS 允许你以模块化和可重用的方式来做这件事,类似于它提供编写 JavaScript 类的功能。在接下来的几章中,我们将更深入地讨论这个和其他主题功能。
摘要
我们在设计并实现这个应用时采取了不同的方法。而不是讨论我们在前几章中已有的相同细节,我们避免了重复旧地,而是讨论了一个更高层次的架构。
通过比以往更广泛地实现路由、视图模型绑定和事件,我们展示了声明式方法如何简化 Ext JS 的代码,并使其理解起来极其容易。同时,发出和监听事件的组件更加解耦,导致错误更少,可测试性更高。
我们还涉及了主题,展示了几行代码如何极大地影响应用的外观,以及我们如何编写自定义样式规则来创建全新的组件。
在下一章中,我们将继续探索我们已经使用的架构思想,但将它们扩展到设计阶段如何最好地使用它们。我们将探讨 Ext JS 架构如何应用于主题,以及我们如何继续在整个应用中提高代码的重用性。
第八章. 实践 – 问卷组件
现在我们已经有了三个实际的应用程序。目标是展示设计应用程序的过程,并通过一系列谨慎的步骤来思考它,以清晰地描绘出我们将要构建的项目。这一次,我们将有所改变,而不是查看“我们”如何设计和构建应用程序,而是我将引导你通过我创建多步骤、动态问卷组件的自身过程。
这里列出了本章将要讨论的应用程序的功能:
-
一个可重用的包,可以包含在任何应用程序中
-
主题变量和混入,以允许在其他应用程序中进行视觉集成
-
将问卷拆分为多个步骤,每个步骤包含多个问题
-
从传入的服务器数据动态构建 UI
-
由问卷生成的 JSON 对象,表示步骤、问题和用户提供的答案
-
使用 Ext JS 关联来帮助结构化应用程序数据
为什么从包容性的“我们”转向审视我自己的架构过程?我希望这能让你更实际地了解应用程序设计中涉及的思想过程。你还可以看到建筑师总是有多个正确的路径可以选择。在编写任何代码之前绘制这些路径并调查它们,将避免许多项目遇到的“分析瘫痪”,当需要答案时,这会导致项目停滞不前。
应用程序设计 – 我的风格
当我思考一个应用程序时,我的第一步并不涉及花哨的工具或 IDE。一旦确立了客户需求,我会尽可能地远离电脑,从我的脑海中清除 JavaScript 和 Ext JS 的所有想法,并尽可能地消除所有干扰。
完成这些后,我拿起一个大笔记本和一支铅笔开始涂鸦。我从未找到过比一点手工劳动更好的快速草拟想法的方法,所以当我以我们在前几章中看到的方式设计时,我可以避免陷入绘图工具和 UML 图中的困境。
小贴士
所有这些都需要在后续的文档化设计决策中规范化。目的是快速测试想法并避免死胡同。
我是如何开始的?

嗯,它并不漂亮,但你可以看到这与我们在前几章中看到的 UI 视图图之间的关系。请注意,这些图像的意图并不是详细说明设计,而是简单地提供我对过程的洞察,包括所有瑕疵!最终,我会用这些草图构建类似的东西:

我从原始草图中挑选出了哪些功能?
-
随步骤变化的问卷标题
-
包含问卷引言和结论的窗格
-
应用中每个步骤的窗格
-
一个步骤可以有一个简介文本
-
一个问题需要解释文本
-
导航按钮,用于在步骤之间移动
-
一个进度条来显示已完成的步骤数量和剩余的数字
此外,这个组件需要能够验证在用户可以移动到下一步之前已经完成的步骤中的必填问题。
与其花大量时间在电脑前创建完美的 UI 设计,我能够快速地 flesh out 一个想法,看看它看起来如何,这让我对我的设计要求有了了解。
数据结构
这些初步的草图使我能够快速思考支撑组件的底层数据。在前几章中,我们在这里停下来讨论设计要求是否可以通过 Ext JS 来满足。相反,我们将继续前进,把技术放在一边,纯粹关注架构。
在某些方面——因为我们采用 MVVM——架构是技术无关的。我们可以创建我们想要构建的应用,而不会受到我们使用的框架的限制。这可能会很危险;在那儿,我们可能会失去对特定客户需求的关注,或者开始草拟一些可能无法实现的东西。为此,我们引入了新的检查和平衡措施,我们稍后会讨论。
现在,我继续用铅笔和纸来制定数据结构。和以前的情况一样,UI 设计决定了底层的数据需求。以下是我设计涂鸦的快速洞察:

和前几章一样,只是稍微乱一些!再次提醒,不要担心细节。看看粗略的笔记和涂鸦,你会意识到应用设计的初期是一个有机的过程,事情可能会(并且确实会)走一条蜿蜒的道路。以下是我涂鸦中关于数据结构的一些想法:
-
问卷
-
标题
-
简介
-
结论
-
步骤[]
标题
简介
问题[]
名称
是否必需?
问题文本
-
-
字段类型
在这个阶段,我还没有担心什么继承什么的问题。我只知道我想有一个包含许多步骤的单个问卷,每个步骤都会有许多问题。从这里,我只是 fleshed out 每个类需要的字段。
仔细考虑
在这一点上,我必须告诉你,我对你撒了谎。在纯粹的意义上,想象你可以在纸上绘制出完整的应用架构图是极好的,但现实中,你总是带着对现实世界的关注来做这件事。回顾我上一页的铅笔涂鸦,你会看到一些带有巨大下划线的笔记,比如“关联”。这是因为我知道之前描述的数据结构将使用 Ext JS 的关联来实现,这是我们在这本书中还没有使用过的功能,但它可以非常强大。
尽管如此,我尽可能地抛开细节的思考,并信任我对 Ext JS 的了解,使其在没有任何警告的情况下浮现出来。
数据流
考虑到之前的数据结构,我开始思考从 API 提供的信息如何通过视图模型流动,并允许我构建用户界面。
我有一种感觉,验证将会使应用程序复杂化,所以我开始勾勒出受影响的 UI 部分的想法。用户必须完成当前步骤的验证才能继续到下一步。在这个过程中,其强制性问题已经回答,因此“下一步”按钮需要在此发生之前禁用。我开始再次绘图,但很快,就出现了一种“分析瘫痪”的程度。这应该如何工作?有几个 Ext JS 特性可能有所帮助;模型验证绑定在这里是否适用,或者是否需要手动处理验证事件。早些时候,我提到了“制衡”以避免构建 Ext JS 不支持的东西;我决定抽出一些时间来调查在 Ext JS 中实现这一点的最佳方式。
就像我们在第五章中做的那样,称之为“原型”,或者称之为“原型”。我们是否编写并丢弃这段代码并不重要;如果它有助于过程,那么它是一项有价值的练习。我是独立构建的(在 MVC 或 MVVM 结构之外),而且很糟糕。看看这个代码片段:
s.questions().each(function(q) {
var input = stepForm.add({
xtype: 'textfield', modelValidation: true,
viewModel: {
data: { question: q },
formulas: {
isValid: {
bind: {
bindTo: '{question}',
deep: true
},
get: function() {
return this.get('step').isValid();
}
}
}
},
bind: '{question.answer}',
fieldLabel: q.get('text')
});
});
我不希望这段代码成为我的最大敌人。我们写的每一行代码不应该是纯净和经过深思熟虑的吗?在现实世界中,几乎没有人能看到这段代码。它是为了测试我的想法并找出什么感觉是对的,它不需要整洁或可维护。它是一次性的。这是我想要测试的内容:
-
modelValidation功能与自动生成的表单字段一起使用会有用吗? -
我能否绑定到问题以获取当前步骤的验证状态?
-
使用这些功能会感觉自然吗?或者有更简单的方法吗?
我们已经看到视图模型可以用来创建简单的声明性应用程序,并且当正确使用时它们可以非常强大。在这个应用程序中,字段是从 API 数据生成的,并且每个字段都有自己的问题模型。通过视图模型将这个模型的验证状态传播到用户界面的相关部分似乎可能很困难。我发现了什么?
-
一个视图模型绑定的技巧
-
与 Sencha 团队关于
modelValidation行为的看法略有不同 -
一种将按钮状态绑定到关联状态的方法
这似乎是对这段肮脏代码的一个很好的结果。让我们逐一查看每个要点。
一个绑定技巧
这段代码片段来自一个问题表单视图模型:
formulas: {
isValid: {
bind: {
bindTo: '{question}',
deep: true
},
get: function() {
return this.get('step').isValid();
}
}
}
视图模型有一个问题模型,并从其父视图模型继承步骤模型。我们告诉 Ext JS 我们希望绑定到问题模型的变化,深层次的变化,因此任何属性的变化都会触发父步骤上的isValid方法。这很好,因为isValid方法可以反过来触发父步骤上的变化,将问题的状态传播到步骤。下面是isValid的代码:
isValid: function() {
var valid = true;
this.questions().each(function(q) {
if(q.isValid() === false) {
valid = false;
}
});
this.set('valid', valid);
return valid;
}
它检查所有子问题的有效性,并相应地设置步骤的有效属性。然后我们可以绑定到这个有效值,并让它影响其他事物(例如 UI 中“下一步”按钮的禁用状态)。
这一点稍微有些反直觉,因为我们并不是直接绑定到isValid公式上。相反,我们使用它来监视问题上的变化,然后触发步骤模型上的变化。
不同的意见
5.0 和 5.0.1 之间有行为上的变化。modelValidation功能将模型中的验证与表单中的验证匹配,减少了代码的重复。在 Ext JS 5 中,当表单值改变时,这些变化将通过绑定同步到模型。在 Ext JS 5.0.1 中,这种同步只会发生在表单字段有效时。想法是模型不应该基于表单变化而处于无效状态,但问题发生如下:
-
用户完成表单字段,它变为有效
-
表单值传递到模型的字段,该字段随后变为有效
-
用户更改表单字段使其再次无效
-
变化没有传递到模型,模型仍然有效
这意味着当绑定到模型值时,你得到的东西并不反映 UI 的真实状态。模型会说一切有效,而实际上 UI 却表示不同。在这个应用程序的情况下,计划是将“下一步”按钮绑定到模型状态,这样在先前的情境中按钮会被错误地启用。我的解决方案是覆盖这个行为,并返回到 Ext JS 5.0 的行为,如下所示:
Ext.form.field.Text.prototype.publishValue = function () {
var me = this;
// Don't check for errors when publishing the field value
//if (me.rendered && !me.getErrors().length) {
if (me.rendered) {
me.publishState('value', me.getValue());
}
}
另一个替代方案是将“下一步”按钮绑定到表单字段的值而不是模型值,但在我们的情况下,我们需要对模型值进行进一步的工作,所以这不是一个很好的解决方案。
一种达到目的的手段
所有这些允许你有一个从每个单独的问题字段到问题模型,再到步骤模型,以及依赖于它的用户界面更高层次配置的链。
当我写这一章时,我脑海中还有其他关于如何实现它的想法。一个处理验证事件并使用监听器通过应用程序传播状态的解决方案是另一种方法,但最终的想法虽然比我们之前的绑定代码稍微复杂一些,但最终并没有那么优雅。
Ext JS 允许您监视关联的深度变化,这会有所帮助,但在撰写本书时,这一变化尚未记录。希望它将在 5.0.1 版本之后出现!
注意
您可以在 5.0.1 版本说明中看到这一变化,作为 EXTJS-13933 在dev.sencha.com/extjs/5.0.1/release-notes.html的记录。
草图绘制
我对应用程序的最大担忧已经得到了满意的解决;我不会详细介绍我的代码激增,因为稍后我会重新使用其中的一些代码。
我继续研究在这个应用程序中控制器的作用。我们需要处理哪些由用户发起的事件?

控制器的初始草图;这里有一些东西没有进入最终版本
用户可以执行两个主要操作:完成问卷本身和在页面之间导航。控制器将关注的是导航。
在这一点上,我相当自信地认为我已经详细说明了应用程序的大部分难点。作为架构师,我绘制的每个图表和交互都需要转化为正式文档,以便为开发者提供参考点。以下是我为 UI 提出的方案:

没有必要重复我已经向您展示的工作,因此我不会逐一介绍这些正式化的图表,但应该强调的是,这不是一个可以跳过的步骤。作为开发者,在编写代码时有一个坚实的基础设计文档是必不可少的,同时也可以让架构师负责。下一步是从小纸条和笔回到电脑前,开始编写我们的问卷组件的代码。
准备就绪
本章我们将构建的问卷组件将在任何应用程序中可重用。为此,我们可以使用 Ext JS 包创建一个可以像正常 Ext JS 应用程序一样构建的代码包,但它可以作为一个组件纳入我们的未来应用程序中。我们已在第三章中讨论了包,但现在我们将看到它们在实际中的应用。
这是启动作为此组件及其测试应用程序基础的项目的方式:
sencha generate workspace ./questionnaire-space
cd questionnaire-space
sencha generate package wizard
sencha -sdk ~/Downloads/ext-5.0.1 generate app Questions ./questions
创建一个包需要我们首先创建一个工作区,因此在使用我下载的最新版本的 SDK 之后,我转向 workspace 目录并发布了创建包本身的命令,我将其命名为 wizard。然后,我创建了一个测试应用程序,在开发期间将托管该包;我将其称为 questions。
我随后启动了测试应用程序,并使用以下命令:
cd questions-space/questions
sencha app watch
现在 Web 服务器已经启动并运行在http://localhost:1841/。最后一步是告诉测试应用程序包含新的包。我编辑了questions-package/questions/app.json并修改了requires成员:
// was "requires": []"requires": ["wizard"]
由于我们给包命名为wizard,只需将其添加到数组中,我们就可以开始了。
我们将要构建的包位于questions-space/packages/wizard,并且最初将包含与 Ext JS 应用程序相同的多数目录。我们现在可以移动到这个目录,并像过去几章中那样开始构建它。
数据层
在编写原型时,我部分构建了所需的数据类,因此现在让我们完全实现它们:
// packages/wizard/src/model/Questionnaire.js
Ext.define('Wizard.model.Questionnaire', {
extend: 'Ext.data.Model',
fields: [
{ name: 'title' },
{ name: 'introduction' },
{ name: 'conclusion' }
],
proxy: {
type: 'rest',
url: 'http://localhost:3000/questionnaire'
},
toJSON: function() {
return this.getData(true);
}
});
这里是标准操作,只有一个例外,那就是一个toJSON方法,应用程序可以覆盖它以获取他们可以用于进一步处理的 JSON 格式。默认实现返回问卷对象的数据及其关联数据。或者,他们可以覆盖代理配置以将问卷数据保存到他们自己的服务器。
让我们看看我在问卷中表示步骤所使用的模型:
// packages/wizard/src/model/Step.js
Ext.define('Wizard.model.Step', {
extend: 'Ext.data.Model',
fields: [
{ name: 'title' },
{ name: 'introduction' },
{
name: 'questionnaireId',
reference: {
type: 'Wizard.model.Questionnaire',
inverse: 'steps'
}
}
],
isValid: function() {
var valid = true;
this.questions().each(function(q) {
if(q.isValid() === false) {
valid = false;
}
});
this.set('valid', valid);
return valid;
}
});
在Step模型上有几点需要注意;首先,关联的使用,我意识到这将提供一种非常简单的方式来通过单个操作加载整个问卷的嵌套数据。
提示
关联是通过在具有type选项指定父模型完整类名和反向为在此父Questionnaire上创建的关联存储名称的字段上使用reference选项来创建的。Ext JS 5 的关联一开始可能会有些令人困惑,因为它们总是在子模型上定义,而不是在父模型上。
其次,isValid方法枚举属于此步骤的问题,并根据其问题的有效性设置步骤自己的有效字段。
最后,这是我所构建的Question模型:
// packages/wizard/src/model/Question.js
Ext.define('Wizard.model.Question', {
extend: 'Ext.data.Model',
fields: [
{ name: 'name' },
{ name: 'required', type: 'boolean' },
{ name: 'questionText' },
{ name: 'type' },
{ name: 'answer' },
{
name: 'stepId',
reference: {
type: 'Wizard.model.Step',
inverse: 'questions'
}
}
],
validators: { answer: 'presence' },
getValidation: function() {
if(this.get('required')) {
return this.callParent();
} else {
return new Ext.data.Validation();
}
}
});
再次,我以与Questionnaire -> Steps相同的方式定义了Step -> Questions关联的child-side。使用validators配置,我指定答案始终存在,但我在这里遇到了一个我从未想过的问题。
我真的很想能够在运行时添加验证器,这样我就可以检查Step模型的必填字段,并将presence检查添加到答案中。这使得最终用户可以切换特定问题是否必填。
不幸的是,在与 Ext JS 源代码一些亲密接触之后,发现验证器只能在模型类实例定义时定义,而不能在类的每个实例上定义。希望这将在稍后的版本中得到允许——在撰写本书时是 5.0.1 版本——但我设法找到了一些工作区,使得这个功能得以实现。
我们需要重写Question类的getValidation方法。如果required为true,我会调用超类的getValidation方法以正常进行验证。然而,如果它是false,我会返回一个新的Ext.data.Validation实例,但实际上并不运行其验证,提供与验证通过相同的结果。
虽然这样做是有效的,而且很简单,但这是那种应该随着每个新的 Ext JS 版本重新审视的问题之一,看看是否有更优雅的解决方案。我建议这样的代码应该添加注释,以便让其他人知道为什么需要这种解决方案以及它适用于哪个版本。
有效载荷
关联的一个有趣特性是它允许使用嵌套数据。通过将问卷及其步骤和问题作为单个 JSON 对象加载数据,子步骤和问题的关联也会被填充。例如,看看这个 JSON:
{
"id": 1,
"title": "Quiz Questions!"
"introduction": "Welcome!",
"conclusion": "Thanks!",
"steps": [
{
"id": 1,
"title": "Round 1"
"introduction": "Welcome to Round One!",
"questionnaireId": 1,
"questions": [{
"id": 1,
"questionText": "Turku is the third largest city by population of which European country?",
"required": true,
"stepId": 1,
"type": "textfield"
}],
}
]
}
此对象包含问卷的数据,以及子步骤的数组。步骤有自己的问题数组,如下所示:
Questionnaire.load(1, {
success: function(q) {
console.log(q.steps().first().get('title'));
}
});
如果我们加载这个,我们会在控制台看到“round 1”被记录下来。有些人可能会质疑为什么我们甚至要使用模型;我们可以使用Ext.Ajax直接将 JSON 对象加载到视图模型中。模型允许我们使用验证,并让我们为每个Model实例添加实用方法。在父模型上自动创建的存储提供了枚举记录和查找单个子记录的快捷方法。
在设置和操作模型方面有一些开销,但从长远来看是值得的。
向导组件
我对自己构建的数据层感到相当满意。花费时间试图绕过我发现的验证器限制是令人沮丧的,但最终结果工作得很好。现在是时候转向用户界面,从问卷向导的主容器开始:
// packages/wizard/src/view/Wizard.js
Ext.define('Wizard.view.wizard.Wizard', {
extend: 'Ext.Panel',
xtype: 'wizard',
requires: [
'Wizard.model.Questionnaire',
'Wizard.model.Step',
'Wizard.model.Question'
],
ui: 'wizard',
bodyCls: 'wizard-body',
viewModel: 'wizard',
controller: 'wizard',
layout: 'card',
config: {
questionnaire: null
},
bind: {
questionnaire: '{questionnaire}',
activeItem: '{currentPosition}',
title: '{questionnaire.title}'
},
applyQuestionnaire: function(questionnaire) {
if(!questionnaire) {
return;
}
var intro = questionnaire.get('introduction'),
conclusion = questionnaire.get('conclusion');
this.add({ html: intro });
questionnaire.steps().each(this.addStepPane, this);
this.add({ html: conclusion });
return questionnaire;
},
setActiveItem: function() {
if(this.items.length > 0) {
this.callParent(arguments);
}
},
addStepPane: function(step, i) {
this.add({
xtype: 'wizard-step',
viewModel: {
data: { step: step }
},
bind: { step: '{step}' }
});
},
load: function(id) {
this.getViewModel().setLinks({
questionnaire: {
type: 'Wizard.model.Questionnaire',
id: 1
}
});
},
dockedItems: [
{ xtype: 'wizard-navigation', dock: 'bottom' },
{
xtype: 'wizard-progress', dock: 'bottom',
bind: '{questionnaire.steps}'
}
]
});
我将分解这段代码的每个重要部分,并尝试解释其背后的设计决策。
ui和bodyCls选项被设置为一种方式,以便通过主题和 CSS 在以后将其与组件挂钩。特别是,ui选项是重用 Ext JS 主题系统部分的一个很好的方式。我们将在本章末尾重新审视这一点。
在配置了viewModel、controller和layout选项之后,我使用了一种之前看到过的策略,即创建一个新的自定义配置选项,该选项将被用于绑定。我创建了一个questionnaire配置,以便将视图模型值绑定到它,并且你可以看到绑定的值也被称为questionnaire。
配置难题的最后一部分是将问卷的标题绑定到向导面板本身的标题。
自己动手做
我喜欢创建自定义配置选项的一个原因是,你得到一个额外的钩子,即applyOptionName方法。它为每个配置选项自动创建,并在设置值之前由 Ext JS 调用。这让我们可以自定义或验证配置选项,如果我们将其绑定到视图模型值,它还允许我们在绑定的值更新时执行操作。
我使用applyQuestionnaire与它一起使用,当问卷绑定时用于构建向导面板的项目。它执行以下三个操作:
-
为问卷介绍添加一个容器。
-
为问卷结论添加一个容器。
-
使用
addStepPane方法为问卷中的每个步骤添加一个wizard-step组件。
在addStepPane中,新的wizard-step组件被提供一个包含Step模型本身的视图模型,并将其立即绑定到一个步骤配置选项。直接传递Step模型作为配置选项而不是使用视图模型会更简单,但这意味着我们无法在进一步的绑定公式中使用此步骤,并且对Step的变化(如验证)做出反应会更困难。
apply的第二个用途是通过applyActiveItem,它将在视图模型的currentPosition值每次更改时触发。它用于更新面板的currentPosition,以便在用户通过问卷时从卡片切换到卡片。但我添加了一个检查,以确保在执行此操作之前向导的项目已被初始化。如果没有这个检查,在项目设置之前更改currentPosition可能会引发错误。
工具向导
主向导面板的最后一块配置是创建导航按钮和进度指示器。我将这些作为dockedItems添加,并将停靠位置设置为“底部”,以便它们位于面板的页脚中。进度条绑定到视图模型中的问卷步骤,以构建其步骤图标。
向前迈出一步
导航栏不仅允许用户通过问卷,还可以返回到前面的步骤以进行审查。有一个“重启”按钮,可以将用户带回到介绍部分。而“完成”按钮将在所有问题完成后用于与主机应用程序通信。
每个这些按钮的启用或禁用都取决于每个步骤(因此每个问题)的验证状态以及用户在问卷中的位置。以下是代码:
// packages/wizard/src/view/Navigation.js
Ext.define('Wizard.view.wizard.Navigation', {
extend: 'Ext.Toolbar',
xtype: 'wizard-navigation',
items: [
{
text: 'Restart', itemId: 'restart',
bind: { disabled: '{isIntroduction}' }
},
{
text: 'Previous', itemId: 'prev',
bind: { disabled: '{isIntroduction}' }
},
'->',
{
text: 'Next', itemId: 'next',
bind: { disabled: '{!isNextEnabled}' }
},
{
text: 'Finish', itemId: 'finish',
bind: { disabled: '{isNotLastStep}' }
}
]
});
我已将启用和禁用这些按钮的责任委托给一个视图模型。由于还有其他组件会对这些值感兴趣,它们将位于向导的顶级视图模型中。我们稍后会查看此代码。
正在取得进展
进度条是一系列按钮,允许用户确定他们在问卷过程中的进度并跳转到前面的步骤。每个按钮都需要了解用户在问卷中的位置,以便确定它是否应该启用或禁用。"开始"和"结束"按钮是固定的,在每次问卷中都是可用的,但编号步骤按钮需要自动生成并绑定到当前加载的问卷的步骤。让我们看看代码:
// packages/wizard/src/view/Progress.js
Ext.define('Wizard.view.wizard.Progress', {
extend: 'Ext.Container',
xtype: 'wizard-progress',
config: {
steps: null
},
defaultBindProperty: 'steps',
defaultType: 'button',
baseCls: 'wizard-progress',
layout: {
type: 'hbox',
pack: 'center'
},
applySteps: function(steps) {
var lineHtml = '<div class="wizard-progress-bar"></div>',
stepArr = steps.getData().items,
items = this.buildProgressIcons(stepArr),
container;
this.removeAll();
items.unshift({ text: 'Start', stepIndex: 0 });
items.push({
text: 'End', bind: {
disabled: '{isNotLastStep}'
}
});
container = this.add({
xtype: 'container', cls: 'wizard-progress-inner',
defaultType: 'button', items: items
});
container.getEl().insertHtml('afterBegin', lineHtml);
return steps;
},
buildProgressIcons: function(steps) {
return Ext.Array.map(steps, function(step, i){
return {
text: i + 1, stepIndex: i + 1,
bind: { disabled: '{!isEnabled}' },
viewModel: {
formulas: {
isEnabled: function(get) {
return get('currentPosition') > i;
}
}
}
};
});
}
});
在这个组件中有很多事情在进行。记住,在向导面板中,我将此进度组件的 bind 配置设置为问卷步骤,在前面的代码中,你可以看到自定义步骤配置选项——以及 defaultBindProperty——它启用了这一点。
小贴士
记住,defaultBindProperty 允许您避免显式设置要绑定的属性,并且 Ext JS 将自动使用默认值。
我会跳过 layout、cls 和 defaultType 选项,转到实现 applySteps 的方式。它构建了一个以下按钮的数组:
-
始终启用的开始按钮
-
每个步骤的数字按钮,只有当用户已经进入该步骤时才启用
-
结束按钮仅在用户处于问卷结论时启用
结束按钮的禁用状态被推迟到父视图模型上的绑定。对于单个步骤图标,使用一个简单的视图模型和 isEnabled 公式来根据当前活动步骤面板切换禁用状态。
当我研究第二章 MVC 和 MVVM 时,我发现了一段关于 Smalltalk 中 MVC 的描述以及单个 UI 组件将拥有自己的控制器,直到文本框等。虽然我们很少在我们的 Ext JS 应用程序中走那么远,但为步骤图标使用一个小型、单公式视图模型让我想起了这个概念。
我在编写时唯一的抱怨是,虽然功能很棒,但仅绑定一个属性这样的语法感觉有点冗长。另一方面,引入另一种简写语法意味着 Ext JS 开发者需要学习新功能;我认为我们已经足够多了!
关于这个组件的最后一个要点是,我在容器中附加了 lineHtml 并配置了几个样式钩子。这允许有一条细线连接进度按钮;这是一个微小的视觉元素,但效果很好。
逐步进行
下一个要检查的组件是代表问卷步骤并显示相关问题的组件:
// packages/wizard/src/view/Step.js
Ext.define('Wizard.view.wizard.Step', {
extend: 'Ext.form.Panel',
xtype: 'wizard-step',
cls: 'wizard-step',
defaults: {
labelSeparator: '', labelAlign: 'top',
labelWidth: 250, msgTarget: 'side',
width: '100%'
},
config: {
step: null
},
modelValidation: true,
applyStep: function(step) {
this.add({
xtype: 'container',
cls: 'wizard-step-introduction',
html: step.get('introduction')
});
step.questions().each(function(question) {
this.add({
xtype: question.get('type'),
fieldLabel: question.get('questionText'),
required: question.get('required'),
bind: '{question.answer}',
viewModel: 'progress-step'
}).getViewModel().set('question', question);
}, this);
step.isValid();
}
});
这里是 Ext.form.Panel 的标准设置,配置标签等。再次,我创建了一个自定义步骤配置,并在父 Wizard 面板的 addStepPane 方法中绑定,并使用匹配的 apply 方法构建表单的内容。
注意我如何将modelValidation配置设置为true。由于我在本章前面的原型中创建的验证,我知道这是通过在模型上创建验证并在表单 UI 中生效来避免代码重复的绝佳方式。在构建applyStep步骤中的问题,我确保将question.answer绑定到表单字段值。这意味着对问题模型答案字段的任何验证都将自动应用于表单字段 UI。
表单字段的其余属性都是根据问题数据动态构建的,例如标签和字段类型。这里需要特别注意的一点是,我使用了一个单独的视图模型,并立即用问题填充它。看看这个视图模型的代码:
// packages/wizard/src/view/ProgressStepModel.js
Ext.define('Wizard.view.wizard.ProgressStepModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.progress-step',
data: {
question: null
},
formulas: {
isValid: {
bind: {
bindTo: '{question}',
deep: true
},
get: function() {
return this.get('step').isValid();
}
}
}
});
这并不复杂;只是定义起来有点长,所以我将其移动到了一个单独的文件。这里的代码也应该看起来非常熟悉,因为它与我之前在本章标题为“一个绑定技巧”下展示的原型中的代码相同。我知道这会很有用!
问卷调查命令与控制
几乎所有的组件都已就绪。请注意,到目前为止,我在Wizard组件中使用了大量的绑定表达式,但我还没有展示顶层视图模型或向导如何处理用户交互。我总是喜欢保持控制器简洁,这里的视图控制器也不例外:
// packages/wizard/src/view/WizardController.js
Ext.define('Wizard.view.wizard.WizardController', {
extend: 'Ext.app.ViewController',
alias: 'controller.wizard',
listen: {
component: {
'#next': { click: 'onNextClick' },
'#prev': { click: 'onPrevClick' },
'#restart': { click: 'onRestartClick' },
'#finish': { click: 'onFinishClick' },
'wizard-progress button': { click: 'onStepClick' }
}
},
onNextClick: function() {
var current = this.getViewModel().get('currentPosition');
this.getViewModel().set('currentPosition', current + 1);
},
onPrevClick: function() {
var current = this.getViewModel().get('currentPosition');
this.getViewModel().set('currentPosition', current - 1);
},
onRestartClick: function() {
this.getViewModel().set('currentPosition', 0);
},
onFinishClick: function() {
var q = this.getViewModel().get('questionnaire');
this.fireEvent('wizardcomplete', q);
},
onStepClick: function(btn) {
this.getViewModel().set('currentPosition', btn.stepIndex);
}
});
视图控制器监听导航按钮和进度条上任何启用的按钮的点击事件,在除了onFinishClick之外的所有情况下,它都会在视图模型上操作currentStepIndex的值。它不需要与其他任何组件通信,只需与这个值通信。我真的非常喜欢这个解决方案的优雅性。当我们回顾向导的视图模型时,我们将看到currentStepIndex如何影响应用程序。
当用户点击完成按钮时,会调用onFinishClick方法,并引发一个名为wizardcomplete的controller-level事件,其唯一参数是完成的问卷调查。宿主应用程序可以处理此事件,检索完成的问卷数据,并根据需要处理向导组件。
这是将此组件与其宿主解耦的另一个很好的例子;向导不需要了解它嵌入的应用程序。它只需触发事件,然后忘记它。
向导模型
最后一部分,也是我认为最重要的部分,是顶层视图模型。这是向导面板直接使用的模型,并且由于视图模型继承,它对所有面板的子组件都可用。以下是代码:
// packages/wizard/src/view/WizardModel.js
Ext.define('Wizard.view.wizard.WizardModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.wizard',
data: {
currentPosition: 0
},
formulas: {
currentStep: function(get) {
var pos = get('currentPosition') – 1;
return get('questionnaire').steps().getAt(pos);
},
stepCount: function(get) {
return get('questionnaire').steps().count();
},
isIntroduction: function(get) {
return get('currentPosition') === 0;
},
isNotLastStep: function(get) {
return get('currentPosition') < get('stepCount') + 1;
},
isNextEnabled: function(get) {
// when current step is valid
var stiv = get('currentStep') ? get('currentStep.valid') : true;
// when not last step
var last = get('isNotLastStep');
return stiv && last;
}
}
});
注意currentPosition初始化为0,这代表 UI 中的问卷调查介绍页面和向导面板中的第一张卡片。
这个值可能是整个应用程序中最重要的,因为它不仅通过将其绑定到向导面板上的activeItem来驱动 UI 上显示的内容,而且还影响导航和进度按钮的状态。这既是直接的——进度组件继承并消耗currentPosition来设置其按钮的禁用状态——也是间接的,例如isNextEnabled使用它来获取currentStep模型的有效性,然后反过来绑定到“下一个”导航按钮。
通过构建几个依赖公式并允许它们级联到子组件,可以清楚地了解数据是如何从单个源(向导视图模型上的questionnaire和currentPosition)流动的。
一位令人愉悦的主办人
向导在功能上已经完成,现在是时候展示我们如何将其嵌入到应用程序中。回顾一下,你会发现主要向导组件有以下load方法:
load: function(id) {
this.getViewModel().setLinks({
questionnaire: {
type: 'Wizard.model.Questionnaire',
id: id
}
});
}
这使用视图模型的links功能来触发使用其预配置代理加载问卷。考虑到这一点,调用代码可能看起来像这样:
Ext.define('Questions.view.main.MainController', {
extend: 'Ext.app.ViewController',
requires: [
'Wizard.view.wizard.Wizard'
],
alias: 'controller.main',
listen: {
controller: {
'wizard': {
'wizardcomplete': function(q) {
console.log(q);
}
}
}
},
onClickButton: function () {
this.wizard = Ext.create('Ext.Window', {
header: false, modal: true, layout: 'fit',
autoShow: true, resizable: false,
width: 800, height: 600,
items: [{ xtype: 'wizard' }],
});
this.wizard.down('wizard').load(1);
}
});
当onClickButton处理程序触发时,会创建一个包含我们的向导组件的Ext.Window,然后我们在向导本身上调用load方法,传递要加载的问卷的 ID。
小贴士
记得在应用程序的app.json中包含向导包,正如本章前面所讨论的。
视图控制器还监听wizardcomplete事件,并可以使用此事件获取完成的问卷实例以进行进一步处理,也可以关闭向导窗口。这两个集成点是开发者在使用自己的应用程序中应用向导所需的所有内容,但我在构建此组件时还想要探索最后一件事:主题化。
混合夜晚
我想让向导组件的消费者能够自定义它,但这种情况在大多数情况下都非常简单。由于向导的主要容器是Ext.Panel的子类,因此可以覆盖这个类中所有相关的 SCSS 变量,并且它将对向导的容器也生效。
然而,我想要为进度条创建一个自定义的外观,并允许最终用户进行自定义。为此,我编写了一个自定义 mixin:
// packages/wizard/sass/src/Wizard/view/wizard/Progress.scss
@mixin wizard-progress-ui(
$ui: null,
$ui-border-color: #2163A3,
$ui-background-color: #ffffff,
$ui-button-border-width: 4px,
$ui-button-border-radius: 20px
) {
.wizard-progress-#{$ui} {
padding: 10px 0;
.#{$prefix}btn:last-child {
margin-left: 20px;
margin-right: 0px;
}
.#{$prefix}btn {
margin: 0 10px;
}
.#{$prefix}btn:first-child {
margin-right: 20px;
margin-left: 0px;
}
@include extjs-button-small-ui(
$ui: 'default',
$border-radius: $ui-button-border-radius,
$border-width: $ui-button-border-width,
$background-color: $ui-background-color,
$background-color-disabled: mix($ui-border-color, $ui-background-color, 50%),
$border-color: $ui-border-color,
$color: black,
$color-disabled: shade($ui-border-color, 50%),
$opacity-disabled: 0.9999,
$inner-opacity-disabled: 0.9999
);
.wizard-progress-bar {
width: 100%;
background: $ui-border-color;
height: $ui-button-border-width * 1.5;
position: absolute;
top: 50%;
margin-top: -(($ui-button-border-width * 1.5) / 2);
}
}
}
@include wizard-progress-ui(
$ui: 'default'
);
这个 mixin 接受四个变量,每个都有一个默认值:
-
$ui-border-color:#2163A3 -
$ui-background-color:#ffffff -
$ui-button-border-width:4px -
$ui-button-border-radius:20px
通过这种方式,我不仅可以样式化组件,开发者也可以从自己的代码中调用此 mixin 并覆盖颜色和边框。由于 mixin 的许多其他样式都基于这些变量的计算值,因此各种颜色和大小应始终与这些变量保持一致。例如,以下自定义将导致具有更细、更不圆的边框和粉红色的进度条:
@include wizard-progress-ui(
$ui: 'default',
$ui-border-color: #ff69b4,
$ui-background-color: #ffffff,
$ui-button-border-width: 1px,
$ui-button-border-radius: 4px
);
摘要
在前面的章节中,我们以正式的方式逐步介绍了系列应用程序的设计和创建,使用一系列图表来展示这个过程。这次,我尝试展示一些我构建系统架构的方法和一些我日常使用的技巧。
此外,我们看到了视图模型如何成为数据在应用程序中流动的主要点,通过子视图模型和子组件级联,并通过绑定触发多个 UI 更新。
我们重新审视了样式设计,展示了 Ext JS 主题系统如何允许你构建可重用的混合,这使我们 SASS 代码中的模块化与 JavaScript 类中的模块化相同。
在下一章中,我们将编写一个平板设备上的购物应用程序,允许用户浏览和购买在线商店的商品。这将是我们迄今为止最复杂的应用程序,它结合了我们迄今为止讨论的所有不同想法和技术。
第九章. 购物应用
在本章中,我们将从头到尾设计最后一个应用。而不是继续介绍新的想法或技术来可视化和构建应用架构,我们将加倍努力完成我们迄今为止所做的工作。我们将结合路由、视图模型和事件,以巩固所讨论的一切,并创建设计文档来告知应用的结构。
在本书到目前为止的每一章中,我都试图强调,没有“唯一正确”的应用架构方法。每个应用都是不同的;有足够的空间进行解释。在本书的开头,我们讨论了 MVC 和 MVVM 设计模式,以及架构师必须找到自己的模式(一种感觉自然的工作方式)。更重要的是,模式是由于我们工作中的规律性而产生的,也就是说,重复做同样的事情。虽然不同的架构师会有不同的工作方式,并且可以在不同的方式中使用不同的方法,但在实际章节中,我们已经完成了一个逻辑的发展路径,这是很容易陷入的。
“成功陷阱”这个短语用来描述一种用户无需努力就能顺利遵循的方法。这正是我们一直在努力的方向。向您展示 Ext JS 提供的选项,不仅给您提供选择,还试图说明为什么它们是好的选择。到现在为止,您应该对 Ext JS 应用的设计和您可以使用来满足这种设计的功能有一个清晰的了解。
在这个最后的实践章节中,我们再次看到,尽管我们有选择如何实现这个应用,但最终我们会选择一条与之前相似的路径,一条感觉合理且使开发变得容易的路径。
关于应用
我们将要构建的应用是为平板电脑大小的屏幕设计的。我们将利用蓬勃发展的“手工啤酒”场景,创建一个允许客户从商店的分类啤酒中选择的应用。以下是完整的功能列表:
-
登录和注册
-
分类列表
-
带排序的产品列表
-
购物车
-
便于触摸
换句话说,这是在直接电子商务网站上可以看到的标准功能集。最终产品看起来是这样的:

我们有一个简单的界面,为平板电脑用户提供了大型的可触摸区域。在这个应用中,比我们迄今为止工作的任何其他应用都有更多的屏幕,所以让我们勾勒出这些屏幕,看看完整的应用将是什么样子:

这里,我们有用户打开网站时看到的第一个屏幕的线框图。正如您所看到的,它映射出了功能和布局,如前一个屏幕截图所示。
此原型提到了应用程序的所有主要功能。请注意,与我们的早期电子邮件应用程序不同,用户不需要登录即可开始浏览。只有在他们想要下单时才会发生这种情况。
啤酒类别按字母顺序列在左侧;当列表超出屏幕底部时,用户可以滚动浏览列表。在屏幕的主要部分,所选类别的啤酒按列表上方的组合框确定的排序顺序列出。每款在售啤酒都由其名称、图片和价格表示。
最后,在屏幕的右上角,我们有“登录”和“购物车按钮”,它们将根据应用程序状态进行更改,并且都是可点击的,可以揭示更多窗口。
产品窗口
以下屏幕显示了用户选择查看产品时的产品详情页面:

此屏幕详细说明了我们的需求和它们将如何实现。当用户点击列表中的产品时,会弹出一个模态窗口,显示有关该产品的更多信息,例如显示价格折扣的优惠信息、完整描述、生产啤酒的酿酒厂以及一句吸引顾客的标语。所有这些信息都伴随着产品的大幅图片,以及将啤酒添加到购物车和关闭产品窗口的按钮。
购物车
在主应用程序屏幕的右上角,我们可以看到一个购物车图标和一个标签,标签会根据购物车中的项目数量进行更改。当用户点击图标或标签时,将显示购物车窗口。

此模态窗口包含购物车中的产品列表以及每个项目的数量。用户可以通过点击加号或减号图标来调整每个项目的数量。此窗口的另一个关键功能是立即下单按钮,它将当前购物车处理成订单。
如果用户尚未登录,点击此按钮将显示登录和注册屏幕,我们将在下一节中讨论。
登录和注册
以下截图显示了登录和注册选项:

登录和注册表单并排显示,因为它们足够简单,可以放在同一个窗口中。字段将进行验证,以确保电子邮件格式正确且必填字段已填写。当完成注册或登录过程后,主屏幕上的登录图标将被用户的电子邮件地址替换;点击此链接将显示账户屏幕。
用户账户
用户账户页面允许用户编辑他们的详细信息并查看他们的历史订单:

左侧的文本字段允许用户修改他们的地址和用户凭据,右侧的面板是历史订单列表,显示已订购的项目、日期和总金额。
设计概述
我们已经逐一查看所有主要的应用程序视图;为了简单起见,我们排除了实际的支付过程,以避免与第三方服务集成。当用户继续订单时,它将立即被处理并添加到他们的账户中的历史订单中。
Ext JS 5 的一个非常好的特点是它增加了对触摸设备的支持,并包含了一个触摸友好的主题。这应该会使向平板用户展示此应用的目标变得非常简单;然而,我们将包括一些主题调整以创建自定义外观并改善平板设备用户的体验。
回到 Ext JS
虽然我们现在知道 Ext JS 可以帮助创建触摸友好的界面,但我们的设计是否提出了 Ext JS 可能无法应对的其他想法?记住,设计阶段是对用户适用性和现有技术可能性的探索。让我们来分析一下:
-
滚动产品分类列表可以是具有大多数功能的
Ext.grid.Panel类,例如标题、禁用 -
滚动产品列表可以是
Ext.view.View类(也称为 DataView),因为我们需要为每个产品包含自定义 HTML 来显示图片和其他详细信息 -
产品列表排序将通过包含排序标准的组合框来实现
除了这个之外,我们只需要模态窗口(Ext JS 支持),另一个用于购物车的网格,以及几个用于登录和注册的表单字段。我们基本上已经完成了。
此外,我们将使用路由来提供对分类或产品的书签功能,这使用户能够分享链接。我们还将使用视图模型和事件来连接一切。数据将再次从服务器端 API 中获取,所以让我们看看下一个。
数据层
购物应用需要提供以下数据:
-
分类列表
-
按分类过滤并按选定标准排序的产品列表
-
单个产品的详细信息
这使得事情非常直接。所以,在接下来的一段时间里,我们将具体说明当我们发起服务器请求时将看到什么样的数据响应。
在此之前,你可能注意到我们正在跳过将与应用程序服务器端集成的部分。这样做的主要原因是它将增加我们示例应用的复杂性,而不会增加太多价值;我们希望突出我们为了拼凑这个应用所做的决策,并以简洁易懂的方式呈现。虽然我们可以向这个应用和我们的前一个示例添加许多功能,但我们想确保构建应用程序的重要方面能够得到突出。
让我们回到我们希望我们的后端提供的数据。首先,检索类别列表:
GET: /category
Accepts: N/A
Returns: [{"id":1,"name":"Pilsner"},{"id":2,"name":"IPA"}]
它不接受任何参数,并返回一个包含每个可用类别的 ID 和名称的 JSON 数组。要查看某个类别的产品,我们与产品 API 进行通信:
GET: /product
Accepts:
sort = [{"property":"id","direction":"ASC"}]
filter = [{"property":"categoryId","value":2}]
Returns: [{"id":1,"name":"Sierra Nevada Torpedo Extra IPA","price":"19.99", "imagePath":"snte1.jpg"}]
它返回一个对象数组,每个对象都包含渲染产品列表项所需的属性。可以通过传递一个包含排序字段的 JSON 数组的排序查询参数来过滤数组,并且我们通过传递一个包含 JSON 数组的 filter 查询参数来获取所需的商品类别。此数组包含一个对象,用于过滤 categoryId 属性。这种 JSON 过滤和排序方法是我们过去使用过的,并且与 Ext JS 在客户端的工作方式很好地匹配。
最后,我们有以下单个产品详情的请求。
GET: /product/:id
Accepts: N/A
Returns: { "id": 1, <all product fields omitted> }
它本身不接受任何查询参数。相反,ID 作为 URL 路径的一部分传递,这在 RESTful API 中更为常见。为了简洁,省略了完整的 JSON 响应,但它返回填充产品窗口所需的全套字段。
现在我们已经收集了这些信息,我们可以开始思考它将如何塑造我们的数据类。
信息沉思
根据我们刚刚描述的 API,我们有两个主要模型及其关联的存储:
Alcohology.model.Product: extends Alcohology.model.BaseModel
- id
- name
- imagePath
- description
- price
- previousPrice
- brewery
- features
- categoryId
Alcohology.model.Category: extends Alcohology.model.BaseModel
- id
- name
这些将会有伴随的存储,它们除了包装它们的模型之外不做任何事情。除了与 API 交互的类之外,我们还将有其他几个类来处理应用程序中的其他动态部分。首先,我们将查看购物车中的各种项目:
Alcohology.model.CartItem: extends Alcohology.model.BaseModel
- productId
- productName
- price
- quantity
此设计的替代方案是只保留 productId 和 quantity,并在渲染时从产品存储中查找产品详情。然而,我们选择的方法使生成的代码更简单,并且它还允许您存储用户将其添加到购物车时的数据,如价格。如果产品价格在用户将其添加到购物车后发生变化,这在复杂或繁忙的网站上可能会很有用。
其次,我们有一个模型来保存订单:
Alcohology.model.Order: extends Alcohology.model.BaseModel
- date
- totalCost
- items[]
- productId
- productName
- price
- quantity
这将用于表示已转换为订单的购物车。虽然这个类将由一个简单的包装存储器消费,但 CartItem 将有一个执行更多操作的存储器:
Alcohology.store.Cart: extends Ext.data.Store
- addProduct
- toOrder
addProduct 方法可以传递给要添加到购物车的产品模型。它添加了一些逻辑来检测是否已存在匹配的产品在购物车中;如果存在,则增加数量而不是创建新的购物车项。
toOrder 方法将购物车及其所有项目转换为 Order 模型,然后可以将其保存到服务器。
这个项目的 API 很简单,但我们也将使用模型和存储来组织我们的数据和应用程序状态,优先使用Ext.data类而不是标准 JavaScript 对象,以便利用它们强大的功能。随着数据设计基本完成,我们可以继续看看这些数据将如何与应用程序的其余部分交互。
组件交互
在 Ext JS 4 中,我们有 MVC 模式来构建和清理结构良好的应用程序。然而,回顾我们过去的几个实践章节,似乎很难想象从 Ext JS 提供的 MVVM 架构回到 MVC。因为在每个例子中,我们都有效地使用了视图模型来提供数据通过我们应用程序的逻辑流动方式。
这些例子有趣的地方在于,在许多情况下我们写的代码很少。分析应用程序需求并解决一些棘手区域会导致编写少量 UI、控制器、视图模型等的配置。Ext JS 自动构建了数据流动的管道。
这是另一个说明为什么应用程序架构如此重要的例子,尤其是当它与对现有工具的深入了解相结合时。一个天真的开发者可能会轻易地跳进来,开始编写代码来手动处理数据从 API 通过到用户界面的移动,使用Ext.Ajax而不是模型和代理,并手动将数据加载到组件中。然而,通过采取耐心和有条理的方法,我们可以构建一个概念性的应用程序概述,它可以轻松地适应 Ext JS 提供的框架。通过提前思考,我们使后续使用变得更加简单。
为了达到这个目的,让我们思考一下在这个应用程序中我们需要哪些控制器和视图。
In control
控制器的目的是什么?正如你在第二章“MVC 和 MVVM”中学到的,它是作为应用程序其他部分之间的粘合剂,在大多数情况下,这体现在处理事件的代码中。从 UI 点击到路由事件,控制器消耗它们并将实际工作传递给另一个类。
这对我们思考架构的方式意味着什么?这意味着应用程序中的任何动作或事件都可能需要一个相关的控制器。如果这些动作可以捆绑成一个独立的分组,那么这可能表明它们需要自己的控制器。考虑到这一点,让我们再次看看我们的购物应用程序:

页面上的哪些元素可能会引发事件?在蓝色左侧面板中点击一个类别,因此除了类别列表视图外,我们还将有一个如下所示的类别视图控制器:
Alcohology.view.categories.CategoriesController: extends Ext.app.ViewController
- onItemClick
在产品列表中点击一个产品是我们需要处理的一个动作,因此我们将有一个产品列表视图和一个产品视图控制器,如下所示:
Alcohology.view.product.ProductController: extends
Ext.app.ViewController
- onSortSelect
- onCategoryRoute
- onProductRoute
- onProductClick
- onProductClose
- onAddToCart
接下来,窗口右上角的两个图标需要触发 UI 更改,因此它们需要一个视图控制器。我们想要一个“标题”控制器来处理购物车和账户图标的事件,还是可以使用一个“主要”控制器?这通常是取决于个人偏好的问题;在这里,我们将使用主要控制器,以保持类数量的可控:
Alcohology.view.main.MainController: extends Ext.app.ViewController
- onLoginRequired
- onCartClick
- onAccountClick
- onAccountRoute
- onCartRoute
让我们回顾一下我们的其他 UI 线框图。还有三个剩余的 UI 组件,它们都是模态窗口。首先,是产品详情窗口(与此交互将由我们已确定的产品控制器处理)。
接下来是购物车窗口,它将与一个控制器配对,该控制器处理用户与购物车中各种按钮的交互:
Alcohology.view.cart.CartController: extends
Ext.app.ViewController
- onCartClose
- onOrderNow
最后,以下代码显示了带有账户视图控制器以处理登录和注册的账户窗口:
Alcohology.view.account.AccountController: extends
Ext.app.ViewController
- onAccountClose
- onLoginRegister
有一个地方会引发事件,我们的应用程序需要处理。这个项目的需求规定,我们需要实现路由,以便通过电子邮件或社交媒体共享产品页面。为了满足这一需求,我们将有一个控制器指定路由的定义和匹配处理程序。具体承担这一角色的控制器将取决于路由定义的性质,例如,如果它与产品相关,则产品控制器将处理它。您可以在上一节中控制器设计中看到一些这些路由处理方法。
以这种方式对动作和事件进行分组通常会使得选择要构建哪些控制器变得容易,尤其是在与允许您查看相应 UI 视图的线框图一起使用时。
视图模型的简洁性
我们已经多次使用“流程”这个词来讨论视图模型和应用程序架构。当用户操作用户界面的各个部分时,数据通过控制器和视图模型流动,以表示应用程序的当前状态。与其列出在这个应用程序中数据可以流动的所有节点,不如让我们尝试以下方式来想象它:

主视图模型与它的依赖项之间的交互
能够在这个层面上概念化您的应用程序是一个好兆头,表明它被充分理解和构思。在更大的应用程序中,很难以这种方式可视化应用程序的每个部分,因此它通常会被分解成多个较小的可视化。无论如何,构建数据流和用户流的顶层映射是确认设计逻辑和简洁性的绝佳方式。
代码,代码,还有更多的代码
是时候动手实践,把手指放在键盘上了。一如既往地,我们使用 Sencha Cmd 生成了一个新基础应用程序,并将生成的“main”视图作为用户界面的起点。不过,首先让我们完善之前设计的底层数据结构:
// app/model/BaseModel.js
Ext.define('Alcohology.model.BaseModel', {
extend: 'Ext.data.Model',
schema: {
namespace: 'Alcohology.model',
urlPrefix: 'http://localhost:3000',
proxy: {
type: 'rest',
url: '{prefix}/{entityName:uncapitalize}'
}
}
});
我们在之前的章节中使用了基础模型,因为它为我们提供了一个很好的集中配置代理的方法。从它继承的模型都是直接的,如下面的代码所示:
// app/model/CartItem.js
Ext.define('Alcohology.model.CartItem', {
extend: 'Alcohology.model.BaseModel',
fields: [
{ name: 'productId' },
{ name: 'productName' },
{ name: 'price' },
{ name: 'quantity' }
]
});
// app/model/Category.js
Ext.define('Alcohology.model.Category', {
extend: 'Alcohology.model.BaseModel',
fields: [
{ name: 'id', type: 'integer'}
]
});
// app/model/Order.js
Ext.define('Alcohology.model.Order', {
extend: 'Alcohology.model.BaseModel',
fields: [
{ name: 'date', type: 'date' },
{ name: 'items', type: 'auto' }
]
});
// app/model/Product.js
Ext.define('Alcohology.model.Product', {
extend: 'Alcohology.model.BaseModel',
fields: [
{ name: 'id', type: 'integer'},
{ name: 'name', type: 'string' },
{ name: 'imagePath', type: 'string' }
]
});
回顾我们的设计,这些模型完全符合我们之前制定的规范。我们真正做的只是在上面的原始字段定义之上添加了 Ext JS 实现。
购物车中有什么?
如我们所知,存储库通常只是包装器,为我们提供一些更多有用的方法来处理模型集合。回顾我们的设计文档,这种情况适用于本应用中的三个存储库:
// app/store/Categories.js
Ext.define('Alcohology.store.Categories', {
extend: 'Ext.data.Store',
model: 'Alcohology.model.Category',
alias: 'store.categories'
});
// app/store/PastOrders.js
Ext.define('Alcohology.store.PastOrders', {
extend: 'Ext.data.Store',
model: 'Alcohology.model.Order',
alias: 'store.pastorders'
});
// app/store/Products.js
Ext.define('Alcohology.store.Products', {
extend: 'Ext.data.Store',
model: 'Alcohology.model.Product',
alias: 'store.products'
});
所有这些都非常直接。购物车存储库稍微有趣一些,如下面的代码所示:
// app/store/Cart.js
Ext.define('Alcohology.store.Cart', {
extend: 'Ext.data.Store',
model: 'Alcohology.model.CartItem',
alias: 'store.cart',
addProduct: function(product) {
// find a product with a matching ID
var item = this.findRecord('productId', product.getId());
if(item) {
item.set('quantity', item.get('quantity') + 1);
item.commit();
} else {
item = this.add({
productName: product.get('name'),
price: product.get('price'),
productId: product.getId(),
quantity: 1
});
}
return item;
},
toOrder: function() {
var items = [], total = 0;
this.each(function(item) {
items.push({
name: item.get('productName'),
quantity: item.get('quantity')
});
total += item.get('price') * item.get('quantity');
});
return Ext.create('Alcohology.model.Order', {
date: new Date(), items: items, total: total
});
}
});
在过去章节中我们构建的许多存储库都用于存储从服务器获取的信息。在这里,我们将存储用作购物车本身的内存表示;我们添加了一些自定义方法来帮助实现这一功能。
addProduct方法将通过将其转换为CartItem模型来将指定的产品添加到存储库中。如果购物车中已经存在具有相同 ID 的产品,则其数量将增加一个,而不是重复添加。
toOrder方法将整个购物车转换为Order模型,该模型在应用程序的后续部分用于显示用户账户中的历史订单。
这些方法很有趣,因为它们展示了我们不是在编写胶水代码或处理事件的代码的地方。这是处理应用程序真正有趣部分的代码,有时也称为“业务逻辑”。开发强大架构和强大开发实践的一个好处是,你将拥有更少的样板代码,并有更多时间专注于对客户重要的业务逻辑。
在第十一章“应用测试”中,我们将探讨隔离这种业务逻辑并创建自动化测试的方法,这些测试将使你对代码库充满信心。
我们的数据层已经就绪,因此我们可以继续构建用户界面。
面前的接口
使用 Sencha Cmd 创建的 Ext JS 应用程序将设置主视图为填充整个浏览器窗口的视口。我们将使用这个视图并根据以下代码对其进行调整以满足我们的需求:
// app/view/main/Main.js
Ext.define('Alcohology.view.main.Main', {
extend: 'Ext.Panel',
xtype: 'app-main',
controller: 'main',
viewModel: 'main',
layout: 'border',
header: { xtype: 'app-header' },
items: [
{ xtype: 'categories', width: 200, region: 'west' },
{ xtype: 'product-list', region: 'center' }
],
initComponent: function() {
this.callParent(arguments);
this.add(Ext.create('Alcohology.view.cart.Cart', {
reference: 'cartWindow'
}));
this.add(Ext.create('Alcohology.view.account.Account', {
reference: 'accountWindow'
}));
}
});
我们已经到了这里!我们的第一个视图组件,将包含应用程序中所有其他内容的面板。header配置设置为稍后我们将构建的自定义xtype。面板中的项目配置为使用边框布局,并包括类别列表和产品列表。
这里有一个奇怪的地方:在 initComponent 方法中向面板添加窗口。这提供了两个好处:
-
主视图控制器可以使用
lookupReference来引用窗口 -
窗口将通过视图模型继承访问主视图模型
这是一个简单的解决方案,解决了听起来很明显的难题,那就是我在哪里创建我的窗口?把它们放在 items 配置中的产品和类别列表中感觉“不对劲”,尽管我们当然可以这样做而没有任何不良影响。另一个常见的解决方案是在视图控制器本身中实例化窗口,但这样窗口就不是主视图的子项了,这会导致视图模型继承的问题。在 initComponent 方法中创建窗口感觉是绕过这个问题的自然方式。
比别人早一步
我们之前决定主视图控制器也将处理来自头部的事件,所以让我们看看下一个头部视图:
// app/view/header/Header.js
Ext.define('Alcohology.view.header.Header', {
extend: 'Ext.panel.Header',
xtype: 'app-header',
cls: 'app-header',
layout: 'hbox',
title: 'alcohology.',
items: [
{ xtype: 'account-indicator', width: 80, bind: '{currentUser}' },
bind: { data: { count: '{cartCount}' } }}
]
});
我们的定制头部组件继承自 Ext.panel.Header 并实现了 hbox 布局。其中包含的两个项目也是自定义类,一个用于购物车图标,另一个用于账户图标。这些配置绑定到 currentUser 和 cartCount,它们是主视图模型中的值,我们稍后会查看。
购物车图标称为 MiniCart,其外观如下:
// app/view/header/MiniCart.js
Ext.define('Alcohology.view.header.MiniCart', {
extend: 'Alcohology.ux.ClickContainer',
xtype: 'minicart',
cls: 'mini-cart',
tpl: new Ext.Template('<span style="font-family:FontAwesome;">',
'</span> {count} items')
});
在 header 组件中,我们指定了 MiniCart 的 data 配置应该是一个具有 count 值的对象。这个 count 值将绑定到视图模型中的 cartCount 值。反过来,我们现在使用这个 count 值在模板中,这使得你可以拥有一个随着购物车中项目数量更新图标。
这里还有其他几件事情需要注意。我们使用 FontAwesome 图标集为购物车添加一些图形风格;你可以在 tpl 配置中的 span 标签中看到它的使用。
小贴士
FontAwesome 可以在 fortawesome.github.io/Font-Awesome/ 找到。
第二个需要注意的点是这个类继承自 Alcohology.ux.ClickContainer。这是什么?看看以下代码:
// app/ux/ClickContainer.js
Ext.define('Alcohology.ux.ClickContainer', {
extend: 'Ext.Container',
xtype: 'clickcontainer',
listeners: {
'afterrender': function(me) {
me.getEl().on('click', function() {
me.fireEvent('click');
});
}
}
});
一个普通的容器没有 click 事件,所以这个 ClickContainer 钩子到允许你处理与容器用户交互的底层元素。如果你不需要按钮样式而想有一个简单的可点击组件,这很有用。
小贴士
此功能也可以作为一个混合类而不是基类来实现。
账户指示器也扩展了 ClickContainer,如下所示:
// app/view/header/AccountIndicator.js
Ext.define('Alcohology.view.header.AccountIndicator', {
extend: 'Alcohology.ux.ClickContainer',
xtype: 'account-indicator',
cls: 'account-indicator',
config: {
user: null
},
defaultBindProperty: 'user',
data: {
label: 'Login'
},
tpl: '<span style="font-family:FontAwesome;"></span> {label}',
applyUser: function(user) {
if(user) {
this.setData({ label: user.email });
}
}
});
我们再次使用绑定到自定义配置选项的技巧,这里有一些变化。如果绑定的用户值为 null,即用户尚未登录,我们使用 data 配置的默认值为此组件设置一个标签。如果他们已经登录,我们将标签设置为他们的电子邮件地址。
您可以在tpl配置中看到我们再次使用了FontAwesome。这也是我们使用默认值为login的标签的地方。
让我们回到处理用户与这些组件交互的代码。
在主控制下
主要控制器不仅处理用户点击和触摸,还定义了一些相关路由的地方。它甚至处理自定义事件。让我们看看:
// app/view/main/MainController.js
Ext.define('Alcohology.view.main.MainController', {
extend: 'Ext.app.ViewController',
alias: 'controller.main',
listen: {
component: {
'component[cls="mini-cart"]': { click: 'onCartClick' },
'component[cls="account-indicator"]': { click: 'onAccountClick' },
},
controller: { '*': { loginrequired: 'onLoginRequired' } }
},
routes: {
'account': 'onAccountRoute',
'cart': 'onCartRoute'
},
onLoginRequired: function() {
Ext.toast('Please login or register.');
this.redirectTo('account', true);
},
onCartClick: function() {
this.redirectTo('cart', true);
},
onAccountClick: function() {
this.redirectTo('account', true);
},
onAccountRoute: function() {
this.lookupReference('accountWindow').show();
},
onCartRoute: function() {
this.lookupReference('cartWindow').show();
}
});
这里展示了一个非常实用的技术:account-indicator和minicart的click处理程序都简单地重定向到它们的相关路由。这意味着我们可以将显示账户和购物车窗口的逻辑放在onAccountRoute和onCartRoute路由处理程序中。
在这个视图控制器中实现的其他功能是监听控制器域。它监听任何触发loginrequired事件的控制器,并使用onLoginRequired方法处理它。在onLoginRequired中,我们通过Ext.toast功能向用户弹出简短的通知,并简单地将他们重定向到login/registration页面。
这使得任何控制器或视图控制器都可以请求用户登录,而无需明确了解账户系统的实现。让我们看看主视口的视图模型:
// app/view/main/MainModel.js
Ext.define('Alcohology.view.main.MainModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.main',
stores: {
cart: { type: 'cart' },
orders: { type: 'pastorders'}
},
data: {
cartCount: 0
},
constructor: function() {
var me = this;
me.callParent(arguments);
me.get('cart').on('datachanged', function(store) {
me.set('cartCount', store.count());
});
}
});
这个顶级视图模型提供了过去订单和购物车的存储,以及一个属性,它给出了购物车中项目的数量。
由于 Ext JS 的默认设置,我们必须手动监听购物车存储上的datachanged事件,以便获取项目的“实时”计数,因为存储大小的变化不会触发数据绑定。
我们已经介绍了“主”视图及其相关类,接下来让我们转向将列出产品类别的视图。
按类别划分
我们将使用简化的网格来构建这个视图:
// app/view/categories/Categories.js
Ext.define('Alcohology.view.categories.Categories', {
extend: 'Ext.grid.Panel',
xtype: 'categories',
controller: 'categories',
viewModel: 'categories',
bodyCls: 'categories-body',
requires: [
'Alcohology.view.categories.CategoriesModel',
'Alcohology.view.categories.CategoriesController'
],
bind: {
store: '{categories}'
},
hideHeaders: true,
viewConfig: {
trackOver: false
},
columns: [
{ text: 'Name', dataIndex: 'name', flex: 1 }
]
});
我们已经隐藏了grid标题,并使用flex配置选项来告诉单列填充所有可用空间。这为我们提供了一个简单的滚动列表所需的功能。
列表的存储绑定到我们在稍后将要查看的类别视图模型中定义的categories。首先,让我们看看类别视图控制器:
// app/view/categories/CategoriesController.js
Ext.define('Alcohology.view.categories.CategoriesController', {
extend: 'Ext.app.ViewController',
alias: 'controller.categories',
listen: {
component: {
'categories': { 'itemclick': 'onItemClick' }
}
},
onItemClick: function(view, record) {
this.redirectTo('category/' + record.getId());
}
});
这实际上不能再简单了;只需捕获itemclick事件,获取所选类别的 ID,并将其传递给路由系统,以便另一个控制器来处理。类别难题的最后一部分是视图模型,它甚至更加直接,如下面的代码所示:
// app/view/categories/CategoriesModel.js
Ext.define('Alcohology.view.categories.CategoriesModel', {
extend: 'Ext.app.ViewModel',
requires: ['Alcohology.store.Categories'],
alias: 'viewmodel.categories',
stores: {
categories: {
type: 'categories',
autoLoad: true
}
}
});
这就是 MVVM 模式的作用,这里显示的三个类都在做自己的事情,没有其他。视图类描述了展示,视图模型提供了这个展示背后的数据,而视图控制器处理用户交互。
产品定位
这是产品列表的代码:
// app/view/product/List.js
Ext.define('Alcohology.view.product.List', {
extend: 'Ext.Panel',
controller: 'product',
xtype: 'product-list',
cls: 'product-list',
viewModel: 'product',
tbar: [
{
xtype: 'combo',
store: Ext.create('Ext.data.Store', {
fields: ['text', 'field', 'direction'],
data : [
{ text: 'Date Added', property: 'id', direction: 'DESC' },
{ text: 'Name A-Z', property: 'name', direction: 'ASC' },
{ text: 'Name Z-A', property: 'name', direction: 'DESC' },
{ text: 'Price ASC', property: 'price', direction: 'ASC' }
]
}),
displayField: 'text',
queryMode: 'local',
fieldLabel: 'Sort By',
emptyText: 'None',
editable: false
}
],
items: [
{
xtype: 'dataview', itemId: 'productListView',
emptyText: '<span class="empty">No Products Found.</span>',
itemSelector: '.product', bind: '{products}',
tpl: '<tpl for="."><div class="product"><h2>{name}</h2><img src="img/{imagePath}-thumb.jpg" /><p>£{price}</p></div></tpl>',
}
],
constructor: function() {
this.callParent(arguments);
this.add(Ext.create('Alcohology.view.product.Detail', {
reference: 'productWindow'
}));
}
});
产品列表的顶部工具栏包含一个组合框,其中包含一个内联存储,包含可用的排序选项。注意,我们包括了排序所针对的属性和排序的方向,这样我们就可以在稍后直接将这些属性传递到服务器。
有一个论点认为这个组合应该提取到一个单独的类中,或者存储应该在视图模型上设置;这可能会使这个类更清晰。另一方面,仅仅是为了文件和类的繁衍也会使事情变得不那么清晰,所以我们将它保持内联。
在这个类中,真正的作业是由绑定到视图模型上的产品存储的 dataview 执行的。注意,我们在这个类的构造函数中再次创建了一个窗口,这将使它能够使用与产品列表 dataview 相同的视图控制器。
下面是这个窗口的代码(它显示产品的详细信息):
// app/view/product/Detail.js
Ext.define('Alcohology.view.product.Detail', {
extend: 'Ext.Window',
modal: true,
header: false,
resizable: false,
autoScroll: true,
height: 600,
width: 800,
layout: 'column',
cls: 'product-detail',
items: [
{
xtype: 'container',
columnWidth: 0.5,
defaults: {
xtype: 'component',
bind: { data: '{currentProduct}' }
},
items: [
{
xtype: 'container',
tpl: '<img src="img/{imagePath}-thumb.jpg" />'
},
{ tpl: '<ul><li>{features}</li></ul>' }
]
},
{
xtype: 'container',
columnWidth: 0.5,
defaults: {
xtype: 'component',
bind: { data: '{currentProduct}' }
},
items: [
{ tpl: new Ext.Template('<h1>{name}</h1>',
'<h2 class="brewery">{brewery}</h2>',
'<h2><p class="price">£{price}</p>',
'<p class="previousPrice">Was: £{previousPrice}</p>',
'</h2>') },
{ tpl: '<div class="description">{description}</div>' }
]
}
],
bbar: [
{ text: 'Back', itemId: 'close', glyph: 0xf190 },
'->',
{ text: 'Add to Cart', itemId: 'addToCart', glyph: 0xf07a }
]
});
这个类中有一个巧妙的小技巧。窗口通过列布局分成两部分,并填充了若干个组件,这些组件的 data 配置绑定到了视图模型上的 currentProduct。通过在这些组件上使用 tpl 配置来设置 HTML 模板,窗口中的每个面板都可以从 currentProduct 中拉取属性,并将它们纳入模板中。这为我们提供了一种混合方法,它利用了 Ext JS 的列布局和标准的 HTML/CSS 进行定制。
注意
在这个窗口的 bbar 中,我们使用 glyph 属性通过图标的相关 unicode 字符代码在按钮上设置 FontAwesome 图标。
与产品列表和详情一起工作的视图控制器有几个有趣的功能如下:
// app/view/product/ProductController.js
Ext.define('Alcohology.view.product.ProductController', {
extend: 'Ext.app.ViewController',
alias: 'controller.product',
listen: {
component: {
'#productListView': { 'itemclick': 'onProductClick' },
'#close': { 'click': 'onProductClose' },
'#addToCart': { 'click': 'onAddToCart' },
'combo': { 'select': 'onSortSelect' }
}
},
routes : {
'product/:id': 'onProductRoute',
'category/:id': 'onCategoryRoute'
},
onSortSelect: function(combo, records) {
if(records.length > 0) {
var prop = records[0].get('property'),
dir = records[0].get('direction');
this.getViewModel().set('sortProperty', prop);
this.getViewModel().set('sortDirection', dir);
}
},
onCategoryRoute: function(id) {
var cfg = { reference: 'Alcohology.model.Category', id: id };
this.getViewModel().linkTo('currentCategory', cfg);
this.lookupReference('productWindow').hide();
},
onProductRoute: function(id) {
var cfg = { reference: 'Alcohology.model.Product', id: id };
this.getViewModel().linkTo('currentProduct', cfg);
this.lookupReference('productWindow').show();
},
onProductClick: function(view, record, el) {
this.redirectTo('product/' + record.getId());
},
onProductClose: function() {
var id = this.getViewModel().get('currentCategory').getId();
this.redirectTo('category/' + id);
},
onAddToCart: function() {
var product = this.getViewModel().get('currentProduct');
this.getViewModel().get('cart').addProduct(product);
Ext.toast('Product Added');
}
});
在连接事件监听器和路由之后,我们有了 onSortSelect 方法,它处理用户对 sort 选项的选择。我们挑选出需要的值并将它们发送到视图模型。
这个视图控制器处理的路由:onCategoryRoute 和 onProductRoute 处理类别的选择(显示产品列表)和产品的选择(显示单个产品),并且使用了一种(对我们来说是新的)技术。
通过使用 linkTo 方法,我们告诉 Ext JS 如果指定的记录尚未加载,则加载该记录。通过这样做,我们节省了手动加载记录的劳动。这是一个方便的快捷方式,让我们能够用最少的代码在视图模型上设置 currentProduct 和 currentCategory。
onProductClick 和 onProductClose 方法使用 redirectTo 将实际行为传递给相关的路由。onAddToCart 方法从视图模型中获取购物车存储,并使用我们在数据层中创建的 addProduct 方法将当前产品推入购物车。
最后,我们有产品视图模型:
// app/view/product/ProductModel.js
Ext.define('Alcohology.view.product.ProductModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.product',
links: {
currentCategory: {
type: 'Alcohology.model.Category',
id: 1
}
},
data: {
sortProperty: 'id',
sortDirection: 'ASC'
},
stores: {
products: {
type: 'products',
autoLoad: true,
remoteFilter: true,
remoteSort: true,
sorters: [{
property: '{sortProperty}',
direction: '{sortDirection}'
}],
filters: [{
property: 'categoryId',
value: '{currentCategory.id}'
}]
}
}
});
links配置设置了要加载的初始类别;Ext JS 将自动完成此操作,并且任何绑定到它的东西都可以在加载完成后立即使用它。这里不需要手动干预;只需连接配置并继续即可。
data对象包含产品排序的默认值,您可以看到这些值被products存储使用,并通过remoteSort发送到服务器。产品存储用于为类别中的产品列表提供动力,为此它有一个绑定到currentCategory ID 的过滤器。这将作为 JSON 与排序选项一起发送。
类别和产品已经处理好了。现在是时候转向购物车 UI 了。
一个篮子案例
购物车本身是一个网格,显示购物车中的产品和每个产品的数量。它被包含在一个窗口中,底部有几个操作按钮,如下所示:
// app/view/cart/Cart.js
Ext.define('Alcohology.view.cart.Cart', {
extend: 'Ext.Window',
requires: ['Alcohology.view.cart.CartController'],
controller: 'cart',
width: 500,
height: 350,
modal: true,
resizable: false,
header: false,
onEsc: Ext.emptyFn,
layout: 'fit',
items: [
{
xtype: 'grid',
bind: '{cart}',
plugins: {
ptype: 'cellediting',
clicksToEdit: 1
},
listeners: {
edit: function(editor, e) {
e.record.commit();
}
},
hideHeaders: true,
emptyText: 'No items in the cart.',
columns: [
{ name: 'Product', dataIndex: 'productName', flex: 1 },
{
name: 'Quantity', dataIndex: 'quantity',
editor: {
xtype: 'numberfield',
allowBlank: false
}
}
]
}
],
bbar: [
'->',
{ text: 'Close', itemId: 'closeCart' },
{ text: 'Order Now', itemId: 'orderNow' }
]
});
在网格中,我们使用了cellediting插件,允许用户轻触数量列并使用 Ext JS 在触摸友好主题中提供的加号和减号图标来调整项目数量。当数量被编辑并且网格上的edit事件被触发时,我们立即将更改提交到购物车存储,该存储与父视图模型绑定。
注意,这个视图没有特定的视图模型。相反,由于我们在主视图的构造函数中实例化了此窗口,它将继承主视图模型。这意味着我们可以通过在视图模型层次结构中将其放置得更高来与多个组件共享购物车存储。
让我们继续到购物车视图控制器,如下面的代码所示:
// app/view/cart/CartController.js
Ext.define('Alcohology.view.cart.CartController', {
extend: 'Ext.app.ViewController',
alias: 'controller.cart',
listen: {
component: {
'#closeCart': { click: 'onCartClose' },
'#orderNow': { click: 'onOrderNow' }
}
},
onCartClose: function() {
this.getView().hide();
},
onOrderNow: function() {
var vm = this.getViewModel();
if(!vm.get('currentUser')) {
this.fireEvent('loginrequired');
} else {
var order = vm.get('cart').toOrder();
vm.get('cart').removeAll();
vm.get('orders').add(order);
Ext.toast('Order Accepted!');
this.getView().hide();
}
}
});
我们通过使用在视图中定义的选择器itemId来为窗口的按钮连接事件处理器。onCartClose方法很简单,但onOrderNow方法则更有趣。
它首先通过检查视图模型上的currentUser是否为null来确定用户是否已登录。如果用户未登录,将触发loginrequired事件;如果您还记得,我们之前在主视图控制器中处理了这个问题。如果用户已登录,我们将执行以下操作:
-
从购物车存储调用
toOrder方法以获取Order模型 -
从购物车中移除所有项目
-
将新的
Order模型添加到视图模型上的订单存储 -
向用户显示一个吐司通知
-
隐藏购物车窗口
所有这些最终导致购物车被移动到一个订单。在一个全面的电子商务应用中,这部分将由信用卡捕获和支付处理所取代,但在这里我们采取了简单的方法,即通过调用其他类的方法来执行我们需要的操作。
如前所述,购物车视图没有自己的视图模型,因为它继承自父视图,所以我们现在将转向应用中的最后一个视图;账户窗口。
账户窗口
账户视图是一个包含多个子组件(如登录、注册和过往订单)的窗口。让我们看看它的冗长但直接的代码:
// app/view/account/Account.js
Ext.define('Alcohology.view.account.Account', {
extend: 'Ext.Window',
xtype: 'account',
layout: 'fit',
controller: 'account',
modal: true,
resizable: false,
header: false,
onEsc: Ext.emptyFn,
width: 800,
autoHeight: true,
frame: true,
items: [
{
xtype: 'container',
layout: 'column',
items: [
{ xtype: 'login', title: 'Login', columnWidth: 0.5 },
{ xtype: 'register', title: 'Register', columnWidth: 0.5 }
],
bind: { hidden: '{currentUser}' }
},
{
xtype: 'container',
layout: 'column',
items: [
{ xtype: 'register', title: 'Register', columnWidth: 0.5 },
{
xtype: 'panel', title: 'Past Orders',
columnWidth: 0.5, items: [
{ xtype: 'pastorders', bind: '{orders}' }
]
}
],
bind: { hidden: '{!currentUser}' }
}
],
bbar: [
'->',
{ text: 'Close', itemId: 'close' },
{
text: 'Login/Register', itemId: 'loginRegister',
bind: { hidden: '{currentUser}' }
}
]
});
这里有两个面板,都设置为使用column布局。一个包含登录和注册表单,当用户注销时显示。另一个显示注册表单,作为让用户编辑其个人资料详情和过往订单的方式。第二个面板仅在用户登录时显示。
在账户窗口中隐藏和显示组件是通过将hidden配置绑定到顶级主视图模型中的currentUser来实现的。Ext JS 将用户对象转换为“真值”,即 true 或 false。这用于设置组件的可见性。
接下来,我们有一个login组件,它只是一个带有相关字段和一些解释性文本的Ext.FormPanel,如下面的代码所示:
// app/view/account/Login.js
Ext.define('Alcohology.view.account.Login', {
extend: 'Ext.FormPanel',
xtype: 'login',
items: [
{
xtype: 'fieldset', margin: 10, padding: 10,
defaults: { xtype: 'textfield', width: '100%' },
items: [
{ fieldLabel: 'Email', bind: '{email}', vtype: 'email' },
{ fieldLabel: 'Password', inputType: 'password' }
]
},
{
xtype: 'container',
padding: 10,
html: 'If you've already got an Alcohology account,please enter your login details above. If not, please complete the registration form and join us!'
}
]
});
然后,我们有一个register组件,另一个包含用户必须完成以注册的字段的表单:
// app/view/account/Register.js
Ext.define('Alcohology.view.account.Register', {
extend: 'Ext.FormPanel',
xtype: 'register',
defaultType: 'textfield',
items: [
{
xtype: 'fieldset', margin: 10, padding: 10,
defaults: { xtype: 'textfield', width: '100%' },
items: [
{ fieldLabel: 'Email', bind: '{email}', vtype: 'email' },
{ fieldLabel: 'Password', inputType: 'password' }
]
},
{
xtype: 'fieldset', margin: 10, padding: 10,
defaults: { xtype: 'textfield', width: '100%' },
items: [
{ fieldLabel: 'House Number' },
{ fieldLabel: 'Street' },
{ fieldLabel: 'Town' },
{ fieldLabel: 'County' },
{ fieldLabel: 'Postcode' }
]
}
]
});
账户用户界面的最后一部分是过往订单组件:
// app/view/account/PastOrders.js
Ext.define('Alcohology.view.account.PastOrders', {
extend: 'Ext.DataView',
xtype: 'pastorders',
tpl: new Ext.XTemplate('<tpl for="."><div class="past-order">',
'<h3>Ordered on {date:date("m F Y")}</h3>',
'<ul><tpl for="items">{name} x {quantity}</tpl></ul>',
'<p>Total: £{total}</p></div></tpl>'),
itemSelector: '.fake',
emptyText: 'No Previous Orders.'
});
在这里,我们使用配置了itemTpl的数据视图来输出所有订单以及遍历此订单内的项目。由于过往订单均不可点击,因此没有可点击的订单详情视图。因此,我们需要指定一个假的itemSelector。在包含的账户窗口中将此组件的存储绑定到视图模型上的订单存储。
最后,我们有一个简单的视图控制器来处理与账户窗口的交互,如下面的代码所示:
// app/view/account/AccountController.js
Ext.define('Alcohology.view.account.AccountController', {
extend: 'Ext.app.ViewController',
alias: 'controller.account',
listen: {
component: {
'#close': { click: 'onAccountClose' },
'#loginRegister': { click: 'onLoginRegister' }
}
},
onAccountClose: function(btn) {
this.getView().hide();
},
onLoginRegister: function() {
this.getViewModel().set('currentUser', {
email: this.getViewModel().get('email')
});
this.getView().hide();
}
});
在onAccountClose中,这是标准操作,但在onLoginRegister中,我们执行了一个非常简单的登录操作,其中currentUser被设置为用户为登录或注册输入的电子邮件地址的对象。如前所述,我们为了简单起见绕过了完整的认证系统,但这演示了基本思想,即执行一个最终将用户设置在继承的视图模型上的操作。再次强调,您将看到我们没有单独的账户视图模型,因为所有内容都是向上和向下传递到主视图上定义的那个。
摘要
本章简要介绍了我们应已熟悉的特性和想法。这是过去几章工作的总结,以及如何以低复杂度构建具有多个视图的应用程序的演示。
在下一章中,我们将从完整的应用程序转向查看 Ext JS 项目中涉及的性能和调试考虑。我们如何设计我们的应用程序以帮助开发者解决问题?我们如何尝试确保我们的应用程序对最终用户来说感觉响应迅速?我们将在下一章深入探讨这些问题。
第十章。调试和性能
在前面的章节中,我们讨论了架构师的多面角色。从理解客户需求到根据 UI 线框设计代码结构,再到编码标准和命名约定,架构师在项目生命周期中会戴许多帽子。
其中一个角色是利用现有技术的知识进行规划。为了确保软件平台的使用者获得响应式体验,不会导致挫败感,我们需要确保现有的设计能够快速加载,并能迅速响应用户输入。使用 Ext JS,这意味着要理解在正确的时间使用正确的组件,以高效的方式处理布局,设计视图模型层次结构以避免过度嵌套,等等。有许多事情需要考虑。
一旦设计完成,随着开发的进行,可能会出现意外的性能问题或错误。在这些情况下,架构师可能需要承担专家问题解决者的角色,跳入应用他们对技术的实际知识。与第三方开发工具一起逐步检查源代码和性能缓慢的区域是推动项目完成的要点。
在本章中,我们将从 Ext JS 和更多背景下涵盖这些主题:
-
使用浏览器工具进行调试和跟踪性能
-
检查 Ext JS 源代码
-
Ext JS 性能的要点和禁忌
-
Ext JS 开发的常见陷阱
到本章结束时,我们将具备作为项目救火员的能力,在情况失控之前,迅速跳入需要快速、权威解决方案的情境。
浏览器内调试
我们将检查 Google Chrome 浏览器开发者工具的几个功能(首先是逐步执行代码和调试)。为什么是 Google Chrome?除了它是我的首选浏览器之外,它的工具感觉比 Firefox 等浏览器更流畅。话虽如此,如果你更喜欢坚持使用 Mozilla,Firefox 也会让你完成本章中讨论的大部分内容。在撰写本书时,Chrome 的版本是 40,但这些功能中大多数至少已经存在一年了。
在开发过程中,不可避免地会遇到代码无法按预期工作的情况,无论是我们编写的代码,还是我们开发团队其他成员的代码,或者是第三方库(如 Ext JS)中的代码。
当这种情况发生时,能够直接停止代码执行并检查应用程序的状态是非常有用的。这正是 Chrome 调试器允许我们做到的。
注意
使用 Chrome 开发者工具调试 JavaScript 的文档可以在 developer.chrome.com/devtools/docs/javascript-debugging 找到。
我们将使用来自第九章的 Alcohology 应用,购物应用程序,来展示调试会话可能如何进行。
步入
让我们在 Chrome 中加载 Alcohology 项目并导航到一个产品,然后通过访问视图菜单,然后开发者,以及开发者工具来弹出 Chrome 开发者工具。选择开发者工具中的源选项卡,你应该会看到类似这样的内容:

使用 Chrome 开发者工具的 Alcohology 应用
让我们想象一个理论情况,当我们将此产品添加到购物车时,可能有些不对劲。我们希望进入处理添加到购物车按钮点击事件的代码,因此我们在开发者工具的左侧面板中使用文件资源管理器导航到/app/view/product/ProductController.js并滚动到onAddToCart方法。
通过单击第54行的行号,将出现一个蓝色指示器,表示我们在onAddToCart方法的开头设置了“断点”。当代码执行到达第54行时,调试器将暂停执行,并给我们机会检查应用程序状态:

ProductController.js 在第 54 行设置了断点(以蓝色表示)
已经设置了断点,所以下一步是触发此代码的执行并检查onAddToCart。
断点
点击添加到购物车按钮将调用onAddToCart点击处理程序并在第 54 行暂停执行。整行将高亮显示为蓝色,以显示我们暂停的位置。这一行代码从视图模型中获取当前产品并将其存储在product变量中。
在这一点上,高亮显示的代码行尚未运行,因此product变量将是未定义的。让我们继续到下一行,通过点击右侧面板中的曲线箭头来检查product的内容:

红圈中高亮显示的“跳过下一个函数调用”按钮
这将跳过当前行并移动到下一行,这次将第 56 行高亮显示为蓝色。
在这一点上,product变量将被设置,因此你可以将鼠标悬停在其上,将出现一个弹出窗口显示其各种属性和方法。由于它是一个Alcohology.model.Product的实例,而Alcohology.model.Product是Ext.data.Model,我们将看到继承链中所有类提供的对象上的所有属性和方法。
根据继承链的确切结构,一些属性和方法只能通过通过__proto__属性向下钻取并展开来揭示。
注意
__proto__属性是一个指向此对象原型的 JS 对象的内部属性。Ext JS 通过使用 JavaScript 的原型继承来复制经典继承。你可以在 MDN 上了解更多详细信息developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Inheritance_and_the_prototype_chain。
第 56 行看起来是这样的:
this.getViewModel().get('cart').addProduct(product);
假设我们想深入到addProduct方法中,调查这里发生了什么。Step in to next function call按钮将允许我们这样做(它位于Skip over按钮旁边的向下箭头)。问题是它确实做了它所说的,那就是进入下一个函数调用。第 56 行实际上由三个单独的函数调用组成:
-
一个调用
getViewModel,它返回分配给这个视图控制器的视图模型 -
一个带有参数'cart'的
get调用,它从视图中返回购物车 -
最后,带有产品变量作为参数的
addProduct调用
结果是,我们第一次点击Step In时,会进入getViewModel的 Ext JS 框架代码。然后我们点击向上的箭头Step Out返回到第 56 行。再次点击Step In和Step Out会将我们带入get并再次退出。最后,在我们这个小舞蹈的第三部分,Step In会带我们进入addProduct方法的源代码:

进入addProduct代码
我们现在可以检查产品在这个方法中的使用情况,并验证代码是否按预期运行,如果不是,原因是什么?
重复的进入/退出可能会很痛苦且令人困惑,尤其是对于想要使用这些高级工具的新开发者来说。作为问题解决架构师,我们需要为了我们自己以及帮助我们的团队在调试会话中理解这些怪癖。有几种方法可以解决这个问题;我们将在下一节中探讨这些方法。
黑盒化和直截了当
Chrome 有一个名为“黑盒化”的功能,它告诉 Chrome 在调试时跳过代码文件。这正是我们需要的,以避免我们之前看到的进入/退出舞蹈;通过黑盒化 Ext JS 源代码,Chrome 将不会进入此代码。
设置黑盒化很容易。只需使用左侧的文件导航器打开 Ext JS 源代码,在开发构建中可能类似于这样:
/ext/build/ext-all-rtl-debug.js
我们在遍历代码时也打开了此文件,因此我们有一种替代方法在中央面板中打开它。当我们得到有问题的文件时,只需右键单击它并从菜单中选择Blackbox Script选项即可:

带有高亮 Blackbox Script 选项的源文件选项菜单
会在窗格顶部出现一个横幅,指示文件已被黑盒化。这是一种简化调试会话的好方法。由于 Ext JS 源代码超过 10 万行,这也可以大大加快过程。
注意
有多种方法可以将脚本黑盒化。有关 Chrome 开发者工具文档的更多信息,请参阅 developer.chrome.com/devtools/docs/blackboxing。
避免逐步进入/退出痛苦的第二种方法是完全绕过相关的代码。如果我们知道我们真正想要调试的方法的来源,那么为什么不在那里设置另一个断点呢?在我们的上一个例子中,在 Cart.js 的第 9 行设置断点,正好在 addProduct 方法中,会更有意义。通过点击表示为蓝色向右箭头的 恢复脚本执行 图标,我们可以立即从 ProductController 中的断点跳转到 Cart 类中的断点。
软件开发中有一个谚语:写代码比读代码容易。花时间去理解别人写的代码是一项所有开发者都应该掌握的关键技能,但对于经常处于评估现有代码和理解其如何融入更大图景的架构师来说,尤其有价值。
调试和逐步执行代码是这个过程中的关键部分,追踪不同路径的执行以构建应用程序行为的图景。
打破和破解
在项目的某个开发阶段,开发者不可避免地会面临一个晦涩的错误,这个错误是在应用程序加载时从 Ext JS 的深处抛出的。这引发了 Ext JS 设置其缓存机制的方式问题。例如,一个正常(如果稍微有些天真)的 JavaScript 文件请求可能看起来像这样:
GET: /assets/javascripts/jquery.min.js
默认情况下,Ext.Loader 将拉取如下脚本:
GET: /ext/build/ext-all-rtl-debug.js?_dc=1420215165267
它将时间戳查询变量附加到请求中。这是为了确保我们总是通过绕过浏览器缓存机制来获取脚本的最新版本。
这可能非常有用;然而,在我们的情况下,这意味着当页面重新加载时,我们代码中设置的任何断点都将被移除,因为 Chrome 认为文件是不同的,因为时间戳不同。
我们该如何解决这个问题?实际上非常简单;只需在 Ext JS 项目的根目录中打开 app.json 并搜索以 "this option is used to configure the dynamic loader" 开头的注释。该注释包含传递给加载器的各种选项,例如传递 true,这 "允许请求接收缓存的响应"。因此,我们可以添加以下代码:
"loader": {
"cache": true
},
此外,缓存破坏的时间戳也将被移除。注意尾随逗号。这是确保文件保持可解析 JSON 的必要条件。
被当场抓住
回到出现错误让我们感到惊讶的情况。错误可能发生在页面加载时,用户交互时,或者由于某些后台任务。能够捕捉到错误发生时的情况将非常棒,这样我们就可以用调试器四处探索并尝试确定出了什么问题。
幸运的是,Chrome 提供了一个正好做这件事的功能:“断点错误”,如下所示:

被红色圆圈高亮的“断点错误”图标
让我们尝试一个假设的例子。点击“断点错误”按钮,它变成蓝色,然后打开 Alcohology 项目的Application.js文件,并在启动方法中添加以下代码:
Ext.create('Ext.Panel', {
html: 'Break on error test!',
renderTo: 'myElement'
});
保存并重新加载网页浏览器。Chrome 将立即跳转到 Ext JS 代码深处错误的源头:

Chrome 调试暂停在抛出错误的代码行上
原始抛出的错误是Uncaught TypeError: Cannot read property 'dom' of null。在没有上下文的情况下,这没有太多意义。现在我们处于代码中,我们可以看到周围的变量并确定到底哪个变量为 null,在这个例子中,是el变量。
我们还可以使用右侧面板中的调用栈面板跳过调用栈,到最初发起这个代码路径的代码,并查看中间的所有代码调用。这在复杂场景中非常好,可以让我们追踪错误的根本原因:

单独来看,这些都无法帮助我们解决问题。能够看到错误、错误发生时应用程序的状态以及通过代码到原始调用站的路径,所有这些结合对 Ext JS 的理解和一些直觉,给我们提供了解决这个问题的有力证据。
el变量,简称"元素",为空,回顾我们在启动方法中的代码,我们发现我们将面板设置为renderTo一个名为myElement的元素。使用调用栈面板,我们可以向下到构造函数并做一些侦探工作:
-
Ext.create将调用面板的构造函数;我们可以通过在调用栈面板中点击Ext.panel.Panel来看到这一点。 -
这反过来又调用了
Ext.util.Renderable混入的渲染方法。它传递了renderTo配置的值作为参数。 -
在渲染方法中,这个参数被称为容器。
-
渲染方法调用
Ext.DomHelper.append并带有容器参数,调试器显示该参数为 null。 -
这表明容器变量在渲染方法中被其他地方操作。
-
追溯回去,我们发现罪魁祸首:
container = me.initContainer(container)。 -
initComponent包含以下行container.dom ? container : Ext.get(container)。
所有这些最终都指向这个特定错误的根本原因:将字符串 myElement 传递给 Ext.get。由于我们没有在 HTML 页面上添加具有此 ID 的元素,Ext JS 无法找到它。这意味着面板没有有效的容器来渲染,从而导致抛出错误。
能够进行这种调查的能力可能是一个项目能否按时完成与因意外问题而停滞之间的区别。以这种方式深入代码是一种避免障碍并保持开发者前进的必要技能。
Ext JS 中的性能
作为架构师,我们将从客户那里开始一个项目,客户会提供一个需要实现的长长的需求列表,以满足他们的需求。其中会有明确的事项,例如“登录功能”和“移动友好”,但还有一些需求并未包含在这个列表中,然而这些却是每个客户不可避免的需求。
其中之一是应用程序应该表现良好。这是一个包含以下内容的通用术语:
-
UI 元素的响应性
-
应用程序初始启动时间
-
远程请求,如加载/保存
慢速应用程序是用户沮丧的关键来源,诊断此类问题的第一步是收集信息。
让我们看看 Chrome 开发者工具如何帮助我们以各种方式解决使用 Ext JS 的常见问题。
网络性能
正如我们在示例中所看到的,许多 Ext JS 应用程序将通过与后端 API 服务进行通信来读取和写入数据。如果用户反馈显示远程请求无响应,我们需要首先诊断导致问题的调用。Chrome 在这里可以帮助我们。
在打开开发者工具的情况下,点击网络选项卡并刷新页面。我们得到了应用程序加载时请求的所有资源的列表;我们可以通过点击面板顶部的其中一个标题来过滤这些资源。以下是过滤到“XHR”或 Ajax 请求的 Alcohology 应用程序:

已选择 Ajax 请求的 Chrome 开发者工具
在这个例子中,已经选择了一个请求,并在右侧显示了时间细节的分解。虽然 Alcohology 应用程序的响应时间非常快,但它很好地说明了如何分析网络性能。
注意
更多关于网络面板的详细信息可以在developer.chrome.com/devtools/docs/network找到。
这对我们有什么帮助?如果远程服务器很慢,那么作为前端开发者,我们对此无能为力;除了将我们的远程请求保持在最低限度外,我们只能接受现有的情况。以下是我们可以采取行动来加快速度的一个例子。
注意“停滞”的条目在时间线中。这通常是由于浏览器对一次可以活跃到同一来源(如域名或子域名)的连接数所施加的限制。当这个数字超过时,浏览器将阻止任何新的请求,直到有连接可用。这种知识给我们提供了几个优化的机会。
减少请求数量
解决这个问题的简单方法是从服务器请求更少的东西,而 Ext JS 提供了几种方法来实现这一点。首先是一个我们在 Alcohology 应用中使用的技术(使用图标字体,如FontAwesome而不是图像图标)。这意味着我们只需要下载一个字体文件,而不是多个图标文件。菜单项和按钮上的glyph配置为我们提供了一个非常简单的方式来使用这个功能,并消除使用位图图像作为图标的需要。
接下来是嵌套数据。这是我们用于Questionnaire组件的方法,但需要谨慎使用。通过设置模型关联,我们可以一次性请求整个层次结构的数据,并填充我们的数据模型,而不是按模式类型逐个请求。
例如,对于Questionnaire组件,我们本可以分别通过三个单独的请求加载问卷、步骤和问题,但通过一次性下载所有内容,我们避免了两次远程请求。这个想法有两个注意事项;首先,服务器需要支持嵌套数据;其次,嵌套数据的大小可能更大,因此可能导致更慢的响应时间。多个单独的请求可能会带来更好的感知性能,因此这个概念的应用强烈依赖于具体情况,比如性能感知等。
感知性能
在某些情况下,我们无法提高应用程序某些方面的加载时间,例如,可能是报告生成,其中涉及的计算是一个长时间运行的操作。
在这种情况下,我们需要尽我们所能来提高用户对性能的认知,并确保他们正在执行的操作正在进行中,并且很快就会完成。
在 Ext JS 中,实现这一目标的一个关键机制是Ext.LoadMask。它在 Ext JS 内部在几种情况下被使用:

在 Ext JS Kitchen Sink 的先前示例中,一个带有分页工具栏的网格在等待服务器返回下一页时会自动使用LoadMask。
对于用户来说,优点是他们虽然必须等待,但至少被告知他们必须等待。如果没有LoadMask,他们可能会点击按钮,但不会收到服务器正在考虑请求的任何反馈。
我们可以通过在需要时创建一个新的实例来在我们的代码中利用LoadMask,但更有可能的是,我们会在现有的容器上使用它,并利用许多 Ext JS 组件内置的LoadMask功能。
我们可以使用事件域,这是我们之前在第二章中讨论的,作为钩入远程请求和屏蔽我们组件的一种方式:
listen: {
store: {
'products': {
'beforeload': function() {
this.lookupReference('list').mask('Loading...');
},
'load': function() {
this.lookupReference('list').mask(false);
}
}
}
}
在我们添加到 Alcohology 的ProductController的这段代码中,我们告诉listen配置监视产品存储上的beforeload和load事件。监听器可以使用我们在产品store类上已经设置好的“products”别名。当beforeload事件触发时,我们获取产品列表视图并调用其 mask 方法来显示其LoadMask。当服务器响应返回并且load事件触发时,我们使用 false 参数调用 mask 方法来再次隐藏它。
这是一个简单且不引人注目的方法来连接加载机制,并给用户提供至关重要的反馈,显示他们的操作已经触发了效果。
加载较少
一种非常简单的方法来加快 Ajax 请求的响应速度是请求更少的东西!如果服务器支持分页,那么任何由存储支持的组件都可以通过请求单页数据而不是一次性拉取数据来立即提高性能。
Ext.PagingToolbar可以链接到存储并在应用程序的任何位置放置,以提供分页 UI:
bbar: [{
xtype: 'pagingtoolbar',
store: { bind: '{products}' },
displayInfo: true
}]
例如,以下是我们在 Alcohology 应用程序的产品列表视图中添加分页工具栏的开始。它自动采用我们使用的主题的触摸友好样式:

Alcohology 的产品列表,包括分页工具栏
这种技术的更高级版本涉及使用Ext.data.reader.Reader.metaData属性或Ext.data.proxy.Proxy.metachange事件。这可以用来从服务器传递数据到客户端,然后用于配置 Ext JS 应用程序;一个常见的例子是允许服务器在首次加载时指定网格包含的列。
这可能意味着在初始加载时,服务器省略了一个字段和匹配的网格列,例如描述,这可能包含大量数据。用户可以通过列标题 UI 自定义网格,在他们需要时显示它。
乐观更新
当将记录保存到服务器时,我们通常会看到以下操作:
-
用户点击按钮保存记录
-
Ext JS 显示“正在保存”消息
-
保存完成并显示“成功”消息
然而,在某些情况下,我们可以相信服务器将成功保存,并将操作简化为以下内容:
-
用户点击按钮保存记录
-
保存进行并显示“已保存”消息
在这种情况下,保存操作完全在后台进行,不会阻止用户执行其他操作。您可以在许多电子邮件应用程序中看到这种行为,服务器交互在幕后进行,并且有一个“发件箱”来存储第一次尝试发送失败的消息。
如果服务器确实抛出某种错误,我们可以显示一个错误消息并回滚用户所做的更改。Ext JS 网格包括用于此目的的 UI;它将以红色标记突出显示更改的值,当我们确信记录已成功提交到服务器时可以清除这些标记。
这是一个需要额外 UI 设计工作的高级技术,但它可以极大地改善用户体验。
快速反应
除了确保我们的应用程序与服务器交互的方式始终及时外,我们还需要关注用户界面的渲染速度以及浏览器绘制 UI 的速度。在这一点上,Ext JS 存在一些常见的陷阱:
过度使用面板
面板具有一些功能,例如包含工具图标的标题、可拖动、可折叠以及具有停靠项的能力。在许多情况下,这些功能根本不是必需的。在这种情况下,容器是一个更好的选择。它在内存中更轻量级,并且它生成的标记也更少。
过度嵌套
容器和面板的过度嵌套,尤其是那些实现边框布局的,是性能问题的一个非常常见来源,尤其是当用户需要在包含过度嵌套组件的各种屏幕之间移动时。Ext JS 需要进行大量的计算来构建视口,布局过程尤其昂贵。每次我们发现自己在层次结构中添加一个新的组件时,都应该让我们停下来重新评估。
我们还需要考虑一个深层层次结构可能如何影响我们查询组件结构的情况。随着组件树变得更加复杂和 DOM 更加复杂,从应用程序中获取组件或元素的操作将会变慢。在许多情况下,这种减速将是微不足道的;在某些情况下,当提高应用程序性能时,它将是一个关键的考虑因素。
延迟渲染
当创建具有卡片布局的容器或面板时,默认情况下,布局将立即渲染每个卡片内的所有组件。这通常是不必要的,因为用户一开始只会看到第一张卡片。相反,我们可以使用卡片布局上的 deferredRender 配置选项,仅在父卡片变为活动状态时渲染项目。这减少了初始渲染时间,因此,直到应用程序可以响应用户输入的时间。
另一种类似的方法涉及网格。Ext JS 允许你将大量的服务器响应加载到存储中,并尝试在网格上显示它,但在有数千条记录的情况下,存储会导致内存使用激增,浏览器将难以保持如此大量 DOM 节点组成的网格行的流畅性。
解决方案是替换为Ext.data.BufferedStore标准存储。这使用增强的分页机制预先加载数据页。同时,网格将自动使用Ext.grid.plugin.BufferedRenderer,当用户滚动网格条目时,后端的BufferedStore将自动加载新页面数据,同时删除旧页面。它这样做无缝,因此用户不会意识到行是从服务器即时加载的。BufferedRenderer还会在它们滚动过去时销毁旧节点中的 DOM 节点,从而减轻大量数据的内存负担。
所有这些技术都有其位置。它们也是架构过程开始时需要了解的有用工具。如果客户询问应用程序是否可以处理数据网格中的 10,000 条记录,我们现在知道缓冲存储可以提供所需的功能。另一方面,我们需要意识到过早优化;当我们只处理少量记录时,实现缓冲存储是没有意义的。
性能分析
处理性能问题最初是一个情报收集的问题。应用程序感觉缓慢可能有多个原因,用户的反馈往往无助于解决问题。将应用程序的一部分描述为“粘性”并不是诊断根本原因的好方法。在我们希望找到解决方案之前,我们需要将确凿的事实和数字摆在我们面前。虽然我们将查看渲染和响应时间等指标,但必须考虑许多因素,例如内存使用和用户感知。这些主题留作读者练习。
Chrome 开发者工具再次伸出援手。这次,我们将探讨两个有助于诊断性能问题的主要功能:配置文件和时序图。
注意
通常,当 Ext JS 应用程序感觉缓慢时,有两种情况:糟糕的设计决策和过度复杂的应用程序。话虽如此,当问题出现时,这些技术非常有价值,并且适用于任何 JavaScript 技术。
可以通过打开开发者工具并选择配置文件选项卡来找到分析器:

虽然可以发出多种类型的配置文件,但我们将查看 CPU 配置文件。在前面的屏幕截图中,我们会点击开始立即开始分析。以下是在 Alcohology 中点击产品时发生的情况:

网格按耗时最长的函数调用从上到下排序,并且每一行都可以展开,以便我们可以看到依次调用的函数。Self列显示在该函数体内花费的时间百分比;Total列显示该函数以及它调用的所有函数所花费的时间。
虽然 Alcohology 应用的这部分运行得很好,但这个简单查看代码中频繁使用部分的视图是诊断严重性能问题的关键途径。然而,我们还能做更多。
我们已经使用 Chrome 开发者工具评估了远程请求的性能,现在我们使用它们来追踪代码中的热点。那么,有没有一个单一的视图可以帮助我们跟踪应用程序的一般行为,并帮助我们可视化那些减慢事情进展的交互类型?这正是时间线标签提供的。
注意
这是对时间线的快速浏览。有关更多详细信息及完整文档,请参阅developer.chrome.com/devtools/docs/timeline。
通过选择时间线标签并点击记录按钮,Chrome 会立即开始记录页面上当前发生的每个事件。再次点击记录按钮,您可以分析结果:

时间线允许您筛选事件的时间范围,并依次查看每个事件,从用户点击到 Ajax 请求,再到 URL 更改和浏览器重绘。从第一个事件开始,我们可以追踪每个后续事件,查看堆栈跟踪、详细的计时和有关问题行为的警告。
时间线是一个强大的工具,您需要时间和经验才能充分利用它。这是值得花费的时间;它可以揭示其他方式难以或无法发现的线索。这些线索可以帮助解决其他方式可能需要几天时间才能解决的问题。
摘要
本章为我们提供了多个角度来处理构建成功应用的两个最重要的部分:解决问题和添加润色。虽然明确的客户需求构成了我们工作的主体,但我们始终应努力确保我们的项目性能良好,尽可能无错误。
Chrome 开发者工具是我们武器库中的无价之宝。通过在调试和性能的黑暗世界中打开一个窗口,我们可以采取有针对性的步骤来快速解决问题。利用断点和调用堆栈探索器的力量,我们可以更容易地逐步执行我们的代码以及 Ext JS 框架的代码。
在下一章中,我们将迈出构建全面且稳健应用的最后一步。虽然我们一直在努力构建最佳架构,但自动化测试可以提供一种保证,将我们工作的稳定性提升到新的水平。
当我们的测试套件能够告诉我们是否因最新的修改而破坏了某些内容时,我们可以有信心地进行代码更改和重构。集成测试可以确保我们的最终应用满足客户需求,单元测试可以鼓励代码分离并确保我们的业务逻辑正确。
在下一章中,我们将探讨多种实现这些想法及更多内容的方法。
第十一章。应用程序测试
我们作为架构师的角色不仅仅是勾选框,将应用程序发送给客户,然后忘记它。从专业和商业的角度来看,我们有责任生产试图超越预期的软件。其中一部分在第十章中提到,即调试与性能,我们讨论了构建对用户操作反应迅速的应用程序的需求。现在,我们将讨论构建一个鲁棒的应用程序,一个在重压下经得起考验的应用程序。
应用程序要具备鲁棒性意味着什么?这意味着如果我们点击一个按钮,我们会看到预期的结果。如果我们尝试输入一个无效的电子邮件地址,我们会看到验证消息。如果我们刷新页面,我们会发现自己回到了之前的屏幕。如果网络连接断开,远程请求会在稍后重试。如果我们尝试破坏应用程序,等等,我们能否成功?
构建鲁棒应用程序的核心是应用程序应该始终按照用户的预期行为,即使在意外情况下也是如此。我们必须认识到开发者(和架构师)是会犯错的,并且不太可能考虑到即使是微小的代码更改的每一个可能的后果;这就是错误出现的原因,也是为什么对鲁棒性的追求是持续不断的。
我们需要安全网来应对编码的易出错性。如果我们更改产品列表视图中的一个特定方法,我们能否保证它不会影响购物车?我们可以在文本编辑器中使用查找和替换,但我们永远无法在没有刷新应用程序并检查产品列表和购物车的功能以证明客户需求仍然得到满足的情况下,达到 100%的确定性。
质量保证流程是一个安全网,其中自动化测试是其关键组成部分。当使用 Ext JS 时,我们有众多工具可以使用,以及一系列方法来确保我们的应用程序以有利于自动化测试的方式构建。为此,在本章中,我们将探讨:
-
不同的测试类型及其使用时机
-
将 Ext JS 代码关注点分离以促进单元测试
-
命名和编码规范以帮助进行集成测试
-
单元测试和集成测试的测试工具
-
Ext JS 特定的测试工具
本章的目标是建立对测试优势的理解,如何编写易于测试的 Ext JS 应用程序,以及如何选择和运用合适的测试工具。完成之后,我们将涵盖 Ext JS 架构师需要生产优秀产品所需的所有主题。
全面测试
在本章中,我们将介绍两种类型的测试,一种在细节层面,另一种在“大局”层面。第一种,单元测试,非常适合帮助处理通常构成业务逻辑的算法和计算;第二种,集成测试,帮助我们确保满足客户需求并且用户体验良好。让我们逐一查看。
单元测试
使用单元测试时,我们不出所料地测试一个单元,一个单元是一个单独的代码单元。这通常是一个完整的类,但会根据情况专注于单个方法。在单元测试中,我们可以说出如下内容:
Create cart object
Add product #1 to cart
Add product #1 to cart
Assert that there is only one item in the cart
为了设置测试,我们将同一产品添加到购物车两次。这应该导致一个数量为两个的行项目,而不是两个数量各为一的行项目。在第一个测试中,我们断言购物车计数等于一个,这确保了添加到购物车不会添加重复项。下一个测试将检查数量是否按预期增加:
Create cart object
Add product #1 to cart
Add product #1 to cart
Assert that first cart item has a quantity of two
它执行与上一个测试相同的设置,然后选择购物车中的第一行项目,并断言其数量等于两个,每个产品被添加到购物车一次。
断言自己
所有这些“断言”是什么意思?它只是说“如果这个条件不满足,就有问题”。在先前的例子中,如果实际值和预期值不相等,就有问题。在大多数单元测试库中,有许多不同的断言,例如:
-
等于
-
小于
-
大于
-
它是数字吗?
-
它是否包含指定的值?
每个测试库都有自己的断言方法风格。有些会使用稍微不同的术语(如期望或规范)。术语不如单元测试背后的原则重要,即对隔离的代码进行严格的审查。
集成测试
虽然单元测试关注的是一小块功能,但集成测试则走向了相反的极端。其目的是检查应用程序的所有移动部件是否正确协同工作,复制一些现实世界用户可能会采取的操作。
因此,集成测试通常被称为 UI 测试,因为它直接作用于界面。假设我们想验证当点击产品时,详细窗口是否会显示。我们可以这样做:
-
找到问题产品的链接。
-
在链接上模拟点击事件。
-
验证问题产品所在的 DOM 元素是否按预期显示。
这与我们之前在单元测试中拥有的那种关注点完全不同。在单元测试中,我们是深入到代码中的单个函数或类。在这里,我们要测试的操作将跨越应用程序中的多个类,检查它们是否集成并正确协同工作。
积分和微分
集成测试的本质意味着它操作的是用户可以看到的相同应用程序;测试实际上加载了一个浏览器并模拟用户会采取的路径。然而,与用户手动移动鼠标光标的方式不同,集成测试框架通常通过在屏幕上选择 HTML 元素并允许您直接执行操作来工作。
这既有好的一面也有不好的一面。当用户协商网页时,他们通常可以相当快地找到他们感兴趣的用户界面组件,但当编写测试的人来挑选这个相同的组件时,他们需要一种引用它的方法。通常的做法是使用 CSS 或 XPath 选择器。例如,要使用 CSS 和 XPath 引用页面上具有 ID 的元素,请使用以下代码:
#someElement
//*[@id="someElement"]
此外,获取容器中第一个按钮的另一个稍微复杂一些的代码如下:
#container > button:nth-child(1)
//*[@id="container"]/button[1]
这在一定程度上展示了编写集成测试的潜在痛点。如果someElement的 ID 发生变化怎么办?这将破坏测试,但这是一个相当简单的修复。如果"container"的 ID 发生变化呢?嗯,这不仅会破坏前面的例子,还会破坏任何寻找此div内按钮或其他元素的测试。
这是在集成测试中持续存在的问题:测试的脆弱性。在本章的后面部分,我们将探讨一些在 Ext JS 中解决此问题的方法。
使用 Jasmine 的测试工具时间
理论已经足够了!让我们通过使用第九章中的 Alcohology 应用程序(第九章 {
it('contains spec with an expectation', function() {
expect(true).toBe(true);
});
});
`describe`方法用`expect`方法声明的期望包围一个或多个规格,这些规格本身是在`it`方法中声明的。要将前面的代码翻译成普通语言,请使用以下命令:
```js
We have "a suite", which "contains spec with an expectation". This expectation expects "true" to be "true".
显然,这是一个虚构的套件,因为我们希望总是为真!然而,它应该作为一个有用的 Jasmine 测试通用语法的演示。在我们能够开始使用它并应用于我们自己的应用程序之前,我们需要花点时间下载并设置 Jasmine 库。
Jasmine – 安装和配置
开始使用 Jasmine 的最简单方法是下载项目发布页面上的最新版本。在撰写本书时,当前版本是 2.1.3。有关更多信息,请参阅github.com/jasmine/jasmine/releases。
解压缩 ZIP 文件,您会看到下载包括一些我们不需要的示例规范;让我们从新的 Jasmine 目录中清除这些内容:
rm MIT.LICENSE spec/* src/*
现在,我们可以将 Jasmine 库移动到 Alcohology 项目的根目录,假设我们的当前目录现在位于 Alcohology 项目中:
mkdir ./testsmv ~/Downloads/jasmine-2.1.3 ./tests/jasmine
现在,我们可以启动我们的应用程序;如果您已下载项目文件,则 readme 文件将告诉您运行npm start,这将启动 Ext JS 项目和 API 服务器。一旦完成,我们就可以在我们的浏览器中打开http://localhost:1841/tests/jasmine/SpecRunner.html来运行规范,如图所示:

编写规范之前的 Jasmine 规范运行器
在这个屏幕截图中,我们可以看到规范运行器,但它没有任何作用。在我们开始编写一些规范之前,我们还需要做一些配置。让我们在编辑器中打开SpecRunner.html文件,并调整它看起来像这样:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Jasmine Spec Runner v2.1.3</title>
<link rel="stylesheet" href="lib/jasmine-2.1.3/jasmine.css">
<script src="img/jasmine.js"></script>
<script src="img/jasmine-html.js"></script>
<script src="img/boot.js"></script>
<script src="img/ext-all-debug.js"></script>
<script type="text/javascript">
Ext.Loader.setConfig({
enabled: true,
paths: {
'Alcohology': '../../app'
}
});
</script>
<script src="img/Cart.js"></script>
</head>
<body></body>
</html>
这个 HTML 文件实际上只是 Jasmine 库的宿主,但它也是我们将 Ext JS 连接到应用程序上下文之外的地方。通过包含ext-allJavaScript 文件并重新配置Ext.Loader以从正确的目录获取任何 Alcohology 类,我们可以实例化用于测试的类,并且 Ext JS 将自动从我们的应用程序目录请求所需的文件。剩下的只是将实际的 JavaScript 规范文件包含在 head 元素的底部。在这里,我们已经添加了对spec/Cart.js的引用。
在完成所有设置后,我们可以继续编写一些测试!
让它发生
之前,我们编写了一些伪代码来展示如何测试购物车存储上的addProduct方法。现在,让我们构建一个真正的 Jasmine 规范,以实现这一目标。我们需要创建一个包含购物车存储的测试套件,该存储将用作测试对象:
describe('Cart store', function() {
var cart;
beforeEach(function() {
cart = Ext.create('Alcohology.store.Cart');
});
});
我们的第一个套件简单地称为Cart store。我们有一个购物车变量,它在beforeEach规范运行之前被重新分配。它通过Ext.create分配一个购物车存储实例。多亏了上一节中的配置,Ext.create将自动拉入相关的源代码文件,包括任何依赖项。通过在每次测试之前重新实例化,我们可以确保套件中较晚的测试不会受到早期测试操纵购物车的方式的影响。
我们现在可以勾勒出我们想要测试的功能。以下代码跟在beforeEach调用之后:
describe('#addProduct', function() {
it('should accept a Product model');
it('should create a new CartItem line');
it('should increment the quantity when adding an existing Product');
it('should not create a new CartItem when adding an existing Product');
});
如果我们刷新SpecRunner.html页面,我们实际上能看到类似这样的内容:

这些规范只是占位符,但它们出现在运行器中对于练习测试优先开发的开发者来说很有用。我们可以编写一系列描述所需功能的规范语句,然后是规范,最后是代码本身。这样,我们指定了所需的行为,代码本身随后跟来,我们可以放心地知道它符合我们的要求。这对于架构师来说,可以是一个强大的方法,详细说明一个类应该如何表现。
让我们逐个过一遍每个规范:
it('should accept a Product model', function() {
expect(cart.addProduct.bind(cart, {})).toThrow();
});
我们期望如果addProduct传递的不是产品模型,而是其他东西,它将抛出一个异常。我们将方法传递给预填充空对象字面量的expect调用。正如预期的那样,这不是产品模型,因此它抛出一个异常,并如下满足测试:
it('should create a new CartItem line', function() {
var product = Ext.create('Alcohology.model.Product');
cart.addProduct(product);
expect(cart.count()).toBe(1);
});
当产品被添加到购物车时,我们期望这将在商店中创建一个新的行项目。我们简单地检查在添加产品后购物车数量是否符合预期:
it('should increment the quantity when adding an existing Product', function() {
var product = Ext.create('Alcohology.model.Product');
cart.addProduct(product);
cart.addProduct(product);
expect(cart.first().get('quantity')).toBe(2);
});
在添加已经存在于购物车中的产品后,我们期望这将增加相应购物行项目的数量。我们传入相同的产品两次,并检查数量是否为预期的两个:
it('should not create a new CartItem when adding an existing Product', function() {
var product = Ext.create('Alcohology.model.Product');
cart.addProduct(product);
cart.addProduct(product);
expect(cart.count()).toBe(1);
});
这与上一个测试的设置类似,但我们期望不会有一个重复的购物车行,而只会在购物车中有一个项目。
在编写完所有规范后,我们可以再次刷新规范运行器:

如您所见,所有规范都是绿色的,表明它们已成功通过。
这只是关于使用 Jasmine 进行单元测试的简要入门,但它展示了可用的功能和以这种方式测试的实用性。它让我们对我们的代码充满信心,并确保对addProduct方法的任何添加都不会破坏现有的功能。
使用机器人进行测试
现在我们已经介绍了一种测试代码细节的方法,接下来让我们看看一种完全不同的在整个应用程序中运行功能检查的方法。为此,我们需要一个新的工具:CasperJS。它允许你驱动一个“无头浏览器”——一个没有用户界面的浏览器——在应用程序中导航,并对我们找到的内容进行评估。第一步是安装,这取决于平台。有关说明,请参阅docs.casperjs.org/en/latest/installation.html。
完成后,我们将有一个可用的 CasperJS 命令来运行。
在使用 Jasmine 时,我们使用带有期望的行为驱动测试方法来验证代码。在 CasperJS 中,我们回归使用断言风格的测试。看看 CasperJS 文档中的一个最小测试脚本:
casper.test.begin("Hello, Test!", 1, function(test) {
test.assert(true);
test.done();
});
这相当直接。真正的魔法出现在我们将它与 CasperJS 控制无头浏览器和与构成我们应用程序的网页交互的能力结合起来时。看看这个:
casper.test.begin('Google search retrieves 10 or more results', 4, function suite(test) {
casper.start('http://www.google.com/', function() {
test.assertTitle('Google', 'google homepage title is the one expected');
test.assertExists('form[action="/search"]', 'main form is found');
casper.fill('form[action="/search"]', {
q: 'casperjs'
}, true);
});
casper.then(function() {
test.assertUrlMatch(/q=casperjs/, 'search term has been submitted');
test.assertEval(function() {
return __utils__.findAll('h3.r').length >= 10;
}, 'google search for \'casperjs\' retrieves 10 or more results');
});
casper.run(function() {
test.done();
});
});
这看起来更有趣!让我们分析一下这里发生了什么:
-
创建一个新的 CasperJS 测试套件。
-
导航到 Google 首页。
-
断言页面标题与预期相符,并且我们可以找到搜索框。
-
填充搜索框并提交表单。
-
然后,当页面加载完成后,断言 URL 与预期相符,并且包含我们的搜索查询作为参数。
-
断言页面上至少有十个搜索结果。
太棒了!这个例子展示了如何轻松使用 CasperJS 控制网页,以及它的测试功能如何允许我们评估页面内容和应用程序的行为。
下一步是看看如何使用这些功能来测试我们自己的应用程序,所以让我们将 CasperJS 集成到我们的 Alcohology 项目中并进行测试。
使用 CasperJS 启动
让我们在项目中的tests/casper子目录下创建一个新的子目录,然后在那里创建一个名为Sanity.js的新文件。我们将编写一些简单的检查以确保应用程序正确加载。以下是代码的起点:
casper.test.begin('Alcohology Sanity Checks', 0, function suite(test) {
casper.start('http://localhost:1841/', function() {
});
casper.run(function() {
test.done();
});
});
我们首先调用casper.test.begin方法,它启动一个新的测试套件并接受三个参数:套件的描述、我们期望运行的测试数量,以及当套件创建时被调用的回调函数。回调函数接收一个测试对象,我们可以调用各种断言方法。
然后,我们调用 CasperJS 的start方法并传入我们应用程序的 URL。为了触发测试运行,我们调用 CasperJS 的run方法,当一切完成后,我们在测试对象上调用done方法。
我们将要编写的第一个测试将检查应用程序左侧的类别菜单是否按预期填充。为此,我们将查找第一个菜单项并检查它是否包含我们期望的文本,但当我们使用 Ajax 加载此内容时,这会稍微复杂一些。我们需要能够等待页面加载,选择相关元素,并检查它是否包含我们期望的内容。
为了选择元素,我们将使用 CSS 选择器,等等,但我们需要一个机制来找到正确的选择器。幸运的是,Chrome 开发者工具将再次伸出援手;如果我们右键单击 Alcohology 顶部类别菜单项中的 Pilsner 文本,然后选择 检查元素,元素面板将显示并选中该菜单项的元素。
接下来,右键单击元素并点击 复制 CSS 路径选项:

这个 div 标签的 CSS 选择器将被复制到你的剪贴板,并应如下所示:
#gridview-1014-record-6 > tbody > tr > td > div
我们现在可以使用 CasperJS 来做这件事:
casper.test.begin('Application sanity checks', 0, function suite(test) {
casper.start('http://localhost:1841/', function() {
var selector = '#gridview-1014-record-6 > tbody > tr > td > div';
casper.waitForSelector(selector, function() {
test.assertSelectorHasText(selector, 'Pilsner');
});
});
casper.run(function() {
test.done();
});
});
在告诉 CasperJS 开始并加载应用程序的网页后,我们将使用 waitForSelector 方法等待指定的选择器出现在页面上;默认情况下,它将在 5 秒后抛出失败消息。当选择器出现时,回调函数被触发,我们使用 assertSelectorHasText 方法来检查 div 标签是否有正确的文本:

运行我们的第一个 CasperJS 测试
这很简单,但很有效。如果我们因为对类别存储代码的修改而破坏了某些东西,错误地将数据绑定到视图模型,或者由于其他一些微小的更改而级联影响这个关键功能,那么这个测试将立即指出。
注意
CasperJS 依赖于另一个名为 PhantomJS 的库来驱动无头浏览器。在当前版本中,这两个库之间存在一个问题,导致你可以在前面的屏幕截图中看到的“不安全的 JavaScript 尝试...”消息;忽略它是可以的。
我们可以做更多的事情。尽管正确的文本显示出来了,但菜单不仅仅是为了显示目的,当用户点击菜单项时,它将加载这个类别的产品。我们能为此创建一个 CasperJS 测试吗?
当然可以!请查看 CasperJS API 文档docs.casperjs.org/en/latest/modules/casper.html#casper-prototype。
除了 start 方法之外,我们还有 fill 等方法,允许你填写表单字段:scrollTo,它让我们移动到页面上的特定位置,以及对我们当前目的:click,它提供了一个通过选择器指定元素进行点击的手段。让我们使用 click 构建另一个测试,以运行更高级的步骤集,类似于这样:
-
加载应用程序
-
点击 IPA 类别
-
选择拉古尼塔斯 IPA产品
-
检查产品窗口是否出现
-
检查是否正确显示了产品名称和价格
这是一个更全面的测试,它在一定程度上展示了 CasperJS 复制用户操作和验证我们应用程序行为的能力。以下是代码:
casper.test.begin('Product walk through', 2, function suite(test) {
casper.start('http://localhost:1841/', function() {
var categorySelector = '.categories-body table:nth-child(2) td',
productSelector = '.product-list .product:nth-child(2)',
windowSelector = '.product-detail',
headerSelector = '.product-detail h1',
priceSelector = '.product-detail p.price';
// Wait for the categories to load.
casper.waitForSelector(categorySelector, function() {
// Click the specified category.
casper.click(categorySelector);
});
// Wait for the category products to load.
casper.waitForSelector(productSelector, function() {
// Click the specified product.
casper.click(productSelector);
});
// Wait for the product window to appear.
casper.waitForSelector(windowSelector, function() {
// Assert text for heading and price.
test.assertSelectorHasText(headerSelector, 'Lagunitas IPA');
test.assertSelectorHasText(priceSelector, '£12.99');
// Capture a screenshot.
casper.capture('products-page.png');
});
});
casper.run(function() {
test.done();
});
});
代码中的注释应该使其相当直观。在设置好所有需要的选择器后,我们等待类别出现并点击我们需要的那个。接下来,我们等待我们想要的产品出现并点击它,然后再等待产品窗口出现并对其内容进行断言。对于最后的技巧,我们指示 CasperJS 进行截图,这个功能对于调试或进一步评估可能很有用。
运行此代码会给我们以下截图:

成功!我们已经模拟并验证了 Alcohology 应用程序中一个小型用户路径,检查了我们的项目中的几个动态部分是否按预期协同工作。
留意细节的读者会注意到,这个例子中的选择器看起来比我们使用 Google Chrome 获取的第一个例子中的选择器友好一些。这有一个非常好的原因,并且与使你的应用程序更容易测试的一系列想法有关。
测试性
就像软件开发中的许多方面一样,有许多不同的测试方法。有些人会提倡测试优先的方法,即首先创建测试,然后编写代码以满足这些测试。无论使用哪种方法,我们都需要采取措施确保我们的代码可以测试。虽然这偶尔意味着添加一些仅用于测试的有用钩子和技巧,但我们应该尽量避免这种方法,并努力编写自然可测试的代码;这通常会有一个幸运的结果,那就是代码质量也会更好。
之前,我们讨论了选择器及其与测试性的关系。让我们看看 Chrome 开发者工具给我们提供的那个选择器:
#gridview-1014-record-6 > tbody > tr > td > div
这是一个非常具体的选择器,它的第一部分使用了一个由 Ext JS 自动生成的 ID。一旦我们添加或更改应用程序的组件层次结构或更改页面上显示的记录,这就会中断,我们的 CasperJS 测试将失败。
我们可以通过确保我们的测试代码不那么脆弱来提高我们应用程序的测试性。在这种情况下,我们可以利用我们用来样式化应用程序的 CSS 类。在第九章中,当我们构建类别列表视图时,我们将bodyCls配置选项设置为categories-body。这为我们提供了一个很好的方法来定位我们知道不会随机更改的列表。
结合使用nth-child伪选择器,我们可以得到一个更简单、更健壮的原始开发者工具选择器版本:
.categories-body table:nth-child(2) td
用简单的话说,获取具有 categories-body 类的元素,找到第二个子表,它对应于第二行,然后获取其 td 单元格元素。
使用 Chrome 开发者工具仍然是一种查看页面 HTML 结构并为每种情况找到最佳选择器的好方法,但它很少能提供最健壮的选择器。
我应该做还是不应该做?
关于测试应该如何影响你的代码,如果有的话,有很多热烈的讨论。在上一个例子中,我们有一个有用的 CSS 选择器,它已经被用于样式化,但如果我们还没有将其放置在那里,是否应该专门添加它来支持样式化?
在这种情况下,这是一个非常小的改动,所以我们可能不必对此感到难过。我们甚至可以用 Sencha Cmd 指令将其包装起来,以确保它不会包含在生产构建中:
//<debug>
bodyCls: 'categories-body',
//</debug>
然而,总的来说,任何仅仅为了提高可测试性而增加我们的主要代码库复杂性和维护开销的东西都应该避免。相反,我们可以考虑应用设计如何自然地促进可测试性的方法。
在本书早期,我们讨论了 MVC 和 MVVM 以及这种模式的一个好处是促进代码关注点的分离。在整个过程中,我们使用事件来确保组件可以“触发并忘记”并触发操作,而无需了解系统的其他部分。
这是一个关键特性,它提供了一个优雅、清晰的设计,同时附带组件分离的额外好处。我们可以提取单个视图,在简单的页面上单独渲染它们,并对单个组件进行隔离测试。正如我们在本章开头提到的 Jasmine 示例一样,我们可以取一个单独的模型并实例化它,而无需担心用户界面层。
好的应用程序架构之美在于它提供了一个易于理解的应用程序,它立即适合于测试。尽管集成测试是我们武器库中的重要武器,但在尝试启动整个系统之前,确保机器的各个部分都构建得很好要重要得多。
使用模拟来伪造
当它运行起来时,我们的应用程序仍然有一个巨大的依赖:服务器。在集成测试期间,这意味着服务器端数据库需要预先填充测试数据,测试套件将导致许多请求来回发送。在一个大型测试套件中,这可能会很慢,数据库设置可能会很痛苦。
解决这个问题的常见方法是完全绕过服务器 API。当我们的应用程序发出 Ajax 请求时,我们可以劫持 XMLHttpRequest 并向调用代码提供一些静态测试数据。
为了展示这一点并展示该技术的灵活性,我们将创建一个小型的 Jasmine 测试用例,展示如何向产品存储提供模拟 JSON 数据。尽管伪造 Ajax 请求在集成测试中非常有用,但这将以简洁的方式展示该技术,这种方式可以用于单元测试和集成测试。
我们将使用 Ext JS 中默认不包含的功能:Simlets。实现 Simlets 的类包含在 Ext JS 分发的 examples 目录中,所以我们只需要打开本章早些时候的 SpecRunner.html 文件,并修改它以指导 Ext.Loader 从正确的位置拉入文件:
Ext.Loader.setConfig({
enabled: true,
paths: {
'Alcohology': '../../app',
'Ext.ux': '../../ext/examples/ux' // added
}
});
我们添加的只是一行 Ext.ux 路径。现在,我们可以构建我们的 Jasmine 测试,让我们直接进入代码:
describe('Mocking Ajax', function() {
var productStore,
fakeJsonData = [{
"id":1,
"name":"Test Product",
"price":"19.99",
"description":"Test Product Description"
}];
beforeEach(function() {
Ext.syncRequire('Ext.ux.ajax.SimManager');
Ext.syncRequire('Alcohology.model.Product');
Ext.ux.ajax.SimManager.init().register({
'http://localhost:3000/product': {
type: 'json',
data: fakeJsonData
}
});
productStore = Ext.create('Ext.data.Store', {
model: 'Alcohology.model.Product'
});
});
it('Uses fake JSON data', function(done) {
productStore.load({
callback: function(records) {
expect(records.length).toBe(1);
expect(records[0].get('name')).toBe('Test Product');
done();
}
});
});
});
这里有很多事情在进行!首先,我们设置了一些变量,一个用于在测试之间包含 productStore,另一个包含我们的模拟 JSON。
在 beforeEach 函数调用中,我们加载了 Alcohology 中的 SimletManager 类和 Product 模型;Ext.Loader 将在允许执行继续到下一行之前为我们拉入文件。接下来,我们通过注册要拦截的 URL 和应该返回的数据而不是正常的服务器响应来设置 SimletManager。
实际上,这就是我们设置模拟请求所需的所有内容;其余的代码将像正常的 Jasmine 测试一样继续执行,其中我们在产品存储加载后设置两个期望,即将返回一条记录,其名称设置为 Test Product,就像在测试数据中一样。
通过规格运行器运行测试,所有预期的情况都通过了,并展示了该技术的强大功能。我们可以将所有测试与后端断开连接,独立于数据库运行;测试数据直接在测试中提供,而不是在服务器上某个数据库行中。
持续覆盖
你在本章中学习了如何使用几个不同的测试工具,但当你使用这些工具时怎么办?
当涉及到代码测试时,有一个称为“代码覆盖率”的度量标准,它告诉我们我们的代码中有多少百分比被测试覆盖。一个热情的建筑师在开始一个新项目时的第一个想法是,一切都应该被测试覆盖,到处都是测试!
实际上,有些事情我们根本不需要测试;像往常一样,我们应该采取实用主义的方法。组件的配置可能不需要测试;我们不需要测试第三方代码库的返回值;有很多例子。
话虽如此,代码覆盖率是确保项目中保持一定测试水平的有用方法。例如,我们可能希望我们的模型代码有 90% 的测试覆盖率,而控制器只有 50% 的覆盖率,因为控制器中包含更多不需要测试的样板代码。确切的比率将取决于项目、开发者和架构师。
注意
对于 JavaScript,有许多代码覆盖率工具,其中之一是 Istanbul。它提供了一套全面的特性,以多种方式检查代码覆盖率,并以多种格式报告覆盖率水平。你可以在 GitHub 上找到它:github.com/gotwarlost/Istanbul。
当一个项目被单元测试很好地覆盖,并且有集成测试来确保用户体验保持一致时,我们只剩下拼图的一个部分:何时运行这些测试?
当然,开发者应该运行他们正在工作的代码部分的相应测试,但在现实中,一个完整的测试套件可能需要很长时间才能执行。在这种情况下,我们可以利用一种称为持续集成(CI)的东西。
注意
Jenkins CI 是一个开源的 CI 系统(jenkins-ci.org/),而 Circle CI([https://circleci.com/](https://circleci.com/))是付费的,但有一个免费计划。
每当开发者将新代码推送到源代码控制仓库时,CI 服务器就会抓取这些代码并运行测试。这使我们能够看到开发者提交了破坏当前构建的代码;这也让我们放心,成功的 CI 构建将是通过我们的自动化检查并且正在顺利进入生产的构建。
摘要
测试是一个庞大的主题,涉及许多不同的库、程序和技术,它们都在争夺软件架构师的关注。正如我们角色的许多方面一样,没有唯一的正确答案,最重要的是确定适合我们特定项目的方法。
在本章中,我们回顾了各种不同的测试 Ext JS 的想法和方法,并触及了确保我们的架构决策能够简化测试的方法。从单元测试到集成测试,测试的不同极端总会在我们将测试系统中的某些部分隔离出来时受益,从远程请求到代码关注点的分离。
测试是 Ext JS 应用程序架构的一个基本但常常被忽视的部分,而本章只是一个概述。这本书中涵盖的其他主题必须结合使用,并且要不断寻找新想法,才能真正掌握我们触及的概念。


浙公网安备 33010602011771号