深入 JavaScript:理论和技术(上)

第一部分:前言

原文:exploringjs.com/deep-js/pt_frontmatter.html

译者:飞龙

协议:CC BY-NC-SA 4.0

下一步:1 关于本书

一、关于这本书

原文:exploringjs.com/deep-js/ch_about-book.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 1.1?这本书的主页在哪里?

  • 1.2?这本书包括什么?

  • 1.3?我用我的钱能得到什么?

  • 1.4?我如何预览内容?

  • 1.5?我如何报告错误?

  • 1.6?阅读提示

  • 1.7?符号和约定

    • 1.7.1?什么是类型签名?为什么我在这本书中看到静态类型?

    • 1.7.2?带图标的注释是什么意思?

  • 1.8?致谢


1.1?这本书的主页在哪里?

“深入 JavaScript”的主页是exploringjs.com/deep-js/

1.2?这本书包括什么?

这本书深入探讨了 JavaScript:

  • 它教授了更好地使用这种语言的实用技术。

  • 它教授了这种语言的工作原理和原因。它所教授的内容牢固地基于 ECMAScript 规范(本书对此进行了解释和参考)。

  • 它只涵盖语言(忽略特定平台的功能,如浏览器 API),但不是详尽无遗。相反,它专注于一些重要的主题。

1.3?我用我的钱能得到什么?

如果你购买这本书,你会得到:

  • 当前内容有四个无 DRM 的版本:

    • PDF 文件

    • 无广告 HTML 的 ZIP 存档

    • EPUB 文件

    • MOBI 文件

  • 任何未来添加到这个版本的内容。我能添加多少取决于这本书的销售情况。

当前价格是介绍性的。随着内容的增加,价格会上涨。

1.4?我如何预览内容?

在这本书的主页,有关于这本书所有版本的广泛预览。

1.5?我如何报告错误?

  • 这本书的 HTML 版本在每章的末尾有一个评论链接。

  • 它们跳转到 GitHub 的问题页面,你也可以直接访问。

1.6?阅读提示

  • 你可以按任何顺序阅读章节。每一章都是独立的,但偶尔会有参考其他章节的进一步信息。

  • 一些章节的标题标有“(可选)”,意思是它们不是必要的。如果你跳过它们,你仍然会理解章节的其余部分。

1.7?符号和约定

1.7.1?什么是类型签名?为什么我在这本书中看到静态类型?

例如,你可能会看到:

Number.isFinite(num: number): boolean

这被称为Number.isFinite()类型签名。这种符号,特别是num的静态类型number和结果的boolean,并不是真正的 JavaScript。这种符号是从编译成 JavaScript 的语言 TypeScript 中借用的(它主要是 JavaScript 加上静态类型)。

为什么使用这种符号?它可以帮助你快速了解一个函数是如何工作的。这种符号在2ality 博客文章中有详细解释,但通常相对直观。

1.7.2?带图标的注释是什么意思?

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 阅读说明

解释了如何最好地阅读内容。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 外部内容

指向额外的外部内容。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 提示

提供了与当前内容相关的提示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 问题

问及并回答与当前内容相关的问题(常见问题解答)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 警告

警告关于陷阱等。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 细节

提供额外的细节,补充当前的内容。类似于脚注。

1.8 致谢

  • 感谢 Allen Wirfs-Brock 通过 Twitter 和博客评论给予的建议。这些帮助使本书变得更好。

  • 更多为本书作出贡献的人在各章中得到了感谢。

评论

第二部分:类型、值、变量

原文:exploringjs.com/deep-js/pt_types-values-variables.html

译者:飞龙

协议:CC BY-NC-SA 4.0

下一步:2 JavaScript 中的类型强制转换

二、JavaScript 中的类型强制转换

原文:exploringjs.com/deep-js/ch_type-coercion.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 2.1 什么是类型强制转换?

    • 2.1.1 处理类型强制转换
  • 2.2 实现 ECMAScript 规范中的强制转换的操作

    • 2.2.1 转换为原始类型和对象

    • 2.2.2 转换为数值类型

    • 2.2.3 转换为属性键

    • 2.2.4 转换为数组索引

    • 2.2.5 转换为 Typed Array 元素

  • 2.3 插曲:用 JavaScript 表达规范算法

  • 2.4 强制转换算法示例

    • 2.4.1 ToPrimitive()

    • 2.4.2 ToString()和相关操作

    • 2.4.3 ToPropertyKey()

    • 2.4.4 ToNumeric()和相关操作

  • 2.5 强制转换的操作

    • 2.5.1 加法运算符(+

    • 2.5.2 抽象相等比较(==

  • 2.6 术语表:与类型转换相关的术语


在本章中,我们将深入探讨 JavaScript 中类型强制转换的作用。我们将相对深入地研究这个主题,例如,看看 ECMAScript 规范如何处理强制转换。

2.1 什么是类型强制转换?

每个操作(函数、运算符等)都期望其参数具有某些类型。如果一个值对于参数没有正确的类型,例如,函数的三种常见选项是:

  1. 该函数可以抛出异常:

    function multiply(x, y) {
     if (typeof x !== 'number' || typeof y !== 'number') {
     throw new TypeError();
     }
     // ···
    }
    
  2. 该函数可以返回错误值:

    function multiply(x, y) {
     if (typeof x !== 'number' || typeof y !== 'number') {
     return NaN;
     }
     // ···
    }
    
  3. 该函数可以将其参数转换为有用的值:

    function multiply(x, y) {
     if (typeof x !== 'number') {
     x = Number(x);
     }
     if (typeof y !== 'number') {
     y = Number(y);
     }
     // ···
    }
    

在(3)中,该操作执行隐式类型转换。这就是所谓的类型强制转换

JavaScript 最初没有异常,这就是为什么它对大多数操作使用强制转换和错误值的原因:

// Coercion
assert.equal(3 * true, 3);

// Error values
assert.equal(1 / 0, Infinity);
assert.equal(Number('xyz'), NaN);

然而,也有一些情况(特别是涉及较新功能时),如果参数没有正确的类型,它会抛出异常:

  • 访问nullundefined的属性:

    > undefined.prop
    TypeError: Cannot read property 'prop' of undefined
    > null.prop
    TypeError: Cannot read property 'prop' of null
    > 'prop' in null
    TypeError: Cannot use 'in' operator to search for 'prop' in null
    
  • 使用符号:

    > 6 / Symbol()
    TypeError: Cannot convert a Symbol value to a number
    
  • 混合大整数和数字:

    > 6 / 3n
    TypeError: Cannot mix BigInt and other types
    
  • 调用或函数调用不支持该操作的值:

    > 123()
    TypeError: 123 is not a function
    > (class {})()
    TypeError: Class constructor  cannot be invoked without 'new'
    
    > new 123
    TypeError: 123 is not a constructor
    > new (() => {})
    TypeError: (intermediate value) is not a constructor
    
  • 更改只读属性(在严格模式下会抛出):

    > 'abc'.length = 1
    TypeError: Cannot assign to read only property 'length'
    > Object.freeze({prop:3}).prop = 1
    TypeError: Cannot assign to read only property 'prop'
    
2.1.1 处理类型强制转换

处理强制转换的两种常见方法是:

  • 调用者可以显式转换值,使其具有正确的类型。例如,在以下交互中,我们想要将两个编码为字符串的数字相乘:

    let x = '3';
    let y = '2';
    assert.equal(Number(x) * Number(y), 6);
    
  • 调用者可以让操作为其进行转换:

    let x = '3';
    let y = '2';
    assert.equal(x * y, 6);
    

我通常更喜欢前者,因为它澄清了我的意图:我希望xy不是数字,但想要将两个数字相乘。

2.2 实现 ECMAScript 规范中的强制转换的操作

以下各节描述了 ECMAScript 规范中使用的最重要的内部函数,用于将实际参数转换为期望的类型。

例如,在 TypeScript 中,我们会这样写:

function isNaN(number: number) {
 // ···
}

在规范中,这看起来像如下(转换为 JavaScript,以便更容易理解):

function isNaN(number) {
 let num = ToNumber(number);
 // ···
}
2.2.1 转换为原始类型和对象

每当期望原始类型或对象时,将使用以下转换函数:

  • ToBoolean()

  • ToNumber()

  • ToBigInt()

  • ToString()

  • ToObject()

这些内部函数在 JavaScript 中有非常相似的类似物:

> Boolean(0)
false
> Boolean(1)
true

> Number('123')
123

在引入了与数字并存的 bigint 之后,规范通常在先前使用ToNumber()的地方使用ToNumeric()。继续阅读以获取更多信息。

2.2.2?转换为数值类型

目前,JavaScript 有两种内置的数值类型:number 和 bigint。

  • ToNumeric()返回一个数值num。它的调用者通常调用规范类型num的方法mthd

    Type(num)::mthd(···)
    

    除其他外,以下操作使用ToNumeric

    • 前缀和后缀++运算符

    • *运算符

  • ToInteger(x)在期望没有小数的数字时使用。结果的范围通常在之后进一步限制。

    • 它调用ToNumber(x)并删除小数(类似于Math.trunc())。

    • 使用ToInteger()的操作:

      • Number.prototype.toString(radix?)

      • String.prototype.codePointAt(pos)

      • Array.prototype.slice(start, end)

      • 等等。

  • ToInt32()ToUint32()将数字强制转换为 32 位整数,并被位运算符使用(见 tbl. 1)。

    • ToInt32():有符号,范围[?231, 231?1](包括限制)

    • ToUint32():无符号(因此有U),范围[0, 232?1](包括限制)

表 1:位数运算符的操作数的强制转换(BigInt 运算符不限制位数)。

运算符 左操作数 右操作数 结果类型
<< ToInt32() ToUint32() Int32
signed >> ToInt32() ToUint32() Int32
unsigned >>> ToInt32() ToUint32() Uint32
&, ^, &#124; ToInt32() ToUint32() Int32
~ ToInt32() Int32
2.2.3?转换为属性键

ToPropertyKey()返回一个字符串或符号,并被以下使用:

  • 括号运算符[]

  • 对象字面量中的计算属性键

  • in运算符的左操作数

  • Object.defineProperty(_, P, _)

  • Object.fromEntries()

  • Object.getOwnPropertyDescriptor()

  • Object.prototype.hasOwnProperty()

  • Object.prototype.propertyIsEnumerable()

  • Reflect的几种方法

2.2.4?转换为数组索引
  • ToLength()主要用于字符串索引。

    • ToIndex()的辅助函数

    • 结果范围l:0 ≤ l ≤ 2?3?1

  • ToIndex()用于 Typed Array 索引。

    • ToLength()的主要区别:如果参数超出范围,则抛出异常。

    • 结果范围i:0 ≤ i ≤ 2?3?1

  • ToUint32()用于数组索引。

    • 结果范围i:0 ≤ i < 232?1(上限被排除,为.length留出空间)
2.2.5?转换为 Typed Array 元素

当我们设置 Typed Array 元素的值时,将使用以下转换函数之一:

  • ToInt8()

  • ToUint8()

  • ToUint8Clamp()

  • ToInt16()

  • ToUint16()

  • ToInt32()

  • ToUint32()

  • ToBigInt64()

  • ToBigUint64()

2.3?中场休息:用 JavaScript 表达规范算法

在本章的其余部分,我们将遇到几种规范算法,但是“实现”为 JavaScript。以下列表显示了一些经常使用的模式如何从规范转换为 JavaScript:

  • 规范:如果 Type(value)是 String

    JavaScript:if (TypeOf(value) === 'string')

    (非常宽松的翻译;TypeOf()在下面定义)

  • 规范:如果 IsCallable(method)为 true

    JavaScript:if (IsCallable(method))

    IsCallable()在下面定义)

  • 规范:让 numValue 成为 ToNumber(value)

    JavaScript:let numValue = Number(value)

  • 规范:让 isArray 成为 IsArray(O)

    JavaScript:let isArray = Array.isArray(O)

  • 规范:如果 O 具有[[NumberData]]内部插槽

    JavaScript:if ('__NumberData__' in O)

  • 规范:让 tag 成为 Get(O, @@toStringTag)

    JavaScript:let tag = O[Symbol.toStringTag]

  • 规范:返回字符串连接的“[object ”、tag 和“]”。

    JavaScript:return '[object ' + tag + ']';

let(而不是const)用于匹配规范的语言。

有一些东西被省略了 - 例如,ReturnIfAbrupt shorthands ?!

/**
 * An improved version of typeof
 */
function TypeOf(value) {
 const result = typeof value;
 switch (result) {
 case 'function':
 return 'object';
 case 'object':
 if (value === null) {
 return 'null';
 } else {
 return 'object';
 }
 default:
 return result;
 }
}

function IsCallable(x) {
 return typeof x === 'function';
}

2.4 示例强制算法

2.4.1 ToPrimitive()

The operation ToPrimitive() 是许多强制算法的中间步骤(本章后面将看到其中一些)。它将任意值转换为原始值。

ToPrimitive()在规范中经常使用,因为大多数操作符只能使用原始值。例如,我们可以使用加法操作符(+)来添加数字和连接字符串,但不能用它来连接数组。

这是 JavaScript 版本的ToPrimitive()的样子:

/**
 * @param  hint Which type is preferred for the result:
 *             string, number, or don’t care?
 */
function ToPrimitive(input: any,
 hint: 'string'|'number'|'default' = 'default') {
 if (TypeOf(input) === 'object') {
 let exoticToPrim = input[Symbol.toPrimitive]; // (A)
 if (exoticToPrim !== undefined) {
 let result = exoticToPrim.call(input, hint);
 if (TypeOf(result) !== 'object') {
 return result;
 }
 throw new TypeError();
 }
 if (hint === 'default') {
 hint = 'number';
 }
 return OrdinaryToPrimitive(input, hint);
 } else {
 // input is already primitive
 return input;
 }
 }

ToPrimitive()允许对象通过Symbol.toPrimitive(第 A 行)覆盖转换为原始值。如果对象没有这样做,则将其传递给OrdinaryToPrimitive()

function OrdinaryToPrimitive(O: object, hint: 'string' | 'number') {
 let methodNames;
 if (hint === 'string') {
 methodNames = ['toString', 'valueOf'];
 } else {
 methodNames = ['valueOf', 'toString'];
 }
 for (let name of methodNames) {
 let method = O[name];
 if (IsCallable(method)) {
 let result = method.call(O);
 if (TypeOf(result) !== 'object') {
 return result;
 }
 }
 }
 throw new TypeError();
}
2.4.1.1 调用ToPrimitive()的提示是什么?

参数hint可以有三个值中的一个:

  • 'number'表示:如果可能的话,input应该转换为数字。

  • 'string'表示:如果可能的话,input应该转换为字符串。

  • 'default'表示:对于数字或字符串没有偏好。

以下是各种操作如何使用ToPrimitive()的几个示例:

  • hint === 'number'。以下操作更偏向于数字:

    • ToNumeric()

    • ToNumber()

    • ToBigInt()BigInt()

    • 抽象关系比较(<

  • hint === 'string'。以下操作更偏向于字符串:

    • ToString()

    • ToPropertyKey()

  • hint === 'default'。以下操作对返回的原始值的类型是中立的:

    • 抽象相等比较(==

    • 加法操作符(+

    • new Date(value)value可以是数字或字符串)

正如我们所见,默认行为是将'default'处理为'number'。只有SymbolDate的实例覆盖了这种行为(稍后会介绍)。

2.4.1.2 将对象转换为原始值时调用的方法是哪些?

如果通过Symbol.toPrimitive未覆盖对原始值的转换,OrdinaryToPrimitive()将调用以下两种方法中的一个或两个:

  • 如果hint指示我们希望原始值是字符串,则首先调用'toString'

  • 如果hint指示我们希望原始值是数字,则首先调用'valueOf'

以下代码演示了这是如何工作的:

const obj = {
 toString() { return 'a' },
 valueOf() { return 1 },
};

// String() prefers strings:
assert.equal(String(obj), 'a');

// Number() prefers numbers:
assert.equal(Number(obj), 1);

具有属性键Symbol.toPrimitive的方法覆盖了正常的转换为原始值。标准库中只有两次这样做:

  • Symbol.prototypeSymbol.toPrimitive

    • 如果接收者是Symbol的实例,则此方法始终返回包装的符号。

    • 其理由是Symbol的实例具有返回字符串的.toString()方法。但是,即使hint'string',也不应该调用.toString(),以免意外将Symbol的实例转换为字符串(这是一种完全不同类型的属性键)。

  • Date.prototypeSymbol.toPrimitive

    • 下面会更详细解释。
2.4.1.3 Date.prototype[Symbol.toPrimitive]()

这是日期处理转换为原始值的方式:

Date.prototype[Symbol.toPrimitive] = function (
 hint: 'default' | 'string' | 'number') {
 let O = this;
 if (TypeOf(O) !== 'object') {
 throw new TypeError();
 }
 let tryFirst;
 if (hint === 'string' || hint === 'default') {
 tryFirst = 'string';
 } else if (hint === 'number') {
 tryFirst = 'number';
 } else {
 throw new TypeError();
 }
 return OrdinaryToPrimitive(O, tryFirst);
 };

与默认算法唯一的区别是'default'变为'string'(而不是'number')。如果我们使用将hint设置为'default'的操作,就可以观察到这一点:

  • The == operator 如果另一个操作数是除undefinednullboolean之外的原始值,则将对象强制转换为原始值(使用默认提示)。在以下交互中,我们可以看到将日期强制转换的结果是一个字符串:

    const d = new Date('2222-03-27');
    assert.equal(
     d == 'Wed Mar 27 2222 01:00:00 GMT+0100'
     + ' (Central European Standard Time)',
     true);
    
  • +运算符将两个操作数强制转换为原始值(使用默认提示)。如果其中一个结果是字符串,则执行字符串连接(否则执行数字相加)。在下面的交互中,我们可以看到将日期强制转换的结果是一个字符串,因为操作符返回一个字符串。

    const d = new Date('2222-03-27');
    assert.equal(
     123 + d,
     '123Wed Mar 27 2222 01:00:00 GMT+0100'
     + ' (Central European Standard Time)');
    
2.4.2?ToString()和相关操作

这是ToString()的 JavaScript 版本:

function ToString(argument) {
 if (argument === undefined) {
 return 'undefined';
 } else if (argument === null) {
 return 'null';
 } else if (argument === true) {
 return 'true';
 } else if (argument === false) {
 return 'false';
 } else if (TypeOf(argument) === 'number') {
 return Number.toString(argument);
 } else if (TypeOf(argument) === 'string') {
 return argument;
 } else if (TypeOf(argument) === 'symbol') {
 throw new TypeError();
 } else if (TypeOf(argument) === 'bigint') {
 return BigInt.toString(argument);
 } else {
 // argument is an object
 let primValue = ToPrimitive(argument, 'string'); // (A)
 return ToString(primValue);
 }
}

请注意,此函数在将对象转换为字符串之前使用ToPrimitive()作为中间步骤,然后将原始结果转换为字符串(第 A 行)。

ToString()以有趣的方式偏离了String()的工作方式:如果argument是一个符号,前者会抛出TypeError,而后者不会。为什么会这样?符号的默认值是将它们转换为字符串会抛出异常:

> const sym = Symbol('sym');

> ''+sym
TypeError: Cannot convert a Symbol value to a string
> `${sym}`
TypeError: Cannot convert a Symbol value to a string

String()Symbol.prototype.toString()中都覆盖了默认值(它们都在下一小节中描述):

> String(sym)
'Symbol(sym)'
> sym.toString()
'Symbol(sym)'
2.4.2.1?String()
function String(value) {
 let s;
 if (value === undefined) {
 s = '';
 } else {
 if (new.target === undefined && TypeOf(value) === 'symbol') {
 // This function was function-called and value is a symbol
 return SymbolDescriptiveString(value);
 }
 s = ToString(value);
 }
 if (new.target === undefined) {
 // This function was function-called
 return s;
 }
 // This function was new-called
 return StringCreate(s, new.target.prototype); // simplified!
}

String()的工作方式不同,取决于是通过函数调用还是通过new调用。它使用new.target来区分这两种情况。

这些是辅助函数StringCreate()SymbolDescriptiveString()

/** 
 * Creates a String instance that wraps `value`
 * and has the given protoype.
 */
function StringCreate(value, prototype) {
 // ···
}

function SymbolDescriptiveString(sym) {
 assert.equal(TypeOf(sym), 'symbol');
 let desc = sym.description;
 if (desc === undefined) {
 desc = '';
 }
 assert.equal(TypeOf(desc), 'string');
 return 'Symbol('+desc+')';
}
2.4.2.2?Symbol.prototype.toString()

除了String()之外,我们还可以使用方法.toString()将符号转换为字符串。其规范如下。

Symbol.prototype.toString = function () {
 let sym = thisSymbolValue(this);
 return SymbolDescriptiveString(sym);
};
function thisSymbolValue(value) {
 if (TypeOf(value) === 'symbol') {
 return value;
 }
 if (TypeOf(value) === 'object' && '__SymbolData__' in value) {
 let s = value.__SymbolData__;
 assert.equal(TypeOf(s), 'symbol');
 return s;
 }
}
2.4.2.3?Object.prototype.toString

.toString()的默认规范如下:

Object.prototype.toString = function () {
 if (this === undefined) {
 return '[object Undefined]';
 }
 if (this === null) {
 return '[object Null]';
 }
 let O = ToObject(this);
 let isArray = Array.isArray(O);
 let builtinTag;
 if (isArray) {
 builtinTag = 'Array';
 } else if ('__ParameterMap__' in O) {
 builtinTag = 'Arguments';
 } else if ('__Call__' in O) {
 builtinTag = 'Function';
 } else if ('__ErrorData__' in O) {
 builtinTag = 'Error';
 } else if ('__BooleanData__' in O) {
 builtinTag = 'Boolean';
 } else if ('__NumberData__' in O) {
 builtinTag = 'Number';
 } else if ('__StringData__' in O) {
 builtinTag = 'String';
 } else if ('__DateValue__' in O) {
 builtinTag = 'Date';
 } else if ('__RegExpMatcher__' in O) {
 builtinTag = 'RegExp';
 } else {
 builtinTag = 'Object';
 }
 let tag = O[Symbol.toStringTag];
 if (TypeOf(tag) !== 'string') {
 tag = builtinTag;
 }
 return '[object ' + tag + ']';
};

如果我们将普通对象转换为字符串,则使用此操作:

> String({})
'[object Object]'

默认情况下,如果我们将类的实例转换为字符串,则也会使用它:

class MyClass {}
assert.equal(
 String(new MyClass()), '[object Object]');

通常,我们会重写.toString()以配置MyClass的字符串表示形式,但我们也可以更改字符串中“object”后面的内容,使用方括号:

class MyClass {}
MyClass.prototype[Symbol.toStringTag] = 'Custom!';
assert.equal(
 String(new MyClass()), '[object Custom!]');

比较.toString()的重写版本与Object.prototype中的原始版本是很有趣的:

> ['a', 'b'].toString()
'a,b'
> Object.prototype.toString.call(['a', 'b'])
'[object Array]'

> /^abc$/.toString()
'/^abc$/'
> Object.prototype.toString.call(/^abc$/)
'[object RegExp]'
2.4.3?ToPropertyKey()

ToPropertyKey()被方括号运算符等使用。它的工作方式如下:

function ToPropertyKey(argument) {
 let key = ToPrimitive(argument, 'string'); // (A)
 if (TypeOf(key) === 'symbol') {
 return key;
 }
 return ToString(key);
}

再次,对象在使用原始值之前被转换为原始值。

2.4.4?ToNumeric()和相关操作

ToNumeric()被乘法运算符(*)等使用。它的工作方式如下:

function ToNumeric(value) {
 let primValue = ToPrimitive(value, 'number');
 if (TypeOf(primValue) === 'bigint') {
 return primValue;
 }
 return ToNumber(primValue);
}
2.4.4.1?ToNumber()

ToNumber()的工作方式如下:

function ToNumber(argument) {
 if (argument === undefined) {
 return NaN;
 } else if (argument === null) {
 return +0;
 } else if (argument === true) {
 return 1;
 } else if (argument === false) {
 return +0;
 } else if (TypeOf(argument) === 'number') {
 return argument;
 } else if (TypeOf(argument) === 'string') {
 return parseTheString(argument); // not shown here
 } else if (TypeOf(argument) === 'symbol') {
 throw new TypeError();
 } else if (TypeOf(argument) === 'bigint') {
 throw new TypeError();
 } else {
 // argument is an object
 let primValue = ToPrimitive(argument, 'number');
 return ToNumber(primValue);
 }
}

ToNumber()的结构类似于ToString()的结构。

2.5?强制转换操作

2.5.1?加法运算符(+)

这是 JavaScript 的加法运算符的规范:

function Addition(leftHandSide, rightHandSide) {
 let lprim = ToPrimitive(leftHandSide);
 let rprim = ToPrimitive(rightHandSide);
 if (TypeOf(lprim) === 'string' || TypeOf(rprim) === 'string') { // (A)
 return ToString(lprim) + ToString(rprim);
 }
 let lnum = ToNumeric(lprim);
 let rnum = ToNumeric(rprim);
 if (TypeOf(lnum) !== TypeOf(rnum)) {
 throw new TypeError();
 }
 let T = Type(lnum);
 return T.add(lnum, rnum); // (B)
}

此算法的步骤:

  • 两个操作数都被转换为原始值。

  • 如果其中一个结果是字符串,则两者都将转换为字符串并连接(第 A 行)。

  • 否则,两个操作数将转换为数值并相加(第 B 行)。Type()返回lnum的 ECMAScript 规范类型。.add()是数值类型的一个方法。

2.5.2?抽象相等比较(==)
/** Loose equality (==) */
function abstractEqualityComparison(x, y) {
 if (TypeOf(x) === TypeOf(y)) {
 // Use strict equality (===)
 return strictEqualityComparison(x, y);
 }

 // Comparing null with undefined
 if (x === null && y === undefined) {
 return true;
 }
 if (x === undefined && y === null) {
 return true;
 }

 // Comparing a number and a string
 if (TypeOf(x) === 'number' && TypeOf(y) === 'string') {
 return abstractEqualityComparison(x, Number(y));
 }
 if (TypeOf(x) === 'string' && TypeOf(y) === 'number') {
 return abstractEqualityComparison(Number(x), y);
 }

 // Comparing a bigint and a string
 if (TypeOf(x) === 'bigint' && TypeOf(y) === 'string') {
 let n = StringToBigInt(y);
 if (Number.isNaN(n)) {
 return false;
 }
 return abstractEqualityComparison(x, n);
 }
 if (TypeOf(x) === 'string' && TypeOf(y) === 'bigint') {
 return abstractEqualityComparison(y, x);
 }

 // Comparing a boolean with a non-boolean
 if (TypeOf(x) === 'boolean') {
 return abstractEqualityComparison(Number(x), y);
 }
 if (TypeOf(y) === 'boolean') {
 return abstractEqualityComparison(x, Number(y));
 }

 // Comparing an object with a primitive
 // (other than undefined, null, a boolean)
 if (['string', 'number', 'bigint', 'symbol'].includes(TypeOf(x))
 && TypeOf(y) === 'object') {
 return abstractEqualityComparison(x, ToPrimitive(y));
 }
 if (TypeOf(x) === 'object'
 && ['string', 'number', 'bigint', 'symbol'].includes(TypeOf(y))) {
 return abstractEqualityComparison(ToPrimitive(x), y);
 }

 // Comparing a bigint with a number
 if ((TypeOf(x) === 'bigint' && TypeOf(y) === 'number')
 || (TypeOf(x) === 'number' && TypeOf(y) === 'bigint')) {
 if ([NaN, +Infinity, -Infinity].includes(x)
 || [NaN, +Infinity, -Infinity].includes(y)) {
 return false;
 }
 if (isSameMathematicalValue(x, y)) {
 return true;
 } else {
 return false;
 }
 }

 return false;
}

以下操作在此处未显示:

  • strictEqualityComparison()

  • StringToBigInt()

  • isSameMathematicalValue()

2.6?与类型转换相关的术语表

现在我们已经更仔细地了解了 JavaScript 的类型转换工作方式,让我们用与类型转换相关的术语简要总结一下:

  • 类型转换中,我们希望输出值具有给定类型。如果输入值已经具有该类型,则简单地返回它。否则,它将被转换为具有所需类型的值。

  • 显式类型转换意味着程序员使用操作(函数、运算符等)来触发类型转换。显式转换可以是:

    • 已检查:如果值无法转换,则会抛出异常。

    • 未经检查:如果一个值无法转换,就会返回一个错误值。

  • 类型转换取决于编程语言。例如,在 Java 中,它是显式的检查类型转换。

  • 类型强制是隐式类型转换:一个操作会自动将其参数转换为它所需的类型。可以是检查的、未经检查的或介于两者之间的。

[来源:维基百科]

评论

三、解构算法

原文:exploringjs.com/deep-js/ch_destructuring-algorithm.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 3.1?为模式匹配算法做准备

    • 3.1.1?使用声明性规则指定匹配算法

    • 3.1.2?基于声明性规则评估表达式

  • 3.2?模式匹配算法

    • 3.2.1?模式

    • 3.2.2?变量的规则

    • 3.2.3?对象模式的规则

    • 3.2.4?数组模式的规则

  • 3.3?空对象模式和数组模式

  • 3.4?应用算法

    • 3.4.1?背景:通过匹配传递参数

    • 3.4.2?使用move2()

    • 3.4.3?使用move1()

    • 3.4.4?结论:默认值是模式部分的特性


在本章中,我们以不同的角度看待解构:作为一种递归模式匹配算法。

该算法将使我们更好地理解默认值。这在最后将会很有用,我们将尝试弄清楚以下两个函数的区别:

function move({x=0, y=0} = {})         { ··· }
function move({x, y} = { x: 0, y: 0 }) { ··· }

3.1?为模式匹配算法做准备

解构赋值看起来像这样:

?pattern? = ?value?

我们想使用patternvalue中提取数据。

我们现在将看一个执行这种赋值的算法。这个算法在函数式编程中被称为模式匹配(简称:匹配)。它指定了运算符(“匹配”)来将patternvalue匹配并在此过程中赋值给变量:

?pattern? ← ?value?

我们只会探讨解构赋值,但解构变量声明和解构参数定义的工作方式类似。我们也不会涉及高级特性:计算属性键,属性值简写,以及对象属性和数组元素作为赋值目标,超出了本章的范围。

匹配运算符的规范包括下降到两个操作数的结构的声明性规则。声明性符号可能需要一些时间来适应,但它使规范更加简洁。

3.1.1?使用声明性规则指定匹配算法

本章中使用的声明性规则操作输入,并通过副作用产生算法的结果。这是一个这样的规则(我们稍后会再次看到):

  • (2c) {key: ?pattern?, ?properties?} ← obj

    ?pattern? ← obj.key
    {?properties?} ← obj
    

此规则有以下部分:

  • (2c)是规则的编号。该编号用于引用规则。

  • head(第一行)描述了输入必须是什么样子,以便应用此规则。

  • body(剩余行)描述了规则应用时发生的情况。

在规则(2c)中,头部表示如果存在至少一个属性(其键为key)和零个或多个剩余属性的对象模式,则可以应用此规则。此规则的效果是继续执行,将属性值模式与obj.key匹配,并将剩余属性与obj匹配。

让我们考虑本章中的另一条规则:

  • (2e) {} ← obj(没有剩余属性)

    // We are finished
    

在规则(2e)中,头部意味着如果空对象模式{}与值obj匹配,则执行此规则。主体意味着在这种情况下,我们已经完成了。

规则(2c)和规则(2e)一起形成了一个声明性循环,它遍历箭头左侧模式的属性。

3.1.2?基于声明性规则评估表达式

完整的算法是通过一系列声明性规则指定的。假设我们想要评估以下匹配表达式:

{first: f, last: l} ← obj

为了应用一系列规则,我们从上到下遍历它们,并执行第一个适用的规则。如果在该规则的主体中有匹配的表达式,则再次应用规则。依此类推。

有时头部包括一个条件,该条件还确定了规则是否适用-例如:

  • (3a) [?elements?] ← non_iterable(非法值)

    if (!isIterable(non_iterable))

    throw new TypeError();
    

3.2?模式匹配算法

3.2.1?模式

模式要么是:

  • 一个变量:x

  • 一个对象模式:{?properties?}

  • 一个数组模式:[?elements?]

接下来的三个部分指定了处理这三种情况的匹配表达式的规则。

3.2.2?变量规则
    1. x ← value(包括undefinednull
    x = value
    
3.2.3?对象模式规则
  • (2a) {?properties?} ← undefined(非法值)

    throw new TypeError();
    
  • (2b) {?properties?} ← null(非法值)

    throw new TypeError();
    
  • (2c) {key: ?pattern?, ?properties?} ← obj

    ?pattern? ← obj.key
    {?properties?} ← obj
    
  • (2d) {key: ?pattern? = default_value, ?properties?} ← obj

    const tmp = obj.key;
    if (tmp !== undefined) {
     ?pattern? ← tmp
    } else {
     ?pattern? ← default_value
    }
    {?properties?} ← obj
    
  • (2e) {} ← obj(没有剩余属性)

    // We are finished
    

规则 2a 和 2b 处理非法值。规则 2c-2e 循环遍历模式的属性。在规则 2d 中,我们可以看到默认值提供了一个与obj中没有匹配属性对应的替代方案。

3.2.4?数组模式规则

数组模式和可迭代对象。 数组解构的算法从数组模式和可迭代对象开始:

  • (3a) [?elements?] ← non_iterable(非法值)

    if (!isIterable(non_iterable))

    throw new TypeError();
    
  • (3b) [?elements?] ← iterable

    if (isIterable(iterable))

    const iterator = iterable[Symbol.iterator]();
    ?elements? ← iterator
    

辅助函数:

function isIterable(value) {
 return (value !== null
 && typeof value === 'object'
 && typeof value[Symbol.iterator] === 'function');
}

数组元素和迭代器。 算法继续进行:

  • 模式的元素(箭头左侧)

  • 从可迭代对象(箭头右侧)获得的迭代器

这些是规则:

  • (3c) ?pattern?, ?elements? ← iterator

    ?pattern? ← getNext(iterator) // undefined after last item
    ?elements? ← iterator
    
  • (3d) ?pattern? = default_value, ?elements? ← iterator

    const tmp = getNext(iterator);  // undefined after last item
    if (tmp !== undefined) {
     ?pattern? ← tmp
    } else {
     ?pattern? ← default_value
    }
    ?elements? ← iterator
    
  • (3e) , ?elements? ← iterator(空位,省略)

    getNext(iterator); // skip
    ?elements? ← iterator
    
  • (3f) ...?pattern? ← iterator(总是最后一部分!)

    const tmp = [];
    for (const elem of iterator) {
     tmp.push(elem);
    }
    ?pattern? ← tmp
    
  • (3g) ← iterator(没有剩余元素)

    // We are finished
    

辅助函数:

function getNext(iterator) {
 const {done,value} = iterator.next();
 return (done ? undefined : value);
}

迭代器完成类似于对象中缺少的属性。

3.3?空对象模式和数组模式

算法规则的有趣结果:我们可以使用空对象模式和空数组模式进行解构。

给定一个空对象模式{}:如果要解构的值既不是undefined也不是null,则什么也不会发生。否则,将抛出TypeError

const {} = 123; // OK, neither undefined nor null
assert.throws(
 () => {
 const {} = null;
 },
 /^TypeError: Cannot destructure 'null' as it is null.$/)

给定一个空的数组模式[]:如果要解构的值是可迭代的,则什么也不会发生。否则,将抛出TypeError

const [] = 'abc'; // OK, iterable
assert.throws(
 () => {
 const [] = 123; // not iterable
 },
 /^TypeError: 123 is not iterable$/)

换句话说:空解构模式强制值具有某些特征,但没有其他效果。

3.4?应用算法

在 JavaScript 中,通过对象模拟命名参数:调用者使用对象文字,被调用者使用解构。这个模拟在“JavaScript for impatient programmers”中有详细解释。以下代码显示了一个例子:函数move1()有两个命名参数xy

function move1({x=0, y=0} = {}) { // (A)
 return [x, y];
}
assert.deepEqual(
 move1({x: 3, y: 8}), [3, 8]);
assert.deepEqual(
 move1({x: 3}), [3, 0]);
assert.deepEqual(
 move1({}), [0, 0]);
assert.deepEqual(
 move1(), [0, 0]);

A 行中有三个默认值:

  • 前两个默认值允许我们省略xy

  • 第三个默认值允许我们调用move1()而不带参数(就像最后一行一样)。

但是为什么我们要像前面的代码片段中定义参数呢?为什么不像下面这样?

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

要看move1()为什么是正确的,我们将在两个示例中使用这两个函数。在这之前,让我们看看参数的传递如何可以通过匹配来解释。

3.4.1 背景:通过匹配传递参数

在函数调用中,形式参数(在函数定义内部)与实际参数(在函数调用内部)进行匹配。例如,考虑以下函数定义和以下函数调用。

function func(a=0, b=0) { ··· }
func(1, 2);

参数ab的设置类似于以下解构。

[a=0, b=0] ← [1, 2]
3.4.2 使用move2()

让我们看看move2()的解构是如何工作的。

示例 1. 函数调用move2()导致这种解构:

[{x, y} = { x: 0, y: 0 }] ← []

左侧的单个数组元素在右侧没有匹配,这就是为什么{x,y}与默认值匹配而不是与右侧数据匹配的原因(规则 3b,3d):

{x, y} ← { x: 0, y: 0 }

左侧包含属性值简写。这是一个缩写形式:

{x: x, y: y} ← { x: 0, y: 0 }

这种解构导致以下两个赋值(规则 2c,1):

x = 0;
y = 0;

这就是我们想要的。但是,在下一个示例中,我们就没有那么幸运了。

示例 2. 让我们检查函数调用move2({z: 3}),这导致以下解构:

[{x, y} = { x: 0, y: 0 }] ← [{z: 3}]

右侧有一个索引为 0 的数组元素。因此,默认值被忽略,下一步是(规则 3d):

{x, y} ← { z: 3 }

这导致xy都设置为undefined,这不是我们想要的。问题在于{x,y}不再与默认值匹配,而是与{z:3}匹配。

3.4.3 使用move1()

让我们尝试move1()

示例 1: move1()

[{x=0, y=0} = {}] ← []

我们在右侧没有一个索引为 0 的数组元素,并且使用默认值(规则 3d):

{x=0, y=0} ← {}

左侧包含属性值简写,这意味着这种解构等同于:

{x: x=0, y: y=0} ← {}

左侧的属性x和属性y都没有在右侧匹配。因此,使用默认值,并且接下来执行以下解构(规则 2d):

x ← 0
y ← 0

这导致以下赋值(规则 1):

x = 0
y = 0

在这里,我们得到了我们想要的。让我们看看我们的运气是否在下一个示例中持续。

示例 2: move1({z: 3})

[{x=0, y=0} = {}] ← [{z: 3}]

数组模式的第一个元素在右侧有一个匹配,并且该匹配用于继续解构(规则 3d):

{x=0, y=0} ← {z: 3}

与示例 1 一样,在右侧没有属性xy,并且使用默认值:

x = 0
y = 0

它按预期工作!这次,模式中的xy{z:3}匹配不是问题,因为它们有自己的本地默认值。

3.4.4 结论:默认值是模式部分的一个特性

这些示例表明默认值是模式部分(对象属性或数组元素)的一个特性。如果一个部分没有匹配或与undefined匹配,则使用默认值。也就是说,模式与默认值匹配,而不是与实际值匹配。

评论

四、环境:变量的内部工作原理

原文:exploringjs.com/deep-js/ch_environments.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 4.1 环境:管理变量的数据结构

  • 4.2 通过环境进行递归

    • 4.2.1 执行代码
  • 4.3 通过环境进行嵌套作用域

    • 4.3.1 执行代码
  • 4.4 闭包和环境


在本章中,我们将更仔细地研究 ECMAScript 语言规范如何处理变量。

4.1 环境:管理变量的数据结构

环境是 ECMAScript 规范用于管理变量的数据结构。它是一个字典,其键是变量名,值是这些变量的值。每个作用域都有其关联的环境。环境必须能够支持与变量相关的以下现象:

  • 递归

  • 嵌套作用域

  • 闭包

我们将使用示例来说明每种现象是如何实现的。

4.2 通过环境进行递归

我们首先解决递归。考虑以下代码:

function f(x) {
 return x * 2;
}
function g(y) {
 const tmp = y + 1;
 return f(tmp);
}
assert.equal(g(3), 8);

对于每个函数调用,您需要为被调用函数的变量(参数和局部变量)提供新的存储空间。这是通过所谓的执行上下文的堆栈来管理的,它们是环境的引用(本章的目的)。环境本身存储在堆上。这是必要的,因为它们偶尔在执行离开其作用域后继续存在(我们将在探索闭包时看到)。因此,它们本身不能通过堆栈管理。

4.2.1 执行代码

在执行代码时,我们进行以下暂停:

function f(x) {
 // Pause 3
 return x * 2;
}
function g(y) {
 const tmp = y + 1;
 // Pause 2
 return f(tmp);
}
// Pause 1
assert.equal(g(3), 8);

发生了什么:

  • 暂停 1 - 在调用g()之前(图 1)。

  • 暂停 2 - 在执行g()时(图 2)。

  • 暂停 3 - 在执行f()时(图 3)。

  • 剩下的步骤:每次有return时,一个执行上下文将从堆栈中移除。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1:递归,暂停 1 - 在调用g()之前:执行上下文堆栈有一个条目,指向顶层环境。在该环境中,有两个条目;一个是f(),一个是g()

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2:递归,暂停 2 - 在执行g()时:执行上下文堆栈的顶部指向为g()创建的环境。该环境包含了参数y和本地变量tmp的条目。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3:递归,暂停 3 - 在执行f()时:顶部执行上下文现在指向f()的环境。

4.3 通过环境进行嵌套作用域

我们使用以下代码来探索如何通过环境实现嵌套作用域。

function f(x) {
 function square() {
 const result = x * x;
 return result;
 }
 return square();
}
assert.equal(f(6), 36);

在这里,我们有三个嵌套作用域:顶层作用域,f()的作用域和square()的作用域。观察:

  • 作用域是连接的。内部作用域“继承”了外部作用域的所有变量(减去它遮蔽的变量)。

  • 作为一种机制,嵌套作用域是独立于递归的。后者最好由独立环境的堆栈管理。前者是每个环境与“创建它的”环境的关系。

因此,每个作用域的环境都通过一个名为outer的字段指向周围作用域的环境。当我们查找变量的值时,首先在当前环境中搜索其名称,然后在外部环境中搜索,然后在外部环境的外部环境中搜索,依此类推。整个外部环境链包含了当前可以访问的所有变量(减去被遮蔽的变量)。

当你进行函数调用时,你创建了一个新的环境。该环境的外部环境是函数创建时的环境。为了帮助设置通过函数调用创建的环境的outer字段,每个函数都有一个名为[[Scope]]的内部属性,指向它的“诞生环境”。

4.3.1 执行代码

这是我们在执行代码时所做的暂停:

function f(x) {
 function square() {
 const result = x * x;
 // Pause 3
 return result;
 }
 // Pause 2
 return square();
}
// Pause 1
assert.equal(f(6), 36);

发生了什么:

  • 暂停 1 - 在调用f()之前(图 4)。

  • 暂停 2 - 在执行f()时(图 5)。

  • 暂停 3 - 在执行square()时(图 6)。

  • 之后,return语句将执行条目从堆栈中弹出。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4:嵌套作用域,暂停 1 - 在调用f()之前:顶层环境只有一个条目,即f()f()的诞生环境是顶层环境。因此,f[[Scope]]指向它。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5:嵌套作用域,暂停 2 - 在执行f()时:现在有一个用于函数调用f(6)的环境。该环境的外部环境是f()的诞生环境(索引为 0 的顶层环境)。我们可以看到outer字段被设置为f[[Scope]]的值。此外,新函数square()[[Scope]]是刚刚创建的环境。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6:嵌套作用域,暂停 3 - 在执行square()时:重复了之前的模式:最近环境的outer是通过我们刚刚调用的函数的[[Scope]]设置的。通过outer创建的作用域链包含了当前活动的所有变量。例如,我们可以访问resultsquaref。环境反映了变量的两个方面。首先,外部环境链反映了嵌套的静态作用域。其次,执行上下文的堆栈反映了动态地进行了哪些函数调用。

4.4 闭包和环境

为了看到环境是如何用来实现闭包的,我们使用以下示例:

function add(x) {
 return (y) => { // (A)
 return x + y;
 };
}
assert.equal(add(3)(1), 4); // (B)

这里发生了什么?add()是一个返回函数的函数。当我们在 B 行进行嵌套函数调用add(3)(1)时,第一个参数是给add(),第二个参数是给它返回的函数。这是因为在 A 行创建的函数在离开该作用域时不会失去与其诞生作用域的连接。通过该连接,相关环境保持活动状态,函数仍然可以访问该环境中的变量xx在函数内部是自由的)。

这种嵌套调用add()的方式有一个优势:如果你只进行第一次函数调用,你会得到一个add()的版本,其中参数x已经填充:

const plus2 = add(2);
assert.equal(plus2(5), 7);

将具有两个参数的函数转换为具有一个参数的两个嵌套函数,称为柯里化add()是一个柯里化的函数。

只填写函数的一些参数称为部分应用(函数尚未完全应用)。函数的.bind()方法执行部分应用。在前面的例子中,我们可以看到,如果一个函数是柯里化的,部分应用是简单的。

4.4.0.1 执行代码

当我们执行以下代码时,我们会做三次暂停:

function add(x) {
 return (y) => {
 // Pause 3: plus2(5)
 return x + y;
 }; // Pause 1: add(2)
}
const plus2 = add(2);
// Pause 2
assert.equal(plus2(5), 7);

这就是发生的事情:

  • 暂停 1 - 在执行add(2)期间(图 7)。

  • 暂停 2 - 在执行add(2)之后(图 8)。

  • 暂停 3 - 在执行plus2(5)时(图 9)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7:闭包,暂停 1 - 在执行add(2)期间:我们可以看到add()返回的函数已经存在(见右下角),并且它通过其内部属性[[Scope]]指向其诞生环境。请注意,plus2仍处于其暂时死区并未初始化。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8:闭包,暂停 2 - 在执行add(2)之后:plus2现在指向add(2)返回的函数。该函数通过其[[Scope]]保持其诞生环境(add(2)的环境)的活力。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9:闭包,暂停 3 - 在执行plus2(5)时:plus2[[Scope]]用于设置新环境的outer。这就是当前函数如何访问x的方式。

接下来:5?全局变量的详细查看

五、全局变量的详细了解

原文:exploringjs.com/deep-js/ch_global-scope.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 5.1?作用域

  • 5.2?词法环境

  • 5.3?全局对象

  • 5.4?在浏览器中,globalThis 不直接指向全局对象

  • 5.5?全局环境

    • 5.5.1?脚本作用域和模块作用域

    • 5.5.2?创建变量:声明记录 vs. 对象记录

    • 5.5.3?获取或设置变量

    • 5.5.4?全局 ECMAScript 变量和全局宿主变量

  • 5.6?结论:为什么 JavaScript 同时具有普通全局变量和全局对象?

  • 5.7?本章的进一步阅读和来源


在本章中,我们将详细了解 JavaScript 的全局变量是如何工作的。有几个有趣的现象起到了作用:脚本的作用域,所谓的全局对象等等。

5.1?作用域

变量的词法作用域(简称:作用域)是程序中可以访问它的区域。JavaScript 的作用域是静态的(在运行时不会改变),并且可以嵌套 - 例如:

function func() { // (A)
 const aVariable = 1;
 if (true) { // (B)
 const anotherVariable = 2;
 }
}

if 语句引入的作用域(B 行)嵌套在函数 func() 的作用域(A 行)内。

作用域 S 的最内层包围作用域称为 S 的外部作用域。在例子中,funcif 的外部作用域。

5.2?词法环境

在 JavaScript 语言规范中,作用域通过词法环境“实现”。它们由两个组件组成:

  • 将变量名映射到变量值的环境记录(类似于字典)。这是作用域变量的实际存储空间。记录中的名称-值条目称为绑定

  • 作用域的外部环境的引用 - 外部作用域的环境。

因此,嵌套作用域的树由外部环境引用链接的环境树表示。

5.3?全局对象

全局对象是一个对象,其属性成为全局变量。(我们很快将会详细讨论它如何适配环境树。)可以通过以下全局变量访问它:

  • 在所有平台上都可用:globalThis。其名称基于它在全局作用域中与 this 具有相同的值。

  • 全局对象的其他变量在所有平台上都不可用:

    • window 是指向全局对象的经典方式。它在正常的浏览器代码中有效,但在Web Workers(与正常浏览器进程并发运行的进程)和 Node.js 中无效。

    • self 在浏览器中随处可用(包括在 Web Workers 中)。但它不受 Node.js 支持。

    • global 仅在 Node.js 上可用。

5.4?在浏览器中,globalThis 不直接指向全局对象

在浏览器中,globalThis 不直接指向全局对象,而是有一个间接引用。例如,考虑网页上的 iframe:

  • 每当 iframe 的 src 改变时,它都会获得一个新的全局对象。

  • 然而,globalThis 总是具有相同的值。可以从 iframe 外部检查该值,如下所示(灵感来自globalThis提案中的一个例子)。

文件 parent.html

<iframe src="iframe.html?first"></iframe>
<script>
 const iframe = document.querySelector('iframe');
 const icw = iframe.contentWindow; // `globalThis` of iframe

 iframe.onload = () => {
 // Access properties of global object of iframe
 const firstGlobalThis = icw.globalThis;
 const firstArray = icw.Array;
 console.log(icw.iframeName); // 'first'

 iframe.onload = () => {
 const secondGlobalThis = icw.globalThis;
 const secondArray = icw.Array;

 // The global object is different
 console.log(icw.iframeName); // 'second'
 console.log(secondArray === firstArray); // false

 // But globalThis is still the same
 console.log(firstGlobalThis === secondGlobalThis); // true
 };
 iframe.src = 'iframe.html?second';
 };
</script>

文件 iframe.html

<script>
 globalThis.iframeName = location.search.slice(1);
</script>

浏览器如何确保在这种情况下globalThis不会改变?它们内部区分了两个对象:

  • Window是全局对象。它会在位置改变时改变。

  • WindowProxy是一个对象,它将所有访问都转发到当前的Window。这个对象永远不会改变。

在浏览器中,globalThis 指的是 WindowProxy;在其他地方,它直接指向全局对象。

5.5 全局环境

全局范围是“最外层”的范围 - 它没有外部范围。它的环境是全局环境。每个环境都通过一系列由外部环境引用链接的环境链与全局环境连接。全局环境的外部环境引用是null

全局环境记录使用两个环境记录来管理其变量:

  • 对象环境记录具有与普通环境记录相同的接口,但它将其绑定存储在 JavaScript 对象中。在这种情况下,对象就是全局对象。

  • 一个普通(声明性)环境记录,它有自己的绑定存储。

哪个环境记录将在何时使用将很快解释。

5.5.1 脚本范围和模块范围

在 JavaScript 中,我们只在脚本的顶层处于全局范围。相反,每个模块都有自己的作用域,它是脚本范围的子作用域。

如果我们忽略了变量绑定如何添加到全局环境的相对复杂的规则,那么全局范围和模块范围的工作就好像它们是嵌套的代码块一样:

{ // Global scope (scope of *all* scripts)

 // (Global variables)

 { // Scope of module 1
 ···
 }
 { // Scope of module 2
 ···
 }
 // (More module scopes)
}
5.5.2 创建变量:声明性记录 vs. 对象记录

为了创建一个真正全局的变量,我们必须处于全局范围内 - 这只有在脚本的顶层才是这样:

  • 顶层的constletclass在声明性环境记录中创建绑定。

  • 顶层的var和函数声明在对象环境记录中创建绑定。

<script>
 const one = 1;
 var two = 2;
</script>
<script>
 // All scripts share the same top-level scope:
 console.log(one); // 1
 console.log(two); // 2

 // Not all declarations create properties of the global object:
 console.log(globalThis.one); // undefined
 console.log(globalThis.two); // 2
</script>
5.5.3 获取或设置变量

当我们获取或设置一个变量,并且两个环境记录都有该变量的绑定时,那么声明性记录会胜出:

<script>
 let myGlobalVariable = 1; // declarative environment record
 globalThis.myGlobalVariable = 2; // object environment record

 console.log(myGlobalVariable); // 1 (declarative record wins)
 console.log(globalThis.myGlobalVariable); // 2
</script>
5.5.4 全局 ECMAScript 变量和全局宿主变量

除了通过var和函数声明创建的变量之外,全局对象还包含以下属性:

  • 所有 ECMAScript 的内置全局变量

  • 宿主平台(浏览器、Node.js 等)的所有内置全局变量

使用 constlet 可以保证全局变量声明不会影响(或受到影响)ECMAScript 和宿主平台的内置全局变量。

例如,浏览器有全局变量.location

// Changes the location of the current document:
var location = 'https://example.com';

// Shadows window.location, doesn’t change it:
let location = 'https://example.com';

如果变量已经存在(比如在这种情况下的 location),那么带有初始化器的 var 声明就像是一个赋值。这就是为什么我们在这个例子中遇到麻烦的原因。

请注意,这只是在全局范围中的一个问题。在模块中,我们永远不会处于全局范围(除非我们使用eval()或类似的东西)。

图 10 总结了本节中我们所学到的一切。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10:全局范围的环境通过一个全局环境记录来管理其绑定,该记录又基于两个环境记录:一个对象环境记录,其绑定存储在全局对象中,以及一个声明性环境记录,它使用内部存储来存储其绑定。因此,全局变量可以通过向全局对象添加属性或通过各种声明来创建。全局对象初始化为 ECMAScript 和宿主平台的内置全局变量。每个 ECMAScript 模块都有自己的环境,其外部环境是全局环境。

5.6 结论:为什么 JavaScript 既有普通的全局变量又有全局对象?

全局对象通常被认为是一个错误。因此,新的构造,如constlet和类,在脚本范围内创建普通的全局变量。

值得庆幸的是,现代 JavaScript 中编写的大多数代码都存在于ECMAScript 模块和 CommonJS 模块中。每个模块都有自己的作用域,这就是为什么控制全局变量的规则很少适用于基于模块的代码。

5.7 进一步阅读和本章的来源

ECMAScript 规范中的环境和全局对象:

  • “词法环境”部分提供了环境的概述。

  • “全局环境记录”部分涵盖了全局环境。

  • “ECMAScript 标准内置对象”部分描述了 ECMAScript 如何管理其内置对象(其中包括全局对象)。

globalThis

  • 2ality 发布了“ES 功能:globalThis”的帖子

  • 各种访问全局this值的方式:Mathias Bynens 的“一个可怕的globalThis多填充在通用 JavaScript 中”

浏览器中的全局对象:

  • 关于浏览器中发生的事情的背景:Anne van Kesteren 的“定义 WindowProxy、Window 和 Location 对象”

  • 非常技术性:WHATWG HTML 标准中的“领域、设置对象和全局对象”部分

  • 在 ECMAScript 规范中,我们可以看到 Web 浏览器如何自定义全局this:“InitializeHostDefinedRealm()”部分

评论

第三部分:处理数据

原文:exploringjs.com/deep-js/pt_data.html

译者:飞龙

协议:CC BY-NC-SA 4.0

下一步:6?复制对象和数组

六、复制对象和数组

原文:exploringjs.com/deep-js/ch_copying-objects-and-arrays.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 6.1?浅复制 vs. 深复制

  • 6.2?JavaScript 中的浅复制

    • 6.2.1?通过扩展复制普通对象和数组

    • 6.2.2?通过Object.assign()进行浅复制(可选)

    • 6.2.3?通过Object.getOwnPropertyDescriptors()Object.defineProperties()进行浅复制(可选)

  • 6.3?JavaScript 中的深复制

    • 6.3.1?通过嵌套扩展手动进行深复制

    • 6.3.2?技巧:通过 JSON 进行通用深复制

    • 6.3.3?实现通用深复制

  • 6.4?进一步阅读


在本章中,我们将学习如何在 JavaScript 中复制对象和数组。

6.1?浅复制 vs. 深复制

数据可以以两种“深度”进行复制:

  • 浅复制只会复制对象和数组的顶层条目。条目的值在原始和副本中仍然相同。

  • 深复制还会复制值的条目的条目等。也就是说,它遍历了以要复制的值为根的完整树,并复制了所有节点。

接下来的部分涵盖了两种复制方式。不幸的是,JavaScript 只内置了对浅复制的支持。如果我们需要深复制,就需要自己实现。

6.2?JavaScript 中的浅复制

让我们看看浅复制数据的几种方法。

6.2.1?通过扩展复制普通对象和数组

我们可以将扩展到对象字面量和扩展到数组字面量中进行复制:

const copyOfObject = {...originalObject};
const copyOfArray = [...originalArray];

遗憾的是,扩展存在一些问题。这些将在下一小节中介绍。其中一些是真正的限制,另一些只是特殊情况。

6.2.1.1?对象扩展不会复制原型

例如:

class MyClass {}

const original = new MyClass();
assert.equal(original instanceof MyClass, true);

const copy = {...original};
assert.equal(copy instanceof MyClass, false);

请注意以下两个表达式是等价的:

obj instanceof SomeClass
SomeClass.prototype.isPrototypeOf(obj)

因此,我们可以通过给副本设置与原始相同的原型来解决这个问题:

class MyClass {}

const original = new MyClass();

const copy = {
 __proto__: Object.getPrototypeOf(original),
 ...original,
};
assert.equal(copy instanceof MyClass, true);

或者,我们可以在创建副本后通过Object.setPrototypeOf()设置其原型。

6.2.1.2?许多内置对象具有特殊的“内部插槽”,对象扩展不会复制它们

此类内置对象的示例包括正则表达式和日期。如果我们复制它们,就会丢失其中存储的大部分数据。

6.2.1.3?对象扩展只会复制自有(非继承)属性

考虑到原型链的工作方式,这通常是正确的方法。但我们仍然需要意识到这一点。在下面的例子中,原型的继承属性.inheritedPropcopy中不可用,因为我们只复制自有属性,而不保留原型。

const proto = { inheritedProp: 'a' };
const original = {__proto__: proto, ownProp: 'b' };
assert.equal(original.inheritedProp, 'a');
assert.equal(original.ownProp, 'b');

const copy = {...original};
assert.equal(copy.inheritedProp, undefined);
assert.equal(copy.ownProp, 'b');
6.2.1.4?对象扩展只会复制可枚举属性

例如,数组实例的自有属性.length不可枚举,也不会被复制。在下面的例子中,我们通过对象扩展(行 A)复制了数组arr

const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);

const copy = {...arr}; // (A)
assert.equal({}.hasOwnProperty.call(copy, 'length'), false);

这也很少是一个限制,因为大多数属性都是可枚举的。如果我们需要复制不可枚举的属性,我们可以使用Object.getOwnPropertyDescriptors()Object.defineProperties()来复制对象(如何做到这一点稍后会解释):

  • 它们考虑所有属性(不仅仅是value),因此正确地复制了 getter、setter、只读属性等。

  • Object.getOwnPropertyDescriptors()检索可枚举和不可枚举的属性。

有关可枚举性的更多信息,请参见§12“属性的可枚举性”。

6.2.1.5?对象扩展并不总是忠实地复制属性

不考虑属性的属性,它的副本始终是一个可写和可配置的数据属性。

例如,在这里,我们创建了属性original.prop,其属性writableconfigurable都是false

const original = Object.defineProperties(
 {}, {
 prop: {
 value: 1,
 writable: false,
 configurable: false,
 enumerable: true,
 },
 });
assert.deepEqual(original, {prop: 1});

如果我们复制.prop,那么writableconfigurable都是true

const copy = {...original};
// Attributes `writable` and `configurable` of copy are different:
assert.deepEqual(
 Object.getOwnPropertyDescriptors(copy),
 {
 prop: {
 value: 1,
 writable: true,
 configurable: true,
 enumerable: true,
 },
 });

因此,getter 和 setter 也不会被忠实地复制:

const original = {
 get myGetter() { return 123 },
 set mySetter(x) {},
};
assert.deepEqual({...original}, {
 myGetter: 123, // not a getter anymore!
 mySetter: undefined,
});

前面提到的Object.getOwnPropertyDescriptors()Object.defineProperties()总是以完整的属性传输自有属性(如后面所示)。

6.2.1.6?复制是浅的

副本中有原始每个键值条目的新版本,但原始的值本身并没有被复制。例如:

const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {...original};

// Property .name is a copy: changing the copy
// does not affect the original
copy.name = 'John';
assert.deepEqual(original,
 {name: 'Jane', work: {employer: 'Acme'}});
assert.deepEqual(copy,
 {name: 'John', work: {employer: 'Acme'}});

// The value of .work is shared: changing the copy
// affects the original
copy.work.employer = 'Spectre';
assert.deepEqual(
 original, {name: 'Jane', work: {employer: 'Spectre'}});
assert.deepEqual(
 copy, {name: 'John', work: {employer: 'Spectre'}});

我们将在本章后面讨论深拷贝。

6.2.2?通过Object.assign()进行浅复制(可选)

Object.assign()在大多数情况下像扩展到对象中。也就是说,以下两种复制方式大部分是等价的:

const copy1 = {...original};
const copy2 = Object.assign({}, original);

使用方法而不是语法的好处是,它可以在旧的 JavaScript 引擎上通过库进行填充。

Object.assign()并不完全像扩展,它在一个相对微妙的地方有所不同:它以不同的方式创建属性。

  • Object.assign()使用赋值来创建副本的属性。

  • 扩展定义了副本中的新属性。

除其他事项外,赋值会调用自有和继承的 setter,而定义不会(有关赋值与定义的更多信息)。这种差异很少会被注意到。以下代码是一个例子,但它是刻意的:

const original = {['__proto__']: null}; // (A)
const copy1 = {...original};
// copy1 has the own property '__proto__'
assert.deepEqual(
 Object.keys(copy1), ['__proto__']);

const copy2 = Object.assign({}, original);
// copy2 has the prototype null
assert.equal(Object.getPrototypeOf(copy2), null);

通过在 A 行使用计算属性键,我们创建了.__proto__作为自有属性,并且没有调用继承的 setter。然而,当Object.assign()复制该属性时,它会调用 setter。(有关.__proto__的更多信息,请参见“JavaScript for impatient programmers”。)

6.2.3?通过Object.getOwnPropertyDescriptors()Object.defineProperties()进行浅复制(可选)

JavaScript 允许我们通过属性描述符创建属性,这些属性描述了属性的属性。例如,通过Object.defineProperties(),我们已经看到了它的作用。如果我们将该方法与Object.getOwnPropertyDescriptors()结合使用,我们可以更忠实地复制:

function copyAllOwnProperties(original) {
 return Object.defineProperties(
 {}, Object.getOwnPropertyDescriptors(original));
}

这消除了通过扩展复制对象的两个问题。

首先,自有属性的所有属性都被正确地复制。因此,我们现在可以复制自有的 getter 和 setter:

const original = {
 get myGetter() { return 123 },
 set mySetter(x) {},
};
assert.deepEqual(copyAllOwnProperties(original), original);

其次,由于Object.getOwnPropertyDescriptors(),不可枚举的属性也被复制了:

const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);

const copy = copyAllOwnProperties(arr);
assert.equal({}.hasOwnProperty.call(copy, 'length'), true);

6.3?JavaScript 中的深拷贝

现在是时候解决深拷贝的问题了。首先,我们将手动进行深拷贝,然后我们将研究通用的方法。

6.3.1?通过嵌套扩展手动进行深拷贝

如果我们嵌套扩展,我们会得到深拷贝:

const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {name: original.name, work: {...original.work}};

// We copied successfully:
assert.deepEqual(original, copy);
// The copy is deep:
assert.ok(original.work !== copy.work);
6.3.2?技巧:通过 JSON 进行通用深拷贝

这是一个技巧,但在紧急情况下,它提供了一个快速解决方案:为了深拷贝一个对象original,我们首先将其转换为 JSON 字符串,然后解析该 JSON 字符串:

function jsonDeepCopy(original) {
 return JSON.parse(JSON.stringify(original));
}
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = jsonDeepCopy(original);
assert.deepEqual(original, copy);

这种方法的重大缺点是,我们只能复制由 JSON 支持的键和值的属性。

一些不支持的键和值会被简单地忽略:

assert.deepEqual(
 jsonDeepCopy({
 // Symbols are not supported as keys
 [Symbol('a')]: 'abc',
 // Unsupported value
 b: function () {},
 // Unsupported value
 c: undefined,
 }),
 {} // empty object
);

其他会引发异常:

assert.throws(
 () => jsonDeepCopy({a: 123n}),
 /^TypeError: Do not know how to serialize a BigInt$/);
6.3.3 实现通用的深拷贝

以下函数通用地深拷贝一个值original

function deepCopy(original) {
 if (Array.isArray(original)) {
 const copy = [];
 for (const [index, value] of original.entries()) {
 copy[index] = deepCopy(value);
 }
 return copy;
 } else if (typeof original === 'object' && original !== null) {
 const copy = {};
 for (const [key, value] of Object.entries(original)) {
 copy[key] = deepCopy(value);
 }
 return copy;
 } else {
 // Primitive value: atomic, no need to copy
 return original;
 }
}

该函数处理三种情况:

  • 如果original是一个数组,我们创建一个新的数组,并将original的元素深拷贝到其中。

  • 如果original是一个对象,我们使用类似的方法。

  • 如果original是一个原始值,我们不需要做任何事情。

让我们试试deepCopy()

const original = {a: 1, b: {c: 2, d: {e: 3}}};
const copy = deepCopy(original);

// Are copy and original deeply equal?
assert.deepEqual(copy, original);

// Did we really copy all levels
// (equal content, but different objects)?
assert.ok(copy     !== original);
assert.ok(copy.b   !== original.b);
assert.ok(copy.b.d !== original.b.d);

请注意,deepCopy()只修复了扩展的一个问题:浅拷贝。其他问题仍然存在:原型不会被复制,特殊对象只会被部分复制,不可枚举的属性会被忽略,大多数属性特性会被忽略。

通用地实现完全的复制通常是不可能的:并非所有数据都是树状结构,有时我们不想复制所有属性,等等。

6.3.3.1 更简洁的deepCopy()版本

如果我们使用.map()Object.fromEntries(),我们可以使我们先前的deepCopy()实现更加简洁:

function deepCopy(original) {
 if (Array.isArray(original)) {
 return original.map(elem => deepCopy(elem));
 } else if (typeof original === 'object' && original !== null) {
 return Object.fromEntries(
 Object.entries(original)
 .map(([k, v]) => [k, deepCopy(v)]));
 } else {
 // Primitive value: atomic, no need to copy
 return original;
 }
}

6.4 进一步阅读

  • 第 14 节“复制类的实例:.clone() vs. copy constructors”解释了基于类的复制模式。

  • “扩展到对象文字”部分在“JavaScript for impatient programmers”中

  • “扩展到数组文字”部分在“JavaScript for impatient programmers”中

  • “原型链”部分在“JavaScript for impatient programmers”中

评论

七、更新数据的破坏性和非破坏性

原文:exploringjs.com/deep-js/ch_updating-destructively-and-nondestructively.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 7.1 示例:破坏性和非破坏性地更新对象

  • 7.2 示例:破坏性和非破坏性地更新数组

  • 7.3 手动深层更新

  • 7.4 实现通用的深层更新


在本章中,我们学习了两种不同的更新数据的方式:

  • 破坏性更新会改变数据,使其具有所需的形式。

  • 非破坏性更新会创建一个具有所需形式的数据副本。

后一种方式类似于首先制作副本,然后进行破坏性更改,但它同时进行了两者。

7.1 示例:破坏性和非破坏性地更新对象

以下代码展示了一个更新对象属性的函数,并在对象上使用它。

function setPropertyDestructively(obj, key, value) {
 obj[key] = value;
 return obj;
}

const obj = {city: 'Berlin', country: 'Germany'};
setPropertyDestructively(obj, 'city', 'Munich');
assert.deepEqual(obj, {city: 'Munich', country: 'Germany'});

以下代码演示了对对象进行非破坏性更新:

function setPropertyNonDestructively(obj, key, value) {
 const updatedObj = {};
 for (const [k, v] of Object.entries(obj)) {
 updatedObj[k] = (k === key ? value : v);
 }
 return updatedObj;
}

const obj = {city: 'Berlin', country: 'Germany'};
const updatedObj = setPropertyNonDestructively(obj, 'city', 'Munich');

// We have created an updated object:
assert.deepEqual(updatedObj, {city: 'Munich', country: 'Germany'});

// But we didn’t change the original:
assert.deepEqual(obj, {city: 'Berlin', country: 'Germany'});

扩展使setPropertyNonDestructively()更简洁:

function setPropertyNonDestructively(obj, key, value) {
 return {...obj, [key]: value};
}

setPropertyNonDestructively()的两个版本都是浅层更新:它们只改变对象的顶层。

7.2 示例:破坏性和非破坏性地更新数组

以下代码展示了一个更新数组元素的函数,并在数组上使用它。

function setElementDestructively(arr, index, value) {
 arr[index] = value;
}

const arr = ['a', 'b', 'c', 'd', 'e'];
setElementDestructively(arr, 2, 'x');
assert.deepEqual(arr, ['a', 'b', 'x', 'd', 'e']);

以下代码演示了对数组进行非破坏性更新:

function setElementNonDestructively(arr, index, value) {
 const updatedArr = [];
 for (const [i, v] of arr.entries()) {
 updatedArr.push(i === index ? value : v);
 }
 return updatedArr;
}

const arr = ['a', 'b', 'c', 'd', 'e'];
const updatedArr = setElementNonDestructively(arr, 2, 'x');
assert.deepEqual(updatedArr, ['a', 'b', 'x', 'd', 'e']);
assert.deepEqual(arr, ['a', 'b', 'c', 'd', 'e']);

.slice()和扩展使setElementNonDestructively()更简洁:

function setElementNonDestructively(arr, index, value) {
 return [
 ...arr.slice(0, index), value, ...arr.slice(index+1)];
}

setElementNonDestructively()的两个版本都是浅层更新:它们只改变数组的顶层。

7.3 手动深层更新

到目前为止,我们只是浅层更新了数据。让我们来解决深层更新的问题。以下代码展示了如何手动进行深层更新。我们正在更改名称和雇主。

const original = {name: 'Jane', work: {employer: 'Acme'}};
const updatedOriginal = {
 ...original,
 name: 'John',
 work: {
 ...original.work,
 employer: 'Spectre'
 },
};

assert.deepEqual(
 original, {name: 'Jane', work: {employer: 'Acme'}});
assert.deepEqual(
 updatedOriginal, {name: 'John', work: {employer: 'Spectre'}});

7.4 实现通用的深层更新

以下函数实现了通用的深层更新。

function deepUpdate(original, keys, value) {
 if (keys.length === 0) {
 return value;
 }
 const currentKey = keys[0];
 if (Array.isArray(original)) {
 return original.map(
 (v, index) => index === currentKey
 ? deepUpdate(v, keys.slice(1), value) // (A)
 : v); // (B)
 } else if (typeof original === 'object' && original !== null) {
 return Object.fromEntries(
 Object.entries(original).map(
 (keyValuePair) => {
 const [k,v] = keyValuePair;
 if (k === currentKey) {
 return [k, deepUpdate(v, keys.slice(1), value)]; // (C)
 } else {
 return keyValuePair; // (D)
 }
 }));
 } else {
 // Primitive value
 return original;
 }
}

如果我们将value视为正在更新的树的根,那么deepUpdate()只会深度更改单个分支(A 和 C 行)。所有其他分支都是浅层复制(B 和 D 行)。

使用deepUpdate()看起来像这样:

const original = {name: 'Jane', work: {employer: 'Acme'}};

const copy = deepUpdate(original, ['work', 'employer'], 'Spectre');
assert.deepEqual(copy, {name: 'Jane', work: {employer: 'Spectre'}});
assert.deepEqual(original, {name: 'Jane', work: {employer: 'Acme'}});

评论

八、共享的可变状态的问题及如何避免它们

原文:exploringjs.com/deep-js/ch_shared-mutable-state.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 8.1?什么是共享的可变状态,为什么有问题?

  • 8.2?通过复制数据来避免共享

    • 8.2.1?复制如何帮助处理共享的可变状态?
  • 8.3?通过非破坏性更新避免突变

    • 8.3.1?非破坏性更新如何帮助处理共享的可变状态?
  • 8.4?通过使数据不可变来防止突变

    • 8.4.1?不可变性如何帮助处理共享的可变状态?
  • 8.5?用于避免共享可变状态的库

    • 8.5.1?Immutable.js

    • 8.5.2?Immer


本章回答以下问题:

  • 什么是共享的可变状态?

  • 为什么这是有问题的?

  • 如何避免它的问题?

8.1?什么是共享的可变状态,为什么有问题?

共享的可变状态工作如下:

  • 如果两个或更多参与方可以更改相同的数据(变量、对象等)。

  • 以及它们的生命周期是否重叠。

  • 那么就有一种风险,即一个参与方的修改会阻止其他参与方正确工作。

请注意,此定义适用于函数调用、协作式多任务处理(例如 JavaScript 中的异步函数)等。在每种情况下风险是相似的。

以下代码是一个例子。这个例子并不现实,但它演示了风险并且容易理解:

function logElements(arr) {
 while (arr.length > 0) {
 console.log(arr.shift());
 }
}

function main() {
 const arr = ['banana', 'orange', 'apple'];

 console.log('Before sorting:');
 logElements(arr);

 arr.sort(); // changes arr

 console.log('After sorting:');
 logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'

在这种情况下,有两个独立的参与方:

  • 函数 main() 想要在对数组进行排序之前和之后记录日志。

  • 函数 logElements() 记录其参数 arr 的元素,但在这样做时会将它们删除。

logElements() 打破了 main() 并导致它在 A 行记录一个空数组。

在本章的其余部分,我们将看看避免共享可变状态问题的三种方法:

  • 通过复制数据来避免共享

  • 通过非破坏性更新避免突变

  • 通过使数据不可变来防止突变

特别是,我们将回到刚刚看到的例子并加以修复。

8.2?通过复制数据来避免共享

复制数据是避免共享数据的一种方式。

背景

有关在 JavaScript 中复制数据的背景,请参考本书中以下两章:

  • §6 “复制对象和数组”

  • §14 “复制类的实例:.clone() vs. 复制构造函数”

8.2.1?复制如何帮助处理共享的可变状态?

只要我们只是从共享状态中读取,我们就没有任何问题。在修改之前,我们需要通过复制来“取消共享”它(尽可能深入)。

防御性复制 是一种技术,总是在可能出现问题时进行复制。其目标是保持当前实体(函数、类等)的安全:

  • 输入:复制(可能)共享的数据传递给我们,让我们可以使用该数据而不受外部实体的干扰。

  • 输出:在向外部参与方暴露内部数据之前复制内部数据,意味着该参与方无法干扰我们的内部活动。

请注意,这些措施保护我们免受其他参与方的伤害,但它们也保护其他参与方免受我们的伤害。

接下来的章节将说明两种防御性复制的方式。

8.2.1.1?复制共享输入

请记住,在本章开头的激励示例中,我们因为logElements()修改了它的参数arr而陷入了麻烦:

function logElements(arr) {
 while (arr.length > 0) {
 console.log(arr.shift());
 }
}

让我们在这个函数中添加防御性复制:

function logElements(arr) {
 arr = [...arr]; // defensive copy
 while (arr.length > 0) {
 console.log(arr.shift());
 }
}

现在,如果在main()中调用logElements(),它就不会再引起问题:

function main() {
 const arr = ['banana', 'orange', 'apple'];

 console.log('Before sorting:');
 logElements(arr);

 arr.sort(); // changes arr

 console.log('After sorting:');
 logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'
// 'apple'
// 'banana'
// 'orange'
8.2.1.2?复制暴露的内部数据

让我们从一个不复制暴露的内部数据的StringBuilder类开始(A 行):

class StringBuilder {
 _data = [];
 add(str) {
 this._data.push(str);
 }
 getParts() {
 // We expose internals without copying them:
 return this._data; // (A)
 }
 toString() {
 return this._data.join('');
 }
}

只要不使用.getParts(),一切都运行良好:

const sb1 = new StringBuilder();
sb1.add('Hello');
sb1.add(' world!');
assert.equal(sb1.toString(), 'Hello world!');

然而,如果.getParts()的结果发生了变化(A 行),那么StringBuilder将无法正常工作:

const sb2 = new StringBuilder();
sb2.add('Hello');
sb2.add(' world!');
sb2.getParts().length = 0; // (A)
assert.equal(sb2.toString(), ''); // not OK

解决方案是在暴露之前(A 行)防御性地复制内部的._data

class StringBuilder {
 this._data = [];
 add(str) {
 this._data.push(str);
 }
 getParts() {
 // Copy defensively
 return [...this._data]; // (A)
 }
 toString() {
 return this._data.join('');
 }
}

现在更改.getParts()的结果不再干扰sb的操作:

const sb = new StringBuilder();
sb.add('Hello');
sb.add(' world!');
sb.getParts().length = 0;
assert.equal(sb.toString(), 'Hello world!'); // OK

8.3?通过非破坏性更新避免突变

如果我们只进行非破坏性地更新数据,就可以避免突变。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 背景

有关更新数据的更多信息,请参见§7 “破坏性和非破坏性更新数据”。

8.3.1?非破坏性更新如何帮助共享可变状态?

通过非破坏性更新,共享数据变得不成问题,因为我们从不改变共享的数据。(这仅在所有访问数据的人都这样做时才有效!)

有趣的是,复制数据变得非常简单:

const original = {city: 'Berlin', country: 'Germany'};
const copy = original;

这是有效的,因为我们只进行非破坏性的更改,因此需要按需复制数据。

8.4?通过使数据不可变来防止突变

我们可以通过使数据不可变来防止共享数据的突变。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 背景

有关如何使 JavaScript 中的数据不可变的背景,请参阅本书中的以下两章:

  • §10 “保护对象免受更改”

  • §15 “不可变集合的包装器”

8.4.1?不可变性如何帮助共享可变状态?

如果数据是不可变的,它可以在没有任何风险的情况下共享。特别是,没有必要进行防御性复制。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 非破坏性更新是不可变数据的重要补充

如果我们将这两者结合起来,不可变数据变得几乎和可变数据一样多样化,但没有相关的风险。

8.5?避免共享可变状态的库

JavaScript 有几个可用的库支持不可变数据和非破坏性更新。其中两个流行的是:

  • Immutable.js为列表、堆栈、集合、映射等提供不可变的数据结构。

  • Immer 还支持对普通对象、数组、集合和映射进行不可变性和非破坏性更新。也就是说,不需要新的数据结构。

这些库将在接下来的两个部分中更详细地描述。

8.5.1?Immutable.js

在其存储库中,Immutable.js 库被描述为:

JavaScript 的不可变持久数据集,可以提高效率和简单性。

Immutable.js 提供不可变的数据结构,如:

  • List

  • Stack

  • Set(与 JavaScript 内置的Set不同)

  • Map(与 JavaScript 内置的Map不同)

  • 等等。

在以下示例中,我们使用不可变的Map

import {Map} from 'immutable/dist/immutable.es.js';
const map0 = Map([
 [false, 'no'],
 [true, 'yes'],
]);

// We create a modified version of map0:
const map1 = map0.set(true, 'maybe');

// The modified version is different from the original:
assert.ok(map1 !== map0);
assert.equal(map1.equals(map0), false); // (A)

// We undo the change we just made:
const map2 = map1.set(true, 'yes');

// map2 is a different object than map0,
// but it has the same content
assert.ok(map2 !== map0);
assert.equal(map2.equals(map0), true); // (B)

注:

  • 与修改接收器不同,“破坏性”操作(如.set())返回修改后的副本。

  • 要检查两个数据结构是否具有相同的内容,我们使用内置的.equals()方法(A 行和 B 行)。

8.5.2?Immer

在其存储库中,Immer 库被描述为:

通过对当前状态进行突变来创建下一个不可变状态。

Immer 有助于对(可能嵌套的)普通对象、数组、集合和映射进行非破坏性更新。也就是说,没有涉及自定义数据结构。

使用 Immer 看起来像这样:

import {produce} from 'immer/dist/immer.module.js';

const people = [
 {name: 'Jane', work: {employer: 'Acme'}},
];

const modifiedPeople = produce(people, (draft) => {
 draft[0].work.employer = 'Cyberdyne';
 draft.push({name: 'John', work: {employer: 'Spectre'}});
});

assert.deepEqual(modifiedPeople, [
 {name: 'Jane', work: {employer: 'Cyberdyne'}},
 {name: 'John', work: {employer: 'Spectre'}},
]);
assert.deepEqual(people, [
 {name: 'Jane', work: {employer: 'Acme'}},
]);

原始数据存储在people中。produce()为我们提供了一个变量draft。我们假装这个变量是people,并使用通常会进行破坏性更改的操作。Immer 拦截这些操作。它不会改变draft,而是以非破坏性的方式改变people。结果被引用为modifiedPeople。作为奖励,它是深度不可变的。

assert.deepEqual()之所以有效是因为 Immer 返回普通对象和数组。

评论

第四部分:OOP:对象属性属性

原文:exploringjs.com/deep-js/pt_object-property-attributes.html

译者:飞龙

协议:CC BY-NC-SA 4.0

接下来:9 属性属性:介绍

九、属性属性:介绍

原文:exploringjs.com/deep-js/ch_property-attributes-intro.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 9.1?对象的结构

    • 9.1.1?内部插槽

    • 9.1.2?属性键

    • 9.1.3?属性属性

  • 9.2?属性描述符

  • 9.3?检索属性的描述符

    • 9.3.1?Object.getOwnPropertyDescriptor(): 检索单个属性的描述符

    • 9.3.2?Object.getOwnPropertyDescriptors(): 检索对象所有属性的描述符

  • 9.4?通过描述符定义属性

    • 9.4.1?Object.defineProperty(): 通过描述符定义单个属性

    • 9.4.2?Object.defineProperties(): 通过描述符定义多个属性

  • 9.5?Object.create(): 通过描述符创建对象

  • 9.6?Object.getOwnPropertyDescriptors()的用例

    • 9.6.1?用例:将属性复制到对象中

    • 9.6.2?Object.getOwnPropertyDescriptors()的用例:克隆对象

  • 9.7?省略描述符属性

    • 9.7.1?在创建属性时省略描述符属性

    • 9.7.2?在更改属性时省略描述符属性

  • 9.8?内置构造使用什么属性属性?

    • 9.8.1?通过赋值创建的自有属性

    • 9.8.2?通过对象字面量创建的自有属性

    • 9.8.3?数组的自有属性.length

    • 9.8.4?内置类的原型属性

    • 9.8.5?用户定义类的原型属性和实例属性

  • 9.9?API:属性描述符

  • 9.10?进一步阅读


在本章中,我们更详细地了解了 ECMAScript 规范如何看待 JavaScript 对象。特别是,在规范中,属性不是原子的,而是由多个属性(类似记录中的字段)组成。甚至数据属性的值也存储在属性中!

9.1?对象的结构

在 ECMAScript 规范中,对象由以下组成:

  • 内部槽是存储位置,无法从 JavaScript 访问,只能从规范中的操作访问。

  • 属性的集合。每个属性将属性(类似于记录中的字段)关联起来。

9.1.1?内部槽

规范描述了内部槽如下。我添加了项目符号并强调了一部分:

  • 内部槽对应于与对象关联并由各种 ECMAScript 规范算法使用的内部状态。

  • 内部槽不是对象属性,也不会被继承。

  • 根据特定的内部槽规范,这种状态可能包括:

    • 任何 ECMAScript 语言类型的值或

    • 特定 ECMAScript 规范类型值。

  • 除非另有明确规定,否则内部槽将作为创建对象的过程的一部分分配,并且可能不会动态添加到对象中。

  • 除非另有规定,内部槽的初始值为undefined

  • 本规范中的各种算法创建具有内部槽的对象。然而,ECMAScript 语言没有直接的方法将内部槽与对象关联起来

  • 在本规范中,内部方法和内部槽使用双方括号[[ ]]括起来的名称进行标识。

有两种类型的内部槽:

  • 用于操作对象的方法槽(获取属性,设置属性等)。

  • 存储值的数据槽。

普通对象具有以下数据槽:

  • .[[Prototype]]: null | object

    • 存储对象的原型。

    • 可以通过Object.getPrototypeOf()Object.setPrototypeOf()间接访问。

  • .[[Extensible]]: boolean

    • 指示是否可以向对象添加属性。

    • 可以通过Object.preventExtensions()设置为false

  • .[[PrivateFieldValues]]: EntryList

    • 用于管理私有类字段。
9.1.2?属性键

属性的键可以是:

  • 一个字符串

  • 一个符号

9.1.3?属性属性

有两种属性,它们的特征是它们的属性:

  • 数据属性存储数据。它的属性value保存任何 JavaScript 值。

  • 访问器属性由 getter 函数和/或 setter 函数组成。前者存储在属性get中,后者存储在属性set中。

此外,两种属性都具有的属性。以下表列出了所有属性及其默认值。

属性类型 属性名称和类型 默认值
数据属性 value: any undefined
writable: boolean false
访问器属性 get: (this: any) => any undefined
set: (this: any, v: any) => void undefined
所有属性 configurable: boolean false
enumerable: boolean false

我们已经遇到了valuegetset属性。其他属性的工作方式如下:

  • writable确定数据属性的值是否可以更改。

  • configurable确定属性的属性是否可以更改。如果为false,则:

    • 我们不能删除属性。

    • 我们不能将属性从数据属性更改为访问器属性,反之亦然。

    • 我们不能更改除value之外的任何属性。

    • 但是,允许进行一次属性更改:我们可以将writabletrue更改为false。这种异常背后的原因是历史:数组的.length属性一直是可写的且不可配置的。允许更改其writable属性使我们能够冻结数组。

  • enumerable会影响一些操作(例如Object.keys())。如果它为false,那么这些操作会忽略该属性。大多数属性是可枚举的(例如通过赋值或对象文字创建的属性),这就是为什么你很少会在实践中注意到这个属性。如果你仍然对它的工作原理感兴趣,请参见[§12“属性的可枚举性”]。

9.1.3.1?陷阱:继承的不可写属性阻止通过赋值创建自有属性

如果继承的属性是不可写的,我们就不能使用赋值来创建具有相同键的自有属性:

const proto = {
 prop: 1,
};
// Make proto.prop non-writable:
Object.defineProperty(
 proto, 'prop', {writable: false});

const obj = Object.create(proto);

assert.throws(
 () => obj.prop = 2,
 /^TypeError: Cannot assign to read only property 'prop'/);

有关更多信息,请参见[§11.3.4“继承的只读属性阻止通过赋值创建自有属性”]。

9.2?属性描述符

属性描述符将属性的属性编码为 JavaScript 对象。它们的 TypeScript 接口如下所示。

interface DataPropertyDescriptor {
 value?: any;
 writable?: boolean;
 configurable?: boolean;
 enumerable?: boolean;
}
interface AccessorPropertyDescriptor {
 get?: (this: any) => any;
 set?: (this: any, v: any) => void;
 configurable?: boolean;
 enumerable?: boolean;
}
type PropertyDescriptor = DataPropertyDescriptor | AccessorPropertyDescriptor;

问号表示所有属性都是可选的。[§9.7“省略描述符属性”]描述了如果省略它们会发生什么。

9.3?检索属性的描述符

9.3.1?Object.getOwnPropertyDescriptor(): 检索单个属性的描述符

考虑以下对象:

const legoBrick = {
 kind: 'Plate 1x3',
 color: 'yellow',
 get description() {
 return `${this.kind} (${this.color})`;
 },
};

让我们首先获取数据属性.color的描述符:

assert.deepEqual(
 Object.getOwnPropertyDescriptor(legoBrick, 'color'),
 {
 value: 'yellow',
 writable: true,
 enumerable: true,
 configurable: true,
 });

访问器属性.description的描述符如下所示:

const desc = Object.getOwnPropertyDescriptor.bind(Object);
assert.deepEqual(
 Object.getOwnPropertyDescriptor(legoBrick, 'description'),
 {
 get: desc(legoBrick, 'description').get, // (A)
 set: undefined,
 enumerable: true,
 configurable: true
 });

在 A 行使用实用函数desc()可以确保.deepEqual()起作用。

9.3.2?Object.getOwnPropertyDescriptors(): 检索对象的所有属性的描述符
const legoBrick = {
 kind: 'Plate 1x3',
 color: 'yellow',
 get description() {
 return `${this.kind} (${this.color})`;
 },
};

const desc = Object.getOwnPropertyDescriptor.bind(Object);
assert.deepEqual(
 Object.getOwnPropertyDescriptors(legoBrick),
 {
 kind: {
 value: 'Plate 1x3',
 writable: true,
 enumerable: true,
 configurable: true,
 },
 color: {
 value: 'yellow',
 writable: true,
 enumerable: true,
 configurable: true,
 },
 description: {
 get: desc(legoBrick, 'description').get, // (A)
 set: undefined,
 enumerable: true,
 configurable: true,
 },
 });

在 A 行使用辅助函数desc()可以确保.deepEqual()起作用。

9.4?通过描述符定义属性

如果我们通过属性描述符propDesc定义具有键k的属性,那么发生的情况取决于:

  • 如果没有键为k的属性,则会创建一个具有propDesc指定的属性的新自有属性。

  • 如果存在键为k的属性,定义会更改属性的属性,使其与propDesc匹配。

9.4.1?Object.defineProperty(): 通过描述符定义单个属性

首先,让我们通过描述符创建一个新属性:

const car = {};

Object.defineProperty(car, 'color', {
 value: 'blue',
 writable: true,
 enumerable: true,
 configurable: true,
});

assert.deepEqual(
 car,
 {
 color: 'blue',
 });

接下来,我们通过描述符改变属性的种类;我们将数据属性转换为 getter:

const car = {
 color: 'blue',
};

let readCount = 0;
Object.defineProperty(car, 'color', {
 get() {
 readCount++;
 return 'red';
 },
});

assert.equal(car.color, 'red');
assert.equal(readCount, 1);

最后,我们通过描述符改变数据属性的值:

const car = {
 color: 'blue',
};

// Use the same attributes as assignment:
Object.defineProperty(
 car, 'color', {
 value: 'green',
 writable: true,
 enumerable: true,
 configurable: true,
 });

assert.deepEqual(
 car,
 {
 color: 'green',
 });

我们使用了与赋值相同的属性属性。

9.4.2?Object.defineProperties(): 通过描述符定义多个属性

Object.defineProperties()Object.defineProperty()的多属性版本:

const legoBrick1 = {};
Object.defineProperties(
 legoBrick1,
 {
 kind: {
 value: 'Plate 1x3',
 writable: true,
 enumerable: true,
 configurable: true,
 },
 color: {
 value: 'yellow',
 writable: true,
 enumerable: true,
 configurable: true,
 },
 description: {
 get: function () {
 return `${this.kind} (${this.color})`;
 },
 enumerable: true,
 configurable: true,
 },
 });

assert.deepEqual(
 legoBrick1,
 {
 kind: 'Plate 1x3',
 color: 'yellow',
 get description() {
 return `${this.kind} (${this.color})`;
 },
 });

9.5?Object.create(): 通过描述符创建对象

Object.create()创建一个新对象。它的第一个参数指定该对象的原型。它的可选第二个参数指定该对象的属性的描述符。在下一个示例中,我们创建了与上一个示例中相同的对象。

const legoBrick2 = Object.create(
 Object.prototype,
 {
 kind: {
 value: 'Plate 1x3',
 writable: true,
 enumerable: true,
 configurable: true,
 },
 color: {
 value: 'yellow',
 writable: true,
 enumerable: true,
 configurable: true,
 },
 description: {
 get: function () {
 return `${this.kind} (${this.color})`;
 },
 enumerable: true,
 configurable: true,
 },
 });

// Did we really create the same object?
assert.deepEqual(legoBrick1, legoBrick2); // Yes!

9.6?Object.getOwnPropertyDescriptors()的用例

Object.getOwnPropertyDescriptors()在与Object.defineProperties()Object.create()结合使用时,可以帮助我们处理两种用例。

9.6.1?用例:将属性复制到对象中

自 ES6 以来,JavaScript 已经有了一个用于复制属性的工具方法:Object.assign()。但是,该方法使用简单的获取和设置操作来复制键为key的属性:

target[key] = source[key];

这意味着只有在以下情况下才会创建属性的忠实副本:

  • 它的属性writabletrue,它的属性enumerabletrue(因为这就是赋值创建属性的方式)。

  • 它是一个数据属性。

以下示例说明了这个限制。对象source具有一个键为data的 setter。

const source = {
 set data(value) {
 this._data = value;
 }
};

// Property `data` exists because there is only a setter
// but has the value `undefined`.
assert.equal('data' in source, true);
assert.equal(source.data, undefined);

如果我们使用Object.assign()来复制属性data,那么访问器属性data将被转换为数据属性:

const target1 = {};
Object.assign(target1, source);

assert.deepEqual(
 Object.getOwnPropertyDescriptor(target1, 'data'),
 {
 value: undefined,
 writable: true,
 enumerable: true,
 configurable: true,
 });

// For comparison, the original:
const desc = Object.getOwnPropertyDescriptor.bind(Object);
assert.deepEqual(
 Object.getOwnPropertyDescriptor(source, 'data'),
 {
 get: undefined,
 set: desc(source, 'data').set,
 enumerable: true,
 configurable: true,
 });

幸运的是,使用Object.getOwnPropertyDescriptors()Object.defineProperties()一起确实可以复制属性data

const target2 = {};
Object.defineProperties(
 target2, Object.getOwnPropertyDescriptors(source));

assert.deepEqual(
 Object.getOwnPropertyDescriptor(target2, 'data'),
 {
 get: undefined,
 set: desc(source, 'data').set,
 enumerable: true,
 configurable: true,
 });
9.6.1.1?陷阱:复制使用super的方法

使用super的方法与其home object(存储在其中的对象)紧密相关。目前没有办法将这样的方法复制或移动到不同的对象。

9.6.2 Object.getOwnPropertyDescriptors()的用例:克隆对象

浅克隆类似于复制属性,这就是为什么在这里使用Object.getOwnPropertyDescriptors()也是一个不错的选择。

要创建克隆,我们使用Object.create()

const original = {
 set data(value) {
 this._data = value;
 }
};

const clone = Object.create(
 Object.getPrototypeOf(original),
 Object.getOwnPropertyDescriptors(original));

assert.deepEqual(original, clone);

有关此主题的更多信息,请参阅§6“复制对象和数组”。

9.7 省略描述符属性

描述符的所有属性都是可选的。省略属性时会发生什么取决于操作。

9.7.1 创建属性时省略描述符属性

当我们通过描述符创建新属性时,省略属性意味着使用它们的默认值:

const car = {};
Object.defineProperty(
 car, 'color', {
 value: 'red',
 });
assert.deepEqual(
 Object.getOwnPropertyDescriptor(car, 'color'),
 {
 value: 'red',
 writable: false,
 enumerable: false,
 configurable: false,
 });
9.7.2 更改属性时省略描述符属性

如果我们改变一个现有属性,那么省略描述符属性意味着相应的属性不受影响:

const car = {
 color: 'yellow',
};
assert.deepEqual(
 Object.getOwnPropertyDescriptor(car, 'color'),
 {
 value: 'yellow',
 writable: true,
 enumerable: true,
 configurable: true,
 });
Object.defineProperty(
 car, 'color', {
 value: 'pink',
 });
assert.deepEqual(
 Object.getOwnPropertyDescriptor(car, 'color'),
 {
 value: 'pink',
 writable: true,
 enumerable: true,
 configurable: true,
 });

内置构造使用什么属性属性?

属性属性的一般规则(少数例外)是:

  • 原型链开头的对象属性通常是可写的,可枚举的和可配置的。

  • 如枚举性章节中所述,大多数继承属性都是不可枚举的,以隐藏它们,使它们不被for-in循环等遗留构造所发现。继承属性通常是可写的和可配置的。

9.8.1 通过赋值创建的自有属性
const obj = {};
obj.prop = 3;

assert.deepEqual(
 Object.getOwnPropertyDescriptors(obj),
 {
 prop: {
 value: 3,
 writable: true,
 enumerable: true,
 configurable: true,
 }
 });
9.8.2 通过对象字面量创建的自有属性
const obj = { prop: 'yes' };

assert.deepEqual(
 Object.getOwnPropertyDescriptors(obj),
 {
 prop: {
 value: 'yes',
 writable: true,
 enumerable: true,
 configurable: true
 }
 });
9.8.3 数组的自有属性.length

数组的自有属性.length是不可枚举的,因此它不会被Object.assign()、扩展和类似操作复制。它也是不可配置的:

> Object.getOwnPropertyDescriptor([], 'length')
{ value: 0, writable: true, enumerable: false, configurable: false }
> Object.getOwnPropertyDescriptor('abc', 'length')
{ value: 3, writable: false, enumerable: false, configurable: false }

.length是一个特殊的数据属性,它受其他自有属性(特别是索引属性)的影响。

9.8.4 内置类的原型属性
assert.deepEqual(
 Object.getOwnPropertyDescriptor(Array.prototype, 'map'),
 {
 value: Array.prototype.map,
 writable: true,
 enumerable: false,
 configurable: true
 });
9.8.5 用户定义类的原型属性和实例属性
class DataContainer {
 accessCount = 0;
 constructor(data) {
 this.data = data;
 }
 getData() {
 this.accessCount++;
 return this.data;
 }
}
assert.deepEqual(
 Object.getOwnPropertyDescriptors(DataContainer.prototype),
 {
 constructor: {
 value: DataContainer,
 writable: true,
 enumerable: false,
 configurable: true,
 },
 getData: {
 value: DataContainer.prototype.getData,
 writable: true,
 enumerable: false,
 configurable: true,
 }
 });

请注意,DataContainer实例的所有自有属性都是可写的,可枚举的和可配置的:

const dc = new DataContainer('abc')
assert.deepEqual(
 Object.getOwnPropertyDescriptors(dc),
 {
 accessCount: {
 value: 0,
 writable: true,
 enumerable: true,
 configurable: true,
 },
 data: {
 value: 'abc',
 writable: true,
 enumerable: true,
 configurable: true,
 }
 });

9.9 API:属性描述符

以下工具方法使用属性描述符:

  • Object.defineProperty(obj: object, key: string|symbol, propDesc: PropertyDescriptor): object ^([ES5])

    obj上创建或更改一个属性,其键为key,属性通过propDesc指定。返回修改后的对象。

    const obj = {};
    const result = Object.defineProperty(
     obj, 'happy', {
     value: 'yes',
     writable: true,
     enumerable: true,
     configurable: true,
     });
    
    // obj was returned and modified:
    assert.equal(result, obj);
    assert.deepEqual(obj, {
     happy: 'yes',
    });
    
  • Object.defineProperties(obj: object, properties: {[k: string|symbol]: PropertyDescriptor}): object ^([ES5])

    Object.defineProperty()的批量版本。对象properties的每个属性p指定要添加到obj的一个属性:p的键指定属性的键,p的值是一个描述符,指定属性的属性。

    const address1 = Object.defineProperties({}, {
     street: { value: 'Evergreen Terrace', enumerable: true },
     number: { value: 742, enumerable: true },
    });
    
  • Object.create(proto: null|object, properties?: {[k: string|symbol]: PropertyDescriptor}): object ^([ES5])

    首先创建一个原型为proto的对象。然后,如果提供了可选参数properties,则以与Object.defineProperties()相同的方式向其添加属性。最后返回结果。例如,以下代码片段产生与上一个片段相同的结果:

    const address2 = Object.create(Object.prototype, {
     street: { value: 'Evergreen Terrace', enumerable: true },
     number: { value: 742, enumerable: true },
    });
    assert.deepEqual(address1, address2);
    
  • Object.getOwnPropertyDescriptor(obj: object, key: string|symbol): undefined|PropertyDescriptor ^([ES5])

    返回obj的自有(非继承)属性的描述符,其键为key。如果没有这样的属性,则返回undefined

    assert.deepEqual(
     Object.getOwnPropertyDescriptor(Object.prototype, 'toString'),
     {
     value: {}.toString,
     writable: true,
     enumerable: false,
     configurable: true,
     });
    assert.equal(
     Object.getOwnPropertyDescriptor({}, 'toString'),
     undefined);
    
  • Object.getOwnPropertyDescriptors(obj: object): {[k: string|symbol]: PropertyDescriptor} ^([ES2017])

    返回一个对象,其中obj的每个属性键'k'都映射到obj.k的属性描述符。结果可以用作Object.defineProperties()Object.create()的输入。

    const propertyKey = Symbol('propertyKey');
    const obj = {
     [propertyKey]: 'abc',
     get count() { return 123 },
    };
    
    const desc = Object.getOwnPropertyDescriptor.bind(Object);
    assert.deepEqual(
     Object.getOwnPropertyDescriptors(obj),
     {
     [propertyKey]: {
     value: 'abc',
     writable: true,
     enumerable: true,
     configurable: true
     },
     count: {
     get: desc(obj, 'count').get, // (A)
     set: undefined,
     enumerable: true,
     configurable: true
     }
     });
    

    在 A 行使用desc()是一个解决方法,使.deepEqual()起作用。

9.10 进一步阅读

接下来的三章将更详细地介绍属性特性:

  • §10 “保护对象免受更改”

  • §11 “属性:赋值 vs. 定义”

  • §12 “属性的可枚举性”

评论

十、保护对象免受更改

原文:exploringjs.com/deep-js/ch_protecting-objects.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 10.1?保护级别:防止扩展、封闭、冻结

  • 10.2?防止对象扩展

    • 10.2.1?检查对象是否可扩展
  • 10.3?封闭对象

    • 10.3.1?检查对象是否已封闭
  • 10.4?冻结对象

    • 10.4.1?检查对象是否已冻结

    • 10.4.2?冻结是浅层的

    • 10.4.3?实现深冻结

  • 10.5?进一步阅读


在本章中,我们将探讨如何保护对象免受更改。例如:防止添加属性和防止更改属性。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 必需知识:属性特性

在本章中,您应该熟悉属性特性。如果您不熟悉,请查看§9“属性特性:简介”。

10.1?保护级别:防止扩展、封闭、冻结

JavaScript 有三个级别的对象保护:

  • 防止扩展使得无法向对象添加新属性。但我们仍然可以删除和更改属性。

    • 方法:Object.preventExtensions(obj)
  • 封闭防止扩展并使所有属性不可配置(大致上:我们无法再改变属性的工作方式)。

    • 方法:Object.seal(obj)
  • 冻结在使所有属性不可写后封闭对象。也就是说,对象不可扩展,所有属性都是只读的,没有办法改变。

    • 方法:Object.freeze(obj)

10.2?防止对象扩展

Object.preventExtensions<T>(obj: T): T

该方法的工作方式如下:

  • 如果obj不是一个对象,则返回它。

  • 否则,它会改变obj,使得我们无法再添加属性,并返回它。

  • 类型参数<T>表示结果与参数具有相同的类型。

让我们在一个例子中使用Object.preventExtensions()

const obj = { first: 'Jane' };
Object.preventExtensions(obj);
assert.throws(
 () => obj.last = 'Doe',
 /^TypeError: Cannot add property last, object is not extensible$/);

但我们仍然可以删除属性:

assert.deepEquals(
 Object.keys(obj), ['first']);
delete obj.first;
assert.deepEquals(
 Object.keys(obj), []);
10.2.1?检查对象是否可扩展
Object.isExtensible(obj: any): boolean

检查obj是否可扩展 - 例如:

> const obj = {};
> Object.isExtensible(obj)
true
> Object.preventExtensions(obj)
{}
> Object.isExtensible(obj)
false

10.3?封闭对象

Object.seal<T>(obj: T): T

该方法的描述:

  • 如果obj不是一个对象,则返回它。

  • 否则,它会防止obj的扩展,使得所有属性不可配置,并返回它。属性不可配置意味着它们无法再被更改(除了它们的值):只读属性保持只读,可枚举属性保持可枚举,等等。

以下示例演示了封闭使对象不可扩展且其属性不可配置。

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

// Before sealing
assert.equal(Object.isExtensible(obj), true);
assert.deepEqual(
 Object.getOwnPropertyDescriptors(obj),
 {
 first: {
 value: 'Jane',
 writable: true,
 enumerable: true,
 configurable: true
 },
 last: {
 value: 'Doe',
 writable: true,
 enumerable: true,
 configurable: true
 }
 });

Object.seal(obj);

// After sealing
assert.equal(Object.isExtensible(obj), false);
assert.deepEqual(
 Object.getOwnPropertyDescriptors(obj),
 {
 first: {
 value: 'Jane',
 writable: true,
 enumerable: true,
 configurable: false
 },
 last: {
 value: 'Doe',
 writable: true,
 enumerable: true,
 configurable: false
 }
 });

但我们仍然可以更改属性.first的值:

obj.first = 'John';
assert.deepEqual(
 obj, {first: 'John', last: 'Doe'});

但我们无法更改其属性:

assert.throws(
 () => Object.defineProperty(obj, 'first', { enumerable: false }),
 /^TypeError: Cannot redefine property: first$/);
10.3.1?检查对象是否已封闭
Object.isSealed(obj: any): boolean

检查obj是否已封闭 - 例如:

> const obj = {};
> Object.isSealed(obj)
false
> Object.seal(obj)
{}
> Object.isSealed(obj)
true

10.4?冻结对象

Object.freeze<T>(obj: T): T;
  • 该方法立即返回obj,如果它不是一个对象。

  • 否则,它会使所有属性不可写,封闭obj并返回它。也就是说,obj不可扩展,所有属性都是只读的,没有办法改变。

const point = { x: 17, y: -5 };
Object.freeze(point);

assert.throws(
 () => point.x = 2,
 /^TypeError: Cannot assign to read only property 'x'/);

assert.throws(
 () => Object.defineProperty(point, 'x', {enumerable: false}),
 /^TypeError: Cannot redefine property: x$/);

assert.throws(
 () => point.z = 4,
 /^TypeError: Cannot add property z, object is not extensible$/);
10.4.1?检查对象是否已冻结
Object.isFrozen(obj: any): boolean

检查obj是否被冻结 - 例如:

> const point = { x: 17, y: -5 };
> Object.isFrozen(point)
false
> Object.freeze(point)
{ x: 17, y: -5 }
> Object.isFrozen(point)
true
10.4.2?冻结是浅层的

Object.freeze(obj)只会冻结obj及其属性。它不会冻结这些属性的值 - 例如:

const teacher = {
 name: 'Edna Krabappel',
 students: ['Bart'],
};
Object.freeze(teacher);

// We can’t change own properties:
assert.throws(
 () => teacher.name = 'Elizabeth Hoover',
 /^TypeError: Cannot assign to read only property 'name'/);

// Alas, we can still change values of own properties:
teacher.students.push('Lisa');
assert.deepEqual(
 teacher, {
 name: 'Edna Krabappel',
 students: ['Bart', 'Lisa'],
 });
10.4.3?实现深冻结

如果我们想要深冻结,我们需要自己实现它:

function deepFreeze(value) {
 if (Array.isArray(value)) {
 for (const element of value) {
 deepFreeze(element);
 }
 Object.freeze(value);
 } else if (typeof value === 'object' && value !== null) {
 for (const v of Object.values(value)) {
 deepFreeze(v);
 }
 Object.freeze(value);
 } else {
 // Nothing to do: primitive values are already immutable
 } 
 return value;
}

重新审视上一节的示例,我们可以检查deepFreeze()是否真的深度冻结:

const teacher = {
 name: 'Edna Krabappel',
 students: ['Bart'],
};
deepFreeze(teacher);

assert.throws(
 () => teacher.students.push('Lisa'),
 /^TypeError: Cannot add property 1, object is not extensible$/);

10.5 进一步阅读

  • 有关防止数据结构被更改的模式的更多信息:§15 “不可变集合的包装器”

评论