`

使用闭包构造模块(提高篇_实现jQuery)——Object-Oriented Javascript之五

 
阅读更多

通过前面两篇博文的积累,

使用闭包构造模块(基础篇)——Object-Oriented Javascript之三

使用闭包构造模块(优化篇)——Object-Oriented Javascript之四

我们现在已经具备了足够的知识,去完成一个比较有挑战性的任务——构造一个简化版的jQuery库——myQuery。

我选择去构造myQuery的动机是:

1 jQuery足够优秀,是模块封装的典范;

2 如果我们能够实现一个良好的jQuery模块,那么就意味着我们能够应付绝大多数的模块封装问题。

 

如果读者读过jQuery源码,本文依然能够为你带来一些价值,因为在本文中封装myQuery所使用的方法是与jQuery完全不同的,而且还实现了jQuery所没有做到的底层数据的私有性。

 

jQuery最大好处在于免除了开发人员学习那糟糕的DOM API。另外从编码的角度去看,jQuery的选择器和链式调用等,大大简化了代码。

为了实现以上的优点,jQuery将DOM元素和相应DOM操作封装成一个jQuery对象,使得我们能够舍弃糟糕的DOM API,而去拥抱简单易用的jQuery API

 

jQuery还有一个重要的特点,它是可以被动态扩展的,我们可以简单地添加自己的jQuery API/插件。为实现这一点,jQuery的数据和方法是“分开”定义的,方法的集合对象暴漏出去,让开发人员有机会往里面加自定义的内容。分开的数据和方法,最后以某种方式结合起来,统一为一个jQuery对象。方法可以不和数据耦合在一起,“分开”定义,听起来很不可思议,但是javascript利用this延迟绑定就能够做到这一点。我们可以在不同的地方分别定义数据和方法,然后可以利用 apply/call/bind/原型链 简单地将二者结合起来。这给人的感觉就像,可以简单地把任意的数据和方法拼成一个模块。

 

我们的myQuery将着眼于如何封装实现上面提及到jQuery的重要特点,需求如下:

1. selector支持#id和tagName

2. 支持text(),text(str),each(function(index,dom)), size()方法

3. 支持链式调用

4. 支持方法的扩展

尽管需求很少,但是在这些需求实现的背后,往往是我们封装模块的时候最可能遇到的问题,而且在这个简化版jQuery的基础上,完全可以扩展为一个完整的jQuery。 

 

目录:

私有模式实现myQuery

委托模式实现myQuery

 

私有模式实现myQuery

 

myQuery架构思想:

1. 底层数据:使用一个[],存放选择器选择出来的dom元素;

2. 对外方法:使用一个{},存放所有公用方法。

3. 关联数据与方法:调用$("xxx")时,dom元素数组和公用方法object关联起来构成一个myQuery对象。

 

对看过jQuery源码的同学说的话:jQuery底层数据其实是一个由dom元素构成的“类数组”,所有的方法都是操作这个类数组。但是jQuery的类数组不是私有的,可以被直接修改,例如$("div")[0] = document.getElementById("xxx")。我们可以使用前两章学过的闭包克服这个缺点,将dom数组隐藏起来,使外界完全不可能直接修改dom数组,仅仅能修改dom数组的成员,就是说类数组[div1,div2]中的div1、div2的属性可以改变,但是[div1,div2]不能变成[div1,div3]。

 

实现如下

<!DOCTYPE HTML>
<HTML>
<HEAD>
<TITLE>闭包实现myQuery</TITLE>
</HEAD>

<BODY>
<div id="lazy2009">hello,lazy2009!</div>
<div id="lazy_">hello,lazy_!</div>

<script>
	if (!Function.prototype.bind) {
		Function.prototype.bind = function(context) {
			var args = Array.prototype.slice.call(arguments, 1);
			var that = this;
			return function() {
				return that.apply(context, args.concat(Array.prototype.slice.call(arguments)));
			}
		}
	}

	(function(win) {
		var slice = Array.prototype.slice;

		//使用闭包实现的类似jQuery的封装
		//selector只支持#id和tagName两种选择器,例如$("#id"),$("div")
		var myQuery = function(selector) {
			//私有成员,不让外界直接修改
			var arrDom = [];
			//调用document.getElementById或者document.getElementsByTagName获取元素集合
			if (selector.charAt(0) === '#') {
				arrDom[0] = document.getElementById(selector.substring(1));
			} else {
				var elements = document.getElementsByTagName(selector);
				for ( var i = 0; i < elements.length; i++) {
					arrDom[i] = elements[i];
				}
			}
			var myQueryObj = {};
			//导入myQuery对象公用方法
			var methods = myQuery.methods;
			for ( var methodName in methods) {
				myQueryObj[methodName] = methods[methodName].bind(myQueryObj,
						arrDom);
			}
			return myQueryObj;
		};
		//myQuery导出到window全局作用域
		win.$ = win.myQuery = myQuery;

		//myQuery对象公用方法
		myQuery.methods = {
			version : function() {
				return "1.0";
			},
			text : function(arrDom, s) {
				if (!s) {
					return arrDom[0].innerText;
				} else {
					for ( var i = 0; i < arrDom.length; i++) {
						arrDom[i].innerText = s;
					}
				}
				return this;
			},
			each : function(arrDom, fn) {
				for ( var i = 0; i < arrDom.length; i++) {
					if (false === fn.call(arrDom[i], i, arrDom[i])) {
						break;
					}
				}
				return this;
			},
			get : function(arrDom, index) {
				if (!index) {
					return slice.call(arrDom);
				} else {
					return arrDom[i];
				}
			},
			size : function(arrDom) {
				return arrDom.length;
			}
		};
	})(window);
</script>
<script>
	//封装DOM API
	alert($("#lazy2009").text());
	alert($("#lazy_").text());
	//链式代码
	$("div").text("hello, world").each(function(i, o) {
		alert(o.innerText + "==" + this.innerText);
	});
	alert($("div").size());
	//扩展方法
	if (!myQuery.methods.toArray) {
		myQuery.methods.toArray = function(arrDom) {
			return arrDom.slice(0);
		}
	}
	alert($("div").toArray());
</script>
</BODY>
</HTML>

 

下面重点解析代码的几个地方

1. 防止过多的全局变量
    (function(win){...})(window) :杜绝全局变量污染
    win.$ = win.myQuery = myQuery : 仅仅把$、myQuery这两个元素导出到全局作用域,外部可以通过$.methods或者myQuery.methods扩展myQuery。
  
2. 选择器
var arrDom = [];
//调用document.getElementById或者document.getElementsByTagName获取元素集合
if (selector.charAt(0) === '#') {
    arrDom[0] = document.getElementById(selector.substring(1));
} else {
var elements = document.getElementsByTagName(selector);
for ( var i = 0; i < elements.length; i++) {
    arrDom[i] = elements[i];
}
}

var myQueryObj = {};
 
这个是简化的选择器,#代表通过id选择,其余表示通过TagName选择,远远没有jQuery强大。jQuery内部除了应用基本的getElementById和getElementsByTagName,还应用了Sizzle CSS Selector Engine,querySelector,querySelectorAll。虽然我们的选择器确实非常简陋,但是并不妨碍我们实现功能简单化的myQuery。
 
3. 关联私有数据与公用方法
var myQueryObj = {};
//导入myQuery对象公用方法
var methods = myQuery.methods;
for ( var methodName in methods) {
	myQueryObj[methodName] = methods[methodName].bind(myQueryObj,arrDom);
}
 
  这是最关键的一步,将dom数组与methods对象关联起来,完整地构造了myQuery对象。关联具体实现的方式是,使用一个for循环,把方法逐个导入到myQueryObj;每个循环中,将myQueryObj绑定到公用方法的this,并且把私有数据arrDom作为公用方法第一个参数。把arrDom作为传参是必要的的,因为arrDom不在myQueryObj里面,公用方法不能从this中访问arrDom,唯一的途径只能是从传参中获取arrDom。
 
3.1 如何保证数据私有?
a) 不把arrDom放到myQueryObj对象里。在funciton myQuery(){...}中, 如果把arrDom放到myQueryObj对象中,即myQueryObj={arrDom : arrDom},那么client就能通过$("div").arrDom直接获取了内部的私有数据并能随意修改。
b) 把arrDom放置到公用方法的第一个参数。 
把arrDom作为公用方法参数, 是不会存在被修改的风险的。
对于text、size方法,client调用这些方法的时候,是没有机会修改arrDom的。
对于each,虽然arrDom[i]绑定到回调函数的this,并且作为回调函数的参数,但是client依然是无法修改arrDom的,只能修改arrDom[i]本身的属性,而无法修改arrDom。
对于get,为了防止底层的arrDom被修改,调用slice克隆了一份arrDom,使client无法修改底层的arrDom。
也就是说,公用方法完全可以做到不把arrDom暴漏给client,能够保证arrDom是私有的。
c) 是否存在其他私有的实现方案?应该是有的,上面的方法只是笔者探索出来的一种方法,如果您有更好的方案,希望能够留言。
 
3.2 如何关联?
关联使用了bind函数。bind(context,arg1,arg2...)的功能是在原函数基础上产生一个新的函数,调用新函数时,this设置为context,arg1,arg2..插入到参数的最前面。bind函数一般用于回调函数,setTimeout,curry中。详情见https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind
 
myQueryObj[methodName] = methods[methodName].bind(myQueryObj,arrDom);这一行代码,通过两步完成了数据与方法的关联:
  • 第1步 使用bind"改造"公用方法,返回一个绑定了具体数据的新方法(this确定为myQueryObj,第一个参数确定为arrDom)。
                  改造后的方法,对比原来的公用方法,this变成了myQueryObj,并且省去了arrDom参数。方法可以被动态地改造为另一个方法,让人不得不赞叹bind的强大。
 
  • 第2步  将"改造"后的方法,设置到最终要导出的对象中
 
如果对bind不了解,可以使用apply实现等价的效果,但是代码就比较冗长了。
//导入myQuery对象公用方法
var methods = myQuery.methods;
for ( var methodName in methods) {
	myQueryObj[methodName] = (function() {
		var _methodName = methodName;//这一步的意义是什么?循环中访问闭包的变量要注意
		return function() {
			return methods[_methodName].apply(myQueryObj,
					[ arrDom ].concat(slice.call(arguments)));//闭包访问arrDom
		}
	})();
}
 
 
3.3 链式代码实现。
公用方法末尾return this.
 
3.4  myQuery可能有性能的问题。
每个通过$("xxx")产生的myQuery对象,都需要通过一个for循环来导入所有的公用方法,假如公用方法比较多,那么就可能会影响了生成对象的速度。 既然每一个myQuery对象所使用的方法都是一样,那么从直觉上,所有的myQuery对象都应该共用一个公用方法对象才合理,而不应该在每个myQuery对象生成的时候导入一次公用方法。
如何能够让所有的myQuery对象应该共用一个公用方法对象?最好的方案是原型链,只要每个myQuery对象把共用方法作为自己的原型对象,就肯定能解决myQuery的性能问题。但是使用原型链就必须放弃私有性,因为原型对象上的方法,只能通过this访问数据,真是鱼肉熊掌不可兼得。
从理论上,闭包是js实现私有性的唯一途径,而且原型对象的方法有不能访问私有变量,所以实现了私有性的模块必然要在每个生成的对象上使用O(n)(n为方法数量)时间设置所有的方法。对于想要封装具有私有数据的模块的读者,这篇文章应该能给予一些参考价值。最后,下面给出一个套用了这个“私有”模式的Counter的封装例子:
 
<!DOCTYPE HTML>
<HTML>
<HEAD>
<TITLE>"私有"模式实现Counter</TITLE>
</HEAD>

<BODY>

<script>
	if (!Function.prototype.bind) {
		Function.prototype.bind = function(context) {
			var args = Array.prototype.slice.call(arguments, 1);
			var that = this;
			return function() {
				return that.apply(context, args.concat(Array.prototype.slice
						.call(arguments)));
			}
		}
	}
	(function() {
		//定义方法
		var methods = {
			add : function(data) {
				data.i++;
				return this;
			},
			sub : function(data) {
				data.i--;
				return this;
			},
			get : function(data) {
				return data.i;
			}
		};
		function createCounter(num) {

			//定义返回对象
			var ret = {};

			//定义私有数据
			var data = {
				i : num||0
			};

			//关联数据与方法,构成一个模块
			for ( var methodName in methods) {
				ret[methodName] = methods[methodName].bind(ret, data);
			}
			//返回对象
			return ret;
		}
		window.createCounter = createCounter;
	})();

	var Counter = createCounter(1);
	alert(Counter.add().add().sub().get());//2
</script>
</BODY>
</HTML>
 

委托模式实现myQuery

 

有一种封装的方法,既能够做到底层数据是私有的,又能保证生成对象的高效,具体代码如下:

 

<!DOCTYPE HTML>
<HTML>
<HEAD>
<TITLE>闭包实现myQuery</TITLE>
</HEAD>

<BODY>
<div id="lazy2009">hello,lazy2009!</div>
<div id="lazy_">hello,lazy_!</div>

<script>
	(function(win) {
		var slice = Array.prototype.slice;

		//使用闭包实现的类似jQuery的封装
		//selector只支持#id和tagName两种选择器,例如$("#id"),$("div")
		var myQuery = function(selector) {
			//私有成员,不让外界直接修改
			var arrDom = [];
			//调用document.getElementById或者document.getElementsByTagName获取元素集合
			if (selector.charAt(0) === '#') {
				arrDom[0] = document.getElementById(selector.substring(1));
			} else {
				var elements = document.getElementsByTagName(selector);
				for ( var i = 0; i < elements.length; i++) {
					arrDom[i] = elements[i];
				}
			}
			var myQueryObj = {};
			//导入myQuery对象公用方法
			var methods = myQuery.methods;
			//myQuery对象执行exec方法,会根据methodName委托到真正要执行的函数
			myQueryObj.exec = function(methodName) {
				return methods[methodName].apply(myQueryObj, [ arrDom ]
						.concat(slice.call(arguments, 1)));
			}
			return myQueryObj;
		};
		//myQuery导出到window全局作用域
		win.$ = win.myQuery = myQuery;

		//myQuery对象公用方法
		myQuery.methods = {
			version : function() {
				return "1.0";
			},
			text : function(arrDom, s) {
				if (!s) {
					return arrDom[0].innerText;
				} else {
					for ( var i = 0; i < arrDom.length; i++) {
						arrDom[i].innerText = s;
					}
				}
				return this;
			},
			each : function(arrDom, fn) {
				for ( var i = 0; i < arrDom.length; i++) {
					if (false === fn.call(arrDom[i], i, arrDom[i])) {
						break;
					}
				}
				return this;
			},
			get : function(arrDom, index) {
				if (!index) {
					return slice.call(arrDom);
				} else {
					return arrDom[i];
				}
			},
			size : function(arrDom) {
				return arrDom.length;
			}
		};
	})(window);
</script>
<script>
	//封装DOM API
	alert($("#lazy2009").exec("text"));
	alert($("#lazy_").exec("text"));
	//链式代码
	$("div").exec("text", "hello, world").exec("each", function(i, o) {
		alert(o.innerText + "==" + this.innerText);
	});
	alert($("div").exec("size"));
	//扩展方法
	if (!myQuery.methods.toArray) {
		myQuery.methods.toArray = function(arrDom) {
			return arrDom.slice(0);
		}
	}
	alert($("div").exec("toArray"));
</script>
</BODY>
</HTML>
 这个myQuery实现与私有模式的唯一区别仅仅在于

 

 

                        //myQuery对象执行exec方法,会根据methodName委托到真正要执行的函数
			myQueryObj.exec = function(methodName) {
				return methods[methodName].apply(myQueryObj, [ arrDom ]
						.concat(slice.call(arguments, 1)));
			}
 

myQuery对象只有一个方法exec,这个方法的第一个参数是实际上要执行方法的名字,当执行exec时候,exec委托了第一个参数指定的方法去执行。execMethod接下来的其他参数是依次传递到要执行方法的参数。

这个实现版本确实满足了生成对象的高性能以及底层数据的私有性,但是缺点是调用语法不符合常规。jQuery easy ui采取了类似这样的模式来封装api,不过目的不是为了私有性,而是为了每一次方法调用都返回一个jQuery对象。例如$("div").datagrid('addRow',{xxx:xxx}).datagrid('deleteRow',3)。尽管直觉上$("div").getDatagrid().addRow({XXX:XXX}).deleteRow(3)更可读,但是中途getDatagrid()就不是返回一个jQuery对象了。委托模式除了调用语法有些别扭之外,其余在性能、扩展性、适应各种变态要求上等等方面是非常好的,大家可以将之作为一种候选的封装模式。

 

至此,闭包封装模块就暂告一段落了,关于闭包的应用,如果您有建议或问题,希望能在下方留言,谢谢。后面将介绍使用应用最广泛的new,prototype和Object.create来封装模块,并且实现另一个版本的myQuery。
4
2
分享到:
评论
4 楼 lazy_ 2013-02-20  
javabang 写道
基本还看不明白。

直接看可能看不明白,你试着自己写一个jQuery框架,并且DOM数组要是私有的,写着写着,估计你就能明白了本文的用意了。实践中遇到问题,再带着问题学习才有效率。

推荐你看一下

Javascript Secret Garden

深刻理解JavaScript基于原型的面向对象

可能对你的理解有帮助
3 楼 javabang 2013-02-20  
基本还看不明白。
2 楼 jiangwenxian 2013-02-19  
第一次接触,学习了。没想到以前不重视的js竟然能弄出这么高深的东西。
1 楼 敲代码的小北 2013-02-19  
Js,真是高深,解释性语言就是这么灵活,受教了,学习。

相关推荐

Global site tag (gtag.js) - Google Analytics