Backbone.js是一个开源JavaScript“MV*”框架,在三年前它的第一次发布的时候就获得了显著的推动。尽管Backbone.js为Javascript应用程序提供了自己的结构,但它留下了大量根据开发者的需要而使用的设计模式和决策,并且当开发者们第一次使用Backbone.js开发的时候都会遇到许多共同的问题。 因此,在这篇文章中,我们除了会探索各种各样你能够应用到你的Backbone.js应用中的设计模式外,我们也会关注一些困惑开发者的常见问题。
JavaScript中所有原始类型变量的传递都是值传递。所以,当变量被引用的时候会传递该变量的值。
1
2
|
var
helloWorld = “Hello World”;
var
helloWorldCopy = helloWorld;
|
例如,以上代码会将helloWorldCopy 的值设为helloWorld的值。所以对于helloWorldCopy 的所有修改都不会改变helloWorld, 因为它是一个拷贝。另一方面,JavaScript所有非原始类型变量的传递都是引用传递,意思是当变量被引用的时候,JavaScript会传递一个其内存地址的参照。
1
2
3
4
|
var
helloWorld = {
‘hello’: ‘world’
}
var
helloWorldCopy = helloWorld;
|
举个例子,上面的代码会将helloWorldCopy 设为helloWorld 对象的别名,此时,你可能会猜到,对helloWorldCopy 的所有修改都会直接在helloWorld 对象上进行。如果你想得到一个helloWorld 对象的拷贝,你必须对这个对象进行一次复制。 你可能想知道,“为什么他(作者)要在这篇文章中解释这些按引用传递的东西?”很好,这是因为在Backbone.js的对象传递中,并不对进行对象复制,意味着如果你在一个模型中调用 get( ) 方法来取得一个对象,那么对它的任何修改都会直接在模型中的那个对象进行操作!让我们通过一个例子来看看什么时候它会成为你的麻烦。假设现在你有一个如下的Person 模型:
1
2
3
4
5
6
7
8
9
10
11
|
var
Person = Backbone.Model.extend({
defaults: {
'name'
:
'John Doe'
,
'address'
: {
'street'
:
'1st Street'
'city'
:
'Austin'
,
'state'
:
'TX'
'zipCode'
: 78701
}
}
});
|
并且假设你创建了一个新的person 对象:
1
2
3
|
var
person =
new
Person({
'name'
:
'Phillip W'
});
|
现在让我们对新对象person 的属性做一点修改。
1
|
person.set(
'name'
,
'Phillip W.'
);
|
上述代码会对person 对象中的name 属性进行修改。接下来让我们尝试修改person 对象的address 属性。在这之前,我们先对address属性添加校验。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
var
Person = Backbone.Model.extend({
validate:
function
(attributes) {
if
(isNaN(attributes.address.zipCode))
return
"Address ZIP code must be a number!"
;
},
defaults: {
'name'
:
'John Doe'
,
'address'
: {
'street'
:
'1st Street'
'city'
:
'Austin'
,
'state'
:
'TX'
'zipCode'
: 78701
}
}
});
|
现在,我们会尝试使用一个不正确的ZIP 码来修改对象的address 属性。
1
2
3
4
5
6
7
8
9
10
11
12
|
var
address = person.get(
'address'
);
address.zipCode =
'Hello World'
;
// 应该产生一个一个错误因为ZIP码是无效的
person.set(
'address'
, address);
console.log(person.get(
'address'
));
/* 打印包含如下属性的对象.
{
'street': '1st Street'
'city': 'Austin',
'state': 'TX'
'zipCode': 'Hello World'
}
*/
|
为什么会这样?我们的校验方法不是已经返回一个错误了吗?!为什么attributes 属性还是被改变了?原因正如前面所说,Backbone.js不会复制模型的attributes对象;它仅仅返回你所请求的东西。所以,你可能会猜到,如果你请求的是一个对象(如上面的address),你会得到那个对象的引用,并且你对这个对象的所有修改都会直接地操作在模型中的实际对象中(因此这样的修改方式并不会导致校验失败,因为对象的引用并没有改变)。这个问题很可能会导致你花费几小时来进行调试和诊断。 这个问题会逮住一些使用Backbone,js的新手甚至经验丰富却不够警惕的JavaScript开发者。这个问题已经在GitHub issues 的Backbone.js部分引起了大量的讨论。像 Jeremy Ashkenas 所指出的,执行深复制是一个非常棘手的问题,对那些有较大深度的对象来说,它将会是个非常昂贵的操作。 幸运地,jQuery提供了一些深复制的实现,$.extend。顺带说一句,Underscore.js,Backbone.js的一个依赖插件,也提供了类似的方法 _.extend ,但我会避免使用它,因为它并不执行深复制。
1
|
var
address = $.extend(
true
, {}, person.address);
|
我们现在得到了 address 对象的一个精确的拷贝,因此我们可以随心所欲地修改它的内容而不用担心修改到person中的address 对象。你应该意识到此模式适用于上述那个例子仅因为address 对象的所有成员都是原始值(numbers, strings, 等等),所以当深复制的对象中还包含有子对象时必须谨慎地使用。你应该知道执行一个对象的深复制会产生一个小的性能影响,但我从没见过它导致了什么显而易见的问题。尽管这样,如果你对一个复杂对象的执行深复制或者一次性执行上千个对象的深复制,你可能会想做一些性能分析。这正是下一个模式出现的原因。
在真实的世界里,需求经常会更改,所以那些通过模型和集合的查询而从终端返回的JSON数据也会有所改变。如果你的视图与底层数据模型紧紧地耦合,这将会让你感到非常麻烦。因此,我为所有的对象创建了获取器和设置器。 很多人赞成这种模式。就是如果任何底层数据结构被改变,视图层不应该更新太多;当你只有一个数据入口的时候,你就不太可能忘记执行深复制,并且你的代码会变得更加可维护和调试。但带来的负面影响是这种模式会让你的模型和集合有点膨胀。 让我们通过一个例子来搞清楚这个模式。假设我们有一个Hotel 模型,其中包含了rooms和当前可用的rooms,我们希望能够通过床位尺寸值来取得相应的rooms。
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
|
var
Hotel = Backbone.Model.extend({
defaults: {
"availableRooms"
: [
"a"
],
"rooms"
: {
"a"
: {
"size"
: 1200,
"bed"
:
"queen"
},
"b"
: {
"size"
: 900,
"bed"
:
"twin"
},
"c"
: {
"size"
: 1100,
"bed"
:
"twin"
}
},
getRooms:
function
() {
$.extend(
true
, {},
this
.get(
"rooms"
));
},
getRoomsByBed:
function
(bed) {
return
_.where(
this
.getRooms(), {
"bed"
: bed });
}
}
});
|
让我们假设明天你将会发布你的代码,并且终端的开发者忘记告诉你rooms的数据结构从Object变成了一个array。你的代码现在如下所示:
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
37
38
39
|
var
Hotel = Backbone.Model.extend({
defaults: {
"availableRooms"
: [
"a"
],
"rooms"
: [
{
"name"
:
"a"
,
"size"
: 1200,
"bed"
:
"queen"
},
{
"name"
:
"b"
,
"size"
: 900,
"bed"
:
"twin"
},
{
"name"
:
"c"
,
"size"
: 1100,
"bed"
:
"twin"
}
],
getRooms:
function
() {
var
rooms = $.extend(
true
, {},
this
.get(
"rooms"
)),
newRooms = {};
// transform rooms from an array back into an object
_.each(rooms,
function
(room) {
newRooms[room.name] = {
"size"
: room.size,
"bed"
: room.bed
}
});
},
getRoomsByBed:
function
(bed) {
return
_.where(
this
.getRooms(), {
"bed"
: bed });
}
}
});
|
为了将Hotel 转换为应用所期望的数据结构,我们仅仅更新了一个方法,这让我们整个App的仍然正常工作。如果我们没有创建一个rooms数据的获取器,我们可能不得不更新每一个rooms的数据入口。理想情况下,你为了使用一个新的数据结构而会想要更新所有的接口方法。但如果由于时间紧迫而不得不尽快发布代码的话,这个模式能拯救你。 顺带提一下,这个模式既可以被认为是一个facade 设计模式,因为它隐藏了对象复制的细节,也可以被称为bridge 设计模式,因为它可以被用于转换所期望的数据结构。因而一个好的习惯是在所有的对象上使用获取器和设置器。
尽管Backbone.js规定模型和集合会映射到REST-ful终端,但你有时候会发现你只是想将数据存储在模型或者集合而不同步到服务器。一些其他关于Backbone.js的文章,像“Backbone.js Tips: Lessons From the Trenches”就讲解过这个模式。让我们快速地通过一个例子来看看什么时候这个模式会派上用场。假设你有个ul列表。
1
2
3
4
5
6
|
<ul>
<li><a href=
"#"
data-id=
"1"
>One</a></li>
<li><a href=
"#"
data-id=
"2"
>Two</a></li>
. . .
<li><a href=
"#"
data-id=
"n"
>n</a></li>
</ul>
|
当n值为200并且用户点击了其中一个列表项,那个列表项会被选中并添加了一个类以直观地显示。实现它的一个方法如下所示:
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
37
38
39
40
41
42
43
44
45
46
|
var
Model = Backbone.Model.extend({
defaults: {
items: [
{
"name"
:
"One"
,
"id"
: 1
},
{
"name"
:
"Two"
,
"id"
: 2
},
{
"name"
:
"Three"
,
"id"
: 3
}
]
}
});
var
View = Backbone.View.extend({
template: _.template($(
'#list-template'
).html()),
events: {
"#items li a"
:
"setSelectedItem"
},
render:
function
() {
$(
this
.el).html(
this
.template(
this
.model.toJSON()));
},
setSelectedItem:
function
(event) {
var
selectedItem = $(event.currentTarget);
// Set all of the items to not have the selected class
$(
'#items li a'
).removeClass(
'selected'
);
selectedItem.addClass(
'selected'
);
return
false
;
}
});
<script id=
"list-template"
type=
"template"
>
<ul id=
"items"
>
<%
for
(i = items.length - 1; i >= 0; i--) { %>
<li>
<a href=
"#"
data-id=
"<%= item[i].id %>"
><%= item[i].name %></a></li>
<% } %></ul>
</script>
|
现在我们想要知道哪一个item被选中。一个方法是遍历整个列表。但如果这个列表过长,这会是一个昂贵的操作。因此,当用户点击其中的列表项时,我们应该将它存储起来。
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
37
38
39
40
41
42
43
44
45
46
|
var
Model = Backbone.Model.extend({
defaults: {
selectedId: undefined,
items: [
{
"name"
:
"One"
,
"id"
: 1
},
{
"name"
:
"Two"
,
"id"
: 2
},
{
"name"
:
"Three"
,
"id"
: 3
}
]
}
});
var
View = Backbone.View.extend({
initialize:
function
(options) {
// Re-render when the model changes
this
.model.on(
'change:items'
,
this
.render,
this
);
},
template: _.template($(
'#list-template'
).html()),
events: {
"#items li a"
:
"setSelectedItem"
},
render:
function
() {
$(
this
.el).html(
this
.template(
this
.model.toJSON()));
},
setSelectedItem:
function
(event) {
var
selectedItem = $(event.currentTarget);
// Set all of the items to not have the selected class
$(
'#items li a'
).removeClass(
'selected'
);
selectedItem.addClass(
'selected'
);
// Store a reference to what item was selected
this
.model.set(
'selectedId'
, selectedItem.data(
'id'
));
return
false
;
}
});
|
现在我们能够轻易地搜索我们的模型来确定哪一个item被选中,并且我们避免了遍历文档对象模型 (DOM)。这个模式对于存储一些你想要跟踪的外部数据非常有用;还要记住的是你能够创建不需要与终端相关联的模型和集合。 这个模式的消极影响是你的模型或集合并不是真正地采用RESTful 架构因为它们没有完美地映射到网络资源。另外,这个模式会让你的模型带来一点儿膨胀;并且如果你的终端严格地只接收它所期望的JSON数据,它会给你带来一点儿麻烦。
当你第一次开发Backbone.js应用,你的视图一般会是这样的结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
var
View = Backbone.View.extend({
initialize:
function
(options) {
this
.model.on(
'change'
,
this
.render,
this
);
},
template: _.template($(‘
#template’).html()),
render:
function
() {
this
.$el.html(template(
this
.model.toJSON());
$(‘
#a’, this.$el).html(this.model.get(‘a’));
$(‘
#b’, this.$el).html(this.model.get(‘b’));
}
});
|
在这里,你的模型的任何改变都会触发一次视图的完整的重新渲染。当我第一次使用Backbone.js来做开发的时候,我也使用过这种模式。但随着我代码的膨胀,我很快意识到这个方法是不可维护和不理想的,因为模型的任何属性的改变都会让视图完全重新渲染。 当我遇到这个问题的时候,我马上在Google搜索其他人是怎么做的并且找到了Ian Storm Taylor的博客写的一篇文章, “Break Apart Your Backbone.js Render Methods,”,其中他提到了监听模型个别的属性改变并且响应的方法仅仅重新渲染视图的一部分。Taylor也提到重渲染方法应该返回自身的this对象,这样那些单独的重渲染方法就可以轻易地串联起来。下面的这个例子已经作出了修改而变得更易于维护和管理了,因为当模型属性改变的时候我们仅仅更新相应部分的视图。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
var
View = Backbone.View.extend({
initialize:
function
(options) {
this
.model.on(
'change:a'
,
this
.renderA,
this
);
this
.model.on(
'change:b'
,
this
.renderB,
this
);
},
renderA:
function
() {
$(‘
#a’, this.$el).html(this.model.get(‘a’));
return
this
;
},
renderB:
function
() {
$(‘
#b’, this.$el).html(this.model.get(‘b’));
return
this
;
},
render:
function
() {
this
.renderA()
.renderB();
}
});
|
还要提到的是,许多插件,像 Backbone.StickIt 和 Backbone.ModelBinder,提供了视图元素和模型属性之间的键值绑定,这能够节省你很多的相似代码。因此,如果你有很多复杂的表单字段,可以试着使用它们。
像Jeremy Ashkenas 在Backbone.js的 GitHub issues指出的一个问题,除了模型不能够由它们的视图来创建以外,Backbone.js并不在数据层和视图层之间实施任何真正的关注点分离。你觉得应该在数据层和视图层之间实施关注点分离吗?我和其他的一些Backbone.js开发者,像Oz Katz和 Dayal,都认为这个答案毫无疑问应该是要的:模型和集合,代表着数据层,应该禁止任何绑定到它们的视图的入口,从而保持一个完全的关注点分离。如果你不遵循这个关注点分离,你的代码很快就会变得像意大利面条那样纠缠不清,而没有人会喜欢这种代码。 保持你的数据层和视图层完全地分离可以使你拥有更加地模块化,可重用和可维护的代码。你能够轻易地在你的应用中重用和拓展模型和集合而不需要担心和他们绑定的视图。遵循这个模式能让新加入项目的开发者快速的投入到代码中。因为它们精确的知道哪里会发生视图的渲染以及哪里存放着应用的业务逻辑。 这个模式也强制使用了单一责任原则,该原则规定了每一个类应该只有一个单独的责任,并且它的职责应该封装在这个类中,因为你的模型和集合应该只负责处理数据,视图应该只负责处理渲染。
使用例子是展示这个模式如何产生的最好方法。例如:有一些搜索页面,它们允许用户添加两个不同的过滤类型,foo 和bar,每一个都附有大量的选项。因此,你的URL结构看起来将会像这样:
1
2
3
|
'search/:foo'
'search/:bar'
'search/:foo/:bar'
|
现在,所有的路由使用一个确切的视图和模型,所以,理想状况下,你会乐意它们都用同一个方法,search()。但是,如果你检查Backbone.js,会发现没有任何形式的参数映射;这些参数只是简单地从左到右扔到方法里面去。所以,为了让它们都能使用相同的方法,你最终要创建不同的方法来正确地映射参数到search()方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
routes: {
'search/:foo'
:
'searchFoo'
,
'search/:bar'
:
'searchBar'
,
'search/:foo/:bar'
:
'search'
},
search:
function
(foo, bar) {
},
// I know this function will actually still map correctly, but for explanatory purposes, it's left in.
searchFoo:
function
(foo) {
this
.search(foo, undefined);
},
searchBar:
function
(bar) {
this
.search(undefined, bar);
},
|
和你想的一样,这种模式会快速地膨胀你的路由。当我第一次使用接触这种模式的时候,我尝试使用正则表达式在实际方法定义中做一些解析而“神奇地”映射这些参数,但这只能在参数容易区分的情况下起作用。所以我放弃了这个方法(我有时候依然会在Backbone插件中使用它)。我在issue on GitHub上提出过这个问题,Ashkenas 给我的建议是在search方法中映射所有的参数。 下面这段代码已经变得更加具备可维护性:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
routes: {
'base/:foo'
:
'search'
,
'base/:bar'
:
'search'
,
'base/:foo/:bar'
:
'search'
},
search:
function
() {
var
foo, bar, i;
for
(i = arguments.length - 1; i >= 0; i--) {
if
(arguments[i] ===
'something to determine foo'
) {
foo = arguments[i];
continue
;
}
else
if
(arguments[i] ===
'something to determine bar'
) {
bar = arguments[i];
continue
;
}
}
},
|
这个模式可以彻底地减少路由器的膨胀。然而,要意识到它对于不可识别的参数时无效的。举个例子,如果你有两个传递ID的参数并且都它们以 XXXX-XXXX 这种模式表现,你将无法确定哪一个ID对应的是哪一个参数。
这个问题通常会绊倒使用Backbone.js的新手: model.fetch() 并不会清理你的模型,而是会将取回来的数据合并到你的模型当中。因此,如果你当前的模型有x,,y 和 z 属性并且你通过fetch得到了一个新的 y 和z 值,接下来 x 会保持模型原来的值,仅仅 y 和z 的值会得到更新,下面这个例子直观地说明了这个概念。
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
|
var
Model = Backbone.Model.extend({
defaults: {
x: 1,
y: 1,
z: 1
}
});
var
model =
new
Model();
/* model.attributes yields
{
x: 1,
y: 1,
z: 1
} */
model.fetch();
/* let’s assume that the endpoint returns this
{
y: 2,
z: 2,
} */
/* model.attributes now yields
{
x: 1,
y: 2,
z: 2
} */
|
这个问题也只通常出现在Backbone.js的新手中。当你调用.save() 方法时,你会发送一个HTTP PUT 请求,要求你的模型已经设置了一个ID属性。HTTP PUT 是被设计为一个更新动作的,所以发送PUT请求的时候要求你的模型已有一个ID属性是合情理的。在理想的世界里,你的所有模型都会有一个名为id的属性,但现实情况是,你从终端接收的JSON数据的ID属性并不总是会刚好命名为id。 因此,如果你需要更新你的模型,请确定在保存前你的模型具有一个ID。当终端返回的ID属性变量名不为 id 的时候,0.5及以上的版本的Backbone.js允许你使用 idAttribute 来改变ID属性的名字。 如果使用的Backbone.js的版本仍低于0.5,我建议你修改集合或模型中的 parse 方法来映射期望的ID属性到真正的ID属性。这里有一个让你快速掌握这个技巧的例子,让我们假设你有一个cars集合,它的ID属性名为carID .
1
2
3
4
5
6
7
8
9
|
parse:
function
(response) {
_.each(response.cars,
function
(car, i) {
// map the returned ID of carID to the correct attribute ID
response.cars[i].id = response.cars[i].carID;
});
return
response;
},
|
一些时候你会发现你需要在页面加载的时候就使用数据来初始化你的集合和模型。一些关于Backbone.js模式的文章,像Rico Sta Cruz的“Backbone Patterns”和Katz的“Avoiding Common Backbone.js Pitfalls,”谈论到了这个模式。使用你选择的服务端语言,通过嵌入代码到页面并将数据放在单个模型的属性或JSON当中,你能够轻易地实现这个模式。举个例子,在Rails中,我会这样使用:
1
2
3
4
5
|
// a single attribute
var
model =
new
Model({
hello: <%= @world %>
});
// or to have json
var
model =
new
Model(<%= @hello_world.to_json %>);
|
使用这个模式能够通过“马上渲染你的页面”来提高你的搜索引擎排名,并且它能通过限制应用的HTTP请求来彻底地缩短你的应用启动和运行所花费的时间。
你经常会想知道哪一个模型属性的验证失败了。举个例子,如果你有一个极度复杂的表单域,你可能想知道哪一个模型属性验证失败,这样你能够高亮显示相应的表单域。不幸的是,提醒你的视图哪一个模型属性验证失败并没有直接在Backbone.js中实现,但你可以使用不同的模式来处理这个问题。
通知你的视图哪一个模型属性验证失败的一个模式是回传一个带有某种标志的对象,该对象中详述哪一个模型属性验证失败,就像下面这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|