原文:Patterns For Large-Scale JavaScript Application Architecture by @Addy Osmani
今天我们要讨论大型 JavaScript 应用架构中的有效模式。这篇文章基于我最近在 LondonJS 的同名演讲,灵感则来自 Nicholas Zakas 之前的成果。
译注:Nicholas Zakas: Scalable JavaScript Application Architecture
1. 我是谁,以及我为什么写这个主题?
我目前是 AOL 的一名 JavaScript 和 UI 开发人员,负责为我们下一代面向客户的应用程序计划和编写前端架构。 由于这些应用程序不仅复杂,而且需要可扩展和高度可重用的架构,因此我的职责之一就是确保用于实现应用程序的模式尽可能是可持续的。
我自认为是一名设计模式爱好者(虽然关于这个主题有很多专家比我更专业)。我之前基于创作共用许可证写了Essential JavaScript Design Patterns 一书,现在我想写得更详尽一些,作为这本书的后续部分。
2. 可以用 140 个字概述这篇文章吗?
如果你时间不够,下面是这篇文章的摘要,只有一条 tweet 的长度:
解耦应用。架构/模块,外观和中介者模式。模块生产消息,中介者发布/订阅消息,外观处理安全问题。
3. 究竟什么是“大型” JavaScript 应用程序?
在开始之前,让我们尝试弄清一点,当我们提到某个 JavaScript 应用程序是“大型”时,究竟是什么意思。这个问题对于有多年经验的开发人员仍然是一项挑战,而且答案也相当主观。
我作了一个试验,咨询了几位中级开发人员,让他们试着做出非正式的定义。一个开发人员建议“JavaScript 应用程序的代码行数超过 100,000 行”,而另一位建议“应用程序中 JavaScript 代码超过 1MB”。虽然是勇敢的建议(如果不是故意吓人),但是这些都不正确,因为代码库的大小并不总是与应用程序的复杂度相关 - 100,000 行代码很可能是相当琐碎的代码。
译注:LOC,Lines of Code,代码行数
我自己的定义可能会也可能不会被普遍接受,但是我相信它更接近大型应用程序的真实含义。
在我看来,大型 JavaScript 应用程序是成体系的,需要开发人员的努力维护,而最繁重的数据处理和显示则是在浏览器中。
这个定义的最后一部分可能是最重要的。
4. 让我们回顾一下当前的架构
如果开发一个大型 JavaScript 应用程序,记得要投入足够的时间来规划基础架构,这是最有意义的事情。它往往比你最初想象的要更复杂。
我无法再强调基础架构的重要性——我见过一些开发人员在遇到大型应用程序时,先退后几步,然后说:“好吧。恩,在我最近的中型项目中已经有一套思路和模式工作的不错。当然,它们也应该大致适用于稍大一点的项目,对吧?”。虽然在某种程度上这么说可能是正确的,但是请不要想当然 - 大型应用程序通常需要考虑更大的问题。我稍后要讨论为什么多花点时间来为你的应用程序规划结构在长远来看是值得的。
大部分 JavaScript 开发人员可能在他们的当前架构中混合使用下面的概念:
- 自定义控件 custom widgets
- 模型 models
- 视图 views
- 控制器 controllers
- 模板 templates
- 库/工具集 libraries/toolkits
- 应用程序的核心 an application core
相关阅读
- Rebecca Murphey - Structuring JavaScript Applications
- Peter Michaux - MVC Architecture For JavaScript Applications
- StackOverflow - A discussion on modern MVC frameworks
- Doug Neiner - Stateful Plugins and the Widget Factory
你也可能把应用程序分解为一个个的模块,或者应用其他模式来实现。这么做不错,但如果这就是你的应用程序的全部架构,那么你仍然可能栽到一些潜在的问题中。
1. 这种架构有多少是可立即复用的? ⬆
单个模块可以独立存在吗?它们是自包含的吗?如果我现在要看看你或你的团队所工作的大型应用程序的代码库,并且随机选择一个模块,我可以简单地把它放入一个新页面,然后开始使用它吗?你可能会质疑这么做背后的理由,但是我鼓励你多想想未来。假使你的公司开始构建越来越多的非凡应用,而它们之间在功能上共享某些交叉点,情形将会怎么样呢?如果有人说,“我们的用户喜欢在我们的邮件客户端中使用聊天模块。让我们把它放到协同编辑套件”,不显著修改代码可以做到这点吗?
2. 在这个系统中,模块之间的依赖有多少? ⬆
它们是紧耦合的吗?在深入挖掘这为什么会是一个问题之前,我要指出的是,一个系统中的模块绝对无依赖并不总是可行的。在某个粒度级别,你有充分的理由让模块从其他模块扩展基本功能,但问题在于具有不同功能的模块组之间的关联度。而在你的应用程序中,所有这些不同的模块组在正常运行时,不依赖太多其他模块的存在和加载,应该是有可能的。
3. 如果应用程序的特定部分崩溃了,应用程序仍然可以运行吗? ⬆
如果你正在构建一个类似 GMail 的应用程序,并且你的 webmail 模块崩溃了,此时不应该阻塞 UI 的其余部分,或者阻止用户使用页面上的其他部分,例如聊天模块。同时,按照之前说的,模块最好可以在当前应用程序的架构之外独立存在。在我的演讲中,我提到了基于用户意图的动态依赖(或模块)加载,用户意图以相关的事件来表达。例如,在 GMail 中,聊天模块默认是收起的,在页面初始化时不需要核心模块代码已经加载完毕。如果用户表示出使用聊天特性的意图,只需要动态加载即可。理想情况下,你期望不受应用程序其余部分的负面影响是可能的。
4. 可以轻松地测试各个模块吗? ⬆
当在一个有着显著规模的系统上工作,而且这个系统有数以百万计的潜在用户使用或误用系统的不同部分时,模块必然会被多个经过充分测试的应用程序所复用。既需要对模块在(负责初始化它的)架构内部的情况进行测试,也需要对模块在架构之外的情况进行测试。在我看来,当模块应用在另一个系统时,测试为模块不会崩溃提供了最大限度的保证。
5. 想得长远一些
当为你的大型项目设计架构时,最重要的是超前思考。不仅仅是从现在开始的一个月或一年,比这要久的多。会改变什么吗?猜测你的应用程序会如何成长当然是不可能的,但是肯定有空间来考虑什么是可能的。在这节内容中,至少会思考应用程序的某个特定方面。
开发人员经常把 DOM 操作代码与应用程序的其他部分耦合地相当紧密——甚至在他们已经把核心业务分离为模块时。想想为什么这么做不是一个好主意,如果我们正在做长期规划的话。
我的观众之一认为原因是,现在定义的这种僵硬架构在未来可能不再合适。这种观点千真万确,而且还有另一层担忧,就是如果现在不考虑进来的话,将来花费的成本甚至可能会更多。
你可能在未来因为性能、安全或设计的原因,决定把正在使用的 Dojo、jQuery、Zepto 或 YUI 切换为某个完全不同的东西。如果库与你的应用程序紧密耦合的话,这种决定就会演变为一个问题,因为互换库并不容易,而且切换的成本高昂。
如果你是一个 Dojo 开发人员(例如我演讲会上的一些观众),目前你可能没有值得切换的、更好的库,但是谁敢说在 2-3 年内不会出现更好的、你想要切换的库?
在较小的代码库中,这是一个比较琐碎(容易)的决定,但是对于大型应用程序,拥有一个灵活到可以不关心模块所用库的架构,从财政和节省时间的角度来看,都可以带来很大的好处。
总之,如果你现在回顾你的架构,能够做出无需重写整个应用程序就可以切换库的决定吗?如果不能,请继续读下去,因为我觉得今天介绍的架构可能正是你所感兴趣的。
到目前为止,对于我所关注的问题,一些有影响力的 JavaScript 开发人员已经有所涉猎。我想要分享他们的三个关键观点,引文如下所示:
“构建大型应用程序的秘诀是永不构建大型应用程序。把你的应用程序分解为小块。然后把这些可测试、粒度合适的小块组装到你的大型应用程序中” - Justin Meyer,JavaScriptMVC 的作者
“关键是从一开始就承认你不知道该如何成长。当你接受了你是一无所知的之后,你会开始保守地设计系统。你确定可能会改变的关键领域,当你花一点时间在这上面的话,要做到这点往往很容易。举个例子,你应该想到与应用程序中其他系统进行通信的部分将可能会改变,因此你需要把它抽象出来。” - Nicholas Zakas,《High-performance JavaScript websites》的作者
最后但并非最不重要的:
“彼此紧密绑定的组件,较少复用的组件,以及因为会影响到其他组件而变得更难改变的组件” - Rebecca Murphey, 《jQuery Fundamentals》的
这些原则是构建架构的关键,能够经得起时间的考验,应该始终牢记。
6. 头脑风暴
思考一下我们要达到什么目的。
我们希望有一个松耦合的架构,功能可以分解为独立的模块,最好模块间没有依赖。当有趣的事情发生时,模块通知应用程序的其他部分,一个中间层解释并响应这些消息。
例如,如果我们有一个负责在线面包店的 JavaScript 应用程序,从一个模块发来的“有趣”消息可能是“准备配送第 42 批次的面包卷”。
我们使用一个不同的分层来解释模块的消息,以便于:a) 模块不直接访问核心,b) 模块不需要直接调用其他模块或或与之交互。这有助于防止应用程序因为特定模块的错误而崩溃,并且提供了一种方式来重启崩溃的模块。
另一个令人关注的问题是安全性。然而真实情况是,我们中的大多数人不会考虑应用程序内部的安全性。我们告诉自己,因为是我们构建了应用程序,有足够的聪明来弄清楚哪些应该是公开或试下访问。
然而,如果你有办法判断系统中一个模块允许做什么,就不会有帮助了吗?例如,如果我已经在系统中限制了权限,不允许一个公开的聊天部件与权限管理模块,或者与一个用于数据库写权限模块交互,就可以防范有人利用聊天部件的已知漏洞来发起 XSS 攻击。模型不应该有能力访问所有的事务。目前的大多数架构可能可以做到这一点,但是真的需要这么做吗?
用一个中间层来处理权限问题,来决定哪些模块可以访问框架的哪部分,可以天然的增强安全性。这意味一个模块唯一能做的就是我们允许它做的。
7. 架构提议
我们所寻求的架构解决方案是三个著名设计模式的组合体:模块化,外观模式和中介者模式。
在传统的模式中,模块彼此之间直接进行通信,而在解耦架构中,模块只发布感兴趣的事件(在理想情况下,不需要知道系统中的其他模块)。中介者模式将订阅从模块来的消息,并在收到通知时给与适当的响应。中介者模式将用于模块鉴权。
我将在后面阐述这些模式的更多细节:
- 设计模式
- 模块化理论
- 摘要
- 模块模式
- 对象字面量
- CommonJS 模块
- 外观模式
- 中介者模式
- 模块化理论
- 在架构中应用
- 外观 - 抽象的核心
- 中介者 - 应用程序的核心
- 集成
7.1 模块化理论
你可能在现有架构中已经使用了一些模块。但如果没有的话,本节将简要介绍关于模块的一些引文。
在任何健壮的应用程序的架构中,模块是一个完整部件,并且在可互换的较大系统中,模块通常是单一用途的。
按照实现模块的方式,你可以定义模块的依赖,并瞬间自动把其他部分加载进来。相较于无奈地跟踪它们的各种依赖关系,然后手动加载模块或插入 script 标签,这种方式被认为更具有扩展性。
任何成体系的应用程序都应该基于模块化组件构建。回到 GMail,你可以把模块理解为可以独立存在的功能单元,就像聊天模块。然而这取决于功能单元的复杂度,它很可能还依赖于更精细的子模块。例如,有一个子模块负责简单地处理表情符号,而该系统的聊天部件和邮件部件则共享使用这些表情符号。
在正讨论的架构中,模块对系统其他部分的情况所知甚少。而且,我通过一个外观把职责代理到一个中介者上。
这个刻意设计的,因为如果一个模块只负责通知系统所感兴趣的事情发生了,而不用担心其他模块是否正在运行,那么系统就能够支持添加、移除或更换模块,而系统中的其他模块不会因为紧密耦合而崩溃。
这种思路行得通的关键是松耦合。松耦合通过在必要时移除代码依赖关系,简化了模块的维护。在我们的例子中,模块不应该依赖于其他模块才能正常运行。当松耦合被有效地贯彻时,看看系统某个部分的变化是如何影响其他部分的。
在 JavaScript 中,有几种可选的模块化实现方式,包括广为人知的模块模式和对象字面量。有经验的开发人员应该已经熟知这些知识,如果是这样的话,请跳到介绍 CommonJS 模块的部分。
模块模式
模块模式是一种流行的设计模式,通过使用闭包来封装“隐私”、状态和结构。它可以包裹公开和私有的方法和变量,避免它们污染全局作用域,以及避免与其他开发人员的接口冲突。这种模式只会返回公开的 API,此外的一切则是封闭和私有的。
模块模式提供了一种清爽的解决方案,屏蔽了承担繁重任务的逻辑,只向应用程序的其他部分暴露希望它们使用的接口。这种模式与立即调用的函数表达式(IIFE)非常相似,只不过前者返回的是一个对象,而后者返回的是一个函数。。
应该指出的是,在 JavaScript 中并不存在真正意义上的“隐私”,因为它不像一些传统语言一样具有访问修饰符。从技术的角度,变量不能被声明为公开或私有,所以我们用函数作用域来模拟这个概念。在模块模式中,仰赖于闭包机制,声明的变量或方法只在模块自身内部有效。而返回的对象中的变量或方法对所有人都是可用的。
你可以在下面看到一个购物车示例,其中使用了模块模式。该模块自身被包含在一个称为basketModule
的全局对象中,完全自给自足。模块中的数组 basket
是私有的,应用程序的其他部分无法直接读取它。它只存在于这个模块的闭包中,因此,只有可以访问它所属作用域的方法(即 addItem()
、getItem()
等),才可以访问它。
var basketModule = (function() { var basket = []; //private return { //exposed to public addItem: function(values) { basket.push(values); }, getItemCount: function() { return basket.length; }, getTotal: function(){ var q = this.getItemCount(),p=0; while(q--){ p+= basket[q].price; } return p; } } }());
在模块内部,你会发现它返回了一个对象。这种做法使得返回值被自动赋值给 basketModule,因此你像下面这样与它交互:
//basketModule is an object with properties which can also be methods basketModule.addItem({item:'bread',price:0.5}); basketModule.addItem({item:'butter',price:0.3}); console.log(basketModule.getItemCount()); console.log(basketModule.getTotal()); //however, the following will not work: console.log(basketModule.basket);// (undefined as not inside the returned object) console.log(basket); //(only exists within the scope of the closure)
上面的方法被有效的限制在命名空间 basketModule 中。
从历史的角度看,模块模式最初是由一些人发现的,包括 Richard Cornford(2013年)。后来被 Douglas Crockford 在他的演讲中推广,并被 Eric Miraglia 在 YUI 的博客中再次介绍。
在具体的工具库或框架中,模块模式是什么样的情况呢?
Dojo
Dojo 尝试通过 dojo.declare
来实现模块模式,提供与“class”类似的功能。例如,如果我们想把 basket
声明为命名空间 store
下的一个模块,可以做如下实现:
//traditional way var store = window.store || {}; store.basket = store.basket || {}; //using dojo.setObject dojo.setObject("store.basket.object", (function() { var basket = []; function privateMethod() { console.log(basket); } return { publicMethod: function(){ privateMethod(); } }; }()));
如果 dojo.declare
与 dojo.provide
和 mixins 结合使用,可以变得非常强大。
YUI
下面的例子基于 Eric Miraglia 实现的原始 YUI 模块模式,虽然有些厚重,但尚能自圆其说。
YAHOO.store.basket = function () { //"private" variables: var myPrivateVar = "I can be accessed only within YAHOO.store.basket ."; //"private" method: var myPrivateMethod = function () { YAHOO.log("I can be accessed only from within YAHOO.store.basket"); } return { myPublicProperty: "I'm a public property.", myPublicMethod: function () { YAHOO.log("I'm a public method."); //Within basket, I can access "private" vars and methods: YAHOO.log(myPrivateVar); YAHOO.log(myPrivateMethod()); //The native scope of myPublicMethod is store so we can //access public members using "this": YAHOO.log(this.myPublicProperty); } };
}();
jQuery
把 jQuery 代码(不局限于插件)封装为模块模式有很多种方式。Ben Cherry 曾经建议过一种实现:用一个函数把模块定义包裹起来,模块定义则含有一些共性事件。
在下面的示例中,定义了一个函数 library
,该函数用于声明一个新库,当新库(即模块)被创建时,会并自动把函数 init
绑定到 document.ready
。
function library(module) { $(function() { if (module.init) { module.init(); } }); return module; } var myLibrary = library(function() { return { init: function() { /*implementation*/ } }; }());
相关阅读
- Ben Cherry - The Module Pattern In-Depth
- John Hann - The Future Is Modules, Not Frameworks
- Nathan Smith - A Module pattern aliased window and document gist
- David Litmark - An Introduction To The Revealing Module Pattern
对象字面量 ⬆
在对象字面量中,一个对象被描述为一组用逗号分隔的名称/值对,并用大括号({}
)包裹起来。对象中的名称可以是字符串或唯一标识,后跟一个冒号。不应该在对象中最后一对名称/值的后面使用逗号,因为这可能导致错误。
对象字面量不需要使用操作符 new
来实例化,但是不应该使用在语句的起始处,因为 {
可能会被解释为代码块的开始。你可以在下面看到一个使用对象字面量来定义模块的示例。新成员可能被通过赋值添加到对象上,就像下面的 myModule.property = 'someValue';
。
虽然模块模式适用于很多场景,但如果你发现并不需要特定的私有属性或方法,那么对象字面量无疑是更合适的替代品。
var myModule = { myProperty : 'someValue', //object literals can contain properties and methods. //here, another object is defined for configuration //purposes: myConfig:{ useCaching:true, language: 'en' }, //a very basic method myMethod: function(){ console.log('I can haz functionality?'); }, //output a value based on current configuration myMethod2: function(){ console.log('Caching is:' + (this.myConfig.useCaching)?'enabled':'disabled'); }, //override the current configuration myMethod3: function(newConfig){ if(typeof newConfig == 'object'){ this.myConfig = newConfig; console.log(this.myConfig.language); } } }; myModule.myMethod(); //I can haz functionality myModule.myMethod2(); //outputs enabled myModule.myMethod3({language:'fr',useCaching:false}); //fr
相关阅读
- Rebecca Murphey - Using Objects To Organize Your Code
- Stoyan Stefanov - 3 Ways To Define A JavaScript Class
- Ben Alman - Clarifications On Object Literals (There's no such thing as a JSON Object)
- John Resig - Simple JavaScript Inheritance
7.2 CommonJS 模块 ⬆
在过去一两年中,你可能已经听说过 CommonJS - 一个致力于设计、原型化和标准化 JavaScript API 的志愿者工作组。迄今为止,他们已经批准了针对模块和包的标准。CommonJS AMD 建议规范一个简单的 API 来声明模块,并且可以在浏览器中通过同步和异步 script 标签来加载声明的模块。他们的模块模式相对比较清爽,并且我认为它是 ES Harmony(JavaScript 语言的下一个版本)所建议的模块系统的可靠基石。
从结构的角度来看,一个 CommonJS 模块是一段可重用的 JavaScript,它输出特定的对象以供任何依赖它的代码使用。这种模块格式正在变得相当普及,成为事实上的 JS 标准模块格式。有许多关于实施 CommonJS 模块的伟大教程,但是从高层次角度看的话,它们基本上包含两个主要部分:一个 exports
对象包含了希望对其他模块可用的模块,一个 require
函数用来让模块导入其他模块的输出。
/* Example of achieving compatibility with AMD and standard CommonJS by putting boilerplate around the standard CommonJS module format: */
(function(define){ define(function(require,exports){ // module contents var dep1 = require("dep1"); exports.someExportedFunction = function(){...}; //... }); })(typeof define=="function"?define:function(factory){factory(require,exports)});
有许多伟大的 JavaScript 库可以按照 CommonJS 模块规范来处理模块加载,但我个人偏好于 RequireJS。完整的 RequireJS 教程超出了本文的范畴,不过我推荐读一读 James Burke 的博文 ScriptJunkie。我知道有些人也喜欢 Yabble
。
从使用的角度看,RequireJS 提供了一些包装方法,来简化静态模块的创建过程和异步加载。它可以很容易的加载模块以及模块的依赖,然后在模块就绪时执行模块的内容。
有些开发人员声称 CommonJS 模块不太适用在浏览器中。原因是 CommonJS 模块无法通过 script 标签加载,除非有服务端协助。我们假设有一个把图片编码为 ASCII 的库,它暴露出一个 encodeToASCII
函数。它的模块类似于:
var encodeToASCII = require("encoder").encodeToASCII; exports.encodeSomeSource = function(){ //process then call encodeToASCII }
在这类情况下,script 标签将无法正常工作,因为作用域不匹配,这就意味着方法 encodeToASCII
将被绑定到window
对象、require
未定义,并且需要为每个模块单独创建 exports。但是,客户端库在服务端的协助下,或者库通过 XHR 请求加载脚本并使用了 eval()
,都可以很容易地处理这种情况,
使用 RequireJS,该模块的早期版本可以重写为下面这样:
define(function(require, exports, module) { var encodeToASCII = require("encoder").encodeToASCII; exports.encodeSomeSource = function(){ //process then call encodeToASCII } });
对于不只依赖于静态 JavaScript 的项目来说,CommonJS 模块是很好的选择,不过一定要花一些时间来阅读相关的内容。我仅仅涉及到了冰山一角,如果你想进一步阅读的话,CommonJS 的 wikie 和 Sitepen 有着大量资源。
相关阅读
- The CommonJS Module Specifications
- Alex Young - Demystifying CommonJS Modules
- Notes on CommonJS modules with RequireJS
7.3 外观模式 ⬆
接下来,我们要看看外观模式,这个设计模式在今天定义的架构中扮演着关键角色。
当构造一个外观时,通常是创建一个掩盖了不同现实的外在表现。外观模式为更大的代码块提供了一个方便的高层接口,通过隐藏其真正复杂的底层。把它看成是提交给其他开发人员的简化版 API。
外观是结构模式的一种,经常可以在 JavaScript 库和框架中看到它,它的内部实现虽然可以提供各种行为的方法,但是只有一个“外观”或这些方法的有限抽象被提交给客户使用。
这样一来,我们是与外观交互,而不是与幕后的子系统交互。
外观之所以好用的原因在于,它能够隐藏各个模块中功能的具体实现细节。模块实现的改变甚至可以在客户不知情的情况下进行。
通过维护一个统一的外观(简化后的 API),对模块是否使用 dojo、jQuery、YUI、zepto 或者别的东西的担心就显得不太重要。只要交互层不改变,就保留了在将来切换库(例如用 jQuery 替换 Dojo)的能力,而不会影响系统的其他部分。
下面是一个非常简单的外观行为示例。正如你可以看到的,我们的模块包含了一些定位为私有的方法。然后用外观提供的更简单的 API 来访问这些方法。
var module = (function() { var _private = { i:5, get : function() { console.log('current value:' + this.i); }, set : function( val ) { this.i = val; }, run : function() { console.log('running'); }, jump: function(){ console.log('jumping'); } }; return { facade : function( args ) { _private.set(args.val); _private.get(); if ( args.run ) { _private.run(); } } } }());
module.facade({run: true, val:10}); //outputs current value: 10, running
在把外观应用到我们的架构中之前,关于外观就介绍这么多。接下来,我们将深入激动人心的中介者模式。外观模式和中介者模式之间的核心区别在于,外观模式(一种结构模式)只公开已有的功能,而中介者模式(一种行为模式)可以添加功能。
相关阅读
- Dustin Diaz, Ross Harmes - Pro JavaScript Design Patterns (Chapter 10, available to read on Google Books)
7.4 中介者模式 ⬆
介绍中介者模式的最佳方式是用一个简单的比喻——想象一下机场交通管制。塔台处理哪些飞机可以起飞或降落,因为所有的通信都由飞机和控制塔完成,而不是由飞机之间。集中控制是这个系统成功的关键,而这就是一个中介者。
当模块之间的通信有可能是复杂的,请使用中介者,但是这一点不易鉴定。如果有这样一个系统,代码中的模块之间有大多的关系,那么就该有一个中央控制点了,这就是这个模式的用武之地。
一个中介者封装了不同模块之间的交互行为,就像现实世界中的中间人。该模式阻止了对象彼此之间直接引用,从而促进了松耦合——这有助于我们解决系统中模块互相依赖的问题。
它还必须提供什么其他的优势呢?恩,中介者允许每个模块的行为可以独立变化,所以它非常灵活。如果你曾经在你的系统使用过观察者(发布/订阅)模式来实现模块之间的事件广播系统,你将会发现中介者相对而言比较容易理解。
让我们以高层次的视角来看看模块是如何与中介者交互的:
模块是发布者,中介者则既是发布者又是订阅者。模块 1 广播一个事件了通知中介者有事要做。中介者捕获这个消息,继而启动需要完成这项任务的模块 2,模块 2 执行模块 1 要求的任务,并向中介者广播一个完成事件。与此同时,模块 3 也会被中介者启动,记录从中介者传来的任何通知。
任何模块没有机会与其他模块直接通信,请注意是如何做到这点的。如果调用链中的模块 3 失败或停止运行,中介者可以假装“暂停”其他模块的任务,停止模块 3 并重启它,然后继续工作,这对系统而言几乎没有影响。这种程度的解耦是中介者模块提供的主要优势之一。
回复一下,中介者的优势如下:
它通过引入一个中间人作为中央控制点来解耦模块。它允许模块广播或监听消息,而不必关注系统的其他的部分。消息可以同时被任意数量的模块所处理。
显然,向松耦合的系统添加或移除功能变得更容易。
但它的缺点是:
通过在模块之间增加中介者,模块必须总是间接地通信。这可能会导致轻微的性能下降——因为松耦合的性质所然,而且很难预期一个关注广播的系统会如何响应。紧耦合令人各种头疼,而中介者正是一条解决之道。
示例:这是中介者模式在 @rpflorence 早先工作基础上的一种可能实现。
var mediator = (function(){ var subscribe = function(channel, fn){ if (!mediator.channels[channel]) mediator.channels[channel] = []; mediator.channels[channel].push({ context: this, callback: fn }); return this; }, publish = function(channel){ if (!mediator.channels[channel]) return false; var args = Array.prototype.slice.call(arguments, 1); for (var i = 0, l = mediator.channels[channel].length; i < l; i++) { var subscription = mediator.channels[channel][i]; subscription.callback.apply(subscription.context, args); } return this; }; return { channels: {}, publish: publish, subscribe: subscribe, installTo: function(obj){ obj.subscribe = subscribe; obj.publish = publish; } }; }());
示例:这是前面实现的两个使用示例。发布/订阅被有效的管理起来。
//Pub/sub on a centralized mediator
mediator.name = "tim"; mediator.subscribe('nameChange', function(arg){ console.log(this.name); this.name = arg; console.log(this.name); }); mediator.publish('nameChange', 'david'); //tim, david //Pub/sub via third party mediator var obj = { name: 'sam' }; mediator.installTo(obj); obj.subscribe('nameChange', function(arg){ console.log(this.name); this.name = arg; console.log(this.name); }); obj.publish('nameChange', 'john'); //sam, john
相关阅读
- Stoyan Stefanov - Page 168, JavaScript Patterns
- HB Stone - JavaScript Design Patterns: Mediator
- Vince Huston - The Mediator Pattern (not specific to JavaScript, but a concise)
7.5 应用外观:核心的抽象 ⬆
架构建议:
一个外观作为应用程序核心的抽象,位于中介者和模块之间——理想情况下,它应该是系统中唯一可以感知其他模式的模块。
这个抽象的职责包括了为这些模块提供统一的接口,以及确保在任何时候都是可用的。这一点非常类似于杰出架构中沙箱控制器的角色,它由 Nicholas Zakas 首次提出。
组件将通过外观与中介者通信,所以外观必须是可靠的。应该澄清的是,当我说“通信”时实际上是指与外观进行通信,外观是中介者的抽象,将监听模块的广播,再把广播回传给中介者。
除了为模块提供接口,中介者还扮演者安保的角色,确定一个模块可以访问应用程序的哪些部分。组件只能访问它们自己的方法,对于它没有权限的任何东西,则不能与之行交互。假设一个模块可以广播dataValidationCompletedWriteToDB
。此时,安全检查的概念是指确保有权限的模块才能请求数据写操作。我们最好避免让模块意外地试图做一些它们本不该做的事情。
总之,中介者是发布/订阅的管理者,不过,只有通过外观权限检查的感兴趣事件才会被传给中介者。
7.6 应用中介者:应用程序的核心 ⬆
中介者扮演的角色是应用程序的核心。我们已经简要介绍了一些它的职责,不过还是要澄清下它的所有职责。
核心的主要任务是管理模块的生命周期。当核心侦测到一个感兴趣的事件时,它需要决定应用程序该如何响应——这实际上意味着决定是否需要启动或停止一个或一组模块。
理想情况下,一旦某个模块被启动,它应该自动执行。模块是否在 DOM 就绪时运行,以及运行条件是否全部满足,决定这些并不是核心的任务,而是由架构中的模块指定决定。
你可能想知道一个模块在什么情况下可能需要“停止”——如果应用程序侦测到某个特定模块出现故障或正处于严重的错误中,可以决定让这个模块中的方法停止继续执行,并且可能会重新启动它。这么做的目的是帮助降低对用户体验的破坏。
此外,核心应该可以添加或移除模块而不破坏任何东西。一个典型的应用场景是,功能在页面初始化时尚不可用,而是基于用户的意图动态加载,例如,回到 GMail 的例子,Google 可以让聊天部件默认收起,只有在用户表现出使用它的兴趣时才会动态加载。从性能优化的角度看,这么做是有意义的。
错误管理应该由应用程序的核心处理。模块除了广播感兴趣的事件外,也会广播发生的任何错误,然后核心可以做出相应的反馈(例如停止模块、重启模块等)。提供足够的上下文,以便用更新或更好的方式来处理或者向终端用户显示错误,而不必手动改变每个模块,是解耦架构中重要的一环。通过中介者使用发布/订阅机制,可以做到这一点。
7.7 整合 ⬆
模块 为应用程序提供特定的功能。每当发生了感兴趣的事情,模块发布消息通知应用程序——这是它们的主要关注点。正如我在 FAQ(常见问题)中介绍的,模块可以依赖 DOM 工具方法,但是理想情况下不应该依赖系统的任何其他模块。它们不应该关注:
- 什么对象或模块将订阅它们发布的消息
- 这些对象在哪里(是否在客户端或服务端)
- 有多少对象订阅了消息
外观 抽象核心,用于避免模块直接接触核心。它订阅(从模块来的)感兴趣的事情,并且说“干得好!发生了什么事?把详细资料给我!”。它还负责检查模块的安全性,以确保发布消息的模块具备必要的权限来传递可接受的事件。
中介者(应用程序的核心) 扮演“发布/订阅”管理者的角色。负责管理模块,在需要时启动或停止模块。特别适用于动态依赖加载,并确保失败的模块可以在需要时集中重启。
如此架构的结果是模块(大多数情况下)在理论上不再依赖于其他模块。它们可以很容易地独立测试和维护,正因为这种程度的解耦,可以把模块放入一个新页面中供其他系统使用,而不需要做太多额外的工作。模块可以被动态地添加或移除,而不会导致应用程序崩溃。
7.8 超越发布/订阅:自动注册事件 ⬆
正如 Michael Mahemoff 在前面提到的,当考虑大型 JavaScript 时,适当利用这门语言的动态特性是有益的。关于详细内容请阅读 Michael 的 G+ 页面,我特别关注其中一个概念——自动注册事件(AER Automatic Event Registration)。
AER 通过引入基于命名约定的自动连接模式,解决了订阅者到发布者的连接问题。例如,如果某个模块发布一个称为 messageUpdate
的事件,所有相关的 messageUpdate
方法将被自动调用。
译注:有点类似于 jQuery 事件系统的手动触发方法 .trigger(),即可以触发通过 jQuery 事件方法(.on())绑定的事件,也可以触发行内事件(elem.click())。
这种模式的结构涉及到:注册所有可能订阅事件的模块,注册所有可能被订阅的事件,最后为组件库中的每个订阅者注册方法。对于这篇文章所讨论的架构来说,这是一个非常有趣的方法,但也确实带来一些有趣的挑战。
例如,当动态地执行时,对象可以被要求在创建时注册自身。请阅读 Michael 关于 AER 的文章,他更深入地讨论了如何处理这类问题。
7.9 常问问题 ⬆
问:是否有可能避免必须实现一个沙箱或外观? ⬆
答:虽然前面介绍的架构使用了一个外观来来实现安全功能,但是如果不用外观,而是用一个中介者和发布/订阅机制来通信系统中感情兴趣的事件是也完全可行的。这个轻量级版本(后者)可以提供类似程度的解耦,但如果选择这么做,模块就可以随意地直接接触应用程序的核心(中介者)。
问:你提到了模块没有任何依赖。是否包括对第三方库的依赖(例如 jQuery)? ⬆
答:我特别指对其他模块的依赖。一些开发人员为架构做出的选择实际上等同于 DOM 库的的公用抽象——例如,可以一个构建 DOM 公用类,使用 jQuery 来查询选择起表达式并返回查找到的 DOM(或者 Dojo,如果将来切换了的话)。通过这种方式,尽管模块依然会查询 DOM,但不会以硬编码的方式直接使用任何特定的库或工具。有相当多的方式可以实现这一点,但要选择的话,它们的共同点是核心模块(理想情况下)不应该依赖其他模块。
在这种情况下,你会发现,有时只需要一点额外的工作量,就可以让一个项目的完整模块运行在另一个项目中。我应该说清楚的是,我完全同意对模块进行扩展或者只使用模块的部分功能,而且有时可能是更明智的选择,但是记住,在某些情况下,想要把这样的模块应用到其他项目会增加工作量。
问:我想开始使用这种架构。是否有可供参考的样板代码? ⬆
答:如果时间允许的话,我打算为这篇文章发布一个样板包,但目前你最好的选择是 Andrew Burgees 的超值教程 Writing Modular JavaScript(在推荐之前需要完全披露的是,这仅仅是一个推荐链接,收到的任何反馈都将有助于完善内容)。Andrew 的样板包包含一张屏幕截屏以及代码,覆盖了这篇文章的的大部分主要观点,但选择把外观称作“沙箱”,就像 Zakas。还有一些讨论是关于如何理想地在这样一个架构中实现 DOM 抽象库———类似于我对第二个问题的回答,Andrew 在实现选择器表达式查询时采用了一些有趣的模式,使得在大多数情况下,用短短几行代码就可以做到切换库。我并不是说它是正确的或最好的实现方式,但是它是一种可能,而且我个人也在使用它。
问:如果模块需要直接与核心通信,这么做可能吗? ⬆
答:正如 Zakas 之前暗示的,为什么模块不应该访问核心在技术上没有理由,但这是最佳实现,比其他任何事情都重要。如果你想严格地坚持这种架构,你需要遵循定义的这些规则,或者选择一个更松散的结构,就像第一个问题的答案。