面向急切程序员的 JavaScript(ES2022 版)
请支持本书:购买捐赠
(广告,请不要屏蔽。)

25 可调用值



在本章中,我们将介绍 JavaScript 中可以调用的值:函数、方法和类。

25.1 函数类型

JavaScript 有两类函数

继续阅读以了解所有这些内容的含义。

25.2 普通函数

以下代码展示了两种执行(大致)相同操作的方法:创建普通函数。

// Function declaration (a statement)
function ordinary1(a, b, c) {
  // ···
}

// const plus anonymous (nameless) function expression
const ordinary2 = function (a, b, c) {
  // ···
};

在作用域内,函数声明会提前激活(请参阅 §11.8 “声明:作用域和激活”),并且可以在声明之前调用。这有时很有用。

变量声明(例如 ordinary2 的声明)不会提前激活。

25.2.1 命名函数表达式(高级)

到目前为止,我们只看到了匿名函数表达式,它们没有名称

const anonFuncExpr = function (a, b, c) {
  // ···
};

但也有命名函数表达式

const namedFuncExpr = function myName(a, b, c) {
  // `myName` is only accessible in here
};

myName 只能在函数体内访问。函数可以使用它来引用自身(用于自递归等),而与其分配给哪个变量无关

const func = function funcExpr() { return funcExpr };
assert.equal(func(), func);

// The name `funcExpr` only exists inside the function body:
assert.throws(() => funcExpr(), ReferenceError);

即使未分配给变量,命名函数表达式也有名称(第 A 行)

function getNameOfCallback(callback) {
  return callback.name;
}

assert.equal(
  getNameOfCallback(function () {}), ''); // anonymous

assert.equal(
  getNameOfCallback(function named() {}), 'named'); // (A)

请注意,通过函数声明或变量声明创建的函数始终具有名称

function funcDecl() {}
assert.equal(
  getNameOfCallback(funcDecl), 'funcDecl');

const funcExpr = function () {};
assert.equal(
  getNameOfCallback(funcExpr), 'funcExpr');

函数具有名称的一个好处是,这些名称会显示在 错误堆栈跟踪 中。

25.2.2 术语:函数定义和函数表达式

函数定义是创建函数的语法

函数声明始终生成普通函数。函数表达式生成普通函数或专用函数

虽然函数声明在 JavaScript 中仍然很流行,但在现代代码中,函数表达式几乎始终是箭头函数。

25.2.3 函数声明的组成部分

让我们通过以下示例来检查函数声明的组成部分。大多数术语也适用于函数表达式。

function add(x, y) {
  return x + y;
}
25.2.3.1 参数列表中的尾随逗号

JavaScript 始终允许并在数组字面量中忽略尾随逗号。自 ES5 起,它们也允许在对象字面量中使用。自 ES2017 起,我们可以在参数列表(声明和调用)中添加尾随逗号

// Declaration
function retrieveData(
  contentText,
  keyword,
  {unique, ignoreCase, pageSize}, // trailing comma
) {
  // ···
}

// Invocation
retrieveData(
  '',
  null,
  {ignoreCase: true, pageSize: 10}, // trailing comma
);

25.2.4 普通函数的作用

请考虑上一节中的以下函数声明

function add(x, y) {
  return x + y;
}

此函数声明创建了一个名为 add 的普通函数。作为普通函数,add() 可以扮演三种角色

25.2.5 术语:实体 vs. 语法 vs. 作用(高级)

语法实体作用这三个概念之间的区别很微妙,而且通常无关紧要。但我想让你对此更加敏锐

许多其他编程语言只有一个实体扮演真实函数的作用。然后,它们可以使用名称函数来表示作用和实体。

25.3 专用函数

专用函数是普通函数的单一用途版本。它们中的每一个都专门用于一种角色

除了更简洁的语法外,每种专用函数还支持新特性,使其在工作中比普通函数更出色。

表 16 列出了普通函数和专用函数的功能。

表 16:四种函数的功能。如果单元格值在括号中,则表示存在某种限制。特殊变量 this§25.3.3 “方法、普通函数和箭头函数中的特殊变量 this 中解释。
函数调用 方法调用 构造函数调用
普通函数 (this === undefined)
箭头函数 (词法 this)
方法 (this === undefined)

25.3.1 专用函数仍然是函数

重要的是要注意,箭头函数、方法和类仍然被归类为函数

> (() => {}) instanceof Function
true
> ({ method() {} }.method) instanceof Function
true
> (class SomeClass {}) instanceof Function
true

25.3.2 箭头函数

将箭头函数添加到 JavaScript 中有两个原因

  1. 提供一种更简洁的函数创建方式。
  2. 它们在方法内部作为真实函数效果更好:方法可以通过特殊变量 this 引用接收方法调用的对象。箭头函数可以访问周围方法的 this,而普通函数则不能(因为它们有自己的 this)。

我们将首先检查箭头函数的语法,然后了解 this 在各种函数中的工作原理。

25.3.2.1 箭头函数的语法

让我们回顾一下匿名函数表达式的语法

const f = function (x, y, z) { return 123 };

(大致)等效的箭头函数如下所示。箭头函数是表达式。

const f = (x, y, z) => { return 123 };

在这里,箭头函数的主体是一个块。但它也可以是一个表达式。以下箭头函数的工作原理与前一个完全相同。

const f = (x, y, z) => 123;

如果箭头函数只有一个参数,并且该参数是一个标识符(而不是 解构模式),则可以省略参数周围的括号

const id = x => x;

将箭头函数作为参数传递给其他函数或方法时,这很方便

> [1,2,3].map(x => x+1)
[ 2, 3, 4 ]

前面的示例演示了箭头函数的一个优点:简洁性。如果我们使用函数表达式执行相同的任务,则代码会更加冗长

[1,2,3].map(function (x) { return x+1 });
25.3.2.2 语法陷阱:从箭头函数返回对象字面量

如果希望箭头函数的表达式主体是对象字面量,则必须将字面量放在括号中

const func1 = () => ({a: 1});
assert.deepEqual(func1(), { a: 1 });

如果不这样做,JavaScript 会认为箭头函数有一个块主体(不返回任何内容)

const func2 = () => {a: 1};
assert.deepEqual(func2(), undefined);

{a: 1} 被解释为带有 标签 a: 的块和表达式语句 1。如果没有显式的 return 语句,则块主体返回 undefined

此陷阱是由 语法歧义 引起的:对象字面量和代码块具有相同的语法。我们使用括号来告诉 JavaScript 主体是一个表达式(对象字面量),而不是一个语句(块)。

25.3.3 方法、普通函数和箭头函数中的特殊变量 this

  特殊变量 this 是面向对象的特性

我们在这里快速了解一下特殊变量 this,以便理解为什么箭头函数比普通函数更适合作为真实函数。

但此特性仅在面向对象编程中才有意义,并在 §28.5 “方法和特殊变量 this 中进行了更深入的介绍。因此,如果您现在还没有完全理解它,请不要担心。

在方法内部,特殊变量 this 允许我们访问接收者,即接收方法调用的对象

const obj = {
  myMethod() {
    assert.equal(this, obj);
  }
};
obj.myMethod();

普通函数可以是方法,因此也具有隐式参数 this

const obj = {
  myMethod: function () {
    assert.equal(this, obj);
  }
};
obj.myMethod();

即使我们将普通函数用作真实函数,this 也是一个隐式参数。然后它的值是 undefined(如果 严格模式处于活动状态,而它几乎总是处于活动状态)

function ordinaryFunc() {
  assert.equal(this, undefined);
}
ordinaryFunc();

这意味着,用作真实函数的普通函数无法访问周围方法的 this(A 行)。相反,箭头函数没有将 this 作为隐式参数。它们像对待任何其他变量一样对待它,因此可以访问周围方法的 this(B 行)

const jill = {
  name: 'Jill',
  someMethod() {
    function ordinaryFunc() {
      assert.throws(
        () => this.name, // (A)
        /^TypeError: Cannot read properties of undefined \(reading 'name'\)$/);
    }
    ordinaryFunc();

    const arrowFunc = () => {
      assert.equal(this.name, 'Jill'); // (B)
    };
    arrowFunc();
  },
};
jill.someMethod();

在这段代码中,我们可以观察到两种处理 this 的方式

25.3.4 建议:优先选择专用函数而不是普通函数

通常,您应该优先选择专用函数而不是普通函数,尤其是类和方法。

但是,当涉及到真实函数时,在箭头函数和普通函数之间的选择就不那么明确了

25.4 总结:可调用值的种类

  本节涉及即将推出的内容

本节主要作为当前和即将推出的章节的参考。如果您不了解所有内容,请不要担心。

到目前为止,我们看到的所有(真实)函数和方法都是

后面的章节将介绍其他编程模式

这些模式可以组合使用——例如,有同步可迭代对象和异步可迭代对象。

几种新型函数和方法有助于实现某些模式组合

这给我们留下了 4 种(2 × 2)函数和方法

表 17 概述了创建这 4 种函数和方法的语法。

表 17:创建函数和方法的语法。最后一列指定实体生成的值的数量。
结果 #
同步函数 同步方法
function f() {} { m() {} } 1
f = function () {}
f = () => {}
同步生成器函数 同步生成器方法
function* f() {} { * m() {} } 可迭代对象 0+
f = function* () {}
异步函数 异步方法
async function f() {} { async m() {} } Promise 1
f = async function () {}
f = async () => {}
异步生成器函数 异步生成器方法
async function* f() {} { async * m() {} } 异步可迭代对象 0+
f = async function* () {}

25.5 从函数和方法返回值

(本节中提到的所有内容均适用于函数和方法。)

return 语句从函数显式返回值

function func() {
  return 123;
}
assert.equal(func(), 123);

另一个例子

function boolToYesNo(bool) {
  if (bool) {
    return 'Yes';
  } else {
    return 'No';
  }
}
assert.equal(boolToYesNo(true), 'Yes');
assert.equal(boolToYesNo(false), 'No');

如果在函数结束时没有显式返回任何内容,JavaScript 会为您返回 undefined

function noReturn() {
  // No explicit return
}
assert.equal(noReturn(), undefined);

25.6 参数处理

再次强调,我在本节中仅提及函数,但所有内容也适用于方法。

25.6.1 术语:参数与参数

术语_参数_和术语_参数_基本上指的是同一件事。如果您愿意,可以进行以下区分

25.6.2 术语:回调

_回调_或_回调函数_是作为函数或方法调用的参数的函数。

以下是回调的示例

const myArray = ['a', 'b'];
const callback = (x) => console.log(x);
myArray.forEach(callback);

// Output:
// 'a'
// 'b'

25.6.3 参数过多或过少

如果函数调用提供的参数数量与函数定义期望的不同,JavaScript 不会报错

例如

function foo(x, y) {
  return [x, y];
}

// Too many arguments:
assert.deepEqual(foo('a', 'b', 'c'), ['a', 'b']);

// The expected number of arguments:
assert.deepEqual(foo('a', 'b'), ['a', 'b']);

// Not enough arguments:
assert.deepEqual(foo('a'), ['a', undefined]);

25.6.4 参数默认值

参数默认值指定在未提供参数时使用的值——例如

function f(x, y=0) {
  return [x, y];
}

assert.deepEqual(f(1), [1, 0]);
assert.deepEqual(f(), [undefined, 0]);

undefined 也会触发默认值

assert.deepEqual(
  f(undefined, undefined),
  [undefined, 0]);

25.6.5 剩余参数

剩余参数通过在标识符前面加上三个点 (...) 来声明。在函数或方法调用期间,它接收一个包含所有剩余参数的数组。如果最后没有额外的参数,则它是一个空数组——例如

function f(x, ...y) {
  return [x, y];
}
assert.deepEqual(
  f('a', 'b', 'c'), ['a', ['b', 'c']]
);
assert.deepEqual(
  f(), [undefined, []]
);

关于如何使用剩余参数有两个限制

25.6.5.1 通过剩余参数强制执行一定数量的参数

您可以使用剩余参数来强制执行一定数量的参数。以以下函数为例

function createPoint(x, y) {
  return {x, y};
    // same as {x: x, y: y}
}

这就是我们强制调用者始终提供两个参数的方式

function createPoint(...args) {
  if (args.length !== 2) {
    throw new Error('Please provide exactly 2 arguments!');
  }
  const [x, y] = args; // (A)
  return {x, y};
}

在 A 行中,我们通过_解构_访问 args 的元素。

25.6.6 命名参数

当有人调用函数时,调用者提供的参数将分配给被调用者接收的参数。执行映射的两种常见方法是

  1. 位置参数:如果参数具有相同的位置,则将参数分配给参数。只有位置参数的函数调用如下所示。

    selectEntries(3, 20, 2)
  2. 命名参数:如果参数具有相同的名称,则将参数分配给参数。JavaScript 没有命名参数,但您可以模拟它们。例如,这是一个只有(模拟的)命名参数的函数调用

    selectEntries({start: 3, end: 20, step: 2})

命名参数有几个好处

25.6.7 模拟命名参数

JavaScript 没有真正的命名参数。模拟它们的官方方法是通过对象字面量

function selectEntries({start=0, end=-1, step=1}) {
  return {start, end, step};
}

此函数使用_解构_来访问其单个参数的属性。它使用的模式是以下模式的缩写

{start: start=0, end: end=-1, step: step=1}

此解构模式适用于空对象字面量

> selectEntries({})
{ start: 0, end: -1, step: 1 }

但是,如果您在没有任何参数的情况下调用该函数,则它不起作用

> selectEntries()
TypeError: Cannot read properties of undefined (reading 'start')

您可以通过为整个模式提供默认值来解决此问题。此默认值的工作方式与更简单的参数定义的默认值相同:如果缺少参数,则使用默认值。

function selectEntries({start=0, end=-1, step=1} = {}) {
  return {start, end, step};
}
assert.deepEqual(
  selectEntries(),
  { start: 0, end: -1, step: 1 });

25.6.8 扩展 (...) 到函数调用中

如果在函数调用的参数前面加上三个点 (...),则表示您_扩展_它。这意味着参数必须是_可迭代_对象,并且迭代的值都将成为参数。换句话说,单个参数被扩展为多个参数——例如

function func(x, y) {
  console.log(x);
  console.log(y);
}
const someIterable = ['a', 'b'];
func(...someIterable);
  // same as func('a', 'b')

// Output:
// 'a'
// 'b'

扩展和剩余参数使用相同的语法 (...),但它们的目的相反

25.6.8.1 示例:扩展到 Math.max()

Math.max() 返回其零个或多个参数中最大的一个。唉,它不能用于数组,但扩展为我们提供了一种解决方法

> Math.max(-1, 5, 11, 3)
11
> Math.max(...[-1, 5, 11, 3])
11
> Math.max(-1, ...[-5, 11], 3)
11
25.6.8.2 示例:扩展到 Array.prototype.push()

类似地,数组方法 .push() 将其零个或多个参数破坏性地添加到其数组的末尾。JavaScript 没有将数组破坏性地附加到另一个数组的方法。再一次,我们通过扩展得救了

const arr1 = ['a', 'b'];
const arr2 = ['c', 'd'];

arr1.push(...arr2);
assert.deepEqual(arr1, ['a', 'b', 'c', 'd']);

  练习:参数处理

25.7 函数的方法:.call().apply().bind()

函数是对象并且具有方法。在本节中,我们将研究其中三种方法:.call().apply().bind()

25.7.1 函数方法 .call()

每个函数 someFunc 都有以下方法

someFunc.call(thisValue, arg1, arg2, arg3);

此方法调用大致等效于以下函数调用

someFunc(arg1, arg2, arg3);

但是,使用 .call(),我们还可以为隐式参数 this指定一个值。换句话说:.call() 使隐式参数 this 显式化。

以下代码演示了 .call() 的使用

function func(x, y) {
  return [this, x, y];
}

assert.deepEqual(
  func.call('hello', 'a', 'b'),
  ['hello', 'a', 'b']);

正如我们之前所见,如果我们函数调用一个普通函数,它的 thisundefined

assert.deepEqual(
  func('a', 'b'),
  [undefined, 'a', 'b']);

因此,前面的函数调用等效于

assert.deepEqual(
  func.call(undefined, 'a', 'b'),
  [undefined, 'a', 'b']);

在箭头函数中,通过 .call()(或其他方式)为 this 提供的值将被忽略。

25.7.2 函数方法 .apply()

每个函数 someFunc 都有以下方法

someFunc.apply(thisValue, [arg1, arg2, arg3]);

此方法调用大致等效于以下函数调用(使用扩展

someFunc(...[arg1, arg2, arg3]);

但是,使用 .apply(),我们还可以为隐式参数 this指定一个值。

以下代码演示了 .apply() 的使用

function func(x, y) {
  return [this, x, y];
}

const args = ['a', 'b'];
assert.deepEqual(
  func.apply('hello', args),
  ['hello', 'a', 'b']);

25.7.3 函数方法 .bind()

.bind() 是函数对象的另一种方法。此方法的调用方式如下

const boundFunc = someFunc.bind(thisValue, arg1, arg2);

.bind() 返回一个新函数 boundFunc()。调用该函数将使用设置为 thisValuethis 以及以下参数调用 someFunc()arg1arg2,后跟 boundFunc() 的参数。

也就是说,以下两个函数调用是等效的

boundFunc('a', 'b')
someFunc.call(thisValue, arg1, arg2, 'a', 'b')
25.7.3.1 .bind() 的替代方法

另一种预填充 this 和参数的方法是通过箭头函数

const boundFunc2 = (...args) =>
  someFunc.call(thisValue, arg1, arg2, ...args);
25.7.3.2 .bind() 的实现

考虑到上一节,.bind() 可以实现为如下真实函数

function bind(func, thisValue, ...boundArgs) {
  return (...args) =>
    func.call(thisValue, ...boundArgs, ...args);
}
25.7.3.3 示例:绑定真实函数

对真实函数使用 .bind() 有点违反直觉,因为我们必须为 this 提供一个值。鉴于在函数调用期间它是 undefined,因此通常将其设置为 undefinednull

在以下示例中,我们通过将 add() 的第一个参数绑定到 8 来创建一个带有一个参数的函数 add8()

function add(x, y) {
  return x + y;
}

const add8 = add.bind(undefined, 8);
assert.equal(add8(1), 9);

  测验

参见测验应用程序