函数
提示
函数是ECMAScript 中最有意思的部分之一,这主要是因为函数实际上是对象。每个函数都是Function类型的实例,而Function 也有属性和方法,跟其他引用类型一样。因为函数是对象,所以函数名就是指向函数对象的指针,而且不一定与函数本身紧密绑定。
# 函数名
ECMAScript 6 的所有函数对象都会暴露一个只读的name 属性,其中包含关于函数的信息。多数情况下,这个属性中保存的就是一个函数标识符,或者说是一个字符串化的变量名。即使函数没有名称,也会如实显示成空字符串。如果它是使用Function 构造函数创建的,则会标识成"anonymous":
function foo() {}
let bar = function() {};
let baz = () => {};
console.log(foo.name); // foo
console.log(bar.name); // bar
console.log(baz.name); // baz
console.log((() => {}).name); //(空字符串)
console.log((new Function()).name); // anonymous
2
3
4
5
6
7
8
如果函数是一个获取函数、设置函数,或者使用bind()实例化,那么标识符前面会加上一个前缀:
function foo() {}
console.log(foo.bind(null).name); // bound foo
let dog = {
years: 1,
get age() {
return this.years;
},
set age(newAge) {
this.years = newAge;
}
}
let propertyDescriptor = Object.getOwnPropertyDescriptor(dog, 'age');
console.log(propertyDescriptor.get.name); // get age
console.log(propertyDescriptor.set.name); // set age
2
3
4
5
6
7
8
9
10
11
12
13
14
# 函数内部
# caller
ECMAScript 5 也会给函数对象上添加一个属性:caller。虽然ECMAScript 3 中并没有定义,但所有浏览器除了早期版本的Opera 都支持这个属性。这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为null。
function outer() {
inner();
}
function inner() {
console.log(inner.caller);
}
outer();
2
3
4
5
6
7
以上代码会显示outer()函数的源代码。这是因为ourter()调用了inner(),inner.caller指向outer()。如果要降低耦合度,则可以通过arguments.callee.caller 来引用同样的值:
function outer() {
inner();
}
function inner() {
console.log(arguments.callee.caller);
}
outer();
2
3
4
5
6
7
在严格模式下访问arguments.callee 会报错。ECMAScript 5 也定义了arguments.caller,但在严格模式下访问它会报错,在非严格模式下则始终是undefined。这是为了分清arguments.caller和函数的caller 而故意为之的。而作为对这门语言的安全防护,这些改动也让第三方代码无法检测同一上下文中运行的其他代码。
严格模式下还有一个限制,就是不能给函数的caller 属性赋值,否则会导致错误。
# new.target
ECMAScript 中的函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。ECMAScript 6 新增了检测函数是否使用new 关键字调用的new.target 属性。如果函数是正常调用的,则new.target 的值是undefined;如果是使用new 关键字调用的,则new.target 将引用被调用的构造函数。
function King() {
if (!new.target) {
throw 'King must be instantiated using "new"'
}
console.log('King instantiated using "new"');
}
new King(); // King instantiated using "new"
King(); // Error: King must be instantiated using "new"
2
3
4
5
6
7
8
# 尾调用优化
ECMAScript 6 规范新增了一项内存管理优化机制,让JavaScript 引擎在满足条件时可以重用栈帧。具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。
function outerFunction() {
return innerFunction(); // 尾调用
}
2
3
在ES6 优化之前,执行这个例子会在内存中发生如下操作。 (1) 执行到outerFunction 函数体,第一个栈帧被推到栈上。 (2) 执行outerFunction 函数体,到return 语句。计算返回值必须先计算innerFunction。 (3) 执行到innerFunction 函数体,第二个栈帧被推到栈上。 (4) 执行innerFunction 函数体,计算其返回值。 (5) 将返回值传回outerFunction,然后outerFunction 再返回值。 (6) 将栈帧弹出栈外。 在ES6 优化之后,执行这个例子会在内存中发生如下操作。 (1) 执行到outerFunction 函数体,第一个栈帧被推到栈上。 (2) 执行outerFunction 函数体,到达return 语句。为求值返回语句,必须先求值innerFunction。 (3) 引擎发现把第一个栈帧弹出栈外也没问题,因为innerFunction 的返回值也是outerFunction的返回值。 (4) 弹出outerFunction 的栈帧。 (5) 执行到innerFunction 函数体,栈帧被推到栈上。 (6) 执行innerFunction 函数体,计算其返回值。 (7) 将innerFunction 的栈帧弹出栈外。
# 尾调用优化的条件
尾调用优化的条件就是确定外部栈帧真的没有必要存在了。涉及的条件如下:
- 代码在严格模式下执行;
- 外部函数的返回值是对尾调用函数的调用;
- 尾调用函数返回后不需要执行额外的逻辑;
- 尾调用函数不是引用外部函数作用域中自由变量的闭包。
下面展示了几个违反上述条件的函数,因此都不符号尾调用优化的要求:
"use strict";
// 无优化:尾调用没有返回
function outerFunction() {
innerFunction();
}
// 无优化:尾调用没有直接返回
function outerFunction() {
let innerFunctionResult = innerFunction();
return innerFunctionResult;
}
// 无优化:尾调用返回后必须转型为字符串
function outerFunction() {
return innerFunction().toString();
}
// 无优化:尾调用是一个闭包
function outerFunction() {
let foo = 'bar';
function innerFunction() {
return foo;
}
return innerFunction();
}
// 下面是几个符合尾调用优化条件的例子:
"use strict";
// 有优化:栈帧销毁前执行参数计算
function outerFunction(a, b) {
return innerFunction(a + b);
}
// 有优化:初始返回值不涉及栈帧
function outerFunction(a, b) {
if (a < b) {
return a;
}
return innerFunction(a + b);
}
// 有优化:两个内部函数都在尾部
function outerFunction(condition) {
return condition ? innerFunctionA() : innerFunctionB();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
差异化尾调用和递归尾调用是容易让人混淆的地方。无论是递归尾调用还是非递归尾调用,都可以应用优化。引擎并不区分尾调用中调用的是函数自身还是其他函数。不过,这个优化在递归场景下的效果是最明显的,因为递归代码最容易在栈内存中迅速产生大量栈帧。
# 闭包
闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。
函数执行时,每个执行上下文中都会有一个包含其中变量的对象。全局上下文中的叫变量对象,它会在代码执行期间始终存在。而函数局部上下文中的叫活动对象,只在函数执行期间存在。在定义compare()
函数时,就会为它创建作用域链,预装载全局变量对象,并保存在内部的[[Scope]]
中。在调用这个函数时,会创建相应的执行上下文,然后通过复制函数的[[Scope]]
来创建其作用域链。接着会创建函数的活动对象(用作变量对象)并将其推入作用域链的前端。
# this 对象
在闭包中使用this 会让代码变复杂。如果内部函数没有使用箭头函数定义,则this 对象会在运行时绑定到执行函数的上下文。如果在全局函数中调用,则this 在非严格模式下等于window,在严格模式下等于undefined。如果作为某个对象的方法调用,则this 等于这个对象。匿名函数在这种情况下不会绑定到某个对象,这就意味着this 会指向window,除非在严格模式下this 是undefined。不过,由于闭包的写法所致,这个事实有时候没有那么容易看出来。
在一些特殊情况下,this 值可能并不是我们所期待的值。比如下面这个修改后的例子:
window.identity = 'The Window';
let object = {
identity: 'My Object',
getIdentity () {
return this.identity;
}
};
2
3
4
5
6
7
getIdentity()
方法就是返回this.identity 的值。以下是几种调用object.getIdentity()的方式及返回值:
object.getIdentity(); // 'My Object'
(object.getIdentity)(); // 'My Object'
(object.getIdentity = object.getIdentity)(); // 'The Window'
2
3
第一行调用object.getIdentity()是正常调用,会返回"My Object",因为this.identity就是object.identity。第二行在调用时把object.getIdentity 放在了括号里。虽然加了括号之后看起来是对一个函数的引用,但this 值并没有变。这是因为按照规范,object.getIdentity 和(object.getIdentity)是相等的。第三行执行了一次赋值,然后再调用赋值后的结果。因为赋值表达式的值是函数本身,this 值不再与任何对象绑定,所以返回的是"The Window"。一般情况下,不大可能像第二行和第三行这样调用对象上的方法。但通过这个例子,我们可以知道,即使语法稍有不同,也可能影响this 的值。
# 内存泄露
由于IE 在IE9 之前对JScript 对象和COM对象使用了不同的垃圾回收机制(第4 章讨论过),所以闭包在这些旧版本IE 中可能会导致问题。在这些版本的IE 中,把HTML 元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁。来看下面的例子:
function assignHandler() {
let element = document.getElementById('someElement');
element.onclick = () => console.log(element.id);
}
2
3
4
以上代码创建了一个闭包,即element 元素的事件处理程序(事件处理程序将在第13 章讨论)。而这个处理程序又创建了一个循环引用。匿名函数引用着assignHandler()的活动对象,阻止了对element 的引用计数归零。只要这个匿名函数存在,element 的引用计数就至少等于1。也就是说,内存不会被回收。其实只要这个例子稍加修改,就可以避免这种情况,比如:
function assignHandler() {
let element = document.getElementById('someElement');
let id = element.id;
element.onclick = () => console.log(id);
element = null;
}
2
3
4
5
6
在这个修改后的版本中,闭包改为引用一个保存着element.id 的变量id,从而消除了循环引用。不过,光有这一步还不足以解决内存问题。因为闭包还是会引用包含函数的活动对象,而其中包含element。即使闭包没有直接引用element,包含函数的活动对象上还是保存着对它的引用。因此,必须再把element 设置为null。这样就解除了对这个COM 对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收。
# 立即调用的函数表达式
立即调用的匿名函数又被称作立即调用的函数表达式(IIFE,Immediately Invoked Function Expression)
使用IIFE 可以模拟块级作用域,即在一个函数表达式内部声明变量,然后立即调用这个函数。这样位于函数体作用域的变量就像是在块级作用域中一样。ECMAScript 5 尚未支持块级作用域,使用IIFE模拟块级作用域是相当普遍的。比如下面的例子:
# 私有变量
严格来讲,JavaScript 没有私有成员的概念,所有对象属性都公有的。不过,倒是有私有变量的概念。任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。私有变量包括函数参数、局部变量,以及函数内部定义的其他函数。
特权方法(privileged method)是能够访问函数私有变量(及私有函数)的公有方法。在对象上有两种方式创建特权方法。第一种是在构造函数中实现。
function MyObject() {
// 私有变量和私有函数
let privateVariable = 10;
function privateFunction() {
return false;
}
// 特权方法
this.publicMethod = function() {
privateVariable++;
return privateFunction();
};
}
2
3
4
5
6
7
8
9
10
11
12
这个模式是把所有私有变量和私有函数都定义在构造函数中。然后,再创建一个能够访问这些私有成员的特权方法。这样做之所以可行,是因为定义在构造函数中的特权方法其实是一个闭包,它具有访问构造函数中定义的所有变量和函数的能力。在这个例子中,变量privateVariable 和函数privateFunction()只能通过publicMethod()方法来访问。在创建MyObject 的实例后,没有办法直接访问privateVariable 和privateFunction(),唯一的办法是使用publicMethod()。
如下面的例子所示,可以定义私有变量和特权方法,以隐藏不能被直接修改的数据:
function Person(name) {
this.getName = function() {
return name;
};
this.setName = function (value) {
name = value;
};
}
let person = new Person('Nicholas');
console.log(person.getName()); // 'Nicholas'
person.setName('Greg');
console.log(person.getName()); // 'Greg'
2
3
4
5
6
7
8
9
10
11
12
# 静态私有变量
特权方法也可以通过使用私有作用域定义私有变量和函数来实现。这个模式如下所示:
(function() {
// 私有变量和私有函数
let privateVariable = 10;
function privateFunction() {
return false;
}
// 构造函数
MyObject = function() {};
// 公有和特权方法
MyObject.prototype.publicMethod = function() {
privateVariable++;
return privateFunction();
};
})();
2
3
4
5
6
7
8
9
10
11
12
13
14
# 模块模式
前面的模式通过自定义类型创建了私有变量和特权方法。而下面要讨论的Douglas Crockford 所说的模块模式,则在一个单例对象上实现了相同的隔离和封装。单例对象(singleton)就是只有一个实例的对象。按照惯例,JavaScript 是通过对象字面量来创建单例对象的,如下面的例子所示:
let singleton = {
name: value,
method: function() {
// ...
}
};
2
3
4
5
6
模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法。模块模式的样板代码如下:
let singleton = function() {
// 私有变量和私有函数
let privateVariable = 10;
function privateFunction() {
return false;
}
// 特权/公有方法和属性
return {
publicProperty: true,
publicMethod() {
privateVariable++;
return privateFunction();
}
};
}();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
模块模式使用了匿名函数返回一个对象。在匿名函数内部,首先定义私有变量和私有函数。之后,创建一个要通过匿名函数返回的对象字面量。这个对象字面量中只包含可以公开访问的属性和方法。因为这个对象定义在匿名函数内部,所以它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。本质上,对象字面量定义了单例对象的公共接口。如果单例对象需要进行某种初始化,并且需要访问私有变量时,那就可以采用这个模式:
let application = function() {
// 私有变量和私有函数
let components = new Array();
// 初始化
components.push(new BaseComponent());
// 公共接口
return {
getComponentCount() {
return components.length;
},
registerComponent(component) {
if (typeof component == 'object') {
components.push(component);
}
}
};
}();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 模块增强模式
另一个利用模块模式的做法是在返回对象之前先对其进行增强。这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。来看下面的例子:
let singleton = function() {
// 私有变量和私有函数
let privateVariable = 10;
function privateFunction() {
return false;
}
// 创建对象
let object = new CustomType();
// 添加特权/公有属性和方法
object.publicProperty = true;
object.publicMethod = function() {
privateVariable++;
return privateFunction();
};
// 返回对象
return object;
}();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
如果前一节的application 对象必须是BaseComponent 的实例,那么就可以使用下面的代码来创建它:
let application = function() {
// 私有变量和私有函数
let components = new Array();
// 初始化
components.push(new BaseComponent());
// 创建局部变量保存实例
let app = new BaseComponent();
// 公共接口
app.getComponentCount = function() {
return components.length;
};
app.registerComponent = function(component) {
if (typeof component == "object") {
components.push(component);
}
};
// 返回实例
return app;
}();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19