Skip to content

Latest commit

 

History

History
2285 lines (1687 loc) · 77.1 KB

08章:函数.md

File metadata and controls

2285 lines (1687 loc) · 77.1 KB

本章内容

  • 创建函数
  • 理解函数
  • 函数参数
  • 函数内部
  • 函数属性与方法
  • 闭包和私有变量
  • 递归和尾调用优化

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

1.1. 创建函数

在 JavaScript 中,可以通过函数声明,函数表达式,箭头函数,以及 Function() 构造函数创建一个函数实例。这 4 种方式各有特点,理解这 4 种方法的区别是非常重要的。

1.1.1. 函数声明

函数通常以函数声明的方式定义,比如:

function sum(num1, num2) {
  return num1 + num2;
}

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

函数声明提升

JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。来看下面的例子:

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

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

需要注意的是,函数定义声明的提升优先级高于 var 变量的提升优先级,例如:

var foo = 'foo';
function foo() {}
console.log(typeof foo);
// string

以上代码实际上等价于:

function foo() {}
var foo;
foo = 'foo';
console.log(typeof foo);
// string

1.1.2. 函数表达式

另一种定义函数的语法是函数表达式。函数表达式与函数声明几乎是等价的:

let sum = function (num1, num2) {
  return num1 + num2;
};

这里,代码定义了一个变量 sum 并将其初始化为一个函数。注意 function 关键字后面没有名称,因为不需要。这个函数可以通过变量 sum 来引用。

注意这里的函数末尾是有分号的,与任何变量初始化语句一样。

函数表达式看起来就像一个普通的变量定义和赋值,即创建一个函数再把它赋值给一个变量 functionName。这样创建的函数叫作 匿名函数(anonymous funtion),因为 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!');
  };
}

这个例子可以如预期一样,根据 condition 的值为变量 sayHi 赋予相应的函数。

创建函数并赋值给变量的能力也可以用于在一个函数中把另一个函数当作值返回:

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()内部,那个函数是匿名的。任何时候,只要函数被当作值来使用,它就是一个函数表达式。

函数表达式除了匿名函数表达式之外,还有 命名函数表达式,这种函数表达式在递归时非常有用。详细可见递归和尾调优化一节。

1.1.3. 箭头函数

还有一种定义函数的方式与函数表达式很像,叫作 箭头函数(arrow function) ,如下所示:

const sum = (num1, num2) => {
  return num1 + num2;
};

很大程度上,箭头函数实例化的函数对象与正式的函数表达式创建的函数对象行为是相同的。任何可以使用函数表达式的地方,都可以使用箭头函数:

const arrowSum = (a, b) => {
  return a + b;
};

const functionExpressionSum = function (a, b) {
  return a + b;
};

console.log(arrowSum(5, 8));
// -> 13

console.log(functionExpressionSum(5, 8));
// -> 13

箭头函数表达式的语法比函数表达式更简洁,更适用于那些本来需要匿名函数的地方:

const ints = [1, 2, 3];

console.log(
  ints.map(function (i) {
    return i + 1;
  }),
);
// -> [2, 3, 4]

console.log(
  ints.map((i) => {
    return i + 1;
  }),
);
// -> [2, 3, 4]

如果只有一个参数,那也可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号:

// 以下两种写法都有效
const double = (x) => { return 2 * x; };
const triple = x => { return 3 * x; };

// 没有参数需要括号
const getRandom = () => { return Math.random(); };

// 多个参数需要括号
const sum = (a, b) => { return a + b; };

// 无效的写法:
const multiply = a, b => { return a * b; };

箭头函数也可以不用大括号,但这样会改变函数的行为。使用大括号就说明包含“函数体”,可以在一个函数中包含多条语句,跟常规的函数一样。如果不使用大括号,那么箭头后面就只能有一行代码,比如一个赋值操作,或者一个表达式。而且,省略大括号会隐式返回这行代码的值:

// 以下两种写法都有效,而且返回相应的值
const double = (x) => { return 2 * x; };
const triple = (x) => 3 * x;

// 可以赋值
let value = {};
const setName = (x) => x.name = 'Matt';
setName(value);
console.log(value.name);
// -> 'Matt'

// 无效的写法:
const multiply = (a, b) => return a * b;

如果要返回一个对象字面量,就要加上括号:

(params) => ({foo: bar});

箭头函数虽然语法简洁,但也有很多场合不适用。箭头函数没有自己的 this,arguments,super,new.target 以及 prototype。当然它也不能用作构造函数。

来看下面这个例子:

const arrowFunc = ()=>{
 // ReferenceError: arguments is not defined
 console.log(arguments);

 // SyntaxError: new.target expression is not allowed here
 console.log(new.target);
};

arrowFunc();

console.log(arrowFunc.prototype);
// -> undefined

在箭头函数内部没有 arguments,不能使用 new.target,当然也不能用作构造函数。箭头函数的 prototype 返回 undefined。

1.1.4. Function() 构造函数

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

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

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

1.2. 理解函数

1.2.1. 函数的本质

函数本质上是 Function 类型的对象。

1.2.1.1. 可调用性

Function 类型对象和非 Function 类型对象的显著区别就是 可调用性(Callable),说一个对象是可调用的,意味着你可以在对象后加上小括号,并可以在括号里写入调用参数。

来看这个例子。

const func = function () {
  console.log('be called');
};

func();

const notFunc = {};

// TypeError: notFunc is not a function
notFunc();

在这个例子中,func 对象是一个 Function 类型的对象,这很显然,我们用匿名函数表达式赋值给了 func。而 notFunc 是一个非 Function 类型的对象,因为我们用对象字面量赋值给了 notFunc。因此,当我们在 func 对象后加上括号调用时,没有报错。而 notFunc 则提示 notFunc 不是一个函数。

1.2.1.2. 函数也有属性或方法

函数是 Function 类型的对象,这意味着一个函数也和对象一样有属性或方法。

Function 类型的对象,除了箭头函数以外,都有一个属性 prototype,用于实现原型链继承。Function 类型的对象也有 name,length 属性,分别表示函数的名称和参数个数。这些属性在后面章节会详细讨论。

Function 类型的对象也有方法,如:call(),apply(),bind() 方法,这些方法用于指定函数被调用时的内部 this 值。这些方法在后面章节会详细讨论。

1.2.1.3. 函数也是值

函数是 Function 类型的对象,这也表示一个函数就是一个引用值,这是显然的。因此,函数也可以被赋值给一个变量,当然也可以当作参数传进函数。

来看第 1 个例子:

const func1 = function () {
  console.log('be called');
};

const func2 = func1;
func1.foo = 'foo';
console.log(func2.foo);
// -> 'foo'

在这个例子中,我们先定义了一个函数表达式(函数),把它赋值给了 func1,之后再把 func1 这个引用值赋值给 func2,显然,这是一次浅拷贝,因此,当我们给 func1 添加了 foo 属性时,func2 也被添加了 foo 属性。

来看第 2 个例子:

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'

callSomeFunction()函数是通用的,第一个参数传入的是什么函数都可以,而且它始终返回调用作为第一个参数传入的函数的结果。要注意的是,如果是访问函数而不是调用函数,那就必须不带括号,所以传给 callSomeFunction()的必须是 add10 和 getGreeting,而不能是它们的执行结果。

从一个函数中返回另一个函数也是可以的,而且非常有用。例如,假设有一个包含对象的数组,而我们想按照任意对象属性对数组进行排序。为此,可以定义一个 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

在上面的代码中,数组 data 中包含两个结构相同的对象。每个对象都有一个 name 属性和一个 age 属性。默认情况下,sort()方法要对这两个对象执行 toString(),然后再决定它们的顺序,但这样得不到有意义的结果。而通过调用 createComparisonFunction('name')来创建一个比较函数,就可以根据每个对象 name 属性的值来排序,结果 name 属性值为'Nicholas'、age 属性值为 29 的对象会排在前面。而调用 createComparisonFunction('age')则会创建一个根据每个对象 age 属性的值来排序的比较函数,结果 name 属性值为'Zachary'、age 属性值为 28 的对象会排在前面。

1.2.2. 立即调用函数表达式

通常当我们定义好一个函数表达式后,我们会将它赋值给一个变量:

const func = function () {
  console.log('be called');
};

这样做的好处就是我们可以使用这个变量名在之后的代码中多次调用:

// ...
func();
// ...
func();

但是,我们实际上也可以直接在这个函数表达式之后加上括号来调用,为了避免语法识别错误,我们还要将这个函数表达式用小括号括起来表示它是一个整体:

(function () {
  console.log('be called');
})();
// -> 'be called'

这种写法合理的原因就在于函数表达式实际上就是一个 Function 类型的对象,因此我们可以在其后加上括号来调用。和通常我们将函数表达式赋值给一个变量的写法不同的是:这个函数表达式无法通过变量名调用,而且这个函数表达式在定义之后被 立即调用。以这种写法调用的函数表达式又被称作 立即调用函数表达式(IIFE,Immediately Invoked Function Expression)

使用 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);
  });
}

1.3. 参数

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

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

arguments 对象既是一个类数组对像,又是一个可迭代对象。要确定传进来多少个参数,可以访问 arguments.length 属性。

在下面的例子中,sayHi()函数的第一个参数叫 name:

function sayHi(name, message) {
  console.log('Hello ' + name + ', ' + message);
}

可以通过 arguments[0] 取得相同的参数值。因此,把函数重写成不声明参数也可以:

function sayHi() {
  console.log('Hello ' + arguments[0] + ', ' + arguments[1]);
}

在重写后的代码中,没有命名参数。name 和 message 参数都不见了,但函数照样可以调用。这就表明,ECMAScript 函数的参数只是为了方便才写出来的,并不是必须写出来的。与其他语言不同,在 ECMAScript 中的命名参数不会创建让之后的调用必须匹配的函数签名。这是因为根本不存在验证命名参数的机制。

也可以通过 arguments 对象的 length 属性检查传入的参数个数。下面的例子展示了在每调用一个函数时,都会打印出传入的参数个数:

function howManyArgs() {
  console.log(arguments.length);
}
howManyArgs('string', 45); // 2
howManyArgs(); // 0
howManyArgs(12); // 1

这个例子分别打印出 2、0 和 1(按顺序)。既然如此,那么开发者可以想传多少参数就传多少参数。比如:

function doAdd() {
  return Array.from(arguments).reduce((pre, cur) => pre + cur, 0);
}

console.log(doAdd()); // 0
console.log(doAdd(1, 2)); // 3
console.log(doAdd(1, 2, 3, 4, 5)); // 15

这个函数 doAdd() 在没有参数时,返回初始值 0。在有参数时使用 Array.from() 将 arguments 对象转为数组,并用归并方法 reduce() 返回参数的和。

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

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

arguments 对象的另一个有意思的地方就是,它的值始终会与对应的命名参数同步。来看下面的例子:

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

这个 doAdd()函数把第二个参数的值重写为 10。因为 arguments 对象的值会自动同步到对应的命名参数,所以修改 arguments[1]也会修改 num2 的值,因此两者的值都是 10。但这并不意味着它们都访问同一个内存地址,它们在内存中还是分开的,只不过会保持同步而已。另外还要记住一点:如果只传了一个参数,然后把 arguments[1]设置为某个值,那么这个值并不会反映到第二个命名参数。这是因为 arguments 对象的长度是根据传入的参数个数,而非定义函数时给出的命名参数个数确定的。

对于命名参数而言,如果调用函数时没有传这个参数,那么它的值就是 undefined。这就类似于定义了变量而没有初始化。比如,如果只给 doAdd()传了一个参数,那么 num2 的值就是 undefined。

严格模式下,arguments 会有一些变化。首先,像前面那样给 arguments[1]赋值不会再影响 num2 的值。就算把 arguments[1]设置为 10,num2 的值仍然还是传入的值。其次,在函数中尝试重写 arguments 对象会导致语法错误。(代码也不会执行。)

1.3.0.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 中的所有参数都按值传递的。不可能按引用传递参数。如果把对象作为参数传递,那么传递的值就是这个对象的引用。

1.3.1. 没有重载

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

如果在 ECMAScript 中声明了两个同名的函数定义,则后定义的会覆盖先定义的。来看下面的例子:

function addSomeNumber(num) {
  return num + 100;
}
function addSomeNumber(num) {
  return num + 200;
}
let result = addSomeNumber(100); // 300

这里,函数 addSomeNumber()被定义了两次。第一个版本给参数加 100,第二个版本加 200。最后一行调用这个函数时,返回了 300,因为第二个定义覆盖了第一个定义。

前面也提到过,可以通过检查参数的类型和数量,然后分别执行不同的逻辑来模拟函数重载。

把函数名当成指针也有助于理解为什么 ECMAScript 没有函数重载。在前面的例子中,定义两个同名的函数显然会导致后定义的重写先定义的。而那个例子几乎跟下面这个是一样的:

let addSomeNumber = function (num) {
  return num + 100;
};
addSomeNumber = function (num) {
  return num + 200;
};
let result = addSomeNumber(100); // 300

看这段代码应该更容易理解发生了什么。在创建第二个函数时,变量 addSomeNumber 被重写成保存第二个函数对象了。

1.3.2. 默认参数值

在 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.3.2.1. 默认参数作用域与暂时性死区

因为在求值默认参数时可以定义对象,也可以动态调用函数,所以函数参数肯定是在某个作用域中求值的。

给多个参数定义默认值实际上跟使用 let 关键字顺序声明变量一样。来看下面的例子:

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

这里的默认参数会按照定义它们的顺序依次被初始化。可以依照如下示例想象一下这个过程:

function makeKing() {
  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}`;
}

参数也存在于自己的作用域中,它们不能引用函数体的作用域:

// 调用时不传第二个参数会报错
function makeKing(name = 'Henry', numerals = defaultNumeral) {
  let defaultNumeral = 'VIII';
  return `King ${name} ${numerals}`;
}

1.3.3. 参数扩展和收集

ECMAScript 6 新增了扩展操作符,使用它可以非常简洁地操作和组合集合数据。扩展操作符最有用的场景就是函数定义中的参数列表,在这里它可以充分利用这门语言的弱类型及参数长度可变的特点。扩展操作符既可以用于调用函数时传参,也可以用于定义函数参数。

1.3.3.1. 扩展参数

在给函数传参时,有时候可能不需要传一个数组,而是要分别传入数组的元素。

假设有如下函数定义,它会将所有传入的参数累加起来:

let values = [1, 2, 3, 4];
function getSum() {
  return Array.from(arguments).reduce((pre, cur) => pre + cur, 0);
}

这个函数希望将传入的数组拆分,然后累加。如果不使用扩展操作符,想把定义在这个函数这面的数组拆分,那么就得求助于 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

对函数中的 arguments 对象而言,它并不知道扩展操作符的存在,而是按照调用函数时传入的参数接收每一个值:

let values = [1, 2, 3, 4];
function countArguments() {
  console.log(arguments.length);
}
countArguments(-1, ...values); // 5
countArguments(...values, 5); // 5
countArguments(-1, ...values, 5); // 6
countArguments(...values, ...[5, 6, 7]); // 7

arguments 对象只是消费扩展操作符的一种方式。在普通函数和箭头函数中,也可以将扩展操作符用于命名参数,当然同时也可以使用默认参数:

function getProduct(a, b, c = 1) {
  return a * b * c;
}
let getSum = (a, b, c = 0) => {
  return a + b + c;
};
console.log(getProduct(...[1, 2])); // 2
console.log(getProduct(...[1, 2, 3])); // 6
console.log(getProduct(...[1, 2, 3, 4])); // 6
console.log(getSum(...[0, 1])); // 1
console.log(getSum(...[0, 1, 2])); // 3
console.log(getSum(...[0, 1, 2, 3])); // 3

1.3.3.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));

1.4. 函数内部

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

1.4.1. arguments

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。这意味着无论函数叫什么名称,都可以引用正确的函数。考虑下面的情况:

let trueFactorial = factorial;
factorial = function () {
  return 0;
};
console.log(trueFactorial(5)); // 120
console.log(factorial(5)); // 0

这里,trueFactorial 变量被赋值为 factorial,实际上把同一个函数的指针又保存到了另一个位置。然后,factorial 函数又被重写为一个返回 0 的函数。如果像 factorial()最初的版本那样不使用 arguments.callee,那么像上面这样调用 trueFactorial()就会返回 0。不过,通过将函数与名称解耦,trueFactorial()就可以正确计算阶乘,而 factorial()则只能返回 0。

1.4.2. this

另一个特殊的对象是 this。this 的值取决于多种因素。

1.4.2.1. 全局上下文

this 在全局上下文中,即在任何函数体的外部时,都指向全局对象,在浏览器宿主环境中就是 window。

来看下面的例子:

let o1 = {
  this: this,
  o2: {
    this: [this],
  },
};

console.log(o1.this === window); // true
console.log(o1.o2.this[0] === window); // true

在这个例子中,对象 o1 的 this 属性引用 this 值,而这个 this 值在全局上下文中指向 window。同样的,o2 的 this 值引用一个数组对象,这个数组的第一个元素为 this,但无论如何 this 值都在全局上下文中,所以都指向 window。

1.4.2.2. 标准函数上下文

this 出现在标准函数上下文中,此时的 this 取决于函数调用的方式。

  • 在非严格模式下直接调用标准函数,则其内部的 this 指向全局对象。

来看下面几个例子:

function getThis() {
  return this;
}

console.log(getThis() === window);
// -> true

在这个例子中,getThis 是一个函数声明,它返回内部的 this 值。之后在全局上下文中直接调用了 getThis,它返回了全局对象。

非严格模式下直接调用标准函数,其内部的 this 值指向全局对象,而不论调用的上下文:

function foo() {
  function getThis() {
    console.log(this === window);
    // -> true
  }
  getThis();
}

foo();

在这个例子中,getThis 在函数 foo 的上下文中直接调用,但不论是在全局上下文还是函数上下文中,非严格模式下直接调用标准函数,其内部的 this 都指向全局对象。

  • 严格模式下直接调用标准函数,则其内部的 this 为 undefined。

来看下面几个例子:

function getThisInStrict() {
  'use strict';
  return this;
}

console.log(getThisInStrict() === window);
// -> false

console.log(getThisInStrict() === undefined);
// -> true

在这个例子中,函数 getThisInStrict 的作用域内使用了严格模式。之后,在全局上下文中调用了 getThisInStrict,它内部的 this 为 undefined 而不是全局对象。

同样的,严格模式下直接调用标准函数,其内部的 this 为 undefined,而不论调用的上下文。

  • 标准函数作为方法调用时,this 指向直接调用者,而非间接调用者。

来看下面几个例子:

function getThis() {
  return this;
}

let o = {
  name: 'o',
};

o.getThis = getThis;
console.log(o.getThis().name);
// -> 'o'

在这个例子中,getThis 作为 o 的方法在全局上下文中调用。getThis 内部的 this 指向 o。

这个规则不受严格模式和调用方法时的上下文影响:

'use strict';

function getThis() {
  return this;
}

function foo() {
  const o = {
    name: 'o',
  };

  o.getThis = getThis;
  console.log(o.getThis().name);
  // -> 'o'
}

foo();

在这个例子中,全局使用了严格模式。之后在函数 foo 上下文中,getThis 作为 o 的方法被调用。其内部的 this 指向 o。

复杂的情况是通过间接方式调用函数:

function getThis() {
  return this;
}

console.log(getThis.prototype.constructor === getThis);
// -> true

console.log(getThis.prototype.constructor() === getThis.prototype);
// -> true

let constructor = getThis.prototype.constructor;
console.log(constructor() === window);
// -> true

在这个例子中,getThis.prototype.constructor 返回 getThis 函数对象本身。在执行 getThis.prototype.constructor()时,getThis 的直接调用者为 getThis.prototype,因此 this 指向 getThis.prototype。之后,将 getThis.prototype.constructor 赋给 constructor,并直接调用,因此 this 指向全局对象。

  • 标准函数作为构造函数调用,this 指向正在构建的对象:
function Person() {
  this.name = 'Nicholas';
  console.log(this instanceof Person);
  // -> true
}

new Person();

在这个例子中,Person 作为构造方法调用,this 指向被构建的对象,因此 this instanceOf Person 为 true。

1.4.2.3. 箭头函数

箭头函数的 this 行为和标准函数的截然不同。

  • 箭头函数在全局上下文中,则其 this 绑定全局对象,且严格模式和方法调用对该 this 没有影响。

来看下面几个例子:

const getThisInArrowFunc = () => {
  'use strict';
  return this;
};

console.log(getThisInArrowFunc() === window);
// -> true

在这个例子中,getThisInArrowFunc 位于全局上下文,其内部使用了严格模式,但这不影响 this 的值。this 依旧绑定全局对象。

var name = 'window';
const o1 = {
  name: 'o1',
  o2: {
    name: 'o2',
    getThis: () => this,
  },
};

console.log(o1.o2.getThis().name);
// -> 'window'

在这个例子中,getThis 作为 o2 的方法被 o1 间接在全局上下文中调用,但不论是否作为方法被调用,getThis 都在全局上下文中,因此其内部的 this 还是绑定全局对象。

  • 箭头函数在函数上下文中,则其 this 绑定紧邻的外层函数的 this。
var name = 'window';
function foo() {
  const o1 = {
    name: 'o1',
    o2: {
      name: 'o2',
      getThis: () => this,
    },
  };
  console.log(o1.o2.getThis().name); // window
}
foo();

在这个例子中,箭头函数 getThis 在 foo 函数的上下文中。这里 foo 在非严格模式下直接调用,所以函数 foo 的 this 指向全局对象,箭头函数绑定了该值。

当然,如过外层的函数 foo 以方法调用,则该箭头函数内部的 this 就绑定 foo 的直接调用者。

有读者知道,在事件回调或定时回调中调用某个函数时,this 值指向的并非想要的对象。此时将回调函数写成箭头函数就可以解决问题:

function King() {
  this.royaltyName = 'Henry';
  // this 引用King 的实例
  setTimeout(() => console.log(this.royaltyName), 1000);
}
function Queen() {
  this.royaltyName = 'Elizabeth';
  // this 引用window 对象
  setTimeout(function () {
    console.log(this.royaltyName);
  }, 1000);
}
new King(); // Henry
new Queen(); // undefined

1.4.3. new.target

ECMAScript 中的函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。ECMAScript 6 新增了检测函数是否使用 new 关键字调用的 new.target 属性。如果函数是正常调用的,则 new.target 的值是 undefined;如果是使用 new 关键字调用的,则 new.target 将引用被调用的构造函数。

来看这个例子:

function foo() {
  const funcName = arguments.callee.name;
  if (new.target) {
    throw `函数${funcName}不能用作构造函数!`;
  } else {
    console.log(`函数${funcName}用为非构造函数!`);
  }
}

new foo();
foo();

在这个例子中,函数 foo 的名字使用 arguments.callee.name 取得,如果 foo 用作构造函数,则 new.target 指向构造函数本身,为非空对象,则抛出错误。如果 foo 没有用作构造函数,则 new.target 为 undefined,正常执行。

1.5. 函数属性

1.5.1. name

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

如果函数是一个获取函数、设置函数,或者使用了 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;
  },
};
let propertyDescriptor = Object.getOwnPropertyDescriptor(dog, 'age');
console.log(propertyDescriptor.get.name); // get age
console.log(propertyDescriptor.set.name); // set age

如果使用符号作为方法名称,则使用空参数符号的方法名称返回空字符串,有描述符的符号作为方法名称返回 [描述符],使用内置常用符号作为方法名称返回 [内置常用符号],如 [Symbol.iterator]

const emptySymbol = Symbol();
const fooSymbol = Symbol('foo');

const o = {
  [emptySymbol]() {},
  [fooSymbol]() {},
  [Symbol.iterator]() {},
};

console.log(o[emptySymbol].name); // (空字符串)
console.log(o[fooSymbol].name); // [foo]
console.log(o[Symbol.iterator].name); // [Symbol.iterator]

1.5.2. length

length 属性表示函数的命名参数的个数,它区别于 arguments.length :这个值一般情况下是函数实际传入的参数个数。

length 的特性为 writable: false, enumerable: false, configurable: true。因此只读,不可枚举,但可配置。

来看下面这个例子:

console.log(Function.length); // 1
console.log(function () {}.length); // 0
console.log(function (a) {}.length); // 1
console.log(function (a, b) {}.length); // 2

函数的构造函数 Function 本身也是一个函数,它的 length 返回 1。如果没有一个函数没有命名参数则返回 0。

需要注意的是:

  • 收集参数不计入数量。
  • 命名参数的计数在第一个有默认参数值的命名参数前停止。

如下所示:

console.log(function (a, b, ...args) {}.length); // 2
console.log(function (a, b = 1, c) {}.length); // 1

1.5.3. caller

ECMAScript 5 也会给函数对象上添加一个属性:caller。虽然 ECMAScript 3 中并没有定义,但所有浏览器除了早期版本的 Opera 都支持这个属性。这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为 null。比如:

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

以上代码会显示 outer()函数的源代码。这是因为 ourter()调用了 inner(),inner.caller 指向 outer()。如果要降低耦合度,则可以通过 arguments.callee.caller 来引用同样的值:

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

在严格模式下访问 arguments.callee 会报错。ECMAScript 5 也定义了 arguments.caller,但在严格模式下访问它会报错,在非严格模式下则始终是 undefined。这是为了分清 arguments.caller 和函数的 caller 而故意为之的。而作为对这门语言的安全防护,这些改动也让第三方代码无法检测同一上下文中运行的其他代码。

严格模式下还有一个限制,就是不能给函数的 caller 属性赋值,否则会导致错误。

1.5.4. 其他属性

prototype 属性也许是 ECMAScript 核心中最有趣的部分。prototype 是保存引用类型所有实例方法的地方,这意味着 toString()、valueOf()等方法实际上都保存在 prototype 上,进而由所有实例共享。这个属性在自定义类型时特别重要。(相关内容已经在第 8 章详细介绍过了。)这个属性的特性在 ES6 中为 writable: true, enumerable: false, configurable: false。因此可写入,不可枚举,不可配置。

1.6. 函数方法

每个函数实例都继承了 Function 原型上的 call(),apply(),以及 bind() 方法。这些方法用于指定调用该函数时的内部 this 值。

在 10.9.2 节我们详细介绍了 this 的行为,现在让我们来回顾一下。

在标准函数上下文中,有以下规则:

  • 非严格模式下直接调用函数,则其内部的 this 指向全局对象。
  • 严格模式下直接调用函数,则其内部的 this 为 undefined。
  • 函数作为方法调用,其内部的 this 指向直接调用者,而非间接调用者。
  • 函数作为构造函数调用,其内部的 this 指向被构建的对象。

在箭头函数中,有以下规则:

  • 箭头函数在全局上下文中,则其 this 绑定全局对象。
  • 箭头函数在函数上下文中,则其 this 绑定紧邻的外层函数的 this 值。

1.6.1. call()

call(thisArg, arg1, arg2, ...) 的第一个参数 thisArg 是可选的,它指定在 function 函数运行时使用的 this 值。后面的 arg1, arg2, ... 是指定的参数。call() 方法在标准函数上使用时,有以下规则:

  1. 不指定 thisArg,相当于不调用 call()。
function showThis() {
  'use strict';
  console.log(this);
}
showThis.call(); // undefined 相当于 showThis()
  1. 非严格模式下,thisArg 为 undefined 或 null,相当于指定了 thisArg 为全局对象。如果 thisArg 指定了其他的原始值,则相当于指定了 thisArg 为对应的包装类型。
var name = 'window';
function showThis() {
  console.log(this.name);
}
showThis.call(undefined); // window
showThis.call(null); // window
showThis.call(0); // Number{0}
showThis.call(false); // Boolean{false}
  1. 严格模式下,thisArg 就是所指定的值。
function showThis() {
  'use strict';
  console.log(this);
}
showThis.call(null); // null

1.6.2. apply()

apply(thisArg, [args]) 方法和 call()方法除了 thisArg 后跟的参数不同,其他都相同。apply() 方法要求传入的参数为数组对象或类数组对象。

function sum() {
  const result = [...arguments].reduce((pre, cur) => pre + cur, 0);
  console.log(result);
}

const arrLikeO = {
  0: 1,
  1: 2,
  2: 3,
  length: 3,
};

const arr = [1, 2, 3];

sum.apply(null, arrLikeO); // 6
sum.apply(null, arr); // 6

1.6.3. bind()

bind(thisArg, arg1, arg2, ...) 方法用于返回一个固定 this 的函数。它接收的参数中,thisArg 的含义及处理和 call() 的一致,其余参数被称为预置参数。

来看这个例子:

const o = {name: 'o'};

function showThis() {
  console.log(this.name);
}

showThis = showThis.bind(o);

showThis(); // o

在这个例子中,showThis 被重写为 this 固定于 o 上的函数。在被直接调用时,如果没有 bind(),则它内部的 this 就是全局对象,会输出 undefined,但这里输出了 o。显然 this 的固定起了效果。

  1. bind() 的 this 固定效果是一次性的,意思就是已经 bind 过的函数不能再 bind 一次,而且对已经 bind 过的函数调用 call() 或 apply() 方法没有影响。

来看这个例子:

var name = 'window';
const o = {name: 'o'};

function showThis() {
  console.log(this.name);
}

showThis = showThis.bind(o);

showThis = showThis.bind(this);

showThis(); // o
showThis.call(this); // o
showThis.apply(this); // o

在这个例子中,虽然 showThis 在固定 this 为 o 后,再次被固定为全局对象,但 bind 的效果是一次性的,因此输出不是 window,而是 o。

  1. 预置参数会自动插入传入的参数前面。

来看下面的例子:

function sum() {
  console.log(...arguments);
  return [...arguments].reduce((pre, cur) => pre + cur, 0);
}

sum = sum.bind(null, 1, 2, 3);

console.log(sum());
// 1 2 3
// 6

console.log(sum(4));
// 1 2 3 4
// 10

在这个例子中,sum 函数返回参数相加的结果。之后 sum 被重写为具有预置 1,2,3 参数的函数。这就表示在调用时,这些预置参数会自动插入传入的参数前面。因此,sum() 返回 6,而 sum(4)返回 10。

需要注意的是,如果你使用预置参数就要意识到你传入的参数并不是实际的参数。

function introduce(name, age, job) {
  console.log(`My name is ${name}, I am ${age}, and I work as a ${job}.`);
}

introduce = introduce.bind(null, 'Trigold', '20', 'Web Engineer');

introduce();
// My name is Trigold, I am 20, and I work as a Web Engineer.
introduce('Greg', '30', 'doctor');
// My name is Trigold, I am 20, and I work as a Web Engineer.

1.6.3.1. 箭头函数

对于箭头函数调用以上 3 种方法,实际上箭头函数会忽略传进去的 this 参数,这就是为什么我们在箭头函数的 this 规则中说箭头函数的 this “绑定” 了什么。

来看下面的例子:

var name = 'window';
let showThis = () => console.log(this.name);
const o = {name: 'o'};
showThis.call(o); // window
showThis.apply(o); // window
showThis = showThis.bind(o);
showThis(); // window

1.6.4. 其他函数方法

对函数而言,继承的方法 toLocaleString()和 toString()始终返回函数的代码。返回代码的具体格式因浏览器而异。有的返回源代码,包含注释,而有的只返回代码的内部形式,会删除注释,甚至代码可能被解释器修改过。由于这些差异,因此不能在重要功能中依赖这些方法返回的值,而只应在调试中使用它们。继承的方法 valueOf()返回函数本身。

1.7. 闭包和私有变量

闭包是一种特殊的函数,这种函数在定义时访问了函数体外部的标识符。这种函数访问的标识符称为 自由变量。这种访问称为 捕获

当闭包执行时,由于静态作用域的原因,它访问的总是自由变量。

当自由变量被限制于某个局部作用域中时,称捕获了该自由变量的闭包为 闭包(狭义闭包,经典闭包)。而当自由变量位于全局作用域时,称捕获了该自由变量的闭包为 广义闭包

闭包一直以来难以被人们理解。下面的章节详细讲解了闭包。希望读者对闭包会有更深的理解。

1.7.1. 静态作用域

作用域主要规定了标识符的访问规则。然而,JavaScript 的作用域同时是 静态作用域

所谓静态作用域,就是指函数执行时,访问标识符的一种机制。

通常情况下,我们通过函数字面量定义了一个函数:

const foo = function () {
  console.log('bar');
};

然后再调用它:

foo();
// -> 'bar'

这很容易。

但是如果这样定义呢:

let foo;

if (true) {
  const bar = 'bar';

  foo = function () {
    console.log(bar);
  };
}

const bar = 'baz';
foo();
// -> 'bar'

我们看到,foo 函数字面量访问了 if 块里面的 bar 变量。而在执行时,foo() 没有打印出声明在它前面的同名 bar 变量。实际上打印的依然是 if 块中的 bar 变量。

这就是静态作用域。静态作用域下,函数执行时,访问的标识符在定义它的那个块中,而不是执行的那个块中。这是因为 JavaScript 在编译时就确定好了 foo 函数,而不是在动态的执行时。

1.7.2. 自由变量

在静态作用域一节的例子中:

let foo;

if (true) {
  const bar = 'bar';

  foo = function () {
    console.log(bar);
  };
}

const bar = 'baz';
foo();
// -> 'bar'

我们看到,foo 函数字面量访问了函数体外的一个变量: bar。实际上,这个被访问的 bar 变量就是自由变量。

我们还注意到,被访问的 bar 变量位于 if 块中,即位于一个局部作用域中,所以 foo 函数就是一个捕获了在局部作用域中自由变量的闭包。显然,这是一个狭义的闭包,或者简称闭包。

来看下面一个例子。

let bar = 'bar';

function foo() {
  const changeBar = function () {
    bar = 'bar changed';
  };

  changeBar();
}

foo();
console.log(bar);
// -> 'bar changed'

这个例子中,changeBar 函数字面量访问了位于全局作用域中的 bar。因此,changeBar 函数是一个广义闭包。

广义闭包和狭义闭包在静态作用域的表现上没有差别。只是,广义闭包捕获的自由变量位于全局作用域,也就是在任何地方都可以访问到,没有任何的作用域访问限制。

闭包访问到的自由变量是动态的,实时的,而不是一个快照

let foo;

if (true) {
  let bar = 'bar';

  setTimeout(() => {
    bar = 'bar changed';
  }, 2000);

  foo = function () {
    console.log(bar);
  };
}

foo();
// -> 'bar'

setTimeout(foo, 2500);
// -> 'bar changed'

在这个例子中,闭包 foo 捕获了 bar 变量。这意味着,调用 foo 时,访问到的 bar 变量是动态的。我们先同步调用了 foo,发现打印出了 'bar'。而在大约 2500 后,调用 foo,发现打印出了 'bar changed'。

1.7.3. 私有变量

我们知道,在 JavaScript (es6)中,想要让一个变量不能被外界访问时,就用一个块将它包裹起来。这样的变量只能在内部块中使用,因此称为 私有变量

// 现在 foo 可以被访问
const foo = 'foo';
console.log(foo);
// -> 'foo'

用一个块,比如 if 块包裹它:

if (true) {
  const foo = 'foo';
}

console.log(foo);
// 报错,找不到 foo

这样,就访问不到 foo 了。

1.7.3.1. 使用函数块和 return

然而,使用 if 块过于粗暴。我们往往想限制一部分标识符,然后暴露一部分标识符,这可以办到吗?

if (true) {
  const foo = 'foo';
  const bar = 'bar';
}

console.log(foo, bar);
// 报错

在这个例子中,我们想限制 foo 的访问,但是暴露 bar 给外界。但是 if 块把所有在内的标识符都限制了。

这时,函数的 return 语句使得我们有了新的灵感。

function controlScope() {
  const foo = 'foo';
  const bar = 'bar';
}

我们使用函数的块将 foo 和 bar 包裹起来,此时外部无法访问 foo 和 bar。这和 if 块的效果是一样的。

然而使用了 return:

function controlScope() {
  const foo = 'foo';
  const bar = 'bar';

  return bar;
}

console.log(controlScope());
// -> 'bar'

这样我们就只限制 foo 了。

当我们不需要在多个地方调用 controlScope 时,使用立即调用函数显得更为简便。

const bar = (function () {
  const foo = 'foo';
  const bar = 'bar';

  return bar;
})();

console.log(bar);
// -> 'bar'

当我们想暴露多个标识符,而不是像上面的例子中只暴露 bar 一个变量时,常见的作法是,返回一个对象,而让像暴露的变量挂载到对象的属性上。

const exposure = (function () {
  const foo = 'foo';
  const bar = 'bar';
  const baz = 'baz';

  return {bar, baz};
})();

console.log(exposure.bar, exposure.baz);
// -> 'bar', 'baz'

1.7.3.2. 使用引用

但我们还是忽略了一个重要的方面,那就是到目前为止,暴露出来的变量只是变量的一个快照,没有动态性。

const exposure = (function () {
  const foo = 'foo';
  let bar = 'bar';

  setTimeout(() => {
    bar = 'bar changed';
  }, 2000);

  return {bar};
})();

console.log(exposure.bar);
// -> 'bar'

setTimeout(() => console.log(exposure.bar), 2500);
// -> 'bar'

这个例子中,虽然暴露了 bar 变量,然而当我们试图在大约 2500 ms 之后访问 bar 时,理想的情况应该是打印出 'bar changed'。但实际打印出了 'bar'。这是因为暴露出的 bar 属性只是简单被赋值为 'bar'。

但是如果我们暴露引用,这个问题就可以解决了。

const exposure = (function () {
  const foo = 'foo';
  const exposure = {bar: 'bar'};

  setTimeout(() => {
    exposure.bar = 'bar changed';
  }, 2000);

  return exposure;
})();

console.log(exposure.bar);
// -> 'bar'

setTimeout(() => console.log(exposure.bar), 2500);
// -> 'bar changed'

下面这个例子利用暴露引用,使用 es5 封装了 es6 的 class。

var Person = (function () {
  var Person = function Person(name, interest) {
    this.name = name;
    this.interest = interest;
  };

  Person.prototype.introduce = function () {
    console.log(this.name, this.interest);
  };

  return Person;
})();

var person = new Person('trigold', '前端开发');
person.introduce();
// -> 'trigold', '前端开发'

1.7.3.3. 使用闭包

另外一种做法是使用闭包,由于闭包捕获的自由变量具有动态性,因此使用闭包可以非常容易的解决这个问题。

const exposure = (function () {
  const foo = 'foo';
  let bar = 'bar';
  setTimeout(() => {
    bar = 'bar changed';
  }, 2000);

  return {
    getBar() {
      return bar;
    },
  };
})();

console.log(exposure.getBar());
// -> 'bar'
setTimeout(() => console.log(exposure.getBar()), 2500);
// -> 'bar changed'

除此之外,闭包还能附加一些逻辑。比如上面的例子中,我们只暴露了获取 bar 的闭包,但是没有暴露设置 bar 的闭包。这样相当于控制 bar 为只读。

上面例子的这种模式被称为 模块模式。在 es6 的模块系统还没有出现以前,为了保证模块之间的封装性,就会使用这种模式。

假设我们有 1 个模块: A。我们想让模块 A 中的变量一些私有(只能在模块 A 中使用)和公有(其他模块也可以使用)。那么下面的例子就满足了我们的目标。

// moduleA
const expoureA = (function () {
  let a = 'a';
  let _a = 'private a';

  const funcA = function () {
    console.log('funcA');
  };

  const _funcA = function () {
    console.log('private funcA');
  };

  return {
    get a() {
      return a;
    },
    set a(newA) {
      a = newA;
    },
    funcA,
  };
})();

console.log(expoureA.a);
// -> 'a'

expoureA.a = 'a changed';
console.log(expoureA.a);
// -> 'a changed'

expoureA.funcA();
// -> 'funcA'

上面例子中,模块 A 向其他模块暴露了公有的 a 变量,以及 funcA 函数。而保持了 _a 变量和 _funcA 私有。

1.8. 递归和尾调优化

本节讲解一种函数执行逻辑,这种逻辑涉及不断调用自身,我们把这种执行逻辑称为 递归(recursion)。不过在讲解递归之前,有必要对调用栈进行说明。

1.8.1. 调用栈

调用栈(call stack),严格来说称为 执行上下文栈(ECS, execution context stack),是 JavaScript 脚本执行时,用来管理 执行上下文(execution context) 的一种结构。

JavaScript 代码在运行时,执行引擎会创建一些称为执行上下文的记录。执行上下文用来保存代码执行的一些状态,比如:执行的作用域,函数的特殊内部值(arguments, this), global 对象等等。这些状态对执行代码是必要的。

执行上下文主要有 2 种:全局执行上下文(GEC, global execution context)函数执行上下文(FEC, function execution context)

全局上下文每当在 JavaScript 引擎执行一个脚本时,就会创建。而函数执行上下文,则是每运行到一个函数体才会创建。

在一个脚本整个执行过程中的众多的执行上下文被执行上下文栈管理着。执行上下文栈逻辑上被设计为一个栈的结构,而每个执行上下文就是栈中的元素,脚本的执行过程伴随着执行上下文的入栈和出栈。

全局执行上下文在运行脚本之初就进入了执行上下文栈,此后,每运行到一个函数体便有对应的函数执行上下文进入执行上下文栈,当执行到函数内部的 return 语句,便弹出该函数的执行上下文。直到最后,所有的函数都被执行完毕,此时执行上下文栈中,只有全局上下文保留在栈底。全局上下文只有 tab 页被关闭才会被弹出执行上下文栈。

为了使得读者更加具体地理解执行上下文栈。下面给出了一个例子。

function baz() {
  // return undefined;
}

function bar() {
  baz();
  // return undefined;
}

function foo() {
  bar();
  // return undefined;
}

foo();

当引擎执行这段代码时,最初就会创建一个全局执行上下文(GEC),然后全局上下文进入执行上下文栈(ECS)。

这里我们使用一个数组表示执行上下文栈。

const ECS = [];

那么全局上下文全局上下文进入执行上下文栈:

const ECS = [];

// 最初执行脚本
const GEC = {};
ECS.push(GEC);

当引擎执行到 foo() 这条语句时,便转移到 foo 函数体中执行。此时,引擎会创建一个对应的函数执行上下文: fooFEC。

const ECS = [];

// 最初执行脚本
const GEC = {};
ECS.push(GEC);

// 遇到 foo() 语句
const fooFEC = {};
ECS.push(fooFEC);

然而,在 foo 函数体执行时,又遇到 bar() 语句,于是 barFEC 被创建:

const ECS = [];

// 最初执行脚本
const GEC = {};
ECS.push(GEC);

// 遇到 foo() 语句
const fooFEC = {};
ECS.push(fooFEC);

// 遇到 bar() 语句
const barFEC = {};
ECS.push(barFEC);

类似地,当在 bar 函数体中执行时,又遇到 baz() 语句,于是:

const ECS = [];

// 最初执行脚本
const GEC = {};
ECS.push(GEC);

// 遇到 foo() 语句
const fooFEC = {};
ECS.push(fooFEC);

// 遇到 bar() 语句
const barFEC = {};
ECS.push(barFEC);

// 遇到 baz() 语句
const bazFEC = {};
ECS.push(bazFEC);

可以看出出此时的 ECS: [GEC, fooFEC, barFEC, bazFEC]

当 baz 函数体执行完毕,实际上执行到 return undefined 时,便会弹出 bazFEC。这样 baz() 语句执行完毕。继续执行到 bar 中的 return undefined 时,便会弹出 barFEC。这样 bar() 语句执行完毕。继续执行到 foo 中的 return undefined 时,便会弹出 fooFEC。

不难想象此时的 ECS: [GEC]。只剩下全局上下文在栈底。

最后,直到用户关闭了对应的 tab 页,GEC 也会被弹出。

1.8.2. 递归

递归简单来说就是函数调用本身。来看下面一个例子:

function factorial(num) {
  if (Number.isInteger(num) && num >= 0) {
    if (num === 0) {
      return 1;
    } else {
      return num * factorial(num - 1);
    }
  }
}

console.log(factorial(3));
// -> 6

上面这个例子中,factorial 函数其实就是求解阶乘。factorial(3) 打印出 6 就是因为 3! = 1 * 2 *3 = 6;

我们看到传入的 num 等于 0 的时候,直接返回了 1。因为 0! = 1;。而当 num 大于 0 时,则返回 num * factorial(num - 1)。这是什么意思呢?

一般情况下,我们计算阶乘都是类似 3! = 1 * 2 * 3 这样算。但是其实阶乘也有另外一种计算方式: 3! = 3 * 2! = 3 * 2 * 1 = 6。看到这里相信读者已经明白了。这个表达式就是 num * factorial(num - 1)

我们看到这个表达式显然调用了 factorial(num - 1),这就涉及调用了函数本身。因此这就是一个典型的递归。

那么为什么需要递归呢?这是因为在一些情况下,这样编写代码更简洁和容易,比如在二叉搜索树结构的遍历时。

1.8.2.1. 终止条件

但是递归运行起来也容易出错。最常出现的错误就是没有给递归一个终止条件。

我们来看下面一个例子。

function foo() {
  foo();
}

foo();

这是一段很简单的代码,但是执行结果是什么呢?

function foo() {
  foo();
}

foo();
// -> RangeError: Maximum call stack size exceeded

执行结果爆出了错误。错误显示超出了最大调用栈。

回想调用栈一节我们讲过的函数执行上下文: 每当一个函数调用时,便会创建一个对应的函数执行上下文,并进入执行上下文栈。

现在我们来看看这段代码的执行过程。

当引擎遇到 foo() 语句时,便创建了一个 fooFEC 进入了执行上下文栈。接着继续执行foo函数中的 foo() 语句,于是便又创建了一个 fooFEC 进入了执行上下文栈。接着继续执行...。

看到这里,读者应该明白什么意思了。由于没有终止条件,引擎便会一直创建 fooFEC 进入执行上下文栈。直到栈的容量超出限制。

在编写涉及递归的代码时,评估代码是否会终止是非常重要的。

对于上面例子中的代码,我们可以使用一个额外的变量 callCount 以记录 foo 函数调用的次数,当 callCount 小于某个限制时,才允许 foo 函数得到执行。

let callCount = 0;

function foo() {
  if (callCount < 100) {
    callCount++;
    foo();
  }
}

foo();

这样便不会报错。

1.8.2.2. 命名函数表达式

递归函数通常的形式是一个函数通过名称调用自己,如下面的例子所示:

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

这是经典的递归阶乘函数。虽然这样写是可以的,但如果把这个函数赋值给其他变量,就会出问题:

const anotherFactorial = factorial;
factorial = null;

// 报错
console.log(anotherFactorial(4));

这里把 factorial()函数保存在了另一个变量 anotherFactorial 中,然后将 factorial 设置为 null,于是只保留了一个对原始函数的引用。而在调用 anotherFactorial()时,要递归调用 factorial(),但因为它已经不是函数了,所以会出错。在写递归函数时使用 arguments.callee 可以避免这个问题。

arguments.callee 就是一个指向正在执行的函数的指针,因此可以在函数内部递归调用,如下所示:

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

像这里加粗的这一行一样,把函数名称替换成 arguments.callee,可以确保无论通过什么变量调用这个函数都不会出问题。因此在编写递归函数时,arguments.callee 是引用当前函数的首选。

不过,在严格模式下运行的代码是不能访问 arguments.callee 的,因为访问会出错。此时,可以使用 命名函数表达式(named function expression) 达到目的。比如:

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

这里创建了一个命名函数表达式 f(),然后将它赋值给了变量 factorial。即使把函数赋值给另一个变量,函数表达式的名称 f 也不变,因此递归调用不会有问题。这个模式在严格模式和非严格模式下都可以使用。

1.8.3. 尾调优化

ECMAScript 6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧。具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。比如:

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 规范所规定的,兼容的浏览器实现都能保证在代码满足条件的情况下应用这个优化。

1.8.3.1. 尾调用优化的条件

尾调用优化的条件就是确定外部栈帧真的没有必要存在了。涉及的条件如下:

  • 代码在严格模式下执行;
  • 外部函数的返回值是对尾调用函数的调用;
  • 尾调用函数返回后不需要执行额外的逻辑;
  • 尾调用函数不是引用外部函数作用域中自由变量的闭包。

下面展示了几个违反上述条件的函数,因此都不符号尾调用优化的要求:

'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,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。

1.8.3.2. 使用尾调优化

可以通过把简单的递归函数转换为待优化的代码来加深对尾调用优化的理解。下面是一个通过递归计算斐波纳契数列的函数:

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(2n)。因此,即使这么一个简单的调用也可以给浏览器带来麻烦:

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) 就不会对浏览器造成威胁了。