JS界面框架的思考——控件的继承、组合和可见性

  JS框架的另一个重要任务,就是控件的访问控制,即通过怎样的方式访问一个控件,如何保证不丢失引用,以及如何确保控件可见性的合理和安全。

  控件的继承是一个额外的问题,因为控件公共的属性和方法太多是需要使用继承特性的一个重要原因,而另一个重要原因则是为了使用多态性带来的便利。JavaScript的语法决定了,在JavaScript中实现多重继承是可行的,多重继承写法可以有意识地写成类似接口继承的形式,避免混淆的对象引用。

  例如,一个Form控件,要如何控制允许出现在其中的控件类型呢?比较合理的做法就是,将文本框、单选框、提交按钮等抽象FormItem的基类即可。某种控件里,应该限定允许存在的控件类型。

  一般情况下理想情况是,组合关系不可以形成环,但是有时形成环是无法避免的,这时候框架必须小心避免实际控件的循环包含关系,否则将导致无穷递归包含的现象发生。因此,要由框架来限制环的形成(毕竟无穷递归的广义表只是理论上研究的数据结构)。

  站在安全可控的角度,我们不应该让使用者用new关键字来建立一个控件,使用new建立的控件意味着框架无法得知控件的引用情况,这对于面向对象的界面控制是一种灾难,因为既无法保证安全的可见性,也无法保证不发生引用丢失。不安全的可见性就意味着,框架将无法感知控件的更改,而丢失引用意味着一些DOM没有逻辑控件对象可以操控。

  因此,我们往往通过父对象的方法来创建一个子对象,这个子对象的可见性自然处于父对象之中,由框架感知、掌控其所有操作和引用计数。这时候程序员的设计方法就变成,先创建父对象,然后向其中添加子对象,与使用new关键字方式创建控件的常见实现正好相反。

  不过这自然会导致代码复用性的问题,因为基于这种方式就意味着无法直接使用已经创建好的控件。对于这个问题的解决方案是,重用用于创建这个控件的选项参数,而不是重用这个控件本身。

  站在消息传递的角度,控件产生的消息只能由其创建者捕获,父控件捕获消息后,自行处理或将其转发到合适的子控件的处理队列中。这种消息传递机制非常严格,类似于树形网络的消息传递,是非常严谨可靠的,但是不可避免对于开发效率有一定的影响。这种写法是有长远的好处的,因为如果程序员无法明确控件的父子关系,那么很有可能无法预料异常结果的发生。

JS界面框架的思考——控件逻辑结构与DOM的控制

  纯JS实现的Web界面框架,被认为是一种基于组件的管理方式,可以认为是在浏览器上近似实现桌面应用GUI的方式。Web组件的封装与桌面控件的实现不同,JS框架需要通过封装来做的,就是管理与其逻辑结构相关联的DOM元素;而这两者在思想上却具有相似的地方(这是一定的,因为本质上这是一种主动的模仿)。

  因此有以下几个问题需要关心:

  1. 封装必然导致的问题就是灵活性受限,提供的API不可能是完备的。用户如果跨越框架直接操纵DOM,将是非常不可靠的,框架的更新或者仅仅简单的界面风格切换,都可能导致用户的越级操作失效。因此,接口提供用户需要的尽可能多的功能,是非常必要的。
  2. 对于控件的操作,应该总是从逻辑对象入手,由框架操作DOM对象,就像Windows编程时程序员会得到控件句柄,而控件句柄永远是指向了该控件的逻辑结构所在的空间,而不是二维层缓冲区。因此,一个逻辑控件的构造与析构,与实际控件是否在缓冲区或被释放,是没有直接的联系的(对JS来说,就是与DOM对象的存在与否没有关系)。
  3. 理论上良好的实现是需要时才产生DOM,而不是在构造时就产生DOM,尽管由于浏览器的优化行为,构造一个不用显示的DOM,计算开销是比较小的,然而还是由于浏览器的优化行为(空间换时间),DOM往往必须占据不少内存空间,远大于控件逻辑对象。实际上程序员是比较习惯手动控制DOM的创建和移除,因此许多框架中程序员都需要明确向框架发出构造DOM的指示,这就要求程序员对与何时构造DOM有一个基本的考虑。
  4. 意图不明确的封装是糟糕的。如果在控件的逻辑结构上需要再进行一次封装,那么最好有明确的理由和完整的架构考虑,就像在Windows的C库上封装的MFC,甚至虚拟机。当然,使用MFC的程序员,往往不再同时直接接触Windows API,因为那样是被视为混乱的程序结构。然而在架构不明确的框架中,使用不同层次的框架往往是难免的,这样将同样产生不可靠的操作。另外,除了造成内聚性差以外,层次不明确导致开发人员也无法知道,使用哪一层的操作才是最优的,往往可能导致不同的实现,代码可读性也变差。
  5. 框架要提供必要的文档,帮助开发人员了解,对逻辑控件的操作,会如何影响用户交互。
  6. 不能够直接操纵逻辑对象的数据成员,因为直接操作数据成员一般都是不好的行为,而应该总是使用方法来进行调用,尽管JS语法上并没有可见性修饰符,但是从封装性的角度而言是不希望用户这样操作的。因此,对于可以修改的数据成员,总是应该提供修改/获取它们的值的方法。

JavaScript的this关键字

  和一些面向对象的语言一样,JavaScript也支持this关键字,顾名思义,this关键字指代本对象,在传统的面向对象语言中,this代表该方法所属的对象,或者该对象的指针。

  在JavaScript中没有类的概念,this方法的指代含义也有些许的变化,指代的是调用该方法的对象。

  还是以上次的程序作为例子:

 

var User = function() {
	this.username = 'username';
	this.password = 'password';
}

User.prototype.toString = function() {	
	return this.username + ':' + this.password;
};

  toString方法中就使用了this关键字来指代调用者本身,一般情况下,调用者和该方法的所属对象是一致的,这种情况下,就与其他语言没什么不同。不过,JavaScript中调用者和方法所属的对象并不一定是一致的,许多情况下与闭包特性结合,也能产生许多特殊的效果。下面是一个稍微复杂的例子。

 

var x = function() {
	this.show = function(msg, func, scope) {
		document.write(msg + " : " + this.value + '<br />');
		if (scope === undefined)
			scope = this;
		func.apply(scope, []);
		func.call(scope);
	}
};

var y = new x();
x.prototype.value = 4;

y.value = 5;

var z = new x();
z.value = 6;

var w = new x();
x.prototype.value = 7;

y.show('value is', function() {
	document.write('value in scope : ' + this.value + '<br />');
}, z);

y.show('value is', function() {
	document.write('value in scope : ' + this.value + '<br />');
}, x.prototype);

y.show('value is', function() {
	document.write('value in scope : ' + this.value + '<br />');
}, w);

  x构造函数定义了一个show方法,首先输出调用者的value值,然后如果scope没有传入,则定义为方法的调用者本身。

  随后出现了两个方法,分别为apply和call,这两个方法是所有function都具有的,作用是强制指定某对象作为该方法的调用者,两者除了调用格式略有不同,作用是相同的。这两句话以scope的身份调用了func。

  后面定义了y、z、w三个变量,都分别是x的实例,接着以y的身份调用了3次show方法,只有scope参数传入了不同的值。

  第一次传入了对象z,此时y作为show的调用者,而show中的参数匿名函数func的调用者却被设定为z,因此输出结果为:

 

value is : 5
value in scope : 6
value in scope : 6

  第二次传入x.prototype,原理如上面相同,show的func参数是由x.prototype调用的,因此输入结果为:

 

value is : 5
value in scope : 7
value in scope : 7

  第三次传入了对象w,原理也和上面相同,func的调用者是w,那么此时w的值是多少呢?我们在实例化w以后,我们修改了x.prototype.value的值,那么此时w的value应该是x.prototype.value修改前的值还是修改后的值呢?

  答案是修改后的值,结果是:

 

value is : 5
value in scope : 7
value in scope : 7

  因为w并没有显式定义value属性,因此w的value属性是位于其__proto__属性中,也就是x.prototype的引用,因此当x.prototype发生了改变,对于w也是会同时产生影响,具体关于prototype的原理和行为,请参考之前的其他文章。

 

 

JavaScript继承特性的一种实现

  上次讲到了JavaScript实现封装的特性,那么JavaScript能否模拟继承的特性呢?

var User = function() {
	this.username = 'username';
	this.password = 'password';
}

User.prototype.toString = function() {	
	return this.username + ':' + this.password;
};

  这个是上次所写的User的定义。现在我们要新建一个Student,使其具有User的所有属性,和就类似与继承特性。

var extend = function(derived, base) {
	baseObj = new base();
 	for (property in baseObj) {
 		derived.prototype[property] = baseObj[property];
 	}
}

var Student = function() {
	this.studentNumber = '11111';
}

extend(Student, User);

var student = new Student();

document.write(student);

  extend方法是为了模拟继承特性,函数体很简单,首先新建一个“基类”的对象,然后将“基类”对象中的所有属性,放到“派生类”的prototype中,就使得derived的对象可以访问到所有base对象具有的属性。

  实际操作时,我们首先创建一个Student,并且定义其相对与User不同的部分。随后调用extend函数进行“继承”,使其具有User构造的对象的所有属性。

  然后尝试使用Student来构造一个student对象。

JavaScript中prototype关键字的作用

  JavaScript并不是一种面向对象的程序设计语言,严格来讲,它并没有什么类的概念,顶多只是JSON对象而已,然而在JavaScript中有prototype这个关键字,利用它,我们可以间接地实现一些面向对象的特性。

var User = function() {
	this.username = 'username';
	this.password = 'password';
	this.toString = function() {	
		return this.username + ':' + this.password;
	}
}

var user = new User();
document.write(user + '<br />');

  这是一个很简单的封装实现,此时的User可以被视为一个“类”,这里还有一个方法叫做toString,实际上,在需要字符串的地方,对象的toString方法会自动被调用。

  如果在上面这个例子中,toString没有被定义在User里面,也可以通过prototype关键字来定义在外部,效果是等价的。

User.prototype.toString = function() {
	return this.username + ':' + this.password;
};

  如果在这个例子中没有写prototype关键字,那么user对象实际上是没有toString这个函数可以调用的,因为没有prototype代表这个toString函数仅被定义在User本身之上。

  这是什么原理呢?

  首先,使用new关键字进行新对象user的构造时,是调用了上面赋值给User的匿名函数,这个函数赋予了新对象user一些属性,就像在函数体中所写的那样。如果toString没有被定义在里面,那么此时user只有username和password两个属性。

  如果在外部定义的toString没有加上prototype关键字,那么会发生什么情况呢?User对象具有了toString方法,然而User本身却没有username和password。因此,此时User的username与password均为undefined,有没有觉得比较像面向对象语言中的静态方法呢?是的,相同点在于:我们可以通过“类名”直接调用该方法,该方法不能使用“非静态”成员。

  如果在toString被定义在User.prototype之下,user对象就会具有toString方法,这是为什么呢?因为每个对象都隐含了一个名为__proto__的属性,相当于在User构造函数的最后增加了:

this.__proto__ = User.prototype; //注意,这个__proto__是内部名称,不同浏览器实现不同名称也可能不相同。

  prototype这个特殊的属性在构造函数定义时被初始化,初始化时包含了User的构造函数本身和自己的__proto__属性,当用户通过User.prototype的方式定义新属性或方法的时候,这些属性或方法就被增加到User.prototype这个JSON对象之中。由于this.__proto__ = User.prototype;的缘故,调用这个构造函数构造的所有对象的__proto__属性中,都包含有这个新属性或方法。因此,实际上toString并没有成为user下的一个方法,而是user.__proto__下的一个方法。

  然后,实际执行user.toString()时,浏览器将检查user是否具有toString方法可以调用,如果没有,浏览器将继续检查其__proto__属性中是否有toString方法,如果仍然没有,则继续递归向上寻找这个__proto__属性的__proto__属性。如此,就可以解释为什么prototype下定义的方法,都可以被该“类”的所有实例调用的原因了。

关于JavaScript匿名函数与闭包的一些解释

  JavaScript作为一种非常奇特的动态脚本语言,具有一些特有的语法特性,其中匿名函数和闭包特性可以说很大程度上区别了JavaScript程序设计与其他传统语言程序设计的方法。

匿名函数:

var x = function() {
	document.write('hello world!');
};

x();

  上面这个例子是一个最简单的匿名函数,显而易见的事实有三点:第一,函数没有被命名;第二,函数以对象的形式赋值给变量x;第三,我们也可以通过变量名x来调用这个函数。这使得函数能够非常方便地以参数的形式进行传递。

闭包:

  关于闭包特性有许多复杂的解释,但是为了便于理解,简单来说就是,一个变量的作用域或者生命期,与其所在的源代码中的位置有关,而不是与运行时的上下文相关。下面是一个关于闭包的小例子(来自维基百科):

function derivative(f,  dx) {
	return  function (x) {
		return (f(x + dx) - f(x)) / dx;
	};
}

var result = derivative(function(x) {
	return x * x;
}, 0.000001)(5);

document.write(result + '<br />');

  上面的代码实际上是在求函数某一点上的近似的导数值。

  derivative这个函数接受两个参数,f应该是接受一个数值型变量的函数,dx应该是一个很接近0的浮点数。然而derivative实际上并没有返回一个确切的导数值,因为并没有得到需要求解的确切的横坐标的值,因此返回了原函数f的导函数。请注意,有一个只接受一个参数x的匿名函数作为返回值被derivative函数返回了。

  下面定义了result变量,首先调用derivative求导函数,需要传入两个参数,f传入了一个匿名函数x平方,dx按照要求传入一个很小的值0.000001(你也可以取得更大一些或更小一些)。接着应该就得到了一个导函数,再为导函数传入常量参数5,得到了x平方在x=5这一点上的近似导数值,输出的结果非常接近10,也是我们期待的结果。

  接下来反过来看一看函数执行的实际过程。

  1. derivative函数被定义。
  2. derivative被调用,此时derivative函数得到的实参分别是f=function(x) {return x * x;}和dx=0.000001。
  3. 函数返回一个函数function (x) { return (f(x + dx) - f(x)) / dx; }。
  4. 随后调用这个刚刚被返回的函数,实参是x=5。请注意,按照传统语言的变量作用域,此时derivative函数应当早已退出堆栈,其参数f和dx的值应当早已不在内存,而在JavaScript中,此时f和dx的值却仍然保留为derivative函数被调用时的值,仅因为这个匿名函数被写在了derivative函数的内部。