BackboneAnalyze
backbone源码解读
Install / Use
/learn @aircloud/BackboneAnalyzeREADME
注:本篇解读对应的backbone.js版本为1.3.3
backbone源码解读
写在前面
backbone是我两年多前入门前端的时候接触到的第一个框架,当初被backbone的强大功能所吸引(当然的确比裸写js要好得多),虽然现在backbone并不算最主流的前端框架了,但是,它里面大量设计模式的灵活运用,以及令人赞叹的处理技巧,还是非常值得学习。个人认为,读懂老牌框架的源代码比会用流行框架的API要有用的多。
另外,backbone的源代码最近也改了许多(特别是针对ES6),所以有些老旧的分析,可能会和现在的源代码有些出入。
所以我写这一篇分析backbone的文章,供自己和大家一起学习,本文适合使用过backbone的朋友,笔者水平有限,而内容又实有点多,难免会出差错,欢迎大家在<a href="https://github.com/aircloud/backboneAnalyze" target="_blank">GitHub</a>上指正
接下来,我们将通过一篇文章解析backbone,我们是按照源码的顺序来讲解的,这有利于大家边看源代码边解读,另外,我给源代码加了全部的中文注释和批注,请见<a href="https://github.com/aircloud/backboneAnalyze/tree/master/src" target="_blank">这里</a>,强烈建议大家边看源码边看解析,并且遇到我给出外链的地方,最好把外链的内容也看看(如果能够给大家帮助,欢迎给star鼓励~)
当然,这篇文章很长[为了避免文章有上没下,我还是整合到一篇文章中了]。
backbone宏观解读
backbone是很早期将MVC的思想带入前端的框架,现在MVC以及后来的MVVM这么火可以在一定程度上归功于backbone。关于前端MVC,我在自己的<a href="http://aircloud.10000h.top/29" target="_blank">这篇文章</a>中结合阮一峰老师的图示简单分析过,简单来讲就是Model层控制数据,View层通过发布订阅(在backbone中)来处理和用户的交互,Controller是控制器,在这里主要是指backbone的路由功能。这样的设计非常直接清晰,有利于前端工程化。
backbone中主要实现了Model、Collection、View、Router、History几大功能,前四种我们用的比较多,另外backbone基于发布-订阅模式自己实现了一套对象的事件系统Events,简单来说Events可以让对象拥有事件能力,其定义了比较丰富的API,并且如果你引入了backbone,这套事件系统还可以集成到自己的对象上,这是一个非常好的设计。
另外,源代码中所有的以_开头的方法,可以认为是私有方法,是没有必要直接使用的,也不建议用户覆盖。
backbone模块化处理、防止冲突和underscore混入
代码首先进行了区分使用环境(self或者是global,前者代表浏览器环境(self和window等价),后者代表node环境)和模块化处理操作,之后处理了在AMD和CommonJS加载规范下的引入方式,并且明确声明了对jQuery(或者Zepto)和underscore的依赖。
很遗憾的是,虽然backbone这样做了,但是backbone并不适合在node端直接使用,也不适合服务端渲染,另外还和ES6相处的不是很融洽,这个我们后面还会陆续提到原因。
backbone noConflict
backbone也向jQuery致敬,学习了它的处理冲突的方式:
var previousBackbone = root.Backbone;
//...
Backbone.noConflict = function() {
root.Backbone = previousBackbone;
return this;
};
这段代码的逻辑非常简单,我们可以通过以下方式使用:
var localBackbone = Backbone.noConflict();
var model = localBackbone.Model.extend(...);
混入underscore的方法
backbone通过addUnderscoreMethods将一些underscore的实用方法混入到自己定义的几个类中(注:确切地说是可供构造调用的函数,我们下文也会用类这个简单明了的说法代替)。
这里面值得一提的是关于underscore的方法(underscore的源码解读请移步<a href="https://github.com/aircloud/underscore-analysis" target="_blank">这里</a>,fork from韩子迟),underscore的所有方法的参数序列都是固定的,也就是说第一个参数代表什么第二个参数代表什么,所有函数都是一致的,第一个参数一定代表目标对象,第二个参数一定代表作用函数(有的函数可能只有一个参数),在有三个参数的情况下,第三个参数代表上下文this,另外如果有第四个参数,第三个参数代表初始值或者默认值,第四个参数代表上下文。所以addMethod就是根据以上规定来使用的。
另外关于javascript中的this,我曾经写过博客<a href="http://aircloud.10000h.top/38" target="_blank">在这里</a>,有兴趣的可以看
混入方法的实现逻辑:
var addMethod = function(length, method, attribute) {
//...
};
var addUnderscoreMethods = function(Class, methods, attribute) {
_.each(methods, function(length, method) {
if (_[method]) Class.prototype[method] = addMethod(length, method, attribute);
});
};
//之后使用:
var modelMethods = {keys: 1, values: 1, pairs: 1, invert: 1, pick: 0,
omit: 0, chain: 1, isEmpty: 1};
//混入一些underscore中常用的方法
addUnderscoreMethods(Model, modelMethods, 'attributes');
backbone Events
backbone的Events是一个对象,其中的方法(on\listenTo\off\stopListening\once\listenToOnce\trigger)都是对象方法。
总体上,backbone的Events实现了监听/触发/解除对自己对象本身的事件,也可以让一个对象监听/解除监听另外一个对象的事件。
绑定对象自身的监听事件on
关于对象自身事件的绑定,这个比较简单,除了最基本的绑定之外(一个事件一个回调),backbone还支持以下两种方式的绑定:
//传统方式
model.on("change", common_callback);
//传入一个名称,回调函数的对象
model.on({
"change": on_change_callback,
"remove": on_remove_callback
});
//使用空格分割的多个事件名称绑定到同一个回调函数上
model.on("change remove", common_callback);
这用到了它定义的一个中间函数eventsApi,这个函数比较实用,可以根据判断使用的是哪种方式(实际上这个判断也比较简单,根据传入的是对象判断属于上述第二种方式,根据正则表达式判断是上述的第三种方式,否则就是传统的方式)。然后再进行递归或者循环或者直接处理。
在对象中存储事件实际上大概是下述形式:
events:{
change:[事件一,事件二]
move:[事件一,事件二,事件三]
}
而其中的事件实际上是一个整理好的对象,是如下形式:
{callback: callback, context: context, ctx: context || ctx, listening: listening}
这样在触发的时候,一个个调用就是了。
监听其他对象的事件listenTo
backbone还支持监听其他对象的事件,比如,B对象上面发生b事件的时候,通知A调用回调函数A.listenTo(B, “b”, callback);,而这也是backbone处理非常巧妙的地方,我们来看看它是怎么做的。
实际上,这和B监听自己的事件,并且在回调函数的时候把上下文变成A,是差不多的:B.on(“b”, callback, A);(on的第三个参数代表上下文)。
但是backbone还做了另外的事情,这里我们假设是A监听B的一个事件(比如change事件好了)。
首先A有一个A._listeningTo属性,这个属性是一个对象,存放着它监听的别的对象的信息A._listeningTo[id] = {obj: obj, objId: id, id: thisId, listeningTo: listeningTo, count: 0},这个id并不是数字,是每一个对象都有的唯一字符串,是通过_.uniqueId这个underscore方法生成的,这里的obj是B,objId是B的_listenId,id是A的_listenId,count是一个计数功能,而这个A._listeningTo[id]会被直接引用赋值到上面事件对象的listening属性中。
为什么要多listenTo?Inversion of Control
通过以上我们似乎有一个疑问,好像on就能把listenTo的功能搞定了,用一个listenTo纯属多余,并且许多其他的类库也是只有一个on方法。
首先,这里会引入一个概念:控制反转,所谓控制反转,就是原来这个是B对象来控制的事件我们现在交由A对象来控制,那现在假设A分别listenTo B、C、D三个对象,那么这个时候假设A不监听了,那么我们直接对A调用一个stopListening方法,则可以同时解除对B、C、D的监听(这里我讲的可能不是十分正确,这里另外推荐一个<a href="https://segmentfault.com/a/1190000002549651" target="_blank">文章</a>)。
另外,我们需要从backbone的设计初衷来看,backbone的重点是View、Model和Collection,实际上,backbone的View可以对应一个或者多个Collection,当然我们也可以让View直接对应Model,但问题是View也并不一定对应一个Model,可能对应多个Model,那么这个时候我们通过listenTo和stopListening可以非常方便的添加、解除监听。
//on的方式绑定
var view = {
DoSomething :function(some){
//...
}
}
model.on('change:some',view.DoSomething,view);
model2.on('change:some',view.DoSomething,view);
//解绑,这个时候要做的事情比较多且乱
model.off('change:some',view.DoSomething,view);
model2.off('change:some',view.DoSomething,view);
//listenTo的方式绑定
view.listenTo(model,'change:some',view.DoSomething);
view.listenTo(model2,'change:some',view.DoSomething);
//解绑
view.stopListening();
另外,在实际使用中,listengTo的写法也的确更加符合用户的习惯.
以下是摘自backbone官方文档的一些解释,仅供参考:
The advantage of using this form, instead of other.on(event, callback, object), is that listenTo allows the object to keep track of the events, and they can be removed all at once later on. The callback will always be called with object as context.
解除绑定事件off、stopListening
与on不同,off的三个参数都是可选的
- 如果没有任何参数,off相当于把对应的_events对象整体清空
- 如果有name参数但是没有具体指定哪个callback的时候,则把这个name(事件)对应的回调队列全部清空
- 如果还有进一步详细的callback和context,那么这个时候移除回调函数非常严格,必须要求上下文和原来函数完全一致
off的最终实现函数是offApi,这个函数算上注释有大概50行。
var offApi = function(events, name, callback, options) {
//...
}
这里面需要单独提一下,前面有这样的几行:
if (!name && !callback && !context) {
var ids = _.keys(listeners);//所有监听它的对应的属性
for (; i < ids.length; i++) {
listening = listeners[ids[i]];
delete listeners[listening.id];
delete listening.listeningTo[listening.objId];
}
return;
}
这几行是做了一件什么事呢?
删除了所有的多对象监听事件记录,之后删除自身的监听事件。我们假设A监听了B的一个事件,这个时候A._listenTo中就会多一个条目,存储这个监听事件的信息,而这个时候B的B._listeners也会多一个条目,存储监听事件的信息,注意这两个条目都是按照id为键的键值对来存储,但是这个键是不一样的,值都指向同一个对象,这里删除对这个对象的引用,之后就可以被垃圾回收机制回收了。如果这个时候调用B.off(),那么这个时候,以上的两个条目都被删除了。另外,注意最后的return,以及Events.off中的:
this._events = eventsApi(offApi, this._events, name, callback, {
context: context,
listeners: this._listeners
});
所以如果B.off()这样调用然后直接把 B._events 在之后也清空了,太巧妙了。
之后有一个对names(事件名)的循环(如果没有指定,那么默认就是所有names),这个循环内容理解起来比较简单,里面也顺便照顾了_listeners_listenTo这些变量。这里不过多解释了。
另外,stopListening实际上也是调用offApi,先处理了一下交给off函数,这也是设计模式运用典范(适配器模式)。
once和listenToOnce
这两个函数顾名思义,和on以及listenTo的区别不大,唯一的区别就是回调函数只供调用一次,多触发调用也没有用(实际上不会被触发了)。
两者都用到了onceMap这个函数,我们分析一下这个函数:
var onceMap = function(map, name, callback, offer) {
if (callback) {
//_.once:创建一个只能调用一次的函数。重复调用改进的方法也没有效果,只会返回第一次执行时的结果。 作为初始化函数使用时非常有用, 不用再设一个boolean值来检查是否已经初始化完成.
var once = map[name] = _.once(function() {
offer(name, once);
callback.apply(this, arguments);
});
//这个在解绑的时候有一个分辨效果
once._callback = callback;
}
return map;
};
backbone的设计思路是这样的:用_.once()创建一个只能被调用一次的函数,这个函数在第一次被触发调用的时候,进行解除绑定(offer实际上是一个已经绑定好this的解除绑定函数,这个可以参见once和listenToOnce的源代码),然后再调用callback,这样既实现了调用一次的目的,也方便了垃圾回收。
其他和on以及listenTo的时候一样,这里就不过多介绍了。
trigger
trigger函数是用于触发事件,支持多个参数,除了第一个参数以外,其他的参数会依次放入触发事件的回调函数的参数中(backbone默认对3个参数及以下的情况下进行call调用,这种处理方式原因之一是call调用比apply调用的效率更高从而优先使用(关于call和apply的性能对比:<a href="https://jsperf.com/call-apply-segu">https://jsperf.com/call-apply-segu</a>),另外一方面源码中并没有超过三个参数的情况,所以用call支持到了三个参数,其余情况采用性能较差但是写起来方便的apply)。
另外值得一提的是,Events支持all事件,即如果你监听了all事件,那么任何事件的触发都会调用all事件的回调函数列。
关于trigger部分的源代码比较简单,并且我也增加了一些评注,这里就不贴代码了。
context 和 ctx
有心的朋友也许注意到,backbone在事件中用到了context和ctx这两个"貌似"表示当前上下文的对象,并且在如果有context的情况下,这两个几乎一样:
handlers.push({callback: callback, context: context, ctx: context || ctx, listening: listening});
这里我根据自己的理解,尽量解释一下。
我们可以主要看off方法及trigger方法,我们发现上面两属性在这两个方法中分别被使用了。
在off里需要对context进行比较决定是否要删除对应的事件,所以model._events中保存下来的context,必须是未做修改的。
而trigger里在执行回调函数时,需要指定其作用域,当绑定事件时没有给定作用域,则会使用被监听的对象当回调函数的作用域。
实际上,我觉得这个ctx有点多余,我们完全可以在trigger中这样写:
(ev = events[i]).callback.call(ev.context || ev.obj)
backbone Model
backbone的Model实际上是一个可供构造调用的函数,backbone采用污染原型的方式把定义好的属性都定义在了prototype上,这可能并不是一个非常妥当的做法,但是在backbone中这样做却是没有什么不可以的,这个我们在之后讲extend方法的时候会进行补充。
我们先看看这个函数在实例化的时候会做点什么:
var Model = Backbone.Model = function(attributes, options) {
var attrs = attributes || {};
options || (options = {});
//这个preinitialize函数实际上是为空的,可以给有兴趣的开发者重写这个函数,在初始化Model之前调用
this.preinitialize.apply(this, arguments);
//Model的唯一的id
this.cid = _.uniqueId(this.cidPrefix);
this.attributes = {};
if (options.collection) this.collection = options.collection;
//如果之后new的时候传入的是JSON,我们必须在options选项中声明parse为true
if (options.parse) attrs = this.parse(attrs, options) || {};
//_.result:如果指定的property的值是一个函数,那么将在object上下文内调用它;否则,返回它。如果提供默认值,并且属性不存在,那么默认值将被返回。如果设置defaultValue是一个函数,它的结果将被返回。
//这里调用_.result相当于给出了余地,自己写defaults的时候可以直接写一个对象,也可以写一个函数,通过return一个对象的方式把属性包含进去
var defaults = _.result(this, 'defaults');
//defaults应该是在Backbone.Model.extends的时候由用户添加的,用defaults对象填充object 中的undefined属性。 并且返回这个object。一旦这个属性被填充,再使用defaults方法将不会有任何效果。
attrs = _.defaults(_.extend({}, defaults, attrs), defaults);
this.set(attrs, options);
//存储历史变化记录
this.changed = {};
//这个initialize也是空的,给初始化之后调用
this.initialize.apply(this, arguments);
};
我们可以看出,this.attributes是存储实际内容的。
另外,preinitialize和initialize不仅在Model中有,在之后的Collection、View和Router中也都出现了,一个是在初始化前调
