JavaScript 从入门到放弃
2023-08-13 17:28:10 # Language # JavaScript

JAVASCRIPT 编程语言

1. 基础知识

1.1 Hello,world!

一般来说,只有最简单的脚本才嵌入到 HTML 中。更复杂的脚本存放在单独的文件中。

使用独立文件的好处是浏览器会下载它,然后将它保存到浏览器的缓存中。

之后,其他页面想要相同的脚本就会从缓存中获取,而不是下载它。所以文件实际上只会下载一次。

这可以节省流量,并使得页面(加载)更快。

  • 如果设置了 src 特性,script 标签内容将会被忽略。

    • 一个单独的 <script> 标签不能同时有 src 特性和内部包裹的代码。
  • typelanguage 特性(attribute)不是必需的。

  • 外部的脚本可以通过 <script src="path/to/script.js"></script> 的方式插入。

1.2 代码结构

  • 分号

    • 当存在换行符(line break)时,在大多数情况下可以省略分号。JavaScript 将换行符理解成“隐式”的分号。这也被称为 自动分号插入

    • 但存在 JavaScript 无法确定是否真的需要自动插入分号的情况。

      1
      2
      3
      4
      5
      alert("Hello")

      [1, 2].forEach(alert);

      //被视为alert("Hello")[1, 2].forEach(alert); 只有第一个 Hello 会被显示出来,并且有一个报错
  • 不支持注释嵌套!

1.3 现代模式,”use strict”

ES5 规范增加了新的语言特性并且修改了一些已经存在的特性。为了保证旧的功能能够使用,大部分的修改是默认不生效的。需要一个特殊的指令 —— "use strict" 来明确地激活这些特性。

当它处于脚本文件的顶部时,则整个脚本文件都将以“现代”模式进行工作。

1
2
"use strict";
...

"use strict" 可以被放在函数体的开头。这样则可以只在该函数中启用严格模式。但通常人们会在整个脚本中启用严格模式。

  • 确保 “use strict” 出现在最顶部

    • 只有注释可以出现在 "use strict" 的上面。
  • 没有办法取消 use strict

  • 现代 JavaScript 支持 “class” 和 “module” —— 高级语言结构,它们会自动启用 use strict。因此,如果我们使用它们,则无需添加 "use strict" 指令。

变量

  • 我们可以使用 varletconst 声明变量来存储数据。

    • let — 现代的变量声明方式。
    • var — 老旧的变量声明方式。一般情况下,我们不会再使用它。
    • const — 类似于 let,但是变量的值无法被修改。

    变量应当以一种容易理解变量内部是什么的方式进行命名。

  • 未采用 use strict 下的赋值

    • 一般,我们需要在使用一个变量前定义它。但是在早期,我们可以不使用 let 进行变量声明,而可以简单地通过赋值来创建一个变量。现在如果我们不在脚本中使用 use strict 声明启用严格模式,这仍然可以正常工作,这是为了保持对旧脚本的兼容。

      1
      2
      3
      4
      5
      // 注意:这个例子中没有 "use strict"

      num = 5; // 如果变量 "num" 不存在,就会被创建

      alert(num); // 5
    • 上面这是个糟糕的做法,严格模式下会报错。

      1
      2
      3
      "use strict";

      num = 5; // 错误:num 未定义

1.5 数据类型

JavaScript 中有八种基本的数据类型(译注:前七种为基本数据类型,也称为原始类型,而 object 为复杂数据类型)。

Number 类型

  • 用于任何类型的数字:整数或浮点数,在 $\pm (2^{53}-1)$ 范围内的整数。

  • 特殊数值(special numeric values)也属于这种类型:Infinity-InfinityNaN

    • 可以通过除以 0 来得到它, 或者在代码中直接使用它:

      1
      2
      alert( 1 / 0 ); // Infinity
      alert( Infinity ); // Infinity
    • NaN 代表一个计算错误。它是一个不正确的或者一个未定义的数学操作所得到的结果,比如:

      1
      alert( "not a number" / 2 ); // NaN,这样的除法是错误的
    • NaN 是粘性的。任何对 NaN 的进一步数学运算都会返回 NaN

      1
      2
      3
      4
      alert( NaN + 1 ); // NaN
      alert( 3 * NaN ); // NaN
      alert( "not a number" / 2 - 1 ); // NaN
      // 只有一个例外:NaN ** 0 (幂) 结果为 1

Bigint 类型

  • 用于任意长度的整数。

  • “number” 类型无法表示大于 $(2^{53}-1)$ (即 9007199254740991),或小于 $-(2^{53}-1)$ 的整数

  • 更准确的说,“number” 类型可以存储更大的整数(最多 1.7976931348623157 * $10^{308}$),但超出安全整数范围 $±(2^{53}-1)$ 会出现精度问题,因为并非所有数字都适合固定的 64 位存储。因此,可能存储的是“近似值”。

  • 可以通过将 n 附加到整数字段的末尾来创建 BigInt 值。

    1
    2
    // 尾部的 "n" 表示这是一个 BigInt 类型
    const bigInt = 1234567890123456789012345678901234567890n;

String 类型

  • 用于字符串:一个字符串可以包含 0 个或多个字符,所以没有单独的单字符类型。

  • JavaScript 中的字符串必须被括在引号里。

    1
    2
    3
    let str = "Hello";
    let str2 = 'Single quotes are ok too';
    let phrase = `can embed another ${str}`;
  • 双引号和单引号都是“简单”引用,在 JavaScript 中两者几乎没有什么差别。

  • 反引号是 功能扩展 引号。它们允许我们通过将变量和表达式包装在 ${...} 中,来将它们嵌入到字符串中。

  • JavaScript 中没有 character 类型。

Boolean 类型

  • 用于 truefalse

null 值

  • 用于未知的值: 只有一个 null 值的独立类型
  • 不是一个 “对不存在的 object 的引用” 或者 “null 指针”

undefined 值

  • 用于未定义的值: 只有一个 undefined 值的独立类型。

  • 通常,使用 null 将一个“空”或者“未知”的值写入变量中,而 undefined 则保留作为未进行初始化的事物的默认初始值

Symbol 类型

  • 用于创建对象的唯一标识符

Object 类型

  • 用于储存数据集合和更复杂的实体

typeof 运算符

  • 通常用作 typeof x,但 typeof(x) 也可行。

  • 以字符串的形式返回类型名称,例如 "string"

    1
    2
    3
    4
    5
    typeof undefined // "undefined"
    typeof Symbol("id") // "symbol"
    typeof Math // "object"
    typeof null // "object"
    typeof alert // "function"

typeof null 会返回 "object" —— 这是 JavaScript 编程语言的一个错误,实际上它并不是一个 object

在 JavaScript 语言中没有一个特别的 “function” 类型。函数隶属于 object 类型。但是 typeof 会对函数区分对待,并返回 function

1.6 交互:alert、prompt、confirm

alert

  • 显示信息。对 alert 的调用没有返回值。或者说返回的是 undefined

prompt

  • 显示信息要求用户输入文本。点击确定返回文本,点击取消或按下 Esc 键返回 null。以字符串形式返回
1
result = prompt(title, [default]);

confirm

  • 显示信息等待用户点击确定或取消。点击确定返回 true,点击取消或按下 Esc 键返回 false
1
let isBoss = confirm("Are you the boss?");

这些方法都是模态的:它们暂停脚本的执行,并且不允许用户与该页面的其余部分进行交互,直到窗口被解除。

上述所有方法共有两个限制:

  1. 模态窗口的确切位置由浏览器决定。通常在页面中心。
  2. 窗口的确切外观也取决于浏览器。我们不能修改它。

1.7 类型转换

有三种常用的类型转换:转换为 string 类型、转换为 number 类型和转换为 boolean 类型。

字符串转换 —— 转换发生在输出内容alert的时候,也可以通过 String(value) 进行显式转换。原始类型值的 string 类型转换通常是很明显的。

数字型转换 —— 转换发生在进行算术操作时,也可以通过 Number(value) 进行显式转换。

变成
undefined NaN
null 0
true / false 1 / 0
string “按原样读取”字符串,两端的空白字符会被忽略。空字符串变成 0。转换出错则输出 NaN

布尔型转换 —— 转换发生在进行逻辑操作时,也可以通过 Boolean(value) 进行显式转换。

变成
0, null, undefined, NaN, "" false
其他值 true

上述的大多数规则都容易理解和记忆。人们通常会犯错误的值得注意的例子有以下几个:

  • undefined 进行数字型转换时,输出结果为 NaN,而非 0
  • "0" 和只有空格的字符串(比如:" ")进行布尔型转换时,输出结果为 true

1.8 基础运算符,数学运算

+, -, *, /, %, **

1
2
alert( 2 ** 2 ); // 2² = 4
alert( 8 ** (1/3) ); // 2(1/3 次方与立方根相同)

用二元运算符 + 连接字符串

1
2
let s = "my" + "string";
alert(s); // mystring
  • 注意:只要任意一个运算元是字符串,那么另一个运算元也将被转化为字符串。
1
2
3
4
alert(2 + 2 + '1' ); // "41"
alert('1' + 2 + 2); // "122"
alert( 6 - '2' ); // 4,将 '2' 转换为数字
alert( '6' / '2' ); // 3,将两个运算元都转换为数字

数字转化,一元运算符 +

一元运算符加号,或者说,加号 + 应用于单个值,对数字没有任何作用。但是如果运算元不是数字,加号 + 则会将其转化为数字。它的效果和 Number(...) 相同,但是更加简短。

1
2
3
4
5
6
7
8
9
10
11
12
// 对数字无效
let x = 1;
alert( +x ); // 1

// 转化非数字
alert( +true ); // 1
alert( +"" ); // 0

let apples = "2";
let oranges = "3";
// 在二元运算符加号起作用之前,所有的值都被转化为了数字
alert( +apples + +oranges ); // 5

逗号运算符

  • 逗号运算符能让我们处理多个语句,使用 , 将它们分开。每个语句都运行了,但是只有最后的语句的结果会被返回。

    1
    2
    3
    let a = (1 + 2, 3 + 4);

    alert(a); // 7(3 + 4 的结果)

1.9 值的比较

  • 字符串的比较,会按照“词典”顺序逐字符地比较大小。

    • "a" 大于 "A"
  • 当对不同类型的值进行比较时,它们会先被转化为数字(不包括严格相等检查)再进行比较

    1
    2
    3
    4
    alert( '2' > 1 ); // true,字符串 '2' 会被转化为数字 2
    alert( '01' == 1 ); // true,字符串 '01' 会被转化为数字 1
    alert( true == 1 ); // true
    alert( false == 0 ); // true
  • 严格相等运算符 === 在进行比较时不会做任何的类型转换。

  • 在非严格相等 == 下,nullundefined 相等且各自不等于任何其他的值。

  • 在使用 > >=< <= 进行比较时,需要注意变量可能为 null/undefined 的情况。比较好的方法是单独检查变量是否等于 null/undefined

    • null/undefined 会被转化为数字:null 被转化为 0undefined 被转化为 NaN

奇怪的结果:null vs 0

1
2
3
alert( null > 0 );  // (1) false
alert( null == 0 ); // (2) false
alert( null >= 0 ); // (3) true
  • 这是因为相等性检查 == 和普通比较符 > < >= <= 的代码逻辑是相互独立的。
  • 进行值的比较时,null 会被转化为数字,因此它被转化为了 0

    • (3)中 null >= 0 返回值是 true
    • (1)中 null > 0 返回值是 false
  • 另一方面,undefinednull 在相等性检查 == 中不会进行任何的类型转换,它们有自己独立的比较规则,所以除了它们之间互等外,不会等于任何其他的值

    • (2)中 null == 0 会返回 false。

特立独行的 undefined

undefined 不应该被与其他值进行比较:

1
2
3
alert( undefined > 0 ); // false (1)
alert( undefined < 0 ); // false (2)
alert( undefined == 0 ); // false (3)
  • (1)(2) 都返回 false 是因为 undefined 在比较中被转换为了 NaN,而 NaN 是一个特殊的数值型值,它与任何值进行比较都会返回 false
  • (3) 返回 false 是因为这是一个相等性检查,而 undefined 只与 null 相等,不会与其他值相等。

1.10 逻辑运算符

或运算寻找第一个真值

1
result = value1 || value2 || value3;
  • 或运算将返回第一个真值,如果不存在真值,就返回该链的最后一个值

  • 获取变量列表或者表达式中的第一个真值

    1
    2
    3
    4
    5
    let firstName = "";
    let lastName = "";
    let nickName = "SuperCoder";

    alert( firstName || lastName || nickName || "Anonymous"); // SuperCoder
  • 短路求值(Short-circuit evaluation)

    • 有时,人们利用这个特性,只在左侧的条件为假时才执行命令

与运算寻找第一个假值

1
result = value1 && value2 && value3;
  • 与运算返回第一个假值,如果没有假值就返回最后一个值

与运算 && 在或运算 || 之前进行

  • 与运算 && 的优先级比或运算 || 要高

不要用 ||&& 来取代 if

!(非)

  • 两个非运算 !! 有时候用来将某个值转化为布尔类型:

    1
    2
    alert( !!"non-empty string" ); // true
    alert( !!null ); // false

    有更多详细的方法可以完成同样的事 —— 一个内建的 Boolean 函数:

    1
    2
    alert( Boolean("non-empty string") ); // true
    alert( Boolean(null) ); // false
  • 非运算符 ! 的优先级在所有逻辑运算符里面最高,所以它总是在 &&|| 之前执行。

1.11 空值合并运算符’??’

  • 当一个值既不是 null 也不是 undefined 时,我们将其称为“已定义的(defined)”。

    • a ?? b 的结果是:

      • 如果 a 是已定义的,则结果为 a
      • 如果 a 不是已定义的,则结果为 b
  • ?? 的常见使用场景是提供默认值。

    1
    2
    3
    4
    5
    6
    let firstName = null;
    let lastName = null;
    let nickName = "Supercoder";

    // 显示第一个已定义的值:
    alert(firstName ?? lastName ?? nickName ?? "匿名"); // Supercoder

||比较

  • 它们之间重要的区别是:

    • || 返回第一个 值。
    • ?? 返回第一个 已定义的 值。
  • 换句话说,|| 无法区分 false0、空字符串 ""null/undefined。它们都一样 —— 假值(falsy values)。如果其中任何一个是 || 的第一个参数,那么我们将得到第二个参数作为结果。

  • ?? 运算符的优先级与 || 相同

    • 这意味着,就像 || 一样,空值合并运算符在 =? 运算前计算,但在大多数其他运算(例如 +*)之后计算。
  • 出于安全原因,JavaScript 禁止将 ?? 运算符与 &&|| 运算符一起使用,除非使用括号明确指定了优先级。

1.12 循环:while和for

  • 禁止 break/continue 在 ‘?’ 的右边

    1
    (i > 5) ? alert(i) : continue; // 代码会停止运行,并显示有语法错误。
  • break/continue 标签

    • 标签 是在循环之前带有冒号的标识符:

      1
      2
      3
      labelName: for (...) {
      ...
      }
    • break <labelName> 语句跳出循环至标签处:

      1
      2
      3
      4
      5
      6
      7
      8
      outer: for (let i = 0; i < 3; i++) {
      for (let j = 0; j < 3; j++) {
      let input = prompt(`Value at coords (${i},${j})`, '');
      // 如果是空字符串或被取消,则中断并跳出这两个循环。
      if (!input) break outer; // (*)
      }
      }
      alert('Done!');

      我们还可以将标签移至单独一行:

      1
      2
      outer:
      for (let i = 0; i < 3; i++) { ... }
    • continue 指令也可以与标签一起使用。在这种情况下,执行跳转到标记循环的下一次迭代。

  • 标签并不允许“跳到”所有位置

    1
    2
    3
    break label;  // 跳转至下面的 label 处(无效)

    label: for (...)
    • break 指令必须在代码块内。从技术上讲,任何被标记的代码块都有效,例如:
    1
    2
    3
    4
    5
    label: {
    // ...
    break label; // 有效
    // ...
    }
    • continue 只有在循环内部才可行。

1.14 switch语句

  • switch/case 有通过 case 进行“分组”的能力,其实是 switch 语句没有 break 时的副作用。

  • 这里的相等是严格相等。被比较的值必须是相同的类型才能进行匹配。

1.15 函数

  • 作为参数传递给函数的值,会被复制到函数的局部变量。
  • 如果在函数内部声明了同名变量,那么函数会 遮蔽 外部变量。
  • 函数可以返回值。如果没有返回值或为空值,则其返回的结果是 undefined

参数(parameter)是函数声明中括号内列出的变量(它是函数声明时的术语)

参数(argument)是调用函数时传递给函数的值(它是函数调用时的术语)

  • 如果一个函数被调用,但有参数(argument)未被提供,那么相应的值就会变成 undefined
  • 我们可以使用 = 为函数声明中的参数指定所谓的“默认”(如果对应参数的值未被传递则使用)值:

不要在 return 与返回值之间添加新行

  • JavaScript 默认会在 return 之后加上分号。

1.16 函数表达式

  • 另一种创建函数的语法称为 函数表达式。它允许我们在任何表达式的中间创建一个新函数。

    1
    2
    3
    let sayHi = function() {
    alert( "Hello" );
    };
    • function 关键字后面没有函数名。函数表达式允许省略函数名。
  • 函数是值。它们可以在代码的任何地方被分配,复制或声明。

    • 我们还可以用 alert 显示这个变量的值:

      1
      2
      3
      4
      5
      6
      7
      function sayHi() {
      alert( "Hello" );
      }
      let func = sayHi; // 复制

      alert( sayHi ); // 显示函数源代码
      func(); // 执行
  • 回调函数

    • 函数需要提出 question(问题),并根据用户的回答,调用 yes()no()

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      function ask(question, yes, no) {
      if (confirm(question)) yes()
      else no();
      }

      function showOk() {
      alert( "You agreed." );
      }

      function showCancel() {
      alert( "You canceled the execution." );
      }

      // 用法:函数 showOk 和 showCancel 被作为参数传入到 ask
      ask("Do you agree?", showOk, showCancel);
    • ask 的两个参数值 showOkshowCancel 可以被称为 回调函数 或简称 回调

  • 匿名函数

    • 我们可以使用函数表达式来编写一个等价的、更简洁的函数:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      function ask(question, yes, no) {
      if (confirm(question)) yes()
      else no();
      }

      ask(
      "Do you agree?",
      function() { alert("You agreed."); },
      function() { alert("You canceled the execution."); }
      );
    • 这里直接在 ask(...) 调用内进行函数声明。这两个函数没有名字,所以叫 匿名函数。这样的函数在 ask 外无法访问(因为没有对它们分配变量)

  • 在执行代码块之前,内部算法会先处理函数声明。所以函数声明在其被声明的代码块内的任何位置都是可见的。

  • 函数表达式是在代码执行到达时被创建,并且仅从那一刻起可用。

  • 严格模式下,当一个函数声明在一个代码块内时,它在该代码块内的任何位置都是可见的。但在代码块外不可见。

    • 可以使用函数表达式,并将函数赋值给在 if 外声明的变量,并具有正确的可见性。

    • 或者可以使用问号运算符 ? 来进一步对代码进行简化:

1
2
3
4
5
6
7
let age = prompt("What is your age?", 18);

let welcome = (age < 18) ?
function() { alert("Hello!"); } :
function() { alert("Greetings!"); };

welcome(); // 现在可以了

在大多数情况下,当我们需要声明一个函数时,最好使用函数声明,因为函数在被声明之前也是可见的。这使我们在代码组织方面更具灵活性,通常也会使得代码可读性更高。

1.17 箭头函数,基础知识

创建函数还有另外一种非常简单的语法,并且这种方法通常比函数表达式更好。它被称为“箭头函数”

1
let func = (arg1, arg2, ..., argN) => expression;

这里创建了一个函数 func,它接受参数 arg1..argN,然后使用参数对右侧的 expression 求值并返回其结果。

  • 如果我们只有一个参数,可以省略掉参数外的圆括号,使代码更短

  • 如果没有参数,括号则是空的(但括号必须保留)

  • 多行的箭头函数

有时我们需要更复杂一点的函数,比如带有多行的表达式或语句。在这种情况下,我们可以使用花括号将它们括起来。主要区别在于,用花括号括起来之后,需要包含 return 才能返回值(就像常规函数一样)。

1
2
3
4
5
6
let sum = (a, b) => {  // 花括号表示开始一个多行函数
let result = a + b;
return result; // 如果我们使用了花括号,那么我们需要一个显式的 “return”
};

alert( sum(1, 2) ); // 3

2. Object(对象):基础知识

2.1 对象

可以通过使用带有可选 属性列表 的花括号 {…} 来创建对象。一个属性就是一个键值对(“key: value”),其中键(key)是一个字符串(也叫做属性名),值(value)可以是任何值。

  • 可以用下面两种语法中的任一种来创建一个空的对象:

    1
    2
    3
    4
    5
    6
    7
    let user = new Object(); // “构造函数” 的语法
    let user = {}; // “字面量” 的语法

    let user = { // 一个对象
    name: "John", // 键 "name",值 "John"
    age: 30 // 键 "age",值 30
    };
  • 可以使用点符号访问属性值:

    1
    2
    3
    // 读取文件的属性:
    alert( user.name ); // John
    alert( user.age ); // 30
  • 属性的值可以是任意类型,如添加一个布尔类型:

    1
    user.isAdmin = true;
  • 可以用 delete 操作符移除属性:

    1
    delete user.age;
  • 也可以用多字词语来作为属性名,但必须给它们加上引号:

    1
    2
    3
    4
    5
    let user = {
    name: "John",
    age: 30,
    "likes birds": true // 多词属性名必须加引号
    };
  • 对于多词属性,点操作就不能用了,另一种方法,就是使用方括号,可用于任何字符串:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    let user = {};

    // 设置
    user["likes birds"] = true;

    // 读取
    alert(user["likes birds"]); // true

    // 删除
    delete user["likes birds"];
  • 方括号同样提供了一种可以通过任意表达式来获取属性名的方式:

    1
    2
    3
    4
    let key = "likes birds";

    // 跟 user["likes birds"] = true; 一样
    user[key] = true;

    在这里,变量 key 可以是程序运行时计算得到的,也可以是根据用户的输入得到的。然后我们可以用它来访问属性。这给了我们很大的灵活性。

  • 当创建一个对象时,我们可以在对象字面量中使用方括号。这叫做 计算属性

    1
    2
    3
    4
    5
    6
    7
    let fruit = prompt("Which fruit to buy?", "apple");

    let bag = {
    [fruit]: 5, // 属性名是从 fruit 变量中得到的
    };

    alert( bag.apple ); // 5 如果 fruit="apple"
  • 我们可以在方括号中使用更复杂的表达式:

    1
    2
    3
    4
    let fruit = 'apple';
    let bag = {
    [fruit + 'Computers']: 5 // bag.appleComputers = 5
    };
    • 方括号比点符号更强大。它允许任何属性名和变量,但写起来也更加麻烦。

    • 所以,大部分时间里,当属性名是已知且简单的时候,就使用点符号。如果我们需要一些更复杂的内容,那么就用方括号。

  • 属性值简写

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function makeUser(name, age) {
    return {
    name: name,
    age: age,
    // ……其他的属性
    };
    }

    let user = makeUser("John", 30);
    alert(user.name); // John

    在上面的例子中,属性名跟变量名一样。这种通过变量生成属性的应用场景很常见,在这有一种特殊的 属性值缩写 方法,使属性名变得更短。

    可以用 name 来代替 name:name 像下面那样:

    1
    2
    3
    4
    5
    6
    7
    function makeUser(name, age) {
    return {
    name, // 与 name: name 相同
    age, // 与 age: age 相同
    // ...
    };
    }

    我们可以把属性名简写方式和正常方式混用:

    1
    2
    3
    4
    let user = {
    name, // 与 name:name 相同
    age: 30
    };
  • 属性命名没有限制。属性名可以是任何字符串或者 symbol

    • 其他类型会被自动地转换为字符串。

      1
      2
      3
      4
      5
      6
      7
      let obj = {
      0: "test" // 等同于 "0": "test"
      };

      // 都会输出相同的属性(数字 0 被转为字符串 "0")
      alert( obj["0"] ); // test
      alert( obj[0] ); // test (相同的属性)
    • 这里有个小陷阱:一个名为 __proto__ 的属性。我们不能将它设置为一个非对象的值:

      1
      2
      3
      let obj = {};
      obj.__proto__ = 5; // 分配一个数字
      alert(obj.__proto__); // [object Object] —— 值为对象,与预期结果不同
  • 属性存在性测试,”in”操作符

    • 相比于其他语言,JavaScript 的对象有一个需要注意的特性:能够被访问任何属性。即使属性不存在也不会报错!

    • 读取不存在的属性只会得到 undefined。所以我们可以很容易地判断一个属性是否存在:

      1
      2
      let user = {};
      alert( user.noSuchProperty === undefined ); // true: 没有这个属性
    • 检查属性是否存在的操作符 "in"

      1
      2
      3
      4
      let user = { name: "John", age: 30 };

      alert( "age" in user ); // true,user.age 存在
      alert( "blabla" in user ); // false,user.blabla 不存在。
    • 请注意,in 的左边必须是 属性名。通常是一个带引号的字符串。

      如果我们省略引号,就意味着左边是一个变量,它应该包含要判断的实际属性名。例如:

      1
      2
      3
      4
      let user = { age: 30 };

      let key = "age";
      alert( key in user ); // true,属性 "age" 存在
    • 为何会有 in 运算符呢?

      属性存在,但存储的值是 undefined 的时候:

      1
      2
      3
      4
      5
      6
      7
      let obj = {
      test: undefined
      };

      alert( obj.test ); // 显示 undefined,所以属性不存在?

      alert( "test" in obj ); // true,属性存在!
  • “for..in” 循环

    • 为了遍历一个对象的所有键(key),可以使用一个特殊形式的循环:for..in

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      let user = {
      name: "John",
      age: 30,
      isAdmin: true
      };

      for (let key in user) {
      // keys
      alert( key ); // name, age, isAdmin
      // 属性键的值
      alert( user[key] ); // John, 30, true
      }
  • 排序

    • 对象有顺序吗?如果我们遍历一个对象,我们获取属性的顺序是和属性添加时的顺序相同吗?

    • 整数属性会被进行排序,其他属性则按照创建的顺序显示。详情如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      let codes = {
      "49": "Germany",
      "41": "Switzerland",
      "44": "Great Britain",
      // ..,
      "1": "USA"
      };

      for(let code in codes) {
      alert(code); // 1, 41, 44, 49
      }

2.2 对象引用和复制

  • 对象通过引用被赋值和拷贝。赋值了对象的变量存储的不是对象本身,而是该对象“在内存中的地址” —— 换句话说就是对该对象的“引用”。因此,拷贝此类变量或将其作为函数参数传递时,所拷贝的是引用,而不是对象本身。

  • 所有通过被拷贝的引用的操作(如添加、删除属性)都作用在同一个对象上。

  • 仅当两个对象为同一对象时,两者才相等。=====

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    let user = {
    name: "John",
    age: 30
    };

    let clone = {}; // 新的空对象

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

    // 现在 clone 是带有相同内容的完全独立的对象
    clone.name = "Pete"; // 改变了其中的数据

    alert( user.name ); // 原来的对象中的 name 属性依然是 John
    • 我们也可以使用 Object.assign 方法来达成同样的效果。
  • 为了创建“真正的拷贝”(一个克隆),我们可以使用 Object.assign 来做所谓的“浅拷贝”(嵌套对象被通过引用进行拷贝)或者使用“深拷贝”函数,例如 _.cloneDeep(obj)

    • 语法是:

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

      1
      2
      3
      4
      5
      6
      7
      8
      9
      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 }
    • 如果被拷贝的属性的属性名已经存在,那么它会被覆盖:

      1
      2
      3
      4
      5
      let user = { name: "John" };

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

      alert(user.name); // 现在 user = { name: "Pete" }
    • 我们也可以用 Object.assign 代替 for..in 循环来进行简单克隆:

      1
      2
      3
      4
      5
      6
      let user = {
      name: "John",
      age: 30
      };

      let clone = Object.assign({}, user);
  • 深层克隆

    • 属性可以是对其他对象的引用。
    • 为了解决这个问题,我们应该使用一个拷贝循环来检查 user[key] 的每个值,如果它是一个对象,那么也复制它的结构。这就是所谓的“深拷贝”。
  • 使用 const 声明的对象也是可以被修改的

    通过引用对对象进行存储的一个重要的副作用是声明为 const 的对象 可以 被修改。

    1
    2
    3
    4
    5
    6
    7
    const user = {
    name: "John"
    };

    user.name = "Pete"; // (*)

    alert(user.name); // Pete
    • user 的值是一个常量,它必须始终引用同一个对象,但该对象的属性可以被自由修改。

    • 换句话说,只有当我们尝试将 user=... 作为一个整体进行赋值时,const user 才会报错。

2.3 垃圾回收

  • 垃圾回收是自动完成的,我们不能强制执行或是阻止执行。
  • 当对象是可达状态时,它一定是存在于内存中的。
  • 被引用与可访问(从一个根)不同:一组相互连接的对象可能整体都不可达

垃圾回收的基本算法被称为 “mark-and-sweep”。定期执行以下“垃圾回收”步骤:

  1. 垃圾收集器找到所有的根,并“标记”(记住)它们。
  2. 然后它遍历并“标记”来自它们的所有引用。
  3. 然后它遍历标记的对象并标记 它们的 引用。所有被遍历到的对象都会被记住,以免将来再次遍历到同一个对象。
  4. ……如此操作,直到所有可达的(从根部)引用都被访问到。
  5. 没有被标记的对象都会被删除。

一些优化建议:

  • 分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”。在典型的代码中,许多对象的生命周期都很短:它们出现、完成它们的工作并很快死去,因此在这种情况下跟踪新对象并将其从内存中清除是有意义的。那些长期存活的对象会变得“老旧”,并且被检查的频次也会降低。
  • 增量收集(Incremental collection)—— 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。因此,引擎将现有的整个对象集拆分为多个部分,然后将这些部分逐一清除。这样就会有很多小型的垃圾收集,而不是一个大型的。这需要它们之间有额外的标记来追踪变化,但是这样会带来许多微小的延迟而不是一个大的延迟。
  • 闲时收集(Idle-time collection)—— 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。

现代引擎实现了垃圾回收的高级算法。

2.4 对象方法,”this”

  • 方法示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    let user = {
    name: "John",
    age: 30
    };

    user.sayHi = function() {
    alert("Hello!");
    };

    user.sayHi(); // Hello!
    • 我们也可以使用预先声明的函数作为方法,就像这样:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    let user = {
    // ...
    };

    // 首先,声明函数
    function sayHi() {
    alert("Hello!");
    }

    // 然后将其作为一个方法添加
    user.sayHi = sayHi;

    user.sayHi(); // Hello!
  • 方法简写

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 这些对象作用一样
    user = {
    sayHi: function() {
    alert("Hello");
    }
    };

    // 方法简写看起来更好,对吧?
    let user = {
    sayHi() { // 与 "sayHi: function(){...}" 一样
    alert("Hello");
    }
    };
  • “this” 不受限制

    • JavaScript 中的 this 可以用于任何函数,即使它不是对象的方法。
    1
    2
    3
    function sayHi() {
    alert( this.name );
    }
    • this 的值是在代码运行时计算出来的,它取决于代码上下文。

    • 例如,这里相同的函数被分配给两个不同的对象,在调用中有着不同的 “this” 值:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    let user = { name: "John" };
    let admin = { name: "Admin" };

    function sayHi() {
    alert( this.name );
    }

    // 在两个对象中使用相同的函数
    user.f = sayHi;
    admin.f = sayHi;

    // 这两个调用有不同的 this 值
    // 函数内部的 "this" 是“点符号前面”的那个对象
    user.f(); // John(this == user)
    admin.f(); // Admin(this == admin)

    admin['f'](); // Admin(使用点符号或方括号语法来访问这个方法,都没有关系。)
    • 这个规则很简单:如果 obj.f() 被调用了,则 thisf 函数调用期间是 obj。所以在上面的例子中 this 先是 user,之后是 admin
  • 在没有对象的情况下调用:this == undefined

    • 在这种情况下,严格模式下的 this 值为 undefined。如果我们尝试访问 this.name,将会报错。

    • 在非严格模式的情况下,this 将会是 全局对象(浏览器中的 window)。这是一个历史行为,"use strict" 已经将其修复了。

  • 箭头函数没有自己的”this”

    • 箭头函数有些特别:它们没有自己的 this。如果我们在这样的函数中引用 thisthis 值取决于外部“正常的”函数。

      举个例子,这里的 arrow() 使用的 this 来自于外部的 user.sayHi() 方法:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      let user = {
      firstName: "Ilya",
      sayHi() {
      let arrow = () => alert(this.firstName);
      arrow();
      }
      };

      user.sayHi(); // Ilya

2.5 构造器和操作符 “new”

  • 构造函数,或简称构造器,就是常规函数,但大家对于构造器有个共同的约定,就是其命名首字母要大写。

    例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function User(name) {
    this.name = name;
    this.isAdmin = false;
    }

    let user = new User("Jack");

    alert(user.name); // Jack
    alert(user.isAdmin); // false
  • 当一个函数被使用 new 操作符执行时,它按照以下步骤:

    1. 一个新的空对象被创建并分配给 this
    2. 函数体执行。通常它会修改 this,为其添加新的属性。
    3. 返回 this 的值。
  • 构造函数只能使用 new 来调用。这样的调用意味着在开始时创建了空的 this,并在最后返回填充了值的 this

  • 从技术上讲,任何函数(除了箭头函数,它没有自己的 this)都可以用作构造器。即可以通过 new 来运行,它会执行上面的算法。“首字母大写”是一个共同的约定,以明确表示一个函数将被使用 new 来运行。

  • new function() { … }

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 创建一个函数并立即使用 new 调用它
    let user = new function() {
    this.name = "John";
    this.isAdmin = false;

    // ……用于用户创建的其他代码
    // 也许是复杂的逻辑和语句
    // 局部变量等
    };

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

  • 构造器的return

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

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

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

    如果没有参数,我们可以省略 new 后的括号:

    1
    2
    3
    let user = new User; // <-- 没有参数
    // 等同于
    let user = new User();
  • 构造器中的方法

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    function User(name) {
    this.name = name;

    this.sayHi = function() {
    alert( "My name is: " + this.name );
    };
    }

    let john = new User("John");

    john.sayHi(); // My name is: John

    /*
    john = {
    name: "John",
    sayHi: function() { ... }
    }
    */

2.6 可选链 “?.”

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

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

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

    1
    2
    3
    let user = {}; // user 没有 address 属性

    alert( user?.address?.street ); // undefined(不报错)
  • 不要过度使用可选链

    • 我们应该只将 ?. 使用在一些东西可以不存在的地方。
    • 例如,如果根据我们的代码逻辑,user 对象必须存在,但 address 是可选的,那么我们应该这样写 user.address?.street,而不是这样 user?.address?.street
    • 那么,如果 user 恰巧为 undefined,我们会看到一个编程错误并修复它。否则,如果我们滥用 ?.,会导致代码中的错误在不应该被消除的地方消除了,这会导致调试更加困难。
  • ?. 前的变量必须已声明

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

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

  • 其他变体:?.(),?.[]

    • ?.() 用于调用一个可能不存在的函数。

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

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      let userAdmin = {
      admin() {
      alert("I am admin");
      }
      };

      let userGuest = {};

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

      userGuest.admin?.(); // 啥都没发生(没有这样的方法)
    • 如果我们想使用方括号 [] 而不是点符号 . 来访问属性,语法 ?.[] 也可以使用。跟前面的例子类似,它允许从一个可能不存在的对象上安全地读取属性。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      let key = "firstName";

      let user1 = {
      firstName: "John"
      };

      let user2 = null;

      alert( user1?.[key] ); // John
      alert( user2?.[key] ); // undefined
  • 我们可以使用 ?. 来安全地读取或删除,但不能写入

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

      例如:

      1
      2
      3
      4
      let user = null;

      user?.name = "John"; // Error,不起作用
      // 因为它在计算的是:undefined = "John"
  • 可选链 ?. 语法有三种形式:

    1. obj?.prop —— 如果 obj 存在则返回 obj.prop,否则返回 undefined
    2. obj?.[prop] —— 如果 obj 存在则返回 obj[prop],否则返回 undefined
    3. obj.method?.() —— 如果 obj.method 存在则调用 obj.method(),否则返回 undefined

2.7 symbol类型

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

    • 字符串类型

    • symbol 类型

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

  • symbol 是唯一标识符的基本类型

  • symbol 是使用带有可选描述(name)的 Symbol() 调用创建的。

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

    1
    2
    3
    4
    // id 是描述为 "id" 的 symbol
    let id = Symbol("id");

    let id = Symbol();
  • symbol 总是不同的值,即使它们有相同的名字。如果我们希望同名的 symbol 相等,那么我们应该使用全局注册表:Symbol.for(key) 返回(如果需要的话则创建)一个以 key 作为名字的全局 symbol。使用 Symbol.for 多次调用 key 相同的 symbol 时,返回的就是同一个 symbol。

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

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

      1
      2
      let id = Symbol("id");
      alert(id); // 类型错误:无法将 symbol 值转换为字符串。
      • 这是一种防止混乱的“语言保护”,因为字符串和 symbol 有本质上的不同,不应该意外地将它们转换成另一个。
    • 如果我们真的想显示一个 symbol,我们需要在它上面调用 .toString(),如下所示:

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

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

      1
      2
      let id = Symbol("id");
      alert(id.description); // id
  • symbol 有两个主要的使用场景:

    1. “隐藏” 对象属性。

      • 如果我们想要向“属于”另一个脚本或者库的对象添加一个属性,我们可以创建一个 symbol 并使用它作为属性的键。symbol 属性不会出现在 for..in 中,因此它不会意外地被与其他属性一起处理。并且,它不会被直接访问,因为另一个脚本没有我们的 symbol。因此,该属性将受到保护,防止被意外使用或重写。

      • 因此我们可以使用 symbol 属性“秘密地”将一些东西隐藏到我们需要的对象中,但其他地方看不到它。

      • 但如果我们处于同样的目的,使用字符串 "id" 而不是用 symbol,那么 就会 出现冲突:

    2. JavaScript 使用了许多系统 symbol,这些 symbol 可以作为 Symbol.* 访问。我们可以使用它们来改变一些内建行为。例如,在本教程的后面部分,我们将使用 Symbol.iterator 来进行 迭代 操作,使用 Symbol.toPrimitive 来设置 对象原始值的转换 等等。

从技术上说,symbol 不是 100% 隐藏的。有一个内建方法 Object.getOwnPropertySymbols(obj) 允许我们获取所有的 symbol。还有一个名为 Reflect.ownKeys(obj) 的方法可以返回一个对象的 所有 键,包括 symbol。但大多数库、内建方法和语法结构都没有使用这些方法。

  • 对象字面量中的symbol

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

      就像这样:

      1
      2
      3
      4
      5
      6
      let id = Symbol("id");

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

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

  • 全局symbol

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

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

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

    • 例如:

      1
      2
      3
      4
      5
      6
      7
      8
      // 从全局注册表中读取
      let id = Symbol.for("id"); // 如果该 symbol 不存在,则创建它

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

      // 相同的 symbol
      alert( id === idAgain ); // true
  • 对于全局 symbol,Symbol.for(key) 按名字返回一个 symbol。相反,通过全局 symbol 返回一个名字,我们可以使用 Symbol.keyFor(sym)

    例如:

    1
    2
    3
    4
    5
    6
    7
    let globalSymbol = Symbol.for("name");
    let localSymbol = Symbol("name");

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

    alert( localSymbol.description ); // name

2.8 对象 — 原始值转换*

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

    在此类运算的情况下,对象会被自动转换为原始值,然后对这些原始值进行运算,并得到运算结果(也是一个原始值)。

  • 转换规则

    • 没有转换为布尔值。所有的对象在布尔上下文(context)中均为 true,就这么简单。只有字符串和数字转换。
    • 数字转换发生在对象相减或应用数学函数时。例如,Date 对象可以相减,date1 - date2 的结果是两个日期之间的差值。
    • 至于字符串转换 —— 通常发生在我们像 alert(obj) 这样输出一个对象和类似的上下文中。
  • 对象到原始值的转换,是由许多期望以原始值作为值的内建函数和运算符自动调用的。

    这里有三种类型(hint):

    • "string"(对于 alert 和其他需要字符串的操作)
    • "number"(对于数学运算)
    • "default"(少数运算符,通常对象以和 "number" 相同的方式实现 "default" 转换)

    规范明确描述了哪个运算符使用哪个 hint。

    转换算法是:

    1. 调用 obj[Symbol.toPrimitive](hint) 如果这个方法存在,
    2. 否则,如果 hint 是"string"
      • 尝试调用 obj.toString()obj.valueOf(),无论哪个存在。
    3. 否则,如果 hint 是"number"或者"default"
      • 尝试调用 obj.valueOf()obj.toString(),无论哪个存在。

    所有这些方法都必须返回一个原始值才能工作(如果已定义)。

  • 在实际使用中,通常只实现 obj.toString() 作为字符串转换的“全能”方法就足够了,该方法应该返回对象的“人类可读”表示,用于日志记录或调试。

3. 数据类型

3.1 原始类型的方法

  • nullundefined 以外的原始类型都提供了许多有用的方法。我们后面的章节中学习这些内容。

    • 例如,字符串方法 str.toUpperCase() 返回一个大写化处理的字符串。

      用法演示如下:

      1
      2
      3
      let str = "Hello";

      alert( str.toUpperCase() ); // HELLO
    • 很简单,对吧?以下是 str.toUpperCase() 中实际发生的情况:

      1. 字符串 str 是一个原始值。因此,在访问其属性时,会创建一个包含字符串字面值的特殊对象,并且具有可用的方法,例如 toUpperCase()
      2. 该方法运行并返回一个新的字符串(由 alert 显示)。
      3. 特殊对象被销毁,只留下原始值 str
  • 从形式上讲,这些方法通过临时对象工作,但 JavaScript 引擎可以很好地调整,以在内部对其进行优化,因此调用它们并不需要太高的成本。

  • 构造器 String/Number/Boolean 仅供内部使用

    • 像 Java 这样的一些语言允许我们使用 new Number(1)new Boolean(false) 等语法,明确地为原始类型创建“对象包装器”。

      在 JavaScript 中,由于历史原因,这也是可以的,但极其 不推荐。因为这样会出问题。

      例如:

      1
      2
      3
      alert( typeof 0 ); // "number"

      alert( typeof new Number(0) ); // "object"!

      对象在 if 中始终为真,所以此处的 alert 将显示:

      1
      2
      3
      4
      5
      let zero = new Number(0);

      if (zero) { // zero 为 true,因为它是一个对象
      alert( "zero is truthy?!?" );
      }
    • 另一方面,调用不带 new(关键字)的 String/Number/Boolean 函数是可以的且有效的。它们将一个值转换为相应的类型:转成字符串、数字或布尔值(原始类型)。

      例如,下面完全是有效的:

      1
      let num = Number("123"); // 将字符串转成数字
  • null/undefined 没有任何方法

3.2 数字类型

要写有很多零的数字:

  • "e" 和 0 的数量附加到数字后。就像:123e6123 后面接 6 个 0 相同。
  • "e" 后面的负数将使数字除以 1 后面接着给定数量的零的数字。例如 123e-6 表示 0.000123(123 的百万分之一)。

对于不同的数字系统:

  • 可以直接在十六进制(0x),八进制(0o)和二进制(0b)系统中写入数字。
  • parseInt(str,base) 将字符串 str 解析为在给定的 base 数字系统中的整数,2 ≤ base ≤ 36
  • num.toString(base) 将数字转换为在给定的 base 数字系统中的字符串。
  • 使用两个点来调用一个方法
    • 请注意 123456..toString(36) 中的两个点不是打错了。如果我们想直接在一个数字上调用一个方法,比如上面例子中的 toString,那么我们需要在它后面放置两个点 ..
    • 如果我们放置一个点:123456.toString(36),那么就会出现一个 error,因为 JavaScript 语法隐含了第一个点之后的部分为小数部分。如果我们再放一个点,那么 JavaScript 就知道小数部分为空,现在使用该方法。
    • 也可以写成 (123456).toString(36)

对于常规数字检测:

  • isNaN(value) 将其参数转换为数字,然后检测它是否为 NaN
  • isFinite(value) 将其参数转换为数字,如果它是常规数字,则返回 true,而不是 NaN/Infinity/-Infinity

要将 12pt100px 之类的值转换为数字:

  • 使用 parseInt/parseFloat 进行“软”转换,它从字符串中读取数字,然后返回在发生 error 前可以读取到的值。

小数:

  • 使用 Math.floorMath.ceilMath.truncMath.roundnum.toFixed(precision) 进行舍入。
  • 请确保记住使用小数时会损失精度。

更多数学函数:

  • 需要时请查看 Math 对象。这个库很小,但是可以满足基本的需求。

3.3 字符串

3.4 数组

3.5 数组方法

3.6 Iterable object(可迭代对象)

3.7 Map and Set(映射和集合)

3.8 WeakMap and WeakSet(弱映射和弱集合)

3.9 Object.keys,values,entries

3.10 解构赋值

3.11 日期和时间

3.12 JSON方法,toJSON

4. 函数进阶

5. 对象属性配置

6. 原型,继承

6.1 原型继承

[[Prototype]]

在 JavaScript 中,所有的对象都有一个隐藏的 [[Prototype]] 属性,它要么是另一个对象,要么就是 null, 通过 [[Prototype]] 引用的对象被称为“原型”。

  • 我们可以使用 obj.__proto__ 访问它(历史遗留下来的 getter/setter)。
  • 只能有一个 [[Prototype]]。一个对象不能从其他两个对象获得继承

image-20230222152010231

当我们从 object 中读取一个缺失的属性时,JavaScript 会自动从原型中获取该属性。在编程中,这被称为“原型继承”

1
2
3
4
5
6
7
8
9
10
11
12
let animal = {
eats: true
};
let rabbit = {
jumps: true
};

rabbit.__proto__ = animal; // (*) 将 animal 设置为 rabbit 的原型。

// 现在这两个属性我们都能在 rabbit 中找到:
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true

原型链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let animal = {
eats: true,
walk() {
alert("Animal walk");
}
};

let rabbit = {
jumps: true,
__proto__: animal
};

let longEar = {
earLength: 10,
__proto__: rabbit
};

// walk 是通过原型链获得的
longEar.walk(); // Animal walk
alert(longEar.jumps); // true(从 rabbit)

这里只有两个限制:

  1. 引用不能形成闭环。如果我们试图给 __proto__ 赋值但会导致引用形成闭环时,JavaScript 会抛出错误。
  2. __proto__ 的值可以是对象,也可以是 null。而其他的类型都会被忽略。

__proto__[[Prototype]] 的因历史原因而留下来的 getter/setter

  • __proto__ 与内部的 [[Prototype]] 不一样__proto__[[Prototype]] 的 getter/setter。

  • __proto__ 属性有点过时了。它的存在是出于历史的原因,现代编程语言建议我们应该使用函数 Object.getPrototypeOf/Object.setPrototypeOf 来取代 __proto__ 去 get/set 原型。

  • 根据规范,__proto__ 必须仅受浏览器环境的支持。但实际上,包括服务端在内的所有环境都支持它,因此我们使用它是非常安全的。

写入不使用原型

写/删除操作直接在对象上进行,它们不使用原型(假设它是数据属性,不是 setter)。

image-20230222152746444

“this”的值

this 不受原型的影响。

  • 无论在哪里找到方法:在一个对象还是在原型中。在一个方法调用中,this 始终是点符号 . 前面的对象。

  • 如果我们调用 obj.method(),而且 method 是从原型中获取的,this 仍然会引用 obj。因此,方法始终与当前对象一起使用,即使方法是继承的。

for…in 循环

  • for..in 循环在其自身和继承的属性上进行迭代。所有其他的键/值获取方法仅对对象本身起作用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let animal = {
eats: true
};

let rabbit = {
jumps: true,
__proto__: animal
};

// Object.keys 只返回自己的 key
alert(Object.keys(rabbit)); // jumps

// for..in 会遍历自己以及继承的键
for(let prop in rabbit) alert(prop); // jumps,然后是 eats

6.2 F.prototype

F.prototype 属性(不要把它与 [[Prototype]] 弄混了)在 new F() 被调用时为新对象的 [[Prototype]] 赋值。

  • 这里的 F.prototype 指的是 F 的一个名为 "prototype" 的常规属性
  • F.prototype 的值要么是一个对象,要么就是 null:其他值都不起作用。
  • "prototype" 属性仅当设置在一个构造函数上,并通过 new 调用时,才具有这种特殊的影响。
1
2
3
4
5
6
7
8
9
10
11
12
13
let animal = {
eats: true
};

function Rabbit(name) {
this.name = name;
}

Rabbit.prototype = animal;

let rabbit = new Rabbit("White Rabbit"); // rabbit.__proto__ == animal

alert( rabbit.eats ); // true

默认的 F.prototype, 构造器属性

每个函数都有 "prototype" 属性,即使我们没有提供它。

默认的 "prototype" 是一个只有属性 constructor 的对象,属性 constructor 指向函数自身。

1
2
3
4
5
6
function Rabbit() {}
// 默认:
// Rabbit.prototype = { constructor: Rabbit }

let rabbit = new Rabbit(); // 继承自 {constructor: Rabbit}
alert(rabbit.constructor == Rabbit); // true (from prototype)

所以我们可以通过访问它的 "constructor" 属性来获取一个对象的构造器。

1
2
// 当我们有一个对象,但不知道它使用了哪个构造器(例如它来自第三方库),并且我们需要创建另一个类似的对象时,用这种方法就很方便。
let rabbit2 = new rabbit.constructor("Black Rabbit");

在常规对象上,prototype 没什么特别的:

1
2
3
4
let user = {
name: "John",
prototype: "Bla-bla" // 这里只是普通的属性
};

6.3 原生的原型

Object.prototype

1
2
let obj = {};
alert( obj ); // "[object Object]" ?

obj = {}obj = new Object() 是一个意思,其中 Object 就是一个内建的对象构造函数,其自身的 prototype 指向一个带有 toString 和其他方法的一个巨大的对象。

new Object() 被调用(或一个字面量对象 {...} 被创建),这个对象的 [[Prototype]] 属性被设置为 Object.prototype

image-20230222154740488

可以验证

1
2
3
4
5
6
7
8
9
let obj = {};

alert(obj.__proto__ === Object.prototype); // true

alert(obj.toString === obj.__proto__.toString); //true
alert(obj.toString === Object.prototype.toString); //true

// 请注意在 Object.prototype 上方的链中没有更多的 [[Prototype]]
alert(Object.prototype.__proto__); // null

其他内建原型

其他内建对象,像 ArrayDateFunction 及其他,都在 prototype 上挂载了方法。

例如,当我们创建一个数组 [1, 2, 3],在内部会默认使用 new Array() 构造器。因此 Array.prototype 变成了这个数组的 prototype,并为这个数组提供数组的操作方法。这样内存的存储效率是很高的。

按照规范,所有的内建原型顶端都是 Object.prototype

image-20230222155008991

基本数据类型

字符串、数字和布尔值并不是对象。但是如果我们试图访问它们的属性,那么临时包装器对象将会通过内建的构造器 StringNumberBoolean 被创建。它们提供给我们操作字符串、数字和布尔值的方法然后消失。

这些对象对我们来说是无形地创建出来的。大多数引擎都会对其进行优化,但是规范中描述的就是通过这种方式。这些对象的方法也驻留在它们的 prototype 中,可以通过 String.prototypeNumber.prototypeBoolean.prototype 进行获取。

特殊值 nullundefined 比较特殊。它们没有对象包装器,所以它们没有方法和属性。并且它们也没有相应的原型。

原生的原型是可以被修改的, 但这通常是一个很不好的想法。

原型是全局的,所以很容易造成冲突。如果有两个库都添加了 String.prototype.show 方法,那么其中的一个方法将被另一个覆盖。

在现代编程中,只有一种情况下允许修改原生原型。那就是 polyfilling。

Polyfilling 是一个术语,表示某个方法在 JavaScript 规范中已存在,但是特定的 JavaScript 引擎尚不支持该方法,那么我们可以通过手动实现它,并用以填充内建原型。

1
2
3
4
5
6
7
8
9
10
11
12
13
if (!String.prototype.repeat) { // 如果这儿没有这个方法
// 那就在 prototype 中添加它

String.prototype.repeat = function(n) {
// 重复传入的字符串 n 次

// 实际上,实现代码比这个要复杂一些(完整的方法可以在规范中找到)
// 但即使是不够完美的 polyfill 也常常被认为是足够好的
return new Array(n + 1).join(this);
};
}

alert( "La".repeat(3) ); // LaLaLa

从原型中继承

一些原生原型的方法通常会被借用。

例如,如果我们要创建类数组对象,则可能需要向其中复制一些 Array 方法。

1
2
3
4
5
6
7
8
9
let obj = {
0: "Hello",
1: "world!",
length: 2,
};

obj.join = Array.prototype.join;

alert( obj.join(',') ); // Hello,world!

上面这段代码有效,是因为内建的方法 join 的内部算法只关心正确的索引和 length 属性。它不会检查这个对象是否是真正的数组。许多内建方法就是这样。

另一种方式是通过将 obj.__proto__ 设置为 Array.prototype,这样 Array 中的所有方法都自动地可以在 obj 中使用了。

但是如果 obj 已经从另一个对象进行了继承,那么这种方法就不可行了(译注:因为这样会覆盖掉已有的继承。此处 obj 其实已经从 Object 进行了继承,但是 Array 也继承自 Object,所以此处的方法借用不会影响 obj 对原有继承的继承,因为 obj 通过原型链依旧继承了 Object)。请记住,我们一次只能继承一个对象

方法借用很灵活,它允许在需要时混合来自不同对象的方法。

6.4 原型方法,没有 __proto__ 的对象