10.10 函数属性与方法
前面提到过,ECMAScript中的函数是对象,因此有属性和方法。每个函数都有两个属性:length和prototype。其中,length属性保存函数定义的命名参数的个数,如下例所示:
function sayName(name) {
console.log(name);
}
function sum(num1, num2) {
return num1 + num2;
}
function sayHi() {
console.log("hi");
}
console.log(sayName.length); // 1
console.log(sum.length); // 2
console.log(sayHi.length); // 0
以上代码定义了3个函数,每个函数的命名参数个数都不一样。sayName()函数有1个命名参数,所以其length属性为1。类似地,sum()函数有两个命名参数,所以其length属性是2。而sayHi()没有命名参数,其length属性为0。
prototype属性也许是ECMAScript核心中最有趣的部分。prototype是保存引用类型所有实例方法的地方,这意味着toString()、valueOf()等方法实际上都保存在prototype上,进而由所有实例共享。这个属性在自定义类型时特别重要。在ECMAScript 5中,prototype属性是不可枚举的,因此使用for-in循环不会返回这个属性。
函数还有两个方法:apply()和call()。这两个方法都会以指定的this值来调用函数,即会设置调用函数时函数体内this对象的值。apply()方法接收两个参数:函数内this的值和一个参数数组。第二个参数可以是Array的实例,但也可以是arguments对象。来看下面的例子:
function sum(num1, num2) {
return num1 + num2;
}
function callSum1(num1, num2) {
return sum.apply(this, arguments); // 传入arguments对象
}
function callSum2(num1, num2) {
return sum.apply(this, [num1, num2]); // 传入数组
}
console.log(callSum1(10, 10)); // 20
console.log(callSum2(10, 10)); // 20
在这个例子中,callSum1()会调用sum()函数,将this作为函数体内的this值(这里等于window,因为是在全局作用域中调用的)传入,同时还传入了arguments对象。callSum2()也会调用sum()函数,但会传入参数的数组。这两个函数都会执行并返回正确的结果。
注意 在严格模式下,调用函数时如果没有指定上下文对象,则this值不会指向window。除非使用apply()或call()把函数指定给一个对象,否则this的值会变成undefined。
call()方法与apply()的作用一样,只是传参的形式不同。第一个参数跟apply()一样,也是this值,而剩下的要传给被调用函数的参数则是逐个传递的。换句话说,通过call()向函数传参时,必须将参数一个一个地列出来,比如:
function sum(num1, num2) {
return num1 + num2;
}
function callSum(num1, num2) {
return sum.call(this, num1, num2);
}
console.log(callSum(10, 10)); // 20
这里的callSum()函数必须逐个地把参数传给call()方法。结果跟
apply()的例子一样。到底是使用apply()还是call(),完全取决于怎么
给要调用的函数传参更方便。如果想直接传arguments对象或者一个数
组,那就用apply();否则,就用call()。当然,如果不用给被调用的
函数传参,则使用哪个方法都一样。
apply()和call()真正强大的地方并不是给函数传参,而是控制函数调用上下文即函数体内this值的能力。考虑下面的例子:
window.color = 'red';
let o = {
color: 'blue'
};
function sayColor() {
console.log(this.color);
}
sayColor(); // red
sayColor.call(this); // red
sayColor.call(window); // red
sayColor.call(o); // blue
这个例子是在之前那个关于this对象的例子基础上修改而成的。同样,sayColor()是一个全局函数,如果在全局作用域中调用它,那么会显示"red"。这是因为this.color会求值为window.color。如果在全局作用域中显式调用sayColor.call(this)或者sayColor.call(window),则同样都会显示"red"。而在使用sayColor.call(o)把函数的执行上下文即this切换为对象o之后,结果就变成了显示"blue"了。
使用call()或apply()的好处是可以将任意对象设置为任意函数的作用域,这样对象可以不用关心方法。在前面例子最初的版本中,为切换上下文需要先把sayColor()直接赋值为o的属性,然后再调用。而在这个修改后的版本中,就不需要这一步操作了。
ECMAScript 5出于同样的目的定义了一个新方法:bind()。bind()方法会创建一个新的函数实例,其this值会被绑定到传给bind()的对象。比如:
window.color = 'red';
var o = {
color: 'blue'
};
function sayColor() {
console.log(this.color);
}
let objectSayColor = sayColor.bind(o);
objectSayColor(); // blue
这里,在sayColor()上调用bind()并传入对象o创建了一个新函数objectSayColor()。objectSayColor()中的this值被设置为o,因此直接调用这个函数,即使是在全局作用域中调用,也会返回字符串"blue"。
对函数而言,继承的方法toLocaleString()和toString()始终返回函数的代码。返回代码的具体格式因浏览器而异。有的返回源代码,包含注释,而有的只返回代码的内部形式,会删除注释,甚至代码可能被解释器修改过。由于这些差异,因此不能在重要功能中依赖这些方法返回的值,而只应在调试中使用它们。继承的方法valueOf()返回函数本身。
10.11 函数表达式
函数表达式虽然更强大,但也更容易让人迷惑。我们知道,定义函数有两种方式:函数声明和函数表达式。函数声明是这样的:
function functionName(arg0, arg1, arg2) {
// 函数体
}
函数声明的关键特点是函数声明提升,即函数声明会在代码执行之前获得定义。这意味着函数声明可以出现在调用它的代码之后:
sayHi();
function sayHi() {
console.log("Hi!");
}
这个例子不会抛出错误,因为JavaScript引擎会先读取函数声明,然后再执行代码。
第二种创建函数的方式就是函数表达式。函数表达式有几种不同的形式,最常见的是这样的:
let functionName = function(arg0, arg1, arg2) {
// 函数体
};
函数表达式看起来就像一个普通的变量定义和赋值,即创建一个函数再把它赋值给一个变量functionName。这样创建的函数叫作匿名函数(anonymous funtion),因为function关键字后面没有标识符。(匿名函数有也时候也被称为兰姆达函数)。未赋值给其他变量的匿名函数的name属性是空字符串。
函数表达式跟JavaScript中的其他表达式一样,需要先赋值再使用。下面的例子会导致错误:
sayHi(); // Error! function doesn't exist yet
let sayHi = function() {
console.log("Hi!");
};
理解函数声明与函数表达式之间的区别,关键是理解提升。比如,以下代码的执行结果可能会出乎意料:
// 千万别这样做!
if (condition) {
function sayHi() {
console.log('Hi!');
}
} else {
function sayHi() {
console.log('Yo!');
}
}
这段代码看起来很正常,就是如果condition为true,则使用第一个sayHi()定义;否则,就使用第二个。事实上,这种写法在ECAMScript中不是有效的语法。JavaScript引擎会尝试将其纠正为适当的声明。问题在于浏览器纠正这个问题的方式并不一致。多数浏览器会忽略condition直接返回第二个声明。Firefox会在condition为true时返回第一个声明。这种写法很危险,不要使用。不过,如果把上面的函数声明换成函数表达式就没问题了:
// 没问题
let sayHi;
if (condition) {
sayHi = function() {
console.log("Hi!");
};
} else {
sayHi = function() {
console.log("Yo!");
};
}
这个例子可以如预期一样,根据condition的值为变量sayHi赋予相应的函数。
创建函数并赋值给变量的能力也可以用于在一个函数中把另一个函数当作值返回:
function createComparisonFunction(propertyName) {
return function(object1, object2) {
let value1 = object1[propertyName];
let value2 = object2[propertyName];
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
};
}
这里的createComparisonFunction()函数返回一个匿名函数,这个匿名函数要么被赋值给一个变量,要么可以直接调用。但在createComparisonFunction()内部,那个函数是匿名的。任何时候,只要函数被当作值来使用,它就是一个函数表达式。本章后面会介绍,这并不是使用函数表达式的唯一方式。
10.12 递归
递归函数通常的形式是一个函数通过名称调用自己,如下面的例子所示:
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
}
}
这是经典的递归阶乘函数。虽然这样写是可以的,但如果把这个函数赋值给其他变量,就会出问题:
let anotherFactorial = factorial;
factorial = null;
console.log(anotherFactorial(4)); // 报错
这里把factorial()函数保存在了另一个变量anotherFactorial中,然后将factorial设置为null,于是只保留了一个对原始函数的引用。而在调用anotherFactorial()时,要递归调用factorial(),但因为它已经不是函数了,所以会出错。在写递归函数时使用arguments.callee可以避免这个问题。
arguments.callee就是一个指向正在执行的函数的指针,因此可以在函数内部递归调用,如下所示:
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * arguments.callee(num - 1);
}
}
像这里加粗的这一行一样,把函数名称替换成arguments.callee,可以确保无论通过什么变量调用这个函数都不会出问题。因此在编写递归函数时,arguments.callee是引用当前函数的首选。
不过,在严格模式下运行的代码是不能访问arguments.callee的,因为访问会出错。此时,可以使用命名函数表达式(named function expression)达到目的。比如:
const factorial = (function f(num) {
if (num <= 1) {
return 1;
} else {
return num * f(num - 1);
}
});
这里创建了一个命名函数表达式f(),然后将它赋值给了变量factorial。即使把函数赋值给另一个变量,函数表达式的名称f也不变,因此递归调用不会有问题。这个模式在严格模式和非严格模式下都可以使用。
10.13 尾调用优化
ECMAScript 6规范新增了一项内存管理优化机制,让JavaScript引擎在满足条件时可以重用栈帧。具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。比如:
function outerFunction() {
return innerFunction(); // 尾调用
}
在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的栈帧弹出栈外。
很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。这就是ES6尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。
注意 现在还没有办法测试尾调用优化是否起作用。不过,因为这是ES6规范所规定的,兼容的浏览器实现都能保证在代码满足条件的情况下应用这个优化。
10.13.1 尾调用优化的条件
尾调用优化的条件就是确定外部栈帧真的没有必要存在了。涉及的条件如下:
- 代码在严格模式下执行;
- 外部函数的返回值是对尾调用函数的调用;
- 尾调用函数返回后不需要执行额外的逻辑;
- 尾调用函数不是引用外部函数作用域中自由变量的闭包。
下面展示了几个违反上述条件的函数,因此都不符合尾调用优化的要求:
"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();
}
差异化尾调用和递归尾调用是容易让人混淆的地方。无论是递归尾调用还是非递归尾调用,都可以应用优化。引擎并不区分尾调用中调用的是函数自身还是其他函数。不过,这个优化在递归场景下的效果是最明显的,因为递归代码最容易在栈内存中迅速产生大量栈帧。
注意 之所以要求严格模式,主要因为在非严格模式下函数调用中允许使用f.arguments和f.caller,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。
10.13.2 尾调用优化的代码
可以通过把简单的递归函数转换为待优化的代码来加深对尾调用优化的理解。下面是一个通过递归计算斐波纳契数列的函数:
function fib(n) {
if (n < 2) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
console.log(fib(0)); // 0
console.log(fib(1)); // 1
console.log(fib(2)); // 1
console.log(fib(3)); // 2
console.log(fib(4)); // 3
console.log(fib(5)); // 5
console.log(fib(6)); // 8
显然这个函数不符合尾调用优化的条件,因为返回语句中有一个相加的操作。结果,fib(n)的栈帧数的内存复杂度是O(2^n) 。因此,即使这么一个简单的调用也可以给浏览器带来麻烦:
fib(1000);
当然,解决这个问题也有不同的策略,比如把递归改写成迭代循环形式。不过,也可以保持递归实现,但将其重构为满足优化条件的形式。为此可以使用两个嵌套的函数,外部函数作为基础框架,内部函数执行递归:
"use strict";
// 基础框架
function fib(n) {
return fibImpl(0, 1, n);
}
// 执行递归
function fibImpl(a, b, n) {
if (n === 0) {
return a;
}
return fibImpl(b, a + b, n - 1);
}
这样重构之后,就可以满足尾调用优化的所有条件,再调用fib(1000)就不会对浏览器造成威胁了。
10.14 闭包
匿名函数经常被人误认为是闭包(closure)。闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。比如,下面是之前展示的createComparisonFunction()函数,注意其中加粗的代码:
function createComparisonFunction(propertyName) {
return function(object1, object2) {
let value1 = object1[propertyName];
let value2 = object2[propertyName];
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
};
}
这里加粗的代码位于内部函数(匿名函数)中,其中引用了外部函数的变量propertyName。在这个内部函数被返回并在其他地方被使用后,它仍然引用着那个变量。这是因为内部函数的作用域链包含createComparisonFunction()函数的作用域。要理解为什么会这样,可以想想第一次调用这个函数时会发生什么。
理解作用域链创建和使用的细节对理解闭包非常重要。在调用一个函数时,会为这个函数调用创建一个执行上下文,并创建一个作用域链。然后用arguments和其他命名参数来初始化这个函数的活动对象。外部函数的活动对象是内部函数作用域链上的第二个对象。这个作用域链一直向外串起了所有包含函数的活动对象,直到全局执行上下文才终止。
在函数执行时,要从作用域链中查找变量,以便读、写值。来看下面的代码:
function compare(value1, value2) {
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
}
let result = compare(5, 10);
这里定义的compare()函数是在全局上下文中调用的。第一次调用compare()时,会为它创建一个包含arguments、value1和value2的活动对象,这个对象是其作用域链上的第一个对象。而全局上下文的变量对象则是compare()作用域链上的第二个对象,其中包含this、result和compare。下图展示了以上关系。
函数执行时,每个执行上下文中都会有一个包含其中变量的对象。全局上下文中的叫变量对象,它会在代码执行期间始终存在。而函数局部上下文中的叫活动对象,只在函数执行期间存在。在定义compare()函数时,就会为它创建作用域链,预装载全局变量对象,并保存在内部的
[[Scope]]中。在调用这个函数时,会创建相应的执行上下文,然后通过复制函数的[[Scope]]来创建其作用域链。接着会创建函数的活动对象(用作变量对象)并将其推入作用域链的前端。在这个例子中,这意味着compare()函数执行上下文的作用域链中有两个变量对象:局部变量对象和全局变量对象。作用域链其实是一个包含指针的列表,每个指针分别指向一个变量对象,但物理上并不会包含相应的对象。
函数内部的代码在访问变量时,就会使用给定的名称从作用域链中查找变量。函数执行完毕后,局部活动对象会被销毁,内存中就只剩下全局作用域。不过,闭包就不一样了。
在一个函数内部定义的函数会把其包含函数的活动对象添加到自己的作用域链中。因此,在createComparisonFunction()函数中,匿名函数的作用域链中实际上包含createComparisonFunction()的活动对象。下图展示了以下代码执行后的结果。
let compare = createComparisonFunction('name');
let result = compare({ name: 'Nicholas' }, { name: 'Matt' });
在createComparisonFunction()返回匿名函数后,它的作用域链被初始化为包含createComparisonFunction()的活动对象和全局变量对象。这样,匿名函数就可以访问到createComparisonFunction()可以访问的所有变量。另一个有意思的副作用就是,createComparisonFunction()的活动对象并不能在它执行完毕后销毁,因为匿名函数的作用域链中仍然有对它的引用。在createComparisonFunction()执行完毕后,其执行上下文的作用域链会销毁,但它的活动对象仍然会保留在内存中,直到匿名函数被销毁后才会被销毁:
// 创建比较函数
let compareNames = createComparisonFunction('name');
// 调用函数
let result = compareNames({ name: 'Nicholas' }, { name: 'Matt' });
// 解除对函数的引用,这样就可以释放内存了
compareNames = null;
这里,创建的比较函数被保存在变量compareNames中。把compareNames设置为等于null会解除对函数的引用,从而让垃圾回收程序可以将内存释放掉。作用域链也会被销毁,其他作用域(除全局作用域之外)也可以销毁。
注意 因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。V8等优化的JavaScript引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。
10.14.1 this对象
在闭包中使用this会让代码变复杂。如果内部函数没有使用箭头函数定义,则this对象会在运行时绑定到执行函数的上下文。如果在全局函数中调用,则this在非严格模式下等于window,在严格模式下等于undefined。如果作为某个对象的方法调用,则this等于这个对象。匿名函数在这种情况下不会绑定到某个对象,这就意味着this会指向window,除非在严格模式下this是undefined。不过,由于闭包的写法所致,这个事实有时候没有那么容易看出来。来看下面的例子:
window.identity = 'The Window';
let object = {
identity: 'My Object',
getIdentityFunc() {
return function() {
return this.identity;
};
}
};
console.log(object.getIdentityFunc()()); // 'The Window'
这里先创建了一个全局变量identity,之后又创建一个包含identity属性的对象。这个对象还包含一个getIdentityFunc()方法,返回一个匿名函数。这个匿名函数返回this.identity。因为getIdentityFunc()返回函数,所以object.getIdentityFunc()()会立即调用这个返回的函数,
从而得到一个字符串。可是,此时返回的字符串是"The Winodw",即全局变量identity的值。为什么匿名函数没有使用其包含作用域(getIdentityFunc())的this对象呢?
前面介绍过,每个函数在被调用时都会自动创建两个特殊变量:this和arguments。内部函数永远不可能直接访问外部函数的这两个变量。但是,如果把this保存到闭包可以访问的另一个变量中,则是行得通的。比如:
window.identity = 'The Window';
let object = {
identity: 'My Object',
getIdentityFunc() {
let that = this;
return function() {
return that.identity;
};
}
};
console.log(object.getIdentityFunc()()); // 'My Object'
这里加粗的代码展示了与前面那个例子的区别。在定义匿名函数之前,先把外部函数的this保存到变量that中。然后在定义闭包时,就可以让它访问that,因为这是包含函数中名称没有任何冲突的一个变量。即使在外部函数返回之后,that仍然指向object,所以调用object.getIdentityFunc()()就会返回"My Object"。
注意 this和arguments都是不能直接在内部函数中访问的。如果想访问包含作用域中的arguments对象,则同样需要将其引用先保存到闭包能访问的另一个变量中。
在一些特殊情况下,this值可能并不是我们所期待的值。比如下面这个修改后的例子:
window.identity = 'The Window';
let object = {
identity: 'My Object',
getIdentity () {
return this.identity;
}
};
getIdentity()方法就是返回this.identity的值。以下是几种调用object.getIdentity()的方式及返回值:
object.getIdentity(); // 'My Object'
(object.getIdentity)(); // 'My Object'
(object.getIdentity = object.getIdentity)(); // 'The Window'
第一行调用object.getIdentity()是正常调用,会返回"My Object",因为this.identity就是object.identity。第二行在调用时把object.getIdentity放在了括号里。虽然加了括号之后看起来是对一个函数的引用,但this值并没有变。这是因为按照规范,object.getIdentity和(object.getIdentity)是相等的。第三行执行了一次赋值,然后再调用赋值后的结果。因为赋值表达式的值是函数本身,this值不再与任何对象绑定,所以返回的是"The Window"。
一般情况下,不大可能像第二行和第三行这样调用对象上的方法。但通过这个例子,我们可以知道,即使语法稍有不同,也可能影响this的值。
10.14.2 内存泄漏
由于IE在IE9之前对JScript对象和COM对象使用了不同的垃圾回收机制,所以闭包在这些旧版本IE中可能会导致问题。在这些版本的IE中,把HTML元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁。来看下面的例子:
function assignHandler() {
let element = document.getElementById('someElement');
element.onclick = () => console.log(element.id);
}
以上代码创建了一个闭包,即element元素的事件处理程序。而这个处理程序又创建了一个循环引用。匿名函数引用着assignHandler()的活动对象,阻止了对element的引用计数归零。只要这个匿名函数存在,element的引用计数就至少等于1。也就是说,内存不会被回收。其实只要这个例子稍加修改,就可以避免这种情况,比如:
function assignHandler() {
let element = document.getElementById('someElement');
let id = element.id;
element.onclick = () => console.log(id);
element = null;
}
在这个修改后的版本中,闭包改为引用一个保存着element.id的变量id,从而消除了循环引用。不过,光有这一步还不足以解决内存问题。因为闭包还是会引用包含函数的活动对象,而其中包含element。即使闭包没有直接引用element,包含函数的活动对象上还是保存着对它的引用。因此,必须再把element设置为null。这样就解除了对这个COM对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收。
10.15 立即调用的函数表达式
立即调用的匿名函数又被称作立即调用的函数表达式(IIFE,Immediately Invoked Function Expression)。它类似于函数声明,但由于被包含在括号中,所以会被解释为函数表达式。紧跟在第一组括号后面的第二组括号会立即调用前面的函数表达式。下面是一个简单的例子:
(function() {
// 块级作用域
})();
使用IIFE可以模拟块级作用域,即在一个函数表达式内部声明变量,然后立即调用这个函数。这样位于函数体作用域的变量就像是在块级作用域中一样。ECMAScript 5尚未支持块级作用域,使用IIFE模拟块级作用域是相当普遍的。比如下面的例子:
// IIFE
(function () {
for (var i = 0; i < count; i++) {
console.log(i);
}
})();
console.log(i); // 抛出错误
前面的代码在执行到IIFE外部的console.log()时会出错,因为它访问的变量是在IIFE内部定义的,在外部访问不到。在ECMAScript 5.1及以前,为了防止变量定义外泄,IIFE是个非常有效的方式。这样也不会导致闭包相关的内存问题,因为不存在对这个匿名函数的引用。为此,只要函数执行完毕,其作用域链就可以被销毁。
在ECMAScript 6以后,IIFE就没有那么必要了,因为块级作用域中的变量无须IIFE就可以实现同样的隔离。下面展示了两种不同的块级作用域形式:
// 内嵌块级作用域
{
let i;
for (i = 0; i < count; i++) {
console.log(i);
}
}
console.log(i); // 抛出错误
// 循环的块级作用域
for (let i = 0; i < count; i++) {
console.log(i);
}
console.log(i); // 抛出错误
说明IIFE用途的一个实际的例子,就是可以用它锁定参数值。比如:
let divs = document.querySelectorAll('div');
// 达不到目的!
for (var i = 0; i < divs.length; ++i) {
divs[i].addEventListener('click', function() {
console.log(i);
});
}
这里使用var关键字声明了循环迭代变量i,但这个变量并不会被限制在for循环的块级作用域内。因此,渲染到页面上之后,点击每个<div>都会弹出元素总数。这是因为在执行单击处理程序时,迭代变量的值是循环结束时的最终值,即元素的个数。而且,这个变量i存在于循环体外部,随时可以访问。
以前,为了实现点击第几个<div>就显示相应的索引值,需要借助IIFE来执行一个函数表达式,传入每次循环的当前索引,从而“锁定”点击时应该显示的索引值:
let divs = document.querySelectorAll('div');
for (var i = 0; i < divs.length; ++i) {
divs[i].addEventListener('click', (function(frozenCounter) {
return function() {
console.log(frozenCounter);
};
})(i));
}
而使用ECMAScript块级作用域变量,就不用这么大动干戈了:
let divs = document.querySelectorAll('div');
for (let i = 0; i < divs.length; ++i) {
divs[i].addEventListener('click', function() {
console.log(i);
});
}
这样就可以让每次点击都显示正确的索引了。这里,事件处理程序执行时就会引用for循环块级作用域中的索引值。这是因为在ECMAScript 6中,如果对for循环使用块级作用域变量关键字,在这里就是let,那么循环就会为每个循环创建独立的变量,从而让每个单击处理程序都能引用特定的索引。
但要注意,如果把变量声明拿到for循环外部,那就不行了。下面这种写法会碰到跟在循环中使用var i = 0同样的问题:
let divs = document.querySelectorAll('div');
// 达不到目的!
let i;
for (i = 0; i < divs.length; ++i) {
divs[i].addEventListener('click', function() {
console.log(i);
});
}
10.16 私有变量
严格来讲,JavaScript没有私有成员的概念,所有对象属性都公有的。不过,倒是有私有变量的概念。任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。私有变量包括函数参数、局部变量,以及函数内部定义的其他函数。来看下面的例子:
function add(num1, num2) {
let sum = num1 + num2;
return sum;
}
在这个函数中,函数add()有3个私有变量:num1、num2和sum。这几个变量只能在函数内部使用,不能在函数外部访问。如果这个函数中创建了一个闭包,则这个闭包能通过其作用域链访问其外部的这3个变量。基于这一点,就可以创建出能够访问私有变量的公有方法。
特权方法(privileged method)是能够访问函数私有变量(及私有函数)的公有方法。在对象上有两种方式创建特权方法。第一种是在构造函数中实现,比如:
function MyObject() {
// 私有变量和私有函数
let privateVariable = 10;
function privateFunction() {
return false;
}
// 特权方法
this.publicMethod = function() {
privateVariable++;
return privateFunction();
};
}
这个模式是把所有私有变量和私有函数都定义在构造函数中。然后,再创建一个能够访问这些私有成员的特权方法。这样做之所以可行,是因为定义在构造函数中的特权方法其实是一个闭包,它具有访问构造函数中定义的所有变量和函数的能力。在这个例子中,变量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'
这段代码中的构造函数定义了两个特权方法:getName()和setName()。每个方法都可以构造函数外部调用,并通过它们来读写私有的name变量。在Person构造函数外部,没有别的办法访问name。因为两个方法都定义在构造函数内部,所以它们都是能够通过作用域链访问name的闭包。私有变量name对每个Person实例而言都是独一无二的,因为每次调用构造函数都会重新创建一套变量和方法。不过这样也有个问题:必须通过构造函数来实现这种隔离。正如第8章所讨论的,构造函数模式的缺点是每个实例都会重新创建一遍新方法。使用静态私有变量实现特权方法可以避免这个问题。
10.16.1 静态私有变量
特权方法也可以通过使用私有作用域定义私有变量和函数来实现。这个模式如下所示:
(function() {
// 私有变量和私有函数
let privateVariable = 10;
function privateFunction() {
return false;
}
// 构造函数
MyObject = function() {};
// 公有和特权方法
MyObject.prototype.publicMethod = function() {
privateVariable++;
return privateFunction();
};
})();
在这个模式中,匿名函数表达式创建了一个包含构造函数及其方法的私有作用域。首先定义的是私有变量和私有函数,然后又定义了构造函数和公有方法。公有方法定义在构造函数的原型上,与典型的原型模式一样。注意,这个模式定义的构造函数没有使用函数声明,使用的是函数表达式。函数声明会创建内部函数,在这里并不是必需的。基于同样的原因(但操作相反),这里声明MyObject并没有使用任何关键字。因为不使用关键字声明的变量会创建在全局作用域中,所以MyObject变成了全局变量,可以在这个私有作用域外部被访问。注意在严格模式下给未声明的变量赋值会导致错误。
这个模式与前一个模式的主要区别就是,私有变量和私有函数是由实例共享的。因为特权方法定义在原型上,所以同样是由实例共享的。特权方法作为一个闭包,始终引用着包含它的作用域。来看下面的例子:
(function() {
let name = '';
Person = function(value) {
name = value;
};
Person.prototype.getName = function() {
return name;
};
Person.prototype.setName = function(value) {
name = value;
};
})();
let person1 = new Person('Nicholas');
console.log(person1.getName()); // 'Nicholas'
person1.setName('Matt');
console.log(person1.getName()); // 'Matt'
let person2 = new Person('Michael');
console.log(person1.getName()); // 'Michael'
console.log(person2.getName()); // 'Michael'
这里的Person构造函数可以访问私有变量name,跟getName()和setName()方法一样。使用这种模式,name变成了静态变量,可供所有实例使用。这意味着在任何实例上调用setName()修改这个变量都会影响其他实例。调用 setName()或创建新的Person实例都要把name变量设置为一个新值。而所有实例都会返回相同的值。
像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。
注意 使用闭包和私有变量会导致作用域链变长,作用域链越长,则查找变量所需的时间也越多。
10.16.2 模块模式
前面的模式通过自定义类型创建了私有变量和特权方法。而下面要讨论的Douglas Crockford所说的模块模式,则在一个单例对象上实现了相同的隔离和封装。单例对象(singleton)就是只有一个实例的对象。按照惯例,JavaScript是通过对象字面量来创建单例对象的,如下面的例子所示:
let singleton = {
name: value,
method() {
// 方法的代码
}
};
模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法。模块模式的样板代码如下:
let singleton = function() {
// 私有变量和私有函数
let privateVariable = 10;
function privateFunction() {
return false;
}
// 特权/公有方法和属性
return {
publicProperty: true,
publicMethod() {
privateVariable++;
return privateFunction();
}
};
}();
模块模式使用了匿名函数返回一个对象。在匿名函数内部,首先定义私有变量和私有函数。之后,创建一个要通过匿名函数返回的对象字面量。这个对象字面量中只包含可以公开访问的属性和方法。因为这个对象定义在匿名函数内部,所以它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。本质上,对象字面量定义了单例对象的公共接口。如果单例对象需要进行某种初始化,并且需要访问私有变量时,那就可以采用这个模式:
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);
}
}
};
}();
在Web开发中,经常需要使用单例对象管理应用程序级的信息。上面这个简单的例子创建了一个application对象用于管理组件。在创建这个对象之后,内部就会创建一个私有的数组components,然后将一个BaseComponent组件的新实例添加到数组中。(BaseComponent组件的代码并不重要,在这里用它只是为了说明模块模式的用法。)对象字面量中定义的getComponentCount()和registerComponent()方法都是可以访问components私有数组的特权方法。前一个方法返回注册组件的数量,后一个方法负责注册新组件。
在模块模式中,单例对象作为一个模块,经过初始化可以包含某些私有的数据,而这些数据又可以通过其暴露的公共方法来访问。以这种方式创建的每个单例对象都是Object的实例,因为最终单例都由一个对象字面量来表示。不过这无关紧要,因为单例对象通常是可以全局访问的,而不是作为参数传给函数的,所以可以避免使用instanceof操作符确定参数是不是对象类型的需求。
10.16.3 模块增强模式
另一个利用模块模式的做法是在返回对象之前先对其进行增强。这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。来看下面的例子:
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;
}();
如果前一节的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;
}();
在这个重写的application单例对象的例子中,首先定义了私有变量和私有函数,跟之前例子中一样。主要区别在于这里创建了一个名为app的变量,其中保存了BaseComponent组件的实例。这是最终要变成application的那个对象的局部版本。在给这个局部变量app添加了能够访问私有变量的公共方法之后,匿名函数返回了这个对象。然后,这个对象被赋值给application。
10.17 小结
函数是JavaScript编程中最有用也最通用的工具。ECMAScript 6新增了更加强大的语法特性,从而让开发者可以更有效地使用函数。
- 函数表达式与函数声明是不一样的。函数声明要求写出函数名称,而函数表达式并不需要。没有名称的函数表达式也被称为匿名函数。
- ES6新增了类似于函数表达式的箭头函数语法,但两者也有一些重要区别。
- JavaScript中函数定义与调用时的参数极其灵活。arguments对象,以及ES6新增的扩展操作符,可以实现函数定义和调用的完全动态化。
- 函数内部也暴露了很多对象和引用,涵盖了函数被谁调用、使用什么调用,以及调用时传入了什么参数等信息。
- JavaScript引擎可以优化符合尾调用条件的函数,以节省栈空间。
- 闭包的作用域链中包含自己的一个变量对象,然后是包含函数的变量对象,直到全局上下文的变量对象。
- 通常,函数作用域及其中的所有变量在函数执行完毕后都会被销毁。
- 闭包在被函数返回之后,其作用域会一直保存在内存中,直到闭包被销毁。
- 函数可以在创建之后立即调用,执行其中代码之后却不留下对函数的引用。
- 立即调用的函数表达式如果不在包含作用域中将返回值赋给一个变量,则其包含的所有变量都会被销毁。
- 虽然JavaScript没有私有对象属性的概念,但可以使用闭包实现公共方法,访问位于包含作用域中定义的变量。
- 可以访问私有变量的公共方法叫作特权方法。
- 特权方法可以使用构造函数或原型模式通过自定义类型中实现,也可以使用模块模式或模块增强模式在单例对象上实现。