目录
函数一共三篇文章:
1. 函数表达式
1.1 函数声明提升(hoist)
函数表达式虽然更强大,但也更容易让人迷惑。我们知道,定义函数有两种方式:函数声明和函数表达式。函数声明是这样的:
function functionName(arg0, arg1, arg2) {
// 函数体
}
函数声明的关键特点是函数声明提升,即函数声明会在代码执行之前获得定义。这意味着函数声明可以出现在调用它的代码之后:
sayHi();
function sayHi() {
console.log("Hi!");
}
这个例子不会抛出错误,因为 JavaScript 引擎会先读取函数声明,然后再执行代码。
1.2 函数表达式的常见形式
函数表达式有几种不同的形式,最常见的是 使用匿名函数给变量赋值的形式:
let functionName = function(arg0, arg1, arg2) {
// 函数体
};
函数表达式看起来就像一个普通的变量定义和赋值,即创建一个函数再把它赋值给一个变量functionName 。这样创建的函数叫作匿名函数(anonymous funtion),因为 function 关键字后面没有标识符。(兰姆达 lamda函数也称为匿名函数)。未赋值给其他变量的匿名函数的 name 属性是空字符串。
1.3 函数表达式没有提升(hoist)
函数表达式跟 JavaScript 中的其他表达式一样,需要先赋值再使用。下面的例子会导致错误:
sayHi(); // Error! function doesn't exist yet
let sayHi = function() {
console.log("Hi!");
};
1.3.1 提升的造成问题
理解函数声明与函数表达式之间的区别,关键是理解提升。比如,以下代码的执行结果可能会出乎意料:
// 千万别这样做!
if (condition) {
function sayHi() {
console.log('Hi!');
}
} else {
function sayHi() {
console.log('Yo!');
}
}
这段代码看起来很正常,就是如果 condition 为 true ,则使用第一个 sayHi() 定义;否则,就使用第二个。事实上,这种写法在 ECAMScript 中不是有效的语法。JavaScript 引擎会尝试将其纠正为适当的声明。问题在于浏览器纠正这个问题的方式并不一致。多数浏览器会忽略 condition 直接返回第二个声明。Firefox 会在 condition 为 true 时返回第一个声明。这种写法很危险,不要使用。
1.3.2 函数表达式解决的问题
如果把上面的函数声明换成函数表达式就没问题了:
// 没问题
let sayHi;
if (condition) {
sayHi = function() {
console.log("Hi!");
};
} else {
sayHi = function() {
console.log("Yo!");
};
}
这个例子可以如预期一样,根据 condition 的值为变量 sayHi 赋予相应的函数。
1.4 函数表达式的另一个形式:
函数的返回值是一个函数,也可以赋值给一个变量,那么它也是一个函数表达式。
创建函数并赋值给变量的能力也可以用于在一个函数中把另一个函数当作值返回:
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() 内部,那个函数是匿名的。任何时候,只要函数被当作值来使用,它就是一个函数表达式。本章后面会介绍,这并不是使用函数表达式的唯一方式。
2. 递归
2.1 递归的经典形式
递归函数通常的形式是一个函数通过名称调用自己,如下面的例子所示:
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
}
}
2.2 经典形式可能出现的问题
这是经典的递归阶乘函数。虽然这样写是可以的,但如果把这个函数赋值给其他变量,就会出问题:
let anotherFactorial = factorial;
factorial = null;
console.log(anotherFactorial(4)); // 报错
这里把 factorial() 函数保存在了另一个变量 anotherFactorial 中,然后将 factorial 设置为 null ,于是只保留了一个对原始函数的引用。而在调用 anotherFactorial() 时,要递归调用factorial() ,但因为它已经不是函数了,所以会出错。
2.3 使用 arguments.callee 解决
在写递归函数时使用 arguments.callee 可以避免上面的问题。
arguments.callee 就是一个指向正在执行的函数的指针,因此可以在函数内部递归调用,如下所示:
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * arguments.callee(num - 1);
}
}
上面代码第5行,函数名称替换成 arguments.callee ,可以确保无论通过什么变量调用这个函数都不会出问题。因此在编写递归函数时, arguments.callee 是引用当前函数的首选。
2.4 使用 命名函数表达式 解决
最常见的函数表达式是匿名的,我们这里的函数表达式是命名的,所以它叫“ 命名函数表达式 ”。
在严格模式下运行的代码是不能访问 arguments.callee 的,因为访问会出错。此时,可以使用命名函数表达式(named function expression)达到目的。比如:
const factorial = (function f(num) {
if (num <= 1) {
return 1;
} else {
return num * f(num - 1);
}
});
这里创建了一个命名函数表达式 f() ,然后将它赋值给了变量 factorial 。即使把函数赋值给另一个变量,函数表达式的名称 f 也不变,因此递归调用不会有问题。这个模式在严格模式和非严格模式下都可以使用。
3. 尾调用优化 Tail Call Optimization
尾调用 其实就是字面的意思: 在函数的尾部调用另一个函数。
3.1 尾调用优化的原理
ECMAScript 6规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧 stack frame。具体来说,这项优化非常适合 “尾调用”,即外部函数的返回值是一个内部函数的返回值。比如:
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 规范所规定的,兼容的浏览器实现都能保证在代码满足条件的情况下应用这个优化。
3.2 尾调用优化的条件
尾调用优化的条件就是确定外部栈帧真的没有必要存在了。涉及的条件如下:
- 代码在严格模式下执行;
- 外部函数的返回值是对尾调用函数的调用;
- 尾调用函数返回后不需要执行额外的逻辑;
- 尾调用函数不是引用外部函数作用域中自由变量的闭包。
下面展示了几个违反上述条件的函数,因此都不符号尾调用优化的要求:
"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 ,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。
3.3 尾调用优化的代码
可以通过把简单的递归函数转换为待优化的代码来加深对尾调用优化的理解。下面是一个通过递归计算斐波纳契数列的函数:
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) 就不会对浏览器造成威胁了。
4. 闭包 Closures
匿名函数经常被人误认为是闭包(closure)。闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。比如,下面是之前展示的 createComparisonFunction() 函数,注意其中第3行和第4行的代码:
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;
}
};
}
这里第3行和第4行代码位于内部函数(匿名函数)中,其中引用了外部函数的变量 propertyName 。在这个内部函数被返回并在其他地方被使用后,它仍然引用着那个变量。这是因为内部函数的作用域链包含createComparisonFunction() 函数的作用域。要理解为什么会这样,可以想想第一次调用这个函数时会发生什么。
《js 上下文和作用域 》介绍过作用域链的概念。理解作用域链创建和使用的细节对理解闭包非常重要。在调用一个函数时,会为这个函数调用创建一个执行上下文,并创建一个作用域链。然后用 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 。图 1 展示了以上关系。
函数执行时,每个执行上下文中都会有一个包含其中变量的对象。全局上下文中的叫变量对象,它会在代码执行期间始终存在。而函数局部上下文中的叫活动对象,只在函数执行期间存在。在定义compare() 函数时,就会为它创建作用域链,预装载全局变量对象,并保存在内部的 [[Scope]] 中。在调用这个函数时,会创建相应的执行上下文,然后通过复制函数的 [[Scope]] 来创建其作用域链。接着会创建函数的活动对象(用作变量对象)并将其推入作用域链的前端。在这个例子中,这意味着 compare() 函数执行上下文的作用域链中有两个变量对象:局部变量对象和全局变量对象。作用域链其实是一个包含指针的列表,每个指针分别指向一个变量对象,但物理上并不会包含相应的对象。
函数内部的代码在访问变量时,就会使用给定的名称从作用域链中查找变量。函数执行完毕后,局部活动对象会被销毁,内存中就只剩下全局作用域。不过,闭包就不一样了。
在一个函数内部定义的函数会把其包含函数的活动对象添加到自己的作用域链中。因此,在 createComparisonFunction() 函数中,匿名函数的作用域链中实际上包含 createComparisonFunction() 的活动对象。图 2 展示了以下代码执行后的结果。
let compare = createComparisonFunction('name');
let result = compare({ name: 'Nicholas' }, { name: 'Matt' });
// 创建比较函数
let compareNames = createComparisonFunction('name');
// 调用函数
let result = compareNames({ name: 'Nicholas' }, { name: 'Matt' });
// 解除对函数的引用,这样就可以释放内存了
compareNames = null;
这里,创建的比较函数被保存在变量 compareNames 中。把 compareNames 设置为等于 null 会解除对函数的引用,从而让垃圾回收程序可以将内存释放掉。作用域链也会被销毁,其他作用域(除全局作用域之外)也可以销毁。图 2 展示了调用 compareNames() 之后作用域链之间的关系。
注意 因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。V8 等优化的 JavaScript 引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。
4.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 的值。
4.2 内存泄漏
由于 IE 在 IE9 之前对 JScript 对象和 COM 对象使用了不同的垃圾回收机制(我们在《js 上下文和作用域 》学习过),所以闭包在这些旧版本 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对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收。
5. 立即调用的函数表达式(ES 6以后 用的少了)
立即调用的匿名函数又被称作立即调用的函数表达式(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);
});
}
6. 私有变量
6.1 构造函数中创建私有变量
严格来讲,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();
};
}
let obj =new MyObject();
console.log(obj.publicMethod()); //false
console.log(obj.privateFunction()); //Error privateFunction is not a function.
这个模式是把所有私有变量和私有函数都定义在构造函数中。然后,再创建一个能够访问这些私有成员的特权方法。这样做之所以可行,是因为定义在构造函数中的特权方法其实是一个闭包,它具有访问构造函数中定义的所有变量和函数的能力。在这个例子中,变量 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 实例而言都是独一无二的,因为每次调用构造函数都会重新创建一套变量和方法。不过这样也有个问题:必须通过构造函数来实现这种隔离。正如《js 深入理解原型(prototype)及如何创建对象》所讨论的,构造函数模式的缺点是每个实例都会重新创建一遍新方法。使用静态私有变量实现特权方法可以避免这个问题。
6.2 静态私有变量
特权方法也可以通过使用私有作用域定义私有变量和函数来实现。这个模式如下所示:
(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 变量设置为一个新值。而所有实例都会返回相同的值。
像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。
注意 使用闭包和私有变量会导致作用域链变长,作用域链越长,则查找变量所需的时间也越多。
6.3 模块模式 (扩展单例对象方式实现)
前面的模式通过自定义类型创建了私有变量和特权方法。而下面要讨论的 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 操作符确定参数是不是对象类型的需求。
6.4 模块增强模式
另一个利用模块模式的做法是在返回对象之前先对其进行增强。这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。来看下面的例子:
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 。
小结
- JavaScript 引擎可以优化符合尾调用条件的函数,以节省栈空间。
- 闭包的作用域链中包含自己的一个变量对象,然后是包含函数的变量对象,直到全局上下文的变量对象。
- 通常,函数作用域及其中的所有变量在函数执行完毕后都会被销毁。
- 闭包在被函数返回之后,其作用域会一直保存在内存中,直到闭包被销毁。
- 函数可以在创建之后立即调用,执行其中代码之后却不留下对函数的引用。
- 立即调用的函数表达式如果不在包含作用域中将返回值赋给一个变量,则其包含的所有变量都会被销毁。
- 虽然 JavaScript 没有私有对象属性的概念,但可以使用闭包实现公共方法,访问位于包含作用域中定义的变量。
- 可以访问私有变量的公共方法叫作特权方法。
- 特权方法可以使用构造函数或原型模式通过自定义类型中实现,也可以使用模块模式或模块增强模式在单例对象上实现。