随着项目的规模越来越大,项目的维护性就可能会变得越来越差,有时可能会出现牵一发而动全身的情况。如果需要修改某个功能的代码,或者添加某项功能,会耗费大量的人力和时间。这种情况下,高可扩展性的、低耦合的应用程序就变得非常重要了。
本文通过构建一个时钟程序,来讲解高扩展的应用程序是如何一步一步搭建的。

什么是可扩展的应用程序?

一个可扩展的应用程序应该能够以某种方式实现增长,并且添加、删除、增强、重构某些组件,对于其他组件的影响微乎其微。
再大的应用程序,往往都是从很小的规模开始,然后一点一点发展起来的。但有时可能会由于增长过快,规模变得越来越大,导致项目难以管理,最终软件可能需要完全重写。

开发人员在一开始编码时就要充分考虑到这种情况。本文以编写功能独立的JavaScript应用为例来说明如何构建可扩展的应用程序,同时还将讨论如何编写可测试、可维护、可调试、直观的代码。

本文将使用soma.js框架来编写一个高可扩展性的JavaScript时钟应用。

什么是soma.js?

构建高扩展性的应用的要点在于构建组成该应用的小的、单个的模块。soma.js是一个JavaScript框架,它提供了一系列工具来帮助开发者创建一个可分解为若干个块的松耦合的架构。
soma.js不依赖于特定的架构模式。该框架可以作为一个MVCMV* 框架,此外, 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中实现“汽车”接口的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12

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视图的接口代码如下:

1
2
3
4
5
6
7
8
9
10

interface ITimerModel {
add(callback: function);
remove(callback: function);
update();
}
interface IClockView {
update(time: Object);
dispose();
}

下图显示了时钟应用程序中的不同的接口实现:

应用程序实例

创建soma.js应用的第一步是创建一个应用程序实例,这是决定应用框架功能是否可扩展性的唯一一个重要时刻。所有的其他实体可以是可重复使用的JavaScript函数,并且可以不受框架约束。
应用程序实例主要执行两个函数: init 和 start ,以便应用程序可以通过架构所需的功能来进行设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

(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元素有硬依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

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'));

测试一个应用程序是否是自包含的简单方法是,在屏幕中创建多个实例,看它们是否能够独立工作。
注入映射规则
现在该应用程序已经有了一个基础架构,可以创建注射映射规则了。
映射规则无非是指定一个函数,或为字符串指定一个值。字符串在其他地方可以作为“命名变量”使用,以便让注入器知道要注入什么。

1

this.injector.mapClass('timer', clock.TimerModel, true);

该映射规则可以让注入器知道,当遇到timer变量时,应该注入clock.TimerModel函数的实例。第三个参数告诉注入器总是注入相同的实例,而不是创建一个新的。

1
2
3
4

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个映射规则。

1
2
3
4
5

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:

1

this.mediators.create(clock.ClockMediator, this.element.querySelector('.clock'));

下面是clock mediator的代码:

1
2
3
4
5
6

(function(clock) {
var ClockMediator = function(target) {
};
clock.ClockMediator = ClockMediator;
})(window.clock = window.clock || {});

实例化选择器视图
选择器视图用来表示用于创建时钟的3个按钮的DOM元素。

1
2
3
4
5
6

(function(clock) {
var SelectorView = function() {
};
clock.SelectorView = SelectorView;
})(window.clock = window.clock || {});

创建第一个时钟
应用程序调用一个create事件来创建第一个时钟。

1

this.dispatcher.dispatch('create', 'analog');

关于事件的详细信息可阅读这个文档
完整的应用程序实例代码
应用程序实例clock.js的完整代码:clock.js源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

(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。它们都带有一个用于发送当前时间的参数。

1
2
3
4
5
6
7
8
9
10
11
12

(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)不会被修改。
即使该模型被用在应用程序中的其他地方,也只需修改下面的几行代码:

1
2

var ModelFunction = isOnline ? ServerTimeModel : TimeModel;
this.injector.mapClass('timer', ModelFunction, true);

可查看Github上的这部分源码
选择器视图
选择器视图的唯一作用是处理用户事件。当用户点击一个按钮时,视图获取这次点击事件,并调度一个自定义事件,由clock mediator进行监听并创建一个新的时钟。
此处使用soma-template(可作为一个独立的库或soma.js插件)来监听用户的点击事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

<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方法

这使得它们高度可重用。下面是数字时钟的视图结构:

1
2
3
4
5
6
7
8
9
10
11
12

(function(clock) {
var DigitalView = function(target) {
};
DigitalView.prototype.update = function(time) {
};
DigitalView.prototype.dispose = function() {
};
clock.DigitalView = DigitalView;
})(window.clock = window.clock || {});

可查看Github上的如下相关源码:

依赖注入图

时钟应用的单元测试
单元测试的目的是隔离应用程序的每一部分,并测试各个部分是否正确。单元测试是应用程序是否可扩展的一个非常重要的一步。
时钟应用程序中的元素应该是高可测试的,因为它们彼此之间不耦合。这就是依赖注入带来的好处。本例使用MochaJasmine进行测试,这是两个使用广泛的JavaScript单元测试框架,你可以通过 Mocking对象轻松创建模拟函数,并单独测试每个元素。
在浏览器中测试时钟应用
查看集成测试的源码
单元测试也可以在命令行中运行。这需要使用NPM安装依赖:

1
2

$ npm install
$ npm install -g mocha

运行测试:

1

$ npm test


结论
要创建一个可扩展的应用程序,或要将现有应用程序改为可扩展的,离不开这两个过程:分析问题和解决问题。首先要考虑以下两个问题:

  • 是什么使得应用程序具有可扩展性?
  • 如何使应用程序具有可扩展性?
  • 是什么使得应用程序具有可扩展性?

可以通过不同的方法来找出应用程序的可扩展级别。首先,要看应用程序中的所有元素是否满足如下要求:

  • 这个元素应该被重用吗?
  • 这是元素是可测试的吗?
  • 这个元素是否有依赖性?
  • 这个元素的目的是单一的吗?

如果一个元素很难被测试的,或者其包含的功能太多,或者有太多的依赖,那么这个元素就应该加以改进,以使应用程序具有可扩展性。
如何使应用程序具有可扩展性?
下面这个列表中的每一项任务都可以用来提高应用程序的可扩展性。

  • 标识非单一用途的元素,并分解它们
  • 找出“坏味道代码”并重构
  • 避免代码重复(DRY)
  • 避免大的函数
  • 避免匿名函数
  • 一个可用的、公共的、可测试的API
  • 使用基于构造函数或setter方法的引用,避免实例化对象
  • 尽可能消除依赖
  • 使用观察者模式(事件)来移除依赖和发送信息
  • 创建元素的mediators来移除依赖和接收消息
  • 尽可能地实现清晰的接口
  • 隐藏或私有化与其他元素无关的一些内容。
  • 建议尽可能使用组合(Composition),而不是继承(inheritance)

当出现下面的这些情况时,说明元素已经具有可扩展性了:

  • 该元素可以很容易地与其他元素进行互换,而不会破坏应用程序
  • 该元素可以轻松重用于项目外部
  • 该元素可以成功地进行单元测试

通常,你需要找到坏味道代码,然后进行重构和改进。坏味道代码是对代码中存在的潜在问题的警示信号,对于大多数坏味道,均有必要加以查看并做出相应决定。代码坏味道是需要重构的征兆。
最后来分析本文所创建的时钟应用
在时钟应用程序中,框架中被依赖的两个元素是:

  • 应用实例
  • clock mediator

高度独立和高可用的元素是:

  • timer模型
  • clock视图

为了实现高扩展性和重用性,应用程序已经被分解,所有元素的目的都是单一的:

  • timer模型用于处理时间。
  • clock mediator用于隐藏和创建时钟
  • 选择器视图用于处理用户事件
  • clock视图创建在屏幕上创建时钟外观
  • 中介者模式被用于移除timer模型和clock视图的依赖关系,如果没有mediator,它们将会对彼此有一个直接依赖。
  • 观察者模式(创建事件)被用来从其他一些元素中分离出选择器视图,其他元素可以监听相同的框架事件,这也使得应用程序更具可扩展性,
  • 依赖注入被用来发送所有元素的参考引用,并解耦它们,使它们高度可测试。
  • clock视图接收构造函数中的DOM元素的参考引用,这使得它们可以与任何其他的DOM元素一起使用。此外,还提供了一个公共API来更新自己的内容,使得其他元素可以在外部使用它,而无需了解它的相关信息。
  • timer模型提供了一个接口来添加和删除回调,使得它可以发送当前时间到一组已注册的actors中,而无需了解它们的相关信息。
  • 以上这种结构使得项目的代码更容易重构,可以轻松更改项目结构、添加或移除元素、测试每个组件、更新单个元素,而无需担心影响整个应用程序。

英文原文:Soma.js – Your Way Out of Chaotic JavaScript