随着项目的规模越来越大,项目的维护性就可能会变得越来越差,有时可能会出现牵一发而动全身的情况。如果需要修改某个功能的代码,或者添加某项功能,会耗费大量的人力和时间。这种情况下,高可扩展性的、低耦合的应用程序就变得非常重要了。
本文通过构建一个时钟程序,来讲解高扩展的应用程序是如何一步一步搭建的。
什么是可扩展的应用程序?
一个可扩展的应用程序应该能够以某种方式实现增长,并且添加、删除、增强、重构某些组件,对于其他组件的影响微乎其微。
再大的应用程序,往往都是从很小的规模开始,然后一点一点发展起来的。但有时可能会由于增长过快,规模变得越来越大,导致项目难以管理,最终软件可能需要完全重写。
开发人员在一开始编码时就要充分考虑到这种情况。本文以编写功能独立的JavaScript应用为例来说明如何构建可扩展的应用程序,同时还将讨论如何编写可测试、可维护、可调试、直观的代码。
本文将使用
soma.js框架来编写一个高可扩展性的JavaScript时钟应用。
什么是soma.js?
构建高扩展性的应用的要点在于构建组成该应用的小的、单个的模块。
soma.js是一个JavaScript框架,它提供了一系列工具来帮助开发者创建一个可分解为若干个块的
松耦合的架构。
soma.js不依赖于特定的架构模式。该框架可以作为一个
MVC 或
MV* 框架,此外, soma.js也可以用来管理
独立的模块、创建
独立的窗口小部件或其他任何的体系结构。
耦合问题
让我们想象一种常见的场景,“A”组件需要组件“B”才能运行,这意味着A对B有一个直接的依赖。如果代码中的组件对彼此的依赖性非常大,就称为
高耦合的代码。这种代码最终会导致项目很难维护和更改,一更改就会影响其他部分代码。
高、低
耦合的代码在开发人员的工作中有很大的差别,最直接的体现是,在修改部分模块代码所需的时间上,低耦合的代码可能需要5分钟,而高耦合的代码可能会需要5个小时。
解决办法是——编写自包含、自封装、不影响其他组件的代码,最大化地减少依赖。这在理论上很简单,但实践起来非常难。
同时,减少依赖还会带来了另一个问题:如果组件彼此之间无联系,那么组件之间如何进行通信?此时,
设计模式就有了用武之地。
soma.js中提供了一系列用于架构解耦和测试的工具,以及各种设计模式解决方案,比如
依赖注入(dependency injection)、
观察者模式(observer pattern)、
中介者模式(mediator pattern)、
外观模式(facade pattern)、
命令模式(command pattern),
面向对象(OOP)工具集,并提供了一个DOM操作模板引擎作为可选插件。
示例应用
下面来看一个示例应用,该应用可以在屏幕中创建3种不同的时钟:数字时钟、模拟时钟和极性时钟。可以通过不同的设计模式,来使得项目中的元素可以重用于该项目之外。
观看演示:
时钟应用
下面来看看这个程序是如何构建的。
项目规划和元素去耦
项目规划和功能分解(元素去耦)是编写代码前的一个非常重要的步骤,在下面的练习中,将有两种不同类型的函数:
- 没有依赖性的函数(理想情况下,所有的模型和视图都应该是这样,以便可重用)
- 从其他组件中移除依赖性的函数
通过分析,该应用程序中应该包含以下这些不同的实体:
- 一个作为起始组件的应用实例——/js/app/clock.js (ClockDemo)。用于准备应用程序所需要的东西,作用是定义依赖关系和创建元素。它接收DOM元素来作为参数,所以应用程序中没有硬DOM引用。一个保存应用程序的状态(时间)的模型——/js/app/models/timer.js (TimerModel)。用于提供必要的时间信息,以便view层可以显示当前时间。
- 3种时钟的视图。作用是在屏幕上以不同的方式显示一个时钟,所有视图实现相同的接口,以便它们可以以同样的方式收到时间信息。/js/app/views/clocks/analog/analog.js (AnalogView)、/js/app/views/clocks/digital/digital.js (DigitalView)/js/app/views/clocks/polar/polar.js (PolarView)
- 一个中介者(mediator)——/js/app/mediators/clock.js (ClockMediator),表示一个DOM元素的。其作用是隐藏和创造时钟,它还连接timer模型到view层,以便可以接收时间。中介者封装了通信事件,并从model层和view层中消除了依赖。
- 一个selector视图——/js/app/views/selector.js (SelectorView),用来创建3种不同时钟的按钮。其作用是调度事件,并通知元素需要删除当前的时钟,再创建一个新的时钟。
该应用的源码:
somajs-flippin-clock-app
应用程序的文件结构和体系结构如下图所示。
对接口的思考
尽管接口在JavaScript语言中不存在,但其广泛用于Java或其他语言中。因此,我们也可以在JavaScript程序中应用接口的概念。
接口是对一组公共方法和属性的描述。一个函数如果要实现接口,那么也需要去实现接口中的所有方法。
在面向对象编程中,接口可以
解决许多代码重用相关的问题。一些严格的JavaScript的超集(如
Typescript)也包含接口功能。
看下面这个例子,在Typescript中实现“汽车”接口的代码如下:
interface ICar {
engine: IEngine;
basePrice: number;
state: string;
make: string;
model: string;
year: number;
}
class Car implements Icar {
// must implement the ICar signature in this class
}
在JavaScript中,接口不是一个内置的功能,但可以通过编写几个函数来实现相同的功能。
首先开发者应该思考应用程序内的哪些元素的接口需要是独立、可重用的?比如,clock视图必须是可互换、可重用的,并提供完全相同的方法,以便它们可以在不影响其他元素的情况下进行互换。
timer模式和clock视图的接口代码如下:
interface ITimerModel {
add(callback: function);
remove(callback: function);
update();
}
interface IClockView {
update(time: Object);
dispose();
}
下图显示了时钟应用程序中的不同的接口实现:
应用程序实例
创建soma.js应用的第一步是创建一个应用程序实例,这是决定应用框架功能是否可扩展性的唯一一个重要时刻。所有的其他实体可以是可重复使用的JavaScript函数,并且可以不受框架约束。
应用程序实例主要执行两个函数: init 和 start ,以便应用程序可以通过架构所需的功能来进行设置。
(function(clock, soma) {
var ClockDemo = soma.Application.extend({
init: function() {
},
start: function() {
}
});
var clockDemo = new ClockDemo();
})(window.clock = window.clock || {}, soma);
更多信息,可查看soma.js
应用程序实例文档。
一个自包含的应用程序
在应用程序内部使用一个DOM元素作为root是一个非常好的实践,这对于自包含的应用程序来说是非常有用的。任何DOM选择和操作都应该从这个root开始。
此外,通常来说,建议使用CSS “class”选择器,而不是“ID”。因为使用“ID”可能会导致应用程序对于特定的DOM元素有硬依赖。
var ClockDemo = soma.Application.extend({
constructor: function(element) {
// store the root DOM Element
this.element = element;
// call the super constructor
soma.Application.call(this);
},
init: function() {
},
start: function() {
}
});
var clockDemo = new ClockDemo(document.querySelector('.clock-app'));
测试一个应用程序是否是自包含的简单方法是,在屏幕中创建多个实例,看它们是否能够独立工作。
注入映射规则
现在该应用程序已经有了一个基础架构,可以创建注射映射规则了。
映射规则无非是指定一个函数,或为字符串指定一个值。字符串在其他地方可以作为“命名变量”使用,以便让注入器知道要注入什么。
this.injector.mapClass('timer', clock.TimerModel, true);
该映射规则可以让注入器知道,当遇到timer变量时,应该注入clock.TimerModel函数的实例。第三个参数告诉注入器总是注入相同的实例,而不是创建一个新的。
this.injector.mapClass('face', clock.FaceView);
this.injector.mapClass('needleSeconds', clock.NeedleSeconds);
this.injector.mapClass('needleMinutes', clock.NeedleMinutes);
this.injector.mapClass('needleHours', clock.NeedleHours);
由于模拟时钟已经被分为了几个视图,它需要上面的4个映射规则。
this.injector.mapValue('views', {
'digital': clock.DigitalView,
'analog': clock.AnalogView,
'polar': clock.PolarView
});
包含了所有不同时钟的对象在注入器中也应该被创建和映射。clock mediator负责创建使用这个对象的时钟,并实例化为正确的视图。
实例化clock Mediator
clock mediator用于表示被创建的时钟的DOM元素。第一个参数是实例化的mediator函数,第二个参数是它所表示的DOM元素。被注入的target变量用来表示DOM元素。
创建使用框架核心要素“mediators”的mediator:
this.mediators.create(clock.ClockMediator, this.element.querySelector('.clock'));
下面是clock mediator的代码:
(function(clock) {
var ClockMediator = function(target) {
};
clock.ClockMediator = ClockMediator;
})(window.clock = window.clock || {});
实例化选择器视图
选择器视图用来表示用于创建时钟的3个按钮的DOM元素。
(function(clock) {
var SelectorView = function() {
};
clock.SelectorView = SelectorView;
})(window.clock = window.clock || {});
创建第一个时钟
应用程序调用一个create事件来创建第一个时钟。
this.dispatcher.dispatch('create', 'analog');
关于事件的详细信息可阅读
这个文档。
完整的应用程序实例代码
应用程序实例clock.js的完整代码:
clock.js源码
(function(clock, soma) {
var ClockDemo = soma.Application.extend({
constructor: function(element) {
// store root DOM Element
this.element = element;
// call super constructor
soma.Application.call(this);
},
init: function() {
// mapping rules
this.injector.mapClass('timer', clock.TimerModel, true);
this.injector.mapClass('face', clock.FaceView);
this.injector.mapClass('needleSeconds', clock.NeedleSeconds);
this.injector.mapClass('needleMinutes', clock.NeedleMinutes);
this.injector.mapClass('needleHours', clock.NeedleHours);
this.injector.mapValue('views', {
'digital': clock.DigitalView,
'analog': clock.AnalogView,
'polar': clock.PolarView
});
// create clock mediator
this.mediators.create(clock.ClockMediator, this.element.querySelector('.clock'));
// create clock selector template
this.createTemplate(clock.SelectorView, this.element.querySelector('.clock-selector'));
},
start: function() {
// dispatch event to create an analog clock
this.dispatcher.dispatch('create', 'analog');
}
});
// instantiate clock application with a root DOM Element
var clockDemo = new ClockDemo(document.querySelector('.clock-app'));
})(window.clock = window.clock || {}, soma);
Timer模型
Timer模型的作用是为其他元素提供当前时间,而无需知道其他元素的相关信息。它的接口提供了两种方法:add和remove。它们都带有一个用于发送当前时间的参数。
(function(clock) {
var TimerModel = function() {
};
TimerModel.prototype.add = function(callback) {
// register functions
};
TimerModel.prototype.remove = function(callback) {
// remove registered functions
};
clock.TimerModel = TimerModel;
})(window.clock = window.clock || {});
timer模型没有依赖性,它不实例化其他函数,并且只要它们实现相同的接口(add和remove)就可以互相交换,应用程序中的其他元素(如视图和clock mediator)不会被修改。
即使该模型被用在应用程序中的其他地方,也只需修改下面的几行代码:
var ModelFunction = isOnline ? ServerTimeModel : TimeModel;
this.injector.mapClass('timer', ModelFunction, true);
可查看
Github上的这部分源码。
选择器视图
选择器视图的唯一作用是处理用户事件。当用户点击一个按钮时,视图获取这次点击事件,并调度一个自定义事件,由clock mediator进行监听并创建一个新的时钟。
此处使用
soma-template(可作为一个独立的库或soma.js插件)来监听用户的点击事件。
<div class="clock-selector">
<button data-click="select('digital')">Digital clock</button>
<button data-click="select('analog')">Analog clock</button>
<button data-click="select('polar')">Polar clock</button>
</div>
(function(clock) {
var SelectorView = function(scope, dispatcher) {
scope.select = function(event, id) {
dispatcher.dispatch('create', id);
};
};
clock.SelectorView = SelectorView;
})(window.clock = window.clock || {});
可查看
Github上的这部分源码,以及
soma-template文档。
时钟视图
应用程序中的3种类型的时钟视图:
- Analog view (clock.AnalogView);
- DigitalView (clock.DigitalView);
- PolarView (clock.PolarView);
通过timer模型,视图变得高度可重用,因为:
- 它们无需知道其他的应用程序元素
- 它们是自由的框架代码
- 它们提供了一个简单的API来更新其当前状态
它们的视图之间是可以互换的,因为它们都提供了相同的接口:
- 一个接收DOM元素的构造函数
- 一个接收当前事件的update方法
这使得它们高度可重用。下面是数字时钟的视图结构:
(function(clock) {
var DigitalView = function(target) {
};
DigitalView.prototype.update = function(time) {
};
DigitalView.prototype.dispose = function() {
};
clock.DigitalView = DigitalView;
})(window.clock = window.clock || {});
可查看Github上的如下相关源码:
依赖注入图
时钟应用的单元测试
单元测试的目的是隔离应用程序的每一部分,并测试各个部分是否正确。单元测试是应用程序是否可扩展的一个非常重要的一步。
时钟应用程序中的元素应该是高可测试的,因为它们彼此之间不耦合。这就是依赖注入带来的好处。本例使用
Mocha和
Jasmine进行测试,这是两个使用广泛的JavaScript单元测试框架,你可以通过
Mocking对象轻松创建模拟函数,并单独测试每个元素。
在浏览器中测试时钟应用
查看集成测试的源码
单元测试也可以在命令行中运行。这需要使用
NPM安装依赖:
$ npm install
$ npm install -g mocha
运行测试:
$ npm test
结论
要创建一个可扩展的应用程序,或要将现有应用程序改为可扩展的,离不开这两个过程:分析问题和解决问题。首先要考虑以下两个问题:
- 是什么使得应用程序具有可扩展性?
- 如何使应用程序具有可扩展性?
是什么使得应用程序具有可扩展性?
可以通过不同的方法来找出应用程序的可扩展级别。首先,要看应用程序中的所有元素是否满足如下要求:
- 这个元素应该被重用吗?
- 这是元素是可测试的吗?
- 这个元素是否有依赖性?
- 这个元素的目的是单一的吗?
如果一个元素很难被测试的,或者其包含的功能太多,或者有太多的依赖,那么这个元素就应该加以改进,以使应用程序具有可扩展性。
如何使应用程序具有可扩展性?
下面这个列表中的每一项任务都可以用来提高应用程序的可扩展性。
- 标识非单一用途的元素,并分解它们
- 找出“坏味道代码”并重构
- 避免代码重复(DRY)
- 避免大的函数
- 避免匿名函数
- 一个可用的、公共的、可测试的API
- 使用基于构造函数或setter方法的引用,避免实例化对象
- 尽可能消除依赖
- 使用观察者模式(事件)来移除依赖和发送信息
- 创建元素的mediators来移除依赖和接收消息
- 尽可能地实现清晰的接口
- 隐藏或私有化与其他元素无关的一些内容。
- 建议尽可能使用组合(Composition),而不是继承(inheritance)
当出现下面的这些情况时,说明元素已经具有可扩展性了:
- 该元素可以很容易地与其他元素进行互换,而不会破坏应用程序
- 该元素可以轻松重用于项目外部
- 该元素可以成功地进行单元测试
通常,你需要找到
坏味道代码,然后进行重构和改进。坏味道代码是对代码中存在的潜在问题的警示信号,对于大多数坏味道,均有必要加以查看并做出相应决定。代码坏味道是需要重构的征兆。
最后来分析本文所创建的时钟应用
在时钟应用程序中,框架中被依赖的两个元素是:
高度独立和高可用的元素是:
为了实现高扩展性和重用性,应用程序已经被分解,所有元素的目的都是单一的:
- timer模型用于处理时间。
- clock mediator用于隐藏和创建时钟
- 选择器视图用于处理用户事件
- clock视图创建在屏幕上创建时钟外观
- 中介者模式被用于移除timer模型和clock视图的依赖关系,如果没有mediator,它们将会对彼此有一个直接依赖。
- 观察者模式(创建事件)被用来从其他一些元素中分离出选择器视图,其他元素可以监听相同的框架事件,这也使得应用程序更具可扩展性,
- 依赖注入被用来发送所有元素的参考引用,并解耦它们,使它们高度可测试。
- clock视图接收构造函数中的DOM元素的参考引用,这使得它们可以与任何其他的DOM元素一起使用。此外,还提供了一个公共API来更新自己的内容,使得其他元素可以在外部使用它,而无需了解它的相关信息。
- timer模型提供了一个接口来添加和删除回调,使得它可以发送当前时间到一组已注册的actors中,而无需了解它们的相关信息。
以上这种结构使得项目的代码更容易重构,可以轻松更改项目结构、添加或移除元素、测试每个组件、更新单个元素,而无需担心影响整个应用程序。
英文原文:
Soma.js – Your Way Out of Chaotic JavaScript
3 楼 washingtonDC 2014-01-26 13:31
2 楼 qiu768 2013-07-30 13:11
1 楼 flex_莫冲 2013-07-25 11:54