JavaScript高级程序设计

JavaScript高级程序设计

一、什么是JavaScript

1.1 JavaScript

完整的JavaScript实现包含以下:

  • 核心(ECMAScript

  • 文档对象模型(DOM

  • 浏览器对象模型(BOM

1.1.1 ECMAScript

即ECMA-262定义的语言,不局限于Web浏览器。ECMAScript只是对实现规范描述的所有方面的一门语言的称呼。Web 浏览器只是 ECMAScript 实现可能存在的一种宿主环境(host environment)。

在基本层面,描述这门语言的如下部分:

  • 语法

  • 类型

  • 语句

  • 关键词

  • 保留字

  • 操作符

  • 全局变量

1.1.2 DOM

文档对象模型(DOM,Document Object Model) 是一个应用编程接口(API),用于HTML中使用扩展的XML。DOM将整个页面抽象为一组分层节点。HTML或XML页面的每个组成部分都是一种节点,包含不同的数据。

!()[/images/2023-05-05-22-17-03-image.png]

!()[/images/2023-05-05-22-17-23-image.png]

这些代码可通过DOM表示为一组分层节点。

1.1.3 BOM

BOM(浏览器对象模型),用于支持访问和操作浏览器的窗口

BOM主要针对浏览器窗口和子窗口(frame)

1.2 小结

JavaScript是一门用来与网页交互的脚本语言,包含以下三个组成部分

  • ECMAScript:由ECMA-262定义并提供核心功能

  • 文档对象模型(DOM):提供与网页内容交互的方法和接口

  • 浏览器对象模型(BOM):提供与浏览器交互的方法和接口

JavaScript 的这三个部分得到了五大 Web 浏览器(IE、Firefox、Chrome、Safari 和 Opera)不同程度的支持。

二、HTML中的JavaScript

2.1 <**script**>元素

利用<**script**>元素实现将JavaScript插入HTML。

有以下元素属性:

  • async:可选,异步执行脚本
    • 表示应立即开始脚本,且不阻止其他页面动作。

    • 只适用于外部脚本。

    • 不能保证相关脚本按顺序执行。

    • 异步脚本保证会在页面的load事件之前执行,但可能会在DOMContentLoaded之前或之后

对于 XHTML 文档,指定async属性时应该写成async=”async”。

  • charset:可选

    • 使用src属性指定的代码字符集。
  • crossorigin:可选

    • 配置相关请求的CORS(跨源资源共享)设置。

    • 默认不使用 CORS。

    • crossorigin= “anonymous”配置文件请求不必设置凭据标志。

    • crossorigin=”use-credentials”设置凭据标志,意味着出站请求会包含凭据。

  • defer:可选

    • 表示脚本在执行的时候不会改变页面的结构,即脚本会被延迟到整个页面都解析完毕之后再运行。

    • 按顺序执行,即第一个推迟的脚本会在第二个推迟的脚本之前执行,而且两者都会在DOMContentLoaded事件之前执行

    • 只对外部脚本文件有效。

对于 XHTML 文档,指定defer属性时应该写成defer=”defer”。

  • integrity:可选。

    • 允许比对接收到的资源和指定的加密签名以验证子资源完整性(SRI,Subresource Integrity)。

    • 如果接收到的资源的签名与这个属性指定的签名不匹配,则页面会报错,脚本不会执行。

    • 可用于确保内容分发网络(CDN,Content Delivery Network)不会提供恶意内容。

  • src:可选。表示包含要执行的代码的外部文件。

  • type:可选。

    • 表示代码块中脚本语言的内容类型(MIME类型)。

    • 该值始终是“text/javascript”,JavaScript 文件的 MIME 类型通常是”application/x-javascript”。

    • 如果该值是module,则代码会被当成ES6代码,而且只有这个时候代码中才能出现import和export关键字。

使用<**script**>的方式

  • 直接在网页中嵌入JS代码

    • 嵌入行内JS代码,直接把代码放入<**script**>元素中就行

    • 包含在<**script**>内的代码会被从上到下依次解释

    • 代码中不能出现字符串</**script**>

  • 采用外部JS文件的方式

    • 必须使用scr属性——该属性的值是一个URL,指向包含JS代码的文件
      • URL指向的资源可以跟包含它的HTML页面不再同一个域中
    • 按顺序依次解释,前提是没有使用defer和async属性

2.1.1 标签位置

将JavaScript引用放在<**body**>元素中的页面内容 后面

2.1.2 动态加载脚本

  • 添加<**script**>标签

  • 向DOM中动态添加script元素

    • 默认情况下,该方式创建的<script**>元素是以异步方式加载的,相当于添加了async属性。此外,把HTMLElement元素添加到 DOM 且执行到这段代码之前不会发送请求**。
      1
      2
      3
      let script = document.createElement('script'); 
      script.src = 'gibberish.js';
      document.head.appendChild(script);

2.2 行内代码与外部文件

尽可能将JavaScript代码放在外部文件中

  • 可维护性

  • 缓存——浏览器会根据特定的设置缓存所有外部链接的JS文件,提高页面加载速度

  • 适应未来

2.3 文档模式

可以使用doctype切换文档模式

  • 混杂模式(quirks mode)

    • 以省略文档开头的doctype声明作为开关
  • 标准模式(standards mode)

    • ```html
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      158
      159
      160
      161
      162
      163
      164
      165
      166
      167
      168
      169
      170
      171
      172
      173
      174
      175
      176
      177
      178
      179
      180
      181
      182
      183
      184
      185
      186
      187
      188
      189
      190
      191
      192
      193
      194
      195
      196
      197
      198
      199
      200
      201
      202
      203
      204
      205
      206
      207
      208
      209
      210
      211
      212
      213
      214
      215
      216
      217
      218
      219
      220
      221
      222
      223
      224
      225
      226
      227
      228
      229
      230
      231
      232
      233
      234
      235
      236
      237
      238
      239
      240
      241
      242
      243
      244
      245
      246
      247
      248
      249
      250
      251
      252
      253
      254
      255
      256
      257
      258
      259
      260
      261
      262
      263
      264
      265
      266
      267
      268
      269
      270
      271
      272
      273
      274
      275
      276
      277
      278
      279
      280
      281
      282
      283
      284
      285
      286
      287
      288
      289
      290
      291
      292
      293
      294
      295
      296
      297
      298
      299
      300
      301
      302
      303
      304
      305
      306
      307
      308
      309
      310
      311
      312
      313
      314
      315
      316
      317
      318
      319
      320
      321
      322
      323
      324
      325
      326
      327
      328
      329
      330
      331
      332
      333
      334
      335
      336
      337
      338
      339
      340
      341
      342
      343
      344
      345
      346
      347
      348
      349
      350
      351
      352
      353
      354
      355
      356
      357
      358
      359
      360
      361
      362
      363
      364
      365
      366
      367
      368
      369
      370

      - 准标准模式(almost standards mode)

      - 通过过渡性文档类型(Transitional)和框架集文档类型(Frameset)来触发

      ### 2.4 < noscript >元素

      用于给不支持 JavaScript 的浏览器提供替代内容。
      <**noscript**>元素可以包含任何可以出现在<**body**>中的 HTML 元素,<**script**>除外。

      在下列两种情况下,浏览器将显示包含在<**noscript**>中的内容:

      - 浏览器不支持脚本;

      - 浏览器对脚本的支持被关闭。

      任何一个条件被满足,包含在<**noscript**>中的内容就会被渲染。

      ### 2.5 小结

      - 要包含外部 JavaScript 文件,必须将src属性设置为要包含文件的 URL。

      - 文件可以与网页在同一服务器上,也可以位于完全不同的域。

      - 在不使用defer和async属性的情况下,包含在<**script**>元素中的代码必须严格按次序解释。

      - 对**不推迟执行**的脚本,浏览器**必须**解释完位于<**script**>元素中的代码,然后**才能继续**渲染页面的**剩余部分**。通常应该把<**script**>元素放到页面末尾,介于主内容之后</**body**>标签之前。

      - 使用**defer属性**把脚本**推迟**到**文档渲染完毕**后再执行。

      - 推迟脚本原则上按照次序执行。

      - 使用**async属性**表示脚本**不需要**等待其他脚本,同时也**不阻塞文档渲染**,即**异步加载**。

      - 异步脚本**不能保证**按照出现的次序执行。

      - 使用<**noscript**>元素,可以指定在浏览器不支持脚本时显示的内容。如果浏览器支持并启用脚本,则<**noscript**>元素中的任何内容都不会被渲染。

      ## 三、语言基础

      ### 3.1 语法

      #### 3.1.1 区分大小写

      ECMAScript中一起**都区分大小**写。

      #### 3.1.2 标识符

      **标识符**就是变量、函数、属性或函数参数的名称。

      标识符**组成**:

      - 第一个字符**必须**是一个字母、下划线或美元符号。

      - 剩下的字符可以是字母、下划线、美元符号或数字

      标识符使用**驼峰大小写**形式,即第一个单词的首字母小写,后面每个单词的首字母大写。

      > 关键字、保留字、true、false、null不能作为标识符。

      #### 3.1.3 注释

      单行注释:以两个斜杆字符开头。

      块注释:以一个斜杠和一个星号开头,以它们的反向组合结尾。

      #### 3.1.4 严格模式

      ES5增加严格模式,要对整个脚本启动严格模式,需要在脚本开头加上`use strict;`

      这是一个**预处理指令**。

      可以单独指定一个函数在严格模式下执行,只需将指令放在函数体开头即可。

      ### 3.2 关键字和保留字

      break——do——in——typeof——case——else——instanceof——var

      catch——export——new——void——class——extends——return——while

      const——fanally——super——with——continue——for——switch——yield

      debugger——function——this——default——if——throw——delete——import——try

      ### 3.3 变量

      变量可以用于保存**任何类型**的数据,每个变量是一个用于保存任意值的**命名占位符**。

      **声明**变量:var、const、let

      #### 3.3.1 var关键字

      定义变量: **var 变量名;**

      > 在不初始化的情况下,变量会保存一个特殊值undefined。

      var声明的变量后续不仅可以**改变**保存的**值**,也可以改变值的**类型**。

      1. ##### var声明作用域
      - 范围是函数作用域。

      - 使用var操作符定义的变量会成为包含它的函数的**局部变量**。

      > 使用var在函数内部定义一个变量 —— 变量将在函数退出时被销毁。
      >
      > 但在函数内部定义变量时省略var操作符 —— 该变量是全局变量。

      - 定义多个变量时,可以在一条语句中用**逗号分隔**每个变量。
      2. ##### var声明提升
      - var声明的变量会**自动提升**到函数作用域顶部。

      - **提升**就是把所有变量声明都拉到函数作用域的顶部。

      - var 可反复声明同一变量。

      var在全局作用域中声明的变量会成为window对象的属性。

      #### 3.3.2 let 关键字

      1. ##### let声明作用域
      - 范围是**块作用域**。

      - 块作用域是函数作用域的**子集**。

      - let**不允许**同一个块作用域中出现冗余声明。
      2. ##### 暂时性死区
      - let与var的**区别**:let声明的变量不会在作用域中被提升。
      3. ##### 全局声明
      - let与var的区别:let在全局作用域中声明的变量不会成为window对象的属性。

      > let声明是在全局作用域中发生的,相应变量会在生命周期内存续,必须确保不会重复进行声明。
      4. ##### 条件声明

      let的作用域是块,**不能依赖**条件声明模式。

      5. ##### for循环中的 let 声明
      - var 进行 for 循环时,定义的迭代变量会渗透到循环体外部。

      - 会对迭代变量的奇特声明和修改。

      - 在退出循环时,迭代变量保存的是导致循环退出的值。在之后执行超时逻辑时,所有的i都是同一个变量,因而输出的都是同一个最终值。

      - 使用 let 则解决渗透问题 —— 迭代变量的作用域仅限于for循环块内部。

      - 使用let声明迭代变量时,JavaScript 引擎在后台会为每个迭代循环声明一个新的迭代变量。引用的都是不同的变量实例,所以console.log输出的就是循环执行过程中每个迭代变量的值。

      #### 3.3.3 const关键字

      const的行为与let基本相同。

      - 不允许重复声明。

      - 声明的域是块级作用域。

      与let的**区别**:const声明变量时**必须**同时**初始化**变量且尝试修改const声明的变量会导致运行错误。

      const声明的**限制**只适用于它指向的变量的有引用,即如果const变量**引用**的是一个**对象**,那么可以**修改**该对象内部的**属性**。

      不能用const来声明迭代变量(存在迭代变量自增问题)。

      ### 3.4 数据类型

      ECMAScript 有 6 种简单数据类型(也称为原始类型):Undefined、Null、Boolean、Number、String和Symbol。Symbol(符号)是 ECMAScript 6 新增的。

      一种复杂数据类型叫Object(对象)。Object是一种无序名值对的集合。

      #### 3.4.1 typeof 操作符

      用于确定变量的数据类型。

      - "undefined"表示值未定义;

      > 无论声明还是未声明,typeof 对于未初始化的变量返回的都是字符串“undefined”

      - "boolean"表示值为布尔值;

      - "string"表示值为字符串;

      - "number"表示值为数值;

      - "object"表示值为**对象**(而不是函数)或**null**;

      - "function"表示值为函数;

      - "symbol"表示值为符号。

      简单数据类型(原始类型)

      #### 3.4.2 Undefined 类型

      - 该类型只有一个值,即特殊值undefined。

      - 当使用var或let声明了变量但未初始化时,就相当于给变量赋予了undefined值。

      - undefined是一个假值。

      #### 3.4.3 Null 类型

      - 该类型只有一个值,即特殊值null,逻辑上null值表示一个**空对象指针**。

      - 在定义要保存对象值的变量时,用null来进行初始化。

      - null 是一个假值。

      #### 3.4.4 Boolean 类型

      - 有两个字面值:true 和 false 。
      - 将一个其他类型的值转换为布尔值,可以调用特定的 Boolean()转型函数。

      | 数据类型 | 转换为true的值 | 转换为false的值 |
      | --------- | ----------- | ---------- |
      | Boolean | true | false |
      | String | 非空字符串 | ""(空字符串) |
      | Number | 非零数值(包括无穷值) | 0、NaN |
      | Object | 任意对象 | null |
      | Undefined | N/A(不存在) | undefined |

      > if 等流**控制语句**会**自动**执行其他类型值到布尔值的转换。

      #### 3.4.5 Number 类型

      - 最基本的数值字面量格式是十进制整数。

      - 八进制字面量,第一个数字必须是**0**;八进制字面量在严格模式下是无效的。

      - 十六进制字面量,前缀必须是**0x**(区分大小写)。
      1. ##### 浮点值
      - 数值中**必须**包含小数点,且小数点后面**必须至少**有一个数字。

      - 存储浮点值使用的内存空间是存储整数值的两倍。

      - 浮点值可以用科学计数法来表示。

      - 浮点数的精确度最高可达17位小数。
      2. ##### 值的范围
      - ECMAScript 可以表示的最小数值保存在 Number.MIN_VALUE;保存的最大数值保存在Number.MAX_VALUE。
      3. ##### NaN
      - 用来表示本来要返回数值的操作失败了。

      > 在ES中,0、+0或-0相除会返回NaN。

      - **属性**

      - 任何涉及 NaN 的操作始终返回 NaN。

      - NaN 不等于包括 NaN 在内的任何值。

      - **isNaN()** 函数,接受一个参数,可以是任意数据类型,然后判断该参数是否“**不是数值**”,该函数会尝试把参数转换成数值。
      4. ##### 数值转换
      - 将非数值转化为数值的函数 **Number()、parseInt()、parseFloat()** 。

      - **Number()** 可用于任何数据类型。

      - 基于以下规则执行转换

      - 布尔值,true 转化为 1,false 转换为 0。

      - 数值,直接返回。

      - null,返回0 。

      - undefined,返回 NaN。

      - 字符串

      - 如果字符串包含**数值**字符,包括数值字符前面带加减号的情况,则转换为一个十进制数值。

      - 如果字符串包含**有效的浮点值**格式,则会转换为相应的浮点值,**忽略**前面的零。

      - 如果字符串包含有效的**十六进制**格式,则会转换为与该十六进制对应的十进制整数。

      - 如果是**空字符串**(不含字符),则返回 0。

      - 如果字符串包含除上述情况之外的其他字符,则返回 NaN。

      - 对象,调用 **valueOf()** 方法,如果转换结果是 NaN,则调用 toString()方法,再按照转换字符串的规则转换。

      - **parseInt()** 函数专注于字符串是否包含数值模式。

      - 需得到整数时通常优先考虑该函数。

      - 字符串**最前面的空格**会被**忽略**,从第一个非空字符开始转换,如果第一个字符不是数值字符、加减号,立即返回NaN。

      - 空字符串返回NaN。

      - 字符串中的第一个字符是数值字符,parseInt()函数也能识别不同的整数格式。

      - 该函数接收第二个参数,用于**指定底数**(进制数)。

      - **parseFloat()** 函数

      - 从位置0开始检测每个字符,解析到字符串末尾或解析到一个无效的浮点数值字符为止。

      - **始终忽略**字符串开头的零。

      - **只解析**十进制值。

      - 字符串表示整数(没有小数点或者小数点后面只有一个零),则parseFloat()返回**整数**。

      #### 3.4.6 String 类型

      表示零或多个16位 Unicode 字符序列。

      1. ##### 字符字面量
      - 字符串数据类型包含一些字符字面量,用于表示**非打印字符**或有**其他用途**的字符。

      | 字 面 量 | 含 义 |
      |:------:|:------------------------------------------:|
      | \n | 换行 |
      | \t | 制表 |
      | \b | 退格 |
      | \r | 回车 |
      | \f | 换页 |
      | \\\\ | 反斜杠(\) |
      | \' | 单引号('),在字符串以单引号标示时使用,例如'He said, \'hey.\'' |
      | \" | 双引号("),在字符串以双引号标示时使用,例如"He said, \"hey.\"" |
      | \ ` | 反引号( `) ,在字符串以反引号标示时使用。 |
      | \xnn | 以十六进制编码nn表示的字符(其中n是十六进制数字 0~F),例如\x41等于"A" |
      | \unnnn | 以十六进制编码nnnn表示的 Unicode 字符(其中n是十六进制数字 0~F)。 |

      - 可以出现在字符串中的任意位置,且可以单个字符被解释。

      > 如果字符串中包含**双字节字符**,那么**length属性**返回的值可能不是准确的字符数。
      2. ##### 字符串的特点
      - 字符串是不可变的。
      3. ##### 转换为字符串
      - 用 **toString()** 返回当前值的字符串等价物。

      - 可用于数值、布尔值、对象、字符串值。

      > null和undefined没有toString()方法。

      - 多数情况不接受任何参数。

      > 在对数值调用该方法时,toString()可接受一个底数参数。

      - 默认返回数值的十进制字符串形式。

      - 若不确定某值是否为null或undefined,可用String()转型函数。

      - String()函数遵循规则

      - 如果值有 toString()方法, 则调用该方法(不传参数)并返回结果。

      - 如果值是null,则返回“null”。

      - 如果值是undefined,返回“undefined”。

      > 用加号操作符给一个值加上一个空字符串""也可以将其转换为字符串。

      4. ##### 模板字面量(``)
      - 模板字面量 **保留** 换行字符,可以跨行定义字符串。

      - 模板字面量会 **保持** 反引号内部的空格。

      - 一种特殊的JS句法表达式。

      - 模板字面量在定义时立即求值并转换为字符串实例,任何 **插入的变量** 也会从它们 **最接近** 的作用域中取值。
      5. ##### 字符串插值
      - 在一个连续定义中插入一个或多个值。

      - 字符串插值通过在 **${}** 中使用一个JS表达式实现。

      ```html
      let value = 5;
      let exponent = 'second';
      let interpolatedTemplateLiteral =
      `${ value } to the ${ exponent } power is ${ value * value }`;

      console.log(interpolatedTemplateLiteral); // 5 to the second power is 25
  • 所有插入的值都会 使用toString()强制转型 为字符串,而且任何 JavaScript 表达式都可以用于插值。

  • 在插值表达式中 可调用 函数和方法。

  • 模板可以插入之前的值。

  1. 模板字面量标签函数
  • 通过标签函数可以自定义插值行为。

  • 标签函数会接收被插值记号分隔后的模板和每个表达式求值的结果。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    let a = 6; 
    let b = 9;
    function simpleTag(strings, aValExpression, bValExpression, sumExpression) {
    console.log(strings);
    console.log(aValExpression);
    console.log(bValExpression);
    console.log(sumExpression);
    return 'foobar';
    }

    let untaggedResult = `${ a } + ${ b } = ${ a + b }`;
    let taggedResult = simpleTag`${ a } + ${ b } = ${ a + b }`;
    // ["", " + ", " = ", ""]
    // 6
    // 9
    // 15
    console.log(untaggedResult); // "6 + 9 = 15"
    console.log(taggedResult); // "foobar"
  • 标签函数本身是一个 常规函数 ,通过 前缀 到模板字面量来应用自定义行为。

  1. 原始字符串
  • 使用模板字面量可以 直接获取 原始的模板字面量内容。

  • 默认使用 String.raw 标签函数。

    1
    2
    3
    // \u00A9 是版权符号 
    console.log(`\u00A9`); // ©
    console.log(String.raw`\u00A9`); // \u00A9
  • 通过标签函数的第一个参数,即字符串数值的 .raw 属性获得每个字符串的原始内容。

3.4.7 Symbol 类型

符号是原始值,且符号实例是 唯一的、不可变 的。

  • 用于确保对象属性使用唯一标识符,不会出现属性冲突的危险。

  • 用于创建唯一记号,进而用作非字符串形式的对象属性。

  1. 符号的基本使用
  • Symbol() 函数初始化。

    typeof 返回 symbol 。

  • 调用Symbol()函数时,可传入一个字符串参数作为对符号的描述,但该参数与符号定义或标识完全无关。

  • 符号没有字面量语法。

  • 创建Symbol()实例并将其用作对象的新属性,可以保证它 不会覆盖 已有的对象属性。

  • Symbol()函数 不能与new关键字 一起 作为构造函数 使用。

    想使用符号包装对象,可以借用Object()函数

    1
    2
    3
    let mySymbol = Symbol(); 
    let myWrappedSymbol = Object(mySymbol);
    console.log(typeof myWrappedSymbol); // "object"
  1. 使用全局符号注册表
  • 使用 Symbol.for() 方法,用一个字符串作为键,在全局符号注册表中创建并重用符号。

    • Symbol.for()对每个字符串键都执行幂等操作。
  • 全局注册表中的符号 必须 使用 字符串键 来创建。

    • 作为参数传给Symbol.for()的任何值都会被转换为字符串。

    • 注册表中使用的键同时也会被用作符号描述。

  • 使用 Symbol.keyFor() 来查询全局注册表,该方法接收符号,返回该全局符号对应的字符串键。

    • 查询的不是全局符号,则返回undefined。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 创建全局符号 
    let s = Symbol.for('foo');
    console.log(Symbol.keyFor(s)); // foo

    // 创建普通符号
    let s2 = Symbol('bar');
    console.log(Symbol.keyFor(s2)); // undefined
    如果传给Symbol.keyFor()的不是符号,则该方法抛出TypeError:
    Symbol.keyFor(123); // TypeError: 123 is not a symbol
  1. 使用符号作为属性

凡是可以使用 字符串或数值 作为属性的地方,都可以使用符号。

  • 对象字面量属性、Object.defineProperty()、Object.defineProperties()定义的属性。

  • 对象字面量 只能在计算属性 语法中使用符号作为属性。

  • 符号属性是对内存中符号的一个引用,所以直接创建并用作属性的符号不会丢失。但如果没有显式地保存对这些属性的引用,那么 必须遍历 对象的所有符号属性才能找$到$相应的属性键:

    • ```html
      let o = {
      [Symbol(‘foo’)]: ‘foo val’,
      [Symbol(‘bar’)]: ‘bar val’
      };
      console.log(o);
      // {Symbol(foo): “foo val”, Symbol(bar): “bar val”}let barSymbol = Object.getOwnPropertySymbols(o)
                  .find((symbol) => symbol.toString().match(/bar/));
      
      console.log(barSymbol);
      // Symbol(bar)
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      4. ##### 内置符号
      - 用于重新定义它们,从而改变原生结构的行为。

      - 所有内置符号属性都是 **不可写、不可枚举、不可配置** 的。

      > 在提到 ECMAScript 规范时,经常会引用符号在规范中的名称,**前缀为@@**。比如,@@iterator指的就是Symbol.iterator。
      5. ##### Symbol.asyncIterator
      - 作为一个属性表示一个方法,该方法返回对象 **默认的AsyncIterator**。

      - 由for-await-of语句使用,表示实现异步迭代器API的函数。
      6. ##### Symbol.hasInstance
      - 作为一个属性表示一个方法,该方法决定一个构造器对象是否认可一个对象是它的实例。

      - 由instanceof操作符使用,**instanceof操作符** 可以用来确定一个对象实例的原型链上是否有原型。
      7. ##### Symbol.isConcatSpreadable
      - 作为一个属性表示一个布尔值,如果是true,则意味着对象应该用Array.prototype.concat()打平其数组元素。
      8. ##### Symbol.iterator
      - 作为一个属性表示一个方法,该方法返回对象默认的迭代器。
      由for-of语句使用”,这个符号表示实现迭代器 API 的函数。
      9. ##### Symbol.match
      - 作为一个属性表示一个正则表达式方法,该方法用正则表达式去匹配字符串。由String.prototype.match()方法使用。

      - String.prototype.match()方法会使用以Symbol.match为键的函数来对正则表达式求值。
      10. ##### Symbol.replace
      - 作为一个属性表示一个正则表达式方法,该方法替换一个字符串中匹配的子串。

      - 由String.prototype.replace()方法使用,String.prototype.replace()方法会使用以Symbol.replace为键的函数来对正则表达式求值。

      - Symbol.replace函数接收两个参数,即调用replace()方法的 **字符串实例** 和 **替换字符串** ,返回的值没有限制。
      11. ##### Symbol.search
      - 作为一个属性表示一个正则表达式方法,该方法返回字符串中匹配正则表达式的索引

      - 由String.prototype.search()方法使用,String.prototype.search()方法会使用以Symbol.search为键的函数来对正则表达式求值。
      12. ##### Symbol.species
      - 作为一个属性表示一个函数值,该函数作为创建派生对象的构造函数。

      - 在内置类型中最常用,用于对内置类型实例方法的返回值暴露实例化派生对象的方法。

      - 用Symbol.species定义静态的获取器(getter)方法,可以覆盖新创建实例的原型定义。
      13. ##### Symbol.split
      - 作为一个属性表示一个正则表达式方法,该方法在匹配正则表达式的索引位置拆分字符串。

      - 由String.prototype.split()方法使用,String.prototype. split()方法会使用Symbol.split为键的函数来对正则表达式求值。
      14. ##### Symbol.toPrimitive
      - 作为一个属性表示一个方法,该方法将对象转换为相应的原始值。

      - 由ToPrimitive抽象操作使用。很多内置操作都会尝试强制将对象转换为原始值,包括字符串、数值和未指定的原始类型。

      - 对于一个自定义对象实例,通过在这个实例的Symbol.toPrimitive属性上定义一个函数可以改变默认行为。
      15. ##### Symbol.toStringTag
      - 作为一个属性表示一个字符串,该字符串用于创建对象的默认字符串描述。

      - 由内置方法Object.prototype.toString()使用。通过toString()方法获取对象标识时,会检索由Symbol.toStringTag指定的实例标识符,默认为"Object"。
      16. ##### Symbol.unscopables
      - 作为一个属性表示一个对象,该对象所有的以及继承的属性,都会从关联对象的with环境绑定中排除。

      - 设置这个符号并让其映射对应属性的键值为true,就可以阻止该属性出现在with环境绑定中。

      > 不推荐使用with,也不推荐使用Symbol.unscopables。

      #### 3.4.8 Object 类型

      对象是一种无序名值对的集合,一组数据和功能的集合。

      - 对象通过 **new** 操作符后跟对象类型的名称来创建。

      > let o = new Object();
      >
      > 给构造函数提供参数时使用括号。

      - Object是派生其他对象的基类,Object类型的所有属性呵方法在派生对象上同样存在。

      - Object实例的属性和方法

      - **constructor**:用于创建当前对象的函数。这个属性的值就是Object() 函数。

      - **hasOwnProperty(*propertyName*)**:用于判断当前对象实例(**不是原型**)上是否存在给定的属性。要检查的属性名必须是字符串(o.hasOwnProperty("name"))或符号。

      - **isPrototypeOf( *object* )**:用于判断当前对象是否为另一个对象的原型。

      - **propertyIsEnumerable(*propertyName*)**:用于判断给定的属性是否可以使用**for-in语句枚举** 。与hasOwnProperty()一样,属性名 **必须** 是字符串。

      - **toLocaleString()**:返回对象的字符串表示,该字符串反映对象所在的本地化执行环境。

      - **toString()**:返回对象的 **字符串** 表示。

      - **valueOf()**:返回对象对应的 **字符串、数值或布尔值** 表示。通常与toString()的返回值相同。

      可以采用 **typeof** 操作符来确定变量的数据类型。

      - 注意调用 typeof null 返回的是“object”,这是因为特殊值null被认为是一个对空对象的引用。

      ### 3.5 操作符

      一组可以用于操作数据值的操作符,包括数学操作符、位操作符、关系操作符、相等操作符等。

      > 在应用给对象时,操作符通常会调用valueOf()和/或toString()方法来取得可以计算的值。

      #### 3.5.1 一元操作符

      只操作一个值得操作符叫一元操作符。

      1. ##### 递增/递减操作符
      - 分为前缀版和后缀版。前缀版位于要操作的变量前头,后缀版位于要操作的变量后头。

      - **前缀** 递增和递减在语句中的 **优先级是相等** 的。

      - **后缀** 递增和递减在语句 **被求值后** 才发生。

      - 递增和递减操作符遵循规则(用于 **任何值** )

      - 对于 **字符串**,如果是 **有效** 的数值形式,则 **转换为数值** 再应用改变。变量 **类型** 从字符串变成数值。

      - 对于 **字符串** ,如果是 **无效** 的数值形式,则将变量的值设置为 **NaN** 。变量 **类型** 从字符串变成数值。

      - 对于 **布尔值** ,如果是 **false**,则转换为 **0** 再应用改变。变量 **类型** 从布尔值变成数值。

      - 对于 **布尔值** ,如果是 **true**,则转换为 **1** 再应用改变。变量 **类型** 从布尔值变成数值。

      - 对于 **浮点值**,加 1 或减 1。

      - 如果是 **对象**,则 **调用其valueOf()** 方法取得可以操作的值。对得到的值应用上述规则。如果是 **NaN**,则 **调用toString()** 并再次应用其他规则。变量 **类型** 从对象变成数值。
      2. ##### 一元加和减
      - 一元加由一个加号(+)表示,放在变量前头,对数值**没有任何影响**。

      > ```html
      > let num = 25;
      > num = +num;
      > console.log(num); // 25
  • 一元加 应用到 非数值,则会执行与 使用Number() 转型函数一样得类型转换。

  • 一元减 由一个减号(-)表示,放在变量前头,主要用于把数值 变成负值

  • 一元加和减操作符主要用于基本的算术,但也可以用于数据类型转换。

3.5.2 位操作符

用于数值的 底层操作 ,即操作内存中表示数据的比特(位)。

ECMAScript中的所有数值都以 IEEE 754 64 位 格式存储,但位操作并不直接应用到 64 位表示,而是先把值转换为32 位整数,再进行位操作,之后再把结果转换为 64 位。对开发者而言,就好像只有 32 位整数一样,因为 64 位整数存储格式是不可见的。

有符号整数 使用 32 位的前 31 位表示整数值。第 32 位表示数值的符号,如 0 表示正,1 表示负。这一位称为 符号位(sign bit) ,它的值 决定了 数值其余部分的格式。

正值 以真正的二进制格式存储,即 31位中的每一位都代表 2 的幂。

负值 以一种称为 二补数(或补码)的二进制编码存储。

  • 一个数值的二补数
    (1) 确定 绝对值二进制 表示(如,对于-18,先确定 18 的二进制表示)。
    (2) 找到 数值的一 补数(或反码)(就是每个 0 都变成 1,每个 1 都变成 0) 。
    (3) 给结果 加1

默认情况下,ECMAScript中的 所有整数 都表示为 有符号数

在对 ECMAScript 中的数值应用位操作符时,会导致了一个奇特的 副作用 ,即特殊值 NaN和Infinity 在位操作中都会被当成 0 处理。
如果将位操作符应用到 非数值 ,那么首先会 使用Number() 函数将该值转换为数值(这个过程是 自动的),然后再应用位操作,最终 结果是数值

  1. 按位非
  • ~ 表示,用于返回数值的一 补数

  • 最终结果是对数值 取反并减1

  1. 按位与
  • & 表示,有两个操作数。

  • 将两个数的每一个位对齐, 基于真值表 的规则,对每一位执行相应的 与操作

    • 按位与操作在两个位都是 1 时返回 1,在任何一位是 0 时返回 0。
  1. 按位或
  • | 表示,有两个操作数。

  • 将两个数的每一个位对齐, 基于真值表 的规则,对每一位执行相应的 或操作

    • 按位或操作在至少一位是 1 时返回 1,两位都是 0 时返回 0。
  1. 按位异或
  • ^ 表示,有两个操作数。

  • 将两个数的每一个位对齐, 基于真值表 的规则,对每一位执行相应的 异或操作

    • 按位异或操作只在一位上是 1 的时候返回 1,两位都是 1 或 0,则返回 0。
  1. 有符号左移
  • << 表示,按照指定位数将数值的 所有位向左移 动,空位补零。

  • 左移会 保留 它所操作数值的 符号

  1. 有符号右移
  • >> 表示右移,同时 保留符号(正或负)。
  1. 无符号右移
  • >>> 表示,会将数值的所有 32 位都向右移。

  • 对于 正数 ,无符号右移与有符号右移 结果相同

  • 对于 负数 ,无符号右移会 给空位补 0,无视符号位。

3.5.3 布尔操作符

  1. 逻辑非
  • 表示,该操作符始终 返回布尔值

  • 操作符遵循规则

    • 如果操作数是 对象 ,则返回 false

    • 如果操作数是 空字符串,则返回 true

    • 如果操作数是 非空字符串,则返回 false

    • 如果操作数是数值 0,则返回 true

    • 如果操作数是 非 0 数值(包括Infinity),则返回 false

    • 如果操作数是 null,则返回 true

    • 如果操作数是 NaN,则返回 true

    • 如果操作数是 undefined,则返回 true

    1
    2
    3
    4
    5
    6
    console.log(!false);   // true 
    console.log(!"blue"); // false
    console.log(!0); // true
    console.log(!NaN); // true
    console.log(!""); // true
    console.log(!12345); // false
  • 可用于将任意值转换为布尔值,同时使用 !! 相当于调用了转型函数 Boolean()

    无论操作数是什么类型,第一个叹号总会返回布尔值,第二个叹号对该布尔值取反,从而给出变量真正对应的布尔值。

    1
    2
    3
    4
    5
    console.log(!!"blue"); // true 
    console.log(!!0); // false
    console.log(!!NaN); // false
    console.log(!!""); // false
    console.log(!!12345); // true
  1. 逻辑与
  • && 表示,应用到两个值。

  • 可用于 任何类型 的操作数。

  • 如果有操作数 不是布尔值 ,遵循如下规则

    • 如果 第一个 操作数是 对象 ,则 返回第二个 操作数。

    • 如果 第二个 操作数是对象,则 只有第一个 操作数求值 为true 才会 返回该对象

    • 如果 两个 操作数 都是对象,则 返回第二个 操作数。

    • 如果 有一个 操作数是 null ,则 返回null

    • 如果 有一个 操作数是 NaN ,则返回 NaN

    • 如果 有一个 操作数是 undefined ,则 返回undefined

    • 逻辑与操作符是 一种短路操作符 ,就是如果第一个操作数决定了结果,那么永远不会对第二个操作数求值。

  1. 逻辑或
  • || 表示。

  • 遵循规则。

    • 如果 第一个 操作数是 对象,则 返回第一个 操作数。

    • 如果 第一个 操作数求值为 false,则 返回第二个 操作数。

    • 如果 两个 操作数 都是对象,则 返回第一个 操作数。

    • 如果 两个 操作数 都是null,则 返回null

    • 如果 两个 操作数 都是NaN,则 返回NaN

    • 如果 两个 操作数都是 undefined,则 返回undefined

  • 逻辑或操作符具有 短路 的特性,第一个操作数求值为true,第二个操作数就不会再被求值了。

3.5.4 乘性操作符

乘法、除法和取模。

在处理非数值时,它们会包含一些 自动的类型转换

  1. 乘法操作符
  • 由一个 ***** 表示,用于计算两个数值的乘积。
    • 如果有 任一操作数是 NaN,则 返回NaN

    • 如果是 Infinity乘以 0,则 返回NaN

    • 如果是 Infinity乘以 非 0 的 有限数值,则 根据第二个 操作数的 符号返回Infinity或-Infinity

    • 如果是 Infinity乘Infinity,则 返回Infinity

    • 如果 有不是数值 的操作数,则先在 后台用Number() 将其转换为数值,然后再 应用上述规则

  1. 除法操作符
  • / 表示,用于计算第一个操作数除以第二个操作数的商。
    • 如果操作数 都是数值 ,两个正值相除是正值,两个负值相除也是正值,符号不同的值相除得到负值。如果ECMAScript不能表示商,则返回Infinity或-Infinity。

    • 如果有 任一 操作数是 NaN ,则 返回NaN

    • 如果是 Infinity除以Infinity ,则 0

    • 如果是 0 除以 0,则返回 NaN

    • 如果是 非 0 的有限值 除以 0,则根据 第一个 操作数的符号返回 Infinity或-Infinity

    • 如果是 Infinity 除以 任何数值 ,则根据 第二个 操作数的符号返回 Infinity或-Infinity

    • 如果 有不是数值 的操作数,则先在 后台用Number() 函数将其转换为数值,然后再应用上述规则。

  1. 取模操作符
  • % 表示。
    • 如果操作数是 数值 ,则执行常规除法运算, 返回余数

    • 如果 被除数无限值除数有限值 ,则 返回NaN

    • 如果 被除数有限值除数0,则 返回NaN

    • 如果是 Infinity除以Infinity ,则 返回NaN

    • 如果 被除数有限值除数无限值 ,则 返回被除数

    • 如果 被除数0除数不是 0,则 返回 0

    • 如果 有不是数值 的操作数,则先在 后台用Number() 函数将其转换为数值,然后再应用上述规则。

3.5.5 指数操作符

Math.pow() 的操作符为 ****** ,指数赋值操作符为 ****=** 。

3.5.6 加性操作符

  1. 加法操作符(+)

如果 两个 操作数都是 数值 ,加法操作符执行加法运算并根据如下规则返回结果:

  • 如果有 任一 操作数是 NaN ,则 返回NaN

  • 如果是 Infinity加Infinity,则 返回Infinity

  • 如果是 -Infinity加-Infinity,则 返回-Infinity

  • 如果是 Infinity加-Infinity,则 返回NaN

  • 如果是 +0加+0,则 返回+0

  • 如果是 -0加+0,则 返回+0

  • 如果是 -0加-0,则 返回-0

  • 如果 有一个 操作数是 字符串,则应用如下规则:

    • 如果 两个 操作数都是 字符串,则将第二个字符串 拼接 到第一个字符串后面;

    • 如果 只有一个 操作数是 字符串,则将 另一个 操作数 转换为字符串,再将两个字符串 拼接 在一起。

    • 如果有 任一 操作数是 对象、数值或布尔值 ,则 调用toString() 方法以获取字符串,然后再应用前面的关于字符串的规则。

    • 对于 undefined和null ,则 调用String() 函数,分别获取”undefined”和”null”。

  1. 减法操作符(-)

减法操作符有一组规则用于处理 ECMAScript 中不同类型之间的转换。

  • 如果 两个 操作数都是 数值 ,则执行数学减法运算并返回结果。

  • 如果有 任一操作数是 NaN,则 返回NaN

  • 如果是 Infinity减Infinity,则 返回NaN

  • 如果是 -Infinity减-Infinity ,则 返回NaN

  • 如果是 Infinity减-Infinity ,则 返回Infinity

  • 如果是 -Infinity减Infinity ,则 返回-Infinity

  • 如果是 +0减+0,则 返回+0

  • 如果是 +0减-0,则 返回-0

  • 如果是 -0减-0,则 返回+0

  • 如果有 任一操作数是 字符串、布尔值、null或undefined,则先在后台使用 Number() 将其转换为数值,然后再根据前面的规则执行数学运算。如果 转换结果NaN,则减法计算的 结果是NaN

  • 如果有 任一操作数是 对象,则调用其 valueOf() 方法取得表示它的数值。如果该值是NaN,则减法计算的结果是NaN。如果对象没有valueOf()方法,则调用其toString()方法,然后再将得到的字符串转换为数值。

3.5.7 关系操作符

执行 比较两个值 的操作,包括小于、大于、小于等于、大于等于,都 返回布尔值

应用到不同数据类型时会发生类型转换和其他行为。

  • 如果操作数 都是数值 ,则执行数值比较。

  • 如果操作数 都是字符串,则 逐个比较 字符串中对应 字符的编码

  • 如果有 任一 操作数是 数值 ,则将 另一个 操作数 转换为数值,执行数值比较。

  • 如果有 任一 操作数是 对象 ,则调用其 valueOf() 方法,取得结果后再根据前面的规则执行比较。

    • 如果没有valueOf()操作符,则 调用toString() 方法,取得结果后再根据前面的规则执行比较。
  • 如果有任一操作数是布尔值,则将其转换为数值再执行比较。

3.5.8 相等操作符

判断两个变量是否相等。

  1. 等于和不等于
  • 等于操作符用两个等于号 == 表示,两个操作符都会 先进行类型转换(强制类型转换),再确定操作数是否相等。

  • 在转换操作数的类型时,操作符遵循规则。

    • 如果 任一 操作数是 布尔值,则将其 转换为数值 再比较是否相等。false转换为 0,true转换为 1。

    • 如果 一个 操作数是 字符串另一个 操作数是 数值,则尝试将 字符串转换为数值,再比较是否相等。

    • 如果 一个 操作数是 对象另一个 操作数 不是,则 调用 对象的 valueOf() 方法取得其原始值,再根据前面的规则进行比较。

  • 在进行比较时,这两个操作符会遵循如下规则。

    • null 和 undefined 相等。

    • null和undefined不能转换 为其他类型的值再进行比较。

    • 如果有 任一 操作数是 NaN,则 相等 操作符 返回false不相等 操作符 返回true

      即使两个操作数都是NaN,相等操作符也返回false,因为按照规则,NaN不等于NaN

    • 如果 两个 操作数都是 对象,则比较它们是不是同一个对象。如果两个操作数都 指向同一个对象,则 相等 操作符 返回true。否则,两者不相等。

    表达式 结 果
    null == undefined true
    “NaN” == NaN false
    5 == NaN false
    NaN == NaN false
    NaN != NaN true
    false == 0 true
    true == 1 true
    true == 2 false
    undefined == 0 false
    null == 0 false
    “5” == 5 true
  1. 全等和不全等

比较时 不转换操作数 ,全等操作符由3个等于号 === 表示,只有两个操作数在不转换的前提下相等才返回true。

不全等操作符用 !== 表示,只有两个操作数在不转换的前提下不相等才返回true。

3.5.9 条件操作符

variable = boolean_expression ? true_value : false_value。

3.5.10 逗号操作符

逗号操作符可以用来在一条语句中执行多个操作。

在赋值时使用逗号操作符分隔值,最终返回 表达式中 最后一个值

3.6 语句

3.6.1 if 语句

if(condition) statement1 else statement2

条件(condition)可以是 任何 表达式,并且求值结果 不一定 是布尔值。

语句可以是一行代码也可以是一个代码块。

3.6.2 do-while 语句

do-while 语句是一种 后测试 循环语句,即循环体中的 代码执行后 才会对退出条件进行求值。即循环体内的代码 至少执行 一次。

1
2
3
do { 
statement
} while (expression);

3.6.3 while 语句

while 语句是一种 先测试 循环语句,即先检测退出条件,再执行循环体内的代码。即while循环体内的代码有可能不会执行。

1
2
3
while(expression){
statement;
}

3.6.4 for 语句

for 语句是 先测试 语句,增加了进入循环之前的 初始化代码 ,以及循环执行后要执行的表达式。

1
2
3
for (initialization; expression; post-loop-expression) {
statement;
}

无法通过while 循环实现的逻辑,for循环也无法实现。

for循环中的初始化代码可以不使用变量声明关键字的。

初始化、条件表达式、循环后表达式都非必须。

3.6.5 for-in 语句

for-in 语句是一种严格的 迭代语句 ,用于 枚举 对象中的 非符号键属性

1
2
3
for(const propName in window){
document.write(propName);
}

如果for-in循环要迭代的变量是null或undefined,则不执行循环体。

ECMAScript中对象的属性是无序的,即for-in语句不能保证返回对象属性的顺序。

3.6.6 for-of 语句

for-of 语句是一种严格的 迭代语句,用于遍历 可迭代 对象的 元素

1
2
3
for(property of expression){
statement;
}

for-of 循环会按照可迭代对象的 next() 方法产生值的顺序迭代元素。

3.6.7 标签语句

标签语句用于给语句 加标签

1
label:statement

标签语句的典型应用场景是嵌套循环。

3.6.8 break和continue语句

break和continue 语句为执行循环代码提供了更严格的 控制手段

  • break 语句用于立即退出循环,强制执行 循环后的 下一条 语句。

  • continue 语句用于立即退出循环,但会 再次 从循环 顶部 开始执行。

3.6.9 with语句

with 语句是将代码 作用域 设置为特定的对象。

1
2
3
with(expression) {
statement;
}

使用with语句的主要场景是针对一个对象反复操作。

3.6.10 switch 语句

switch 语句是与 if 语句紧密相关的一种 流控制语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
switch (expression) { 
case value1:
statement
    break;
case value2:
statement
break;
case value3:
statement
break;
case value4:
statement
break;
default:
statement
}

case相当于条件;break 关键字会使代码跳出switch语句;default用于默认执行语句。

switch语句可以用于所有数据类型 ,可以使用字符串甚至对象。
条件的值不需要是常量,也可以是变量或表达式。

swicth语句在比较每个条件的值时会使用 全等操作符,不存在强制类型转换。

3.7 函数

函数的基本语法:

1
2
3
function functionName(arg0, arg1,...,argN) { 
    statements
}

通过 函数名来调用 函数,要传给函数的参数放在括号里(如果有多个参数,则用逗号隔开)。

函数不需要指定是否返回值。

任何函数在任何时间都可以使用 return 语句来返回函数的值,用法是后跟要返回的值。

一个函数中可以有 多个return 语句,return语句后面的代码不会被执行。

return语句可以不带返回值,函数这时会立即 停止执行并返回undefined

严格模式对函数也有一些限制:

  • 函数不能以eval或arguments作为名称。

  • 函数的参数不能叫eval或arguments。

  • 两个命名参数不能拥有同一个名称。
    如果违反上述规则,则会导致语法错误,代码也不会执行。

3.8 小结

JavaScript 的核心语言特性在 ECMA-262 中以伪语言 ECMAScript 的形式来定义。

  • 基本数据类型包括 Undefined、Null、Boolean、Number、String和Symbol

  • ECMAScript 不区分整数和浮点值,只有Number 一种数值数据类型。

  • Object是一种 复杂数据类型,它是这门语言中所有对象的 基类

  • 严格模式 为这门语言中某些容易出错的部分施加了限制。

  • 常见的基本操作符,包括数学操作符、布尔操作符、关系操作符、相等操作符和赋值操作符等。

ECMAScript 中的函数与其他语言中的函数不一样。

  • 不需要指定函数的返回值,因为任何函数可以在任何时候返回任何值。

  • 不指定返回值的函数实际上会返回特殊值undefined。

四、变量、作用域和内存

4.1 原始值和引用值

变量可以包含两种不同类型的数据。

  • 原始值:简单数据。

  • 引用值:由多个值构成的对象。

在把一个 值赋 给变量时,js引擎 必须确定 这个值是原始值还是引用值。

保存 原始值 的变量是 按值访问 的。

保存 引用值 的变脸是 按引用访问 的。

引用值 是保存在 内存中 的对象,JS不允许直接访问内存位置。

在操作对象时,是对该对象的引用,而非实际的对象本身

4.1.1 动态属性

原始值 不能有属性。

只有 引用值 可以动态添加可使用的属性。

原始类型的初始化可以只使用原始字面量形式。

如果使用的是 new 关键字,则JS会创建一个 Object 类型的实例,但其行为类似原始值。

4.1.2 复制值

在通过变量把一个 原始值 赋值到另一个变量时,原始值会被 复制 到新变量的位置。

复制前后的变量独立使用,互不干扰。

在把 引用值 从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置,这里复制的值实际上是一个 指针 ,它指向存储在堆内存中的对象。

4.1.3 传递参数

所有函数的参数都是 按值传递 的。

在按值传递参数时,值会被复制到一个局部变量(即一个命名参数,arguments对象中的一个槽位)

ECMAScript中函数的参数就是局部变量。

4.1.4 确定类型

typeof 操作符适合于判断一个变量是否为原始类型。

instanceof 操作符

1
result = variable instanceof constructor
  • 如果变量时给定 引用类型,则 instanceof 操作符返回true

4.2 执行上下文和作用域

每个上下文都有一个关联的 变量对象 ,而这个上下文中 定义的所有 变量和函数都 存在于 这个对象上。

全局上下文是最外层的上下文所有 通过 var定义 的全局变量和函数都会成为window对象的属性和方法。

全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器。

使用 let 和 const 的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。上下文在其所有代码都执行完毕之后会被销毁,包括定义在它上面的所有变量和函数。

每个 函数调用都有 自己的上下文。

上下文中的代码在执行的时候,会创建变量对象的一个 作用域链。这个作用域链 决定了 各级上下文中的代码在访问变量和函数时的 顺序 。如果上下文是函数,则其 活动对象 用作变量对象。活动对象最初只有一个定义变量:arguments;全局上下文的变量对象 始终 是作用域链的最后一个变量对象。

局部作用域中定义的变量可用于在局部上下文中替换全局变量。

4.2.1 作用域链增强

执行上下文主要有全文上下文和函数上下文

这两种情况下都会在作用域链前端添加一个变量对象。

  • try/catch 语句的 catch

    • 会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象的声明。
  • with 语句

    • 向作用域链前端添加指定的对象。

4.2.2 变量声明

  1. var的函数作用域声明

使用 var 声明变量时,变量会被 自动添加到最接近 的上下文。

  • 在函数中则是函数的局部上下文。

  • 变量未经声明就被初始化,则为全局上下文。

    严格模式下,未经声明就初始化变量会报错。

var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前,这个现象叫做“提升”,提升让同一作用域中的代码不必考虑变量是否已经声明就可以直接使用。

重复的 var 声明会被忽略。

使用 var 声明的迭代变量会泄漏到循环外部。

  1. let的块级作用域声明

块级作用域 由最近的一对包括花括号 { } 界定。

let同一作用域不能 声明两次。

let 适合在循环中声明迭代变量。

  1. const的常量声明

使用 const 声明的变量 必须同时初始化 为某个值。一旦声明,在其生命周期的任何时候都 不能再重新赋予 新值。

const 声明只应用到顶级原语或对象,即赋值为对象的 const 变量不能再被重新赋值为其他的引用值,但 对象的键则不受限制

  1. 标识符查找

作用域链中的对象也有一个原型链。

如果局部上下文中有一个同名的标识符,就不能在该上下文中引用父上下文中的同名标识符。

使用块级作用域声明并不会改变搜索流程,但可以给词法层级添加额外的层次。

4.3 垃圾回收

通过自动内存管理实现内存分配和闲置资源回收。

思路:确定哪个变量不再使用,然后释放它占用的内存,这个过程是 周期性 的,即垃圾回收程序每隔一定时间(或说再代码执行过程中某个预定的收集时间)就会自动运行。

4.3.1 标记清理

最常用的垃圾回收策略是 标记清理 ,当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的 标记 。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。
给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。标记过程的实现并不重要,关键是策略
垃圾回收程序运行的时候,会 标记 内存中存储的 所有变量(记住,标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。

4.3.2 引用计数

思路 是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为 1。如果同一个值又被赋给另一个变量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存。

4.3.3 内存管理

优化内存占用 的最佳手段就是保证在执行代码时 只保存必要 的数据。如果数据不再必要,那么把它设置为null,从而释放其引用,这也可以叫作 解除引用

解除对一个值的引用并不会自动导致相关内存被回收。解除引用的 关键在于 确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。

  1. 通过const和let声明提升性能

const 和 let 都以块(而非函数)为作用域,相比于使用 var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。

  1. 隐藏类和删除操作

运行期间,V8 会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8 会针对这种情况进行优化,但不一定总能够做到。

使用delete关键字会导致生成相同的隐藏类片段。

  1. 内存泄漏

JavaScript 中的内存泄漏大部分是由不合理的引用导致的。

意外声明全局变量 是最常见但也最容易修复的内存泄漏问题。

定时器可能会导致内存泄漏。

使用 JavaScript 闭包 容易造成内存泄漏。

  1. 静态分配与对象池

浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度。

一个策略是使用对象池。在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。

4.4 小结

JavaScript 变量可以保存两种类型的值:原始值和引用值。原始值可能是以下 6 种原始数据类型之一:Undefined、Null、Boolean、Number、String和Symbol。

  • 原始值 大小固定,因此保存在 栈内存 上。

  • 从一个变量到另一个变量复制原始值会创建该值的第二个副本。

  • 引用值 是对象,存储在 堆内存 上。

  • 包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。

  • 从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。

  • typeof 操作符可以确定值的原始类型,而 instanceof 操作符用于确保值的引用类型。

任何变量(不管包含的是原始值还是引用值)都存在于某个执行上下文中(也称为作用域)。这个上下文(作用域)决定了变量的生命周期,以及它们可以访问代码的哪些部分。

  • 执行上下文分 全局上下文、函数上下文和块级上下文

  • 代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。

  • 函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃至全局上下文中的变量。

  • 全局上下文 只能 访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据。

  • 变量的执行上下文用于确定什么时候释放内存。

JavaScript 是使用垃圾回收的编程语言,开发者不需要操心内存分配和回收。

  • 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。

  • 主流的垃圾回收算法是 标记清理 ,即先给当前不使用的值加上标记,再回来回收它们的内存。

  • 引用计数 是另一种垃圾回收策略,需要记录值被引用了多少次。JavaScript 引擎不再使用这种算法,但某些旧版本的 IE 仍然会受这种算法的影响,原因是 JavaScript 会访问非原生 JavaScript 对象(如 DOM 元素)。

  • 引用计数在代码中存在循环引用时会出现问题。

  • 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对象、全局对象的属性和循环引用都应该在不需要时解除引用。

五、基本引用类型

引用值(或者对象)是某个特定 引用类型 的实例。

对象被认为是某个特定引用类型的 实例。新对象通过使用 new 操作符后跟一个 构造函数 (constructor)来创建。构造函数就是用来创建新对象的函数。

5.1 Date

创建 日期对象,使用 new 操作符来调用 Date 构造函数:

1
let now = new Date();

给Date构造函数 传参 数的情况下,创建的对象将 保存当前日期和时间

基于其他日期和时间创建日期对象,必须 传入其毫秒表示(UNIX 纪元 1970 年 1 月 1 日午夜之后的毫秒数)。

ECMAScript提供了两个辅助方法:**Date.parse()和Date.UTC()**。

  1. Date.parse() 方法接收一个表示日期的字符串参数,将这个字符串转换为表示该日期的毫秒数。

    支持下列日期格式:

    • “月/日/年”,如”5/23/2019”;

    • “月 日, 年”,如”May 23, 2019”;

    • “周几 月名 日 年 时:分:秒 时区”,如”Tue May 23 2019 00:00:00 GMT-0700”;

    • ISO 8601 扩展格式“YYYY-MM-DDTHH:mm:ss.sssZ”,如2019-05-23T00:00:00(只适用于兼容 ES5 的实现)。

    若传入的字符串不表示日期,则返回NaN。

  2. Date.UTC() 方法返回日期的毫秒表示。

    • 传给 Date.UTC() 的参数是年、零起点月数(1 月是 0,2 月是 1,以此类推)、日(131)、时(023)、分、秒和毫秒。这些参数中,只有前两个(年和月)是必需的。如果不提供日,那么默认为 1 日。其他参数的默认值都是 0。

与Date.parse()一样,Date.UTC()也会被Date构造函数 隐式调用 ,但存在个区别:这种情况下创建的是本地日期,不是 GMT 日期。

  1. Date.now() 方法,返回表示方法 执行时 日期和时间的毫秒数。
    1
    2
    3
    4
    5
    6
    7
    8
    可以方便地用在代码分析中: 
    // 起始时间
    let start = Date.now();
    // 调用函数
    doSomething();
    // 结束时间
    let stop = Date.now(),
    result = stop - start;

5.1.1 继承的方法

Date 类型 重写了 toLocaleString()、toString() 和 valueOf()方法

与其他类型不同,重写后这些方法的返回值不一样。

Date 类型的 toLocaleString() 方法返回与浏览器运行的 本地环境 一致的日期和时间。这通常意味着格式中包含针对时间的 AM(上午)或 PM(下午),但不包含时区信息(具体格式可能因浏览器而不同)。

toString() 方法通常返回 带时区信息 的日期和时间,而时间也是以 24 小时制(0~23)表示的。

Date 类型的 valueOf() 方法根本就 不返回字符串 ,这个方法被重写后返回的是日期的毫秒表示。因此,操作符(如小于号和大于号)可以直接使用它返回的值。

5.1.2 日期格式化方法

Date类型有几个专门用于格式化日期的方法,都 返回字符串

  • toDateString() 显示日期中的周几、月、日、年(格式特定于实现);

  • toTimeString() 显示日期中的时、分、秒和时区(格式特定于实现);

  • toLocaleDateString() 显示日期中的周几、月、日、年(格式特定于实现和地区);

  • toLocaleTimeString() 显示日期中的时、分、秒(格式特定于实现和地区);

  • toUTCString() 显示完整的 UTC 日期(格式特定于实现)。

5.1.3 日期/时间组件方法

方法 说明
getTime() 返回日期的毫秒表示;与valueOf()相同
setTime(milliseconds) 设置日期的毫秒表示,从而修改整个日期
getFullYear() 返回 4 位数年(即 2019 而不是 19)
getUTCFullYear() 返回 UTC 日期的 4 位数年
setFullYear(year) 设置日期的年(year必须是 4 位数)
setUTCFullYear(year) 设置 UTC 日期的年(year必须是 4 位数)
getMonth() 返回日期的月(0 表示 1 月,11 表示 12 月)
setMonth(month) 设置日期的月(month为大于 0 的数值,大于 11 加年)
getDate() 返回日期中的日(1~31)
setDate(date) 设置日期中的日(如果date大于该月天数,则加月)
getDay() 返回日期中表示周几的数值(0 表示周日,6 表示周六)
getHours() 返回日期中的时(0~23)
setHours(hours) 设置日期中的时(如果hours大于 23,则加日)
getMinutes() 返回日期中的分(0~59)
setMinutes(minutes) 设置日期中的分(如果minutes大于 59,则加时)
getSeconds() 返回日期中的秒(0~59)
setSeconds(seconds) 设置日期中的秒(如果seconds大于 59,则加分)
getMilliseconds() 返回日期中的毫秒
setMilliseconds(milliseconds) 设置日期中的毫秒
getTimezoneOffset() 返回以分钟计的 UTC 与本地时区的偏移量(如美国 EST 即“东部标准时间”
返回 300,进入夏令时的地区可能有所差异)

“UTC 日期”,指的是没有时区偏移(将日期转换为 GMT)时的日期。

5.2 RegExp

通过 RegExp 类型支持 正则表达式。正则表达式使用类似 Perl 的简洁语法来创建

1
let expression =/pattern/flags;

pattern(模式)可以是 任何 简单或复杂的正则表达式,包括字符类、限定符、分组、向前查找和反向引用。每个正则表达式可以带 零个 或 多个flags (标记),用于控制正则表达式的行为。

表示匹配模式的标记:

  • g: 全局模式,表示查找字符串的全部内容,而不是找到第一个匹配的内容就结束。

  • i: 不区分大小写,表示在查找匹配时忽略 pattern 和字符串的大小写。

  • m: 多行模式,表示查找到一行文本末尾时会继续查找。

  • y: 粘附模式,表示只查找从 lastIndex 开始及之后的字符串。

  • u: Unicode 模式,启用 Unicode 匹配。

  • s: dotAll模式,表示元字符 . 匹配任何字符(包括\n或\r)。

所有 元字符 在模式中也必须转义。

元字符在正则表达式中都有一种或多种特殊功能,所以要匹配上面这些字符本身,就必须使用 反斜杠 来转义。

1
2
3
4
5
6
7
8
9
10
11
// 匹配第一个"bat"或"cat",忽略大小写 
let pattern1 = /[bc]at/i;

// 匹配第一个"[bc]at",忽略大小写
let pattern2 = /\[bc\]at/i;

// 匹配所有以"at"结尾的三字符组合,忽略大小写
let pattern3 = /.at/gi;

// 匹配所有".at",忽略大小写
let pattern4 = /\.at/gi;

正则表达式可以使用 RegExp 构造函数来创建,它接收两个参数:模式字符串 和(可选的)标记字符串。任何使用字面量定义的正则表达式也可以通过构造函数来创建。

使用 RegExp 可以基于已有的正则表达式实例,并可选择性地修改它们的标记。

5.2.1 RegExp实例属性

每个RegExp实例都有下列属性,提供有关模式的各方面信息。

  • global:布尔值,表示是否设置了 g 标记。

  • ignoreCase:布尔值,表示是否设置了 i 标记。

  • unicode:布尔值,表示是否设置了 u 标记。

  • sticky:布尔值,表示是否设置了 y 标记。

  • lastIndex:整数,表示在源字符串中下一次搜索的开始位置,始终从 0 开始。

  • multiline:布尔值,表示是否设置了 m 标记。

  • dotAll:布尔值,表示是否设置了 s 标记。

  • source:正则表达式的字面量字符串(不是传给构造函数的模式字符串),没有开头和结尾的斜杠。

  • flags:正则表达式的标记字符串。始终以字面量而非传入构造函数的字符串模式形式返回(没有前后斜杠)。

5.2.2 RegExp 实例方法

RegExp实例的主要方法是 exec() ,主要用于 配合捕获组 使用。这个方法 接收 一个参数,即要应用模式的字符串。如果找到了匹配项,则返回包含 第一个 匹配信息的数组;如果没找到匹配项,则 返回null 。返回的 数组 虽然是 Array的实例,但 包含两个 额外的属性: index 和 inputindex 是字符串中匹配模式的 起始位置input 是要 查找的字符串

1
2
3
4
5
6
7
8
9
let text = "mom and dad and baby"; 
let pattern = /mom( and dad( and baby)?)?/gi;

let matches = pattern.exec(text);
console.log(matches.index); // 0
console.log(matches.input); // "mom and dad and baby"
console.log(matches[0]); // "mom and dad and baby"
console.log(matches[1]); // " and dad and baby"
console.log(matches[2]); // " and baby"

如果模式设置了 全局标记,则 每次调用 exec() 方法会返回一个匹配的信息。如果 没有设置 全局标记,则无论对同一个字符串调用多少次exec(),也 只会返回 第一个匹配的信息。

lastIndex在非全局模式下始终不变。

正则表达式的另一个方法是 test() ,接收一个字符串参数。如果输入的文本与模式匹配,则参数返回 true,否则返回 false。这个方法适用于只想 测试模式是否匹配 ,而不需要实际匹配内容的情况。

正则表达式继承的方法 toLocaleString()和toString() 都返回正则表达式的字面量表示,valueOf() 方法返回正则表达式本身。

5.2.3 RegExp 构造函数属性

RegExp构造函数本身有几个属性。(在其他语言中,这种属性被称为静态属性。)这些属性适用于作用域中的所有正则表达式,而且会根据最后执行的正则表达式操作而变化。这些属性有一个 特点 ,就是可以通过两种不同的方式访问它们。换句话说,每个属性都有一个全名和一个简写。

全名 简写 说明
input $_ 最后搜索的字符串(非标准特性)
lastMatch $& 最后匹配的文本
lastParen $+ 最后匹配的捕获组(非标准特性)
leftContext $` input字符串中出现在lastMatch前面的文本
rightContext $’ input字符串中出现在lastMatch后面的文本

5.3 原始值包装类型

3 种特殊的引用类型:Boolean、Number和String。

每当用到某个原始值的方法或属性时,后台都会创建一个相应原始包装类型的对象,从而暴露出操作原始值的各种方法。

引用类型与原始值包装类型的 主要区别 在于对象的生命周期。在通过 new 实例化引用类型后,得到的实例会在 离开作用域时被销毁 ,而自动创建的原始值包装对象则 只存在 于访问它的那行代码执行期间。这意味着不能在运行时给原始值添加属性和方法。

5.3.1 Boolean

Boolean 是对应布尔值的引用类型。要创建一个Boolean对象,就使用Boolean构造函数并传入 true 或 false

1
let booleanObject = new Boolean(true);

Boolean 的实例会重写 valueOf() 方法,返回一个原始值 true 或 falsetoString() 方法被调用时也会被覆盖,返回**字符串 “true” 或 “false”**。

原始值和引用值(Boolean对象)的 区别

  • typeof 操作符对原始值返回 “boolean” ,但 对引用值 返回 **”object”**。

  • Boolean对象是Boolean类型的实例,在使用 instaceof 操作符时 返回true,但对原始值则返回 false

5.3.2 Number

Number 是对应数值的引用类型。要创建一个 Number 对象,就使用 Number 构造函数并传入一个数值。

1
let numberObject = new Number(10); 

Number 类型重写了 valueOf()、toLocaleString()和toString() 方法。valueOf() 方法返回 Number 对象表示的 原始数值,另外两个方法返回 数值字符串toString() 方法可选地接收一个表示 基数 的参数,并返回相应基数形式的数值字符串。

Number 类型有几个用于将数值格式化为字符串的方法。

  1. toFixed() 方法返回包含指定小数点位数的数值字符串,如果数值本身的小数位超过了参数指定的位数,则四舍五入到最接近的小数位。

  2. toExponential() 方法返回以科学记数法(也称为指数记数法)表示的数值字符串,接收一个参数,表示结果中小数的位数。

  3. toPrecision() 方法可以表示带 1~21 个小数位的数值,接收一个参数,表示结果中数字的总位数(不包含指数)。本质上,toPrecision() 方法会根据数值和精度来决定调用**toFixed() 还是 toExponential()**。

ES6 新增了 Number.isInteger() 方法,用于辨别一个数值是否保存为整数。

5.3.3 String

String 是对应字符串的引用类型。要创建一个 String 对象,使用String构造函数并传入一个数值。

1
let stringObject = new String("hello world");

String 象的方法可以在所有字符串原始值上调用。3 个继承的方法 valueOf()、toLocaleString() 和 toString() 都返回对象的原始字符串值。
每个 String 对象都有一个 length 属性,表示字符串中字符的数量。

  1. JavaScript 字符

JavaScript 字符串由 16 位 码元(code unit)组成。对多数字符来说,每 16 位码元对应一个字符。即字符串的 length 属性表示字符串包含多少 16 位码元。

  • charAt() 方法返回给定索引位置的字符,由传给方法的整数参数指定。
1
2
let message = "abcde"; 
console.log(message.charAt(2)); // "c"
  • JavaScript 字符串使用了两种 Unicode 编码混合的策略:UCS-2 和 UTF-16

对于可以采用 16 位编码的字符(U+0000~U+FFFF),这两种编码实际上是一样的。

  • charCodeAt() 方法可以查看指定码元的字符编码。这个方法返回指定索引位置的码元值,索引以整数指定。

    1
    2
    3
    4
    5
    let message = "abcde"; 
    // Unicode "Latin small letter C"的编码是 U+0063
    console.log(message.charCodeAt(2)); // 99
    // 十进制 99 等于十六进制 63
    console.log(99 === 0x63); // true
  • fromCharCode() 方法用于根据给定的 UTF-16 码元创建字符串中的字符。这个方法可以接受任意多个数值,并返回将所有数值对应的字符拼接起来的字符串。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // Unicode "Latin small letter A"的编码是 U+0061 
    // Unicode "Latin small letter B"的编码是 U+0062
    // Unicode "Latin small letter C"的编码是 U+0063
    // Unicode "Latin small letter D"的编码是 U+0064
    // Unicode "Latin small letter E"的编码是 U+0065

    console.log(String.fromCharCode(0x61, 0x62, 0x63, 0x64, 0x65)); // "abcde"

    // 0x0061 === 97
    // 0x0062 === 98
    // 0x0063 === 99
    // 0x0064 === 100
    // 0x0065 === 101

    console.log(String.fromCharCode(97, 98, 99, 100, 101)); // "abcde"

16 位只能唯一表示 65 536 个字符。这对于大多数语言字符集是足够了,在 Unicode 中称为 基本多语言平面(BMP)。为了表示更多的字符,Unicode 采用了一个策略,即每个字符使用另外 16 位去选择一个增补平面。这种每个字符使用两个 16 位码元的策略称为 代理对

为正确解析既包含单码元字符又包含代理对字符的字符串,可以使用 codePointAt() 来代替 charCodeAt()codePointAt() 接收 16 位码元的索引并返回该索引位置上的码点(code point)。码点是 Unicode 中一个字符的完整标识。

比如,”c”的码点是 0x0063,而”☺”的码点是 0x1F60A。码点可能是 16 位,也可能是 32 位,而codePointAt()方法可以从指定码元位置识别完整的码点。

如果传入的码元索引并非代理对的开头,就会返回错误的码点。

charCodeAt() 有对应的 codePointAt() 一样,fromCharCode() 也有一个对应的 fromCodePoint() 。这个方法接收任意数量的码点,返回对应字符拼接起来的字符串。

  1. normalize()方法

这 4 种规范化形式是:NFD(Normalization Form D)、NFC(Normalization Form C)、NFKD(Normalization Form KD)和 NFKC(Normalization Form KC)。可以使用 normalize() 方法对字符串应用上述规范化形式。

  1. 字符串操作方法
  • concat() 用于将一个或多个字符串 拼接 成一个新字符串。
    • 可以接收任意多个参数。

      多数情况下,对于拼接多个字符串来说,使用加号更方便。

ECMAScript 提供了 3 个从字符串中 提取子字符串 的方法 **slice()、substr() 和 substring()**。

  • 都返回调用它们的字符串的一个子字符串,而且都 接收 一或两个参数。第一个参数表示子字符串 开始的位置 ,第二个参数表示子字符串 结束的位置

  • slice() 和 substring() 而言,第二个参数是提取结束的位置(即该位置 之前的字符 会被提取出来)。

  • substr() 而言,第二个参数表示返回的子字符串数量。

  • 任何情况下,省略第二个参数都意味着提取到字符串末尾。

  • 不会修改调用它们的字符串,而只会返回提取到的原始新字符串值。

  • 当某个参数是 负值 时,slice() 方法将所有负值参数都当成字符串长度加上负参数值;
    substr() 方法将第一个负参数值当成字符串长度加上该值,将第二个负参数值转换为 0;substring() 方法会将所有负参数值都转换为 0。

  1. 字符串位置方法

在字符串中 定位 子字符串 :**indexOf()和lastIndexOf()**。

  • 从字符串中搜索传入的字符串,并返回位置(如果没找到,则返回-1)。

  • 区别indexOf() 方法从字符串 开头 开始查找子字符串,而 lastIndexOf() 方法从字符串 末尾 开始查找子字符串。

  • 可以接收可选的第二个参数,表示开始搜索的位置。

    • indexOf() 会从这个参数指定的位置开始向字符串末尾搜索,忽略 该位置之前的字符。

    • lastIndexOf() 会从这个参数指定的位置开始向字符串开头搜索,忽略 该位置之后直到字符串末尾的字符。

  1. 字符串包含方法

3 个用于判断字符串中是否包含另一个字符串的方法:**startsWith()、endsWith() 和 includes()**。

  • 会从字符串中搜索传入的字符串,并 返回 一个表示是否包含的 布尔值

  • 区别:startsWith() 检查开始于索引 0 的匹配项;endsWith() 检查开始于索
    引( string.length - substring.length )的匹配项;includes() 检查整个字符串。

  • startsWith() 和 includes() 方法接收可选的第二个参数,表示开始搜索的位置。如果传入第二个参数,则意味着这两个方法会从 指定位置 向着字符串末尾搜索,忽略该位置之前的所有字符。

  • endsWith() 方法接收可选的第二个参数,表示应该当作字符串末尾的位置。如果不提供这个参数,那么默认就是字符串长度。

  1. trim()方法

创建字符串的一个副本,删除前、后所有空格符,再返回结果。

  • trim() 返回的是字符串的 副本,因此原始字符串不受影响。

  • trimLeft() 和 trimRight() 方法分别用于从字符串开始和末尾清理空格符。

  1. repeat()方法

接收一个整数参数,表示要将字符串 复制 多少次,然后返回拼接 所有副本 后的结果。

  1. padStart()和 padEnd()方法

padStart() 和 padEnd() 方法会 复制 字符串。

  • 如果小于指定长度,则在相应一边填充字符,直至满足长度条件。

  • 第一个参数是长度,第二个参数是可选的填充字符串,默认为空格。

  • 可选的第二个参数并不限于一个字符。如果提供了多个字符的字符串,则会将其拼接并截断以匹配指定长度。

  • 如果长度小于或等于字符串长度,则会返回原始字符串。

  1. 字符串迭代和结构

字符串的原型上暴露了一个 @@iterator 方法,表示可以迭代字符串的每个字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
手动使用迭代器: 
let message = "abc";
let stringIterator = message[Symbol.iterator]();

console.log(stringIterator.next()); // {value: "a", done: false}
console.log(stringIterator.next()); // {value: "b", done: false}
console.log(stringIterator.next()); // {value: "c", done: false}
console.log(stringIterator.next()); // {value: undefined, done: true}
for-of循环中可以通过这个迭代器按序访问每个字符:
for (const c of "abcde") {
console.log(c);
}

// a
// b
// c
// d
// e

有了这个迭代器之后,字符串就可以通过解构操作符来解构了。

  1. 字符串大小写转换

**toLowerCase()、toLocaleLowerCase()、toUpperCase() 和 toLocaleUpperCase()**。

1
2
3
4
5
let stringValue = "hello world"; 
console.log(stringValue.toLocaleUpperCase()); // "HELLO WORLD"
console.log(stringValue.toUpperCase()); // "HELLO WORLD"
console.log(stringValue.toLocaleLowerCase()); // "hello world"
console.log(stringValue.toLowerCase()); // "hello world"
  1. 字符串匹配模式方法

在字符串中实现模式匹配的方法。

  • match() 方法本质上跟RegExp对象的 exec() 方法相同。

    • 接收一个参数,可以是一个正则表达式字符串,也可以是一个RegExp对象

    • 返回的数组与 RegExp 对象的 exec() 方法返回的数组是一样的:第一个元素是与整个模式匹配的字符串,其余元素则是与表达式中的捕获组匹配的字符串(如果有的话)。

  • search() 方法唯一的参数与match()方法一样:正则表达式字符串或RegExp对象。

    • 返回模式第一个匹配的位置索引,如果没找到则返回-1。

    • search() 始终从字符串 开头向后 匹配模式。

  • split() 方法会根据传入的分隔符将字符串拆 分成数组

    • 作为分隔符的参数可以是字符串,也可以是 RegExp 对象。(字符串分隔符不会被这个方法当成正则表达式。)

    • 可以传入第二个参数,即 数组大小,确保返回的数组不会超过指定大小。

ECMAScript 提供了 replace() 方法子字符串 替换 操作。

  • 接收两个参数,第一个参数可以是一个 RegExp 对象或一个字符串(这个字符串不会转换为正则表达式),第二个参数可以是一个字符串或一个函数。

    • 如果第一个参数是字符串,那么只会替换第一个子字符串。要想替换所有子字符串,第一个参数 必须 为正则表达式并且带全局标记。

    • replace() 的第二个参数可以是一个函数。

  • 在只有一个匹配项时,这个函数会收到 3 个参数:与整个模式匹配的字符串、匹配项在字符串中的开始位置,以及整个字符串。

  • 在有多个捕获组的情况下,每个匹配捕获组的字符串也会作为参数传给这个函数,但最后两个参数还是与整个模式匹配的开始位置和原始字符串。这个函数应该返回一个字符串,表示应该把匹配项替换成什么。使用函数作为第二个参数可以更细致地控制替换过程。

  1. localeCompare()方法

用于比较两个字符串,返回如下 3 个值中的一个。

  • 如果按照字母表顺序,字符串应该排在字符串参数前头,则返回负值。(通常是-1,具体还要看与实际值相关的实现。)

  • 如果字符串与字符串参数相等,则返回0。

  • 如果按照字母表顺序,字符串应该排在字符串参数后头,则返回正值。(通常是1,具体还要看与实际值相关的实现。)

localeCompare() 的独特之处在于,实现所在的地区(国家和语言)决定了这个方法如何比较字符串。在美国,英语是 ECMAScript 实现的标准语言,localeCompare() 区分大小写,大写字母排在小写字母前面。但其他地区未必是这种情况。

5.4 单例内置对象

ECMA-262 对 内置对象 的定义是“任何由 ECMAScript 实现提供、与宿主环境无关,并在 ECMAScript 程序开始执行时就存在的对象”。

5.4.1 Global

Global 对象是 ECMAScript 中最特别的对象,因为代码不会显式地访问它。

ECMA-262 规定 Global 对象为一种 兜底对象,它所针对的是不属于任何对象的属性和方法。事实上,不存在全局变量或全局函数这种东西。在全局作用域中定义的变量和函数都会变成 Global 对象的属性。

  1. URL编码方法

encodeURI() 和 encodeURIComponent() 方法用于编码统一资源标识符(URI),以便传给浏览器。有效的 URI 不能包含某些字符,比如空格。

  • encodeURI() 方法用于对整个 URI 进行编码,比如”www.wrox.com/illegal value.js”。

  • encodeURIComponent() 方法用于编码 URI 中单独的组件,比如前面 URL 中的”illegal value.js”。

两者区别:

  • encodeURI() 不会编码属于 URL 组件的特殊字符,比如冒号、斜杠、问号、
    井号。

  • encodeURIComponent() 会编码它发现的所有非标准字符。

一般来说,使用 encodeURIComponent() 应该比使用 encodeURI() 的频率更高,这是因为编码查询字符串参数比编码基准 URI 的次数更多。

decodeURI() 和 decodeURIComponent() 用于解码。

  • decodeURI() 只对使用 encodeURI() 编码过的字符解码。。

  • decodeURIComponent() 解码所有被 encodeURIComponent() 编码的字符,基本上就是解码所有特殊值。

  1. eval() 方法

这个方法就是一个完整的 ECMAScript 解释器,它接收一个参数,即一个要执行的 ECMAScript(JavaScript)字符串。

1
2
3
eval("console.log('hi')"); 
上面这行代码的功能与下一行等价:
console.log("hi");

通过 eval() 执行的代码属于该 调用所在上下文 ,被执行的代码与该上下文拥有相同的作用域链。这意味着定义在包含上下文中的变量可以在 eval() 调用内部被引用

  • 可以在 eval() 内部定义一个函数或变量,然后在外部代码中引用。

  • 通过 eval() 定义的任何变量和函数都不会被提升,这是因为在解析代码的时候,它们是被包含在一个字符串中的。它们只是在 eval() 执行的时候才会被创建。

  • 在严格模式下,在 eval() 内部创建的变量和函数无法被外部访问。

  1. window对象

所有全局作用域中声明的变量和函数都变成了 window 的属性。

当一个函数再没有明确指定 this 值得情况下执行时,this 值等于 Global 对象。

5.4.2 Math

Math 对象用于保存数学公式、信息和计算。

Math 有些属性主要用于保存数学中得一些特殊值。

  1. min() 和 max() 方法

用于确定一组数值中的最小值和最大值。

  1. 舍入方法
  • Math.ceil() 方法始终向上舍入为最接近的整数。

  • Math.floor() 方法始终向下舍入为最接近的整数。

  • Math.round() 方法执行四舍五入。

  • Math.fround() 方法返回数值最接近的单精度(32位)浮点值表示。

  1. random() 方法

返回一个 0~1 范围内的随机数,其中包含 0 但不包含 1

5.5 小结

JavaScript 中的对象称为引用值,几种内置的引用类型可用于创建特定类型的对象。

  • 引用值与传统面向对象编程语言中的类相似,但实现不同。

  • Date 类型提供关于日期和时间的信息,包括当前日期、时间及相关计算。

  • RegExp 类型是 ECMAScript 支持正则表达式的接口,提供了大多数基础的和部分高级的正则表达式功能。

JavaScript中函数实际上是Function类型的实例,也就是说函数也是对象。因为函数也是对象,所以函数也有方法,可以用于增强其能力。

由于原始值包装类型的存在,JavaScript 中的原始值可以被当成对象来使用。有 3 种原始值包装类型:Boolean、Number 和 String

  • 每种包装类型都映射到同名的原始类型。

  • 以读模式访问原始值时,后台会实例化一个原始值包装类型的对象,借助这个对象可以操作相应的数据。

  • 涉及原始值的语句执行完毕后,包装对象就会被销毁。

当代码开始执行时,全局上下文中会存在两个内置对象:Global 和 Math。其中,Global 对象在大多数 ECMAScript 实现中无法直接访问。浏览器将其实现为window对象。所有全局变量和函数都是 Global 对象的属性

六、集合引用类型

6.1 Object

ObjectECMAScript 中最常用的类型之一,适合存储和在应用程序间交换数据。

显式地创建 Object 的实例有两种方式。

  1. 使用 new 操作符和 Object 构造函数

  2. 使用对象字面量(object literal)表示法。对象字面量是对象定义的简写形式。

    在对象字面量表示法中,属性名可以是字符串或数值。

    可用来定义一个只有默认属性和方法的对象。

    在使用对象字面量表示法定义对象时,并不会实际调用 Object 构造函数。

    数值属性会自动转换为字符串。

属性存取方式

  1. 使用中括号的主要优势就是可以通过变量访问属性。

  2. 点语法是首选的属性存取方式,除非访问属性时必须使用变量。

6.2 Array

ECMAScript 数组也是一组有序的数据,数组中每个槽位可以 存储任意类型 的数据。ECMAScript 数组也是动态大小的。

6.2.1 创建数组

  1. 使用 Array 构造函数

    • 给构造函数传入一个数值。

    • 给构造函数传入要保存的元素。

    使用 Array 构造函数时,也可以省略 new 操作符。

  2. 使用数组字面量(array literal)表示法

与对象一样,在使用数组字面量表示法创建数组不会调用 Array 构造函数。

Array 构造函数用于创建数组的静态方法:**from() 和 of()**。

  1. from() 用于将类数组结构转换为数组实例。

    Array.from() 的第一个参数是一个类数组对象,即任何可迭代的结构,或者有一个length属性和可索引元素的结构。

    • 可将集合和映射转换为一个新数组。

    • 对现有数组执行浅复制。

    • 能转换带有必要属性的自定义对象。

    接收第二个可选的映射函数参数。这个函数可以直接增强新数组的值。

    接收第三个可选参数,用于指定映射函数中this的值。但这个重写的this值在箭头函数中不适用。

  2. of() 用于将一组参数转换为数组实例。

6.2.2 数组空位

用数组字面量初始化数组时,可以使用一串逗号来创建空位(hole)。

ES6 新增方法普遍将这些空位当成存在的元素,只不过值为 undefined

map()会跳过空位置。

join()视空位置为空字符串。

6.2.3 数组索引

要取得或设置数组的值,需要使用中括号并提供相应值的数字索引。

数组 length 属性的独特之处在于,它不是只读的。通过修改length属性,可以从数组末尾删除或添加元素。

6.2.4 检测数组

  1. 使用 instanceof 操作符

  2. Array.isArray() 方法

6.2.5 迭代器方法

ES6 中,Array 的原型上暴露了 3 个用于检索数组内容的方法:**keys()、values() 和 entries()**。

  • keys() 返回数组索引的迭代器

  • values() 返回数组元素的迭代器

  • entries() 返回索引/值对的迭代器

6.2.6 复制和填充方法

  • 批量复制方法 copyWithin()

    • 按照指定范围浅复制数组中的部分内容,然后将它们插入到指定索引开始的位置。开始索引和结束索引则与 fill() 使用同样的计算方法。
  • 填充数组方法 fill()

    • 可向一个已有的数组中插入全部或部分相同的值。开始索引用于指定开始填充的位置,它是可选的。如果不提供结束索引,则一直填充到数组末尾。

    • fill() 静默忽略超出数组边界、零长度及方向相反的索引范围。

需要指定既有数组实例上的一个范围,包含开始索引,不包含结束索引。使用这个方法不会改变数组的大小。

6.2.7 转换方法

所有对象都有 toLocaleString()、toString() 和 valueOf() 方法。

  • valueOf() 返回的是数组本身。

  • toString() 返回由数组中每个值的等效字符串拼接而成的一个逗号分隔的字符串。

  • toLocaleString() 方法也可能返回跟 toString()valueOf() 相同的结果,在调用数组的 toLocaleString() 方法时,会得到一个逗号分隔的数组值的字符串。它与另外两个方法唯一的区别是,为了得到最终的字符串,会调用数组每个值的 toLocaleString() 方法。

如果数组中某一项是null 或 undefined,则在join()、toLocaleString()、toString()和valueOf()返回的结果中会以空字符串表示。

6.2.8 栈方法

栈是一种后进先出(LIFO,Last-In-First-Out)的结构,也就
是最近添加的项先被删除。数据项的插入(称为推入,push)和删除(称为弹出,pop)只在栈的一个地方发生,即栈顶。ECMAScript 数组提供了 push() 和 pop() 方法。

  • push() 方法接收任意数量的参数,并将它们添加到数组末尾,返回数组的最新长度。

  • pop() 方法则用于删除数组的最后一项,同时减少数组的length值,返回被删除的项。

6.2.9 队列方法

队列以先进先出(FIFO,First-In-First-Out)形式限制访问。队列在列表末尾添加数据,但从列表开头获取数据。

  • push() 方法在数据末尾添加数据的。

  • shift() 方法会删除数组的第一项并返回它,然后数组长度减 1。

  • unshift() 方法在数组开头添加任意多个值,然后返回新的数组长度。

  • pop() 在数组末尾取得数据。

6.2.10 排序方法

数组用来对元素重新排序:**reverse() 和 sort()**。

默认情况下,sort() 会按照升序重新排列数组元素。

sort() 方法可以接收一个比较函数,用于判断哪个值应该排在前。

reverse()和sort()都返回调用它们的数组的引用。

6.2.11 操作方法

  1. concat() 方法可以在现有数组全部元素基础上创建一个新数组。

  2. slice() 方法可以接收一个或两个参数:返回元素的开始索引和结束索引。如果只有一个参数,则slice()会返回该索引到数组末尾的所有元素。如果有两个参数,则slice()返回从开始索引到结束索引对应的所有元素,其中不包含结束索引对应的元素。不影响原始数组。

  3. splice() 的主要目的是在数组中间插入元素

    • 删除。需要给 splice() 传 2 个参数:要删除的第一个元素的位置和要删除的元素数量。可以从数组中删除任意多个元素。

    • 插入。需要给 splice() 传 3 个参数:开始位置、0(要删除的元素数量)和要插入的元素,可在数组中指定的位置插入元素。第三个参数之后还可以传第四个、第五个参数,乃至任意多个要插入的元素。比如,splice(2, 0, “red”, “green”)会从数组位置 2 开始插入字符串”red”和”green”。

    • 替换。splice() 在删除元素的同时可以在指定位置插入新元素,同样要传入 3 个参数:开始位置、要删除元素的数量和要插入的任意多个元素。要插入的元素数量不一定跟删除的元素数量一致。比如,splice(2, 1, “red”, “green”)会在位置 2 删除一个元素,然后从该位置开始向数组中插入”red”和”green”。

    splice() 方法始终返回这样一个数组,它包含从数组中被删除的元素(如果没有删除元素,则返回空数组)。

6.2.12 搜索和位置方法

  1. 严格相等

ECMAScript 提供了 3 个严格相等的搜索方法:**indexOf()、lastIndexOf()和includes()**。

这些方法都接收两个参数:要查找的元素和一个可选的起始搜索位置。indexOf() 和 includes() 方法从数组前头(第一项)开始向后搜索,而 lastIndexOf() 从数组末尾(最后一项)开始向前搜索。
indexOf() 和 lastIndexOf() 都返回要查找的元素在数组中的位置,如果没找到则返回1。
includes() 返回布尔值,表示是否至少找到一个与指定元素匹配的项。在比较第一个参数跟数组每一项时,会使用全等(===)比较。

  1. 断言函数

按照定义的断言函数搜索数组,每个索引都会调用这个函数。断言函数的返回值决定了相应索引的元素是否被认为匹配。
断言函数接收 3 个参数:元素、索引和数组本身。其中元素是数组中当前搜索的元素,索引是当前元素的索引,而数组就是正在搜索的数组。断言函数返回真值,表示是否匹配。

find() 和 findIndex() 方法使用了断言函数。这两个方法都从数组的最小索引开始。

find() 返回第一个匹配的元素,findIndex() 返回第一个匹配元素的索引。这两个方法也都接收第二个可选的参数,用于指定断言函数内部this的值。

6.2.13 迭代方法

数组定义了 5 个迭代方法。每个方法接收两个参数:以每一项为参数运行的函数,以及可选的作为函数运行上下文的作用域对象(影响函数中 this 的值)。传给每个方法的函数接收 3
个参数:数组元素、元素索引和数组本身。

  • **every()**:对数组每一项都运行传入的函数,如果对每一项函数都返回true,则这个方法返回true。

  • **filter()**:对数组每一项都运行传入的函数,函数返回true的项会组成数组之后返回。

  • **forEach()**:对数组每一项都运行传入的函数,没有返回值。

  • **map()**:对数组每一项都运行传入的函数,返回由每次函数调用的结果构成的数组。

  • **some()**:对数组每一项都运行传入的函数,如果有一项函数返回 true,则这个方法返回 true

这些方法都不改变调用它们的数组。

6.2.14 归并方法

ECMAScript 为数组提供了两个归并方法:reduce()和reduceRight() 。这两个方法都会迭代数组的所有项,并在此基础上构建一个最终返回值。

reduce() 方法从数组第一项开始遍历到最后一项。

reduceRight() 从最后一项开始遍历至第一项。

这两个方法都接收两个参数:对每一项都会运行的归并函数,以及可选的以之为归并起点的初始值。
传给 reduce() 和 reduceRight() 的函数接收 4 个参数:上一个归并值、当前项、当前项的索引和数组本身。这个函数返回的任何值都会作为下一次调用同一个函数的第一个参数。如果没有给这两个方法传入可选的第二个参数(作为归并起点值),则第一次迭代将从数组的第二项开始,因此传给归并函数的第一个参数是数组的第一项,第二个参数是数组的第二项。

6.3 定型函数

定型数组(typed array)是 ECMAScript 新增的结构,目的是提升向原生库传输数据的效率。

6.3.1 ArrayBuffer

Float32Array 实际上是一种“视图”,可以允许 JavaScript 运行时访问一块名为 ArrayBuffer 的预分配内存。ArrayBuffer 是所有定型数组及视图引用的基本单位。

ArrayBuffer() 是一个普通的 JavaScript 构造函数,可用于在内存中分配特定数量的字节空间。

ArrayBuffer 一经创建就不能再调整大小。可以使用slice() 复制其全部或部分到一个新实例中。

ArrayBuffer 某种程度上类似于 C++的malloc(),但有几个明显的区别。

  • malloc()在分配失败时会返回一个null指针。ArrayBuffer在分配失败时会抛出错误。

  • malloc()可以利用虚拟内存,因此最大可分配尺寸只受可寻址系统内存限制。ArrayBuffer分配的内存不能超过Number.MAX_SAFE_INTEGER(253  1)字节。

  • malloc()调用成功不会初始化实际的地址。声明ArrayBuffer则会将所有二进制位初始化为 0。

  • 通过malloc()分配的堆内存除非调用free()或程序退出,否则系统不能再使用。而通过声明ArrayBuffer分配的堆内存可以被当成垃圾回收,不用手动释放。

要读取或写入 ArrayBuffer,就必须通过视图。视图有不同的类型,但引用的都是 ArrayBuffer 中存储的二进制数据。

6.3.2 DataView

第一种允许你读写 ArrayBuffer 的视图是 DataView。这个视图专为文件 I/O 和网络 I/O 设计,其API 支持对缓冲数据的高度控制,但相比于其他类型的视图性能也差一些。DataView 对缓冲内容没有任何预设,也不能迭代。 必须在对已有的 ArrayBuffer 读取或写入时才能创建 DataView 实例。这个实例可以使用全部或部分 ArrayBuffer,且维护着对该缓冲实例的引用,以及视图在缓冲中开始的位置。

要通过 DataView 读取缓冲,还需要几个组件。

  • 首先是要读或写的字节偏移量。可以看成 DataView 中的某种“地址”。

  • DataView 应该使用 ElementType 来实JavaScriptNumber 类型到缓冲内二进制格式的转换。

  • 最后是内存中值的字节序。默认为大端字节序。

  1. ElementTypeDataView 对存储在缓冲内的数据类型没有预设。它暴露的 API 强制开发者在读、写时指定一个 ElementType ,然后 DataView 就会忠实地为读、写而完成相应的转换。
    ECMAScript 6 支持 8 种不同的 ElementType

DataView 为上表中的每种类型都暴露了 get和set 方法,这些方法使用byteOffset(字节偏移量)定位要读取或写入值的位置。类型是可以互换使用的。

  1. 字节序

“字节序”指的是计算系统维护的一种字节顺序的约定。

DataView 只支持两种约定:大端字节序和小端字节序。大端字节序也称为“网络字节序”,意思是最高有效位保存在第一个字节,而最低有效位保存在最后一个字节。小端字节序正好相反,即最低有效位保存在第一个字节,最高有效位保存在最后一个字节。
JavaScript 运行时所在系统的原生字节序决定了如何读取或写入字节,但 DataView 并不遵守这个约定。对一段内存而言,DataView 是一个中立接口,它会遵循你指定的字节序。DataView 的所有 API 方法都以大端字节序作为默认值,但接收一个可选的布尔值参数,设置为 true 即可启用小端字节序。

  1. 边界情形

DataView 完成读、写操作的前提是必须有充足的缓冲区,否则就会抛出RangeError

6.3.4 定型数组

定型数组是另一种形式的 ArrayBuffer 视图。虽然概念上与DataView接近,但定型数组的区别在于,它特定于一种ElementType 且遵循系统原生的字节序。相应地,定型数组提供了适用面更广的API 和更高的性能。

设计定型数组的目的就是提高与 WebGL 等原生库交换二进制数据的效率。

创建定型数组的方式包括读取已有的缓冲、使用自有缓冲、填充可迭代结构,以及填充基于任意类型的定型数组。另外,通过 <ElementType>.from()和<ElementType>.of() 也可以创建定型数组。

定型数组的构造函数和实例都有一个 BYTES_PER_ELEMENT 属性,返回该类型数组中每个元素的大小。

如果定型数组没有用任何值初始化,则其关联的缓冲会以 0 填充。

1.定性数组行为

定型数组支持如下操作符、方法和属性:

  • []

  • copyWithin()

  • entries()

  • every()

  • fill()

  • filter()

  • find()

  • findIndex()

  • forEach()

  • indexOf()

  • join()

  • keys()

  • lastIndexOf()

  • length

  • map()

  • reduce()

  • reduceRight()

  • reverse()

  • slice()

  • some()

  • sort()

  • toLocaleString()

  • toString()

  • values()

    其中,返回新数组的方法也会返回包含同样元素类型(element type)的新定型数组。

  1. 合并复制和修改定型数组

定型数组同样使用数组缓冲来存储数据,而数组缓冲无法调整大小。因此,下列方法不适用于定型数组: concat() 、pop() 、push() 、shift() 、splice() 、unshift()。

定型数组提供了两个新方法,可以快速向外或向内复制数据:**set() 和 subarray()**。

set() 从提供的数组或定型数组中把值复制到当前定型数组中指定的索引位置。

subarray() 会基于从原始定型数组中复制的值返回一个新定型数组。复制值时的开始索引和结束索引是可选的。

  1. 下溢和上溢

定型数组中值的下溢和上溢不会影响到其他索引,但仍然需要考虑数组的元素应该是什么类型。定型数组对于可以存储的每个索引只接受一个相关位。

除了 8 种元素类型,还有一种“夹板”数组类型:Uint8ClampedArray,不允许任何方向溢出。超出最大值 255 的值会被向下舍入为 255,而小于最小值 0的值会被向上舍入为 0。

6.4 Map

Map 是一种新的集合类型,为这门语言带来了真正的键/值存储机制。

6.4.1 基本API

使用 new 关键字和 Map 构造函数可以创建一个空映射。

想在创建的同时初始化实例,可以给 Map 构造函数传入一个可迭代对象,需要包含键/值对数组。可迭代对象中的每个键/值对都会按照迭代顺序插入到新映射实例。

初始化之后,可以使用 set() 方法再添加键/值对。另外,可以使用 get() 和 has() 进行查询,可以通过 size 属性获取映射中的键/值对的数量,还可以使用 delete()和clear() 删除值。

set() 方法返回映射实例,因此可以把多个操作连缀起来,包括初始化声明。

Map 可以使用任何 JavaScript 数据类型作为键。

6.4.2 顺序与迭代

Object 类型的一个主要差异是,Map 实例会维护键值对的插入顺序,可以根据插入顺序执行迭代操作。
映射实例可提供一个迭代器(Iterator),能以插入顺序生成 [key, value] 形式的数组。可以通过 entries() 方法(或者 Symbol.iterator 属性,它引用 entries() )取得这个迭代器。

entries() 是默认迭代器,可以直接对映射实例使用扩展操作,把映射转换为数组。

6.4.3 Object 和 Map 的选择

  1. 内存占用

Object 和 Map 的工程级实现在不同浏览器间存在明显差异,但存储单个键/值对所占用的内存数量都会随键的数量线性增加。批量添加或删除键/值对则取决于各浏览器对该类型内存分配的工程实现。不同浏览器的情况不同,但给定固定大小的内存,Map 大约可以比 Object 多存储 50% 的键/值对。

  1. 插入性能

插入 Map 在所有浏览器中一般会稍微快一点儿。对这两个类型来说,插入速度并不会随着键/值对数量而线性增加。如果代码涉及大量插入操作,那么显然 Map 的性能更佳。

  1. 查找速度

如果只包含少量键/值对,则 Object 有时候速度更快。在把 Object 当成数组使用的情况下(比如使用连续整数作为属性),浏览器引擎可以进行优化,在内存中使用更高效的布局。这对 Map 来说是不可能的。对这两个类型而言,查找速度不会随着键/值对数量增加而线性增加。如果代码涉及大量查找操作,那么某些情况下可能选择 Object 更好一些。

  1. 删除性能

对大多数浏览器引擎来说,Map 的 delete() 操作都比插入和查找更快。如果代码涉及大量删除操作,那么毫无疑问应该选择 Map

6.5 WeakMap

ECMAScript 6 新增的“弱映射”(WeakMap)是一种新的集合类型,为这门语言带来了增强的键/值对存储机制。WeakMap是Map的“兄弟”类型,其 API 也是Map的子集。WeakMap中的“weak”(弱),描述的是JavaScript 垃圾回收程序对待“弱映射”中键的方式。

6.5.1 基本API

可以使用 new 关键字实例化一个空的 WeakMap

弱映射中的键只能是 Object 或者继承自 Object 的类型,尝试使用非对象设置键会抛出 TypeError。值的类型没有限制。
如果想在初始化时填充弱映射,则构造函数可以接收一个可迭代对象,其中需要包含键/值对数组。可迭代对象中的每个键/值都会按照迭代顺序插入新实例中

初始化之后可以使用 set() 再添加键/值对,可以使用 get() 和 has() 查询,还可以使用 delete() 删除。

6.5.2 弱键

WeakMap 中“weak”表示这些键不属于正式的引用,不会阻止垃圾回收。但要注意的是,弱映射中值的引用可不是“弱弱地拿着”的。只要键存在,键/值对就会存在于映射中,并被当作对值的引用,因此就不会被当作垃圾回收。

6.5.3 不可迭代键

WeakMap 实例之所以限制只能用对象作为键,是为了保证只有通过键对象的引用才能取得值。

6.5.4 使用弱映射

  1. 私有变量

弱映射造就了在 JavaScript 中实现真正私有变量的一种新方式。前提很明确:私有变量会存储在弱映射中,以对象实例为键,以私有成员的字典为值。

  1. DOM 节点元数据

WeakMap 实力不会妨碍垃圾回收,适合保存关联元数据。

6.6 Set

ECMAScript 6 新增的 Set 是一种新集合类型。

6.6.1 基本API

使用 new 关键字和 Set 构造函数可以创建一个空集合。

初始化之后,可以使用 add() 增加值,使用 has() 查询,通过 size 取得元素数量,以及使用 delete() 和 clear() 删除元素。

Set 可以包含任何 JavaScript 数据类型作为值。集合也使用 SameValueZero 操作。

6.6.2 顺序与迭代

Set 会维值插入时的顺序, 可以通过 values() 方法及其别名方法 **keys()**(或者 Symbol.iterator 属性,它引用 values() )取得这个迭代器。

values() 是默认迭代器,所以可以直接对集合实例使用扩展操作,把集合转换为数组。

集合的 entries() 方法返回一个迭代器,可以按照插入顺序产生包含两个元素的数组,这两个元素是集合中每个值的重复出现

6.7 WeakSet

ECMAScript 6 新增的“弱集合”(WeakSet)是一种新的集合类型,为这门语言带来了集合数据结构。WeakSetSet 的“兄弟”类型,其 API 也是 Set 的子集 WeakSet 中的“weak”(弱),描述的是 JavaScript 垃圾回收程序对待“弱集合”中值的方式。

6.7.1 基本API

可以使用 new 关键字实例化一个空的 WeakSet

弱集合中的值只能是 Object 或者继承自 Object 的类型,尝试使用非对象设置值会抛出 TypeError
如果想在初始化时填充弱集合,则构造函数可以接收一个可迭代对象,其中需要包含有效的值。可迭代对象中的每个值都会按照迭代顺序插入到新实例中。

初始化之后可以使用 add() 再添加新值,可以使用 has() 查询,还可以使用 delete() 删除。

6.7.2 弱值

WeakSet中“weak”表示弱集合的值是“弱弱地拿着”的。意思就是,这些值不属于正式的引用,不会阻止垃圾回收。

6.7.3 不可迭代值

因为 WeakSet 中的值任何时候都可能被销毁,所以没必要提供迭代其值的能力。当然,也用不着像 clear() 这样一次性销毁所有值的方法。即便代码可以访问 WeakSet 实例,也没办法看到其中的内容。
WeakSet 之所以限制只能用对象作为值,是为了保证只有通过值对象的引用才能取得值。如果允许原始值,那就没办法区分初始化时使用的字符串字面量和初始化之后使用的一个相等的字符串了。

6.7.4 使用弱集合

弱集合在给对象打标签时还是有价值的。

6.8 迭代与扩展操作

有 4 种原生集合类型定义了默认迭代器:

  • Array

  • 所有定型数组

  • Map

  • Set

上述所有类型都支持顺序迭代,都可以传入for-of 循环。

6.9 小结

JavaScript 中的对象是引用值,可以通过几种内置引用类型创建特定类型的对象。

  • 引用类型与传统面向对象编程语言中的类相似,但实现不同。

  • Object 类型是一个基础类型,所有引用类型都从它继承了基本的行为。

  • Array 类型表示一组有序的值,并提供了操作和转换值的能力。

  • 定型数组包含一套不同的引用类型,用于管理数值在内存中的类型。

  • Date 类型提供了关于日期和时间的信息,包括当前日期和时间以及计算。

  • RegExp 类型是 ECMAScript 支持的正则表达式的接口,提供了大多数基本正则表达式以及一些高级正则表达式的能力。

JavaScript 比较独特的一点是,函数其实是 Function 类型的实例,这意味着函数也是对象。由于函数是对象,因此也就具有能够增强自身行为的方法。
因为原始值包装类型的存在,所以 JavaScript 中的原始值可以拥有类似对象的行为。有 3 种原始值包装类型:Boolean、Number和String。它们都具有如下特点。

  • 每种包装类型都映射到同名的原始类型。

  • 在以读模式访问原始值时,后台会实例化一个原始值包装对象,通过这个对象可以操作数据。

  • 涉及原始值的语句只要一执行完毕,包装对象就会立即销毁。

JavaScript 还有两个在一开始执行代码时就存在的内置对象:Global 和 Math。其中,Global 对象在大多数 ECMAScript 实现中无法直接访问。不过浏览器将 Global 实现为window 对象。所有全局变量和函数都是 Global 对象的属性。Math 对象包含辅助完成复杂数学计算的属性和方法。
ECMAScript 6 新增了一批引用类型:Map、WeakMap、Set 和 WeakSet。这些类型为组织应用程序数据和简化内存管理提供了新能力。

七、迭代器与生成器

7.1 迭代

循环是迭代机制的基础,这是因为它可以指定迭代的次数,以及每次迭代要执行什么操作。每次循环都会在下一次迭代开始之前完成,而每次迭代的顺序都是事先定义好的。 迭代会在一个有序集合上进行。(“有序”可以理解为集合中所有项都可以按照既定的顺序被遍历到,特别是开始和结束项有明确的定义。)

数组是 JavaScript 中有序集合的最典型例子。

  • 迭代之前需要事先知道如何使用数据结构。数组中的每一项都只能先通过引用取得数组对象,然后再通过[]操作符取得特定索引位置上的项。这种情况并不适用于所有数据结构。

  • 遍历顺序并不是数据结构固有的。通过递增索引来访问数据是特定于数组类型的方式,并不适用于其他具有隐式顺序的数据结构。

7.2 迭代器模式

迭代器模式(特别是在 ECMAScript 这个语境下)描述了一个方案,即可以把有些结构称为“可迭代对象”(iterable),因为它们实现了正式的 Iterable 接口,而且可以通过迭代器Iterator消费。

可迭代对象是一种抽象的说法。基本上,可以把可迭代对象理解成数组或集合这样的集合类型的对象。它们包含的元素都是有限的,而且都具有无歧义的遍历顺序。

可迭代对象不一定是集合对象,也可以是仅仅具有类似数组行为的其他数据结构。

任何实现 Iterable 接口的数据结构都可以被实现 Iterator 接口的结构“消费”(consume)。迭代器(iterator)是按需创建的一次性对象。每个迭代器都会关联一个可迭代对象,而迭代器会暴露迭代其关联可迭代对象的 API

7.2.1 可迭代协议

实现 Iterable 接口(可迭代协议)要求同时具备两种能力:支持迭代的自我识别能力和创建实现 Iterator 接口的对象的能力。在 ECMAScript 中,这意味着必须暴露一个属性作为“默认迭代器”,而且这个属性必须使用特殊的 Symbol.iterator 作为键。这个默认迭代器属性必须引用一个迭代器工厂函数,调用这个工厂函数必须返回一个新迭代器。
很多内置类型都实现了 Iterable 接口

  • 字符串

  • 数组

  • 映射

  • 集合

  • arguments 对象

  • NodeListDOM 集合类型
    检查是否存在默认迭代器属性可以暴露这个工厂函数。

实现可迭代协议的所有类型都会自动兼容接收可迭代对象的任何语言特性。接收可迭代对象的原生语言特性包括:

  • for-of循环

  • 数组解构

  • 扩展操作符

  • Array.from()

  • 创建集合

  • 创建映射

  • Promise.all() 接收由期约组成的可迭代对象

  • Promise.race() 接收由期约组成的可迭代对象

  • yield* 操作符,在生成器中使用
    这些原生语言结构会在后台调用提供的可迭代对象的这个工厂函数,从而创建一个迭代器。

7.2.2 迭代器协议

迭代器是一种一次性使用的对象,用于迭代与其关联的可迭代对象。迭代器 API 使用 next() 方法在可迭代对象中遍历数据。每次成功调用 **next()**,都会返回一个 IteratorResult 对象,其中包含迭代器返回的下一个值。若不调用 next(),则无法知道迭代器的当前位置。
next() 方法返回的迭代器对象 IteratorResult 包含两个属性:
done 和 value

  • done 是一个布尔值,表示是否还可以再次调用 next() 取得下一个值。

  • value 包含可迭代对象的下一个值(done 为 false),或者undefined(done 为 true)done: true 状态称为“耗尽”。

每个迭代器都表示对可迭代对象的一次性有序遍历。不同迭代器的实例相互之间没有联系,只会独立地遍历可迭代对象。

迭代器并不与可迭代对象某个时刻的快照绑定,而仅仅是使用游标来记录遍历可迭代对象的历程。如果可迭代对象在迭代期间被修改了,那么迭代器也会反映相应的变化。

迭代器维护着一个指向可迭代对象的引用,因此迭代器会阻止垃圾回收程序回收可迭代对象。

7.2.3 提前终止迭代器

return() 方法用于指定在迭代器提前关闭时执行的逻辑。

执行迭代的结构在想让迭代器知道它不想遍历到可迭代对象耗尽时,就可以“关闭”迭代器。可能的情况包括:

  • for-of 循环通过 break、continue、return 或 throw 提前退出。

  • 解构操作并未消费所有值。

return() 方法必须返回一个有效的 IteratorResult 对象,简单情况下,可以只返回{ done: true }。因为这个返回值只会用在生成器的上下文中。

如果迭代器没有关闭,则还可以继续从上次离开的地方继续迭代。比如,数组的迭代器就是不能关闭的。

因为 return() 方法是可选的,所以并非所有迭代器都是可关闭的。要知道某个迭代器是否可关闭,可以测试这个迭代器实例的 return 属性是不是函数对象。不过,仅仅给一个不可关闭的迭代器增加这个方法并不能让它变成可关闭的。这是因为调用 return() 不会强制迭代器进入关闭状态。即便如此,return() 方法还是会被调用。

7.3 生成器

生成器是 ECMAScript 6 新增的一个极为灵活的结构,拥有在一个函数块内暂停和恢复代码执行的能力。

7.3.1 生成器基础

生成器的形式是一个函数,函数名称前面加一个星号(*)表示它是一个生成器。只要是可以定义函数的地方,就可以定义生成器。

箭头函数不能用来定义生成器函数。

调用生成器函数会产生一个生成器对象。生成器对象一开始处于暂停执行(suspended)的状态。与迭代器相似,生成器对象也实现了 Iterator 接口,因此具有 next() 方法,调用这个方法会让生成器开始或恢复执行。

next() 方法的返回值类似于迭代器,有一个 done 属性和一个 value 属性。函数体为空的生成器函数中间不会停留,调用一次 next() 就会让生成器到达 done: true 状态。

value 属性是生成器函数的返回值,默认值为 undefined,可以通过生成器函数的返回值指定。

生成器函数只会在初次调用 next() 方法后开始执行。

生成器对象实现了 Iterable 接口,它们默认的迭代器是自引用的。

7.3.2 通过 yield 中断执行

yield 关键字可以让生成器停止和开始执行,也是生成器最有用的地方。生成器函数在遇到 yield 关键字之前会正常执行。遇到这个关键字后,执行会停止,函数作用域的状态会被保留。停止执行的生成器函数只能通过在生成器对象上调用next() 方法来恢复执行。

  • 通过 yield 关键字退出的生成器函数会处在 done: false 状态。

  • 通过 return 关键字退出的生成器函数会处于 done: true 状态。

生成器函数内部的执行流程会针对每个生成器对象区分作用域。在一个生成器对象上调用 next() 不会影响其他生成器。

yield 关键字只能在生成器函数内部使用,用在其他地方会抛出错误。类似函数的return 关键字,yield 关键字必须直接位于生成器函数定义中,出现在嵌套的非生成器函数中会抛出语法错误。

  1. 生成器对象作为可迭代对象

如果把生成器对象当成可迭代对象,那么使用起来会更方便。

1
2
3
4
5
6
7
8
9
10
11
function* generatorFn() { 
yield 1;
yield 2;
yield 3;
}
for (const x of generatorFn()) {
console.log(x);
}
// 1
// 2
// 3
  1. 使用 yield 实现输入和输出

yield 关键字可以作为函数的中间返回语句使用,还可以作为函数的中间参数使用。上一次让生成器函数暂停的 yield 关键字会接收到传给 next() 方法的第一个值。——第一次调用 next() 传入的值不会被使用,因为这一次调用是为了开始执行生成器函数。

1
2
3
4
5
6
7
8
9
10
11
function* generatorFn(initial) { 
console.log(initial);
console.log(yield);
console.log(yield);
}

let generatorObject = generatorFn('foo');

generatorObject.next('bar'); // foo
generatorObject.next('baz'); // baz
generatorObject.next('qux'); // qux

yield 关键字可以同时用于输入和输出。

yield 关键字并不是只能使用一次。

  1. 产生可迭代对象

可以使用星号增强 yield 的行为,让它能够迭代一个可迭代对象,从而一次产出一个值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 等价的 generatorFn:  
// function* generatorFn() {
// for (const x of [1, 2, 3]) {
// yield x;
// }
// }
function* generatorFn() {
yield* [1, 2, 3];
}

let generatorObject = generatorFn();

for (const x of generatorFn()) {
console.log(x);
}
// 1
// 2
// 3
  1. 使用 yield 实现递归算法

yield* 最有用的地方是实现递归操作,此时生成器可以产生自身。

7.3.3 生成器作为默认迭代器

因为生成器对象实现了Iterable接口,而且生成器函数和默认迭代器被调用之后都产生迭代器,所以生成器格外适合作为默认迭代器。

7.3.4 提前终止生成器

一个实现 Iterator 接口的对象一定有 next() 方法,还有一个可选的 return() 方法用于提前终止迭代器。生成器对象除了有这两个方法,还有第三个方法:**throw()**。

return() 和 throw() 方法都可以用于强制生成器进入关闭状态。

  1. return()

return() 方法会强制生成器进入关闭状态。提供给 return() 方法的值,就是终止迭代器对象的值。

所有生成器对象都有 return() 方法,只要通过它进入关闭状态,就无法恢复了。后续调用 next() 会显示 done: true 状态,而提供的任何返回值都不会被存储或传播。

for-of 循环等内置语言结构会忽略状态为 done: true 的 IteratorObject 内部返回的值。

  1. throw()

throw() 方法会在暂停的时候将一个提供的错误注入到生成器对象中。如果错误未被处理,生成器就会关闭。

假如生成器函数内部处理了这个错误,那么生成器就不会关闭,而且还可以恢复执行。错误处理会跳过对应的 yield

如果生成器对象还没有开始执行,那么调用 *hrow() 抛出的错误不会在函数内部被捕获,因为这相当于在函数块外部抛出了错误。

7.4 小结

迭代是一种所有编程语言中都可以看到的模式。ECMAScript 6 正式支持迭代模式并引入了两个新的语言特性:迭代器和生成器
迭代器是一个可以由任意对象实现的接口,支持连续获取对象产出的每一个值。任何实现 Iterable 接口的对象都有一个 Symbol.iterator 属性,这个属性引用默认迭代器。默认迭代器就像一个迭代器工厂,也就是一个函数,调用之后会产生一个实现 Iterator 接口的对象。
迭代器必须通过连续调用 next() 方法才能连续取得值,这个方法返回一个 IteratorObject 。这个对象包含一个 done 属性和一个 value 属性。前者是一个布尔值,表示是否还有更多值可以访问;后者包含迭代器返回的当前值。这个接口可以通过手动反复调用 next() 方法来消费,也可以通过原生消费者,比如 for-of 循环来自动消费。
生成器是一种特殊的函数,调用之后会返回一个生成器对象。生成器对象实现了 Iterable 接口,因此可用在任何消费可迭代对象的地方。生成器的独特之处在于支持 yield 关键字,这个关键字能够暂停执行生成器函数。使用 yield 关键字还可以通过 next() 方法接收输入和产生输出。在加上星号之后,yield 关键字可以将跟在它后面的可迭代对象序列化为一连串值。

八、对象、类与面向对象编程

ECMA-262 将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。

8.1 对象

创建自定义对象的通常方式是创建 Object 的一个新实例,然后再给它添加属性和方法。

8.1.1 属性类型

ECMA-262 使用一些内部特性来描述属性的特征。这些特性是由为 JavaScript 实现引擎的规范定义的。因此,开发者不能在 JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如[[Enumerable]]。

属性分为:数据属性和访问器属性

  1. 数据属性

数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。

  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true

  • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true

  • [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是 true

  • [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为 undefined

要修改属性的默认特性,就必须使用 Object.defineProperty() 方法。这个方法接收 3 个参数:要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。

1
2
3
4
5
6
7
8
let person = {};  
Object.defineProperty(person, "name", {
writable: false,
value: "Nicholas"
});
console.log(person.name); // "Nicholas"
person.name = "Greg";
console.log(person.name); // "Nicholas"

一个属性被定义为不可配置之后,就不能再变回可配置的了。再次调用 Object.defineProperty() 并修改任何非 writable 属性会导致错误。

在调用 Object.defineProperty() 时,configurable、enumerable 和 writable 的值如果不指定,则都默认为 false

  1. 访问器属性

访问器属性不包含数据值。它们包含一个获取(getter)函数和一个设置(setter)函数,这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。

  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true

  • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true

  • [[Get]]:获取函数,在读取属性时调用。默认值为 undefined

  • [[Set]]:设置函数,在写入属性时调用。默认值为 undefined

访问器属性是不能直接定义的,必须使用 **Object.defineProperty()**。

获取函数和设置函数不一定都要定义。只定义获取函数意味着属性是只读的,尝试修改属性会被忽略。在严格模式下,尝试写入只定义了获取函数的属性会抛出错误。类似地,只有一个设置函数的属性是不能读取的,非严格模式下读取会返回 undefined ,严格模式下会抛出错误。

8.1.2 定义多个属性

ECMAScript 提供了 Object.defineProperties() 方法。这个方法可以通过多个描述符一次性定义多个属性。它接收两个参数:要为之添加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应。

8.1.3 读取属性的特性

使用 Object.getOwnPropertyDescriptor() 方法可以取得指定属性的属性描述符。这个方法接收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象,对于访问器属性包含 configurable、enumerable、get 和 set 属性,对于数据属性包含 configurable、enumerable、writable 和 value 属性。

ECMAScript 2017 新增了 Object.getOwnPropertyDescriptors() 静态方法。这个方法实际上会在每个自有属性上调用 Object.getOwnPropertyDescriptor() 并在一个新对象中返回它们。

8.1.4 合并对象

ECMAScript 6 专门为合并对象提供了 Object.assign() 方法。这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable() 返回 true)和自有(Object.hasOwnProperty() 返回 true)属性复制到目标对象。以字符串和符号为键的属性会被复制。对每个符合条件的属性,这个方法会使用源对象上的[[Get]]取得属性的值,然后使用目标对象上的[[Set]]设置属性的值。

Object.assign() 实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。此外,从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目标对象。换句话说,不能在两个对象间转移获取函数和设置函数。

8.1.5 对象标识及相等判定

ECMAScript 6 规范新增了 **Object.is()**,这个方法与===很像,但同时也考虑
到了边界情形,这个方法必须接收两个参数。

8.1.6 增强的对象语法

  1. 属性值简写

简写属性名只要使用变量名(不用再写冒号)就会自动被解释为同名的属性键。如果没有找到同名变量,则会抛出 ReferenceError

代码压缩程序会在不同作用域间保留属性名,以防止找不到引用。

  1. 可计算属性

在引入可计算属性之前,如果想使用变量的值作为属性,那么必须先声明对象,然后使用中括号语法来添加属性。换句话说,不能在对象字面量中直接动态命名属性

有了可计算属性,就可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时将其作为 JavaScript 表达式而不是字符串来求值。

可计算属性表达式中抛出任何错误都会中断对象创建。如果计算属性的表达式有副作用,那就要小心了,因为如果表达式抛出错误,那么之前完成的计算是不能回滚的。

  1. 简写方法名

在给对象定义方法时,通常都要写一个方法名、冒号,然后再引用一个匿名函数表达式。

新的简写方法的语法遵循同样的模式,但开发者要放弃给函数表达式命名。相应地,这样也可以明显缩短方法声明。

简写方法名与可计算属性键相互兼容。

8.1.7 对象解构

ECMAScript 6 新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。

使用解构,可以在一个类似对象字面量的结构中,声明多个变量,同时执行多个赋值操作。如果想让变量直接使用属性的名称,那么可以使用简写语法。

解构赋值不一定与对象的属性匹配。赋值的时候可以忽略某些属性,而如果引用的属性不存在,则该变量的值就是 undefined

可以在解构赋值的同时定义默认值,这适用于引用的属性不存在于源对象中的情况。

解构在内部使用函数 ToObject()(不能在运行时环境中直接访问)把源数据结构转换为对象。这意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据 ToObject() 的定义),null 和 undefined 不能被解构,否则会抛出错误。

解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,则赋值表达式必须包含在一对括号中。

  1. 嵌套解构

解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性。

在外层属性没有定义的情况下不能使用嵌套解构。无论源对象还是目标对象都一样。

  1. 部分解构

涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及
多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分。

  1. 参数上下文匹配

在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响 arguments 对象,但可以在函数签名中声明在函数体内使用局部变量。

8.2 创建对象

8.2.1 概述

ECMAScript 6 开始正式支持类和继承。ES6 的类旨在完全涵盖之前规范设计的基于原型的继承模式。

8.2.2 工厂模式

工厂模式是一种众所周知的设计模式,广泛应用于软件工程领域,用于抽象创建特定对象的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
function createPerson(name, age, job) { 
let o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function() {
console.log(this.name);
};
return o;
}

let person1 = createPerson("Nicholas", 29, "Software Engineer");
let person2 = createPerson("Greg", 27, "Doctor");

8.2.3 构造函数模式

ECMAScript 中的构造函数是用于创建特定类型对象的。像 Object 和 Array
样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person(name, age, job){  
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() {
console.log(this.name);
};
}

let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");

person1.sayName(); // Nicholas
person2.sayName(); // Greg

与工厂模式的 区别

  • 没有显式地创建对象。

  • 属性和方法直接赋值给了this。

  • 没有return。

按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。

使用new操作符调用构造函数会执行如下操作。
(1) 在内存中创建一个新对象。
(2) 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性。(3) 构造函数内部的this被赋值为这个新对象(即this指向新对象)。
(4) 执行构造函数内部的代码(给新对象添加属性)。
(5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

constructor 本来是用于标识对象类型的。不过,一般认为 instanceo f操作符是确定对象类型更可靠的方式。

构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以表示构造函数。

  1. 构造函数也是函数

构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。

任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操作符调用的函数就是普通函数。

在调用一个函数而没有明确设置 this 值的情况下(即没有作为对象的方法调用,或者没有使用 call()/apply() 调用),this 始终指向 Global 对象(在浏览器中就是 window 对象)。

  1. 构造函数的问题

主要问题在于,其定义的方法会在每个实例上都创建一遍。

8.2.4 原型模式

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型。

  1. 原型

只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。

在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自 Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。脚本中没有访问这个[[Prototype]]特性的标准方式,但 Firefox、Safari 和 Chrome 会在每个对象上暴露 proto 属性,通过这个属性可以访问对象的原型。在其他实现中,这个特性
完全被隐藏了。关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。

实例与构造函数没有直接联系,与原型对象有直接联系。

ECMAScriptObject 类型有一个方法叫 **Object.getPrototypeOf()**,返回参数的内部特性[[Prototype]]的值。

Object 类型还有一个 setPrototypeOf() 方法,可以向实例的私有特特[[Prototype]]写入一个新值。

为避免使用 Object.setPrototypeOf() 可能造成的性能下降,可以通过 Object.create() 来创建一个新对象,同时为其指定原型。

1
2
3
4
5
6
7
8
9
let biped = {  
numLegs: 2
};
let person = Object.create(biped);
person.name = 'Matt';

console.log(person.name); // Matt
console.log(person.numLegs); // 2
console.log(Object.getPrototypeOf(person) === biped); // true
  1. 原型层级

在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。

constructor 属性只存在于原型对象,因此通过实例对象也是可以访问到的。

hasOwnProperty() 方法用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自 Object 的,会在属性存在于调用它的对象实例上时返回 true

  1. 原型和 in 操作符

有两种方式使用 in 操作符:单独使用和在 for-in 循环中使用。在单独使用时,in 操作符会在可以通过对象访问指定属性时返回 true ,无论该属性是在实例上还是在原型上。

如果要确定某个属性是否存在于原型上,则可以同时使用 hasOwnProperty()和 in 操作符。

for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。遮蔽原型中不可枚举([[Enumerable]]特性被设置为 false)属性的实例属性也会在 for-in 循环中返回,因为默认情况下开发者定义的属性都是可枚举的。

获得对象上所有可枚举的实例属性,可以使用 Object.keys() 方法。这个方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组。

如果想列出所有实例属性,无论是否可以枚举,都可以使用 **Object.getOwnPropertyNames()**。

返回的结果中包含了一个不可枚举的属性 constructorObject.keys() 和 Object. getOwnPropertyNames() 在适当的时候都可用来代替 for-in 循环。

ECMAScript 6 新增符号类型之后,相应地出现了增加一个 Object.getOwnPropertyNames() 的兄弟方法的需求,因为以符号为键的属性没有名称的概念。因此,Object.getOwnProperty- Symbols() 方法就出现了,这个方法与 Object.getOwnPropertyNames() 类似,只是针对符号而已。

  1. 属性枚举顺序

for-in 循环、Object.keys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols() 以及 Object.assign() 在属性枚举顺序方面有很大区别。for-in 循环和 Object.keys() 的枚举顺序是不确定的,取决于 JavaScript 引擎,可能因浏览器而异。
Object.getOwnPropertyNames()、Object.getOwnPropertySymbols() 和 Object.assign() 的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。

8.2.5 对象迭代

ECMAScript 2017 新增了两个静态方法,用于将对象内容转换为序列化的——更重要的是可迭代的——格式。这两个静态方法 Object.values()和Object.entries() 接收一个对象,返回它们内容的数组。Object.values() 返回对象值的数组,Object.entries() 返回键/值对的数组。

非字符串属性会被转换为字符串输出。另外,这两个方法执行对象的浅复制。

  1. 其他原型语法

为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法的对象字面量来重写原型成为了一种常见的做法。

1
2
3
4
5
6
7
8
9
function Person() {} 
Person.prototype = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
  1. 原型的动态性

因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来。

实例的[[Prototype]]指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同的对象也不会变。重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型。记住,实例只有指向原型的指针,没有指向构造函数的指针。

重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型。

  1. 原生对象原型

原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。所有原生引用类型的构造函数(包括 Object、Array、String 等)都在原型上定义了实例方法。

通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。

  1. 原型的问题

首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。原型的最主要问题源自它的共享特性。

8.3 继承

实现继承是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。

8.3.1 原型链

ECMA-262 把原型链定义为 ECMAScript 的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法。

构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想。

  1. 默认原型

默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向 Object.prototype

  1. 原型与继承关系

原型与实例的关系可以通过两种方式来确定。第一种方式是使用 instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则 instanceof 返回 true

第二种方式是使用 isPrototypeOf() 方法。原型链中的每个原型都可以调用这个
方法,只要原型链中包含这个原型,这个方法就返回 true

1
2
3
console.log(Object.prototype.isPrototypeOf(instance));     // true 
console.log(SuperType.prototype.isPrototypeOf(instance)); // true
console.log(SubType.prototype.isPrototypeOf(instance)); // true
  1. 方法

子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上。

以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链。

  1. 原型链问题

主要问题出现在原型中包含引用值的时候。原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性摇身一变成为了原型属性。

原型链的第二个问题是,子类型在实例化时不能给父类型的构造函数传参。

8.3.2 盗用构造函数

为了解决原型包含引用值导致的继承问题,“盗用构造函数”(constructor stealing)的技术在开发社区流行起来(这种技术有时也称作“对象伪装”或“经典继承”)。基本思路很简单:在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用 apply()和call() 方法以新创建的对象为上下文执行构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function SuperType() {  
this.colors = ["red", "blue", "green"];
}

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

let instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"

let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green"
  1. 传递参数

相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。

  1. 盗用构造函数的问题

主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。

8.3.3 组合继承

组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function SuperType(name){  
this.name = name;
this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
console.log(this.name);
};

function SubType(name, age){
// 继承属性
SuperType.call(this, name);

this.age = age;
}

// 继承方法
SubType.prototype = new SuperType();

SubType.prototype.sayAge = function() {
console.log(this.age);
};

let instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
instance1.sayName(); // "Nicholas";
instance1.sayAge(); // 29

let instance2 = new SubType("Greg", 27);
console.log(instance2.colors); // "red,blue,green"
instance2.sayName(); // "Greg";
instance2.sayAge(); // 27

组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。而且组合继承也保留了 instanceof 操作符和 isPrototypeOf() 方法识别合成对象的能力。

组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。

8.3.4 原型式继承

原型式继承(“Prototypal Inheritance in JavaScript”)。一种不涉及严格意义上构造函数的继承方法。出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。

1
2
3
4
5
function object(o) { 
function F() {}
F.prototype = o;
return new F();
}

本质上,object() 是对传入的对象执行了一次浅复制。

原型式继承适用于这种情况:你有一个对象,想在它的基础上再创建一个新对象。
你需要把这个对象先传给 **object()**,然后再对返回的对象进行适当修改。

ECMAScript 5 通过增加 Object.create() 方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时,Object.create() 与这里的 object() 方法效果相同。

Object.create() 的第二个参数与 Object.defineProperties() 的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

8.3.5 寄生式继承

寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。

1
2
3
4
5
6
7
function createAnother(original){  
let clone = object(original); // 通过调用函数创建一个新对象
clone.sayHi = function() { // 以某种方式增强这个对象
console.log("hi");
};
return clone; // 返回这个对象
}

寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object() 函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。

通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。

8.3.6 寄生式组合继承

寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。

1
2
3
4
5
function inheritPrototype(subType, superType) { 
let prototype = object(superType.prototype); // 创建对象
prototype.constructor = subType; // 增强对象
subType.prototype = prototype; // 赋值对象
}

原型链仍然保持不变,因此 instanceof 操作符和 isPrototypeOf() 方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式。

8.4 类

ECMAScript 6 新引入的 class 关键字具有正式定义类的能力。类(class)是ECMAScript 中新的基础性语法糖结构。

8.4.1 类定义

定义类也有两种主要方式:类声明和类表达式。这两种方式都使用 class 关键字加大括号。

1
2
3
4
// 类声明 
class Person {}
// 类表达式
const Animal = class {};

类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数
声明可以提升,但类定义不能。

另一个跟函数声明不同的地方是,函数受函数作用域限制,而类受块作用域限制。

  1. 类的构成

类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。

类名的首字母要大写,以区别于通过它创建的实例。

类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过 name 属性取得类表达式的名称字符串。但不能在类表达式作用域外部访问这个标识符。

8.4.2 类构造函数

constructor 关键字用于在类定义块内部创建类的构造函数。方法名 constructor 会告诉解释器在使用 new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。

  1. 实例化

使用 new 操作符实例化 Person 的操作等于使用 new 调用其构造函数。唯一可感知的不同之处就是,JavaScript 解释器知道使用 new 和类意味着应该使用constructor 函数进行实例化。
使用 new 调用类的构造函数会执行如下操作。
(1) 在内存中创建一个新对象。
(2) 这个新对象内部的[[Prototype]]指针被赋值为构造函数的 prototype 属性。
(3) 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
(4) 执行构造函数内部的代码(给新对象添加属性)。
(5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的。

默认情况下,类构造函数会在执行之后返回 this 对象。构造函数返回的对象会被用作实例化的对象,如果没有什么引用新创建的 this 对象,那么这个对象会被销毁。不过,如果返回的不是 this 对象,而是其他对象,那么这个对象不会通过 instanceof 操作符检测出跟类有关联。

类构造函数与构造函数的主要区别是,调用类构造函数必须使用 new 操作符。而普通构造函数如果不使用 new 调用,那么就会以全局的 this(通常是 window)作为内部对象。调用类构造函数时如果忘了使用 new 则会抛出错误。

  1. 类是特殊函数

ECMAScript 类就是一种特殊函数。声明一个类之后,通过 typeof 操作符检测类标识符,表明它是一个函数。

类标识符有 prototype 属性,而这个原型也有一个 constructor 属性指向类自身。

与普通构造函数一样,可以使用 instanceof 操作符检查构造函数原型是否存在于实例的原型链中。

在类的上下文中,类本身在使用 new 调用时就会被当成构造函数。重点在于,类中定义的 constructor 方法不会被当成构造函数,在对它使用 instanceof 操作符时会返回 false。但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么 instanceof 操作符的返回值会反转。

类是 JavaScript 的一等公民,因此可以像其他对象或函数引用一样把类作为参数传递。

与立即调用函数表达式相似,类也可以立即实例化。

8.4.3 实例、原型和类成员

  1. 实例成员

每次通过 new 调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this)添加“自有”属性。至于添加什么样的属性,则没有限制。另外,在构造函数执行完毕后,仍然可以给实例继续添加新成员。

每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享。

  1. 原型方法与访问器

为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {  
constructor() {
// 添加到 this 的所有内容都会存在于不同的实例上
this.locate = () => console.log('instance');
}
// 在类块中定义的所有内容都会定义在类的原型上
locate() {
console.log('prototype');
}
}
let p = new Person();
p.locate(); // instance
Person.prototype.locate(); // prototype

可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据。

类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键。

类定义也支持获取和设置访问器。语法与行为跟普通对象一样。

  1. 静态类方法

可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。

静态成员每个类上只能有一个。
静态类成员在类定义中使用 static 关键字作为前缀。在静态成员中,this 引用类自身。其他所有约定跟原型成员一样。

静态类方法非常适合作为实例工厂。

  1. 非函数原型和类成员

类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加。

类定义中之所以没有显式支持添加数据成员,是因为在共享目标(原型和类)上添加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过 this 引用的数据。

  1. 迭代器与生成器方法

类定义语法支持在原型和类本身上定义生成器方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Person {  
// 在原型上定义生成器方法
*createNicknameIterator() {
yield 'Jack';
yield 'Jake';
yield 'J-Dog';
}

// 在类上定义生成器方法
static *createJobIterator() {
yield 'Butcher';
yield 'Baker';
yield 'Candlestick maker';
}
}

let jobIter = Person.createJobIterator();
console.log(jobIter.next().value); // Butcher
console.log(jobIter.next().value); // Baker
console.log(jobIter.next().value); // Candlestick maker

let p = new Person();
let nicknameIter = p.createNicknameIterator();
console.log(nicknameIter.next().value); // Jack
console.log(nicknameIter.next().value); // Jake
console.log(nicknameIter.next().value); // J-Dog

因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象。

8.4.4 继承

  1. 基础

ES6 类支持单继承。使用 extends 关键字,就可以继承任何拥有[[Construct]]和原型的对象。很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Vehicle {}  

// 继承类
class Bus extends Vehicle {}

let b = new Bus();
console.log(b instanceof Bus); // true
console.log(b instanceof Vehicle); // true


function Person() {}

// 继承普通构造函数
class Engineer extends Person {}

let e = new Engineer();
console.log(e instanceof Engineer); // true
console.log(e instanceof Person); // true

派生类都会通过原型链访问到类和原型上定义的方法。this 的值会反映调用相应方法的实例或者类。

extends 关键字也可以在类表达式中使用,因此 let Bar = class extends Foo {} 是有效的语法。

  1. 构造函数、HomeObject 和 super()

派生类的方法可以通过 super 关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。在类构造函数中使用 super 可以调用父类构造函数。

不要在调用 super() 之前引用 this,否则会抛出 ReferenceError

ES6 给类构造函数和静态方法添加了内部特性[[HomeObject]],这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在 JavaScript 引擎内部访问。super 始终会定义为[[HomeObject]]的原型。

使用 super 时要注意:

  • super 只能在派生类构造函数和静态方法中使用。

  • 不能单独引用 super 关键字,要么用它调用构造函数,要么用它引用静态方法。

  • 调用 super() 会调用父类构造函数,并将返回的实例赋值给 this

  • super() 的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。

  • 如果没有定义类构造函数,在实例化派生类时会调用 **super()**,而且会传入所有传给派生类的参数。

  • 在类构造函数中,不能在调用 super() 之前引用 this

  • 如果在派生类中显式定义了构造函数,则要么必须在其中调用 **super()**,要么必须在其中返回一个对象。

  1. 抽象基类

有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。虽然 ECMAScript 没有专门支持这种类的语法 ,但通过 new.target 也很容易实现。new.target 保存通过 new 关键字调用的类或函数。通过在实例化时检测 new.target 是不是抽象基类,可以阻止对抽象基类的实例化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 抽象基类  
class Vehicle {
constructor() {
console.log(new.target);
if (new.target === Vehicle) {
throw new Error('Vehicle cannot be directly instantiated');
}
}
}

// 派生类
class Bus extends Vehicle {}

new Bus(); // class Bus {}
new Vehicle(); // class Vehicle {}
// Error: Vehicle cannot be directly instantiated

通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在了,所以可以通过 this 关键字来检查相应的方法。

  1. 继承内置类型

ES6 类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class SuperArray extends Array {  
shuffle() {
// 洗牌算法
for (let i = this.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this[i], this[j]] = [this[j], this[i]];
}
}
}

let a = new SuperArray(1, 2, 3, 4, 5);

console.log(a instanceof Array); // true
console.log(a instanceof SuperArray); // true
console.log(a); // [1, 2, 3, 4, 5]
a.shuffle();
console.log(a); // [3, 1, 4, 5, 2]

有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型是一致的。

如果想覆盖这个默认行为,则可以覆盖 Symbol.species 访问器,这个访问器决定在创建返回的实例时使用的类。

  1. 类混入

把不同类的行为集中到一个类是一种常见的 JavaScript 模式。

Object.assign() 方法是为了混入对象行为而设计的。只有在需要混入类的行为时才有必要自己实现混入表达式。如果只是需要混入多个对象的属性,那么使用 Object.assign() 就可以了。

混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被继承的类。

一个策略是定义一组“可嵌套”的函数,每个函数分别接收一个超类作为参数,而将混入类定义为这个参数的子类,并返回这个类。这些组合函数可以连缀调用,最终组合成超类表达式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Vehicle {}  

let FooMixin = (Superclass) => class extends Superclass {
foo() {
console.log('foo');
}
};
let BarMixin = (Superclass) => class extends Superclass {
bar() {
console.log('bar');
}
};
let BazMixin = (Superclass) => class extends Superclass {
baz() {
console.log('baz');
}
};

class Bus extends FooMixin(BarMixin(BazMixin(Vehicle))) {}

let b = new Bus();
b.foo(); // foo
b.bar(); // bar
b.baz(); // baz

8.5 小结

对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实体。下面的模式适用于创建对象。

  • 工厂模式就是一个简单的函数,这个函数可以创建对象,为它添加属性和方法,然后返回这个对象。这个模式在构造函数模式出现后就很少用了。

  • 使用构造函数模式可以自定义引用类型,可以使用new关键字像创建内置类型实例一样创建自定义类型的实例。不过,构造函数模式也有不足,主要是其成员无法重用,包括函数。考虑到函数本身是松散的、弱类型的,没有理由让函数不能在多个对象实例间共享。

  • 原型模式解决了成员共享的问题,只要是添加到构造函数 prototype 上的属性和方法就可以共享。而组合构造函数和原型模式通过构造函数定义实例属性,通过原型定义共享的属性和方法。

JavaScript 的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。这样一来,子类就可以访问父类的所有属性和方法,就像基于类的继承那样。原型链的问题是所有继承的属性和方法都会在对象实例间共享,无法做到实例私有。盗用构造函数模式通过在子类构造函数中调用父类构造函数,可以避免这个问题。这样可以让每个实例继承的属性都是私有的,但要求类型只能通过构造函数模式来定义(因为子类不能访问父类原型上的方法)。目前最流行的继承模式是组合继承,即通过原型链继承共享的属性和方法,通过盗用构造函数继承实例属性。

除上述模式之外,还有以下几种继承模式。

  • 原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。这种操作的结果之后还可以再进一步增强。

  • 与原型式继承紧密相关的是寄生式继承,即先基于一个对象创建一个新对象,然后再增强这个新对象,最后返回新对象。这个模式也被用在组合继承中,用于避免重复调用父类构造函数导致的浪费。

  • 寄生组合继承被认为是实现基于类型继承的最有效方式。

ECMAScript 6 新增的类很大程度上是基于既有原型机制的语法糖。类的语法让开发者可以优雅地定义向后兼容的类,既可以继承内置类型,也可以继承自定义类型。类有效地跨越了对象实例、对象原型和对象类之间的鸿沟。

九、代理与反射

ECMAScript 6 新增的代理和反射为开发者提供了拦截并向基本操作嵌入额外行为的能力。具体地说,可以给目标对象定义一个关联的代理对象,而这个代理对象可以作为抽象的目标对象来使用。在对目标对象的各种操作影响目标对象之前,可以在代理对象中对这些操作加以控制。

9.1 代理基础

代理是目标对象的抽象。

9.1.1 创建空代理

最简单的代理是空代理,即除了作为一个抽象的目标对象,什么也不做。默认情况下,在代理对象上执行的所有操作都会无障碍地传播到目标对象。因此,在任何可以使用目标对象的地方,都可以通过同样的方式来使用与之关联的代理对象。
代理是使用 Proxy 构造函数创建的。这个构造函数接收两个参数:目标对象和处理程序对象。缺少其中任何一个参数都会抛出 TypeError。要创建空代理,可以传一个简单的对象字面量作为处理程序对象,从而让所有操作畅通无阻地抵达目标对象。

1
2
3
4
5
6
7
const target = {  
id: 'target'
};
const handler = {};
const proxy = new Proxy(target, handler);
// Proxy.prototype 是 undefined
// 因此不能使用 instanceof 操作符

9.1.2 定义捕获器

使用代理的主要目的是可以定义捕获器(trap)。捕获器就是在处理程序对象中定义的“基本操作的拦截器”。每个处理程序对象可以包含零个或多个捕获器,每个捕获器都对应一种基本操作,可以直接或间接在代理对象上调用。每次在代理对象上调用这些基本操作时,代理可以在这些操作传播到目标对象之前先调用捕获器函数,从而拦截并修改相应的行为。

1
2
3
4
5
6
7
8
9
10
const target = {  
foo: 'bar'
};
const handler = {
// 捕获器在处理程序对象中以方法名为键
get() {
return 'handler override';
}
};
const proxy = new Proxy(target, handler);

proxy[property]、proxy.property 或 Object.create(proxy)[property] 等操作都会触发基本的 get() 操作以获取属性。因此所有这些操作只要发生在代理对象上,就会触发 get() 捕获器。注意,只有在代理对象上执行这些操作才会触发捕获器。在目标对象上执行这些操作仍然会产生正常的行为。

9.1.3 捕获器参数和反射 API

所有捕获器都可以访问相应的参数,基于这些参数可以重建被捕获方法的原始行为。比如,get() 捕获器会接收到目标对象、要查询的属性和代理对象三个参数。

所有捕获器都可以基于自己的参数重建原始操作,实际上,开发者并不需要手动重建原始行为,而是可以通过调用全局 Reflect 对象上(封装了原始行为)的同名方法来轻松重建。
处理程序对象中所有可以捕获的方法都有对应的反射(Reflect)API 方法。这些方法与捕获器拦截的方法具有相同的名称和函数签名,而且也具有与被拦截方法相同的行为。

事实上,如果真想创建一个可以捕获所有方法,然后将每个方法转发给对应反射 API 的空代理,那么甚至不需要定义处理程序对象。

1
2
3
4
5
6
const target = { 
foo: 'bar'
};
const proxy = new Proxy(target, Reflect);
console.log(proxy.foo); // bar
console.log(target.foo); // bar

反射 API 为开发者准备好了样板代码,在此基础上开发者可以用最少的代码修改捕获的方法。

9.1.4 捕获器不定式

使用捕获器几乎可以改变所有基本方法的行为,但也不是没有限制。根据 ECMAScript 规范,每个捕获的方法都知道目标对象上下文、捕获函数签名,而捕获处理程序的行为必须遵循“捕获器不变式”(trap invariant)。捕获器不变式因方法不同而异,但通常都会防止捕获器定义出现过于反常的行为。

比如,如果目标对象有一个不可配置且不可写的数据属性,那么在捕获器返回一个与该属性不同的值时,会抛出 TypeError

9.1.5 可撤销代理

有时候可能需要中断代理对象与目标对象之间的联系。对于使用 new Proxy() 创建的普通代理来说,这种联系会在代理对象的生命周期内一直持续存在。
Proxy 也暴露了 revocable() 方法,这个方法支持撤销代理对象与目标对象的关联。撤销代理的操作是不可逆的。而且,撤销函数(**revoke()**)是幂等的,调用多少次的结果都一样。撤销代理之后再调用代理会抛出 TypeError。 撤销函数和代理对象是在实例化时同时生成的。

9.1.6 使用反射API

某些情况下应该优先使用反射 API

  1. 反射 API 与对象 API

(1) 反射 API 并不限于捕获处理程序;
(2) 大多数反射 API 方法在 Object 类型上有对应的方法。
通常,Object 上的方法适用于通用程序,而反射方法适用于细粒度的对象控制与操作。

  1. 状态标记

很多反射方法返回称作“状态标记”的布尔值,表示意图执行的操作是否成功。有时候,状态标记比那些返回修改后的对象或者抛出错误(取决于方法)的反射 API 方法更有用。

以下反射方法都会提供状态标记:

  • Reflect.defineProperty()

  • Reflect.preventExtensions()

  • Reflect.setPrototypeOf()

  • Reflect.set()

  • Reflect.deleteProperty()

  1. 用一等函数替代操作符

以下反射方法提供只有通过操作符才能完成的操作。

  • **Reflect.get()**:可以替代对象属性访问操作符。

  • **Reflect.set()**:可以替代 = 赋值操作符。

  • **Reflect.has()**:可以替代 in 操作符或 **with()**。

  • **Reflect.deleteProperty()**:可以替代 delete 操作符。

  • **Reflect.construct()**:可以替代 new 操作符。

  1. 安全地应用函数

在通过 apply 方法调用函数时,被调用的函数可能也定义了自己的apply属性(虽然可能性极小)。为绕过这个问题,可以使用定义在 Function 原型上的 apply 方法。

9.1.7 代理另一个代理

代理可以拦截反射 API 的操作,而这意味着完全可以创建一个代理,通过它去代理另一个代理。这样就可以在一个目标对象之上构建多层拦截网。

9.1.8 代理的问题与不足

很大程度上,代理作为对象的虚拟层可以正常使用。但在某些情况下,代理也不能与现在的 ECMAScript 机制很好地协同。

  1. 代理中的 this

代理潜在的一个问题来源是this值。方法中的 this 通常指向调用这个方法的对象。

  1. 代理与内部槽位

代理与内置引用类型(比如 Array)的实例通常可以很好地协同,但有些 ECMAScript 内置类型可能会依赖代理无法控制的机制,结果导致在代理上调用某些方法会出错。
一个典型的例子就是 Date 类型。根据 ECMAScript 规范,
Date
类型方法的执行依赖 this 值上的内部槽位[[NumberDate]]。代理对象上不存在这个内部槽位,而且这个内部槽位的值也不能通过普通的 get()set() 操作访问到,于是代理拦截后本应转发给目标对象的方法会抛出 TypeError

9.2 代理捕获器与反射方法

代理可以捕获 13 种不同的基本操作。这些操作有各自不同的反射 API 方法、参数、关联 ECMAScript 操作和不变式。

只要在代理上调用,所有捕获器都会拦截它们对应的反射 API 操作。

9.2.1 get()

get() 捕获器会在获取属性值的操作中被调用。对应的反射 API 方法为 **Reflect.get()**。

1
2
3
4
5
6
7
8
9
10
11
const myTarget = {};  

const proxy = new Proxy(myTarget, {
get(target, property, receiver) {
console.log('get()');
return Reflect.get(...arguments)
}
});

proxy.foo;
// get()
  1. 返回值
    返回值无限制。

  2. 拦截的操作

    • proxy.property

    • proxy[property]

    • Object.create(proxy)[property]

    • Reflect.get(proxy, property, receiver)

  3. 捕获器处理程序参数

    • target:目标对象。

    • property:引用的目标对象上的字符串键属性。

    • receiver:代理对象或继承代理对象的对象。

  4. 捕获器不变式
    如果 target.property 不可写且不可配置,则处理程序返回的值必须与 target.property 匹配。
    如果 target.property 不可配置且[[Get]]特性为 undefined,处理程序的返回值也必须是 undefined

9.2.2 set()

set() 捕获器会在设置属性值的操作中被调用。对应的反射 API 方法为 **Reflect.set()**。

1
2
3
4
5
6
7
8
9
10
const myTarget = {};  
const proxy = new Proxy(myTarget, {
set(target, property, value, receiver) {
console.log('set()');
return Reflect.set(...arguments)
}
});

proxy.foo = 'bar';
// set()
  1. 返回值
    返回 true 表示成功;返回 false 表示失败,严格模式下会抛出 TypeError

  2. 拦截的操作

    • proxy.property = value

    • proxy[property] = value

    • Object.create(proxy)[property] = value

    • Reflect.set(proxy, property, value, receiver)

  3. 捕获器处理程序参数

    • target:目标对象。

    • property:引用的目标对象上的字符串键属性。

    • value:要赋给属性的值。

    • receiver:接收最初赋值的对象。

  4. 捕获器不变式
    如果 target.property 不可写且不可配置,则不能修改目标属性的值。
    如果 target.property 不可配置且[[Set]]特性为 undefined,则不能修改目标属性的值。 在严格模式下,处理程序中返回false会抛出 TypeError

9.2.3 has()

has() 捕获器会在 in 操作符中被调用。对应的反射 API 方法 **Reflect.has()**。

1
2
3
4
5
6
7
8
9
10
11
const myTarget = {};  

const proxy = new Proxy(myTarget, {
has(target, property) {
console.log('has()');
return Reflect.has(...arguments)
}
});

'foo' in proxy;
// has()
  1. 返回值
    has() 必须返回布尔值,表示属性是否存在。返回非布尔值会被转型为布尔值。

  2. 拦截的操作

    • property in proxy

    • property in Object.create(proxy)

    • with(proxy) {(property);}

    • Reflect.has(proxy, property)

  3. 捕获器处理程序参数

    • target:目标对象。

    • property:引用的目标对象上的字符串键属性。

  4. 捕获器不变式
    如果 target.property 存在且不可配置,则处理程序必须返回 true
    如果 target.property 存在且目标对象不可扩展,则处理程序必须返回 true

9.2.4 defineProperty()

defineProperty() 捕获器会在 Object.defineProperty() 中被调用。对应的反射 API 方法为 **Reflect.defineProperty()**。

1
2
3
4
5
6
7
8
9
const myTarget = {};  
const proxy = new Proxy(myTarget, {
defineProperty(target, property, descriptor) {
console.log('defineProperty()');
return Reflect.defineProperty(...arguments)
}
});
Object.defineProperty(proxy, 'foo', { value: 'bar' });
// defineProperty()
  1. 返回值
    defineProperty() 必须返回布尔值,表示属性是否成功定义。返回非布尔值会被转型为布尔值。

  2. 拦截的操作

    • Object.defineProperty(proxy, property, descriptor)

    • Reflect.defineProperty(proxy, property, descriptor)

  3. 捕获器处理程序参数

    • target:目标对象。

    • property:引用的目标对象上的字符串键属性。

    • descriptor:包含可选的 enumerable、configurable、 writable、value、get 和 set 定义的对象。

  4. 捕获器不变式
    如果目标对象不可扩展,则无法定义属性。
    如果目标对象有一个可配置的属性,则不能添加同名的不可配置属性。
    如果目标对象有一个不可配置的属性,则不能添加同名的可配置属性。

9.2.5 getOwnPropertyDescriptor()

getOwnPropertyDescriptor() 捕获器会在 Object.getOwnPropertyDescriptor() 中被调用。对应的反射 API 方法为 **Reflect.getOwnPropertyDescriptor()**。

1
2
3
4
5
6
7
8
9
10
const myTarget = {};  
const proxy = new Proxy(myTarget, {
getOwnPropertyDescriptor(target, property) {
console.log('getOwnPropertyDescriptor()');
return Reflect.getOwnPropertyDescriptor(...arguments)
}
});

Object.getOwnPropertyDescriptor(proxy, 'foo');
// getOwnPropertyDescriptor()
  1. 返回值
    getOwnPropertyDescriptor() 必须返回对象,或者在属性不存在时返回 undefined

  2. 拦截的操作

    • Object.getOwnPropertyDescriptor(proxy, property)

    • Reflect.getOwnPropertyDescriptor(proxy, property)

  3. 捕获器处理程序参数

    • target:目标对象。

    • property:引用的目标对象上的字符串键属性。

  4. 捕获器不变式
    如果自有的 target.property 存在且不可配置,则处理程序必须返回一个表示该属性存在的对象。
    如果自有的 target.property 存在且可配置,则处理程序必须返回表示该属性可配置的对象。
    如果自有的 target.property 存在且 target 不可扩展,则处理程序必须返回一个表示该属性存在的对象。
    如果 target.property 不存在且 target 不可扩展,则处理程序必须返回 undefined 表示该属性不存在。
    如果 target.property 不存在,则处理程序不能返回表示该属性可配置的对象。

9.2.6 deleteProperty()

deleteProperty() 捕获器会在 delete 操作符中被调用。对应的反射 API 方法为 **Reflect. deleteProperty()**。

1
2
3
4
5
6
7
8
9
10
11
const myTarget = {};  

const proxy = new Proxy(myTarget, {
deleteProperty(target, property) {
console.log('deleteProperty()');
return Reflect.deleteProperty(...arguments)
}
});

delete proxy.foo
// deleteProperty()
  1. 返回值
    deleteProperty() 必须返回布尔值,表示删除属性是否成功。返回非布尔值会被转型为布尔值。

  2. 拦截的操作

    • delete proxy.property

    • delete proxy[property]

    • Reflect.deleteProperty(proxy, property)

  3. 捕获器处理程序参数

    • target:目标对象。

    • property:引用的目标对象上的字符串键属性。

  4. 捕获器不变式
    如果自有的 target.property 存在且不可配置,则处理程序不能删除这个属性。

9.2.7 ownKeys()

ownKeys() 捕获器会在 Object.keys() 及类似方法中被调用。对应的反射 API 方法为 **Reflect.ownKeys()**。

1
2
3
4
5
6
7
8
9
10
const myTarget = {};  
const proxy = new Proxy(myTarget, {
ownKeys(target) {
console.log('ownKeys()');
return Reflect.ownKeys(...arguments)
}
});

Object.keys(proxy);
// ownKeys()
  1. 返回值
    ownKeys() 必须返回包含字符串或符号的可枚举对象。

  2. 拦截的操作

    • Object.getOwnPropertyNames(proxy)

    • Object.getOwnPropertySymbols(proxy)

    • Object.keys(proxy)

    • Reflect.ownKeys(proxy)

  3. 捕获器处理程序参数

    • target:目标对象。
  4. 捕获器不变式
    返回的可枚举对象必须包含 target 的所有不可配置的自有属性。
    如果 target 不可扩展,则返回可枚举对象必须准确地包含自有属性键。

9.2.8 getPrototypeOf()

getPrototypeOf() 捕获器会在 Object.getPrototypeOf() 中被调用。对应的反射 API 方法为 **Reflect.getPrototypeOf()**。

1
2
3
4
5
6
7
8
9
10
11
const myTarget = {}; 

const proxy = new Proxy(myTarget, {
getPrototypeOf(target) {
console.log('getPrototypeOf()');
return Reflect.getPrototypeOf(...arguments)
}
});

Object.getPrototypeOf(proxy);
// getPrototypeOf()
  1. 返回值
    getPrototypeOf() 必须返回对象或 null

  2. 拦截的操作

    • Object.getPrototypeOf(proxy)

    • Reflect.getPrototypeOf(proxy)

    • proxy.__proto __

    • Object.prototype.isPrototypeOf(proxy)

    • proxy instanceof Object

  3. 捕获器处理程序参数

    • target:目标对象。
  4. 捕获器不变式
    如果target不可扩展,则Object.getPrototypeOf(proxy) 唯一有效的返回值就是 Object.getPrototypeOf(target) 的返回值。

9.2.9 setPrototypeOf()

setPrototypeOf() 捕获器会在 Object.setPrototypeOf() 中被调用。对应的反射 API 方法为 **Reflect.setPrototypeOf()**。

1
2
3
4
5
6
7
8
9
10
11
const myTarget = {};  

const proxy = new Proxy(myTarget, {
setPrototypeOf(target, prototype) {
console.log('setPrototypeOf()');
return Reflect.setPrototypeOf(...arguments)
}
});

Object.setPrototypeOf(proxy, Object);
// setPrototypeOf()
  1. 返回值
    setPrototypeOf() 必须返回布尔值,表示原型赋值是否成功。返回非布尔值会被转型为布尔值。

  2. 拦截的操作

    • Object.setPrototypeOf(proxy)

    • Reflect.setPrototypeOf(proxy)

  3. 捕获器处理程序参数

    • target:目标对象。

    • prototypetarget 的替代原型,如果是顶级原型则为 null

  4. 捕获器不变式
    如果 target 不可扩展,则唯一有效的 prototype 参数就是 Object.getPrototypeOf(target) 的返回值。

9.2.10 isExtensible()

isExtensible() 捕获器会在 Object.isExtensible() 中被调用。对应的反射 API 方法为 **Reflect.isExtensible()**。

1
2
3
4
5
6
7
8
9
10
11
const myTarget = {};  

const proxy = new Proxy(myTarget, {
isExtensible(target) {
console.log('isExtensible()');
return Reflect.isExtensible(...arguments)
}
});

Object.isExtensible(proxy);
// isExtensible()
  1. 返回值
    isExtensible() 必须返回布尔值,表示 target 是否可扩展。返回非布尔值会被转型为布尔值。

  2. 拦截的操作

    • Object.isExtensible(proxy)

    • Reflect.isExtensible(proxy)

  3. 捕获器处理程序参数

    • target:目标对象。
  4. 捕获器不变式
    如果 target 可扩展,则处理程序必须返回 true
    如果 target 不可扩展,则处理程序必须返回 false

9.2.11 preventExtensions()

preventExtensions() 捕获器会在 Object.preventExtensions() 中被调用。对应的反射 API 方法为 **Reflect.preventExtensions()**。

1
2
3
4
5
6
7
8
9
10
11
const myTarget = {};  

const proxy = new Proxy(myTarget, {
preventExtensions(target) {
console.log('preventExtensions()');
return Reflect.preventExtensions(...arguments)
}
});

Object.preventExtensions(proxy);
// preventExtensions()
  1. 返回值
    preventExtensions() 必须返回布尔值,表示 target 是否已经不可扩展。返回非布尔值会被转型为布尔值。

  2. 拦截的操作

    • Object.preventExtensions(proxy)

    • Reflect.preventExtensions(proxy)

  3. 捕获器处理程序参数

    • target:目标对象。
  4. 捕获器不变式
    如果 Object.isExtensible(proxy)false,则处理程序必须返回 true

9.2.12 apply()

apply() 捕获器会在调用函数时中被调用。对应的反射 API 方法为 **Reflect.apply()**。

1
2
3
4
5
6
7
8
9
10
11
const myTarget = () => {}; 

const proxy = new Proxy(myTarget, {
apply(target, thisArg, ...argumentsList) {
console.log('apply()');
return Reflect.apply(...arguments)
}
});

proxy();
// apply()
  1. 返回值
    返回值无限制。

  2. 拦截的操作

    • proxy(…argumentsList)

    • Function.prototype.apply(thisArg, argumentsList)

    • Function.prototype.call(thisArg, …argumentsList)

    • Reflect.apply(target, thisArgument, argumentsList)

  3. 捕获器处理程序参数

    • target:目标对象。

    • thisArg:调用函数时的this参数。

    • argumentsList:调用函数时的参数列表

  4. 捕获器不变式
    target 必须是一个函数对象。

9.2.13 construct()

construct() 捕获器会在 new 操作符中被调用。对应的反射 API 方法为 **Reflect.construct()**。

1
2
3
4
5
6
7
8
9
10
11
const myTarget = function() {};  

const proxy = new Proxy(myTarget, {
construct(target, argumentsList, newTarget) {
console.log('construct()');
return Reflect.construct(...arguments)
}
});

new proxy;
// construct()
  1. 返回值
    construct() 必须返回一个对象。

  2. 拦截的操作

    • new proxy(…argumentsList)

    • Reflect.construct(target, argumentsList, newTarget)

  3. 捕获器处理程序参数

    • target:目标构造函数。

    • argumentsList:传给目标构造函数的参数列表。

    • newTarget:最初被调用的构造函数。

  4. 捕获器不变式
    target 必须可以用作构造函数。

9.3 代理模式

使用代理可以在代码中实现一些有用的编程模式。

9.3.1 跟踪属性访问

通过捕获 get、set 和 has 等操作,可以知道对象属性什么时候被访问、被查询。把实现相应捕获器的某个对象代理放到应用中,可以监控这个对象何时在何处被访问过。

9.3.2 隐藏属性

代理的内部实现对外部代码是不可见的,因此要隐藏目标对象上的属性也轻而易举。

9.3.3 属性验证

因为所有赋值操作都会触发 set() 捕获器,所以可以根据所赋的值决定是允许还是拒绝赋值。

9.3.4 函数与构造参数验证

跟保护和验证对象属性类似,也可对函数和构造函数参数进行审查。比如,可以让函数只接收某种类型的值。

9.3.5 数据绑定与可观察对象

通过代理可以把运行时中原本不相关的部分联系到一起。这样就可以实现各种模式,从而让不同的代码互操作。
比如,可以将被代理的类绑定到一个全局实例集合,让所有创建的实例都被添加到这个集合中。还可以把集合绑定到一个事件分派程序,每次插入新实例时都会发送消息。

9.4 小结

代理是 ECMAScript 6 新增的令人兴奋和动态十足的新特性。尽管不支持向后兼容,但它开辟出了一片前所未有的 JavaScript 元编程及抽象的新天地。
从宏观上看,代理是真实 JavaScript 对象的透明抽象层。代理可以定义包含捕获器的处理程序对象,而这些捕获器可以拦截绝大部分JavaScript 的基本操作和方法。在这个捕获器处理程序中,可以修改任
何基本操作的行为,当然前提是遵从捕获器不变式。
与代理如影随形的反射 API,则封装了一整套与捕获器拦截的操作相对应的方法。可以把反射 API 看作一套基本操作,这些操作是绝大部分 JavaScript 对象 API 的基础。
代理的应用场景是不可限量的。开发者使用它可以创建出各种编码模式,比如(但远远不限于)跟踪属性访问、隐藏属性、阻止修改或删除属性、函数参数验证、构造函数参数验证、数据绑定,以及可观察对象。

十、函数

函数实际上是对象。每个函数都是 Function 类型的实例,而 Function 也有属性和方法,跟其他引用类型一样。因为函数是对象,所以函数名就是指向函数对象的指针,而且不一定与函数本身紧密绑定。

  1. 函数通常以函数声明的方式定义。

注意函数定义最后没有加分号。

  1. 函数表达式。函数表达式与函数声明几乎是等价的。
1
2
3
let sum = function(num1, num2) { 
return num1 + num2;
};

注意 function 关键字后面没有名称,
注意这里的函数末尾是有分号的,与任何变量初始化语句一样。

  1. 定义函数:“箭头函数”(arrow function)
1
2
3
let sum = (num1, num2) => { 
return num1 + num2;
};
  1. 使用Function构造函数。这个构造函数接收任意多个字符串参数,最后一个参数始终会被当成函数体,而之前的参数都是新函数的参数。

10.1 箭头函数

ECMAScript 6 新增了使用胖箭头(**=>**)语法定义函数表达式的能力。很大程度上,箭头函数实例化的函数对象与正式的函数表达式创建的函数对象行为是相同的。任何可以使用函数表达式的地方,都可以使用箭头函数。

箭头函数简洁的语法非常适合嵌入函数的场景。

如果只有一个参数,那也可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号。

箭头函数也可以不用大括号,但这样会改变函数的行为。使用大括号就说明包含“函数体”,可以在一个函数中包含多条语句,跟常规的函数一样。如果不使用大括号,那么箭头后面就只能有一行代码。

箭头函数不能使用 arguments、super和new.target ,也不能用作构造函数。此外,箭头函数也没有 prototype 属性。

10.2 函数名

函数名就是指向函数的指针,这意味着一个函数可以有多个名称。

使用不带括号的函数名会访问函数指针,而不会执行函数。

ECMAScript 6 的所有函数对象都会暴露一个只读的 name 属性,其中包含关于函数的信息。多数情况下,这个属性中保存的就是一个函数标识符,或者说是一个字符串化的变量名。即使函数没有名称,也会如实显示成空字符串。如果它是使用 Function 构造函数创建的,则会标识成”anonymous“。

1
2
3
4
5
6
7
8
9
function foo() {} 
let bar = function() {};
let baz = () => {};

console.log(foo.name); // foo
console.log(bar.name); // bar
console.log(baz.name); // baz
console.log((() => {}).name); //(空字符串)
console.log((new Function()).name); // anonymous

如果函数是一个获取函数、设置函数,或者使用 bind() 实例化,那么标识符前面会加上一个前缀。

10.3 理解参数

ECMAScript 函数既不关心传入的参数个数,也不关心这些参数的数据类型。定义函数时要接收两个参数,并不意味着调用时就传两个参数。你可以传一个、三个,甚至一个也不传,解释器都不会报错 —— 因为 ECMAScript 函数的参数在内部表现为一个数组。

事实上,在使用 function 关键字定义(非箭头)函数时,可以在函数内部访问 arguments 对象,从中取得传进来的每个参数值。
arguments 对象是一个类数组对象(但不是 Array 的实例),因此可以使用中括号语法访问其中的元素(第一个参数是 **arguments[0]**,第二个参数是 **arguments[1]**)。而要确定传进来多少个参数,可以访问 arguments.length 属性。

ECMAScript 函数的参数只是为了方便才写出来的,并不是必须写出来的。

arguments 对象可以跟命名参数一起使用。

arguments对象的值始终会与对应的命名参数同步。但这并不意味着它们都访问同一个内存地址,它们在内存中还是分开的,只不过会保持同步而已。如果只传了一个参数,然后把 arguments[1] 设置为某个值,那么这个值并不会反映到第二个命名参数。这是因为 arguments 对象的长度是根据传入的参数个数,而非定义函数时给出的命名参数个数确定的。

对于命名参数而言,如果调用函数时没有传这个参数,那么它的值就是 undefined

严格模式下,arguments会有一些变化。首先,像前面那样arguments[1]赋值不会再影响num2的值。就算把arguments[1]设置为 10,num2的值仍然还是传入的值。次,在函数中尝试重写arguments对象会导致语法错误。(代码也不会执行。)

  1. 箭头函数中的参数

如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用 arguments 关键字访问,而只能通过定义的命名参数访问。

虽然箭头函数中没有 arguments 对象,但可以在包装函数中把它提供给箭头函数。

ECMAScript 中的所有参数都按值传递的。不可能按引用传递参数。如果把对象作为参数传递,那么传递的值就是这个对象的引用。

10.4 没有重载

ECMAScript 函数没有签名,因为参数是由包含零个或多个值的数组表示的。没有函数签名,自然也就没有重载。
如果在 ECMAScript 中定义了两个同名函数,则后定义的会覆盖先定义的。

10.5 默认参数值

ECMAScript 6 之后支持显式定义默认参数。

在使用默认参数时,arguments 对象的值不反映参数的默认值,只反映传给函数的参数。当然,跟 ES5 严格模式一样,修改命名参数也不会影响 arguments 对象,它始终以调用函数时传入的值为准。

1
2
3
4
5
6
7
function makeKing(name = 'Henry') { 
name = 'Louis';
return `King ${arguments[0]}`;
}

console.log(makeKing()); // 'King undefined'
console.log(makeKing('Louis')); // 'King Louis'

默认参数值并不限于原始值或对象类型,也可以使用调用函数返回的值

函数的默认参数只有在函数被调用时才会求值,不会在函数定义时求值。而且,计算默认值的函数只有在调用函数但未传相应参数时才会被调用。
箭头函数同样也可以这样使用默认参数,只不过在只有一个参数时,就必须使用括号而不能省略了。

  1. 默认参数作用域与暂时性死区

因为在求值默认参数时可以定义对象,也可以动态调用函数,所以函数参数肯定是在某个作用域中求值的。
给多个参数定义默认值实际上跟使用 let 关键字顺序声明变量一样。

参数初始化顺序遵循“暂时性死区”规则,即前面定义的参数不能引用后面定义的。

参数也存在于自己的作用域中,它们不能引用函数体的作用域。

10.6 参数扩展与收集

扩展操作符最有用的场景就是函数定义中的参数列表,
扩展操作符既可以用于调用函数时传参,也可以用于定义函数参数。

10.6.1 扩展参数

在给函数传参时,有时候可能不需要传一个数组,而是要分别传入数组的元素。

对可迭代对象应用扩展操作符,并将其作为一个参数传入,可以将可迭代对象拆分,并将迭代返回的每个值单独传入。

arguments 对象只是消费扩展操作符的一种方式。在普通函数和箭头函数中,也可以将扩展操作符用于命名参数,当然同时也可以使用默认参数。

10.6.2 收集参数

在构思函数定义时,可以使用扩展操作符把不同长度的独立参数组合为一个数组。这有点类似 arguments 对象的构造机制,只不过收集参数的结果会得到一个 Array 实例。

箭头函数虽然不支持 arguments 对象,但支持收集参数的定义方式,因此也可以实现与使用 arguments 一样的逻辑。

使用收集参数并不影响 arguments 对象,它仍然反映调用时传给函数的参数。

10.7 函数声明与函数表达式

JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。

函数声明会在任何代码执行之前先被读取并添加到执行上下文。这个
过程叫作函数声明提升(function declaration hoisting)。

10.8 函数作为值

因为函数名在 ECMAScript 中就是变量,所以函数可以用在任何可以使用变量的地方。这意味着不仅可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另一个函数。

要注意的是,如果是访问函数而不是调用函数,那就必须不带括号。

10.9 函数内部

ECMAScript 5 中,函数内部存在两个特殊的对象:arguments 和 thisECMAScript 6 又新增了 new.target 属性。

10.9.1 arguments

arguments 对象前面讨论过多次了,它是一个类数组对象,包含调用函数时传入的所有参数。这个对象只有以 function 关键字定义函数(相对于使用箭头语法创建函数)时才会有。虽然主要用于包含函数参数,但 arguments 对象其实还有一个 callee 属性,是一个指向 arguments 对象所在函数的指针。

1
2
3
4
5
6
7
8
function factorial(num) { 
if (num <= 1) {
return 1;
} else {
return num * arguments.callee(num - 1);
}
}
// 这个重写之后的factorial()函数已经用arguments.callee代替了之前硬编码的factorial。这意味着无论函数叫什么名称,都可以引用正确的函数。

10.9.2 this

在标准函数中,this 引用的是把函数当成方法调用的上下文对象,这时候通常称其为 this 值(在网页的全局上下文中调用函数时,this 指向 windows)。

在箭头函数中,this 引用的是定义箭头函数的上下文。

注意 函数名只是保存指针的变量。

10.9.3 caller

ECMAScript 5 也会给函数对象上添加一个属性:caller。这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为 null

在严格模式下访问 arguments.callee 会报错。ECMAScript 5 也定义了 arguments.caller,但在严格模式下访问它会报错,在非严格模式下则始终是 undefined

严格模式下还有一个限制,就是不能给函数的 caller 属性赋值,否则会导致错误。

10.9.4 new.target

ECMAScript 中的函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。ECMAScript 6 新增了检测函数是否使用 new 关键字调用的 new.target 属性。如果函数是正常调用的,则 new.target 的值是 undefined;如果是使用 new 关键字调用的,则 new.target 将引用被调用的构造函数。

10.10 函数属性与方法

ECMAScript 中的函数是对象,因此有属性和方法。每个函数都有两个属性:length 和 prototype。其中,length 属性保存函数定义的命名参数的个数。

prototype 是保存引用类型所有实例方法的地方,这意味着 toString()、valueOf() 等方法实际上都保存在 prototype 上,进而由所有实例共享。这个属性在自定义类型时特别重要。在 ECMAScript 5
中,prototype 属性是不可枚举的,因此使用 for-in 循环不会返回这个属性。
函数还有两个方法:**apply() 和 call()**。这两个方法都会以指定的 this 值来调用函数,即会设置调用函数时函数体内 this 对象的值 —— 控制函数调用上下文即函数体内 this 值的能力。

apply() 方法接收两个参数:函数内 this 的值和一个参数数组。第二个参数可以是 Array 的实例,但也可以是 arguments 对象。

在严格模式下,调用函数时如果没有指定上下文对象,则 this 值不会指向 window。除非使用 apply() 或 call() 把函数指定给一个对象,否则 this 的值会变成 undefined

call() 方法与 apply() 的作用一样,只是传参的形式不同。第一个参数是 this 值,而剩下的要传给被调用函数的参数则是逐个传递的。换句话说,通过 call() 向函数传参时,必须将参数一个一个地列出来。

使用 call() 或 apply() 的好处是可以将任意对象设置为任意函数的作用域,这样对象可以不用关心方法。

ECMAScript 5 出于同样的目的定义了一个新方法:bind()bind() 方法会创建一个新的函数实例,其 this 值会被绑定到传给 bind() 的对象。

对函数而言,继承的方法 toLocaleString() 和 toString() 始终返回函数的代码。继承的方法 valueOf() 返回函数本身。

10.11 函数表达式

定义函数有两种方式:函数声明和函数表达式。

函数声明的关键特点是函数声明提升,即函数声明会在代码执行之前获得定义。这意味着函数声明可以出现在调用它的代码之后。

第二种创建函数的方式就是函数表达式。

1
2
3
let functionName = function(arg0, arg1, arg2) { 
// 函数体
};

函数表达式看起来就像一个普通的变量定义和赋值,即创建一个函数再把它赋值给一个变量 functionName。这样创建的函数叫作匿名函数(anonymous funtion),因为function关键字后面没有标识符。(匿名函数有也时候也被称为兰姆达函数)。未赋值给其他变量的匿名函数的 name 属性是空字符串。

创建函数并赋值给变量的能力也可以用于在一个函数中把另一个函数当作值返回。

10.12 递归

递归函数通常的形式是一个函数通过名称调用自己。

1
2
3
4
5
6
7
8
function factorial(num) {  
if (num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
}
}
// 这是经典的递归阶乘函数。

arguments.callee 是一个指向正在执行的函数的指针,因此可以在函数内部递归调用。

1
2
3
4
5
6
7
8
function factorial(num) { 
if (num <= 1) {
return 1;
} else {
return num * arguments.callee(num - 1);
}
}
//在编写递归函数时,arguments.callee是引用当前函数的首选.

在严格模式下运行的代码是不能访问 arguments.callee 的,因为访问会出错。

10.13 尾调用优化

ECMAScript 6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧。具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。

1
2
3
function outerFunction() { 
return innerFunction(); // 尾调用
}

ES6 优化之前,执行这个例子会在内存中发生如下操作。
(1) 执行到outerFunction函数体,第一个栈帧被推到栈上。
(2) 执行outerFunction函数体,到return语句。计算返回值必须先计算innerFunction。
(3) 执行到innerFunction函数体,第二个栈帧被推到栈上。
(4) 执行innerFunction函数体,计算其返回值。
(5) 将返回值传回outerFunction,然后outerFunction再返回值。
(6) 将栈帧弹出栈外。

ES6 优化之后,执行这个例子会在内存中发生如下操作。
(1) 执行到outerFunction函数体,第一个栈帧被推到栈上。
(2) 执行outerFunction函数体,到达return语句。为求值返回语句,必须先求值innerFunction。
(3) 引擎发现把第一个栈帧弹出栈外也没问题,因为innerFunction的返回值也是outerFunction的返回值。
(4) 弹出outerFunction的栈帧。
(5) 执行到innerFunction函数体,栈帧被推到栈上。
(6) 执行innerFunction函数体,计算其返回值。
(7) 将innerFunction的栈帧弹出栈外。
很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。这就是 ES6 尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。

10.13.1 尾调用优化的条件

尾调用优化的条件就是确定外部栈帧真的没有必要存在了。

  • 代码在严格模式下执行。

  • 外部函数的返回值是对尾调用函数的调用。

  • 尾调用函数返回后不需要执行额外的逻辑。

  • 尾调用函数不是引用外部函数作用域中自由变量的闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 下面是几个符合尾调用优化条件的例子: 
"use strict";

// 有优化:栈帧销毁前执行参数计算
function outerFunction(a, b) {
return innerFunction(a + b);
}

// 有优化:初始返回值不涉及栈帧
function outerFunction(a, b) {
if (a < b) {
return a;
}
return innerFunction(a + b);
}

// 有优化:两个内部函数都在尾部
function outerFunction(condition) {
return condition ? innerFunctionA() : innerFunctionB();
}

10.14 闭包

闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。

函数执行时,每个执行上下文中都会有一个包含其中变量的对象。全局上下文中的叫变量对象,它会在代码执行期间始终存在。而函数局部上下文中的叫活动对象,只在函数执行期间存在。

函数内部的代码在访问变量时,就会使用给定的名称从作用域链中查找变量。函数执行完毕后,局部活动对象会被销毁,内存中就只剩下全局作用域。

因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。V8 等优化的 JavaScript 引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。

10.14.1 this对象

在闭包中使用 this 会让代码变复杂。如果内部函数没有使用箭头函数定义,则 this 对象会在运行时绑定到执行函数的上下文。如果在全局函数中调用,则 this 在非严格模式下等于 window,在严格模式下等于 undefined。如果作为某个对象的方法调用,则 this 等于这个对象。匿名函数在这种情况下不会绑定到某个对象,这就意味着 this 会指向 window,除非在严格模式下 thisundefined

每个函数在被调用时都会自动创建两个特殊变量:this 和 arguments。内部函数永远不可能直接访问外部函数的这两个变量。

thisarguments 都是不能直接在内部函数中访问的。如果想访问包含作用域中的 arguments 对象,则同样需要将其引用先保存到闭包能访问的另一个变量中。

10.15 立即调用的函数表达式

立即调用的匿名函数又被称作立即调用的函数表达式(IIFE,Immediately Invoked Function Expression)。它类似于函数声明,但由于被包含在括号中,所以会被解释为函数表达式。

使用 IIFE 可以模拟块级作用域,即在一个函数表达式内部声明变量,然后立即调用这个函数。

10.16 私有变量

严格来讲,JavaScript 没有私有成员的概念,所有对象属性都公有的。不过,倒是有私有变量的概念。任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。私有变量包括函数参数、局部变量,以及函数内部定义的其他函数。

特权方法(privileged method)是能够访问函数私有变量(及私有函数)的公有方法。在对象上有两种方式创建特权方法。第一种是在构造函数中实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function MyObject() {  
// 私有变量和私有函数
let privateVariable = 10;

function privateFunction() {
return false;
}

// 特权方法
this.publicMethod = function() {
privateVariable++;
return privateFunction();
};
}

10.16.1 静态私有变量

特权方法也可以通过使用私有作用域定义私有变量和函数来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(function() {  
// 私有变量和私有函数
let privateVariable = 10;

function privateFunction() {
return false;
}

// 构造函数
MyObject = function() {};

// 公有和特权方法
MyObject.prototype.publicMethod = function() {
privateVariable++;
return privateFunction();
};
})();

特权方法作为一个闭包,始终引用着包含它的作用域。

10.16.2 模块模式

模块模式,则在一个单例对象上实现了相同的隔离和封装。单例对象(singleton)就是只有一个实例的对象。按照惯例,JavaScript 是通过对象字面量来创建单例对象的。

模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 样板代码如下: 
let singleton = function() {
// 私有变量和私有函数
let privateVariable = 10;

function privateFunction() {
return false;
}

// 特权/公有方法和属性
return {
publicProperty: true,

publicMethod() {
privateVariable++;
return privateFunction();
}
};
}();

模块模式使用了匿名函数返回一个对象。在匿名函数内部,首先定义私有变量和私有函数。之后,创建一个要通过匿名函数返回的对象字面量。这个对象字面量中只包含可以公开访问的属性和方法。因为这个对象定义在匿名函数内部,所以它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。本质上,对象字面量定义了单例对象的公共接口。如果单例对象需要进行某种初始化,并且需要访问私有变量时,那就可以采用这个模式。

在模块模式中,单例对象作为一个模块,经过初始化可以包含某些私有的数据,而这些数据又可以通过其暴露的公共方法来访问。以这种方式创建的每个单例对象都是 Object 的实例,因为最终单例都由一个对象字面量来表示。

10.16.3 模块增强模式

利用模块模式的做法是在返回对象之前先对其进行增强。这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。

10.17 小结

函数是 JavaScript 编程中最有用也最通用的工具。

  • 函数表达式与函数声明是不一样的。函数声明要求写出函数名称,而函数表达式并不需要。没有名称的函数表达式也被称为匿名函数。

  • ES6 新增了类似于函数表达式的箭头函数语法,但两者也有一些重要区别。

  • JavaScript 中函数定义与调用时的参数极其灵活。arguments 对象,以及 ES6 新增的扩展操作符,可以实现函数定义和调用的完全动态化。

  • 函数内部也暴露了很多对象和引用,涵盖了函数被谁调用、使用什么调用,以及调用时传入了什么参数等信息。

  • JavaScript 引擎可以优化符合尾调用条件的函数,以节省栈空间。

  • 闭包的作用域链中包含自己的一个变量对象,然后是包含函数的变量对象,直到全局上下文的变量对象。

  • 通常,函数作用域及其中的所有变量在函数执行完毕后都会被销毁。

  • 闭包在被函数返回之后,其作用域会一直保存在内存中,直到闭包被销毁。

  • 函数可以在创建之后立即调用,执行其中代码之后却不留下对函数的引用。

  • 立即调用的函数表达式如果不在包含作用域中将返回值赋给一个变量,则其包含的所有变量都会被销毁。

  • 虽然 JavaScript 没有私有对象属性的概念,但可以使用闭包实现公共方法,访问位于包含作用域中定义的变量。

  • 可以访问私有变量的公共方法叫作特权方法。

  • 特权方法可以使用构造函数或原型模式通过自定义类型中实现,也可以使用模块模式或模块增强模式在单例对象上实现。

十一、期约与异步函数

ECMAScript 6 新增了正式的 Promise(期约)引用类型,支持优雅地定义和组织异步逻辑。

11.1 异步编程

JavaScript 这种单线程事件循环模型中,同步操作与异步操作更是代码所要依赖的核心机制。

11.1.1 同步与异步

同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。

异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。

11.1.2 以往的异步编程模式

异步行为是 JavaScript 的基础,通常需要深度嵌套的回调函数(俗称“回调地狱”)来解决。

  1. 异步返回值

给异步操作提供一个回调,这个回调中包含要使用异步返回值的代码(作为回调的参数)。

  1. 失败处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function double(value, success, failure) {  
setTimeout(() => {
try {
if (typeof value !== 'number') {
throw 'Must provide number as first argument';
}
success(2 * value);
} catch (e) {
failure(e);
}
}, 1000);
}

const successCallback = (x) => console.log(`Success: ${x}`);
const failureCallback = (e) => console.log(`Failure: ${e}`);

double(3, successCallback, failureCallback);
double('b', successCallback, failureCallback);

// Success: 6(大约 1000 毫秒之后)
// Failure: Must provide number as first argument(大约 1000 毫秒之后)
  1. 嵌套异步回调

11.2 期约

期约是对尚不存在结果的一个替身。期约(promise)描述的是一种异步程序执行的机制。

11.2.1 期约基础

ECMAScript 6 新增的引用类型 Promise,可以通过 new 操作符来实例化。创建新期约时需要传入执行器(executor)函数作为参数。

  1. 期约状态机

期约是一个有状态的对象,可能处于如下 3 种状态之一:

  • 待定(pending

  • 兑现(fulfilled,有时候也称为“解决”,resolved

  • 拒绝(rejected

待定(pending)是期约的最初始状态。在待定状态下,期约可以落定(settled)为代表成功的兑现(fulfilled)状态,或者代表失败的拒绝(rejected)状态。无论落定为哪种状态都是不可逆的。只要从待
定转换为兑现或拒绝,期约的状态就不再改变。而且,也不能保证期约必然会脱离待定状态。因此,组织合理的代码无论期约解决(resolve)还是拒绝(reject),甚至永远处于待定(pending)状态,都应该
具有恰当的行为。
重要的是,期约的状态是私有的,不能直接通过 JavaScript 检测到。这主要是为了避免根据读取到的期约状态,以同步方式处理期约对象。另外,期约的状态也不能被外部 JavaScript 代码修改。

  1. 解决值、拒绝理由及期约用例

期约主要有两大用途。首先是抽象地表示一个异步操作。期约的状态代表期约是否完成。“待定”表示尚未开始或者正在执行中。“兑现”表示已经成功完成,而“拒绝”则表示没有成功完成。

在另外一些情况下,期约封装的异步操作会实际生成某个值,而程序期待期约状态改变时可以访问这个值。相应地,如果期约被拒绝,程序就会期待期约状态改变时可以拿到拒绝的理由。

为了支持这两种用例,每个期约只要状态切换为兑现,就会有一个私有的内部值(value)。类似地,每个期约只要状态切换为拒绝,就会有一个私有的内部理由(reason)。无论是值还是理由,都是包含原始值或对象的不可修改的引用。二者都是可选的,而且默认值为 undefined。在期约到达某个落定状态时执行的异步代码始终会收到这个值或理由。

  1. 通过执行函数控制期约状态

由于期约的状态是私有的,所以只能在内部进行操作。内部操作在期约的执行器函数中完成。执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。其中,控制期约状态的转换是通过调用它的两个函数参数实现的。这两个函数参数通常都命名为 **resolve() 和 reject()**。调用 resolve() 会把状态切换为兑现,调用 reject() 会把状态切换为拒绝。另外,调用 reject() 也会抛出错误。

  1. Promise.resolve()

通过调用 Promise.resolve() 静态方法,可以实例化一个解决的期约。

1
2
let p1 = new Promise((resolve, reject) => resolve()); 
let p2 = Promise.resolve();

对这个静态方法而言,如果传入的参数本身是一个期约,那它的行为就类似于一个空包装。因此,Promise.resolve() 可以说是一个幂等方法。

这个静态方法能够包装任何非期约值,包括错误对象,并将其转换为解决的期约。

  1. Promise.reject()

Promise.reject() 会实例化一个拒绝的期约并抛出一个异步错误(这个错误不能通过 try/catch 捕获,而只能通过拒绝处理程序捕获)。

Promise.reject() 并没有照搬 Promise.resolve() 的幂等逻辑。如果给它传一个期约对象,则这个期约会成为它返回的拒绝期约的理由。

  1. 同步/异步执行的二元性

期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。

11.2.3 期约的实例方法

期约实例的方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的代码。

  1. 实现 Thenable 接口

ECMAScript 暴露的异步结构中,任何对象都有一个 then() 方法。这个方法被认为实现了 Thenable 接口。

1
2
3
class MyThenable { 
then() {}
}
  1. Promise.prototype.then()

Promise.prototype.then() 是为期约实例添加处理程序的主要方法。这个 then() 方法接收最多两个参数:onResolved 处理程序和 onRejected 处理程序。这两个参数都是可选的,如果提供的话,则会在期约分别进入“兑现”和“拒绝”状态时执行。

传给 then() 的任何非函数类型的参数都会被静默忽略。如果想只提供 onRejected 参数,那就要在 onResolved 参数的位置上传入 undefined。这样有助于避免在内存中创建多余的对象,对期待函数参数的类型系统也是一个交代。

onRejected 处理程序也与之类似:onRejected 处理程序返回的值也会被 Promise.resolve() 包装。

  1. Promise.prototype.catch()

Promise.prototype.catch() 方法用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected 处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用 **Promise.prototype. then(null,onRejected)**。

在返回新期约实例方面,Promise.prototype.catch() 的行为与 Promise.prototype.then()onRejected 处理程序是一样的。

  1. Promise.prototype.finally()

Promise.prototype.finally() 方法用于给期约添加 onFinally 处理程序,这个处理程序在期约转换为解决或拒绝状态时都会执行。这个方法可以避免 onResolvedonRejected 处理程序中出现冗余代码。但 onFinally 处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。

onFinally 被设计为一个状态无关的方法,所以在大多数情况下它将表现为父期约的传递。对于已解决状态和被拒绝状态都是如此。

如果返回的是一个待定的期约,或者 onFinally 处理程序抛出了错误(显式抛出或返回了一个拒绝期约),则会返回相应的期约(待定或拒绝)。

  1. 非重入期约方法

当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前先执行。即使期约一开始就是与附加处理程序关联的状态,执行顺序也是这样的。这个特性由 JavaScript 运行时保证,被称为“非重入”(non-reentrancy)特性。

非重入适用于 onResolved/onRejected 处理程序、catch() 处理程序和 finally() 处理程序。

  1. 邻近处理程序的执行顺序

如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。无论是 then()、catch() 还是 finally() 添加的处理程序都是如此。

  1. 传递解决值和拒绝理由

到了落定状态后,期约会提供其解决值(如果兑现)或其拒绝理由(如果拒绝)给相关状态的处理程序。拿到返回值后,就可以进一步对这个值进行操作。

在执行函数中,解决的值和拒绝的理由是分别作为 resolve() 和 reject() 的第一个参数往后传的。然后,这些值又会传给它们各自的处理程序,作为 onResolved 或 onRejected 处理程序的唯一参数。

Promise.resolve() 和 Promise.reject() 在被调用时就会接收解决值和拒绝理由。同样地,它们返回的期约也会像执行器一样把这些值传给 onResolved 或 onRejected 处理程序。

  1. 拒绝期约与拒绝错误处理

拒绝期约类似于 throw() 表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。因此以下这些期约都会以一个错误对象为由被拒绝。

期约可以以任何理由拒绝,包括 undefined,但最好统一使用错误对象。这样做主要是因为创建错误对象可以让浏览器捕获错误对象中的栈追踪信息,而这些信息对调试是非常关键的。

所有错误都是异步抛出且未处理的,通过错误对象捕获的栈追踪信息展示了错误发生的路径。注意错误的顺序:Promise.resolve().then() 的错误最后才出现,这是因为它需要在运行时消息队列中添加处理程序;也就是说,在最终抛出未捕获错误之前它还会创建另一个期约。

正常情况下,在通过 throw() 关键字抛出错误时,JavaScript 运行时的错误处理机制会停止执行抛出错误之后的任何指令。

但是,在期约中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令。

异步错误只能通过异步的 onRejected 处理程序捕获。

1
2
3
4
5
6
7
// 正确  
Promise.reject(Error('foo')).catch((e) => {});

// 不正确
try {
Promise.reject(Error('foo'));
} catch(e) {}

这不包括捕获执行函数中的错误,在解决或拒绝期约之前,仍然可以使用 try/catch 在执行函数中捕获错误。

then() 和 catch() 的 onRejected 处理程序在语义上相当于 try/catch。出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。为此,onRejected 处理程序的任务应该是在捕获异步错误之后返回一个解决的期约。

11.2.4 期约连锁与期约合成

多个期约组合在一起可以构成强大的代码逻辑。这种组合可以通过两种方式实现:期约连锁与期约合成。前者就是一个期约接一个期约地拼接,后者则是将多个期约组合为一个期约。

  1. 期约连锁

把期约逐个地串联起来是一种非常有用的编程模式。

要真正执行异步任务,可以让每个执行器都返回一个期约实例。这样就可以让每个后续期约都等待之前的期约,也就是串行化异步任务。

  1. 期约图

因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。这样,每个期约都是图中的一个节点,而使用实例方法添加的处理程序则是有向顶点。因为图中的每个节点都会等待前一个节点落定,所以图的方向就是期约的解决或拒绝顺序。

树只是期约图的一种形式。考虑到根节点不一定唯一,且多个期约也可以组合成一个期约,所以有向非循环图是体现期约连锁可能性的最准确表达。

  1. Promise.all()和Promise.race()

Promise 类提供两个将多个期约实例组合成一个期约的静态方法:**Promise.all() 和 Promise.race()**。而合成后期约的行为取决于内部期约的行为。

  • Promise.all()

Promise.all() 静态方法创建的期约会在一组期约全部解决之后再解决。这个静态方法接收一个可迭代对象,返回一个新期约。

合成的期约只会在每个包含的期约都解决之后才解决。

如果至少有一个包含的期约待定,则合成的期约也会待定。如果有一个包含的期约拒绝,则合成的期约也会拒绝。

如果所有期约都成功解决,则合成期约的解决值就是所有包含期约解决值的数组,按照迭代器顺序。

如果有期约拒绝,则第一个拒绝的期约会将自己的理由作为合成期约的拒绝理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,这并不影响所有包含期约正常的拒绝操作。合成的期约会静默处理所有包含期约的拒绝操作。

  • Promise.race()

Promise.race() 静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像。这个方法接收一个可迭代对象,返回一个新期约。

Promise.race() 不会对解决或拒绝的期约区别对待。无论是解决还是拒绝,只要是第一个落定的期约,Promise.race() 就会包装其解决值或拒绝理由并返回新期约。

如果有一个期约拒绝,只要它是第一个落定的,就会成为拒绝合成期约的理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,这并不影响所有包含期约正常的拒绝操作。与 Promise.all() 类似,合成的期约会静默处理所有包含期约的拒绝操作。

基于后续期约使用之前期约的返回值来串联期约是期约的基本功能。

11.2.5 期约扩展

  1. 期约取消

实际上,可以在现有实现基础上提供一种临时性的封装,以实现取消期约的功能。这可以用到 Kevin Smith 提到的“取消令牌”(cancel token)。生成的令牌实例提供了一个接口,利用这个接口可以取消期约;同时也提供了一个期约的实例,可以用来触发取消后的操作并求值取消状态。

1
2
3
4
5
6
7
class CancelToken {  
constructor(cancelFn) {
this.promise = new Promise((resolve, reject) => {
cancelFn(resolve);
});
}
}
  1. 期约进度通知

ECMAScript 6 期约并不支持进度追踪,但是可以通过扩展来实现。
一种实现方式是扩展 Promise 类,为它添加 notify() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class TrackablePromise extends Promise { 
constructor(executor) {
const notifyHandlers = [];

super((resolve, reject) => {
return executor(resolve, reject, (status) => {
notifyHandlers.map((handler) => handler(status));
});
});

this.notifyHandlers = notifyHandlers;
}

notify(notifyHandler) {
this.notifyHandlers.push(notifyHandler);
return this;
}
}
//可以像下面这样使用这个函数来实例化一个期约:
let p = new TrackablePromise((resolve, reject, notify) => {
function countdown(x) {
if (x > 0) {
notify(`${20 * x}% remaining`);
setTimeout(() => countdown(x - 1), 1000);
} else {
resolve();
}
}

countdown(5);
});

notify() 函数会返回期约,所以可以连缀调用,连续添加处理程序。多个处理程序会针对收到的每条消息分别执行一遍。

11.3 异步函数

异步函数,也称为“async/await”(语法关键字),是 ES6 期约模式在 ECMAScript 函数中的应用。
async/awaitES8 规范新增的。

11.3.1 异步函数

ES8async/await 旨在解决利用异步结构组织代码的问题。为此,ECMAScript 对函数进行了扩展,为其增加了两个新关键字:async和await

  1. async

async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上。

使用 async 关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通 JavaScript 函数的正常行为。

不过,异步函数如果使用 return 关键字返回了值(如果没有 return 则会返回 undefined),这个值会被Promise.resolve() 包装成一个期约对象。异步函数始终返回期约对象。在函数外部调用这个函数可以得到它返回的期约。

异步函数的返回值期待(但实际上并不要求)一个实现 thenable 接口的对象,但常规的值也可以。如果返回的是实现 thenable 接口的对象,则这个对象可以由提供给 then() 的处理程序“解包”。如果不是,则返回值就被当作已经解决的期约。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 返回一个原始值  
async function foo() {
return 'foo';
}
foo().then(console.log);
// foo

// 返回一个没有实现 thenable 接口的对象
async function bar() {
return ['bar'];
}
bar().then(console.log);
// ['bar']

// 返回一个实现了 thenable 接口的非期约对象
async function baz() {
const thenable = {
then(callback) { callback('baz'); }
};
return thenable;
}
baz().then(console.log);
// baz
// 返回一个期约
async function qux() {
return Promise.resolve('qux');
}
qux().then(console.log);
// qux

与在期约处理程序中一样,在异步函数中抛出错误会返回拒绝的期约。不过,拒绝期约的错误不会被异步函数捕获。

  1. await

因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。使用 await 关键字可以暂停异步函数代码的执行,等待期约解决。

await 关键字会暂停执行异步函数后面的代码,让出 JavaScript 运行时的执行线程。这个行为与生成器函数中的 yield 关键字是一样的。await 关键字同样是尝试“解包”对象的值,然后将这
个值传给表达式,再异步恢复异步函数的执行。
await 关键字的用法与 JavaScript 的一元操作一样。它可以单独使用,也可以在表达式中使用。

await 关键字期待(但实际上并不要求)一个实现 thenable 接口的对象,但常规的值也可以。如果是实现 thenable 接口的对象,则这个对象可以由 await 来“解包”。如果不是,则这个值就被当作已经解决的期约。

等待会抛出错误的同步操作,会返回拒绝的期约。

单独的 Promise.reject() 不会被异步函数捕获,而会抛出未捕获错误。不过,对拒绝的期约使用 await 则会释放(unwrap)错误值(将拒绝期约返回)。

  1. await的限制

await 关键字必须在异步函数中使用,不能在顶级上下文如 <**script**> 标签或模块中使用。不过,定义并立即调用异步函数是没问题的。

异步函数的特质不会扩展到嵌套函数。因此,await 关键字也只能直接出现在异步函数的定义中。在同步函数内部使用 await 会抛出 SyntaxError

11.3.2 停止和恢复执行

异步函数如果不包含 await 关键字,其执行基本上跟普通函数没有什么区别。

JavaScript 运行时在碰到 await 关键字时,会记录在哪里暂停执行。等到 await 的值可用了,JavaScript 运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。 因此,即使 await 后面跟着一个立即可用的值,函数的其余部分也会被异步求值。

11.3.3 异步函数策略

  1. 实现 sleep()

在程序中加入非阻塞的暂停。

1
2
3
4
5
6
7
8
9
10
11
async function sleep(delay) { 
return new Promise((resolve) => setTimeout(resolve, delay));
}

async function foo() {
const t0 = Date.now();
await sleep(1500); // 暂停约 1500 毫秒
console.log(Date.now() - t0);
}
foo();
// 1502
  1. 串行执行期约

使用 async/await,期约连锁会变得很简单。

1
2
3
4
5
6
7
8
9
10
11
12
async function addTwo(x) {return x + 2;} 
async function addThree(x) {return x + 3;}
async function addFive(x) {return x + 5;}

async function addTen(x) {
for (const fn of [addTwo, addThree, addFive]) {
x = await fn(x);
}
return x;
}

addTen(9).then(console.log); // 19
  1. 栈追踪与内存管理

栈追踪信息应该相当直接地表现 JavaScript 引擎当前栈内存中函数调用之间的嵌套关系。在超时处理程序执行时和拒绝期约时,我们看到的错误信息包含嵌套函数的标识符,那是被调用以创建最初期约实例的函数。可是,我们知道这些函数已经返回了,因此栈追踪信息中不应该看到它们。
答案很简单,这是因为 JavaScript 引擎会在创建期约时尽可能保留完整的调用栈。在抛出错误时,调用栈可以由运行时的错误处理逻辑获取,因而就会出现在栈追踪信息中。当然,这意味着栈追踪信息会占用内存,从而带来一些计算和存储成本。

11.4 小结

长期以来,掌握单线程 JavaScript 运行时的异步行为一直都是个艰巨的任务。随着 ES6 新增了期约和 ES8 新增了异步函数,ECMAScript 的异步编程特性有了长足的进步。通过期约和 async/await,不仅可以实现之前难以实现或不可能实现的任务,而且也能写出更清晰、简洁,并且容易理解、调试的代码。
期约的主要功能是为异步代码提供了清晰的抽象。可以用期约表示异步执行的代码块,也可以用期约表示异步计算的值。在需要串行异步代码时,期约的价值最为突出。作为可塑性极强的一种结构,期约可以被序列化、连锁使用、复合、扩展和重组。
异步函数是将期约应用于 JavaScript 函数的结果。异步函数可以暂停执行,而不阻塞主线程。无论是编写基于期约的代码,还是组织串行或平行执行的异步代码,使用异步函数都非常得心应手。异步函数可以说是现代 JavaScript 工具箱中最重要的工具之一。

十二、 BOM

浏览器对象模型(BOM,Browser Object Model)描述为 JavaScript 的核心,但实际上 BOM 是使用 JavaScript 开发 Web 应用程序的核心。BOM 提供了与网页无关的浏览器功能对象。

12.1 window对象

BOM 的核心是 window 对象,表示浏览器的实例。window对象在浏览器中有两重身份,一个是 ECMAScript 中的 Global 对象,另一个就是浏览器窗口的 JavaScript 接口。这意味着网页中定义的所有对象、变量和函数都以 window 作为其 Global 对象,都可以访问其上定义的 parseInt() 等全局方法。

window 对象的属性在全局作用域中有效。

12.1.1 Global作用域

window 对象被复用为 ECMAScriptGlobal 对象,所以通过 var 声明的所有全局变量和函数都会变成 window 对象的属性和方法。

如果使用 letconst 替代 var,则不会把变量添加给全局对象。

另外,访问未声明的变量会抛出错误,但是可以在 window 对象上查询是否存在可能未声明的变量。

12.1.2 窗口关系

top 对象始终指向最上层(最外层)窗口,即浏览器窗口本身。而 parent 对象则始终指向当前窗口的父窗口。如果当前窗口是最上层窗口,则 parent 等于 top(都等于 window)。最上层的 window如果不是通过 window.open() 打开的,那么其 name 属性就不会包含值。 还有一个 self 对象,它是终极 window 属性,始终会指向 window。实际上,selfwindow 就是同一个对象。之所以还要暴露 self,就是为了和 top、parent 保持一致。

12.1.3 窗口位置与像素比

window 对象的位置可以通过不同的属性和方法来确定。现代浏览器提供了 screenLeftscreenTop 属性,用于表示窗口相对于屏幕左侧和顶部的位置 ,返回值的单位是 CSS 像素。
可以使用 moveTo()moveBy() 方法移动窗口。这两个方法都接收两个参数,其中 moveTo() 接收要移动到的新位置的绝对坐标 xy;而 moveBy() 则接收相对当前位置在两个方向上移动的像素数。

CSS 像素是 Web 开发中使用的统一像素单位。这个单位的背后其实是一个角度:0.0213°

window.devicePixelRatio 实际上与每英寸像素数(DPI,dots per inch)是对应的。DPI 表示单位像素密度,而 window.devicePixelRatio 表示物理像素与逻辑像素之间的缩放系数。

12.1.4 窗口大小

所有现代浏览器都支持 4 个属性:innerWidth、innerHeight、outerWidth和outerHeight。outerWidth 和 outerHeight 返回浏览器窗口自身的大小(不管是在最外层 window 上使用,还是在窗格 中使用)。innerWidthinnerHeight 返回浏览器窗口中页面视口的大小(不包含浏览器边框和工具栏)。
document.documentElement.clientWidth 和 document.documentElement.clientHeight 返回页面视口的宽度和高度。

在移动设备上,window.innerWidth 和 window.innerHeight 返回视口的大小,也就是屏幕上页面可视区域的大小。

在其他移动浏览器中,document.documentElement.clientWidth 和 document.documentElement. clientHeight 返回的布局视口的大小,即渲染页面的实际大小。布局视口是相对于可见视口的概念,可 见 视 口 只 能 显 示 整 个 页 面 的 一 小 部 分 。 Mobile Internet Explorer 把 布 局 视 口 的 信 息 保 存 在document.body.clientWidth 和 document.body.clientHeight 中。

可以使用 resizeTo() 和 resizeBy() 方法调整窗口大小。这两个方法都接收两个参数,resizeTo() 接收新的宽度和高度值,而 resizeBy() 接收宽度和高度各要缩放多少。

12.1.5 视口位置

度量文档相对于视口滚动距离的属性有两对,返回相等的值: window.pageXoffset/window. scrollX 和 window.pageYoffset/window.scrollY
可以使用 scroll()、scrollTo() 和 scrollBy() 方法滚动页面。这 3 个方法都接收表示相对视口距离的 xy 坐标,这两个参数在前两个方法中表示要滚动到的坐标,在最后一个方法中表示滚动的距离。

这几个方法也都接收一个 ScrollToOptions 字典,除了提供偏移值,还可以通过 behavior 属性告诉浏览器是否平滑滚动。

12.1.6 导航与打开新窗口

window.open() 方法可以用于导航到指定 URL,也可以用于打开新浏览器窗口。这个方法接收 4 个参数:要加载的 URL、目标窗口、特性字符串和表示新窗口在浏览器历史记录中是否替代当前加载页面的布尔值。通常,调用这个方法时只传前 3 个参数,最后一个参数只有在不打开新窗口时才会使用。

如果 window.open() 的第二个参数是一个已经存在的窗口或窗格(frame)的名字,则会在对应的窗口或窗格中打开 URL

  1. 弹出窗口

如果 window.open() 的第二个参数不是已有窗口,则会打开一个新窗口或标签页。第三个参数,即特性字符串,用于指定新窗口的配置。如果没有传第三个参数,则新窗口(或标签页)会带有所有默认的浏览器特性(工具栏、地址栏、状态栏等都是默认配置)。如果打开的不是新窗口,则忽略第三个参数。

特性字符串是一个逗号分隔的设置字符串,用于指定新窗口包含的特性。

设 置 说 明
fullscreen “yes”或”no” 表示新窗口是否最大化。仅限 IE 支持
height 数值 新窗口高度。这个值不能小于 100
left 数值 新窗口的 x 轴坐标。这个值不能是负值。
location “yes”或”no” 表示是否显示地址栏。不同浏览器的默认值也不一样。在设置为”no”时,地址栏可能隐藏或禁用(取决于浏览器)
Menubar “yes”或”no” 表示是否显示菜单栏。默认为”no”

这些设置需要以逗号分隔的名值对形式出现,其中名值对以等号连接。(特性字符串中不能包含空格。)

window.open() 方法返回一个对新建窗口的引用。

新创建窗口的 window 对象有一个属性 opener,指向打开它的窗口。这个属性只在弹出窗口的最上层 window 对象(top)有定义,是指向调用 window.open() 打开它的窗口或窗格的指针。

在浏览器中,可以将新打开的标签页的 opener 属性设置为 null,表示新打开的标签页可以运行在独立的进程中。

opener 设置为 null 表示新打开的标签页不需要与打开它的标签页通信,因此可以在独立进程中运行。这个连接一旦切断,就无法恢复了。

  1. 安全限制

无论 window.open() 的特性字符串是什么,都不会隐藏弹窗的状态栏。

IE 对打开本地网页的窗口再弹窗解除了某些限制。同样的代码如果来自服务器,则会施加弹窗限制。

  1. 弹窗屏蔽程序

所有现代浏览器都内置了屏蔽弹窗的程序,因此大多数意料之外的弹窗都会被屏蔽。

在浏览器扩展或其他程序屏蔽弹窗时,window.open() 通常会抛出错误。因此要准确检测弹窗是否被屏蔽,除了检测 window.open() 的返回值,还要把它用 try/catch 包装起来。

12.1.7 定时器

JavaScript 在浏览器中是单线程执行的,但允许使用定时器指定在某个时间之后或每隔一段时间就执行相应的代码。setTimeout() 用于指定在一定时间后执行某些代码,而 setInterval() 用于指定每隔一段时间执行某些代码。
setTimeout() 方法通常接收两个参数:要执行的代码和在执行回调函数前等待的时间(毫秒)。第一个参数可以是包含 JavaScript 代码的字符串(类似于传给 eval() 的字符串)或者一个函数。第二个参数是要等待的毫秒数。

setTimeout() 的第二个参数只是告诉 JavaScript 引擎在指定的毫秒数过后把任务添加到这个队列。如果队列是空的,则会立即执行该代码。如果队列不是空的,则代码必须等待前面的任务执行完才能执行。
调用 setTimeout() 时,会返回一个表示该超时排期的数值 ID。这个超时 ID 是被排期执行代码的唯一标识符,可用于取消该任务。要取消等待中的排期任务,可以调用 clearTimeout() 方法并传入超时 ID

所有超时执行的代码(函数)都会在全局作用域中的一个匿名函数中运行,因此函数中的 this 值在非严格模式下始终指向 window,而在严格模式下是 undefined。如果给 setTimeout() 提供了一个箭头函数,那么 this 会保留为定义它时所在的词汇作用域。

setInterval() 指定的任务会每隔指定时间就执行一次,直到取消循环定时或者页面卸载。setInterval() 同样可以接收两个参数:要执行的代码(字符串或函数),以及把下一次执行定时代码的任务添加到队列要等待的时间(毫秒)。

执行时间短、非阻塞的回调函数比较适合 **setInterval()**。

setInterval() 方法也会返回一个循环定时 ID,可以用于在未来某个时间点上取消循环定时。要取消循环定时,可以调用 clearInterval() 并传入定时 ID

12.1.8 系统对话框

使用 alert()、confirm() 和 prompt() 方法,可以让浏览器调用系统对话框向用户显示消息。

这些对话框都是同步的模态对话框,即在它们显示的时候,代码会停止执行,在它们消失以后,代码才会恢复执行。

1.alert() 方法接收一个要显示给用户的字符串。与 console.log 可以接收任意数量的参数且能一次性打印这些参数不同,alert() 只接收一个参数。调用 alert() 时,传入的字符串会显示在一个系统对话框中。对话框只有一个“OK”(确定)按钮。如果传给 alert() 的参数不是一个原始字符串,则会调用这个值的 toString() 方法将其转换为字符串。
警告框(alert)通常用于向用户显示一些他们无法控制的消息,比如报错。

2.第二种对话框叫确认框,通过调用 confirm() 来显示。确认框跟警告框类似,都会向用户显示消息。但不同之处在于,确认框有两个按钮:“Cancel”(取消)和“OK”(确定)。用户通过单击不同的按钮表明希望接下来执行什么操作。

3.最后一种对话框是提示框,通过调用 prompt() 方法来显示。提示框的用途是提示用户输入消息。除了 OKCancel 按钮,提示框还会显示一个文本框,让用户输入内容。prompt() 方法接收两个参数:要显示给用户的文本,以及文本框的默认值(可以是空字符串)。

JavaScript 还可以显示另外两种对话框:find() 和 print()。这两种对话框都是异步显示的,即控制权会立即返回给脚本。用户在浏览器菜单上选择“查找”(find)和“打印”(print)时显示的就是这两种对话框。通过在 window 对象上调用 find() 和 print() 可以显示它们。这两个方法不会返回任何有关用户在对话框中执行了什么操作的信息。

12.2 location对象

location 是最有用的 BOM 对象之一,提供了当前窗口中加载文档的信息,以及通常的导航功能。这 个 对 象 独 特 的 地 方 在 于 , 它 既 是 window 的 属 性 , 也 是 document 的 属 性 。 也 就 是 说 ,
window.location 和 document.location 指向同一个对象。location 对象不仅保存着当前加载文档的信息,也保存着把 URL 解析为离散片段后能够通过属性访问的信息。

假 设 浏 览 器 当 前 加 载 的 URLhttp://foouser:barpassword@www.wrox.com:80/WileyCDA/?q=
javascript#contents,location 对象的内容如下表所示。

12.2.1 查询字符串

location.search 返回了从问号开始直到 URL 末尾的所有内容,但没有办法逐个访问每个查询参数。

URLSearchParams 提供了一组标准 API 方法,通过它们可以检查和修改查询字符串。给 URLSearchParams 构造函数传入一个查询字符串,就可以创建一个实例。这个实例上暴露了 get()、set() 和 delete() 等方法,可以对查询字符串执行相应操作。

大多数支持 URLSearchParams 的浏览器也支持将 URLSearchParams 的实例用作可迭代对象。

12.2.2 操作地址

可以通过修改 location 对象修改浏览器的地址。首先,最常见的是使用 assign() 方法并传入一个 URL

1
location.assign("http://www.wrox.com");

下面两行代码都会执行与显式调用 assign() 一样的操作:

1
2
window.location = "http://www.wrox.com"; 
location.href = "http://www.wrox.com";

在这 3 种修改浏览器地址的方法中,设置 location.href 是最常见的。
修改 location 对象的属性也会修改当前加载的页面。其中,hash、search、hostname、pathname 和 port 属性被设置为新值之后都会修改当前 URL

除了 hash 之外,只要修改 location 的一个属性,就会导致页面重新加载新 URL

在以前面提到的方式修改 URL 之后,浏览器历史记录中就会增加相应的记录。当用户单击“后退”按钮时,就会导航到前一个页面。如果不希望增加历史记录,可以使用 replace() 方法。这个方法接收一个 URL 参数,但重新加载后不会增加历史记录。调用 replace() 之后,用户不能回到前一页。

最后一个修改地址的方法是 **reload()**,它能重新加载当前显示的页面。调用 reload() 而不传参数,页面会以最有效的方式重新加载。也就是说,如果页面自上次请求以来没有修改过,浏览器可能会从缓存中加载页面。如果想强制从服务器重新加载,可以像下面这样给 reload() 传个 true

12.3 navigator对象

只要浏览器启用 JavaScriptnavigator 对象就一定存在。

navigator 对 象 实 现 了 NavigatorID、NavigatorLanguage、NavigatorOnLine、NavigatorContentUtils、NavigatorStorage、NavigatorStorageUtils、NavigatorConcurrentHardware、NavigatorPlugins 和 NavigatorUserMedia 接口定义的属性和方法。

navigator 对象的属性通常用于确定浏览器的类型。

12.3.1 检测插件

检测浏览器是否安装了某个插件是开发中常见的需求。除 IE10 及更低版本外的浏览器,都可以通过 plugins 数组来确定。这个数组中的每一项都包含如下属性。

  • name:插件名称。

  • description:插件介绍。

  • filename:插件的文件名。

  • length:由当前插件处理的 MIME 类型数量。

通常,name 属性包含识别插件所需的必要信息。

IE11window.navigator 对象开始支持 plugins 和 mimeTypes 属性。这意味着前面定义的函数可以适用于所有较新版本的浏览器。而且,IE11 中的 ActiveXObject 也从 DOM 中隐身了,意味着不能再用它来作为检测特性的手段。

12.3.2 注册处理程序

现代浏览器支持 navigator 上的(在 HTML5 中定义的) registerProtocolHandler() 方法。这个方法可以把一个网站注册为处理某种特定类型信息应用程序。

要使用 registerProtocolHandler() 方法,必须传入 3 个参数:要处理的协议(如”mailto“或”ftp“)、处理该协议的 URL,以及应用名称。

12.4 screen对象

window 的另一个属性 screen 对象,这个对象中保存的纯粹是客户端能力信息,也就是浏览器窗口外面的客户端显示器的信息。

12.5 history对象

history 对象表示当前窗口首次使用以来用户的导航历史记录。因为 historywindow 的属性,所以每个 window 都有自己的 history 对象。

12.5.1 导航

go() 方法可以在用户历史记录中沿任何方向导航,可以前进也可以后退。这个方法只接收一个参数,这个参数可以是一个整数,表示前进或后退多少步。负值表示在历史记录中后退(类似点击浏览器的“后退”按钮),而正值表示在历史记录中前进(类似点击浏览器的“前进”按钮)。

go() 有两个简写方法:**back() 和 forward()**。

history 对象还有一个 length 属性,表示历史记录中有多个条目。这个属性反映了历史记录的数量,包括可以前进和后退的页面。

对于窗口或标签页中加载的第一个页面,history.length 等于 1。通过以下方法测试这个值,可以确定用户浏览器的起点是不是你的页面

history 对象通常被用于创建“后退”和“前进”按钮,以及确定页面是不是用户历史记录中的第一条记录。

12.5.2 历史状态管理

hashchange 会在页面 URL 的散列变化时被触发,开发者可以在此时执行某些操作。而状态管理 API 则可以让开发者改变浏览器 URL 而不会加载新页面。为此,可以使用 history.pushState() 方法。这个方法接收 3 个参数:一个 state 对象、一个新状态的标题和一个(可选的)相对 URL

pushState() 方法执行后,状态信息就会被推到历史记录中,浏览器地址栏也会改变以反映新的相对 URL。除了这些变化之外,即使location.href 返回的是地址栏中的内容,浏览器页不会向服务器发送请求。第二个参数并未被当前实现所使用,因此既可以传一个空字符串也可以传一个短标题。第一个参数应该包含正确初始化页面状态所必需的信息。为防止滥用,这个状态的对象大小是有限制的,通常在 500KB~1MB 以内。

因为 pushState() 会创建新的历史记录,所以也会相应地启用“后退”按钮。此时单击“后退”按钮,就会触发 window 对象上的 popstate事件。popstate 事件的事件对象有一个 state 属性,其中包含通过 pushState() 第一个参数传入的 state 对象。

基于这个状态,应该把页面重置为状态对象所表示的状态(因为浏览器不会自动为你做这些)。记住,页面初次加载时没有状态。因此点击“后退”按钮直到返回最初页面时,event.state 会为 null
可以通过 history.state 获取当前的状态对象,也可以使用 replaceState() 并传入与 pushState() 同样的前两个参数来更新状态。更新状态不会创建新历史记录,只会覆盖当前状态。

12.6 小结

浏览器对象模型(BOM,Browser Object Model)是以 window 对象为基础的,这个对象代表了浏览器窗口和页面可见的区域。window 对象也被复用为 ECMAScriptGlobal 对象,因此所有全局变量和函数都是它的属性,而且所有原生类型的构造函数和普通函数也都从一开始就存在于这个对象之上。

  • 要引用其他 window 对象,可以使用几个不同的窗口指针。

  • 通过 location 对象可以以编程方式操纵浏览器的导航系统。通过设置这个对象上的属性,可以改变浏览器 URL 中的某一部分或全部。

  • 使用 replace() 方法可以替换浏览器历史记录中当前显示的页面,并导航到新 URL

  • navigator 对象提供关于浏览器的信息。提供的信息类型取决于浏览器,不过有些属性如 userAgent 是所有浏览器都支持的。

BOM 中的另外两个对象也提供了一些功能。screen 对象中保存着客户端显示器的信息。这些信息通常用于评估浏览网站的设备信息。history 对象提供了操纵浏览器历史记录的能力,开发者可以确定历史记录中包含多少个条目,并以编程方式实现在历史记录中导航,而且也可以修改历史记录。

十三、客户端检测

13.1 能力检测

能力检测(又称特性检测)即在 JavaScript 运行时中使用一套简单的检测逻辑,测试浏览器是否支持某种特性。

能力检测的基本模式如下:

1
2
3
if (object.propertyInQuestion) {  
// 使用 object.propertyInQuestion
}

能力检测的关键是理解两个重要概念。首先,如前所述,应该先检测最常用的方式。其次是必须检测切实需要的特性。

13.1.1 安全能力检测

能力检测最有效的场景是检测能力是否存在的同时,验证其是否能够展现出预期的行为。

进行能力检测时应该尽量使用 typeof 操作符,但光有它还不够。尤其是某些宿主对象并不保证对 typeof 测试返回合理的值。最有名的例子就是 Internet Explorer(IE)。在多数浏览器中,下面的代码都会在 document.createElement() 存在时返回 true

1
2
3
4
// 不适用于 IE8 及更低版本 
function hasCreateElement() {
return typeof document.createElement == "function";
}

DOM 对象是宿主对象,而宿主对象在 IE8 及更低版本中是通过 COM 而非 JScript 实现的。因此,document.createElement() 函数被实现为 COM 对象,typeof 返回”object“。IE9DOM 方法会返回”function“。

13.1.2 基于能力检测进行浏览器分析

恰当地使用能力检测可以精准地分析运行代码的浏览器。使用能力检测而非用户代理检测的优点在于,伪造用户代理字符串很简单,而伪造能够欺骗能力检测的浏览器特性却很难。

  1. 检测特性

可以按照能力将浏览器归类。如果你的应用程序需要使用特定的浏览器能力,那么最好集中检测所有能力,而不是等到用的时候再重复检测。

1
2
3
4
5
6
 // 检测浏览器是否支持 Netscape 式的插件 
let hasNSPlugins = !!(navigator.plugins && navigator.plugins.length);

// 检测浏览器是否具有 DOM Level 1 能力
let hasDOM1 = !!(document.getElementById && document.createElement &&
document.getElementsByTagName);
  1. 检测浏览器

可以根据对浏览器特性的检测并与已知特性对比,确认用户使用的是什么浏览器。这样可以获得比用户代码嗅探(稍后讨论)更准确的结果。

  1. 能力检测的局限

通过检测一种或一组能力,并不总能确定使用的是哪种浏览器。

13.2 用户代理检测

用户代理检测通过浏览器的用户代理字符串确定使用的是什么浏览器。用户代理字符串包含在每个 HTTP 请求的头部,在 JavaScript 中可以通过 navigator.userAgent 访问。在服务器端,常见的做法是根据接收到的用户代理字符串确定浏览器并执行相应操作。而在客户端,用户代理检测被认为是不可靠的,只应该在没有其他选项时再考虑。

13.2.1 用户代理的历史

HTTP 规范(1.01.1)要求浏览器应该向服务器发送包含浏览器名称和版本信息的简短字符串。

13.2.2 浏览器分析

想要知道自己代码运行在什么浏览器上,大部分开发者会分析 window.navigator.userAgent 返回的字符串值。所有浏览器都会提供这个值,如果相信这些返回值并基于给定的一组浏览器检测这个字符串,最终会得到关于浏览器和操作系统的比较精确的结果。

能力检测可以保证脚本不必理会浏览器而正常执行。现代浏览器用户代理字符串的过去、现在和未来格式都是有章可循的,我们能够利用它们准确识别浏览器。

  1. 伪造用户代理

通过检测用户代理来识别浏览器并不是完美的方式,毕竟这个字符串是可以造假的。只不过实现 window.navigator 对象的浏览器(即所有现代浏览器)都会提供 userAgent 这个只读属性。因此,简单地给这个属性设置其他值不会有效。

1
2
3
4
5
6
7
8
9
console.log(window.navigator.userAgent); 
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/65.0.3325.181 Safari/537.36

window.navigator.userAgent = 'foobar';

console.log(window.navigator.userAgent);
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/65.0.3325.181 Safari/537.36

有些浏览器提供伪私有的 __ defineGetter __ 方法,利用它可以篡改用户代理字符串。

1
2
3
4
5
6
7
8
console.log(window.navigator.userAgent); 
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/65.0.3325.181 Safari/537.36

window.navigator.__defineGetter__('userAgent', () => 'foobar');

console.log(window.navigator.userAgent);
// foobar
  1. 分析浏览器

通过解析浏览器返回的用户代理字符串,可以极其准确地推断出下列相关的环境信息:

  • 浏览器

  • 浏览器版本

  • 浏览器渲染引擎

  • 设备类型(桌面/移动)

  • 设备生产商

  • 设备型号

  • 操作系统

  • 操作系统版本

这里推荐一些 GitHub 上维护比较频繁的第三方用户代理解析程序:

  • Bowser

  • UAParser.js

  • Platform.js

  • CURRENT-DEVICE

  • Google Closure

  • Mootools

13.3 软件与硬件检测

现代浏览器提供了一组与页面执行环境相关的信息,包括浏览器、操作系统、硬件和周边设备信息。这些属性可以通过暴露在 window.navigator 上的一组 API 获得。不过,这些 API 的跨浏览器支持还不够好,远未达到标准化的程度。

13.3.1 识别浏览器与操作系统

特性检测和用户代理字符串解析是当前常用的两种识别浏览器的方式。而 navigatorscreen 对象也提供了关于页面所在软件环境的信息。

  1. navigator.oscpu

navigator.oscpu 属性是一个字符串,通常对应用户代理字符串中操作系统/系统架构相关信息。根据 HTML 实时标准:oscpu 属性的获取方法必须返回空字符串或者表示浏览器所在平台的字符串,比如”Windows NT 10.0; Win64; x64”或”Linux x86_64“。

  1. navigator.vendor

navigator.vendor 属性是一个字符串,通常包含浏览器开发商信息。返回这个字符串是浏览器 navigator 兼容模式的一个功能。根据 HTML 实时标准:navigator.vendor 返回一个空字符串,也可能返回字符串”Apple Computer, Inc.“或字符串”Google Inc.“。

  1. navigator.platform

navigator.platform 属性是一个字符串,通常表示浏览器所在的操作系统。根据 HTML 实时标准:navigator.platform 必须返回一个字符串或表示浏览器所在平台的字符串,例如”MacIntel”、”Win32”、”FreeBSD i386”或”WebTV OS

  1. screen.colorDepth和screen.pixelDepth

screen.colorDepth和screen.pixelDepth 返回一样的值,即显示器每像素颜色的位深。根据 CSS 对象模型(CSSOM)规范:
screen.colorDepth 和 screen.pixelDepth属性应该返回输出设备中每像素用于显示颜色的位数,不包含 alpha 通道。

  1. screen.orientation

screen.orientation 属性返回一个 ScreenOrientation 对象,其中包含 Screen Orientation API 定义的屏幕信息。这里面最有意思的属性是 angle 和 type,前者返回相对于默认状态下屏幕的角度,后者返回以下 4 种枚举值之一:

  • portrait-primary

  • portrait-secondary

  • landscape-primary

  • landscape-secondary

13.3.2 浏览器元数据

navigator 对象暴露出一些 API,可以提供浏览器和操作系统的状态信息。

  1. Geolocation API

navigator.geolocation属性暴露了 Geolocation API,可以让浏览器脚本感知当前设备的地理位置。这个 API 只在安全执行环境(通过 HTTPS 获取的脚本)中可用。
这个 API 可以查询宿主系统并尽可能精确地返回设备的位置信息。

根据 Geolocation API 规范: 地理位置信息的主要来源是 GPSIP 地址、射频识别(RFID)、Wi-Fi 及蓝牙 Mac 地址、GSM/CDMA 蜂窝 ID 以及用户输入等信息。

要获取浏览 器当前的位置,可以使用 getCurrentPosition() 方法。这个方法返回一个 Coordinates 对象,其中包含的信息不一定完全依赖宿主系统的能力。

1
2
// getCurrentPosition()会以 position 对象为参数调用传入的回调函数 
navigator.geolocation.getCurrentPosition((position) => p = position);

这个 position 对象中有一个表示查询时间的时间戳,以及包含坐标信息的 Coordinatesn 对象。

1
2
console.log(p.timestamp);  // 1525364883361 
console.log(p.coords); // Coordinates {...}

Coordinates 对象中包含标准格式的经度和纬度,以及以米为单位的精度。精度同样以确定设备位置的机制来判定。

1
2
console.log(p.coords.latitude, p.coords.longitude);   // 37.4854409, -122.2325506 
console.log(p.coords.accuracy); // 58

Coordinates 对象包含一个 altitude(海拔高度)属性,是相对于 1984 世界大地坐标系(World Geodetic System,1984)地球表面的以米为单位的距离。此外也有一个 altitudeAccuracy 属性,这个精度值单位也是米。为了取得 Coordinates 中包含的这些信息,当前设备必须具备相应的能力(比如 GPS 或高度计)。很多设备因为没有能力测量高度,所以这两个值经常有一个或两个是空的。

1
2
console.log(p.coords.altitude);          // -8.800000190734863 
console.log(p.coords.altitudeAccuracy); // 200

Coordinates 对象包含一个 speed 属性,表示设备每秒移动的速度。还有一个 heading(朝向)属性,表示相对于正北方向移动的角度(0 ≤ heading < 360)。为获取这些信息,当前设备必须具备相应的能力(比如加速计或指南针)。很多设备因为没有能力测量高度,所以这两个值经常有一个是空的,或者两个都是空的。

getCurrentPosition() 方法也接收失败回调函数作为第二个参数,这个函数会收到一个 PositionError 对象。在失败的情况下,PositionError 对象中会包含一个 code 属性和一个 message 属性,后者包含对错误的简短描述。code 属性是一个整数,表示以下 3 种错误。

  • PERMISSION_DENIED:浏览器未被允许访问设备位置。页面第一次尝试访问 Geolocation API 时,浏览器会弹出确认对话框取得用户授权(每个域分别获取)。如果返回了这个错误码,则要么是用户不同意授权,要么是在不安全的环境下访问了 Geolocation APImessage属性还会提供额外信息。

  • POSITION_UNAVAILABLE:系统无法返回任何位置信息。这个错误码可能代表各种失败原因,但相对来说并不常见,因为只要设备能上网,就至少可以根据 IP 地址返回一个低精度的坐标。

  • TIMEOUT:系统不能在超时时间内返回位置信息。

Geolocation API 位置请求可以使用 PositionOptions 对象来配置,作为第三个参数提供。这个对象支持以下 3 个属性。

  • enableHighAccuracy:布尔值,true 表示返回的值应该尽量精确,默认值为 false。默认情况下,设备通常会选择最快、最省电的方式返回坐标。这通常意味着返回的是不够精确的坐标。比如,在移动设备上,默认位置查询通常只会采用 Wi-Fi 和蜂窝网络的定位信息。而在 enableHighAccuracytrue 的情况下,则会使用设备的 GPS 确定设备位置,并返回这些值的混合结果。使用 GPS 会更耗时、耗电,因此在使用 enableHighAccuracy 配置时要仔细权衡一下。

  • timeout:毫秒,表示在以TIMEOUT状态调用错误回调函数之前等待的最长时间。默认值是 0xFFFFFFFF(232 – 1)0 表示完全跳过系统调用而立即以 TIMEOUT 调用错误回调函数。

  • maximumAge:毫秒,表示返回坐标的最长有效期,默认值为 0。因为查询设备位置会消耗资源,所以系统通常会缓存坐标并在下次返回缓存的值(遵从位置缓存失效策略)。系统会计算缓存期,如果 Geolocation API 请求的配置要求比缓存的结果更新,则系统会重新查询并返回值。0 表示强制系统忽略缓存的值,每次都重新查询。而 Infinity 会阻止系统重新查询,只会返回缓存的值。JavaScript 可以通过检查 Position 对象的 timestamp 属性值是否重复来判断返回的是不是缓存值。

  1. Connection State 和 NetworkInformation API

浏览器会跟踪网络连接状态并以两种方式暴露这些信息:连接事件和navigator.onLine 属性。在设备连接到网络时,浏览器会记录这个事实并在 window 对象上触发 online 事件。相应地,当设备断开网络连接后,浏览器会在 window 对象上触发 offline 事件。任何时候,都可以通过 navigator. onLine 属性来确定浏览器的联网状态。这个属性返回一个布尔值,表示浏览器是否联网。

navigator 对象还暴露了 NetworkInformation API,可以通过 navigator.connection 属性使用。这个 API 提供了一些只读属性,并为连接属性变化事件处理程序定义了一个事件对象。

13.3.3 硬件

navigator 对象通过一些属性提供了基本信息。

  1. 处理器核心数

    navigator.hardwareConcurrency 属性返回浏览器支持的逻辑处理器核心数量,包含表示核心数的一个整数值(如果核心数无法确定,这个值就是 1)。关键在于,这个值表示浏览器可以并行执行的最大工作线程数量,不一定是实际的 CPU 核心数。

  2. 设备内存大小
    navigator.deviceMemory 属性返回设备大致的系统内存大小,包含单位为 GB 的浮点数(舍入为最接近的 2 的幂:512MB 返回 0.54GB 返回 4)。

  3. 最大触点数
    navigator.maxTouchPoints 属性返回触摸屏支持的最大关联触点数量,包含一个整数值。

13.4 小结

客户端检测是 JavaScript 中争议最多的话题之一。因为不同浏览器之间存在差异,所以经常需要根据浏览器的能力来编写不同的代码。客户端检测有不少方式,但下面两种用得最多。

  • 能力检测,在使用之前先测试浏览器的特定能力。例如,脚本可以在调用某个函数之前先检查它是否存在。这种客户端检测方式可以让开发者不必考虑特定的浏览器或版本,而只需关注某些能力是否存在。能力检测不能精确地反映特定的浏览器或版本。

  • 用户代理检测,通过用户代理字符串确定浏览器。用户代理字符串包含关于浏览器的很多信息,通常包括浏览器、平台、操作系统和浏览器版本。用户代理字符串有一个相当长的发展史,很多浏览器都试图欺骗网站相信自己是别的浏览器。用户代理检测也比较麻烦,特别是涉及 Opera 会在代理字符串中隐藏自己信息的时候。即使如此,用户代理字符串也可以用来确定浏览器使用的渲染引擎以及平台,包括移动设备和游戏机。

在选择客户端检测方法时,首选是使用能力检测。特殊能力检测要放在次要位置,作为决定代码逻辑的参考。用户代理检测是最后一个选择,因为它过于依赖用户代理字符串。
浏览器也提供了一些软件和硬件相关的信息。这些信息通过 screennavigator 对象暴露出来。利用这些 API,可以获取关于操作系统、浏览器、硬件、设备位置、电池状态等方面的准确信息。

十四、DOM

文档对象模型(DOM,Document Object Model)是 HTMLXML 文档的编程接口。DOM 表示由多层节点构成的文档,通过它开发者可以添加、删除和修改页面的各个部分。

14.1 节点层级

任何 HTMLXML 文档都可以用 DOM 表示为一个由节点构成的层级结构。节点分很多类型,每种类型对应着文档中不同的信息和(或)标记,也都有自己不同的特性、数据和方法,而且与其他类型有某种关系。这些关系构成了层级,让标记可以表示为一个以特定节点为根的树形结构。

document 节点表示每个文档的根节点。在这里,根节点的唯一子节点是 元素,我们称之为文档元素(documentElement)。文档元素是文档最外层的元素,所有其他元素都存在于这个元素之内。每个文档只能有一个文档元素。在 HTML 页面中,文档元素始终是 元素。在 XML 文档中,则没有这样预定义的元素,任何元素都可能成为文档元素。
HTML 中的每段标记都可以表示为这个树形结构中的一个节点。元素节点表示 HTML 元素,属性节点表示属性,文档类型节点表示文档类型,注释节点表示注释。DOM 中总共有 12 种节点类型,这些类型都继承一种基本类型。

14.1.1 Node类型

DOM Level 1 描述了名为 Node 的接口,这个接口是所有 DOM 节点类型都必须实现的。Node 接口在 JavaScript 中被实现为 Node 类型,在除 IE 之外的所有浏览器中都可以直接访问这个类型。在 JavaScript 中,所有节点类型都继承 Node 类型,因此所有类型都共享相同的基本属性和方法。
每个节点都有 nodeType 属性,表示该节点的类型。节点类型由定义在 Node 类型上的 12 个数值常量表示:

  • Node.ELEMENT_NODE(1)

  • Node.ATTRIBUTE_NODE(2)

  • Node.TEXT_NODE(3)

  • Node.CDATA_SECTION_NODE(4)

  • Node.ENTITY_REFERENCE_NODE(5)

  • Node.ENTITY_NODE(6)

  • Node.PROCESSING_INSTRUCTION_NODE(7)

  • Node.COMMENT_NODE(8)

  • Node.DOCUMENT_NODE(9)

  • Node.DOCUMENT_TYPE_NODE(10)

  • Node.DOCUMENT_FRAGMENT_NODE(11)

  • Node.NOTATION_NODE(12)

节点类型可通过与这些常量比较来确定。

1
2
3
if (someNode.nodeType == Node.ELEMENT_NODE){ 
alert("Node is an element.");
}
  1. nodeName与nodeValue

nodeNamenodeValue 保存着有关节点的信息。这两个属性的值完全取决于节点类型。在使用这两个属性前,最好先检测节点类型。

1
2
3
if (someNode.nodeType == 1){ 
value = someNode.nodeName; // 会显示元素的标签名
}

对元素而言,nodeName 始终等于元素的标签名,而 nodeValue 则始终为 null

  1. 节点关系

文档中的所有节点都与其他节点有关系。

每个节点都有一个 childNodes 属性,其中包含一个 NodeList 的实例。NodeList 是一个类数组对象,用于存储可以按位置存取的有序节点。注意,NodeList 并不是 Array 的实例,但可以使用中括号访问它的值,而且它也有 length 属性。NodeList 对象独特的地方在于,它其实是一个对 DOM 结构的查询,因此 DOM 结构的变化会自动地在 NodeList 中反映出来。我们通常说 NodeList 是实时的活动对象,而不是第一次访问时所获得内容的快照。

length 属性表示那一时刻 NodeList 中节点的数量。使用 Array.prototype. slice() 可以像 arguments 时一样把 NodeList 对象转换为数组。

每个节点都有一个 parentNode 属性,指向其 DOM 树中的父元素。childNodes 中的所有节点都有同一个父元素,因此它们的 parentNode 属性都指向同一个节点。此外,childNodes 列表中的每个节点都是同一列表中其他节点的同胞节点。而使用 previousSiblingnextSibling 可以在这个列表的节点间导航。这个列表中第一个节点的 previousSibling 属性是 null,最后一个节点的 nextSibling 属性也是 null

如果 childNodes 中只有一个节点,则它的 previousSiblingnextSibling 属性都是 null

父节点和它的第一个及最后一个子节点也有专门属性:firstChildlastChild 分别指向 childNodes 中的第一个和最后一个子节点。

如果只有一个子节点,则 firstChildlastChild 指向同一个节点。如果没有子节点,则 firstChildlastChild 都是 null

hasChildNodes() 方法如果返回 true 则说明节点有一个或多个子节点。

所有节点都共享的关系 —— ownerDocument 属性是一个指向代表整个文档的文档节点的指针。所有节点都被创建它们(或自己所在)的文档所拥有,因为一个节点不可能同时存在于两个或者多个文档中。

虽然所有节点类型都继承了Node,但并非所有节点都有子节点。

  1. 操纵节点

因为所有关系指针都是只读的,所以 DOM 又提供了一些操纵节点的方法。最常用的方法是 appendChild(),用于在 childNodes 列表末尾添加节点。添加新节点会更新相关的关系指针,包括父节点和之前的最后一个子节点。appendChild() 方法返回新添加的节点。

如果把文档中已经存在的节点传给 **appendChild()**,则这个节点会从之前的位置被转移到新位置。即使 DOM 树通过各种关系指针维系,一个节点也不会在文档中同时出现在两个或更多个地方。因此,如果调用 appendChild() 传入父元素的第一个子节点,则这个节点会成为父元素的最后一个子节点。

如果想把节点放到 childNodes 中的特定位置而不是末尾,则可以使用 insertBefore() 方法。这个方法接收两个参数:要插入的节点和参照节点。调用这个方法后,要插入的节点会变成参照节点的前一个同胞节点,并被返回。如果参照节点是 null,则 insertBefore() 与 appendChild() 效果相同。

appendChild()insertBefore() 在插入节点时不会删除任何已有节点 。相对地 ,replaceChild() 方法接收两个参数:要插入的节点和要替换的节点。要替换的节点会被返回并从文档树中完全移除,要插入的节点会取而代之。

使用 replaceChild() 插入一个节点后,所有关系指针都会从被替换的节点复制过来。虽然被替换的节点从技术上说仍然被同一个文档所拥有,但文档中已经没有它的位置。

要移除节点而不是替换节点,可以使用 removeChild() 方法。这个方法接收一个参数,即要移除的节点。被移除的节点会被返回。

replaceChild() 方法一样,通过 removeChild() 被移除的节点从技术上说仍然被同一个文档所拥有,但文档中已经没有它的位置。

上面 4 个方法都用于操纵某个节点的子元素,也就是说使用它们之前必须先取得父节点。并非所有节点类型都有子节点,如果在不支持子节点的节点上调用这些方法,则会导致抛出错误。

  1. 其他方法

所有节点类型还共享了两个方法。第一个是 cloneNode(),会返回与调用它的节点一模一样的节点。cloneNode() 方法接收一个布尔值参数,表示是否深复制。在传入 true 参数时,会进行深复制,即复制节点及其整个子 DOM 树。如果传入 false,则只会复制调用该方法的节点。复制返回的节点属于文档所有,但尚未指定父节点,所以可称为孤儿节点(orphan)。可以通过 appendChild()、insertBefore() 或 replaceChild() 方法把孤儿节点添加到文档中。

cloneNode() 方法不会复制添加到 DOM 节点的 JavaScript 属性,比如事件处理程序。这个方法只复制 HTML 属性,以及可选地复制子节点。除此之外则一概不会复制。IE 在很长时间内会复制事件处理程序,这是一个 bug,所以推荐在复制前先删除事件处理程序。

normalize() 方法唯一的任务就是处理文档子树中的文本节点。在节点上调用 normalize() 方法会检测这个节点的所有后代,从中搜索上述两种情形。如果发现空文本节点,则将其删除;如果两个同胞节点是相邻的,则将其合并为一个文本节点。

14.1.2 Document类型

Document 类型是 JavaScript 中表示文档节点的类型。在浏览器中,文档对象 documentHTMLDocument 的实例(HTMLDocument 继承 Document),表示整个 HTML 页面。documentwindow 对象的属性,因此是一个全局对象。Document 类型的节点有以下特征:

  • nodeType 等于 9;

  • nodeName 值为”#document”;

  • nodeValue 值为null;

  • parentNode 值为null;

  • ownerDocument 值为null;

  • 子节点可以是 DocumentType(最多一个)、Element(最多一个)、ProcessingInstructionComment 类型。

Document 类型可以表示 HTML 页面或其他 XML 文档,但最常用的还是通过 HTMLDocument 的实例取得 document 对象。document 对象可用于获取关于页面的信息以及操纵其外观和底层结构。

  1. 文档子节点

两个访问子节点的快捷方式。第一个是 documentElement 属性,始终指向 HTML 页面中的 元素。

作为 HTMLDocument 的实例,document 对象还有一个 body 属性,直接指向 元素。

所有主流浏览器都支持 document.documentElement 和 document.body

Document 类型另一种可能的子节点是 DocumentType。**<!doctype>** 标签是文档中独立的部分,其信息可以通过 doctype 属性(在浏览器中是 document.doctype)来访问。

另外,严格来讲出现在 元素外面的注释也是文档的子节点,它们的类型是 Comment

一般来说,appendChild()、removeChild()和replaceChild() 方法不会用在 document 对象上。这是因为文档类型(如果存在)是只读的,而且只能有一个 Element 类型的子节点(即 ****,已经存在了)。

  1. 文档信息

document 作为 HTMLDocument 的实例,还有一些标准 Document 对象上所没有的属性。这些属性提供浏览器所加载网页的信息。其中第一个属性是 title,包含 元素中的文本,通常显示在浏览器窗口或标签页的标题栏。通过这个属性可以读写页面的标题,修改后的标题也会反映在浏览器标题栏上。不过,修改 title 属性并不会改变 元素。

URL 包含当前页面的完整 URL(地址栏中的 URL),domain 包含页面的域名,而 referrer 包含链接到当前页面的那个页面的 URL。如果当前页面没有来源,则 referrer 属性包含空字符串。所有这些信息都可以在请求的 HTTP 头部信息中获取,只是在 JavaScript 中通过这几个属性暴露出来而已。

在这些属性中,只有 domain 属性是可以设置的。出于安全考虑,给 domain 属性设置的值是有限制的。不能给这个属性设置 URL 中不包含的值。

当页面中包含来自某个不同子域的窗格(**)或内嵌窗格(

浏览器对 domain 属性还有一个限制 ,即这个属性一旦放松就不能再收紧 。比如,把 document.domain 设置为”wrox.com“之后,就不能再将其设置回”p2p.wrox.com“,后者会导致错误。

  1. 定位元素

使用 DOM 最常见的情形可能就是获取某个或某组元素的引用,然后对它们执行某些操作。document 对象上暴露了一些方法,可以实现这些操作。getElementById() 和 getElementsByTagName() 就是 Document 类型提供的两个方法。
getElementById() 方法接收一个参数,即要获取元素的 ID,如果找到了则返回这个元素,如果没找到则返回 null。参数 ID 必须跟元素在页面中的 id 属性值完全匹配,包括大小写。

如果页面中存在多个具有相同 ID 的元素,则 getElementById() 返回在文档中出现的第一个元素。

getElementsByTagName() 是另一个常用来获取元素引用的方法。这个方法接收一个参数,即要获取元素的标签名,返回包含零个或多个元素的 NodeList。在 HTML 文档中,这个方法返回一个 HTMLCollection 对象。

HTMLCollection 对象还有一个额外的方法 **namedItem()**,可通过标签的 name 属性取得某一项的引用。对于 name 属性的元素,还可以直接使用中括号来获取。

HTMLCollection 对象而言,中括号既可以接收数值索引,也可以接收字符串索引。而在后台,数值索引会调用 **item()**,字符串索引会调用 **namedItem()**。

要取得文档中的所有元素,可以给 getElementsByTagName() 传入 。在 JavaScript CSS 中,**** 一般被认为是匹配一切的字符。

HTMLDocument 类型上定义的获取元素的第三个方法是 getElementsByName()。这个方法会返回具有给定 name 属性的所有元素。getElementsByName() 方法最常用于单选按钮,因为同一字段的单选按钮必须具有相同的 name 属性才能确保把正确的值发送给服务器。

getElementsByTagName() 一样,getElementsByName() 方法也返回 HTMLCollection。不过在这种情况下,namedItem() 方法只会取得第一项(因为所有项的 name 属性都一样)。

  1. 特殊集合

document 对象上还暴露了几个特殊集合,这些集合也都是 HTMLCollection 的实例。这些集合是访问文档中公共部分的快捷方式。

  • document.anchors 包含文档中所有带 name 属性的 元素。

  • document.forms 包含文档中所有

    元素。

  • document.images包含文档中所有 img 元素。

  • document.links 包含文档中所有带 href 属性的 元素。

  1. DOM 兼容性检测

document.implementation 属性是一个对象,其中提供了与浏览器 DOM 实现相关的信息和能力。

DOM Level 1document.implementation 上只定义了一个方法,即 **hasFeature()**。这个方法接收两个参数:特性名称和 DOM 版本。如果浏览器支持指定的特性和版本,则 hasFeature() 方法返回 true

  1. 文档写入

document 对象有一个古老的能力,即向网页输出流中写入内容。这个能力对应 4 个方法:write()、writeln()、open()和close()。其中,write() 和 writeln() 方法都接收一个字符串参数,可以将这个字符串写入网页中。write() 简单地写入文本,而 writeln() 还会在字符串末尾追加一个换行符 (\n)。这两个方法可以用来在页面加载期间向页面中动态添加内容。

write()和writeln() 方法经常用于动态包含外部资源,如 JavaScript 文件。在包含 JavaScript 文件时,记住不能像下面的例子中这样直接包含字符串”</**script**>“,因为这个字符串会被解释为脚本块的结尾,导致后面的代码不能执行。

如果是在页面加载完之后再调用 **document.write()**,则输出的内容会重写整个页面。

open()和close() 方法分别用于打开和关闭网页输出流。在调用 write()和writeln() 时,这两个方法都不是必需的。

14.1.3 Element类型

Element 表示 XMLHTML 元素,对外暴露出访问元素标签名、子节点和属性的能力。Element 类型的节点具有以下特征:

  • nodeType 等于 1;

  • nodeName 值为元素的标签名;

  • nodeValue 值为 null

  • parentNode 值为 DocumentElement 对象;

  • 子节点可以是 Element、Text、Comment、ProcessingInstruction、CDATASection、EntityReference 类型。

可以通过 nodeNametagName 属性来获取元素的标签名。这两个属性返回同样的值。

HTML 中,元素标签名始终以全大写表示;在 XML(包括 XHTML)中,标签名始终与源代码中的大小写一致。如果不确定脚本是在 HTML 文档还是 XML 文档中运行,最好将标签名转换为小写形式,以便于比较。

  1. HTML 元素

所有 HTML 元素都通过 HTMLElement 类型表示,包括其直接实例和间接实例。另外,HTMLElement 直接继承 Element 并增加了一些属性。每个属性都对应下列属性之一,它们是所有 HTML 元素上都有的标准属性:

  • id,元素在文档中的唯一标识符;

  • title,包含元素的额外信息,通常以提示条形式展示;

  • lang,元素内容的语言代码(很少用);

  • dir,语言的书写方向(”ltr“表示从左到右,”rtl“表示从右到左,同样很少用);

  • className,相当于 class 属性,用于指定元素的 CSS 类(因为 classECMAScript 关键字,所以不能直接用这个名字)。

  1. 取得属性

每个元素都有零个或多个属性,通常用于为元素或其内容附加更多信息。与属性相关的 DOM 方法主要有 3 个:**getAttribute()、setAttribute()和removeAttribute()**。这些方法主要用于操纵属性,包括在 HTMLElement 类型上定义的属性。

如果给定的属性不存在,则 getAttribute() 返回 null

getAttribute() 方法也能取得不是 HTML 语言正式属性的自定义属性的值。

属性名不区分大小写,因此”ID”和”id”被认为是同一个属性。另外,根据 HTML5 规范的要求,自定义属性名应该前缀 data- 以方便验证。

元素的所有属性也可以通过相应 DOM 元素对象的属性来取得。当然,这包括 HTMLElement 上定义的直接映射对应属性的 5 个属性,还有所有公认(非自定义)的属性也会被添加为 DOM 对象的属性。

通过 DOM 对象访问的属性中有两个返回的值跟使用 getAttribute() 取得的值不一样。首先是 style 属性,这个属性用于为元素设定 CSS 样式。在使用 getAttribute() 访问 style 属性时,返回的是 CSS 字符串。而在通过 DOM 对象的属性访问时,style 属性返回的是一个(CSSStyleDeclaration)对象。 DOM 对象的 style 属性用于以编程方式读写元素样式,因此不会直接映射为元素中 style 属性的字符串值。

第二个属性其实是一类,即事件处理程序(或者事件属性),比如onclick。在元素上使用事件属性时(比如onclick),属性的值是一段 JavaScript 代码。如果使用 getAttribute() 访问事件属性,则返回的是字符串形式的源代码。而通过 DOM 对象的属性访问事件属性时返回的则是一个 JavaScript 函数(未指定该属性则返回 null)。这是因为 onclick 及其他事件属性是可以接受函数作为值的。
考虑到以上差异,开发者在进行 DOM 编程时通常会放弃使用 getAttribute() 而只使用对象属性。
getAttribute()*
主要用于取得自定义属性的值。

  1. 设置属性

getAttribute() 配套的方法是 **setAttribute()**,这个方法接收两个参数:要设置的属性名和属性的值。如果属性已经存在,则 setAttribute() 会以指定的值替换原来的值;如果属性不存在,则 setAttribute() 会以指定的值创建该属性。

setAttribute() 适用于 HTML 属性,也适用于自定义属性。另外,使用 setAttribute() 方法设置的属性名会规范为小写形式,因此”ID“会变成”id“。
因为元素属性也是 DOM 对象属性,所以直接给 DOM 对象的属性赋值也可以设置元素属性的值。

DOM 对象上添加自定义属性,不会自动让它变成元素的属性。

最后一个方法 removeAttribute() 用于从元素中删除属性。这样不单单是清除属性的值,而是会把整个属性完全从元素中去掉。

  1. attributes属性

Element 类型是唯一使用 attributes 属性的 DOM 节点类型。attributes 属性包含一个 NamedNodeMap 实例,是一个类似 NodeList 的“实时”集合。元素的每个属性都表示为一个 Attr 节点,并保存在这个 NamedNodeMap 对象中。

NamedNodeMap 对象包含下列方法:

  • **getNamedItem(name)**,返回 nodeName 属性等于 name 的节点;

  • **removeNamedItem(name)**,删除 nodeName 属性等于 name 的节点;

  • **setNamedItem(node)**,向列表中添加 node 节点,以 nodeName 为索引;

  • **item(pos)**,返回索引位置 pos 处的节点。

attributes 属性中的每个节点的 nodeName 是对应属性的名字,nodeValue 是属性的值。

removeNamedItem() 方法与元素上的 removeAttribute() 方法类似,也是删除指定名字的属性。不同之处,就是 removeNamedItem() 返回表示被删除属性的 Attr 节点。

setNamedItem() 方法接收一个属性节点,然后给元素添加一个新属性。

attributes 属性最有用的场景是需要迭代元素上所有属性的时候。这时候往往是要把 DOM 结构序列化为 XMLHTML 字符串。

  1. 创建元素

可以使用 document.createElement() 方法创建新元素。这个方法接收一个参数,即要创建元素的标签名。在 HTML 文档中,标签名是不区分大小写的,而 XML 文档(包括 XHTML)是区分大小写的。

使用 createElement() 方法创建新元素的同时也会将 ownerDocument 属性设置为 document。此时,可以再为其添加属性、添加更多子元素。

在新元素上设置这些属性只会附加信息。因为这个元素还没有添加到文档树,所以不会影响浏览器显示。要把元素添加到文档树,可以使用 **appendChild()、insertBefore()或replaceChild()**。

元素被添加到文档树之后,浏览器会立即将其渲染出来。之后再对这个元素所做的任何修改,都会立即在浏览器中反映出来。

  1. 元素后代

元素可以拥有任意多个子元素和后代元素,因为元素本身也可以是其他元素的子元素。childNodes 属性包含元素所有的子节点,这些子节点可能是其他元素、文本节点、注释或处理指令。

14.1.4 Text类型

Text 节点由 Text 类型表示,包含按字面解释的纯文本,也可能包含转义后的 HTML 字符,但不含 HTML 代码。

Text 类型的节点具有以下特征:

  • nodeType 等于 3

  • nodeName 值为”#text”;

  • nodeValue 值为节点中包含的文本;

  • parentNode 值为 Element 对象;

  • 不支持子节点。

Text 节点中包含的文本可以通过 nodeValue 属性访问,也可以通过 data 属性访问,这两个属性包含相同的值。修改 nodeValuedata 的值,也会在另一个属性反映出来。

文本节点暴露了以下操作文本的方法:

  • appendData(text),向节点末尾添加文本text;

  • deleteData(offset, count),从位置offset开始删除count个字符;

  • insertData(offset, text),在位置offset插入text;

  • replaceData(offset, count, text),用text替换从位置offset到offset + count的文本;

  • splitText(offset),在位置offset将当前文本节点拆分为两个文本节点;

  • substringData(offset, count),提取从位置offset到offset + count的文本。

还可以通过 length 属性获取文本节点中包含的字符数量。这个值等于 nodeValue.length和data.length
默认情况下,包含文本内容的每个元素最多只能有一个文本节点。

只要开始标签和结束标签之间有内容,就会创建一个文本节点。

修改文本节点还有一点要注意,就是 HTMLXML 代码(取决于文档类型)会被转换成实体编码,即小于号、大于号或引号会被转义。

  1. 创建文本节点

document.createTextNode() 可以用来创建新文本节点,它接收一个参数,即要插入节点的文本。跟设置已有文本节点的值一样,这些要插入的文本也会应用 HTMLXML 编码。

一般来说一个元素只包含一个文本子节点。不过,也可以让元素包含多个文本子节点。

  1. 规范化文本节点

有一个方法可以合并相邻的文本节点。这个方法叫 **normalize()**,是在 Node 类型中定义的(因此所有类型的节点上都有这个方法)。在包含两个或多个相邻文本节点的父节点上调用 normalize() 时,所有同胞文本节点会被合并为一个文本节点,这个文本节点的 nodeValue 就等于之前所有同胞节点 nodeValue 拼接在一起得到的字符串。

浏览器在解析文档时,永远不会创建同胞文本节点。同胞文本节点只会出现在 DOM 脚本生成的文档树中。

  1. 拆分文本节点

Text 类型定义了一个与 normalize() 相反的方法—— **splitText()**。这个方法可以在指定的偏移位置拆分 nodeValue,将一个文本节点拆分成两个文本节点。拆分之后,原来的文本节点包含开头到偏移位置前的文本,新文本节点包含剩下的文本。这个方法返回新的文本节点,具有与原来的文本节点相同的 parentNode

14.1.5 Comment类型

DOM 中的注释通过 Comment 类型表示。

Comment 类型的节点具有以下特征:

  • nodeType 等于 8;

  • nodeName 值为”#comment”;

  • nodeValue 值为注释的内容;

  • parentNode 值为 DocumentElement 对象;

  • 不支持子节点。

Comment 类型与 Text 类型继承同一个基类(CharacterData),因此拥有除 splitText() 之外 Text 节点所有的字符串操作方法。与 Text 类型相似,注释的实际内容可以通过 nodeValuedata 属性获得。
注释节点可以作为父节点的子节点来访问。

可以使用 document.createComment() 方法创建注释节点,参数为注释文本。

14.1.6 CDATASection类型

CDATASection 类型表示 XML 中特有的 CDATA 区块 CDATASection 类型继承 Text 类型,因此拥有包括 splitText() 在内的所有字符串操作方法。CDATASection 类型的节点具有以下特征:

  • nodeType 等于 4

  • nodeName 值为”#cdata-section”;

  • nodeValue 值为 CDATA 区块的内容;

  • parentNode 值为Document或Element对象;

  • 不支持子节点。

CDATA 区块只在 XML 文档中有效,因此某些浏览器比较陈旧的版本会错误地将 CDATA 区块解析为 Comment 或 Element

在真正的 XML 文档中,可以使用 document.createCDataSection() 并传入节点内容来创建 CDATA 区块。

14.1.7 DocumentType类型

DocumentType 类型的节点包含文档的文档类型(doctype)信息,具有以下特征:

  • nodeType 等于 10

  • nodeName 值为文档类型的名称;

  • nodeValue 值为 null

  • parentNode 值为 Document 对象;

  • 不支持子节点。

DocumentType 对象在 DOM Level 1 中不支持动态创建,只能在解析文档代码时创建。对于支持这个类型的浏览器,DocumentType 对象保存在 document.doctype 属性中。DOM Level 1 规定了 DocumentType 对象的 3 个属性:name、entities和notations。其中,name 是文档类型的名称,entities 是这个文档类型描述的实体的 NamedNodeMap,而 notations 是这个文档类型描述的表示法的 NamedNodeMap。因为浏览器中的文档通常是 HTMLXHTML 文档类型,所以 entitiesnotations 列表为空。(这个对象只包含行内声明的文档类型。)无论如何,只有 name 属性是有用的。这个属性包含文档类型的名称,即紧跟在<!DOCTYPE后面的那串文本。

14.1.8 DocumentFragment类型

在所有节点类型中,DocumentFragment 类型是唯一一个在标记中没有对应表示的类型。DOM 将文档片段定义为“轻量级”文档,能够包含和操作节点,却没有完整文档那样额外的消耗。

DocumentFragment节点具有以下特征:

  • nodeType 等于 11;

  • nodeName 值为”#document-fragment”;

  • nodeValue 值为null;

  • parentNode 值为null;

  • 子节点可以是 Element、ProcessingInstruction、Comment、Text、CDATASection 或 EntityReference

不能直接把文档片段添加到文档。相反,文档片段的作用是充当其他要被添加到文档的节点的仓库。

文档片段从 Node 类型继承了所有文档类型具备的可以执行 DOM 操作的方法。如果文档中的一个节点被添加到一个文档片段,则该节点会从文档树中移除,不会再被浏览器渲染。添加到文档片段的新节点同样不属于文档树,不会被浏览器渲染。可以通过 appendChild()或insertBefore() 方法将文档片段的内容添加到文档。在把文档片段作为参数传给这些方法时,这个文档片段的所有子节点会被添加到文档中相应的位置。文档片段本身永远不会被添加到文档树。

14.1.9 Attr类型

元素数据在 DOM 中通过 Attr 类型表示。Attr 类型构造函数和原型在所有浏览器中都可以直接访问。技术上讲,属性是存在于元素 attributes 属性中的节点。Attr 节点具有以下特征:

  • nodeType 等于 2;

  • nodeName 值为属性名;

  • nodeValue 值为属性值;

  • parentNode 值为 null

  • HTML 中不支持子节点;

  • XML 中子节点可以是 TextEntityReference

属性节点尽管是节点,却不被认为是 DOM 文档树的一部分。Attr 节点很少直接被引用,通常开发者更喜欢使用 getAttribute()、removeAttribute()和setAttribute() 方法操作属性。
Attr 对象上有 3 个属性:name、value和specified。其中,name 包含属性名(与 nodeName 一样),value 包含属性值(与 nodeValue 一样),而* specified* 是一个布尔值,表示属性使用的是默认值还是被指定的值。
可以使用 document.createAttribute() 方法创建新的 Attr 节点,参数为属性名。

14.2 DOM 编程

14.2.1 动态脚本

<**script**> 元素用于向网页中插入 JavaScript 代码,可以是 src 属性包含的外部文件,也可以是作为该元素内容的源代码。动态脚本就是在页面初始加载时不存在,之后又通过 DOM 包含的脚本。

与对应的HTML 元素一样,有两种方式通过 <**script**> 动态为网页添加脚本:引入外部文件和直接插入源代码。

通过 innerHTML 属性创建的 <**script**> 元素永远不会执行。浏览器会尽责地创建 <**script**> 元素,以及其中的脚本文本,但解析器会给这个 <**script**> 元素打上永不执行的标签。只要是使用 innerHTML 创建的 <**script**> 元素,以后也没有办法强制其执行。

14.2.2 动态样式

CSS 样式在 HTML 页面中可以通过两个元素加载。**< link >** 元素用于包含 CSS 外部文件,而 < style > 元素用于添加嵌入样式。与动态脚本类似,动态样式也是页面初始加载时并不存在,而是在之后才添加到页面中的。

应该把 元素添加到 元素而不是 元素,这样才能保证所有浏览器都能正常运行。

另一种定义样式的方式是使用