函数
函数实际上也是对象,每个函数都是 Function 类型的实例,而 Function 也有属性和方法,跟其他引用类型一样。因为函数是对象,所以函数名就是指向函数对象的指针。函数有四种方式定义:
function sum(num1, num2) {
return num1 + num2;
} // 注意这种方式定义的函数最后没有加分号
这里,代码定义了一个变量 sum 并将其初始化为一个函数。注意 function 关键字后面没有名称,因为不需要。这个函数可以通过变量 sum 来引用。
let sum = function (num1, num2) {
return num1 + num2;
};
let sum = (num1, num2) => {
return num1 + num2;
};
最后一种定义函数的方式是使用 Function 构造函数。这个构造函数接收任意多个字符串参数,最后一个参数始终会被当成函数体,而之前的参数都是新函数的参数。来
let sum = new Function('num1', 'num2', 'return num1 + num2'); // 不推荐
不推荐使用这种语法来定义函数,因为这段代码会被解释两次:第一次是将它当作常规 ECMAScript 代码,第二次是解释传给构造函数的字符串。这显然会影响性能。
箭头函数
ECMAScript 6 新增了使用胖箭头(=>)语法定义函数表达式的能力。很大程度上,箭头函数实例化的函数对象与正式的函数表达式创建的函数对象行为是相同的。任何可以使用函数表达式的地方,都可以使用箭头函数:
let arrowSum = (a, b) => {
return a + b;
};
// 两者几乎是等价的
let functionExpressionSum = function (a, b) {
return a + b;
};
如果只有一个参数,那也可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号。
// 以下两种写法都有效
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; };
箭头函数也可以不用大括号,但这样会改变函数的行为。使用大括号就说明包含“函数体”,可以在一个函数中包含多条语句,跟常规的函数一样。如果不使用大括号,那么箭头后面就只能有一行代码,比如一个赋值操作,或者一个表达式。而且,省略大括号会隐式返回这行代码的值:
// 以下两种写法都有效,而且返回相应的值
let double = x => {
return 2 * x;
};
let triple = x => 3 * x;
// 可以赋值
let value = {};
let setName = x => (x.name = 'Matt');
setName(value);
console.log(value.name); // "Matt"
// 无效的写法:
// let multiply = (a, b) => return a * b;
箭头函数虽然语法简洁,但也有很多场合不适用。箭头函数不能使用 arguments
、super
和 new.target
,也不能用作构造函数。此外,箭头函数也没有 prototype
属性。
函数名
因为函数名就是指向函数的指针,所以它们跟其他包含对象指针的变量具有相同的行为。这意味着,当您将一个函数赋值给一个变量,实际上是将函数对象的引用(指针)赋给了该变量。这样,该变量就成为了对函数的引用,可以通过该变量调用函数。
function myFunction() {
console.log('Hello, World!');
}
// 函数名是指向函数对象的指针
const functionReference = myFunction;
// 调用函数
functionReference(); // 输出: Hello, World!
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
如果函数是一个 getter
函数、setter
函数,或者使用 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;
},
};
/**
* Object.getOwnPropertyDescriptor 它用于获取一个对象自身属性的属性描述符
*/
let propertyDescriptor = Object.getOwnPropertyDescriptor(dog, 'age');
console.log(propertyDescriptor.get.name); // get age
console.log(propertyDescriptor.set.name); // set age
理解参数
ECMAScript 函数的参数跟大多数其他语言不同。ECMAScript 函数既不关心传入的参数个数,也不关心这些参数的数据类型。定义函数时要接收两个参数,并不意味着调用时就传两个参数。你可以传一个、三个,甚至一个也不传,解释器都不会报错。
之所以会这样,主要是因为 ECMAScript 函数的参数在内部表现为一个数组。函数被调用时总会接收一个数组,但函数并不关心这个数组中包含什么。如果数组中什么也没有,那没问题;如果数组的元素超出了要求,那也没问题。事实上,在使用 function 关键字定义(非箭头)函数时,可以在函数内部访问 arguments 对象,从中取得传进来的每个参数值。
arguments
对象是一个类数组对象(但不是 Array 的实例),因此可以使用中括号语法访问其中的元素(第一个参数是 arguments[0]
,第二个参数是 arguments[1]
)。而要确定传进来多少个参数,可以访问 arguments.length
属性。
function sum() {
// arguments 是类数组,不具备数组的 reduce 方法,故必须将 arguments 转成数组
return Array.from(arguments).reduce((a, b) => a + b, 0);
}
console.log(sum(1, 2, 3, 4, 5)); // 15
通过使用 arguments
对象,函数可以在内部访问传递的所有参数,使得函数更加灵活,不受严格的函数签名限制。通过检查 arguments.length
属性,可以确定传递给函数的实际参数个数。此外,arguments
对象与命名参数之间存在同步关系,修改其中一个会影响到另一个。
function people(name, age) {
console.log(`我叫${name}, 今年${age}岁`);
// 通过 arguments 修改
arguments[0] = '王大锤';
console.log(`我叫${name}, 今年${age}岁`);
}
people('AGoodBooK', 1);
// 我叫AGoodBooK, 今年1岁
// 我叫王大锤, 今年1岁
严格模式下,arguments
会有一些变化。首先,像前面那样给 arguments[0]
赋值不会再影响 name 的值。就算把 arguments[0]
设置为 "王大锤",name 的值仍然还是传入的值。其次,在函数中尝试重写 arguments
对象会导致语法错误。
箭头函数中的参数
如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用 arguments
关键字访问,而只能通过定义的命名参数访问。
// 直接抛出异常
let bar = () => {
console.log(arguments[0]);
};
bar(5); // ReferenceError: arguments is not defined
虽然箭头函数中没有 arguments
对象, 但可以在包装函数中把它提供给箭头函数,箭头函数继承了其父级作用域的 arguments
对象。
function foo() {
let bar = () => {
console.log(arguments[0]); // 5
};
bar();
}
foo(5);
剩余参数 允许函数接受不定数量的参数,并将它们收集到一个数组中。箭头函数可以利用这个语法特点获取全部参数,例如:
let sum = (...args) => {
return args.reduce((a, b) => a + b, 0);
};
console.log(sum(1, 2, 3, 4, 5)); // 15
没有重载
在编程中,函数签名是指函数的名称、参数列表和返回类型的组合。函数签名用于唯一标识一个函数。因Javascript函数特性故没有固定的签名;
重载:重载指的是在同一类中,可以定义多个具有相同名称但参数列表不同的函数。
ECMAScript 函数不能像传统编程那样重载。在其他语言比如 Java 中,一个函数可以有两个定义,只要签名(接收参数的类型和数量)不同就行。如前所述,ECMAScript 函数没有签名,因为参数是由包含零个或多个值的数组表示的。没有函数签名,自然也就没有重载。
/**
* 重复定义函数,后者覆盖前者
*/
function addSomeNumber(num) {
return num + 100;
}
function addSomeNumber(num) {
return num + 200;
}
console.log(addSomeNumber(100)); // 300
默认参数值
ECMAScript5.1 及以前,实现默认参数的一种常用方式就是检测某个参数是否等于 undefined,如果是则意味着没有传这个参数,那就给它赋一个值:
function dog(name) {
name = typeof name !== 'undefined' ? name : '狗';
return name;
}
console.log(dog()); // 狗
console.log(dog('贵宾犬')); // 贵宾犬
ECMAScript 6 之后就不用这么麻烦了,因为它支持显式定义默认参数了。只要在函数定义中的参数后面用=
就可以为参数赋一个默认值,像 (params = defaultValue)
// 与前面代码等价的 ES6 写法
function dog(name = '狗') {
return name;
}
console.log(dog()); // 狗
console.log(dog('贵宾犬')); // 贵宾犬
在使用默认参数时,arguments 对象的值不反映参数的默认值,只反映传给函数的参数。当然,跟 ES5 严格模式一样,修改命名参数也不会影响 arguments 对象,它始终以调用函数时传入的值为准:
/**
* 如果开启了严格模式 arguments 修改参数不会导致同步
* 若没有开启严格莫模式,只使用 params = defaultValue 这种赋予默认参数值,参数最后也不会导致被修改
*/
function dog(name = '狗') {
arguments[0] = '默认值';
return name;
}
console.log(dog()); // 狗
console.log(dog('贵宾犬')); // 贵宾犬
默认参数值并不限于原始值或对象类型,也可以使用调用函数返回的值:
function getName() {
return '贵宾犬';
}
function dog(name = getName()) {
return name;
}
console.log(dog()); // 贵宾犬
当我们定义一个函数时,函数的参数并不是在函数定义的地方求值的,而是在函数被调用的时候在其执行的上下文(作用域)中求值的。这意味着函数参数的默认值可以是一个对象,也可以是通过动态调用其他函数得到的结果。
function greet(name = getDefaultName()) {
console.log(`Hello, ${name}!`);
}
function getDefaultName() {
console.log('Calculating default name');
return 'Guest';
}
// 当给 greet 传递了一个明确的参数,则不会触发赋予默认值,也就导致没调用 getDefaultName 函数
greet('John'); // Hello John
函数的默认参数是在函数被调用时才会进行计算和求值,而不是在函数定 义时。此外,只有在调用函数时没有传递相应的参数时,才会触发计算默认值的函数。
默认参数作用域与暂时性死区
因为在求值默认参数时可以定义对象,也可以动态调用函数,所以函数参数肯定是在某个作用域中求值的。给多个参数定义默认值实际上跟使用 let 关键字顺序声明变量一样。
function makeKing(name = 'Henry', numerals = 'VIII') {
return `King ${name} ${numerals}`;
}
// 默认参数会按照定义它们的顺序依次被初始化,像下面这样
function copyMakeKing() {
let name = 'Henry';
let numerals = 'VIII';
return `King ${name} ${numerals}`;
}
因为参数是按顺序初始化的,所以后定义默认值的参数可以引用先定义的参数。
function makeKing(name = 'Henry', numerals = name) {
return `King ${name} ${numerals}`;
}
console.log(makeKing()); // King Henry Henry
参数初始化顺序遵循“暂时性死区”规则,即前面定义的参数不能引用后面定义的。像这样就会抛出错误:
// 不允许去使用后者定义的,给前者赋值,这会导致默认值失败
function makeKing(name = numerals, numerals = 'VIII') {
return `King ${name} ${numerals}`;
}
console.log(makeKing()); // ReferenceError: Cannot access 'numerals' before initialization
// 参数也存在于自己的作用域中,它们不能引用函数体的作用域:
function copyMakeKing(name = 'Henry', numerals = defaultNumeral) {
let defaultNumeral = 'VIII';
return `King ${name} ${numerals}`;
}
console.log(copyMakeKing()); // ReferenceError: defaultNumeral is not defined
参数扩展与收集
ECMAScript 6 新增了扩展操作符 ...
,使用它可以非常简洁地操作和组合集合数据。扩展操作符最有用的场景就是函数定义中的参数列表,在这里它可以充分利用这门语言的弱类型及参数长度可变的特点。扩展操作符既可以用于调用函数时传参,也可以用于定义函数参数。
扩展参数
扩展运算符能将数组(包括 arguments 类数组)拆分成单个元素。
// (1)合并数组
let arr1 = [1, 2, 3];
let arr2 = [4, 5, 6];
let mergeArr = [...arr1, ...arr2];
console.log(mergeArr); // [ 1, 2, 3, 4, 5, 6 ]
// (2)函数传参
function getSum() {
let sum = 0;
for (let i = 0; i < arguments.length; ++i) {
sum += arguments[i];
}
return sum;
}
console.log(getSum(mergeArr)); // 01,2,3,4,5,6 这结果不是预想,该函数无法接收数组进行求和
// 对函数参数进行扩展 ...mergeArr === 1,2,3,4,5,6
console.log(getSum(...mergeArr)); // 21
对函数中的 arguments
对象而言,它并不知道扩展操作符的存在,而是按照调用函数时传入的参数接收每一个值,也就是扩展后的参数传入。
arguments
是类数组,可以使用扩展运算符进行扩展,得到数组 [...arguments]
剩余参数
剩余参数(Rest Parameters)语法最初是在 ECMAScript 2015(ES6)中引入的。在 ES6 中,剩余参数会收集其余的参数;如果没有则会得到空数组。因为收集参数的结果可变,所以只能把它作为最后一个参数:
function ignoreFirst(firstValue, ...values) {
console.log(values);
}
// 错误收集方式:ignoreFirst(...values, lastValue)
函数声明与函数表达式
事实上,JavaScript 引擎在加载数据时对它们是区别对待的。JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。
console.log(sum(10, 10));
// 函数声明提升, 因此会先定义函数,后执行 console.log 方法
function sum(num1, num2) {
return num1 + num2;
}
在执行代码时,JavaScript 引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部。因此即使函数定义出现在调用它们的代码之后,引擎也会把函数声明提升到顶部。如果把前面代码中的函数声明改为等价的函数表达式,那么执行的时候就会出错:
// 抛出异常
// ReferenceError: Cannot access 'sum' before initialization
console.log(sum(10, 10));
let sum = function (num1, num2) {
return num1 + num2;
};
let
不是存在变量提升吗?怎么不行 由上面的报错可知,let
是会变量提升,但并没有初始化,因此它是 undefined
,并不是函数。
// 对比报错信息,当没有声明 sum
// ReferenceError: sum is not defined
console.log(sum(10, 10));
函数作为参数
因为函数名在 ECMAScript 中就是变量,所以函数可以用在任何可以使用变量的地方。这意味着不仅可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另一个函数
/*(1)函数作为参数*/
function getName() {
return '王维';
}
function people(getName) {
let name = getName();
return name;
}
// 不要关注案例实现,关注用法
console.log(people(getName)); // 王维
/*(2)函数返回另一个函数*/
function copyGetName(name) {
return function () {
return name;
};
}
console.log(people(copyGetName('李白'))); // 李白
函数内部
在 ECMAScript 5 中,函数内部存在两个特殊的对象:arguments
和 this
。ECMAScript 6 又新增了 new.target
属性。
arguments.callee
不再推荐使用该特性。虽然一些浏览器仍然支持它,但也许已从相关的 web 标准中移除,也许正准备移除或出于兼容性而保留。
arguments
对象前面讨论过多次了,它是一个类数组对象,包含调用函数时传入的所有参数。这个对象只有以 function 关键字定义函数(相对于使用箭头语法创建函数)时才会有。虽然主要用于包含函数参数,但 arguments
对象其实还有一个 callee
属性,是一个指向 arguments
对象所在函数的指针。
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * arguments.callee(num - 1);
}
}
console.log(factorial(5)); // 120
// 等价于如下递归函数
function copyFactorial(num) {
if (num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
}
}
console.log(copyFactorial(5)); // 120
在严格模式 ("use strict") 中,访问 arguments.callee
会导致 TypeError 错误。使用 arguments.callee
可能导致性能问题,因为它会使 JavaScript 引擎难以进行一些优化。在很多情况下,直接使用函数的引用(函数名)会更加高效。
this
this
在标准函数和箭头函数中有不同的行为。
在标准函数中,this 引用的是把函数当成方法调用的上下文对象,这时候通常称其为 this 值(在网页的全局上下文中调用函数时,this 指向 windows)。
window.color = 'red';
let o = {
color: 'blue',
sayColor: sayColor,
};
function sayColor() {
console.log(this.color);
}
sayColor(); // red
o.sayColor(); // blue
在箭头函数中,this
引用的是定义箭头函数的上下文。
window.color = 'red';
let o = {
color: 'blue',
};
function sayColor() {
console.log(this.color);
}
o.sayColor = sayColor;
sayColor(); // 'red'
o.sayColor(); // 'blue'
函数名只是保存指针的变量。因此全局定义的 sayColor()
函数和 o.sayColor()
是同一个函数,只不过执行的上下文不同。
在事件回调或定时回调中调用某个函数时,this
值指向的并非想要的对象。此时将回调函数写成箭头函数就可以解决问题。这是因为箭头函数中的 this
会保留定义该函数时的上下文:
class King {
constructor() {
this.royaltyName = 'Henry';
// this 引用 King 的实例
setTimeout(() => console.log(this.royaltyName), 1000);
}
}
class Queen {
constructor() {
this.royaltyName = 'Elizabeth';
// this 引用 window 对象
setTimeout(function () {
console.log(this.royaltyName);
}, 1000);
}
}
new King(); // Henry
new Queen(); // undefined
caller
不再推荐使用该特性。虽然一些浏览器仍然支持它,但也许已从相关的 web 标准中移除,也许正准备移除或出于兼容性而保留。
caller
是 JavaScript 中的一个非标准属性,用于访问调用当前函数的函数。然而,caller
在现代 JavaScript 中已被弃用,且在严格模式 ("use strict") 下会导致错误。
在非严格模式下,可以通过 arguments.callee.caller
或 xxxF.caller
来访问调用当前函数的函数。例如:
function outer() {
inner();
}
function inner() {
console.log(inner.caller);
// 如果要 降低耦合度,
console.log(arguments.callee.caller);
}
outer();
new.target
ECMAScript 中的函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。ECMAScript 6 新增了检测函数是否使用 new
关键字调用的 new.target
属性。如果函数是正常调用的,则 new.target
的值是 undefined
;如果是使用 new
关键字调用的,则 new.target
将引用被调用的构造函数。
function King() {
if (!new.target) {
throw 'King 函数是通过调用的';
}
console.log('King 函数通过 new 关键字调用');
}
new King(); // King 函数通过 new 关键字调用
King(); // Error: King 函数是通过调用的
new.target
主要用于在构造函数内部判断是否通过 new
关键字调用。它有助于在构造函数中执行一些初始化逻辑或者强制要求正确使用 new
关键字,以防止在没有 new
的情况下误用构造函数。
// 该 createClass 函数必须使用 new 关键字,否则会导致函数
function MyConstructor() {
if (new.target === undefined) {
throw new Error('Constructor must be called with new keyword');
}
// 构造函数的其他逻辑
}
// 正确使用
const instance = new MyConstructor();
// 错误使用
const instanceWithoutNew = MyConstructor(); // 抛出错误
函数属性与方法
前面提到过,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
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() {
// this 指向 window
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
在严格模式下,调用函数时如果没有指定上下文对象,则 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
apply()
和 call()
真正强大的地方并不是给函数传参,而是控制函数调用上下文即函数体内 this
值的能力。
window.color = 'red';
let o = {
color: 'blue',
};
function sayColor() {
console.log(this.color);
}
// 通过改变 this 指向,影响最终结果
sayColor(); // red
sayColor.call(this); // red
sayColor.call(window); // red
sayColor.call(o); // blue
ECMAScript 5 出于同样的目的定义了一个新方法:bind()
。bind()
方法会创建一个新的函数实例,其 this 值会被绑定到传给 bind()
的对象。同时,还可以传入一系列指定的参数,这些参数会插入到调用新函数时传入的参数的前面, 如: bind(thisArg, arg1, arg2, /* …, */ argN)
。
window.color = 'red';
var o = {
color: 'blue',
};
function sayColor() {
console.log(this.color);
}
let objectSayColor = sayColor.bind(o);
objectSayColor(); // blue
函数表达式
函数表达式看起来就像一个普通的变量定义和赋值,即创建一个函数再把它赋值给一个变量functionName。这样创建的函数叫作匿名函数(anonymous function),因为 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!');
};
}
递归
递归函数通常的形式是一个函数通过名称调用自己,如下面的例子所示:
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
}
}
尾调用优化
ECMAScript 6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧。具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。比如:
function outerFunction() {
return innerFunction(); // 尾调用
}
在 ES6 优化之前,执行这个例子会在内存中发生如下操作。
- 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
- 执行 outerFunction 函数体,到 return 语句。计算返回值必须先计算 innerFunction。
- 执行到 innerFunction 函数体,第二个栈帧被推到栈上。
- 执行 innerFunction 函数体,计算其返 回值。
- 将返回值传回 outerFunction,然后 outerFunction 再返回值。
- 将栈帧弹出栈外。
在 ES6 优化之后,执行这个例子会在内存中发生如下操作。
- 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
- 执行 outerFunction 函数体,到达 return 语句。为求值返回语句,必须先求值 innerFunction。
- 引擎发现把第一个栈帧弹出栈外也没问题,因为 innerFunction 的返回值也是 outerFunction 的返回值。
- 弹出 outerFunction 的栈帧。
- 执行到 innerFunction 函数体,栈帧被推到栈上。
- 执行 innerFunction 函数体,计算其返回值。
- 将 innerFunction 的栈帧弹出栈外。
很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。这就是 ES6 尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。
现在还没有办法测试尾调用优化是否起作用。不过,因为这是 ES6 规范所规定的,兼容的浏览器实现都能保证 在代码满足条件的情况下应用这个优化。
尾调用优化的条件
尾调用优化的条件就是确定外部栈帧真的没有必要存在了。涉及的条件如下:
- 代码在严格模式下执行;
- 代外部函数的返回值是对尾调用函数的调用;
- 尾调用函数返回后不需要执行额外的逻辑;
- 尾调用函数不是引用外部函数作用域中自由变量的闭包;
'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,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性
尾调用优化的代码
可以通过把简单的递归函数转换为待优化的代码来加深对尾调用优化的理解。下面是一个通过递归计算斐波纳契数列的函数:
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()
因此,即使这么一个简单的调用也可以给浏览器带来麻烦:
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)
就不会对浏览器造成威胁了。
闭包
匿名函数经常被人误认为是闭包(closure)。闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。
function outer() {
let value = 10;
function inter() {
// 引用了外部作用域变量 value
return value;
}
inter();
}
outer();
理解作用域链创建和使用的细节对理解闭包非常重要。
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
的值。
内存泄露
由于 IE 在 IE9 之前对 JScript 对象和 COM 对象使用了不同的垃圾回收机制,所以闭包在这些旧版本 IE 中可能会导致问题。在这些版本的 IE 中,把 HTML 元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁。来看下面的例子:
function assignHandler() {
let element = document.getElementById('someElement');
element.onclick = () => console.log(element.id);
}
由于事件处理程序是一个箭头函数,它捕获了 assignHandler() 函数的活动对象,其中包含了 element 变量。这导致了一个循环引用,因为 element 对象的事件处理程序中引用了这个箭头函数,而这个箭头函数又引用了 assignHandler() 函数的作用域,形成了循环引用。
匿名函数引用着 assignHandler()的活动对象,阻止了对element 的引用计数归零。只要这个匿名函数存在,element 的引用计数就至少等于 1。也就是说,内存不会被回收。
其实只要这个例子稍加修改,就可以避免这种情况,比如:
function assignHandler() {
let element = document.getElementById('someElement');
let id = element.id;
element.onclick = () => console.log(id);
element = null;
}
在 JavaScript 中,特别是在涉及到 DOM 元素和事件处理时,确保在不需要时断开引用是很重要的。通过将 element 设置为 null,可以有效地解除了对 DOM 元素的引用,有助于释放相关的内存资源。
立即调用的函数表达式
立即调用的匿名函数又被称作立即调用的函数表达式(IIFE,Immediately Invoked FunctionExpression)。它类似于函数声明,但由于被包含在括号中,所以会被解释为函数表达式。紧跟在第一组括号后面的第二组括号会立即调用前面的函数表达式。下面是一个简单的例子:
(function () {
// 块级作用域
})();
使用 IIFE 可以模拟块级作用域,即在一个函数表达式内部声明变量,然后立即调用这个函数。这样位于函数体作用域的变量就像是在块级作用域中一样。ECMAScript 5 尚未支持块级作用域,使用 IIFE模拟块级作用域是相当普遍的。比如下面的例子:
// IIFE
(function () {
for (var i = 0; i < count; i++) {
console.log(i);
}
})();
console.log(i); // 抛出错误
在 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');
// 达不到目的!
// var 声明时,所有事件监听器中的 i 都会共享同一个变量,导致闭包中的 i 始终是循环结束后的值。
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');
/**
* addEventListener 第二个参数使用 IIFE 函数,来获取每次迭代的 i 值进行锁定传入,后续不会被影响
* return 匿名函数,当点击时去执行该函数,故在这里不能使用箭头函数
*/
for (var i = 0; i < divs.length; ++i) {
divs[i].addEventListener(
'click',
(function (frozenCounter) {
return function () {
console.log(frozenCounter);
};
})(i),
);
}
而使用 ECMAScript 块级作用域变量,就不用这么大动干戈了,直接通过 let
关键字声明 i,因为 let
有块级作用域。
// 由于使用了 let 声明变量 i,每次循环都会创建一个新的块级作用域,因此每个事件监听器中的 i 都是独立的。
for (let i = 0; i < divs.length; ++i) {
divs[i].addEventListener('click', function () {
console.log(i);
});
}
私有变量
严格来讲,JavaScript 没有私有成员的概念,所有对象属性都公有的。不过,倒是有私有变量的概念。任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。 私有变量包括函数参数、局部变量,以及函数内部定义的其他函数。
/**
* @param num1 私有变量
* @param num2 私有变量
* sum 私有变量
*/
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();
};
}
这个模式是把所有私有变量和私有函数都定义在构造函数中。 然后,再创建一个能够访问这些私有成员的特权方法。这样做之所以可行,是因为定义在构造函数中的特权方法其实是一个闭包,它具有访问构造函数中定义的所有变量和函数的能力。
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 实例而言都是独一无二的,因为每次调用构造函数都会重新创建一套变量和方法。不过这样也有个问题:必须通过构造函数来实现这种隔离。但构造函数模式的缺点是每个实例都会重新创建一遍新方法。使用静态私有变量实现特权方法可以避免这个问题。
静态私有变量
特权方法也可以通过使用私有作用域定义私有变量和函数来实现。
在这个模式中,匿名函数表达式创建了一个包含构造函数及其方法的私有作用域。首先定义的是私有变量和私有函数,然后又定义了构造函数和公有方法。公有方法定义在构造函数的原型上,与典型的原型模式一样。
(function () {
// 私有变量和私有函数
let privateVariable = 10;
function privateFunction() {
return false;
}
// 构造函数
// 这里声明 MyObject 并没有使用任何关键字, 使其创建在全局作用域中,在严格模式下给未声明的变量赋值会导致错误。
MyObject = function () {};
// 公有和特权方法
MyObject.prototype.publicMethod = function () {
privateVariable++;
return privateFunction();
};
})();
这个模式与前一个模式的主要区别就是,私有变量和私有函数是由实例共享的。因为特权方法定义在原型上,所以同样是由实例共享的。特权方法作为一个闭包,始终引用着包含它的作用域。如下进阶版:
(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 变量设置为一个新值。而所有实例都会返回相同的值。
像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。
使用闭包和私有变量会导致作用域链变长,作用域链越长,则查找变量所需的时间也越多。
模块模式
前面的模式通过自定义类型创建了私有变量和特权方法。而下面要讨论的 Douglas Crockford 所说的模块模式,则在一个单例对象上实现了相同的隔离和封装。单例对象(singleton)就是只有一个实例的对象。按照惯例,JavaScript 是通过对象字面量来创建单例对象的,如下面的例子所示:
let singleton = {
name: value,
method() {
// 方法的代码
},
};
模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法。模块模式的样板代码如下:
let singleton = (function () {
// 私有变量和私有函数
let privateName = 'JavaScript';
function modifyName(newName) {
privateName = newName;
}
// 共有方法
return {
getName: function () {
return privateName;
},
setName: modifyName,
};
})();
console.log(singleton.getName()); // JavaScript
singleton.setName('Java');
console.log(singleton.getName()); // Java
模块模式使用了匿名函数返回一个对象。在匿名函数内部,首先定义私有变量和私有函数。之后,创建一个要通过匿名函数返回的对象字面量。这个对象字面量中只包含可以公开访问的属性和方法。因为这个对象定义在匿名函数内部,所以它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。
模块增强模式
另一个利用模块模式的做法是在返回对象之前先对其进行增强。这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。
class CustomType {}
let singleton = (function () {
// 私有变量和私有函数
let privateName = 'JavaScript';
function modifyName(newName) {
privateName = newName;
}
// object 必须是CustomType类实例
let object = new CustomType();
// 添加特权/公有属性和方法
object.getName = function () {
return privateName;
};
object.setName = modifyName;
// 返回对象
return object;
})();
console.log(singleton.getName()); // JavaScript
singleton.setName('Java');
console.log(singleton.getName()); // Java