重学JavaScript深刻理解系列(五)

JavaScript深刻理解—-函数(Functions)

概要

本文将给你们介绍ECMAScript中的通常对象之一——函数。咱们将着重介绍不一样类型的函数以及不一样类型的函数是如何影响上下文的变量对象以及函数的做用域链的。 咱们还会解释常常会问到的问题,诸如:“不一样方式建立出来的函数会不同吗?(若是会,那么到底有什么不同呢?)”:html

var foo = function () {
  ...
};
复制代码

上述方式建立的函数和以下方式建立的有什么不一样?算法

function foo() {
  ...
}
复制代码

以下代码中,为啥一个函数要用括号包起来呢?express

(function () {
  ...
})();
复制代码

因为本文和此前几篇文章都是有关联的,所以,要想彻底搞懂这部份内容,建议先去阅读第二章-变量对象 以及第四章-做用域链数组

下面,来咱们先来介绍下函数类型。bash

函数类型

ECMAScript中包含三类函数,每一类都有各自的特性。

函数声明(Function Declaration)

函数声明(简称FD)是指这样的函数
  • 有函数名
  • 代码位置在:要么在程序级别或者直接在另一个函数的函数体(FunctionBody)中
  • 在进入上下文时建立出来的
  • 会影响变量对象
  • 是以以下形式声明的
function exampleFunc() {
  ...
}
复制代码

这类函数的主要特性是:只有它们能够影响变量对象(存储在上下文的VO中)。此特性同时也引出了很是重要的一点(变量对象的天生特性致使的) —— 它们在执行代码阶段就已经存在了(由于FD在进入上下文阶段就收集到了VO中)。ecmascript

下面是例子(从代码位置上来看,函数调用在声明以前 也就是函数提高):ide

foo();
 
function foo() {
  alert('foo');
}
复制代码

从定义中还提到了很是重要的一点 —— 函数声明在代码中的位置:函数

// 函数声明能够直接在程序级别的全局上下文中
function globalFD() {
  // 或者直接在另一个函数的函数体中
  function innerFD() {}
}
复制代码

除了上述提到了两个位置,其余位置均不能出现函数声明 —— 比方说,在表达式的位置或者是代码块中进行函数声明都是不能够的。post

介绍完了函数声明,接下来介绍函数表达式(function expression)。 测试

函数表达式

函数表达式(简称:FE)是指这样的函数:
  • 代码位置必需要在表达式的位置
  • 名字无关紧要
  • 不会影响变量对象
  • 在执行代码阶段建立出来

这类函数的主要特性是:它们的代码老是在表达式的位置。最简单的表达式的例子就是赋值表达式:

var foo = function () {
  ...
};
复制代码

上述例子中将一个匿名FE赋值给了变量“foo”,以后该函数就能够经过“foo”来访问了—— foo()。

正如定义中提到的,FE也能够有名字:

var foo = function _foo() {
  ...
};
复制代码

这里要注意的是,在FE的外部能够经过变量“foo”——foo()来访问,而在函数内部(好比递归调用),还能够用“_foo”(译者注:但在外部是没法使用“_foo”的)。

当FE有名字的时候,它很难和FD做区分。不过,若是仔细看这二者的定义的话,要区分它们仍是很容易的: FE老是在表达式的位置。 以下例子展现的各种ECMAScript表达式都属于FE:

// 在括号中(grouping operator)只多是表达式
(function foo() {});
 
// 在数组初始化中 —— 一样也只能是表达式
[function bar() {}];
 
// 逗号操做符也只能跟表达式
1, function baz() {};
复制代码

定义中还提到FE是在执行代码阶段建立的,而且不是存储在变量对象上的。以下所示:

// 不管是在定义前仍是定义后,FE都是没法访问的
// (由于它是在代码执行阶段建立出来的),
 
alert(foo); // "foo" is not defined
 
(function foo() {});
 
// 后面也没用,由于它根本就不在VO中
 
alert(foo);  // "foo" is not defined
复制代码

问题来了,FE要来干吗?其实答案是很明显的 —— 在表达式中使用,从而避免对变量对象形成“污染”。最简单的例子就是将函数做为参数传递给另一个函数:

function foo(callback) {
  callback();
}
 
foo(function bar() {
  alert('foo.bar');
});
 
foo(function baz() {
  alert('foo.baz');
});
复制代码

上述例子中,部分变量存储了对FE的引用,这样函数就会保留在内存中并在以后,能够经过变量来访问(由于变量是能够影响VO的):

var foo = function () {
  alert('foo');
};
 
foo();
复制代码

以下例子是经过建立一个封装的做用域来对外部上下文隐藏辅助数据(例子中咱们使用FE使得函数建立后就立马执行):

var foo = {};
 
(function initialize() {
 
  var x = 10;
 
  foo.bar = function () {
    alert(x);
  };
 
})();
 
foo.bar(); // 10;
 
alert(x); // "x" is not defined
复制代码

咱们看到函数“foo.bar”(经过其[[Scope]]属性)得到了对函数“initialize”内部变量“x”的访问。 而一样的“x”在外部就没法访问到。不少库都使用这种策略来建立“私有”数据以及隐藏辅助数据。一般,这样的状况下FE的名字都会省略掉:

(function () {
 
  // 初始化做用域
 
})();
复制代码

还有一个FE的例子是:在执行代码阶段在条件语句中建立FE,这种方式也不会影响VO:

var foo = 10;
 
var bar = (foo % 2 == 0
  ? function () { alert(0); }
  : function () { alert(1); }
);
bar(); // 0
复制代码

关于括号的问题

让咱们回到本文之初,来回答下此前提到的问题 —— “为何在函数建立以后当即进行函数调用时,须要用括号将其包起来?”。 要回答此问题,须要先介绍下关于表达式语句的限制。

标准中提到,表达式语句(ExpressionStatement)不能以左大括号{开始 —— 由于这样一来就和代码块冲突了, 也不能以function关键字开始,由于这样一来又和函数声明冲突了。比方说,以以下所示的方式来定义一个立马要执行的函数:

function () {
  ...
}();
 
// or with a name
 
function foo() {
  ...
}();
复制代码

对于这两种状况,解释器都会抛出错误,只是缘由不一样。

若是咱们是在全局代码(程序级别)中这样定义函数,解释器会以函数声明来处理,由于它看到了是以function开始的。 在第一个例子中,会抛出语法错误,缘由是既然是个函数声明,则缺乏函数名了(一个函数声明其名字是必须的)。

而在第二个例子中,看上去已经有了名字了(foo),应该会正确执行。然而,这里仍是会抛出语法错误 —— 组操做符内部缺乏表达式。 这里要注意的是,这个例子中,函数声明后面的()会被当组操做符来处理,而非函数调用的()。所以,若是咱们有以下代码:

// "foo" 是函数声明
// 而且是在进入上下文的时候建立的
 
alert(foo); // function
 
function foo(x) {
  alert(x);
}(1); // 这里只是组操做符,并不是调用!
 
foo(10); // 这里就是调用了, 10
复制代码

上述代码其实就是以下代码:

// function declaration
function foo(x) {
  alert(x);
}
 
// 含表达式的组操做符
(1);
 
// 另一个组操做符
// 包含一个函数表达式
(function () {});
 
// 这里面也是表达式
("foo");
 
// etc
复制代码

当这样的定义出如今语句位置时,也会发生冲突并产生语法错误:

if (true) function foo() {alert(1)}
复制代码

上述结构根据标准规定是不合法的。(表达式是不能以function关键字开始的),然而,正如咱们在后面要看到的,没有一种实现对其抛出错误, 它们各自按照本身的方式在处理。

讲了这么多,那究竟要怎么写才能达到建立一个函数后立马就进行调用的目的呢? 答案是很明显的。它必需要是个函数表达式,而不能是函数声明。而建立表达式最简单的方式就是使用上述提到的组操做符。由于在组操做符中只多是表达式。 这样一来解释器也不会纠结了,会果断将其以FE的方式来处理。这样的函数将在执行阶段建立出来,而后立马执行,随后被移除(若是有没有对其的引用的话):

(function foo(x) {
  alert(x);
})(1); // 好了,这样就是函数调用了,而再也不是组操做符了,1
复制代码

要注意的是,在下面的例子中,函数调用,其括号就再也不是必须的了,由于函数原本就在表达式的位置了,解释器天然会以FE来处理,而且会在执行代码阶段建立该函数:

var foo = {
 
  bar: function (x) {
    return x % 2 != 0 ? 'yes' : 'no';
  }(1)
 
};
 
alert(foo.bar); // 'yes'
复制代码

所以,对“括号有关”问题的完整的回答则以下所示:

若是要在函数建立后立马进行函数调用,而且函数不在表达式的位置时,
括号就是必须的 —— 这样状况下,实际上是手动的将其转换成了FE。
而当解释器直接将其以FE的方式处理的时候,说明FE自己就在函数表达式的位置 —— 这个时候括号就不是必须的了。
复制代码

另外,除了使用括号的方式将函数转换成为FE以外,还有其余的方式,以下所示:

1, function () {
  alert('anonymous function is called');
}();
 
// 或者这样
!function () {
  alert('ECMAScript');
}();
 
// 固然,还有其余不少方式
 
...
复制代码

不过,括号是最通用也是最优雅的方式。

顺便提下,组操做符既能够包含没有调用括号的函数,又能够包含有调用括号的函数,这二者都是合法的FE:

(function () {})();
(function () {}());
复制代码

实现扩展: 函数语句

看以下代码,符合标准的解释器都没法解释这样的代码:
if (true) {
 
  function foo() {
    alert(0);
  }
 
} else {
 
  function foo() {
    alert(1);
  }
 
}
 
foo(); // 1 仍是 0 ? 在不一样引擎中测试
复制代码

这里有必要提下:根据标准,上述代码结构是不合法的,由于,此前咱们就介绍过,函数声明是不能出如今代码块中的(这里if和else就包含代码块)。 此前提到的,函数声明只能出如今两个位置: 程序级别或者另一个函数的函数体中。 为何这种结构是错误的呢?由于在代码块中只容许语句。函数要想在这个位置出现的惟一可能就是要成为表达式语句。 可是,根据定义表达式语句又不能以左大括号开始(这样会与代码块冲突)也不能以function关键字开始(这样又会和FD冲突)。

然而,在错误处理部分,标准容许实现对程序语法进行扩展。而上述例子就是其中一种扩展。目前,全部的实现中都不会对上述状况抛出错误,都会以各自的方式进行处理。

所以根据标准,上述if-else中应当须要FE。然而,绝大多数实现中都在进入上下文的时候在这里简单地建立了FD,而且使用了最后一次的声明。 最后“foo”函数显示了1,尽管理论上else中的代码根本不会被执行到。

而SpiderMonkey(TraceMonkey也是)实现中,会将上述状况以两种方式来处理: 一方面它不会将这样的函数以函数声明来处理(也就意味着函数会在执行代码阶段才会建立出来), 然而,另一方面,它们又不属于真正的函数表达式,由于在没有括号的状况是不能做函数调用的(一样会有解析错误——和FD冲突),它们仍是存储在变量对象中。

我认为SpiderMonkey单独引入了本身的中间函数类型——(FE+FD),这样的作法是正确的。这样的函数会根据时间和对应的条件正确建立出来,不像FE。 和FD有点相似,能够在外部对其进行访问。SpiderMonkey将这种语法扩展命名为函数语句(Function Statement)(简称FS);这部分理论在MDN中有具体的介绍。 JavaScript的发明者 Brendan Eich也提到过这类函数类型。

有名字的函数表达式的特性(NFE)

当FE有名字以后(named function expression,简称:NFE),就产生了一个重要的特性。 正如在定义中提到的,函数表达式是不会影响上下文的变量对象的(这就意味着不管是在定义前仍是在定义后,都是不可能经过名字来进行调用的)。 然而,FE能够经过本身的名字进行递归调用:
(function foo(bar) {
 
  if (bar) {
    return;
  }
 
  foo(true); // "foo" name is available
 
})();
 
// but from the outside, correctly, is not
 
foo(); // "foo" is not defined
复制代码

这里“foo”这个名字究竟保存在哪里呢?在foo的活跃对象中吗?非也,由于在foo函数中根本就没有定义任何“foo”。 那么是在上层上下文的变量对象中吗?也不是,由于根据定义——FE是不会影响VO的——正如咱们在外层对其调用的结果所看到的那样。 那么,它究竟保存在哪里了呢?

不卖关子了,立刻来揭晓。当解释器在执行代码阶段看到了有名字的FE以后,它会在建立FE以前,建立一个辅助型的特殊对象,并把它添加到当前的做用域链中。 而后,再建立FE,在这个时候(根据第四章-做用域链描述的),函数拥有了[[Scope]]属性 —— 建立函数所在上下文的做用域链(这个时候,在[[Scope]]就有了那个特殊对象)。 以后,特殊对象中惟一的属性 —— FE的名字添加到了该对象中;其值就是对FE的引用。在最后,当前上下文退出的时候,就会把该特殊对象移除。 用伪代码来描述此算法就以下所示:

specialObject = {};
 
Scope = specialObject + Scope;
 
foo = FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}
 
delete Scope[0]; // 从做用域链的最前面移除specialObject
复制代码

这就是为何在函数外是没法经过名字访问到该函数的(由于它并不在上层做用域中存在),而在函数内部却能够访问到。

而这里要注意的一点是: 在某些实现中,好比Rhino,FE的名字并非保存在特殊对象中的,而是保存在FE的活跃对象中。 再好比微软的实现 —— JScript,则彻底破坏了FE的规则,直接将该名字保存在上层做用域的变量对象中了,这样在外部也能够访问到。

NFE和SpiderMonkey

说到实现,部分版本的SpiderMonkey有一个与上述提到的特殊对象相关的特性,这个特性也能够看做是个bug(既然全部的实现都是严格遵循标准的,那么这个就是标准的问题了)。 此特性和标识符处理相关: 做用域链的分析是二维的,在标识符查询的时候,还要考虑做用域链中每一个对象的原型链。

当在Object.prototype对象上定义一个属性,并将该属性值指向一个“根本不存在”的变量时,就可以体现该特性。 好比,以下例子中的变量“x”,在查询过程当中,经过做用域链,一直到全局对象也是找不到“x”的。 然而,在SpiderMonkey中,全局对象继承自Object.prototype,因而,对应的值就在该对象中找到了:

Object.prototype.x = 10;
 
(function () {
  alert(x); // 10
})();
复制代码

活跃对象是没有原型一说的。能够经过内部函数还证实。 若是在定义一个局部变量“x”并声明一个内部函数(FD或者匿名的FE),而后,在内部函数中引用变量“x”,这个时候该变量会在上层函数上下文中查询到(理应如此),而不是在Object.prototype中:

Object.prototype.x = 10;
 
function foo() {
 
  var x = 20;
 
  // function declaration 
 
  function bar() {
    alert(x);
  }
 
  bar(); // 20, from AO(foo)
 
  // the same with anonymous FE
 
  (function () {
    alert(x); // 20, also from AO(foo)
  })();
 
}
 
foo();
复制代码

在有些实现中,存在这样的异常:它们会在活跃对象设置原型。比方说,在Blackberry的实现中,上述例子中变量“x”值就会变成10。 由于,“x”从Object.prototype中就找到了:

AO(bar FD or anonymous FE) -> no ->
AO(bar FD or anonymous FE).[[Prototype]] -> yes - 10
复制代码

当出现有名字的FE的特殊对象的时候,在SpiderMonkey中也是有一样的异常。该特殊对象是常见对象 —— “和经过new Object()表达式产生的同样”。 相应地,它也应当继承自Object.prototype,上述描述只针对SpiderMonkey(1.7版本)。其余的实现(包括新的TraceMonkey)是不会给这个特殊对象设置原型的:

function foo() {
 
  var x = 10;
 
  (function bar() {
 
    alert(x); // 20, but not 10
    
    // "x" is resolved by the chain:
    // AO(bar) - no -> __specialObject(bar) -> no
    // __specialObject(bar).[[Prototype]] - yes: 20
 
  })();
}
 
Object.prototype.x = 20;
 
foo();
复制代码

NFE和JScript

微软的实现——JScript,是IE的JS引擎(截至本文撰写时最新是JScript5.8——IE8),该引擎与NFE相关的bug有不少。每一个bug基本上都和ECMA-262-3rd标准是彻底违背的。 有些甚至会引起严重的错误。

第一,针对上述这样的状况,JScript彻底破坏了FE的规则:不该当将函数名字保存在变量对象中的。 另外,FE的名字应当保存在特殊对象中,而且只有在函数自身内部才能够访问(其余地方均不能够)。而JScript却将其直接保存在上层上下文的变量对象中。 而且,JScript竟然还将FE以FD的方式处理,在进入上下文的时候就将其建立出来,并在定义以前就能够访问到:

// FE 保存在变量对象中
// 和FD同样,在定义前就能够经过名字访问到
testNFE();
 
(function testNFE() {
  alert('testNFE');
});
 
// 一样的,在定义以后也能够经过名字访问到
testNFE();
复制代码

正如你们所见,彻底破坏了FE的规则。

第二,在声明同时,将NFE赋值给一个变量的时候,JScript会建立两个不一样的函数对象。 这种行为感受彻底不符合逻辑(特别是考虑到在NFE外层,其名字根本是没法访问到的):

var foo = function bar() {
  alert('foo');
};
 
alert(typeof bar); // "function", NFE 有在VO中了 – 这里就错了
 
// 而后,还有更有趣的
alert(foo === bar); // false!
 
foo.x = 10;
alert(bar.x); // undefined
 
// 然而,两个函数彻底作的是一样的事情
 
foo(); // "foo"
bar(); // "foo"
复制代码

然而,要注意的是: 当将NFE和赋值给变量这两件事情分开的话(好比,经过组操做符),在定义好后,再进行变量赋值,这样,两个对象就相同了,返回true:

(function bar() {});
 
var foo = bar;
 
alert(foo === bar); // true
 
foo.x = 10;
alert(bar.x); // 10
复制代码

这个时候就好解释了。实施上,一开始的确建立了两个对象,不过以后就只剩下一个了。这里将NFE以FD的方式来处理,而后,当进入上下文的时候,FD bar就建立出来了。 在这以后,到了执行代码阶段,又建立出了第二个对象 —— FE bar,该对象不会进行保存。相应的,因为没有变量对其进行引用,随后FE bar对象就被移除了。 所以,这里就只剩下一个对象——FD bar对象,对该对象的引用就赋值给了foo变量。

第三,经过arguments.callee对一个函数进行间接引用,它引用的是和激活函数名一致的对象(事实上是——函数,由于有两个对象):

var foo = function bar() {
 
  alert([
    arguments.callee === foo,
    arguments.callee === bar
  ]);
 
};
 
foo(); // [true, false]
bar(); // [false, true]
复制代码

第四,JScript会将NFE以FD来处理,但当遇到条件语句又不遵循此规则了。好比说,和FD那样,NFE会在进入上下文的时候就建立出来,这样最后一次定义的就会被使用:

var foo = function bar() {
  alert(1);
};
 
if (false) {
 
  foo = function bar() {
    alert(2);
  };
 
}
bar(); // 2
foo(); // 1
复制代码

上述行为从逻辑上也是能够解释通的: 当进入上下文的时候,最后一次定义的FD bar被建立出来(有alert(2)的函数), 以后到了执行代码阶段又一个新的函数 —— FE bar被建立出来,对其引用赋值给了变量foo。所以(if代码块中因为判断条件是false,所以其代码块中的代码永远不会被执行到)foo函数的调用会打印出1。 尽管“逻辑上”是对的,可是这个仍然算是IE的bug。由于它明显就破坏了实现的规则,因此我这里用了引号“逻辑上”。

第五个JScript中NFE的bug和经过给一个未受限的标识符赋值(也就是说,没有var关键字)来建立全局对象的属性相关。 因为这里NFE会以FD的方式来处理,并相应地会保存在变量对象上,赋值给未受限的标识符(不是给变量而是给全局对象的通常属性), 当函数名和标识符名字相同的时候,该属性就不会是全局的了。

(function () {
 
  // 没有var,就不是局部变量,而是全局对象的属性
 
  foo = function foo() {};
 
})();
 
// 然而,在匿名函数的外层,foo又是不可访问的
 
alert(typeof foo); // undefined
复制代码

这里从“逻辑上”又是能够解释通的: 进入上下文时,函数声明在匿名函数本地上下文的活跃对象中。 当进入执行代码阶段的时候,由于foo这个名字已经在AO中存在了(本地),相应地,赋值操做也只是简单的对AO中的foo进行更新而已。 并无在全局对象上建立新的属性。

经过Function构造器建立的函数

这类函数有别于FD和FE,有本身的专属特性: 它们的[[Scope]]属性中只包含全局对象:
var x = 10;
 
function foo() {
 
  var x = 20;
  var y = 30;
 
  var bar = new Function('alert(x); alert(y);');
 
  bar(); // 10, "y" is not defined
 
}
复制代码

咱们看到bar函数的[[Scope]]属性并未包含foo上下文的AO —— 变量“y”是没法访问的,而且变量“x”是来自全局上下文。 顺便提下,这里要注意的是,Function构造器能够经过new关键字和省略new关键字两种用法。上述例子中,这两种用法都是同样的。

此类函数其余特性则和同类语法产生式以及联合对象有关。 该机制在标准中建议在做优化的时候采用(固然,具体的实现者也彻底有权利不使用这类优化)。比方说,有100元素的数组,在循环数组过程当中会给数组每一个元素赋值(函数), 这个时候,实现的时候就能够采用联合对象的机制了。这样,最终全部的数组元素都会引用同一个函数(只有一个函数)

var a = [];
 
for (var k = 0; k < 100; k++) {
  a[k] = function () {}; // 这里就可使用联合对象
}
复制代码

可是,经过Function构造器建立的函数就没法使用联合对象了:

var a = [];
 
for (var k = 0; k $lt; 100; k++) {
  a[k] = Function(''); // 只能是100个不一样的函数
}
复制代码

下面是另一个和联合对象相关的例子:

function foo() {
 
  function bar(z) {
    return z * z;
  }
 
  return bar;
}
 
var x = foo();
var y = foo();
复制代码

上述例子,在实现过程当中一样可使用联合对象。来使得x和y引用同一个对象,由于函数(包括它们内部的[[Scope]]属性)物理上是不可分辨的。 所以,经过Function构造器建立的函数老是会占用更多内存资源。

函数建立的算法

以下所示使用伪代码表示的函数建立的算法(不包含联合对象的步骤)。有助于理解ECMAScript中的函数对象。此算法对全部函数类型都是同样的。

F = new NativeObject();
 
// 属性 [[Class]] is "Function"
F.[[Class]] = "Function"
 
// 函数对象的原型
F.[[Prototype]] = Function.prototype
 
// 对函数自身引用
// [[Call]] 在函数调用时F()激活
// 同时建立一个新的执行上下文
F.[[Call]] = <reference to function>
 
// 内置的构造器
// [[Construct]] 会在使用“new”关键字的时候激活
// 事实上,它会为新对象申请内存
// 而后调用 F.[[Call]]来初始化建立的对象,将this值设置为新建立的对象
F.[[Construct]] = internalConstructor
 
// 当前上下文(建立函数F的上下文)的做用域名链
F.[[Scope]] = activeContext.Scope
// 若是是经过new Function(...)来建立的,则
F.[[Scope]] = globalContext.Scope
 
// 形参的个数
F.length = countParameters
 
// 经过F建立出来的对象的原型
__objectPrototype = new Object();
__objectPrototype.constructor = F // {DontEnum}, 在遍历中不能枚举
F.prototype = __objectPrototype
 
return F
复制代码

要注意的是,F.[[Prototype]]是函数(构造器)的原型,而F.prototype是经过该函数建立出来的对象的原型(由于一般对这两个概念都会混淆,在有些文章中会将F.prototype叫作“构造器的原型”,这是错误的)。

总结

本文介绍了不少关于函数的内容;不过在后面的关于对象和原型的文章中,还会提到函数做为构造器是如何工做的。

原文地址
译文地址

重学系列传送门

重学JavaScript深刻理解系列(一)
重学JavaScript深刻理解系列(二)
重学JavaScript深刻理解系列(三)
重学JavaScript深刻理解系列(四)
重学JavaScript深刻理解系列(六)

相关文章
相关标签/搜索