js红宝书笔记十一 第十章 函数 闭包

本文继续对JavaScript高级程序设计第四版 第十章 函数 进行学习

一、创建函数

函数是ECMAScript中最有意思的部分之一,这主要是因为函数实际上是对象。每个函数都是Function类型的实例,而 Function 也有属性和方法,跟其他引用类型一样。因为函数是对象,所以函数名就是指向函数对象的指针,而且不一定与函数本身紧密绑定。

1.以函数声明的方式定义
function sum (num1, num2) {
 return num1 + num2;
}

注意函数定义最后没有加分号。

function sum(num1, num2) {
 return num1 + num2;
}
console.log(sum(10, 10)); // 20
let anotherSum = sum;
console.log(anotherSum(10, 10)); // 20
sum = null;
console.log(anotherSum(10, 10)); // 20

以上代码定义了一个名为 sum()的函数,用于求两个数之和。然后又声明了一个变量 anotherSum,并将它的值设置为等于 sum。注意,使用不带括号的函数名会访问函数指针,而不会执行函数。此时,anotherSum 和 sum 都指向同一个函数。调用 anotherSum()也可以返回结果。把 sum 设置为 null之后,就切断了它与函数之间的关联。而 anotherSum()还是可以照常调用,没有问题。

2.函数表达式
let sum = function(num1, num2) {
 return num1 + num2;
};

这里,代码定义了一个变量 sum 并将其初始化为一个函数。注意 function 关键字后面没有名称,因为不需要。这个函数可以通过变量 sum 来引用。注意这里的函数末尾是有分号的,与任何变量初始化语句一样。

3.箭头函数
let sum = (num1, num2) => {
 return num1 + num2;
}; 

任何可以使用函数表达式的地方,都可以使用箭头函数。如果只有一个参数,那也可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号:

// 以下两种写法都有效
let double = (x) => { return 2 * x; };
let triple = x => { return 3 * x; };
// 没有参数需要括号
let getRandom = () => { return Math.random(); };
// 多个参数需要括号
let sum = (a, b) => { return a + b; };
// 无效的写法:
let multiply = a, b => { return a * b; }; 

箭头函数虽然语法简洁,但也有很多场合不适用。箭头函数不能使用 arguments、super 和new.target,也不能用作构造函数。此外,箭头函数也没有 prototype 属性。

4.使用 Function 构造函数

这个构造函数接收任意多个字符串参数,最后一个参数始终会被当成函数体,而之前的参数都是新函数的参数。来看下面的例子:

let sum = new Function("num1", "num2", "return num1 + num2"); // 不推荐

我们不推荐使用这种语法来定义函数,因为这段代码会被解释两次:第一次是将它当作常规ECMAScript 代码,第二次是解释传给构造函数的字符串。这显然会影响性能。不过,把函数想象为对象,把函数名想象为指针是很重要的。而上面这种语法很好地诠释了这些概念。

二、理解参数

ECMAScript 函数的参数跟大多数其他语言不同。ECMAScript 函数既不关心传入的参数个数,也不关心这些参数的数据类型。定义函数时要接收两个参数,并不意味着调用时就传两个参数。你可以传一个、三个,甚至一个也不传,解释器都不会报错。

之所以会这样,主要是因为 ECMAScript 函数的参数在内部表现为一个数组。函数被调用时总会接收一个数组,但函数并不关心这个数组中包含什么。如果数组中什么也没有,那没问题;如果数组的元素超出了要求,那也没问题。事实上,在使用 function 关键字定义(非箭头)函数时,可以在函数内部访问 arguments 对象,从中取得传进来的每个参数值。

arguments 对象是一个类数组对象(但不是 Array 的实例),因此可以使用中括号语法访问其中的元素(第一个参数是 arguments[0],第二个参数是 arguments[1])。而要确定传进来多少个参数,可以访问 arguments.length 属性。

function doAdd() {
 if (arguments.length === 1) {
 console.log(arguments[0] + 10);
 } else if (arguments.length === 2) {
 console.log(arguments[0] + arguments[1]);
 }
}
doAdd(10); // 20
doAdd(30, 20); // 50 

虽然不像真正的函数重载那么明确,但这已经足以弥补 ECMAScript 在这方面的缺失了。

ECMAScript 函数不能像传统编程那样重载。在其他语言比如 Java 中,一个函数可以有两个定义,只要签名(接收参数的类型和数量)不同就行。如前所述,ECMAScript 函数没有签名,因为参数是由包含零个或多个值的数组表示的。没有函数签名,自然也就没有重载。如果在 ECMAScript 中定义了两个同名函数,则后定义的会覆盖先定义的。

还有一个必须理解的重要方面,那就是 arguments 对象可以跟命名参数一起使用,比如:

function doAdd(num1, num2) {
 if (arguments.length === 1) {
 console.log(num1 + 10);
 } else if (arguments.length === 2) {
 console.log(arguments[0] + num2);
 }
}

在这个 doAdd()函数中,同时使用了两个命名参数和 arguments 对象。命名参数 num1 保存着与arugments[0]一样的值,因此使用谁都无所谓。(同样,num2 也保存着跟 arguments[1]一样的值。)

如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用 arguments 关键字访问,而只能通过定义的命名参数访问。

function foo() {
 console.log(arguments[0]);
}
foo(5); // 5
let bar = () => {
 console.log(arguments[0]);
};
bar(5); // ReferenceError: arguments is not defined 

虽然箭头函数中没有 arguments 对象,但可以在包装函数中把它提供给箭头函数:

function foo() {
 let bar = () => {
 console.log(arguments[0]); // 5
 };
 bar();
}
foo(5);

注意 ECMAScript 中的所有参数都按值传递的。不可能按引用传递参数。如果把对象作为参数传递,那么传递的值就是这个对象的引用。

三、默认参数

在 ECMAScript5.1 及以前,实现默认参数的一种常用方式就是检测某个参数是否等于 undefined,如果是则意味着没有传这个参数,那就给它赋一个值:

function makeKing(name) {
 name = (typeof name !== 'undefined') ? name : 'Henry';
 return `King ${name} VIII`;
}
console.log(makeKing()); // 'King Henry VIII'
console.log(makeKing('Louis')); // 'King Louis VIII'

ECMAScript 6 之后就不用这么麻烦了,因为它支持显式定义默认参数了。下面就是与前面代码等价的 ES6 写法,只要在函数定义中的参数后面用=就可以为参数赋一个默认值:

function makeKing(name = 'Henry') {
 return `King ${name} VIII`;
}
console.log(makeKing('Louis')); // 'King Louis VIII'
console.log(makeKing()); // 'King Henry VIII'

给参数传 undefined 相当于没有传值,不过这样可以利用多个独立的默认值:

function makeKing(name = 'Henry', numerals = 'VIII') {
 return `King ${name} ${numerals}`;
}
console.log(makeKing()); // 'King Henry VIII'
console.log(makeKing('Louis')); // 'King Louis VIII'
console.log(makeKing(undefined, 'VI')); // 'King Henry VI'

在使用默认参数时,arguments 对象的值不反映参数的默认值,只反映传给函数的参数。当然,跟 ES5 严格模式一样,修改命名参数也不会影响 arguments 对象,它始终以调用函数时传入的值为准:

function makeKing(name = 'Henry') {
 name = 'Louis';
 return `King ${arguments[0]}`;
}
console.log(makeKing()); // 'King undefined'
console.log(makeKing('Louis')); // 'King Louis'

默认参数值并不限于原始值或对象类型,也可以使用调用函数返回的值:

let romanNumerals = ['I', 'II', 'III', 'IV', 'V', 'VI'];
let ordinality = 0;
function getNumerals() {
 // 每次调用后递增
 return romanNumerals[ordinality++];
}
function makeKing(name = 'Henry', numerals = getNumerals()) {
 return `King ${name} ${numerals}`;
}
console.log(makeKing()); // 'King Henry I' 
console.log(makeKing('Louis', 'XVI')); // 'King Louis XVI'
console.log(makeKing()); // 'King Henry II'
console.log(makeKing()); // 'King Henry III'

函数的默认参数只有在函数被调用时才会求值,不会在函数定义时求值。而且,计算默认值的函数只有在调用函数但未传相应参数时才会被调用。

箭头函数同样也可以这样使用默认参数,只不过在只有一个参数时,就必须使用括号而不能省略了:

let makeKing = (name = 'Henry') => `King ${name}`;
console.log(makeKing()); // King Henry 
四、参数扩展与收集
1.扩展参数

在给函数传参时,有时候可能不需要传一个数组,而是要分别传入数组的元素。假设有如下函数定义,它会将所有传入的参数累加起来:

let values = [1, 2, 3, 4];
function getSum() {
 let sum = 0;
 for (let i = 0; i < arguments.length; ++i) {
 sum += arguments[i];
 }
 return sum;
}

这个函数希望将所有加数逐个传进来,然后通过迭代 arguments 对象来实现累加。如果不使用扩展操作符,想把定义在这个函数这面的数组拆分,那么就得求助于 apply()方法:

console.log(getSum.apply(null, values)); // 10

但在 ECMAScript 6 中,可以通过扩展操作符极为简洁地实现这种操作。对可迭代对象应用扩展操作符,并将其作为一个参数传入,可以将可迭代对象拆分,并将迭代返回的每个值单独传入。比如,使用扩展操作符可以将前面例子中的数组像这样直接传给函数:

console.log(getSum(...values)); // 10

因为数组的长度已知,所以在使用扩展操作符传参的时候,并不妨碍在其前面或后面再传其他的值,包括使用扩展操作符传其他参数:

console.log(getSum(-1, ...values)); // 9
console.log(getSum(...values, 5)); // 15
console.log(getSum(-1, ...values, 5)); // 14
console.log(getSum(...values, ...[5,6,7])); // 28 
2.收集参数

在构思函数定义时,可以使用扩展操作符把不同长度的独立参数组合为一个数组。这有点类似arguments 对象的构造机制,只不过收集参数的结果会得到一个 Array 实例。

function getSum(...values) { 
 // 顺序累加 values 中的所有值
 // 初始值的总和为 0 
 return values.reduce((x, y) => x + y, 0); 
} 
console.log(getSum(1,2,3)); // 6 

收集参数的前面如果还有命名参数,则只会收集其余的参数;如果没有则会得到空数组。因为收集参数的结果可变,所以只能把它作为最后一个参数:

// 不可以
function getProduct(...values, lastValue) {} 
// 可以
function ignoreFirst(firstValue, ...values) { 
 console.log(values); 
} 
ignoreFirst(); // [] 
ignoreFirst(1); // [] 
ignoreFirst(1,2); // [2] 
ignoreFirst(1,2,3); // [2, 3] 

箭头函数虽然不支持 arguments 对象,但支持收集参数的定义方式,因此也可以实现与使用arguments 一样的逻辑:

let getSum = (...values) => { 
 return values.reduce((x, y) => x + y, 0); 
} 
console.log(getSum(1,2,3)); // 6 

另外,使用收集参数并不影响 arguments 对象,它仍然反映调用时传给函数的参数:

function getSum(...values) { 
 console.log(arguments.length); // 3 
 console.log(arguments); // [1, 2, 3] 
 console.log(values); // [1, 2, 3] 
} 
console.log(getSum(1,2,3));
五、函数声明与函数表达式

本章到现在一直没有把函数声明和函数表达式区分得很清楚。事实上,JavaScript 引擎在加载数据时对它们是区别对待的。JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。来看下面的例子:

// 没问题 
console.log(sum(10, 10)); 
function sum(num1, num2) { 
 return num1 + num2; 
} 

以上代码可以正常运行,因为函数声明会在任何代码执行之前先被读取并添加到执行上下文。这个过程叫作函数声明提升(function declaration hoisting)。在执行代码时,JavaScript 引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部。因此即使函数定义出现在调用它们的代码之后,引擎也会把函数声明提升到顶部。如果把前面代码中的函数声明改为等价的函数表达式,那么执行的时候就会出错:

// 会出错
console.log(sum(10, 10)); 
let sum = function(num1, num2) { 
 return num1 + num2; 
};

除了函数什么时候真正有定义这个区别之外,这两种语法是等价的。

六、函数作为值

因为函数名在 ECMAScript 中就是变量,所以函数可以用在任何可以使用变量的地方。这意味着不仅可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另一个函数。来看下面的例子:

function callSomeFunction(someFunction, someArgument) { 
 return someFunction(someArgument); 
} 

这个函数接收两个参数。第一个参数应该是一个函数,第二个参数应该是要传给这个函数的值。任何函数都可以像下面这样作为参数传递:

function add10(num) { 
 return num + 10; 
} 
let result1 = callSomeFunction(add10, 10); 
console.log(result1); // 20 
function getGreeting(name) { 
 return "Hello, " + name; 
} 
let result2 = callSomeFunction(getGreeting, "Nicholas"); 
console.log(result2); // "Hello, Nicholas"

从一个函数中返回另一个函数也是可以的,而且非常有用。例如,假设有一个包含对象的数组,而我们想按照任意对象属性对数组进行排序。为此,可以定义一个 sort()方法需要的比较函数,它接收两个参数,即要比较的值。但这个比较函数还需要想办法确定根据哪个属性来排序。这个问题可以通过定义一个根据属性名来创建比较函数的函数来解决。比如:

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; 
 } 
 }; 
} 

这个函数的语法乍一看比较复杂,但实际上就是在一个函数中返回另一个函数,注意那个 return操作符。内部函数可以访问 propertyName 参数,并通过中括号语法取得要比较的对象的相应属性值。取得属性值以后,再按照 sort()方法的需要返回比较值就行了。这个函数可以像下面这样使用:

let data = [ 
 {name: "Zachary", age: 28}, 
 {name: "Nicholas", age: 29} 
]; 
data.sort(createComparisonFunction("name")); 
console.log(data[0].name); // Nicholas
data.sort(createComparisonFunction("age")); 
console.log(data[0].name); // Zachary
七、函数内部

在 ECMAScript 5 中,函数内部存在两个特殊的对象:arguments 和 this。ECMAScript 6 又新增了 new.target 属性。

1.arguments.callee

arguments 对象前面讨论过多次了,它是一个类数组对象,包含调用函数时传入的所有参数。这个对象只有以 function 关键字定义函数(相对于使用箭头语法创建函数)时才会有。虽然主要用于包含函数参数,但 arguments 对象其实还有一个 callee 属性,是一个指向 arguments 对象所在函数的指针。来看下面这个经典的阶乘函数:

function factorial(num) { 
 if (num <= 1) { 
 return 1; 
 } else { 
 return num * factorial(num - 1); 
 } 
} 

阶乘函数一般定义成递归调用的,就像上面这个例子一样。只要给函数一个名称,而且这个名称不会变,这样定义就没有问题。但是,这个函数要正确执行就必须保证函数名是 factorial,从而导致了紧密耦合。使用 arguments.callee 就可以让函数逻辑与函数名解耦:

function factorial(num) { 
 if (num <= 1) { 
 return 1; 
 } else { 
 return num * arguments.callee(num - 1); 
 } 
} 

这个重写之后的 factorial()函数已经用 arguments.callee 代替了之前硬编码的 factorial。这意味着无论函数叫什么名称,都可以引用正确的函数。

2.this

另一个特殊的对象是 this,它在标准函数和箭头函数中有不同的行为。在标准函数中,this 引用的是把函数当成方法调用的上下文对象,这时候通常称其为 this 值(在网页的全局上下文中调用函数时,this 指向 windows)。来看下面的例子:

window.color = 'red'; 
let o = { 
 color: 'blue' 
}; 
function sayColor() { 
 console.log(this.color); 
} 
sayColor(); // 'red' 
o.sayColor = sayColor; 
o.sayColor(); // 'blue' 

定义在全局上下文中的函数 sayColor()引用了 this 对象。这个 this 到底引用哪个对象必须到函数被调用时才能确定。因此这个值在代码执行的过程中可能会变。如果在全局上下文中调用sayColor(),这结果会输出"red",因为 this 指向 window,而 this.color 相当于 window.color。而在把 sayColor()赋值给 o 之后再调用 o.sayColor(),this 会指向 o,即 this.color 相当于o.color,所以会显示"blue"。

在箭头函数中,this引用的是定义箭头函数的上下文。下面的例子演示了这一点。在对sayColor()的两次调用中,this 引用的都是 window 对象,因为这个箭头函数是在 window 上下文中定义的:

window.color = 'red'; 
let o = { 
 color: 'blue' 
}; 
let sayColor = () => console.log(this.color); 
sayColor(); // 'red' 
o.sayColor = sayColor; 
o.sayColor(); // 'red' 

有读者知道,在事件回调或定时回调中调用某个函数时,this 值指向的并非想要的对象。此时将回调函数写成箭头函数就可以解决问题。这是因为箭头函数中的 this 会保留定义该函数时的上下文。

更多参考js es6 => arrow function箭头函数

3.caller
function outer() { 
 inner(); 
} 
function inner() { 
 console.log(arguments.callee.caller); 
} 
outer();

以上代码会显示 outer()函数的源代码

ƒ outer() { 
 inner(); 
}
4.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"
八、函数属性与方法
1.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 上,进而由所有实例共享。这个属性在自定义类型时特别重要。(相关内容已经在第 8 章详细介绍过了。)在 ECMAScript 5中,prototype 属性是不可枚举的,因此使用 for-in 循环不会返回这个属性。

2.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

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"。

九、闭包

匿名函数经常被人误认为是闭包(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()函数的作用域。要理解为什么会这样,可以想想第一次调用这个函数时会发生什么。

本书在第 4 章曾介绍过作用域链的概念。理解作用域链创建和使用的细节对理解闭包非常重要。在调用一个函数时,会为这个函数调用创建一个执行上下文,并创建一个作用域链。然后用 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。 图 10-1 展示了以上关系。


image.png

函数执行时,每个执行上下文中都会有一个包含其中变量的对象。全局上下文中的叫变量对象,它会在代码执行期间始终存在。而函数局部上下文中的叫活动对象,只在函数执行期间存在。在定义compare()函数时,就会为它创建作用域链,预装载全局变量对象,并保存在内部的[[Scope]]中。在调用这个函数时,会创建相应的执行上下文,然后通过复制函数的[[Scope]]来创建其作用域链。接着会创建函数的活动对象(用作变量对象)并将其推入作用域链的前端。在这个例子中,这意味着 compare()函数执行上下文的作用域链中有两个变量对象:局部变量对象和全局变量对象。作用域链其实是一个包含指针的列表,每个指针分别指向一个变量对象,但物理上并不会包含相应的对象。

函数内部的代码在访问变量时,就会使用给定的名称从作用域链中查找变量。函数执行完毕后,局部活动对象会被销毁,内存中就只剩下全局作用域。不过,闭包就不一样了。

在一个函数内部定义的函数会把其包含函数的活动对象添加到自己的作用域链中。因此,在createComparisonFunction()函数中,匿名函数的作用域链中实际上包含 createComparisonFunction()的活动对象。图 10-2 展示了以下代码执行后的结果。

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 会解除对函数的引用,从而让垃圾回收程序可以将内存释放掉。作用域链也会被销毁,其他作用域(除全局作用域之外)也可以销毁。图 10-2 展示了调用 compareNames()之后作用域链之间的关系。

注意 因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。V8 等优化的 JavaScript 引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。

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 的值。

2.内存泄漏

由于 IE 在 IE9 之前对 JScript 对象和 COM 对象使用了不同的垃圾回收机制(第 4 章讨论过),所以闭包在这些旧版本 IE 中可能会导致问题。在这些版本的 IE 中,把 HTML 元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁。来看下面的例子:

function assignHandler() {
 let element = document.getElementById('someElement');
 element.onclick = () => console.log(element.id);
}

以上代码创建了一个闭包,即 element 元素的事件处理程序(事件处理程序将在第 13 章讨论)。而这个处理程序又创建了一个循环引用。匿名函数引用着 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 对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收。

十、立即调用的函数表达式(IIFE,Immediately Invoked Function Expression)

立即调用的匿名函数又被称作立即调用的函数表达式(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); // 抛出错误

你可能感兴趣的:(js红宝书笔记十一 第十章 函数 闭包)