Javascript.info

空值合并运算符(nullish coalescing operator)的写法为两个问号 ??。

空值合并运算符 ’??’

空值合并运算符(nullish coalescing operator)的写法为两个问号 ??

由于它对待 nullundefined 的方式类似,所以在本文中我们将使用一个特殊的术语对其进行表示。为简洁起见,当一个值既不是 null 也不是 undefined 时,我们将其称为“已定义的(defined)”。

a ?? b 的结果是:

  • 如果 a 是已定义的,则结果为 a
  • 如果 a 不是已定义的,则结果为 b

我们可以使用我们已知的运算符重写 result = a ?? b,像这样:

javascript
result = (a !== null && a !== undefined) ? a : b;

?? 的常见使用场景是提供默认值。

如果 user 的值不为 null/undefined 则显示 user,否则显示 匿名

javascript
let user;

alert(user ?? "匿名"); // 匿名(user 未定义)

与 || 比较

或运算符 || 可以以与 ?? 运算符相同的方式使用。像我们在 上一章 所讲的那样。

例如,在上面的代码中,我们可以用 || 替换掉 ??,也可以获得相同的结果:

javascript
let firstName = null;
let lastName = null;
let nickName = "Supercoder";

// 显示第一个真值:
alert(firstName || lastName || nickName || "Anonymous"); // Supercoder

空值合并运算符 ?? 是最近才被添加到 JavaScript 中的,它的出现是因为人们对 || 不太满意。

它们之间重要的区别是:

  • || 返回第一个 值。

  • ?? 返回第一个 已定义的 值。

换句话说,|| 无法区分 false0、空字符串 ""null/undefined。它们都一样 —— 假值(falsy values)。

不过在实际中,我们可能只想在变量的值为 null/undefined 时使用默认值。也就是说,当该值确实未知或未被设置时。

例如,考虑下面这种情况:

javascript
let height = 0;

alert(height || 100); // 100
alert(height ?? 100); // 0

克隆与合并,Object.assign

那么,拷贝一个对象变量会又创建一个对相同对象的引用。但是,如果我们想要复制一个对象,那该怎么做呢?

我们可以创建一个新对象,通过遍历已有对象的属性,并在原始类型值的层面复制它们,以实现对已有对象结构的复制。

就像这样:

javascript
// 将 user 中所有的属性拷贝到其中
for (let key in user) {
  clone[key] = user[key];
}

我们也可以使用 Object.assign 方法来达成同样的效果。

语法是:

javascript
Object.assign(dest, [src1, src2, src3...])
  • 第一个参数 dest 是指目标对象。
  • 更后面的参数 src1, ..., srcN(可按需传递多个参数)是源对象。
  • 该方法将所有源对象的属性拷贝到目标对象 dest 中。换句话说,从第二个开始的所有参数的属性都被拷贝到第一个参数的对象中。
  • 调用结果返回 dest

Object.assign是会复制Symbol属性的,浅层复制不行(要使用Object.getOwnPropertySymbols或者Reflect.ownKeys才能获取Symbol属性用于复制)[^1]

例如,我们可以用它来合并多个对象:

javascript
let user = { name: "John" };

let permissions1 = { canView: true };
let permissions2 = { canEdit: true };

// 将 permissions1 和 permissions2 中的所有属性都拷贝到 user 中
Object.assign(user, permissions1, permissions2);

// 现在 user = { name: "John", canView: true, canEdit: true }

如果被拷贝的属性的属性名已经存在,那么它会被覆盖:

javascript
let user = { name: "John" };

Object.assign(user, { name: "Pete" });

alert(user.name); // 现在 user = { name: "Pete" }

我们也可以用 Object.assign 代替 for..in 循环来进行简单克隆:

javascript
let user = {
  name: "John",
  age: 30
};
let clone = Object.assign({}, user);

它将 user 中的所有属性拷贝到了一个空对象中,并返回这个新的对象。

还有其他克隆对象的方法,例如使用 spread 语法 clone = {...user},在后面的章节中我们会讲到。

深层克隆

到现在为止,我们都假设 user 的所有属性均为原始类型。但属性可以是对其他对象的引用。

例如:

javascript
let user = {
  name: "John",
  sizes: {
    height: 182,
    width: 50
  }
};

alert( user.sizes.height ); // 182

现在这样拷贝 clone.sizes = user.sizes 已经不足够了,因为 user.sizes 是个对象,它会以引用形式被拷贝。因此 cloneuser 会共用一个 sizes:

javascript
let user = {
  name: "John",
  sizes: {
    height: 182,
    width: 50
  }
};
let clone = Object.assign({}, user);
alert( user.sizes === clone.sizes ); // true,同一个对象

为了解决这个问题,并让 userclone 成为两个真正独立的对象,我们应该使用一个拷贝循环来检查 user[key] 的每个值,如果它是一个对象,那么也复制它的结构。这就是所谓的“深拷贝”。

我们可以使用递归来实现它。或者为了不重复造轮子,采用现有的实现,例如 lodash 库的 _.cloneDeep(obj)。

构造器和操作符 “new”

常规的 {...} 语法允许创建一个对象。但是我们经常需要创建很多类似的对象,例如多个用户或菜单项等。

这可以使用构造函数和 "new" 操作符来实现。

构造函数

构造函数在技术上是常规函数。不过有两个约定:

  1. 它们的命名以大写字母开头。
  2. 它们只能由 "new" 操作符来执行。

例如:

javascript
function User(name) {
  this.name = name;
  this.isAdmin = false;
}
let user = new User("Jack");

当一个函数被使用 new 操作符执行时,它按照以下步骤:

  1. 一个新的空对象被创建并分配给 this
  2. 函数体执行。通常它会修改 this,为其添加新的属性。
  3. 返回 this 的值。

换句话说,new User(...) 做的就是类似的事情:

javascript
function User(name) {
  // this = {};(隐式创建)
  // 添加属性到 this
  this.name = name;
  this.isAdmin = false;
  // return this;(隐式返回)
}

所以 new User("Jack") 的结果和以下操作的结果相同:

javascript
let user = {
  name: "Jack",
  isAdmin: false
};

让我们再强调一遍 —— 从技术上讲,任何函数(除了箭头函数,它没有自己的 this)都可以用作构造器。即可以通过 new 来运行,它会执行上面的算法。

如果我们有许多行用于创建单个复杂对象的代码,我们可以将它们封装在一个立即调用的构造函数中,像这样:

javascript
// 创建一个函数并立即使用 new 调用它
let user = new function() {
  this.name = "John";
  this.isAdmin = false;
};

这个构造函数不能被再次调用,因为它不保存在任何地方,只是被创建和调用。因此,这个技巧旨在封装构建单个对象的代码,而无需将来重用。

构造器模式测试:new.target

本节涉及的语法内容很少使用,除非你想了解所有内容,否则你可以直接跳过该语法。

在一个函数内部,我们可以使用 new.target 属性来检查它是否被使用 new 进行调用了。

对于常规调用,它为 undefined,对于使用 new 的调用,则等于该函数:

javascript
function User() {
  alert(new.target);
}
// 不带 "new":
User(); // undefined
// 带 "new":
new User(); // function User { ... }

它可以被用在函数内部,来判断该函数是被通过 new 调用的“构造器模式”,还是没被通过 new 调用的“常规模式”。

这种方法有时被用在库中以使语法更加灵活。这样人们在调用函数时,无论是否使用了 new,程序都能工作。

构造器的 return

通常,构造器没有 return 语句。它们的任务是将所有必要的东西写入 this,并自动转换为结果。

但是,如果这有一个 return 语句,那么规则就简单了:

  • 如果 return 返回的是一个对象,则返回这个对象,而不是 this
  • 如果 return 返回的是一个原始类型,则忽略。

换句话说,带有对象的 return 返回该对象,在所有其他情况下返回 this

例如,这里 return 通过返回一个对象覆盖 this

javascript
function BigUser() {

  this.name = "John";

  return { name: "Godzilla" };  // <-- 返回这个对象
}

alert( new BigUser().name );  // Godzilla,得到了那个对象

这里有一个 return 为空的例子(或者我们可以在它之后放置一个原始类型,没有什么影响):

javascript
function SmallUser() {

  this.name = "John";

  return; // <-- 返回 this
}

alert( new SmallUser().name );  // John

通常构造器没有 return 语句。这里我们主要为了完整性而提及返回对象的特殊行为。

省略括号

顺便说一下,如果没有参数,我们可以省略 new 后的括号:

javascript
let user = new User; // <-- 没有参数
// 等同于
let user = new User();

构造器中的方法

使用构造函数来创建对象会带来很大的灵活性。构造函数可能有一些参数,这些参数定义了如何构造对象以及要放入什么。

当然,我们不仅可以将属性添加到 this 中,还可以添加方法。

例如,下面的 new User(name) 用给定的 name 和方法 sayHi 创建了一个对象:

javascript
function User(name) {
  this.name = name;

  this.sayHi = function() {
    alert( "My name is: " + this.name );
  };
}
let john = new User("John");

类 是用于创建复杂对象的一个更高级的语法,我们稍后会讲到。

可选链 ”?.”

可选链 ?. 是一种访问嵌套对象属性的安全的方式。即使中间的属性不存在,也不会出现错误。

“不存在的属性”的问题

举个例子,假设我们有很多个 user 对象,其中存储了我们的用户数据。

我们大多数用户的地址都存储在 user.address 中,街道地址存储在 user.address.street 中,但有些用户没有提供这些信息。

在这种情况下,当我们尝试获取 user.address.street,而该用户恰好没提供地址信息,我们则会收到一个错误:

javascript
let user = {}; // 一个没有 "address" 属性的 user 对象

alert(user.address.street); // Error!

这是预期的结果。JavaScript 的工作原理就是这样的。因为 user.addressundefined,尝试读取 user.address.street 会失败,并收到一个错误。

但是在很多实际场景中,我们更希望得到的是 undefined(表示没有 street 属性)而不是一个错误。

在 Web 开发中,我们可以使用特殊的方法调用(例如 document.querySelector('.elem'))以对象的形式获取一个网页元素,如果没有这种对象,则返回 null

javascript
// 如果 document.querySelector('.elem') 的结果为 null,则这里不存在这个元素
let html = document.querySelector('.elem').innerHTML; // 如果 document.querySelector('.elem') 的结果为 null,则会出现错误

同样,如果该元素不存在,则访问 null.innerHTML 属性时会报错。在某些情况下,当元素的缺失是没问题的时候,我们希望避免出现这种错误,而是接受 html = null 作为结果。

可能最先想到的方案是在访问该值的属性之前,使用 if 或条件运算符 ? 对该值进行检查,像这样:

javascript
let user = {};
alert(user.address ? user.address.street : undefined);

这样可以,这里就不会出现错误了……但是不够优雅。就像你所看到的,"user.address" 在代码中出现了两次。

我们看一个以相同方式获取 document.querySelector 的例子:

javascript
let html = document.querySelector('.elem') ? document.querySelector('.elem').innerHTML : null;

我们可以看到用于进行元素搜索的 document.querySelector('.elem') 在这里实际上被调用了两次。这样不优雅。

对于嵌套层次更深的属性,代码会变得更丑,因为需要更多的重复。

例如,让我们以相同的方式尝试获取 user.address.street.name

javascript
let user = {}; // user 没有 address 属性

alert(user.address ? user.address.street ? user.address.street.name : null : null);

这样就太扯淡了,并且这可能导致写出来的代码很难让别人理解。

这里有一种更好的实现方式,就是使用 && 运算符:

javascript
let user = {}; // user 没有 address 属性

alert( user.address && user.address.street && user.address.street.name ); // undefined(不报错)

依次对整条路径上的属性使用与运算进行判断,以确保所有节点是存在的(如果不存在,则停止计算),但仍然不够优雅。

就像你所看到的,在代码中我们仍然重复写了好几遍对象属性名。例如在上面的代码中,user.address 被重复写了三遍。

这就是为什么可选链 ?. 被加入到了 JavaScript 这门编程语言中。那就是彻底地解决以上所有问题!

可选链

如果可选链 ?. 前面的值为 undefined 或者 null,它会停止运算并返回 undefined

为了简明起见,在本文接下来的内容中,我们会说如果一个属性既不是 null 也不是 undefined,那么它就“存在”。

换句话说,例如 value?.prop

  • 如果 value 存在,则结果与 value.prop 相同,
  • 否则(当 valueundefined/null 时)则返回 undefined

下面这是一种使用 ?. 安全地访问 user.address.street 的方式:

javascript
let user = {}; // user 没有 address 属性

alert( user?.address?.street ); // undefined(不报错)

代码简洁明了,也不用重复写好几遍属性名。

这里是一个结合 document.querySelector 使用的示例:

javascript
let html = document.querySelector('.elem')?.innerHTML; // 如果没有符合的元素,则为 undefined

即使 对象 user 不存在,使用 user?.address 来读取地址也没问题:

javascript
let user = null;

alert( user?.address ); // undefined
alert( user?.address.street ); // undefined

请注意:?. 语法使其前面的值成为可选值,但不会对其后面的起作用。

例如,在 user?.address.street.name 中,?. 允许 usernull/undefined(在这种情况下会返回 undefined)也不会报错,但这仅对于 user。更深层次的属性是通过常规方式访问的。如果我们希望它们中的一些也是可选的,那么我们需要使用更多的 ?. 来替换 .

不要过度使用可选链

我们应该只将 ?. 使用在一些东西可以不存在的地方。

例如,如果根据我们的代码逻辑,user 对象必须存在,但 address 是可选的,那么我们应该这样写 user.address?.street,而不是这样 user?.address?.street

那么,如果 user 恰巧为 undefined,我们会看到一个编程错误并修复它。否则,如果我们滥用 ?.,会导致代码中的错误在不应该被消除的地方消除了,这会导致调试更加困难。

?. 前的变量必须已声明

如果未声明变量 user,那么 user?.anything 会触发一个错误:

javascript
// ReferenceError: user is not defined
user?.address;

?. 前的变量必须已声明(例如 let/const/var user 或作为一个函数参数)。可选链仅适用于已声明的变量。

短路效应

正如前面所说的,如果 ?. 左边部分不存在,就会立即停止运算(“短路效应”)。

因此,如果在 ?. 的右侧有任何进一步的函数调用或操作,它们均不会执行。

例如:

javascript
let user = null;
let x = 0;

user?.sayHi(x++); // 没有 "user",因此代码执行没有到达 sayHi 调用和 x++

alert(x); // 0,值没有增加

其它变体:?.(),?.[]

可选链 ?. 不是一个运算符,而是一个特殊的语法结构。它还可以与函数和方括号一起使用。

例如,将 ?.() 用于调用一个可能不存在的函数。

在下面这段代码中,有些用户具有 admin 方法,而有些没有:

javascript
let userAdmin = {
  admin() {
    alert("I am admin");
  }
};

let userGuest = {};

userAdmin.admin?.(); // I am admin

userGuest.admin?.(); // 啥都没发生(没有这样的方法)

在这两行代码中,我们首先使用点符号(userAdmin.admin)来获取 admin 属性,因为我们假定对象 userAdmin 存在,因此可以安全地读取它。

然后 ?.() 会检查它左边的部分:如果 admin 函数存在,那么就调用运行它(对于 userAdmin)。否则(对于 userGuest)运算停止,没有报错。

如果我们想使用方括号 [] 而不是点符号 . 来访问属性,语法 ?.[] 也可以使用。跟前面的例子类似,它允许从一个可能不存在的对象上安全地读取属性。

javascript
let key = "firstName";

let user1 = {
  firstName: "John"
};

let user2 = null;

alert( user1?.[key] ); // John
alert( user2?.[key] ); // undefined

此外,我们还可以将 ?.delete 一起使用:

javascript
delete user?.name; // 如果 user 存在,则删除 user.name

我们可以使用 ?. 来安全地读取或删除,但不能写入

可选链 ?. 不能用在赋值语句的左侧。

javascript
let user = null;
user?.name = "John"; // Error,不起作用
// 因为它在计算的是:undefined = "John"

Symbol

根据规范,只有两种原始类型可以用作对象属性键:

  • 字符串类型
  • symbol 类型

否则,如果使用另一种类型,例如数字,它会被自动转换为字符串。所以 obj[1]obj["1"] 相同,而 obj[true]obj["true"] 相同。

symbol

“symbol” 值表示唯一的标识符。

可以使用 Symbol() 来创建这种类型的值:

javascript
let id = Symbol();

创建时,我们可以给 symbol 一个描述(也称为 symbol 名),这在代码调试时非常有用:

javascript
// id 是描述为 "id" 的 symbol
let id = Symbol("id");

symbol 保证是唯一的。即使我们创建了许多具有相同描述的 symbol,它们的值也是不同。描述只是一个标签,不影响任何东西。

例如,这里有两个描述相同的 symbol —— 它们不相等:

javascript
let id1 = Symbol("id");
let id2 = Symbol("id");

alert(id1 == id2); // false

symbol 不会被自动转换为字符串

JavaScript 中的大多数值都支持字符串的隐式转换。例如,我们可以 alert 任何值,都可以生效。symbol 比较特殊,它不会被自动转换。

例如,这个 alert 将会提示出错:

javascript
let id = Symbol("id");
alert(id); // 类型错误:无法将 symbol 值转换为字符串。

这是一种防止混乱的“语言保护”,因为字符串和 symbol 有本质上的不同,不应该意外地将它们转换成另一个。

如果我们真的想显示一个 symbol,我们需要在它上面调用 .toString(),如下所示:

javascript
let id = Symbol("id");
alert(id.toString()); // Symbol(id),现在它有效了

或者获取 symbol.description 属性,只显示描述(description):

javascript
let id = Symbol("id");
alert(id.description); // id

“隐藏”属性

symbol 允许我们创建对象的“隐藏”属性,代码的任何其他部分都不能意外访问或重写这些属性。

例如,如果我们使用的是属于第三方代码的 user 对象,我们想要给它们添加一些标识符。

我们可以给它们使用 symbol 键:

javascript
let user = { // 属于另一个代码
  name: "John"
};
let id = Symbol("id");
user[id] = 1;
alert( user[id] ); // 我们可以使用 symbol 作为键来访问数据

使用 Symbol("id") 作为键,比起用字符串 "id" 来有什么好处呢?

由于 user 对象属于另一个代码库,所以向它们添加字段是不安全的,因为我们可能会影响代码库中的其他预定义行为。但 symbol 属性不会被意外访问到。第三方代码不会知道新定义的 symbol,因此将 symbol 添加到 user 对象是安全的。

对象字面量中的 symbol

如果我们要在对象字面量 {...} 中使用 symbol,则需要使用方括号把它括起来。

就像这样:

javascript
let id = Symbol("id");

let user = {
  name: "John",
  [id]: 123 // 而不是 "id":123
};

这是因为我们需要变量 id 的值作为键,而不是字符串 “id”。

symbol 在 for…in 中会被跳过

symbol 属性不参与 for..in 循环。

javascript
let id = Symbol("id");
let user = {
  name: "John",
  age: 30,
  [id]: 123
};
for (let key in user) alert(key); // name, age(没有 symbol)
// 使用 symbol 任务直接访问
alert("Direct: " + user[id]); // Direct: 123

Object.keys(user) 也会忽略它们。这是一般“隐藏符号属性”原则的一部分。如果另一个脚本或库遍历我们的对象,它不会意外地访问到符号属性。

相反,Object.assign 会同时复制字符串和 symbol 属性:

javascript
let id = Symbol("id");
let user = {
  [id]: 123
};

let clone = Object.assign({}, user);

alert( clone[id] ); // 123

这里并不矛盾,就是这样设计的。这里的想法是当我们克隆或者合并一个 object 时,通常希望 所有 属性被复制(包括像 id 这样的 symbol)。

全局 symbol

正如我们所看到的,通常所有的 symbol 都是不同的,即使它们有相同的名字。但有时我们想要名字相同的 symbol 具有相同的实体。例如,应用程序的不同部分想要访问的 symbol "id" 指的是完全相同的属性。

为了实现这一点,这里有一个 全局 symbol 注册表。我们可以在其中创建 symbol 并在稍后访问它们,它可以确保每次访问相同名字的 symbol 时,返回的都是相同的 symbol。

要从注册表中读取(不存在则创建)symbol,请使用 Symbol.for(key)

该调用会检查全局注册表,如果有一个描述为 key 的 symbol,则返回该 symbol,否则将创建一个新 symbol(Symbol(key)),并通过给定的 key 将其存储在注册表中。

javascript
// 从全局注册表中读取
let id = Symbol.for("id"); // 如果该 symbol 不存在,则创建它

// 再次读取(可能是在代码中的另一个位置)
let idAgain = Symbol.for("id");

// 相同的 symbol
alert( id === idAgain ); // true

注册表内的 symbol 被称为 全局 symbol。如果我们想要一个应用程序范围内的 symbol,可以在代码中随处访问 —— 这就是它们的用途。

Symbol.keyFor

我们已经看到,对于全局 symbol,Symbol.for(key) 按名字返回一个 symbol。相反,通过全局 symbol 返回一个名字,我们可以使用 Symbol.keyFor(sym)

例如:

javascript
// 通过 name 获取 symbol
let sym = Symbol.for("name");
let sym2 = Symbol.for("id");
// 通过 symbol 获取 name
alert( Symbol.keyFor(sym) ); // name
alert( Symbol.keyFor(sym2) ); // id

Symbol.keyFor 内部使用全局 symbol 注册表来查找 symbol 的键。所以它不适用于非全局 symbol。如果 symbol 不是全局的,它将无法找到它并返回 undefined

但是,所有 symbol 都具有 description 属性。

javascript
let globalSymbol = Symbol.for("name");
let localSymbol = Symbol("name");

alert( Symbol.keyFor(globalSymbol) ); // name,全局 symbol
alert( Symbol.keyFor(localSymbol) ); // undefined,非全局

alert( localSymbol.description ); // name

系统 symbol

JavaScript 内部有很多“系统” symbol,我们可以使用它们来微调对象的各个方面。

它们都被列在了 众所周知的 symbol 表的规范中:

  • Symbol.hasInstance
  • Symbol.isConcatSpreadable
  • Symbol.iterator
  • Symbol.toPrimitive
  • ……等等。

例如,Symbol.toPrimitive 允许我们将对象描述为原始值转换。我们很快就会看到它的使用。

当我们研究相应的语言特征时,我们对其他的 symbol 也会慢慢熟悉起来。

注意

从技术上说,symbol 不是 100% 隐藏的。有一个内建方法 Object.getOwnPropertySymbols(obj) 允许我们获取所有的 symbol。还有一个名为 Reflect.ownKeys(obj) 的方法可以返回一个对象的 所有 键,包括 symbol。

对象 —— 原始值转换

当对象相加 obj1 + obj2,相减 obj1 - obj2,或者使用 alert(obj) 打印时会发生什么?

JavaScript 不允许自定义运算符对对象的处理方式。与其他一些编程语言(Ruby,C++)不同,我们无法实现特殊的对象处理方法来处理加法(或其他运算)。

转换规则

  1. 没有转换为布尔值。所有的对象在布尔上下文(context)中均为 true,就这么简单。只有字符串和数字转换。
  2. 数字转换发生在对象相减或应用数学函数时。例如,Date 对象(将在 日期和时间 一章中介绍)可以相减,date1 - date2 的结果是两个日期之间的差值。
  3. 至于字符串转换 —— 通常发生在我们像 alert(obj) 这样输出一个对象和类似的上下文中。

我们可以使用特殊的对象方法,自己实现字符串和数字的转换。

hint

JavaScript 是如何决定应用哪种转换的?

类型转换在各种情况下有三种变体。它们被称为 “hint”,在 规范 所述:

  • "string"

    对象到字符串的转换,当我们对期望一个字符串的对象执行操作时

    js
    // 输出 
    alert(obj); 
    // 将对象作为属性键 
    anotherObj[obj] = 123;
  • "number"

    对象到数字的转换,例如当我们进行数学运算时:

    js
    // 显式转换 
    let num = Number(obj); 
    // 数学运算(除了二元加法) 
    let n = +obj; 
    // 一元加法 
    let delta = date1 - date2; 
    // 小于/大于的比较 
    let greater = user1 > user2;

    大多数内建的数学函数也包括这种转换。

  • "default"

    在少数情况下发生,当运算符“不确定”期望值的类型时。例如,二元加法 + 可用于字符串(连接),也可以用于数字(相加)。因此,当二元加法得到对象类型的参数时,它将依据 "default" hint 来对其进行转换。此外,如果对象被用于与字符串、数字或 symbol 进行 == 比较,这时到底应该进行哪种转换也不是很明确,因此使用 "default" hint。

上面这些规则看起来比较复杂,但在实践中其实挺简单的。

除了一种情况(Date 对象,我们稍后会讲到)之外,所有内建对象都以和 "number" 相同的方式实现 "default" 转换。我们也可以这样做。

为了进行转换,JavaScript 尝试查找并调用三个对象方法:

  1. 调用 obj[Symbol.toPrimitive](hint) —— 带有 symbol 键 Symbol.toPrimitive(系统 symbol)的方法,如果这个方法存在的话,
  2. 否则,如果 hint 是 "string" —— 尝试调用 obj.toString()obj.valueOf(),无论哪个存在。
  3. 否则,如果 hint 是 "number""default" —— 尝试调用 obj.valueOf()obj.toString(),无论哪个存在。

Symbol.toPrimitive

我们从第一个方法开始。有一个名为 Symbol.toPrimitive 的内建 symbol,它被用来给转换方法命名,像这样:

javascript
obj[Symbol.toPrimitive] = function(hint) {
  // 这里是将此对象转换为原始值的代码
  // 它必须返回一个原始值
  // hint = "string"、"number" 或 "default" 中的一个
}

如果 Symbol.toPrimitive 方法存在,则它会被用于所有 hint,无需更多其他方法。

例如,这里 user 对象实现了它:

javascript
let user = {
  name: "John",
  money: 1000,

  [Symbol.toPrimitive](hint) {
    alert(`hint: ${hint}`);
    return hint == "string" ? `{name: "${this.name}"}` : this.money;
  }
};
// 转换演示:
alert(user); // hint: string -> {name: "John"}
alert(+user); // hint: number -> 1000
alert(user + 500); // hint: default -> 1500

从代码中我们可以看到,根据转换的不同,user 变成一个自描述字符串或者一个金额。user[Symbol.toPrimitive] 方法处理了所有的转换情况。

toString/valueOf

如果没有 Symbol.toPrimitive,那么 JavaScript 将尝试寻找 toStringvalueOf 方法:

  • 对于 "string" hint:调用 toString 方法,如果它不存在,则调用 valueOf 方法(因此,对于字符串转换,优先调用 toString)。
  • 对于其他 hint:调用 valueOf 方法,如果它不存在,则调用 toString 方法(因此,对于数学运算,优先调用 valueOf 方法)。

toStringvalueOf 方法很早己有了。它们不是 symbol(那时候还没有 symbol 这个概念),而是“常规的”字符串命名的方法。它们提供了一种可选的“老派”的实现转换的方法。

这些方法必须返回一个原始值。如果 toStringvalueOf 返回了一个对象,那么返回值会被忽略(和这里没有方法的时候相同)。

默认情况下,普通对象具有 toStringvalueOf 方法:

  • toString 方法返回一个字符串 "[object Object]"
  • valueOf 方法返回对象自身。

下面是一个示例:

javascript
let user = {name: "John"};

alert(user); // [object Object]
alert(user.valueOf() === user); // true

所以,如果我们尝试将一个对象当做字符串来使用,例如在 alert 中,那么在默认情况下我们会看到 [object Object]

这里提到的默认的 valueOf 只是为了完整起见,以避免混淆。正如你看到的,它返回对象本身,因此被忽略。别问我为什么,这是历史原因。所以我们可以假设它根本就不存在。

让我们实现一下这些方法来自定义转换。

例如,这里的 user 执行和前面提到的那个 user 一样的操作,使用 toStringvalueOf 的组合(而不是 Symbol.toPrimitive):

javascript
let user = {
  name: "John",
  money: 1000,

  // 对于 hint="string"
  toString() {
    return `{name: "${this.name}"}`;
  },

  // 对于 hint="number" 或 "default"
  valueOf() {
    return this.money;
  }

};

alert(user); // toString -> {name: "John"}
alert(+user); // valueOf -> 1000
alert(user + 500); // valueOf -> 1500

我们可以看到,执行的动作和前面使用 Symbol.toPrimitive 的那个例子相同。

通常我们希望有一个“全能”的地方来处理所有原始转换。在这种情况下,我们可以只实现 toString,就像这样:

javascript
let user = {
  name: "John",

  toString() {
    return this.name;
  }
};

alert(user); // toString -> John
alert(user + 500); // toString -> John500

如果没有 Symbol.toPrimitivevalueOftoString 将处理所有原始转换。

转换可以返回任何原始类型

关于所有原始转换方法,有一个重要的点需要知道,就是它们不一定会返回 “hint” 的原始值。

没有限制 toString() 是否返回字符串,或 Symbol.toPrimitive 方法是否为 "number" hint 返回数字。

唯一强制性的事情是:这些方法必须返回一个原始值,而不是对象。

历史原因

由于历史原因,如果 toStringvalueOf 返回一个对象,则不会出现 error,但是这种值会被忽略(就像这种方法根本不存在)。这是因为在 JavaScript 语言发展初期,没有很好的 “error” 的概念。

相反,Symbol.toPrimitive 更严格,它 必须 返回一个原始值,否则就会出现 error。

进一步的转换

我们已经知道,许多运算符和函数执行类型转换,例如乘法 * 将操作数转换为数字。

如果我们将对象作为参数传递,则会出现两个运算阶段:

  1. 对象被转换为原始值(通过前面我们描述的规则)。
  2. 如果还需要进一步计算,则生成的原始值会被进一步转换。

例如:

javascript
let obj = {
  // toString 在没有其他方法的情况下处理所有转换
  toString() {
    return "2";
  }
};
alert(obj * 2); // 4,对象被转换为原始值字符串 "2",之后它被乘法转换为数字 2。
  1. 乘法 obj * 2 首先将对象转换为原始值(字符串 “2”)。
  2. 之后 "2" * 2 变为 2 * 2(字符串被转换为数字)。

二元加法在同样的情况下会将其连接成字符串,因为它更愿意接受字符串:

javascript
let obj = {
  toString() {
    return "2";
  }
};

alert(obj + 2); // 22("2" + 2)被转换为原始值字符串 => 级联

数字类型

在现代 JavaScript 中,数字(number)有两种类型:

  1. JavaScript 中的常规数字以 64 位的格式 IEEE-754 存储,也被称为“双精度浮点数”。这是我们大多数时候所使用的数字,我们将在本章中学习它们。
  2. BigInt 用于表示任意长度的整数。有时会需要它们,因为正如我们在前面的章节 数据类型 中提到的,常规整数不能安全地超过 (253-1) 或小于 -(253-1)。由于仅在少数特殊领域才会用到 BigInt,因此我们在特殊的章节 BigInt 中对其进行了介绍。

所以,在这里我们将讨论常规数字类型。现在让我们开始学习吧。

toString(base)

方法 num.toString(base) 返回在给定 base 进制数字系统中 num 的字符串表示形式。

举个例子:

javascript
let num = 255;

alert( num.toString(16) );  // ff
alert( num.toString(2) );   // 11111111

base 的范围可以从 236。默认情况下是 10

常见的用例如下:

  • base=16 用于十六进制颜色,字符编码等,数字可以是 0..9A..F

  • base=2 主要用于调试按位操作,数字可以是 01

  • base=36 是最大进制,数字可以是 0..9A..Z。所有拉丁字母都被用于了表示数字。对于 36 进制来说,一个有趣且有用的例子是,当我们需要将一个较长的数字标识符转换成较短的时候,例如做一个短的 URL。可以简单地使用基数为 36 的数字系统表示:

    javascript
    alert( 123456..toString(36) ); // 2n9c

使用两个点来调用一个方法

请注意 123456..toString(36) 中的两个点不是打错了。如果我们想直接在一个数字上调用一个方法,比如上面例子中的 toString,那么我们需要在它后面放置两个点 ..

如果我们放置一个点:123456.toString(36),那么就会出现一个 error,因为 JavaScript 语法隐含了第一个点之后的部分为小数部分。如果我们再放一个点,那么 JavaScript 就知道小数部分为空,现在使用该方法。

也可以写成 (123456).toString(36)

舍入

舍入(rounding)是使用数字时最常用的操作之一。

这里有几个对数字进行舍入的内建函数:

  • Math.floor

    向下舍入:3.1 变成 3-1.1 变成 -2

  • Math.ceil

    向上舍入:3.1 变成 4-1.1 变成 -1

  • Math.round

    向最近的整数舍入:3.1 变成 33.6 变成 4,中间值 3.5 变成 4

  • Math.trunc(IE 浏览器不支持这个方法)

    移除小数点后的所有内容而没有舍入:3.1 变成 3-1.1 变成 -1

这个是总结它们之间差异的表格:

Math.floorMath.ceilMath.roundMath.trunc
3.13433
3.63443
-1.1-2-1-1-1
-1.6-2-1-2-1

这些函数涵盖了处理数字小数部分的所有可能方法。但是,如果我们想将数字舍入到小数点后 n 位,该怎么办?

例如,我们有 1.2345,并且想把它舍入到小数点后两位,仅得到 1.23

有两种方式可以实现这个需求:

  1. 乘除法

    例如,要将数字舍入到小数点后两位,我们可以将数字乘以 100,调用舍入函数,然后再将其除回。

    javascript
    let num = 1.23456;
    
    alert( Math.round(num * 100) / 100 ); // 1.23456 -> 123.456 -> 123 -> 1.23
  2. 函数 toFixed(n) 将数字舍入到小数点后 n 位,并以字符串形式返回结果。

    javascript
    let num = 12.34;
    alert( num.toFixed(1) ); // "12.3"

    这会向上或向下舍入到最接近的值,类似于 Math.round

    javascript
    let num = 12.36;
    alert( num.toFixed(1) ); // "12.4"

    请注意 toFixed 的结果是一个字符串。如果小数部分比所需要的短,则在结尾添加零:

    javascript
    let num = 12.34;
    alert( num.toFixed(5) ); // "12.34000",在结尾添加了 0,以达到小数点后五位

    我们可以使用一元加号或 Number() 调用,将其转换为数字,例如 + num.toFixed(5)

parseInt 和 parseFloat

使用加号 +Number() 的数字转换是严格的。如果一个值不完全是一个数字,就会失败:

javascript
alert( +"100px" ); // NaN

唯一的例外是字符串开头或结尾的空格,因为它们会被忽略。

但在现实生活中,我们经常会有带有单位的值,例如 CSS 中的 "100px""12pt"。并且,在很多国家,货币符号是紧随金额之后的,所以我们有 "19€",并希望从中提取出一个数值。

这就是 parseIntparseFloat 的作用。

它们可以从字符串中“读取”数字,直到无法读取为止。如果发生 error,则返回收集到的数字。函数 parseInt 返回一个整数,而 parseFloat 返回一个浮点数:

javascript
alert( parseInt('100px') ); // 100
alert( parseFloat('12.5em') ); // 12.5

alert( parseInt('12.3') ); // 12,只有整数部分被返回了
alert( parseFloat('12.3.4') ); // 12.3,在第二个点出停止了读取

某些情况下,parseInt/parseFloat 会返回 NaN。当没有数字可读时会发生这种情况:

javascript
alert( parseInt('a123') ); // NaN,第一个符号停止了读取

parseInt(str, radix) 的第二个参数

parseInt() 函数具有可选的第二个参数。它指定了数字系统的基数,因此 parseInt 还可以解析十六进制数字、二进制数字等的字符串:

javascript
alert( parseInt('0xff', 16) ); // 255
alert( parseInt('ff', 16) ); // 255,没有 0x 仍然有效

alert( parseInt('2n9c', 36) ); // 123456

其他数学函数

JavaScript 有一个内建的 Math 对象,它包含了一个小型的数学函数和常量库。

几个例子:

  • Math.random()

    返回一个从 0 到 1 的随机数(不包括 1)。`

  • Math.max(a, b, c...)Math.min(a, b, c...)

    从任意数量的参数中返回最大值和最小值。

  • Math.pow(n, power)

    返回 n 的给定(power)次幂。

Math 对象中还有更多函数和常量,包括三角函数,你可以在 Math 对象文档 中找到这些内容。

字符串

在 JavaScript 中,文本数据被以字符串形式存储,单个字符没有单独的类型。

字符串的内部格式始终是 UTF-16,它不依赖于页面编码。

引号(Quotes)

让我们回忆一下引号的种类。

字符串可以包含在单引号、双引号或反引号中:

javascript
let single = 'single-quoted';
let double = "double-quoted";
let backticks = `backticks`;

单引号和双引号基本相同。但是,反引号允许我们通过 ${…} 将任何表达式嵌入到字符串中:

使用反引号的另一个优点是它们允许字符串跨行:

javascript
let guestList = `Guests:
 * John
 * Pete
 * Mary
`;
alert(guestList); // 多行

看起来很自然,不是吗?但是单引号和双引号可不能这样做。

如果我们使用单引号或双引号来实现字符串跨行的话,则会出现错误:

javascript
let guestList = "Guests: // Error: Unexpected token ILLEGAL
  * John";

单引号和双引号来自语言创建的古老时代,当时没有考虑到多行字符串的需要。反引号出现较晚,因此更通用。

特殊字符

我们仍然可以通过使用“换行符(newline character)”,以支持使用单引号和双引号来创建跨行字符串。换行符写作 \n,用来表示换行:

javascript
let guestList = "Guests:\n * John\n * Pete\n * Mary";
alert(guestList); // 一个多行的客人列表还有其他不常见的“特殊”字符。

这是完整列表:

字符描述
\n换行
\r在 Windows 文本文件中,两个字符 \r\n 的组合代表一个换行。而在非 Windows 操作系统上,它就是 \n。这是历史原因造成的,大多数的 Windows 软件也理解 \n
\', \"引号
\\反斜线
\t制表符
\b, \f, \v退格,换页,垂直标签 —— 为了兼容性,现在已经不使用了。
\xXX具有给定十六进制 Unicode XX 的 Unicode 字符,例如:'\x7A''z' 相同。
\uXXXX以 UTF-16 编码的十六进制代码 XXXX 的 Unicode 字符,例如 \u00A9 —— 是版权符号 © 的 Unicode。它必须正好是 4 个十六进制数字。
\u{X…XXXXXX}(1 到 6 个十六进制字符)具有给定 UTF-32 编码的 Unicode 符号。一些罕见的字符用两个 Unicode 符号编码,占用 4 个字节。这样我们就可以插入长代码了。

Unicode 示例:

javascript
alert( "\u00A9" ); // ©
alert( "\u{20331}" ); // 佫,罕见的中国象形文字(长 Unicode)
alert( "\u{1F60D}" ); // 😍,笑脸符号(另一个长 Unicode)

所有的特殊字符都以反斜杠字符 \ 开始。它也被称为“转义字符”。

如果我们想要在字符串中插入一个引号,我们也会使用它。

例如:

javascript
alert( 'I\'m the Walrus!' ); // I'm the Walrus!

正如你所看到的,我们必须在内部引号前加上反斜杠 \',否则它将表示字符串结束。

当然,只有与外部闭合引号相同的引号才需要转义。因此,作为一个更优雅的解决方案,我们可以改用双引号或者反引号:

javascript
alert( `I'm the Walrus!` ); // I'm the Walrus!

但是如果我们需要在字符串中显示一个实际的反斜杠 \ ,只需要将其书写两次 \\

javascript
alert( `The backslash: \\` ); // The backslash: \

字符串长度

length 属性表示字符串长度:

javascript
alert( `My\n`.length ); // 3

注意 \n 是一个单独的“特殊”字符,所以长度确实是 3

访问字符

要获取在 pos 位置的一个字符,可以使用方括号 [pos] 或者调用 str.charAt(pos) 方法。第一个字符从零位置开始:

javascript
let str = `Hello`;

// 第一个字符
alert( str[0] ); // H
alert( str.charAt(0) ); // H
// 最后一个字符
alert( str[str.length - 1] ); // o

方括号是获取字符的一种现代化方法,而 charAt 是历史原因才存在的。

它们之间的唯一区别是,如果没有找到字符,[] 返回 undefined,而 charAt 返回一个空字符串:

javascript
let str = `Hello`;
alert( str[1000] ); // undefined
alert( str.charAt(1000) ); // ''(空字符串)

我们也可以使用 for..of 遍历字符:

javascript
for (let char of "Hello") {
  alert(char); // H,e,l,l,o(char 变为 "H",然后是 "e",然后是 "l" 等)
}

字符串是不可变的

改变大小写

toLowerCase() 和 toUpperCase() 方法可以改变大小写:

javascript
alert( 'Interface'.toUpperCase() ); // INTERFACE
alert( 'Interface'.toLowerCase() ); // interface

查找子字符串

在字符串中查找子字符串有很多种方法。

str.indexOf

第一个方法是 str.indexOf(substr, pos)

它从给定位置 pos 开始,在 str 中查找 substr,如果没有找到,则返回 -1,否则返回匹配成功的位置。

例如:

javascript
let str = 'Widget with id';

alert( str.indexOf('Widget') ); // 0,因为 'Widget' 一开始就被找到
alert( str.indexOf('widget') ); // -1,没有找到,检索是大小写敏感的

alert( str.indexOf("id") ); // 1,"id" 在位置 1 处(……idget 和 id)

可选的第二个参数允许我们从一个给定的位置开始检索。

如果我们对所有存在位置都感兴趣,可以在一个循环中使用 indexOf。每一次新的调用都发生在上一匹配位置之后:

javascript
let str = 'As sly as a fox, as strong as an ox';

let target = 'as'; // 这是我们要查找的目标

let pos = 0;
while (true) {
  let foundPos = str.indexOf(target, pos);
  if (foundPos == -1) break;
  alert( `Found at ${foundPos}` );
  pos = foundPos + 1; // 继续从下一个位置查找
}

相同的算法可以简写:

javascript
let str = "As sly as a fox, as strong as an ox";
let target = "as";

let pos = -1;
while ((pos = str.indexOf(target, pos + 1)) != -1) {
  alert( pos );
}

str.lastIndexOf(substr, pos)

还有一个类似的方法 str.lastIndexOf(substr, position),它从字符串的末尾开始搜索到开头。

它会以相反的顺序列出这些事件。

includes,startsWith,endsWith

更现代的方法 str.includes(substr, pos) 根据 str 中是否包含 substr 来返回 true/false

如果我们需要检测匹配,但不需要它的位置,那么这是正确的选择:

javascript
alert( "Widget with id".includes("Widget") ); // true
alert( "Hello".includes("Bye") ); // false

str.includes 的第二个可选参数是开始搜索的起始位置:

javascript
alert( "Widget".includes("id") ); // true
alert( "Widget".includes("id", 3) ); // false, 从位置 3 开始没有 "id"

方法 str.startsWithstr.endsWith 的功能与其名称所表示的意思相同:

javascript
alert( "Widget".startsWith("Wid") ); // true,"Widget" 以 "Wid" 开始
alert( "Widget".endsWith("get") ); // true,"Widget" 以 "get" 结束

获取子字符串

JavaScript 中有三种获取字符串的方法:substringsubstrslice

方法选择方式……负值参数
slice(start, end)startend(不含 end允许
substring(start, end)startend(不含 end负值被视为 0
substr(start, length)start 开始获取长为 length 的字符串允许 start 为负数

比较字符串

字符串按字母顺序逐字比较。

不过,也有一些奇怪的地方。

  1. 小写字母总是大于大写字母:

    javascript
    alert( 'a' > 'Z' ); // true
  2. 带变音符号的字母存在“乱序”的情况:

    javascript
    alert( 'Österreich' > 'Zealand' ); // true

    如果我们对这些国家名进行排序,可能会导致奇怪的结果。通常,人们会期望 Zealand 在名单中的 Österreich 之后出现。

为了明白发生了什么,我们回顾一下在 JavaScript 中字符串的内部表示。

所有的字符串都使用 UTF-16 编码。即:每个字符都有对应的数字代码。有特殊的方法可以获取代码表示的字符,以及字符对应的代码。

  • str.codePointAt(pos)

    返回在 pos 位置的字符代码 :

    js
    // 不同的字母有不同的代码 
    alert( "z".codePointAt(0) ); // 122 
    alert( "Z".codePointAt(0) ); // 90
  • String.fromCodePoint(code)

    通过数字 code 创建字符

现在我们看一下代码为 65..220 的字符(拉丁字母和一些额外的字符),方法是创建一个字符串:

javascript
let str = '';

for (let i = 65; i <= 220; i++) {
  str += String.fromCodePoint(i);
}
alert( str );
// ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~€‚ƒ„
// ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜ

看到没?先是大写字符,然后是一些特殊字符,然后是小写字符,而 Ö 几乎是最后输出。

正确的比较

执行字符串比较的“正确”算法比看起来更复杂,因为不同语言的字母都不相同。

因此浏览器需要知道要比较的语言。

幸运的是,所有现代浏览器(IE10- 需要额外的库 Intl.JS) 都支持国际化标准 ECMA-402

它提供了一种特殊的方法来比较不同语言的字符串,遵循它们的规则。

调用 str.localeCompare(str2) 会根据语言规则返回一个整数,这个整数能指示字符串 str 在排序顺序中排在字符串 str2 前面、后面、还是相同:

  • 如果 str 排在 str2 前面,则返回负数。
  • 如果 str 排在 str2 后面,则返回正数。
  • 如果它们在相同位置,则返回 0

例如:

javascript
alert( 'Österreich'.localeCompare('Zealand') ); // -1

内部,Unicode

这部分会深入字符串内部。如果你计划处理 emoji、罕见的数学或象形文字或其他罕见的符号,这些知识会对你有用。

如果你不打算支持它们,你可以跳过这一部分。

代理对

所有常用的字符都是一个 2 字节的代码。大多数欧洲语言,数字甚至大多数象形文字中的字母都有 2 字节的表示形式。

但 2 字节只允许 65536 个组合,这对于表示每个可能的符号是不够的。所以稀有的符号被称为“代理对”的一对 2 字节的符号编码。

这些符号的长度是 2

javascript
alert( '𝒳'.length ); // 2,大写数学符号 X
alert( '😂'.length ); // 2,笑哭表情
alert( '𩷶'.length ); // 2,罕见的中国象形文字

注意,代理对在 JavaScript 被创建时并不存在,因此无法被编程语言正确处理!

我们实际上在上面的每个字符串中都有一个符号,但 length 显示长度为 2

String.fromCodePointstr.codePointAt 是几种处理代理对的少数方法。它们最近才出现在编程语言中。在它们之前,只有 String.fromCharCodestr.charCodeAt。这些方法实际上与 fromCodePoint/codePointAt 相同,但是不适用于代理对。

获取符号可能会非常麻烦,因为代理对被认为是两个字符:

javascript
alert( '𝒳'[0] ); // 奇怪的符号……
alert( '𝒳'[1] ); // ……代理对的一块

请注意,代理对的各部分没有任何意义。因此,上述示例中的 alert 显示的实际上是垃圾信息。

技术角度来说,代理对也是可以通过它们的代码检测到的:如果一个字符的代码在 0xd800..0xdbff 范围内,那么它是代理对的第一部分。下一个字符(第二部分)必须在 0xdc00..0xdfff 范围中。这些范围是按照标准专门为代理对保留的。

在上述示例中:

javascript
// charCodeAt 不理解代理对,所以它给出了代理对的代码
alert( '𝒳'.charCodeAt(0).toString(16) ); // d835,在 0xd800 和 0xdbff 之间
alert( '𝒳'.charCodeAt(1).toString(16) ); // dcb3, 在 0xdc00 和 0xdfff 之间

本章节后面的 Iterable object(可迭代对象) 章节中,你可以找到更多处理代理对的方法。可能有专门的库,这里没有什么足够好的建议了。

变音符号与规范化

在许多语言中,都有一些由基本字符组成的符号,在其上方/下方有一个标记。

例如,字母 a 可以是 àáâäãåā 的基本字符。最常见的“复合”字符在 UTF-16 表中都有自己的代码。但不是全部,因为可能的组合太多。

为了支持任意组合,UTF-16 允许我们使用多个 Unicode 字符:基本字符紧跟“装饰”它的一个或多个“标记”字符。

例如,如果我们 S 后跟有特殊的 “dot above” 字符(代码 \u0307),则显示 Ṡ。

javascript
alert( 'S\u0307' ); // Ṡ

如果我们需要在字母上方(或下方)添加额外的标记 —— 没问题,只需要添加必要的标记字符即可。

例如,如果我们追加一个字符 “dot below”(代码 \u0323),那么我们将得到“S 上面和下面都有点”的字符:Ṩ

例如:

javascript
alert( 'S\u0307\u0323' ); // Ṩ

这在提供良好灵活性的同时,也存在一个有趣的问题:两个视觉上看起来相同的字符,可以用不同的 Unicode 组合表示。

例如:

javascript
let s1 = 'S\u0307\u0323'; // Ṩ,S + 上点 + 下点
let s2 = 'S\u0323\u0307'; // Ṩ,S + 下点 + 上点
alert( `s1: ${s1}, s2: ${s2}` );
alert( s1 == s2 ); // false,尽管字符看起来相同(?!)

为了解决这个问题,有一个 “Unicode 规范化”算法,它将每个字符串都转化成单个“通用”格式。

它由 str.normalize() 实现。

javascript
alert( "S\u0307\u0323".normalize() == "S\u0323\u0307".normalize() ); // true

有趣的是,在实际情况下,normalize() 实际上将一个由 3 个字符组成的序列合并为一个:\u1e68(S 有两个点)。

javascript
alert( "S\u0307\u0323".normalize().length ); // 1
alert( "S\u0307\u0323".normalize() == "\u1e68" ); // true

事实上,情况并非总是如此,因为符号 Ṩ 是“常用”的,所以 UTF-16 创建者把它包含在主表中并给它了对应的代码。

XHR & Fetch

XHR

创建 XHR 对象
js
cosnt xhr = new XMLHttpRequest()
xhr.open()

传入 请求类型URL是否异步(默认 true )

js
xhr.open('get', 'https://hong97.ltd')
xhr.send()

传入请求体,如果没有,默认传入 null

js
xhr.send(null)
readyState 属性
状态名描述
0UNSENT代理被创建,但尚未调用 open() 方法。
1OPENEDopen() 方法已经被调用。
2HEADERS_RECEIVEDsend() 方法已经被调用,并且头部和状态已经可获得。
3LOADING正在下载数据;responseText 属性已经包含部分数据。
4DONE下载操作已完成(请求已完成,无论成功或失败)。
异步回调
js
xhr.onreadystatechange = function() {
	if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
        // more actions
    }
}
设置头部
js
xhr.setRequestHeader('header', 'value')

Fetch

语法
js
fetch(url: string, option: object)
与 jQuery.ajax() 的不同
  • fetch 即便收到如 4xx, 5xx 的返回,也不会返回一个 rejected 的 promise;只有网络故障时,才返回 rejected 的 promise
  • fetch 不跨域发送 cookie,除非设置 credentials 属性

Generators(生成器)

Generator(生成器)函数 是一种可以中途暂停执行、再恢复执行的函数。 它能把函数的执行过程分段,每次调用 .next() 就从上次暂停的地方继续执行。

👉 它是 ES6 引入的一种异步控制流方案(在 async/await 之前)。


✨ 二、语法形式

Generator 用特殊语法定义:

function* gen() {
  console.log('start');
  yield 1;
  console.log('middle');
  yield 2;
  console.log('end');
  return 3;
}

调用时:

const g = gen();  // 仅创建,不执行

console.log(g.next()); // { value: 1, done: false }
console.log(g.next()); // { value: 2, done: false }
console.log(g.next()); // { value: 3, done: true }

🧠 三、关键特性

特性说明
function*声明生成器函数
yield暂停函数执行,并返回一个值
.next()恢复执行,继续到下一个 yield
.next(value)可以向上一次暂停处“传值”
return提前结束生成器
throw在生成器内部抛出异常

👀 举个例子:yield 传值机制

function* demo() {
  const a = yield 1;
  console.log('a:', a);
  const b = yield 2;
  console.log('b:', b);
}

const g = demo();

console.log(g.next());       // { value: 1, done: false }
console.log(g.next('X'));    // a: X
                             // { value: 2, done: false }
console.log(g.next('Y'));    // b: Y
                             // { value: undefined, done: true }

注意:yield 暂停时,下一次调用 .next(value) 的参数会作为上一个 yield 表达式的返回值。


🔄 四、Generator 与异步编程

Generator 可以配合 Promise 使用,实现异步流程控制。 例如:

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => resolve('数据来了'), 1000);
  });
}

function* main() {
  const result = yield fetchData();
  console.log(result);
}

// 手动执行器
const it = main();
const p = it.next().value;
p.then((res) => it.next(res));

这段代码通过 yield 暂停函数执行,等异步任务完成后再恢复。

五、co 库与自动执行器

手动 .next() 很麻烦,社区出现了一个著名的工具函数 co,能自动执行生成器:

const co = require('co');

function* task() {
  const res = yield fetchData();
  console.log(res);
}

co(task);

它能自动识别 yield 返回的是 Promise,然后等待其完成后自动 .next()

可以控制函数的执行,yield 暂停函数,next() 启动执行

js
function* foo() {
    yield 'hello'
    yield 'world'
    return 'end'
}

const f = foo()
console.log(f.next())	// {value: 'hello', done: false}
console.log(f.next()) // {value: 'world', done: false}
console.log(f.next())	// {value: 'end', done: true}

Event Loop 事件循环

代码分同步代码和异步代码,而异步代码分为:

  • 宏任务:script, setTimeout, setInterval,由宿主环境发起的任务
  • 微任务:Promise, await,由 JS 引擎本身发起的任务

另外,还需要知道:

  • setTimeout 属于宏任务
  • Promise 本身是同步的立即执行函数,Promise.then 属于微任务
  • async 方法执行时,遇到 await 会立即执行表达式,表达式之后的代码放到微任务执行
  • 微任务优先级大于宏任务

顺序

  • 先同后异
  • 先微后宏

image_2

js
setTimeout(() => { 
    console.log('timeout callback1()')
    Promise.resolve(3).then(
        value => { 
            console.log('Promise onResolved3()', value)
        }
    )
}, 0)

setTimeout(() => { 
    console.log('timeout callback2()')
}, 0)

Promise.resolve(1).then(
    value => { 
        console.log('Promise onResolved1()', value)
        setTimeout(() => {
            console.log('timeout callback3()', value)
        }, 0)
    }
)

Promise.resolve(2).then(
    value => { 
        console.log('Promise onResolved2()', value)
    }
)

// Promise onResolved1() 1
// Promise onResolved2() 2
// timeout callback1()
// Promise onResolved3() 3
// timeout callback2()
// timeout callback3() 1

image-20251025143137937

Prototype 原型

js
Object.create = function(o) {
    function fn() {}
    fn.prototype = o
    // 返回一个新对象,这个对象的 [[prototype]] 链接到了 o
    return new fn()
}

概念

JS 通过原型链来实现继承,而在集成时,不会复制父类属性,还是在子类和父类之间创建一个关联。

image_3

原型链

当我们访问一个属性的时候,如果没有,就会顺着 __proto__ 原型链一路向上找。如果找到 Object.prototype 还是没有找到,就返回 undefined

prototype proto

  • 函数都有 prototype
  • 对象都有 __proto__
  • 所用函数都由 Function 创建而来,所以它们的 proto 都是 Function.prototype
  • Function.protype = Function.proto

解决原型链污染问题

我们可以覆盖掉对象原型上的属性,如下:

js
Object.prototype.toString = function() {
    alert('Hello World')
}
const obj = {}
obj.toString() 
解决方法
  1. Object.create(null),将对象的原型设置为 null
js
const obj = Object.create(null)
  1. Object.freeze(obj),使对象的属性不可修改

ES5 继承方式

原型链继承

将子类的原型指向父类的实例

js
function SuperType() {
    this.foo = 'bar'
}
function SubType() {}
// 继承
SubType.prototype = new SuperType()

他自己原型链上的东西不要了?比如构造函数constructor,还有使用的是new,副作用很大啊

盗用构造函数

子类中使用 call 方法,将 this 绑定到父类上

js
function SuperType() {
    this.foo = 'bar'
}

function SubType() {
    SuperType.call(this)
}
console.log(new SubType().foo)  // bar

这个没有继承原型链

组合继承

原型链 + 盗用

js
function SuperType() {
    this.foo = 'bar'
}

SuperType.prototype.output = function() {
    console.log(this.foo)
}

function SubType() {
    SuperType.call(this)            // 继承属性
}

SubType.prototype = new SuperType()   // 集成方法

const subType = new SubType()
console.log(subType.foo)            // 访问父级属性 bar
subType.output()                    // 访问父级方法 bar5

使用的是new继承原型链,副作用很大,而且貌似属性构造了两份

寄生组合继承
js
function SuperType() {
}
function SubType() {
    SuperType.call(this)    // 继承父类属性
}
SubType.prototype = Object.create(SuperType.prototype)	// 取得父类的一个副本
SubType.prototype.constructor = SubType	//还原本身构造函数

什么是原型

[[Prototype]]

JavaScript 中几乎所有对象都有一个特殊的 [[Prototype]] 属性它其实就是对另一个对象的引用。

仔细体会上面这句话:每个对象都有个 [[Prototype]] 属性,它指向的其实就是一个对象

这个对象和其他普通对象一样,并没有什么神奇的地方!

因此,不要被所谓“原型继承”的概念误导,更不要把原型和面向对象中的“类”混为一谈!(他们完全不是一个东西,也不要借用面向对象的想法来理解原型)

[[Prototype]] 的作用就是当触发 [[Get]] 操作时,如果在对象本身找不到对应的属性,就会顺着原型链一路寻找上去,这种方式称为“委托”,在后文会详细展开。

js
var obj = Object.create({
    a: 0
})

// a 并不在 obj 上,而是在 obj 的原型上
// 但是他能“正确”地找到 0
obj.a   // 0

当遍历完整条原型链都没有找到对应属性时,返回 undefined

在使用 for ... in 时也会遍历整条原型链(当然确保属性的 enmerabletrue

js
var obj = Object.create({
    b: 0
})

obj.a = 1

for (var k in obj) {
    console.log(k)  // a b
}

原型链的尽头

所有普通的原型链的尽头就是 Object.prototype ,这个对象内有许多通用的方法,如 toString() valueOf() 等…

当然,Object.prototype[[Prototype]]null

属性屏蔽

当给一个对象设置属性时,并没有想象中的简单。而是与该对象所关联的原型链有关。

对于一条的简单赋值语句 obj.a = 0 来说,可以分为三种情况:

  • 如果对象中已经存在该属性,则修改该属性
  • 如果对象中不存在该属性,并且整条原型链上也不存在;那么属性就会被添加到对象上面
  • 如果对象中不存在,但原型链上存在同名属性;那么情况就会变得非常复杂,下面展开细说

对于对象中不存在某一属性,但原型链上却存在同名属性的情况;尝试设置某一属性也许会得到意想不到的结果:

  1. 如果原型链上的同名属性的 writable 不为 false ,则就在该对象上添加这一属性并对原型链上的同名属性形成遮蔽
  2. 如果同名属性的 writablefalse ,那么即不能修改这个同名属性,也不能在对象本上创建一个屏蔽属性(默认静默,严格模式抛出 TypeError)
  3. 如果原型链上存在一个同名 setter ,那么就一定会调用这个 setter ,而不会将这个属性添加到对象自身上

可见,对于上述的三种情况,只有在属性不是只读的情况下,并且不存在同名 setter 时,才能正确的进行属性屏蔽。

js
var foo = {}

// foo.bar 是一个只读属性
Object.defineProperty(foo, 'bar', {
    value: 0,
    writable: false
})

var obj = Object.create(foo)

// 静默处理
obj.bar = 1

image-20251024142535801

其实对于第 2 条,如果改用 defineProperty() 而不是使用 = 进行属性设置,则不会有这条限制。

image-20251024142756431

原型 vs 类?

千万不要把原型和类混为一谈,JavaScript 完全没有所谓”类“的概念;也没有所谓的实例化、类构造函数等概念。

JavaScript 中一些关键词如 newprototype.constructor 非常有误导性!不要用其他面向对象的语言的概念来对照这理解这些关键词

然而,多年以来社区一直尝试用各种手段来“模拟”类的行为,具体如下:

函数 prototype 对象

函数有个特性:所有的函数默认都拥有一个名为 prototype 的公有不可枚举属性,他会指向另一个对象。而一个普通的对象,并不会自带这个 prototype 属性。

image-20251024143209673

也就是说,一个函数,他默认就“附赠”一个对象;即 .prototype 所指向的那个

这个对象通常叫做 “Foo 的原型” ;这又造成了极大的误导!他只是一个普通对象,和原型链其实没有实际的关联。

只是我们通常会把某一个对象的 [[prototype]] 链接到这个对象上而已;又或者是在 new 调用时会将它所返回的对象的 [[prototype]] 链接到函数的 prototype 对象上:

js
function foo() {}
// 使用 new 调用会返回一个新对象
// 这个对象的 [[prototype]] 会链接到 foo.prototype 上
const bar = new foo()
Object.getPrototypeOf(bar) === foo.prototype    // true 

其实将 [[Prototype]] 链接到 prototype 对象上不过是 new 的一个副作用;可以使用 Object.create() 来更加直接显式的将两个对象“关联”起来(后文详细展开)

constructor 属性

函数prototype 对象中默认还有一个 constructor 属性,他在函数声明时自动创建。

而这个 constructor 又指向所关联的函数:

js
function foo() {}

foo.prototype.constructor === foo   // true

紧接着,观察下面代码:

js
function foo() {}

// new 调用自动将 bar 的 [[Prototype]] 链到 foo.prototype
var bar = new foo()

// 所以,bar 本身并没有 constructor
// 他是顺着原型链找到了上一级 foo.protype
bar.constructor === foo.prototype.constructor   // true

上面的这种写法,配合着 newconstructor 让我们很容易和面向对象中类的相似概念混淆在一起

其实,bar.constructor 不过是顺着原型链查找到的;而且原型链的创建也是因为 new 的一个副作用而已

长久以来社区为了尽可能的实现“模拟类”的行为,甚至有一种约定: new 调用的函数名必须首字母大写

然而,无论他是否是大写,它本质上就是一个函数。new 也不过只是一种特殊的调用方式而已!

不要把 constructor 理解为 “构造”,这个属性完全不能正确地 ”标识一个对象是否由另一个构造”;参照以下代码:

js
function foo() {}

foo.prototype = {}

var bar = new foo()

bar.constructor    // f Object()

虽然 bar 是通过 foo() ”new 出来的“ ,但是 barconstructor 属性并不指向 foo() ,反而是指向 Object() (通过原型委托)

image-20251024160223290

千万不要有这样错误地认识!

原型和类的区别

在面向对象的语言中,类本质上就是进行“复制”。

然而,在 JavaScript 当中,尽管可以使用原型来模拟“类”的行为,但是一个最大的区别就是:原型并没有复制操作,仅仅是用委托关联到了另一个对象之上。

典型的“原型风格”

下面的代码是一种典型的使用原型来模拟类的行为:

js
function Foo(name) {
    this.name = name
}

Foo.prototype.myName = function() {
    return this.name
}

function Bar(name, label) {
    Foo.call(this, name)    // 盗用构造
    this.label = label
}

// Object.create() 创建一个新对象
// 并且把这个对象的 [[prototype]] 链接到指定对象
Bar.prototype = Object.create(Foo.prototype)

// 修复丢失的 constructor 属性(如果你需要的话)
// 不然他会“错误地”指向 Foo
Bar.prototype.constructor = Bar

Bar.prototype.myLabel = function() {
    return this.label
}

var a = new Bar('a', 'obj a')

a.myName()  // a
a.myLabel() // obj a

image-20251024161022534

恢复constructor

image-20251024161239718

这与最初的constructor一致:

image-20251024161355604

其实结果和使用:Bar.prototype.__proto__ = Foo.prototype一致

差异点Object.create(Foo.prototype)Bar.prototype.__proto__ = Foo.prototype
新对象创建创建了一个全新的对象,并把其 [[Prototype]] 指向 Foo.prototype直接修改现有对象的原型指向
constructor 丢失会丢失 constructor 属性,需要手动修复不会改变原对象的 constructor
更安全、更规范✅ ECMAScript 标准写法,兼容性好⚠️ __proto__ 是非标准历史遗留属性(虽被支持但不推荐)
潜在副作用不会影响已有引用(每个prototype都是新创建的)直接修改现有对象的原型链,可能造成副作用或性能问题(比如影响已有实例)。
js
function Bar() {}
Bar.prototype.someValue = 42;

// 方式1
Bar.prototype = Object.create(Foo.prototype);
console.log(Bar.prototype.someValue); // undefined(因为新对象了)

// 方式2
Bar.prototype.__proto__ = Foo.prototype;
console.log(Bar.prototype.someValue); // 42(原对象没丢)
也就是说:

//Object.create 会新建一个干净的对象;
//proto 只是改了链,不会丢掉原本的属性。

注意,下面的两种写法都是错误的:

js
// 只是把 Bar 的 prototype 指向了 Foo 的 prototype
// 而并非创建一个新对象并且链接 [[prototype]]
Bar.prototype = Foo.prototype
// 这不是继承,这是覆盖

// 基本上正确,的确创建了新对象并且链接了 [[prototype]]
// 但是 new 本质上是一次 “函数调用”;因此在函数内部可能会执行一些不想要的 “副作用”
Bar.prototype = new Foo()

如何链接 [[prototype]]

可以通过以下几种方式来将一个对象的 [[prototype]] 链接到另一个对象:

  • Object.create()(创建一个新对象,链接到目标对象)
  • 使用 .__proto__ 来设置
  • Object.setPrototypeOf()

例如对于上面 “模拟类” 的实现代码中,可以使用 setPrototypeOf() 来替换原先的 Object.create() 写法:

js
// 可以将这种写法替换为下面这种:
Bar.prototype = Object.create(Foo.prototype)
Object.setPrototypeOf(Bar.prototype, Foo.prototype)

第二种做法的 “好处” 就是:不会因为 Object.create 创建了一个 ”新对象“ 从而覆盖掉了默认的 prototype 对象,还需要重新 “修复” constructor 属性;并且被覆盖的对象还需要被 GC。

image-20251025132833293

不过被上面这种写法过于限制思想,为什么一定要模拟类的行为?

为什么一定要将一个对象的 **prototype** 对象的 **[[prototype]]** 链接到另一个对象的 **prototype** 对象上面?为什么不能直接将一个对象 “关联” 到另一个对象上面?

在后文中,会展开介绍一种新的 “面向原型” 的设计方式

检查原型链

instanceof

instanceof 操作符左边是 “对象”,右边是 “函数”;它检查的是:

在这个 “对象” 的整条原型链上,是否有指向该 “函数” 的 prototype 对象?

function foo() {}

const bar = new foo()

bar instanceof foo  // true

instanceof 只能检查一个对象和一个函数的 prototype 对象之间的关联,**实属有点丑陋!**而且又会给人一种 “类” 的错觉

使用 bind() 方法返回的包装函数,不会有 prototype 属性;如果对该包装函数使用 instanceof ,实际上会使用 bind() 的目标函数的 prototype 属性。

Object.prototype.isPrototypeOf()

你不能通过 instanceof 关键字来**直接判断两个对象之间的关系;**如果硬要实现,可以通过下面这种丑陋的方式:

js
var obj1 = {}
var obj2 = {}

// obj1 的 [[prototype]] 链接到了 obj2 
Object.setPrototypeOf(obj1, obj2)

// 创建一个临时函数
// 并设置它的 prototype 为需要检测的对象
function foo() {}
foo.prototype = obj2

// 借用这个临时函数就可以判断 obj1 的 [[prototype]] 是否链接到了 obj2
obj1 instanceof foo    // true

借助 Object.prototype.isPrototypeOf() 就可以实现:

js
obj2.isPrototypeOf(obj1)    // true

它检查的是:目标对象 (obj1 )的原型链上,是否存在自己 (obj2)

Object.getPrototypeOf()

我们可以使用标准的 Object.getPrototypeOf() 来检查一个对象的 [[prototype]] 属性,从而避免使用 .__proto__ 这样的写法:

Object.getPrototypeOf(obj1) === obj2    // true

讲一下new的流程和原理

  • 创建一个新对象
    • 首先,JavaScript 引擎会创建一个新的空对象,并且这个对象会继承构造函数的 prototype 属性。此时,已经建立了原型链。
    • 这个新对象类似于 let obj = Object.create(Constructor.prototype);
  • 绑定 this
    • 接下来,构造函数会被执行,并且在构造函数内部,this 被绑定到新创建的对象上。换句话说,构造函数中的 this 现在指向这个新对象。
    • 这个绑定发生在构造函数执行时,this 的绑定是隐式的。
  • 设置属性
    • 在构造函数中,可以通过 this 来给新创建的对象添加属性和方法。例如,this.name = name; 这样就把属性 name 绑定到了新对象上。
    • 这些属性的设置也是在构造函数执行时完成的。
  • 返回新对象
    • 默认情况下,构造函数会返回新创建的对象。如果构造函数显式返回了一个对象,那么返回的将是这个对象而不是默认的新对象。如果显式返回的不是对象(例如返回一个基本类型),那么返回的还是默认的新对象。

new的属性设置是在什么时候绑定的

在构造函数内部,this 被绑定到新创建的对象上。换句话说,构造函数中的 this 现在指向这个新对象。

this

call apply bind

js
function Base(foo) {
    this.foo = foo
}
function Child(foo) {
    Base.call(this, foo)	// 可以理解成继承,不过是把 this 替换成了 Child 的了
}
const child = new Child('foo')
相同

改变函数内部的 this 指向

区别

call,apply 会调用函数;而 bind 不会调用

call 接受若干参数,apply 接受一整个数组

应用
  • call 多用作继承

  • apply 多用于跟数组有关的操作,如求 max, min

js
const arr = [1, 22, -10, 0, 9]
const max = Math.max.apply(Math, arr)
console.log(max)		// 22
// 利用 ... 运算符也可以
const max = Math.max(...arr)
Math.max.call(Math, ...arr)
  • bind 不调用函数,但会改变内部 this
js
x = 0       // global
const foo = {
    x: 10,  // foo
    getX: function() { return this.x }
}

console.log(foo.getX())     // 10

const retrieveX = foo.getX
console.log(retrieveX())    // 0,函数里的 this 实际指向的是 global

// 注意 bind 返回的才是绑定过的对象
const boundGetX = retrieveX.bind(foo)
console.log(boundGetX())    // 10, 函数里的 this 重新指向了 foo

this 指向

普通函数中的 this

谁调用了这个函数,this就指向谁

js
function foo() {
    console.log(this)
}
const obj = {
    bar: function() {
        console.log(this)
    }
}
foo()       // window,因为 foo 是在全局作用于调用的
obj.bar()   // obj
匿名函数的 this

匿名函数的 this 具有全局性,指向 window

js
const obj = {
    bar: function () {
        return function () { console.log(this) }
    }
}
obj.bar()()   // window
箭头函数的 this
  • 箭头函数的 this 是在定义时候确定下来的,而不是在调用时决定的

  • this 指向父级作用域的上下文

如何寻找箭头函数的 this 指向?

找到离箭头函数最近的function,与该function平级的执行上下文中的this即是箭头函数中的this

  • 箭头函数无法使用 apply,call,bind 改变 this 指向
js
const obj = {
    bar: function () {
        return () => console.log(this)
    }
}
obj.bar()()   // obj

了解 Proxy、Reflect 元编程。

js异步

async和await要如何实现

Async 和 await 是generator 的语法糖。

async / awaitPromise 的语法糖不是 generator 的语法糖,但它的设计受到了 generator 的启发

一、从实现角度看:

async / await 在底层就是基于 Promise 来实现的。

例如:

async function foo() {
  return 1;
}

实际上等价于:

function foo() {
  return Promise.resolve(1);
}

再比如:

async function foo() {
  const res = await bar();
  console.log(res);
}

等价于(简化示意):

function foo() {
  return Promise.resolve()
    .then(() => bar())
    .then(res => console.log(res));
}

✅ 所以它依赖的是 Promise 的 then 链机制

js
function* generator(){
    const a = yield request(1);
    console.log(b);
}

const gen = generator();
gen.next().value.then(xxxxx)

setTimeout和setInterval 有什么区别,他们有什么问题,为什么

区别:setTimeout 是delay后执行一次, setInterval是每 delay执行一次

问题:会出现偏差

setTimeOutsetInterVal中时间参数并不是到点就立即执行,而是到点将其回调事件加入事件队列中。按照队列先进先出的性质,该回调事件到点之后是否能执行取决于是否属于队列首位,如果前头还有其他事件在等待,则不能按点执行

setTimeout 和 setInterval 要怎么校对?

倒计时纠偏,使用setTimeout 模拟 setInterval

js
const interval = 1000
let ms = 50000,  // 从服务器和活动开始时间计算出的时间差,这里测试用 50000 ms
let count = 0
const startTime = new Date().getTime()
let timeCounter
if( ms >= 0) {
  timeCounter = setTimeout(countDownStart, interval)
}

function countDownStart () {
   count++
   const offset = new Date().getTime() - (startTime + count * interval) // A
   let nextTime = interval - offset
   if (nextTime < 0) {
       nextTime = 0
   }
   ms -= interval
   console.log(`误差:${offset} ms,下一次执行:${nextTime} ms 后,离活动开始还有:${ms} ms`)
   if (ms < 0) {
     clearTimeout(timeCounter)
   } else {
     timeCounter = setTimeout(countDownStart, nextTime)
   }
 }

评论

评论加载中……