探索-JavaScript-ES2025-版--二-

探索 JavaScript(ES2025 版)(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

10 控制台:交互式 JavaScript 命令行

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

  1. 10.1 尝试运行 JavaScript 代码

    1. 10.1.1 浏览器控制台

    2. 10.1.2 Node.js 的 REPL

    3. 10.1.3 其他选项

  2. 10.2 console.* API:打印数据和更多

    1. 10.2.1 打印值:console.log() (stdout)

    2. 10.2.2 打印错误信息:console.error() (stderr)

    3. 10.2.3 通过JSON.stringify()打印嵌套对象

10.1 尝试运行 JavaScript 代码

你有很多选项可以快速运行 JavaScript 代码片段。以下小节将描述其中的一些。

10.1.1 浏览器控制台

网络浏览器有所谓的控制台:你可以通过console.log()向其打印文本的交互式命令行,你可以在其中运行代码片段。打开控制台的方法因浏览器而异。图 10.1 显示了 Google Chrome 的控制台。

要了解如何在你的网络浏览器中打开控制台,你可以通过搜索“控制台 «你的浏览器名称»”来进行网络搜索。以下是一些常用网络浏览器的页面:

图 10.1:在访问网页时,网络浏览器“Google Chrome”的控制台已打开(在窗口的下半部分)。

10.1.2 Node.js 的 REPL

REPL代表读取-评估-打印循环,基本上意味着命令行。要使用它,你必须首先从操作系统命令行启动 Node.js,通过命令node。然后与它的交互看起来如图 10.2 所示:>后面的文本是用户输入;其余的都是 Node.js 的输出。

图 10.2:启动和使用 Node.js 的 REPL(交互式命令行)。

图标“阅读”阅读:REPL 交互

我偶尔会通过 REPL 交互演示 JavaScript。然后我也使用大于号符号(>)来标记输入 - 例如:

> 3 + 5
8

10.1.3 其他选项

其他选项包括:

  • 有许多网络应用允许你在网络浏览器中尝试 JavaScript - 例如,Babel 的 REPL

  • 还有一些原生应用和 IDE 插件可以运行 JavaScript。

图标“警告”控制台通常以非严格模式运行

在现代 JavaScript 中,大多数代码(例如,模块)都是在 严格模式 下执行的。然而,控制台通常以非严格模式运行。因此,当使用控制台执行本书中的代码时,你可能会偶尔得到略微不同的结果。

10.2 console.* API:打印数据和更多

在浏览器中,控制台是通常隐藏的可以调出的东西。对于 Node.js,控制台是 Node.js 当前运行的终端。

完整的 console.* API 文档位于 MDN 网络文档Node.js 网站上。它不是 JavaScript 语言标准的一部分,但许多功能都得到了浏览器和 Node.js 的支持。

在本章中,我们只查看以下两种打印数据的方法(“打印”意味着在控制台显示):

  • console.log()

  • console.error()

10.2.1 打印值:console.log()(标准输出)

这个操作有两种变体:

console.log(...values: Array<any>): void
console.log(pattern: string, ...values: Array<any>): void

10.2.1.1 打印多个值

第一个变体在控制台上打印(值的)文本表示:

console.log('abc', 123, true);

输出:

abc 123 true

最后,console.log() 总是打印一个换行符。因此,如果你不带任何参数调用它,它只会打印一个换行符。

10.2.1.2 打印带有替换的字符串

第二个变体执行字符串替换:

console.log('Test: %s %j', 123, 'abc');

输出:

Test: 123 "abc"

这些是一些你可以使用的替换指令:

  • %s 将相应的值转换为字符串并插入。

    console.log('%s %s', 'abc', 123);
    
    

    输出:

    abc 123
    
    
  • %o 插入对象的字符串表示。

    console.log('%o', {foo: 123, bar: 'abc'});
    
    

    输出:

    { foo: 123, bar: 'abc' }
    
    
  • %j 将值转换为 JSON 字符串并插入。

    console.log('%j', {foo: 123, bar: 'abc'});
    
    

    输出:

    {"foo":123,"bar":"abc"}
    
    
  • %% 插入一个单个的 %

    console.log('%s%%', 99);
    
    

    输出:

    99%
    
    

10.2.2 打印错误信息:console.error()(标准错误)

console.error()console.log() 的工作方式相同,但它记录的内容被认为是错误信息。对于 Node.js,这意味着输出会发送到 Unix 上的 stderr 而不是 stdout。

10.2.3 通过 JSON.stringify() 打印嵌套对象

JSON.stringify() 有时对于打印嵌套对象很有用:

console.log(JSON.stringify({first: 'Jane', last: 'Doe'}, null, 2));

输出:

{
  "first": "Jane",
  "last": "Doe"
}

11 断言 API

原文:exploringjs.com/js/book/ch_assertion-api.html

  1. 11.1 软件开发中的断言

  2. 11.2 本书中使用断言的方式

    1. 11.2.1 通过断言在代码示例中记录结果

    2. 11.2.2 通过断言实现测试驱动练习

  3. 11.3 正常比较与深度比较

  4. 11.4 快速参考:模块 assert

    1. 11.4.1 正常相等:assert.equal()

    2. 11.4.2 深度相等:assert.deepEqual()

    3. 11.4.3 期望异常:assert.throws()

    4. 11.4.4 总是失败:assert.fail()

11.1 软件开发中的断言

在软件开发中,断言用于声明关于值或代码片段的事实,这些事实必须是真实的。如果不是,则会抛出异常。Node.js 通过其内置模块 assert 支持断言 – 例如:

import assert from 'node:assert/strict';
assert.equal(3 + 5, 8);

此断言声明期望的结果是 3 加 5 等于 8。导入语句使用了推荐的 strict 版本assert

11.2 本书中使用断言的方式

在本书中,断言以两种方式使用:在代码示例中记录结果以及实现测试驱动练习。

11.2.1 通过断言在代码示例中记录结果

在代码示例中,断言表达期望的结果。例如,以下函数:

function id(x) {
  return x;
}

id() 返回其参数。我们可以通过断言来展示其作用:

assert.equal(id('abc'), 'abc');

在示例中,我通常省略导入 assert 的语句。

使用断言背后的动机是:

  • 我们可以精确地指定期望的内容。

  • 代码示例可以自动测试,这确保了它们确实有效。

11.2.2 通过断言实现测试驱动练习

本书的学习练习是通过测试框架 Mocha 驱动的。测试中的检查是通过 assert 的方法进行的。

以下是一个此类测试的示例:

// For the exercise, we must implement the function hello().
// The test checks if we have done it properly.
test('First exercise', () => {
  assert.equal(hello('world'), 'Hello world!');
  assert.equal(hello('Jane'), 'Hello Jane!');
  assert.equal(hello('John'), 'Hello John!');
  assert.equal(hello(''), 'Hello !');
});

更多信息,请参阅“开始练习”(§12)。

11.3 正常比较与深度比较

严格的 equal() 使用 === 来比较值。因此,一个对象仅等于自身 – 即使另一个对象具有相同的内容(因为 === 不比较对象的内容,只比较它们的标识):

assert.notEqual({foo: 1}, {foo: 1});

deepEqual() 是比较对象的更好选择:

assert.deepEqual({foo: 1}, {foo: 1});

此方法也适用于数组:

assert.notEqual(['a', 'b', 'c'], ['a', 'b', 'c']);
assert.deepEqual(['a', 'b', 'c'], ['a', 'b', 'c']);

11.4 快速参考:模块 assert

对于完整的文档,请参阅 Node.js 文档

11.4.1 正常相等:assert.equal()

  • assert.equal(actual, expected, message?)

    actual === expected 必须为 true。如果不为真,将抛出 AssertionError

    assert.equal(3+3, 6);
    
    
  • assert.notEqual(actual, expected, message?)

    actual !== expected 必须为 true。如果不为真,将抛出 AssertionError

    assert.notEqual(3+3, 22);
    
    

可选的最后一个参数 message 可以用来解释所断言的内容。如果断言失败,该消息用于设置抛出的 AssertionError

let e;
try {
  const x = 3;
  assert.equal(x, 8, 'x must be 8')
} catch (err) {
  assert.equal(
    String(err),
    'AssertionError [ERR_ASSERTION]: x must be 8\n\n3 !== 8\n');
}

11.4.2 深度相等:assert.deepEqual()

  • assert.deepEqual(actual, expected, message?)

    actual 必须与 expected 深度相等。如果不相等,将抛出 AssertionError

    assert.deepEqual([1,2,3], [1,2,3]);
    assert.deepEqual([], []);
    
    // To .equal(), an object is only equal to itself:
    assert.notEqual([], []);
    
    
  • assert.notDeepEqual(actual, expected, message?)

    actual 必须与 expected 不深度相等。如果相等,将抛出 AssertionError

    assert.notDeepEqual([1,2,3], [1,2]);
    
    

11.4.3 期望异常:assert.throws()

如果我们想要(或期望)收到一个异常,我们需要 assert.throws():这个函数调用它的第一个参数,即函数 callback,并且只有当它抛出异常时才成功。可以使用额外的参数来指定那个异常必须看起来像什么。

  • assert.throws(callback, message?): void

    assert.throws(
      () => {
        null.prop;
      }
    );
    
    
  • assert.throws(callback, errorClass, message?): void

    assert.throws(
      () => {
        null.prop;
      },
      TypeError
    );
    
    
  • assert.throws(callback, errorRegExp, message?): void

    assert.throws(
      () => {
        null.prop;
      },
      /^TypeError: Cannot read properties of null \(reading 'prop'\)$/
    );
    
    
  • assert.throws(callback, errorObject, message?): void

    assert.throws(
      () => {
        null.prop;
      },
      {
        name: 'TypeError',
        message: "Cannot read properties of null (reading 'prop')",
      }
    );
    
    

11.4.4 始终失败:assert.fail()

  • assert.fail(messageOrError?)

    默认情况下,当它被调用时,它会抛出 AssertionError。这在单元测试中偶尔很有用。"messageOrError" 可以是:

    • 一个字符串。这允许覆盖默认的错误信息。

    • Error 的一个实例(或其子类)。这使我们能够抛出不同的值。

    try {
      functionThatShouldThrow();
      assert.fail();
    } catch (_) {
      // Success
    }
    
    

12 开始使用练习

原文

  1. 12.1 练习

    1. 12.1.1 安装练习

    2. 12.1.2 运行练习

  2. 12.2 JavaScript 中的单元测试

    1. 12.2.1 典型测试

    2. 12.2.2 Mocha 中的异步测试

在大多数章节中,都有指向练习的框。这些是付费功能,但提供了全面的预览。本章解释了如何开始使用它们。

12.1 练习

12.1.1 安装练习

要安装练习:

  • 下载并解压 exploring-js-code.zip

  • 按照以下 README.txt 中的说明操作。

12.1.2 运行练习

  • 本书中的练习通过路径引用。

    • 例如:exercises/exercises/first_module_test.mjs
  • 在每个文件中:

    • 第一行包含运行练习的命令。

    • 以下行描述了您必须执行的操作。

12.2 JavaScript 中的单元测试

本书中的所有练习都是通过测试框架 Mocha 运行的测试。本节提供了简要介绍。

12.2.1 典型测试

典型的测试代码分为两部分:

  • 第一部分:待测试的代码。

  • 第二部分:代码的测试。

以以下两个文件为例:

  • id.mjs (待测试的代码)

  • id_test.mjs (测试)

12.2.1.1 第一部分:代码

代码本身位于 id.mjs

export function id(x) {
  return x;
}

这里关键的是:我们想要测试的所有内容都必须导出。否则,测试代码无法访问它。

12.2.1.2 第二部分:测试

阅读图标不要担心测试的详细细节

您不需要担心测试的详细细节:它们总是为您实现。因此,您只需要阅读它们,而不需要编写它们。

代码的测试位于 id_test.mjs

/* npm t demos/exercises/id_test.mjs

Instructions: Implement id.mjs
*/
suite('id_test.mjs');

import * as assert from 'node:assert/strict';
import {id} from './id.mjs';

test('My test', () => {
  assert.equal(
    id('abc'), 'abc'
  );
});

这个文件里有什么?

  • 它以运行测试的命令开始。

  • 接下来是实施解决方案的说明。

  • suite() 为此文件中的测试提供标题。

  • 我们在严格模式下使用 Node.js 的 assert 模块。

  • 函数 test() 定义了命名的测试用例。

  • 测试的核心是 assert.equal() 检查导入的函数 id() 的结果。这就是我们必须实现的函数。

要运行测试,我们在 shell 中执行以下命令:

npm t demos/exercises/id_test.mjs

ttest 的缩写。也就是说,这个命令的完整版本是:

npm test demos/exercises/id_test.mjs

练习图标练习:您的第一个练习

以下练习让您首次体验练习的样子:

  • exercises/exercises/first_module_test.mjs

12.2.2 Mocha 中的异步测试

Icon “reading”阅读

你可能想等到你读到关于异步编程的章节后再阅读这一节。

为异步代码编写测试需要额外的工作:测试结果会在之后收到,并且当它返回时必须向 Mocha 信号表示它尚未完成。以下小节将探讨三种实现此功能的方法。

12.2.2.1 通过回调实现异步

如果我们传递给 test() 的回调函数有一个参数(例如,done),Mocha 将切换到基于回调的异步。当我们完成我们的异步工作时,我们必须调用 done

test('divideCallback', (done) => {
  divideCallback(8, 4, (error, result) => {
    if (error) {
      done(error);
    } else {
      assert.strictEqual(result, 2);
      done();
    }
  });
});

这就是 divideCallback() 的样子:

function divideCallback(x, y, callback) {
  if (y === 0) {
    callback(new Error('Division by zero'));
  } else {
    callback(null, x / y);
  }
}

12.2.2.2 通过 Promises 实现异步

如果测试返回一个 Promise,Mocha 将切换到基于 Promise 的异步。如果 Promise 被满足,则测试被认为是成功的;如果 Promise 被拒绝,或者解决时间超过超时,则测试被认为是失败的。

test('dividePromise 1', () => {
  return dividePromise(8, 4)
  .then(result => {
    assert.strictEqual(result, 2);
  });
});

dividePromise() 的实现如下:

function dividePromise(x, y) {
  return new Promise((resolve, reject) => {
    if (y === 0) {
      reject(new Error('Division by zero'));
    } else {
      resolve(x / y);
    }
  });
}

12.2.2.3 异步函数作为测试“主体”

异步函数总是返回 Promises。因此,异步函数是实现异步测试的一种便捷方式。以下代码与上一个示例等价。

test('dividePromise 2', async () => {
  const result = await dividePromise(8, 4);
  assert.strictEqual(result, 2);
  // No explicit return necessary!
});

我们不需要显式地返回任何内容:隐式返回的 undefined 用于满足此异步函数返回的 Promise。如果测试代码抛出异常,则异步函数会负责拒绝返回的 Promise。

III 变量和值

原文:exploringjs.com/js/book/pt_variables-values.html

13 变量和赋值

原文:exploringjs.com/js/book/ch_variables-assignment.html

  1. 13.1 let

  2. 13.2 const

    1. 13.2.1 const和不可变性

    2. 13.2.2 const和循环

  3. 13.3 在constlet之间做出选择

  4. 13.4 变量的作用域

    1. 13.4.1 变量的遮蔽
  5. 13.5 (高级)

  6. 13.6 术语:静态与动态

    1. 13.6.1 静态现象:变量的作用域

    2. 13.6.2 动态现象:函数调用

  7. 13.7 JavaScript 全局变量的作用域

    1. 13.7.1 globalThis (ES2020)
  8. 13.8 声明:作用域和激活

    1. 13.8.1 constlet:临时死区

    2. 13.8.2 函数声明和早期激活

    3. 13.8.3 类声明不会早期激活

    4. 13.8.4 var:提升(部分早期激活)

  9. 13.9 闭包

    1. 13.9.1 绑定变量与自由变量

    2. 13.9.2 什么是闭包?

    3. 13.9.3 示例:增量器工厂

    4. 13.9.4 闭包的使用场景

这些是 JavaScript 声明变量的主要方式:

  • let声明可变变量。

  • const声明常量(不可变变量)。

在 ES6 之前,也存在var。但它有几个怪癖,所以在现代 JavaScript 中最好避免使用它。你可以在Speaking JavaScript中了解更多相关信息。

13.1 let

通过let声明的变量是可变的:

let i;
i = 0;
i = i + 1;
assert.equal(i, 1);

我们也可以同时声明和赋值:

let i = 0;

13.2 const

通过const声明的变量是不可变的。我们必须始终立即初始化:

const i = 0; // must initialize

assert.throws(
  () => { i = i + 1 },
  {
    name: 'TypeError',
    message: 'Assignment to constant variable.',
  }
);

13.2.1 const和不可变性

在 JavaScript 中,const仅意味着绑定(变量名与变量值之间的关联)是不可变的。值本身可能是可变的,如下面的示例中的obj

const obj = { prop: 0 };

// Allowed: changing properties of `obj`
obj.prop = obj.prop + 1;
assert.equal(obj.prop, 1);

// Not allowed: assigning to `obj`
assert.throws(
  () => { obj = {} },
  {
    name: 'TypeError',
    message: 'Assignment to constant variable.',
  }
);

13.2.2 const和循环

我们可以在for-of循环中使用const,其中为每次迭代创建一个新的绑定:

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

输出:

hello
world

在普通的for循环中,我们必须使用let,然而:

const arr = ['hello', 'world'];
for (let i=0; i<arr.length; i++) {
  const elem = arr[i];
  console.log(elem);
}

13.3 在constlet之间做出选择

我推荐以下规则来决定使用const还是let

  • const表示不可变的绑定,即变量永远不会改变其值。优先选择它。

  • let表示变量的值会改变。只有在你不能使用const时才使用它。

“练习”图标 练习:const

exercises/variables-assignment/const_exrc.mjs

13.4 变量的作用域

变量的作用域是指程序中可以访问该变量的区域。考虑以下代码。

{ // // Scope A. Accessible: x
  const x = 0;
  assert.equal(x, 0);
  { // Scope B. Accessible: x, y
    const y = 1;
    assert.equal(x, 0);
    assert.equal(y, 1);
    { // Scope C. Accessible: x, y, z
      const z = 2;
      assert.equal(x, 0);
      assert.equal(y, 1);
      assert.equal(z, 2);
    }
  }
}
// Outside. Not accessible: x, y, z
assert.throws(
  () => console.log(x),
  {
    name: 'ReferenceError',
    message: 'x is not defined',
  }
);

  • 作用域 A 是x(直接)作用域

  • 作用域 B 和 C 是作用域 A 的内部作用域

  • 作用域 A 是作用域 B 和作用域 C 的外部作用域

每个变量在其直接作用域以及该作用域嵌套的所有作用域中都是可访问的。

通过constlet声明的变量被称为块级作用域,因为它们的作用域始终是最近的包围块。

13.4.1 遮蔽变量

我们不能在同一级别上声明相同的变量两次:

assert.throws(
  () => {
    eval('let x = 1; let x = 2;');
  },
  {
    name: 'SyntaxError',
    message: "Identifier 'x' has already been declared",
  }
);

图标“详情” 为什么使用eval()

eval() 延迟解析(因此SyntaxError),直到assert.throws()的回调执行。如果我们没有使用它,当解析此代码时,我们就会得到错误,而assert.throws()甚至不会执行。

然而,我们可以嵌套一个块并使用与块外相同的变量名x

const x = 1;
assert.equal(x, 1);
{
  const x = 2;
  assert.equal(x, 2);
}
assert.equal(x, 1);

在块内部,内层的x是唯一可访问的具有该名称的变量。内层的x被称为遮蔽外层的x。一旦我们离开块,我们就可以再次访问旧值。

13.5 (高级)

所有剩余的部分都是高级内容。

13.6 术语:静态与动态

这两个形容词描述了编程语言中的现象:

  • 静态意味着某物与源代码相关,并且可以在不执行代码的情况下确定。

  • 动态意味着在运行时。

让我们看看这两个术语的例子。

13.6.1 静态现象:变量的作用域

变量作用域是一种静态现象。考虑以下代码:

function f() {
 const x = 3;
 // ···
}

x静态地(或词法地作用域。也就是说,其作用域是固定的,并且在运行时不改变。

变量作用域形成一个静态树(通过静态嵌套)。

13.6.2 动态现象:函数调用

函数调用是一种动态现象。考虑以下代码:

function g(x) {}
function h(y) {
  if (Math.random()) g(y); // (A)
}

行 A 中的函数调用是否发生,只能在运行时决定。

函数调用形成一个动态树(通过动态调用)。

13.7 JavaScript 的全局变量作用域

JavaScript 的作用域是嵌套的。它们形成一个树:

  • 最外层的作用域是树的根。

  • 直接包含在该作用域中的作用域是根的子节点。

  • 等等。

根也被称为全局作用域。在网页浏览器中,唯一直接处于该作用域的位置是脚本的最顶层。全局作用域的变量被称为全局变量,在所有地方都可以访问。有两种类型的全局变量:

  • 全局声明变量是普通变量:

    • 它们只能在脚本的顶层通过 constlet 和类声明创建。
  • 全局对象变量存储在所谓的 全局对象 的属性中:

    • 它们在脚本的顶层通过 var 和函数声明创建。

    • 可以通过全局变量 globalThis 访问全局对象。它可以用来创建、读取和删除全局对象变量。

    • 除了这些,全局对象变量像普通变量一样工作。

以下 HTML 片段演示了 globalThis 和两种类型的全局变量。

<script>
 const declarativeVariable = 'd';
 var objectVariable = 'o';
</script>
<script>
 // All scripts share the same top-level scope:
 console.log(declarativeVariable); // 'd'
 console.log(objectVariable); // 'o'

 // Not all declarations create properties of the global object:
 console.log(globalThis.declarativeVariable); // undefined
 console.log(globalThis.objectVariable); // 'o'
</script>

每个模块都有自己的变量作用域,它是全局作用域的直接子级。因此,存在于模块顶层的变量不是全局的。图 13.1 展示了各种作用域之间的关系。

图 13.1:全局作用域是 JavaScript 的最外层作用域。它有两种类型的变量:对象变量(通过 全局对象 管理)和正常的 声明性变量。每个 ECMAScript 模块都有自己的作用域,该作用域包含在全局作用域中。

13.7.1 globalThis (ES2020)

全局变量 globalThis 是访问全局对象的标准方式。它的名字来源于它在全局作用域(脚本作用域,不是模块作用域)中与 this 的值相同。

图标“详情”globalThis 并不总是直接指向全局对象

例如,在浏览器中,存在一个间接引用。这种间接引用通常不明显,但它确实存在,并且可以被观察到。

13.7.1.1 globalThis 的替代方案

以下全局变量让我们可以在 某些 平台上访问全局对象:

  • window:引用全局对象的经典方式。但在 Node.js 和 Web Workers 中不起作用。

  • self:在 Web Workers 和浏览器中普遍可用。但 Node.js 不支持。

  • global:仅在 Node.js 中可用。

主浏览器线程 Web Workers Node.js
globalThis
window
self
global
13.7.1.2 globalThis 的用例

由于向后兼容性,全局对象现在被认为是一个错误,JavaScript 无法摆脱。它对性能产生负面影响,并且通常令人困惑。

ECMAScript 6 引入了一些特性,使得避免全局对象变得更加容易——例如:

  • constlet 和类声明在全局作用域中使用时不会创建全局对象属性。

  • 每个 ECMAScript 模块都有自己的局部作用域。

通常,通过变量而不是通过 globalThis 的属性访问全局对象变量会更好。前者在所有 JavaScript 平台上始终表现一致。

网上的教程偶尔会通过 window.globVar 访问全局变量 globVar。但前缀 “window.” 是不必要的,我建议省略它:

window.encodeURIComponent(str); // no
encodeURIComponent(str); // yes

因此,globalThis 的使用案例相对较少——例如:

  • Polyfills 为旧版 JavaScript 引擎添加新功能。

  • 功能检测,用于找出 JavaScript 引擎支持哪些功能。

13.8 声明:作用域和激活

这些是声明的两个关键方面:

  • 作用域:声明的实体在哪里可见?这是一个静态特性。

  • 激活:我何时可以访问一个实体?这是一个动态特性。某些实体在我们进入它们的作用域时就可以访问。对于其他实体,我们必须等待执行到达它们的声明。

下表总结了各种声明如何处理这些方面:

作用域 激活 重复 全局属性
const 声明(TDZ)
let 声明(TDZ)
function 块 (*) 开始
class 声明(TDZ)
import 模块 开始
var 函数 开始(部分)

(*) 函数声明通常是块级作用域,但在非严格模式下是函数级作用域。

声明的方面:

  • 对于大多数构造,它们的作用域是最内层的周围块。有两个例外:

    • import 只能在模块的最顶层使用。

    • 通过 var 声明的变量的作用域是其最内层的周围函数(不是块)。

  • 构造函数的激活(当我们能够访问它们时)是变化的,将在稍后进行更详细的描述——例如,TDZ 表示 时间死区

  • “重复”描述了声明是否可以使用相同的名称(在每个作用域内)使用两次。

  • “全局属性”描述了当声明在脚本的全球作用域中执行时,它是否向全局对象添加属性。

import 在 “ECMAScript 模块” (§29.5) 中进行了描述。以下小节将更详细地描述其他构造和现象。

13.8.1 constlet:时间死区

13.8.1.1 在变量声明之前访问变量时应该做什么?

对于 JavaScript,TC39 需要决定如果我们直接访问一个常量,在它的声明之前会发生什么:

{
  console.log(x); // What happens here?
  const x = 123;
}

一些可能的方法是:

  1. 名称在当前作用域周围的范围内解析。

  2. 我们得到 undefined

  3. 存在一个错误。

第一种方法被拒绝,因为没有语言中的先例,因此这对 JavaScript 程序员来说可能不直观。

第二种方法被拒绝,因为这样 x 就不会是一个常量——它在声明前后会有不同的值。

let使用与const相同的方法 3,因此它们的工作方式相似,并且很容易在它们之间切换。

13.8.1.2 时间死区

变量进入其作用域和执行其声明之间的时间被称为该变量的时间死区 (TDZ):

  • 在这段时间内,变量被认为是未初始化的(就像它有一个特殊值一样)。

  • 如果我们访问一个未初始化的变量,我们会得到一个ReferenceError

  • 一旦我们到达变量声明,该变量将被设置为初始化器的值(通过赋值符号指定)或undefined——如果没有初始化器。

以下代码说明了时间死区:

if (true) { // entering scope of `tmp`, TDZ starts
  // `tmp` is uninitialized:
  assert.throws(() => (tmp = 'abc'), ReferenceError);
  assert.throws(() => console.log(tmp), ReferenceError);

  let tmp; // TDZ ends
  assert.equal(tmp, undefined);
}

下一个例子表明时间死区确实是时间的(与时间相关):

if (true) { // entering scope of `myVar`, TDZ starts
  const func = () => {
 console.log(myVar); // executed later
 };

 // We are within the TDZ:
 // Accessing `myVar` causes `ReferenceError`

 let myVar = 3; // TDZ ends
 func(); // OK, called outside TDZ
}

即使func()位于myVar声明之前并使用该变量,我们也可以调用func()。但我们必须等待myVar的时间死区结束。

13.8.2 函数声明和早期激活

“阅读”图标更多关于函数的信息

在本节中,我们正在使用函数——在我们有机会正确学习它们之前。希望一切仍然有意义。如果它没有,请参阅“可调用值” (§27)。

函数声明总是在进入其作用域时执行,无论它在作用域内的位置如何。这使得我们可以在声明函数funcDecl()之前调用它。

assert.equal(funcDecl(), 123); // OK
function funcDecl() { return 123; }

funcDecl()的早期激活意味着前面的代码等同于:

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

如果我们通过constlet声明一个函数,那么它不会早期激活。在下面的例子中,我们只能在声明之后使用arrowFunc()

assert.throws(
  () => arrowFunc(), // before declaration
  ReferenceError
);

const arrowFunc = () => { return 123 };

assert.equal(arrowFunc(), 123); // after declaration 

13.8.2.1 无早期激活的提前调用

函数f()可以调用后来声明且未早期激活的函数g()——只要我们在g()声明之后调用f()

const f = () => g();
const g = () => 123;

// We call f() after g() was declared:
assert.equal(f(), 123); // OK 

模块中的函数通常在其主体完全执行后调用。因此,在模块中,我们很少需要担心函数的顺序(即使它们不是函数声明)。

13.8.2.2 早期激活的陷阱

如果我们依赖于早期激活在声明之前调用一个函数,那么我们需要小心它不会访问未早期激活的数据。

funcDecl();

const MY_STR = 'abc';
function funcDecl() {
 assert.throws(
 () => MY_STR,
 ReferenceError
 );
}

如果我们在MY_STR声明之后调用funcDecl(),问题就会消失。

13.8.2.3 早期激活的优缺点

我们已经看到早期激活有一个陷阱,并且我们可以在不使用它的情况下获得其大部分好处。因此,最好避免早期激活。但我不对此有强烈的看法,并且如前所述,我经常使用函数声明,因为我喜欢它们的语法。

13.8.3 类声明不会提前激活

尽管它们在某些方面与函数声明相似,但类声明不会提前激活:

assert.throws(
  () => new MyClass(),
  ReferenceError
);

class MyClass {}

assert.equal(new MyClass() instanceof MyClass, true);

为什么会这样?考虑以下类声明:

class MyClass extends Object {}

extends运算符的操作数是一个表达式。因此,我们可以这样做:

const identity = x => x;
class MyClass extends identity(Object) {}

评估这样的表达式必须在它被提及的位置进行。其他任何地方都会造成混淆。这也解释了为什么类声明不会提前激活。

13.8.4 var:提升(部分提前激活)

var是声明变量的一种较老的方式,它早于constlet(现在是首选)。考虑以下var声明。

var x = 123;

这个声明有两个部分:

  • 声明var xvar声明的变量的作用域是最近的周围函数,而不是最近的周围块,就像大多数其他声明一样。这样的变量在其作用域的开始就已经激活,并使用undefined初始化。

  • 赋值x = 123:赋值始终在原地执行。

以下代码演示了var的效果:

function f() {
 // Partial early activation:
 assert.equal(x, undefined);
 if (true) {
 var x = 123;
 // The assignment is executed in place:
 assert.equal(x, 123);
 }
 // Scope is function, not block:
 assert.equal(x, 123);
}

13.9 闭包

在我们探索闭包之前,我们需要了解绑定变量和自由变量。

13.9.1 绑定变量与自由变量

在每个作用域中,都有一组提到的变量。在这些变量中,我们区分:

  • 绑定变量是在作用域内声明的。它们是参数和局部变量。

  • 自由变量是在外部声明的。它们也被称为非局部变量

考虑以下代码:

function func(x) {
  const y = 123;
  console.log(z);
}

func()的主体中,xy是绑定变量。z是自由变量。

13.9.2 什么是闭包?

那么闭包是什么呢?一个闭包是一个函数加上对其“出生地”存在的变量的连接。

保持这种连接有什么意义?它为函数的自由变量提供值——例如:

function funcFactory(value) {
  return () => {
    return value;
  };
}

const func = funcFactory('abc');
assert.equal(func(), 'abc'); // (A)

funcFactory返回一个闭包,并将其分配给func。因为func与其出生地处的变量有连接,所以当它在行 A 中被调用时,它仍然可以访问自由变量value(即使它“逃离”了其作用域”)。

图标“提示”所有 JavaScript 函数都是闭包

JavaScript 通过闭包支持静态作用域。因此,每个函数都是一个闭包。

13.9.3 示例:增量器工厂

以下函数返回增量器(这是我刚刚想出的一个名字)。增量器是一个内部存储数字的函数。当它被调用时,它会通过添加参数来更新那个数字,并返回新的值。

function createInc(startValue) {
  return (step) => { // (A)
    startValue += step;
    return startValue;
  };
}
const inc = createInc(5);
assert.equal(inc(2), 7);

我们可以看到,在行 A 中创建的函数在自由变量startValue中保留了其内部编号。这次,我们不仅从出生作用域中读取,还用它来存储我们更改的数据,这些数据在函数调用之间持续存在。

我们可以通过局部变量在出生作用域中创建更多的存储槽位:

function createInc(startValue) {
  let index = -1;
  return (step) => {
    startValue += step;
    index++;
    return [index, startValue];
  };
}
const inc = createInc(5);
assert.deepEqual(inc(2), [0, 7]);
assert.deepEqual(inc(2), [1, 9]);
assert.deepEqual(inc(2), [2, 11]);

13.9.4 使用闭包的场景

闭包有什么好处?

  • 首先,它们只是静态作用域的一种实现。因此,它们为回调提供了上下文数据。

  • 它们还可以被函数用来存储在函数调用之间持续存在的状态。createInc()就是这样一个例子。

  • 它们还可以为对象(通过字面量或类产生)提供私有数据。这一机制的具体工作原理在探索 ES6中有详细解释。

14 值

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

  1. 14.1 什么是类型?

  2. 14.2 JavaScript 的类型层次

  3. 14.3 语言规范中的类型

  4. 14.4 原始值与对象

  5. 14.5 原始值(简称原始类型)

    1. 14.5.1 原始类型是不可变的

    2. 14.5.2 原始类型是按值传递

    3. 14.5.3 原始类型是按值比较

  6. 14.6 对象

    1. 14.6.1 对象默认是可变的

    2. 14.6.2 对象是按身份传递

    3. 14.6.3 对象是按身份比较

    4. 14.6.4 按引用传递与按身份传递(高级)

    5. 14.6.5 ECMAScript 规范中的身份(高级)

  7. 14.7 运算符 typeofinstanceof:值的类型是什么?

    1. 14.7.1 typeof运算符

    2. 14.7.2 instanceof运算符

  8. 14.8 类和构造函数

    1. 14.8.1 与原始类型关联的构造函数
  9. 14.9 在类型之间进行转换

    1. 14.9.1 类型之间的显式转换

    2. 14.9.2 类型转换(类型之间的自动转换)

在本章中,我们将探讨 JavaScript 有哪些类型的值。

图标“阅读”支持工具:===

在本章中,我们偶尔会使用严格相等运算符。a === b 如果 ab 相等,则计算结果为 true。这究竟意味着什么将在“严格相等(===!==)”(§15.5.1)中解释。

14.1 什么是类型?

对于本章,我认为类型是值集的集合——例如,类型 boolean 是集合 { false, true }

14.2 JavaScript 的类型层次

图片

图 14.1:JavaScript 类型的一个部分层次结构。缺失的是错误类、与原始类型关联的类以及更多。

图 14.1 展示了 JavaScript 的类型层次结构:

  • JavaScript 区分两种类型的值:原始值和对象。我们很快就会看到它们之间的区别。

  • 图表暗示了一个重要的事实:某些对象不是 Object 类的实例(更多信息)。然而,这样的对象很少见。我们几乎会遇到的所有对象确实是 Object 的实例。

14.3 语言规范中的类型

ECMAScript 规范只知道总共八种类型。这些类型的名称是(我使用 TypeScript 的名称,而不是规范中的名称):

  • undefined 类型,其唯一元素为 undefined

  • null 类型,其唯一元素为 null

  • boolean 类型,其元素为 falsetrue

  • number 类型,所有数字的类型(例如,-1233.141

  • bigint 类型,所有大整数的类型(例如,-123n

  • string 类型,所有字符串的类型(例如,'abc'

  • symbol 类型,所有符号的类型(例如,Symbol('My Symbol')

  • object 类型,所有对象的类型(不同于 Object 类型,它是所有 Object 类及其子类的实例的类型)

14.4 原始值与对象

规范在值之间做出了重要的区分:

  • 原始值 是类型 undefinednullbooleannumberbigintstringsymbol 的元素。

  • 所有其他值都是 对象

与 Java(在这里启发了 JavaScript)相比,原始值不是二等公民。它们与对象之间的区别更为微妙。简而言之:

  • 原始值:是 JavaScript 中数据的原子构建块。

    • 它们是通过值传递的:当原始值被赋给变量或传递给函数时,它们的内容会被复制。

    • 它们是通过值来比较的:当比较两个原始值时,比较的是它们的值内容。

  • 对象:是复合数据块。

    • 它们是通过标识传递的(新术语):当对象被赋给变量或传递给函数时,它们的标识(想想指针)会被复制。

    • 它们是通过标识来比较的(新术语):当比较两个对象时,比较的是它们的标识。

除了这些,原始值和对象相当相似:它们都有 属性(键值对)并且可以在相同的位置使用。

接下来,我们将更深入地探讨原始值和对象。

14.5 原始值(简称:原始值)

14.5.1 原始值是不可变的

我们不能改变、添加或删除原始值的属性:

const str = 'abc';
assert.equal(str.length, 3);
assert.throws(
  () => { str.length = 1 },
  /^TypeError: Cannot assign to read only property 'length'/
);

14.5.2 原始值是通过值传递的

原始值是通过值传递的:变量(包括参数)存储原始值的值。当将原始值赋给变量或将它作为函数的参数传递时,其内容会被复制。

const x = 123;
const y = x;
// `y` is the same as any other number 123
assert.equal(y, 123);

图标“详情” 观察值传递和标识传递之间的差异

由于原始值是不可变的并且按值比较(见下一小节),因此无法观察到按值传递和按标识符传递(如 JavaScript 中用于对象的)之间的区别。

14.5.3 原始值通过值比较

原始值通过值比较:当比较两个原始值时,我们比较它们的内部内容。

assert.equal(123 === 123, true);
assert.equal('abc' === 'abc', true);

要了解这种比较方式有何特别之处,请继续阅读并了解对象是如何比较的。

14.6 对象

对象在“对象”(§30)和下一章中进行了详细说明。在这里,我们主要关注它们与原始值的不同之处。

让我们先探索两种常见的创建对象的方法:

  • 对象字面量:

    const obj = {
      first: 'Jane',
      last: 'Doe',
    };
    
    

    对象字面量以花括号 {} 开头和结尾。它创建一个具有两个属性的对象。第一个属性具有键 'first'(一个字符串)和值 'Jane'。第二个属性具有键 'last' 和值 'Doe'。有关对象字面量的更多信息,请参阅“对象字面量:属性”(§30.3.1)。

  • 数组字面量:

    const fruits = ['strawberry', 'apple'];
    
    

    数组字面量以方括号 [] 开头和结尾。它创建一个包含两个 元素 的数组:'strawberry''apple'。有关数组字面量的更多信息,请参阅“创建、读取、写入数组”(§34.3.1)。

14.6.1 对象默认是可变的

默认情况下,我们可以自由地更改、添加和删除对象的属性:

const obj = {};

obj.count = 2; // add a property
assert.equal(obj.count, 2);

obj.count = 3; // change a property
assert.equal(obj.count, 3);

14.6.2 对象通过标识符传递

对象通过标识符传递(新术语):变量(包括参数)存储对象的 标识符。对象的标识符是对对象实际数据在 (JavaScript 进程的共享主内存)上的 透明引用(想想指针)。当将对象赋给变量或将它作为函数的参数传递时,其标识符被复制。

每个对象字面量在堆上创建一个新的对象并返回其标识符:

const a = {}; // fresh empty object
// Pass the identity in `a` to `b`:
const b = a;

// Now `a` and `b` point to the same object
// (they “share” that object):
assert.equal(a === b, true);

// Changing `a` also changes `b`:
a.name = 'Tessa';
assert.equal(b.name, 'Tessa');

JavaScript 使用 垃圾回收 来自动管理内存:

let obj = { prop: 'value' };
obj = {};

现在,obj 的旧值 { prop: 'value' }垃圾(不再使用)。JavaScript 将自动 垃圾回收 它(在某个时间点从内存中删除),(可能永远不会,如果内存足够的话)。

14.6.3 对象通过标识符比较

对象通过标识符比较(新术语):只有当两个变量包含相同的对象标识符时,它们才相等。如果它们引用的是具有相同内容的不同对象,则它们不相等。

const obj = {}; // fresh empty object
assert.equal(obj === obj, true); // same identity
assert.equal({} === {}, false); // different identities, same content

14.6.4 按引用传递与按标识符传递(高级)

如果一个参数是 按引用传递,它指向一个变量,对参数的赋值会改变该变量 - 例如,在以下 C++ 代码中,参数 xy 是按引用传递的。行 A 中的调用会影响调用者的变量 ab

void swap_ints(int &x, int &y) {
  int temp = x;
  x = y;
  y = temp;
}
int main() {
  int a = 1;
  int b = 2;

  swap_ints(a, b); // (A)
  // Now `a` is 2 and `b` is 1

  return 0;
}

如果一个参数是 按身份传递(这是一个新术语),则对象的身份(一个透明引用)是按值传递的。对参数的赋值只有局部影响。这种方法也称为 按共享传递

认可: 术语 按身份传递 是由 Allen Wirfs-Brock 在 2019 年 建议 的。

14.6.5 ECMAScript 规范中的身份(高级)

ECMAScript 规范如下使用术语 身份 (来源):

  • 没有身份的值 如果它们的所有固有特征都相同,则等于其他没有身份的值 - 例如整数的幅度或序列的长度。

    • 没有身份的值可以通过完全描述其特征来在没有先前引用的情况下显现。
  • 相反,每个 具有身份的值 都是唯一的,因此仅等于自身。具有身份的值类似于没有身份的值,但有一个额外的不可猜测的、不可更改的、普遍唯一的特征,称为身份。

    • 对现有具有身份的值的引用不能仅仅通过描述它们来显现,因为身份本身是不可描述的;相反,这些值的引用必须明确地从一处传递到另一处。

    • 一些具有身份的值是可变的,因此它们的特点(除了它们的身份)可以在原地更改,导致所有持有该值的对象观察到新的特点。

在语言级别上:

  • 具有身份的值:通过 Symbol() 创建的对象和符号

  • 没有身份的值:原始值和通过 Symbol.for() 创建的符号

14.7 运算符 typeofinstanceof:值的类型是什么?

两个运算符 typeofinstanceof 允许我们确定给定值 x 的类型:

if (typeof x === 'string') ···
if (x instanceof Array) ···

它们有何不同?

  • typeof 区分了规范中的 7 种类型(减去一个省略,加上一个增加)。

  • instanceof 测试哪个类创建了给定的值。

图标“提示”经验法则:typeof 用于原始值;instanceof 用于对象

14.7.1 typeof 操作符

x typeof x
undefined 'undefined'
null 'object'
布尔值 'boolean'
数字 'number'
大整数 'bigint'
字符串 'string'
符号 'symbol'
函数 'function'
所有其他对象 'object'

表 14.1:typeof 操作符的结果。

表 14.1 列出了 typeof 的所有结果。它们大致对应于语言规范的 7 种类型。遗憾的是,有两个差异,它们是语言的怪癖:

  • typeof null 返回 'object' 而不是 'null'。这是一个错误。不幸的是,它无法修复。TC39 尝试这样做,但它破坏了网络上太多的代码。

  • 函数的 typeof 应该是 'object'(函数是对象)。引入一个单独的函数类别会让人困惑。

这些是使用 typeof 的几个示例:

> typeof undefined
'undefined'
> typeof 123n
'bigint'
> typeof 'abc'
'string'
> typeof {}
'object'

图标“练习”练习:关于 typeof 的两个练习

  • exercises/values/typeof_exrc.mjs

  • 奖励:exercises/values/is_object_test.mjs

14.7.2 instanceof 操作符

这个操作符回答的问题是:类 C 是否创建了一个值 x

x instanceof C

例如:

> (function() {}) instanceof Function
true
> ({}) instanceof Object
true
> [] instanceof Array
true 

原始值不是任何事物的实例:

> 123 instanceof Number
false
> '' instanceof String
false
> '' instanceof Object
false

关于此操作符的更多信息,请参阅“instanceof 操作符的详细说明(高级)”(§31.7.3)。

图标“练习”练习:instanceof

exercises/values/instanceof_exrc.mjs

14.8 类和构造函数

JavaScript 的原始对象工厂是 构造函数:普通函数,如果我们通过 new 操作符调用它们,它们会返回“自身”的“实例”。

ES6 引入了 ,这主要是构造函数的更好语法。

在这本书中,我使用术语 构造函数 互换。

类可以将规范中的单个类型 object 划分为子类型——它们为我们提供了比规范中有限的 7 个类型更多的类型。每个类都是它所创建的对象的类型。

14.8.1 与原始类型相关的构造函数

每个原始类型(除了 undefinednull 类型)都有一个相关的 构造函数(想想类):

  • 构造函数 Boolean 与布尔值相关联。

  • 构造函数 Number 与数字相关联。

  • 构造函数 String 与字符串相关联。

  • 构造函数 Symbol 与符号相关联。

这些函数都扮演着几个角色——例如,Number

  • 我们可以用它作为函数并将值转换为数字:

    assert.equal(Number('123'), 123);
    
    
  • Number.prototype 提供了数字的属性——例如,方法 .toString()

    assert.equal((123).toString, Number.prototype.toString);
    
    
  • Number 是数字工具函数的命名空间/容器对象——例如:

    assert.equal(Number.isInteger(123), true);
    
    
  • 最后,我们还可以使用 Number 作为类来创建数字对象。这些对象与实数不同,应该避免使用。它们几乎从未出现在正常代码中。更多信息请参见下一小节。

14.8.1.1 原始值的包装类(高级)

如果我们使用与原始类型关联的构造函数进行 new 调用,它返回一个所谓的 包装对象。这是将原始值转换为对象的标准方式——通过“包装”它。

原始值不是包装类的实例:

const prim = true;
assert.equal(typeof prim, 'boolean');
assert.equal(prim instanceof Boolean, false);

包装对象不是一个原始值:

const wrapper = Object(prim);
assert.equal(typeof wrapper, 'object'); // not 'boolean'
assert.equal(wrapper instanceof Boolean, true);

我们可以解包包装对象以获取原始值:

assert.equal(wrapper.valueOf(), prim); // unwrap

14.9 类型之间的转换

在 JavaScript 中,有两种方式可以将值转换为其他类型:

  • 显式转换:通过如 String() 这样的函数。

  • 强制转换(自动转换):当操作接收它无法处理的操作数/参数时发生。

14.9.1 显式类型转换

与原始类型关联的函数会显式地将值转换为该类型:

> Boolean(0)
false
> Number('123')
123
> String(123)
'123'

我们还可以使用 Object() 将值转换为对象:

> typeof Object(123)
'object'

下表更详细地描述了这种转换的工作方式:

x Object(x)
undefined {}
null {}
布尔值 new Boolean(x)
数字 new Number(x)
大整数 BigInt 的实例 (new 抛出 TypeError)
字符串 new String(x)
符号 Symbol 的实例 (new 抛出 TypeError)
对象 x

14.9.2 强制转换(类型之间的自动转换)

对于许多操作,如果操作数/参数的类型不匹配,JavaScript 会自动转换它们。这种自动转换称为 强制转换

例如,乘法运算符会将其操作数强制转换为数字:

> '7' * '3'
21

许多内置函数也会进行强制转换。例如,Number.parseInt() 在解析之前将其参数强制转换为字符串。这也解释了以下结果:

> Number.parseInt(123.45)
123

在解析之前,数字 123.45 被转换为字符串 '123.45'。解析在第一个非数字字符之前停止,这就是为什么结果是 123

“练习”图标练习:将值转换为原始类型

exercises/values/conversion_exrc.mjs

15 运算符

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

  1. 15.1 理解运算符

    1. 15.1.1 运算符将它们的操作数转换为适当的类型

    2. 15.1.2 大多数运算符仅与原始值一起工作

  2. 15.2 将值转换为原始值(高级)

  3. 15.3 加号运算符(+

  4. 15.4 赋值运算符

    1. 15.4.1 普通赋值运算符

    2. 15.4.2 复合赋值运算符

  5. 15.5 相等:=====Object.is()

    1. 15.5.1 严格相等(===!==

    2. 15.5.2 宽松相等(==!=

    3. 15.5.3 建议:始终使用严格相等

    4. 15.5.4 比 === 更严格:Object.is()(高级)

  6. 15.6 排序运算符

  7. 15.7 其他各种运算符

    1. 15.7.1 逗号运算符

    2. 15.7.2 void 运算符

15.1 理解运算符

JavaScript 的运算符有时会产生不直观的结果。以下两个规则使它们更容易理解:

  • 运算符会将它们的操作数强制转换为适当的类型。

  • 大多数运算符仅与原始值一起工作。

15.1.1 运算符将它们的操作数转换为适当的类型

如果运算符接收到不正确的类型的操作数,它很少抛出异常。相反,它会强制转换(自动转换)操作数,以便可以与它们一起工作。让我们看看两个例子。

首先,乘法运算符只能与数字一起工作。因此,在计算结果之前,它会将字符串转换为数字。

> '7' * '3'
21

其次,用于访问对象属性的方括号运算符([ ])只能处理字符串和符号。所有其他值都会被强制转换为字符串:

const obj = {};
obj['true'] = 123;

// Coerce true to the string 'true'
assert.equal(obj[true], 123);

15.1.2 大多数运算符仅与原始值一起工作

如前所述,大多数运算符仅与原始值一起工作。如果一个操作数是对象,它通常会被强制转换为原始值——例如:

> [1,2,3] + [4,5,6]
'1,2,34,5,6'

为什么?加号运算符首先将它的操作数强制转换为原始值:

> String([1,2,3])
'1,2,3'
> String([4,5,6])
'4,5,6'

然后,它连接这两个字符串:

> '1,2,3' + '4,5,6'
'1,2,34,5,6'

15.2 将值转换为原始值(高级)

以下 JavaScript 代码解释了任意值是如何转换为原始值的:

import assert from 'node:assert/strict';

/**
 * @param {any} input 
 * @param {'STRING'|'NUMBER'} [preferredType] optional
 * @returns {primitive}
 * @see https://tc39.es/ecma262/#sec-toprimitive
 */
function ToPrimitive(input, preferredType) {
  if (isObject(input)) {
    // `input` is an object
    const exoticToPrim = input[Symbol.toPrimitive]; // (A)
    if (exoticToPrim !== undefined) {
      let hint;
      if (preferredType === undefined) {
        hint = 'default';
      } else if (preferredType === 'STRING') {
        hint = 'string';
      } else {
        assert(preferredType === 'NUMBER');
        hint = 'number';
      }
      const result = exoticToPrim.apply(input, [hint]);
      if (!isObject(result)) return result;
      throw new TypeError();
    }
    if (preferredType === undefined) {
      preferredType = 'NUMBER';
    }
    return OrdinaryToPrimitive(input, preferredType);
  }
  // `input` is primitive
  return input;
}

/**
 * @param {object} O 
 * @param {'STRING'|'NUMBER'} hint 
 * @returns {primitive}
 */
function OrdinaryToPrimitive(O, hint) {
  let methodNames;
  if (hint === 'STRING') {
    methodNames = ['toString', 'valueOf'];
  } else {
    methodNames = ['valueOf', 'toString'];
  }
  for (const name of methodNames) {
    const method = O[name];
    if (isCallable(method)) {
      const result = method.apply(O);
      if (!isObject(result)) return result;
    }
  }
  throw new TypeError();
}

function isObject(value) {
  return typeof value === 'object' && value !== null;
}

function isCallable(value) {
  return typeof value === 'function';
}

只有以下对象定义了具有键 Symbol.toPrimitive 的方法:

  • Symbol.prototype[Symbol.toPrimitive]

  • Date.prototype[Symbol.toPrimitive]

因此,让我们关注 OrdinaryToPrimitive():如果我们更喜欢字符串,则首先调用 .toString()。如果我们更喜欢数字,则首先调用 .valueOf()。我们可以在以下代码中看到这一点。

const obj = {
  toString() {
 return '1';
 },
 valueOf() {
 return 2;
 },
};
assert.equal(
 String(obj), '1'
);
assert.equal(
 Number(obj), 2
); 

15.3 加号运算符 (+)

加号运算符在 JavaScript 中的工作方式如下:

  • 首先,它将两个操作数都转换为原始值(默认情况下,转换为原始值优先于数字)。然后它切换到两种模式之一:

    • 字符串模式:如果两个原始值中有一个是字符串,则将另一个转换为字符串,将两个字符串连接起来,并返回结果。

    • 数字模式:否则,它将两个操作数都转换为数字,将它们相加,并返回结果。

字符串模式允许我们使用 + 来拼接字符串:

> 'There are ' + 3 + ' items'
'There are 3 items'

数字模式意味着如果两个操作数都不是字符串(或变成字符串的对象)则将所有内容强制转换为数字:

> 4 + true
5

Number(true)1

15.4 赋值运算符

15.4.1 简单赋值运算符

简单赋值运算符用于更改存储位置:

x = value; // assign to a previously declared variable
obj.propKey = value; // assign to a property
arr[index] = value; // assign to an Array element

变量声明中的初始化器也可以视为一种赋值形式:

const x = value;
let y = value;

15.4.2 复合赋值运算符

JavaScript 支持以下赋值运算符:

  • 算术赋值运算符:+= -= *= /= %= (ES1)

    • += 也可以用于字符串连接

    • 后续引入:**= (ES2016)

  • 位运算赋值运算符:&= ^= |= (ES1)

  • 位运算移位赋值运算符:<<= >>= >>>= (ES1)

  • 逻辑赋值运算符:||= &&= ??= (ES2021)

15.4.2.1 逻辑赋值运算符 (ES2021)

逻辑赋值运算符与其他复合赋值运算符的工作方式不同:

赋值运算符 等价于 只有当 a
a ??= b a ?? (a = b) Falsy
a &&= b a && (a = b) Truthy
a ??= b a ?? (a = b) Nullish

为什么 a ||= b 等价于以下表达式?

a || (a = b)

为什么不使用这个表达式?

a = a || b

前一个表达式的好处是 短路:只有当 a 评估为 false 时才进行赋值。因此,只有当必要时才执行赋值。相比之下,后一个表达式总是执行赋值。

更多关于 ??= 的信息,请参阅 [“nullish 合并赋值运算符 (??=) (§16.4.4)” (ch_undefined-null.html#nullish-coalescing-assignment-operator)]。

15.4.2.2 剩余的复合赋值运算符

对于 op 除了 || && ?? 的运算符,以下两种赋值方式是等价的:

myvar op= value
myvar = myvar op value

例如,如果 op+,则我们得到以下工作方式的运算符 +=

let str = '';
str += '<b>';
str += 'Hello!';
str += '</b>';

assert.equal(str, '<b>Hello!</b>');

15.5 相等:== vs. === vs. Object.is()

JavaScript 有两种类型的相等运算符:

  • (==) 松散相等(“双等号”)

  • ===)严格相等(“三等号”)

图标“提示”建议:始终使用严格相等(===

松散相等有很多怪癖,难以理解。我的建议是始终使用严格相等。我将解释松散相等是如何工作的,但这不是值得记住的事情。

15.5.1 严格相等(===!==

只有当两个值具有相同的类型时,它们才严格相等。严格相等从不进行转换。

原始值(包括字符串,但不包括符号)通过值进行比较:

> undefined === null
false
> null === null
true

> true === false
false
> true === true
true

> 1 === 2
false
> 3 === 3
true

> 'a' === 'b'
false
> 'c' === 'c'
true

所有其他值都必须具有相同的标识符:

> {} === {} // two different empty objects
false

> const obj = {};
> obj === obj
true

符号的比较方式类似于对象:

> Symbol() === Symbol() // two different symbols
false
> const sym = Symbol();
> sym === sym
true

number 错误值 NaN 著名地不严格等于自身(因为,在内部,它不是一个单一值):

> typeof NaN
'number'
> NaN === NaN
false

15.5.2 松散相等(==!=

松散相等是 JavaScript 的怪癖之一。让我们探索其行为。

15.5.2.1 如果两个操作数具有相同的类型

如果两个操作数具有相同的原始类型,松散相等的行为类似于严格相等:

> 1 == 2
false
> 3 == 3
true
> 'a' == 'b'
false
> 'c' == 'c'
true

如果两个操作数都是对象,则适用相同的规则:松散相等的行为类似于严格相等,并且它们只有在具有相同的标识符时才相等。

> [1, 2, 3] == [1, 2, 3] // two different objects
false

> const arr = [1, 2, 3];
> arr == arr
true

符号的比较方式类似。

15.5.2.2 转换

如果操作数具有不同的类型,松散相等通常会进行转换。其中一些类型转换是有意义的:

> '123' == 123
true
> false == 0
true

其他情况较少:

> 0 == '\r\n\t ' // only whitespace
true

如果另一个操作数是原始值,则对象会被转换为原始值(仅限):

> [1, 2, 3] == '1,2,3'
true
> ['17'] == 17
true

15.5.2.3 ==Boolean()

与布尔值的比较与通过 Boolean() 转换为布尔值不同:

> Boolean(0)
false
> Boolean(2)
true

> 0 == false
true
> 2 == true
false
> 2 == false
false

> Boolean('')
false
> Boolean('abc')
true

> '' == false
true
> 'abc' == true
false
> 'abc' == false
false

15.5.2.4 undefined == null

==undefinednull 视为相等:

> undefined == null
true

15.5.2.5 松散相等是如何工作的?(高级)

在 ECMAScript 规范中,松散相等是通过以下操作定义的:以下操作

IsLooselyEqual(x: any, y: any): boolean

  • 如果两个操作数具有相同的类型,则返回 IsStrictlyEqual(x, y) 的结果(此处未解释)。

  • 如果一个操作数是 null 而另一个是 undefined,则返回 true

  • 如果一个操作数是数字而另一个是字符串,则将字符串转换为数字,并返回应用 IsLooselyEqual() 的结果。

  • 如果一个操作数是 bigint 而另一个是字符串,则将字符串转换为 bigint 并返回应用 IsLooselyEqual() 的结果。

  • 如果一个操作数是布尔值,则将其转换为数字,并返回应用 IsLooselyEqual() 的结果。

  • 如果一个操作数是对象而另一个是字符串、数字、bigint 或符号,则通过 ToPrimitive() 将对象转换为原始值,并返回应用 IsLooselyEqual() 的结果。

  • 如果一个操作数是 bigint 而另一个操作数是数字:

    • 如果任一操作数不是有限的,则返回 false

    • 如果两个操作数表示相同的数学值,则返回 true;否则返回 false

  • 返回 false

如您所见,这个算法并不完全直观。因此有以下建议。

15.5.3 建议:始终使用严格相等

我建议始终使用 ===。这使得我们的代码更容易理解,并使我们免于思考 == 的怪癖。

让我们看看 == 的两个用例以及我建议的替代方案。

15.5.3.1 == 的用例:与数字或字符串比较

== 允许我们检查值 x 是否为数字或该数字的字符串表示——只需一次比较:

if (x == 123) {
  // x is either 123 or '123'
}

我更喜欢以下两种替代方案之一:

if (x === 123 || x === '123') ···
if (Number(x) === 123) ···

我们也可以在我们第一次遇到 x 时将其转换为数字。

15.5.3.2 == 的用例:与 undefinednull 比较

== 的另一个用例是检查值 x 是否为 undefinednull

if (x == null) {
  // x is either null or undefined
}

这段代码的问题是我们无法确定某人是否有意这样编写,还是他们打字错误,本意是 === null

我更喜欢这个替代方案:

if (x === undefined || x === null) ···

15.5.4 比严格等于 === 更严格:Object.is()(高级)

方法 Object.is() 比较两个值:

> Object.is(3, 3)
true
> Object.is(3, 4)
false
> Object.is(3, '3')
false

Object.is() 比严格等于 === 更严格——例如:

  • 它将 涉及数字的计算的错误值 NaN 视为等于自身:

    > Object.is(NaN, NaN)
    true
    > NaN === NaN
    false
    
    
  • 它区分正零和负零(这两个值通常被认为是相同的,因此此功能并不那么有用):

    > Object.is(0, -0)
    false
    > 0 === -0
    true
    
    
15.5.4.1 通过 Object.is() 检测 NaN

Object.is()NaN 视为等于自身,偶尔很有用。例如,我们可以用它来实现数组方法 .indexOf() 的改进版本:

const myIndexOf = (arr, elem) => {
  return arr.findIndex(x => Object.is(x, elem));
};

myIndexOf() 在数组中查找 NaN,而 .indexOf() 则不:

> myIndexOf([0,NaN,2], NaN)
1
> [0,NaN,2].indexOf(NaN)
-1

结果 -1 表示 .indexOf() 在数组中找不到其参数。

15.6 排序运算符

运算符 名称
< 小于
<= 小于等于
> 大于
>= 大于等于

表 15.1:JavaScript 的排序运算符。

JavaScript 的排序运算符(表 15.1)适用于数字和字符串:

> 5 >= 2
true
> 'bar' < 'foo'
true

<=>= 基于严格相等。

图标“警告” 排序运算符在处理人类语言时效果不佳

排序运算符在比较人类语言文本时效果不佳,例如,当涉及大小写或重音符号时。详情请见“比较字符串”(§22.6)。

15.7 其他各种运算符

以下运算符在其他章节中有所介绍:

  • 用于布尔值、数字、字符串、对象的运算符

  • 用于默认值的空值合并运算符(??

下两个小节讨论了两个很少使用的运算符。

15.7.1 逗号运算符

逗号运算符有两个操作数,评估它们并返回第二个:

const result = (console.log('evaluated'), 'YES');
assert.equal(
  result, 'YES'
);

输出:

evaluated

关于此运算符的更多信息,请参阅Speaking JavaScript

15.7.2 void运算符

void运算符评估其操作数并返回undefined

const result = void console.log('evaluated');
assert.equal(
  result, undefined
);

输出:

evaluated

关于此运算符的更多信息,请参阅Speaking JavaScript

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