探索-JavaScript-ES2025-版--六-

探索 JavaScript(ES2025 版)(六)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

28 动态评估代码:eval(),new Function()(高级)

原文:exploringjs.com/js/book/ch_dynamic-code-evaluation.html

  1. 28.1 eval()

  2. 28.2 new Function()

  3. 28.3 推荐事项

在本章中,我们将探讨两种动态评估代码的方式:eval()new Function()

28.1 eval()

给定一个包含 JavaScript 代码的字符串 streval(str) 评估该代码并返回结果:

> eval('2 ** 4')
16

调用 eval() 有两种方式:

  • 直接地,通过函数调用。然后,其参数中的代码在当前作用域内被评估。

  • 间接地,不是通过函数调用。然后它在全局作用域中评估其代码。

“不是通过函数调用”意味着“任何看起来与 eval(···) 不同的东西”:

  • eval.call(undefined, '···')(使用函数的 .call() 方法)

  • eval?.('···')(使用可选链)

  • (0, eval)('···')(使用逗号操作符)

  • globalThis.eval('···')

  • const e = eval; e('···')

  • 等等。

以下代码说明了这种差异:

globalThis.myVariable = 'global';
function func() {
 const myVariable = 'local';

 // Direct eval
 assert.equal(eval('myVariable'), 'local');

 // Indirect eval
 assert.equal(eval.call(undefined, 'myVariable'), 'global');
}

在全局上下文中评估代码更安全,因为代码可以访问的内部信息更少。

28.2 new Function()

new Function() 创建一个函数对象,并如下调用:

const func = new Function('«param_1»', ···, '«param_n»', '«func_body»');

前一个语句等同于下一个语句。注意,«param_1」等,不再位于字符串字面量内部。

const func = function («param_1», ···, «param_n») {
  «func_body»
};

在下一个示例中,我们两次创建相同的函数,首先是通过 new Function(),然后是通过函数表达式:

const times1 = new Function('a', 'b', 'return a * b');
const times2 = function (a, b) { return a * b };

图标“警告”new Function() 创建非严格模式函数

默认情况下,通过 new Function() 创建的函数是宽松的。如果我们想使函数体处于严格模式,我们必须手动开启它。

28.3 推荐事项

尽可能避免代码的动态评估:

  • 这是一个安全风险,因为它可能允许攻击者以您的代码的权限执行任意代码。

  • 它可能被关闭——例如,在浏览器中,通过内容安全策略

很频繁地,JavaScript 足够动态,以至于您不需要 eval() 或类似的东西。在以下示例中,我们使用 eval()(行 A)所做的事情,也可以在不使用它的情况下(行 B)实现。

const obj = {a: 1, b: 2};
const propKey = 'b';

assert.equal(eval('obj.' + propKey), 2); // (A)
assert.equal(obj[propKey], 2); // (B)

如果您必须动态评估代码:

  • 相比 eval(),更倾向于使用 new Function():它始终在全局上下文中执行其代码,并且函数提供了一个干净的接口来访问评估后的代码。

  • 相比直接 eval(),更倾向于间接 eval:在全局上下文中评估代码更安全。

VI 模块化

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

29 模块 ES6

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

  1. 29.1 模块速查表

    1. 29.1.1 命名导出、命名导入、命名空间导入

    2. 29.1.2 通过 import() 动态导入(ES2020)

    3. 29.1.3 默认导出和导入

    4. 29.1.4 模块指定符的类型

  2. 29.2 JavaScript 的源代码单元:脚本和模块

    1. 29.2.1 在内置模块之前编写的代码是 ECMAScript 5
  3. 29.3 在我们有模块之前,我们有脚本

  4. 29.4 在 ES6 之前创建的模块系统

    1. 29.4.1 服务器端:CommonJS 模块

    2. 29.4.2 客户端:AMD(异步模块定义)模块

    3. 29.4.3 JavaScript 模块的特点

  5. 29.5 ECMAScript 模块

    1. 29.5.1 ES 模块:语法、语义、加载器 API
  6. 29.6 命名导出和导入

    1. 29.6.1 命名导出

    2. 29.6.2 命名导入

    3. 29.6.3 命名空间导入

    4. 29.6.4 命名导出风格:内联与子句(高级)

  7. 29.7 默认导出和默认导入

    1. 29.7.1 两种默认导出风格

    2. 29.7.2 默认导出作为命名导出(高级)

    3. 29.7.3 建议:命名导出与默认导出

  8. 29.8 重新导出

  9. 29.9 导出和导入的更多细节

    1. 29.9.1 导入是导出的只读视图

    2. 29.9.2 ESM 对循环导入的透明支持(高级)

  10. 29.10 包:JavaScript 的软件分发单元

    1. 29.10.1 发布包:包注册表、包管理器、包名称

    2. 29.10.2 包的文件系统布局

    3. 29.10.3 package.json

    4. 29.10.4 包导出:控制其他包可见的内容

    5. 29.10.5 包导入

  11. 29.11 模块命名

  12. 29.12 模块指定符

    1. 29.12.1 模块指定符的类型

    2. 29.12.2 模块指定符中的文件扩展名

    3. 29.12.3 Node.js 中的模块指定符

    4. 29.12.4 浏览器中的模块指定符

  13. 29.13 import.meta – 当前模块的元数据 (ES2020)

    1. 29.13.1‌ import.meta.url

    2. 29.13.2 import.meta.urlURL

    3. 29.13.3‌ Node.js 上的 import.meta.url

  14. 29.14‌ 通过 import() 动态加载模块 (ES2020) (高级)

    1. 29.14.1 静态 import 语句的限制

    2. 29.14.2‌ 通过 import() 操作符动态导入

    3. 29.14.3 import() 的用例

  15. 29.15 模块中的顶层 await (ES2022) (高级)

    1. 29.15.1 顶层 await 的用例

    2. 29.15.2 顶层 await 在底层是如何工作的?

    3. 29.15.3 顶层 await 的优缺点

  16. 29.16 导入属性:导入非 JavaScript 艺术品 (ES2025)

    1. 29.16.1‌ 导入非 JavaScript 艺术品的历史

    2. 29.16.2 导入非 JavaScript 艺术品的用例

    3. 29.16.3 导入属性

    4. 29.16.4 导入属性的语法

    5. 29.16.5 JSON 模块 (ES2025)

  17. 29.17‌ Polyfills:模拟原生 Web 平台功能 (高级)

    1. 29.17.1 本节的来源

29.1 速查表:模块

29.1.1 命名导出、命名导入、命名空间导入

如果我们在模块内的一个命名实体前加上 export,它就变成了该模块的 命名导出。所有其他实体对该模块都是私有的。

//===== lib.mjs =====
// Named exports
export const one = 1, two = 2;
export function myFunc() {
 return 3;
}

//===== main.mjs =====
// Named imports
import {one, myFunc as f} from './lib.mjs';
assert.equal(one, 1);
assert.equal(f(), 3);

// Namespace import
import * as lib from './lib.mjs';
assert.equal(lib.one, 1);
assert.equal(lib.myFunc(), 3);

from 后面的字符串被称为 模块指定符。它标识了我们想从哪个模块导入。

29.1.2 通过 import() 动态导入 (ES2020)

到目前为止,我们看到的所有导入都是 静态的,具有以下约束:

  • 它们必须出现在模块的最顶层。

  • 模块指定符是固定的。

通过 import() 的动态导入没有这些约束:

//===== lib.mjs =====
// Named exports
export const one = 1, two = 2;
export function myFunc() {
 return 3;
}

//===== main.mjs =====
function importLibrary(moduleSpecifier) {
  return import(moduleSpecifier)
  .then((lib) => {
    assert.equal(lib.one, 1);
    assert.equal(lib.myFunc(), 3);
  });
}
await importLibrary('./lib.mjs');

29.1.3‌ 默认导出和导入

当模块只包含单个实体时,最常使用 默认导出(即使它可以与命名导出结合使用):

//===== lib1.mjs =====
export default function getHello() {
 return 'hello';
}

至多只能有一个默认导出。这就是为什么 constlet 不能作为默认导出(行 A):

//===== lib2.mjs =====
export default 123; // (A) instead of `const`

这是导入默认导出的语法:

//===== main.mjs =====
import lib1 from './lib1.mjs';
assert.equal(lib1(), 'hello');

import lib2 from './lib2.mjs';
assert.equal(lib2, 123);

29.1.4 模块指定符的类型

模块指定符标识模块。它们有三种类型:

  • 绝对指定符 是完整的 URL – 例如:

    'https://www.unpkg.com/browse/yargs@17.3.1/browser.mjs'
    'file:///opt/nodejs/config.mjs'
    
    

    绝对指定符主要用于访问直接托管在网上的库。

  • 相对指定符 是相对 URL(以 '/''./''../' 开头)——例如:

    './sibling-module.js'
    '../module-in-parent-dir.mjs'
    '../../dir/other-module.js'
    
    

    每个模块都有一个 URL,其协议取决于其位置(file:, https: 等)。如果它使用相对指定符,JavaScript 会通过解析它来将指定符转换为完整的 URL。

    相对指定符主要用于访问同一代码库中的其他模块。

  • 裸指定符 是路径(没有协议和域名),不以斜杠或点开头。它们以包的名称开始。这些名称可以可选地后面跟有 子路径

    'some-package'
    'some-package/sync'
    'some-package/util/files/path-tools.js'
    
    

    裸指定符也可以引用具有作用域名称的包:

    '@some-scope/scoped-name'
    '@some-scope/scoped-name/async'
    '@some-scope/scoped-name/dir/some-module.mjs'
    
    

    每个裸指定符恰好指向一个包内的一个模块;如果没有子路径,它指向其包的指定“主”模块。

    裸指定符永远不会直接使用,但总是 解析 —— 转换为绝对指定符。解析的工作方式取决于平台。

29.2 JavaScript 的源代码单元:脚本和模块

在 JavaScript 的世界中,“源代码单元”是什么意思?

  • 一段 JavaScript 源代码(文本)

  • 通常一个单元存储在一个单独的文件中。

  • 我们还可以在一个单独的 HTML 文件中嵌入多个单元。

JavaScript 拥有丰富的源代码单元历史:ES6 带来了内置模块,但较旧的格式仍然存在。了解后者有助于理解前者,因此让我们来调查。接下来的几节将描述以下交付 JavaScript 源代码的方式:

  • 脚本 是浏览器在全局范围内运行的代码片段。它们是模块的先驱。

  • CommonJS 模块 是为服务器设计的模块格式(例如,通过 Node.js)。

  • AMD 模块 是为浏览器设计的模块格式。

  • ECMAScript 模块 是 JavaScript 的内置模块格式。它取代了所有之前的格式。

表 29.1 提供了这些源代码单元的概述。请注意,我们可以选择两种文件扩展名用于 CommonJS 模块和 ECMAScript 模块。选择哪种取决于我们如何使用文件。详细信息将在本章后面给出。

用途 运行在 加载 文件名扩展名
脚本 传统 浏览器 异步 .js
CommonJS 模块 下降 服务器 同步 .js .cjs
AMD 模块 传统 浏览器 异步 .js
ECMAScript 模块 现代 浏览器、服务器 异步 .js .mjs

表 29.1:交付 JavaScript 源代码的方式。

29.2.1 内置模块之前的代码是用 ECMAScript 5 编写的

在我们接触到内置模块(ES6 引入的)之前,我们将看到的所有代码都将使用 ES5 编写。其中之一是:

  • ES5 没有使用 constlet;只有 var

  • ES5 没有箭头函数;只有函数表达式。

29.3 在我们拥有模块之前,我们有脚本

最初,浏览器只有 脚本 – 在全局作用域中执行的代码片段。例如,考虑一个通过以下 HTML 加载脚本文件的 HTML 文件:

<script src="other-module1.js"></script>
<script src="other-module2.js"></script>
<script src="my-module.js"></script>

主文件是 my-module.js,在那里我们模拟一个模块:

var myModule = (function () { // Open IIFE
 // Imports (via global variables)
 var importedFunc1 = otherModule1.importedFunc1;
 var importedFunc2 = otherModule2.importedFunc2;

 // Body
 function internalFunc() {
 // ···
 }
 function exportedFunc() {
 importedFunc1();
 importedFunc2();
 internalFunc();
 }

 // Exports (assigned to global variable `myModule`)
 return {
 exportedFunc: exportedFunc,
 };
})(); // Close IIFE 

myModule 是一个全局变量,它被分配给立即调用函数表达式的结果。函数表达式从第一行开始。它在最后一行被调用。

这种封装代码片段的方式被称为“立即执行函数表达式”(IIFE,由 Ben Alman 提出)。我们从 IIFE 中获得了什么?var 不是块级作用域(像 constlet),它是函数级作用域:创建 var 声明变量新作用域的唯一方法是通过函数或方法(对于 constlet,我们可以使用函数、方法或块 {)。因此,示例中的 IIFE 隐藏了以下所有变量,从而最小化了名称冲突:importedFunc1importedFunc2internalFuncexportedFunc

注意,我们以一种特定的方式使用 IIFE:在最后,我们选择我们想要导出的内容,并通过对象字面量返回它。这被称为 揭示模块模式(由 Christian Heilmann 提出)。

这种模拟模块的方式有几个问题:

  • 脚本文件中的库通过全局变量导出和导入功能,这可能导致名称冲突。

  • 依赖关系没有明确声明,也没有内置的方法让脚本加载它所依赖的脚本。因此,网页不仅要加载页面需要的脚本,还要加载这些脚本的依赖项、依赖项的依赖项等,并且必须按正确的顺序加载!

29.4 在 ES6 之前创建的模块系统

在 ECMAScript 6 之前,JavaScript 没有内置的模块。因此,语言的灵活语法被用来在语言内部实现自定义模块系统。其中两个流行的是:

  • CommonJS(面向服务器端)

  • AMD (异步模块定义,面向客户端)

29.4.1 服务器端:CommonJS 模块

原始的 CommonJS 模块标准是为服务器和桌面平台创建的。它是原始 Node.js 模块系统的基础,在那里它获得了巨大的流行度。对这种流行度的贡献包括 Node 的 npm 包管理器和能够使用 Node 模块在客户端(browserify、webpack 等)上使用的工具。

从现在起,CommonJS 模块 指的是该标准的 Node.js 版本(它有一些额外的功能)。这是一个 CommonJS 模块的例子:

// Imports
var importedFunc1 = require('./other-module1.js').importedFunc1;
var importedFunc2 = require('./other-module2.js').importedFunc2;

// Body
function internalFunc() {
 // ···
}
function exportedFunc() {
 importedFunc1();
 importedFunc2();
 internalFunc();
}

// Exports
module.exports = {
 exportedFunc: exportedFunc,
}; 

CommonJS 可以描述如下:

  • 专为服务器设计。

  • 模块旨在 同步 加载(导入者等待导入的模块加载和执行)。

  • 紧凑的语法。

29.4.2 客户端:AMD(异步模块定义)模块

AMD 模块格式被创建出来是为了在浏览器中使用比 CommonJS 格式更容易。它最流行的实现是RequireJS。以下是一个 AMD 模块的示例。

define(['./other-module1.js', './other-module2.js'],
  function (otherModule1, otherModule2) {
    var importedFunc1 = otherModule1.importedFunc1;
    var importedFunc2 = otherModule2.importedFunc2;

    function internalFunc() {
 // ···
 }
 function exportedFunc() {
 importedFunc1();
 importedFunc2();
 internalFunc();
 }

 return {
 exportedFunc: exportedFunc,
 };
 }); 

AMD 可以这样描述:

  • 为浏览器设计。

  • 模块旨在异步加载。这对于浏览器来说是一个关键要求,因为代码不能等待模块下载完成。一旦模块可用,就必须通知它。

  • 语法稍微复杂一些。

AMD 模块的好处(以及为什么它们在浏览器中表现良好):它们可以直接执行。相比之下,CommonJS 模块必须在部署前编译,或者必须生成和动态评估自定义源代码(例如eval())。这在网上并不总是允许的。

29.4.3 JavaScript 模块的特性

观察 CommonJS 和 AMD,JavaScript 模块系统之间的相似性显现出来:

  • 每个文件有一个模块。

  • 这样的文件基本上是一段要执行的代码:

    • 局部作用域:代码在局部“模块作用域”中执行。因此,默认情况下,其中声明的所有变量、函数和类都是内部的,而不是全局的。

    • 导出:如果我们想导出任何声明的实体,我们必须明确将其标记为导出。

    • 导入:每个模块都可以从其他模块导入导出的实体。这些其他模块通过模块指定符(通常是路径,偶尔是完整 URL)来识别。

  • 模块是单例的:即使一个模块被导入多次,也只有一个“实例”存在。

  • 不使用全局变量。相反,模块指定符作为全局 ID。

29.5 ECMAScript 模块

ECMAScript 模块ES 模块ESM)随着 ES6 的引入而出现。它们继续了 JavaScript 模块的传统,并具有上述所有特性。此外:

  • 在 CommonJS 中,ES 模块共享紧凑的语法和对循环依赖的支持。

  • 在 AMD 中,ES 模块共享为异步加载而设计的特性。

ES 模块也有新的好处:

  • 语法比 CommonJS 的更紧凑。

  • 模块具有静态结构(在运行时无法更改)。这有助于静态检查、优化导入访问、删除死代码等。

  • 对循环导入的支持是完全透明的。

这是一个 ES 模块语法的示例:

import {importedFunc1} from './other-module1.mjs';
import {importedFunc2} from './other-module2.mjs';

function internalFunc() {
 ···
}

export function exportedFunc() {
 importedFunc1();
 importedFunc2();
 internalFunc();
} 

从现在开始,“模块”指的是“ECMAScript 模块”。

29.5.1 ES 模块:语法、语义、加载器 API

ES 模块的完整标准包括以下部分:

  1. 语法(代码的编写方式):什么是模块?如何声明导入和导出?等等。

  2. 语义(代码的执行方式):变量绑定是如何导出的?导入是如何与导出连接的?等等。

  3. 用于配置模块加载的程序化加载器 API。

第一部分和第二部分是在 ES6 中引入的。第三部分的工作正在进行中。

29.6 命名导出和导入

29.6.1 命名导出

每个模块可以有零个或多个命名导出

作为例子,考虑以下两个文件:

lib/my-math.mjs
main.mjs

模块 my-math.mjs 有两个命名导出:squareLIGHT_SPEED

// Not exported, private to module
function times(a, b) {
  return a * b;
}
export function square(x) {
  return times(x, x);
}
export const LIGHT_SPEED = 299792458;

要导出某些内容,我们在声明前放置关键字 export。未导出的实体对模块是私有的,并且不能从外部访问。

29.6.2 命名导入

模块 main.mjs 有一个命名导入,square

import {square} from './lib/my-math.mjs';
assert.equal(square(3), 9);

它也可以重命名其导入:

import {square as sq} from './lib/my-math.mjs';
assert.equal(sq(3), 9);

29.6.2.1 语法陷阱:命名导入不是解构

命名导入和解构看起来很相似:

import {func} from './util.mjs'; // import
const {func} = require('./util.mjs'); // destructuring

但它们相当不同:

  • 导入与其导出保持连接。

  • 我们可以在解构模式内部再次解构,但导入语句中的 {} 不能嵌套。

  • 重命名的语法不同:

    import {func as f} from './util.mjs'; // importing
    const {func: f} = require('./util.mjs'); // destructuring
    
    

    理由:解构让人联想到对象字面量(包括嵌套),而导入则唤起了重命名的想法。

图标“练习” 练习:命名导出

exercises/modules/export_named_test.mjs

29.6.3 命名空间导入

命名空间导入是命名导入的替代方案。如果我们命名空间导入一个模块,它将变成一个对象,其属性是命名导出。这是如果我们使用命名空间导入的 main.mjs 的样子:

import * as myMath from './lib/my-math.mjs';
assert.equal(myMath.square(3), 9);

assert.deepEqual(
  Object.keys(myMath), ['LIGHT_SPEED', 'square']
);

29.6.4 命名导出风格:内联与子句(高级)

我们之前看到的命名导出风格是内联的:我们通过在实体前添加关键字 export 来导出实体。

但我们也可以使用独立的导出子句。例如,这是使用导出子句的 lib/my-math.mjs 的样子:

function times(a, b) {
  return a * b;
}
function square(x) {
  return times(x, x);
}
const LIGHT_SPEED = 299792458;

export { square, LIGHT_SPEED }; // semicolon!

使用导出子句,我们可以在导出之前重命名,并在内部使用不同的名称:

function times(a, b) {
  return a * b;
}
function sq(x) {
  return times(x, x);
}
const LS = 299792458;

export {
  sq as square,
  LS as LIGHT_SPEED, // trailing comma is optional
};

29.7 默认导出和默认导入

每个模块最多只能有一个默认导出。其想法是模块就是默认导出的值。

作为默认导出的例子,考虑以下两个文件:

my-func.mjs
main.mjs

模块 my-func.mjs 有一个默认导出:

const GREETING = 'Hello!';
export default function () {
 return GREETING;
}

模块 main.mjs 默认导入导出的函数:

import myFunc from './my-func.mjs';
assert.equal(myFunc(), 'Hello!');

注意语法差异:命名导入周围的括号表示我们正在进入模块内部,而默认导入就是模块。

图标“问题” 默认导出的用例有哪些?

默认导出的最常见用例是包含单个函数或单个类的模块。

29.7.1 推荐项:两种默认导出方式

有两种进行默认导出的方式。

首先,我们可以使用 export default 标记现有的声明:

export default function myFunc() {} // no semicolon!
export default class MyClass {} // no semicolon!

其次,我们可以直接默认导出值。这种 export default 的风格与声明非常相似。

export default myFunc; // defined elsewhere
export default MyClass; // defined previously
export default Math.sqrt(2); // result of invocation is default-exported
export default 'abc' + 'def';
export default { no: false, yes: true };

29.7.1.1 为什么有两种默认导出方式?

原因是 export default 不能用来标记 constconst 可以定义多个值,但 export default 需要正好一个值。考虑以下假设代码:

// Not legal JavaScript!
export default const a = 1, b = 2, c = 3;

使用此代码,我们不知道三个值中的哪一个才是默认导出。

图标“练习” 练习:默认导出

exercises/modules/export_default_test.mjs

29.7.2 默认导出作为命名导出(高级)

内部,默认导出只是一个名为 default 的命名导出。例如,考虑之前具有默认导出的模块 my-func.mjs

const GREETING = 'Hello!';
export default function () {
 return GREETING;
}

以下模块 my-func2.mjs 与该模块等价:

const GREETING = 'Hello!';
function greet() {
 return GREETING;
}

export {
 greet as default,
};

对于导入,我们可以使用正常的默认导入:

import myFunc from './my-func2.mjs';
assert.equal(myFunc(), 'Hello!');

或者我们可以使用命名导入:

import {default as myFunc} from './my-func2.mjs';
assert.equal(myFunc(), 'Hello!');

默认导出也可以通过命名空间导入的 .default 属性访问:

import * as mf from './my-func2.mjs';
assert.equal(mf.default(), 'Hello!');

图标“问题” default作为变量名不合法吗?

default 不能作为变量名,但它可以作为导出名和属性名:

const obj = {
  default: 123,
};
assert.equal(obj.default, 123);

29.7.3 推荐项:命名导出与默认导出

这些是我的推荐:

  • 避免混合命名导出和默认导出:一个模块可以同时有命名导出和默认导出,但通常最好每个模块只坚持一种导出风格。

    • 有一个例外:对于单元测试,将内部函数(等)命名为导出,以补充默认导出(模块的公共 API)是有意义的。
  • 在某些情况下,你可能确信该模块只会导出一个值(通常是函数或类)。也就是说,从概念上讲,模块 就是 该值——类似于一个变量。那么默认导出是一个不错的选择。

  • 只使用命名导出永远不会出错。

29.8 重新导出

一个模块 library.mjs 可以将另一个模块 internal.mjs 的一个或多个导出作为它自己的导出导出。这被称为 重新导出

//===== internal.mjs =====
export function internalFunc() {}
export const INTERNAL_DEF = 'hello';
export default 123;

//===== library.mjs =====
// Named re-export [ES6]
export {internalFunc as func, INTERNAL_DEF as DEF} from './internal.mjs';
// Wildcard re-export [ES6]
export * from './internal.mjs';
// Namespace re-export [ES2020]
export * as ns from './internal.mjs';

  • 通配符重新导出将模块 internal.mjs 的所有导出转换为 library.mjs 的导出,除了默认导出。

  • 命名空间重新导出将模块 internal.mjs 的所有导出转换为成为 library.mjs 的命名导出 ns 的对象。因为 internal.mjs 有默认导出,所以 ns 有一个属性 .default

以下代码演示了上述两个要点:

//===== main.mjs =====
import * as library from './library.mjs';

assert.deepEqual(
  Object.keys(library),
  ['DEF', 'INTERNAL_DEF', 'func', 'internalFunc', 'ns']
);
assert.deepEqual(
  Object.keys(library.ns),
  ['INTERNAL_DEF', 'default', 'internalFunc']
);

29.9 更多关于导出和导入的细节

29.9.1 导入是导出的只读视图

到目前为止,我们直观地使用了导入和导出,一切似乎都按预期工作。但现在,是时候更深入地了解导入和导出是如何真正关联的了。

考虑以下两个模块:

counter.mjs
main.mjs

counter.mjs导出一个(可变的!)变量和一个函数:

export let counter = 3;
export function incCounter() {
 counter++;
}

main.mjs同时命名导出。当我们使用incCounter()时,我们发现与counter的连接是活跃的——我们总能访问该变量的实时状态:

import { counter, incCounter } from './counter.mjs';

// The imported value `counter` is live
assert.equal(counter, 3);
incCounter();
assert.equal(counter, 4);

注意,虽然连接是活跃的,我们可以读取counter,但我们不能改变这个变量(例如,通过counter++)。

以这种方式处理导入有两个好处:

  • 由于之前共享的变量可以成为导出,因此更容易拆分模块。

  • 这种行为对于支持透明的循环导入至关重要。继续阅读以获取更多信息。

29.9.2 ESM 对循环导入的透明支持(高级)

ESM 透明地支持循环导入。为了理解这是如何实现的,考虑以下示例:图 29.1 显示了模块导入其他模块的有向图。在这种情况下,P 导入 M 是一个循环。

图 29.1:模块导入模块的有向图:M 导入 N 和 O,N 导入 P 和 Q 等。

解析后,这些模块在两个阶段被设置:

  • 实例化:每个模块都被访问,并且其导入与导出连接。在父模块实例化之前,必须先实例化所有子模块。

  • 评估:模块的主体被执行。再次强调,子模块在父模块之前被评估。

这种方法正确处理了循环导入,归功于 ES 模块的两个特性:

  • 由于 ES 模块的静态结构,解析后导出就已经知道了。这使得在 M 之前实例化 P 成为可能:P 已经可以查找 M 的导出。

  • 当 P 被评估时,M 还没有被评估。然而,P 中的实体已经可以提及来自 M 的导入。它们只是还不能使用,因为导入的值稍后才会填充。例如,P 中的一个函数可以访问来自 M 的导入。唯一的限制是我们必须等待 M 评估完毕后,才能调用该函数。

    导入被填充是通过它们成为导出的“活跃不可变视图”来实现的。

29.10 包:JavaScript 的软件分发单元

在 JavaScript 生态系统中,一个是组织软件项目的一种方式:它是一个具有标准化布局的目录。一个包可以包含各种文件——例如:

  • 一个用 JavaScript 编写的 Web 应用程序,将被部署到服务器上

  • JavaScript 库(用于 Node.js、浏览器、所有 JavaScript 平台等)

  • 除了 JavaScript 之外的其他编程语言的库:TypeScript、Rust 等。

  • 单元测试(例如,针对软件包中的库)

  • 基于 Node.js 的 shell 脚本 - 例如,编译器、测试运行器和文档生成器等开发工具

  • 许多其他类型的工件

一个软件包可以依赖于其他软件包(这些称为其依赖项):

  • 软件包的 JavaScript 代码所需的库

  • 开发期间使用的 shell 脚本

  • 等等。

软件包的依赖项将安装在该软件包内部(我们很快就会看到)。

软件包之间的一种常见区别是:

  • 已发布的软件包可以被我们安装:

    • 全局安装:我们可以全局安装它们,这样它们的 shell 脚本就可以在命令行中使用了。

    • 本地安装:我们可以将它们作为依赖项安装到我们自己的软件包中。

  • 未发布的软件包永远不会成为其他软件包的依赖项,但它们本身也有依赖项。例如,部署到服务器的 Web 应用程序。

下一个子节将解释如何发布软件包。

29.10.1 发布软件包:软件包注册表、软件包管理器、软件包名称

发布软件包的主要方式是将它上传到软件包注册表 - 一个在线软件仓库。两个流行的公共注册表是:

公司也可以托管他们自己的私有注册表。

软件包管理器是一个命令行工具,它从注册表(或其他来源)下载软件包,并将它们作为 shell 脚本和/或作为依赖项安装。最受欢迎的软件包管理器称为npm,它捆绑在 Node.js 中。其名称最初代表“Node Package Manager”。后来,当 npm 和 npm 注册表不仅用于 Node.js 软件包时,这个含义被改为“npm 不是软件包管理器”(来源)。还有其他流行的软件包管理器,如 jsr、vlt、pnpm 和 yarn。所有这些软件包管理器都支持 npm 注册表和 JSR 中的任何一个或两个。

让我们探索 npm 注册表是如何工作的。每个软件包都有一个名称。有两种类型的名称:

  • 全局名称在整个注册表中是唯一的。以下是一些示例:

    minimatch
    mocha
    
    
  • 范围名称由两部分组成:一个范围和一个名称。范围是全球唯一的,名称在每个范围内是唯一的。以下是一些示例:

    @babel/core
    @rauschma/iterable
    
    

    范围以一个@符号开始,并用斜杠与名称分开。

29.10.2 软件包的文件系统布局

一旦软件包my-package完全安装,它通常看起来是这样的:

my-package/
  package.json
  node_modules/
  [More files]

这些文件系统条目的目的是什么?

  • package.json是每个软件包都必须拥有的文件:

    • 它包含描述软件包的元数据(其名称、版本、作者等)。

    • 它列出了包的依赖项:它需要的其他包,例如库和工具。对于每个依赖项,我们记录:

      • 一系列版本号。不指定特定版本允许升级以及依赖项之间的代码共享。

      • 默认情况下,依赖项来自 npm 注册表。但我们也可以指定其他来源:本地目录、GZIP 文件、指向 GZIP 文件的 URL、除 npm 之外的其他注册表、git 仓库等。

  • node_modules/ 是一个目录,其中安装了包的依赖项。每个依赖项也有一个包含其依赖项的 node_modules 文件夹,等等。结果是依赖项的树状结构。

大多数包也都有一个名为 package-lock.json 的文件,它位于 package.json 旁边:它记录了已安装依赖项的确切版本,并且如果通过 npm 添加更多依赖项,它将保持更新。

29.10.3 package.json

这是一个可以通过 npm 创建的初始 package.json

{
  "name": "my-package",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

这些属性的目的是什么?

  • 一些属性对于公共包(发布在 npm 注册表中)是必需的:

    • name 指定此包的名称。

    • version 用于版本管理,并遵循 语义化版本控制,由三个点分隔的数字组成:

      • 当进行不兼容的 API 变更时,会递增 主版本

      • 当以向后兼容的方式添加功能时,会递增 次要版本

      • 当进行的小改动不会真正改变功能时,会递增 修补版本

  • 公共包的其他属性是可选的:

    • descriptionkeywordsauthor 是可选的,这使得查找包变得更加容易。

    • license 阐明了如何使用此包。如果包以任何方式是公共的,提供此值是有意义的。“选择开源许可” 可以帮助做出这个选择。

  • main 是一个遗留属性,已被 exports 取代。它指向库包的代码。

  • scripts 是一个用于设置开发时 shell 命令缩写的属性。这些可以通过 npm run 执行。例如,test 脚本可以通过 npm run test 执行。

更有用的属性:

  • 通常,nameversion 属性是必需的,如果它们缺失,npm 会警告我们。但是,我们可以通过以下设置来更改这一点:

    "private": true
    
    

    这防止了包意外发布,并允许我们省略名称和版本。

  • exports 用于 包导出 – 指定导入者如何看到此包的内容。我们将在 稍后 了解更多关于包导出的内容。

  • imports 用于 包导入 – 定义了包可以内部使用的模块指定符别名。我们将在 稍后 了解更多关于包导入的内容。

  • dependencies 列出了包的依赖项。

  • devDependencies 是仅在开发期间安装的依赖项(不是当包作为依赖项添加时)。

  • 以下设置意味着所有以 .js 为扩展名的文件都被解释为 ECMAScript 模块。除非我们正在处理遗留代码,否则添加它是合理的:

    "type": "module"
    
    
  • bin 列出作为 shell 脚本安装的包内的模块。

图标“外部” 关于 package.json 的更多信息

查看 npm 文档

29.10.4 包导出:控制其他包可见的内容

包导出 通过 package.json 中的 "exports" 属性指定,并支持三个重要特性:

  • 隐藏包的内部结构:

    • 如果不存在 "exports" 属性,则包 my-lib 中的每个模块都可以通过包名后的相对路径访问——例如:

      'my-lib/dist/src/internal/internal-module.js'
      
      
    • 一旦存在该属性,就只能使用其中列出的指定符。其他所有内容对外部都是隐藏的。

  • 更好的模块指定符:包导出允许我们更改导入包模块的裸指定符子路径:它们可以更短,无扩展名等。

  • 条件导出:相同的模块指定符导出不同的模块——这取决于导入者使用的 JavaScript 平台(浏览器、Node.js 等)。

接下来,我们将查看一些例子。要更详细地了解包导出是如何工作的,请参阅“使用 Node.js 的 Shell 脚本”中的“包导出:控制其他包可见的内容”部分。

29.10.4.1 例子:包导出

例子——指定通过包的裸指定符导入的模块(过去,这是通过 main 属性指定的):

"exports": {
  ".": "./dist/src/main.js"
}

例子——指定模块更好的路径:

"exports": {
  // With filename extension
  "./util/errors.js": "./dist/src/util/errors.js",

  // Without filename extension
  "./util/errors": "./dist/src/util/errors.js"
}

例子——指定模块树更好的路径:

"exports": {
  // With filename extensions
  "./*": "./dist/src/*",

  // Without filename extensions
  "./*": "./dist/src/*.js"
}

29.10.4.2 例子:条件包导出

本小节中的例子展示了 package.json 的摘录。

例子——为 Node.js、浏览器和其他平台导出不同的模块:

"exports": {
  ".": {
    "node": "./main-node.js",
    "browser": "./main-browser.js",
    "default": "./main-browser.js"
  }
}

例子——开发与生产:

"exports": {
  ".": {
    "development": "./main-development.js",
    "production": "./main-production.js",
  }
}

在 Node.js 中,我们可以指定一个环境如下:

node --conditions development app.mjs

29.10.5 包导入

包导入 允许一个包定义它自己内部使用的模块指定符的缩写。这是一个例子:

package.json

{
  "imports": {
    "#some-pkg": {
      "node": "some-pkg-node-native",
      "default": "./polyfills/some-pkg-polyfill.js"
    }
  },
  "dependencies": {
    "some-pkg-node-native": "¹.2.3"
  }
}

"imports" 的每个键都必须以井号(#)开头。键 "#some-pkg"条件性的(具有与条件包导出相同的特性):

  • 如果当前包在 Node.js 上使用,模块指定符 '#some-pkg' 指的是 some-pkg-node-native 包。

  • 在其他地方,'#some-pkg'指的是当前包内./polyfills/some-pkg-polyfill.js文件。

注意,只有包导入可以引用外部包,包导出不能这样做。

包导入有哪些用例?

  • 通过相同的模块指定符引用不同平台特定的实现模块(如上所示)。

  • 当前包内模块的别名 – 以避免相对指定符(在深层嵌套目录中可能会变得复杂)。

29.10.5.1 示例:通过包导入访问 package.json

让我们探讨两种通过包导入访问package.json的方法。

首先,我们可以为包的根级别定义一个包导入:

"imports": {
  "#root/*": "./*"
},

然后,导入语句看起来像这样:

import pkg from '#root/package.json' with { type: 'json' };
console.log(pkg.version);

其次,我们可以为package.json定义一个包导入:

"imports": {
  "#pkg": "./package.json"
},

然后,导入语句看起来像这样:

import pkg from '#pkg' with { type: 'json' };
console.log(pkg.version);

29.11 命名模块

对于命名模块文件及其导入的变量,没有建立最佳实践。

在本章中,我使用以下命名风格:

  • 模块文件的名称是破折号命名,并且只有小写字母:

    ./my-module.mjs
    ./some-func.mjs
    
    
  • 命名空间导入的名称是驼峰式,并且以小写字母开头:

    import * as myModule from './my-module.mjs';
    
    
  • 默认导入的名称是驼峰式,并且以小写字母开头:

    import someFunc from './some-func.mjs';
    
    

这种风格的背后是什么思考?我们希望模块文件名与包名相似:

  • 在包名中,破折号比下划线更常用。这可能受到下划线在域名中非常罕见的影响。

  • npm 不允许包名中使用大写字母(来源)。

多亏了 CSS,有明确的规则将破折号命名转换为驼峰命名。我们可以使用这些规则来处理命名空间导入和默认导入。

29.12 模块指定符

模块指定符是标识模块的字符串。它们在浏览器和 Node.js 中的工作方式略有不同。在我们能够查看这些差异之前,我们需要了解不同类别的模块指定符。

29.12.1 模块指定符的类型

模块指定符有三种类型:

  • 绝对指定符是完整的 URL – 例如:

    'https://www.unpkg.com/browse/yargs@17.3.1/browser.mjs'
    'file:///opt/nodejs/config.mjs'
    
    

    绝对指定符主要用于访问直接托管在网上的库。

  • 相对指定符是相对 URL(以'/', './''../'开头) – 例如:

    './sibling-module.js'
    '../module-in-parent-dir.mjs'
    '../../dir/other-module.js'
    
    

    每个模块都有一个 URL,其协议取决于其位置(file:, https:等)。如果它使用相对指定符,JavaScript 会将该指定符转换为完整的 URL,通过解析模块的 URL 来实现。

    相对指定符主要用于访问同一代码库中的其他模块。

  • 裸指定符是不带协议和域的路径,不以斜杠或点开头。它们以包的名称开始。这些名称可以可选地后面跟有子路径

    'some-package'
    'some-package/sync'
    'some-package/util/files/path-tools.js'
    
    

    裸露指定符也可以指向具有范围名称的包:

    '@some-scope/scoped-name'
    '@some-scope/scoped-name/async'
    '@some-scope/scoped-name/dir/some-module.mjs'
    
    

    每个裸露指定符恰好指向包内部的一个模块;如果没有子路径,它指向其包指定的“主”模块。

    裸露指定符永远不会直接使用,而是始终解析 – 转换为绝对指定符。解析的工作方式取决于平台。我们很快就会了解更多。

29.12.2 模块指定符中的文件扩展名

  • 绝对指定符和相对指定符始终具有文件扩展名 – 主要为 .js.mjs

  • 裸露指定符有三种样式:

    • 样式 1:没有子路径

      'my-library'
      
      
    • 样式 2:没有文件扩展名的子路径。在这种情况下,子路径类似于对包名的修饰符:

      'my-parser/sync'
      'my-parser/async'
      
      'assertions'
      'assertions/strict'
      
      
    • 样式 3:具有文件扩展名的子路径。在这种情况下,包被视为模块集合,子路径指向其中之一:

      'large-package/misc/util.js'
      'large-package/main/parsing.js'
      'large-package/main/printing.js'
      
      

样式 3 裸露指定符的注意事项:文件扩展名的解释取决于依赖项,可能与导入包不同。例如,导入包可能使用 .mjs 用于 ESM 模块和 .js 用于 CommonJS 模块,而依赖项导出的 ESM 模块可能具有带有文件扩展名 .js 的裸路径。

29.12.3 在 Node.js 中的模块指定符

让我们看看模块指定符在 Node.js 中的工作方式。特别是裸露指定符的处理方式与浏览器不同。

29.12.3.1 在 Node.js 中解析模块指定符

Node.js 解析算法 的工作方式如下:

  • 参数:

    • 导入模块的 URL

    • 模块指定符

  • 结果:模块指定符的解析 URL

这是算法:

  • 如果指定符是绝对的,解析已经完成。最常见的协议有三个:

    • file: 用于本地文件

    • https: 用于远程文件

    • node: 用于内置模块

  • 如果指定符是相对的,它将相对于导入模块的 URL 进行解析。

  • 如果指定符是裸露的:

    • 如果它以 '#' 开头,它将通过查找包导入(这将在稍后解释)并解析结果来解析。

    • 否则,它是一个具有以下格式之一的裸露指定符(子路径是可选的):

      • «package»/sub/path

      • @«scope»/«scoped-package»/sub/path

      解析算法遍历当前目录及其父目录,直到找到一个包含与裸露指定符开头匹配的子目录 node_modules,即:

      • node_modules/«package»/

      • node_modules/@«scope»/«scoped-package»/

      该目录是包的目录。默认情况下,包 ID 之后的(可能为空)子路径被解释为相对于包目录。默认值可以通过包导出来覆盖,这将在下文中解释。

解析算法的结果必须指向一个文件。这解释了为什么绝对指定符和相对指定符始终具有文件扩展名。裸指定符通常没有,因为它们是查找包导出的缩写。

模块文件通常具有以下文件扩展名:

  • 如果文件具有.mjs扩展名,它始终是 ES 模块。

  • 如果最接近的package.json有此条目,则具有.js扩展名的文件是 ES 模块:

    • "type": "module"

如果 Node.js 执行通过 stdin、--eval--print提供的代码,我们使用以下命令行选项以便将其解释为 ES 模块:

--input-type=module

29.12.4 浏览器中的模块指定符

在浏览器中,我们可以这样编写内联模块:

<script type="module">
 // Inline module
</script>

type="module"告诉浏览器这是一个 ES 模块,而不是浏览器脚本。

我们只能使用两种模块指定符:

<!-- Absolute module specifier -->
<script type="module" src="https://unpkg.com/lodash"></script>

<!-- Relative module specifier -->
<script type="module" src="bundle.js"></script>

继续阅读以了解如何绕过此限制并使用 npm 包。

29.12.4.1 浏览器中的文件扩展名

浏览器不关心文件扩展名,只关心内容类型。

因此,只要它们以JavaScript 内容类型(推荐使用text/javascript)提供,我们可以为 ECMAScript 模块使用任何文件扩展名。

29.12.4.2 在浏览器中使用 npm 包

在 Node.js 上,npm 包被下载到node_modules目录,并通过裸模块指定符访问。Node.js 遍历文件系统以查找包。我们无法在 Web 浏览器中这样做。将 npm 包带到浏览器中的三种常见方法。

29.12.4.2.1 方法 1:使用内容分发网络

内容分发网络(CDN)如unpkg.comesm.sh允许我们通过 URL 导入 npm 包。这是unpkg.com URL 的示例:

https://unpkg.com/«package»@«version»/«file»

例如:

https://unpkg.com/lodash@4.17.21/lodash.js

CDN 的一个缺点是它们引入了一个额外的故障点:

  • CDN 可能会离线。

  • CDN 提供恶意代码的风险存在——例如,如果它们被黑客攻击或被新的维护者接管。

29.12.4.2.2 方法 2:使用带有裸指定符和打包器的node_modules

打包器是一个构建工具。它大致工作如下:

  • 给定一个包含 Web 应用的目录。我们将打包器指向应用的入口点——执行开始的模块。

  • 它收集该模块导入的所有内容(它的导入、导入的导入等)。

  • 它生成一个bundle,一个包含所有代码的单个文件。该文件可以从 HTML 页面中使用。

如果一个应用有多个入口点,打包器会生成多个包。也可以指示它为按需加载的应用部分创建包。

在打包时,我们可以在文件中使用裸导入指定符,因为打包器知道如何在 node_modules 中找到相应的模块。打包器还尊重包导出和包导入。

为什么需要打包?

  • 加载单个文件通常比加载多个文件更快 – 尤其是当有很多小文件时。

  • 打包器只包含文件中实际使用的代码(这对于库尤其相关)。这节省了存储空间,也加快了加载速度。

打包的一个缺点是每次我们想要运行应用时都需要打包整个应用。

29.12.4.2.3 方法 3:将 npm 包转换为浏览器兼容文件

浏览器有包管理器,允许我们下载作为单个打包文件使用的模块。例如,考虑以下 Web 应用的目录:

my-web-app/
  assets/
    lodash-es.js
  src/
    main.js

我们使用打包器将包 lodash-es 安装到单个文件中。模块 main.js 可以这样导入它:

import {pick} from '../assets/lodash-es.js';

为了部署此应用,assets/src/ 目录的内容被复制到生产服务器(除了非 JavaScript 艺术品之外)。

与使用打包器相比,这种方法的优点是什么?

  • 我们安装外部依赖项一次,然后可以始终立即运行我们的应用 – 不需要先打包(这可能很耗时)。

  • 未打包的代码更容易调试。

29.12.4.2.4 改进方法 3:导入映射

方法 3 可以进一步改进:导入映射 是一种浏览器技术,允许我们为模块指定符定义缩写 – 例如,'lodash-es' 对应 '../assets/lodash-es.js'

如果我们将导入映射存储在 HTML 文件中,它会看起来像这样:

<script type="importmap">
{
 "imports": {
 "lodash-es": "./assets/lodash-es.js"
 }
}
</script>

我们还可以将导入映射存储在外部文件中(内容类型必须是 application/importmap+json):

<script type="importmap" src="imports.importmap"></script>

现在 main.js 中的导入看起来是这样的:

import {pick} from 'lodash-es';

29.13 import.meta – 当前模块的元数据 (ES2020)

对象 import.meta 包含当前模块的元数据。

29.13.1 import.meta.url

import.meta 的最重要的属性是 .url,它包含一个字符串,包含当前模块文件的 URL – 例如:

'https://example.com/code/main.mjs'

29.13.2 import.meta.urlURL

URL 类在浏览器和 Node.js 中通过全局变量可用。我们可以在 Node.js 文档 中查找其完整功能。当使用 import.meta.url 时,其构造函数特别有用:

new URL(input: string, base?: string|URL)

参数 input 包含要解析的 URL。如果提供了第二个参数 base,则可以是相对路径。

换句话说,这个构造函数允许我们相对于基本 URL 解析相对路径:

> new URL('other.mjs', 'https://example.com/code/main.mjs').href
'https://example.com/code/other.mjs'
> new URL('../other.mjs', 'https://example.com/code/main.mjs').href
'https://example.com/other.mjs'

这是我们如何获取一个指向当前模块旁边 data.txt 文件的 URL 实例:

const urlOfData = new URL('data.txt', import.meta.url);

29.13.3 在 Node.js 上的 import.meta.url

在 Node.js 上,import.meta.url 总是一个带有 file: URL 的字符串 – 例如:

'file:///Users/rauschma/my-module.mjs'

29.13.3.1 示例:读取模块的兄弟文件

许多 Node.js 文件系统操作接受路径的字符串或 URL 实例。这使得我们可以读取当前模块的兄弟文件 data.txt

import * as fs from 'node:fs';
function readData() {
 // data.txt sits next to current module
 const urlOfData = new URL('data.txt', import.meta.url);
 return fs.readFileSync(urlOfData, {encoding: 'UTF-8'});
}

29.13.3.2 模块 fs 和 URLs

对于 fs 模块的大多数函数,我们可以通过以下方式引用文件:

  • 路径 – 字符串或 Buffer 实例。

  • URLs – 在 URL 实例中(协议为 file:

关于这个主题的更多信息,请参阅Node.js API 文档

29.13.3.3 在 file: URLs 和路径之间转换

Node.js 模块 url 有两个函数用于在 file: URLs 和路径之间进行转换:

  • fileURLToPath(url: URL|string): string

    file: URL 转换为路径。

  • pathToFileURL(path: string): URL

    将路径转换为 file: URL。

如果我们需要可以在本地文件系统中使用的路径,那么 URL 实例的 .pathname 属性并不总是有效:

assert.equal(
  new URL('file:///tmp/with%20space.txt').pathname,
  '/tmp/with%20space.txt');

因此,最好使用 fileURLToPath()

import * as url from 'node:url';
assert.equal(
  url.fileURLToPath('file:///tmp/with%20space.txt'),
  '/tmp/with space.txt'); // result on Unix

类似地,pathToFileURL() 不仅将 'file://' 前缀添加到绝对路径。

29.14 通过 import() 动态加载模块 (ES2020) (高级)

图标“阅读” import() 操作符返回 Promises

Promises 是处理异步计算结果(即,不是立即)的技术。可能有必要在理解它们之后再阅读这一节。更多信息:

  • “异步编程的 Promises (ES6)” (§43)

  • “异步函数 (ES2017)” (§44)(解释了用于 Promises 的 await 操作符,我们将在本节中使用它)

29.14.1 静态 import 语句的限制

到目前为止,导入模块的唯一方法是通过 import 语句。该语句有几个限制:

  • 我们必须在模块的最顶层使用它。也就是说,例如,当我们处于函数内部或 if 语句内部时,我们不能导入任何东西。

  • 模块指定符始终是固定的。也就是说,我们不能根据条件更改我们导入的内容。我们也不能动态地组装指定符。

29.14.2 通过 import() 操作符进行动态导入

import() 操作符没有 import 语句的限制。它看起来像这样:

const namespaceObject = await import(moduleSpecifierStr);
console.log(namespaceObject.namedExport);

这个操作符的使用方式类似于函数,接收一个包含模块指定符的字符串,并返回一个解析为命名空间对象的 Promise。该对象的属性是导入模块的导出。

注意,await 可以在模块的最高级别使用(参见 下一节)。

29.14.2.1 示例:动态加载模块

考虑以下文件:

lib/my-math.mjs
main1.mjs
main2.mjs

我们已经看到了模块 my-math.mjs

// Not exported, private to module
function times(a, b) {
  return a * b;
}
export function square(x) {
  return times(x, x);
}
export const LIGHT_SPEED = 299792458;

我们可以使用 import() 按需加载此模块:

// main1.mjs
const moduleSpecifier = './lib/my-math.mjs';

async function getLightSpeedAsync() {
 const myMath = await import(moduleSpecifier);
 return myMath.LIGHT_SPEED;
}

const result = await getLightSpeedAsync();
assert.equal(result, 299792458);

以下代码中的两个操作无法使用 import 语句完成:

  • 我们在函数内部导入(而不是在顶层)。

  • 模块指定符来自一个变量。

图标“问题”为什么 import() 是一个操作符而不是一个函数?

import() 看起来像一个函数,但无法作为一个函数实现:

  • 它需要知道当前模块的 URL,以便解析相对模块指定符。

  • 如果 import() 是一个函数,我们就必须显式地传递这个信息给它(例如,通过参数)。

  • 相比之下,操作符是一个核心语言构造,并且可以隐式访问更多数据,包括当前模块的 URL。

29.14.3 import() 的用例

29.14.3.1 按需加载代码

一些 Web 应用的功能在它们启动时不必存在,可以在需要时加载。这时 import() 就很有用,因为我们可以将这些功能放入模块中——例如:

button.addEventListener('click', async (event) => {
  const dialogBox = await import('./dialogBox.mjs');
  dialogBox.open();
});

29.14.3.2 条件加载模块

我们可能想要根据条件是否为真来加载模块。例如,一个包含 polyfill 的模块,该 polyfill 在旧平台上提供新功能:

if (isLegacyPlatform()) {
  await import('./my-polyfill.mjs');
}

29.14.3.3 计算模块指定符

对于国际化等应用,如果我们能够动态计算模块指定符,那就很有帮助:

const message = await import(`messages_${getLocale()}.mjs`);

29.15 模块中的顶层 await (ES2022) (高级)

图标“阅读”await 是异步函数的一个特性

await 在 “异步函数 (ES2017)” (§44) 中进行了解释。可能有必要在理解异步函数之后再阅读这一节。

我们可以在模块的最高级别使用 await 操作符。如果我们这样做,模块就会变为异步,并且工作方式不同。幸运的是,作为程序员,我们通常不会看到这种情况,因为它由语言透明地处理。

29.15.1 顶层 await 的用例

为什么我们想在模块的最高级别使用 await 操作符呢?它允许我们使用异步加载数据初始化模块。接下来的三个小节展示了这种用法在哪些情况下是有用的。

29.15.1.1 动态加载模块
const params = new URLSearchParams(location.search);
const language = params.get('lang');
const messages = await import(`./messages-${language}.mjs`); // (A)

console.log(messages.welcome);

在行 A 中,我们动态导入了一个模块。多亏了顶层 await,这几乎与使用正常的静态导入一样方便。

29.15.1.2 如果模块加载失败,使用回退
let mylib;
try {
  mylib = await import('https://primary.example.com/mylib');
} catch {
  mylib = await import('https://secondary.example.com/mylib');
}

29.15.1.3 使用加载最快的资源
const resource = await Promise.any([
  fetch('http://example.com/first.txt')
    .then(response => response.text()),
  fetch('http://example.com/second.txt')
    .then(response => response.text()),
]);

由于 Promise.any(),变量 resource 通过最先完成的下载进行初始化。

29.15.2 顶层 await 在底层是如何工作的?

考虑以下两个文件。

first.mjs:

const response = await fetch('http://example.com/first.txt');
export const first = await response.text();

main.mjs:

import {first} from './first.mjs';
import {second} from './second.mjs';
assert.equal(first, 'First!');
assert.equal(second, 'Second!');

这两种方法大致等同于以下代码:

first.mjs:

export let first;
export const promise = (async () => { // (A)
  const response = await fetch('http://example.com/first.txt');
  first = await response.text();
})();

main.mjs:

import {promise as firstPromise, first} from './first.mjs';
import {promise as secondPromise, second} from './second.mjs';
export const promise = (async () => { // (B)
  await Promise.all([firstPromise, secondPromise]); // (C)
  assert.equal(first, 'First!');
  assert.equal(second, 'Second!');
})();

一个模块如果成为异步的:

  1. 它直接使用顶层 await (first.mjs)。

  2. 它导入一个或多个异步模块(main.mjs)。

每个异步模块导出一个 Promise(行 A 和行 B),在它的主体执行完毕后得到满足。到那时,可以安全地访问该模块的导出。

在情况 (2) 中,导入模块会等待所有导入的异步模块的 Promise 被满足,然后才进入其主体(行 C)。同步模块按常规处理。

等待拒绝和同步异常的管理方式与异步函数中相同。

29.15.3 顶层 await 的优缺点

顶层 await 的优缺点是什么?

  • 优点:

    • 在模块的顶层有这个操作符是非常方便的,尤其是对于动态导入的模块。

    • 这避免了需要复杂的技术来确保导入者不会在数据准备好之前访问数据。

    • 它透明地支持异步性:导入者不需要知道导入的模块是否是异步的。

  • 缺点:

    • 顶层 await 延迟导入模块的初始化。因此,最好谨慎使用。耗时较长的异步任务最好在需要时再执行。然而,即使没有顶层 await 的模块也可以阻塞导入者(例如,通过顶层无限循环),所以阻塞本身并不是反对它的理由。

    • 在 Node.js 上,使用顶层 await 的 ESM 模块不能从 CommonJS 中导入。如果你编写了一个基于 ESM 的包,并希望它可以从 CommonJS 代码库中使用,这很重要。更多信息,请参阅 Node.js 文档中的“使用 require() 加载 ECMAScript 模块”部分。

29.16 导入属性:导入非 JavaScript 艺术品 (ES2025)

29.16.1 导入非 JavaScript 艺术品的历程

将非 JavaScript 代码作为模块导入,在 JavaScript 生态系统中有着悠久的历史。例如,JavaScript 模块加载器 RequireJS 支持所谓的插件。为了给您一个关于 RequireJS 多么古老的感觉:版本 1.0.0 于 2009 年发布。通过插件导入的模块的指定符看起来如下:

'«specifier-of-plugin-module»!«specifier-of-artifact»'

例如,以下模块指定符将文件作为 JSON 导入:

'json!./data/config.json'

受 RequireJS 启发,webpack 支持相同的模块指定符语法来支持其加载器

29.16.2 导入非 JavaScript 艺术品的用例

这些是导入非 JavaScript 艺术品的几个用例:

  • 导入 JSON 配置数据

  • 将 WebAssembly 代码作为 JavaScript 模块导入

  • 将 CSS 导入以构建用户界面

对于更多用例,您可以查看webpack 的加载器列表

29.16.3 导入属性

导入属性的激励用例是将 JSON 数据作为模块导入。这看起来如下所示:

import configData from './config-data.json' with { type: 'json' };

type 是一个导入属性(关于语法的更多内容很快就会介绍)。

您可能会想知道为什么 JavaScript 引擎不能使用文件扩展名 .json 来确定这是 JSON 数据。然而,网络的核心架构原则是永远不要使用文件扩展名来确定文件内部的内容。相反,使用内容类型。

如果服务器设置正确,为什么不进行正常的导入并省略导入属性呢?

  • 服务器可能故意配置错误——例如,一个不由编写代码的人控制的第三方服务器。它可能会用一个将被导入器执行的代码替换导入的 JSON 文件。

  • 服务器可能意外配置错误。使用导入属性,我们可以更快地获得反馈。

  • 由于预期的内容类型在代码中不是明确的,属性也记录了程序员的期望。

29.16.4 导入属性的语法

让我们更详细地检查导入属性的外观。

29.16.4.1 静态导入语句

我们已经看到了一个正常的(静态)导入语句:

import configData from './config-data.json' with { type: 'json' };

导入属性以with关键字开头。该关键字后面跟着一个对象字面量。目前,支持以下对象字面量特性:

  • 未引用的键和引用的键

  • 值必须是字符串

没有对键和值的语法进行其他限制,但如果引擎不支持某个键和/或值,则应抛出异常:

  • 属性会改变导入的内容,因此简单地忽略它们是危险的,因为这会改变代码的运行时行为。

  • 一个副作用是,这使得将来更容易添加更多功能,因为没有人会以意想不到的方式使用键和值。

29.16.4.2 动态导入

为了支持导入属性,动态导入 获得了第二个参数——一个包含配置数据的对象:

const configData = await import(
  './config-data.json', { with: { type: 'json' } }
);

导入属性不在顶层;它们通过 with 属性指定。这使得将来能够添加更多的配置选项。

29.16.4.3 重新导出语句

重新导出在单个步骤中导入和导出。对于前者,我们需要属性:

export { default as config } from './config-data.json' with { type: 'json' };

29.16.5 JSON 模块 (ES2025)

导入属性实际上只是语法。它们为使用该语法的实际功能奠定了基础。第一个基于导入属性的 ECMAScript 功能是 JSON 模块——我们已经在实际操作中看到了它:

这是一个文件 config-data.json

{
  "version": "1.0.0",
  "maxCount": 20
}

它紧邻以下 ECMAScript 模块 main.js

import configData from './config-data.json' with { type: 'json' };
assert.deepEqual(
  configData,
  {
    version: '1.0.0',
    maxCount: 20
  }
);

图标“练习”练习:导入 JSON

exercises/modules/get-version_test.mjs

29.17 Polyfills:模拟原生 Web 平台功能(高级)

图标“详情”后端也有 polyfills

本节是关于前端开发和 Web 浏览器的,但类似的想法也适用于后端开发。

Polyfills 帮助我们在用 JavaScript 开发 Web 应用程序时遇到的冲突:

  • 一方面,我们希望使用使应用程序更好和/或开发更简单的现代 Web 平台功能。

  • 另一方面,应用程序应该在尽可能多的浏览器上运行。

给定一个 Web 平台功能 X:

  • X 的 polyfill 是一段代码。如果它在已经内置了对 X 的支持的平台上执行,则不会做任何事情。否则,它使该功能在平台上可用。在后一种情况下,polyfill 的功能(主要)与原生实现不可区分。为了实现这一点,polyfill 通常会进行全局更改。例如,它可能会修改全局数据或配置全局模块加载器。Polyfills 通常被打包成模块。

    • 术语 polyfill 是由 Remy Sharp 提出的。
  • 推测性 polyfill 是针对提议的 Web 平台功能(尚未标准化)的 polyfill。

    • 术语:prollyfill
  • X 的 复制品 是一个库,它在本地上重现了 X 的 API 和功能。这样的库独立于 X 的本地(和全局)实现。

    • 复制品 是本节中引入的新术语。术语:ponyfill
  • 此外,还有术语 shim,但它没有普遍认同的定义。它通常意味着与 polyfill 大致相同。

每次我们的 Web 应用程序启动时,它必须首先执行所有可能不在所有地方都有的功能的 polyfills。之后,我们可以确信那些功能是原生可用的。

29.17.1 本节来源

30 对象

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

  1. 30.1 速查表:对象

    1. 30.1.1 速查表:单个对象

    2. 30.1.2 速查表:原型链

  2. 30.2 什么是对象?

    1. 30.2.1 使用对象的方式
  3. 30.3 固定布局对象

    1. 30.3.1 对象字面量:属性

    2. 30.3.2 对象字面量:属性值简写

    3. 30.3.3 获取属性

    4. 30.3.4 设置属性

    5. 30.3.5 对象字面量:方法

    6. 30.3.6 对象字面量:访问器

  4. 30.4 对象字面量中的展开(...)(ES2018)

    1. 30.4.1 展开的使用场景:缺失属性的默认值

    2. 30.4.2 展开的使用场景:非破坏性更改属性

    3. 30.4.3 “破坏性展开”:Object.assign()(ES6)

  5. 30.5 复制对象:展开与Object.assign()structuredClone()的比较

    1. 30.5.1 通过展开复制对象是浅拷贝

    2. 30.5.2 通过structuredClone()深度复制对象

    3. 30.5.3 structuredClone()可以复制哪些值?

    4. 30.5.4 复制的对象属性

    5. 30.5.5 没有structuredClone()限制的替代方案?

    6. 30.5.6 本节来源

  6. 30.6 方法和特殊变量this

    1. 30.6.1 方法是其值为函数的属性

    2. 30.6.2 特殊变量this

    3. 30.6.3 方法和.call()

    4. 30.6.4 方法和.bind()

    5. 30.6.5 this陷阱:提取方法

    6. 30.6.6 this陷阱:意外遮蔽this

    7. 30.6.7 不同上下文中this的值(高级)

  7. 30.7 属性获取和方法调用中的可选链(ES2020)(高级)

    1. 30.7.1 示例:可选固定属性获取

    2. 30.7.2 运算符的详细说明(高级)

    3. 30.7.3 使用可选属性获取进行短路

    4. 30.7.4 可选链的缺点和替代方案

    5. 30.7.5 常见问题

  8. 30.8 原型链

    1. 30.8.1 JavaScript 的操作:所有属性与自有属性

    2. 30.8.2 陷阱:只有原型链的第一个成员会被修改

    3. 30.8.3 使用原型的技巧(高级)

    4. 30.8.4 Object.hasOwn(): 给定属性是否为自有(非继承的)?

    5. 30.8.5 通过原型共享数据

  9. 30.9 字典对象(高级)

    1. 30.9.1 对象字面量中的引号键

    2. 30.9.2 对象字面量中的计算键

    3. 30.9.3 in 操作符:是否存在具有给定键的属性?

    4. 30.9.4 删除属性

    5. 30.9.5 可枚举性

    6. 30.9.6 通过 Object.keys() 等列出属性键

    7. 30.9.7 通过 Object.values() 列出属性值

    8. 30.9.8 通过 Object.entries() 列出属性条目(ES2017)

    9. 30.9.9 属性按确定性列出

    10. 30.9.10 通过 Object.fromEntries() 组装对象(ES2019)

    11. 30.9.11 具有 null 原型的对象是好的字典和查找表

  10. 30.10 属性属性和属性描述符(ES5)(高级)

  11. 30.11 保护对象不被更改(ES5)(高级)

  12. 30.12 快速参考:Object

    1. 30.12.1 Object.*:创建对象,处理原型

    2. 30.12.2 Object.*:属性属性

    3. 30.12.3 Object.*:属性键、值、条目

    4. 30.12.4 Object.*:保护对象

    5. 30.12.5 Object.*:杂项

    6. 30.12.6 Object.prototype.*

  13. 30.13 快速参考:Reflect

    1. 30.13.1 Reflect.*Object.*

在本书中,JavaScript 的面向对象编程(OOP)风格分四步介绍。本章涵盖第 1 步和第 2 步;下一章涵盖第 3 步和第 4 步。步骤如下(图 30.1):

  1. 单个对象(本章):JavaScript 的基本 OOP 构建块 对象 在独立状态下是如何工作的?

  2. 原型链(本章):每个对象都有一个零个或多个 原型对象 的链。原型是 JavaScript 的核心继承机制。

  3. 类(下一章): JavaScript 的是对象的工厂。类与其实例之间的关系基于原型继承(步骤 2)。

  4. 子类化(下一章): 子类与其超类之间的关系也是基于原型继承。

图 30.1:本书通过四个步骤介绍了 JavaScript 中的面向对象编程。

30.1 速查表:对象

30.1.1 速查表:单个对象

通过对象字面量创建对象(以花括号开始和结束):

const myObject = { // object literal
  myProperty: 1,
  myMethod() {
 return 2;
 }, // comma!
 get myAccessor() {
 return this.myProperty;
 }, // comma!
 set myAccessor(value) {
 this.myProperty = value;
 }, // last comma is optional
};

assert.equal(
 myObject.myProperty, 1
);
assert.equal(
 myObject.myMethod(), 2
);
assert.equal(
 myObject.myAccessor, 1
);
myObject.myAccessor = 3;
assert.equal(
 myObject.myProperty, 3
);

能够直接创建对象(而不使用类)是 JavaScript 的一个亮点。

对象的展开操作:

const original = {
  a: 1,
  b: {
    c: 3,
  },
};

// Spreading (...) copies one object “into” another one:
const modifiedCopy = {
  ...original, // spreading
  d: 4,
};

assert.deepEqual(
  modifiedCopy,
  {
    a: 1,
    b: {
      c: 3,
    },
    d: 4,
  }
);

// Caveat: spreading copies shallowly (property values are shared)
modifiedCopy.a = 5; // does not affect `original`
modifiedCopy.b.c = 6; // affects `original`
assert.deepEqual(
  original,
  {
    a: 1, // unchanged
    b: {
      c: 6, // changed
    },
  },
);

我们还可以使用展开操作来创建一个未修改的(浅拷贝)对象:

const exactCopy = {...obj};

30.1.2 速查表:原型链

原型是 JavaScript 的基本继承机制。即使是类也是基于它。每个对象都有一个null或对象作为其原型。后者对象也可以有原型,等等。一般来说,我们得到原型链

原型是这样管理的:

// `obj1` has no prototype (its prototype is `null`)
const obj1 = Object.create(null); // (A)
assert.equal(
  Object.getPrototypeOf(obj1), null // (B)
);

// `obj2` has the prototype `proto`
const proto = {
  protoProp: 'protoProp',
};
const obj2 = {
  __proto__: proto, // (C)
  objProp: 'objProp',
}
assert.equal(
  Object.getPrototypeOf(obj2), proto
);

注意事项:

  • 在创建对象时设置对象的原型:行 A,行 C

  • 获取对象的原型:行 B

每个对象继承其原型的所有属性:

// `obj2` inherits .protoProp from `proto`
assert.equal(
  obj2.protoProp, 'protoProp'
);
assert.deepEqual(
  Reflect.ownKeys(obj2),
  ['objProp'] // own properties of `obj2`
);

对象的非继承属性称为其自有属性。

原型的最重要用途是,多个对象可以通过从公共原型继承来共享方法。

30.2 什么是对象?

JavaScript 中的对象:

  • 对象是一组槽位(键值对)。

  • 公共槽位称为属性

    • 属性键只能是一个字符串或一个符号。
  • 私有槽位只能通过类创建,并在“公共槽位(属性)与私有槽位”(§31.2.4)中解释。

30.2.1 使用对象的方式

在 JavaScript 中有两种使用对象的方式:

  • 固定布局对象:以这种方式使用,对象就像数据库中的记录。它们有固定数量的属性,其键在开发时已知。它们的值通常具有不同的类型。

    const fixedLayoutObject = {
      product: 'carrot',
      quantity: 4,
    };
    
    
  • 字典对象:以这种方式使用,对象就像查找表或映射。它们有可变数量的属性,其键在开发时未知。它们的值具有相同的类型。

    const dictionaryObject = {
      ['one']: 1,
      ['two']: 2,
    };
    
    

注意,两种方式也可以混合使用:一些对象既是固定布局对象也是字典对象。

使用对象的方式会影响本章中对它们的解释:

  • 首先,我们将探索固定布局对象。 尽管在底层属性键是字符串或符号,但它们将对我们显示为固定标识符。

  • 稍后我们将探索字典对象。 注意,映射通常比对象更好的字典。然而,我们将遇到的一些操作对于固定布局对象也是有用的。

30.3 固定布局对象

让我们先探索固定布局对象

30.3.1 对象字面量:属性

对象字面量是创建固定布局对象的一种方式。它是 JavaScript 的一个突出特点:我们可以直接创建对象——不需要类!以下是一个示例:

const jane = {
  first: 'Jane',
  last: 'Doe', // optional trailing comma
};

在示例中,我们通过对象字面量创建了一个对象,它以大括号 {} 开头和结尾。在其中,我们定义了两个属性(键值对):

  • 第一个属性具有键 first 和值 'Jane'

  • 第二个属性具有键 last 和值 'Doe'

自 ES5 以来,对象字面量允许使用尾随逗号。

我们将稍后看到其他指定属性键的方法,但使用这种方法指定时,它们必须遵循 JavaScript 变量名的规则。例如,我们可以使用 first_name 作为属性键,但不能使用 first-name)。然而,允许使用保留字:

const obj = {
  if: true,
  const: true,
};

为了检查各种操作对对象的影响,我们将在本章的这一部分偶尔使用 Object.keys()。它列出了属性键:

> Object.keys({a:1, b:2})
[ 'a', 'b' ]

30.3.2 对象字面量:属性值简写

当属性的值通过具有与键相同名称的变量定义时,我们可以省略键。

function createPoint(x, y) {
  return {x, y}; // Same as: {x: x, y: y}
}
assert.deepEqual(
  createPoint(9, 2),
  { x: 9, y: 2 }
);

30.3.3 获取属性

这是我们如何获取(读取)一个属性(行 A):

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

// Get property .first
assert.equal(jane.first, 'Jane'); // (A)

获取一个未知属性会产生 undefined

assert.equal(jane.unknownProperty, undefined);

30.3.4 设置属性

这是我们如何设置(写入)一个属性(行 A):

const obj = {
  prop: 1,
};
assert.equal(obj.prop, 1);
obj.prop = 2; // (A)
assert.equal(obj.prop, 2);

我们通过设置更改了一个现有的属性。如果我们设置一个未知的属性,我们将创建一个新的条目:

const obj = {}; // empty object
assert.deepEqual(
  Object.keys(obj), []);

obj.unknownProperty = 'abc';
assert.deepEqual(
  Object.keys(obj), ['unknownProperty']);

30.3.5 对象字面量:方法

以下代码展示了如何通过对象字面量创建方法 .says()

const jane = {
  first: 'Jane', // value property
  says(text) {   // method
    return `${this.first} says “${text}”`; // (A)
  }, // comma as separator (optional at end)
};
assert.equal(jane.says('hello'), 'Jane says “hello”');

在方法调用 jane.says('hello') 中,jane 被称为方法调用的接收者,并分配给特殊变量 this(关于 this 的更多信息请参阅“方法和特殊变量 this” (§30.6))。这使得方法 .says() 能够访问行 A 中的兄弟属性 .first

练习图标“exercise” 练习:通过对象字面量创建对象

exercises/objects/color_point_object_test.mjs

30.3.6 对象字面量:访问器

访问器是通过访问属性来调用的方法。它由以下一个或两个组成:

  • 获取器是通过获取属性来调用的。

  • 设置器是通过设置属性来调用的。

30.3.6.1 获取器

通过在方法定义前缀添加修饰符 get 来创建获取器:

const jane = {
  first: 'Jane',
  last: 'Doe',
  get full() {
    return `${this.first} ${this.last}`;
  },
};

assert.equal(jane.full, 'Jane Doe');
jane.first = 'John';
assert.equal(jane.full, 'John Doe');

30.3.6.2 设置器

通过在方法定义前缀添加修饰符 set 创建一个设置器:

const jane = {
  first: 'Jane',
  last: 'Doe',
  set full(fullName) {
    const parts = fullName.split(' ');
    this.first = parts[0];
    this.last = parts[1];
  },
};

jane.full = 'Richard Roe';
assert.equal(jane.first, 'Richard');
assert.equal(jane.last, 'Roe');

30.3.6.3 getter 的使用场景:值会改变的只读属性

在以下代码中,计数器的实际值是私有的。从外部,它只能通过 getter 读取:

function createCounter() {
 // Private data via closure
 let value = 0;
 return {
 get value() {
 return value;
 },
 inc() {
 value++;
 },
 };
}

const counter = createCounter();
assert.equal(counter.value, 0);

counter.inc();
assert.equal(counter.value, 1);

assert.throws(
 () => counter.value = 5,
 /^TypeError: Cannot set property value of #<Object> which has only a getter$/
); 

Icon “exercise”练习:通过对象实现堆栈

exercises/objects/stack-via-object_test.mjs

30.3.6.4 getter 的使用场景:从属性切换到更多封装

在面向对象编程中,我们担心暴露过多的内部状态。访问器使我们能够在不破坏现有代码的情况下改变我们对属性的看法:我们可以从暴露开始,使用正常属性,然后切换到访问器和更多的封装。

30.4 将传播应用于对象字面量(...)^(ES2018)

在对象字面量内部,一个 传播属性 将另一个对象的属性添加到当前对象中:

const obj1 = {a: 1, b: 2};
const obj2 = {c: 3};
assert.deepEqual(
  {...obj1, ...obj2, d: 4},
  {a: 1, b: 2, c: 3, d: 4}
);

如果属性键冲突,最后提到的属性“获胜”:

> const obj = {one: 1, two: 2, three: 3};
> {...obj, one: true}
{ one: true, two: 2, three: 3 }
> {one: true, ...obj}
{ one: 1, two: 2, three: 3 }

所有值都是可传播的,即使是 undefinednull

> {...undefined}
{}
> {...null}
{}
> {...123}
{}
> {...'abc'}
{ '0': 'a', '1': 'b', '2': 'c' }
> {...['a', 'b']}
{ '0': 'a', '1': 'b' }

字符串和数组中 .length 属性对此类操作是隐藏的(它不是 可枚举的;有关更多信息,请参阅“属性属性和属性描述符^(ES5) (高级)” (§30.10))。

传播包括键为符号(由 Object.keys()Object.values()Object.entries() 忽略)的属性:

const symbolKey = Symbol('symbolKey');
const obj = {
  stringKey: 1,
  [symbolKey]: 2,
};
assert.deepEqual(
  {...obj, anotherStringKey: 3},
  {
    stringKey: 1,
    [symbolKey]: 2,
    anotherStringKey: 3,
  }
);

30.4.1 传播的使用场景:缺失属性的默认值

如果我们的代码的输入之一是一个包含数据的对象,我们可以通过指定默认值来使属性可选,如果这些属性缺失,则使用这些默认值。完成此操作的一种技术是通过一个其属性包含默认值的对象。在以下示例中,该对象是 DEFAULTS

const DEFAULTS = {alpha: 'a', beta: 'b'};
const providedData = {alpha: 1};

const allData = {...DEFAULTS, ...providedData};
assert.deepEqual(allData, {alpha: 1, beta: 'b'});

结果,对象 allData 是通过复制 DEFAULTS 并用 providedData 的属性覆盖其属性创建的。

但我们不需要对象来指定默认值;我们也可以在对象字面量内部单独指定它们:

const providedData = {alpha: 1};

const allData = {alpha: 'a', beta: 'b', ...providedData};
assert.deepEqual(allData, {alpha: 1, beta: 'b'});

30.4.2 传播的使用场景:非破坏性地更改属性

到目前为止,我们已经遇到了一种更改对象属性 .alpha 的方法:我们 设置 它(行 A)并修改对象。也就是说,这种更改属性的方式是破坏性的。

const obj = {alpha: 'a', beta: 'b'};
obj.alpha = 1; // (A)
assert.deepEqual(obj, {alpha: 1, beta: 'b'});

使用传播,我们可以非破坏性地更改 .alpha – 我们创建一个 obj 的副本,其中 .alpha 的值不同:

const obj = {alpha: 'a', beta: 'b'};
const updatedObj = {...obj, alpha: 1};
assert.deepEqual(updatedObj, {alpha: 1, beta: 'b'});

Icon “exercise”练习:通过传播(固定键)非破坏性地更新属性

exercises/objects/update_name_test.mjs

30.4.3 “破坏性展开”:Object.assign() (ES6)

Object.assign() 是一个工具方法:

Object.assign(target, source_1, source_2, ···)

这个表达式将 source_1 的所有属性赋值给 target,然后是 source_2 的所有属性,等等。最后,它返回 target——例如:

const target = { a: 1 };

const result = Object.assign(
  target,
  {b: 2},
  {c: 3, b: true}
);

assert.deepEqual(
  result, { a: 1, b: true, c: 3 }
);
// target was modified and returned:
assert.equal(result, target);

Object.assign() 的用例与展开属性的用例相似。在某种程度上,它是破坏性地展开。

30.5 复制对象:展开与 Object.assign()structuredClone()

30.5.1 通过展开复制对象是浅复制

在 JavaScript 中复制数组和普通对象的一种常见方法是使用展开。以下代码演示了后者:

const obj = {id: 'e1fd960b', values: ['a', 'b']};
const shallowCopy = {...obj};

可惜,这种复制方式是浅复制:属性(键值对)被复制,但属性值没有被复制。

一方面,键值对 shallowCopy.id 是一个复制,所以修改它不会改变 obj

shallowCopy.id = 'yes';
assert.equal(obj.id, 'e1fd960b');

另一方面,shallowCopy.values 中的数组与 obj 共享。如果我们修改它,也会修改 obj

shallowCopy.values.push('x');
assert.deepEqual(
  shallowCopy, {id: 'yes', values: ['a', 'b', 'x']}
);
assert.deepEqual(
  obj, {id: 'e1fd960b', values: ['a', 'b', 'x']}
);

通过 Object.assign() 复制与通过展开类似,也是浅复制:

const obj = {id: 'e1fd960b', values: ['a', 'b']};
// Copy the properties of `obj` into a new object
const shallowCopy = Object.assign({}, obj);

30.5.2 通过 structuredClone() 深度复制对象

structuredClone() 是一个用于复制的函数。尽管它不是 ECMAScript 的一部分,但它被所有主要的 JavaScript 平台良好支持。良好支持。它有以下类型签名:

structuredClone(value: any): any

structuredClone() 深度复制对象:

const obj = {id: 'e1fd960b', values: ['a', 'b']};
const deepCopy = structuredClone(obj);

deepCopy.values.push('x');
assert.deepEqual(
  deepCopy, {id: 'e1fd960b', values: ['a', 'b', 'x']}
);
assert.deepEqual(
  obj, {id: 'e1fd960b', values: ['a', 'b']}
);

图标“详情”structuredClone() 有第二个参数

structuredClone() 有一个超出本章范围的第二个参数。更多信息,请参阅:

30.5.3 structuredClone() 可以复制哪些值?

  • 它可以复制所有原始值,除了符号。

  • 它可以复制所有内置对象,除了函数和 DOM 节点。

  • 用户定义类的实例变为普通对象。

  • 私有字段不会被复制。

  • 循环引用被正确复制。

由于 structuredClone() 的原始用途是将对象复制到其他进程,这些限制是有意义的。

继续阅读以获取更多信息。

30.5.3.1 大多数原始值都可以复制
> typeof structuredClone(true)
'boolean'
> typeof structuredClone(123)
'number'
> typeof structuredClone(123n)
'bigint'
> typeof structuredClone('abc')
'string'

30.5.3.2 大多数内置对象可以被复制

数组和普通对象可以被复制:

> structuredClone({prop: true})
{ prop: true }
> structuredClone(['a', 'b', 'c'])
[ 'a', 'b', 'c' ]

大多数内置类的实例可以被复制——即使它们有内部槽位。它们仍然是它们类的一个实例。

> structuredClone(/^a+$/) instanceof RegExp
true
> structuredClone(new Date()) instanceof Date
true

30.5.3.3 复制符号和一些对象会产生异常

符号和一些对象不能被复制——如果我们尝试复制它们,或者尝试复制包含它们的对象,structuredClone() 会抛出 DOMException

  • 符号

  • 函数(普通函数、箭头函数、类、方法)

  • DOM 节点

示例——克隆符号:

> structuredClone(Symbol())
DOMException [DataCloneError]: Symbol() could not be cloned.
> structuredClone({[Symbol()]: true}) // property is ignored
{}
> structuredClone({prop: Symbol()})
DOMException [DataCloneError]: Symbol() could not be cloned.

示例——克隆函数:

> structuredClone(function () {}) // ordinary function
DOMException [DataCloneError]: function () {} could not be cloned.
> structuredClone(() => {}) // arrow function
DOMException [DataCloneError]: () => {} could not be cloned.
> structuredClone(class {})
DOMException [DataCloneError]: class {} could not be cloned.

> structuredClone({ m(){} }.m) // method
DOMException [DataCloneError]: m(){} could not be cloned.
> structuredClone({ m(){} }) // object with method
DOMException [DataCloneError]: m(){} could not be cloned. 

structuredClone() 抛出的异常是什么样的?

try {
  structuredClone(() => {});
} catch (err) {
  assert.equal(
    err instanceof DOMException, true
  );
  assert.equal(
    err.name, 'DataCloneError'
  );
  assert.equal(
    err.code, DOMException.DATA_CLONE_ERR
  );
}

30.5.3.4 用户定义类的实例变为普通对象

在以下示例中,我们复制了类 C 的一个实例。结果 copy 不是一个 C 的实例。

class C {}
const copy = structuredClone(new C());

assert.equal(copy instanceof C, false);
assert.equal(
  Object.getPrototypeOf(copy),
  Object.prototype
);

30.5.3.5 私有字段不会被复制

这种限制与前面的子节有关——私有字段不会被 structuredClone() 复制:

class C {
  static hasPrivateField(value) {
    return #privateField in value;
  }
  #privateField = true;
}

const original = new C();
assert.equal(
  C.hasPrivateField(original), true
);
const copy = structuredClone(original);
assert.equal(
  C.hasPrivateField(copy), false
);

30.5.3.6 循环引用被正确复制

如果我们复制一个具有引用循环的对象,结果具有相同的结构:

const cycle = {};
cycle.prop = cycle;

const copy = structuredClone(cycle);
assert.equal(
  copy.prop, copy
);

30.5.4 复制对象的属性属性

structuredClone() 并不总是忠实地复制对象的 属性属性:

  • 访问器被转换为数据属性。

  • 在复制中,属性属性始终具有默认值。

查看更多信息。

30.5.4.1 访问器变为数据属性

访问器变为数据属性:

const obj = Object.defineProperties(
  {},
  {
    accessor: {
      get: function () {
 return 123;
 },
 set: undefined,
 enumerable: true,
 configurable: true,
 },
 }
);
const copy = structuredClone(obj);
assert.deepEqual(
 Object.getOwnPropertyDescriptors(copy),
 {
 accessor: {
 value: 123,
 writable: true,
 enumerable: true,
 configurable: true,
 },
 }
);

30.5.4.2 属性复制具有默认属性值

复制的数据属性始终具有以下属性:

writable: true,
enumerable: true,
configurable: true,

const obj = Object.defineProperties(
  {},
  {
    readOnlyProp: {
      value: 'abc',
      writable: false,
      enumerable: true,
      configurable: false,
    },
  }
);
const copy = structuredClone(obj);
assert.deepEqual(
  Object.getOwnPropertyDescriptors(copy),
  {
    readOnlyProp: {
      value: 'abc',
      writable: true,
      enumerable: true,
      configurable: true,
    }
  }
);

30.5.5 没有 structuredClone() 限制的替代方案?

如果我们不能忍受 structuredClone() 的限制,例如将类的实例转换为普通对象,我们可以使用 Lodash 函数 cloneDeep()(它具有更少的限制)。

30.5.6 本节来源

30.6 方法和特殊变量 this

30.6.1 方法值是函数的属性

让我们回顾一下用于介绍方法的示例:

const jane = {
  first: 'Jane',
  says(text) {
    return `${this.first} says “${text}”`;
  },
};

有点令人惊讶,方法实际上是函数:

assert.equal(typeof jane.says, 'function');

为什么会这样?我们在关于可调用值的章节(ch_callables.html#roles-of-ordinary-functions)中了解到,普通函数扮演着多个角色。方法是其中之一。因此,在内部,jane 大概如下所示。

const jane = {
  first: 'Jane',
  says: function (text) {
    return `${this.first} says “${text}”`;
  },
};

30.6.2 特殊变量 this

考虑以下代码:

const obj = {
  someMethod(x, y) {
    assert.equal(this, obj); // (A)
    assert.equal(x, 'a');
    assert.equal(y, 'b');
  }
};
obj.someMethod('a', 'b'); // (B)

在行 B 中,obj是方法调用的接收者。它通过一个名为this的隐式(隐藏)参数传递给存储在obj.someMethod中的函数(行 A)。

图标“提示” 如何理解this

理解this的最佳方式是将它视为普通函数和方法的隐式参数。

30.6.3 方法与 .call()

方法是函数,函数本身也有方法。其中一种方法是.call()。让我们通过一个示例来了解这个方法是如何工作的。

在上一节中,有这个方法调用:

obj.someMethod('a', 'b')

这个调用等价于:

obj.someMethod.call(obj, 'a', 'b');

这也是等价的:

const func = obj.someMethod;
func.call(obj, 'a', 'b');

.call()使通常隐式的参数this显式:通过.call()调用函数时,第一个参数是this,后面跟着常规(显式)函数参数。

作为旁注,这意味着实际上存在两个不同的点操作符:

  1. 用于访问属性:obj.prop

  2. 另一个用于调用方法:obj.prop()

它们在以下方面不同:(2)不仅仅是(1)后面跟着函数调用操作符()。相反,(2)还提供了this的值。

30.6.4 方法与 .bind()

.bind()是函数对象的一种方法。在下面的代码中,我们使用.bind()将方法.says()转换为独立的函数func()

const jane = {
  first: 'Jane',
  says(text) {
    return `${this.first} says “${text}”`; // (A)
  },
};

const func = jane.says.bind(jane, 'hello');
assert.equal(func(), 'Jane says “hello”');

通过.bind()this设置为jane在这里至关重要。否则,func()将无法正常工作,因为this在行 A 中使用。在下一节中,我们将探讨这是为什么。

30.6.5 this陷阱:提取方法

我们现在对函数和方法有了相当多的了解,准备看看涉及方法和this的最大陷阱:如果我们不小心,从对象中提取的方法进行函数调用可能会失败。

在以下示例中,当我们从jane.says()中提取方法,将其存储在变量func中,并调用func时,我们失败了。

const jane = {
  first: 'Jane',
  says(text) {
    return `${this.first} says “${text}”`;
  },
};
const func = jane.says; // extract the method
assert.throws(
  () => func('hello'), // (A)
  {
    name: 'TypeError',
    message: "Cannot read properties of undefined (reading 'first')",
  }
);

在行 A 中,我们正在进行正常的函数调用。在正常的函数调用中,thisundefined(如果严格模式处于活动状态,这几乎总是如此)。因此,行 A 等价于:

assert.throws(
  () => jane.says.call(undefined, 'hello'), // `this` is undefined!
  {
    name: 'TypeError',
    message: "Cannot read properties of undefined (reading 'first')",
  }
);

我们如何修复这个问题?我们需要使用.bind()来提取方法.says()

const func2 = jane.says.bind(jane);
assert.equal(func2('hello'), 'Jane says “hello”');

.bind()确保在调用func()this始终是jane

我们还可以使用箭头函数来提取方法:

const func3 = text => jane.says(text);
assert.equal(func3('hello'), 'Jane says “hello”');

30.6.5.1 示例:提取方法

以下是在实际 Web 开发中可能看到的简化代码版本:

class ClickHandler {
  constructor(id, elem) {
    this.id = id;
    elem.addEventListener('click', this.handleClick); // (A)
  }
  handleClick(event) {
    alert('Clicked ' + this.id);
  }
}

在行 A 中,我们没有正确提取方法.handleClick()。相反,我们应该这样做:

const listener = this.handleClick.bind(this);
elem.addEventListener('click', listener);

// Later, possibly:
elem.removeEventListener('click', listener);

每次调用.bind()都会创建一个新的函数。这就是为什么如果我们想在以后删除它,我们需要将结果存储在某个地方的原因。

30.6.5.2 避免提取方法的陷阱

可惜,没有简单的方法可以绕过提取方法的陷阱:每次我们提取一个方法时,我们必须小心并正确地执行它——例如,通过绑定 this 或使用箭头函数。

图标“练习” 练习:提取方法

exercises/objects/method_extraction_exrc.mjs

30.6.6 this 陷阱:意外覆盖 this

图标“提示” 意外覆盖 this 只是在普通函数中是一个问题

箭头函数不会覆盖 this

考虑以下问题:当我们处于普通函数内部时,我们无法访问周围作用域的 this,因为普通函数有自己的 this。换句话说,内层作用域中的变量会隐藏外层作用域中的变量。这被称为覆盖。以下代码是一个示例:

const prefixer = {
  prefix: '==> ',
  prefixStringArray(stringArray) {
    return stringArray.map(
      function (x) {
        return this.prefix + x; // (A)
      });
  },
};
assert.throws(
  () => prefixer.prefixStringArray(['a', 'b']),
  {
    name: 'TypeError',
    message: "Cannot read properties of undefined (reading 'prefix')",
  }
);

在行 A,我们想要访问 .prefixStringArray()this。但我们无法做到,因为周围普通函数有自己的 this,它会覆盖(并阻止访问)方法的 this。由于回调是函数调用,前者的 this 值是 undefined。这解释了错误信息。

解决这个问题的最简单方法是使用箭头函数,它没有自己的 this,因此不会覆盖任何内容:

const prefixer = {
  prefix: '==> ',
  prefixStringArray(stringArray) {
    return stringArray.map(
      (x) => {
        return this.prefix + x;
      });
  },
};
assert.deepEqual(
  prefixer.prefixStringArray(['a', 'b']),
  ['==> a', '==> b']);

我们也可以将 this 存储在不同的变量中(行 A),这样它就不会被覆盖:

prefixStringArray(stringArray) {
  const that = this; // (A)
  return stringArray.map(
    function (x) {
      return that.prefix + x;
    });
},

另一个选项是通过 .bind() 为回调指定一个固定的 this(行 A):

prefixStringArray(stringArray) {
  return stringArray.map(
    function (x) {
      return this.prefix + x;
    }.bind(this)); // (A)
},

最后,.map() 允许我们指定一个值作为 this(行 A),它在调用回调时使用:

prefixStringArray(stringArray) {
  return stringArray.map(
    function (x) {
      return this.prefix + x;
    },
    this); // (A)
},

30.6.6.1 避免意外覆盖 this 的陷阱

如果我们遵循“建议:优先使用专用函数而不是普通函数”(§27.3.4)的建议,我们可以避免意外覆盖 this 的陷阱。以下是总结:

  • 将箭头函数用作匿名内联函数。它们没有 this 作为隐式参数,也不会覆盖它。

  • 对于命名的独立函数声明,我们可以使用箭头函数或函数声明。如果我们选择后者,我们必须确保在它们的主体中不提及 this

30.6.7 在各种上下文中 this 的值(高级)

在各种上下文中 this 的值是什么?

在可调用实体内部,this 的值取决于如何调用该可调用实体以及它是什么类型:

  • 函数调用:

    • 普通函数:this === undefined(在严格模式下)

    • 箭头函数:this 与周围作用域相同(词法 this

  • 方法调用:this 是调用的接收者

  • newthis 指向新创建的实例

我们还可以在所有常见的顶层作用域中访问 this

  • <script> 元素:this === globalThis

  • ECMAScript 模块:this === undefined

  • CommonJS 模块:this === module.exports

图标“提示”提示:在顶层作用域中假装 this 不存在

我喜欢这样做,因为顶层 this 很令人困惑,并且有更好的替代方案来解决其(少数)用例。

30.7 可选链用于属性获取和方法调用(ES2020)(高级)

存在以下类型的可选链操作:

obj?.prop     // optional fixed property getting
obj?.[«expr»] // optional dynamic property getting
func?.(«arg0», «arg1», ···) // optional function or method call

大致的想法是:

  • 如果问号前的值既不是 undefined 也不是 null,则执行问号后的操作。

  • 否则,返回 undefined

三个语法中的每一个都会在稍后更详细地介绍。以下是一些初步示例:

> null?.prop
undefined
> {prop: 1}?.prop
1

> null?.(123)
undefined
> String?.(123)
'123'

图标“提示”可选链操作符(?.)的助记符

你是否偶尔不确定可选链操作符是以点(.?)还是问号(?.)开头?那么这个助记符可能对你有帮助:

  • 如果 (?) 左侧不是空值

  • then (.) 访问一个属性。

30.7.1 示例:可选固定属性获取

考虑以下数据:

const persons = [
  {
    surname: 'Zoe',
    address: {
      street: {
        name: 'Sesame Street',
        number: '123',
      },
    },
  },
  {
    surname: 'Mariner',
  },
  {
    surname: 'Carmen',
    address: {
    },
  },
];

我们可以使用可选链安全地提取街道名称:

const streetNames = persons.map(
  p => p.address?.street?.name);
assert.deepEqual(
  streetNames, ['Sesame Street', undefined, undefined]
);

30.7.1.1 通过空值合并处理默认值

空值合并操作符 允许我们使用默认值 '(no name)' 而不是 undefined

const streetNames = persons.map(
  p => p.address?.street?.name ?? '(no name)');
assert.deepEqual(
  streetNames, ['Sesame Street', '(no name)', '(no name)']
);

30.7.2 详细介绍操作符(高级)

30.7.2.1 可选固定属性获取

以下两个表达式是等价的:

o?.prop
(o !== undefined && o !== null) ? o.prop : undefined

示例:

assert.equal(undefined?.prop, undefined);
assert.equal(null?.prop,      undefined);
assert.equal({prop:1}?.prop,  1);

30.7.2.2 可选动态属性获取

以下两个表达式是等价的:

o?.[«expr»]
(o !== undefined && o !== null) ? o[«expr»] : undefined

示例:

const key = 'prop';
assert.equal(undefined?.[key], undefined);
assert.equal(null?.[key], undefined);
assert.equal({prop:1}?.[key], 1);

30.7.2.3 可选函数或方法调用

以下两个表达式是等价的:

f?.(arg0, arg1)
(f !== undefined && f !== null) ? f(arg0, arg1) : undefined

示例:

assert.equal(undefined?.(123), undefined);
assert.equal(null?.(123), undefined);
assert.equal(String?.(123), '123');

注意,如果其左侧不是可调用的,则此操作符会产生错误:

assert.throws(
  () => true?.(123),
  TypeError);

为什么?这个想法是,操作符只容忍故意的省略。不可调用的值(除了 undefinednull)可能是一个错误,应该报告,而不是绕过。

30.7.3 使用可选属性获取进行短路

在属性获取和方法调用链中,一旦第一个可选操作符在其左侧遇到 undefinednull,评估就会停止:

function invokeM(value) {
  return value?.a.b.m(); // (A)
}

const obj = {
  a: {
    b: {
      m() { return 'result' }
 }
 }
};
assert.equal(
 invokeM(obj), 'result'
);
assert.equal(
 invokeM(undefined), undefined // (B)
);

考虑 B 行中的 invokeM(undefined)undefined?.aundefined。因此我们预计 A 行中的 .b 会失败。但它没有:?. 操作符遇到值 undefined,整个表达式的评估立即返回 undefined

这种行为与正常操作符不同,JavaScript 总是在评估操作符之前评估所有操作数。这被称为短路。其他短路操作符包括:

  • (a && b): 只有当 a 是真值时,才会评估 b

  • (a || b): 只有当 a 是假值时,才会评估 b

  • (c ? t : e): 如果 c 是真值,则评估 t。否则,评估 e

30.7.4 可选链:缺点和替代方案

可选链也有缺点:

  • 深层嵌套的结构更难管理。例如,如果有许多属性名序列,重构会更困难:每个序列都强制多个对象的结构。

  • 在访问数据时过于宽容会隐藏出问题,这些问题会在以后显现出来,那时调试会更困难。例如,在一系列可选属性名中早期出现的拼写错误比普通拼写错误有更负面的影响。

可选链的另一种替代方法是提取信息一次,在单个位置:

  • 我们可以编写一个辅助函数来提取数据。

  • 或者我们可以编写一个函数,其输入是深层嵌套的数据,其输出是更简单、规范化的数据。

使用任何一种方法,如果存在问题,都有可能进行检查并在早期失败。

进一步阅读:

30.7.5 常见问题

30.7.5.1 为什么 o?.[x]f?.() 中有点?

以下两个可选操作符的语法并不理想:

obj?.[«expr»]          // better: obj?[«expr»]
func?.(«arg0», «arg1») // better: func?(«arg0», «arg1»)

可惜,这种不太优雅的语法是必要的,因为区分理想语法(第一个表达式)和条件操作符(第二个表达式)太复杂了:

obj?['a', 'b', 'c'].map(x => x+x)
obj ? ['a', 'b', 'c'].map(x => x+x) : []

30.7.5.2 为什么 null?.prop 评估为 undefined 而不是 null

操作符 ?. 主要关注其右侧:属性 .prop 是否存在?如果不存在,则提前停止。因此,保留其左侧的信息很少是有用的。然而,只有一个“早期终止”值确实简化了事情。

30.8 原型链

原型是 JavaScript 的唯一继承机制:每个对象都有一个原型,该原型要么是 null,要么是一个对象。在后一种情况下,对象继承原型上的所有属性。

在对象字面量中,我们可以通过特殊属性 __proto__ 设置原型:

const proto = {
  protoProp: 'a',
};
const obj = {
  __proto__: proto,
  objProp: 'b',
};

// obj inherits .protoProp:
assert.equal(obj.protoProp, 'a');
assert.equal('protoProp' in obj, true);

由于原型对象本身可以有原型,我们得到一个对象链——所谓的原型链。继承让我们有单对象的印象,但实际上我们处理的是对象链。

图 30.2 展示了 obj 的原型链看起来像什么。

图 30.2:obj 开始一个对象链,该链继续与 proto 和其他对象相连。

非继承属性被称为自身属性obj有一个自身属性,.objProp

30.8.1 JavaScript 的操作:所有属性与自身属性

一些操作考虑所有属性(自身和继承的)——例如,获取属性:

> const obj = { one: 1 };
> typeof obj.one // own
'number'
> typeof obj.toString // inherited
'function'

其他操作仅考虑自身属性——例如,Object.keys()

> Object.keys(obj)
[ 'one' ]

继续阅读,了解另一个仅考虑自身属性的运算:设置属性。

30.8.2 漏洞:原型链的第一个成员是唯一被修改的

给定一个具有原型对象链的对象obj,设置obj的自身属性只会改变obj是有意义的。然而,通过obj设置继承属性也只会改变obj。它在obj中创建了一个新的自身属性,覆盖了继承属性。让我们通过以下对象来探索它是如何工作的:

const proto = {
  protoProp: 'a',
};
const obj = {
  __proto__: proto,
};

在下一个代码片段中,我们设置了继承属性obj.protoProp(行 A)。通过创建一个自身属性来“改变”它:当读取obj.protoProp时,首先找到自身属性,其值覆盖了继承属性的值。

// In the beginning, obj has no own properties
assert.deepEqual(Object.keys(obj), []);

obj.protoProp = 'x'; // (A)

// We created an own property:
assert.deepEqual(Object.keys(obj), ['protoProp']);

// The inherited property itself is unchanged:
assert.equal(proto.protoProp, 'a');

// The own property overrides the inherited property:
assert.equal(obj.protoProp, 'x');

obj的原型链在图 30.3 中展示。

图 30.3:obj的自身属性.protoProp覆盖了从proto继承的属性。

30.8.3 使用原型的技巧(高级)

30.8.3.1 获取和设置原型

关于属性键__proto__的建议:

  • 不要使用所有Object实例都具有的访问器Object.prototype.__proto__

    • 它不能与所有对象一起使用——例如,不是Object实例的对象没有它。

    • 它在 ECMAScript 规范中已被弃用。

    有关此功能的更多信息,请参阅“Object.prototype.__proto__ (访问器) (§31.9.7)”.

  • 在对象字面量中使用属性键__proto__来指定原型是不同的:这是一个仅适用于对象字面量的特性,碰巧与已弃用的访问器有相同的名称。

获取和设置原型的推荐方法如下:

  • 获取对象的原型:

    Object.getPrototypeOf(obj: object): object
    
    
  • 设置对象原型的最佳时机是在创建对象时。我们可以通过对象字面量中的__proto__或以下方式来实现:

    Object.create(proto: object): object
    
    
  • 如果必须,我们可以使用Object.setPrototypeOf()来更改现有对象的原型。但这可能会对该对象的表现产生负面影响。

这是这些功能的使用方式:

// Two objects with null prototypes
const obj1 = {__proto__: null};
const obj2 = Object.create(null);

assert.equal(
  Object.getPrototypeOf(obj1), null
);

const proto = {};
Object.setPrototypeOf(obj1, proto);
assert.equal(
  Object.getPrototypeOf(obj1), proto
);

30.8.3.2 检查一个对象是否在另一个对象的原型链中

到目前为止,“protoobj 的原型”总是意味着“protoobj直接 原型”。但它也可以更宽松地使用,表示 protoobj 的原型链中的一员。这种更宽松的关系可以通过 .isPrototypeOf() 来检查:

例如:

const a = {};
const b = {__proto__: a};
const c = {__proto__: b};

assert.equal(a.isPrototypeOf(b), true);
assert.equal(a.isPrototypeOf(c), true);

assert.equal(c.isPrototypeOf(a), false);
assert.equal(a.isPrototypeOf(a), false);

更多关于此方法的信息,请参阅 “Object.prototype.isPrototypeOf() (ES3)” (§31.9.5)。

30.8.4 Object.hasOwn(): 给定属性是自身的(非继承的)?^(ES2022)

in 操作符(行 A)检查对象是否具有给定的属性。相比之下,Object.hasOwn()(行 B 和 C)检查属性是否是自身的。

const proto = {
  protoProp: 'protoProp',
};
const obj = {
  __proto__: proto,
  objProp: 'objProp',
}
assert.equal('protoProp' in obj, true); // (A)
assert.equal(Object.hasOwn(obj, 'protoProp'), false); // (B)
assert.equal(Object.hasOwn(proto, 'protoProp'), true); // (C)

图标“提示”ES2022 之前的替代方案:.hasOwnProperty()

在 ES2022 之前,我们可以使用另一个特性:“Object.prototype.hasOwnProperty() (ES3)” (§31.9.8)。这个特性有陷阱,但参考部分解释了如何绕过它们。

30.8.5 通过原型共享数据

考虑以下代码:

const jane = {
  firstName: 'Jane',
  describe() {
 return 'Person named '+this.firstName;
 },
};
const tarzan = {
 firstName: 'Tarzan',
 describe() {
 return 'Person named '+this.firstName;
 },
};

assert.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan'); 

我们有两个非常相似的对象。它们都有两个属性,属性名为 .firstName.describe。此外,方法 .describe() 是相同的。我们如何避免重复该方法?

我们可以将其移动到对象 PersonProto 中,并使该对象成为 janetarzan 的原型:

const PersonProto = {
  describe() {
 return 'Person named ' + this.firstName;
 },
};
const jane = {
 __proto__: PersonProto,
 firstName: 'Jane',
};
const tarzan = {
 __proto__: PersonProto,
 firstName: 'Tarzan',
};

原型的名称反映了 janetarzan 都是人的事实。

图 30.4:对象 janetarzan 通过它们的公共原型 PersonProto 共享方法 .describe()

图 30.4 展示了三个对象是如何连接的:底部的对象现在包含 janetarzan 特有的属性。顶部的对象包含它们之间共享的属性。

当我们调用方法 jane.describe() 时,this 指向该方法调用的接收者,即 jane(在图的下左角)。这就是为什么该方法仍然有效。tarzan.describe() 的工作方式类似。

assert.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan');

预览下一章关于类的章节——这是类是如何在内部组织的:

  • 所有实例都共享一个具有方法的公共原型。

  • 实例特定的数据存储在每个实例的自身属性中。

“类的内部结构” (§31.3) 更详细地解释了这一点。

30.9 字典对象(高级)

对象作为固定布局对象工作得最好。但在 ES6 之前,JavaScript 没有字典数据结构(ES6 带来了 Maps)。因此,对象必须用作字典,这强加了一个显著的约束:字典键必须是字符串(ES6 也引入了符号)。

我们首先看看与字典相关但同时也适用于固定布局对象的对象特性。本节以实际使用对象作为字典的技巧结束。(剧透:如果可能的话,最好使用 Maps。)

30.9.1 对象字面量中的引号键

到目前为止,我们一直使用固定布局对象。属性键是固定的标记,必须是有效的标识符并在内部成为字符串:

const obj = {
  mustBeAnIdentifier: 123,
};

// Get property
assert.equal(obj.mustBeAnIdentifier, 123);

// Set property
obj.mustBeAnIdentifier = 'abc';
assert.equal(obj.mustBeAnIdentifier, 'abc');

作为下一步,我们将超越属性键的限制:在本子节中,我们将使用任意的固定字符串作为键。在下一下子节中,我们将动态计算键。

两种语法使我们能够使用任意字符串作为属性键。

首先,在通过对象字面量创建属性键时,我们可以引用属性键(使用单引号或双引号):

const obj = {
  'Can be any string!': 123,
};

其次,在获取或设置属性时,我们可以使用包含字符串的方括号:

// Get property
assert.equal(obj['Can be any string!'], 123);

// Set property
obj['Can be any string!'] = 'abc';
assert.equal(obj['Can be any string!'], 'abc');

我们还可以使用这些语法来定义方法:

const obj = {
  'A nice method'() {
    return 'Yes!';
  },
};

assert.equal(obj['A nice method'](), 'Yes!');

30.9.2 对象字面量中的计算键

在前面的子节中,属性键是通过对象字面量中的固定字符串指定的。在本节中,我们将学习如何动态计算属性键。这使我们能够使用任意字符串或符号。

对象字面量中动态计算属性键的语法受到了动态访问属性的影响。也就是说,我们可以使用方括号来包裹表达式:

const obj = {
  ['Hello world!']: true,
  ['p'+'r'+'o'+'p']: 123,
  [Symbol.toStringTag]: 'Goodbye', // (A)
};

assert.equal(obj['Hello world!'], true);
assert.equal(obj.prop, 123);
assert.equal(obj[Symbol.toStringTag], 'Goodbye');

计算键的主要用途是作为属性键的符号(行 A)。

注意,用于获取和设置属性的方括号运算符与任意表达式一起工作:

assert.equal(obj['p'+'r'+'o'+'p'], 123);
assert.equal(obj['==> prop'.slice(4)], 123);

方法也可以有计算属性键:

const methodKey = Symbol();
const obj = {
  [methodKey]() {
    return 'Yes!';
  },
};

assert.equal(obj[methodKey](), 'Yes!');

在本章的剩余部分,我们将主要再次使用固定属性键(因为它们在语法上更方便)。但所有功能也适用于任意字符串和符号。

“练习”图标 练习:通过扩展(计算键)非破坏性地更新属性

exercises/objects/update_property_test.mjs

30.9.3 in运算符:是否存在具有给定键的属性?

in运算符检查对象是否具有给定键的属性:

const obj = {
  alpha: 'abc',
  beta: false,
};

assert.equal('alpha' in obj, true);
assert.equal('beta' in obj, true);
assert.equal('unknownKey' in obj, false);

30.9.3.1 通过真值检查检查属性是否存在

我们还可以使用真值检查来确定属性是否存在:

assert.equal(
  obj.alpha ? 'exists' : 'does not exist',
  'exists');
assert.equal(
  obj.unknownKey ? 'exists' : 'does not exist',
  'does not exist');

前面的检查之所以有效,是因为obj.alpha是真值,并且读取缺失的属性返回undefined(这是假值)。

然而,有一个重要的注意事项:如果属性存在但具有假值(undefinednullfalse0""等),则真值检查会失败:

assert.equal(
  obj.beta ? 'exists' : 'does not exist',
  'does not exist'); // should be: 'exists'

30.9.4 删除属性

我们可以通过delete运算符删除属性:

const obj = {
  myProp: 123,
};

assert.deepEqual(Object.keys(obj), ['myProp']);
delete obj.myProp;
assert.deepEqual(Object.keys(obj), []);

30.9.5 可枚举性

可枚举性 是属性的一个属性。非可枚举属性在某些操作中被忽略——例如,在 Object.keys() 和属性展开时。默认情况下,大多数属性都是可枚举的。下一个示例将展示如何更改这一点以及它如何影响展开。

const enumerableSymbolKey = Symbol('enumerableSymbolKey');
const nonEnumSymbolKey = Symbol('nonEnumSymbolKey');

// We create enumerable properties via an object literal
const obj = {
  enumerableStringKey: 1,
  [enumerableSymbolKey]: 2,
}

// For non-enumerable properties, we need a more powerful tool
Object.defineProperties(obj, {
  nonEnumStringKey: {
    value: 3,
    enumerable: false,
  },
  [nonEnumSymbolKey]: {
    value: 4,
    enumerable: false,
  },
});

// Non-enumerable properties are ignored by spreading:
assert.deepEqual(
  {...obj},
  {
    enumerableStringKey: 1,
    [enumerableSymbolKey]: 2,
  }
);

Object.defineProperties() 在本章的后面解释。下一小节将展示这些操作如何受可枚举性的影响:

30.9.6 列出属性键通过 Object.keys() 等.

可枚举 不可枚举 字符串 符号
Object.keys()
Object.getOwnPropertyNames()
Object.getOwnPropertySymbols()
Reflect.ownKeys()

表 30.1:列出自有(非继承)属性键的标准库方法。所有这些方法都返回包含字符串和/或符号的数组。

表 30.1 中的每个方法都返回一个包含参数自有属性键的数组。在方法名称中,我们可以看到以下区分:

  • 属性键 可以是字符串或符号。(Object.keys() 较旧,尚未遵循此约定。)

  • 属性名 是一个属性键,其值是一个字符串。

  • 属性符号 是一个属性键,其值是一个符号。

为了演示四种运算,我们回顾前一小节中的示例:

const enumerableSymbolKey = Symbol('enumerableSymbolKey');
const nonEnumSymbolKey = Symbol('nonEnumSymbolKey');

const obj = {
  enumerableStringKey: 1,
  [enumerableSymbolKey]: 2,
}
Object.defineProperties(obj, {
  nonEnumStringKey: {
    value: 3,
    enumerable: false,
  },
  [nonEnumSymbolKey]: {
    value: 4,
    enumerable: false,
  },
});

assert.deepEqual(
  Object.keys(obj),
  ['enumerableStringKey']
);
assert.deepEqual(
  Object.getOwnPropertyNames(obj),
  ['enumerableStringKey', 'nonEnumStringKey']
);
assert.deepEqual(
  Object.getOwnPropertySymbols(obj),
  [enumerableSymbolKey, nonEnumSymbolKey]
);
assert.deepEqual(
  Reflect.ownKeys(obj),
  [
    'enumerableStringKey', 'nonEnumStringKey',
    enumerableSymbolKey, nonEnumSymbolKey,
  ]
);

30.9.7 列出属性值通过 Object.values()

Object.values() 列出对象所有自有可枚举字符串键属性的值:

const firstName = Symbol('firstName');
const obj = {
  [firstName]: 'Jane',
  lastName: 'Doe',
};
assert.deepEqual(
  Object.values(obj),
  ['Doe']);

30.9.8 列出属性条目通过 Object.entries() (ES2017)

Object.entries(obj) 返回一个数组,其中包含每个属性的键值对:

  • 每对都编码为一个两元素数组。

  • 只包括具有字符串键的自有可枚举属性。

const firstName = Symbol('firstName');
const obj = {
  [firstName]: 'Jane',
  lastName: 'Doe',
};
Object.defineProperty(
  obj, 'city', {value: 'Metropolis', enumerable: false}
);
assert.deepEqual(
  Object.entries(obj),
  [
    ['lastName', 'Doe'],
  ]);

30.9.8.1 Object.entries() 的简单实现

以下函数是 Object.entries() 的简化版本:

function entries(obj) {
  return Object.keys(obj)
  .map(key => [key, obj[key]]);
}

图标“练习”练习:Object.entries()

exercises/objects/find_key_test.mjs

30.9.9 属性按确定性列出

对象的自有(非继承)属性始终按以下顺序列出:

  1. 包含整数索引的字符串键属性:

    按升序数值顺序

  2. 剩余的字符串键属性:

    按照它们被添加的顺序

  3. 具有符号键的属性:

    按照它们被添加的顺序

以下示例演示了属性键是按照这些规则排序的:

const obj = {
  b: true,
  a: true,
  10: true,
  2: true,
};
assert.deepEqual(
  Object.keys(obj),
  ['2', '10', 'b', 'a']
);

图标“详情”属性的顺序

ECMAScript 规范更详细地描述了属性是如何排序的。

30.9.9.1 属性顺序为什么是确定的?

作为一种数据结构,对象主要是无序的。因此,我们不会期望,例如,Object.keys() 总是按相同的顺序返回属性键。然而,JavaScript 确实为属性定义了一个确定性的顺序,因为这有助于测试和其他用例。

30.9.10 通过 Object.fromEntries() 组装对象(ES2019)

给定一个 [键,值] 对的可迭代对象,Object.fromEntries() 创建一个对象:

const symbolKey = Symbol('symbolKey');
assert.deepEqual(
  Object.fromEntries(
    [
      ['stringKey', 1],
      [symbolKey, 2],
    ]
  ),
  {
    stringKey: 1,
    [symbolKey]: 2,
  }
);

Object.fromEntries()Object.entries() 的作用相反。然而,虽然 Object.entries() 忽略了以符号为键的属性,但 Object.fromEntries() 不忽略(参见前一个示例)。

为了演示这两个,我们将使用它们来实现下一个子子节中库 Underscore 的两个工具函数。

30.9.10.1 示例:pick()

Underscore 函数 pick() 的签名如下:

pick(object, ...keys)

它返回一个 object 的副本,其中只包含在尾随参数中提到的键的属性:

const address = {
  street: 'Evergreen Terrace',
  number: '742',
  city: 'Springfield',
  state: 'NT',
  zip: '49007',
};
assert.deepEqual(
  pick(address, 'street', 'number'),
  {
    street: 'Evergreen Terrace',
    number: '742',
  }
);

我们可以这样实现 pick()

function pick(object, ...keys) {
  const filteredEntries = Object.entries(object)
    .filter(([key, _value]) => keys.includes(key));
  return Object.fromEntries(filteredEntries);
}

30.9.10.2 示例:invert()

Underscore 函数 invert() 的签名如下:

invert(object)

它返回一个 object 的副本,其中所有属性的键和值都被交换:

assert.deepEqual(
  invert({a: 1, b: 2, c: 3}),
  {1: 'a', 2: 'b', 3: 'c'}
);

我们可以这样实现 invert()

function invert(object) {
  const reversedEntries = Object.entries(object)
    .map(([key, value]) => [value, key]);
  return Object.fromEntries(reversedEntries);
}

30.9.10.3 Object.fromEntries() 的简单实现

以下函数是 Object.fromEntries() 的简化版本:

function fromEntries(iterable) {
  const result = {};
  for (const [key, value] of iterable) {
    let coercedKey;
    if (typeof key === 'string' || typeof key === 'symbol') {
      coercedKey = key;
    } else {
      coercedKey = String(key);
    }
    result[coercedKey] = value;
  }
  return result;
}

图标“练习”练习:使用 Object.entries()Object.fromEntries()

exercises/objects/omit_properties_test.mjs

具有 null 原型的对象是好的字典和查找表

如果我们将普通对象(通过对象字面量创建)用作字典,我们必须注意两个陷阱。

30.9.11.1 陷阱 1:获取继承属性

以下字典对象应该是空的。然而,如果我们读取继承属性,我们会得到一个值(而不是 undefined):

const dict = {};
assert.equal(
  typeof dict['toString'], 'function'
);

dictObject 的一个实例,并从 Object.prototype 继承了 .toString()

30.9.11.2 陷阱 2:检查属性是否存在

如果我们使用 in 操作符来检查属性是否存在,我们再次检测到继承属性:

const dict = {};
assert.equal(
  'toString' in dict, true
);

顺便说一句:Object.hasOwn() 没有这个陷阱。正如其名称所示,它只考虑 自己的(非继承的)属性:

const dict = {};
assert.equal(
  Object.hasOwn(dict, 'toString'), false
);

30.9.11.3 陷阱 3:属性键 '__proto__'

我们不能使用属性键 '__proto__',因为它具有特殊功能(它设置对象的原型):

const dict = {};

dict['__proto__'] = 123;
// No property was added to dict:
assert.deepEqual(
  Object.keys(dict), []
);

30.9.11.4 作为字典的对象具有 null 原型

当涉及到字典时,映射通常是最佳选择:它们有一个方便的方法式 API,并支持字符串和符号之外的键。

然而,具有 null 原型的对象也是相当好的字典,并且没有我们刚才遇到的陷阱:

const dict = Object.create(null);

// No inherited properties
assert.equal(
  dict['toString'], undefined
);
assert.equal(
  'toString' in dict, false
);

// No special behavior with key '__proto__'
dict['__proto__'] = true;
assert.deepEqual(
  Object.keys(dict), ['__proto__']
);

我们避免了陷阱:

  • 没有原型的对象不继承任何内容。因此,获取属性和使用 in 操作符总是安全的。

  • Object.prototype.__proto__ 访问器被关闭,因为 Object.prototype 不是 dict 的原型链中的一部分。

“练习”图标 练习:对象作为字典

  • 以字典形式作为 null 原型对象:exercises/objects/null-proto-obj-dict_test.mjs

  • 以字典形式作为普通对象:exercises/objects/plain-obj-dict_test.mjs

30.9.11.5 作为固定查找表使用的 null 原型对象

null 原型对于用作固定查找表的对象也很有用:

const htmlToLatex = {
  __proto__: null,
  'i': 'textit',
  'b': 'textbf',
  'u': 'underline',
};

30.9.11.6 标准库中的 null 原型

因为它们是好的字典,标准库也在某些位置使用具有 null 原型的对象 – 例如:

  • import.meta 的值:

    assert.equal(
      Object.getPrototypeOf(import.meta), null
    );
    
    
  • Object.groupBy() 的结果:

    const grouped = Object.groupBy([], x => x);
    assert.equal(
      Object.getPrototypeOf(grouped), null
    );
    
    
  • 当匹配正则表达式时 – matchObj.groups 的值:

    const matchObj = /(?<group>x)/.exec('x');
    assert.equal(
      Object.getPrototypeOf(matchObj.groups), null
    );
    
    

30.10 属性属性和属性描述符 (ES5) (高级)

正如对象由属性组成一样,属性由 属性 组成。属性有两种类型,它们通过其属性来区分:

  • 数据属性存储数据。其属性 value 保存任何 JavaScript 值。

    • 方法是数据属性,其值是函数。
  • 访问器属性由一个获取器函数和/或一个设置器函数组成。前者存储在属性 get 中,后者存储在属性 set 中。

此外,还有一些属性两种类型的属性都有。以下表格列出了所有属性及其默认值。

属性类型 属性名称和类型 默认值
所有属性 configurable: boolean false
enumerable: boolean false
数据属性 value: any undefined
writable: boolean false
访问器属性 get: (this: any) => any undefined
set: (this: any, v: any) => void undefined

我们已经遇到了属性 valuegetset。其他属性的工作方式如下:

  • writable 决定了数据属性值是否可以被更改。

  • configurable 决定了属性的属性是否可以被更改。如果它是 false,那么:

    • 我们不能删除该属性。

    • 我们不能将属性从数据属性更改为访问器属性,反之亦然。

    • 我们不能更改除 value 之外的任何属性。

    • 然而,还有一个属性更改是允许的:我们可以将 writabletrue 更改为 false。这种异常背后的原因是历史原因:数组的 .length 属性始终是可写的和非可配置的。允许其 writable 属性被更改使我们能够冻结数组。

  • enumerable 影响某些操作(例如 Object.keys())。如果它是 false,则这些操作会忽略该属性。可枚举性在本章的早期部分有更详细的介绍。

当我们使用处理属性属性的操作之一时,属性通过 属性描述符 指定:每个属性代表一个属性的对象。例如,这就是我们读取属性 obj.myProp 的属性的方式:

const obj = { myProp: 123 };
assert.deepEqual(
  Object.getOwnPropertyDescriptor(obj, 'myProp'),
  {
    value: 123,
    writable: true,
    enumerable: true,
    configurable: true,
  });

这就是改变 obj.myProp 属性的属性的方式:

assert.deepEqual(Object.keys(obj), ['myProp']);

// Hide property `myProp` from Object.keys()
// by making it non-enumerable
Object.defineProperty(obj, 'myProp', {
  enumerable: false,
});

assert.deepEqual(Object.keys(obj), []);

最后,让我们看看方法和获取器是什么样子:

const obj = {
  myMethod() {},
 get myGetter() {},
};
const propDescs = Object.getOwnPropertyDescriptors(obj);
propDescs.myMethod.value = typeof propDescs.myMethod.value;
propDescs.myGetter.get = typeof propDescs.myGetter.get;
assert.deepEqual(
 propDescs,
 {
 myMethod: {
 value: 'function',
 writable: true,
 enumerable: true,
 configurable: true
 },
 myGetter: {
 get: 'function',
 set: undefined,
 enumerable: true,
 configurable: true
 }
 }
);

图标“外部”进一步阅读

关于属性属性和属性描述符的更多信息,请参阅深度 JavaScript

30.11 保护对象不被更改(ES5)(高级)

JavaScript 有三个级别的对象保护:

  • 防止扩展 使无法向对象添加新属性和更改其原型成为可能。尽管如此,我们仍然可以删除和更改属性。

    • 应用:Object.preventExtensions(obj)

    • 检查:Object.isExtensible(obj)

  • 密封 阻止扩展并使所有属性 不可配置(大致上:我们不能再更改属性的工作方式了)。

    • 应用:Object.seal(obj)

    • 检查:Object.isSealed(obj)

  • 冻结 在使所有属性不可写后密封对象。也就是说,对象不可扩展,所有属性都是只读的,并且无法更改。

    • 应用:Object.freeze(obj)

    • 检查:Object.isFrozen(obj)

图标“警告”注意事项:对象仅进行浅层保护

所述的三个 Object.* 方法仅影响对象的顶层,不影响其内部嵌套的对象。

这就是使用 Object.freeze() 的样子:

const frozen = Object.freeze({ x: 2, y: 5 });
assert.throws(
  () => frozen.x = 7,
  {
    name: 'TypeError',
    message: /^Cannot assign to read only property 'x'/,
  }
);

在严格模式下更改冻结属性只会导致异常。宽松模式中,它将静默失败。

图标“外部”进一步阅读

关于冻结和其他锁定对象的方法的更多信息,请参阅深度 JavaScript

30.12 快速参考:Object

30.12.1 Object.*: 创建对象,处理原型

  • Object.create(proto, propDescObj?) ES5

    • 返回一个新的对象,其原型是 proto

    • 可选的propDescObj是一个包含属性描述符的对象,用于在新的对象中定义属性。

    > const obj = Object.create(null);
    > Object.getPrototypeOf(obj)
    null
    
    

    在以下示例中,我们通过第二个参数定义自身属性:

    const obj = Object.create(
      null,
      {
        color: {
          value: 'green',
          writable: true,
          enumerable: true,
          configurable: true,
        },
      }
    );
    assert.deepEqual(
      obj,
      {
        __proto__: null,
        color: 'green',
      }
    );
    
    
  • Object.getPrototypeOf(obj) ES5

    返回obj的原型——它可以是对象或null

    assert.equal(
      Object.getPrototypeOf({__proto__: null}), null
    );
    assert.equal(
      Object.getPrototypeOf({}), Object.prototype
    );
    assert.equal(
      Object.getPrototypeOf(Object.prototype), null
    );
    
    
  • Object.setPrototypeOf(obj, proto) ES6

    obj的原型设置为proto(必须是null或对象)并返回前者。

    const obj = {};
    assert.equal(
      Object.getPrototypeOf(obj), Object.prototype
    );
    Object.setPrototypeOf(obj, null);
    assert.equal(
      Object.getPrototypeOf(obj), null
    );
    
    

30.12.2 Object.*: 属性属性

  • Object.defineProperty(obj, propKey, propDesc) ES5

    • 定义obj中的一个属性,由属性键propKey和属性描述符 propDesc指定。

    • 返回obj

    const obj = {};
    Object.defineProperty(
      obj, 'color',
      {
        value: 'green',
        writable: true,
        enumerable: true,
        configurable: true,
      }
    );
    assert.deepEqual(
      obj,
      {
        color: 'green',
      }
    );
    
    
  • Object.defineProperties(obj, propDescObj) ES5

    • 定义obj中的属性,由包含属性描述符的对象propDescObj指定。

    • 返回obj

    const obj = {};
    Object.defineProperties(
      obj,
      {
        color: {
          value: 'green',
          writable: true,
          enumerable: true,
          configurable: true,
        },
      }
    );
    assert.deepEqual(
      obj,
      {
        color: 'green',
      }
    );
    
    
  • Object.getOwnPropertyDescriptor(obj, propKey) ES5

    • 返回obj的自身属性的一个属性描述符,其键是propKey。如果不存在这样的属性,则返回undefined

    • 更多关于属性描述符的信息:“属性属性和属性描述符^(ES5) (高级)” (§30.10)

    > Object.getOwnPropertyDescriptor({a: 1, b: 2}, 'a')
    { value: 1, writable: true, enumerable: true, configurable: true }
    > Object.getOwnPropertyDescriptor({a: 1, b: 2}, 'x')
    undefined
    
    
  • Object.getOwnPropertyDescriptors(obj) ES2017

    • 返回一个包含属性描述符的对象,每个obj的自身属性都有一个。

    • 更多关于属性描述符的信息:“属性属性和属性描述符^(ES5) (高级)” (§30.10)

    > Object.getOwnPropertyDescriptors({a: 1, b: 2})
    {
      a: { value: 1, writable: true, enumerable: true, configurable: true },
      b: { value: 2, writable: true, enumerable: true, configurable: true },
    }
    
    

30.12.3 Object.*: 属性键、值、条目

  • Object.keys(obj) ES5

    返回一个包含所有自身可枚举属性键(字符串键)的数组。

    const enumSymbolKey = Symbol('enumSymbolKey');
    const nonEnumSymbolKey = Symbol('nonEnumSymbolKey');
    
    const obj = Object.defineProperties(
      {},
      {
        enumStringKey: {
          value: 1, enumerable: true,
        },
        [enumSymbolKey]: {
          value: 2, enumerable: true,
        },
        nonEnumStringKey: {
          value: 3, enumerable: false,
        },
        [nonEnumSymbolKey]: {
          value: 4, enumerable: false,
        },
      }
    );
    assert.deepEqual(
      Object.keys(obj),
      ['enumStringKey']
    );
    
    
  • Object.getOwnPropertyNames(obj) ES5

    返回一个包含所有自身属性键(可枚举和不可枚举的字符串键)的数组。

    const enumSymbolKey = Symbol('enumSymbolKey');
    const nonEnumSymbolKey = Symbol('nonEnumSymbolKey');
    
    const obj = Object.defineProperties(
      {},
      {
        enumStringKey: {
          value: 1, enumerable: true,
        },
        [enumSymbolKey]: {
          value: 2, enumerable: true,
        },
        nonEnumStringKey: {
          value: 3, enumerable: false,
        },
        [nonEnumSymbolKey]: {
          value: 4, enumerable: false,
        },
      }
    );
    assert.deepEqual(
      Object.getOwnPropertyNames(obj),
      ['enumStringKey', 'nonEnumStringKey']
    );
    
    
  • Object.getOwnPropertySymbols(obj) ES6

    返回一个包含所有自身属性键(可枚举和不可枚举的符号键)的数组。

    const enumSymbolKey = Symbol('enumSymbolKey');
    const nonEnumSymbolKey = Symbol('nonEnumSymbolKey');
    
    const obj = Object.defineProperties(
      {},
      {
        enumStringKey: {
          value: 1, enumerable: true,
        },
        [enumSymbolKey]: {
          value: 2, enumerable: true,
        },
        nonEnumStringKey: {
          value: 3, enumerable: false,
        },
        [nonEnumSymbolKey]: {
          value: 4, enumerable: false,
        },
      }
    );
    assert.deepEqual(
      Object.getOwnPropertySymbols(obj),
      [enumSymbolKey, nonEnumSymbolKey]
    );
    
    
  • Object.values(obj) ES2017

    返回一个包含所有可枚举的自身字符串键属性的值的数组。

    > Object.values({a: 1, b: 2})
    [ 1, 2 ]
    
    
  • Object.entries(obj) ES2017

    • 返回一个包含每个obj属性的一个键值对(编码为一个包含两个元素的数组)的数组。

    • 仅包含具有字符串键的自身可枚举属性。

    • 逆操作:Object.fromEntries()

    const obj = {
      a: 1,
      b: 2,
      [Symbol('myKey')]: 3,
    };
    assert.deepEqual(
      Object.entries(obj),
      [
        ['a', 1],
        ['b', 2],
        // Property with symbol key is ignored
      ]
    );
    
    
  • Object.fromEntries(keyValueIterable) ES2019

    • 创建一个对象,其自身属性由keyValueIterable指定。

    • 逆操作:Object.entries()

    > Object.fromEntries([['a', 1], ['b', 2]])
    { a: 1, b: 2 }
    
    
  • Object.hasOwn(obj, key) ES2022

    • 如果obj有一个具有键key的自身属性,则返回true。如果没有,则返回false
    > Object.hasOwn({a: 1, b: 2}, 'a')
    true
    > Object.hasOwn({a: 1, b: 2}, 'x')
    false
    
    

30.12.4 Object.*: 保护对象

更多信息:“保护对象不被更改^(ES5) (高级)” (§30.11)

  • Object.preventExtensions(obj) ES5

    • 使 obj 不可扩展并返回它。

    • 影响:

      • obj 不可扩展:我们无法添加属性或更改其原型。
    • 只改变 obj 的顶层(浅改变)。嵌套对象不受影响。

    • 相关:Object.isExtensible()

  • Object.isExtensible(obj) ES5

    • 如果 obj 可扩展则返回 true,否则返回 false

    • 相关:Object.preventExtensions()

  • Object.seal(obj) ES5

    • 密封 obj 并返回它。

    • 影响:

      • obj 不可扩展:我们无法添加属性或更改其原型。

      • 如果 obj 被密封:此外,它的所有属性都是不可配置的。

    • 只改变 obj 的顶层(浅改变)。嵌套对象不受影响。

    • 相关:Object.isSealed()

  • Object.isSealed(obj) ES5

    • 如果 obj 被密封则返回 true,否则返回 false

    • 相关:Object.seal()

  • Object.freeze(obj) ES5

    • 冻结 obj 并返回它。

    • 影响:

      • obj 不可扩展:我们无法添加属性或更改其原型。

      • obj 被密封:此外,它的所有属性都是不可配置的。

      • obj 被冻结:此外,它的所有属性都是不可写的。

    • 只改变 obj 的顶层(浅改变)。嵌套对象不受影响。

    • 相关:Object.isFrozen()

    const frozen = Object.freeze({ x: 2, y: 5 });
    assert.equal(
      Object.isFrozen(frozen), true
    );
    assert.throws(
      () => frozen.x = 7,
      {
        name: 'TypeError',
        message: /^Cannot assign to read only property 'x'/,
      }
    );
    
    
  • Object.isFrozen(obj) ES5

    • 如果 obj 被冻结则返回 true

    • 相关:Object.freeze()

30.12.5 Object.*:杂项

  • Object.assign(target, ...sources) ES6

    将每个 sources 的所有可枚举的自身字符串键属性分配给 target 并返回 target

    > const obj = {a: 1, b: 1};
    > Object.assign(obj, {b: 2, c: 2}, {d: 3})
    { a: 1, b: 2, c: 2, d: 3 }
    > obj
    { a: 1, b: 2, c: 2, d: 3 }
    
    
  • Object.groupBy(items, computeGroupKey) ES2024

    Object.groupBy<K extends PropertyKey, T>(
      items: Iterable<T>,
      computeGroupKey: (item: T, index: number) => K,
    ): {[key: K]: Array<T>}
    
    
    • 回调 computeGroupKey 为每个 items 返回一个 组键

    • Object.groupBy() 的结果是这样一个对象:

      • 每个属性的键是一个组键,并且

      • 其值是一个包含所有具有该组键的项的数组。

    assert.deepEqual(
      Object.groupBy(
        ['orange', 'apricot', 'banana', 'apple', 'blueberry'],
        (str) => str[0] // compute group key
      ),
      {
        __proto__: null,
        'o': ['orange'],
        'a': ['apricot', 'apple'],
        'b': ['banana', 'blueberry'],
      }
    );
    
    
  • Object.is(value1, value2) ES6

    主要等同于 value1 === value2 ——但有两个例外:

    > NaN === NaN
    false
    > Object.is(NaN, NaN)
    true
    
    > -0 === 0
    true
    > Object.is(-0, 0)
    false
    
    
    • 将所有 NaN 值视为相等可能很有用——例如,在搜索数组中的值时。

    • -0 很少见,通常最好假装它与 0 相同。

30.12.6 Object.prototype.*

Object.prototype 有以下属性:

  • Object.prototype.__proto__(获取器和设置器)

  • Object.prototype.hasOwnProperty()

  • Object.prototype.isPrototypeOf()

  • Object.prototype.propertyIsEnumerable()

  • Object.prototype.toLocaleString()

  • Object.prototype.toString()

  • Object.prototype.valueOf()

这些方法在“快速参考:Object.prototype.*”(§31.10)中有详细解释。

30.13 快速参考:Reflect

Reflect 提供了用于 JavaScript 代理 的功能,这些功能偶尔在其他地方也很有用:

  • Reflect.apply(target, thisArgument, argumentsList) ES6

    • 使用argumentsList提供的参数调用target,并将this设置为thisArgument

    • 等同于target.apply(thisArgument, argumentsList)

  • Reflect.construct(target, argumentsList, newTarget=target) ES6

    • 作为函数的new运算符。

    • target是要调用的构造函数。

    • 可选参数newTarget指向启动当前构造函数调用链的构造函数。

  • Reflect.defineProperty(target, propertyKey, propDesc) ES6

    • 类似于Object.defineProperty()

    • 返回一个布尔值,指示操作是否成功。

  • Reflect.deleteProperty(target, propertyKey) ES6

    作为函数的delete运算符。尽管它的工作方式略有不同:

    • 如果成功删除了属性或属性原本不存在,则返回true

    • 如果属性无法删除且仍然存在,则返回false

    在宽松模式下,delete运算符返回与该方法相同的结果。但在严格模式下,它抛出TypeError而不是返回false

    保护属性不被删除的唯一方法是将它们设置为不可配置。

  • Reflect.get(target, propertyKey, receiver=target) ES6

    获取属性的功能。如果get在原型链中的某个地方遇到 getter,则需要可选参数receiver。然后它提供this的值。

  • Reflect.getOwnPropertyDescriptor(target, propertyKey) ES6

    Object.getOwnPropertyDescriptor()相同。

  • Reflect.getPrototypeOf(target) ES6

    Object.getPrototypeOf()相同。

  • Reflect.has(target, propertyKey) ES6

    作为函数的in运算符。

  • Reflect.isExtensible(target) ES6

    Object.isExtensible()相同。

  • Reflect.ownKeys(target) ES6

    以数组形式返回所有自有属性键(字符串和符号)。

  • Reflect.preventExtensions(target) ES6

    • 类似于Object.preventExtensions()

    • 返回一个布尔值,指示操作是否成功。

  • Reflect.set(target, propertyKey, value, receiver=target) ES6

    • 设置属性。

    • 返回一个布尔值,指示操作是否成功。

  • Reflect.setPrototypeOf(target, proto) ES6

    • Object.setPrototypeOf()相同。

    • 返回一个布尔值,指示操作是否成功。

30.13.1 Reflect.*Object.*

一般建议:

  • 在可能的情况下,使用Object.*

  • 在使用ECMAScript 代理时使用Reflect.*。其方法很好地适应了 ECMAScript 的元对象协议(MOP),该方法也返回布尔错误标志而不是抛出异常。

Reflect除了代理之外还有什么用例?

  • Reflect.ownKeys()列出所有自有属性键——这是其他地方没有提供的功能。

  • Object具有相同的功能,但返回值不同:Reflect复制了Object的以下方法,但其方法返回布尔值,指示操作是否成功(而Object方法返回被修改的对象)。

    • Object.defineProperty(obj, propKey, propDesc)

    • Object.preventExtensions(obj)

    • Object.setPrototypeOf(obj, proto)

  • 作为函数的运算符:以下Reflect方法实现了其他情况下仅通过运算符可用的功能:

    • Reflect.construct(target, argumentsList, newTarget=target)

    • Reflect.deleteProperty(target, propertyKey)

    • Reflect.get(target, propertyKey, receiver=target)

    • Reflect.has(target, propertyKey)

    • Reflect.set(target, propertyKey, value, receiver=target)

  • apply()函数的简短版本:如果我们想完全安全地在一个函数上调用apply()方法,我们不能通过动态分派来这样做,因为该函数可能有一个键为'apply'的自有属性:

    func.apply(thisArg, argArray) // not safe
    Function.prototype.apply.call(func, thisArg, argArray) // safe
    
    

    使用Reflect.apply()更简洁:

    Reflect.apply(func, thisArg, argArray)
    
    
  • 删除属性时无例外:在严格模式下,如果我们尝试删除一个不可配置的自有属性,delete运算符会抛出异常。Reflect.deleteProperty()在这种情况下返回false

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