JavaScript 动态类型详解

在前一章中,我们探讨了 JavaScript 的内置值和类型,并讨论了使用它们时所涉及的一些挑战。 下一个自然的步骤是探索 JavaScript 的动态系统在现实世界中是如何发挥作用的。 因为 JavaScript 是一种动态类型语言,所以代码中的变量不受它们所引用的值类型的限制。 这给干净的程序员带来了巨大的挑战。 如果不确定我们的类型,我们的代码就会以意想不到的方式中断,并且变得非常脆弱。 这种脆弱性可以简单地通过想象一个嵌入在字符串中的数值来解释:

const possiblyNumeric = '203.45';

在这里,我们可以看到值是数值,但它被包装在字符串字面量中,因此,就 JavaScript 而言,只是一个普通的字符串。 但由于 JavaScript 是动态的,我们可以自由地将这个值传递给任何函数——甚至是需要一个数字的函数:

setWidth('203.45');

function setWidth(width) {
  width += 20;       // Add margins
  applyWidth(width); // Apply the width
}

该函数通过+=操作符为数字添加一个边距值。 这个操作符是操作a = a + b的别名,如果其中一个操作数是String类型,这里的+操作符将简单地将两个字符串连接在一起。 有趣的是,这个简单且看起来无辜的实现细节是世界各地在不同时间发生的数百万令人疲惫的调试会话的关键。 值得庆幸的是,了解这个操作符及其确切的行为将为您节省无数小时的痛苦和疲惫,并将在您的脑海中巩固编写代码的重要性,以避免我们因possiblyNumeric的价值而陷入的陷阱。

在本章中,我们将涵盖以下主题:

要想更容易地识别我们的类型,关键的第一步是学会检测,这是一种能够以最简单的方式识别你正在处理的类型的技能。

检测指的是确定值类型的实践。 通常,这样做的目的是使用确定的类型来执行特定的行为,例如退回到默认值或在误用的情况下抛出错误。

由于 JavaScript 的动态特性,检测类型是一项重要的实践,通常对其他程序员有很大帮助。 如果您可以在有人错误地使用界面时抛出错误或警告,这对他们来说可能意味着更流畅和更快的开发流程。 如果您可以用智能默认值填充undefinednull或空值,那么它将允许您提供一个更无缝和直观的界面。

不幸的是,由于 JavaScript 内部的遗留问题,以及在其设计中做出的一些选择,检测类型可能具有挑战性。 使用了许多不同的方法,其中一些不被认为是最佳实践。 我们将在本节中介绍所有这些实践。 然而,首先,有必要讨论一个关于检测的基本问题:您到底想检测什么?

我们经常认为我们需要特定的类型来执行特定的操作,但由于 JavaScript 的动态特性,我们可能不需要这样做。 事实上,这样做会导致我们创建不必要的限制性或僵化的代码。

考虑一个接受people对象数组的函数,如下:

registerPeopleForMarathon([
  new Person({ id: 1, name: 'Marcus Wu' }),
  new Person({ id: 2, name: 'Susan Smith' }),
  new Person({ id: 3, name: 'Sofia Polat' })
]);

在我们的registerPeopleForMarathon中,我们可能会尝试实现某种检查,以确保传递的参数是预期的类型和结构:

function registerPeopleForMarathon(people) {
  if (Array.isArray(people)) {
    throw new Error('People is not an array');
  }
  for (let person in people) {
    if (!(person instanceof Person)) {
      throw new Error('Each person should be an instance of Person');
    }
    registerForMarathon(person.id, person.name);
  }
}

这些支票是必要的吗? 您可能倾向于说是,因为他们确保我们的代码对潜在错误情况具有弹性(或防御性),因此更可靠。 但如果我们仔细想想,这些检查都不是必要的,以确保我们正在寻求的那种可靠性。 我们的检查的目的,大概是在错误的类型或结构被传递给我们的函数的情况下防止下游错误,但是如果我们仔细查看前面的代码,就不会有我们担心的类型出现下游错误的风险。

我们进行的第一个检查是Array.isArray(people),以确定people值是否确实是一个数组。 我们这样做,表面上,是为了安全地循环数组。 但是,正如我们在前一章中所发现的,for...of迭代风格并不依赖于它的of {...}值是一个数组。 它只关心值是否可迭代。 下面是一个例子:

function* marathonPeopleGenerator() {
  yield new Person({ id: 1, name: 'Marcus Wu' });
  yield new Person({ id: 2, name: 'Susan Smith' });
  yield new Person({ id: 3, name: 'Sofia Polat' });
}

for (let person of marathonPeopleGenerator()) {
 console.log(person.name);
}

// Logged => "Marcus Wu"
// Logged => "Susan Smith"
// Logged => "Sofia Polat"

在这里,我们使用生成器作为可迭代对象。 在for...of中迭代时,这只会在数组中工作,所以,技术上,我们可以讨论我们的registerPeopleForMarathon函数应该接受这样的值:

// Should we allow this?
registerPeopleForMarathon(
  marathonPeopleGenerator()
);

到目前为止,我们所做的检查将拒绝这个值,因为它不是一个数组。 这有什么意义吗? 你还记得抽象的原则吗?我们应该如何关注接口而不是实现? 这样看来,可以证明,我们的registerPeopleForMarathon函数不需要知道传递值的类型的实现细节。 它只关心值根据它的需要执行。 在这种情况下,它需要通过for...of遍历该值,所以任何可迭代对象都是合适的。 为了检查一个可迭代对象,我们可以使用这样的助手:

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

isIterable([1, 2, 3]); // => true
isIterable(marathonPeopleGenerator()); // => true

另外,考虑到我们目前正在检查所有的person值都是Person构造函数的实例:

// ...
if (!(person instanceof Person)) {
  throw new Error('Each person should be an instance of Person');
}

我们是否有必要以这种方式显式地检查实例? 我们可以简单地检查希望访问的属性吗? 也许我们需要断言的只是属性是非假的(空字符串,null, undefined, 0,等等):

// ...
if (!person || !person.name || !person.id) {
  throw new Error('Each person should have a name and id');
}

这种检查更能满足我们的真实需求。 像这样的检查通常被称为duck-typing,也就是说,如果它走路像鸭子,呱呱叫也像鸭子,那么它一定是一只鸭子。 我们并不总是需要检查特定类型; 我们可以检查我们真正依赖的属性、方法和特征。 通过这样做,我们正在创建更灵活的代码。

我们的新检查,当集成到我们的函数中时,看起来像这样:

function registerPeopleForMarathon(people) {
  if (isIterable(people)) {
    throw new Error('People is not iterable');
  }
  for (let person in people) {
    if (!person || !person.name || !person.id) {
      throw new Error('Each person should have a name and id');
    }
    registerForMarathon(person.id, person.name);
  }
}

通过在person对象上使用更灵活的isIterable检查和duck-typing,可以传递registerPeopleForMarathon函数; 例如,这里我们有一个生成普通对象的生成器:

function* marathonPeopleGenerator() {
  yield { id: 1, name: 'Marcus Wu' };
  yield { id: 2, name: 'Susan Smith' };
  yield { id: 3, name: 'Sofia Polat' };
}

registerPeopleForMarathon(
  marathonPeopleGenerator()
);

如果我们始终保持严格的类型检查,就不可能实现这种级别的灵活性。 更严格的检查通常会创建更严格的代码,并不必要地限制灵活性。 然而,这里有一个平衡。 我们不能无限地灵活。 从长远来看,更严格的类型检查所提供的刚性和确定性使我们能够确保更清晰的代码。 但反过来也可能是正确的。 在灵活性和刚性之间取得平衡是你应该不断考虑的问题。

通常,接口的期望应该尽量接近实现的要求。 也就是说,我们不应该执行检测或其他检查,除非这些检查真正防止了我们实现中的错误。 过分热心的检查可能看起来更安全,但可能只意味着未来的需求和用例更难以适应。

既然我们已经讨论了为什么要检测事物并公开了一些用例,现在我们可以开始讨论 JavaScript 提供给我们的检测技术了。 我们将从typeof操作符开始。

当你第一次尝试在 JavaScript 中检测一个类型时,你经常会接触到的第一件事是typeof操作符:

typeof 1; // => number

typeof操作符接受右侧的一个操作数,根据传入的值,计算结果为 8 个可能的字符串值之一:

typeof 1; // => "number"
typeof ''; // => "string"
typeof {}; // => "object"
typeof function(){}; // => "function"
typeof undefined; // => "undefined"
typeof Symbol(); // => "symbol"
typeof 0n; // => "bigint"
typeof true; // => boolean

如果你的操作数是一个没有绑定的标识符,也就是一个未声明的变量,那么typeof将有用地返回"undefined",而不是像其他对该变量的引用那样抛出ReferenceError:

typeof somethingNotYetDeclared; // => "undefined"

typeof是 JavaScript 语言中唯一能做到这一点的操作符。 如果还没有声明值,其他操作符和引用值的其他方法都会抛出错误。

除了检测未声明的变量外,typeof实际上只在确定基元类型时有用——即使这样也太慷慨了,因为并不是所有基元类型都是可检测的。 例如,当一个null值被传递给typeof时,它将被计算为一个毫无用处的"object":

typeof null; // => "object"

这是 JavaScript 语言遗留下来的不幸和不可修复的问题。 它可能永远都不会被修复。 要检查null,最好显式地检查值本身:

let someValue = null;
someValue === null; // => true

除了函数外,typeof运算符不能区分不同类型的对象。 JavaScript 中的所有非函数对象都会返回"object":

typeof [];         // => "object"
typeof RegExp(''); // => "object"
typeof {};         // => "object"

所有函数,无论是通过类定义、方法定义还是普通函数表达式声明的,都将求值为"function":

typeof () => {};          // => "function"
typeof function() {};     // => "function"
typeof class {};          // => "function"
typeof ({ foo(){} }).foo; // => "function"

如果typeof class {}"function"的计算令人困惑,请考虑一下,正如我们所了解的,所有类都只是带有准备好的原型的构造函数(稍后将确定任何生成实例的[[Prototype]])。 他们没什么特别的。 类在 JavaScript 中不是唯一的类型或实体。

当将typeof的结果与给定字符串进行比较时,可以使用严格相等(===)或抽象相等(==)操作符。 因为typeof总是返回一个字符串,所以我们不必担心这里的差异,所以您是否采用严格的相等检查还是抽象的相等检查取决于您。 从技术上讲,这两种方法都没问题:

if (typeof 123 == 'number') {...}
if (typeof 123 === 'number') {...}

The strict and abstract equality operators (double-equals and triple-equals) behave slightly differently, although when the values on both sides of the operator are of the same type, they act identically. Skip ahead to the Operator section to get the lowdown on how they differ. In general, it's best to prefer === over ==.

总之,typeof经营者只是一个不能共患难的朋友。 我们不能在任何情况下都依赖它。 有时,我们需要使用其他类型检测技术。

考虑到typeof操作符不适合检测许多类型,特别是物体,我们不得不依赖于许多不同的方法,取决于我们想要检查的确切东西。 有时,我们可能想要检测一个特征而不是类型,例如,一个对象是构造函数的实例还是一个普通对象。 在本节中,我们将探讨一些常见的检测需求及其解决方案。

谢天谢地,布尔值检查起来非常简单。 typeof操作符对truefalse的值正确计算为"boolean":

typeof true;  // => "boolean"
typeof false; // => "boolean"

不过,我们很少想这样做。 通常,当您接收一个Boolean值时,您最感兴趣的是检查它的真实性,而不是它的类型。

当将一个布尔值放在一个布尔上下文中,比如一个条件语句时,我们隐式地依赖于它的真伪。 例如,进行以下检查:

function process(isEnabled) {
  if (isEnabled) {
    // ... do things
  }
}

此检查并不确定isEnabled值是否为真正的布尔值。 它只是检查结果是否真实。 isEnabled可能的值是多少? 是否有一个所有这些真实值的列表? 这些值实际上是无限的,所以没有列表。 关于真值,我们能说的就是它们不是假的。 我们知道,只有 7 个假值。 如果我们希望观察特定值的真伪,我们总是可以通过作为函数调用的Boolean构造函数强制转换为Boolean:

Boolean(true); // => true
Boolean(1); // => true
Boolean(42); // => true
Boolean([]); // => true
Boolean('False'); // => true
Boolean(0.0001); // => true

在大多数情况下,隐式强制Boolean是充分的,不会咬我们,但如果我们希望绝对确定一个值是专门Booleantruefalse,我们可以用严格的相等操作符来比较它们,就像这样:

if (isEnabled === true) {...}
if (isEnabled === false) {...}

由于 JavaScript 的动态特性,有些人更喜欢这种级别的确定性,但通常情况下,这是不必要的。 如果我们要检查的值显然是一个Boolean值,那么我们可以这样使用它。 通过typeof或严格相等检查其类型通常是不必要的,除非有可能值是非Boolean

Number的情况下,我们可以依靠typeof操作符来正确计算"number":

typeof 555; // => "number"

但在NaNInfinity-Infinity的情况下,也会评估为"number":

typeof Infinity;  // => "number"
typeof -Infinity; // => "number"
typeof NaN;       // => "number"

因此,我们可能希望执行额外的检查,以确定某个数字是否不是这些值中的任何一个。 值得庆幸的是,JavaScript 为这种场景提供了本机帮助程序:

  • isFinite(n):如果Number(n)不是Infinity-InfinityNaN,则返回true
  • isNaN(n):返回true,如果Number(n)不是NaN
  • Number.isNaN(n):返回true,如果n不是NaN
  • Number.isFinite(n):如果n不是Infinity-InfinityNaN,则返回true

这两种全局变体都是该语言的较老部分,如您所见,它们与对应的Number.*略有不同。 全局isFiniteisNaN通过Number(n)将其值转换为数字,而等效的Number.*方法则不这样做。 造成这种差异的原因主要是遗留问题。

最近添加的Number.isNaNNumber.isFinite被引入,使更明确的检查,而不依赖于 cast:

isNaN(NaN)   // => true
isNaN('foo') // => true

Number.isNaN(NaN);   // => true
Number.isNaN('foo'); // => false

如您所见,Number.isNaN限制性更强,因为它不会在检查NaN之前将值转换为Number。 对于'foo'字符串,在传递它之前,我们需要将其强制转换为Number(从而求值为NaN):

const string = 'foo';
const nan = Number(string);
Number.isNaN(nan); // => true

全局的isFinite函数以同样的方式工作,也就是说,它在检查有限性之前将其值转换为一个数字,而Number.isFinite方法则不会执行任何类型的转换:

isFinite(42)   // => true
isFinite('42') // => true

Number.isFinite(42);   // => true
Number.isFinite('42'); // => false

如果您确信您的值已经是一个数字,那么不妨使用更简洁的isNaNisFinite,因为它们的隐式强制转换对您没有任何影响。 如果你想让 JavaScript 尝试将你的非Number值转换为Number,那么你应该再次使用isNaNisFinite。 但是,如果出于某种原因需要显式检查,那么应该使用Number.isNaNNumber.isFinite

结合所有这些讨论过的检查,通过使用typeof和全局isFinite,我们能够自信地检测出一个既不是NaN也不是Infinity的数字。 正如我们之前提到的,isFinite会检查NaN本身,所以我们不需要额外的isNaN检查:

function isNormalNumber(n) {
  return typeof n === 'number' && isFinite(n);
}

当涉及到检测时,您的需求应该由代码的上下文驱动。 例如,如果您嵌入了一段代码,您可以安全地假设该数字是有限的,那么可能就没有必要检查有限数字。 但是,如果您正在构建一个更公开的 API,那么您可能希望在将这些值发送到内部接口之前进行此类检查,这既是为了减少错误的可能性,也是为了向您的用户提供有用的、合理的错误或警告。

检测字符串非常简单。 我们只需要typeof操作符:

typeof 'hello'; // => "string"

为了检查给定的String的长度,我们可以简单地使用length属性:

'hello'.length; // => 5

如果我们需要检查一个String的长度是否大于 0,我们可以通过length显式地做到这一点,或者依赖于一个 0 长度的假值,甚至依赖于空string本身的假值:

const string = '';

Boolean(string);            // => false
Boolean(string.length);     // => false
Boolean(string.length > 0); // => false

// Since an empty String is falsy we can just check `string` directly:
if (string) { }

// Or we can be more explicit:
if (string.length) { }

// Or we can be maximally explicit:
if (string.length > 0) { }

如果我们只检查一个值的真实性,那么我们也可能检测所有可能的真值,包括非零数字和对象。 要完全确定你有一个String并且它不是空的,最简单的方法如下:

if (typeof myString === 'string' && myString) {
  // ...
}

然而,空性本身可能不是我们感兴趣的全部。 我们可能希望检测字符串中是否有实际内容。 在大多数情况下,实际内容开始于String的开头,结束于String的结尾,但在某些情况下,它可能被嵌入到两边的空格中。 为了解释这一点,我们可以修剪String,然后确认它是空的:

function isNonEmptyString(string) {
  return typeof string === 'string' && string.trim().length > 0;
}

isNonEmptyString('hi');  // => true
isNonEmptyString('');    // => false
isNonEmptyString(' ');   // => false
isNonEmptyString(' \n'); // => false

注意,我们的函数isNonEmptyString使用了length > 0检查修剪后的字符串,而不是仅仅依赖它作为空字符串的假值。 这样我们就可以放心地知道isNonEmptyString函数将始终返回一个布尔值。 尽管 99%的情况下,它将用于布尔上下文,如if (isNonEmptyString(...)),我们仍然应该确保我们的函数有一个直观和一致的契约。

The logical AND operator (a && b) will, if its left-hand side is truthy, return its right-hand side. Therefore, expressions such as typeof str === "string" && str are not always guaranteed to return a Boolean. Go to the Operator – Logical Operators – Logical AND Operator section of Chapter 8, Operators for more information.

字符串很容易检测,但正如我们在前一章中提到的,由于 Unicode,使用它们可能是一个挑战。 因此,重要的是要记住,虽然对字符串的检测可以为我们提供一些确定性,但它并不能告诉我们字符串中有什么,以及它是否是我们期望的值。 如果您的检测目的是为正在使用您的接口的人提供指导或警告,那么最好通过显式地检查值的内容。

undefined类型可以通过严格相等操作符引用其全局可用值来直接检查:

if (value === undefined) {
  // ...
}

然而,不幸的是,由于 undefined 可以在非全局作用域内被覆盖(取决于您的精确设置和环境),因此这种方法可能会很麻烦。 从历史上看,undefined可能会在全球范围内被覆盖。 这意味着像这样的事情是可能的:

let value = void 0;  // <- actually undefined
let undefined = 123; // <- cheeky override

if (value === undefined) {
  // Does not occur
}

void运算符,我们将在后面讨论,它的右边取一个操作数(void foo),并且总是求出undefined。 因此,void 0已成为undefined的同义词,并可用作undefined的替代品。 所以,如果你对undefined的值没有信心,那么你可以简单地检查void 0,像这样:

if (value === void 0) {
  // value is undefined
}

各种其他方法出现,以确保可靠的undefined值。 例如,一种方法是简单地声明一个未赋值的变量(默认为undefined),然后在作用域内使用它:

function myModule() {
  // My local `undefined`:
  const undef;

  void 0 === undef; // => true

  if (someValue === undef) {
    // Instead of `VALUE === undefined` I can
    // use `VALUE === undef` within this scope
  }
}

随着时间的推移,undefined值的可变性已被锁定。 ECMAScript 2015禁止全局修改,但奇怪的是仍然允许局部修改。

值得庆幸的是,通过简单的typeof操作符始终可以检查undefined:

typeof undefined; // => "undefined"

虽然随着检测工具的出现,直接检查undefined通常是安全的,但以这种方式使用typeof比依赖undefined作为文字值风险要小得多。

We'll explore ESLint, a popular JavaScript linting tool, in Chapter 15, Tools For Cleaner Code. In the case of overwriting undefined in a local scope, which is unquestionably a bad thing to do, it'll helpfully give us a warning. Such warnings can provide us with a level of confidence, allowing us to safely use previously risky aspects of the language.

如我们所见,typeof null等于"object"。 这是语言遗留下来的奇怪现象。 不幸的是,这意味着我们不能依靠typeof来检测null。 相反,我们必须使用严格质量操作符直接比较文字null值,如下所示:

if (someValue === null) {
  // someValue is null...
}

undefined不同,null在该语言的任何版本或任何环境中都不能被覆盖,因此它的使用不会带来任何麻烦。

到目前为止,我们已经介绍了如何独立检查undefinednull,但我们可能想同时检查两者。 例如,具有可选参数的函数签名是很常见的。 如果没有传递该参数或显式地将其设置为null,则通常会返回到某个默认值。 这可以通过显式检查nullundefined来实现,如下所示:

function printHello(name, message) {
  if (message === null || message === undefined) {
    // Default to a hello message:
    message = 'Hello!';
  }
  console.log(`${name} says: ${message}`);
}

通常,由于nullundefined都是假值,通过检查给定值的假值来暗示它们的存在是很正常的:

if (!value) {
  // Value is definitely not null and definitely not undefined
}

然而,这也将检查该值是否为任何其他假值(包括,falseNaN,0,等等)。 所以,如果我们想确认一个值是特定的nullundefined,而不是其他假值,那么我们应该坚持显式变异:

if (value === null || value === undefined) //...

然而,更简单的是,我们可以采用抽象的(非严格的)相等运算符来检查nullundefined,因为它认为这些值相等:

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

尽管这使用了通常令人讨厌的抽象相等运算符(我们将在本章的后面讨论),但它仍然是检查undefinednull的流行方法。 这是由于其简洁的本质。 然而,采用这种更简洁的检查会使代码不那么明显。 它甚至可能给人留下这样的印象:作者只是想检查null。 这种模棱两可的意图应该让我们怀疑它是否干净。 因此,在大多数情况下,我们应该选择更明确、更严格的检查。

多亏了Array.isArray方法,JavaScript 中的数组检测非常简单:

if (Array.isArray(value)) {
 // ...
}

这个方法告诉我们传递的值是通过数组构造函数或数组字面量构造的。 但是,它不检查值的[[Prototype]],因此,尽管该值看起来像一个数组,但可能没有您想要的特征,这是完全可能的(尽管不太可能)。

当我们认为需要检查一个值是否为数组时,重要的是要问自己我们真正想要检测的是什么。 也许我们可以检查我们想要的特征,而不是类型本身。 重要的是要考虑我们将如何处理这些价值。 如果我们打算通过for...of遍历它,那么检查它的可迭代性可能比检查它的数组性更合适。 正如我们前面提到的,我们可以雇佣这样的助手:

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

const foo = [1,2,3];
if (isIterable(foo)) {
  for (let f in foo) {
    console.log(f);
  }
}

// Logs: 1, 2, 3

如果,或者,我们正在寻找使用特定的数组方法,如forEachmap,那么最好通过isArray进行检查,因为这将给我们一个合理的信心,这些方法存在:

if (Array.isArray(someValue)) {
  // Using Array methods
  someValue.forEach(v => {/*...*/});
  someValue.sort((a, b) => {/*...*/});
}

如果我们想做得更彻底,我们也可以单独检查特定的方法,或者我们甚至可以将值强制放入我们自己的数组中,这样我们就可以自由地操作它,同时知道这个值确实是一个数组:

const myArrayCopy = [...myArray];

注意,通过扩展语法([...value])复制类似数组的值只在值是可迭代的情况下有效。 一个使用[...value]比较合适的例子是对从 DOM API 返回的NodeLists操作:

const arrayOfParagraphElements = [...document.querySelectorAll('p')];

ANodeList不是一个真正的Array,所以它不能给我们访问本地数组方法。 因此,创建并使用一个真正的Array的副本是非常有用的。

总的来说,它是安全的采纳并依靠Array.isArray,但重要的是要考虑你是否需要检查Array,是否更合适的检查值是否 iterable,甚至它是否有一个特定的方法或属性。 与所有其他检查一样,我们应该设法使我们的意图显而易见。 如果我们使用比Array.isArray更晦涩的检查,那么添加注释或使用描述性的命名函数抽象操作可能是谨慎的做法。

要检测一个对象是否构造函数的实例,只需使用instanceof操作符:

const component = new Component();
component instanceof Component; 

The instanceof operator will be covered in more detail in Chapter 8, Operators.

当我们说普通对象时,我们通常指的是那些以Object字面量或Object构造函数构造的对象:

const plainObject = {
  name: 'Pikachu',
  species: 'Pokémon'
};

const anotherPlainObject = new Object();
anotherPlainObject.name = 'Pikachu';
anotherPlainObject.species = 'Pokémon';

这与其他对象形成了对比,比如语言本身提供的对象(例如数组)和我们自己通过实例化构造函数构造的对象(例如,new Pokemon()):

function Pokemon() {}
new Pokemon(); // => A non-plain object

检测普通对象的最简单方法是查询其[[Prototype]]。 如果它有一个[[Prototype]]等于Object.prototype,那么我们可以说它是 plain:

function isPlainObject(object) {
  return Object.getPrototypeOf(object) === Object.prototype;
}

isPlainObject([]);            // => false
isPlainObject(123);           // => false
isPlainObject(new String);    // => false
isPlainObject(new Pokemon()); // => false

isPlainObject(new Object());  // => true
isPlainObject({});            // => true

为什么我们需要知道一个值是否是普通对象? 例如,在创建除了接受更复杂的对象类型之外还接受配置对象的接口或函数时,它可能有助于区分普通对象和非普通对象。

In most situations, we will need to detect a plain object explicitly. Instead, we should rely only on the interface or data that it provides us. If a user of our abstraction wishes to pass us a non-plain object but it still has the properties that we require, then who are we to complain?

到目前为止,我们已经学习了如何使用检测来区分 JavaScript 中的各种类型和特征。 正如我们所看到的,当需要在意外或不兼容值的情况下提供替代值或警告时,检测是有用的。 然而,还有一种处理这些值的附加机制:我们可以将它们从我们不需要的值转换为我们需要的值。

为了转换一个值,我们使用了一种称为casting的机制。 强制转换是有意地、显式地从一种类型派生另一种类型。 与铸造相比,还有强迫。 强制是 JavaScript 在使用需要特定类型的操作符或语言构造时所采用的隐式内部转换过程。 一个例子是将String值传递给乘法运算符。 操作符会自然地将其String操作数强制为数字,以便尝试将它们相乘:

'5' * '2'; // => 10 (Number)

铸型胁迫的基本机制是相同的。 它们都是转换机制。 但我们如何接触这些低级行为是关键。 如果我们明确地、清晰地传达了我们的意图,那么代码的读者将会有一个更好的时间。

考虑下面的代码,它包含两种不同的机制来将String转换为Number:

Number('123'); // => 123
+'123'; // => 123

这里,我们使用了两种不同的技术来强制将一个值从String转换为Number。 当作为函数调用时,Number()构造函数将在内部将传入的值转换为Number原语。 一元+操作符也会这样做,尽管它可能不那么明确。 强制就更不明显了,因为它似乎经常是作为某些其他操作的副作用出现的。 以下是一些例子:

1 + '123'; // => "1234"
[2] * [3]; // => 6
'22' / 2;  // => 11

当其中一个操作数是字符串时,+操作符将将相反的操作数强制转换为字符串,然后将它们连接在一起。 当给定数组时,*操作符将对其调用toString(),然后将结果String强制转换为Number,这实际上意味着[2] * [3]等于2 * 3。 此外,除法运算符在操作数之前将其操作数强制转换为数字。 所有这些强迫行为都是隐性的。

The line between coercion and casting is not set in stone. It is possible, for example, to explicitly and intentionally convert a type via a coercive side effect. Consider the expression someString * 1, which could be used to cast a string to a number, using coercion to do so. In our conversions, what's crucial is that we clearly communicate our intent.

强制,因为它是隐式发生的,可能是许多错误和意外行为的原因。 为了避免这个陷阱,我们应该始终对操作数的类型有很强的置信度。 然而,强制转换完全是有意为之的,可以帮助创建更可靠的代码库。 在接口的更公开或公开的方面,为了防止接收到的类型不正确,通常会先强制转换为您想要的类型。

注意这里我们是如何显式地将haystackneedle值转换为String类型的:

function countOccurrences(haystack, needle) {

  haystack = String(haystack);
  needle = String(needle);

  let count = 0;

  for (let i = 0; i < haystack.length; count++, i += needle.length) {
    i = haystack.indexOf(needle, i);
    if (i === -1) break;
  }

  return count;
}

countOccurrences('What apple is the best type of apple?', 'apple'); // => 2
countOccurrences('ABC ABC ABC', 'A'); // => 3

由于我们依赖于haystack字符串上的indexOf()方法,根据我们期望的防御级别,将haystack转换为字符串是有意义的,这样我们就可以确保它有可用的方法。 将needle转换为字符串也编码了更高层次的确定性,因此我们和其他程序员可以放心。

The defensive approach of preemptively casting values to protect against undesirable types is best when we're crafting reusable utilities, public-facing APIs, or any interfaces that'll be consumed in a way that reduces your confidence in the types you'll be receiving.

像 JavaScript 这样的动态类型语言被许多人看作是混乱的邀请。 这些人可能已经习惯了严格打字的语言所提供的舒适和确定。 事实上,如果充分而谨慎地使用动态语言,它可以让我们的代码更加深思熟虑地编写,更有弹性地适应用户不断变化的需求。 在本节的其余部分中,我们将讨论到到单个类型的转换,包括我们可以使用的显式强制转换机制和语言内部采用的各种强制行为。 我们将从布尔转换开始。

在 JavaScript 中所有值转换为一个布尔什么时候返回true除非他们其中一个七 falsy 原语(falsenullundefined,0n,0,"",NaN),在这种情况下,他们将返回【显示】。

要将一个值转换为布尔值,只需将该值传递给布尔构造函数,并将其作为函数调用:

Boolean(0); // => false
Boolean(1); // => true

当值存在于布尔上下文中时,该语言将强制值为布尔值。 以下是一些此类上下文的例子(每一个都用HERE标记):

  • if ( HERE ) {...}
  • do {...} while (HERE)
  • while (HERE) {...}
  • for (...; HERE; ...) {...}
  • [...].filter(function() { return HERE })
  • [...].some(function() { return HERE })

这份清单并不详尽。 还有相当多的其他情况,我们的值将被强制为布尔值。 这通常很容易判断。 如果一个语言结构或 natively-provided 函数或方法允许您指定两种可能的途径(即如果 X,那么这样做否则这么做),那么你可以打赌它将内部强迫任何你表达一个布尔值。

除了对Boolean()的更显式调用外,对一个布尔类型进行强制转换的常见习惯用法是:double-bang,即一元逻辑NOT操作符(!)重复两次:

!!1;  // => true
!![]; // => true
!!0;  // => false
!!""; // => false

重复两次逻辑NOT操作符将使值的布尔表示反转两次。 双弹的语义更容易理解:

!( !( value ) )

这有效地做了四件事:

  • 将值转换为布尔值(Boolean(value))。
  • 如果值为true,则设置为false。 如果值为false,则返回true
  • 将结果值转换为布尔值(Boolean(value))。
  • 如果值为true,则设置为false。 如果值为 false,则返回true

换句话说:这做了一个逻辑上的非,然后是另一个,结果是原始值本身的布尔表示。

当创建必须返回布尔值但处理非布尔值的函数或方法时,显式将值强制转换为布尔值特别有用。 例如,我可能希望创建一个isNamePopulated函数,如果名称变量不是一个填充字符串,或者是nullundefined,则返回false:

function isNamePopulated(name) {
  return !!name;
}

如果name为空Stringnullundefined,则返回false:

isNamePopulated('');        // => false
isNamePopulated(null);      // => false
isNamePopulated(undefined); // => false

isNamePopulated('Sandra');  // => true

如果name是任何其他假值(例如 0),它也会顺便返回false,如果name是任何其他真值,它会返回true:

isNamePopulated(0); // => false
isNamePopulated(1); // => true

这似乎完全不受欢迎的,但在这种情况下,它可能是好的,因为我们已经假设下操作nameString,nullundefined,所以我们只关心合同关于的函数的实现这些值。 您对它的适应程度完全取决于您的特定实现及其提供的接口。

将一个值转换为String可以通过将String构造函数作为常规函数调用(也就是说,不是构造函数)来实现:

String(456); // => "456"
String(true); // => "true"
String(null); // => "null"
String(NaN); // => NaN
String([1, 2, 3]); // => "1,2,3"
String({ foo: 1 }); // => "[object Object]"
String(function(){ return 'wow' }); // => "function(){ return 'wow' }"

使用你的值调用String()是转换为String的最明确和清晰的方式,尽管有时会使用更简洁的模式:

'' + 1234; // => "1234"
`${1234}`; // => "1234"

这两个表达式可能看起来是等价的,对于许多值来说,它们是等价的。 但是,在内部,它们的工作方式是不同的。 稍后我们将看到,+操作符将辨别是否一个给定的操作数是一个String``ToPrimitive通过调用其内部机制,操作数的valueOf(如果它有一个)将查询之前toString实现。 然而,当使用模板文字(如${value})时,任何插值值将直接转换为字符串(不通过ToPrimitive)。 总是有可能一个值的valueOftoString方法将提供不同的值。 看一下下面的例子,它展示了如何通过定义我们自己的toStringvalueOf实现来操作两个看起来等价的表达式的返回值:

const myFavoriteNumber = {
  name: 'Forty Two',
  number: 42,
  valueOf() { return number; },
  toString() { return name; }
};

`${myFavoriteNumber}`; // => "Forty Two"
'' + myFavoriteNumber; // => 42

这是一种罕见的情况,但仍然值得思考。 通常,我们假定可以很容易地将任意值可靠地转换为字符串,但情况并非总是如此。

传统上,依赖于 value 的toString()方法并直接调用它是很常见的:

(123).toString(); // => 123

但是,如果值为nullundefined,则会收到TypeError:

null.toString();      // ! TypeError: Cannot read property 'toString' of null
undefined.toString(); // ! TypeError: Cannot read property 'toString' of undefined

另外,toString方法不能保证返回string。 在这里观察我们如何实现自己的toString方法返回Array:

({
  toString() { return ['not', 'a', 'string'] }
}).toString(); // => ["not", "a", "string"]

因此,建议通过非常明确的String(...)强制转换为string。 使用间接形式的强迫,副作用,或盲目依赖toString会产生意想不到的结果。 请记住,即使您对这些机制有很好的了解,并且对使用它们感到满意,这并不意味着其他程序员也会这样做。

将一个值强制转换为Number可以通过调用Number构造函数来实现:

Number('10e3');     // => 10000
Number(' 4.6');     // => 4.6
Number('Infinity'); // => Infinity
Number('wat');      // => NaN
Number(false);      // => 0
Number('');         // => 0

此外,还有一元加+操作符,它做的基本上是相同的事情:

+'Infinity'; // => Infinity
+'55.66';    // => 55.66
+'foo';      // => NaN

这是将非Number类型转换为Number类型的仅有的两种方法,但 JavaScript 还提供了从字符串中提取数值的其他技术。 其中一种技术是parseInt,这是一个全局可用的本机函数,它接受String和一个可选的radix参数(默认为base 10,即十进制)。 如果第一个参数还不是String,它自然会将其强制为String,然后尝试从String中提取指定的radix的第一个整数。 通过这样做,你可以达到以下结果:

parseInt('1000');   // => 1000
parseInt('100', 8); // => 64 (i.e. octal to decimal)
parseInt('AA', 12); // => 130 (i.e. hexadecimal to decimal)

如果字符串的前缀为0x0X,则parseInt将假定radix16(16 进制):

parseInt('0x10'); // => 16

一些浏览器和其他环境也可能将前缀0作为八进制radix的指示符:

// (In **some** environments)
parseInt('023'); // => 19 (assumed octal -> decimal)

parseInt()也将有效地修剪String,忽略任何初始空格,并将忽略String中发现的第一个整数以外的所有内容:

parseInt(' 111 222 333'); // => 111
parseInt('\t\n0xFF');     // => 255

parseInt is usually frowned upon due to its obscure mechanism of extracting an integer from String and the fact that it may dynamically pick its own radix if none is provided. If you must use parseInt, use it with caution and full awareness of how it operates. And always provide a radix argument.

类似的精神parseInt``parseFloat还有一个本地函数,它将尝试提取浮动(,浮点数)从给定String:

parseFloat('42.01');  // => 42.01
parseFloat('\n1e-3'); // => 0.001

parseFloat将修剪字符串,然后寻找最长的一组字符从0 届字符,可以以同样的方式自然语言解析的数值文字可能被解析。 因此,它适用于包含非数字字符的字符串,而非可解析的数字序列:

parseFloat('   123 ... rubbish here...'); // => 123

如果我们将NaN传递给Number(...),这样的字符串将导致NaN被计算。 所以,在一些罕见的情况下,parseFloat可能对你更有用。

parseFloatparseInt都将在尝试提取之前将初始参数转换为String。 因此,如果您的第一个参数是一个对象,您应该小心它如何自然地强制到字符串。 如果你的对象实现了不同的toStringvalueOf方法,那么你应该期望parseIntparseFloat只使用toString(除非[Symbol.toPrimitive]()也实现了)。 这与Number(...)相反,Number(...)会尝试直接将其参数转换为Number(而不是先将其转换为String),因此将valueOf优先于toString:

const rareSituation = {
  valueOf() { return "111"; },
  toString() { return "999"; }
};

Number(rareSituation); // => 111
parseFloat(rareSituation); // => 999
parseFloat(rareSituation); // => 999

在大多数情况下,应该通过Number或一元加+操作符尝试将任何值转换为Number。 如果您需要使用parseFloatparseInt的数值提取算法,则应该使用它们。

将一个值转换成它的原始表示直接不是我们可以做,但是是隐式(也就是说,【显示】强制地)的语言在许多不同的情况下,例如当您尝试使用抽象的平等运算符,==,比较一个String,NumberSymbol的值是一个Object。 在该场景中,Object将通过一个名为ToPrimitive的内部过程转换为其原始表示形式,该过程大致做以下工作:

  1. 如果存在object[Symbol.toPrimitive],并且在调用它时返回一个原始值,请使用它

  2. 如果存在object.valueOf,并且它返回一个原语(非Object),使用它的返回值

  3. 如果存在object.toString,则使用其返回值

如果我们将==ToPrimitive进行比较,就可以看出ToPrimitive的作用:

function toPrimitive() { return 1; }
function valueOf() { return 2; }
function toString() { return 3; }

const one = { [Symbol.toPrimitive]: toPrimitive, valueOf, toString };
const two = { valueOf, toString };
const three = { toString };

1 == one; // => true
2 == two; // => true
3 == three; // => true

正如你所看到的,如果一个对象有三个方法([Symbol.toPrimitive]valueOftoString),那么就使用[Symbol.toPrimitive]。 如果只有valueOftoString,则使用valueOf。 当然,如果只有toString,那么它就会被使用。

如果在调用ToPrimitive时带有String提示(这意味着它已被指示尝试强制调用String而不是任何原语),则该过程中的*2**3*有可能发生交换。 这种情况下的一个例子是当你使用一个计算成员访问操作符(object[something]),如果something是一个对象,它将被转换成通过【显示】StringString的提示,意义toString()valueOf()。 我们可以在这里看到它的作用:

const object = { foo: 123 };
const something = {
  valueOf() { return 'baz'; },
  toString() { return 'foo'; }
};

object[something]; // => 123

我们在something上定义了toStringvalueOf,但仅使用toString来确定在object上访问哪个属性。

如果我们没有定义自己的方法,比如valueOftoString,那么我们将使用任何对象[[Prototype]]上可用的默认方法。 例如,数组的原语表示是由Array.prototype.toString定义的,它只是用逗号作为分隔符将其元素连接在一起:

[1, 2, 3].toString(); // => "1,2,3"

所有类型都有自己的本地提供valueOftoString的方法,所以如果我们希望力ToPrimitive内部过程使用我们自己的方法,然后我们需要覆盖本地的通过提供我们的对象有自己的方法直接或通过继承[[Prototype]]。 例如,如果你想提供一个自定义数组抽象,它有自己的原始转换行为,那么你可以通过扩展Array构造函数来实现它:

class CustomArray extends Array {
  toString() {
    return this.join('|');
  }
}

然后,你就可以依靠ToPrimitive过程以自己独特的方式处理你的CustomArray实例:

String(new CustomArray(1, 2, 3));    // => 1|2|3
new CustomArray(1, 2, 3) == '1|2|3'; // => true

所有操作符和本地语言构造的强制行为都是不同的。 任何时候,当你将一个值传递给语言构造或运算符时,需要一个原语(通常是字符串或数字),它很可能通过ToPrimitive传递。 因此,了解这个内部过程很有用。 我们将参考这一节,并开始详细研究所有 JavaScript 操作符。

在本章中,我们将继续探索 JavaScript 的内部结构,包括该语言的动态特性。 我们已经看到了如何检测各种类型以及强制和强制转换的微妙复杂性。 这些主题很难掌握,但它们将是有用的。 JavaScript 代码中出现的许多反模式都归结为对语言构造和机制的基本误解,因此深入理解这些主题将有助于我们极大地实现编写干净代码的雄心。

在下一章中,我们将通过探索 JavaScript 的操作符来继续探索类型。 您可能已经非常了解其中的许多内容,但由于 JavaScript 的动态特性,它们的使用有时会产生意想不到的结果。 由于这个原因,下一章将完全致力于仔细探索语言的操作符。

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

技术与商业案例解读 -〔徐飞〕

技术领导力实战笔记 -〔TGO鲲鹏会〕

MySQL实战45讲 -〔林晓斌〕

Java并发编程实战 -〔王宝令〕

Netty源码剖析与实战 -〔傅健〕

互联网人的英语私教课 -〔陈亦峰〕

体验设计案例课 -〔炒炒〕

恋爱必修课 -〔李一帆〕

云时代的JVM原理与实战 -〔康杨〕