《JavaScript 高级程序设计(第 4 版)》
第 1 章 什么是 JavaScript
JavaScript 是一门用来与网页交互的脚本语言,包含以下三个组成部分。
- ECMAScript:由 ECMA-262 定义并提供核心功能。
- 文档对象模型(DOM):提供与网页内容交互的方法和接口。
- 浏览器对象模型(BOM):提供与浏览器交互的方法和接口。
JavaScript 的这三个部分得到了五大 Web 浏览器(IE、Firefox、Chrome、Safari 和 Opera)不同程度的支持。所有浏览器基本上对 ES5(ECMAScript 5)提供了完善的支持,而对 ES6(ECMAScript 6)和 ES7(ECMAScript 7)的支持度也在不断提升。这些浏览器对 DOM 的支持各不相同,但对 Level 3 的支持日益趋于规范。
第 2 章 HTML 中的 JavaScript
<script>
元素
- async:可选。表示立即下载脚本,但不阻止其他页面动作,比如下载资源或等待其他脚本加载。只对外部脚本文件有效。
- charset:可选。使用 src 属性指定的代码的字符集。
- crossOrigin:可选。配置相关请求的 CORS(跨域资源共享)设置。
- defer:可选。表示脚本可以延迟到文档完全被解析和显示之后再执行。只对外部脚本有效。
- integrity:可选。允许比对接收到的资源和指定的加密签名以验证子资源完整性。
- src:可选。表示包含要执行代码的外部文件。
- language:已废弃。应该使用 type 属性。
- type:可选。代替 language,表示代码块中脚本语言的内容类型(MIME 类型)。
- MIME 类型通常是"application/x-javascript",不过给 type 属性值有可能导致脚本被忽略。
- 如果这个值是 module,则代码会被当成 ES6 模块,而且只有这时候代码中才能出现 import 和 export 关键字。
defer 的属性。延迟执行脚本,这个属性表示脚本在执行的时候不会改变页面的结构。也就是说,脚本会被延迟到整个页面都解析完毕后再运行。因此,在<script>
元素上设置 defer 属性,相当于告诉浏览器立即下载,但延迟执行。
async 属性。异步执行脚本,都会告诉浏览器立即开始下载。不过,与 defer 不同的是,标记为 async 的脚本并不保证能按照它们出现的次序执行。
<!DOCTYPE html>
<html>
<head>
<title>Example HTML Page</title>
<script defer src="example1.js"></script>
<script async src="example2.js"></script>
</head>
<body>
<!-- 这里是页面内容 -->
</body>
</html>
最佳实践是尽可能将 JavaScript 代码放在外部文件中:
- 代码如果分散到很多 HTML 页面,会导致维护困难。而用一个目录保存所有 JavaScript 文件,则更容易维护,这样开发者就可以独立于使用它们的 HTML 页面来编辑代码。
- 缓存。浏览器会根据特定的设置缓存所有外部链接的 JavaScript 文件,这意味着如果两个页面都用到同一个文件,则该文件只需下载一次。这最终意味着页面加载更快。
- JavaScript 放到外部文件中,就不必考虑用 XHTML 或前面提到的注释黑科技。包含外部 JavaScript 文件的语法在 HTML 和 XHTML 中是一样的。
<noscript>
元素。被用于给不支持 JavaScript 的浏览器提供替代内容。
<!DOCTYPE html>
<html>
<head>
<title>Example HTML Page</title>
<script defer="defer" src="example1.js"></script>
<script defer="defer" src="example2.js"></script>
</head>
<body>
<noscript>
<!-- 这个例子是在脚本不可用时让浏览器显示一段话。如果浏览器支持脚本,则用户永远不会看到它。 -->
<p>This page requires a JavaScript-enabled browser.</p>
</noscript>
</body>
</html>
第 3 章 语言基础
语法
区分大小写:无论是变量、函数名还是操作符,都区分大小写。
标识符: 变量、函数、属性的名字,或者函数的参数。
- 第一个字符必须是一个字母、下划线(_)或美元符号($)。
- 其他字符可以是字母、下划线、美元符号或数字。
- 不能把关键字、保留字、true、false 和 null 用作标识符。
注释:单行注释和多行注释。
严格模式:"use strict" 在整个脚本文件或单独的函数中启用或禁用严格模式。
语句:语句以分号结尾。省略分号意味着由解析器确定语句在哪里结尾,
关键字和保留字
- 关键字:ECMAScript 已经实现的关键字。
- 保留字:ECMAScript 尚未实现的关键字。
break | do | in | typeof | case | else | instanceof |
var | catch | export | new | void | class | extends |
return | while | const | finally | super | with | continue |
for | switch | yield | debugger | function | this | default |
if | throw | delete | import | try |
变量
var
- var 声明的范围是函数作用域。
- 不初始化的情况下,变量会保存一个特殊值 undefined
- var 声明作用域:使用 var 操作符定义的变量会成为包含它的函数的局部变量。比如,使用 var 在一个函数内部定义一个变量,就意味着该变量将在函数退出时被销毁。
- var 声明提升:var 声明会被提升到函数或全局作用域的顶部。
let
- let 声明的范围是块作用域。
- let 不能在同一个块作用域中声明两次。
- 暂时性死区(在 let 声明之前的执行瞬间),let 与 var 的另一个重要的区别,就是 let 声明的变量不会在作用域中被提升
- 全局声明:使用 let 在全局作用域中声明的变量不会成为 window 对象的属性
// name 会被提升
console.log(name) // undefined
var name = 'Matt'
// age 不会被提升
console.log(age) // ReferenceError:age 没有定义
let age = 26
var name = 'Matt'
console.log(window.name) // 'Matt'
let age = 26
console.log(window.age) // undefined
for 循环中的 let 声明
// 在 let 出现之前,for 循环定义的迭代变量会渗透到循环体外部:
for (var i = 0; i < 5; ++i) {
// 循环逻辑
}
console.log(i) // 5
// 改成使用 let 之后,这个问题就消失了,因为迭代变量的作用域仅限于 for 循环块内部:
for (let i = 0; i < 5; ++i) {
// 循环逻辑
}
console.log(i) // ReferenceError: i 没有定义
// 在使用 var 的时候,最常见的问题就是对迭代变量的奇特声明和修改:
for (var i = 0; i < 5; ++i) {
setTimeout(() => console.log(i), 0)
}
// 你可能以为会输出 0、1、2、3、4
// 实际上会输出 5、5、5、5、5
for (let i = 0; i < 5; ++i) {
setTimeout(() => console.log(i), 0)
}
// 会输出 0、1、2、3、4
之所以会这样,是因为在退出循环时,迭代变量保存的是导致循环退出的值:5。在之后执行超时逻辑时,所有的 i 都是同一个变量,因而输出的都是同一个最终值。
而在使用 let 声明迭代变量时,JavaScript 引擎在后台会为每个迭代循环声明一个新的迭代变量。每个 setTimeout 引用的都是不同的变量实例,所以 console.log 输出的是我们期望的值,也就是循环执行过程中每个迭代变量的值。
- const
- 唯一一个重要的区别是用它声明变量时必须同时初始化变量,且尝试修改 const 声明的变量会导致运行时错误。
- const 声明的限制只适用于它指向的变量的引用。如果 const 变量引用的是一个对象,那么修改这个对象内部的属性并不违反 const 的限制。
- 不能用 const 来声明迭代变量(因为迭代变量会自增)
const age = 26
age = 36 // TypeError: 给常量赋值
// const 也不允许重复声明
const name = 'Matt'
const name = 'Nicholas' // SyntaxError
// const 声明的作用域也是块
const name = 'Matt'
if (true) {
const name = 'Nicholas'
}
console.log(name) // Matt
const person = {}
person.name = 'Matt' // ok
// 不能用 const 来声明迭代变量(因为迭代变量会自增)
for (const i = 0; i < 10; ++i) {} // TypeError:给常量赋值
你只想用 const 声明一个不会被修改的 for 循环变量,那也是可以的。也就是说,每次迭代只是创建一个新变量。这对 for-of 和 for-in 循环特别有意义
let i = 0
for (const j = 7; i < 5; ++i) {
console.log(j)
}
// 7, 7, 7, 7, 7
for (const key in { a: 1, b: 2 }) {
console.log(key)
}
// a, b
for (const value of [1, 2, 3, 4, 5]) {
console.log(value)
}
// 1, 2, 3, 4, 5
最佳实践:
- 不使用 var 有了 let 和 const,大多数开发者会发现自己不再需要 var 了。限制自己只使用 let 和 const 有助于提升代码质量,因为变量有了明确的作用域、声明位置,以及不变的值。
- const 优先,let 次之 使用 const 声明可以让浏览器运行时强制保持变量不变,也可以让静态代码分析工具提前发现不合法的赋值操作。因此,很多开发者认为应该优先使用 const 来声明变量,只在提前知道未来会有修改时,再使用 let。这样可以让开发者更有信心地推断某些变量的值永远不会变,同时也能迅速发现因意外赋值导致的非预期行为。
数据类型
ECMAScript 有 6 种简单数据类型(也称为原始类型):Undefined、Null、Boolean、Number、String 和 Symbol。Symbol(符号)是 ECMAScript 6 新增的。Object 是一种无序名值对的集合。
typeof 操作符:ECMAScript 的类型系统是松散的,所以需要一种手段来确定任意变量的数据类型。typeof 操作符就是为此而生的。对一个值使用 typeof 操作符会返回下列字符串之一:
- "undefined"表示值未定义;
- "boolean"表示值为布尔值;
- "string"表示值为字符串;
- "number"表示值为数值;
- "object"表示值为对象(而不是函数)或 null;
- "function"表示值为函数;
- "symbol"表示值为符号。
Undefined 类型
Undefined 类型只有一个值,就是特殊值 undefined。当使用 var 或 let 声明了变量但没有初始化时,就相当于给变量赋予了 undefined 值
永远不必显式地将变量值设置为 undefined
let message; console.log(message == undefined); // true
Null 类型
Null 类型同样只有一个值,即特殊值 null。null 值表示一个空对象指针,这也是给 typeof 传一个 null 会返回"object"的原因:
在定义将来要保存对象值的变量时,建议使用 null 来初始化,不要使用其他值
- js
let car = null consle.log(typeof car) // 'object'
Boolean 类型
- 有两个字面值:true 和 false。
- 像 if 等流控制语句会自动执行其他类型值到布尔值的转换。
数据类型 | 转换为 true 的值 | 转换为 false 的值 |
---|---|---|
Boolean | true | flase |
String | 非空字符串 | "" 空字符串 |
Number | 非零数值 | 0,NaN |
Object | 任意对象 | null |
Undefiend | N/A (不存在) | undefined |
Number 类型
- 最基本的数值字面量格式是十进制整数
- 八进制字面量,第一个数字必须是零(0),然后是相应的八进制数字(数值 0~7)。如果字面量中包含的数字超出了应有的范围,就会忽略前缀的零,后面的数字序列会被当成十进制数。
- 十六进制字面量,必须让真正的数值前缀 0x(区分大小写),然后是十六进制数字(0~9 以及 A~F)。十六进制数字中的字母大小写均可。
- 浮点值:数值中必须包含小数点,而且小数点后面必须至少有一个数字。对于非常大或非常小的数值,浮点值可以用科学记数法来表示。实际上相当于说:“以 3.125 作为系数,乘以 10 的 7 次幂。”
- 值的范围:最小数值保存在 Number.MIN_VALUE 中;如果超过范围,自动转换为一个特殊的 Infinity(无穷)值。
- isFinite()函数:要确定一个值是不是有限大(即介于 JavaScript 能表示的最小值和最大值之间)。
- NaN:“不是数值”(Not a Number),用于表示本来要返回数值的操作失败了(而不是抛出错误),用 0 除任意数值都是 NaN。
- isNaN()函数:该函数接收一个参数,可以是任意数据类型,然后判断这个参数是否“不是数值”。
- Number():任意类型,严格转换整个值,
Number("123abc") → NaN
- parseInt():字符串优先,逐个解析字符直到遇到非数字字符,
parseInt("123abc") → 123
- parseFloat():解析到第一个无效浮点数字符为止,
parseFloat("12.34.56") → 12.34
// 十进制整数
let intNum = 55 // 整数
// 八进制
let octalNum1 = 070 // 八进制的56
let octalNum2 = 079 // 无效的八进制值,当成 79 处理
let octalNum3 = 08 // 无效的八进制值,当成 8 处理
// 十六进制
let hexNum1 = 0xa // 十六进制10
let hexNum2 = 0x1f // 十六进制31
// 浮点值
let floatNum1 = 1.1
let floatNum2 = 0.1
let floatNum = 3.125e7 // 等于31250000
// 确定一个值是不是有限大
let result = Number.MAX_VALUE + Number.MAX_VALUE
console.log(isFinite(result)) // false
// 判断是否不是数值
console.log(isNaN(NaN)) // true
console.log(isNaN(10)) // false,10 是数值
console.log(isNaN('10')) // false,可以转换为数值 10
console.log(isNaN('blue')) // true,不可以转换为数值
console.log(isNaN(true)) // false,可以转换为数值 1
// Number转换规则
let num1 = Number('Hello world!') // NaN
let num2 = Number('') // 0
let num3 = Number('000011') // 11
let num4 = Number(true) // 1
let num1 = parseInt('1234blue') // 1234
let num2 = parseInt('') // NaN
let num3 = parseInt('0xA') // 10,解释为十六进制整数
let num1 = parseFloat('1234blue') // 1234,按整数解析
let num2 = parseFloat('0xA') // 0
let num3 = parseFloat('22.5') // 22.5
let num4 = parseFloat('22.34.5') // 22.34
String 类型
String(字符串)数据类型表示零或多个 16 位 Unicode 字符序列。字符串可以使用双引号(")、单引号(')或反引号(`)标示。
- 转换为字符串:
- toString():返回调用字符串值的字符串形式。
- String():返回表示相应类型值的字符串。
- 显式地将一个值转换为字符串(使用 + 操作符)
let age = 18
console.log(age.toString()) // '18'
let value1 = 10
let value2 = true
let value3 = null
let value4
console.log(String(value1)) // '10'
console.log(String(value2)) // 'true'
console.log(String(value3)) // 'null'
console.log(String(value4)) // 'undefined'
模板字符串:模板字面量保留换行字符,可以跨行定义字符串。模板字面量在定义模板时特别有用,可以用它来定义 HTML 片段。
- 插值:最常用的功能是插值。插值是在模板字面量中使用 ${} 语法来包含一个或多个表达式。
let pageHTML = `
<div>
<a href="#">
<span>Jake</span>
</a>
</div>`
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
// 在插值表达式中可以调用函数和方法:
function capitalize(word) {
return `${word[0].toUpperCase()}${word.slice(1)}`
}
console.log(`${capitalize('hello')}, ${capitalize('world')}!`) // Hello, World!
Symbol 类型
ES6 新增的 Symbol 类型是一种新的原始数据类型。Symbol 类型的值是唯一的,就像 ID 一样。符号的用途是确保对象属性使用唯一标识符,不会发生属性冲突的危险。
全局符号:Symbol.for()方法。
查询全局注册表:Symbol.keyFor()方法。
常用内置符号:
- Symbol.iterator:定义对象的默认迭代器,使对象可用于 for...of 循环。
- Symbol.asyncIterator:表示“一个方法,该方法返回对象默认的 AsyncIterator。由 for-await-of 语句使用”。
- Symbol.hasInstance:表示一个方法,该方法决定一个构造器对象是否认可一个对象是它的实例。
- Symbol.isConcatSpreadable:一个布尔值,如果是 true,则意味着对象应该用 Array.prototype.concat()打平其数组元素。
- Symbol.replace:一个函数值,用于在字符串替换时调用的方法。
- Symbol.search:一个函数值,用于在字符串搜索时调用的方法。
- Symbol.species:一个函数值,用于创建派生对象。
- Symbol.split:一个函数值,用于在字符串分割时调用的方法。
- Symbol.match:一个函数值,用于在字符串匹配时调用的方法。
- Symbol.toPrimitive:控制对象转换为原始值的行为,自定义类型转换。
- Symbol.toStringTag:自定义 Object.prototype.toString() 的返回值。
- Symbol.unscopables:号作为一个属性表示“一个对象,该对象所有的以及继承的属性,都会从关联对象的 with 环境绑定中排除”。设
// 创建基本 Symbol
let sym = Symbol()
console.log(typeof sym) // 'symbol'
// 创建带描述的 Symbol(仅用于调试)
let userSymbol = Symbol('user')
let anotherUserSymbol = Symbol('user')
console.log(userSymbol === anotherUserSymbol) // false - 即使描述相同也是不同的 Symbol
let fooGlobalSymbol = Symbol.for('foo') // 创建新符号
let otherFooGlobalSymbol = Symbol.for('foo') // 重用已有符号
console.log(fooGlobalSymbol === otherFooGlobalSymbol) // true
// 查询全局注册表
let fooSymbol = Symbol.for('foo')
console.log(Symbol.keyFor(fooSymbol)) // foo
let fooSymbol = Symbol('foo')
console.log(Symbol.keyFor(fooSymbol)) // undefined
实际使用场景
// 在库或框架中安全添加属性
const _id = Symbol('id')
class User {
constructor(name) {
this.name = name
this[_id] = Date.now()
}
getId() {
return this[_id]
}
}
// 实现私有属性
const _privateData = Symbol('privateData')
class MyClass {
constructor() {
this[_privateData] = { secret: '不可直接访问' }
}
getSecret() {
return this[_privateData].secret
}
}
const instance = new MyClass()
console.log(instance[_privateData]) // 外部可以访问,但需要知道确切的 Symbol
// 自定义对象的迭代行为
const collection = {
items: ['A', 'B', 'C'],
[Symbol.iterator]: function* () {
for (let item of this.items) {
yield item
}
},
}
for (let item of collection) {
console.log(item) // 'A', 'B', 'C'
}
// 常量枚举
const Direction = {
UP: Symbol('UP'),
DOWN: Symbol('DOWN'),
LEFT: Symbol('LEFT'),
RIGHT: Symbol('RIGHT'),
}
function move(direction) {
switch (direction) {
case Direction.UP:
return '向上移动'
case Direction.DOWN:
return '向下移动'
case Direction.LEFT:
return '向左移动'
case Direction.RIGHT:
return '向右移动'
}
}
Object 类型
对象其实就是一组数据和功能的集合。对象通过 new 操作符后跟对象类型的名称来创建。开发者可以通过创建 Object 类型的实例来创建自己的对象,然后再给对象添加属性和方法:
let o = new Object();
Object 实例都有如下属性和方法:
- constructor:用于创建当前对象的函数。在前面的例子中,这个属性的值就是 Object()函数。
- hasOwnProperty(propertyName):用于判断当前对象实例(不是原型)上是否存在给定的属性。要检查的属性名必须是字符串(如 o.hasOwnProperty("name"))或符号。
- isPrototypeOf(object):用于判断当前对象是否为另一个对象的原型。(第 8 章将详细介绍原型。)
- propertyIsEnumerable(propertyName):用于判断给定的属性是否可以使用(本章稍后讨论的)for-in 语句枚举。与 hasOwnProperty()一样,属性名必须是字符串。
- toLocaleString():返回对象的字符串表示,该字符串反映对象所在的本地化执行环境。
- toString():返回对象的字符串表示。
- valueOf():返回对象对应的字符串、数值或布尔值表示。通常与 toString()的返回值相同。
操作符
一组可用于操作数据值的操作符,包括数学操作符(如加、减)、位操作符、关系操作符和相等操作符等。它们可用于各种值,包括字符串、数值、布尔值,甚至还有对象。
一元操作符
- 递增和递减操作符
- 一元加和减操作符
// 一元操作符
let s1 = '2'
let s2 = 'z'
let b = false
let o = {
valueof() {
return -1
}
}
s1++; // 值变成数值 3
s2++; // 值变成 NaN
b++; // 值变成数值 1
f--; // 值变成 0.10000000000000009(因为浮点数不精确)
o--; // 值变成-2
位操作符
- 按位非(~):对数值的二进制补码按位取反,最终效果等同于对数值取反并减 1
- 按位与(&):两个操作数对应位都为 1 时,结果位才为 1,否则为 0
- 按位或(|):两个操作数对应位只要有一个为 1,结果位就为 1
- 按位异或(^):两个操作数对应位不同时,结果位为 1,相同时为 0
- 左移(<<):将数值的所有位向左移动指定的位数,右侧用 0 填充,相当于乘以 2 的幂
- 有符号右移(>>):将数值的所有位向右移动指定的位数,保留符号位,左侧用符号位填充
- 无符号右移(>>>):将数值的所有位向右移动指定的位数,左侧用 0 填充,忽略符号位
实际场景:
// 标志位/权限管理 :使用二进制位表示多个布尔值状态
const READ = 1 // 001
const WRITE = 2 // 010
const EXECUTE = 4 // 100
// 授予权限
let userPermission = 0
userPermission |= READ // 添加读权限
userPermission |= WRITE // 添加写权限
// 检查权限
if (userPermission & READ) {
console.log('用户有读权限')
}
// 移除权限
userPermission &= ~WRITE // 移除写权限
布尔操作符
- 逻辑非(!):逻辑非操作符首先将操作数转换为布尔值,然后再对其取反。
- 如果操作数是对象,则返回 false。
- 如果操作数是空字符串,则返回 true。
- 如果操作数是非空字符串,则返回 false。
- 如果操作数是数值 0,则返回 true。
- 如果操作数是非 0 数值(包括 Infinity),则返回 false。
- 如果操作数是 null,则返回 true。
- 如果操作数是 NaN,则返回 true。
- 如果操作数是 undefined,则返回 true。
- (!!) 相当于调用了转型函数 Boolean()。无论操作数是什么类型,第一个叹号总会返回布尔值。第二个叹号对该布尔值取反,
console.log(!false); // true
console.log(!"blue"); // false
console.log(!0); // true
console.log(!NaN); // true
console.log(!""); // true
console.log(!12345); // false
console.log(!!"blue"); // true
console.log(!!0); // false
console.log(!!NaN); // false
console.log(!!""); // false
console.log(!!12345); // true
- 逻辑与(&&):可用于任何类型的操作数,不限于布尔值。如果有操作数不是布尔值,则逻辑与并不一定会返回布尔值。它是一种短路操作符,如果第一个操作数决定了结果,那么永远不会对第二个操作数求值。
- 如果第一个操作数是对象,则返回第二个操作数。
- 如果第二个操作数是对象,则只有第一个操作数求值为 true 才会返回该对象。
- 如果两个操作数都是对象,则返回第二个操作数。
- 如果有一个操作数是 null,则返回 null。
- 如果有一个操作数是 NaN,则返回 NaN。
- 如果有一个操作数是 undefined,则返回 undefined。
let found = true;
let result = (found && someUndeclaredVariable); // 这里会出错
console.log(result); // 不会执行这一行
- 逻辑或(||):如果有一个操作数不是布尔值,那么逻辑或操作符也不一定返回布尔值。第一个操作数求值为 true,第二个操作数就不会再被求值了。
- 如果第一个操作数是对象,则返回第一个操作数。
- 如果第一个操作数求值为 false,则返回第二个操作数。
- 如果两个操作数都是对象,则返回第一个操作数。
- 如果两个操作数都是 null,则返回 null。
- 如果两个操作数都是 NaN,则返回 NaN。
- 如果两个操作数都是 undefined,则返回 undefined。
let found = true;
let result = (found || someUndeclaredVariable); // 这里不会出错
console.log(result); // true
乘性操作符
- 乘法(*):乘法操作符用于计算两个数值的乘积。
- 如果操作数都是数值,则执行常规的乘法运算,即两个正值相乘是正值,两个负值相乘也是正值,正负符号不同的值相乘得到负值。如果 ECMAScript 不能表示乘积,则返回 Infinity 或-Infinity。
- 如果有任一操作数是 NaN,则返回 NaN。
- 如果是 Infinity 乘以 0,则返回 NaN。
- 如果是 Infinity 乘以非 0 的有限数值,则根据第二个操作数的符号返回 Infinity-Infinity。
- 如果是 Infinity 乘以 Infinity,则返回 Infinity。
- 如果有不是数值的操作数,则先在后台用 Number()将其转换为数值,然后再应用上述规则。
- 除法(/):除法操作符用于计算两个数值的商。
- 如果操作数都是数值,则执行常规的除法运算,即两个正值相除是正值,两个负值相除也是正值,符号不同的值相除得到负值。如果 ECMAScript 不能表示商,则返回 Infinity 或-Infinity。
- 如果有任一操作数是 NaN,则返回 NaN。
- 如果是 Infinity 除以 Infinity,则返回 NaN。
- 如果是 0 除以 0,则返回 NaN。
- 如果是非 0 的有限值除以 0,则根据第一个操作数的符号返回 Infinity 或-Infinity。
- 如果是 Infinity 除以任何数值,则根据第二个操作数的符号返回 Infinity 或-Infinity。
- 如果有不是数值的操作数,则先在后台用 Number()函数将其转换为数值,然后再应用上述规则。
- 取模(%):取模操作符用于计算两个数值的余数。
- 如果操作数是数值,则执行常规除法运算,返回余数。
- 如果被除数是无限值,除数是有限值,则返回 NaN。
- 如果被除数是有限值,除数是 0,则返回 NaN。
- 如果是 Infinity 除以 Infinity,则返回 NaN。
- 如果被除数是有限值,除数是无限值,则返回被除数。
- 如果被除数是 0,除数不是 0,则返回 0。
- 如果有不是数值的操作数,则先在后台用 Number()函数将其转换为数值,然后再应用上述规则。
指数操作符
ECMAScript 7 新增了指数操作符,Math.pow()现在有了自己的操作符**。指数操作符也有自己的指数赋值操作符**=,该操作符执行指数运算和结果的赋值操作
console.log(Math.pow(3, 2); // 9
console.log(3 ** 2); // 9
console.log(Math.pow(16, 0.5); // 4
console.log(16 ** 0.5); // 4
// 指数赋值操作符**=,
let squared = 3;
squared **= 2;
console.log(squared); // 9
let sqrt = 16;
sqrt **= 0.5;
console.log(sqrt); // 4
加性操作符
- 加法(+):加法操作符用于计算两个数值的和。
- 如果有任一操作数是 NaN,则返回 NaN;
- 如果是 Infinity 加 Infinity,则返回 Infinity;
- 如果是-Infinity 加-Infinity,则返回-Infinity;
- 如果是 Infinity 加-Infinity,则返回 NaN;
- 如果是+0 加+0,则返回+0;
- 如果是-0 加+0,则返回+0;
- 如果是-0 加-0,则返回-0。
let result1 = 5 + 5 // 两个数值
console.log(result1) // 10
let result2 = 5 + '5' // 一个数值和一个字符串
console.log(result2) // "55"
// 最常犯的一个错误,就是忽略加法操作中涉及的数据类型
let num1 = 5
let num2 = 10
let message = 'The sum of 5 and 10 is ' + num1 + num2
console.log(message) // "The sum of 5 and 10 is 510"
减法操作符
- 如果两个操作数都是数值,则执行数学减法运算并返回结果。
- 如果有任一操作数是 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()方法,然后再将得到的字符串转换为数值。
let result1 = 5 - true; // true 被转换为 1,所以结果是 4
let result2 = NaN - 1; // NaN
let result3 = 5 - 3; // 2
let result4 = 5 - ""; // ""被转换为 0,所以结果是 5
let result5 = 5 - "2"; // "2"被转换为 2,所以结果是 3
let result6 = 5 - null; // null 被转换为 0,所以结果是 5
关系操作符
- 大于(>):如果第一个操作数大于第二个操作数,则返回 true;否则返回 false。
- 小于(<):如果第一个操作数小于第二个操作数,则返回 true;否则返回 false。
- 大于等于(>=):如果第一个操作数大于或等于第二个操作数,则返回 true;否则返回 false。
- 小于等于(<=):如果第一个操作数小于或等于第二个操作数,则返回 true;否则返回 false。
相等操作符
- 等于(=):如果两个操作数相等,则返回 true;否则返回 false。
- 全等(===):如果两个操作数相等且类型相同,则返回 true;否则返回 false。
- 不全等(!==):如果两个操作数不相等或类型不同,则返回 true;否则返回 false。
- 不相等(!=):如果两个操作数不相等,则返回 true;否则返回 false。
表达式 | 结果 |
---|---|
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 |
let result1 = '55' == 55 // true,转换后相等
let result2 = '55' === 55 // false,不相等,因为数据类型不同
let result1 = '55' != 55 // false,转换后相等
let result2 = '55' !== 55 // true,不相等,因为数据类型不同
条件操作符
variable = boolean_expression ? true_value : false_value;
根据条件表达式 boolean_expression 的值决定将哪个值赋给变量 variable 。如果 boolean_expression 是 true ,则赋值 true_value ;如果 oolean_expression 是 false,则赋值 false_value。
赋值操作符
简单赋值用等于号(=)表示,将右手边的值赋给左手边的变量
复合赋值使用乘性、加性或位操作符后跟等于号(=)表示。
- 乘后赋值(*=)
- 除后赋值(/=)
- 取模后赋值(%=)
- 加后赋值(+=)
- 减后赋值(-=)
- 左移后赋值(<<=)
- 右移后赋值(>>=)
- 无符号右移后赋值(>>>=)
let num = 10
let num = 10
num = num + 10
let num = 10
num = num + 10
逗号操作符
逗号操作符用于在一条语句中执行多个操作。在一条语句中同时声明多个变量是逗号操作符最常用的场景。
let num1 = 1,
num2 = 2,
num3 = 3
语句
if 语句
if (condition) statement1 else statement2
这里的条件(condition)可以是任何表达式,并且求值结果不一定是布尔值。ECMAScript 会自调用 Boolean()函数将这个表达式的值转换为布尔值。如果条件求值为 true,则执行语句 statement1;如果条件求值为 false,则执行语句 statement2。这里的语句可能是一行代码,也可能是一个代码块(即包含在一对花括号中的多行代码)。
if (i > 25) console.log('Greater than 25.') // 只有一行代码的语句
else {
console.log('Less than or equal to 25.') // 一个语句块
}
do-while 语句
do-while 语句是一种后测试循环语句,即循环体中的代码执行后才会对退出条件进行求值。换句话说,循环体内的代码至少执行一次。
let i = 0
do {
i += 2
} while (i < 10)
在这个例子中,只要 i 小于 10,循环就会重复执行。i 从 0 开始,每次循环递增 2
while 语句
while 语句是一种先测试循环语句,即先检测退出条件,再执行循环体内的代码。因此,while 循环体内的代码有可能不会执行。
let i = 0;
while (i < 10) {
i += 2;
}
在这个例子中,变量 i 从 0 开始,每次循环递增 2。只要 i 小于 10,循环就会继续。
for 语句
for 语句也是先测试语句,只不过增加了进入循环之前的初始化代码,以及循环执行后要执行的表达式。
let count = 10
for (let i = 0; i < count; i++) {
console.log(i)
}
// 无穷循环
for (;;) {
doSomething()
}
在循环开始前定义了变量 i 的初始值为 0。然后求值条件表达式,如果求值结果为 true(i < count),则执行循环体。因此循环体也可能不会被执行。如果循环体被执行了,则循环后表达式也会执行,以便递增变量 i。
for-in 语句
for-in 语句是一种精准的迭代语句,可以用来枚举对象的属性。如果 for-in 循环要迭代的变量是 null 或 undefined,则不执行循环体
for (const key in { a: 1, b: 2 }) {
console.log(key)
}
for-of 语句
for-of 语句是一种精准的迭代语句,可以用来枚举数组的值。如果 for-of 循环要迭代的变量是 null 或 undefined,则不执行循环体
for (const el of [2, 4, 6, 8]) {
console.log(el)
}
//分别打印出2、4、6、8
标签语句
标签语句用于给语句加标签
start: for (let i = 0; i < count; i++) {
console.log(i)
}
在这个例子中,start 是一个标签,可以在后面通过 break 或 continue 语句引用。标签语句的典型应用场景是嵌套循环。
break 和 continue 语句
break 和 continue 语句为执行循环代码提供了更严格的控制手段。其中,break 语句用于立即退出循环,强制执行循环后的下一条语句。而 continue 语句也用于立即退出循环,但会再次从循环顶部开始执行。
let num = 0
for (let i = 1; i < 10; i++) {
if (i % 5 === 0) {
break
}
num++
}
console.log(num) // 4
for 循环会将变量 i 由 1 递增到 10。而在循环体内,有一个 if 语句用于检查 i 能否被 5 整除(使用取模操作符)。如果是,则执行 break 语句,退出循环。变量 num 的初始值为 0,表示循环在退出前执行了多少次。当 break 语句执行后,下一行执行的代码是 console.log(num),显示 4。之所以循环执行了 4 次,是因为当 i 等于 5 时,break 语句会导致循环退出,该次循环不会执行递增 num 的代码。
let num = 0
outermost: for (let i = 0; i < 10; i++) {
for (let j = 0; j < 10; j++) {
if (i == 5 && j == 5) {
continue outermost
}
num++
}
}
console.log(num) // 95
这一次,continue 语句会强制循环继续执行,但不是继续执行内部循环,而是继续执行外部循环。当 i 和 j 都等于 5 时,会执行 continue,跳到外部循环继续执行,从而导致内部循环少执行 5 次,结果 num 等于 95。
with 语句
with 语句的作用是将代码的作用域设置到一个特定的对象中。
使用 with 语句的主要场景是针对一个对象反复操作,这时候将代码作用域设置为该对象能提供便利,如下面的例子所示:
let qs = location.search.substring(1)
let hostName = location.hostname
let url = location.href
with (location) {
let qs = search.substring(1)
let hostName = hostname
let url = href
}
这里,with 语句用于连接 location 对象。这意味着在这个语句内部,每个变量首先会被认为是一个局部变量。如果没有找到该局部变量,则会搜索 location 对象,看它是否有一个同名的属性。如果有,则该变量会被求值为 location 对象的属性。
switch 语句
switch 语句是一种多分支语句,用于基于不同的条件执行不同的代码。switch 语句是与 if 语句紧密相关的一种流控制语句。
switch (i) {
case 25:
console.log('25')
break
case 35:
console.log('35')
break
case 45:
console.log('45')
break
default:
console.log('Other')
}
函数
- 函数对任何语言来说都是核心组件,因为它们可以封装语句,然后在任何地方、任何时间执行。ECMAScript 中的函数使用 function 关键字声明,后跟一组参数,然后是函数体。
function sayHi(name, message) {
console.log('Hello ' + name + ', ' + message) // Hello Nicholas, how are you today?
}
sayHi('Nicholas', 'how are you today?')
函数不需要指定是否返回值。任何函数在任何时间都可以使用 return 语句来返回函数的值,用法是后跟要返回的值。只要碰到 return 语句,函数就会立即停止执行并退出。
return 语句也可以不带返回值。这时候,函数会立即停止执行并返回 undefined。这种用法最常用于提前终止函数执行,并不是为了返回值。
// 函数 sum()会将两个值相加并返回结果
function sum(num1, num2) {
return num1 + num2
}
const result = sum(5, 10)
console.log(result) // 15
// 只要碰到 return 语句,函数就会立即停止执行并退出。
function sum(num1, num2) {
return num1 + num2
console.log('Hello world') // 不会执行
}
// diff()函数用于计算两个数值的差
function diff(num1, num2) {
if (num1 < num2) {
return num2 - num1
} else {
return num1 - num2
}
}
// return 语句也可以不带返回值
function sayHi(name, message) {
return
console.log('Hello ' + name + ', ' + message) // 不会执行
}
第 4 章 变量、作用域与内存
原始值与引用值
原始值(primitive value)就是最简单的数据,引用值(reference value)则是由多个值构成的对象。有以下特点:
原始值大小固定,因此保存在栈内存上。
从一个变量到另一个变量复制原始值会创建该值的第二个副本。
引用值是对象,存储在堆内存上。
包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。
从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。
typeof 操作符可以确定值的原始类型,而 instanceof 操作符用于确保值的引用类型。
动态属性
对于引用值而言,可以随时添加、修改和删除其属性和方法。
let person = new Object()
person.name = 'Nicholas'
console.log(person.name) // "Nicholas"
这里,首先创建了一个对象,并把它保存在变量 person 中。然后,给这个对象添加了一个名为 name 的属性,并给这个属性赋值了一个字符串"Nicholas"。在此之后,就可以访问这个新属性,直到对象被销毁或属性被显式地删除。
原始类型的初始化可以只使用原始字面量形式。如果使用的是 new 关键字,则 JavaScript 会创建一个 Object 类型的实例,但其行为类似原始值。
let name1 = 'Nicholas'
let name2 = new String('Matt')
name1.age = 27
name2.age = 26
console.log(name1.age) // undefined
console.log(name2.age) // 26
console.log(typeof name1) // string
console.log(typeof name2) // object
复制值
在通过变量把一个原始值赋值 到另一个变量时,原始值会被复制到新变量的位置。
let num1 = 5;
let num2 = num1;
let obj1 = new Object();
let obj2 = obj1;
obj1.name = "Nicholas";
console.log(obj2.name); // "Nicholas"
传递参数
所有函数的参数都是按值传递的,这意味着函数外的值会被复制到函数内部的参数中,就像从一个变量复制到另一个变量一样。如果是原始值,那么就跟原始值变量的复制一样,如果是引用值,那么就跟引用值变量的复制一样。
function addTen(num) {
13
num += 10
return num
}
let count = 20
let result = addTen(count)
console.log(count) // 20,没有变化
console.log(result) // 30
函数 addTen()有一个参数 num,它其实是一个局部变量。在调用时,变量 count 作为参数传入。count 的值是 20,这个值被复制到参数 num 以便在 addTen()内部使用。在函数内部,参数 num 的值被加上了 10,但这不会影响函数外部的原始变量 count。参 数 num 和变量 count 互不干扰,它们只不过碰巧保存了一样的值。如果 num 是按引用传递的,那么 count 的值也会被修改为 30。这个事实在使用数值这样的原始值时是非常明显的。
function setName(obj) {
obj.name = 'Nicholas'
obj = new Object()
obj.name = 'Greg'
}
let person = new Object()
setName(person)
console.log(person.name) // "Nicholas"
确定类型
typeof 虽然对原始值很有用,但它对引用值的用处不大。我们通常不关心一个值是不是对象,而是想知道它是什么类型的对象。为了解决这个问题,ECMAScript 提供了 instanceof 操作符,
console.log(person instanceof Object) // 变量person 是 Object 吗?
console.log(colors instanceof Array) // 变量colors 是 Array 吗?
console.log(pattern instanceof RegExp) // 变量pattern 是 RegExp 吗?
所有引用值都是 Object 的实例,因此通过 instanceof 操作符检测任何引用值和 Object 构造函数都会返回 true。类似地,如果用 instanceof 检测原始值,则始终会返回 false,因为原始值不是对象。
执行上下文与作用域
任何变量(不管包含的是原始值还是引用值)都存在于某个执行上下文中(也称为作用域)。这个上下文(作用域)决定了变量的生命周期,以及它们可以访问代码的哪些部分。执行上下文可以总结如下。
- 执行上下文分全局上下文、函数上下文和块级上下文。
- 代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。
- 函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃至全局上下文中的变量。
- 全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据。
- 变量的执行上下文用于确定什么时候释放内存。
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。虽然无法通过代码访问变量对象,但后台处理数据会用到它。
在浏览器中,全局上下文就是我们常说的 window 对象。
每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript 程序的执行流就是通过这个上下文栈进行控制的。
上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。如果上下文是函数,则其活动对象(activation object)用作变量对象。活动对象最初只有 一个定义变量:arguments。(全局上下文中没有这个变量。)作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终是作用域链的最后一个变量对象。
var color = 'blue'
function changeColor() {
let anotherColor = 'red'
function swapColors() {
let tempColor = anotherColor
anotherColor = color
color = tempColor
// 这里可以访问 color、anotherColor 和 tempColor
}
// 这里可以访问 color 和 anotherColor,但访问不到 tempColor
swapColors()
}
// 这里只能访问 color
changeColor()
以上代码涉及 3 个上下文:全局上下文、changeColor()的局部上下文和 swapColors()的局部上下文。 全局上下文中有一个变量 color 和一个函数 changeColor()。changeColor()的局部上下中 有一个变量 anotherColor 和一个函数 swapColors(),但在这里可以访问全局上下文中的变量 color。swapColors()的局部上下文中有一个变量 tempColor,只能在这个上下文中访问到。全局上下文和 changeColor()的局部上下文都无法访问到 tempColor。而在 swapColors()中则可以访问另外两个上下文中的变量,因为它们都是父上下文。图片展示了前面这个例子的作用域链。
作用域链增强
执行上下文主要有全局上下文和函数上下文两种,但有其他方式来增强作用域链。某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。
- try-catch 语句的 catch 块
- with 语句
function buildUrl() {
let qs = '?debug=true'
with (location) {
let url = href + qs
}
return url
}
这里,with 语句将 location 对象作为上下文,因此 location 会被添加到作用域链前端。buildUrl()函数中定义了一个变量 qs。当 with 语句中的代码引用变量 href 时,实际上引用的是 location.href,也就是自己变量对象的属性。在引用 qs 时,引用的则是定义在 buildUrl()中的那个变量,它定义在函数上下文的变量对象上。而在 with 语句中使用 var 声明的变量 url 会成为函数上下文的一部分,可以作为函数的值被返回;但像这里使用 let 声明的变量 url,因为被限制在块级作用域(稍后介绍),所以在 with 块之外没有定义。
变量声明
- 使用 var 的函数作用域声明
- 在使用 var 声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。在 with 语句中,最接近的上下文也是函数上下文。如果变量未经声明就被初始化了,那么它就会自动被添加到全局上下文。
- var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这个现象叫作“提升”(hoisting)。提升让同一作用域中的代码不必考虑变量是否已经声明就可以直接使用。可是在实践中,提升也会导致合法却奇怪的现象,即在变量声明之前使用变量。
console.log(name); // undefined
var name = 'Jake';
function() {
console.log(name); // undefined
var name = 'Jake';
}
function add(num1, num2) {
var sum = num1 + num2
// sum = num1 + num2 // 省略var关键字,就可以被添加到全局上下文
return sum
}
let result = add(10, 20) // 30
console.log(sum) // 报错:sum 在这里不是有效变量
这里,函数 add()定义了一个局部变量 sum,保存加法操作的结果。这个值作为函数的值被返回,但变量 sum 在函数外部是访问不到的。如果省略上面例子中的关键字 var,那么 sum 在 add()被调用之后就变成可以访问的了。
- 使用 let 的块级作用域声明
- let 声明的范围是块作用域,而不是函数作用域。块作用域由花括号封闭。
- let 与 var 的另一个不同之处是在同一作用域内不能声明两次,重复的 var 声明会被忽略,而重复的 let 声明会抛出 SyntaxError。
- let 的行为非常适合在循环中声明迭代变量。
if (true) {
let a
}
console.log(a) // ReferenceError: a 没有定义
while (true) {
let b
}
console.log(b) // ReferenceError: b 没有定义
function foo() {
let c
}
console.log(c) // ReferenceError: c 没有定义
// 这没什么可奇怪的
// var 声明也会导致报错
var a
var a
// 不会报错
{
let b
let b
}
// SyntaxError: 标识符 b 已经声明过了
for (var i = 0; i < 10; ++i) {}
console.log(i) // 10
for (let j = 0; j < 10; ++j) {}
console.log(j) // ReferenceError: j 没有定义
- 使用 const 的常量声明
- 使用 const 声明的变量必须同时初始化为某个值。一经声明,在其生命周期的任何时候都不能再重新赋予新值。
- const 声明只应用到顶级原语或者对象。赋值为对象的 const 变量不能再被重新赋值为其他引用值,但对象的键则不受限制。
const a; // SyntaxError: 常量声明时没有初始化
const b = 3;
console.log(b); // 3
b = 4; // TypeError: 给常量赋值
if (true) {
const a = 0;
}
console.log(a); // ReferenceError: a 没有定义
const o1 = {};
o1 = {}; // TypeError: 给常量赋值
const o2 = {};
o2.name = 'Jake';
console.log(o2.name); // 'Jake'
// 如果想让整个对象都不能被修改,可以使用 Object.freeze()
const o3 = Object.freeze({});
o3.name = 'Jake';
console.log(o3.name); // undefined
如果开发流程并不会因此而受很大影响,就应该尽可能地多使用 const 声明,除非确实需要一个将来会重新赋值的变量。这样可以从根本上保证提前发现重新赋值导致的 bug。
- 标识符查找
- 标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直到找到标识符为止(如果找不到标识符,通常会导致错误发生)。
- 标识符解析过程只会查找标识符的赋值操作。它不会查找对标识符的引用。
var color = 'blue'
function getColor() {
let color = 'red'
return color
}
console.log(getColor()) // 'red'
对这个搜索过程而言,引用局部变量会让搜索自动停止,而不继续搜索下一级变量对象。也就是说,如果局部上下文中有一个同名的标识符,那就不能在该上下文中引用父上下文中的同名标识符.
垃圾回收
JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。通过自动内存管理实现内存分配和闲置资源回收。
基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。
- 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。
- 主流的垃圾回收算法是标记清理,即先给当前不使用的值加上标记,再回来回收它们的内存。
- 引用计数是另一种垃圾回收策略,需要记录值被引用了多少次。JavaScript 引擎不再使用这种算法,但某些旧版本的 IE 仍然会受这种算法的影响,原因是 JavaScript 会访问非原生 JavaScript 对象(如 DOM 元素)。
- 引用计数在代码中存在循环引用时会出现问题。
- 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对象、全局对象的属性和循环引用都应该在不需要时解除引用。
标记清理
JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep)。变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。
给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。标记过程的实现并不重要,关键是策略。
垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。
引用计数
另一种没那么常用的垃圾回收策略是引用计数(reference counting)。其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为 1。如果同一个值又被赋给另一个变量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存。
很快就遇到了严重的问题:循环引用。所谓循环引,就是对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A。
为避免类似的循环引用问题,应该在确保不使用的情况下切断原生 JavaScript 对象与 DOM 元素之间的连接。把变量设置为 null 实际上会切断变量与其之前引用值之间的关系。当下次垃圾回收程序运行时,这些值就会被删除,内存也会被回收。
性能
垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。开发者不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。
现代垃圾回收程序会基于对 JavaScript 运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的。比如,根据 V8 团队 2016 年的一篇博文的说法:“在一次完整的垃圾回收之后,V8 的堆增长策略会根据活跃对象的数量外加一些余量来确定何时再次垃圾回收。”
IE7 发布后,JavaScript 引擎的垃圾回收程序被调优为动态改变分配变量、字面量或数组槽位等会触发垃圾回收的阈值。IE7 的起始阈值都与 IE6 的相同。如果垃圾回收程序回收的内存不到已分配的 15%,这些变量、字面量或数组槽位的阈值就会翻倍。如果有一次回收的内存达到已分配的 85%,则阈值重置为默认值。这么一个简单的修改,极大地提升了重度依赖 JavaScript 的网页在浏览器中的性能
内存管理
在使用垃圾回收的编程环境中,开发者通常无须关心内存管理。不过,JavaScript 运行在一个内存管理与垃圾回收都很特殊的环境。分配给浏览器的内存通常比分配给桌面软件的要少很多,分配给移动浏览器的就更少了。这更多出于安全考虑而不是别的,就是为了避免运行大量 JavaScript 的网页耗尽系统内存而导致操作系统崩溃。这个内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量。
将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为 null,从而释放其引用。这也可以叫作解除引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用。
function createPerson(name) {
let localPerson = new Object()
localPerson.name = name
return localPerson
}
let globalPerson = createPerson('Nicholas')
console.log('1', globalPerson) // { name: 'Nicholas' }
// 解除 globalPerson 对值的引用
globalPerson = null
console.log('2:', globalPerson) // 2: null
- 通过 const 和 let 声明提升性能
- ES6 增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为 const 和 let 都以块(而非函数)为作用域,所以相比于使用 var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。
- 隐藏类和删除操作
- 根据 JavaScript 所在的运行环境,有时候需要根据浏览器使用的 JavaScript 引擎来采取不同的性能优化策略。截至 2017 年,Chrome 是最流行的浏览器,使用 V8 JavaScript 引擎。V8 在将解释后的 JavaScript 代码编译为实际的机器码时会利用“隐藏类”。如果你的代码非常注重性能,那么这一点可能对你很重要.
- 最佳实践是把不想要的属性设置为 null。这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果。
function Article() {
this.title = 'Inauguration Ceremony Features Kazoo Band'
this.author = 'Jake'
}
let a1 = new Article()
let a2 = new Article()
a1.author = null
// a1: Article {
// title: 'Inauguration Ceremony Features Kazoo Band',
// author: null
// }
// a2: Article {
// title: 'Inauguration Ceremony Features Kazoo Band',
// author: 'Jake'
// }
内存泄露
- 内存泄露指的是不再用到的内存,没有及时释放。内存泄露在长时间运行的网站中很常见,因为内存消耗很大。如果内存泄露积累多了,就可能导致响应变得更慢,或者程序崩溃。
- 意外声明全局变量是最常见但也最容易修复的内存泄漏问题。
- 定时器也可能会悄悄地导致内存泄漏。
- 使用 JavaScript 闭包很容易在不知不觉间造成内存泄漏
// 下面的代码没有使用任何关键字声明
function setName() {
name = 'Jake'
}
此时,解释器会把变量 name 当作 window 的属性来创建(相当于 window.name = 'Jake')。可想而知,在 window 对象上创建的属性,只要 window 本身不被清理就不会消失。这个问题很容易解决,只要在变量声明前头加上 var、let 或 const 关键字即可,这样变量就会在函数执行完毕后离开作用域。
定时器也可能会悄悄地导致内存泄漏。下面的代码中,定时器的回调通过闭包引用了外部变量:
let name = 'Jake'
setInterval(() => {
console.log(name)
}, 100)
使用 JavaScript 闭包很容易在不知不觉间造成内存泄漏。
let outer = function () {
let name = 'Jake'
return function () {
console.log('🤩 y2k:', y2k)
return name
}
}
调用 outer()会导致分配给 name 的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理 name,因为闭包一直在引用着它。假如 name 的内容很大(不止是一个小字符串),那可能就是个大问题了。
- 静态分配与对象池
为了提升 JavaScript 性能,最后要考虑的一点往往就是压榨浏览器了。此时,一个关键问题就是如何减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。
一个策略是使用对象池。在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。
// vectorPool 是已有的对象池
let v1 = vectorPool.allocate();
let v2 = vectorPool.allocate();
let v3 = vectorPool.allocate();
v1.x = 10;
v1.y = 5;
v2.x = -3;
v2.y = -6;
addVector(v1, v2, v3);
console.log([v3.x, v3.y]); // [7, -1]
vectorPool.free(v1);
vectorPool.free(v2);
vectorPool.free(v3);
// 如果对象有属性引用了其他对象
// 则这里也需要把这些属性设置为 null
v1 = null;
v2 = null;
v3 = null;
第 5 章 基本引用类型
引用值(或者对象)是某个特定引用类型的实例。引用类型是把数据和功能组织到一起的结构。
Date
Date 类型将日期保存为自协调世界时(UTC,Universal Time Coordinated)时间 1970 年 1 月 1 日午夜(零时)至今所经过的毫秒数。
- Date.parse():接收一个表示日期的字符串参数,尝试将这个字符串转换为表示该日期的毫秒数。
- Date.UTC():同样接收年月日等日期信息的参数,返回相应日期的毫秒数。
- Date.now():返回表示调用这个方法时的日期和时间的毫秒数。
let now = new Date()
console.log(now) // Wed Jul 18 2018 11:39:43 GMT+0800 (中国标准时间)
let someDate = new Date(Date.parse('May 23, 2019'))
console.log(someDate) // 2019-05-22T16:00:00.000Z
// 第一个日期是 2000 年 1 月 1 日零点(GMT),2000 代表年,0 代表月(1 月)
let y2k = new Date(Date.UTC(2000, 0))
console.log(y2k) // 2000-01-01T00:00:00.000Z
// 本地时间 2000 年 1 月 1 日零点
let y2k = new Date(2000, 0)
// 本地时间 2005 年 5 月 5 日下午 5 点 55 分 55 秒
let allFives = new Date(2005, 4, 5, 17, 55, 55)
// 当前时间的毫秒数
let start = Date.now()
console.log(start) // 1743234838473
日期格式化方法
Date 类型有几个专门用于格式化日期的方法,它们都会返回字符串:
- toDateString():显示日期中的周几、月、日、年(格式特定于实现);
- toTimeString():显示日期中的时、分、秒和时区(格式特定于实现);
- toLocaleDateString():显示日期中的周几、月、日、年(格式特定于实现和地区);
- toLocaleTimeString():显示日期中的时、分、秒(格式特定于实现和地区);
- toUTCString():显示完整的 UTC 日期(格式特定于实现)。
日期/时间组件方法
方法 | 说明 |
---|---|
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 月) |
getUTCMonth() | 返回 UTC 日期的月(0 表示 1 月,11 表示 12 月) |
setMonth(month) | 设置日期的月(month 为大于 0 的数值,大于 11 加年) |
setUTCMonth(month) | 设置 UTC 日期的月(month 为大于 0 的数值,大于 11 加年) |
getDate() | 返回日期中的日(1~31) |
getUTCDate() | 返回 UTC 日期中的日(1~31) |
setDate(date) | 设置日期中的日(如果 date 大于该月天数,则加月) |
setUTCDate(date) | 设置 UTC 日期中的日(如果 date 大于该月天数,则加月) |
getDay() | 返回日期中表示周几的数值(0 表示周日,6 表示周六) |
getUTCDay() | 返回 UTC 日期中表示周几的数值(0 表示周日,6 表示周六) |
getHours() | 返回日期中的时(0~23) |
getUTCHours() | 返回 UTC 日期中的时(0~23) |
setHours(hours) | 设置日期中的时(如果 hours 大于 23,则加日) |
setUTCHours(hours) | 设置 UTC 日期中的时(如果 hours 大于 23,则加日) |
getMinutes() | 返回日期中的分(0~59) |
getUTCMinutes() | 返回 UTC 日期中的分(0~59) |
setMinutes(minutes) | 设置日期中的分(如果 minutes 大于 59,则加时) |
setUTCMinutes(minutes) | 设置 UTC 日期中的分(如果 minutes 大于 59,则加时) |
getSeconds() | 返回日期中的秒(0~59) |
getUTCSeconds() | 返回 UTC 日期中的秒(0~59) |
setSeconds(seconds) | 设置日期中的秒(如果 seconds 大于 59,则加分) |
setUTCSeconds(seconds) | 设置 UTC 日期中的秒(如果 seconds 大于 59,则加分) |
getMilliseconds() | 返回日期中的毫秒 |
getUTCMilliseconds() | 返回 UTC 日期中的毫秒 |
setMilliseconds(milliseconds) | 设置日期中的毫秒 |
setUTCMilliseconds(milliseconds) | 设置 UTC 日期中的毫秒 |
getTimezoneOffset() | 返回以分钟计的 UTC 与本地时区的偏移量(如美国 EST 即"东部标准时间"返回 300,进入夏令时的地区可能有所差异) |
RegExp
ECMAScript 通过 RegExp 类型支持正则表达式。正则表达式使用类似 Perl 的简洁语法来创建let expression = /pattern/flags;
这个正则表达式的 pattern(模式)可以是任何简单或复杂的正则表达式,包括字符类、限定符、 分组、向前查找和反向引用。每个正则表达式可以带零个或多个 flags(标记),用于控制正则表达式的行为。下面给出了表示匹配模式的标记。
- g:全局模式,表示查找字符串的全部内容,而不是找到第一个匹配的内容就结束。
- i:不区分大小写,表示在查找匹配时忽略 pattern 和字符串的大小写。
- m:多行模式,表示查找到一行文本末尾时会继续查找。
- y:粘附模式,表示只查找从 lastIndex 开始及之后的字符串。
- u:Unicode 模式,启用 Unicode 匹配。
- s:dotAll 模式,表示元字符.匹配任何字符(包括\n 或\r)。
// 匹配字符串中的所有"at"
let pattern1 = /at/g
// 匹配第一个"bat"或"cat",忽略大小写
let pattern2 = /[bc]at/i
// 匹配所有以"at"结尾的三字符组合,忽略大小写
let pattern3 = /.at/gi
// 匹配第一个"bat"或"cat",忽略大小写
let pattern1 = /[bc]at/i
// 匹配第一个"[bc]at",忽略大小写
let pattern2 = /\[bc\]at/i
// 匹配所有以"at"结尾的三字符组合,忽略大小写
let pattern3 = /.at/gi
// 匹配所有".at",忽略大小写
let pattern4 = /\.at/gi
// 匹配第一个"bat"或"cat",忽略大小写
let pattern1 = /[bc]at/i
// 跟 pattern1 一样,只不过是用构造函数创建的
let pattern2 = new RegExp('[bc]at', 'i')
RegExp 实例属性
- global:布尔值,表示是否设置了 g 标记。
- ignoreCase:布尔值,表示是否设置了 i 标记。
- unicode:布尔值,表示是否设置了 u 标记。
- sticky:布尔值,表示是否设置了 y 标记。
- lastIndex:整数,表示在源字符串中下一次搜索的开始位置,始终从 0 开始。
- multiline:布尔值,表示是否设置了 m 标记。
- dotAll:布尔值,表示是否设置了 s 标记。 4
- source:正则表达式的字面量字符串(不是传给构造函数的模式字符串),没有开头和结尾的斜杠。
- flags:正则表达式的标记字符串。始终以字面量而非传入构造函数的字符串模式形式返回(没有前后斜杠)。
let pattern1 = /\[bc\]at/i
console.log(pattern1.global) // false
console.log(pattern1.ignoreCase) // true
console.log(pattern1.multiline) // false
console.log(pattern1.lastIndex) // 0
console.log(pattern1.source) // "\[bc\]at"
console.log(pattern1.flags) // "i"
let pattern2 = new RegExp('\\[bc\\]at', 'i')
console.log(pattern2.global) // false 9
console.log(pattern2.ignoreCase) // true
console.log(pattern2.multiline) // false
console.log(pattern2.lastIndex) // 0
console.log(pattern2.source) // "\[bc\]at"
console.log(pattern2.flags) // "i"
RegExp 实例方法
RegExp 实例的主要方法是 exec(),主要用于配合捕获组使用。这个方法只接收一个参数,即要应用模式的字符串。如果找到了匹配项,则返回包含第一个匹配信息的数组;如果没找到匹配项,则返回 null。返回的数组虽然是 Array 的实例,但包含两个额外的属性:index 和 input。index 是字符串中匹配模式的起始位置,input 是要查找的字符串。这个数组的第一个元素是匹配整个模式的字符串,其他元素是与表达式中的捕获组匹配的字符串。如果模式中没有捕获组,则数组只包含一个元素。
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()只返回第一个匹配项("cat")。lastIndex 在非全局模式下始终不变。
正则表达式的另一个方法是 test(),接收一个字符串参数。如果输入的文本与模式匹配,则参数返回 true,否则返回 false。这个方法适用于只想测试模式是否匹配,而不需要实际匹配内容的情况。test()经常用在 if 语句中:
let text = '000-00-0000'
let pattern = /\d{3}-\d{2}-\d{4}/
if (pattern.test(text)) {
console.log('The pattern was matched.')
}
在这个例子中,正则表达式用于测试特定的数值序列。如果输入的文本与模式匹配,则显示匹配成功的消息。这个用法常用于验证用户输入,此时我们只在乎输入是否有效,不关心为什么无效。
RegExp 构造函数属性
RegExp 构造函数本身也有几个属性。(在其他语言中,这种属性被称为静态属性。)这些属性适用于作用域中的所有正则表达式,而且会根据最后执行的正则表达式操作而变化。这些属性还有一个特点,就是可以通过两种不同的方式访问它们。换句话说,每个属性都有一个全名和一个简写。
全名 | 简写 | 说明 |
---|---|---|
input | $_ | 最后搜索的字符串(非标准特性) |
lastMatch | $& | 最后匹配的文本 |
lastParen | $+ | 最后匹配的捕获组(非标准特性) |
leftContext | $` | input 字符串中出现在 lastMatch 前面的文本 |
rightContext | $' | input 字符串中出现在 lastMatch 后面的文本 |
模式局限
虽然 ECMAScript 对正则表达式的支持有了长足的进步,但仍然缺少 Perl 语言中的一些高级特性。下列特性目前还没有得到 ECMAScript 的支持。
- \A 和\Z 锚(分别匹配字符串的开始和末尾)
- 联合及交叉类
- 原子组
- x(忽略空格)匹配模式
- 条件式匹配
- 正则表达式注释
原始包装类型
为了方便操作原始值,具有与各自原始类型对应的特殊行为。每当用到某个原始值的方法或属性时,后台都会创建一个相应原始包装类型的对象,从而暴露出操作原始值的各种方法。
引用类型与原始值包装类型的主要区别在于对象的生命周期。在通过 new 实例化引用类型后,得到的实例会在离开作用域时被销毁,而自动创建的原始值包装对象则只存在于访问它的那行代码执行期间。这意味着不能在运行时给原始值添加属性和方法。
let s1 = 'some text'
s1.color = 'red'
console.log(s1.color) // undefined
这里的第二行代码尝试给字符串 s1 添加了一个 color 属性。可是,第三行代码访问 color 属性时,它却不见了。原因就是第二行代码运行时会临时创建一个 String 对象,而当第三行代码执行时,这个对象已经被销毁了。实际上,第三行代码在这里创建了自己的 String 对象,但这个对象没有 color 属性。
Boolean
Boolean 是对应布尔值的引用类型。要创建一个 Boolean 对象,就使用 Boolean 构造函数并传入 true 或 false.
Boolean 的实例会重写 valueOf()方法,返回一个原始值 true 或 false。toString()方法被调用时也会被覆盖,返回字符串"true"或"false"。
let falseObject = new Boolean(false)
let result = falseObject && true
console.log(result) // true
let falseValue = false
result = falseValue && true
console.log(result) // false
在这段代码中,我们创建一个值为 false 的 Boolean 对象。然后,在一个布尔表达式中通过&&操作将这个对象与一个原始值 true 组合起来。在布尔算术中,false && true 等于 false。可是,这个表达式是对 falseObject 对象而不是对它表示的值(false)求值。前面刚刚说过,所有对象在布尔表达式中都会自动转换为 true,因此 falseObject 在这个表达式里实际上表示一个 true 值。那么 true && true 当然是 true。
除此之外,原始值和引用值(Boolean 对象)还有几个区别。首先,typeof 操作符对原始值返回"boolean",但对引用值返回"object"。同样,Boolean 对象是 Boolean 类型的实例,在使用 instaceof 操作符时返回 true,但对原始值则返回 false,理解原始布尔值和 Boolean 对象之间的区别非常重要,强烈建议永远不要使用后者。
console.log(typeof falseObject) // object
console.log(typeof falseValue) // boolean
console.log(falseObject instanceof Boolean) // true
console.log(falseValue instanceof Boolean) // false
Number
Number 是对应数值的引用类型。要创建一个 Number 对象,就使用 Number 构造函数并传入一个数值. Number 类型重写了 valueOf()、toLocaleString()和 toString()方法。valueOf()方法返回 Number 对象表示的原始数值,另外两个方法返回数值字符串。toString()方法可选地接收一个表示基数的参数,并返回相应基数形式的数值字符串。
- toFixed():方法返回包含指定小数点位数的数值字符串。
- toExponential():返回以科学记数法(也称为指数记数法)表示的数值字符串。本质上,toPrecision()方法会根据数值和精度来决定调用 toFixed()还是 toExponential()。
- isInteger()方法:用于判断某个数值是否为整数。
let num = 10
console.log(num.toFixed(2)) // "10.00"
let num = 99
console.log(num.toPrecision(1)) // "1e+2"
console.log(num.toPrecision(2)) // "99"
console.log(num.toPrecision(3)) // "99.0"
console.log(Number.isInteger(1)) // true
console.log(Number.isInteger(1.0)) // true
console.log(Number.isInteger(1.01)) // false
String
String 是对应字符串的引用类型。String 对象的方法可以在所有字符串原始值上调用。3 个继承的方法 valueOf()、toLocaleString()和 toString()都返回对象的原始字符串值。
- JS 字符
- charAt()方法:返回给定索引位置的字符,由传给方法的整数参数指定。
- charCodeAt()方法:可以查看指定码元的字符编码。这个方法返回指定索引位置的码元值,索引以整数指定。
- fromCharCode()方法:用于根据给定的 UTF-16 码元创建字符串中的字符。
- codePointAt():接收 16 位码元的索引并返回该索引位置上的码点(code point)。码点是 Unicode 中一个字符的完整标识。
let stringValue = 'hello world'
console.log(stringValue.length) // "11"
let message = 'abcde'
console.log(message.charAt(2)) // "c"
let message = 'abcde'
// Unicode "Latin small letter C"的编码是 U+0063
console.log(message.charCodeAt(2)) // 99
// 十进制 99 等于十六进制 63
console.log(99 === 0x63) // true
// 0x0061 === 97
// 0x0062 === 98
// 0x0063 === 99
// 0x0064 === 100
// 0x0065 === 101
console.log(String.fromCharCode(97, 98, 99, 100, 101)) // "abcde"
let message = 'ab☺de'
console.log(message.codePointAt(1)) // 98
console.log(message.codePointAt(2)) // 128522
console.log(message.codePointAt(3)) // 56842
console.log(message.codePointAt(4)) // 100
- normalize()方法
某些 Unicode 字符可以有多种编码方式。有的字符既可以通过一个 BMP 字符表示,也可以通过一个代理对表示.
Unicode 提供了 4 种规范化形式,可以将类似上面的字符规范化为一致的格式,无论底层字符的代码是什么。这 4 种规范化形式是:
- NFD(Normalization Form D)
- NFC(Normalization Form C)
- NFKD(Normalization Form KD)
- NFKC(Normalization Form KC)
let a1 = String.fromCharCode(0x00c5),
a2 = String.fromCharCode(0x212b),
a3 = String.fromCharCode(0x0041, 0x030a)
console.log(a1.normalize('NFD') === a2.normalize('NFD')) // true
console.log(a2.normalize('NFKC') === a3.normalize('NFKC')) // true
console.log(a1.normalize('NFC') === a3.normalize('NFC')) // true
- 字符串操作方法
- concat():方法用于将一个或多个字符串拼接起来,返回拼接得到的新字符串。但更常用的方式是使用加号操作符(+)。而且多数情况下,对于拼接多个字符串来说,使用加号更方便。
- slice()方法:它的第一个参数指定子字符串的开始位置,第二个参数表示子字符串到哪里结束。如果省略第二个参数,则子字符串一直到原字符串末尾。
- substring()方法:它的第一个参数表示子字符串的开始位置,第二个位置表示子字符串到哪里结束。与 slice()方法不同的是,substring()方法不会接受负的参数。
- substr()方法:它的第一个参数表示子字符串的开始位置,第二个参数表示返回的子字符串包含的字符个数。
let stringValue = 'hello '
let result = stringValue.concat('world')
console.log(result) // "hello world"
console.log(stringValue) // "hello"
let stringValue = 'hello world'
console.log(stringValue.slice(3)) // "lo world"
console.log(stringValue.substring(3)) // "lo world"
console.log(stringValue.substr(3)) // "lo world"
console.log(stringValue.slice(3, 7)) // "lo w"
console.log(stringValue.substring(3, 7)) // "lo w"
console.log(stringValue.substr(3, 7)) // "lo worl"
- 字符串位置方法
- indexOf():从字符串开头开始查找子字符串。第二个参数指定的位置开始向字符串末尾搜索。
- lastIndexOf(): 从字符串末尾开始查找子字符串。从这个参数指定的位置开始向字符串开头搜索,忽略该位置之后直到字符串末尾的字符。
let stringValue = "hello world";
console.log(stringValue.indexOf("o")); // 4
console.log(stringValue.lastIndexOf("o")); // 7
console.log(stringValue.indexOf('o', 6)) // 7
console.log(stringValue.lastIndexOf('o', 6)) // 4. 因为它从位置 6 开始反向搜索至字符串开头,因此找到了"hello"中的"o"。
- 字符串包含方法
用于判断字符串中是否包含另一个字符串的方法
- startsWith(): 检查开始于索引 0 的匹配项,接收可选的第二个参数,表示开始搜索的位置
- endsWith(): 检查开始于索引(string.length - substring.length)的匹配项
- includes(): 检查整个字符串,接收可选的第二个参数,表示开始搜索的位置
let message = 'foobarbaz'
console.log(message.startsWith('foo')) // true
console.log(message.startsWith('bar')) // false
console.log(message.endsWith('baz')) // true
console.log(message.endsWith('bar')) // false
console.log(message.includes('bar')) // true
console.log(message.includes('qux')) // false
console.log(message.startsWith('foo', 1)) // false
console.log(message.includes('bar', 4)) // false
- trim()方法
这个方法会创建字符串的一个副本,删除前、后所有空格符,再返回结果。trimLeft()和 trimRight()方法分别用于从字符串开始和末尾清理空格符。
let stringValue = ' hello world '
let trimmedStringValue = stringValue.trim()
console.log(stringValue) // " hello world "
console.log(trimmedStringValue) // "hello world"
- repeat()方法
这个方法接收一个整数参数,表示要将字符串复制多少次,然后返回拼接所有副本后的结果。
let stringValue = 'na '
console.log(stringValue.repeat(16) + 'batman')
// na na na na na na na na na na na na na na na na batman
- padStart()和 padEnd()方法
padStart()和 padEnd()方法会复制字符串,如果小于指定长度,则在相应一边填充字符,直至满足长度条件。这两个方法的第一个参数是长度,第二个参数是可选的填充字符串,默认为空格.
可选的第二个参数并不限于一个字符。如果提供了多个字符的字符串,则会将其拼接并截断以匹配指定长度。此外,如果长度小于或等于字符串长度,则会返回原始字符串。
let stringValue = 'foo'
console.log(stringValue.padStart(6)) // " foo"
console.log(stringValue.padStart(9, '-')) // "---foo"
console.log(stringValue.padEnd(6)) // "foo "
console.log(stringValue.padEnd(9, '.')) // "foo......"
console.log(stringValue.padStart(8, 'bar')) // "barbafoo"
console.log(stringValue.padStart(2)) // "foo"
- 字符串迭代与解构
字符串的原型上暴露了一个@@iterator 方法,表示可以迭代字符串的每个字符。
有了这个迭代器之后,字符串就可以通过解构操作符来解构了。比如,可以更方便地把字符串分割为字符数组
// 在 for-of 循环中可以通过这个迭代器按序访问每个字符
for (const c of 'abcde') {
console.log(c) // a b c d e
}
let message = 'abcde'
console.log([...message]) // [ 'a', 'b', 'c', 'd', 'e' ]
- 字符串大小写转换
- toLowerCase()
- toUpperCase()
- toLocaleLowerCase(): 基于特定地区实现
- toLocaleUpperCase(): 基于特定地区实现
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"
- 字符串模式匹配方法
- match()方法: 接收一个参数,可以是一个正则表达式字符串,也可以是一个 RegExp 对象.
let text = "cat, bat, sat, fat";
let pattern = /.at/;
// 等价于 pattern.exec(text)
let matches = text.match(pattern);
console.log(matches.index); // 0
console.log(matches[0]); // "cat"
console.log(pattern.lastIndex); // 0
- search()方法: 接收一个参数,可以是一个正则表达式字符串,也可以是一个 RegExp 对象.\
let text = "cat, bat, sat, fat";
let pos = text.search(/at/);
console.log(pos); // 1
这里,search(/at/)返回 1,即"at"的第一个字符在字符串中的位置。
- replace()方法: 接收两个参数,第一个参数可以是一个 RegExp 对象或者一个字符串(这个字符串不会被转换成正则表达式),第二个参数可以是一个字符串或者一个函数。
let text = 'cat, bat, sat, fat'
let result = text.replace('at', 'ond') // cond, bat, sat, fat
console.log(result)
result = text.replace(/at/g, 'ond')
console.log(result) // cond, bond, sond, fond
// 这里,每个以"at"结尾的词都会被替换成"word"后跟一对小括号,其中包含捕获组匹配的内容$1
let text = 'cat, bat, sat, fat'
result = text.replace(/(.at)/g, 'word ($1)')
console.log(result) // word (cat), word (bat), word (sat), word (fat)
// 函数 htmlEscape()用于将一段 HTML 中的 4 个字符替换成对应的实体
function htmlEscape(text) {
return text.replace(/[<>"&]/g, function (match, pos, originalText) {
switch (match) {
case '<':
return '<'
case '>':
return '>'
case '&':
return '&'
case '"':
return '"'
}
})
}
console.log(htmlEscape('<p class="greeting">Hello world!</p>'))
// "<p class="greeting">Hello world!</p>"
- split()方法: 接收一个参数,即分隔符,可以是一个字符串或者一个 RegExp 对象。
let colorText = 'red,blue,green,yellow'
let colors1 = colorText.split(',')
- localeCompare()方法
- 如果按照字母表顺序,字符串应该排在字符串参数前头,则返回负值。(通常是-1,具体还要看与实际值相关的实现。)
- 如果字符串与字符串参数相等,则返回 0。
- 如果按照字母表顺序,字符串应该排在字符串参数后头,则返回正值。(通常是 1,具体还要看与实际值相关的实现。)
// 所有实现中都能正确判断字符串的顺序了
function determineOrder(value) {
let result = stringValue.localeCompare(value)
if (result < 0) {
console.log(`The string 'yellow' comes before the string '${value}'.`)
} else if (result > 0) {
console.log(`The string 'yellow' comes after the string '${value}'.`)
} else {
console.log(`The string 'yellow' is equal to the string '${value}'.`)
}
}
determineOrder('brick') // 1
determineOrder('yellow') // 0
determineOrder('zoo') // -1
单例内置对象
“任何由 ECMAScript 实现提供、与宿主环境无关,并在 ECMAScript 程序开始执行时就存在的对象”。
Global
Global 对象是 ECMAScript 中最特别的对象,因为代码不会显式地访问它。它所针对的是不属于任何对象的属性和方法。
- URL 编码方法
用于编码统一资源标识符(URI),以便传给浏览器。有效的 URI 不能包含某些字符,比如空格。使用 URI 编码方法来编码 URI 可以让浏览器能够理解它们,同时又以特殊的 UTF-8 编码替换掉所有无效字符。
- encodeURI(): 不会编码属于 URL 组件的特殊字符,比如冒号、斜杠、问号、井号.
- encodeURIComponent(): 会编码它发现的所有非标准字符
- decodeURI(): 不会解码属于 URL 组件的特殊字符,比如冒号、斜杠、问号、井号.
- decodeURIComponent(): 会解码它发现的所有非标准字符
let uri = 'http://www.wrox.com/illegal value.js#start'
console.log(encodeURI(uri))
// "http://www.wrox.com/illegal%20value.js#start"
console.log(encodeURIComponent(uri))
// "http%3A%2F%2Fwww.wrox.com%2Fillegal%20value.js%23start"
使用 encodeURIComponent()应该比使用 encodeURI()的频率更高,这是因为编码查询字符串参数比编码基准 URI 的次数更多。
let uri = 'http%3A%2F%2Fwww.wrox.com%2Fillegal%20value.js%23start'
console.log(decodeURI(uri))
// http%3A%2F%2Fwww.wrox.com%2Fillegal value.js%23start
console.log(decodeURIComponent(uri))
// http:// www.wrox.com/illegal value.js#start
- eval()方法
这个方法就是一个完整的 ECMAScript 解释器,它接收一个参数,即一个要执行的 ECMAScript(JavaScript)字符串。
通过 eval()定义的任何变量和函数都不会被提升,这是因为在解析代码的时候,它们是被包含在一个字符串中的。它们只是在 eval()执行的时候才会被创建。
let msg = 'hello world'
eval('console.log(msg)') // "hello world"
eval("let msg = 'hello world';")
console.log(msg) // Reference Error: msg is not defined
- Global 对象属性
属性 | 说明 |
---|---|
undefined | 特殊值 undefined |
NaN | 特殊值 NaN |
Infinity | 特殊值 Infinity |
Object | Object 的构造函数 |
Array | Array 的构造函数 |
Function | Function 的构造函数 |
Boolean | Boolean 的构造函数 |
String | String 的构造函数 |
Number | Number 的构造函数 |
Date | Date 的构造函数 |
RegExp | RegExp 的构造函数 |
Symbol | Symbol 的伪构造函数 |
Error | Error 的构造函数 |
EvalError | EvalError 的构造函数 |
RangeError | RangeError 的构造函数 |
ReferenceError | ReferenceError 的构造函数 |
SyntaxError | SyntaxError 的构造函数 |
TypeError | TypeError 的构造函数 |
URIError | URIError 的构造函数 |
- window 对象
浏览器将 window 对象实现为 Global 对象的代理。因此,所有全局作用域中声明的变量和函数都变成了 window 的属性。
var color = 'red'
function sayColor() {
console.log(window.color)
}
window.sayColor() // "red"
这里定义了一个名为 color 的全局变量和一个名为 sayColor()的全局函数。在 sayColor()内部,通过 window.color 访问了 color 变量,说明全局变量变成了 window 的属性。接着,又通过 window 对象直接调用了 window.sayColor()函数,从而输出字符串。
Math
Math 对象作为保存数学公式、信息和计算的地方。Math 对象提供了一些辅助计算的属性和方法。
- Math 对象属性
属性 | 说明 |
---|---|
Math.E | 自然对数的基数 e 的值 |
Math.LN10 | 10 为底的自然对数 |
Math.LN2 | 2 为底的自然对数 |
Math.LOG2E | 以 2 为底 e 的对数 |
Math.LOG10E | 以 10 为底 e 的对数 |
Math.PI | π 的值 |
Math.SQRT1_2 | 1/2 的平方根 |
Math.SQRT2 | 2 的平方根 |
- min()和 max()方法
min()和 max()方法用于确定一组数值中的最小值和最大值。这两个方法都接收任意多个参数
let max = Math.max(3, 54, 32, 16)
console.log(max) // 54
let min = Math.min(3, 54, 32, 16)
console.log(min) // 3
let values = [1, 2, 3, 4, 5, 6, 7, 8]
let max = Math.max(...values)
console.log(max) // 8
- 舍入方法
- Math.ceil()方法始终向上舍入为最接近的整数。
- Math.floor()方法始终向下舍入为最接近的整数。
- Math.round()方法执行四舍五入。
- Math.fround()方法返回数值最接近的单精度(32 位)浮点值表示。
console.log(Math.ceil(25.9)) // 26
console.log(Math.ceil(25.5)) // 26
console.log(Math.ceil(25.1)) // 26
console.log(Math.round(25.9)) // 26
console.log(Math.round(25.5)) // 26
console.log(Math.round(25.1)) // 25
console.log(Math.fround(0.4)) // 0.4000000059604645
console.log(Math.fround(0.5)) // 0.5
console.log(Math.fround(25.9)) // 25.899999618530273
console.log(Math.floor(25.9)) // 25
console.log(Math.floor(25.5)) // 25
console.log(Math.floor(25.1)) // 25
对于 25 和 26(不包含)之间的所有值,Math.ceil()都会返回 26,因为它始终向上舍入。Math.round()只在数值大于等于 25.5 时返回 26,否则返回 25。最后,Math.floor()对所有 25 和 26(不包含)之间的值都返回 25。
- random()方法
Math.random()方法返回一个 0~1 范围内的随机数,其中包含 0 但不包含 1。对于希望显示随机名言或随机新闻的网页,这个方法是非常方便的。
// 这样就有 10 个可能的值(1~10),其中最小的值是 1。
let num = Math.floor(Math.random() * 10 + 1)
// 应该返回的最小值和最大值。通过将这两个值相减再加 1 得到可选总数
function selectFrom(lowerValue, upperValue) {
let choices = upperValue - lowerValue + 1
return Math.floor(Math.random() * choices + lowerValue)
}
let colors = ['red', 'green', 'blue', 'yellow', 'black', 'purple', 'brown']
// 传给 selecFrom()的第二个参数是数组长度减 1,即数组最大的索引值
let color = colors[selectFrom(0, colors.length - 1)]
console.log('🤩 color:', color)
- 其他方法
Math 对象还有很多涉及各种简单或高阶数运算的方法。
方法 | 说明 |
---|---|
Math.abs(x) | 返回 x 的绝对值 |
Math.exp(x) | 返回 Math.E 的 x 次幂 |
Math.expm1(x) | 等于 Math.exp(x) - 1 |
Math.log(x) | 返回 x 的自然对数 |
Math.log1p(x) | 等于 1 + Math.log(x) |
Math.pow(x, power) | 返回 x 的 power 次幂 |
Math.hypot(...nums) | 返回 nums 中每个数平方和的平方根 |
Math.clz32(x) | 返回 32 位整数 x 的前置零的数量 |
Math.sign(x) | 返回表示 x 符号的 1、0、-0 或-1 |
Math.trunc(x) | 返回 x 的整数部分,删除所有小数 |
Math.sqrt(x) | 返回 x 的平方根 |
Math.cbrt(x) | 返回 x 的立方根 |
Math.acos(x) | 返回 x 的反余弦 |
Math.acosh(x) | 返回 x 的反双曲余弦 |
Math.asin(x) | 返回 x 的反正弦 |
Math.asinh(x) | 返回 x 的反双曲正弦 |
Math.atan(x) | 返回 x 的反正切 |
Math.atanh(x) | 返回 x 的反双曲正切 |
Math.atan2(y, x) | 返回 y/x 的反正切 |
Math.cos(x) | 返回 x 的余弦 |
Math.sin(x) | 返回 x 的正弦 |
Math.tan(x) | 返回 x 的正切 |
第 6 章 集合引用类型
object
Object 是 ECMAScript 中最常用的类型之一。虽然 Object 的实例没有多少功能,但很适合存储和在应用程序间交换数据。创建 Object 的实例有两种方式。
- 第一种是使用 new 操作符和 Object 构造函数
- 第二种是使用对象字面量表示法
let person = new Object()
person.name = 'Nicholas'
person.age = 29
console.log(person)
let person1 = {
name: 'Nicholas',
age: 29,
}
console.log(person1)
// 对象字面量表示法来定义一个只有默认属性和方法的对象
let person = {} // 与 new Object()相同
person.name = 'Nicholas'
person.age = 29
Array
数组中每个槽位可以存储任意类型的数据。这意味着可以创建一个数组,它的第一个元素是字符串,第二个元素是数值,第三个是对象。
创建数组
- 使用 Array 构造函数
- Array.from():第一个参数是一个类数组对象,即任何可迭代的结构,或者有一个 length 属性和可索引元素的结构。
- Array.of():可以把一组参数转换为数组。这个方法用于替代在 ES6 之前常用的 Array.prototype.slice.call(arguments)
- 使用数组字面量表示法
let colors = new Array(3) // 创建一个包含 3 个元素的数组
let names = new Array('Greg') // 创建一个只包含一个元素,即字符串"Greg"的数组
let colors = ['red', 'blue', 'green'] // 创建一个包含 3 个元素的数组
let names = [] // 创建一个空数组
let values = [1, 2] // 创建一个包含 2 个元素的数组
// 字符串会被拆分为单字符数组
console.log(Array.from('Matt')) // ["M", "a", "t", "t"]
// Array.from()对现有数组执行浅复制
const a1 = [1, 2, 3, 4]
const a2 = Array.from(a1)
console.log(a1) // [1, 2, 3, 4]
alert(a1 === a2) // false
// arguments 对象可以被轻松地转换为数组
function getArgsArray() {
return Array.from(arguments)
}
console.log(getArgsArray(1, 2, 3, 4)) // [1, 2, 3, 4]
const a1 = [1, 2, 3, 4]
const a2 = Array.from(a1, (x) => x ** 2)
const a3 = Array.from(
a1,
function (x) {
return x ** this.exponent
},
{ exponent: 2 },
)
console.log(a2) // [1, 4, 9, 16]
console.log(a3) // [1, 4, 9, 16]
console.log(Array.of(1, 2, 3, 4)) // [1, 2, 3, 4]
console.log(Array.of(undefined)) // [undefined]
数组空位
使用数组字面量初始化数组时,可以使用一串逗号来创建空位
const options = [, , , , ,] // 创建包含 5 个元素的数组
console.log(options.length) // 5
console.log(options) // [,,,,,]
const a = Array.from([, , ,])
for (const val of a) {
console.log(val === undefined)
}
// true
// true
// true
数组索引
- 要取得或设置数组的值,需要使用中括号并提供相应值的数字索引
- 如果将 length 设置为大于数组元素数的值,则新添加的元素都将以 undefined 填充。
- 使用 length 属性可以方便地向数组末尾添加元素
- 数组中最后一个元素的索引始终是 length - 1
let colors = ['red', 'blue', 'green'] // 定义一个字符串数组
console.log(colors[0]) // 显示第一项
colors[2] = 'black' // 修改第三项
colors[3] = 'brown' // 添加第四项
console.log(colors) // [ 'red', 'blue', 'black', 'brown' ]
let colors = ['red', 'blue', 'green'] // 创建一个包含 3 个字符串的数组
colors.length = 4
alert(colors[3]) // undefined
let colors = ['red', 'blue', 'green'] // 创建一个包含 3 个字符串的数组
colors[colors.length] = 'black' // 添加一种颜色(位置 3)
colors[colors.length] = 'brown' // 再添加一种颜色(位置 4)
// colors 数组有一个值被插入到位置 99,结果新 length 就变成了 100(99 + 1)。
let colors = ['red', 'blue', 'green'] // 创建一个包含 3 个字符串的数组
colors[99] = 'black' // 添加一种颜色(位置 99)
alert(colors.length) // 100
检测数组
一个经典的 ECMAScript 问题是判断一个对象是不是数组。在只有一个网页(因而只有一个全局作用域)的情况下,使用 instanceof 操作符就足矣。使用 instanceof 的问题是假定只有一个全局执行上下文。如果网页里有多个框架,则可能涉及两个不同的全局执行上下文,因此就会有两个不同版本的 Array 构造函数。提供了 Array.isArray()方法。这个方法的目的就是确定一个值是否为数组,而不用管它是在哪个全局执行上下文中创建的。
if (value instanceof Array) {
// 对数组执行某些操作
}
if (Array.isArray(value)) {
// 对数组执行某些操作
}
迭代器方法
- keys():返回数组索引的迭代器
- values():返回数组元素的迭代器
- entries():返回索引/值对的迭代器
const a = ['foo', 'bar', 'baz', 'qux']
const keys = Array.from(a.keys())
const values = Array.from(a.values())
const aEntries = Array.from(a.entries())
console.log(keys) // [ 0, 1, 2, 3 ]
console.log(values) // [ 'foo', 'bar', 'baz', 'qux' ]
console.log(aEntries) // [ [ 0, 'foo' ], [ 1, 'bar' ], [ 2, 'baz' ], [ 3, 'qux' ] ]
复制和填充方法
- copyWithin():批量复制方法: 会按照指定范围浅复制数组中的部分内容,然后将它们插入到指定索引开始的位置。copyWithin()静默忽略超出数组边界、零长度及方向相反的索引范围。
- fill():填充数组方法: 可以向一个已有的数组中插入全部或部分相同的值. fill()静默忽略超出数组边界、零长度及方向相反的索引范围。
const zeroes = [0, 0, 0, 0, 0]
// 用 5 填充整个数组
zeroes.fill(5)
console.log(zeroes) // [5, 5, 5, 5, 5]
zeroes.fill(0) // 重置
// 用 6 填充索引大于等于 3 的元素
zeroes.fill(6, 3)
console.log(zeroes) // [0, 0, 0, 6, 6]
zeroes.fill(0) // 重置
// 用 7 填充索引大于等于 1 且小于 3 的元素
zeroes.fill(7, 1, 3)
console.log(zeroes) // [0, 7, 7, 0, 0];
zeroes.fill(0) // 重置
// 用 8 填充索引大于等于 1 且小于 4 的元素
// (-4 + zeroes.length = 1)
// (-1 + zeroes.length = 4)
zeroes.fill(8, -4, -1)
console.log(zeroes) // [0, 8, 8, 8, 0];
// 索引过高,忽略
zeroes.fill(1, 10, 15)
console.log(zeroes) // [0, 0, 0, 0, 0]
// 索引反向,忽略
zeroes.fill(2, 4, 2)
console.log(zeroes) // [0, 0, 0, 0, 0]
let ints,
reset = () => (ints = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
reset()
// 从 ints 中复制索引 0 开始的内容,插入到索引 5 开始的位置
// 在源索引或目标索引到达数组边界时停止
ints.copyWithin(5)
console.log(ints) // [0, 1, 2, 3, 4, 0, 1, 2, 3, 4]
reset()
// 从 ints 中复制索引 5 开始的内容,插入到索引 0 开始的位置
ints.copyWithin(0, 5)
console.log(ints) // [5, 6, 7, 8, 9, 5, 6, 7, 8, 9]
reset()
// 从 ints 中复制索引 0 开始到索引 3 结束的内容
// 插入到索引 4 开始的位置
ints.copyWithin(4, 0, 3)
console.log(ints) // [0, 1, 2, 3, 0, 1, 2, 7, 8, 9]
reset()
// JavaScript 引擎在插值前会完整复制范围内的值
// 因此复制期间不存在重写的风险
ints.copyWithin(2, 0, 6)
console.log(ints) // [0, 1, 0, 1, 2, 3, 4, 5, 8, 9]
reset()
// 支持负索引值,与 fill()相对于数组末尾计算正向索引的过程是一样的
ints.copyWithin(-4, -7, -3)
console.log(ints) // [0, 1, 2, 3, 4, 5, 3, 4, 5, 6]
// copyWithin()静默忽略超出数组边界、零长度及方向相反的索引范围:
let ints,
reset = () => (ints = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
reset()
// 索引过低,忽略
ints.copyWithin(1, -15, -12)
alert(ints) // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
reset()
// 索引过高,忽略
ints.copyWithin(1, 12, 15)
alert(ints) // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
reset()
// 索引反向,忽略
ints.copyWithin(2, 4, 2)
alert(ints) // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
reset()
// 索引部分可用,复制、填充可用部分
ints.copyWithin(4, 7, 10)
alert(ints) // [0, 1, 2, 3, 7, 8, 9, 7, 8, 9];
转换方法
- toString():返回由数组中每个值的字符串形式拼接而成的一个以逗号分隔的字符串。
- toLocaleString():返回由数组中每个值的本地化字符串形式拼接而成的一个以逗号分隔的字符串。
- join():使用指定的分隔符将数组中的所有元素都连接到一个字符串中。
- 如果数组中某一项是 null 或 undefined,则在 join()、toLocaleString()、toString()和 valueOf()返回的结果中会以空字符串表示。
let colors = ['red', 'blue', 'green'] // 创建一个包含 3 个字符串的数组
console.log(colors.toString()) // red,blue,green
console.log(colors.valueOf()) // red,blue,green
console.log(colors) // red,blue,green
let colors = ['red', 'green', 'blue']
console.log(colors.join(',')) // red,green,blue
console.log(colors.join('||')) // red||green||blue
栈方法
数组对象可以像栈一样,也就是一种限制插入和删除项的数据结构。栈是一种后进先出(LIFO,Last-In-First-Out)的结构,也就是最近添加的项先被删除。
- push():方法接收任意数量的参数,并将它们添加到数组末尾,返回数组的最新长度。
- pop():方法则用于删除数组的最后一项,同时减少数组的 length 值,返回被删除的项。
let colors = new Array() // 创建一个数组
let count = colors.push('red', 'green') // 推入两项
console.log(count) // 2
count = colors.push('black') // 再推入一项
console.log(count) // 3
let item = colors.pop() // 取得最后一项
console.log(item) // black
console.log(colors.length) // 2
let colors = ['red', 'blue']
colors.push('brown') // 再添加一项
colors[3] = 'black' // 添加一项
alert(colors.length) // 4
队列方法
队列数据结构的访问规则是 FIFO(先进先出,First-In-First-Out),队列在列表的末端添加项,从列表的前端移除项。
- shift():方法用于移除数组中的第一个项并获取该项,同时数组的长度减 1。
- unshift():方法在数组开头添加任意多个值,然后返回新的数组长度
let colors = new Array()
let count = colors.push('red', 'green')
let item = colors.shift()
console.log(item) // red
console.log(colors.length) // 1
let colors = new Array() // 创建一个数组
let count = colors.unshift('red', 'green') // 从数组开头推入两项
console.log(count) // 2
count = colors.unshift('black') // 再推入一项
console.log(count) // 3
let item = colors.pop() // 取得最后一项
console.log(item) // green
console.log(colors.length) // 2
排序方法
- reverse():方法会反转数组项的顺序。
- sort():方法按升序排列数组项——即最小的值位于最前面,最大的值排在最后面。比较函数接收两个参数,如果第一个参数应该排在第二个参数前面,就返回负值;如果两个参数相等,就返回 0;如果第一个参数应该排在第二个参数后面,就返回正值。
let values = [1, 2, 3, 4, 5]
values.reverse()
alert(values) // 5,4,3,2,1
function compare(value1, value2) {
if (value1 < value2) {
return -1
} else if (value1 > value2) {
return 1
} else {
return 0
}
}
let values = [111, 1, 5, 10, 15]
values.sort(compare)
console.log(values) // [ 1, 5, 10, 15, 111 ]
// 这个比较函数还可简写为一个箭头函数:
let values = [20, 1, 5, 10, 15]
values.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))
console.log('🤩 values:', values) // [ 20, 15, 10, 5, 1 ]
操作方法
concat():合并两个或多个数组,返回一个新数组, 不修改原数组。
slice():从数组中截取一部分,返回新数组, 不修改原数组。
- slice()方法可以接收一个或两个参数:返回元素的开始索引和结束索引。
- 如果只有一个参数,则 slice()会返回该索引到数组末尾的所有元素。
- 如果有两个参数,则 slice()返回从开始索引到结束索引对应的所有元素,其中不包含结束索引对应的元素。
splice():修改原数组,可以删除、替换或插入元素。
- 删除。需要给 splice()传 2 个参数:要删除的第一个元素的位置和要删除的元素数量。可以从数组中删除任意多个元素,比如 splice(0, 2)会删除前两个元素。
- 插入。需要给 splice()传 3 个参数:开始位置、0(要删除的元素数量)和要插入的元素,可以在数组中指定的位置插入元素。第三个参数之后还可以传第四个、第五个参数,乃至任意多个要插入的元素。比如,splice(2, 0, "red", "green")会从数组位置 2 开始插入字符串"red"和"green"。
- 替换。splice()在删除元素的同时可以在指定位置插入新元素,同样要传入 3 个参数:开始位置、要删除元素的数量和要插入的任意多个元素。要插入的元素数量不一定跟删除的元素数量一致。比如,splice(2, 1, "red", "green")会在位置 2 删除一个元素,然后从该位置开始向数组中插入"red"和"green"。
let colors = ['red', 'green', 'blue']
let colors2 = colors.concat('yellow', ['black', 'brown'])
console.log(colors) // ["red", "green","blue"]
console.log(colors2) // ["red", "green", "blue", "yellow", "black", "brown"]
let colors = ['red', 'green', 'blue', 'yellow', 'purple']
let colors2 = colors.slice(1)
let colors3 = colors.slice(1, 4)
alert(colors2) // green,blue,yellow,purple
alert(colors3) // green,blue,yellow
let colors = ['red', 'green', 'blue']
let removed = colors.splice(0, 1) // 删除第一项
alert(colors) // green,blue
alert(removed) // red,只有一个元素的数组
removed = colors.splice(1, 0, 'yellow', 'orange') // 在位置1 插入两个元素
alert(colors) // green,yellow,orange,blue
alert(removed) // 空数组
removed = colors.splice(1, 1, 'red', 'purple') // 插入两个值,删除一个元素
alert(colors) // green,red,purple,orange,blue
alert(removed) // yellow,只有一个元素的数组
搜索和位置方法
- 严格相等:
- indexOf()
- lastIndexOf()
- includes()
这些方法都接收两个参数:要查找的元素和一个可选的起始搜索位置。indexOf()和 includes()方法从数组前头(第一项) 开始向后搜索,而 lastIndexOf()从数组末尾(最后一项)开始向前搜索。
indexOf()和 lastIndexOf()都返回要查找的元素在数组中的位置,如果没找到则返回-1。includes()返回布尔值,表示是否至少找到一个与指定元素匹配的项。在比较第一个参数跟数组每一 项时,会使用全等(===)比较,也就是说两项必须严格相等。
let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1]
alert(numbers.indexOf(4)) // 3
alert(numbers.lastIndexOf(4)) // 5
alert(numbers.includes(4)) // true
alert(numbers.indexOf(4, 4)) // 5
alert(numbers.lastIndexOf(4, 4)) // 3
alert(numbers.includes(4, 7)) // false
let person = { name: 'Nicholas' }
let people = [{ name: 'Nicholas' }]
let morePeople = [person]
alert(people.indexOf(person)) // -1
alert(morePeople.indexOf(person)) // 0
alert(people.includes(person)) // false
alert(morePeople.includes(person)) // true
- 断言函数
断言函数的返回值决定了相应索引的元素是否被认为匹配。断言函数接收 3 个参数:元素、索引和数组身。其中元素是数组中当前搜索的元素,索引是当前元素的索引,而数组就是正在搜索的数组。断言函数返回真值,表示是否匹配。这两个方法也都接收第二个可选的参数,用于指定断言函数内部 this 的值。找到匹配项后,这两个方法都不再继续搜索。、
- find():返回第一个匹配的元素。
- findIndex():返回第一个匹配元素的索引
const people = [
{
name: 'Matt',
age: 27,
},
{
name: 'Nicholas',
age: 29,
},
]
alert(people.find((element, index, array) => element.age < 28))
// {name: "Matt", age: 27}
alert(people.findIndex((element, index, array) => element.age < 28))
// 0
迭代方法
- every():对数组每一项都运行传入的函数,如果对每一项函数都返回 true,则这个方法返回 true。
- filter():对数组每一项都运行传入的函数,函数返回 true 的项会组成数组之后返回。
- forEach():对数组每一项都运行传入的函数,没有返回值。
- map():对数组每一项都运行传入的函数,返回由每次函数调用的结果构成的数组。
- some():对数组每一项都运行传入的函数,如果有一项函数返回 true,则这个方法返回 true。
在这些方法中,every()和 some()是最相似的,都是从数组中搜索符合某个条件的元素。对 every()来说,传入的函数必须对每一项都返回 true,它才会返回 true;否则,它就返回 false。而 对 some()来说,只要有一项让传入的函数返回 true,它就会返回 true。
let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1]
let everyResult = numbers.every((item, index, array) => item > 2)
console.log(everyResult) // false
let someResult = numbers.some((item, index, array) => item > 2)
console.log(someResult) // true
let filterResult = numbers.filter((item, index, array) => item > 2)
console.log(filterResult) // 3,4,5,4,3
let mapResult = numbers.map((item, index, array) => item * 2)
console.log(mapResult) // 2,4,6,8,10,8,6,4,2
numbers.forEach((item, idnex, array) => {
console.log(item) // 循环输出 1, 2, 3, 4, 5, 4, 3, 2, 1
})
归并方法
- reduce():方法从数组第一项开始遍历到最后一项。
- reduceRight():从最后一项开始遍历至第一项。
这两个方法都接收两个参数:对每一项都会运行的归并函数,以及可选的以之为归并起点的初始值。传给 reduce()和 reduceRight()的函数接收 4 个参数:上一个归并值、当前项、当前项的索引和数组本身。这个函数返回的任何值都会作为下一次调用同一个函数的第一个参数。如果没有给这两个方法传入可选的第二个参数(作为归并起点值),则第一次迭代将从数组的第二项开始,因此传给归并函数 的第一个参数是数组的第一项,第二个参数是数组的第二项。
// 可以使用 reduce()函数执行累加数组中所有数值的操作,比如:
let values = [1, 2, 3, 4, 5];
let sum = values.reduce((prev, cur, index, array) => prev + cur);
console.log(sum); // 15
let values = [1, 2, 3, 4, 5];
let sum = values.reduceRight(function (prev, cur, index, array) {
return prev + cur;
});
console.log(sum); // 15
究竟是使用 reduce()还是 reduceRight(),只取决于遍历数组元素的方向。除此之外,这两个方法没什么区别。
定型数组
定型数组(typed array)是 ECMAScript 新增的结构,是用于高效处理二进制数据的工具,与普通数组相比,它们在内存管理和类型固定性上具有显著优势。
- 仅存储特定类型的数值(如整数、浮点数),类型在创建时确定。
- 长度固定,无法动态调整。
- 直接操作内存中的二进制数据,性能更高。
// 1. 直接初始化
// 创建一个长度为 4 的 Int32 数组,初始值为 0
const intArray = new Int32Array(4) // [0, 0, 0, 0]
// 通过普通数组初始化
const floatArray = new Float32Array([1.5, 2.3, 3.7])
// 2. 基于 ArrayBuffer
// 创建一个 16 字节的缓冲区
const buffer = new ArrayBuffer(16)
// 将前 8 字节解释为两个 Int32(每个占 4 字节)
const int32View = new Int32Array(buffer, 0, 2)
// 将后 8 字节解释为一个 Float64(占 8 字节)
const float64View = new Float64Array(buffer, 8)
// 读写数据
const array = new Uint8Array([10, 20, 30])
// 读取第二个元素
console.log(array[1]) // 20
// 修改第三个元素
array[2] = 40 // [10, 20, 40]
// 2. 与普通数组互转
// 定型数组 → 普通数组
const typedArray = new Int16Array([100, 200, 300])
const normalArray = Array.from(typedArray) // [100, 200, 300]
// 普通数组 → 定型数组
const newTypedArray = new Float32Array(normalArray)
//3. 内存操作(与 ArrayBuffer 结合)
const buffer = new ArrayBuffer(8)
const view1 = new Uint16Array(buffer)
const view2 = new Uint8Array(buffer)
// 通过 view1 写入数据
view1[0] = 0x1234
// 通过 view2 读取字节内容
console.log(view2[0], view2[1]) // 0x34, 0x12(小端序)
- WebGL 图形渲染
// 传递顶点数据给 GPU
const vertices = new Float32Array([
-0.5,
-0.5, // 第一个顶点坐标
0.5,
-0.5, // 第二个顶点坐标
0.0,
0.5, // 第三个顶点坐标
])
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW)
- 文件处理
// 读取图片文件并解析像素数据
const fileInput = document.querySelector('input[type="file"]')
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0]
const reader = new FileReader()
reader.onload = (event) => {
const buffer = event.target.result
const pixelData = new Uint8ClampedArray(buffer)
// 操作像素数据(如修改 RGBA 值)
}
reader.readAsArrayBuffer(file)
})
ArrayBuffer
ArrayBuffer():是一个普通的 JavaScript 构造函数,可用于在内存中分配特定数量的字节空间。ArrayBuffer 一经创建就不能再调整大小。
Float32Array:实际上是一种“视图”,可以允许 JavaScript 运行时访问一块名为 ArrayBuffer 的预分配内存。ArrayBuffer 是所有定型数组及视图引用的基本单位。
const buf = new ArrayBuffer(16) // 在内存中分配 16 字节
alert(buf.byteLength) // 16
// 可以使用 slice()复制其全部或部分到一个新实例中
const buf1 = new ArrayBuffer(16)
const buf2 = buf1.slice(0, 8)
console.log(buf2.byteLength) // 8
DataView
是一个用于操作二进制数据的底层接口,它提供了一种灵活的方式读写 ArrayBuffer(二进制数据缓冲区)中的内容, 尤其适合处理包含多种数据类型或需要控制字节序的场(如网络协议解析、文件格式处理)景。
与定型数组(Typed Arrays)相比,DataView 不限制数据类型,允许在同一个缓冲区中自由读写不同的数值类型。
const buf = new ArrayBuffer(16)
// DataView 默认使用整个 ArrayBuffer
const fullDataView = new DataView(buf)
alert(fullDataView.byteOffset) // 0
alert(fullDataView.byteLength) // 16
alert(fullDataView.buffer === buf) // true
// 构造函数接收一个可选的字节偏移量和字节长度
// byteOffset=0 表示视图从缓冲起点开始
// byteLength=8 限制视图为前 8 个字节
const firstHalfDataView = new DataView(buf, 0, 8)
alert(firstHalfDataView.byteOffset) // 0
alert(firstHalfDataView.byteLength) // 8
alert(firstHalfDataView.buffer === buf) // true
// 读取数据:
// 从偏移量 0 读取一个 8 位有符号整数
const int8 = view1.getInt8(0)
// 从偏移量 2 读取一个 16 位无符号整数(小端序)
const uint16 = view1.getUint16(2, true)
// 从偏移量 8 读取一个 64 位双精度浮点数(默认大端序)
const float64 = view1.getFloat64(8)
// 写入数据:
// 在偏移量 0 写入一个 32 位有符号整数(大端序)
view1.setInt32(0, 12345, false)
// 在偏移量 4 写入一个 16 位无符号整数(小端序)
view1.setUint16(4, 0xff00, true)
实际应用场景:
- 解析网络协议(如 TCP/UDP 头部)
// 假设接收到的二进制数据为 buffer
const view = new DataView(buffer)
// 读取源端口(16位大端序)
const srcPort = view.getUint16(0, false)
// 读取目标端口(16位大端序)
const destPort = view.getUint16(2, false)
// 读取数据长度(16位小端序)
const dataLength = view.getUint16(4, true)
- 处理跨平台二进制文件
// 读取一个包含多种数据类型的二进制文件
const fileBuffer = await readFile('data.bin')
const view = new DataView(fileBuffer)
// 读取文件头(假设前4字节为小端序的魔数)
const magicNumber = view.getUint32(0, true)
// 读取浮点数值(偏移4字节,大端序)
const value = view.getFloat32(4, false)
Map
Map 是一种键值对的集合,其中的键可以是任何类型的值。Map 中的键值对是有序的,因此可以根据插入顺序进行迭代。Map 中的键是唯一的,这意味着如果尝试插入一个已经存在的键,它的值将被更新。
初始化之后,可以使用 set()方法再添加键/值对。另外,可以使用 get()和 has()进行查询,可以通过 size 属性获取映射中的键/值对的数量,还可以使用 delete()和 clear()删除值。
Map 可以使用任何 JavaScript 数据类型作为键。
const m = new Map()
console.log(m.has('firstName')) // false
console.log(m.get('firstName')) // undefined
console.log(m.size) // 0
m.set('firstName', 'Matt').set('lastName', 'Frisbie')
console.log(m.has('firstName')) // true
console.log(m.get('firstName')) // Matt
console.log(m.size) // 2
m.delete('firstName') // 只删除这一个键/值对
console.log(m.has('firstName')) // false
console.log(m.has('lastName')) // true
console.log(m.size) // 1
m.clear() // 清除这个映射实例中的所有键/值对
console.log(m.has('firstName')) // false
console.log(m.has('lastName')) // false
console.log(m.size) // 0
顺序与迭代
与 Object 类型的一个主要差异是,Map 实例会维护键值对的插入顺序,因此可以根据插入顺序执行迭代操作。
映射实例可以提供一个迭代器(Iterator),能以插入顺序生成[key, value]形式的数组。可以通过 entries()方法(或者 Symbol.iterator 属性,它引用 entries())取得这个迭代器
keys()和 values()分别返回以插入顺序生成键和值的迭代器
const m = new Map([
['key1', 'val1'],
['key2', 'val2'],
['key3', 'val3'],
])
console.log(m.entries === m[Symbol.iterator]) // true
for (let pair of m.entries()) {
alert(pair)
}
// [key1,val1]
// [key2,val2]
// [key3,val3]
for (let pair of m[Symbol.iterator]()) {
alert(pair)
}
// [key1,val1]
// [key2,val2]
// [key3,val3]
for (let key of m.keys()) {
alert(key)
}
// key1
// key2
// key3
for (let key of m.values()) {
alert(key)
}
// value1
// value2
选择 Object 还是 Map
- 内存占用 Object 和 Map 的工程级实现在不同浏览器间存在明显差异,但存储单个键/值对所占用的内存数量都会随键的数量线性增加。批量添加或删除键/值对则取决于各浏览器对该类型内存分配的工程实现。不同浏览器的情况不同,但给定固定大小的内存,Map 大约可以比 Object 多存储 50%的键/值对。
- 插入性能 向 Object 和 Map 中插入新键/值对的消耗大致相当,不过插入 Map 在所有浏览器中一般会稍微快一点儿。对这两个类型来说,插入速度并不会随着键/值对数量而线性增加。如果代码涉及大量插入操作,那么显然 Map 的性能更佳。
- 查找速度 与插入不同,从大型 Object 和 Map 中查找键/值对的性能差异极小,但如果只包含少量键/值对,则 Object 有时候速度更快。在把 Object 当成数组使用的情况下(比如使用连续整数作为属性),浏览器引擎可以进行优化,在内存中使用更高效的布局。这对 Map 来说是不可能的。对这两个类型而言,查找速度不会随着键/值对数量增加而线性增加。如果代码涉及大量查找操作,那么某些情况下可能选择 Object 更好一些。
- 删除性能 使用 delete 删除 Object 属性的性能一直以来饱受诟病,目前在很多浏览器中仍然如此。为此,出现了一些伪删除对象属性的操作,包括把属性值设置为 undefined 或 null。但很多时候,这都是一种讨厌的或不适宜的折中。而对大多数浏览器引擎来说,Map 的 delete()操作都比插入和查找更快。如果代码涉及大量删除操作,那么毫无疑问应该选择 Map。
WeakMap
“弱映射”(WeakMap)是一种新的集合类型,为这门语言带来了增强的键/值对存储机制。WeakMap 是 Map 的“兄弟”类型,其 API 也是 Map 的子集。
如果想在初始化时填充弱映射,则构造函数可以接收一个可迭代对象,其中需要包含键/值对数组。可迭代对象中的每个键/值都会按照迭代顺序插入新实例中.
初始化是全有或全无的操作,只要有一个键无效就会抛出错误,导致整个初始化失败
const wm = new WeakMap()
const key1 = { id: 1 }
const key2 = { id: 2 }
const key3 = { id: 3 }
const wm1 = new WeakMap([
[key1, 'val1'],
[key2, 'val2'],
[key3, 'val3'],
])
console.log(wm1.get(key1)) // val1
console.log(wm1.get(key2)) // val2
console.log(wm1.get(key3)) // val3
const wm = new WeakMap()
const key1 = { id: 1 },
key2 = { id: 2 }
alert(wm.has(key1)) // false
alert(wm.get(key1)) // undefined
wm.set(key1, 'Matt').set(key2, 'Frisbie')
alert(wm.has(key1)) // true
alert(wm.get(key1)) // Matt
wm.delete(key1) // 只删除这一个键/值对
alert(wm.has(key1)) // false
alert(wm.has(key2)) // true
弱键
WeakMap 中“weak”表示弱映射的键是“弱弱地拿着”的。意思就是,这些键不属于正式的引用,不会阻止垃圾回收。
const wm = new WeakMap()
wm.set({}, 'val')
set()方法初始化了一个新对象并将它用作一个字符串的键。因为没有指向这个对象的其他引用,所以当这行代码执行完成后,这个对象键就会被当作垃圾回收。set()方法初始化了一个新对象并将它用作一个字符串键。因为没有指向这个对象的其他引用,所以当这行代码执行完成后,这个对象键就会被当作垃圾回收。
不可迭代键
因为 WeakMap 中的键/值对任何时候都可能被销毁,所以没必要提供迭代其键/值对的能力。当然,也用不着像 clear()这样一次性销毁所有键/值的方法。WeakMap 确实没有这个方法。因为不可能迭代,所以也不可能在不知道对象引用的情况下从弱映射中取得值。即便代码可以访问 WeakMap 实例,也没办法看到其中的内容。
使用弱映射
- 私有属性: 弱映射造就了在 JavaScript 中实现真正私有变量的一种新方式。前提很明确:私有变量会存储在弱映射中,以对象实例为键,以私有成员的字典为值。
const wm = new WeakMap()
const User = (() => {
class User {
constructor(id) {
this.idProperty = Symbol('id')
this.setId(id)
}
setPrivate(property, value) {
const privateMembers = wm.get(this) || {}
privateMembers[property] = value
wm.set(this, privateMembers)
}
getPrivate(property) {
return wm.get(this)[property]
}
setId(id) {
this.setPrivate(this.idProperty, id)
}
getId() {
return this.getPrivate(this.idProperty)
}
}
return User
})()
const user = new User(123)
console.log(user.getId()) // 123
user.setId(456)
console.log(user.getId()) // 456
- DOM 节点元数据
因为 WeakMap 实例不会妨碍垃圾回收,所以非常适合保存关联元数据。
这里使用的是弱映射,那么当节点从 DOM 树中被删除后,垃圾回收程序就可以立即释放其内存
const wm = new WeakMap()
const loginButton = document.querySelector('#login')
// 给这个节点关联一些元数据
wm.set(loginButton, { disabled: true })
set
ECMAScript 6 新增的 Set 是一种新集合类型,为这门语言带来集合数据结构。Set 在很多方面都像是加强的 Map,这是因为它们的大多数 API 和行为都是共有的。
初始化之后,可以使用 add()增加值,使用 has()查询,通过 size 取得元素数量,以及使用 delete()和 clear()删除元素。
与 Map 类似,Set 可以包含任何 JavaScript 数据类型作为值。
// 使用数组初始化集合
const s1 = new Set(['val1', 'val2', 'val3'])
console.log(s1.size) // 3
// 使用自定义迭代器初始化集合
const s2 = new Set({
[Symbol.iterator]: function* () {
yield 'val1'
yield 'val2'
yield 'val3'
},
})
console.log(s2.size) // 3
const s = new Set()
alert(s.has('Matt')) // false
alert(s.size) // 0
s.add('Matt').add('Frisbie')
alert(s.has('Matt')) // true
alert(s.size) // 2
s.delete('Matt')
alert(s.has('Matt')) // false
alert(s.has('Frisbie')) // true
alert(s.size) // 1
s.clear() // 销毁集合实例中的所有值
alert(s.has('Matt')) // false
alert(s.has('Frisbie')) // false
alert(s.size) // 0
const s = new Set()
const functionVal = function () {}
const symbolVal = Symbol()
const objectVal = new Object()
s.add(functionVal)
s.add(symbolVal)
s.add(objectVal)
alert(s.has(functionVal)) // true
alert(s.has(symbolVal)) // true
alert(s.has(objectVal)) // true
// SameValueZero 检查意味着独立的实例不会冲突
alert(s.has(function () {})) // false
顺序与迭代
Set 会维护值插入时的顺序,因此支持按顺序迭代。集合实例可以提供一个迭代器(Iterator),能以插入顺序生成集合内容。可以通过 values()方法及其别名方法 keys()(或者 Symbol.iterator 属性,它引用 values())取得这个迭代器
const s = new Set(['val1', 'val2', 'val3'])
alert(s.values === s[Symbol.iterator]) // true
alert(s.keys === s[Symbol.iterator]) // true
for (let value of s.values()) {
alert(value)
}
// val1
// val2
// val3
for (let value of s[Symbol.iterator]()) {
alert(value)
}
// val1
// val2
// val3
const s = new Set(['val1', 'val2', 'val3'])
console.log([...s]) // ["val1", "val2", "val3"]
const s = new Set(['val1', 'val2', 'val3'])
for (let pair of s.entries()) {
console.log(pair)
}
// ["val1", "val1"]
// ["val2", "val2"]
// ["val3", "val3"]
WeakSet
“弱集合”(WeakSet)是一种新的集合类型,为这门语言带来了增强的集合存储机制。WeakSet 是 Set 的“兄弟”类型,其 API 也是 Set 的子集。
const val1 = {id: 1},
val2 = {id: 2},
val3 = {id: 3};
// 使用数组初始化弱集合
const ws1 = new WeakSet([val1, val2, val3]);
alert(ws1.has(val1)); // true
alert(ws1.has(val2)); // true
alert(ws1.has(val3)); // true
// 初始化是全有或全无的操作
// 只要有一个值无效就会抛出错误,导致整个初始化失败
const ws2 = new WeakSet([val1, "BADVAL", val3]);
// TypeError: Invalid value used in WeakSet
typeof ws2;
// ReferenceError: ws2 is not defined
// 原始值可以先包装成对象再用作值
const stringVal = new String("val1");
const ws3 = new WeakSet([stringVal]);
alert(ws3.has(stringVal)); // true
// 初始化之后可以使用 add()再添加新值,可以使用 has()查询,还可以使用 delete()删除
const ws = new WeakSet();
const val1 = {id: 1},
val2 = {id: 2};
alert(ws.has(val1)); // false
ws.add(val1).add(val2);
alert(ws.has(val1)); // true
alert(ws.has(val2)); // true
ws.delete(val1); // 只删除这一个值
alert(ws.has(val1)); // false
alert(ws.has(val2)); // true
add()方法返回弱集合实例,因此可以把多个操作连缀起来,包括初始化声明:
const val1 = {id: 1},
val2 = {id: 2},
val3 = {id: 3};
const ws = new WeakSet().add(val1);
ws.add(val2)
.add(val3);
alert(ws.has(val1)); // true
alert(ws.has(val2)); // true
弱值
WeakSet 中“weak”表示弱集合的值是“弱弱地拿着”的。意思就是,这些值不属于正式的引用,不会阻止垃圾回收。
const ws = new WeakSet();
ws.add({});
add()方法初始化了一个新对象,并将它用作一个值。因为没有指向这个对象的其他引用,所以当这行代码执行完成后,这个对象值就会被当作垃圾回收。然后,这个值就从弱集合中消失了,使其成为一个空集合。
不可迭代值
因为 WeakSet 中的值任何时候都可能被销毁,所以没必要提供迭代其值的能力。当然,也用不着像 clear()这样一次性销毁所有值的方法。WeakSet 确实没有这个方法。因为不可能迭代,所以也不可能在不知道对象引用的情况下从弱集合中取得值。即便代码可以访问 WeakSet 实例,也没办法看到其中的内容。
使用弱集合
相比于 WeakMap 实例,WeakSet 实例的用处没有那么大。不过,弱集合在给对象打标签时还是有价值的。
const disabledElements = new Set();
const loginButton = document.querySelector('#login');
// 通过加入对应集合,给这个节点打上“禁用”标签
disabledElements.add(loginButton);
// 不过,假如元素从 DOM 树中被删除了,它的引用却仍然保存在 Set 中,因此垃圾回收程序也不能回收它。
// 为了让垃圾回收程序回收元素的内存,可以在这里使用 WeakSet:
const disabledElements = new WeakSet();
const loginButton = document.querySelector('#login');
// 通过加入对应集合,给这个节点打上“禁用”标签
disabledElements.add(loginButton);
迭代与扩展操作
for-of
let iterableThings = [
Array.of(1, 2),
(typedArr = Int16Array.of(3, 4)),
new Map([
[5, 6],
[7, 8],
]),
new Set([9, 10]),
]
for (const iterableThing of iterableThings) {
for (const x of iterableThing) {
console.log(x)
}
}
// 1
// 2
// 3
// 4
// [5, 6]
// [7, 8]
// 9
// 10
扩展运算符
扩展操作符在对可迭代对象执行浅复制时特别有用
let arr1 = [1, 2, 3];
let arr2 = [...arr1];
console.log(arr1); // [1, 2, 3]
console.log(arr2); // [1, 2, 3]
console.log(arr1 === arr2); // false
// 对于期待可迭代对象的构造函数,只要传入一个可迭代对象就可以实现复制:
let map1 = new Map([[1, 2], [3, 4]]);
let map2 = new Map(map1);
console.log(map1); // Map {1 => 2, 3 => 4}
console.log(map2); // Map {1 => 2, 3 => 4}
// 当然,也可以构建数组的部分元素:
let arr1 = [1, 2, 3];
let arr2 = [0, ...arr1, 4, 5];
console.log(arr2); // [0, 1, 2, 3, 4, 5]
// 浅复制意味着只会复制对象引用:
let arr1 = [{}];
let arr2 = [...arr1];
arr1[0].foo = 'bar';
console.log(arr2[0]); // { foo: 'bar' }
上面的这些类型都支持多种构建方法,比如 Array.of()和 Array.from()静态方法。在与扩展操作符一起使用时,可以非常方便地实现互操作:
let arr1 = [1, 2, 3]
// 把数组复制到定型数组
let typedArr1 = Int16Array.of(...arr1)
let typedArr2 = Int16Array.from(arr1)
console.log(typedArr1) // Int16Array [1, 2, 3]
console.log(typedArr2) // Int16Array [1, 2, 3]
// 把数组复制到映射
let map = new Map(arr1.map((x) => [x, 'val' + x]))
console.log(map) // Map {1 => 'val 1', 2 => 'val 2', 3 => 'val 3'}
// 把数组复制到集合
let set = new Set(typedArr2)
console.log(set) // Set {1, 2, 3}
// 把集合复制回数组
let arr2 = [...set]
console.log(arr2) // [1, 2, 3]
小结
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 章 迭代器与生成器
理解迭代
在 JavaScript 中,计数循环就是一种最简单的迭代:
for (let i = 1; i <= 10; ++i) {
console.log(i);
}
循环是迭代机制的基础,这是因为它可以指定迭代的次数,以及每次迭代要执行什么操作。每次循环都会在下一次迭代开始之前完成,而每次迭代的顺序都是事先定义好的。
迭代会在一个有序集合上进行。因为数组有已知的长度,且数组每一项都可以通过索引获取,所以整个数组可以通过递增索引来遍历。由于如下原因,通过这种循环来执行例程并不理想。
- 迭代之前需要事先知道如何使用数据结构。数组中的每一项都只能先通过引用取得数组对象,然后再通过[]操作符取得特定索引位置上的项。这种情况并不适用于所有数据结构。
- 遍历顺序并不是数据结构固有的。通过递增索引来访问数据是特定于数组类型的方式,并不适用于其他具有隐式顺序的数据结构。
在 ECMAScript 较早的版本中,执行迭代必须使用循环或其他辅助结构。随着代码量增加,代码会变得越发混乱。很多语言都通过原生语言结构解决了这个问题,开发者无须事先知道如何迭代就能实现迭代操作。这个解决方案就是迭代器模式。
迭代器模式
迭代器模式可以把一些复杂的数据结构的内部实现暴露给开发者,开发者无须了解如何实现数据结构,也无须了解数据结构内部的组织方式。
任何实现 Iterable 接口的数据结构都可以被实现 Iterator 接口的结构“消费”(consume)。迭代器(iterator)是按需创建的一次性对象。每个迭代器都会关联一个可迭代对象,而迭代器会暴露迭代其关联可迭代对象的 API。迭代器无须了解与其关联的可迭代对象的结构,只需要知道如何取得连续的值。这种概念上的分离正是 Iterable 和 Iterator 的强大之处。
可迭代协议
实现 Iterable 接口(可迭代协议)要求同时具备两种能力:
- 支持迭代的自我识别能力
- 创建实现 Iterator 接口的对象的能力。
实现可迭代协议的所有类型都会自动兼容接收可迭代对象的任何语言特性。接收可迭代对象的原生语言特性包括:
- for-of 循环
- 数组解构
- 扩展操作符
- Array.from()
- 创建集合
- 创建映射
- Promise.all()接收由期约组成的可迭代对象
- Promise.race()接收由期约组成的可迭代对象
- yield*操作符,在生成器中使用
let arr = ['foo', 'bar', 'baz']
// for-of 循环
for (let el of arr) {
console.log(el)
}
// foo
// bar
// baz
// 数组解构
let [a, b, c] = arr
console.log(a, b, c) // foo, bar, baz
// 扩展操作符
let arr2 = [...arr]
console.log(arr2) // ['foo', 'bar', 'baz']
// Array.from()
let arr3 = Array.from(arr)
console.log(arr3) // ['foo', 'bar', 'baz']
// 构造函数
let set = new Set(arr)
console.log(set) // Set(3) {'foo', 'bar', 'baz'}
// Map 构造函数
let pairs = arr.map((x, i) => [x, i])
console.log(pairs) // [['foo', 0], ['bar', 1], ['baz', 2]]
let map = new Map(pairs)
console.log(map) // Map(3) { 'foo'=>0, 'bar'=>1, 'baz'=>2 }
// 如果对象原型链上的父类实现了 Iterable 接口,那这个对象也就实现了这个接口:
class FooArray extends Array {}
let fooArr = new FooArray('foo', 'bar', 'baz')
for (let item of fooArr) {
console.log(item)
}
// foo
// bar
// baz
迭代器协议
迭代器是一种一次性使用的对象,用于迭代与其关联的可迭代对象。
迭代器 API 使用 next()方法在可迭代对象中遍历数据。每次成功调用 next(),都会返回一个 IteratorResult 对象,其中包含迭代器返回的下一个值。若不调用 next(),则无法知道迭代器的当前位置。
// 可迭代对象
let arr = ['foo', 'bar']
// 迭代器工厂函数
console.log(arr[Symbol.iterator]) // f values() { [native code] }
// 迭代器
let iter = arr[Symbol.iterator]()
console.log(iter) // ArrayIterator {}
// 执行迭代
console.log(iter.next()) // { done: false, value: 'foo' }
console.log(iter.next()) // { done: false, value: 'bar' }
console.log(iter.next()) // { done: true, value: undefined }
console.log(iter.next()) // { done: true, value: undefined }
console.log(iter.next()) // { done: true, value: undefined }
这里通过创建迭代器并调用 next()方法按顺序迭代了数组,直至不再产生新值。迭代器并不知道怎么从可迭代对象中取得下一个值,也不知道可迭代对象有多大。只要迭代器到达 done: true 状态,后续调用 next()就一直返回同样的值了。
每个迭代器都表示对可迭代对象的一次性有序遍历。不同迭代器的实例相互之间没有联系,只会独立地遍历可迭代对象:
let arr = ['foo', 'bar']
let iter1 = arr[Symbol.iterator]()
let iter2 = arr[Symbol.iterator]()
console.log(iter1.next()) // { done: false, value: 'foo' }
console.log(iter2.next()) // { done: false, value: 'foo' }
console.log(iter2.next()) // { done: false, value: 'bar' }
console.log(iter1.next()) // { done: false, value: 'bar' }
自定义迭代器
为了让一个可迭代对象能够创建多个迭代器,必须每创建一个迭代器就对应一个新计数器。为此,可以把计数器变量放到闭包里,然后通过闭包返回迭代器:
class Counter {
constructor(limit) {
this.limit = limit
}
[Symbol.iterator]() {
let count = 1,
limit = this.limit
return {
next() {
if (count <= limit) {
return { done: false, value: count++ }
} else {
return { done: true, value: undefined }
}
},
}
}
}
let counter = new Counter(3)
for (let i of counter) {
console.log(i)
}
// 1
// 2
// 3
for (let i of counter) {
console.log(i)
}
// 1
// 2
// 3
提前终止迭代器
可选的 return()方法用于指定在迭代器提前关闭时执行的逻辑。执行迭代的结构在想让迭代器知道它不想遍历到可迭代对象耗尽时,就可以“关闭”迭代器。可能的情况包括:
根据迭代器协议,一个迭代器对象需要实现 next() 方法。但某些场景下,迭代器还可以实现以下两个方法:
- return():当迭代被提前终止时调用(如 break、throw 或手动调用)。
- throw():当迭代过程中抛出错误时调用(较少使用)。
return() 方法的作用:
- 释放迭代器占用的资源(如关闭文件、清理内存)。
- 返回一个对象 { done: true, value: any },表示迭代终止。
- for...of 循环中使用 break 或 return:
const iterable = {
[Symbol.iterator]() {
let count = 0
return {
next() {
if (count < 3) return { value: count++, done: false }
return { done: true }
},
return() {
// 提前终止时调用
console.log('迭代器已终止')
return { done: true }
},
}
},
}
for (const num of iterable) {
console.log(num) // 0, 1
if (num === 1) break // 触发 return()
}
- 手动调用迭代器的 return():
const iterator = iterable[Symbol.iterator]();
console.log(iterator.next()); // { value: 0, done: false }
console.log(iterator.return("提前终止")); // { done: true, value: "提前终止" }
生成器
生成器的形式是一个函数,函数名称前面加一个星号(*)表示它是一个生成器。只要是可以定义函数的地方,就可以定义生成器。
// 生成器函数声明
function* generatorFn() {}
// 生成器函数表达式
let generatorFn = function* () {}
// 作为对象字面量方法的生成器函数
let foo = {
* generatorFn() {}
}
// 作为类实例方法的生成器函数
class Foo {
* generatorFn() {}
}
// 作为类静态方法的生成器函数
class Bar {
static * generatorFn() {}
}
// 等价的生成器函数:
function* generatorFnA() {}
function *generatorFnB() {}
function * generatorFnC() {}
// 等价的生成器方法:
class Foo {
*generatorFnD() {}
* generatorFnE() {}
}
调用生成器函数会产生一个生成器对象。生成器对象一开始处于暂停执行(suspended)的状态。与迭代器相似,生成器对象也实现了 Iterator 接口,因此具有 next()方法。调用这个方法会让生成器开始或恢复执行。
function* generatorFn() {}
const g = generatorFn()
console.log(g) // generatorFn {<suspended>}
console.log(g.next) // f next() { [native code] }
next()方法的返回值类似于迭代器,有一个 done 属性和一个 value 属性。函数体为空的生成器函数中间不会停留,调用一次 next()就会让生成器到达 done: true 状态。
function* generatorFn() {}
let generatorObject = generatorFn();
console.log(generatorObject); // generatorFn {<suspended>}
console.log(generatorObject.next()); // { done: true, value: undefined }
value 属性是生成器函数的返回值,默认值为 undefined,可以通过生成器函数的返回值指定:
function* generatorFn() {
return 'foo';
}
let generatorObject = generatorFn();
console.log(generatorObject); // generatorFn {<suspended>}
console.log(generatorObject.next()); // { done: true, value: 'foo' }
通过 yield 中断执行
yield 关键字可以让生成器停止和开始执行,也是生成器最有用的地方。生成器函数在遇到 yield 关键字之前会正常执行。遇到这个关键字后,执行会停止,函数作用域的状态会被保留。停止执行的生成器函数只能通过在生成器对象上调用 next()方法来恢复执行:
function* generatorFn() {
yield
}
let generatorObject = generatorFn()
console.log(generatorObject.next()) // { done: false, value: undefined }
console.log(generatorObject.next()) // { done: true, value: undefined }
yield 关键字只能在生成器函数内部使用,用在其他地方会抛出错误。类似函数的 return 关键字,yield 关键字必须直接位于生成器函数定义中,出现在嵌套的非生成器函数中会抛出语法错误:
// 有效
function* validGeneratorFn() {
yield
}
// 无效
function* invalidGeneratorFnA() {
function a() {
yield
}
}
// 无效
function* invalidGeneratorFnB() {
const b = () => {
yield
}
}
// 无效
function* invalidGeneratorFnC() {
;(() => {
yield
})()
}
- 生成器对象作为可迭代对象
在生成器对象上显式调用 next()方法的用处并不大。其实,如果把生成器对象当成可迭代对象,那么使用起来会更方便:
function* generatorFn() {
yield 1
yield 2
yield 3
}
for (const x of generatorFn()) {
console.log(x)
}
// 1
// 2
// 3
在需要自定义迭代对象时,这样使用生成器对象会特别有用。比如,我们需要定义一个可迭代对象,而它会产生一个迭代器,这个迭代器会执行指定的次数。使用生成器,可以通过一个简单的循环来实现:
// 传给生成器的函数可以控制迭代循环的次数。在 n 为 0 时,while 条件为假,循环退出,生成器函数返回。
function* nTimes(n){
while(n--){
yield;
}
}
for(let _ of nTimes(3)){
console.log('hello');
}
// hello
// hello
// hello
- 使用 yield 实现输入和输出
yield 关键字还可以作为函数的中间参数使用。上一次让生成器函数暂停的 yield 关键字会接收到传给 next()方法的第一个值。这里有个地方不太好理解——第一次调用 next()传入的值不会被使用,因为这一次调用是为了开始执行生成器函数:
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 关键字可以同时用于输入和输出,如下例所示:
function* generatorFn() {
return yield 'foo'
}
let generatorObject = generatorFn()
console.log(generatorObject.next()) // { done: false, value: 'foo' }
console.log(generatorObject.next('bar')) // { done: true, value: 'bar' }
因为函数必须对整个表达式求值才能确定要返回的值,所以它在遇到 yield 关键字时暂停执行并计算出要产生的值:"foo"。下一次调用 next()传入了"bar",作为交给同一个 yield 的值。然后这个值被确定为本次生成器函数要返回的值。
假设我们想定义一个生成器函数,它会根据配置的值迭代相应次数并产生迭代的索引。初始化一个新数组可以实现这个需求,但不用数组也可以实现同样的行为:
function* nTimes(n) {
let i = 0
while (n--) {
yield i++
}
}
for (let x of nTimes(3)) {
console.log(x)
}
// 0
// 1
// 2
- 产生可迭代对象
可以使用星号增强 yield 的行为,让它能够迭代一个可迭代对象,从而一次产出一个值:
与生成器函数的星号类似,yield 星号两侧的空格不影响其行为。
function* generatorFn() {
yield* [1, 2, 3]
}
let generatorObject = generatorFn()
for (const x of generatorFn()) {
console.log(x)
}
// 1
// 2
// 3
- 使用 yield*实现递归算法
yield*最有用的地方是实现递归操作,此时生成器可以产生自身。
function* nTimes(n) {
if (n > 0) {
yield* nTimes(n - 1)
yield n - 1
}
}
for (const x of nTimes(3)) {
console.log(x)
}
在这个例子中,每个生成器首先都会从新创建的生成器对象产出每个值,然后再产出一个整数。结果就是生成器函数会递归地减少计数器值,并实例化另一个生成器对象。从最顶层来看,这就相当于创建一个可迭代对象并返回递增的整数。
生成器作为默认迭代器
因为生成器对象实现了 Iterable 接口,而且生成器函数和默认迭代器被调用之后都产生迭代器,所以生成器格外适合作为默认迭代器。
class Foo {
constructor() {
this.values = [1, 2, 3]
}
*[Symbol.iterator]() {
yield* this.values
}
}
const f = new Foo()
for (const x of f) {
console.log(x)
}
这里,for-of 循环调用了默认迭代器(它恰好又是一个生成器函数)并产生了一个生成器对象。这个生成器对象是可迭代的,所以完全可以在迭代中使用。
提前终止生成器
- return()
return()方法会强制生成器进入关闭状态。这个方法有一个可选参数,返回的值就是生成器最终完成时返回的值。
function* generatorFn() {
for (const x of [1, 2, 3]) {
yield x
}
}
const g = generatorFn()
console.log(g) // generatorFn {<suspended>}
console.log(g.return(4)) // { done: true, value: 4 }
与迭代器不同,所有生成器对象都有 return()方法,只要通过它进入关闭状态,就无法恢复了。后续调用 next()会显示 done: true 状态,而提供的任何返回值都不会被存储或传播:
function* generatorFn() {
for (const x of [1, 2, 3]) {
yield x
}
}
const g = generatorFn()
console.log(g.next()) // { done: false, value: 1 }
console.log(g.return(4)) // { done: true, value: 4 }
console.log(g.next()) // { done: true, value: undefined }
console.log(g.next()) // { done: true, value: undefined }
console.log(g.next()) // { done: true, value: undefined }
- throw()
throw()方法会在暂停的时候将一个提供的错误注入到生成器对象中。如果错误未被处理,生成器就会关闭。
function* generatorFn() {
for (const x of [1, 2, 3]) {
yield x
}
}
const g = generatorFn()
console.log(g) // generatorFn {<suspended>}
try {
g.throw('foo')
} catch (e) {
console.log(e) // foo
}
console.log(g) // generatorFn {<closed>}
小结
迭代器是一个可以由任意对象实现的接口,支持连续获取对象产出的每一个值。任何实现 Iterable 接口的对象都有一个 Symbol.iterator 属性,这个属性引用默认迭代器。默认迭代器就像一个迭代器工厂,也就是一个函数,调用之后会产生一个实现 Iterator 接口的对象。
迭代器必须通过连续调用 next()方法才能连续取得值,这个方法返回一个 IteratorObject。这个对象包含一个 done 属性和一个 value 属性。前者是一个布尔值,表示是否还有更多值可以访问;后者包含迭代器返回的当前值。这个接口可以通过手动反复调用 next()方法来消费,也可以通过原生消费者,比如 for-of 循环来自动消费。
生成器是一种特殊的函数,调用后会返回一个生成器对象。生成器对象实现了 Iterable 接口,因此可用在任何消费可迭代对象的地方。生成器的独特之处在于支持 yield 关键字,这个关键字能够暂停执行生成器函数。使用 yield 关键字还可以通过 next()方法接收输入和产生输出。在加上星号之后,yield 关键字可以将跟在它后面的可迭代对象序列化为一连串值。
第 8 章 对象、类与面向对象编程
理解对象
- 数据属性
数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4 个特性描述它们的行为。
- [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
- [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
- [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
- [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为 undefined。
在像前面例子中那样将属性显式添加到对象之后,[[Configurable]]、[[Enumerable]]和[[Writable]]都会被设置为 true,而[[Value]]特性会被设置为指定的值。
let person = {
name: 'Nicholas',
}
Object.defineProperty():方法可以修改某个属性的默认特性。这个方法接收 3 个参数:属性所在的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。
let person = {}
Object.defineProperty(person, 'name', {
writable: false,
configurable: false,
value: 'Nicholas',
})
console.log(person.name) // "Nicholas"
person.name = 'Greg'
console.log(person.name) // "Nicholas"
delete person.name
console.log(person.name) // "Nicholas"
这个例子创建了一个名为 name 的属性并给它赋予了一个只读的值"Nicholas"。这个属性的值就不能再修改了,在非严格模式下尝试给这个属性重新赋值会被忽略。
这个例子把 configurable 设置为 false,意味着这个属性不能从对象上删除。非严格模式下对这个属性调用 delete 没有效果,严格模式下会抛出错误。
在调用 Object.defineProperty()时,configurable、enumerable 和 writable 的值如果不指定,则都默认为 false。多数情况下,可能都不需要 Object.defineProperty()提供的这些强大的设置。
- 访问器属性
访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。访问器属性有 4 个特性描述它们的行为。
- [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Get]]:获取函数,在读取属性时调用。默认值为 undefined。
- [[Set]]:设置函数,在写入属性时调用。默认值为 undefined。
访问器属性是不能直接定义的,必须使用 Object.defineProperty()。
let book = {
year_: 2017,
edition: 1,
}
Object.defineProperty(book, 'year', {
get() {
return this.year_
},
set(newValue) {
if (newValue > 2017) {
this.year_ = newValue
this.edition += newValue - 2017
}
},
})
book.year = 2018
console.log(book.edition) // 2
定义多个属性
在一个对象上同时定义多个属性的可能性是非常大的。为此,提供了 Object.defineProperties()。这个方法可以通过多个描述符一次性定义多个属性。它接收两个参数:要为之添加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应。
let book = {}
Object.defineProperties(book, {
year_: {
value: 2017,
},
edition: {
value: 1,
},
year: {
get() {
return this.year_
},
set(newValue) {
if (newValue > 2017) {
this.year_ = newValue
this.edition += newValue - 2017
}
},
},
})
代码创建了一个空对象 book ,然后定义了三个属性:
- year_ :一个数据属性,初始值为 2017
- edition :一个数据属性,初始值为 1
- year :一个访问器属性,包含 getter 和 setter 方法
当设置 book.year = 2018 时,setter 方法被调用,它检查新值是否大于 2017,如果是,则更新 year_ 属性并增加 edition 属性的值(增加的值等于新年份与 2017 的差值)。
读取属性的特性
Object.getOwnPropertyDescriptor():方法可以取得指定属性的属性描述符。这个方法接收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象,对于访问器属性包含 configurable、enumerable、get 和 set 属性,对于数据属性包含 configurable、enumerable、writable 和 value 属性。
let book = {}
Object.defineProperties(book, {
year_: {
value: 2017,
},
edition: {
value: 1,
},
year: {
get: function () {
return this.year_
},
set: function (newValue) {
if (newValue > 2017) {
this.year_ = newValue
this.edition += newValue - 2017
}
},
},
})
let descriptor = Object.getOwnPropertyDescriptor(book, 'year_')
console.log(descriptor.value) // 2017
console.log(descriptor.configurable) // false
console.log(typeof descriptor.get) // "undefined"
let descriptor = Object.getOwnPropertyDescriptor(book, 'year')
console.log(descriptor.value) // undefined
console.log(descriptor.enumerable) // false
console.log(typeof descriptor.get) // "function"
对于数据属性 year_,value 等于原来的值,configurable 是 false,get 是 undefined。对于访问器属性 year,value 是 undefined,enumerable 是 false,get 是一个指向获取函数的指针。
合并对象
Object.assign():方法可以把任意多个的源对象自身的可枚举属性拷贝给目标对象,然后返回目标对象。
let dest, src, result
/**
* 简单复制
*/
dest = {}
src = { id: 'src' }
result = Object.assign(dest, src)
// Object.assign 修改目标对象
// 也会返回修改后的目标对象
console.log(dest === result) // true
console.log(dest !== src) // true
console.log(result) // { id: src }
console.log(dest) // { id: src }
/**
* 多个源对象
*/
dest = {}
result = Object.assign(dest, { a: 'foo' }, { b: 'bar' })
console.log(result) // { a: foo, b: bar }
/**
* 获取函数与设置函数
*/
dest = {
set a(val) {
console.log(`Invoked dest setter with param ${val}`)
},
}
src = {
get a() {
console.log('Invoked src getter')
return 'foo'
},
}
Object.assign(dest, src)
// 调用 src 的获取方法
// 调用 dest 的设置方法并传入参数"foo"
// 因为这里的设置函数不执行赋值操作
// 所以实际上并没有把值转移过来
console.log(dest) // { set a(val) {...} }
Object.assign()实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。此外,从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目标对象。换句话说,不能在两个对象间转移获取函数和设置函数。
let dest, src, result
/**
* 覆盖属性
*/
dest = { id: 'dest' }
result = Object.assign(dest, { id: 'src1', a: 'foo' }, { id: 'src2', b: 'bar' })
// Object.assign 会覆盖重复的属性
console.log(result) // { id: src2, a: foo, b: bar }
// 可以通过目标对象上的设置函数观察到覆盖的过程:
dest = {
set id(x) {
console.log(x)
},
}
Object.assign(dest, { id: 'first' }, { id: 'second' }, { id: 'third' })
// first
// second
// third
/**
* 对象引用
*/
dest = {}
src = { a: {} }
Object.assign(dest, src)
// 浅复制意味着只会复制对象的引用
console.log(dest) // { a :{} }
console.log(dest.a === src.a) // true
对象标识及相等判定
ECMAScript 6 之前,有些特殊情况即使是===操作符也无能为力:
// 这些情况在不同 JavaScript 引擎中表现不同,但仍被认为相等
console.log(+0 === -0) // true
console.log(+0 === 0) // true
console.log(-0 === 0) // true
// 要确定 NaN 的相等性,必须使用极为讨厌的 isNaN()
console.log(NaN === NaN) // false
console.log(isNaN(NaN)) // true
Object.is(),ECMAScript 6 规范新增了这个方法,与===很像,但同时也考虑到了上述边界情形。
console.log(Object.is(true, 1)); // false
console.log(Object.is({}, {})); // false
console.log(Object.is("2", 2)); // false
// 正确的 0、-0、+0 相等/不等判定
console.log(Object.is(+0, -0)); // false
console.log(Object.is(+0, 0)); // true
console.log(Object.is(-0, 0)); // false
// 正确的 NaN 相等判定
console.log(Object.is(NaN, NaN)); // true
要检查超过两个值,递归地利用相等性传递即可:
function recursivelyCheckEqual(x, ...rest) {
return Object.is(x, rest[0]) &&
(rest.length < 2 || recursivelyCheckEqual(...rest));
}
增强的对象语法
- 属性值简写
简写属性名只要使用变量名(不用再写冒号)就会自动被解释为同名的属性键。如果没有找到同名变量,则会抛出 ReferenceError。
let name = 'Matt'
let person = {
name,
}
console.log(person) // { name: 'Matt' }
- 可计算属性
可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时将其作为 JavaScript 表达式而不是字符串来求值。
因为被当作 JavaScript 表达式求值,所以可计算属性本身可以是复杂的表达式,在实例化时再求值。
const nameKey = 'name'
const ageKey = 'age'
const jobKey = 'job'
let uniqueToken = 0
function getUniqueKey(key) {
return `${key}_${uniqueToken++}`
}
let person = {
[getUniqueKey(nameKey)]: 'Matt',
[getUniqueKey(ageKey)]: 27,
[getUniqueKey(jobKey)]: 'Software engineer',
}
console.log(person) // { name_0: 'Matt', age_1: 27, job_2: 'Software engineer' }
- 简写方法名
开发者要放弃给函数表达式命名(不过给作为方法的函数命名通常没什么用)。相应地,这样也可以明显缩短方法声明。
let person = {
sayName: function (name) {
console.log(`My name is ${name}`)
},
}
person.sayName('Matt') // My name is Matt
// 和之前的代码行为上是等价的
let person = {
sayName(name) {
console.log(`My name is ${name}`)
},
}
person.sayName('Matt') // My name is Matt
对象解构
ECMAScript 6 新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。
解构赋值不一定与对象的属性匹配。赋值的时候可以忽略某些属性,而如果引用的属性不存在,则该变量的值就是 undefined。
解构赋值的同时定义默认值,这适用于前面刚提到的引用的属性不存在于源对象中的情况。
let person = {
name: 'Matt',
age: 27,
}
let { name, age } = person
console.log(name) // Matt
console.log(age) // 27
console.log(sex) // undefined
let person = {
name: 'Matt',
age: 27,
}
let { name, job = 'Software engineer' } = person
console.log(name) // Matt
console.log(job) // Software engineer
- 嵌套解构
解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性。
let person = {
name: 'Matt',
age: 27,
job: {
title: 'Software engineer',
},
}
// 声明 title 变量并将 person.job.title 的值赋给它
let {
job: { title },
} = person
console.log(title) // Software engineer
- 部分解构
涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分:
let person = {
name: 'Matt',
age: 27,
}
let personName, personBar, personAge
try {
// person.foo 是 undefined,因此会抛出错误
;({
name: personName,
foo: { bar: personBar },
age: personAge,
} = person)
} catch (e) {}
console.log(personName, personBar, personAge)
// Matt, undefined, undefined
- 参数上下文匹配
在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响 arguments 对象,但可以在函数签名中声明在函数体内使用局部变量:
let person = {
name: 'Matt',
age: 27,
}
function printPerson(foo, { name, age }, bar) {
console.log(arguments)
console.log(name, age)
}
function printPerson2(foo, { name: personName, age: personAge }, bar) {
console.log(arguments)
console.log(personName, personAge)
}
printPerson('1st', person, '2nd')
// ['1st', { name: 'Matt', age: 27 }, '2nd']
// 'Matt', 27
printPerson2('1st', person, '2nd')
// ['1st', { name: 'Matt', age: 27 }, '2nd']
// 'Matt', 27
创建对象
ECMAScript 6 开始正式支持类和继承。ES6 的类旨在完全涵盖之前规范设计的基于原型的继承模式。不过,无论从哪方面看,ES6 的类都仅仅是封装了 ES5.1 构造函数加原型继承的语法糖而已。
工厂模式
工厂模式是一种众所周知的设计模式,广泛应用于软件工程领域,用于抽象创建特定对象的过程。
工厂模式的主要优点:
- 封装了对象创建的复杂性
- 可以批量创建结构相似的对象
- 客户端无需了解对象的具体创建细节
工厂模式的局限性:
- 创建的对象无法识别具体类型(都是 Object 类型)
- 每个对象都会创建重复的方法实例,造成内存浪费
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')
console.log(person1)
// {
// name: 'Nicholas',
// age: 29,
// job: 'Software Engineer',
// sayName: [Function (anonymous)]
// }
let person2 = createPerson('Greg', 27, 'Doctor')
console.log(person2)
// {
// name: 'Greg',
// age: 27,
// job: 'Doctor',
// sayName: [Function (anonymous)]
// }
构造函数模式
构造函数模式是一种用于创建特定类型对象的模式,它把对象的属性和方法封装在一个构造函数中,通过 new 操作符来创建对象。
构造函数的主要优点:
- 可以创建多个相同类型的对象
- 可以将对象的属性和方法封装在一个构造函数中
- 可以通过 new 操作符来创建对象
构造函数的局限性:
- 每个对象都会创建重复的方法实例,造成内存浪费
- 无法识别具体类型(都是 Object 类型)
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
- 构造函数也是函数
任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操作符调用的函数就是普通函数。
// 作为构造函数
let person = new Person('Nicholas', 29, 'Software Engineer')
person.sayName() // "Nicholas"
// 作为函数调用
Person('Greg', 27, 'Doctor') // 添加到 window 对象
window.sayName() // "Greg"
// 在另一个对象的作用域中调用
let o = new Object()
Person.call(o, 'Kristen', 25, 'Nurse')
o.sayName() // "Kristen"
- 构造函数的问题
构造函数模式虽然解决了创建特定类型对象的问题,但也存在一个问题,即每个方法都要在每个实例上重新创建一遍。
原型模式
每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型
function Person() {}
Person.prototype.name = 'Nicholas'
Person.prototype.age = 29
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
console.log(this.name)
}
let person1 = new Person()
person1.sayName() // "Nicholas"
let person2 = new Person()
person2.sayName() // "Nicholas"
console.log(person1.sayName == person2.sayName) // true
所有属性和 sayName()方法都直接添加到了 Person 的 prototype 属性上,构造函数体中什么也没有。 使用这种原型模式定义的属性和方法是由所有实例共享的。因此 person1 和 person2 访问的都是相同的属性和相同的 sayName()函数。
- 理解原型
只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。
在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自 Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。脚本中没有访问这个[[Prototype]]特性的标准方式,但 Firefox、Safari 和 Chrome 会在每个对象上暴露proto属性,通过这个属性可以访问对象的原型。在其他实现中,这个特性完全被隐藏了。
关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
正常的原型链都会终止于 Object 的原型对象。Object 原型的原型是 null。
构造函数、原型对象和实例,是 3 个完全不同的对象。
实例通过proto链接到原型对象,它实际上指向隐藏特性[[Prototype]]。
构造函数通过 prototype 属性链接到原型对象, 实例与构造函数没有直接联系,与原型对象有直接联系。
function Person() {}
console.log(typeof Person.prototype)
console.log(Person.prototype)
console.log(Person.prototype.constructor === Person) // true
console.log(Person.prototype.__proto__ === Object.prototype) // true
console.log(Person.prototype.__proto__.constructor === Object) // true
console.log(Person.prototype.__proto__.__proto__ === null) // true
console.log(person1 !== Person) // true
console.log(person1 !== Person.prototype) // true
console.log(Person.prototype !== Person) // true
- 原型层级
在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。
function Person() {}
Person.prototype.name = 'Nicholas'
Person.prototype.age = 29
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
console.log(this.name)
}
let person1 = new Person()
let person2 = new Person()
person1.name = 'Greg'
console.log(person1.name) // "Greg",来自实例
console.log(person2.name) // "Nicholas",来自原型
**hasOwnProperty()**方法用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自 Object 的,会在属性存在于调用它的对象实例上时返回 true
function Person() {}
Person.prototype.name = 'Nicholas'
Person.prototype.age = 29
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
console.log(this.name)
}
let person1 = new Person()
let person2 = new Person()
console.log(person1.hasOwnProperty('name')) // false
person1.name = 'Greg'
console.log(person1.name) // "Greg",来自实例
console.log(person1.hasOwnProperty('name')) // true
console.log(person2.name) // "Nicholas",来自原型
console.log(person2.hasOwnProperty('name')) // false
delete person1.name
console.log(person1.name) // "Nicholas",来自原型
console.log(person1.hasOwnProperty('name')) // false
- 原型与 in 操作符
in 操作符会在通过对象能够访问给定属性时返回 true,无论该属性存在于实例中还是原型中。
- 在单独使用时,in 操作符会在可以通过对象访问指定属性时返回 true,无论该属性是在实例上还是在原型上。
- 在 for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。
function Person() {}
Person.prototype.name = 'Nicholas'
Person.prototype.age = 29
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
console.log(this.name)
}
let person1 = new Person()
let person2 = new Person()
console.log(person1.hasOwnProperty('name')) // false
console.log('name' in person1) // true
person1.name = 'Greg'
console.log(person1.name) // "Greg",来自实例
console.log(person1.hasOwnProperty('name')) // true
console.log('name' in person1) // true
console.log(person2.name) // "Nicholas",来自原型
console.log(person2.hasOwnProperty('name')) // false
console.log('name' in person2) // true
delete person1.name
console.log(person1.name) // "Nicholas",来自原型
console.log(person1.hasOwnProperty('name')) // false
console.log('name' in person1) // true
function Person() {}
Person.prototype.name = 'Nicholas'
Person.prototype.age = 29
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
console.log(this.name)
}
let keys = Object.keys(Person.prototype)
console.log(keys) // "name,age,job,sayName"
let p1 = new Person()
p1.name = 'Rob'
p1.age = 31
let p1keys = Object.keys(p1)
console.log(p1keys) // "[name,age]"
- 属性枚举顺序
Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和 Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。
let k1 = Symbol('k1'),
k2 = Symbol('k2')
let o = {
1: 1,
first: 'first',
[k1]: 'sym2',
second: 'second',
0: 0,
}
o[k2] = 'sym2'
o[3] = 3
o.third = 'third'
o[2] = 2
console.log(Object.getOwnPropertyNames(o))
// ["0", "1", "2", "3", "first", "second", "third"]
console.log(Object.getOwnPropertySymbols(o))
// [Symbol(k1), Symbol(k2)]
对象迭代
- Object.values()返回对象值的数组
- Object.entries()返回键/值对的数组
const o = {
foo: 'bar',
baz: 1,
qux: {},
}
console.log(Object.values(o)) // [ 'bar', 1, {} ]
console.log(Object.entries(o)) // [["foo", "bar"], ["baz", 1], ["qux", {}]]
原型的问题:。首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共享特性。真正的问题来自包含引用值的属性。
function Person() {}
Person.prototype = {
constructor: Person,
name: 'Nicholas',
age: 29,
job: 'Software Engineer',
friends: ['Shelby', 'Court'],
sayName() {
console.log(this.name)
},
}
let person1 = new Person()
let person2 = new Person()
person1.friends.push('Van')
console.log(person1.friends) // "Shelby,Court,Van"
console.log(person2.friends) // "Shelby,Court,Van"
console.log(person1.friends === person2.friends) // true
继承
原型链
其基本思想就是通过原型继承多个引用类型的属性和方法。
构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。 如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想。
function SuperType() {
this.property = true
}
SuperType.prototype.getSuperValue = function () {
return this.property
}
function SubType() {
this.subproperty = false
}
// 继承SuperType
SubType.prototype = new SuperType()
SubType.prototype.getSubValue = function () {
return this.subproperty
}
let instance = new SubType()
console.log(instance.getSuperValue()) // true
SubType 通过创建 SuperType 的实例并将其赋值给自己的原型 SubTtype.prototype 实现了对 SuperType 的继承。
原型链扩展了前面描述的原型搜索机制。我们知道,在读取实例上的属性时,首先会在实例上搜索这个属性。如果没找到,则会继承搜索实例的原型。在通过原型链实现继承之后,搜索就可以继承向上,搜索原型的原型。
对前面的例子而言,调用 instance.getSuperValue()经过了 3 步搜索:instance、SubType.prototype 和 SuperType.prototype,最后一步才找到这个方法。对属性和方法的搜索会一直持续到原型链的末端。
- 默认原型
默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向 Object.prototype。这也是为什么自定义类型能够继承包括 toString()、valueOf()在内的所有默认方法的原因。
- 原型与继承关系
原型与实例的关系可以通过两种方式来确定。第一种方式是使用 instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则 instanceof 返回 true。
console.log(instance instanceof Object) // true
console.log(instance instanceof SuperType) // true
console.log(instance instanceof SubType) // true
第二种方式是使用 isPrototypeOf()方法。原型链中的每个原型都可以调用这个方法
console.log(Object.prototype.isPrototypeOf(instance)) // true
console.log(SuperType.prototype.isPrototypeOf(instance)) // true
console.log(SubType.prototype.isPrototypeOf(instance)) // true
- 原型链的问题
主要问题出现在原型中包含引用值的时候。前面在谈到原型的问题时也提到过,原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。
function SuperType() {
this.colors = ['red', 'blue', 'green']
}
function SubType() {}
// 继承 SuperType
SubType.prototype = new SuperType()
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,black"
盗用构造函数
基本思路很简单:在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用 apply()和 call()方法以新创建的对象为上下文执行构造函数。
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"
通过使用 call()(或 apply())方法,SuperType 构造函数在为 SubType 的实例创建的新对象的上下文中执行了。这相当于新的 SubType 对象上运行了 SuperType()函数中的所有初始化代码。结果就是每个实例都会有自己的 colors 属性。
- 传递参数:盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。
function SuperType(name) {
this.name = name
}
function SubType() {
// 继承 SuperType 并传参
SuperType.call(this, 'Nicholas')
// 实例属性
this.age = 29
}
let instance = new SubType()
console.log(instance.name) // "Nicholas";
console.log(instance.age) // 29
- 盗用构造函数的问题:
- 方法都在构造函数中定义,因此函数复用就无从谈起了。
- 子类不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。
组合继承
组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。
组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。而且组合继承也保留了 instanceof 操作符和 isPrototypeOf()方法识别合成对象的能力。
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
原型式继承
你有一个对象,想在它的基础上再创建一个新对象。你需要把这个对象先传给 object(),然后再对返回的对象进行适当修改。、
**Object.create()**方法将原型式继承的概念规范化了,这个方法接收两个参数:
- 作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。
- 第二个参数与 Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。
let person = {
name: 'Nicholas',
friends: ['Shelby', 'Court', 'Van'],
}
let anotherPerson = Object.create(person)
anotherPerson.name = 'Greg'
anotherPerson.friends.push('Rob')
let yetAnotherPerson = Object.create(person)
yetAnotherPerson.name = 'Linda'
yetAnotherPerson.friends.push('Barbie')
console.log(person.friends) // "Shelby,Court,Van,Rob,Barbie"
let person = {
name: 'Nicholas',
friends: ['Shelby', 'Court', 'Van'],
}
let anotherPerson = Object.create(person, {
name: {
value: 'Greg',
},
})
console.log(anotherPerson.name) // "Greg"
寄生式继承
寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种形式来做增强对象,最后返回对象。
function createAnother(original) {
let clone = object(original) // 通过调用函数创建一个新对象
clone.sayHi = function () {
// 以某种方式增强这个对象
console.log('hi')
}
return clone // 返回这个对象
}
let person = {
name: 'Nicholas',
friends: ['Shelby', 'Court', 'Van'],
}
let anotherPerson = createAnother(person)
anotherPerson.sayHi() // "hi"
寄生式组合继承
寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。
function inheritPrototype(subType, superType) {
let prototype = object(superType.prototype) // 创建对象
prototype.constructor = subType // 增强对象
subType.prototype = prototype // 赋值对象
}
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
}
inheritPrototype(SubType, SuperType)
SubType.prototype.sayAge = function () {
console.log(this.age)
}
这个 inheritPrototype()函数实现了寄生式组合继承的核心逻辑。这个函数接收两个参数:子类构造函数和父类构造函数。在这个函数内部,第一步是创建父类原型的一个副本。然后,给返回的 prototype 对象设置 constructor 属性,解决由于重写原型导致默认 constructor 丢失的问题。最后将新创建的对象赋值给子类型的原型。
类
类定义
定义类也有两种主要方式:类声明和类表达式。这两种方式都使用 class 关键字加大括号:
// 类声明
class Person {}
// 类表达式
const Animal = class {}
类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。空的类定义照样有效。
// 空类定义,有效
class Foo {}
// 有构造函数的类,有效
class Bar {
constructor() {}
}
// 有获取函数的类,有效
class Baz {
get myBaz() {}
}
// 有静态方法的类,有效
class Qux {
static myQux() {}
} ```
类构造函数
- 实例化
使用 new 操作符实例化 Person 的操作等于使用 new 调用其构造函数。使用 new 调用类的构造函数会执行如下操作。
(1) 在内存中创建一个新对象。 (2) 这个新对象内部的[[Prototype]]指针被赋值为构造函数的 prototype 属性。 (3) 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。 (4) 执行构造函数内部的代码(给新对象添加属性)。 (5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
class Animal {}
class Person {
constructor() {
console.log('person ctor')
}
}
class Vegetable {
constructor() {
this.color = 'orange'
}
}
let a = new Animal()
let p = new Person() // person ctor
let v = new Vegetable()
console.log(v.color) // orange
类构造函数与构造函数的主要区别是,调用类构造函数必须使用 new 操作符。而普通构造函数如果不使用 new 调用,那么就会以全局的 this(通常是 window)作为内部对象。调用类构造函数时如果忘了使用 new 则会抛出错误。
- 把类当成特殊函数
类可以被当做函数使用。类的所有方法都会定义在类的 prototype 属性上。当把类作为函数调用时,其行为与构造函数调用完全一致。
class Person {}
console.log(Person) // class Person {}
console.log(typeof Person) // function
class Person {}
let p = new Person()
console.log(p instanceof Person) // true
实例、原型和类成员
- 实例成员
每次通过 new 调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this)添加“自有”属性。至于添加什么样的属性,则没有限制。另外,在构造函数执行完毕后,仍然可以给实例继续添加新成员。
class Person {
constructor() {
// 这个例子先使用对象包装类型定义一个字符串
// 为的是在下面测试两个对象的相等性
this.name = new String('Jack')
this.sayName = () => console.log(this.name)
this.nicknames = ['Jake', 'J-Dog']
}
}
let p1 = new Person(),
p2 = new Person()
p1.sayName() // Jack
p2.sayName() // Jack
console.log(p1.name === p2.name) // false
console.log(p1.sayName === p2.sayName) // false
console.log(p1.nicknames === p2.nicknames) // false
p1.name = p1.nicknames[0]
p2.name = p2.nicknames[1]
p1.sayName() // Jake
p2.sayName() // J-Dog
- 原型方法与访问器
class Person {
constructor() {
// 添加到 this 的所有内容都会存在于不同的实例上
this.locate = () => console.log('instance')
}
// 在类块中定义的所有内容都会定义在类的原型上
locate() {
console.log('prototype')
}
}
let p = new Person()
p.locate() // instance
Person.prototype.locate() // prototype
- 静态类方法
可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。静态类成员在类定义中使用 static 关键字作为前缀。在静态成员中,this 引用类自身。其他所有约定跟原型成员一样:
class Person {
constructor() {
// 添加到 this 的所有内容都会存在于不同的实例上
this.locate = () => console.log('instance', this)
}
// 定义在类的原型对象上
locate() {
console.log('prototype', this)
}
// 定义在类本身上
static locate() {
console.log('class', this)
}
}
let p = new Person()
p.locate() // instance, Person {}
Person.prototype.locate() // prototype, {constructor: ... }
Person.locate() // class, class Person {}
- 非函数原型和类成员
虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加:
class Person {
sayName() {
console.log(`${Person.greeting} ${this.name}`)
}
}
Person.greeting = 'my name is'
Person.prototype.name = 'jake'
let p = new Person()
p.sayName() // my name is jake
继承
ECMAScript 6 新增特性中最出色的一个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链。
- 继承基础
ES6 类支持单继承。使用 extends 关键字,就可以继承任何拥有[[Construct]]和原型的对象。很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数。
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
- 构造函数、HomeObject 和 super()
派生类的方法可以通过 super 关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。在类构造函数中使用 super 可以调用父类构造函数。
class Vehicle {
constructor() {
this.hasEngine = true
}
}
class Bus extends Vehicle {
constructor() {
// 不要在调用 super()之前引用 this,否则会抛出 ReferenceError
super() // 相当于 super.constructor()
console.log(this instanceof Vehicle) // true
console.log(this) // Bus { hasEngine: true }
}
}
new Bus()
在静态方法中可以通过 super 调用继承的类上定义的静态方法:
class Vehicle {
static identify() {
console.log('vehicle')
}
}
class Bus extends Vehicle {
static identify() {
super.identify()
}
}
Bus.identify() // vehicle
在使用 super 要注意的几个问题:
- super 只能在派生类构造函数和静态方法中使用。
- 不能单独引用 super 关键字,要么用它调用构造函数,要么用它引用静态方法。
- 调用 super()会调用父类构造函数,并将返回的实例赋值给 this。
- super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。
- 如果没有定义类构造函数,在实例化派生类时会调用 super(),而且会传入所有传给派生类的参数。
- 在类构造函数中,不能在调用 super()之前引用 this。
- 如果在派生类中显式定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回一个对象。
class Vehicle {
constructor() {
super();
// SyntaxError: 'super' keyword unexpected
}
}
class Vehicle {}
class Bus extends Vehicle {
constructor() {
console.log(super);
// SyntaxError: 'super' keyword unexpected here
}
}
class Vehicle {}
class Bus extends Vehicle {
constructor() {
super();
console.log(this instanceof Vehicle);
}
}
new Bus(); // true
class Vehicle {
constructor(licensePlate) {
this.licensePlate = licensePlate;
}
}
class Bus extends Vehicle {
constructor(licensePlate) {
super(licensePlate);
}
}
console.log(new Bus('1337H4X')); // Bus { licensePlate: '1337H4X' }
class Vehicle {
constructor(licensePlate) {
this.licensePlate = licensePlate;
}
}
class Bus extends Vehicle {}
console.log(new Bus('1337H4X')); // Bus { licensePlate: '1337H4X' }
class Vehicle {}
class Bus extends Vehicle {
constructor() {
console.log(this);
}
}
new Bus();
// ReferenceError: Must call super constructor in derived class
// before accessing 'this' or returning from derived constructor
class Vehicle {}
class Car extends Vehicle {}
class Bus extends Vehicle {
constructor() {
super();
}
}
class Van extends Vehicle {
constructor() {
return {};
}
}
console.log(new Car()); // Car {}
console.log(new Bus()); // Bus {}
console.log(new Van()); // {}
- 抽象基类
有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在了,所以可以通过 this 关键字来检查相应的方法:
// 抽象基类
class Vehicle {
constructor() {
if (new.target === Vehicle) {
throw new Error('Vehicle cannot be directly instantiated')
}
if (!this.foo) {
throw new Error('Inheriting class must define foo()')
}
console.log('success!')
}
}
// 派生类
class Bus extends Vehicle {
foo() {}
}
// 派生类
class Van extends Vehicle {}
new Bus() // success!
new Van() // Error: Inheriting class must define foo()
- 继承内置类型
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]
总结
对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实体。下面的模式适用于创建对象。
- 工厂模式就是一个简单的函数,这个函数可以创建对象,为它添加属性和方法,然后返回这个对象。这个模式在构造函数模式出现后就很少用了。
- 使用构造函数模式可以自定义引用类型,可以使用 new 关键字像创建内置类型实例一样创建自定义类型的实例。不过,构造函数模式也有不足,主要是其成员无法重用,包括函数。考虑到函数本身是松散的、弱类型的,没有理由让函数不能在多个对象实例间共享。
- 原型模式解决了成员共享的问题,只要是添加到构造函数 prototype 上的属性和方法就可以共享。而组合构造函数和原型模式通过构造函数定义实例属性,通过原型定义共享的属性和方法。
JavaScript 的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。这样一来,子类就可以访问父类的所有属性和方法,就像基于类的继承那样。原型链的问题是所有继承的属性和方法都会在对象实例间共享,无法做到实例私有。盗用构造函数模式通过在子类构造函数中调用父类构造函数,可以避免这个问题。这样可以让每个实例继承的属性都是私有的,但要求类型只能通过构造函数模式来定义(因为子类不能访问父类原型上的方法)。目前最流行的继承模式是组合继承,即通过原型链继承共享的属性和方法,通过盗用构造函数继承实例属性。
- 原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。这种操作的结果之后还可以再进一步增强。
- 与原型式继承紧密相关的是寄生式继承,即先基于一个对象创建一个新对象,然后再增强这个新对象,最后返回新对象。这个模式也被用在组合继承中,用于避免重复调用父类构造函数导致的浪费。
- 寄生组合继承被认为是实现基于类型继承的最有效方式
第 9 章 代理与反射
代理是目标对象的抽象。从很多方面看,代理类似 C++指针,因为它可以用作目标对象的替身,但又完全独立于目标对象。目标对象既可以直接被操作,也可以通过代理来操作。但直接操作会绕过代理施予的行为。
代理
创建空代理
代理是使用 Proxy 构造函数创建的。这个构造函数接收两个参数:目标对象和处理程序对象。缺少其中任何一个参数都会抛出 TypeError。要创建空代理,可以传一个简单的对象字面量作为处理程序对象,从而让所有操作畅通无阻地抵达目标对象。
const target = {
id: 'target',
}
const handler = {}
const proxy = new Proxy(target, handler)
// id 属性会访问同一个值
console.log(target.id) // target
console.log(proxy.id) // target
// 给目标属性赋值会反映在两个对象上
// 因为两个对象访问的是同一个值
target.id = 'foo'
console.log(target.id) // foo
console.log(proxy.id) // foo
// 给代理属性赋值会反映在两个对象上
// 因为这个赋值会转移到目标对象
proxy.id = 'bar'
console.log(target.id) // bar
console.log(proxy.id) // bar
// hasOwnProperty()方法在两个地方
// 都会应用到目标对象
console.log(target.hasOwnProperty('id')) // true
console.log(proxy.hasOwnProperty('id')) // true
// Proxy.prototype 是 undefined
// 因此不能使用 instanceof 操作符
console.log(target instanceof Proxy) // TypeError: Function has non-object prototype 'undefined' in instanceof check
console.log(proxy instanceof Proxy) // TypeError: Function has non-object prototype 'undefined' in instanceof check
// 严格相等可以用来区分代理和目标
console.log(target === proxy) // false
定义捕获器
捕获器就是在处理程序对象中定义的“基本操作的拦截器”。每个处理程序对象可以包含零个或多个捕获器,每个捕获器都对应一种基本操作,可以直接或间接在代理对象上调用。每次在代理对象上调用这些基本操作时,代理可以在这些操作传播到目标对象之前先调用捕获器函数,从而拦截并修改相应的行为。
const target = {
foo: 'bar',
}
const handler = {
// 捕获器在处理程序对象中以方法名为键
get() {
return 'handler override'
},
}
const proxy = new Proxy(target, handler)
console.log(target.foo) // bar
console.log(proxy.foo) // handler override
console.log(target['foo']) // bar
console.log(proxy['foo']) // handler override
console.log(Object.create(target)['foo']) // bar
console.log(Object.create(proxy)['foo']) // handler override
捕获器参数和反射 API
所有捕获器都可以访问相应的参数,基于这些参数可以重建被捕获方法的原始行为。比如,get()捕获器会接收到目标对象、要查询的属性和代理对象三个参数。
处理程序对象中所有可以捕获的方法都有对应的反射(Reflect)API 方法。这些方法与捕获器拦截的方法具有相同的名称和函数签名,而且也具有与被拦截方法相同的行为。因此,使用反射 API 也可以像下面这样定义出空代理对象:
const target = {
foo: 'bar',
}
const handler = {
get() {
return Reflect.get(...arguments)
},
}
const proxy = new Proxy(target, handler)
console.log(proxy.foo) // bar
console.log(target.foo) // bar
如果真想创建一个可以捕获所有方法,然后将每个方法转发给对应反射 API 的空代理,那么甚至不需要定义处理程序对象:
const target = {
foo: 'bar',
}
const proxy = new Proxy(target, Reflect)
console.log(proxy.foo) // bar
console.log(target.foo) // bar
反射 API 为开发者准备好了样板代码,在此基础上开发者可以用最少的代码修改捕获的方法。下面的代码在某个属性被访问时,会对返回的值进行一番修饰:
const target = {
foo: 'bar',
baz: 'qux',
}
const handler = {
get(trapTarget, property, receiver) {
let decoration = ''
if (property === 'foo') {
decoration = '!!!'
}
return Reflect.get(...arguments) + decoration
},
}
const proxy = new Proxy(target, handler)
console.log(proxy.foo) // bar!!!
console.log(target.foo) // bar
console.log(proxy.baz) // qux
console.log(target.baz) // qux
捕获器不变式
根据 ECMAScript 规范,每个捕获的方法都知道目标对象上下文、捕获函数签名,而捕获处理程序的行为必须遵循“捕获器不变式”(trap invariant)。捕获器不变式因方法不同而异,但通常都会防止捕获器定义出现过于反常的行为。
如果目标对象有一个不可配置且不可写的数据属性,那么在捕获器返回一个与该属性不同的值时,会抛出 TypeError:
const target = {}
Object.defineProperty(target, 'foo', {
configurable: false,
writable: false,
value: 'bar',
})
const handler = {
get() {
return 'qux'
},
}
const proxy = new Proxy(target, handler)
console.log(proxy.foo) // TypeError
可撤销代理
Proxy 也暴露了 revocable()方法,这个方法支持撤销代理对象与目标对象的关联。撤销代理的操作是不可逆的。而且,撤销函数(revoke())是幂等的,调用多少次的结果都一样。撤销代理之后再调用代理会抛出 TypeError。
// 撤销函数和代理对象是在实例化时同时生成的
const target = {
foo: 'bar',
}
const handler = {
get() {
return 'intercepted'
},
}
const { proxy, revoke } = Proxy.revocable(target, handler)
console.log(proxy.foo) // intercepted
console.log(target.foo) // bar
revoke()
console.log(proxy.foo) // TypeError
实用反射 API
反射 API 与对象 API。在使用反射 API 时,要记住:
(1) 反射 API 并不限于捕获处理程序; (2) 大多数反射 API 方法在 Object 类型上有对应的方法。
通常,Object 上的方法适用于通用程序,而反射方法适用于细粒度的对象控制与操作。
- 状态标记
很多反射方法返回称作“状态标记”的布尔值,表示意图执行的操作是否成功。有时候,状态标记比那些返回修改后的对象或者抛出错误(取决于方法)的反射 API 方法更有用。例如,可以使用反射 API 对下面的代码进行重构:
// 初始代码
const o = {}
try {
Object.defineProperty(o, 'foo', 'bar')
console.log('success')
} catch (e) {
console.log('failure')
}
// 重构后的代码
const o = {}
if (Reflect.defineProperty(o, 'foo', { value: 'bar' })) {
console.log('success')
} else {
console.log('failure')
}
以下反射方法都会提供状态标记:
- Reflect.defineProperty()
- Reflect.preventExtensions()
- Reflect.setPrototypeOf()
- Reflect.set()
- Reflect.deleteProperty()
- 用一等函数替代操作符 以下反射方法提供只有通过操作符才能完成的操作。
- Reflect.get():可以替代对象属性访问操作符。
- Reflect.set():可以替代=赋值操作符。
- Reflect.has():可以替代 in 操作符或 with()。
- Reflect.deleteProperty():可以替代 delete 操作符。
- Reflect.construct():可以替代 new 操作符。
- 安全地应用函数
在通过 apply 方法调用函数时,被调用的函数可能也定义了自己的 apply 属性(虽然可能性极小)。为绕过这个问题,可以使用定义在 Function 原型上的 apply 方法,比如: Function.prototype.apply.call(myFunc, thisVal, argumentList);
这种可怕的代码完全可以使用 Reflect.apply 来避免: Reflect.apply(myFunc, thisVal, argumentsList);
代理另一个代理
代理可以拦截反射 API 的操作,而这意味着完全可以创建一个代理,通过它去代理另一个代理。这样就可以在一个目标对象之上构建多层拦截网:
const target = {
foo: 'bar',
}
const firstProxy = new Proxy(target, {
get() {
console.log('first proxy')
return Reflect.get(...arguments)
},
})
const secondProxy = new Proxy(firstProxy, {
get() {
console.log('second proxy')
return Reflect.get(...arguments)
},
})
console.log(secondProxy.foo)
// second proxy
// first proxy
// bar
代理的问题与不足
- 代理中的 this
代理潜在的一个问题来源是 this 值。我们知道,方法中的 this 通常指向调用这个方法的对象:
const wm = new WeakMap()
class User {
constructor(userId) {
wm.set(this, userId)
}
set id(userId) {
wm.set(this, userId)
}
get id() {
return wm.get(this)
}
}
// 由于这个实现依赖 User 实例的对象标识,在这个实例被代理的情况下就会出问题:
const user = new User(123)
console.log(user.id) // 123
const userInstanceProxy = new Proxy(user, {})
console.log(userInstanceProxy.id) // undefined
这是因为 User 实例一开始使用目标对象作为 WeakMap 的键,代理对象却尝试从自身取得这个实例。要解决这个问题,就需要重新配置代理,把代理 User 实例改为代理 User 类本身。之后再创建代理的实例就会以代理实例作为 WeakMap 的键了:
const UserClassProxy = new Proxy(User, {})
const proxyUser = new UserClassProxy(456)
console.log(proxyUser.id)
- 代理与内部槽位
代理与内置引用类型(比如 Array)的实例通常可以很好地协同,但有些 ECMAScript 内置类型可能会依赖代理无法控制的机制,结果导致在代理上调用某些方法会出错。
const target = new Date()
const proxy = new Proxy(target, {})
console.log(proxy instanceof Date) // true
proxy.getDate() // TypeError: 'this' is not a Date object
代理捕获器与反射方法
get()
get()捕获器会在获取属性值的操作中被调用。对应的反射 API 方法为 Reflect.get()。
const myTarget = {}
const proxy = new Proxy(myTarget, {
get(target, property, receiver) {
console.log('get()')
return Reflect.get(...arguments)
},
})
proxy.foo
// get()
返回值 返回值无限制。
拦截的操作
- proxy.property
- proxy[property]
- Object.create(proxy)[property]
- Reflect.get(proxy, property, receiver)
捕获器处理程序参数
- target:目标对象。
- property:引用的目标对象上的字符串键属性。①
- receiver:代理对象或继承代理对象的对象。
捕获器不变式 如果 target.property 不可写且不可配置,则处理程序返回的值必须与 target.property 匹配。 如果 target.property 不可配置且[[Get]]特性为 undefined,处理程序的返回值也必须是 undefined
set()
set()捕获器会在设置属性值的操作中被调用。对应的反射 API 方法为 Reflect.set()。
const myTarget = {}
const proxy = new Proxy(myTarget, {
set(target, property, value, receiver) {
console.log('set()')
return Reflect.set(...arguments)
},
})
proxy.foo = 'bar'
// set()
返回值:返回 true 表示成功;返回 false 表示失败,严格模式下会抛出 TypeError。
拦截的操作
- proxy.property = value
- proxy[property] = value
- Object.create(proxy)[property] = value
- Reflect.set(proxy, property, value, receiver)
- 捕获器处理程序参数
- target:目标对象。
- property:引用的目标对象上的字符串键属性。
- value:要赋给属性的值。
- receiver:接收最初赋值的对象。
- 捕获器不变式
如果 target.property 不可写且不可配置,则不能修改目标属性的值。 如果 target.property 不可配置且[[Set]]特性为 undefined,则不能修改目标属性的值。 在严格模式下,处理程序中返回 false 会抛出 TypeError。
has()
has()捕获器会在 in 操作符中被调用。对应的反射 API 方法为 Reflect.has()。
const myTarget = {}
const proxy = new Proxy(myTarget, {
has(target, property) {
console.log('has()')
return Reflect.has(...arguments)
},
})
'foo' in proxy
// has()
返回值:has()必须返回布尔值,表示属性是否存在。返回非布尔值会被转型为布尔值。
拦截的操作
- property in proxy
- property in Object.create(proxy)
- Reflect.has(proxy, property)
捕获器处理程序参数
- target:目标对象。
- property:引用的目标对象上的字符串键属性。
- 捕获器不变式 如果 target.property 存在且不可配置,则处理程序必须返回 true。 如果 target.property 存在且目标对象不可扩展,则处理程序必须返回 true。
defineProperty()
defineProperty()捕获器会在 Object.defineProperty()中被调用。对应的反射 API 方法为 Reflect.defineProperty()。
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()
- 返回值 defineProperty()必须返回布尔值,表示属性是否成功定义。返回非布尔值会被转型为布尔值。
- 拦截的操作
- Object.defineProperty(proxy, property, descriptor)
- Reflect.defineProperty(proxy, property, descriptor)
- 捕获器处理程序参数
- target:目标对象。
- property:引用的目标对象上的字符串键属性。
- descriptor:包含可选的 enumerable、configurable、writable、value、get 和 set 定义的对象。
- 捕获器不变式 如果目标对象不可扩展,则无法定义属性。 如果目标对象有一个可配置的属性,则不能添加同名的不可配置属性。 如果目标对象有一个不可配置的属性,则不能添加同名的可配置属性。
getOwnPropertyDescriptor()
getOwnPropertyDescriptor()捕获器会在 Object.getOwnPropertyDescriptor()中被调用。对应的反射 API 方法为 Reflect.getOwnPropertyDescriptor()。
const myTarget = {}
const proxy = new Proxy(myTarget, {
getOwnPropertyDescriptor(target, property) {
console.log('getOwnPropertyDescriptor()')
return Reflect.getOwnPropertyDescriptor(...arguments)
},
})
Object.getOwnPropertyDescriptor(proxy, 'foo')
// getOwnPropertyDescriptor()
返回值:getOwnPropertyDescriptor()必须返回对象,或者在属性不存在时返回 undefined。
拦截的操作
- Object.getOwnPropertyDescriptor(proxy, property)
- Reflect.getOwnPropertyDescriptor(proxy, property)
- 捕获器处理程序参数
- target:目标对象。
- property:引用的目标对象上的字符串键属性。
- 捕获器不变式
如果自有的 target.property 存在且不可配置,则处理程序必须返回一个表示该属性存在的对象。 如果自有的 target.property 存在且可配置,则处理程序必须返回表示该属性可配置的对象。 如果自有的 target.property 存在且 target 不可扩展,则处理程序必须返回一个表示该属性存在的对象。 如果 target.property 不存在且 target 不可扩展,则处理程序必须返回 undefined 表示该属性不存在。 如果 target.property 不存在,则处理程序不能返回表示该属性可配置的对象。
deleteProperty()
deleteProperty()捕获器会在 delete 操作符中被调用。对应的反射 API 方法为 Reflect.deleteProperty()。
const myTarget = {}
const proxy = new Proxy(myTarget, {
deleteProperty(target, property) {
console.log('deleteProperty()')
return Reflect.deleteProperty(...arguments)
},
})
delete proxy.foo
// deleteProperty()
- 返回值 deleteProperty()必须返回布尔值,表示删除属性是否成功。返回非布尔值会被转型为布尔值。
- 拦截的操作
- delete proxy.property
- delete proxy[property]
- Reflect.deleteProperty(proxy, property)
- 捕获器处理程序参数
- target:目标对象。
- property:引用的目标对象上的字符串键属性。
- 捕获器不变式 如果自有的 target.property 存在且不可配置,则处理程序不能删除这个属性。
ownKeys()
ownKeys()捕获器会在 Object.keys()及类似方法中被调用。对应的反射 API 方法为 Reflect.ownKeys()。
const myTarget = {}
const proxy = new Proxy(myTarget, {
ownKeys(target) {
console.log('ownKeys()')
return Reflect.ownKeys(...arguments)
},
})
Object.keys(proxy)
// ownKeys()
- 返回值:ownKeys()必须返回包含字符串或符号的可枚举对象。
- 拦截的操作
- Object.getOwnPropertyNames(proxy)
- Object.getOwnPropertySymbols(proxy)
- Object.keys(proxy)
- Reflect.ownKeys(proxy)
- 捕获器处理程序参数
- target:目标对象。
- 捕获器不变式
返回的可枚举对象必须包含 target 的所有不可配置的自有属性。 如果 target 不可扩展,则返回可枚举对象必须准确地包含自有属性键。
getPrototypeOf()
getPrototypeOf()捕获器会在 Object.getPrototypeOf()中被调用。对应的反射 API 方法为 Reflect.getPrototypeOf()。
const myTarget = {}
const proxy = new Proxy(myTarget, {
getPrototypeOf(target) {
console.log('getPrototypeOf()')
return Reflect.getPrototypeOf(...arguments)
},
})
Object.getPrototypeOf(proxy)
// getPrototypeOf()
- 返回值:getPrototypeOf()必须返回对象或 null。
- 拦截的操作
- Object.getPrototypeOf(proxy)
- Reflect.getPrototypeOf(proxy)
- proxy.proto
- Object.prototype.isPrototypeOf(proxy)
- proxy instanceof Object
- 捕获器处理程序参数
- target:目标对象。
- 捕获器不变式
如果 target 不可扩展,则 Object.getPrototypeOf(proxy)唯一有效的返回值就是 Object.getPrototypeOf(target)的返回值。
setPrototypeOf()
setPrototypeOf()捕获器会在 Object.setPrototypeOf()中被调用。对应的反射 API 方法为 Reflect.setPrototypeOf()。
const myTarget = {}
const proxy = new Proxy(myTarget, {
setPrototypeOf(target, prototype) {
console.log('setPrototypeOf()')
return Reflect.setPrototypeOf(...arguments)
},
})
Object.setPrototypeOf(proxy, Object)
// setPrototypeOf()
- 返回值:setPrototypeOf()必须返回布尔值,表示原型赋值是否成功。返回非布尔值会被转型为布尔值。
- 拦截的操作
- Object.setPrototypeOf(proxy)
- Reflect.setPrototypeOf(proxy)
- 捕获器处理程序参数
- target:目标对象。
- prototype:target 的替代原型,如果是顶级原型则为 null。
- 捕获器不变式
如果 target 不可扩展,则唯一有效的 prototype 参数就是 Object.getPrototypeOf(target)的返回值。
isExtensible()
isExtensible()捕获器会在 Object.isExtensible()中被调用。对应的反射 API 方法为 Reflect.isExtensible()。
const myTarget = {}
const proxy = new Proxy(myTarget, {
isExtensible(target) {
console.log('isExtensible()')
return Reflect.isExtensible(...arguments)
},
})
Object.isExtensible(proxy)
// isExtensible()
- 返回值:isExtensible()必须返回布尔值,表示 target 是否可扩展。返回非布尔值会被转型为布尔值。
- 拦截的操作
- Object.isExtensible(proxy)
- Reflect.isExtensible(proxy)
- 捕获器处理程序参数
- target:目标对象。
- 捕获器不变式 如果 target 可扩展,则处理程序必须返回 true。 如果 target 不可扩展,则处理程序必须返回 false。
preventExtensions()
preventExtensions()捕获器会在 Object.preventExtensions()中被调用。对应的反射 API 方法为 Reflect.preventExtensions()。
const myTarget = {}
const proxy = new Proxy(myTarget, {
preventExtensions(target) {
console.log('preventExtensions()')
return Reflect.preventExtensions(...arguments)
},
})
Object.preventExtensions(proxy)
// preventExtensions()
- 返回值:preventExtensions()必须返回布尔值,表示 target 是否已经不可扩展。返回非布尔值会被转型为布尔值。
- 拦截的操作
- Object.preventExtensions(proxy)
- Reflect.preventExtensions(proxy)
- 捕获器处理程序参数
- target:目标对象。
- 捕获器不变式
如果 Object.isExtensible(proxy)是 false,则处理程序必须返回 true
apply()
apply()捕获器会在调用函数时中被调用。对应的反射 API 方法为 Reflect.apply()。
const myTarget = () => {}
const proxy = new Proxy(myTarget, {
apply(target, thisArg, ...argumentsList) {
console.log('apply()')
return Reflect.apply(...arguments)
},
})
proxy()
// apply()
- 返回值:返回值无限制
- 拦截的操作
- proxy(...argumentsList)
- Function.prototype.apply(thisArg, argumentsList)
- Function.prototype.call(thisArg, ...argumentsList)
- Reflect.apply(target, thisArgument, argumentsList)
- 捕获器处理程序参数
- target:目标对象。
- thisArg:调用函数时的 this 参数。
- argumentsList:调用函数时的参数列表
- 捕获器不变式
target 必须是一个函数对象。
construct()
construct()捕获器会在 new 操作符中被调用。对应的反射 API 方法为 Reflect.construct()。
const myTarget = function () {}
const proxy = new Proxy(myTarget, {
construct(target, argumentsList, newTarget) {
console.log('construct()')
return Reflect.construct(...arguments)
},
})
new proxy()
// construct()
- 返回值:construct()必须返回一个对象。
- 拦截的操作
- new proxy(...argumentsList)
- Reflect.construct(target, argumentsList, newTarget)
- 捕获器处理程序参数
- target:目标构造函数。
- argumentsList:传给目标构造函数的参数列表。
- newTarget:最初被调用的构造函数。
- 捕获器不变式
target 必须可以用作构造函数。
代理模式
跟踪属性访问
通过捕获 get、set 和 has 等操作,可以知道对象属性什么时候被访问、被查询。把实现相应捕获器的某个对象代理放到应用中,可以监控这个对象何时在何处被访问过:
const user = {
name: 'jake',
}
const proxy = new Proxy(user, {
get(target, property, receiver) {
console.log(`Getting ${property}`)
return Reflect.get(...arguments)
},
set(target, property, value, receiver) {
console.log(`Setting ${property}=${value}`)
return Reflect.set(...arguments)
},
})
proxy.name
proxy.age = 27
隐藏属性
代理的内部实现对外部代码是不可见的,因此要隐藏目标对象上的属性也轻而易举。
const hiddenProperties = ['foo', 'bar']
const targetObject = {
foo: 1,
bar: 2,
baz: 3,
}
const proxy = new Proxy(targetObject, {
get(target, property) {
if (hiddenProperties.includes(property)) {
return undefined
} else {
return Reflect.get(...arguments)
}
},
has(target, property) {
if (hiddenProperties.includes(property)) {
return false
} else {
return Reflect.has(...arguments)
}
},
})
// get()
console.log(proxy.foo) // undefined
console.log(proxy.bar) // undefined
console.log(proxy.baz) // 3
// has()
console.log('foo' in proxy) // false
console.log('bar' in proxy) // false
console.log('baz' in proxy) // true
属性验证
因为所有赋值操作都会触发 set()捕获器,所以可以根据所赋的值决定是允许还是拒绝赋值:
const target = {
onlyNumbersGoHere: 0,
}
const proxy = new Proxy(target, {
set(target, property, value) {
if (typeof value !== 'number') {
return false
} else {
return Reflect.set(...arguments)
}
},
})
proxy.onlyNumbersGoHere = 1
console.log(proxy.onlyNumbersGoHere) // 1
proxy.onlyNumbersGoHere = '2'
console.log(proxy.onlyNumbersGoHere) // 1
函数与构造函数参数验证
跟保护和验证对象属性类似,也可对函数和构造函数参数进行审查。比如,可以让函数只接收某种类型的值:
function median(...nums) {
return nums.sort()[Math.floor(nums.length / 2)]
}
const proxy = new Proxy(median, {
apply(target, thisArg, argumentsList) {
for (const arg of argumentsList) {
if (typeof arg !== 'number') {
throw 'Non-number argument provided'
}
}
return Reflect.apply(...arguments)
},
})
console.log(proxy(4, 7, 1)) // 4
console.log(proxy(4, '7', 1))
// Error: Non-number argument provided
// 类似地,可以要求实例化时必须给构造函数传参:
class User {
constructor(id) {
this.id_ = id
}
}
const proxy = new Proxy(User, {
construct(target, argumentsList, newTarget) {
if (argumentsList[0] === undefined) {
throw 'User cannot be instantiated without id'
} else {
return Reflect.construct(...arguments)
}
},
})
new proxy(1)
new proxy()
// Error: User cannot be instantiated without id
数据绑定与可观察对象
通过代理可以把运行时中原本不相关的部分联系到一起。这样就可以实现各种模式,从而让不同的代码互操作。
比如,可以将被代理的类绑定到一个全局实例集合,让所有创建的实例都被添加到这个集合中:
const userList = []
class User {
constructor(name) {
this.name_ = name
}
}
const proxy = new Proxy(User, {
construct() {
const newUser = Reflect.construct(...arguments)
userList.push(newUser)
return newUser
},
})
new proxy('John')
new proxy('Jacob')
new proxy('Jingleheimerschmidt')
console.log(userList) // [User {}, User {}, User{}]
另外,还可以把集合绑定到一个事件分派程序,每次插入新实例时都会发送消息:
const userList = []
function emit(newValue) {
console.log(newValue)
}
const proxy = new Proxy(userList, {
set(target, property, value, receiver) {
const result = Reflect.set(...arguments)
if (result) {
emit(Reflect.get(target, property, receiver))
}
return result
},
})
proxy.push('John')
// John
proxy.push('Jacob')
// Jacob
总结
- 代理是 ECMAScript 6 新增的令人兴奋和动态十足的新特性。尽管不支持向后兼容,但它开辟出了一片前所未有的 JavaScript 元编程及抽象的新天地。
- 从宏观上看,代理是真实 JavaScript 对象的透明抽象层。代理可以定义包含捕获器的处理程序对象,而这些捕获器可以拦截绝大部分 JavaScript 的基本操作和方法。在这个捕获器处理程序中,可以修改任何基本操作的行为,当然前提是遵从捕获器不变式。
- 与代理如影随形的反射 API,则封装了一整套与捕获器拦截的操作相对应的方法。可以把反射 API 看作一套基本操作,这些操作是绝大部分 JavaScript 对象 API 的基础。
- 代理的应用场景是不可限量的。开发者使用它可以创建出各种编码模式,比如(但远远不限于)跟踪属性访问、隐藏属性、阻止修改或删除属性、函数参数验证、构造函数参数验证、数据绑定,以及可观察对象。
第 10 章 函数
箭头函数
ECMAScript 6 新增了使用胖箭头(=>)语法定义函数表达式的能力。很大程度上,箭头函数实例化的函数对象与正式的函数表达式创建的函数对象行为是相同的。任何可以使用函数表达式的地方,都可以使用箭头函数:
let arrowSum = (a, b) => {
return a + b
}
let functionExpressionSum = function (a, b) {
return a + b
}
console.log(arrowSum(5, 8)) // 13
console.log(functionExpressionSum(5, 8)) // 13
- 箭头函数简洁的语法非常适合嵌入函数的场景。
- 如果只有一个参数,那也可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号。
- 如果不使用大括号,那么箭头后面就只能有一行代码,比如一个赋值操作,或者一个表达式。
- 但也有很多场合不适用。箭头函数不能使用 arguments、super 和 new.target,也不能用作构造函数。此外,箭头函数也没有 prototype 属性。
let ints = [1, 2, 3]
console.log(
ints.map(function (i) {
return i + 1
}),
) // [2, 3, 4]
console.log(
ints.map((i) => {
return i + 1
}),
) // [2, 3, 4]
// 没有参数需要括号
let getRandom = () => {
return Math.random()
}
// 多个参数需要括号
let sum = (a, b) => {
return a + b
}
// 以下两种写法都有效,而且返回相应的值
let double = (x) => {
return 2 * x
}
let triple = (x) => 3 * x
// 可以赋值
let value = {}
let setName = (x) => (x.name = 'Matt')
setName(value)
console.log(value.name) // "Matt"
函数名
因为函数名就是指向函数的指针,所以它们跟其他包含对象指针的变量具有相同的行为。这意味着一个函数可以有多个名称,
function sum(num1, num2) {
return num1 + num2
}
console.log(sum(10, 10)) // 20
let anotherSum = sum
console.log(anotherSum(10, 10)) // 20
sum = null
console.log(anotherSum(10, 10)) // 20
如果函数是一个获取函数、设置函数,或者使用 bind()实例化,那么标识符前面会加上一个前缀:
function foo() {}
console.log(foo.bind(null).name) // bound foo
let dog = {
years: 1,
get age() {
return this.years
},
set age(newAge) {
this.years = newAge
},
}
let propertyDescriptor = Object.getOwnPropertyDescriptor(dog, 'age')
console.log(propertyDescriptor.get.name) // get age
console.log(propertyDescriptor.set.name) // set age
理解参数
ECMAScript 函数的参数在内部表现为一个数组。函数被调用时总会接收一个数组,但函数并不关心这个数组中包含什么。如果数组中什么也没有,那没问题;如果数组的元素超出了要求,那也没问题。事实上,在使用 function 关键字定义(非箭头)函数时,可以在函数内部访问 arguments 对象,从中取得传进来的每个参数值。
arguments 对象是一个类数组对象(但不是 Array 的实例),因此可以使用中括号语法访问其中的元素(第一个参数是 arguments[0],第二个参数是 arguments[1])。而要确定传进来多少个参数,可以访问 arguments.length 属性。
function sayHi(name, message) {
console.log('Hello ' + name + ', ' + message) // Hello BI, SUCCESS
}
sayHi('BI', 'SUCCESS')
// 可以通过 arguments[0]取得相同的参数值。因此,把函数重写成不声明参数也可以:
function sayHi() {
console.log('Hello ' + arguments[0]) // Hello arg
}
sayHi('arg')
也可以通过 arguments 对象的 length 属性检查传入的参数个数。下面的例子展示了在每调用一个函数时,都会打印出传入的参数个数:
function howManyArgs() {
console.log(arguments.length)
}
howManyArgs('string', 45) // 2
howManyArgs() // 0
howManyArgs(12) // 1
如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用 arguments 关键字访问,而只能通过定义的命名参数访问。
function foo() {
console.log(arguments[0])
}
foo(5) // 5
let bar = () => {
console.log(arguments[0])
}
bar(5) // ReferenceError: arguments is not defined
没有重载
函数没有签名,因此也就没有重载。如果定义了两个名字相同的函数,那么后面的函数会覆盖前面的函数。
function addSomeNumber(num) {
return num + 100
}
function addSomeNumber(num) {
return num + 200
}
let result = addSomeNumber(100) // 300
这里,函数 addSomeNumber()被定义了两次。第一个版本给参数加 100,第二个版本加 200。最后一行调用这个函数时,返回了 300,因为第二个定义覆盖了第一个定义。
默认参数值
ECMAScript 6 允许为函数的参数设置默认值,这样即使没有传入参数值,参数也会有一个默认值。
function makeKing(name = 'Henry') {
return `King ${name} VIII`
}
console.log(makeKing('Louis')) // 'King Louis VIII'
console.log(makeKing()) // 'King Henry VIII'
箭头函数同样也可以这样使用默认参数,只不过在只有一个参数时,就必须使用括号而不能省略了:
let makeKing = (name = 'Henry') => `King ${name}`
console.log(makeKing()) // King Henry
因为在求值默认参数时可以定义对象,也可以动态调用函数,所以函数参数肯定是在某个作用域中求值的。给多个参数定义默认值实际上跟使用 let 关键字顺序声明变量一样。
function makeKing(name = 'Henry', numerals = 'VIII') {
return `King ${name} ${numerals}`
}
console.log(makeKing()) // King Henry VIII
参数初始化顺序遵循“暂时性死区”规则,即前面定义的参数不能引用后面定义的。像这样就会抛出错误:
// 调用时不传第一个参数会报错
function makeKing(name = numerals, numerals = 'VIII') {
return `King ${name} ${numerals}`
}
// 参数也存在于自己的作用域中,它们不能引用函数体的作用域:
// 调用时不传第二个参数会报错
function makeKing(name = 'Henry', numerals = defaultNumeral) {
let defaultNumeral = 'VIII'
return `King ${name} ${numerals}`
}
参数扩展与收集
扩展参数
在给函数传参时,有时候可能不需要传一个数组,而是要分别传入数组的元素。假设有如下函数定义,它会将所有传入的参数累加起来:
let values = [1, 2, 3, 4]
function getSum() {
let sum = 0
for (let i = 0; i < arguments.length; ++i) {
sum += arguments[i]
}
return sum
}
console.log(getSum(...values)) // 10
console.log(getSum(-1, ...values)) // 9
console.log(getSum(...values, 5)) // 15
console.log(getSum(-1, ...values, 5)) // 14
console.log(getSum(...values, ...[5, 6, 7])) // 28
arguments 对象只是消费扩展操作符的一种方式。在普通函数和箭头函数中,也可以将扩展操作符用于命名参数,当然同时也可以使用默认参数:
function getProduct(a, b, c = 1) {
return a * b * c
}
let getSum = (a, b, c = 0) => {
return a + b + c
}
console.log(getProduct(...[1, 2])) // 2
console.log(getProduct(...[1, 2, 3])) // 6
console.log(getProduct(...[1, 2, 3, 4])) // 6
console.log(getSum(...[0, 1])) // 1
console.log(getSum(...[0, 1, 2])) // 3
console.log(getSum(...[0, 1, 2, 3])) // 3
收集参数
在函数内部,可以使用扩展操作符来收集参数。收集参数的前面如果还有命名参数,则只会收集其余的参数;如果没有则会得到空数组。因为收集参数的结果可变。
// 不可以
function getProduct(...values, lastValue) {}
// 可以
function ignoreFirst(firstValue, ...values) {
console.log(values);
}
ignoreFirst(); // []
ignoreFirst(1); // []
ignoreFirst(1,2); // [2]
ignoreFirst(1,2,3); // [2, 3]
箭头函数虽然不支持 arguments 对象,但支持收集参数的定义方式,因此也可以实现与使用 arguments 一样的逻辑:
let getSum = (...values) => {
return values.reduce((x, y) => x + y, 0)
}
console.log(getSum(1, 2, 3)) // 6
函数声明与函数表达式
JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。
// 没问题
console.log(sum(10, 10))
function sum(num1, num2) {
return num1 + num2
}
因为函数声明会在任何代码执行之前先被读取并添加到执行上下文。这个过程叫作函数声明提升(function declaration hoisting)。 在执行代码时,JavaScript 引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部。因此即使函数定义出现在调用它们的代码之后,引擎也会把函数声明提升到顶部。
// 会出错
console.log(sum(10, 10))
let sum = function (num1, num2) {
return num1 + num2
}
上面的代码之所以会出错,是因为这个函数定义包含在一个变量初始化语句中,而不是函数声明中。这意味着代码如果没有执行到加粗的那一行,那么执行上下文中就没有函数的定义,所以上面的代码会出错。
函数作为值
因为函数名在 ECMAScript 中就是变量,所以函数可以用在任何可以使用变量的地方。这意味着不仅可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另一个函数。
function add10(num) {
return num + 10
}
let result1 = callSomeFunction(add10, 10)
console.log(result1) // 20
function getGreeting(name) {
return 'Hello, ' + name
}
let result2 = callSomeFunction(getGreeting, 'Nicholas')
console.log(result2) // "Hello, Nicholas"
callSomeFunction()函数是通用的,第一个参数传入的是什么函数都可以,而且它始终返回调用作为第一个参数传入的函数的结果。要注意的是,如果是访问函数而不是调用函数,那就必须不带括号,所以传给 callSomeFunction()的必须是 add10 和 getGreeting,而不能是它们的执行结果。
let data = [
{ name: 'Zachary', age: 28 },
{ name: 'Nicholas', age: 29 },
]
data.sort(createComparisonFunction('name'))
console.log(data[0].name) // Nicholas
data.sort(createComparisonFunction('age'))
console.log(data[0].name) // Zachary
在上面的代码中,数组 data 中包含两个结构相同的对象。每个对象都有一个 name 属性和一个 age 属性。默认情况下,sort()方法要对这两个对象执行 toString(),然后再决定它们的顺序,但这样得不到有意义的结果。而通过调用 createComparisonFunction("name")来创建一个比较函数,就可以根据每个对象 name 属性的值来排序,结果 name 属性值为"Nicholas"、age 属性值为 29 的对象会排在前面。而调用 createComparisonFunction("age")则会创建一个根据每个对象 age 属性的值来排序的比较函数,结果 name 属性值为"Zachary"、age 属性值为 28 的对象会排在前面。
函数内部
arguments
arguments 是一个类数组对象,包含传入函数中的所有参数。使用数组语法访问它的每一个元素(第一个参数是 arguments[0],第二个参数是 arguments[1])。但 arguments 对象其实还有一个 callee 属性,是一个指向 arguments 对象所在函数的指针。
function factorial(num) {
if (num <= 1) {
return 1
} else {
return num * arguments.callee(num - 1)
}
}
let trueFactorial = factorial
factorial = function () {
return 0
}
console.log(trueFactorial(5)) // 120
这里,trueFactorial 变量被赋值为 factorial,实际上把同一个函数的指针又保存到了另一个位置。然后,factorial 函数又被重写为一个返回 0 的函数。如果像 factorial()最初的版本那样不使用 arguments.callee,那么像上面这样调用 trueFactorial()就会返回 0。不过,通过将函数与名称解耦,trueFactorial()就可以正确计算阶乘,而 factorial()则只能返回 0。
this
this 是在运行时基于函数的执行环境绑定的:在全局函数中,this 等于 window,而当函数被作为某个对象的方法调用时,this 等于那个对象。不过,匿名函数的执行环境具有全局性,因此其 this 对象通常指向 window。
window.color = 'red'
let o = {
color: 'blue',
}
function sayColor() {
console.log(this.color)
}
sayColor() // 'red'
o.sayColor = sayColor
o.sayColor() // 'blue'
定义在全局上下文中的函数 sayColor()引用了 this 对象。这个 this 到底引用哪个对象必须到函数被调用时才能确定。因此这个值在代码执行的过程中可能会变。 如果在全局上下文中调用 sayColor(),这结果会输出"red",因为 this 指向 window,而 this.color 相当于 window.color。而在把 sayColor()赋值给 o 之后再调用 o.sayColor(),this 会指向 o,即 this.color 相当于 o.color,所以会显示"blue"。
在箭头函数中,this 引用的是定义箭头函数的上下文。下面的例子演示了这一点。在对 sayColor()的两次调用中,this 引用的都是 window 对象,因为这个箭头函数是在 window 上下文中定义的:
window.color = 'red'
let o = {
color: 'blue',
}
let sayColor = () => console.log(this.color)
sayColor() // 'red'
o.sayColor = sayColor
o.sayColor() // 'red'
在事件回调或定时回调中调用某个函数时,this 值指向的并非想要的对象。此时将回调函数写成箭头函数就可以解决问题。这是因为箭头函数中的 this 会保留定义该函数时的上下文:
function King() {
this.royaltyName = 'Henry'
// this 引用 King 的实例
setTimeout(() => console.log(this.royaltyName), 1000)
}
function Queen() {
this.royaltyName = 'Elizabeth'
// this 引用 window 对象
setTimeout(function () {
console.log(this.royaltyName)
}, 1000)
}
new King() // Henry
new Queen() // undefined
caller
这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为 null。
function outer() {
inner()
}
function inner() {
console.log(inner.caller)
}
outer()
以上代码会显示 outer()函数的源代码。这是因为 ourter()调用了 inner(),inner.caller 指向 outer()。如果要降低耦合度,则可以通过 arguments.callee.caller 来引用同样的值:
function outer() {
inner()
}
function inner() {
console.log(arguments.callee.caller)
}
outer()
new.target
ECMAScript 6 新增了检测函数是否使用 new 关键字调用的 new.target 属性。如果函数是正常调用的,则 new.target 的值是 undefined;如果是使用 new 关键字调用的,则 new.target 将引用被调用的构造函数。
function King() {
if (!new.target) {
throw 'King must be instantiated using "new"'
}
console.log('King instantiated using "new"')
}
new King() // King instantiated using "new"
King() // Error: King must be instantiated using "new"
函数属性与方法
ECMAScript 中的函数是对象,因此有属性和方法。每个函数都有两个属性:length 和 prototype。其中,length 属性保存函数定义的命名参数的个数。
function sayName(name) {
console.log(name)
}
function sum(num1, num2) {
return num1 + num2
}
function sayHi() {
console.log('hi')
}
console.log(sayName.length) // 1
console.log(sum.length) // 2
console.log(sayHi.length) // 0
prototype 属性也许是 ECMAScript 核心中最有趣的部分。prototype 是保存引用类型所有实例方法的地方,这意味着 toString()、valueOf()等方法实际上都保存在 prototype 上,进而由所有实例共享。这个属性在自定义类型时特别重要。在 ECMAScript 5 中,prototype 属性是不可枚举的,因此使用 for-in 循环不会返回这个属性。
函数还有两个方法:**apply()**和 call()。这两个方法都会以指定的 this 值来调用函数,即会设置调用函数时函数体内 this 对象的值。apply()方法接收两个参数:函数内 this 的值和一个参数数组。第二个参数可以是 Array 的实例,但也可以是 arguments 对象。来看下面的例子:
function sum(num1, num2) {
return num1 + num2
}
function callSum1(num1, num2) {
return sum.apply(this, arguments) // 传入 arguments 对象
}
function callSum2(num1, num2) {
return sum.apply(this, [num1, num2]) // 传入数组
}
console.log(callSum1(10, 10)) // 20
console.log(callSum2(10, 10)) // 20
在这个例子中,callSum1()会调用 sum()函数,将 this 作为函数体内的 this 值(这里等于 window,因为是在全局作用域中调用的)传入,同时还传入了 arguments 对象。callSum2()也会调用 sum()函数,但会传入参数的数组。这两个函数都会执行并返回正确的结果。
call()方法与 apply()的作用一样,只是传参的形式不同。第一个参数跟 apply()一样,也是 this 值,而剩下的要传给被调用函数的参数则是逐个传递的。 换句话说,通过 call()向函数传参时,必须将参数一个一个地列出来, 而 apply()则是将参数放在一个数组中。 到底是使用 apply()还是 call(),完全取决于怎么给要调用的函数传参更方便。 如果想直接传 arguments 对象或者一个数组,那就用 apply();否则,就用 call()。当然,如果不用给被调用的函数传参,则使用哪个方法都一样。
function sum(num1, num2) {
return num1 + num2
}
function callSum(num1, num2) {
return sum.call(this, num1, num2)
}
console.log(callSum(10, 10)) // 20
apply()和 call()真正强大的地方并不是给函数传参,而是控制函数调用上下文即函数体内 this 值的能力。
window.color = 'red'
let o = {
color: 'blue',
}
function sayColor() {
console.log(this.color)
}
sayColor() // red
sayColor.call(this) // red
sayColor.call(window) // red
sayColor.call(o) // blue
sayColor()是一个全局函数,如果在全局作用域中调用它,那么会显示"red"。这是因为 this.color 会求值为 window.color。 如果在全局作用域中显式调用 sayColor.call(this)或者 sayColor.call(window),则同样都会显示"red"。 而在使用 sayColor.call(o)把函数的执行上下文即 this 切换为对象 o 之后,结果就变成了显示"blue"了。
使用 call()或 apply()的好处是可以将任意对象设置为任意函数的作用域,这样对象可以不用关心方法。 在前面例子最初的版本中,为切换上下文需要先把 sayColor()直接赋值为 o 的属性,然后再调用。而在这个修改后的版本中,就不需要这一步操作了。
bind():方法会创建一个新的函数实例,其 this 值会被绑定到传给 bind()的对象。
window.color = 'red'
var o = {
color: 'blue',
}
function sayColor() {
console.log(this.color)
}
let objectSayColor = sayColor.bind(o)
objectSayColor() // blue
在 sayColor()上调用 bind()并传入对象 o 创建了一个新函数 objectSayColor()。objectSayColor()中的 this 值被设置为 o,因此直接调用这个函数,即使是在全局作用域中调用,也会返回字符串"blue"。
函数表达式
定义函数有两种方式:函数声明和函数表达式。
函数声明的关键特点是函数声明提升,即函数声明会在代码执行之前获得定义。这意味着函数声明可以出现在调用它的代码之后
// 这个例子不会抛出错误,因为 JavaScript 引擎会先读取函数声明,然后再执行代码。
sayHi()
function sayHi() {
console.log('Hi!')
}
函数表达式看起来就像一个普通的变量定义和赋值,即创建一个函数再把它赋值给一个变量 functionName。这样创建的函数叫作匿名函数(anonymous funtion),因为 function 关键字后面没有标识符。(匿名函数有也时候也被称为兰姆达函数)。未赋值给其他变量的匿名函数的 name 属性是空字符串。
let functionName = function (arg0, arg1, arg2) {
// 函数体
}
// 函数表达式跟 JavaScript 中的其他表达式一样,需要先赋值再使用。下面的例子会导致错误:
sayHi() // Error! function doesn't exist yet
let sayHi = function () {
console.log('Hi!')
}
function createComparisonFunction(propertyName) {
return function (object1, object2) {
let value1 = object1[propertyName]
let value2 = object2[propertyName]
if (value1 < value2) {
return -1
} else if (value1 > value2) {
return 1
} else {
return 0
}
}
}
这里的 createComparisonFunction()函数返回一个匿名函数,这个匿名函数要么被赋值给一个变量,要么可以直接调用。但在 createComparisonFunction()内部,那个函数是匿名的。任何时候,只要函数被当作值来使用,它就是一个函数表达式。
递归
递归函数通常的形式是一个函数通过名称调用自己
function factorial(num) {
if (num <= 1) {
return 1
} else {
return num * factorial(num - 1)
}
}
// 但如果把这个函数赋值给其他变量,就会出问题:
let anotherFactorial = factorial
factorial = null
console.log(anotherFactorial(4)) // 报错
这里把 factorial()函数保存在了另一个变量 anotherFactorial 中,然后将 factorial 设置为 null,于是只保留了一个对原始函数的引用。而在调用 anotherFactorial()时,要递归调用 factorial(),但因为它已经不是函数了,所以会出错。在写递归函数时使用 arguments.callee 可以避免这个问题。
arguments.callee 就是一个指向正在执行的函数的指针,因此可以在函数内部递归调用:
function factorial(num) {
if (num <= 1) {
return 1
} else {
return num * arguments.callee(num - 1)
}
}
把函数名称替换成 arguments.callee,可以确保无论通过什么变量调用这个函数都不会出问题。因此在编写递归函数时,arguments.callee 是引用当前函数的首选。不过,在严格模式下运行的代码是不能访问 arguments.callee 的,因为访问会出错。此时,可以使用命名函数表达式(named function expression)达到目的。
const factorial = function f(num) {
if (num <= 1) {
return 1
} else {
return num * f(num - 1)
}
}
这里创建了一个命名函数表达式 f(),然后将它赋值给了变量 factorial。即使把函数赋值给另一个变量,函数表达式的名称 f 也不变,因此递归调用不会有问题。这个模式在严格模式和非严格模式下都可以使用。
尾调用优化
尾调用优化的条件就是确定外部栈帧真的没有必要存在了。涉及的条件如下:
- 代码在严格模式下执行;
- 外部函数的返回值是对尾调用函数的调用;
- 尾调用函数返回后不需要执行额外的逻辑;
- 尾调用函数不是引用外部函数作用域中自由变量的闭包。
'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()
}
比如把递归改写成迭代循环形式。不过,也可以保持递归实现,但将其重构为满足优化条件的形式。为此可以使用两个嵌套的函数,外部函数作为基础框架,内部函数执行递归:
'use strict'
// 基础框架
function fib(n) {
return fibImpl(0, 1, n)
}
// 执行递归
function fibImpl(a, b, n) {
if (n === 0) {
return a
}
return fibImpl(b, a + b, n - 1)
}
console.log(fib(0)) // 0
console.log(fib(1)) // 1
console.log(fib(2)) // 1
闭包
闭包是 JavaScript 中一个强大而常用的特性,指的是那些能够访问自由变量的函数(自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量)。简单来说,闭包就是一个函数能够记住并访问它的词法作用域,即使当这个函数在其作用域之外执行时。
闭包通常在嵌套函数中形成,当内部函数引用了外部函数的变量时,即使外部函数已经执行完毕,这些变量也不会被垃圾回收机制回收。看下面这个例子:
function createComparisonFunction(propertyName) {
// 外部函数定义了propertyName变量
return function (object1, object2) {
// 内部函数引用了外部函数的propertyName变量
let value1 = object1[propertyName]
let value2 = object2[propertyName]
if (value1 < value2) {
return -1
} else if (value1 > value2) {
return 1
} else {
return 0
}
}
}
// 使用闭包
let compareNames = createComparisonFunction('name')
let result = compareNames({ name: 'Nicholas' }, { name: 'Matt' })
console.log(result) // -1
// 完成使用后,释放内存
compareNames = null
在这个例子中,内部的匿名函数引用了外部函数 createComparisonFunction 的参数 propertyName。当 createComparisonFunction 执行完毕后,其作用域通常会被销毁,但由于返回的匿名函数仍然引用着 propertyName,所以这个变量会一直保存在内存中。
this 对象
在闭包中使用 this 需要特别注意,因为 this 的值在 JavaScript 中是动态绑定的,取决于函数的调用方式,而不是定义方式。
如果内部函数是普通函数(非箭头函数),则 this 对象会在运行时根据调用上下文动态绑定:
- 在全局函数中调用时,this 在非严格模式下指向 window,在严格模式下是 undefined
- 作为对象方法调用时,this 指向该对象
- 在匿名函数中,this 通常指向 window(非严格模式)或 undefined(严格模式)
window.identity = 'The Window'
let object = {
identity: 'My Object',
getIdentityFunc() {
// 保存外部函数的this值
let that = this
return function () {
// 使用that而不是this
return that.identity
}
},
// 使用箭头函数可以自动继承外部this
getIdentityArrow() {
return () => {
return this.identity
}
},
}
console.log(object.getIdentityFunc()()) // 'My Object'
console.log(object.getIdentityArrow()()) // 'My Object'
在第一个方法中,我们将外部函数的 this 保存到变量 that 中,然后在内部函数中使用 that。这是因为内部函数的 this 会指向 window,而不是 object。
在第二个方法中,我们使用了箭头函数,箭头函数没有自己的 this,它会继承外部函数的 this 值,所以不需要额外的变量来保存 this。
内存泄漏
闭包虽然强大,但使用不当可能导致内存泄漏。当闭包引用了外部函数的变量,这些变量会一直保存在内存中,直到闭包被销毁。
function createLargeData() {
// 创建一个大数组
let largeData = new Array(1000000).fill('some data')
return function () {
// 这个闭包引用了largeData
return largeData.length
}
}
let getDataSize = createLargeData() // largeData会一直存在于内存中
console.log(getDataSize()) // 1000000
// 当不再需要时,应该释放引用
getDataSize = null // 现在largeData可以被垃圾回收了
为了避免内存泄漏,应该在不再需要闭包时将其设置为 null,这样闭包引用的外部变量就可以被垃圾回收。
立即调用的函数表达式
立即调用的匿名函数又被称作立即调用的函数表达式(IIFE,Immediately Invoked FunctionExpression)。它类似于函数声明,但由于被包含在括号中,所以会被解释为函数表达式。紧跟在第一组括号后面的第二组括号会立即调用前面的函数表达式。
;(function () {
// 块级作用域
})()
为了防止变量定义外泄,IIFE 是个非常有效的方式。这样也不会导致闭包相关的内存问题,因为不存在对这个匿名函数的引用。为此,只要函数执行完毕,其作用域链就可以被销毁。在 ECMAScript 6 以后,IIFE 就没有那么必要了,因为块级作用域中的变量无须 IIFE 就可以实现同样的隔离。下面展示了两种不同的块级作用域形式:
// 内嵌块级作用域
{
let i
for (i = 0; i < count; i++) {
console.log(i)
}
}
console.log(i) // 抛出错误
// 循环的块级作用域
for (let i = 0; i < count; i++) {
console.log(i)
}
console.log(i) // 抛出错误
// 以前,为了实现点击第几个<div>就显示相应的索引值,需要借助 IIFE 来执行一个函数表达式,:
let divs = document.querySelectorAll('div')
for (var i = 0; i < divs.length; ++i) {
divs[i].addEventListener(
'click',
(function (frozenCounter) {
return function () {
console.log(frozenCounter)
}
})(i),
)
}
// 而使用 ECMAScript 块级作用域变量,就不用这么大动干戈了:
let divs = document.querySelectorAll('div')
for (let i = 0; i < divs.length; ++i) {
divs[i].addEventListener('click', function () {
console.log(i)
})
}
私有变量
任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。私有变量包括函数参数、局部变量,以及函数内部定义的其他函数。
function add(num1, num2) {
let sum = num1 + num2
return sum
}
在这个函数中,函数 add()有 3 个私有变量:num1、num2 和 sum。这几个变量只能在函数内部使用,不能在函数外部访问。如果这个函数中创建了一个闭包,则这个闭包能通过其作用域链访问其外部的这 3 个变量。基于这一点,就可以创建出能够访问私有变量的公有方法。
特权方法(privileged method)是能够访问函数私有变量(及私有函数)的公有方法。在对象上有两种方式创建特权方法。第一种是在构造函数中实现,比如:
function MyObject() {
// 私有变量和私有函数
let privateVariable = 10
function privateFunction() {
return false
}
// 特权方法
this.publicMethod = function () {
privateVariable++
return privateFunction()
}
}
这个模式是把所有私有变量和私有函数都定义在构造函数中。然后,再创建一个能够访问这些私有成员的特权方法。这样做之所以可行,是因为定义在构造函数中的特权方法其实是一个闭包,它具有访问构造函数中定义的所有变量和函数的能力。在这个例子中,变量 privateVariable 和函数 privateFunction()只能通过 publicMethod()方法来访问。在创建 MyObject 的实例后,没有办法直接访问 privateVariable 和 privateFunction(),唯一的办法是使用 publicMethod()。
如下面的例子所示,可以定义私有变量和特权方法,以隐藏不能被直接修改的数据:
function Person(name) {
this.getName = function () {
return name
}
this.setName = function (value) {
name = value
}
}
let person = new Person('Nicholas')
console.log(person.getName()) // 'Nicholas'
person.setName('Greg')
console.log(person.getName()) // 'Greg'
这段代码中的构造函数定义了两个特权方法:getName()和 setName()。每个方法都可以构造函数外部调用,并通过它们来读写私有的 name 变量。在 Person 构造函数外部,没有别的办法访问 name。因为两个方法都定义在构造函数内部,所以它们都是能够通过作用域链访问 name 的闭包。私有变量 name 对每个 Person 实例而言都是独一无二的,因为每次调用构造函数都会重新创建一套变量和方法。不过这样也有个问题:必须通过构造函数来实现这种隔离。正如第 8 章所讨论的,构造函数模式的缺点是每个实例都会重新创建一遍新方法。使用静态私有变量实现特权方法可以避免这个问题。
静态私有变量
这个模式与前一个模式的主要区别就是,私有变量和私有函数是由实例共享的。因为特权方法定义在原型上,所以同样是由实例共享的。特权方法作为一个闭包,始终引用着包含它的作用域。
;(function () {
let name = ''
Person = function (value) {
name = value
}
Person.prototype.getName = function () {
return name
}
Person.prototype.setName = function (value) {
name = value
}
})()
let person1 = new Person('Nicholas')
console.log(person1.getName()) // 'Nicholas'
person1.setName('Matt')
console.log(person1.getName()) // 'Matt'
let person2 = new Person('Michael')
console.log(person1.getName()) // 'Michael'
console.log(person2.getName()) // 'Michael'
这里的 Person 构造函数可以访问私有变量 name,跟 getName()和 setName()方法一样。使用这种模式,name 变成了静态变量,可供所有实例使用。这意味着在任何实例上调用 setName()修改这个变量都会影响其他实例。调用 setName()或创建新的 Person 实例都要把 name 变量设置为一个新值。而所有实例都会返回相同的值。
像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。
模块模式
模块模式是为单例创建私有变量和方法。单例是指只有一个实例的对象。JavaScript 没有内置的单例支持,单例模式在 JavaScript 应用程序中很常见。
let singleton = (function () {
// 私有变量和私有函数
let privateVariable = 10
function privateFunction() {
return false
}
// 特权/公有方法和属性
return {
publicProperty: true,
publicMethod() {
privateVariable++
return privateFunction()
},
}
})()
模块模式使用了匿名函数返回一个对象。在匿名函数内部,首先定义私有变量和私有函数。之后,创建一个要通过匿名函数返回的对象字面量。这个对象字面量中只包含可以公开访问的属性和方法。因为这个对象定义在匿名函数内部,所以它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。
本质上,对象字面量定义了单例对象的公共接口。如果单例对象需要进行某种初始化,并且需要访问私有变量时,那就可以采用这个模式:
let application = (function () {
// 私有变量和私有函数
let components = new Array()
// 初始化
components.push(new BaseComponent())
// 公共接口
return {
getComponentCount() {
return components.length
},
registerComponent(component) {
if (typeof component == 'object') {
components.push(component)
}
},
}
})()
上面这个简单的例子创建了一个 application 对象用于管理组件。在创建这个对象之后,内部就会创建一个私有的数组 components,然后将一个 BaseComponent 组件的新实例添加到数组中。对象字面量中定义的 getComponentCount()和 registerComponent()方法都是可以访问 components 私有数组的特权方法。前一个方法返回注册组件的数量,后一个方法负责注册新组件。
模块增强模式
这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。
let application = (function () {
// 私有变量和私有函数
let components = new Array()
// 初始化
components.push(new BaseComponent())
// 创建局部变量保存实例
let app = new BaseComponent()
// 公共接口
app.getComponentCount = function () {
return components.length
}
app.registerComponent = function (component) {
if (typeof component == 'object') {
components.push(component)
}
}
// 返回实例
return app
})()
在这个重写的 application 单例对象的例子中,首先定义了私有变量和私有函数,跟之前例子中一样。主要区别在于这里创建了一个名为 app 的变量,其中保存了 BaseComponent 组件的实例。这是最终要变成 application 的那个对象的局部版本。在给这个局部变量 app 添加了能够访问私有变量的公共方法之后,匿名函数返回了这个对象。然后,这个对象被赋值给 application。
总结:
- 函数表达式与函数声明是不一样的。函数声明要求写出函数名称,而函数表达式并不需要。没有名称的函数表达式也被称为匿名函数。
- ES6 新增了类似于函数表达式的箭头函数语法,但两者也有一些重要区别。
- JavaScript 中函数定义与调用时的参数极其灵活。arguments 对象,以及 ES6 新增的扩展操作符,可以实现函数定义和调用的完全动态化。
- 函数内部也暴露了很多对象和引用,涵盖了函数被谁调用、使用什么调用,以及调用时传入了什么参数等信息。
- JavaScript 引擎可以优化符合尾调用条件的函数,以节省栈空间。
- 闭包的作用域链中包含自己的一个变量对象,然后是包含函数的变量对象,直到全局上下文的变量对象。
- 通常,函数作用域及其中的所有变量在函数执行完毕后都会被销毁。
- 闭包在被函数返回之后,其作用域会一直保存在内存中,直到闭包被销毁。
- 函数可以在创建之后立即调用,执行其中代码之后却不留下对函数的引用。
- 立即调用的函数表达式如果不在包含作用域中将返回值赋给一个变量,则其包含的所有变量都会被销毁。
- 虽然 JavaScript 没有私有对象属性的概念,但可以使用闭包实现公共方法,访问位于包含作用域中定义的变量。
- 可以访问私有变量的公共方法叫作特权方法。
- 特权方法可以使用构造函数或原型模式通过自定义类型中实现,也可以使用模块模式或模块增强模式在单例对象上实现。
第 11 章 期约与异步函数
异步编程
特别是在 JavaScript 这种单线程事件循环模型中,同步操作与异步操作更是代码所要依赖的核心机制。异步行为是为了优化因计算量大而时间长的操作。如果在等待其他操作完成的同时,即使运行其他指令,系统也能保持稳定,那么这样做就是务实的。
重要的是,异步操作并不一定计算量大或要等很长时间。只要你不想为等待某个异步操作而阻塞线程执行,那么任何时候都可以使用。
同步与异步
同步行为: 同步是指操作按顺序依次执行。一个任务开始后,必须等待它完成,才能开始执行下一个任务。就像排队一样,前一个人办完事,下一个人才能开始。
特点:
- 阻塞 (Blocking): 当前任务会阻塞后续任务的执行,直到它自己完成。
- 顺序性: 任务严格按照代码的书写顺序或调用顺序执行。
- 可预测性: 流程简单直接,容易理解和预测结果。
例子:
- 函数调用:通常情况下,调用一个函数后,程序会等待该函数执行完毕并返回结果,然后才继续执行下一行代码。
- 读取文件:一个同步的文件读取操作会等待文件完全读入内存后,才继续执行后面的代码。
异步行为:异步是指操作的启动和完成是分离的。一个任务开始后,不需要等待它完成,就可以立即开始执行下一个任务。当那个耗时的任务最终完成时,通常会通过某种机制(如回调函数、Promise、事件等)通知主程序。
特点:
- 非阻塞 (Non-blocking): 发起一个异步任务后,程序可以继续执行其他任务,不会被阻塞。
- 并发性: 可以同时处理多个任务,提高了程序的效率和响应性,尤其是在处理 I/O 操作(如网络请求、文件读写)时。
- 复杂性: 相比同步,异步的流程控制相对复杂,需要处理回调、状态管理等。
例子:
- 网络请求:发起一个网络请求(如从服务器获取数据)通常是异步的。程序发起请求后,可以继续执行其他代码,当服务器响应到达时,再通过回调函数或 Promise 处理返回的数据。
- 定时器 (setTimeout): 设置一个定时器,让某个函数在一段时间后执行,程序不会等待这段时间过去,而是立即继续执行后续代码。
- 事件监听:给按钮添加点击事件监听器。程序不会一直等待按钮被点击,而是继续执行,当用户点击按钮时,相应的事件处理函数才会被异步触发。
以往的异步编程模式
在早期的 JavaScript 中,只支持定义回调函数来表明异步操作完成。串联多个异步操作是一个常见的问题,通常需要深度嵌套的回调函数(俗称“回调地狱”)来解决。
- 异步返回值
假设 setTimeout 操作会返回一个有用的值。有什么好办法把这个值传给需要它的地方?广泛接受的一个策略是给异步操作提供一个回调,这个回调中包含要使用异步返回值的代码(作为回调的参数)。
function double(value, callback) {
setTimeout(() => callback(value * 2), 1000)
}
double(3, (x) => console.log(`I was given: ${x}`))
// I was given: 6(大约 1000 毫秒之后)
这里的 setTimeout 调用告诉 JavaScript 运行时在 1000 毫秒之后把一个函数推到消息队列上。这个函数会由运行时负责异步调度执行。而位于函数闭包中的回调及其参数在异步执行时仍然是可用的。
- 失败处理
异步操作的失败处理在回调模型中也要考虑,因此自然就出现了成功回调和失败回调:
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 毫秒之后)
这种模式已经不可取了,因为必须在初始化异步操作时定义回调。异步函数的返回值只在短时间内存在,只有预备好将这个短时间内存在的值作为参数的回调才能接收到它。
- 嵌套异步回调
如果异步返值又依赖另一个异步返回值,那么回调的情况还会进一步变复杂。在实际的代码中,这就要求嵌套回调:
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) => {
double(x, (y) => console.log(`Success: ${y}`))
}
const failureCallback = (e) => console.log(`Failure: ${e}`)
double(3, successCallback, failureCallback)
// Success: 12(大约 1000 毫秒之后)
显然,随着代码越来越复杂,回调策略是不具有扩展性的。“回调地狱”这个称呼可谓名至实归。嵌套回调的代码维护起来就是噩梦。
期约
Promises/A+规范
ECMAScript 6 增加了对 Promises/A+规范的完善支持,即 Promise 类型。一经推出,Promise 就大受欢迎,成为了主导性的异步编程机制。所有现代浏览器都支持 ES6 期约,很多其他浏览器 API(如 fetch()和 Battery Status API)也以期约为基础。
期约基础
ECMAScript 6 新增的引用类型 Promise,可以通过 new 操作符来实例化。创建新期约时需要传入执行器(executor)函数作为参数,下面的例子使用了一个空函数对象来应付一下解释器:
// 之所以说是应付解释器,是因为如果不提供执行器函数,就会抛出 SyntaxError
let p = new Promise(() => {})
setTimeout(console.log, 0, p)
- 期约状态机
在把一个期约实例传给 console.log()时,控制台输出(可能因浏览器不同而略有差异)表明该实例处于待定(pending)状态。如前所述,期约是一个有状态的对象,可能处于如下 3 种状态之一:
- 待定(pending)
- 兑现(fulfilled,有时候也称为“解决”,resolved)
- 拒绝(rejected)
待定(pending)是期约的最初始状态。在待定状态下,期约可以落定(settled)为代表成功的兑现(fulfilled)状态,或者代表失败的拒绝(rejected)状态。无论落定为哪种状态都是不可逆的。只要从待定转换为兑现或拒绝,期约的状态就不再改变。而且,也不能保证期约必然会脱离待定状态。因此,组织合理的代码无论期约解决(resolve)还是拒绝(reject),甚至永远处于待定(pending)状态,都应该具有恰当的行为。
重要的是,期约的状态是私有的,不能直接通过 JavaScript 检测到。这主要是为了避免根据读取到的期约状态,以同步方式处理期约对象。另外,期约的状态也不能被外部 JavaScript 代码修改。这与不能读取该状态的原因是一样的:期约故意将异步行为封装起来,从而隔离外部的同步代码。
- 解决值、拒绝理由及期约用例
期约主要有两大用途。首先是抽象地表示一个异步操作。期约的状态代表期约是否完成。“待定”表示尚未开始或者正在执行中。“兑现”表示已经成功完成,而“拒绝”则表示没有成功完成。
某些情况下,这个状态机就是期约可以提供的最有用的信息。知道一段异步代码已经完成,对于其他代码而言已经足够了。比如,假设期约要向服务器发送一个 HTTP 请求。请求返回 200~299 范围内的状态码就足以让期约的状态变为“兑现”。类似地,如果请求返回的状态码不在 200~299 这个范围内,那么就会把期约状态切换为“拒绝”。
- 通过执行函数控制期约状态
由于期约的状态是私有的,所以只能在内部进行操作。内部操作在期约的执行器函数中完成。执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。其中,控制期约状态的转换是通过调用它的两个函数参数实现的。这两个函数参数通常都命名为 resolve()和 reject()。调用 resolve()会把状态切换为兑现,调用 reject()会把状态切换为拒绝。另外,调用 reject()也会抛出错误(后面会讨论这个错误)。
比如,可以通过 setTimeout 设置一个 10 秒钟后无论如何都会拒绝期约的回调:
let p = new Promise((resolve, reject) => {
setTimeout(reject, 10000) // 10 秒后调用 reject()
// 执行函数的逻辑
})
setTimeout(console.log, 0, p) // Promise <pending>
setTimeout(console.log, 11000, p) // 11 秒后再检查状态
// (After 10 seconds) Uncaught error
// (After 11 seconds) Promise <rejected>
- Promise.resolve()
期约并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。通过调用 Promise.resolve()静态方法,可以实例化一个解决的期约。
这个解决的期约的值对应着传给 Promise.resolve()的第一个参数。使用这个静态方法,实际上可以把任何值都转换为一个期约:
setTimeout(console.log, 0, Promise.resolve())
// Promise <resolved>: undefined
setTimeout(console.log, 0, Promise.resolve(3))
// Promise <resolved>: 3
// 多余的参数会忽略
setTimeout(console.log, 0, Promise.resolve(4, 5, 6))
// Promise <resolved>: 4
- Promise.reject()
与 Promise.resolve()类似,Promise.reject()会实例化一个拒绝的期约并抛出一个异步错误(这个错误不能通过 try/catch 捕获,而只能通过拒绝处理程序捕获)。下面的两个期约实例实际上是一样的:
let p1 = new Promise((resolve, reject) => reject())
let p2 = Promise.reejct()
// 这个拒绝的期约的理由就是传给 Promise.reject()的第一个参数。这个参数也会传给后续的拒绝处理程序:
let p = Promise.reject(3)
setTimeout(console.log, 0, p) // Promise <rejected>: 3
p.then(null, (e) => setTimeout(console.log, 0, e)) // 3
- 同步/异步执行的二元性
Promise 的设计很大程度上会导致一种完全不同于 JavaScript 的计算模式。
try {
throw new Error('foo')
} catch (e) {
console.log(e) // Error: foo
}
try {
Promise.reject(new Error('bar'))
} catch (e) {
console.log(e)
}
// Uncaught (in promise) Error: bar
第一个 try/catch 抛出并捕获了错误,第二个 try/catch 抛出错误却没有捕获到。乍一看这可能有点违反直觉,因为代码中确实是同步创建了一个拒绝的期约实例,而这个实例也抛出了包含拒绝理由的错误。这里的同步代码之所以没有捕获期约抛出的错误,是因为它没有通过异步模式捕获错误。从这里就可以看出期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。
期约的实例方法
期约实例的方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的代码。
- 实现 Thenable 接口
在 ECMAScript 暴露的异步结构中,任何对象都有一个 then()方法。这个方法被认为实现了 Thenable 接口。下面的例子展示了实现这一接口的最简单的类:
class MyThenable {
then() {}
}
- Promise.prototype.then()
Promise.prototype.then()是为期约实例添加处理程序的主要方法。这个 then()方法接收最多两个参数:onResolved 处理程序和 onRejected 处理程序。这两个参数都是可选的,如果提供的话,则会在期约分别进入“兑现”和“拒绝”状态时执行。
function onResolved(id) {
setTimeout(console.log, 0, id, 'resolved')
}
function onRejected(id) {
setTimeout(console.log, 0, id, 'rejected')
}
let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000))
let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000))
p1.then(
() => onResolved('p1'),
() => onRejected('p1'),
)
p2.then(
() => onResolved('p2'),
() => onRejected('p2'),
)
//(3 秒后)
// p1 resolved
// p2 rejected
如果没有显式的返回语句,则 Promise.resolve()会包装默认的返回值 undefined。
let p1 = Promise.resolve('foo')
// 若调用 then()时不传处理程序,则原样向后传
let p2 = p1.then()
setTimeout(console.log, 0, p2) // Promise <resolved>: foo
// 这些都一样
let p3 = p1.then(() => undefined)
let p4 = p1.then(() => {})
let p5 = p1.then(() => Promise.resolve())
setTimeout(console.log, 0, p3) // Promise <resolved>: undefined
setTimeout(console.log, 0, p4) // Promise <resolved>: undefined
setTimeout(console.log, 0, p5) // Promise <resolved>: undefined
- Promise.prototype.catch()
Promise.prototype.catch()方法用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected 处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用 Promise.prototype.then(null, onRejected)。
let p = Promise.reject()
let onRejected = function (e) {
setTimeout(console.log, 0, 'rejected')
}
// 这两种添加拒绝处理程序的方式是一样的:
p.then(null, onRejected) // rejected
p.catch(onRejected) // rejected
// Promise.prototype.catch()返回一个新的期约实例:
let p1 = new Promise(() => {})
let p2 = p1.catch()
setTimeout(console.log, 0, p1) // Promise <pending>
setTimeout(console.log, 0, p2) // Promise
setTimeout(console.log, 0, p1 === p2) // false
在返回新期约实例方面,Promise.prototype.catch()的行为与 Promise.prototype.then()的 onRejected 处理程序是一样的。
- Promise.prototype.finally()
Promise.prototype.finally()方法用于给期约添加 onFinally 处理程序,这个处理程序在期约转换为解决或拒绝状态时都会执行。这个方法可以避免 onResolved 和 onRejected 处理程序中出现冗余代码。但 onFinally 处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。
let p1 = Promise.resolve();
let p2 = Promise.reject();
let onFinally = function() {
setTimeout(console.log, 0, 'Finally!')
}
p1.finally(onFinally); // Finally
p2.finally(onFinally); // Finally
// Promise.prototype.finally()方法返回一个新的期约实例:
let p1 = new Promise(() => {});
let p2 = p1.finally();
setTimeout(console.log, 0, p1); // Promise <pending>
setTimeout(console.log, 0, p2); // Promise <pending>
setTimeout(console.log, 0, p1 === p2); // false
这个新期约实例不同于 then()或 catch()方式返回的实例。因为 onFinally 被设计为一个状态无关的方法,所以在大多数情况下它将表现为父期约的传递。对于已解决状态和被拒绝状态都是如此。
- 非重入期约方法
当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前先执行。即使期约一开始就是与附加处理程序关联的状态,执行顺序也是这样的。这个特性由 JavaScript 运行时保证,被称为“非重入”(non-reentrancy)特性。
// 创建解决的期约
let p = Promise.resolve()
// 添加解决处理程序
// 直觉上,这个处理程序会等期约一解决就执行
p.then(() => console.log('onResolved handler'))
// 同步输出,证明 then()已经返回
console.log('then() returns')
// 实际的输出:
// then() returns
// onResolved handler
在这个例子中,在一个解决期约上调用 then()会把 onResolved 处理程序推进消息队列。但这个处理程序在当前线程上的同步代码执行完成前不会执行。因此,跟在 then()后面的同步代码一定先于处理程序执行。
let synchronousResolve;
// 创建一个期约并将解决函数保存在一个局部变量中
let p = new Promise((resolve) => {
synchronousResolve = function() {
console.log('1: invoking resolve()');
resolve();
console.log('2: resolve() returns');
};
});
p.then(() => console.log('4: then() handler executes'));
synchronousResolve();
console.log('3: synchronousResolve() returns');
// 实际的输出:
// 1: invoking resolve()
// 2: resolve() returns
// 3: synchronousResolve() returns
// 4: then() handler executes
在这个例子中,即使期约状态变化发生在添加处理程序之后,处理程序也会等到运行的消息队列让它出列时才会执行。
- 邻近处理程序的执行顺序
如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。无论是 then()、catch()还是 finally()添加的处理程序都是如此。
let p1 = Promise.resolve()
let p2 = Promise.reject()
p1.then(() => setTimeout(console.log, 0, 1))
p1.then(() => setTimeout(console.log, 0, 2))
// 1
// 2
p2.then(null, () => setTimeout(console.log, 0, 3))
p2.then(null, () => setTimeout(console.log, 0, 4))
// 3
// 4
p2.catch(() => setTimeout(console.log, 0, 5))
p2.catch(() => setTimeout(console.log, 0, 6))
// 5
// 6
p1.finally(() => setTimeout(console.log, 0, 7))
p1.finally(() => setTimeout(console.log, 0, 8))
// 7
// 8
- 传递解决值和拒绝理由
到了落定状态后,期约会提供其解决值(如果兑现)或其拒绝理由(如果拒绝)给相关状态的处理程序。拿到返回值后,就可以进一步对这个值进行操作。比如,第一次网络请求返回的 JSON 是发送第二次请求必需的数据,那么第一次请求返回的值就应该传给 onResolved 处理程序继续处理。当然,失败的网络请求也应该把 HTTP 状态码传给 onRejected 处理程序。
在执行函数中,解决的值和拒绝的理由是分别作为 resolve()和 reject()的第一个参数往后传的。然后,这些值又会传给它们各自的处理程序,作为 onResolved 或 onRejected 处理程序的唯一参数。下面的例子展示了上述传递过程:
let p1 = new Promise((resolve, reject) => resolve('foo'))
p1.then((value) => console.log(value)) // foo
let p2 = new Promise((resolve, reject) => reject('bar'))
p2.catch((reason) => console.log(reason)) // bar
- 拒绝期约与拒绝错误处理
拒绝期约类似于 throw()表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。因此以下这些期约都会以一个错误对象为由被拒绝:
let p1 = new Promise((resolve, reject) => reject(Error('foo')))
let p2 = new Promise((resolve, reject) => {
throw Error('foo')
})
let p3 = Promise.resolve().then(() => {
throw Error('foo')
})
let p4 = Promise.reject(Error('foo'))
setTimeout(console.log, 0, p1) // Promise <rejected>: Error: foo
setTimeout(console.log, 0, p2) // Promise <rejected>: Error: foo
setTimeout(console.log, 0, p3) // Promise <rejected>: Error: foo
setTimeout(console.log, 0, p4) // Promise <rejected>: Error: foo
// 也会抛出 4 个未捕获错误
then()和 catch()的 onRejected 处理程序在语义上相当于 try/catch。出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。为此,onRejected 处理程序的任务应该是在捕获异步错误之后返回一个解决的期约。下面的例子中对比了同步错误处理与异步错误处理:
console.log('begin synchronous execution')
try {
throw Error('foo')
} catch (e) {
console.log('caught error', e)
}
console.log('continue synchronous execution')
// begin synchronous execution
// caught error Error: foo
// continue synchronous execution
new Promise((resolve, reject) => {
console.log('begin asynchronous execution')
reject(Error('bar'))
})
.catch((e) => {
console.log('caught error', e)
})
.then(() => {
console.log('continue asynchronous execution')
})
// begin asynchronous execution
// caught error Error: bar
// continue asynchronous execution
期约连锁与期约合成
多个期约组合在一起可以构成强大的代码逻辑。这种组合可以通过两种方式实现:期约连锁与期约合成。前者就是一个期约接一个期约地拼接,后者则是将多个期约组合为一个期约。
- 期约连锁
期约连锁指的是通过 then()
、catch()
和 finally()
方法将多个期约连接在一起,前一个期约的返回值会传给下一个期约的回调函数。
// 基本的期约连锁
fetch('/api/user')
.then((response) => response.json())
.then((user) => {
console.log(user)
return fetch(`/api/posts?userId=${user.id}`)
})
.then((response) => response.json())
.then((posts) => {
console.log(posts)
})
.catch((error) => {
console.error('请求出错:', error)
})
链式调用中的错误处理非常灵活,可以在链的任意位置捕获和处理错误:
Promise.resolve()
.then(() => {
// 返回一个解决的期约
return Promise.resolve('第一步成功')
})
.then((data) => {
console.log(data) // 第一步成功
// 抛出错误
throw new Error('第二步出错')
})
.catch((err) => {
console.error(err.message) // 第二步出错
// 返回一个新值来恢复链
return '恢复正常'
})
.then((data) => {
console.log(data) // 恢复正常
})
- 期约图
期约图是指使用期约构建的异步流程控制图,期约之间的依赖关系可以形成一个有向非循环图。
// 期约图示例
function fetchUserData(userId) {
return fetch(`/api/user/${userId}`).then((response) => response.json())
}
function fetchUserPosts(userId) {
return fetch(`/api/posts?userId=${userId}`).then((response) =>
response.json(),
)
}
function fetchPostComments(postId) {
return fetch(`/api/comments?postId=${postId}`).then((response) =>
response.json(),
)
}
// 构建期约图
const userId = 1
const userPromise = fetchUserData(userId)
const postsPromise = userPromise.then((user) => {
console.log('用户信息:', user)
return fetchUserPosts(user.id)
})
const commentsPromise = postsPromise.then((posts) => {
console.log('用户文章:', posts)
// 获取第一篇文章的评论
return posts.length > 0 ? fetchPostComments(posts[0].id) : []
})
commentsPromise
.then((comments) => {
console.log('文章评论:', comments)
})
.catch((error) => {
console.error('期约图执行出错:', error)
})
- Promise.all()和 Promise.race()
Promise.all()
接收一个期约数组作为输入,返回一个新期约,该期约会在所有输入期约都解决后才解决。
// Promise.all 示例
const p1 = fetch('/api/data1').then((r) => r.json())
const p2 = fetch('/api/data2').then((r) => r.json())
const p3 = fetch('/api/data3').then((r) => r.json())
Promise.all([p1, p2, p3])
.then((results) => {
// results 是一个数组,包含三个期约解决后的值
const [data1, data2, data3] = results
console.log('所有数据:', data1, data2, data3)
})
.catch((error) => {
// 只要有一个期约被拒绝,就会立即进入 catch
console.error('获取数据出错:', error)
})
Promise.race()
也接收一个期约数组,但只要有一个期约解决或拒绝,就会采用该期约的值作为自己的值。
// Promise.race 示例
// 设置一个超时期约
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error('请求超时!')), 3000)
})
// 实际请求
const fetchData = fetch('/api/longRunningTask').then((r) => r.json())
Promise.race([fetchData, timeout])
.then((result) => {
console.log('成功获取数据:', result)
})
.catch((error) => {
console.error('操作失败:', error.message)
})
Promise.allSettled()
会等待所有期约完成(无论是解决还是拒绝):
const p1 = Promise.resolve('成功1')
const p2 = Promise.reject('失败')
const p3 = Promise.resolve('成功2')
Promise.allSettled([p1, p2, p3]).then((results) => {
// 所有期约的结果,包括成功和失败
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`期约${index + 1}成功:`, result.value)
} else {
console.log(`期约${index + 1}失败:`, result.reason)
}
})
})
- 串行期约合成
串行期约合成指的是前一个期约完成后,再启动下一个期约,使多个异步任务按顺序执行。
// 串行期约合成
function sequentialPromises(promiseFunctions) {
return promiseFunctions.reduce((chain, promiseFunc) => {
return chain.then((resultsArray) => {
return promiseFunc().then((result) => {
return [...resultsArray, result]
})
})
}, Promise.resolve([]))
}
// 模拟异步任务
function task1() {
return new Promise((resolve) => {
setTimeout(() => {
console.log('任务1完成')
resolve('任务1结果')
}, 1000)
})
}
function task2() {
return new Promise((resolve) => {
setTimeout(() => {
console.log('任务2完成')
resolve('任务2结果')
}, 500)
})
}
function task3() {
return new Promise((resolve) => {
setTimeout(() => {
console.log('任务3完成')
resolve('任务3结果')
}, 800)
})
}
// 按顺序执行任务
sequentialPromises([task1, task2, task3])
.then((results) => {
console.log('所有任务结果:', results)
})
.catch((error) => {
console.error('任务执行出错:', error)
})
另一种使用 async/await
实现串行执行的方式:
async function runSequentially() {
try {
const result1 = await task1()
const result2 = await task2()
const result3 = await task3()
return [result1, result2, result3]
} catch (error) {
console.error('串行执行出错:', error)
throw error
}
}
runSequentially().then((results) => {
console.log('所有任务结果:', results)
})
异步函数
异步函数,也称为"async/await"(语法关键字),是 ES6 期约模式在 ECMAScript 函数中的应用。async/await 是 ES8 规范新增的。这个特性从行为和语法上都增强了 JavaScript,让以同步方式写的代码能够异步执行。
异步函数
异步函数是将 JavaScript 期约的强大功能与同步代码的可读性有机结合起来。通过 async
和 await
关键字,可以像编写同步代码一样处理异步操作。
- async
async
关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法定义中。
// 异步函数声明
async function foo() {
return 1
}
// 异步函数表达式
const bar = async function () {
return 2
}
// 异步箭头函数
const baz = async () => {
return 3
}
// 异步方法定义
class MyClass {
async qux() {
return 4
}
}
使用 async
关键字声明的函数有几个特点:
- 异步函数始终返回期约(Promise)对象
- 返回值会被 Promise.resolve() 包装
- 抛出的错误会被 Promise.reject() 包装
async function foo() {
return 'foo'
}
// 等价于:
function foo() {
return Promise.resolve('foo')
}
console.log(foo()) // Promise {<fulfilled>: "foo"}
foo().then(console.log) // foo
错误处理示例:
async function foo() {
throw new Error('出错了!')
}
// 等价于:
function foo() {
return Promise.reject(new Error('出错了!'))
}
foo().catch(console.error) // Error: 出错了!
- await
await
关键字会暂停异步函数的执行,等待期约解决,然后恢复异步函数的执行并返回解决值。
async function foo() {
console.log(1)
const result = await Promise.resolve(2)
console.log(result) // 2
console.log(3)
}
foo()
console.log(4)
// 输出顺序:
// 1
// 4
// 2
// 3
await
可以用在任何返回期约的表达式之前,不限于正式的 Promise 对象:
// 等待一个原始值
async function foo() {
console.log(await 'foo') // foo
}
// 等待一个实现了 thenable 接口的对象
async function bar() {
const thenable = {
then(callback) {
callback('bar')
},
}
console.log(await thenable) // bar
}
// 等待另一个异步函数
async function baz() {
const value = await bar()
console.log(value)
}
- await 的限制
await
关键字必须在异步函数内部使用,不能在顶级上下文使用(除非是在模块环境中):
// 有效的用法
async function foo() {
await Promise.resolve('foo');
}
// 无效的用法(非异步函数)
function bar() {
await Promise.resolve('bar'); // SyntaxError
}
// 在模块顶级使用 await(ES2022 特性)
// 在支持的浏览器中可以这样使用:
// await Promise.resolve('baz');
停止和恢复执行
使用 await
关键字时,异步函数会暂停执行,让出 JavaScript 运行时的执行线程。函数的剩余部分会被保存在内存中,等待期约解决后再恢复执行。
async function foo() {
console.log('函数开始执行')
console.log('等待期约解决...')
await new Promise((resolve) => setTimeout(resolve, 1000))
console.log('等待结束,函数继续执行')
return '完成'
}
foo().then((result) => console.log(result))
console.log('foo()已调用')
// 输出顺序:
// 函数开始执行
// 等待期约解决...
// foo()已调用
// (1秒后)
// 等待结束,函数继续执行
// 完成
这种行为展示了 await
关键字是如何影响函数执行流程的:
- 遇到
await
时,函数执行会暂停 - 剩余代码会被放入微任务队列
- 主线程继续执行同步代码
- 当期约解决时,微任务队列中的代码会继续执行
异步函数策略
- 实现 sleep() 函数
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function foo() {
console.log('开始')
await sleep(1500)
console.log('这条消息在1.5秒后显示')
}
foo()
- 顺序处理期约数组
function createPromise(value, delay) {
return new Promise((resolve) =>
setTimeout(() => {
console.log(`期约 ${value} 已解决`)
resolve(value)
}, delay),
)
}
// 按顺序处理期约
async function sequentialProcess() {
const promises = [
createPromise('A', 1000),
createPromise('B', 500),
createPromise('C', 2000),
]
console.log('开始顺序处理')
// 按顺序等待每个期约完成
for (const promise of promises) {
const result = await promise
console.log(`处理结果: ${result}`)
}
console.log('顺序处理完成')
}
// 并行处理多个期约并等待所有结果
async function parallelProcess() {
const promises = [
createPromise('A', 1000),
createPromise('B', 500),
createPromise('C', 2000),
]
console.log('开始并行处理')
// 同时开始所有期约,等待全部完成
const results = await Promise.all(promises)
console.log('所有结果:', results)
console.log('并行处理完成')
}
- 错误处理策略
异步函数中的错误处理可以使用 try/catch 块,这使得错误处理变得更加直观:
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data')
if (!response.ok) {
throw new Error(`HTTP 错误: ${response.status}`)
}
const data = await response.json()
return data
} catch (error) {
console.error('获取数据时出错:', error)
// 可以返回默认值或重新抛出错误
return { error: true, message: error.message }
}
}
// 还可以在调用时使用 Promise 的 catch 方法
fetchData()
.then((data) => console.log('获取的数据:', data))
.catch((error) => console.error('未捕获的错误:', error))
- 并发控制
在处理大量异步操作时,可能需要限制并发数量:
async function processBatch(tasks, concurrency = 3) {
// 追踪进行中的任务
const running = new Set()
// 存储所有结果
const results = []
// 遍历所有任务
for (const [index, task] of tasks.entries()) {
// 创建任务处理函数
const taskPromise = (async () => {
try {
return await task()
} finally {
// 任务完成后从运行集合中移除
running.delete(taskPromise)
}
})()
// 添加到运行集合
running.add(taskPromise)
results[index] = taskPromise
// 如果运行任务达到并发上限,等待其中一个完成
if (running.size >= concurrency) {
await Promise.race(running)
}
}
// 等待所有任务完成并返回结果
return Promise.all(results)
}
// 使用示例
async function demo() {
const tasks = Array(10)
.fill(0)
.map(
(_, i) => () =>
new Promise((resolve) =>
setTimeout(() => {
console.log(`任务 ${i} 完成`)
resolve(i)
}, Math.random() * 2000),
),
)
console.log('开始批量处理,最大并发数: 3')
const results = await processBatch(tasks, 3)
console.log('所有任务结果:', results)
}
demo()
- 异步函数与迭代器组合
异步函数可以与迭代器和生成器结合使用,实现更加复杂的异步流程控制:
// 异步生成器函数
async function* asyncGenerator() {
yield await Promise.resolve(1)
yield await Promise.resolve(2)
yield await Promise.resolve(3)
}
// 使用异步迭代器
async function useAsyncGenerator() {
for await (const value of asyncGenerator()) {
console.log(value)
}
}
useAsyncGenerator() // 1, 2, 3
异步函数为 JavaScript 提供了一种更自然、更直观的方式来处理异步操作,显著提高了代码的可读性和可维护性。结合期约和其他 JavaScript 特性,它们成为了现代 JavaScript 异步编程的基础。
小结:
长期以来,掌握单线程 JavaScript 运行时的异步行为一直都是个艰巨的任务。随着 ES6 新增了期约和 ES8 新增了异步函数,ECMAScript 的异步编程特性有了长足的进步。通过期约和 async/await,不仅可以实现之前难以实现或不可能实现的任务,而且也能写出更清晰、简洁,并且容易理解、调试的代码。
期约的主要功能是为异步代码提供了清晰的抽象。可以用期约表示异步执行的代码块,也可以用期约表示异步计算的值。在需要串行异步代码时,期约的价值最为突出。作为可塑性极强的一种结构,期约可以被序列化、连锁使用、复合、扩展和重组。
异步函数是将期约应用于 JavaScript 函数的结果。异步函数可以暂停执行,而不阻塞主线程。无论是编写基于期约的代码,还是组织串行或平行执行的异步代码,使用异步函数都非常得心应手。异步函数可以说是现代 JavaScript 工具箱中最重要的工具之一。
第 12 章 BOM
浏览器对象模型(BOM,Browser Object Model)描述为 JavaScript 的核心,但实际上 BOM 是使用 JavaScript 开发 Web 应用程序的核心。BOM 提供了与网页无关的浏览器功能对象。
window 对象
BOM 的核心是 window 对象,表示浏览器的实例。window 对象在浏览器中有两重身份,一个是 ECMAScript 中的 Global 对象,另一个就是浏览器窗口的 JavaScript 接口。这意味着网页中定义的所有对象、变量和函数都以 window 作为其 Global 对象,都可以访问其上定义的 parseInt()等全局方法。
Global 作用域
因为 window 对象被复用为 ECMAScript 的 Global 对象,所以通过 var 声明的所有全局变量和函数都会变成 window 对象的属性和方法。
var age = 29
var sayAge = () => alert(this.age)
console.log(window.age) // 29
sayAge() // 29
window.sayAge() // 29
窗口关系
window 对象表示一个浏览器窗口或一个框架,在页面中使用框架时,每个框架都有自己的 window 对象。
// top 对象始终指向最顶层窗口,即浏览器窗口本身
console.log(window === top) // 在最外层窗口中为 true // 在框架中为 false
// parent 对象表示当前窗口或框架的父窗口或父框架
console.log(window === parent) // 在没有框架的页面中为 true // 在嵌套框架中为 false
// self 对象是 window 的同义词
console.log(window === self) // 总是 true
// 如果页面包含框架,可以使用 frames 集合访问这些框架
if (frames.length > 0) {
console.log('页面包含框架')
console.log(`第一个框架: ${frames[0].location.href}`)
}
// 当检测窗口位置时,要注意检查窗口层级关系
function isTopWindow() {
return window === top
}
// 在框架中访问父窗口的方法
function callParentMethod() {
if (window !== parent) {
parent.doSomething()
}
}
窗口位置与像素比
window 对象的位置可以通过不同的属性和方法来确定。现代浏览器提供了 screenLeft 和 screenTop 属性,用于表示窗口相对于屏幕左侧和顶部的位置 ,返回值的单位是 CSS 像素。
// 获取窗口相对于屏幕的位置
console.log(`窗口左侧位置: ${window.screenLeft || window.screenX}px`)
console.log(`窗口顶部位置: ${window.screenTop || window.screenY}px`)
// 跨浏览器获取窗口位置的方法
function getWindowPosition() {
let leftPos =
window.screenLeft !== undefined ? window.screenLeft : window.screenX
let topPos =
window.screenTop !== undefined ? window.screenTop : window.screenY
return {
left: leftPos,
top: topPos,
}
}
// 打印窗口位置
const position = getWindowPosition()
console.log(`窗口位置 - 左: ${position.left}, 上: ${position.top}`)
// 移动窗口位置(在某些浏览器中可能被限制)
function moveWindow() {
// 把窗口移动到左上角
window.moveTo(0, 0)
// 把窗口向下移动 100 像素
window.moveBy(0, 100)
// 把窗口移动到坐标位置(200, 300)
window.moveTo(200, 300)
// 把窗口向左移动 50 像素
window.moveBy(-50, 0)
}
像素比:CSS 像素是 Web 开发中使用的统一像素单位。这个单位的背后其实是一个角度:0.0213°。如果屏幕距离人眼是一臂长,则以这个角度计算的 CSS 像素大小约为 1/96 英寸。
// 获取设备像素比
const pixelRatio = window.devicePixelRatio
console.log(`设备像素比: ${pixelRatio}`)
// 根据像素比调整图像显示
function optimizeImagesForDisplay() {
const images = document.querySelectorAll('img')
if (window.devicePixelRatio >= 2) {
// 设备有高像素密度,使用高分辨率图像
for (const img of images) {
const hiResUrl = img.getAttribute('data-hires-src')
if (hiResUrl) {
img.src = hiResUrl
}
}
console.log('已加载高分辨率图像')
}
}
// 调整 Canvas 元素的显示
function setupHiDPICanvas(canvas, width, height) {
const ctx = canvas.getContext('2d')
const ratio = window.devicePixelRatio || 1
// 设置画布的显示尺寸
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
// 设置画布的实际像素尺寸
canvas.width = width * ratio
canvas.height = height * ratio
// 根据像素比缩放上下文
ctx.scale(ratio, ratio)
return ctx
}
窗口大小
浏览器窗口的尺寸获取比较复杂,因为窗口大小可以包括或不包括工具栏、滚动条等浏览器界面元素。
// 获取视口(viewport)尺寸(不包括浏览器工具栏和滚动条)
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
console.log(`视口尺寸: ${viewportWidth} x ${viewportHeight}`)
// 获取浏览器窗口的外部尺寸(包括所有界面元素)
const outerWidth = window.outerWidth
const outerHeight = window.outerHeight
console.log(`浏览器窗口尺寸: ${outerWidth} x ${outerHeight}`)
// 获取页面视口尺寸(跨浏览器方法)
function getViewportSize() {
let width = window.innerWidth
let height = window.innerHeight
// 对于不支持 innerWidth/innerHeight 的浏览器,使用其他方法
if (typeof width !== 'number') {
// 针对标准模式
if (document.compatMode === 'CSS1Compat') {
width = document.documentElement.clientWidth
height = document.documentElement.clientHeight
} else {
// 针对怪异模式
width = document.body.clientWidth
height = document.body.clientHeight
}
}
return { width, height }
}
// 调整窗口大小(在某些浏览器中可能被限制)
function resizeWindow() {
// 调整窗口大小为 800x600
window.resizeTo(800, 600)
// 增加窗口宽度 100px,增加高度 50px
window.resizeBy(100, 50)
}
// 检测窗口尺寸变化
window.addEventListener('resize', () => {
const size = getViewportSize()
console.log(`窗口大小已变更: ${size.width} x ${size.height}`)
// 可以根据新尺寸调整页面布局
adjustLayout(size.width, size.height)
})
视口位置
视口表示当前文档的可见部分,用户可以通过滚动来改变视口位置。window 对象提供了方法来获取和操作滚动位置。
// 获取当前页面的滚动位置
const scrollX = window.pageXOffset || document.documentElement.scrollLeft
const scrollY = window.pageYOffset || document.documentElement.scrollTop
console.log(`滚动位置: 横向 ${scrollX}px, 纵向 ${scrollY}px`)
// 滚动到页面特定位置
function scrollToPosition(x, y) {
window.scrollTo(x, y)
}
// 滚动到页面顶部
function scrollToTop() {
window.scrollTo(0, 0)
}
// 使用平滑滚动效果
function smoothScrollToElement(element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
// 滚动一定距离
function scrollByAmount(x, y) {
window.scrollBy(x, y)
}
// 监听滚动事件
window.addEventListener('scroll', () => {
const scrollPosition = {
x: window.pageXOffset || document.documentElement.scrollLeft,
y: window.pageYOffset || document.documentElement.scrollTop,
}
console.log(`滚动位置已更新: ${scrollPosition.x}, ${scrollPosition.y}`)
// 可以根据滚动位置执行操作,如显示/隐藏回到顶部按钮
if (scrollPosition.y > 300) {
document.getElementById('back-to-top').style.display = 'block'
} else {
document.getElementById('back-to-top').style.display = 'none'
}
})
导航与打开新窗口
window.open() 方法可以打开一个新窗口或新标签页,并提供一系列选项来控制新窗口的外观和行为。
// 基本的新窗口打开
function openNewWindow(url) {
window.open(url)
}
// 打开一个有特定名称的窗口(如果已存在同名窗口则重用)
function openNamedWindow(url, name) {
window.open(url, name)
}
// 配置新窗口的特性
function openCustomWindow(url, name) {
const features =
'width=600,height=400,resizable=yes,scrollbars=yes,status=yes'
const newWindow = window.open(url, name, features)
// 操作新窗口
if (newWindow !== null && !newWindow.closed) {
newWindow.document.title = '新窗口标题'
newWindow.focus()
}
return newWindow
}
// 打开居中显示的窗口
function openCenteredWindow(url, width, height) {
const left = (screen.width - width) / 2
const top = (screen.height - height) / 2
const features = `width=${width},height=${height},left=${left},top=${top}`
return window.open(url, '_blank', features)
}
// 检查新打开的窗口是否被弹窗拦截器阻止
function safeOpenWindow(url) {
const newWindow = window.open(url, '_blank')
if (newWindow === null || typeof newWindow === 'undefined') {
alert('弹窗被拦截。请允许此网站显示弹窗或检查您的浏览器设置。')
return null
}
return newWindow
}
// 关闭窗口
function closeWindow(windowReference) {
if (windowReference && !windowReference.closed) {
windowReference.close()
return true
}
return false
}
// 检测窗口是否已关闭
function isWindowClosed(windowReference) {
return !windowReference || windowReference.closed
}
// 窗口间通信
function communicateWithWindow(windowReference, message) {
if (!isWindowClosed(windowReference)) {
windowReference.postMessage(message, '*')
}
}
// 接收来自其他窗口的消息
window.addEventListener('message', (event) => {
// 验证消息来源
if (trustedOrigins.includes(event.origin)) {
console.log('收到消息:', event.data)
// 处理消息...
// 回复消息
event.source.postMessage('消息已收到', event.origin)
}
})
定时器
window 对象提供了定时器功能,可以在指定的时间后执行代码,或者按照指定的时间间隔重复执行代码。
// 设置一个一次性的定时器
function executeOnce() {
const timerId = setTimeout(() => {
console.log('这段代码在3秒后执行')
}, 3000)
return timerId // 返回定时器ID,可用于取消
}
// 取消一次性定时器
function cancelTimeout(timerId) {
clearTimeout(timerId)
console.log(`已取消ID为 ${timerId} 的定时器`)
}
// 设置一个重复执行的定时器
function executeRepeatedly() {
const timerId = setInterval(() => {
console.log('这段代码每2秒执行一次')
}, 2000)
return timerId // 返回定时器ID,可用于取消
}
// 取消重复定时器
function cancelInterval(timerId) {
clearInterval(timerId)
console.log(`已停止ID为 ${timerId} 的定时器`)
}
// 使用定时器实现倒计时
function countdown(seconds, onTick, onComplete) {
let remainingSeconds = seconds
// 立即执行一次
onTick(remainingSeconds)
const timerId = setInterval(() => {
remainingSeconds--
if (remainingSeconds > 0) {
onTick(remainingSeconds)
} else {
clearInterval(timerId)
onComplete()
}
}, 1000)
return timerId
}
// 使用定时器实现动画
function animateElement(element, duration) {
const startTime = Date.now()
const startPosition = 0
const endPosition = 500
function update() {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// 使用缓动函数使动画更自然
const easeProgress = 0.5 - Math.cos(progress * Math.PI) / 2
// 计算当前位置
const currentPosition =
startPosition + (endPosition - startPosition) * easeProgress
// 更新元素位置
element.style.transform = `translateX(${currentPosition}px)`
// 如果动画未完成,继续更新
if (progress < 1) {
requestAnimationFrame(update)
}
}
// 开始动画
requestAnimationFrame(update)
}
// 零延迟的setTimeout
setTimeout(() => {
console.log('虽然延迟设为0,但这会在当前执行栈清空后才执行')
}, 0)
console.log('这行代码会先执行')
系统对话框
window 对象提供了三种系统级对话框:alert、confirm 和 prompt,用于显示消息、确认操作和获取输入。
// 显示警告对话框
function showAlert(message) {
alert(message)
}
// 显示确认对话框
function showConfirm(message) {
const result = confirm(message)
if (result) {
console.log('用户点击了"确定"')
return true
} else {
console.log('用户点击了"取消"')
return false
}
}
// 显示提示输入对话框
function showPrompt(message, defaultValue = '') {
const userInput = prompt(message, defaultValue)
if (userInput === null) {
console.log('用户取消了输入')
return null
} else {
console.log(`用户输入: ${userInput}`)
return userInput
}
}
// 打印对话框
function printDocument() {
window.print()
}
// 查找对话框
function findInPage() {
window.find()
}
location 对象
location 对象是最有用的 BOM 对象之一,它既是 window 对象的属性,也是 document 对象的属性。也就是说,window.location 和 document.location 指向同一个对象。location 对象不仅保存着当前文档的地址信息,还提供了多种方法来操作浏览器的导航功能。
location 对象的属性(以 https://www.example.com:8080/path/page.html?q=term#section 为例):
属性名 | 例子 | 说明 |
---|---|---|
href | "https://www.example.com:8080/path/page.html?q=term#section" | 完整的 URL |
protocol | "https:" | URL 的协议部分,包括冒号 |
host | "www.example.com:8080" | 服务器名及端口号 |
hostname | "www.example.com" | 服务器名 |
port | "8080" | 端口号 |
pathname | "/path/page.html" | URL 中的路径部分 |
search | "?q=term" | URL 的查询字符串部分,包括问号 |
hash | "#section" | URL 的散列值(哈希值),包括井号 |
origin | "https://www.example.com:8080" | URL 的源地址(只读) |
username | "" | URL 中的用户名(HTTP 认证) |
password | "" | URL 中的密码(HTTP 认证) |
// 假设当前 URL 是 https://www.example.com:8080/path/page.html?q=term#section
console.log(location.href) // "https://www.example.com:8080/path/page.html?q=term#section"
console.log(location.protocol) // "https:"
console.log(location.host) // "www.example.com:8080"
console.log(location.pathname) // "/path/page.html"
console.log(location.search) // "?q=term"
console.log(location.hash) // "#section"
查询字符串
虽然 location.search 可以获取到包含查询参数的字符串,但它并不提供便捷的接口来访问各个参数。为了解析查询字符串,常见的做法是手动解析或使用 URLSearchParams API。
// 假设 URL 是 https://www.example.com/?id=123&name=test&enabled=true
// 手动解析查询字符串
function getQueryParams() {
const params = {}
const searchParams = location.search.slice(1).split('&')
for (let param of searchParams) {
if (param === '') continue
let [name, value] = param.split('=')
params[decodeURIComponent(name)] = decodeURIComponent(value || '')
}
return params
}
const params = getQueryParams()
console.log(params.id) // "123"
console.log(params.name) // "test"
console.log(params.enabled) // "true"
// 使用 URLSearchParams API(现代浏览器支持)
const urlParams = new URLSearchParams(location.search)
console.log(urlParams.get('id')) // "123"
console.log(urlParams.get('name')) // "test"
console.log(urlParams.get('enabled')) // "true"
// 检查参数是否存在
console.log(urlParams.has('id')) // true
console.log(urlParams.has('unknown')) // false
// 获取所有参数名
for (const key of urlParams.keys()) {
console.log(key) // 依次输出: "id", "name", "enabled"
}
// 获取所有参数值
for (const value of urlParams.values()) {
console.log(value) // 依次输出: "123", "test", "true"
}
// 获取所有参数键值对
for (const [key, value] of urlParams.entries()) {
console.log(`${key}: ${value}`)
// 依次输出:
// "id: 123"
// "name: test"
// "enabled: true"
}
操作地址
location 对象提供了多种方法来改变浏览器的地址,并以不同方式加载新页面:
- assign(): 立即导航到新 URL,并在浏览器历史记录中增加一条记录
// 立即导航到新地址
location.assign('https://www.example.com/new-page')
// 以下操作等同于调用 assign()
location.href = 'https://www.example.com/new-page'
location = 'https://www.example.com/new-page'
- replace(): 导航到新 URL,但不会在历史记录中增加新记录(无法通过后退按钮返回)
// 替换当前历史记录项并导航
location.replace('https://www.example.com/new-page')
// 这种导航方式对于实现重定向特别有用,用户无法返回前一个页面
if (userNotAuthorized) {
location.replace('https://www.example.com/login')
}
- reload(): 重新加载当前页面
// 重新加载页面,可能会使用缓存
location.reload()
// 强制从服务器重新加载页面,忽略缓存
location.reload(true)
- 修改 URL 部分属性: 直接修改 location 对象的属性也可以触发页面导航
// 修改 URL 的各个部分
// 假设当前 URL 是 https://www.example.com/page.html
// 修改散列值(不会导致页面刷新,只会滚动到对应的锚点)
location.hash = 'section2'
// URL 变为 https://www.example.com/page.html#section2
// 修改查询字符串(会导致页面刷新)
location.search = '?id=456'
// URL 变为 https://www.example.com/page.html?id=456
// 修改路径(会导致页面刷新)
location.pathname = '/new-path/'
// URL 变为 https://www.example.com/new-path/
// 修改主机(会导致页面刷新)
location.hostname = 'developer.example.com'
// URL 变为 https://developer.example.com/page.html
// 修改端口(会导致页面刷新)
location.port = '8080'
// URL 变为 https://www.example.com:8080/page.html
// 修改协议(会导致页面刷新)
location.protocol = 'http:'
// URL 变为 http://www.example.com/page.html
- 结合 URLSearchParams 构建查询字符串
// 使用 URLSearchParams 构建和修改查询参数
function updateQueryParams(params) {
const urlParams = new URLSearchParams(location.search)
// 更新或添加参数
for (const [key, value] of Object.entries(params)) {
urlParams.set(key, value)
}
// 应用新的查询字符串(会刷新页面)
location.search = urlParams.toString()
}
// 示例:添加或更新查询参数
updateQueryParams({
page: 2,
sort: 'name',
order: 'desc',
})
// URL 将更新为 当前路径?page=2&sort=name&order=desc
- SPA(单页应用)中使用 location
在现代单页应用中,经常需要在不刷新页面的情况下修改 URL。这可以通过 History API 实现,但 location 对象仍然用于读取当前 URL 状态:
// 使用 History API 修改 URL 而不刷新页面
function navigateWithoutRefresh(path, title, state) {
history.pushState(state, title, path)
// 此时 location 对象已更新,可以读取新的 URL 信息
console.log(`当前路径: ${location.pathname}`)
console.log(`当前查询参数: ${location.search}`)
}
// 示例:在不刷新页面的情况下切换到产品页面
navigateWithoutRefresh('/products?category=electronics', 'Electronics', {
section: 'products',
category: 'electronics',
})
// 监听 popstate 事件(用户点击后退/前进按钮时触发)
window.addEventListener('popstate', (event) => {
console.log('URL 已改变:', location.href)
console.log('状态数据:', event.state)
// 根据新的 location 更新页面内容
updatePageContent(location.pathname, location.search)
})
location 对象是浏览器导航功能的核心接口,通过它可以获取当前 URL 信息、解析查询参数、进行页面导航以及修改浏览器地址。在现代 Web 应用中,它与 History API 结合使用,可以实现更加复杂和用户友好的导航体验。
navigator 对象
navigator 对象包含了关于浏览器的信息,是一个表示用户代理(即浏览器)状态和标识的对象。虽然不同浏览器实现的 navigator 对象会有所不同,但所有浏览器都支持一些公共属性和方法。
检测插件
navigator 对象的 plugins 属性包含了浏览器安装的插件信息,可以用来检测特定插件是否可用。
// 检查浏览器是否支持 PDF
function hasPDFPlugin() {
const plugins = navigator.plugins
for (let i = 0; i < plugins.length; i++) {
if (plugins[i].name.toLowerCase().indexOf('pdf') > -1) {
return true
}
}
return false
}
// 检查是否有 Flash 插件
function hasFlashPlugin() {
const plugins = navigator.plugins
for (let i = 0; i < plugins.length; i++) {
if (plugins[i].name.toLowerCase().indexOf('flash') > -1) {
return true
}
}
return false
}
// 在现代浏览器中,很多插件已被淘汰
console.log('支持PDF插件:', hasPDFPlugin())
console.log('支持Flash插件:', hasFlashPlugin()) // 现代浏览器已淘汰Flash
注册处理程序
navigator 对象的 registerProtocolHandler() 方法允许网站注册自己作为特定协议的处理程序。
// 注册网站作为邮件协议处理程序
// %s 会被替换为实际的邮件地址
navigator.registerProtocolHandler(
'mailto',
'https://www.example.com/mail?address=%s',
'Example Mail',
)
// 注册网站作为网络协议处理程序
navigator.registerProtocolHandler(
'web+example',
'https://www.example.com/handle?url=%s',
'Example Protocol Handler',
)
检测网络连接
navigator.onLine 属性表示浏览器是否连接到网络。
// 检查当前网络状态
console.log('网络已连接:', navigator.onLine)
// 监听网络状态变化
window.addEventListener('online', () => {
console.log('网络已连接')
// 可以在这里恢复需要网络连接的功能
})
window.addEventListener('offline', () => {
console.log('网络已断开')
// 可以在这里禁用需要网络连接的功能
// 或显示离线提示
})
获取地理位置信息
navigator.geolocation 对象提供了访问设备地理位置的接口。
// 获取当前位置
function getCurrentPosition() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
// 成功回调
(position) => {
const latitude = position.coords.latitude
const longitude = position.coords.longitude
console.log(`当前位置: 纬度 ${latitude}, 经度 ${longitude}`)
// 可以使用这些坐标进行进一步处理
showLocationOnMap(latitude, longitude)
},
// 错误回调
(error) => {
switch (error.code) {
case error.PERMISSION_DENIED:
console.error('用户拒绝了位置请求')
break
case error.POSITION_UNAVAILABLE:
console.error('位置信息不可用')
break
case error.TIMEOUT:
console.error('获取用户位置超时')
break
case error.UNKNOWN_ERROR:
console.error('发生未知错误')
break
}
},
// 选项
{
enableHighAccuracy: true, // 尝试获取最精确的位置
timeout: 5000, // 5秒超时
maximumAge: 0, // 不使用缓存位置
},
)
} else {
console.error('浏览器不支持地理位置服务')
}
}
// 持续监控位置变化
let watchId
function startWatchingPosition() {
if (navigator.geolocation) {
watchId = navigator.geolocation.watchPosition(
(position) => {
console.log(
`位置已更新: 纬度 ${position.coords.latitude}, 经度 ${position.coords.longitude}`,
)
updateUserLocation(position.coords)
},
(error) => {
console.error('监控位置时出错:', error.message)
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 30000, // 允许使用最多30秒前的缓存位置
},
)
}
}
// 停止监控位置
function stopWatchingPosition() {
if (watchId) {
navigator.geolocation.clearWatch(watchId)
console.log('已停止监控位置')
watchId = null
}
}
screen 对象
screen 对象包含了有关用户屏幕的信息,包括分辨率、可用尺寸和色彩深度等。这些信息通常用于优化网页布局或了解用户的显示能力。
// 获取屏幕信息
console.log('屏幕宽度:', screen.width, '像素')
console.log('屏幕高度:', screen.height, '像素')
console.log('可用宽度:', screen.availWidth, '像素') // 不包括任务栏等系统UI
console.log('可用高度:', screen.availHeight, '像素')
console.log('色彩深度:', screen.colorDepth, '位')
console.log('像素深度:', screen.pixelDepth, '位')
// 检查是否是高分辨率屏幕
function isHighResolution() {
return screen.width >= 1920 && screen.height >= 1080
}
// 根据屏幕尺寸优化布局
function optimizeLayoutForScreen() {
const container = document.querySelector('.container')
if (screen.width < 768) {
container.classList.add('mobile-layout')
} else if (screen.width < 1200) {
container.classList.add('tablet-layout')
} else {
container.classList.add('desktop-layout')
}
}
// 计算窗口在屏幕上的居中位置
function centerWindowPosition(width, height) {
const left = (screen.width - width) / 2
const top = (screen.height - height) / 2
return { left, top }
}
// 打开一个居中的弹出窗口
function openCenteredPopup(url, title, width, height) {
const position = centerWindowPosition(width, height)
const options = `width=${width},height=${height},left=${position.left},top=${position.top}`
window.open(url, title, options)
}
history 对象
history 对象表示浏览器的历史记录,提供了在用户历史记录中前进和后退的功能,以及操作历史堆栈的方法。
// 简单的后退操作
function goBack() {
history.back()
}
// 简单的前进操作
function goForward() {
history.forward()
}
// 跳转到特定的历史记录点
function goToHistoryPoint(delta) {
history.go(delta)
}
// 例如:
// history.go(-2); // 后退两步
// history.go(3); // 前进三步
// history.go(0); // 刷新当前页面
// 检查历史记录中的条目数
console.log('历史堆栈中的条目数:', history.length)
// 使用 History API 添加新的历史记录
function addHistoryEntry(path, title, state) {
// 添加新的历史记录,不刷新页面
history.pushState(state, title, path)
// 更新页面标题
document.title = title
console.log(`添加了新的历史记录: ${path}`)
}
// 例如:在SPA中切换到产品页面
addHistoryEntry('/products', '产品列表', { section: 'products' })
// 替换当前的历史记录
function replaceHistoryEntry(path, title, state) {
// 替换当前历史记录,不刷新页面
history.replaceState(state, title, path)
// 更新页面标题
document.title = title
console.log(`替换了当前历史记录: ${path}`)
}
// 监听历史记录变化(用户点击后退/前进按钮时触发)
window.addEventListener('popstate', (event) => {
const state = event.state
console.log('历史记录已变化:', location.pathname)
if (state) {
console.log('关联的状态数据:', state)
// 根据状态数据更新页面内容
updatePageContent(state)
} else {
console.log('没有关联的状态数据')
// 根据当前URL更新页面内容
loadContentForCurrentPath()
}
})
// 在SPA中实现路由导航
const router = {
routes: {
'/': { title: '首页', handler: showHomePage },
'/products': { title: '产品列表', handler: showProductsPage },
'/about': { title: '关于我们', handler: showAboutPage },
},
navigate(path) {
const route = this.routes[path]
if (route) {
// 添加历史记录
history.pushState({ path }, route.title, path)
document.title = route.title
// 更新页面内容
route.handler()
} else {
console.error(`未找到路由: ${path}`)
this.navigate('/') // 导航到首页
}
},
handlePopState(event) {
const path = location.pathname
const route = this.routes[path]
if (route) {
document.title = route.title
route.handler()
}
},
init() {
// 初始化时处理当前路径
const path = location.pathname
const route = this.routes[path] || this.routes['/']
document.title = route.title
route.handler()
// 监听导航事件
window.addEventListener('popstate', this.handlePopState.bind(this))
},
}
// 初始化路由
router.init()
BOM 提供了一系列强大的对象,允许 JavaScript 与浏览器窗口和用户环境交互。通过这些对象,开发者可以控制浏览器窗口、操作地址栏、检测浏览器和设备特性、访问用户地理位置,以及管理浏览历史。这些能力对于构建现代、动态和响应式的 Web 应用程序至关重要。
第 13 章 客户端检测
在 Web 开发中,我们经常需要确定用户使用的是什么浏览器、操作系统或设备,以便针对不同环境提供最佳的用户体验。JavaScript 提供了多种客户端检测技术,各有优缺点。
能力检测
能力检测(又称特性检测)即在 JavaScript 运行时中使用一套简单的检测逻辑,测试浏览器是否支持某种特性。这种方式不要求事先知道特定浏览器的信息,只需检测自己关心的能力是否存在即可。
基本原则:
- 先检测最常用的方式
- 必须检测切实需要的特性
- 不要仅根据一个特性的存在就推断其他特性也存在
// 检测对象是否支持某个方法
function isMethodSupported(object, method) {
return typeof object[method] === 'function'
}
// 使用示例
if (isMethodSupported(document, 'querySelector')) {
// 使用 document.querySelector()
const element = document.querySelector('.my-class')
} else {
// 使用替代方法
const element = document.getElementsByClassName('my-class')[0]
}
// 检测是否支持 Fetch API
function isFetchSupported() {
return 'fetch' in window
}
// 检测是否支持 Service Worker
function isServiceWorkerSupported() {
return 'serviceWorker' in navigator
}
安全能力检测
直接检测一个属性是否存在还不够,因为有些对象属性可能存在但不能使用,或者类型与预期不符。
// 不安全的检测
if (document.getElementsByTagName) {
// 假设 document.getElementsByTagName 是一个函数
}
// 安全的检测
if (typeof document.getElementsByTagName === 'function') {
// document.getElementsByTagName 确实是一个函数
}
// 检测 WebP 图像格式支持
function checkWebPSupport() {
return new Promise((resolve) => {
const img = new Image()
img.onload = function () {
resolve(img.width > 0 && img.height > 0)
}
img.onerror = function () {
resolve(false)
}
img.src =
'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=='
})
}
// 检测 localStorage 是否可用(考虑隐私模式限制)
function isLocalStorageAvailable() {
try {
const test = '__test__'
localStorage.setItem(test, test)
const result = localStorage.getItem(test) === test
localStorage.removeItem(test)
return result
} catch (e) {
return false
}
}
基于能力检测进行浏览器分析
能力检测通常用于决定使用哪种 API,但有时也可以用于推断浏览器类型。不过这种方法不应该作为主要的浏览器识别方式,因为浏览器特性会不断变化。
// 识别可能的浏览器
function getBrowserInfo() {
const browsers = {
chrome: false,
firefox: false,
safari: false,
edge: false,
ie: false,
}
// Chrome 和基于 Chromium 的浏览器
browsers.chrome = !!window.chrome && !!window.chrome.webstore
// Firefox
browsers.firefox = typeof InstallTrigger !== 'undefined'
// Safari
browsers.safari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
// Edge (基于 Chromium)
browsers.edge = !!window.chrome && navigator.userAgent.indexOf('Edg') != -1
// Internet Explorer
browsers.ie = /*@cc_on!@*/ false || !!document.documentMode
return browsers
}
用户代理检测
用户代理检测通过解析浏览器的 User-Agent 字符串来确定浏览器和操作系统。这是一种更直接但也更脆弱的方法,因为 User-Agent 字符串可能被修改或伪造。
function getUserAgentInfo() {
const ua = navigator.userAgent
let browser = '未知浏览器'
let version = ''
// 检测常见浏览器
if (/MSIE|Trident/.test(ua)) {
browser = 'Internet Explorer'
version = /MSIE (\d+\.\d+)/.exec(ua)
? RegExp.$1
: /rv:(\d+\.\d+)/.exec(ua)
? RegExp.$1
: ''
} else if (/Firefox/.test(ua)) {
browser = 'Firefox'
version = /Firefox\/(\d+\.\d+)/.exec(ua)[1]
} else if (/Chrome/.test(ua)) {
if (/Edg/.test(ua)) {
browser = 'Edge'
version = /Edg\/(\d+\.\d+)/.exec(ua)[1]
} else {
browser = 'Chrome'
version = /Chrome\/(\d+\.\d+)/.exec(ua)[1]
}
} else if (/Safari/.test(ua) && !/Chrome/.test(ua)) {
browser = 'Safari'
version = /Version\/(\d+\.\d+)/.exec(ua)[1]
} else if (/Opera/.test(ua)) {
browser = 'Opera'
version = /Version\/(\d+\.\d+)/.exec(ua)
? RegExp.$1
: /Opera\/(\d+\.\d+)/.exec(ua)[1]
}
return { browser, version }
}
用户代理的历史
User-Agent 字符串有着复杂的历史演变过程,主要受到浏览器竞争和兼容性需求的影响。
- 早期浏览器:最初的 Web 浏览器 Mosaic,其 User-Agent 相对简单。
- Netscape 与 IE 之争:浏览器开始在 UA 中相互模仿,以确保网站兼容性。
- 现代浏览器混战:几乎所有现代浏览器都在 UA 中包含 "Mozilla"、"AppleWebKit"、"Chrome" 等标识,使得解析变得越来越复杂。
// 几种常见浏览器的 User-Agent 示例
const chromeUA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
const firefoxUA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0'
const safariUA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15'
const edgeUA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59'
浏览器分析
复杂的 User-Agent 字符串需要谨慎解析,许多网站使用第三方库如 UAParser.js 来准确识别浏览器。
// 简单的浏览器和操作系统检测函数
function detectBrowserAndOS() {
const ua = navigator.userAgent
let os = '未知操作系统'
// 检测操作系统
if (/Windows/.test(ua)) {
os = 'Windows'
if (/Windows NT 10.0/.test(ua)) os += ' 10'
else if (/Windows NT 6.3/.test(ua)) os += ' 8.1'
else if (/Windows NT 6.2/.test(ua)) os += ' 8'
else if (/Windows NT 6.1/.test(ua)) os += ' 7'
} else if (/Macintosh|Mac OS X/.test(ua)) {
os = 'macOS'
const version = /Mac OS X (10[._]\d+)/.exec(ua)
if (version) os += ' ' + version[1].replace(/_/g, '.')
} else if (/Android/.test(ua)) {
os = 'Android'
const version = /Android (\d+\.\d+)/.exec(ua)
if (version) os += ' ' + version[1]
} else if (/iOS|iPhone|iPad|iPod/.test(ua)) {
os = 'iOS'
const version = /OS (\d+_\d+)/.exec(ua)
if (version) os += ' ' + version[1].replace(/_/g, '.')
} else if (/Linux/.test(ua)) {
os = 'Linux'
}
// 获取之前定义的浏览器信息
const { browser, version } = getUserAgentInfo()
return { browser, version, os }
}
软件与硬件检测
除了浏览器识别,现代 Web 应用还需要检测用户的软件环境和硬件能力,以提供最佳体验。
识别浏览器与操作系统
JavaScript 提供了多种 API 来获取客户端信息,其中 navigator
对象是最常用的信息源。
// 使用 navigator 对象获取基本信息
function getBrowserInfo() {
return {
userAgent: navigator.userAgent,
platform: navigator.platform,
vendor: navigator.vendor,
language: navigator.language || navigator.userLanguage,
cookieEnabled: navigator.cookieEnabled,
onLine: navigator.onLine,
doNotTrack: navigator.doNotTrack,
hardwareConcurrency: navigator.hardwareConcurrency || '未知',
maxTouchPoints: navigator.maxTouchPoints || 0,
}
}
// 检测是否为移动设备
function isMobileDevice() {
return (
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2)
)
}
浏览器元数据
现代浏览器提供了更多 API 来获取环境信息,这些 API 通常比解析 User-Agent 更可靠。
// 获取浏览器元数据
function getBrowserMetadata() {
// 检查浏览器配置
const metadata = {
// 基本网络信息
onLine: navigator.onLine,
// 时区和语言
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
languages: navigator.languages || [navigator.language],
// 存储信息
cookiesEnabled: navigator.cookieEnabled,
localStorage: typeof localStorage !== 'undefined',
sessionStorage: typeof sessionStorage !== 'undefined',
// 屏幕信息
colorDepth: screen.colorDepth,
pixelRatio: window.devicePixelRatio || 1,
// JavaScript 和媒体功能
javaEnabled: navigator.javaEnabled ? navigator.javaEnabled() : false,
pdfViewerEnabled: navigator.pdfViewerEnabled || false,
}
// 添加浏览器功能检测
metadata.webpSupport =
document
.createElement('canvas')
.toDataURL('image/webp')
.indexOf('data:image/webp') === 0
return metadata
}
硬件
检测用户的硬件能力对于优化应用性能至关重要。
// 获取硬件信息
function getHardwareInfo() {
const hardware = {
// CPU 信息
cpuCores: navigator.hardwareConcurrency || '未知',
// 内存信息(如果支持)
memoryInfo: navigator.deviceMemory ? `${navigator.deviceMemory}GB` : '未知',
// 屏幕信息
screenResolution: `${screen.width}x${screen.height}`,
availableResolution: `${screen.availWidth}x${screen.availHeight}`,
colorDepth: `${screen.colorDepth}位`,
pixelRatio: window.devicePixelRatio || 1,
// 触摸支持
touchPoints: navigator.maxTouchPoints || 0,
touchScreen: 'ontouchstart' in window || navigator.maxTouchPoints > 0,
// 连接信息
connectionType: navigator.connection
? navigator.connection.effectiveType || '未知'
: '不支持',
}
// 检测电池状态(如果支持)
if (navigator.getBattery) {
navigator.getBattery().then((battery) => {
const batteryInfo = {
charging: battery.charging,
level: battery.level * 100 + '%',
chargingTime:
battery.chargingTime === Infinity
? '未在充电'
: battery.chargingTime + '秒',
dischargingTime:
battery.dischargingTime === Infinity
? '未知'
: battery.dischargingTime + '秒',
}
console.log('电池信息:', batteryInfo)
})
}
// 检测 WebGL 支持和性能
try {
const canvas = document.createElement('canvas')
const gl =
canvas.getContext('webgl') || canvas.getContext('experimental-webgl')
if (gl) {
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info')
if (debugInfo) {
hardware.gpuVendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL)
hardware.gpuRenderer = gl.getParameter(
debugInfo.UNMASKED_RENDERER_WEBGL,
)
}
}
} catch (e) {
hardware.webglSupport = false
}
return hardware
}
媒体功能检测
检测浏览器的媒体处理能力对于视频和音频应用特别重要。
// 检测媒体功能
function checkMediaCapabilities() {
const mediaInfo = {
// 视频格式支持
videoFormats: {},
// 音频格式支持
audioFormats: {},
// 媒体设备
mediaDevices: navigator.mediaDevices ? true : false,
// 播放能力
canPlayTypes: {},
}
// 视频格式检测
const videoElement = document.createElement('video')
mediaInfo.videoFormats = {
mp4: videoElement.canPlayType('video/mp4') !== '',
webm: videoElement.canPlayType('video/webm') !== '',
ogg: videoElement.canPlayType('video/ogg') !== '',
hevc: videoElement.canPlayType('video/mp4; codecs="hev1"') !== '',
}
// 音频格式检测
const audioElement = document.createElement('audio')
mediaInfo.audioFormats = {
mp3: audioElement.canPlayType('audio/mpeg') !== '',
aac: audioElement.canPlayType('audio/aac') !== '',
ogg: audioElement.canPlayType('audio/ogg; codecs="vorbis"') !== '',
wav: audioElement.canPlayType('audio/wav') !== '',
}
// 检测媒体设备
if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
navigator.mediaDevices
.enumerateDevices()
.then((devices) => {
const hasAudioInput = devices.some(
(device) => device.kind === 'audioinput',
)
const hasVideoInput = devices.some(
(device) => device.kind === 'videoinput',
)
mediaInfo.hasAudioInput = hasAudioInput
mediaInfo.hasVideoInput = hasVideoInput
console.log('媒体设备信息:', { hasAudioInput, hasVideoInput })
})
.catch((err) => {
console.error('无法枚举媒体设备:', err)
})
}
return mediaInfo
}
最佳实践
客户端检测应该遵循以下最佳实践:
- 优先使用特性检测:尽可能使用能力检测,而不是用户代理检测。
- 根据用途选择方法:
- 需要使用特定 API?使用能力检测
- 需要收集统计数据?使用用户代理检测
- 需要优化性能?使用硬件检测
- 使用成熟的库:对于复杂的检测需求,考虑使用如 Modernizr、UAParser.js 等成熟的库。
- 渐进增强:设计应用时,先确保基本功能在所有环境下可用,再根据检测结果增强体验。
// 渐进增强示例
function enhanceUserExperience() {
// 基本功能始终可用
const app = document.getElementById('app')
// 根据能力检测增强体验
if (window.IntersectionObserver) {
// 使用懒加载技术
setupLazyLoading()
}
if ('serviceWorker' in navigator) {
// 启用离线功能
registerServiceWorker()
}
if (navigator.hardwareConcurrency > 4) {
// 启用更复杂的动画效果
enableAdvancedAnimations()
}
// 根据屏幕尺寸调整UI
const { width } = getViewportSize()
if (width > 1200) {
app.classList.add('large-screen')
} else if (width < 600) {
app.classList.add('small-screen')
}
}
第 17 章 事件
事件流
事件流描述了页面接收事件的顺序。
事件冒泡
IE 事件流被称为事件冒泡,这是因为事件被定义为从最具体的元素(文档树中最深的节点)开始触发,然后向上传播至没有那么具体的元素(文档)。现代浏览器中的事件会一直冒泡到 window 对象。
<!DOCTYPE html>
<html>
<head>
<title>Event Bubbling Example</title>
</head>
<body>
<div id="myDiv">Click Me</div>
</body>
</html>
在点击页面中的 div 元素后,click 事件会以如下顺序发生:
(1) <div>
(2) <body>
(3) <html>
(4) document
事件捕获
事件捕获的意思是最不具体的节点应该最先收到事件,而最具体的节点应该最后收到事件。事件捕获实际上是为了在事件到达最终目标前拦截事件。
如果前面的例子使用事件捕获,则点击 div 元素会以下列顺序触发 click 事件:
(1) document
(2) <html>
(3) <body>
(4) <div>
在事件捕获中,click 事件首先由 document 元素捕获,然后沿 DOM 树依次向下传播,直至到达实际的目标元素 div。
通常建议使用事件冒泡,特殊情况下可以使用事件捕获。
DOM 事件流
DOM2 Events 规范规定事件流分为 3 个阶段:事件捕获、到达目标和事件冒泡。事件捕获最先发生,为提前拦截事件提供了可能。然后,实际的目标元素接收到事件。最后一个阶段是冒泡,最迟要在这个阶段响应事件。
事件处理程序
事件意味着用户或浏览器执行的某种动作。比如,单击(click)、加载(load)、鼠标悬停(mouseover)。为响应事件而调用的函数被称为事件处理程序(或事件监听器)。事件处理程序的名字以"on"开头,因此 click 事件的处理程序叫作 onclick,而 load 事件的处理程序叫作 onload。有很多方式可以指定事件处理程序。
HTML 事件处理程序
最早的事件处理方式是在 HTML 元素中指定属性,这些属性的值是 JavaScript 代码。
<button onclick="alert('Clicked!')">点击我</button>
<button onclick="showMessage()">显示消息</button>
<script>
function showMessage() {
alert('按钮被点击了!')
}
</script>
特点:
- 简单直观,直接在 HTML 中可见
- 可能导致 HTML 与 JavaScript 代码混杂
- 如果用户在 JavaScript 加载前就与页面交互,可能会出错
- 事件处理程序的作用域链包含了所有 HTML 元素作为变量
DOM0 事件处理程序
DOM0 级事件处理程序是将一个函数赋值给一个事件处理程序属性。
// 获取元素
var btn = document.getElementById('myBtn')
// 指定事件处理程序
btn.onclick = function () {
alert('按钮被点击了!')
}
// 移除事件处理程序
btn.onclick = null
特点:
- 简单易用,跨浏览器兼容性好
- 每个元素的特定事件只能绑定一个处理程序
- 可以随时移除事件处理程序
- 事件处理程序在元素的作用域中运行,this 引用当前元素
DOM2 事件处理程序
DOM2 级事件定义了两个方法:addEventListener()和 removeEventListener(),用于添加和移除事件处理程序。
// 获取元素
var btn = document.getElementById('myBtn')
// 添加事件监听器
btn.addEventListener(
'click',
function () {
alert('第一个处理程序!')
},
false,
)
// 添加另一个事件监听器
btn.addEventListener(
'click',
function () {
alert('第二个处理程序!')
},
false,
)
// 使用命名函数,便于移除
function handleClick() {
alert('处理点击事件')
}
btn.addEventListener('click', handleClick, false)
// 移除事件监听器
btn.removeEventListener('click', handleClick, false)
特点:
- 可以为同一事件添加多个处理程序
- 事件处理程序按照添加顺序执行
- 第三个参数指定事件是在捕获阶段还是冒泡阶段处理(false 表示冒泡阶段)
- 只能移除具名函数的事件处理程序
- IE9+及其他现代浏览器都支持
跨浏览器事件处理程序
为了处理浏览器差异,尤其是针对旧版 IE 浏览器(IE8 及以下),可以创建一个跨浏览器的事件处理函数。
// 跨浏览器事件处理函数
var EventUtil = {
// 添加事件
addHandler: function (element, type, handler) {
if (element.addEventListener) {
// DOM2级方法
element.addEventListener(type, handler, false)
} else if (element.attachEvent) {
// IE8及以下
element.attachEvent('on' + type, handler)
} else {
// DOM0级方法
element['on' + type] = handler
}
},
// 移除事件
removeHandler: function (element, type, handler) {
if (element.removeEventListener) {
element.removeEventListener(type, handler, false)
} else if (element.detachEvent) {
element.detachEvent('on' + type, handler)
} else {
element['on' + type] = null
}
},
// 获取事件对象
getEvent: function (event) {
return event ? event : window.event
},
// 获取目标元素
getTarget: function (event) {
return event.target || event.srcElement
},
// 阻止默认行为
preventDefault: function (event) {
if (event.preventDefault) {
event.preventDefault()
} else {
event.returnValue = false
}
},
// 阻止冒泡
stopPropagation: function (event) {
if (event.stopPropagation) {
event.stopPropagation()
} else {
event.cancelBubble = true
}
},
}
// 使用方式
var btn = document.getElementById('myBtn')
function handleClick(event) {
// 获取事件和目标
event = EventUtil.getEvent(event)
var target = EventUtil.getTarget(event)
alert('按钮 ' + target.id + ' 被点击了')
// 阻止冒泡
EventUtil.stopPropagation(event)
}
// 添加事件
EventUtil.addHandler(btn, 'click', handleClick)
// 移除事件
EventUtil.removeHandler(btn, 'click', handleClick)
特点:
- 抽象了浏览器差异,提供统一的接口
- 包含了事件处理的常用功能
- 提高代码的可维护性和跨浏览器兼容性
- 适用于需要支持旧版浏览器的项目
事件对象
当事件处理程序执行时,会传入一个 event 对象,包含与事件相关的信息。
// DOM中的事件对象
document.body.addEventListener(
'click',
function (event) {
console.log('事件类型: ' + event.type)
console.log('目标元素: ' + event.target.tagName)
console.log('当前元素: ' + event.currentTarget.tagName)
console.log('事件阶段: ' + event.eventPhase)
console.log('坐标: ' + event.clientX + ', ' + event.clientY)
// 阻止默认行为
event.preventDefault()
// 阻止冒泡
event.stopPropagation()
// 立即阻止冒泡,并阻止其他事件处理程序
// event.stopImmediatePropagation()
},
false,
)
// IE8及以下的事件对象
document.body.attachEvent('onclick', function (event) {
event = event || window.event
console.log('事件类型: ' + event.type)
console.log('目标元素: ' + event.srcElement.tagName)
console.log('坐标: ' + event.clientX + ', ' + event.clientY)
// 阻止默认行为
event.returnValue = false
// 阻止冒泡
event.cancelBubble = true
})
事件委托
事件委托利用事件冒泡,可以通过在父元素上设置一个处理程序来管理一组相似的子元素事件。
// 不使用事件委托,为每个列表项添加事件
var items = document.getElementsByTagName('li')
for (var i = 0; i < items.length; i++) {
items[i].onclick = function () {
console.log('列表项 ' + this.innerHTML + ' 被点击了')
}
}
// 使用事件委托,只在父元素上设置一个事件处理程序
document.getElementById('myList').addEventListener(
'click',
function (event) {
// 确保点击的是列表项
if (event.target.tagName.toLowerCase() === 'li') {
console.log('列表项 ' + event.target.innerHTML + ' 被点击了')
}
},
false,
)
事件委托的优点:
- 减少事件处理程序数量,提高性能
- 自动处理动态添加的元素
- 减少内存占用和需要的代码量
- 简化了 DOM 元素的删除操作,不需要附加的事件移除步骤
事件类型
Web 浏览器中可以发生很多种事件,了解不同的事件类型有助于构建更好的交互体验。DOM3 级事件规范和 HTML5 定义了大量的事件类型。
用户界面事件
用户界面(UI)事件是与 BOM 相关的事件,通常涉及与浏览器窗口本身的交互,而非与页面内容的交互。
// load事件 - 当页面完全加载后触发
window.addEventListener('load', function () {
console.log('页面已完全加载')
})
// unload事件 - 当页面完全卸载后触发
window.addEventListener('unload', function () {
console.log('页面正在卸载')
})
// resize事件 - 当浏览器窗口被调整大小时触发
window.addEventListener('resize', function () {
console.log('窗口大小: ' + window.innerWidth + 'x' + window.innerHeight)
})
// scroll事件 - 当用户滚动页面或元素时触发
window.addEventListener('scroll', function () {
console.log('页面滚动位置: ' + window.pageYOffset)
})
焦点事件
焦点事件在页面元素获得或失去焦点时触发。这些事件可用于表单验证等场景。
var textbox = document.getElementById('myText')
// focus事件 - 元素获得焦点时触发
textbox.addEventListener('focus', function () {
console.log('文本框获得焦点')
this.style.backgroundColor = '#FFFFCC'
})
// blur事件 - 元素失去焦点时触发
textbox.addEventListener('blur', function () {
console.log('文本框失去焦点')
this.style.backgroundColor = ''
// 离开字段时验证
if (this.value == '') {
alert('请输入值!')
}
})
// focusin事件 - 类似focus,但会冒泡
// focusout事件 - 类似blur,但会冒泡
document.forms[0].addEventListener('focusin', function (event) {
console.log('聚焦于: ' + event.target.id)
})
鼠标和滚轮事件
鼠标事件是 Web 开发中最常用的一类事件,用于跟踪用户与页面的交互。
var div = document.getElementById('myDiv')
// click事件 - 单击鼠标按钮时触发
div.addEventListener('click', function (event) {
console.log('鼠标坐标: ' + event.clientX + ', ' + event.clientY)
})
// dblclick事件 - 双击鼠标按钮时触发
div.addEventListener('dblclick', function () {
console.log('双击事件')
})
// mousedown事件 - 按下鼠标按钮时触发
div.addEventListener('mousedown', function (event) {
console.log('按下的鼠标按钮: ' + event.button) // 0-左, 1-中, 2-右
})
// mouseup事件 - 释放鼠标按钮时触发
div.addEventListener('mouseup', function () {
console.log('鼠标按钮释放')
})
// mousemove事件 - 鼠标在元素内移动时持续触发
div.addEventListener('mousemove', function (event) {
// 避免过多的处理,可以节流
// 显示鼠标在元素内的位置
var offsetX = event.clientX - this.getBoundingClientRect().left
var offsetY = event.clientY - this.getBoundingClientRect().top
console.log('相对位置: ' + offsetX + ', ' + offsetY)
})
// mouseenter和mouseleave - 不冒泡的鼠标进入和离开事件
div.addEventListener('mouseenter', function () {
console.log('鼠标进入')
this.style.backgroundColor = 'lightblue'
})
div.addEventListener('mouseleave', function () {
console.log('鼠标离开')
this.style.backgroundColor = ''
})
// mouseover和mouseout - 冒泡的鼠标进入和离开事件
div.addEventListener('mouseover', function () {
console.log('鼠标悬停')
})
div.addEventListener('mouseout', function () {
console.log('鼠标移出')
})
// wheel事件 - 滚动鼠标滚轮时触发
document.addEventListener('wheel', function (event) {
console.log('滚轮方向: ' + (event.deltaY > 0 ? '向下' : '向上'))
// 阻止默认滚动
// event.preventDefault()
})
键盘与输入事件
键盘事件用于响应键盘输入,主要有 keydown、keypress 和 keyup 事件。
var textbox = document.getElementById('myText')
// keydown事件 - 按下键盘按键时触发
textbox.addEventListener('keydown', function (event) {
console.log('按键代码: ' + event.keyCode)
// 阻止特定键的默认行为
if (event.keyCode == 13) {
// Enter键
console.log('按下了回车键!')
event.preventDefault() // 阻止默认换行
}
})
// keypress事件 - 按下字符键时触发
textbox.addEventListener('keypress', function (event) {
console.log('字符代码: ' + event.charCode)
console.log('字符: ' + String.fromCharCode(event.charCode))
})
// keyup事件 - 释放键盘按键时触发
textbox.addEventListener('keyup', function (event) {
console.log('释放的按键: ' + event.key)
})
// input事件 - 当<input>、<textarea>元素的值发生变化时触发
textbox.addEventListener('input', function () {
console.log('当前值: ' + this.value)
})
// textInput事件 - 在文本被插入到文本框之前触发
textbox.addEventListener('textInput', function (event) {
console.log('输入的文本: ' + event.data)
// 可以在此阻止特定字符输入
if (/[^\d]/.test(event.data)) {
// 非数字
event.preventDefault() // 阻止非数字字符输入
}
})
合成事件
合成事件是为 IME(Input Method Editor,输入法编辑器)输入字符时触发的事件,主要用于处理亚洲语言和其他字符输入。
var textbox = document.getElementById('myText')
// compositionstart - 输入法编辑器开始新的输入会话
textbox.addEventListener('compositionstart', function () {
console.log('开始输入法输入')
})
// compositionupdate - 输入法新字符插入到输入字段中
textbox.addEventListener('compositionupdate', function (event) {
console.log('正在输入: ' + event.data)
})
// compositionend - 输入法编辑器结束输入会话
textbox.addEventListener('compositionend', function (event) {
console.log('输入完成: ' + event.data)
})
变化事件
变化事件在 DOM 结构发生变化时触发,包括 mutation 事件和现代 MutationObserver API。
// MutationObserver - 监视DOM变化
var targetNode = document.getElementById('myDiv')
var observerOptions = {
childList: true, // 监视子节点添加或删除
attributes: true, // 监视属性变化
subtree: true, // 监视所有后代节点
}
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type === 'childList') {
console.log('子节点已更改')
} else if (mutation.type === 'attributes') {
console.log('属性 ' + mutation.attributeName + ' 已更改')
}
})
})
// 开始观察
observer.observe(targetNode, observerOptions)
// 停止观察
// observer.disconnect()
HTML5 事件
HTML5 规范定义了许多有用的事件,这些事件在现代浏览器中得到广泛支持。
// contextmenu事件 - 右键点击时触发
document.addEventListener('contextmenu', function (event) {
console.log('显示自定义上下文菜单')
event.preventDefault() // 阻止默认上下文菜单
// 显示自定义上下文菜单
var menu = document.getElementById('customMenu')
menu.style.display = 'block'
menu.style.left = event.clientX + 'px'
menu.style.top = event.clientY + 'px'
})
// beforeunload事件 - 页面即将卸载前触发
window.addEventListener('beforeunload', function (event) {
// 显示确认消息
var message = '您有未保存的更改,确定要离开吗?'
event.returnValue = message
return message
})
// DOMContentLoaded事件 - 在DOM完全加载后触发(不等待样式表、图像)
document.addEventListener('DOMContentLoaded', function () {
console.log('DOM已加载完成')
// 比window.onload更早执行
})
// hashchange事件 - URL的锚部分(#后面的内容)改变时触发
window.addEventListener('hashchange', function () {
console.log('Hash已更改为: ' + location.hash)
})
// pageshow和pagehide事件 - 页面显示和隐藏时触发
window.addEventListener('pageshow', function (event) {
console.log('页面显示,是否来自缓存: ' + event.persisted)
})
window.addEventListener('pagehide', function () {
console.log('页面隐藏')
})
设备事件
设备事件用于响应设备状态变化,如方向改变、移动等。
// orientationchange事件 - 设备方向改变时触发
window.addEventListener('orientationchange', function () {
console.log('方向已更改为: ' + window.orientation)
// 0表示竖屏,90表示向左旋转的横屏,-90表示向右旋转的横屏
})
// deviceorientation事件 - 设备的物理方向信息改变时触发
window.addEventListener('deviceorientation', function (event) {
console.log('Alpha: ' + event.alpha)
console.log('Beta: ' + event.beta)
console.log('Gamma: ' + event.gamma)
})
// devicemotion事件 - 设备加速度改变时触发
window.addEventListener('devicemotion', function (event) {
var acceleration = event.accelerationIncludingGravity
console.log('加速度 X: ' + acceleration.x)
console.log('加速度 Y: ' + acceleration.y)
console.log('加速度 Z: ' + acceleration.z)
})
触摸及手势事件
触摸事件是移动设备上的重要事件类型,用于检测用户与触摸屏的交互。
var div = document.getElementById('myDiv')
// touchstart事件 - 手指放到屏幕上时触发
div.addEventListener('touchstart', function (event) {
console.log('触摸开始')
console.log('触摸点数量: ' + event.touches.length)
// 获取第一个触摸点的位置
var touch = event.touches[0]
console.log('触摸坐标: ' + touch.clientX + ', ' + touch.clientY)
})
// touchmove事件 - 手指在屏幕上滑动时触发
div.addEventListener('touchmove', function (event) {
console.log('触摸移动')
event.preventDefault() // 阻止滚动
})
// touchend事件 - 手指从屏幕上移开时触发
div.addEventListener('touchend', function () {
console.log('触摸结束')
})
// touchcancel事件 - 触摸被打断时触发
div.addEventListener('touchcancel', function () {
console.log('触摸被取消')
})
// 手势事件(仅iOS Safari支持)
div.addEventListener('gesturestart', function (event) {
console.log('手势开始,缩放: ' + event.scale)
})
div.addEventListener('gesturechange', function (event) {
console.log('手势变化,旋转: ' + event.rotation + '度')
})
div.addEventListener('gestureend', function () {
console.log('手势结束')
})
内存与性能
在处理大量事件时,需要注意性能问题。正确管理事件处理程序可以显著提高 Web 应用的响应速度和效率。
事件委托
事件委托利用事件冒泡,在父元素上设置一个事件处理程序来管理多个子元素的事件,而不是为每个子元素单独设置。
// 不使用事件委托 - 为每个列表项添加事件处理程序
var items = document.querySelectorAll('#myList li')
for (var i = 0; i < items.length; i++) {
items[i].addEventListener('click', function () {
console.log('点击了: ' + this.textContent)
})
}
// 使用事件委托 - 只在父元素上设置一个事件处理程序
document.getElementById('myList').addEventListener('click', function (event) {
// 确保点击的是列表项
if (event.target.tagName.toLowerCase() === 'li') {
console.log('点击了: ' + event.target.textContent)
}
})
// 添加新项目时不需要再添加事件处理程序
var newItem = document.createElement('li')
newItem.textContent = '新项目'
document.getElementById('myList').appendChild(newItem)
事件委托的优点:
- 减少事件处理程序数量,提高性能
- 动态添加的元素无需额外处理
- 减少内存消耗
- 减少事件处理程序的注册与注销操作
删除事件处理程序
当不再需要事件处理程序或元素将被删除时,应移除事件处理程序以防止内存泄漏。
// 创建事件处理函数
function handleClick() {
console.log('按钮被点击')
}
// 添加事件处理程序
var btn = document.getElementById('myBtn')
btn.addEventListener('click', handleClick)
// 在不需要时移除事件处理程序
btn.removeEventListener('click', handleClick)
// 移除元素前先移除其事件处理程序
function removeElement() {
var element = document.getElementById('myElement')
// 移除所有事件(使用自定义的数据存储)
var events = element.dataset.events ? element.dataset.events.split(',') : []
events.forEach(function (eventType) {
element.removeEventListener(eventType, element[eventType + 'Handler'])
})
// 移除元素
if (element.parentNode) {
element.parentNode.removeChild(element)
}
}
模拟事件
有时需要在 JavaScript 中模拟用户操作触发的事件,例如在自动化测试或复杂 UI 交互中。
DOM 事件模拟
可以使用 DOM 的 createEvent()和 dispatchEvent()方法创建和触发自定义事件。
// 模拟点击事件
function simulateClick(element) {
// 创建事件
var event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
})
// 触发事件
element.dispatchEvent(event)
}
// 使用模拟点击
var btn = document.getElementById('myBtn')
simulateClick(btn)
// 模拟自定义事件
function simulateCustomEvent() {
// 创建自定义事件
var event = new CustomEvent('myEvent', {
detail: {
time: new Date(),
message: '这是自定义事件的数据',
},
bubbles: true,
cancelable: true,
})
// 添加事件监听器
document.addEventListener('myEvent', function (e) {
console.log('接收到自定义事件,数据: ' + e.detail.message)
})
// 触发事件
document.dispatchEvent(event)
}
simulateCustomEvent()
IE 事件模拟
对于 IE8 及以下版本,需要使用不同的方法模拟事件。
// 在旧版IE中模拟事件
function simulateClickIE(element) {
if (document.createEventObject) {
var event = document.createEventObject()
element.fireEvent('onclick', event)
}
}
// 跨浏览器模拟点击
function crossBrowserSimulateClick(element) {
if (document.createEvent) {
// DOM方式
var event = document.createEvent('MouseEvents')
event.initMouseEvent(
'click',
true,
true,
window,
0,
0,
0,
0,
0,
false,
false,
false,
false,
0,
null,
)
element.dispatchEvent(event)
} else if (document.createEventObject) {
// IE方式
var event = document.createEventObject()
element.fireEvent('onclick', event)
} else if (typeof element.onclick === 'function') {
// 作为最后的手段,直接调用onclick方法
element.onclick()
}
}
模拟事件时需要注意:
- 模拟的事件与真实用户触发的事件可能有细微差别
- 某些浏览器安全限制可能阻止特定事件的模拟(如 beforeunload)
- 模拟的事件应包含足够的事件属性以满足事件处理程序的需求
小结:
事件是 JavaScript 与网页结合的主要方式。最常见的事件是在 DOM3 Events 规范或 HTML5 中定义的。虽然基本的事件都有规范定义,但很多浏览器在规范之外实现了自己专有的事件,以方便开发者更好地满足用户交互需求,其中一些专有事件直接与特殊的设备相关。
围绕着使用事件,需要考虑内存与性能问题。例如:
- 最好限制一个页面中事件处理程序的数量,因为它们会占用过多内存,导致页面响应缓慢;
- 利用事件冒泡,事件委托可以解决限制事件处理程序数量的问题;
- 最好在页面卸载之前删除所有事件处理程序。
使用 JavaScript 也可以在浏览器中模拟事件。DOM2 Events 和 DOM3 Events 规范提供了模拟方法,可以模拟所有原生 DOM 事件。键盘事件一定程度上也是可以模拟的,有时候需要组合其他技术。IE8 及更早版本也支持事件模拟,只是接口与 DOM 方式不同。
事件是 JavaScript 中最重要的主题之一,理解事件的原理及其对性能的影响非常重要。