函数

函数是 JavaScript 中最基本的组成部分之一。JavaScript 中的函数类似于一个过程——一组执行任务或计算值的语句,但一个过程要作为函数,它应该接受一些输入并返回一个输出,并且输入和输出之间存在明显的关联。要使用函数,你必须在希望调用它的作用域中定义它。

另请参阅关于 JavaScript 函数的详尽参考章节以了解详细信息。

定义函数

函数声明

一个函数定义(也称为函数声明函数语句)由 function 关键字组成,后跟

  • 函数名。
  • 用括号括起来并用逗号分隔的函数参数列表。
  • 定义函数的 JavaScript 语句,用花括号 { /* … */ } 括起来。

例如,以下代码定义了一个名为 square 的函数

js
function square(number) {
  return number * number;
}

函数 square 接受一个名为 number 的参数。该函数包含一个语句,该语句表示返回函数的参数(即 number)乘以它自身。 return 语句指定函数返回的值,即 number * number

参数本质上是按值传递给函数的——因此,如果函数体内的代码为传递给函数的参数赋了一个全新的值,则此更改不会全局反映,也不会在调用该函数的代码中反映

当你将一个对象作为参数传递时,如果函数更改了该对象的属性,那么这种更改在函数外部是可见的,如下例所示

js
function myFunc(theObject) {
  theObject.make = "Toyota";
}

const myCar = {
  make: "Honda",
  model: "Accord",
  year: 1998,
};

console.log(myCar.make); // "Honda"
myFunc(myCar);
console.log(myCar.make); // "Toyota"

当你将一个数组作为参数传递时,如果函数更改了数组的任何值,那么这种更改在函数外部是可见的,如下例所示

js
function myFunc(theArr) {
  theArr[0] = 30;
}

const arr = [45];

console.log(arr[0]); // 45
myFunc(arr);
console.log(arr[0]); // 30

函数声明和表达式可以嵌套,这形成了作用域链。例如

js
function addSquares(a, b) {
  function square(x) {
    return x * x;
  }
  return square(a) + square(b);
}

有关更多信息,请参阅函数作用域和闭包

函数表达式

尽管上述函数声明在语法上是一个语句,但函数也可以通过函数表达式创建。

这样的函数可以是匿名函数;它不必有名字。例如,函数 square 可以定义为

js
const square = function (number) {
  return number * number;
};

console.log(square(4)); // 16

然而,函数表达式可以提供一个名称。提供一个名称允许函数引用自身,也使得在调试器的堆栈跟踪中更容易识别该函数

js
const factorial = function fac(n) {
  return n < 2 ? 1 : n * fac(n - 1);
};

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

当将函数作为参数传递给另一个函数时,函数表达式很方便。以下示例定义了一个 map 函数,它应该将一个函数作为第一个参数,一个数组作为第二个参数。然后,它用一个由函数表达式定义的函数调用

js
function map(f, a) {
  const result = new Array(a.length);
  for (let i = 0; i < a.length; i++) {
    result[i] = f(a[i]);
  }
  return result;
}

const numbers = [0, 1, 2, 5, 10];
const cubedNumbers = map(function (x) {
  return x * x * x;
}, numbers);
console.log(cubedNumbers); // [0, 1, 8, 125, 1000]

在 JavaScript 中,可以根据条件定义函数。例如,以下函数定义仅在 num 等于 0 时定义 myFunc

js
let myFunc;
if (num === 0) {
  myFunc = function (theObject) {
    theObject.make = "Toyota";
  };
}

除了这里描述的定义函数之外,你还可以使用 Function 构造函数在运行时从字符串创建函数,这与 eval() 非常相似。

方法是作为对象属性的函数。在使用对象中阅读更多关于对象和方法的信息。

调用函数

定义一个函数不会执行它。定义它只是命名函数并指定函数被调用时要执行的操作。

调用函数实际上使用指定的参数执行指定的操作。例如,如果定义了函数 square,你可以按如下方式调用它

js
square(5);

上述语句以参数 5 调用函数。函数执行其语句并返回 25

函数在被调用时必须在作用域内,但函数声明可以被提升(在代码中出现在调用下方)。函数声明的作用域是声明它的函数(或者如果是顶层声明,则是整个程序)。

函数的参数不限于字符串和数字。你可以将整个对象传递给函数。showProps() 函数(定义在使用对象中)是一个接受对象作为参数的例子。

函数可以调用自身。例如,这是一个递归计算阶乘的函数

js
function factorial(n) {
  if (n === 0 || n === 1) {
    return 1;
  }
  return n * factorial(n - 1);
}

然后你可以按如下方式计算 15 的阶乘

js
console.log(factorial(1)); // 1
console.log(factorial(2)); // 2
console.log(factorial(3)); // 6
console.log(factorial(4)); // 24
console.log(factorial(5)); // 120

还有其他调用函数的方法。通常在需要动态调用函数、函数参数数量可变或函数调用的上下文需要设置为在运行时确定的特定对象的情况下。

事实证明,函数本身就是对象——反过来,这些对象也具有方法。(请参阅Function 对象。)call()apply() 方法可以用于实现此目标。

函数提升

考虑下面的例子

js
console.log(square(5)); // 25

function square(n) {
  return n * n;
}

这段代码运行没有任何错误,尽管在声明 square() 函数之前就调用了它。这是因为 JavaScript 解释器将整个函数声明提升到当前作用域的顶部,所以上面的代码等同于

js
// All function declarations are effectively at the top of the scope
function square(n) {
  return n * n;
}

console.log(square(5)); // 25

函数提升只适用于函数声明——不适用于函数表达式。以下代码将不起作用

js
console.log(square(5)); // ReferenceError: Cannot access 'square' before initialization
const square = function (n) {
  return n * n;
};

递归

函数可以引用并调用自身。它可以通过函数表达式或声明的名称来引用,也可以通过任何引用函数对象的在作用域内的变量来引用。例如,考虑以下函数定义

js
const foo = function bar() {
  // statements go here
};

在函数体内部,你可以将函数本身称为 barfoo,并使用 bar()foo() 调用它。

调用自身的函数称为递归函数。在某些方面,递归类似于循环。两者都执行相同的代码多次,并且两者都需要一个条件(以避免无限循环,或者在这种情况下是无限递归)。

例如,考虑以下循环

js
let x = 0;
// "x < 10" is the loop condition
while (x < 10) {
  // do stuff
  x++;
}

它可以转换为递归函数声明,然后调用该函数

js
function loop(x) {
  // "x >= 10" is the exit condition (equivalent to "!(x < 10)")
  if (x >= 10) {
    return;
  }
  // do stuff
  loop(x + 1); // the recursive call
}
loop(0);

然而,有些算法不能是简单的迭代循环。例如,通过递归获取树结构(例如 DOM)的所有节点更容易

js
function walkTree(node) {
  if (node === null) {
    return;
  }
  // do something with node
  for (const child of node.childNodes) {
    walkTree(child);
  }
}

与函数 loop 相比,这里的每个递归调用本身都会进行许多递归调用。

可以将任何递归算法转换为非递归算法,但逻辑通常要复杂得多,并且这样做需要使用堆栈。

事实上,递归本身就使用了一个栈:函数栈。堆栈式的行为可以在以下示例中看到

js
function foo(i) {
  if (i < 0) {
    return;
  }
  console.log(`begin: ${i}`);
  foo(i - 1);
  console.log(`end: ${i}`);
}
foo(3);

// Logs:
// begin: 3
// begin: 2
// begin: 1
// begin: 0
// end: 0
// end: 1
// end: 2
// end: 3

立即调用的函数表达式 (IIFE)

一个立即调用的函数表达式 (IIFE) 是一种代码模式,它直接调用定义为表达式的函数。它看起来像这样

js
(function () {
  // Do something
})();

const value = (function () {
  // Do something
  return someValue;
})();

函数不是保存在变量中,而是立即调用。这几乎等同于直接编写函数体,但有一些独特的优点

  • 它创建了一个额外的变量作用域,这有助于将变量限制在它们有用的地方。
  • 它现在是一个表达式而不是一系列语句。这允许你在初始化变量时编写复杂的计算逻辑。

更多信息,请参阅 IIFE 术语表条目。

函数作用域和闭包

函数为变量形成作用域——这意味着在函数内部定义的变量无法从函数外部的任何地方访问。函数作用域继承自所有上层作用域。例如,在全局作用域中定义的函数可以访问在全局作用域中定义的所有变量。在另一个函数内部定义的函数也可以访问在其父函数中定义的所有变量,以及父函数有权访问的任何其他变量。另一方面,父函数(以及任何其他父作用域)无权访问内部函数中定义的变量和函数。这为内部函数中的变量提供了一种封装。

js
// The following variables are defined in the global scope
const num1 = 20;
const num2 = 3;
const name = "Chamakh";

// This function is defined in the global scope
function multiply() {
  return num1 * num2;
}

console.log(multiply()); // 60

// A nested function example
function getScore() {
  const num1 = 2;
  const num2 = 3;

  function add() {
    return `${name} scored ${num1 + num2}`;
  }

  return add();
}

console.log(getScore()); // "Chamakh scored 5"

闭包

我们也将函数体称为闭包。闭包是任何引用某些变量的源代码片段(最常见的是函数),即使声明这些变量的作用域已退出,闭包也会“记住”这些变量。

闭包通常用嵌套函数来演示,以表明它们在父作用域的生命周期之外仍能记住变量;但实际上,嵌套函数不是必需的。从技术上讲,JavaScript 中的所有函数都形成闭包——有些只是没有捕获任何东西,闭包甚至不必是函数。有用闭包的关键要素如下

  • 一个父作用域,它定义了一些变量或函数。它应该有一个明确的生命周期,这意味着它应该在某个时刻完成执行。任何不是全局作用域的作用域都满足此要求;这包括块、函数、模块等。
  • 在父作用域内定义的内部作用域,它引用了在父作用域中定义的某些变量或函数。
  • 内部作用域设法在父作用域的生命周期之外存活。例如,它被保存到在父作用域外部定义的变量中,或者它从父作用域返回(如果父作用域是一个函数)。
  • 然后,当你在父作用域之外调用该函数时,即使父作用域已完成执行,你仍然可以访问在父作用域中定义的变量或函数。

以下是闭包的典型示例

js
// The outer function defines a variable called "name"
const pet = function (name) {
  const getName = function () {
    // The inner function has access to the "name" variable of the outer function
    return name;
  };
  return getName; // Return the inner function, thereby exposing it to outer scopes
};
const myPet = pet("Vivie");

console.log(myPet()); // "Vivie"

它可能比上面的代码复杂得多。一个包含用于操纵外部函数内部变量的方法的对象可以被返回。

js
const createPet = function (name) {
  let sex;

  const pet = {
    // setName(newName) is equivalent to setName: function (newName)
    // in this context
    setName(newName) {
      name = newName;
    },

    getName() {
      return name;
    },

    getSex() {
      return sex;
    },

    setSex(newSex) {
      if (
        typeof newSex === "string" &&
        (newSex.toLowerCase() === "male" || newSex.toLowerCase() === "female")
      ) {
        sex = newSex;
      }
    },
  };

  return pet;
};

const pet = createPet("Vivie");
console.log(pet.getName()); // Vivie

pet.setName("Oliver");
pet.setSex("male");
console.log(pet.getSex()); // male
console.log(pet.getName()); // Oliver

在上面的代码中,外部函数的 name 变量对内部函数是可访问的,并且除了通过内部函数之外没有其他方法可以访问内部变量。内部函数的内部变量充当外部参数和变量的安全存储。它们为内部函数提供“持久”和“封装”的数据来处理。函数甚至不必分配给变量,也不必具有名称。

js
const getCode = (function () {
  const apiCode = "0]Eal(eh&2"; // A code we do not want outsiders to be able to modify…

  return function () {
    return apiCode;
  };
})();

console.log(getCode()); // "0]Eal(eh&2"

在上面的代码中,我们使用了IIFE模式。在这个 IIFE 作用域内,存在两个值:一个变量 apiCode 和一个未命名函数,该函数被返回并赋值给变量 getCodeapiCode 在返回的未命名函数的范围内,但不在程序任何其他部分的范围内,因此除了通过 getCode 函数之外,无法读取 apiCode 的值。

多层嵌套函数

函数可以多层嵌套。例如

  • 一个函数(A)包含一个函数(B),而 B 本身又包含一个函数(C)。
  • 函数 BC 在这里都形成了闭包。因此,B 可以访问 A,而 C 可以访问 B
  • 此外,由于 C 可以访问 B,而 B 又可以访问 A,因此 C 也可以访问 A

因此,闭包可以包含多个作用域;它们递归地包含其包含函数的作用域。这被称为作用域链。考虑以下示例

js
function A(x) {
  function B(y) {
    function C(z) {
      console.log(x + y + z);
    }
    C(3);
  }
  B(2);
}
A(1); // Logs 6 (which is 1 + 2 + 3)

在这个例子中,C 访问了 ByAx。这之所以可以做到,是因为

  1. B 形成了一个包含 A 的闭包(即 B 可以访问 A 的参数和变量)。
  2. C 形成了一个包含 B 的闭包。
  3. 因为 C 的闭包包含了 B,而 B 的闭包包含了 A,所以 C 的闭包也包含了 A。这意味着 C 可以访问 B A 的参数和变量。换句话说,C 链接BA 的作用域,按该顺序

然而,反之则不成立。A 无法访问 C,因为 A 无法访问 B 的任何参数或变量,而 CB 的一个变量。因此,C 仍然只对 B 私有。

命名冲突

当闭包作用域中的两个参数或变量具有相同的名称时,会发生命名冲突。更深层嵌套的作用域具有优先权。因此,最内层的作用域具有最高优先权,而最外层的作用域具有最低优先权。这就是作用域链。链中的第一个是最内层的作用域,最后一个是最外层的作用域。考虑以下情况

js
function outside() {
  const x = 5;
  function inside(x) {
    return x * 2;
  }
  return inside;
}

console.log(outside()(10)); // 20 (instead of 10)

命名冲突发生在语句 return x * 2 处,发生在 inside 的参数 xoutside 的变量 x 之间。这里的作用域链是 inside => outside => 全局对象。因此,insidex 优先于 outsidex,返回的是 20 (insidex) 而不是 10 (outsidex)。

使用 arguments 对象

函数的参数以类数组对象的形式维护。在函数内部,你可以按如下方式访问传递给它的参数

js
arguments[i];

其中 i 是参数的序号,从 0 开始。因此,传递给函数的第一个参数将是 arguments[0]。参数的总数由 arguments.length 指示。

使用 arguments 对象,你可以用比正式声明接受的参数更多的参数来调用函数。如果你事先不知道将有多少参数传递给函数,这通常很有用。你可以使用 arguments.length 来确定实际传递给函数的参数数量,然后使用 arguments 对象访问每个参数。

例如,考虑一个连接多个字符串的函数。该函数唯一的正式参数是一个字符串,它指定了用于分隔要连接项的字符。该函数定义如下

js
function myConcat(separator) {
  let result = ""; // initialize list
  // iterate through arguments
  for (let i = 1; i < arguments.length; i++) {
    result += arguments[i] + separator;
  }
  return result;
}

你可以向此函数传递任意数量的参数,它会将每个参数连接成一个字符串“列表”

js
console.log(myConcat(", ", "red", "orange", "blue"));
// "red, orange, blue, "

console.log(myConcat("; ", "elephant", "giraffe", "lion", "cheetah"));
// "elephant; giraffe; lion; cheetah; "

console.log(myConcat(". ", "sage", "basil", "oregano", "pepper", "parsley"));
// "sage. basil. oregano. pepper. parsley. "

注意: arguments 变量是“类数组”的,但不是一个数组。它是类数组的,因为它具有带编号的索引和 length 属性。但是,它不具备所有数组操作方法。

有关更多信息,请参阅 JavaScript 参考中的Function 对象。

函数参数

有两种特殊类型的参数语法:默认参数剩余参数

默认参数

在 JavaScript 中,函数的参数默认值为 undefined。但是,在某些情况下,设置不同的默认值可能很有用。这正是默认参数所做的事情。

过去,设置默认值的通用策略是在函数体中测试参数值,如果它们是 undefined,则分配一个值。

在以下示例中,如果未为 b 提供值,则在计算 a*b 时其值将为 undefined,并且对 multiply 的调用通常会返回 NaN。但是,此示例中的第二行阻止了这种情况

js
function multiply(a, b) {
  b = typeof b !== "undefined" ? b : 1;
  return a * b;
}

console.log(multiply(5)); // 5

使用默认参数,函数体中的手动检查不再是必需的。你可以在函数头中将 1 设置为 b 的默认值

js
function multiply(a, b = 1) {
  return a * b;
}

console.log(multiply(5)); // 5

有关更多详细信息,请参阅参考中的默认参数

剩余参数

剩余参数语法允许我们将不定数量的参数表示为数组。

在以下示例中,函数 multiply 使用剩余参数来收集从第二个参数到末尾的参数。然后,该函数将这些参数乘以第一个参数。

js
function multiply(multiplier, ...theArgs) {
  return theArgs.map((x) => multiplier * x);
}

const arr = multiply(2, 1, 2, 3);
console.log(arr); // [2, 4, 6]

箭头函数

箭头函数表达式(也称为胖箭头,以区别于未来 JavaScript 中假想的 -> 语法)与函数表达式相比具有更短的语法,并且没有自己的 thisargumentssupernew.target。箭头函数始终是匿名的。

有两个因素影响了箭头函数的引入:更短的函数this非绑定

更短的函数

在某些函数式模式中,更短的函数很受欢迎。比较一下

js
const a = ["Hydrogen", "Helium", "Lithium", "Beryllium"];

const a2 = a.map(function (s) {
  return s.length;
});

console.log(a2); // [8, 6, 7, 9]

const a3 = a.map((s) => s.length);

console.log(a3); // [8, 6, 7, 9]

没有单独的 this

在箭头函数出现之前,每个新定义的函数都有自己的 this 值(对于构造函数来说是一个新对象,在严格模式函数调用中为 undefined,如果函数作为“对象方法”调用,则为基本对象,等等)。这在面向对象编程风格中被证明并不理想。

js
function Person() {
  // The Person() constructor defines `this` as itself.
  this.age = 0;

  setInterval(function growUp() {
    // In nonstrict mode, the growUp() function defines `this`
    // as the global object, which is different from the `this`
    // defined by the Person() constructor.
    this.age++;
  }, 1000);
}

const p = new Person();

在 ECMAScript 3/5 中,这个问题通过将 this 中的值分配给一个可以被闭包的变量来解决。

js
function Person() {
  // Some choose `that` instead of `self`.
  // Choose one and be consistent.
  const self = this;
  self.age = 0;

  setInterval(function growUp() {
    // The callback refers to the `self` variable of which
    // the value is the expected object.
    self.age++;
  }, 1000);
}

或者,可以创建一个绑定函数,以便将正确的 this 值传递给 growUp() 函数。

箭头函数没有自己的 this;它使用封闭执行上下文的 this 值。因此,在下面的代码中,传递给 setInterval 的函数内部的 this 与封闭函数中的 this 具有相同的值

js
function Person() {
  this.age = 0;

  setInterval(() => {
    this.age++; // `this` properly refers to the person object
  }, 1000);
}

const p = new Person();