探索-JavaScript-ES2025-版--五-

探索 JavaScript(ES2025 版)(五)

原文:exploringjs.com/js/book/index.html

译者:飞龙

协议:CC BY-NC-SA 4.0

24 符号 ES6

原文:exploringjs.com/js/book/ch_symbols.html

  1. 24.1 符号是具有唯一标识的原始值

    1. 24.1.1 符号是原始值

    2. 24.1.2 符号具有唯一标识且不按值比较

  2. 24.2 符号的描述

  3. 24.3 符号的用途

    1. 24.3.1 符号作为常数的值

    2. 24.3.2 符号作为唯一属性键

  4. 24.4 公开已知的符号

  5. 24.5 转换符号

24.1 符号是具有唯一标识的原始值

符号是通过Symbol()工厂函数创建的原始值:

const mySymbol = Symbol('mySymbol');

参数是可选的,提供描述,这对于调试非常有用。

24.1.1 符号是原始值

符号是原始值:

  • 它们必须通过typeof进行分类:

    const sym = Symbol();
    assert.equal(typeof sym, 'symbol');
    
    
  • 它们可以作为对象的属性键:

    const obj = {
      [sym]: 123,
    };
    
    

24.1.2 符号具有唯一标识且不按值比较

尽管符号是原始值,但它们也像对象一样,由Symbol()创建的值具有唯一标识,并且不是通过值来比较的:

> Symbol() === Symbol()
false

在符号出现之前,如果需要一个具有唯一标识(仅等于自身)的值,对象是最好的选择:

const string1 = 'abc';
const string2 = 'abc';
assert.equal(
  string1 === string2, true // not unique
);

const object1 = {};
const object2 = {};
assert.equal(
  object1 === object2, false // unique
);

const symbol1 = Symbol();
const symbol2 = Symbol();
assert.equal(
  symbol1 === symbol2, false // unique
);

24.2 符号的描述

我们传递给符号工厂函数的参数为创建的符号提供描述:

const mySymbol = Symbol('mySymbol');

描述可以通过两种方式访问。

首先,它是.toString()返回的字符串的一部分:

assert.equal(mySymbol.toString(), 'Symbol(mySymbol)');

其次,自 ES2019 以来,我们可以通过属性.description检索描述:

assert.equal(mySymbol.description, 'mySymbol');

24.3 符号的用途

符号的主要用途包括:

  • 常数的值

  • 唯一属性键

24.3.1 符号作为常数的值

假设你想创建代表红色、橙色、黄色、绿色、蓝色和紫色的常量。一个简单的方法是使用字符串:

const COLOR_BLUE = 'Blue';

优点是,记录该常量会产生有用的输出。缺点是,有误将无关的值误认为是颜色的风险,因为内容相同的两个字符串被认为是相等的:

const MOOD_BLUE = 'Blue';
assert.equal(COLOR_BLUE, MOOD_BLUE);

我们可以通过符号解决这个问题:

const COLOR_BLUE = Symbol('Blue');
const MOOD_BLUE = Symbol('Blue');

assert.notEqual(COLOR_BLUE, MOOD_BLUE);

让我们使用符号值常数来实现一个函数:

const COLOR_RED    = Symbol('Red');
const COLOR_ORANGE = Symbol('Orange');
const COLOR_YELLOW = Symbol('Yellow');
const COLOR_GREEN  = Symbol('Green');
const COLOR_BLUE   = Symbol('Blue');
const COLOR_VIOLET = Symbol('Violet');

function getComplement(color) {
  switch (color) {
    case COLOR_RED:
      return COLOR_GREEN;
    case COLOR_ORANGE:
      return COLOR_BLUE;
    case COLOR_YELLOW:
      return COLOR_VIOLET;
    case COLOR_GREEN:
      return COLOR_RED;
    case COLOR_BLUE:
      return COLOR_ORANGE;
    case COLOR_VIOLET:
      return COLOR_YELLOW;
    default:
      throw new Exception('Unknown color: '+color);
  }
}
assert.equal(getComplement(COLOR_YELLOW), COLOR_VIOLET);

24.3.2 符号作为唯一属性键

对象中属性(字段)的键在两个级别上使用:

  • 程序在基本级别上运行。该级别的键反映了问题域——程序解决问题的领域——例如:

    • 如果一个程序管理员工,属性键可能涉及职位名称、薪资类别、部门 ID 等。

    • 如果程序是一个棋盘游戏应用,属性键可能涉及棋子、棋盘、玩家颜色等。

  • ECMAScript 和许多库在元级别上运行。它们管理数据并提供不属于问题域的服务。例如:

    • 当 ECMAScript 创建对象的字符串表示时,会使用标准方法 .toString()(行 A):

      const point = {
        x: 7,
        y: 4,
        toString() {
       return `(${this.x}, ${this.y})`;
       },
      };
      assert.equal(
       String(point), '(7, 4)'); // (A)
      
      

      .x.y 是基础级别的属性——它们用于解决使用点进行计算的问题。.toString() 是元级别的属性——它与问题域无关。

    • 标准 ECMAScript 方法 .toJSON() 可以用来自定义对象转换为其他形式的方式。

      const point = {
        x: 7,
        y: 4,
        toJSON() {
       return [this.x, this.y];
       },
      };
      assert.equal(
       JSON.stringify(point), '[7,4]');
      
      

      .x.y 是基础级别的属性,.toJSON() 是元级别的属性。

程序的基础级别和元级别必须是独立的:基础级别的属性键不应与元级别的属性键冲突。

如果我们使用名称(字符串)作为属性键,我们将面临两个挑战:

  • 当一种语言最初被创建时,它可以使用它想要的任何元级别名称。基础级别代码被迫避免这些名称。然而,当大量基础级别代码已经存在时,元级别名称不能再自由选择。

  • 我们可以引入命名规则来区分基础级别和元级别。例如,Python 使用两个下划线括起来元级别名称:__init____iter____hash__ 等。然而,语言和库的元级别名称仍然存在于同一个命名空间中,可能会发生冲突。

这两个例子说明了后者对 JavaScript 产生的问题:

  • 2018 年 5 月,Array 方法 .flatten() 必须更名为 .flat(),因为之前的名称已被库使用(来源)。

  • 2020 年 11 月,Array 方法 .item() 必须更名为 .at(),因为之前的名称已被库使用(来源)。

作为属性键使用的符号在这里很有帮助:每个符号都是唯一的,符号键永远不会与任何其他字符串或符号键冲突。

24.3.2.1 示例:具有元级别方法的库

例如,假设我们正在编写一个库,该库根据对象是否实现特殊方法以不同的方式处理对象。定义此类方法的属性键并为其对象实现它的样子如下:

const specialMethod = Symbol('specialMethod');
const obj = {
  _id: 'kf12oi',
  [specialMethod]() { // (A)
    return this._id;
  }
};
assert.equal(obj[specialMethod](), 'kf12oi');

行 A 中的方括号使我们能够指定该方法必须具有键 specialMethod。更多细节请参阅“对象字面量中的计算键”(§30.9.2)。

24.4 公共符号

在 ECMAScript 中扮演特殊角色的符号被称为公开已知符号。例如包括:

  • Symbol.iterator: 使一个对象可迭代。它是返回迭代器的方法的键。有关此主题的更多信息,请参阅“同步迭代(ES6)”(§32)。

  • Symbol.hasInstance: 自定义 instanceof 的工作方式。如果一个对象实现了具有该键的方法,则可以在该运算符的右侧使用它。例如:

    const PrimitiveNull = {
      Symbol.hasInstance {
        return x === null;
      }
    };
    assert.equal(null instanceof PrimitiveNull, true);
    
    
  • Symbol.toStringTag: 影响默认的 .toString() 方法。

    > String({})
    '[object Object]'
    > String({ [Symbol.toStringTag]: 'is no money' })
    '[object is no money]'
    
    

    注意:通常最好重写 .toString()

图标“练习”练习:公开已知符号

  • Symbol.toStringTag: exercises/symbols/to_string_tag_test.mjs

  • Symbol.hasInstance: exercises/symbols/has_instance_test.mjs

24.5 符号转换

如果我们将符号 sym 转换为其他原始类型会发生什么?表 24.1 中有答案。

转换为 显式转换 强制转换(隐式转换)
布尔值 Boolean(sym) → 正确 !sym → 正确
数字 Number(sym)TypeError sym*2TypeError
字符串 String(sym) → 正确 ''+symTypeError
sym.toString() → 正确 `${sym}`TypeError

表 24.1:将符号转换为其他原始类型的结果。

符号的一个关键陷阱是转换它们时抛出异常的频率。背后的想法是什么?首先,转换为数字永远没有意义,应该警告。其次,将符号转换为字符串确实对诊断输出很有用。但同时也应该警告意外地将符号转换为字符串(这是一种不同类型的属性键):

const obj = {};
const sym = Symbol();
assert.throws(
  () => { obj['__'+sym+'__'] = true },
  { message: 'Cannot convert a Symbol value to a string' });

缺点是异常使得使用符号变得更加复杂。您必须显式转换符号,当通过加号运算符组装字符串时:

> const mySymbol = Symbol('mySymbol');
> 'Symbol I used: ' + mySymbol
TypeError: Cannot convert a Symbol value to a string
> 'Symbol I used: ' + String(mySymbol)
'Symbol I used: Symbol(mySymbol)'

V 控制流和数据流

原文:exploringjs.com/js/book/pt_control-flow-data-flow.html

25 控制流语句

原文:exploringjs.com/js/book/ch_control-flow.html

  1. 25.1 控制循环:breakcontinue

    1. 25.1.1 break

    2. 25.1.2 带有标签的 break:离开任何带标签的语句

    3. 25.1.3 continue

  2. 25.2 控制流语句的条件

  3. 25.3 if 语句 (ES1)

    1. 25.3.1 if 语句的语法
  4. 25.4 switch 语句 (ES3)

    1. 25.4.1 switch 语句的第一个示例

    2. 25.4.2 不要忘记 returnbreak

    3. 25.4.3 空的情况子句

    4. 25.4.4 通过 default 子句检查非法值

    5. 25.4.5 switch 的陷阱:所有情况都存在于相同的变量作用域中

  5. 25.5 while 循环 (ES1)

    1. 25.5.1 while 循环的示例
  6. 25.6 do-while 循环 (ES3)

  7. 25.7 for 循环 (ES1)

    1. 25.7.1 for 循环的示例
  8. 25.8 for-of 循环 (ES6)

    1. 25.8.1 constfor-offor 的比较

    2. 25.8.2 遍历可迭代对象

    3. 25.8.3 遍历数组的 [索引,元素] 对

  9. 25.9 for-await-of 循环 (ES2018)

  10. 25.10 for-in 循环(避免)(ES1)

  11. 25.11 循环的建议

本章涵盖了以下控制流语句:

  • if 语句 [ES1]

  • switch 语句 [ES3]

  • while 循环 [ES1]

  • do-while 循环 [ES3]

  • for 循环 [ES1]

  • for-of 循环 [ES6]

  • for-await-of 循环 [ES2018]

  • for-in 循环 [ES1]

25.1 控制循环:breakcontinue

两个运算符 breakcontinue 可以在我们处于循环或其他语句内部时控制它们。

25.1.1 break

break 有两种版本:

  • 没有操作数的一个。

  • 带有 标签 作为操作数的一个。

前者版本可以在以下语句中使用:whiledo-whileforfor-offor-await-offor-inswitch。它立即离开当前语句:

for (const x of ['a', 'b', 'c']) {
  console.log(x);
  if (x === 'b') break;
  console.log('---')
}

输出:

a
---
b

25.1.2 带有标签的 break:离开任何带标签的语句

带有操作数的 break 在任何地方都有效。其操作数是一个 标签。标签可以放在任何语句之前,包括块。break myLabel 将离开标签为 myLabel 的语句:

myLabel: { // label
  if (condition) break myLabel; // labeled break
  // ···
}

25.1.2.1 示例:带有标签的 break

在以下示例中,搜索可以:

  • 失败:循环结束而没有找到 result。这将在循环之后直接处理(行 B)。

  • 成功:在循环过程中,我们找到了 result。然后我们使用带有标签的 break(行 A)来跳过处理失败的代码。

function findSuffix(stringArray, suffix) {
  let result;
  searchBlock: {
    for (const str of stringArray) {
      if (str.endsWith(suffix)) {
        // Success:
        result = str;
        break searchBlock; // (A)
      }
    } // for
    // Failure:
    result = '(Untitled)'; // (B)
  } // searchBlock

  return { suffix, result };
    // Same as: {suffix: suffix, result: result}
}
assert.deepEqual(
  findSuffix(['notes.txt', 'index.html'], '.html'),
  { suffix: '.html', result: 'index.html' }
);
assert.deepEqual(
  findSuffix(['notes.txt', 'index.html'], '.mjs'),
  { suffix: '.mjs', result: '(Untitled)' }
);

25.1.3 continue

continue仅在whiledo-whileforfor-offor-await-offor-in内部有效。它立即离开当前的循环迭代,并继续下一个——例如:

const lines = [
  'Normal line',
  '# Comment',
  'Another normal line',
];
for (const line of lines) {
  if (line.startsWith('#')) continue;
  console.log(line);
}

输出:

Normal line
Another normal line

25.2 控制流语句的条件

ifwhiledo-while的条件在原则上都是布尔值。然而,条件只需是真值(如果转换为布尔值则为true)即可被接受。换句话说,以下两个控制流语句是等价的:

if (value) {}
if (Boolean(value) === true) {}

这是一个所有假值的列表:

  • undefined, null

  • false

  • 0, NaN

  • 0n

  • ''

所有其他值都是真值。有关更多信息,请参阅“假值和真值” (§17.2)。

25.3 if语句^(ES1)

这些是两个简单的if语句:一个只有一个“then”分支,另一个既有“then”分支又有“else”分支:

if (cond) {
  // then branch
}

if (cond) {
  // then branch
} else {
  // else branch
}

除了块之外,else也可以跟另一个if语句:

if (cond1) {
  // ···
} else if (cond2) {
  // ···
}

if (cond1) {
  // ···
} else if (cond2) {
  // ···
} else {
  // ···
}

我们可以用更多的else if继续这个链。

25.3.1 if语句的语法

if语句的一般语法是:

if («cond») «then_statement»
else «else_statement»

到目前为止,then_statement始终是一个块,但我们可以使用任何语句。该语句必须以分号结束:

if (true) console.log('Yes'); else console.log('No');

这意味着else if不是它自己的构造;它只是一个if语句,其else_statement是另一个if语句。

25.4 switch语句^(ES3)

一个switch语句看起来如下:

switch («switch_expression») {
  «switch_body»
}

switch的主体由零个或多个case子句组成:

case «case_expression»:
  «statements»

并且,可选地,一个默认子句:

default:
  «statements»

switch的执行方式如下:

  • 它评估switch表达式。

  • 它跳转到第一个表达式结果与switch表达式相同的case子句。

  • 否则,如果没有这样的子句,则跳转到默认子句。

  • 否则,如果没有默认子句,则不执行任何操作。

25.4.1 switch语句的第一个例子

让我们看看一个例子:以下函数将数字 1-7 转换为星期几的名称。

function dayOfTheWeek(num) {
  switch (num) {
    case 1:
      return 'Monday';
    case 2:
      return 'Tuesday';
    case 3:
      return 'Wednesday';
    case 4:
      return 'Thursday';
    case 5:
      return 'Friday';
    case 6:
      return 'Saturday';
    case 7:
      return 'Sunday';
  }
}
assert.equal(dayOfTheWeek(5), 'Friday');

25.4.2 不要忘记returnbreak!

case子句的末尾,执行将继续到下一个case子句,除非我们returnbreak——例如:

function englishToFrench(english) {
  let french;
  switch (english) {
    case 'hello':
      french = 'bonjour';
    case 'goodbye':
      french = 'au revoir';
  }
  return french;
}
// The result should be 'bonjour'!
assert.equal(englishToFrench('hello'), 'au revoir');

也就是说,我们的dayOfTheWeek()实现之所以有效,仅仅是因为我们使用了return。我们可以通过使用break来修复englishToFrench()

function englishToFrench(english) {
  let french;
  switch (english) {
    case 'hello':
      french = 'bonjour';
      break;
    case 'goodbye':
      french = 'au revoir';
      break;
  }
  return french;
}
assert.equal(englishToFrench('hello'), 'bonjour'); // ok

25.4.3 空case子句

case子句的语句可以省略,这实际上为我们提供了每个case子句的多个case表达式:

function isWeekDay(name) {
  switch (name) {
    case 'Monday':
    case 'Tuesday':
    case 'Wednesday':
    case 'Thursday':
    case 'Friday':
      return true;
    case 'Saturday':
    case 'Sunday':
      return false;
  }
}
assert.equal(isWeekDay('Wednesday'), true);
assert.equal(isWeekDay('Sunday'), false);

25.4.4 通过default子句检查非法值

如果switch表达式没有其他匹配项,则跳转到默认子句。这使得它对于错误检查很有用:

function isWeekDay(name) {
  switch (name) {
    case 'Monday':
    case 'Tuesday':
    case 'Wednesday':
    case 'Thursday':
    case 'Friday':
      return true;
    case 'Saturday':
    case 'Sunday':
      return false;
    default:
      throw new Error('Illegal value: '+name);
  }
}
assert.throws(
  () => isWeekDay('January'),
  {message: 'Illegal value: January'});

图标“练习”练习:switch

  • exercises/control-flow/number_to_month_test.mjs

  • 奖励:exercises/control-flow/is_object_via_switch_test.mjs

25.4.5 switch 的陷阱:所有情况都存在于相同的变量作用域

假设我们想要实现一个 main() 函数,它的工作方式如下:

assert.equal(
  main(['repeat', '3', 'ho']),
  'hohoho'
);
assert.equal(
  main(['once', 'hello']),
  'hello'
);

我们可以将 main() 实现如下(为了减少冗余,省略了错误消息):

function main(args) {
  const command = args[0];
  if (command === undefined) {
    throw new Error();
  }
  switch (command) {
    case 'once':
      const text = args[1];
      if (text === undefined) {
        throw new Error();
      }
      return text;
    case 'repeat':
      const timesStr = args[1];
      const text = args[2]; // (A)
      if (timesStr === undefined || text === undefined) {
        throw new Error();
      }
      const times = Number(timesStr);
      return text.repeat(times);
    default:
      throw new Error();
  }
}

然而,在行 A 中,我们得到以下语法错误:

SyntaxError: Identifier 'text' has already been declared

为什么是这样?switch 的完整主体是一个单独的变量作用域,并且在这个作用域内部,有两个关于变量 text 的声明。

但这个问题很容易解决——我们可以通过将代码包裹在大括号中来为每个 switch 情况创建一个变量作用域:

function main(args) {
  const command = args[0];
  if (command === undefined) {
    throw new Error();
  }
  switch (command) {
    case 'once': {
      const text = args[1];
      if (text === undefined) {
        throw new Error();
      }
      return text;
    }
    case 'repeat': {
      const timesStr = args[1];
      const text = args[2]; // (A)
      if (timesStr === undefined || text === undefined) {
        throw new Error();
      }
      const times = Number(timesStr);
      return text.repeat(times);
    }
    default:
      throw new Error();
  }
}

25.5 while 循环^(ES1)

while 循环有以下语法:

while («condition») {
  «statements»
}

在每次循环迭代之前,while 评估 condition

  • 如果结果是假值,则循环结束。

  • 如果结果是真值,则 while 主体将再执行一次。

25.5.1 while 循环的示例

以下代码使用 while 循环。在每次循环迭代中,它通过 .shift() 移除 arr 的第一个元素并记录它。

const arr = ['a', 'b', 'c'];
while (arr.length > 0) {
  const elem = arr.shift(); // remove first element
  console.log(elem);
}

输出:

a
b
c

如果条件始终评估为 true,则 while 是一个无限循环:

while (true) {
  if (Math.random() === 0) break;
}

25.6 do-while 循环^(ES3)

do-while 循环的工作方式与 while 类似,但它是在每次循环迭代之后而不是之前检查其条件。

let input;
do {
  input = prompt('Enter text:');
  console.log(input);
} while (input !== ':q');

do-while 也可以被视为至少运行一次的 while 循环。

prompt() 是一个在网页浏览器中可用的全局函数。它提示用户输入文本并返回它。

25.7 for 循环^(ES1)

for 循环有以下语法:

for («initialization»; «condition»; «post_iteration») {
  «statements»
}

第一行是循环的 头部,它控制循环体(循环的其余部分)执行的频率。它有三个部分,每个部分都是可选的:

  • initialization:为循环设置变量等。在这里通过 letconst 声明的变量仅存在于循环内部。

  • condition:在每次循环迭代之前检查此条件。如果它是假值,则循环停止。

  • post_iteration: 此代码在每个循环迭代后执行。

因此,for 循环大致等价于以下 while 循环:

«initialization»
while («condition») {
  «statements»
  «post_iteration»
}

25.7.1 for 循环的示例

例如,以下是如何通过 for 循环从零计数到二的:

for (let i=0; i<3; i++) {
  console.log(i);
}

输出:

0
1
2

这就是如何通过 for 循环记录数组内容的方法:

const arr = ['a', 'b', 'c'];
for (let i=0; i<arr.length; i++) {
  console.log(arr[i]);
}

输出:

a
b
c

如果我们省略头部所有三个部分,就会得到一个无限循环:

for (;;) {
  if (Math.random() === 0) break;
}

25.8 for-of 循环^(ES6)

for-of 循环遍历任何 可迭代 对象——一个支持 迭代协议 的数据容器。每个迭代的值都存储在一个变量中,如头部指定:

for («iteration_variable» of «iterable») {
  «statements»
}

迭代变量通常通过变量声明创建:

const iterable = ['hello', 'world'];
for (const elem of iterable) {
  console.log(elem);
}

输出:

hello
world

但我们也可以使用一个已经存在的(可变的)变量:

const iterable = ['hello', 'world'];
let elem;
for (elem of iterable) {
  console.log(elem);
}

25.8.1 constfor-offor 的比较

注意,在for-of循环中我们可以使用const。迭代变量仍然可以在每次迭代中不同(但它不能在迭代过程中改变)。想象一下,每次在新的作用域中执行一个新的const声明。

相比之下,在for循环中,如果变量的值会改变,我们必须通过letvar来声明变量。

25.8.2 遍历可迭代对象

如前所述,for-of与任何可迭代对象一起工作,而不仅仅是数组 - 例如,与集合一起:

const set = new Set(['hello', 'world']);
for (const elem of set) {
  console.log(elem);
}

25.8.3 遍历数组的 [索引,元素] 对

最后,我们还可以使用for-of来遍历数组的 [索引,元素] 条目:

const arr = ['a', 'b', 'c'];
for (const [index, element] of arr.entries()) {
  console.log(`${index} -> ${element}`);
}

输出:

0 -> a
1 -> b
2 -> c

使用[索引,元素],我们正在使用 解构赋值 来访问数组元素。

图标“练习”练习:for-of

exercises/control-flow/array_to_string_test.mjs

25.9 for-await-of循环(ES2018)

for-await-of类似于for-of,但它与异步可迭代对象一起工作,而不是同步的。它只能在异步函数和异步生成器内部使用。

for await (const item of asyncIterable) {
  // ···
}

for-await-of在关于异步迭代的章节中有详细描述 在异步迭代章节中描述了for-await-of

25.10 for-in循环(避免)

for-in循环访问一个对象的所有(包括自身的和继承的)可枚举属性键。当遍历数组时,它很少是一个好的选择:

  • 它访问属性键,而不是值。

  • 作为属性键,数组元素的索引是字符串,而不是数字 (关于数组元素如何工作的更多信息)。

  • 它访问所有可枚举属性键(包括自身的和继承的),而不仅仅是数组元素的属性键。

以下代码演示了这些要点:

const arr = ['a', 'b', 'c'];
arr.propKey = 'property value';

for (const key in arr) {
  console.log(key);
}

输出:

0
1
2
propKey

25.11 循环建议

  • 如果你想要遍历一个 异步可迭代对象(在 ES2018+中),你必须使用for-await-of

  • 对于遍历任何同步可迭代对象(包括数组),你必须使用for-of。在 ES6+中可用。

  • 在 ES5+中,你可以使用 数组的.forEach()方法来遍历数组。

  • 在 ES5 之前,你可以使用普通的for循环来遍历数组。

  • 不要使用for-in来遍历数组。

26 异常处理

原文:exploringjs.com/js/book/ch_exception-handling.html

  1. 26.1 动机:抛出和捕获异常

  2. 26.2 throw

    1. 26.2.1 我们应该抛出什么值?
  3. 26.3 try语句

    1. 26.3.1 try

    2. 26.3.2 catch子句

    3. 26.3.3 finally子句

  4. 26.4 所有内置异常类的超类:Error

    1. 26.4.1 Error.prototype.name

    2. 26.4.2 错误实例属性 .message

    3. 26.4.3 错误实例属性 .stack

  5. 26.5 链式错误:实例属性 .cause (ES2022)

    1. 26.5.1 我们为什么要链式错误?

    2. 26.5.2 我们应该在.cause中存储上下文数据吗?

  6. 26.6 Error的子类

    1. 26.6.1 Error的内置子类

    2. 26.6.2 子类化Error

本章介绍了 JavaScript 如何处理异常。

图标“详情”为什么 JavaScript 不经常抛出异常?

JavaScript 直到 ES3 才支持异常。这解释了为什么语言及其标准库很少使用它们。

26.1 动机:抛出和捕获异常

考虑以下代码。它将存储在文件中的配置文件读取到包含Profile类实例的数组中:

function readProfiles(filePaths) {
  const profiles = [];
  for (const filePath of filePaths) {
    try {
      const profile = readOneProfile(filePath);
      profiles.push(profile);
    } catch (err) { // (A)
      console.log('Error in: '+filePath, err);
    }
  }
}
function readOneProfile(filePath) {
  const profile = new Profile();
  const file = openFile(filePath);
  // ··· (Read the data in `file` into `profile`)
  return profile;
}
function openFile(filePath) {
  if (!fs.existsSync(filePath)) {
    throw new Error('Could not find file '+filePath); // (B)
  }
  // ··· (Open the file whose path is `filePath`)
}

让我们检查行 B 中发生了什么:发生了错误,但处理问题的最佳位置不是当前位置,而是在行 A。在那里,我们可以跳过当前文件并继续下一个文件。

因此:

  • 在行 B 中,我们使用throw语句来指示存在问题。

  • 在行 A 中,我们使用try-catch语句来处理问题。

当我们抛出时,以下结构是活跃的:

readProfiles(···)
  for (const filePath of filePaths)
    try
      readOneProfile(···)
        openFile(···)
          if (!fs.existsSync(filePath))
            throw

逐个throw退出嵌套结构,直到遇到try语句。执行继续在该try语句的catch子句中。

26.2 throw

这是throw语句的语法:

throw «value»;

26.2.1 我们应该抛出什么值?

在 JavaScript 中,任何值都可以被抛出。然而,最好使用Error或其子类的实例,因为它们支持额外的功能,如堆栈跟踪和错误链(参见“所有内置异常类的超类:Error” (§26.4))。

这给我们留下了以下选择:

  • 直接使用Error类。在 JavaScript 中,这比在更静态的语言中限制更少,因为我们可以向实例添加自己的属性:

    const err = new Error('Could not find the file');
    err.filePath = filePath;
    throw err;
    
    
  • 使用Error的子类之一例如TypeErrorRangeError

  • 继承Error(更多细节将在后面解释):

    class MyError extends Error {
    }
    function func() {
     throw new MyError('Problem!');
    }
    assert.throws(
     () => func(),
     MyError
    );
    
    

26.3 try语句

try语句的最大版本如下所示:

try {
  «try_statements»
} catch (error) {
  «catch_statements»
} finally {
  «finally_statements»
}

我们可以将这些子句组合如下:

  • try-catch

  • try-finally

  • try-catch-finally

26.3.1 try

try块可以被认为是语句的主体。这是我们执行常规代码的地方。

26.3.2 catch子句

如果在try块内部(可能在函数/方法调用的树中深度嵌套)抛出异常,则执行切换到catch子句,其中参数引用异常。之后,执行通常在try语句之后继续。如果以下情况发生,这可能会改变:

  • catch块内部有一个returnbreakthrow

  • 有一个finally子句(它在try语句结束时总是执行)。

以下代码演示了在行 A 抛出的值确实在行 B 中被捕获。

const errorObject = new Error();
function func() {
 throw errorObject; // (A)
}

try {
 func();
} catch (err) { // (B)
 assert.equal(err, errorObject);
}

26.3.2.1 省略catch绑定^(ES2019)

如果我们不关心抛出的值,我们可以省略catch参数:

try {
  // ···
} catch {
  // ···
}

这有时可能很有用。例如,Node.js 有一个 API 函数assert.throws(func)链接),用于检查func内部是否抛出错误。它可以如下实现。

function throws(func) {
  try {
    func();
  } catch {
    return; // everything OK
  }
  throw new Error('Function didn’t throw an exception!');
}

然而,这个函数的更完整实现将有一个catch参数,并且例如,会检查其类型是否符合预期。

26.3.3 finally子句

finally子句中的代码总是在try语句的末尾执行——无论try块或catch子句中发生什么。

让我们看看finally的一个常见用例:我们创建了一个资源,并希望在完成使用后始终销毁它,无论在处理它时发生什么。我们会这样实现:

const resource = createResource();
try {
  // Work with `resource`. Errors may be thrown.
} finally {
  resource.destroy();
}

26.3.3.1 finally总是执行

即使抛出错误(行 A),finally子句也总是执行。

let finallyWasExecuted = false;
assert.throws(
  () => {
    try {
      throw new Error(); // (A)
    } finally {
      finallyWasExecuted = true;
    }
  },
  Error
);
assert.equal(finallyWasExecuted, true);

即使有return语句(行 A):

let finallyWasExecuted = false;
function func() {
 try {
 return; // (A)
 } finally {
 finallyWasExecuted = true;
 }
}
func();
assert.equal(finallyWasExecuted, true);

图标“练习”练习:异常处理

exercises/exception-handling/call_function_test.mjs

26.4 所有内置异常类的超类:Error

这就是Error的实例属性和构造函数的外观:

class Error {
  // Actually a prototype data property
  get name(): string {
    return 'Error';
  }

  // Instance properties
  message: string;
  cause?: unknown; // ES2022
  stack: string; // non-standard but widely supported

  constructor(
 message: string = '',
 options?: ErrorOptions // ES2022
  ) {}
}
interface ErrorOptions {
  cause?: unknown; // ES2022
}

构造函数有两个参数:

  • message指定错误消息。

  • options是在 ECMAScript 2022 中引入的。它包含一个对象,其中目前支持一个属性:

    • .cause指定了(如果有的话)导致当前错误的异常。

下一个子节之后将更详细地解释实例属性.message.stack。下一个部分将解释.cause

26.4.1 Error.prototype.name

每个内置错误类 E 都有一个属性 E.prototype.name

> Error.prototype.name
'Error'
> RangeError.prototype.name
'RangeError'

因此,有两种方式来获取内置错误对象的类名:

> new RangeError().name
'RangeError'
> new RangeError().constructor.name
'RangeError'

26.4.2 Error 实例属性 .message

.message 只包含错误消息:

const err = new Error('Hello!');
assert.equal(String(err), 'Error: Hello!');
assert.equal(err.message, 'Hello!');

如果我们省略了消息,则使用空字符串作为默认值(从 Error.prototype.message 继承):

如果我们省略了消息,它将使用空字符串:

assert.equal(new Error().message, '');

26.4.3 Error 实例属性 .stack

实例属性 .stack 不是一个 ECMAScript 功能,但它被 JavaScript 引擎广泛支持。它通常是一个字符串,但它的确切结构没有标准化,并且在不同引擎之间有所不同。

这是在 JavaScript 引擎 V8 中的样子:

function h(z) {
  const error = new Error();
  console.log(error.stack);
}
function g(y) {
  h(y + 1);
}
function f(x) {
  g(x + 1);
}
f(3);

输出:

Error
    at h (demos/async-js/stack_trace.mjs:2:17)
    at g (demos/async-js/stack_trace.mjs:6:3)
    at f (demos/async-js/stack_trace.mjs:9:3)
    at demos/async-js/stack_trace.mjs:11:1

这个 堆栈跟踪(调用栈的跟踪)的第一行显示 Error 是在行 2 处创建的。最后一行显示一切始于行 11。

26.5 链式错误:实例属性 .cause (ES2022)

实例属性 .cause 是通过 new Error() 的第二个参数中的选项对象创建的。它指定了哪个其他错误导致了当前错误。

const err = new Error('msg', {cause: 'the cause'});
assert.equal(err.cause, 'the cause');

26.5.1 我们为什么想要链式错误?

有时,我们会捕获在更深层次的函数调用中抛出的错误,并希望附加更多信息到它:

function readFiles(filePaths) {
  return filePaths.map(
    (filePath) => {
      try {
        const text = readText(filePath);
        const json = JSON.parse(text);
        return processJson(json);
      } catch (error) {
        throw new Error( // (A)
          `While processing ${filePath}`,
          {cause: error}
        );
      }
    });
}

try 语句块内的语句可能会抛出各种错误。在那些错误被抛出的位置,通常没有意识到导致它们的文件。这就是为什么我们在行 A 处附加那个信息。

如果错误在控制台(例如,因为它被捕获或记录)显示,或者如果我们使用 Node 的 util.inspect()(行 A),我们可以看到原因及其堆栈跟踪:

import assert from 'node:assert/strict';
import * as util from 'node:util';

outerFunction();

function outerFunction() {
 try {
 innerFunction();
 } catch (err) {
 const errorWithCause = new Error(
 'Outer error', {cause: err}
 );
 assert.deepEqual(
 util.inspect(errorWithCause).split(/\r?\n/), // (A)
 [
 'Error: Outer error',
 '    at outerFunction (file:///tmp/main.mjs:10:28)',
 '    at file:///tmp/main.mjs:4:1',
 '    ... 2 lines matching cause stack trace ...',
 '  [cause]: TypeError: The cause',
 '      at innerFunction (file:///tmp/main.mjs:31:9)',
 '      at outerFunction (file:///tmp/main.mjs:8:5)',
 '      at file:///tmp/main.mjs:4:1',
 '}',
 ]
 );
 }
}

function innerFunction() {
 throw new TypeError('The cause');
} 

殊可惜,如果我们将错误转换为字符串或查看其 .stack,就无法看到原因。

26.5.2 我们应该在 .cause 中存储上下文数据吗?

error.cause 不仅适用于 Error 的实例;我们存储在其中的任何数据都会被正确显示:

import assert from 'node:assert/strict';
import * as util from 'node:util';

const error = new Error(
  'Could not reach server', {
    cause: {server: 'https://127.0.0.1'}
  }
);
assert.deepEqual(
  util.inspect(error).split(/\r?\n/),
  [
    "Error: Could not reach server",
    "    at file:///tmp/main.mjs:4:15",
    "  [cause]: { server: 'https://127.0.0.1' }",
    "}",
  ]
);

有些人推荐使用 .cause 来提供错误的数据上下文。这样做有哪些优点和缺点?

  • 优点:上下文数据会与错误一起优雅地显示。

  • 缺点:

    • .cause 只支持任意数据,因为在 JavaScript 中,我们可以 throw 任意数据。将其用于非抛出数据意味着我们有点误用了这个机制。

    • 如果我们使用 .cause 作为上下文数据,就无法再链式抛出异常了。

26.6 Error 的子类

26.6.1 Error 的内置子类

Error 有以下子类 – 引用 ECMAScript 规范

  • AggregateError (ES2021) 表示同时发生的多个错误。在标准库中,只有 Promise.any() 使用它。

  • RangeError 表示一个不在允许值集合或范围内的值。

  • ReferenceError 表示检测到无效的引用值。

  • SyntaxError 表示发生了解析错误。

  • TypeError 用于指示当其他任何 NativeError 对象都不是适当的失败原因指示时,操作不成功。

  • URIError 表示在全局 URI 处理函数的使用方式与其定义不兼容。

26.6.2 继承 Error

自从 ECMAScript 2022 以来,Error 构造函数接受两个参数(参见前一小节)。因此,在继承它时,我们有两种选择:我们可以在我们的子类中省略构造函数,或者我们可以像这样调用 super()

class MyCustomError extends Error {
  constructor(message, options) {
    super(message, options);
    // ···
  }
}

27 可调用值

原文:exploringjs.com/js/book/ch_callables.html

  1. 27.1 函数类型

  2. 27.2 普通函数

    1. 27.2.1 命名函数表达式(高级)

    2. 27.2.2 术语:函数定义和函数表达式

    3. 27.2.3 函数声明的组成部分

    4. 27.2.4 普通函数扮演的角色

    5. 27.2.5 术语:实体、语法与角色(高级)

  3. 27.3 专用函数(ES6)

    1. 27.3.1 专用函数仍然是函数

    2. 27.3.2 箭头函数

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

    4. 27.3.4 建议:优先使用专用函数而不是普通函数

  4. 27.4 总结:可调用值的类型

  5. 27.5 从函数和方法返回值

  6. 27.6 参数处理

    1. 27.6.1 术语:参数与参数

    2. 27.6.2 术语:回调函数

    3. 27.6.3 参数过多或不足

    4. 27.6.4 参数默认值(ES6)

    5. 27.6.5 剩余参数(ES6)

    6. 27.6.6 命名参数

    7. 27.6.7 模拟命名参数(ES6)

    8. 27.6.8 将展开(...)应用于函数调用(ES6)

  7. 27.7 函数方法:.call().apply().bind()

    1. 27.7.1 函数方法 .call()

    2. 27.7.2 函数方法 .apply()

    3. 27.7.3 函数方法 .bind()

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

27.1 函数类型

JavaScript 有两种函数类别:

  • 一个 普通函数 可以扮演多个角色:

    • 真实函数

    • 方法

    • 构造函数

  • 一个 专用函数 只能扮演以下角色之一——例如:

    • 一个 箭头函数 只能是一个真正的函数。

    • 一个 方法 只能是一个方法。

    • 一个 只能是一个构造函数。

    专用函数是在 ECMAScript 6 中添加到语言中的。

继续阅读以了解所有这些术语的含义。

27.2 普通函数

以下代码展示了两种(大致)做同样事情的方法:创建一个普通函数。

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

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

在一个作用域内,函数声明会提前激活(参见“声明:作用域和激活”§13.8),可以在声明之前调用。这偶尔是有用的。

变量声明,如 ordinary2 的声明,不会提前激活。

27.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'
); 

函数有名字的一个好处是,这些名字会出现在错误堆栈跟踪中。

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

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

  • 函数声明(一个语句)

  • 函数表达式

函数声明总是产生普通函数。函数表达式可以产生普通函数或特殊函数:

  • 普通函数表达式(我们之前已经遇到过):

    • 匿名函数表达式

    • 命名函数表达式

  • 特殊函数表达式(我们稍后会看到):

    • 箭头函数(它们总是表达式)

虽然函数声明在 JavaScript 中仍然很受欢迎,但现代代码中函数表达式几乎总是箭头函数。

27.2.3 函数声明的各个部分

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

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

  • add 是函数声明的名字

  • add(x, y) 是函数声明的头部

  • xy参数

  • 大括号({})及其之间的一切都是函数声明的主体

  • return 语句明确地从函数中返回一个值。

27.2.3.1 参数列表中的尾随逗号(ES2017)

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

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

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

27.2.4 普通函数所扮演的角色

考虑以下来自上一节的函数声明:

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

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

  • 实际函数:通过函数调用执行。

    assert.equal(add(2, 1), 3);
    
    
  • 方法:存储在属性中,通过方法调用执行。

    const obj = { addAsMethod: add };
    assert.equal(obj.addAsMethod(2, 4), 6); // (A)
    
    

    在行 A 中,obj 被称为方法调用的 接收者

  • 构造函数:通过 new 调用。

    const inst = new add();
    assert.equal(inst instanceof add, true);
    
    

    作为旁白,构造函数(包括类)的名称通常以大写字母开头。

27.2.5 术语:实体 vs. 语法 vs. 角色(高级)

语法实体角色 之间的区别很微妙,通常并不重要。但了解这一点仍然很有用:

  • 实体 是一种 JavaScript 特性,因为它“存在于”RAM 中。普通函数是一个实体。

    • 实体包括:普通函数、箭头函数、方法和类。
  • 语法 是我们用来创建实体的代码。函数声明和匿名函数表达式是语法。它们都创建被称为普通函数的实体。

    • 语法包括:函数声明和匿名函数表达式。产生箭头函数的语法也称为 箭头函数。对于方法和类也是如此。
  • 角色 描述了如何使用实体。实体 普通函数 可以扮演 真实函数 的角色,或者 方法 的角色,或者 的角色。实体 箭头函数 也可以扮演 真实函数 的角色。

    • 函数的角色包括:真实函数、方法和构造函数。

许多其他编程语言只有一个实体扮演 真实函数 的角色。然后它们可以使用 函数 这个名字来指代这个角色和实体。

27.3 特殊函数 (ES6)

特殊函数是普通函数的单用途版本。每个都专注于单一角色:

  • 箭头函数 的目的是成为一个真实函数:

    const arrow = () => {
     return 123;
    };
    assert.equal(arrow(), 123);
    
    
  • 方法 的目的是成为一个方法:

    const obj = {
      myMethod() {
     return 'abc';
     }
    };
    assert.equal(obj.myMethod(), 'abc');
    
    
  • 的目的是成为一个构造函数:

    class MyClass {
      /* ··· */
    }
    const inst = new MyClass();
    
    

除了更简洁的语法外,每种特殊函数也支持新特性,使它们在各自的工作中比普通函数表现得更好。

  • 箭头函数将在后面解释。

  • 方法在 对象章节 中进行解释。

  • 类在 类章节 中进行解释。

表 27.1 列出了普通函数和特殊函数的能力。

函数调用 方法调用 构造函数调用
普通函数 (this === undefined)
箭头函数 (词法 this)
方法 (this === undefined)

表 27.1:四种函数的能力。如果单元格值在括号中,则表示某种限制。特殊变量 this 在 “方法、普通函数和箭头函数中的特殊变量 this”(§27.3.3) 中进行解释。

27.3.1 特殊函数仍然是函数

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

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

27.3.2 箭头函数

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

  1. 为了提供一个更简洁的创建函数的方法。

  2. 它们在方法内部作为真正的函数表现更好:方法可以通过特殊变量 this 来引用接收方法调用的对象。箭头函数可以访问周围方法的 this,普通函数则不能(因为它们有自己的 this)。

我们将首先检查箭头函数的语法,然后讨论 this 在各种函数中的工作方式。

27.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 });

27.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,主体是一个表达式(对象字面量),而不是一个语句(代码块)。

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

“阅读”图标特殊变量 this 是面向对象的一个特性

我们在这里快速查看特殊变量 this,以便了解为什么箭头函数比普通函数更像是真正的函数。

但这个特性只在面向对象编程中才有意义,在“方法和特殊变量 this”(§30.6)中有更深入的介绍。因此,如果你现在还没有完全理解它,请不要担心。

在方法内部,特殊变量 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的方式:

  • 动态this:在行 A 中,我们尝试从一个普通函数中访问.someMethod()this。在那里,它被函数自己的this所掩盖,该thisundefined(由函数调用填充)。鉴于普通函数通过(动态)函数或方法调用接收它们的this,它们的this被称为动态

  • 词法this:在行 B 中,我们再次尝试访问.someMethod()this。这次我们成功了,因为箭头函数没有自己的thisthis是按词法解析的,就像任何其他变量一样。这就是为什么箭头函数的this被称为词法

27.3.4 推荐方案:优先使用专用函数而非普通函数

通常,我们应该优先使用专用函数而非普通函数,尤其是类和方法。

当涉及到真实函数时,箭头函数和普通函数之间的选择并不那么明确:

  • 对于匿名内联函数表达式,箭头函数由于其紧凑的语法和没有this作为隐式参数,是明显的赢家:

    const twiceOrdinary = [1, 2, 3].map(function (x) {return x * 2});
    const twiceArrow = [1, 2, 3].map(x => x * 2);
    
    
  • 对于独立的命名函数声明,箭头函数仍然得益于词法this。但函数声明(产生普通函数)具有很好的语法,早期激活偶尔也很有用(参见“声明:作用域和激活”(§13.8))。如果this在普通函数体中未出现,使用它作为实函数没有缺点。静态检查工具 ESLint 可以在开发过程中通过内置规则警告我们这种错误的使用。

    function timesOrdinary(x, y) {
      return x * y;
    }
    const timesArrow = (x, y) => {
      return x * y;
    };
    
    

27.4 摘要:可调用值的种类

图标“阅读”本节涉及即将到来的内容

本节主要作为当前和即将到来的章节的参考。如果你没有完全理解,请不要担心。

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

  • 单一结果

  • 同步

后续章节将涵盖其他编程模式:

  • 迭代将对象视为数据容器(所谓可迭代)并提供了一种标准化的方式来检索其中的内容。如果一个函数或方法返回一个可迭代对象,它将返回多个值。

  • 异步编程处理长时间运行的计算。当计算完成时,我们会收到通知,并在其间做其他事情。异步传递单个结果的标准模式称为Promise

这些模式可以组合——例如,存在同步可迭代和异步可迭代。

几种新的函数和方法有助于处理一些模式组合:

  • 异步函数帮助实现返回 Promise 的函数。也有异步方法

  • 同步生成器函数帮助实现返回同步可迭代的函数。也有同步生成器方法

  • 异步生成器函数帮助实现返回异步可迭代的函数。也有异步生成器方法

这就留下了 4 种(2×2)函数和方法:

  • 同步与异步

  • 生成器与单结果

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

结果 #
同步函数 同步方法
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* () {}

表 27.2:创建函数和方法的语法。最后一列指定实体产生的值的数量。

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

27.6 参数处理

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

27.6.1 术语:参数与参数

术语参数和术语参数基本上意味着同一件事。如果我们愿意,我们可以做出以下区分:

  • 参数是函数定义的一部分。它们也被称为形式参数形式参数

  • 参数是函数调用的一部分。它们也被称为实际参数实际参数

27.6.2 术语:回调

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

以下是一个回调示例:

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

输出:

a
b

27.6.3 过多或不足的参数

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

  • 额外的参数会被忽略。

  • 缺少的参数会被设置为undefined

例如:

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]);

27.6.4 参数默认值(ES6)

参数默认值指定了如果未提供参数应使用的值——例如:

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]
);

27.6.5 剩余参数(ES6)

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

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

与我们如何使用剩余参数相关的有两个限制:

  • 我们不能在函数定义中使用多个剩余参数。

    assert.throws(
      () => eval('function f(...x, ...y) {}'),
      /^SyntaxError: Rest parameter must be last formal parameter$/
    );
    
    
  • 剩余参数必须始终放在最后。因此,我们不能像这样访问最后一个参数:

    assert.throws(
      () => eval('function f(...restParams, lastParam) {}'),
      /^SyntaxError: Rest parameter must be last formal parameter$/
    );
    
    
27.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的元素。

27.6.6 命名参数

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

  1. 位置参数:如果一个参数与一个参数具有相同的位置,则将其分配给该参数。只有位置参数的函数调用看起来如下。

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

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

命名参数有几个优点:

  • 它们导致代码更具自解释性,因为每个参数都有一个描述性的标签。只需比较selectEntries()的两个版本:在第二个版本中,更容易看到发生了什么。

  • 参数的顺序无关紧要(只要名称正确)。

  • 处理多个可选参数更方便:调用者可以轻松提供所有可选参数的任何子集,而无需了解他们省略了哪些(使用位置参数时,我们必须填写前面的可选参数,使用undefined)。

27.6.7 模拟命名参数 (ES6)

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

27.6.8 将展开 (...) 用于函数调用 (ES6)

如果我们在函数调用参数前放置三个点 (...),则我们 展开 它。这意味着该参数必须是一个 可迭代 对象并且迭代的值都成为参数。换句话说,单个参数被扩展成多个参数——例如:

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

输出:

a
b

展开和剩余参数使用相同的语法 (...),但它们服务于相反的目的:

  • 剩余参数用于定义函数或方法时。它们将参数收集到数组中。

  • 展开参数用于调用函数或方法时。它们将可迭代对象转换为参数。

27.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

27.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']);

图标“练习” 练习:参数处理

  • 位置参数:exercises/callables/positional_parameters_test.mjs

  • 命名参数:exercises/callables/named_parameters_test.mjs

27.7 函数方法:.call().apply().bind()

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

27.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 值将被忽略。

27.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']
);

27.7.3 函数方法 .bind()

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

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

.bind() 返回一个新的函数 boundFunc()。调用该函数时,将使用 thisValue 设置 this 并使用这些参数:arg1arg2,然后是 boundFunc() 的参数。

即,以下两个函数调用是等价的:

boundFunc('a', 'b')
someFunc.call(thisValue, arg1, arg2, 'a', 'b')

27.7.3.1 .bind() 的替代方法

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

const boundFunc2 = (...args) =>
  someFunc.call(thisValue, arg1, arg2, ...args);

27.7.3.2 .bind() 的实现

考虑到上一节,.bind() 可以实现为一个真正的函数,如下所示:

function bind(func, thisValue, ...boundArgs) {
  return (...args) =>
    func.call(thisValue, ...boundArgs, ...args);
}

27.7.3.3 示例:绑定真正的函数

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

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

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

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

posted @ 2025-12-12 18:01  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报