目录
1. 函数内部
在ECMAScript5中,函数内部存在两个特殊的对象:arguments和this。
ECMAScript6又增加了new.target属性。
1.1 arguments
arguments是一个类数组对象,包含调用函数是传入的所有参数。这个对象只有以function关键字定义函数时才会有(箭头函数没有)。arguments对象还有一个callee属性,指向arguments对象所在函数的指针。
阶乘函数:
function factorial(num) {
if(num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
}
}
console.log(factorial(3)); // 6
像上面的例子函数名称和函数逻辑紧密耦合,如果函数名不一致会导致调用失败。可以使用arguments.callee解耦。
function factorial(num) {
if(num <= 1) {
return 1;
} else {
return num * arguments.callee(num - 1);
}
}
console.log(factorial(3)); // 6
重写之后的factorial()函数已经用arguments.callee代替了之前硬编码的factorial。这意味着无论函数叫什么名称,都可以引用正确的函数。
let trueFactorial = factorial;
factorial = function () {
return 0;
}
console.log(trueFactorial(3)); // 6
console.log(factorial(3)); // 0
这里,trueFactorial变量被赋值为factorial,实际上把同一个函数的指针又保存到了另一个位置。然后,factorial函数又被重写为一个返回0的函数。如果像factorial()最初的版本那样不使用arguments.callee,那么像上面这样调用trueFactorial()就会返回0。不过,通过将函数与名称解耦,trueFactorial()就可以正确计算阶乘,而factorial()则只能返回0。
注:严格模式下不能使用callee
1.2 this
this在标准函数和箭头函数中游不同的行为。
在标准函数中,this指向随调用位置而改变,来看面的例子:
window.color = 'red';
let o = {
color: 'blue'
};
function sayColor() {
console.log(this.color);
}
sayColor(); // 全局调用,this指向window,输出red
o.sayColor = sayColor;
o.sayColor(); // 在对象中调用,this指向o,输出blue
在箭头函数中,this指向定义箭头函数的上下文,就是说箭头函数在哪里定义,this就指向哪里。
let o = {
color: 'blue',
o_sayColor() {
console.log(this.color);
}
};
let sayColor = () => {
console.log(this.color);
}
sayColor(); // red
o.sayColor = sayColor;
o.sayColor(); // red
o.o_sayColor(); // blue
在事件回调或定时回调中调用某个函数时,this值指向的并非想要的对象。此时将回调函数写成箭头函数就可以解决问题。因为箭头函数中的this会保留定义该函数时的上下文。
function a() {
this.aname = 'a';
// this引用a的实例
setTimeout(() => {
console.log(this.aname);
})
}
function b() {
this.bname = 'b';
// this引用window对象
setTimeout(function () {
console.log(this.bname);
})
}
new a(); // a
new b(); // undefined
1.3 caller
ES 5也会给函数对象上添加一个属性:caller。
这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为null。比如:
function outer() {
inner();
}
function inner() {
console.log(inner.caller);
}
outer();
// ƒ outer() {
// inner();
// }
以上代码会显示outer()函数的源代码。这是因为ourter()调用了inner(),inner.caller指向outer()。如果要降低耦合度,则可以通过arguments.callee.caller来引用同样的值:
function outer() {
inner();
}
function inner() {
console.log(arguments.callee.caller);
}
outer();
// ƒ outer() {
// inner();
// }
1.4 new.target
检测函数是否使用new关键字实例化。
如果函数是正常调用的,则new.target的值是undefined;
如果是使用new关键字调用的,则new.target将引用被调用的构造函数。
function f() {
if (!new.target) {
console.log('必须使用new实例化f');
throw '必须使用new实例化f'
}
console.log('使用new实例化f');
}
new f(); // 使用new实例化f
f(); // 必须使用new实例化f
function inner() {
console.log(arguments.callee.caller);
}
2. 函数属性与函数方法
2.1 函数属性
每个函数都有两个属性:length和prototype。
length:函数参数的个数
prototype:保存引用类型所有实例方法,由所有实例共享。
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
2.2 函数方法
函数还有三个方法:apply()、call()和bind()。
这三个方法都会以指定的this值来调用函数。
2.2.1 apply
apply()方法接收两个参数:函数内this的值和一个参数数组。
function sum (a, b) {
return a + b;
}
function applySum1 (a, b) {
return sum.apply(this, arguments);
}
function applySum2 (a, b) {
return sum.apply(this, [a, b]);
}
console.log(applySum1(10, 10)); // 20
console.log(applySum2(15, 15)); // 30
2.2.2 call
call()方法与apply()的作用一样,只是传参的形式不同,必须将参数一个一个地列出来。
function sum (a, b) {
return a + b;
}
function callSum (a, b) {
return sum.call(this, a, b);
}
console.log(callSum(10, 10)); // 20
2.2.3 bind
bind()方法会创建一个新的函数实例,其this值会被绑定到传给bind()的对象。
window.color = 'red';
var o = {
color: 'blue'
};
function sayColor() {
console.log(this.color);
}
let objectSayColor=sayColor.bind(o);
objectSayColor(); //blue
3. 尾调用优化
ECMAScript 6规范新增了一项内存管理优化机制,让JavaScript引擎在满足条件时可以重用栈帧。
具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。
function outerFunction() {
return innerFunction(); // 尾调用
}
3.1 ES6内存管理优化前后对比
在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优化后无论调用多少次嵌套函数,都只有一个栈帧。
ES6尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁
3.2 尾调用优化的条件
尾调用优化的条件就是确定外部栈帧真的没有必要存在了。涉及的条件如下:
(1)代码在严格模式下执行;
(2)外部函数的返回值是对尾调用函数的调用;
(3)尾调用函数返回后不需要执行额外的逻辑;
(4)尾调用函数不是引用外部函数作用域中自由变量的闭包。
下面展示了几个违反上述条件的函数,因此都不符号尾调用优化的要求:
"use strict";
// 无优化:尾调用没有返回
function outerFunction() {
innerFunction();
}
// 无优化:尾调用没有直接返回
function outerFunction() {
let innerFunctionResult=innerFunction();
returninnerFunctionResult;
}
// 无优化:尾调用返回后必须转型为字符串
function outerFunction() {
return innerFunction().toString();
}
// 无优化:尾调用是一个闭包
function outerFunction() {
let foo='bar';
function innerFunction() { returnfoo;}
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
显然这个函数不符合尾调用优化的条件,因为返回语句中有一个相加的操作。结果,fib(n)的栈帧数的内存复杂度是O(2n)。因此,即使这么一个简单的调用也可以给浏览器带来麻烦:
fib(1000);
将递归函数进行尾调用优化
首先,将其重构为满足优化条件的形式,使用两个嵌套的函数,外部函数作为基础框架,内部函数执行递归:
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. 闭包
匿名函数经常被人误认为是闭包(closure)。闭包指的是那些引用了另一个函数作用域中变量的函数,通常在嵌套函数中实现。
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]]来创建其作用域链。接着会创建函数的活动对象(用作变量对象)并将其推入作用域链的前端。
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'
此时返回的字符串是"The Winodw",即全局变量identity的值。为什么匿名函数没有使用其包含作用域(getIdentityFunc())的this对象呢?
前面介绍过,每个函数在被调用时都会自动创建两个特殊变量:this和arguments。内部函数永远不可能直接访问外部函数的这两个变量。但是,如果把this保存到闭包可以访问的另一个变量中,则是行得通的。比如:
window.identity = 'The Window';
let object = {
identity: 'My Object',
getIdentityFunc() {
let that=this;
return function() {
returnthat.identity;
};
}
};
console.log(object.getIdentityFunc()()); // 'My Object'
在定义匿名函数之前,先把外部函数的this保存到变量that中。然后在定义闭包时,就可以让它访问that。
4.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对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收。
5. 私有变量
任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。私有变量包括函数参数、局部变量,以及函数内部定义的其他函数。来看下面的例子:
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实例而言都是独一无二的,因为每次调用构造函数都会重新创建一套变量和方法。不过这样也有个问题:必须通过构造函数来实现这种隔离。构造函数模式的缺点是每个实例都会重新创建一遍新方法。使用静态私有变量实现特权方法可以避免这个问题。
5.1 静态私有变量
特权方法也可以通过使用私有作用域定义私有变量和函数来实现。这个模式如下所示:
let MyObject;
(function () {
// 私有变量和私有函数
let privateVariable = 10;
function privateFunction() {
return false;
}
// 构造函数
MyObject = function () {
};
// 公有和特权方法
MyObject.prototype.publicMethod = function () {
privateVariable++;
return privateFunction();
};
})();
在这个模式中,匿名函数表达式创建了一个包含构造函数及其方法的私有作用域。首先定义的是私有变量和私有函数,然后又定义了构造函数和公有方法。公有方法定义在构造函数的原型上,与典型的原型模式一样。注意,这个模式定义的构造函数没有使用函数声明,使用的是函数表达式。函数声明会创建内部函数,在这里并不是必需的。基于同样的原因(但操作相反),这里声明MyObject并没有使用任何关键字。因为不使用关键字声明的变量会创建在全局作用域中,所以MyObject变成了全局变量,可以在这个私有作用域外部被访问。注意在严格模式下给未声明的变量赋值会导致错误。
这个模式与前一个模式的主要区别就是,私有变量和私有函数是由实例共享的。因为特权方法定义在原型上,所以同样是由实例共享的。特权方法作为一个闭包,始终引用着包含它的作用域。来看下面的例子:
let Person;
(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变量设置为一个新值。而所有实例都会返回相同的值。
像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。
5.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();
}
};
}();
5.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;
}();