- 函数
本章内容
- 函数表达式、函数声明及箭头函数
- 默认参数及扩展操作符
- 使用函数实现递归
- 使用闭包实现私有变量
函数实际上是对象。每个函数都是 Function 类型的实例,而 Function 也有属性和方法,跟其他引用类型一样。
因为函数是对象,所以函数名就是指向函数对象的指针,而且不一定与函数本身紧密绑定。
定义函数的 4 种方式:
// 1 函数声明
function sum(num1, num2) {
return num1 + num2;
}
// 2 函数表达式
// 因为 function 关键字后面没有标识符,叫作匿名函数。(匿名函数有也时候也被称为兰姆达函数)。
let sum = function (num1, num2) {
return num1 + num2;
};
// 3 箭头函数
let sum = (num1, num2) => {
return num1 + num2;
};
// 4 使用 Function 构造函数(不推荐)
// 因为这段代码会被解释两次:第一次是将它当作常规ECMAScript 代码,第二次是解释传给构造函数的字符串。这显然会影响性能。
let sum = new Function("num1", "num2", "return num1 + num2"); // 不推荐
- 函数声明的关键特点是函数声明提升,即函数声明会在代码执行之前获得定义。
- 这意味着函数声明可以出现在调用它的代码之后。
- 函数表达式看起来就像一个普通的变量定义和赋值,即创建一个函数再把它赋值给一个变量 functionName 。
- 这样创建的函数叫作匿名函数(anonymous funtion),因为 function 关键字后面没有标识符。(匿名函数有也时候也被称为兰姆达函数)
- 理解函数声明与函数表达式之间的区别,关键是理解提升。
ECMAScript 6 新增了使用胖箭头( => )语法定义函数表达式的能力。
很大程度上,箭头函数实例化的函数对象与正式的函数表达式创建的函数对象行为是相同的。任何可以使用函数表达式的地方,都可以使用箭头函数。
- 箭头函数简洁的语法非常适合嵌入函数的场景
- 如果只有一个参数,那也可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号。
- 箭头函数也可以不用大括号,但这样会改变函数的行为。
- 如果不使用大括号,那么箭头后面就只能有一行代码,省略大括号会隐式返回这行代码的值。
- 箭头函数不能使用 arguments 、 super 和 new.target ,也不能用作构造函数。此外,箭头函数也没有 prototype 属性。
// 以下两种写法都有效,而且返回相应的值
let double = (x) => {
return 2 * x;
};
let triple = (x) => 3 * x;
因为函数名就是指向函数的指针,所以它们跟其他包含对象指针的变量具有相同的行为。这意味着一个函数可以有多个名称。
- 使用不带括号的函数名会访问函数指针,而不会执行函数。
- ECMAScript 6 的所有函数对象都会暴露一个只读的 name 属性,其中包含关于函数的信息。
- 即使函数没有名称,也会如实显示成空字符串。
- 如果它是使用 Function 构造函数创建的,则会标识成 "anonymous"
- 如果函数是一个获取函数(get())、设置函数(set()),或者使用 bind() 实例化,那么标识符前面会加上一个前缀。
function foo() {}
// 使用 bind() 实例化,标识符前面会加上一个前缀
console.log(foo.bind(null).name); // bound foo
let dog = {
years: 1,
// 获取函数
get age() {
return this.years;
},
// 设置函数
set age(newAge) {
this.years = newAge;
},
};
let propertyDescriptor = Object.getOwnPropertyDescriptor(dog, "age");
// set(),get() 标识符前面会加上一个前缀
console.log(propertyDescriptor.get.name); // get age
console.log(propertyDescriptor.set.name); // set age
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
-
ECMAScript 函数既不关心传入的参数个数,也不关心这些参数的数据类型。
- 定义函数时要接收两个参数,并不意味着调用时就传两个参数。你可以传一个、三个,甚至一个也不传,解释器都不会报错。
-
主要是因为 ECMAScript 函数的参数在内部表现为一个数组。
- 函数被调用时总会接收一个数组,但函数并不关心这个数组中包含什么。
- 如果数组中什么也没有,那没问题;
- 如果数组的元素超出了要求,那也没问题。
- 事实上,在使用 function 关键字定义(非箭头)函数时,可以在函数内部访问 arguments 对象,从中取得传进来的每个参数值。
- arguments 对象是一个类数组对象(但不是 Array 的实例),因此可以使用中括号语法访问其中的元素(第一个参数是 arguments[0] ,第二个参数是 arguments[1] )。
- 而要确定传进来多少个参数,可以访问 arguments.length 属性。
-
ECMAScript 函数的参数只是为了方便才写出来的,并不是必须写出来的。
- 与其他语言不同,在 ECMAScript 中的命名参数不会创建让之后的调用必须匹配的函数签名。这是因为根本不存在验证命名参数的机制。
-
arguments 对象可以跟命名参数一起使用。
-
arguments 对象的的值始终会与对应的命名参数同步。
-
arguments 对象的长度是根据传入的参数个数,而非定义函数时给出的命名参数个数确定的。
-
对于命名参数而言,如果调用函数时没有传这个参数,那么它的值就是 undefined 。
-
严格模式下, arguments 会有一些变化。
- 首先,像
function doAdd(num1, num2) {...}
中给 arguments[1] 赋值不会再影响 num2 的值。就算把 arguments[1] 设置为 10, num2 的值仍然还是传入的值。 - 其次,在函数中尝试重写 arguments 对象会导致语法错误。(代码也不会执行。)
- 首先,像
function doAdd() {
if (arguments.length === 1) {
console.log(arguments[0] + 10);
} else if (arguments.length === 2) {
console.log(arguments[0] + arguments[1]);
} else {
console.log(arguments.length);
}
}
doAdd(10); // 20
doAdd(30, 20); // 50
doAdd(); // 0
doAdd(1, 2, 3, 4, 5); // 5
如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用 arguments 关键字访问,而只能通过定义的命名参数访问。
// 函数声明里可以取得arguments对象
function foo() {
console.log(arguments[0]);
}
foo(5); // 5
// 箭头函数中无arguments对象
let bar = () => {
console.log(arguments[0]);
};
bar(5); // {} // chrome中会报错: ReferenceError: arguments is not defined
// 虽然箭头函数中没有 arguments 对象,但可以在包装函数中把它提供给箭头函数
function foo2() {
let bar2 = () => {
console.log(arguments[0]); // 5
};
bar2();
}
foo2(5);
注意: ECMAScript 中的所有参数都按值传递的。不可能按引用传递参数。如果把对象作为参数传递,那么传递的值就是这个对象的引用。
ECMAScript 函数不能像传统编程那样重载:
- 在其他语言比如 Java 中,一个函数可以有两个定义,只要签名(接收参数的类型和数量)不同就行。
- 如前所述,ECMAScript 函数没有签名,因为参数是由包含零个或多个值的数组表示的。没有函数签名,自然也就没有重载。
- 如果在 ECMAScript 中定义了两个同名函数,则后定义的会覆盖先定义的。
- 把函数名当成指针也有助于理解为什么 ECMAScript 没有函数重载。
- 可以通过检查参数的类型和数量,然后分别执行不同的逻辑来模拟函数重载。
在 ECMAScript5.1 及以前,实现默认参数的一种常用方式就是检测某个参数是否等于 undefined ,如果是则意味着没有传这个参数,那就给它赋一个值。
ECMAScript 6 之后支持显式定义默认参数。只要在函数定义中的参数后面用 = 就可以为参数赋一个默认值。
- 给参数传 undefined 相当于没有传值,不过这样可以利用多个独立的默认值。
- 在使用默认参数时, arguments 对象的值不反映参数的默认值,只反映传给函数的参数。
- 跟 ES5 严格模式一样,修改命名参数也不会影响 arguments 对象,arguments 对象始终以调用函数时传入的值为准。
- 默认参数值并不限于原始值或对象类型,也可以使用调用函数返回的值。
- 函数的默认参数只有在函数被调用时才会求值,不会在函数定义时求值。而且,计算默认值的函数只有在调用函数但未传相应参数时才会被调用。
- 箭头函数同样也可以这样使用默认参数,只不过在只有一个参数时,就必须使用括号而不能省略。
/** 1 默认参数 */
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'
/** 2 在使用默认参数时, arguments 对象的值不反映参数的默认值,只反映传给函数的参数。 */
function makeKing2(name = "Henry") {
// 修改命名参数也不会影响 arguments 对象,它始终以调用函数时传入的值为准
name = "Louis";
return `King ${arguments[0]}`;
}
// 调用函数时没有传name的值,即便该函数该参数有默认值,arguments取值依旧为undefined
console.log(makeKing2()); // 'King undefined'
console.log(makeKing2("Louis")); // 'King Louis'
/** 3 箭头函数同样也可以这样使用默认参数 */
let makeKing3 = (name = "Henry") => `King ${name}`;
console.log(makeKing3()); // King Henry
- 因为在求值默认参数时可以定义对象,也可以动态调用函数,所以函数参数肯定是在某个作用域中求值的。
- 给多个参数定义默认值实际上跟使用 let 关键字顺序声明变量一样。
- 默认参数会按照定义它们的顺序依次被初始化,所以后定义默认值的参数可以引用先定义的参数。
- 参数初始化顺序遵循“暂时性死区(Temporal Dead Zone)”规则,即前面定义的参数不能引用后面定义的。
- 参数也存在于自己的作用域中,它们不能引用函数体的作用域。
// 1 参数是按顺序初始化的,所以后定义默认值的参数可以引用先定义的参数
function makeKing(name = "Henry", numerals = name) {
/// 后定义的参数 numerals 使用了先定义参数 name 的值
return `King ${name} ${numerals}`;
}
console.log(makeKing()); // King Henry Henry
// 2 前面定义的参数不能引用后面定义的,调用时不传第一个参数会报错
function makeKing1(name = numerals, numerals = "VIII") {
return `King ${name} ${numerals}`;
}
console.log(makeKing1("Jocy")); // King Jocy VIII
// console.log(makeKing1())
// ReferenceError: Cannot access 'numerals' before initialization
// 3 参数也存在于自己的作用域中,它们不能引用函数体的作用域:
// 调用时不传第二个参数会报错
function makeKing2(name = "Henry", numerals = defaultNumeral) {
let defaultNumeral = "VIII";
return `King ${name} ${numerals}`;
}
console.log(makeKing2("John", "II")); // King John II
// console.log(makeKing2())
// ReferenceError: defaultNumeral is not defined
CMAScript 6 新增了扩展操作符,使用它可以非常简洁地操作和组合集合数据。
扩展操作符最有用的场景就是函数定义中的参数列表,在这里它可以充分利用这门语言的弱类型及参数长度可变的特点。
扩展操作符既可以用于调用函数时传参,也可以用于定义函数参数。
对可迭代对象应用扩展操作符(...
<args>),并将其作为一个参数传入,可以将可迭代对象拆分,并将迭代返回的每个值单独传入。
展开语法(Spread syntax):
- 函数调用:
myFunction(...iterableObj);
- 字面量数组构造或字符串:
[...iterableObj, '4', ...'hello', 6];
- 构造字面量对象时,进行克隆或者属性拷贝(ECMAScript 2018 规范新增特性):
let objClone = { ...obj };
let values = [1, 2, 3, 4];
// 将所有传入的参数累加起来
function getSum() {
let sum = 0;
for (let i = 0; i < arguments.length; ++i) {
sum += arguments[i];
}
return sum;
}
// 使用 apply() 方法
// 如果想将数组元素迭代为函数参数,一般使用Function.prototype.apply 的方式进行调用。
console.log(getSum.apply(null, values)); // 10
// ECMAScript 6 中扩展操作符
console.log(getSum(...values)); // 10
// 因为数组的长度已知,所以在使用扩展操作符传参的时候,并不妨碍在其前面或后面再传其他的值,包括使用扩展操作符传其他参数
console.log(getSum(-1, ...values, ...[5, 6, 7])); // 27
// 对函数中的 arguments 对象而言,它并不知道扩展操作符的存在,而是按照调用函数时传入的参数接收每一个值
// arguments 对象只是消费扩展操作符的一种方式。
// 在普通函数和箭头函数中,也可以将扩展操作符用于命名参数,当然同时也可以使用默认参数
function getProduct(a, b, c = 1) {
return a * b * c;
}
let getSum2 = (a, b, c = 0) => {
return a + b + c;
};
console.log(getProduct(...[1, 2])); // 2
console.log(getSum2(...[0, 1])); // 1
-
在编写函数定义时,可以使用扩展操作符把不同长度的独立参数组合为一个数组。
- 这有点类似 arguments 对象的构造机制,只不过剩余参数的结果会得到一个 Array 实例。
-
剩余参数的前面如果还有命名参数,则只会收集其余的参数;如果没有则会得到空数组。
-
因为剩余参数的结果可变,所以只能把它作为最后一个参数。
-
箭头函数虽然不支持 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));
// 1 使用扩展操作符把不同长度的独立参数组合为一个数组
function getSum(...values) {
// 顺序累加 values 中的所有值
// 初始值的总和为 0
return values.reduce((x, y) => x + y, 0);
}
console.log(getSum(1, 2, 3)); // 6
// 2 因为剩余参数的结果可变,所以只能把它作为最后一个参数。
// 不可以
// function getProduct(...values, lastValue) { }
// 3 剩余参数的前面如果还有命名参数,则只会收集其余的参数;如果没有则会得到空数组。
function ignoreFirst(firstValue, ...values) {
console.log(values);
}
ignoreFirst(); // []
ignoreFirst(1); // []
ignoreFirst(1, 2); // [2]
ignoreFirst(1, 2, 3); // [2, 3]
剩余参数和 arguments 对象的区别:
- 剩余参数只包含那些没有对应形参的实参,而 arguments 对象包含了传给函数的所有实参。
- arguments 对象不是一个真正的数组,而剩余参数是真正的 Array 实例。
- 也就是说你能够在它上面直接使用所有的数组方法,比如 sort,map,forEach 或 pop。
- arguments 对象还有一些附加的属性 (如 callee 属性)。
剩余语法(剩余参数)展开语法(展开参数):
- 剩余语法看起来和展开语法完全相同,不同点在于, 剩余参数用于解构数组和对象。
- 从某种意义上说,剩余语法与展开语法是相反的:
- 展开语法将数组展开为其中的各个元素,而剩余语法则是将多个元素收集起来并“凝聚”为单个元素。
JavaScript 引擎在加载数据时对函数声明和函数表达式是区别对待的。
-
JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。
-
而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。
-
函数声明会在任何代码执行之前先被读取并添加到执行上下文。这个过程叫作函数声明提升(function declaration hoisting)。
- 在执行代码时,JavaScript 引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部。
- 因此即使函数定义出现在调用它们的代码之后,引擎也会把函数声明提升到顶部。
- 如果把前面代码中的函数声明改为等价的函数表达式,那么执行的时候就会出错。
// 没问题 console.log(sum1(10, 10)); // 20 function sum1(num1, num2) { return num1 + num2; } // 会出错 /** * 因为这个函数定义包含在一个变量初始化语句中,而不是函数声明中。 * 这意味着代码如果没有执行到`let ……`这一行,那么执行上下文中就没有函数的定义,所以上面的代码会出错。 */ console.log(sum2(10, 10)); // ReferenceError: Cannot access 'sum2' before initialization let sum2 = function (num1, num2) { return num1 + num2; }; // 会出错 console.log(sum3(10, 10)); // TypeError: sum3 is not a function var sum3 = 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;
}
// 如果是访问函数而不是调用函数,那就必须不带括号,
// 所以传给 callSomeFunction() 的必须是 add10 和 getGreeting ,而不能是它们的执行结果。
let result2 = callSomeFunction(getGreeting, "Nicholas");
console.log(result2); // "Hello, Nicholas"
/**函数作为返回值 */
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;
}
};
}
let data = [
{ name: "Zachary", age: 28 },
{ name: "Nicholas", age: 29 },
];
// 通过调用 createComparisonFunction("name") 来创建一个比较函数,就可以根据每个对象 name 属性的值来排序,
// 结果 name 属性值为 "Nicholas" 、 age 属性值为 29 的对象会排在前面。
data.sort(createComparisonFunction("name"));
console.log(data[0].name); // Nicholas
// 而调用 createComparisonFunction("age") 则会创建一个根据每个对象 age 属性的值来排序的比较函数,
// 结果 name 属性值为 "Zachary" 、 age 属性值为 28 的对象会排在前面。
data.sort(createComparisonFunction("age"));
console.log(data[0].name); // Zachary
在 ECMAScript 5 中,函数内部存在两个特殊的对象: arguments 和 this 。ECMAScript 6 又新增了 new.target 属性。
- arguments 对象是一个类数组对象,包含调用函数时传入的所有参数。
- 这个对象只有以 function 关键字定义函数(相对于使用箭头语法创建函数)时才会有。
- 虽然主要用于包含函数参数,但 arguments 对象其实还有一个 callee 属性,是一个指向 arguments 对象所在函数的指针。
- 使用
arguments.callee
就可以让函数逻辑与函数名解耦。
// 1 这个阶乘函数要正确执行就必须保证函数名是 factorial ,从而导致了紧密耦合。
function factorial2(num) {
if (num <= 1) {
return 1;
} else {
return num * factorial2(num - 1);
}
}
let fac = factorial2;
factorial2 = function () {
return 0;
};
// 因为重写了factorial2函数,所以阶乘函数并不会执行,而是执行新的函数
console.log(fac(5)); // 0
console.log(factorial2(5)); // 0
// 2 使用 arguments.callee 代替了之前硬编码的 factorial 。这意味着无论函数叫什么名称,都可以引用正确的函数。
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * arguments.callee(num - 1);
}
}
// trueFactorial 变量被赋值为 factorial ,实际上把同一个函数的指针又保存到了另一个位置。
let trueFactorial = factorial;
// 然后, factorial 函数又被重写为一个返回 0 的函数。
factorial = function () {
return 0;
};
// 如果像 factorial() 最初的版本那样不使用 arguments.callee ,那么像上面这样调用 trueFactorial() 就会返回 0 。
// 不过,通过将函数与名称解耦, trueFactorial() 就可以正确计算阶乘,而 factorial() 则只能返回 0。
console.log(trueFactorial(5)); // 120
console.log(factorial(5)); // 0
另一个特殊的对象是 this ,它在标准函数和箭头函数中有不同的行为。
- 在标准函数中, this 引用的是把函数当成方法调用的上下文对象,这时候通常称其为 this 值(在网页的全局上下文中调用函数时, this 指向 windows )。
- 这个 this 到底引用哪个对象必须到函数被调用时才能确定。因此这个值在代码执行的过程中可能会变。
/**标准函数 */
// 注意: 在chrome 的console中执行
// 全局的color
windows.color = "red";
// 对象属性color
let o = {
color: "blue",
};
function sayColor() {
console.log(this.color);
}
// 在全局上下文中调用sayColor() ,这结果会输出 "red"
// 在浏览器 this 指向 window ,而 this.color 相当于 window.color 。
// 在nodejs环境 this 指向 global ,而 this.color 相当于 global.color 。
sayColor(); // 'red'
console.log(global.color); // 'red'
// 在把 sayColor() 赋值给 o 之后再调用 o.sayColor() , this 会指向 o ,
// 即 this.color 相当于o.color ,所以会显示 "blue" 。
o.sayColor = sayColor;
o.sayColor(); // 'blue'
- 在箭头函数中, this 引用的是定义该箭头函数的上下文。
/**箭头函数 */
// 注意: 在chrome 的console中执行
window.color = "red";
let o = {
color: "blue",
};
let sayColor = () => console.log(this.color);
sayColor(); // 'red'
o.sayColor = sayColor;
o.sayColor(); // 'red'
// 注意 函数名只是保存指针的变量。
// 因此全局定义的 sayColor() 函数和 o.sayColor()是同一个函数,只不过执行的上下文不同。
/*
如果上述在nodejs环境下,window.color = 'red'为color = 'red'或global.color = 'red',输出均为undefined
但改为this.color = 'red';则会输出red red
因为nodejs中,在最外层的this并不是全局对象global。而是module.exports
*/
// https://blog.csdn.net/nullccc/article/details/108931703
// https://juejin.cn/post/6967715614773215239
// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/this
ECMAScript 5 也会给函数对象上添加一个属性: caller 。
- 虽然 ECMAScript 3 中并没有定义,但所有浏览器除了早期版本的 Opera 都支持这个属性。
- 这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为 null 。
- 在严格模式下访问 arguments.callee 会报错。
- 严格模式下还有一个限制,就是不能给函数的 caller 属性赋值,否则会导致错误。
function outer() {
inner();
}
function inner() {
// 当前inner函数的调用者
console.log(inner.caller);
}
outer(); // [Function: outer]
// 以上代码会显示 outer() 函数的源代码。
// 这是因为 ourter() 调用了 inner() , inner.caller指向 outer() 。
// 如果要降低耦合度,则可以通过 arguments.callee.caller 来引用同样的值:
function outer() {
inner();
}
function inner() {
// 如果使用"use strict"则会报错。这是为了分清 arguments.caller和函数的 caller 而故意为之的。
console.log(arguments.callee.caller);
}
outer(); // [Function: outer]
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"
ECMAScript 中的函数是对象,因此有属性和方法。
- 每个函数都有两个属性:
length
和prototype
。- 其中, length 属性保存函数定义的命名参数的个数。
- prototype 是保存引用类型所有实例方法的地方。
牢记: person1.__proto__ = Person.prototype Person.prototype.constructor = Person
- 这意味着 toString() 、 valueOf() 等方法实际上都保存在 prototype 上,进而由所有实例共享。
- 这个属性在自定义类型时特别重要。
- 函数还有两个方法:
apply()
和call()
。- 这两个方法都会以指定的 this 值来调用函数,即会设置调用函数时函数体内 this 对象的值。
- apply() 方法接收两个参数:函数内 this 的值和一个参数数组。第二个参数可以是 Array 的实例,但也可以是 arguments 对象。
- call() 方法与 apply() 的作用一样,只是传参的形式不同。第一个参数跟 apply() 一样,也是 this 值,而剩下的要传给被调用函数的参数则是逐个传递的。
- 换句话说,通过 call() 向函数传参时,必须将参数一个一个地列出来。
- apply() 和 call() 真正强大的地方并不是给函数传参,而是控制函数调用上下文即函数体内 this 值的能力。
- 使用 call() 或 apply() 的好处是可以将任意对象设置为任意函数的作用域,这样对象可以不用关心方法。
- ECMAScript 5 出于同样的目的定义了一个新方法:
bind()
。- bind() 方法会创建一个新的函数实例,其 this 值会被绑定到传给 bind() 的对象。
// 注意:在chrome的console中执行的结果
window.color = "red";
let o = {
color: "blue",
};
function sayColor() {
console.log(this.color);
}
function sum(num1, num2) {
return num1 + num2;
}
console.log(sayColor.length); // 0
console.log(sum.length); // 2
// sayColor() 是一个全局函数,如果在全局作用域中调用它,那么会显示 "red" 。这是因为 this.color 会求值为 window.color 。
sayColor(); // red
// 在全局作用域中显式调用 sayColor.call(this) 或者 sayColor.call(window) ,则同样都会显示 "red" 。
sayColor.call(this); // red
sayColor.call(window); // red
// 在使用 sayColor.call(o) 把函数的执行上下文即 this 切换为对象 o 之后,结果就变成了显示 "blue" 了。
sayColor.call(o); // blue
// 在 sayColor() 上调用 bind() 并传入对象 o 创建了一个新函数 objectSayColor() 。
// objectSayColor() 中的 this 值被设置为 o ,因此直接调用这个函数,即使是在全局作用域中调用,也会返回字符串 "blue" 。
let objectSayColor = sayColor.bind(o);
objectSayColor(); // blue
递归函数通常的形式是一个函数通过名称调用自己。
// 示例:递归阶乘
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
// return num * arguments.callee(num - 1);
}
}
// 这里把 factorial() 函数保存在了另一个变量 anotherFactorial 中,然后将 factorial 设置为 null ,
// 于是只保留了一个对原始函数的引用。而在调用 anotherFactorial() 时,要递归调用factorial() ,但因为它已经不是函数了,所以会出错。
// 在写递归函数时使用 arguments.callee 可以避免这个问题。
let anotherFactorial = factorial;
factorial = null;
console.log(anotherFactorial(4)); // 报错
// 在严格模式下运行的代码是不能访问 arguments.callee 的,因为访问会出错。
// 此时,可以使用命名函数表达式(named function expression)达到目的。
const factorial = function f(num) {
if (num <= 1) {
return 1;
} else {
return num * f(num - 1);
}
};
尾调用:如果一个函数返回的是另一个函数的调用结果,那么就被称为尾调用。
尾递归:如果一个函数返回的是自己的调用结果就被称为尾递归。也就是说尾递归一定是尾调用,但尾调用不一定是尾递归。
// 尾调用
function outerFunction() {
return innerFunction(); // 尾调用
}
// 非尾调用
function outerFunction() {
return innerFunction() + 1; // 返回的是表达式
}
function outerFunction() {
let a = innerFunction();
return a; // 返回的是变量
}
// 在 ES6 优化之前,执行这个例子会在内存中发生如下操作。
// (1) 执行到 outerFunction 函数体,第一个栈帧(stack frame)被推到栈(stack)上。
// (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 尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。
尾调用优化的条件就是确定外部栈帧真的没有必要存在:
- 代码在严格模式下执行;
- 外部函数的返回值是对尾调用函数的调用;
- 尾调用函数返回后不需要执行额外的逻辑;
- 尾调用函数不是引用外部函数作用域中自由变量的闭包。
"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();
}
// 有优化:栈帧销毁前执行参数计算
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);
}
// 因为返回语句中有一个相加的操作。结果, fib(n) 的栈帧数的内存复杂度是 O(2^n)。
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
// console.log(fib(1000)); // 同样简单调用,直接卡死编译器
/**重构后
* 使用两个嵌套的函数,外部函数作为基础框架,内部函数执行递归:
*/
("use strict");
// 基础框架
function fib2(n) {
return fibImpl(0, 1, n);
}
// 执行递归
function fibImpl(a, b, n) {
if (n === 0) {
return a;
}
return fibImpl(b, a + b, n - 1);
}
// 直接出结果,没什么压力
console.log(fib2(1000)); // 4.346655768693743e+208
闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。
- 一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。
- 也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。
- 在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
/**
* 以下是书中的原本内容,可领看示例,如MDN讲解部分。
*/
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);
console.log(result); // -1
/*
这里定义的 compare() 函数是在全局上下文中调用的。
第一次调用 compare() 时,会为它创建一个包含 arguments 、 value1 和 value2 的活动对象,这个对象是其作用域链上的第一个对象。
而全局上下文的变量对象则是 compare() 作用域链上的第二个对象,其中包含 this 、 result 和 compare 。
函数执行时,每个执行上下文中都会有一个包含其中变量的对象。
全局上下文中的叫【变量对象】,它会在代码执行期间始终存在。
而函数局部上下文中的叫【活动对象】,只在函数执行期间存在。
在定义 compare() 函数时,就会为它创建作用域链,预装载全局变量对象,并保存在内部的 [[Scope]] 中。
在调用这个函数时,会创建相应的执行上下文,然后通过复制函数的 [[Scope]] 来创建其作用域链。
接着会创建函数的活动对象(用作变量对象)并将其推入作用域链的前端。
在这个例子中,这意味着 compare()函数执行上下文的作用域链中有两个变量对象:局部变量对象和全局变量对象。
作用域链其实是一个包含指针的列表,每个指针分别指向一个变量对象,但物理上并不会包含相应的对象。
函数内部的代码在访问变量时,就会使用给定的名称从作用域链中查找变量。
函数执行完毕后,局部活动对象会被销毁,内存中就只剩下全局作用域。
不过,闭包就不一样了。 在一个函数内部定义的函数会把其包含函数的活动对象添加到自己的作用域链中。
因此,在createComparisonFunction() 函数中,匿名函数的作用域链中实际上包含 createComparisonFunction() 的活动对象。
*/
let compare2 = createComparisonFunction("name");
let result2 = compare2({ name: "Nicholas" }, { name: "Matt" });
console.log(result2); // 1
/*
在 createComparisonFunction() 返回匿名函数后,它的作用域链被初始化为包含 createComparisonFunction() 的活动对象和全局变量对象。
这样,匿名函数就可以访问到 createComparisonFunction() 可以访问的所有变量。
另一个有意思的副作用就是, createComparisonFunction() 的活动对象并不能在它执行完毕后销毁,因为匿名函数的作用域链中仍然有对它的引用。
在 createComparisonFunction() 执行完毕后,其执行上下文的作用域链会销毁,但它的活动对象仍然会保留在内存中,直到匿名函数被销毁后才会被销毁:
*/
// 创建比较函数
let compareNames = createComparisonFunction("name");
// 调用函数
let result3 = compareNames({ name: "Nicholas" }, { name: "Matt" });
console.log(result3); // 1
// 解除对函数的引用,这样就可以释放内存了
compareNames = null;
/*
这里,创建的比较函数被保存在变量 compareNames 中。
把 compareNames 设置为等于 null 会解除对函数的引用,从而让垃圾回收程序可以将内存释放掉。
作用域链也会被销毁,其他作用域(除全局作用域之外)也可以销毁。
*/
注意 因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。V8 等优化的 JavaScript 引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。
在闭包中使用 this 会让代码变复杂。
- 如果内部函数没有使用箭头函数定义,则 this 对象会在运行时绑定到执行函数的上下文。
- 如果在全局函数中调用,则 this 在非严格模式下等于 window ,在严格模式下等于 undefined 。
- 如果作为某个对象的方法调用,则 this 等于这个对象。
- 匿名函数在这种情况下不会绑定到某个对象,这就意味着 this 会指向 window ,除非在严格模式下 this 是 undefined 。
- 不过,由于闭包的写法所致,这个事实有时候没有那么容易看出来。
每个函数在被调用时都会自动创建两个特殊变量: this 和 arguments 。
- 内部函数永远不可能直接访问外部函数的这两个变量。
- 但是,如果把 this 保存到闭包可以访问的另一个变量中,则是行得通的。
/**注意,以下为在chrome的console中执行的结果。nodejs环境的结果可能不一样 */
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改global的话,在nodejs下可能不同 */
window.identity2 = "The Window";
let object2 = {
identity2: "My Object",
getIdentityFunc() {
let that = this;
return function () {
return that.identity2;
};
},
};
console.log(object.getIdentityFunc()()); // 'My Object'
注意 this 和 arguments 都是不能直接在内部函数中访问的。如果想访问包含作用域中的 arguments 对象,则同样需要将其引用先保存到闭包能访问的另一个变量中。 即使语法稍有不同,也可能影响 this 的值。
global.identity = "The Window";
let object = {
identity: "My Object",
getIdentity() {
return this.identity;
},
};
// 第一行调用 object.getIdentity() 是正常调用,会返回 "My Object" ,因为 this.identity就是 object.identity 。
let a = object.getIdentity();
// 第二行在调用时把 object.getIdentity 放在了括号里。虽然加了括号之后看起来是对一个函数的引用,但 this 值并没有变。
// 这是因为按照规范, object.getIdentity 和(object.getIdentity) 是相等的。
let b = object.getIdentity();
// 第三行执行了一次赋值,然后再调用赋值后的结果。
// 因为赋值表达式的值是函数本身, this 值不再与任何对象绑定,所以返回的是 "The Window" 。
let c = (object.getIdentity = object.getIdentity)();
console.log(a, b, c); // 'My Object' 'My Object' 'The Window'
// 语法
(function () {
statements;
})();
- 第一部分是包围在 圆括号运算符 () 里的一个匿名函数,这个匿名函数拥有独立的词法作用域。
- 这不仅避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域。
- 第二部分再一次使用 () 创建了一个立即执行函数表达式,JavaScript 引擎到此将直接执行函数。
当函数变成立即执行的函数表达式时,表达式中的变量不能从外部访问。
// IIFE
(function () {
for (var k = 0; k < 3; k++) {
console.log(k);
}
})();
console.log(k); // 抛出错误 ReferenceError: k is not defined
(function () {
var lname = "Barry";
})();
// 无法从外部访问变量 lname
console.log(lname); // 抛出错误:"Uncaught ReferenceError: lname is not defined"
在 ECMAScript 6 以后,块级作用域中的变量无须 IIFE 就可以实现同样的隔离。
// 下面展示了两种不同的块级作用域形式
// 内嵌块级作用域
{
let i;
for (i = 0; i < 3; i++) {
console.log(i); // 依次输出 0 1 2
}
}
console.log(i); // 抛出错误 ReferenceError: i is not defined
// 循环的块级作用域
for (let j = 0; j < 3; j++) {
console.log(j); // 依次输出 0 1 2
}
console.log(j); // 抛出错误 ReferenceError: j is not defined
将 IIFE 分配给一个变量,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。
var result = (function () {
var lname = "Barry";
return lname;
})();
// IIFE 执行后返回的结果:
console.log(result); // "Barry"
严格来讲,JavaScript 没有私有成员的概念,所有对象属性都公有的。不过,倒是有私有变量的概念。
- 任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。
- 私有变量包括函数参数、局部变量,以及函数内部定义的其他函数。
如果这个函数中创建了一个闭包,则这个闭包能通过其作用域链访问其外部的这函数内部变量。基于这一点,就可以创建出能够访问私有变量的公有方法。
特权方法(privileged method)是能够访问函数私有变量(及私有函数)的公有方法。
// 是在构造函数中实现特权方法:
function Person(name) {
// 特权方法
// 定义在构造函数中的特权方法其实是一个闭包,它具有访问构造函数中定义的所有变量和函数的能力。
this.getName = function () {
return name;
};
this.setName = function (value) {
name = value;
};
}
/*
构造函数定义了两个特权方法: getName() 和 setName() 。
每个方法都可以构造函数外部调用,并通过它们来读写私有的 name 变量。
在 Person 构造函数外部,没有别的办法访问 name 。
因为两个方法都定义在构造函数内部,所以它们都是能够通过作用域链访问 name 的闭包。
私有变量name 对每个 Person 实例而言都是独一无二的,因为每次调用构造函数都会重新创建一套变量和方法。
*/
let person = new Person("Nicholas");
console.log(person.getName()); // 'Nicholas'
person.setName("Greg");
console.log(person.getName()); // 'Greg'
构造函数模式的缺点是每个实例都会重新创建一遍新方法。使用静态私有变量实现特权方法可以避免这个问题。
特权方法也可以通过使用私有作用域定义私有变量和函数来实现。
// 静态私有变量
(function () {
let name = "";
Person = function (value) {
name = value;
};
Person.prototype.getName = function () {
return name;
};
Person.prototype.setName = function (value) {
name = value;
};
})();
/*
这里的 Person 构造函数可以访问私有变量 name ,跟 getName() 和 setName() 方法一样。
使用这种模式, name 变成了静态变量,可供所有实例使用。
这意味着【在任何实例上调用 setName() 修改这个变量都会影响其他实例】。
调用 setName() 或创建新的 Person 实例都要把 name 变量设置为一个新值。
而所有实例都会返回相同的值。
像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。
最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。
*/
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'
注意 使用闭包和私有变量会导致作用域链变长,作用域链越长,则查找变量所需的时间也越多。
模块模式,则在一个单例对象上实现了相同的隔离和封装。
- 单例对象(singleton)就是只有一个实例的对象。
- 按照惯例,JavaScript 是通过对象字面量来创建单例对象的。
模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法。
/*
模块模式使用了匿名函数返回一个对象。
在匿名函数内部,首先定义私有变量和私有函数。之后,创建一个要通过匿名函数返回的对象字面量。
这个对象字面量中只包含可以公开访问的属性和方法。
因为这个对象定义在匿名函数内部,所以它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。
本质上,对象字面量定义了单例对象的公共接口。
如果单例对象需要进行某种初始化,并且需要访问私有变量时,那就可以采用这个模式:
*/
let application = (function () {
// 私有变量和私有函数
let components = new Array();
// 初始化
// (BaseComponent 组件的代码并不重要,在这里用它只是为了说明模块模式的用法。)
components.push(new BaseComponent());
// 公共接口
return {
getComponentCount() {
return components.length;
},
registerComponent(component) {
if (typeof component == "object") {
components.push(component);
}
},
};
})();
/** 示例2 */
// let Counter = (function () {
// // 私有变量和私有函数
// let privateCounter = 0;
// function changeBy(val) {
// privateCounter += val;
// }
// // 公共接口
// return {
// increment: function () {
// changeBy(1);
// },
// decrement: function () {
// changeBy(-1);
// },
// value: function () {
// return privateCounter;
// },
// };
// })();
// console.log(Counter.value()); // 0
// Counter.increment();
// Counter.increment();
// console.log(Counter.value()); // 2
// Counter.decrement();
// console.log(Counter.value()); // 1
另一个利用模块模式的做法是在返回对象之前先对其进行增强。
- 这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。
// 如果前一节的 application 对象必须是 BaseComponent 的实例,那么就可以使用下面的代码来创建它
let application = (function () {
// 私有变量和私有函数
let components = new Array();
// 初始化
// (BaseComponent 组件的代码并不重要,在这里用它只是为了说明模块模式的用法。)
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 编程中最有用也最通用的工具。ECMAScript 6 新增了更加强大的语法特性,从而让开发者可以更有效地使用函数。
- 函数表达式与函数声明是不一样的。函数声明要求写出函数名称,而函数表达式并不需要。没有名称的函数表达式也被称为匿名函数。
- ES6 新增了类似于函数表达式的箭头函数语法,但两者也有一些重要区别。
- JavaScript 中函数定义与调用时的参数极其灵活。 arguments 对象,以及 ES6 新增的扩展操作符,可以实现函数定义和调用的完全动态化。
- 函数内部也暴露了很多对象和引用,涵盖了函数被谁调用、使用什么调用,以及调用时传入了什么参数等信息。
- JavaScript 引擎可以优化符合尾调用条件的函数,以节省栈空间。
- 闭包的作用域链中包含自己的一个变量对象,然后是包含函数的变量对象,直到全局上下文的变量对象。
- 通常,函数作用域及其中的所有变量在函数执行完毕后都会被销毁。
- 闭包在被函数返回之后,其作用域会一直保存在内存中,直到闭包被销毁。
- 函数可以在创建之后立即调用,执行其中代码之后却不留下对函数的引用。
- 立即调用的函数表达式如果不在包含作用域中将返回值赋给一个变量,则其包含的所有变量都会被销毁。
- 虽然 JavaScript 没有私有对象属性的概念,但可以使用闭包实现公共方法,访问位于包含作用域中定义的变量。
- 可以访问私有变量的公共方法叫作特权方法。
- 特权方法可以使用构造函数或原型模式通过自定义类型中实现,也可以使用模块模式或模块增强模式在单例对象上实现。