Javascript中的函数、对象与数据可见性
关键词:构造函数 原型 调用对象 词法作用域链 闭包
1.浅谈函数与对象
暂先来点基础性的回顾。
var rect = {
width : 10,
length : 20
}
以上通过直接量的方式定义了一个rect
对象,其中有两个属性width
和length
。
function area (width, length) {
return width * length;
}
以上通过直接量的方式定义了一个area
函数。使用这些对象和函数时,也很简单自然,比如:
var rectArea = area(rect.width, rect.length);
略微深究一下,我们都知道JS是一种基于对象的语言。我们创建的变量和函数通常都是作为对象的属性和方法的出现。比如,在浏览器环境中,以上定义的全局层面的对象和函数都成为了window
对象的属性和方法。
那我们也可以定义自己的既包括属性(property)又包括方法(method)的对象,比如:
var rect = {
width : 10,
length : 20,
area : function(){
return this.width * this.length;
}
}
想必大家也很熟悉这里关于this
的用法:在方法体中,用来调用方法的对象成为关键字this
的值。任何函数都会被有效地传递了一个隐式的参数,即调用函数的对象。
rect.area(); // ->200
关于函数如何调用,有的书籍或者说明介绍中会作以区别:
-
当函数并没有显式地通过某具体对象来调用时,那么这时往往称它为“函数”(function)。其实这时暗含了通过全局对象来进行调用,函数中的
this
也是对全局对象的引用。(浏览器环境中就是window
对象) -
当函数调用时通过“对象引用.函数名()”语法来进行时,那么这是往往称之为“方法”(method)。这时方法中的
this
就是调用方法的对象的引用。
很多地方将this
关键字引用的对象称之为执行上下文。
2.深入函数与对象
以上浅谈函数与对象基本概念,如果这一切你都觉得很简单朴素、浅显易懂。那么接下来的内容或许将完全颠覆你的最初印象。
除了基于对象的特征,JavaScript还是一种兼具函数式特征的程序设计语言。在 JavaScript 中,函数本身是一种特殊对象,属于顶层对象,不依赖于任何其他的对象而存在。事实上JS中所有的对象都派生于函数这个概念。
从我们最熟悉的一行代码开始:
var o = new Object(); // 等价于直接量写法 var o = {}
这行代码创建了一个空的对象,这个结果是毋庸置疑的。
那么熟悉OO语言的同学自然而然地认为Object
是一个类型名称标识符,比如类似于Java中的java.lang.Object
。使用new
运算符,针对一个类型定义来创建一个对象,看上去是那么地顺其自然。
但是,事实完全不是想象那样。Object
是一个函数名称标识符。更详细地说,它是JS内置的一个基础性函数,奠定JS中对象体系的一个基础(后续会讲这个基础到底是什么)。
那么将new
运算符作用于一个函数调用上,这背后暗含什么呢?
首先,当一个函数和new
运算符一起使用的时候,我们一般称这个函数为“构造函数”。顾名思义,这个函数将起到对象的构造作用。那么具体的构造过程如何进行?
此时可以先思考一个问题:前文提到,函数调用都是执行在某对象上下文中的。那么使用new
运算符来调用函数执行,又是何种效果呢?简而言之,new
运算符创建一个新没有任何属性的对象,然后调用构造函数,把新对象作为this
关键字的值来传递。
举个例子:
function Rect(length,width){
this.length = length;
this.width = width;
}
那么:
var r = new Rect(10,20);
的效果就是得到一个对象,包括width
与length
两个属性,值分别为20,10,即:
{
length:20,
width:10
}
需要注意的是:
- 如果构造函数没有返回值,那么
new
运算符的结果将返回为构造函数新创建的执行上下文对象。 - 如果构造函数有返回值,那么
new
运算符的结果将返回构造函数明确返回的值。
而对于Object
这一个构造函数而言,显然它并没有对传入其的this
引用作什么工作。所以当我们调用new Object()
的时候,得到是一个空对象。
这就是事情的全部了么?答案显然:并不是。
所有的函数都有一个prototype
属性,当这个函数被定义的时候,prototype
属性自动创建和初始化。prototype
属性的初始化值是一个对象,这个对象带有一个属性名为constructor
的属性,它指回函数(和prototype
相关联的那个构造函数)。
为了理解上段叙述,首先需要较真一下“对象”概念。在JS中“对象”可以看作是一种极度抽象的数据结构。除了基本的内置数据类型,比如数值、字符串等,其余的基本都是对象结构了。可以不严谨地简单归纳:对象是一种关于基本类型与自身的复合结构。
“函数”在JS中也是一种对象。(精确地说,较之于其它语言中的函数,除了可执行特征外,它更是一种数据对象)
JS在遇到函数定义时,就会创建函数对象,并为此对象初始化相关属性。prototype
就是这些初始化属性其中之一。prototype
引用了另外一个内部对象(JS自动创建的),那这个对象的情况就像之前所说,内含一个constructor
属性指回函数对象。
那么这一切有什么用呢?
JS并不是一个完备的面向对象的语言,至多是“基于对象”的。但即使这样,对象间的内在关系既是客观存在,也是语言必须要维护的。prototype
就是支撑此问题的关键机制。中文术语:原型。
如上图,矩形表示对象,不同颜色表示了不同种类的对象。蓝色是函数对象,绿色是原型对象,紫色是普通的客户对象。
函数对象和原型对象在函数定义时被创建,并建立了其间的相互关系。当使用构造函数来创建客户对象时,客户对象中会自动设置一个__proto__
的属性来引用对应的原型对象。那么这个引用具体起到什么作用呢?添加给原型对象的任何属性,都会成为被构造函数所初始化的对象的属性。
Rect.prototype.type = "BaseRect";
console.info(new Rect().type); // -> "BaseRect"
注意概念混淆,Rect.type
不会得到任何结果。
构造函数相关的原型对象是个单例对象,构造函数初始化的每个对象都确实从原型哪里继承了完全相同的一组属性。这意味着,原型对象是放置方法和其他不变属性的理想位置。原型对象与客户对象的关系可以被描述为一种继承关系。关于这种关系的读取和写入规则,这里不再赘述。(其中会有一些两个方向上的遮盖、隐藏什么的规则)
既然原型对象也是一种对象,它也可以再有自己的原型引用。那它引用的是谁呢?这时轮到Object
出场了。Object
作为构造函数,它不仅仅有自己的原型对象(Object.prototype),而且这个原型对象(Object.prototye)还是其它原型对象的原型对象。如下图所示:
可以看到图中关于原型对象的引用关系。很多地方都会说JS中Object对象是一种根对象,其实背后的实质意义就在于对象原型链中的引用关系。这种引用关系终结于Object.prototype
,它不再有更上层的原型引用。(这种现象的背后其实是,原型对象是由Object
构造函数创建的,只不过创建之后,constructor
属性将会指向确切关联的构造函数。比如示例图中Rect
对象的原型的constructor
就指向了Rect
函数。)
别忘了,“函数”也是对象!函数是不是也有原型引用?答案是肯定的!在示意图中用蓝色矩形表示的“函数对象”的构造函数是Function()
。它跟Object
类似,是JS内置的基础性的构造函数。它本身也遵循函数定义伴随原型对象的规则。单说它的话,如下图所示:
Function
有自己的原型引用,原型对象中也有constructor
指回Function
。而且该原型对象的上层原型同样也是Object.prototype
。
OK。“函数对象”本身的原型对象我们也就算是找到了。如下图所示:
至此,已经基本理清了JS中关于函数和对象的基本概念和体系。那么,这时再去理解“函数是一等公民”这句话,将是水到渠成。
- 函数定义将会维护相关原型对象
- 通过构造函数创建对象,并维护原型对象关系
- 最后,函数也是一种对象,是遵循对象体系规则的普通一员
3.可见性问题
这里要说的可见性主要包括两个方面,一个是从对象的继承体系,另一个是从程序变量的可访问性。 先说对象体系。从前一节内容中,我们了解到,JavaScript支持的是基于原型的继承机制,而不是基于类的继承机制。而且我们也了解到,对象如何从它们构造函数的原型对象中继承属性。这种基于原型继承并不限于一个单个的原型对象,相反,它包含了一个原型对象的链表。所以,new Rect()
对象就继承了Rect.prototype
和Object.prototype
的属性。当在new Rect()
对象中查询某个属性时,首先查询的是这个对象本身。如果在这个对象中没有发现要查询的属性,就查询Rect.prototype
对象。最后,如果在那个对象中还没有找到要查询的属性,就查询Object.prototype
对象。
前文的示例中,Rect
是Object
的直接子对象,然而,在需要的时候,它们也可能是任何其它对象的子对象。具体操作手法如下例所示:
function Rect(w,h){
this.width = w;
this.height = h;
}
Rect.prototype.area = function() { return this.width * this.height;}
function PositionedRect(x,y,w,h){
//一般通过此种方式便捷地按照父对象的逻辑来初始化子对象
Rect.call(this,w,h);
this.x = x;
this.y = y;
}
//缺省的是Object.prototype,所以这里要手动修改
PositionedRect.prototype = new Rect();
//已经在子对象中初始过的属性,这里没有必要了
delete PositionedRect.prototype.width;
delete PositionedRect.prototype.height;
//为原型对象增加一个指回构造函数的引用
PositionedRect.prototype.constructor = PositionedRect;
在这个原型链中,存在许多具体的访问和写入的优先级规则。在此不赘述此部分内容。
以上,从对象原型继承的角度简单讲述了对象成员的继承访问。那么程序中更广泛的存在着各种所谓的全局和局部变量,比如下面的代码:
var g = "hello";
function f(){
var l = "world";
console.info(g+l);
}
像变量g
和变量l
同我们之前讨论过的对象o
的属性i
之间有什么根本区别吗?答案是没有。在JS中,变量基本上和对象的属性是一样的。
全局变量与全局对象:
当JS的解释器开始运行时,它首先要做的事情之一就是在执行任何JS代码之前,创建一个全局对象(global object;在浏览器环境下就是window
对象)。这个对象的属性就是JS程序的全局变量;换句话讲就是,当生声明一个JS的全局变量时,实际上所做的是定义了该全局对象的一个属性。
局部变量与调用对象:
如果全局变量是特殊的全局对象的属性,那么局部变量又是什么呢?他们也是一个对象的属性,这个对象被称为“调用对象”。虽然调用对象的生命周期比全局对象的短,但是它们的用途是一样的。在执行一个函数时,函数的参数和局部变量是作为调用对象的属性而存储的。用一个完全独立的对象来存储局部变量使JS可以防止局部变量覆盖同名的全局变量的值。
在这里先铺垫了以上两类概念的目的是为了接下来讲述函数的词法作用域与闭包的概念。
JS中的函数是通过词法来划分作用域的,而不是动态地划分作用域的。这意味着,变量在定义它们的作用域里运行,而不是在执行它们的作用域里运行。简单说,JS在扫描到函数定义时,就确定了这个函数相关的作用域。
那么这个作用域是如果具体确定的呢?
在最顶级,作用域仅仅就是全局对象。比如下面的代码:
var g1 = "hi";
var g2 = "world";
console.info(g1 + g2); // ->hiworld
JS解释器在执行g1+g2
时,当前的有效作用域如下所示:
scope=[{
g1:"hi",
g2:"world"
}]
可以看到,scope
中就是仅仅全局对象的内容而已。(实际执行环境中全局对象中会有更多的东西,这里仅作示意)
再往下看,带有函数定义的代码:
var g1 = "hi";
var g2 = "world";
function f(){
var l = "hello";
console.info(l+g1);// ->helloworld
}
f();
那么,当解释器执行到f()
函数定义时,当前的作用域就被保存起来,并且被设置为函数的内部状态的一部分。也就是说,只包含全局对象的scope
这时被保存到f()
对象的定义中。(记得之前讲过,函数对象中将会有个prototype
属性引用其原型对象,还有个__proto__
属性引用Function.prototype
,这里又多了一个并不真实存在的概念属性<function scope>
保存了定义现场的作用域)
接下来,马上到了调用f()
的地方。这时,解释器首先将作用域设置为定义函数的时候起作用的那个作用域,这个在定义函数时就已经确定并保存了的,直接拿出来就好了。(在当前的示例代码中,这个被调用的函数作用域与执行作用域并没有发生实质切换,当然很多情况下都会切换)
然后,解释器在更新过的作用域的前面添加一个新的对象,这叫做调用对象(call object,ECMA Script规范使用的术语是激活对象,activation object)。调用对象用一个名为arguments
的属性来初始化,这个属性引用了函数的Arguments
对象。函数的命名的参数添加到调用对象里面。用var
语句声明的任何局部变量也都定义在这个对象中。效果如下所示:
scope=[
{
arguments:< Arguments >,
l:"hello"
},
{
g1:"hi",
g2:"world",
f:< function >
}]
可以看到,这时已经形成了一个作用域链。也可以看做是一个堆栈结构,其中元素具备搜索优先级,顶层的元素会隐藏底层的任何同名的属性。
接下来看一个层次略深的例子:(下例中仅作步骤和过程的示例,省略了一些内容)
var g1 = "hi";
var g2 = "world";
function f(){
var l = "hello";
function f0(greet){
console.info(greet + g1);
}
f0(l);// ->helloworld
}
f();
1、当解释器解释执行到f()
函数的定义时,为它准备了如下的作用域(保存在了函数定义中):
f_def_scope = [
{
g1:"hi",
g2:"world",
f: < function >
}
]
2、接下来,解释器执行到f()
调用时,变更当前的作用域链为如下:
//f_run_scope 等价于 f_def_scope.push({f_call_object});
f_run_scope = [
{
l:"hello",
f0:< function >
},
{
g1:"hi",
g2:"world",
f: < function >
}
]
其实就是在f_def_scope
的基础上加入了f()
函数的调用对象。
3、再接下来,解释器执行到了f0()
函数定义,为它准备如下的作用域(保存在了函数定义中):
//copy new
f0_def_scope = f_run_scope = [
{
l:"hello",
f0:< function >
},
{
g1:"hi",
g2:"world",
f: < function >
}
]
这个作用域就是f_run_scope
,其实就是当前执行中的作用域链。
4、然后,接下来到了f0()
函数调用处。这时解释器变更当前的作用域链为如下:
//f0_run_scope 等价于 f0_def_scope.push({f0_call_object});
f0_run_scope = [
{
greet:"hello"
},
{
l:"hello",
f0:< function >
},
{
g1:"hi",
g2:"world",
f: < function >
}
]
其实就是在f0_def_scope
的基础上加入了f0()
函数的调用对象。
包括最终退出回收,更完整地、更具象地描述上述过程,如下图所示:
再次总结一下:函数在它所定义的作用域中执行。当一个函数被调用的时候,就为它创建一个调用对象并放置到作用域链中。当该函数推出的时候,调用对象也从作用域链中移除。
前文已经明确说明,函数即是对象,那么当我们把嵌套的函数定义在外部引用时,会是什么情况呢?我们把代码稍微做下变更:
var g1 = "hi";
var g2 = "world";
function f(){
var l = "hello";
function f0(){
console.info(l + g2);
}
return f0;
}
var myf0 = f();
myf0(); // ->helloworld
按照同样的方法描述上述代码的执行过程中涉及的各种调用对象的生命周期如下:
可以清楚地看到,如果把对嵌套的函数的引用保存到一个全局作用域中,情况又不同了。使用嵌套的函数作为外围函数的返回值,或者把嵌套的函数存储为某个其他对象的属性,这时,有一个对嵌套的函数的函数的外部引用,并且嵌套的函数将它的引用保留给外围函数的调用对象。结果是,外围函数的一次特定调用的调用对象依然存在,(外围)函数的参数和局部变量的名字和值在这个对象中得以维持。JS代码不会以任何方式直接访问调用对象,但是,它所定义的属性是对嵌入函数任何调用的作用域链的一部分。(当然,如果一个外围函数存储了两个嵌入函数的全局引用,这两个嵌入函数共享同一个调用对象,并且,一个函数的一次调用所做出的改变对于另一个函数的调用来说也是可见的。)
JS函数是将要执行的代码以及执行这些代码的作用域构成的一个综合体。在计算机科学术语里,这种代码和作用域的综合体叫做闭包。所有的JS函数都是闭包。
============= 本文大量参考了《JavaScrit:The Definitive Guide》