现代模式,“use strict”
begin from ES5, 为了标识ES5的新特性
变量
我们可以使用 var、let 或 const 声明变量来存储数据。
- let — 现代的变量声明方式。
- var — 老旧的变量声明方式。一般情况下,我们不会再使用它。
- const — 类似于 let,但是变量的值无法被修改。
用const
声明的对象的值 能 被修改。
数据类型
JavaScript 中有八种基本的数据类型(译注:前七种为基本数据类型,也称为原始类型,而 object
为复杂数据类型)。
number
用于任何类型的数字:整数或浮点数,在±(2ˆ53-1)
范围内的整数。bigint
用于任意长度的整数。string
用于字符串:一个字符串可以包含 0 个或多个字符。boolean
用于true
和false
。null
用于未知的值 —— 只有一个null
值的独立类型。undefined
用于未定义的值 —— 只有一个undefined
值的独立类型。symbol
用于唯一的标识符。object
用于更复杂的数据结构。
我们可以通过 typeof
运算符查看存储在变量中的数据类型。
- 两种形式:
typeof x
或者typeof(x)
。 - 以字符串的形式返回类型名称,例如
"string"
。 typeof null
会返回"object"
—— 这是 JavaScript 编程语言的一个错误,实际上它并不是一个object
。
typeof null == "object" // JavaScript 编程语言的设计错误
typeof function(){} == "function" // 函数被特殊对待
交互:alert、prompt 和 confirm
与用户交互的 3 个浏览器的特定函数:
alert(message)
显示信息。
prompt(question[, default])
显示信息要求用户输入文本。点击确定返回文本,点击取消或按下 Esc 键返回
null
。confirm(question)
显示信息等待用户点击确定或取消。点击确定返回
true
,点击取消或按下 Esc 键返回false
。
这些方法都是模态的:它们暂停脚本的执行,并且不允许用户与该页面的其余部分进行交互,直到窗口被解除。
类型转换
有三种常用的类型转换:转换为 string 类型、转换为 number 类型和转换为 boolean 类型。
字符串转换 —— 转换发生在输出内容的时候,也可以通过 String(value)
进行显式转换。原始类型值的 string 类型转换通常是很明显的。
数字型转换 —— 转换发生在进行算术操作时,也可以通过 Number(value)
进行显式转换。
数字型转换遵循以下规则:
值 | 变成…… |
---|---|
undefined | NaN |
null | 0 |
true / false | 1 / 0 |
string | “按原样读取”字符串,两端的空白会被忽略。空字符串变成 0 。转换出错则输出 NaN 。 |
布尔型转换 —— 转换发生在进行逻辑操作时,也可以通过 Boolean(value)
进行显式转换。
布尔型转换遵循以下规则:
值 | 变成…… |
---|---|
0 , null , undefined , NaN , "" | false |
其他值 | true |
上述的大多数规则都容易理解和记忆。人们通常会犯错误的值得注意的例子有以下几个:
- 对
undefined
进行数字型转换时,输出结果为NaN
,而非0
。 - 对
"0"
和只有空格的字符串(比如:" "
)进行布尔型转换时,输出结果为true
。
基础运算符,数学
二元运算符"+"链接字符串
一元运算符"+"转成数字类型
自增++/自减–
记住返回值是 新(前)旧(后):符号在前返回新值,符号在后返回旧值
值的比较
比较运算符始终返回布尔值。
字符串的比较,会按照“词典”顺序逐字符地比较大小。
当对不同类型的值进行比较时,它们会先被转化为数字(不包括严格相等检查)再进行比较。
普通的相等性检查
==
存在一个问题,它不能区分出0
和false
,无法区分空字符串和false
相等性检查
==
和普通比较符> < >= <=
的代码逻辑是相互独立的alert( null > 0 ); // (1) false alert( null == 0 ); // (2) false alert( null >= 0 ); // (3) true
在非严格相等
==
下,null
和undefined
相等且各自不等于任何其他的值。在使用
>
或<
进行比较时,需要注意变量可能为null/undefined
的情况。比较好的方法是单独检查变量是否等于null/undefined
。另外,number类型中的值 “NaN” 是独一无二的,它不等于任何东西,包括它自身:
alert( NaN === NaN ); // false
注意:
- 除了严格相等
===
外,其他但凡是有undefined/null
参与的比较,我们都需要格外小心。 - 除非你非常清楚自己在做什么,否则永远不要使用
>= > < <=
去比较一个可能为null/undefined
的变量。对于取值可能是null/undefined
的变量,请按需要分别检查它的取值情况。
条件分支:if 和 ‘?’
略
逻辑运算符
JavaScript 里有三个逻辑运算符:||
(或),&&
(与),!
(非)。
- 一个或运算
"||"
的链,将返回第一个真值,如果不存在真值,就返回该链的最后一个值。- 获取变量列表或者表达式的第一个真值。
- 短路求值(Short-circuit evaluation)。
- 与运算符
"&&"
的链返回第一个假值,如果没有假值就返回最后一个值。 - 优先级:
!
>&&
>||
空值合并运算符 ‘??’
(新特性,老的浏览器可能不支持)
空值合并运算符 ??
提供了一种简短的语法,用来获取列表中第一个“已定义”的变量(译注:即值不是 null
或 undefined
的变量)。
a ?? b
的结果是:
a
,如果a
不是null
或undefined
,b
,其他情况。- 区别
"||"
:
let height = 0;
alert(height || 100); // 100
alert(height ?? 100); // 0
??
运算符的优先级非常低,只略高于?
和=
。- 如果没有明确添加括号,不能将其与
||
或&&
一起使用。
循环:while 和 for
三种循环:
while
—— 每次迭代之前都要检查条件。do..while
—— 每次迭代后都要检查条件。for (;;)
—— 每次迭代之前都要检查条件,可以使用其他设置。
通常使用 while(true)
来构造“无限”循环。这样的循环和其他循环一样,都可以通过 break
指令来终止。
如果我们不想在当前迭代中做任何事,并且想要转移至下一次迭代,那么可以使用 continue
指令。
break/continue
支持循环前的标签。标签是 break/continue
跳出嵌套循环以转到外部的唯一方法。
“switch” 语句
如果没有
break
,程序将不经过任何检查就会继续执行下一个case
。共享同一段代码的几个
case
分支可以被分为一组:强调一下,"switch"的相等是严格相等。被比较的值必须是相同的类型才能进行匹配。
函数
函数对外部变量拥有全部的访问权限。函数也可以修改外部变量。只有在没有局部变量的情况下才会使用外部变量。如果在函数内部声明了同名变量,那么函数会 遮蔽 外部变量。
默认值、后备的默认参数:
如果调用时未提供参数,那么其默认值则是
undefined
。后备:跟
undefined
作比较、使用||
、使用??
//1. function showMessage(text) { if (text === undefined) { text = 'empty message'; } alert(text); } //2. function showMessage(text) { text = text || 'empty'; ... } //3. // 如果没有传入 "count" 参数,则显示 "unknown" function showCount(count) { alert(count ?? "unknown"); } showCount(0); // 0 showCount(null); // unknown showCount(); // unknown
空值的 return
或没有 return
的函数返回值为 undefined
函数表达式
//函数声明
function sayHi() {
alert( "Hello" );
}
//函数表达式
let sayHi = function() {
alert( "Hello" );
};
函数表达式结尾有一个分号 ;
,而函数声明没有。
回调函数、匿名函数
function ask(question, yes, no) {
if (confirm(question)) yes()
else no();
}
function showOk() {
alert( "You agreed." );
}
function showCancel() {
alert( "You canceled the execution." );
}
// 用法:函数 showOk 和 showCancel 被作为参数传入到 ask
ask("Do you agree?", showOk, showCancel);
ask
的两个参数值 showOk
和 showCancel
可以被称为 回调函数 或简称 回调。主要思想是我们传递一个函数,并期望在稍后必要时将其“回调”。在我们的例子中,showOk
是回答 “yes” 的回调,showCancel
是回答 “no” 的回调。
区别函数声明:
语法
函数表达式是在代码执行到达时被创建,并且仅从那一刻起可用。
函数声明则不同。在函数声明被定义之前,它就可以被调用。
严格模式下,当一个函数声明在一个代码块内时,它在该代码块内的任何位置都是可见的,但在代码块外不可见。要使其在代码块外可见(例如if外),正确的做法是使用函数表达式将函数赋值给在代码块外声明好的变量。
仅当函数声明不适合对应的任务时,才应使用函数表达式。
箭头函数,基础知识
对于一行代码的函数来说,箭头函数是相当方便的。它具体有两种:
- 不带花括号:
(...args) => expression
— 右侧是一个表达式:函数计算表达式并返回其结果。 - 带花括号:
(...args) => { body }
— 花括号允许我们在函数中编写多个语句,但是我们需要显式地return
来返回一些内容。
代码质量
这一章节有很多干货,包括调试、代码风格、注释、自动化测试BDD、transpiler和Polyfill等等,需要的可以再阅读。
Object(对象):基础知识
对象
方括号
let user = {}; // 设置 user["likes birds"] = true; // 读取 alert(user["likes birds"]); // true // 删除 delete user["likes birds"]; //变量作属性名 let key = "likes birds"; // 跟 user["likes birds"] = true; 一样 user[key] = true;
计算属性
let fruit = 'apple'; let bag = { [fruit + 'Computers']: 5 // bag.appleComputers = 5 };
它们存储属性(键值对),其中:
- 属性的键必须是字符串或者 symbol(通常是字符串)。可以是保留字如 “for”、“let”、“return” 等…
- 值可以是任何类型。
- 整数属性会被进行排序,“整数属性”指的是一个可以在不做任何更改的情况下与一个整数进行相互转换的字符串。
我们可以用下面的方法访问属性:
- 点符号:
obj.property
。 - 方括号
obj["property"]
,方括号允许从变量中获取键,例如obj[varWithKey]
。
其他操作:
- 删除属性:
delete obj.prop
。 - 检查是否存在给定键的属性:
"key" in obj
。 - 遍历对象:
for(let key in obj)
循环。
对象的引用和复制
对象通过引用被赋值和拷贝。换句话说,一个变量存储的不是“对象的值”,而是一个对值的“引用”(内存地址)。因此,拷贝此类变量或将其作为函数参数传递时,所拷贝的是引用,而不是对象本身。
所有通过被拷贝的引用的操作(如添加、删除属性)都作用在同一个对象上。
为了创建“真正的拷贝”(一个克隆),我们可以使用Object.assign
来做所谓的“浅拷贝”(嵌套对象被通过引用进行拷贝)
Object.assign(dest, [src1, src2, src3...])
- 第一个参数
dest
是指目标对象。 - 更后面的参数
src1, ..., srcN
(可按需传递多个参数)是源对象。 - 该方法将所有源对象的属性拷贝到目标对象
dest
中。换句话说,从第二个开始的所有参数的属性都被拷贝到第一个参数的对象中。 - 调用结果返回
dest
。
或者使用“深拷贝”函数,例如 _.cloneDeep(obj),对于对象属性中嵌套了对象的情况。
垃圾回收
- 垃圾回收是自动完成的,我们不能强制执行或是阻止执行。
- 当对象是可达状态时,它一定是存在于内存中的。
- 被引用与可访问(从一个根)不同:一组相互连接的对象可能整体都不可达。
对象方法、“this”
this
的值是在程序运行时得到的。
- 一个函数在声明时,可能就使用了
this
,但是这个this
只有在函数被调用时才会有值。 - 可以在对象之间复制函数。
- 以“方法”的语法调用函数时:
object.method()
,调用过程中的this
值是object
。
请注意箭头函数有些特别:它们没有 this
。在箭头函数内部访问到的 this
都是从外部获取的。
注意:
function makeUser() {
return {
name: "John",
ref: this
};
}
let user = makeUser();
alert( user.ref.name ); // 报错
这里 makeUser()
中的 this
的值是 undefined
,因为它是被作为函数调用的,而不是通过点符号被作为方法调用。this
的值是对于整个函数的,代码段和对象字面量对它都没有影响。所以 ref: this
实际上取的是当前函数的 this
,值为undefined
。所以,必须是通过点符号作为方法被调用是,this
返回的才是当前方法所在的对象
构造器和操作符"new"
当一个函数被使用 new
操作符执行时,它按照以下步骤:
- 一个新的空对象被创建并分配给
this
。 - 函数体执行。通常它会修改
this
,为其添加新的属性。 - 返回
this
的值。
如果没有参数,我们可以省略 new
后的括号。
构造器模式测试:new.target
这种方法有时被用在库中以使语法更加灵活:
function User(name) {
if (!new.target) { // 如果你没有通过 new 运行我
return new User(name); // ……我会给你添加 new
}
this.name = name;
}
let john = User("John"); // 将调用重定向到新用户
alert(john.name); // John
构造器的return
如果这有一个 return
语句,那么规则就简单了:
- 如果
return
返回的是一个对象,则返回这个对象,而不是this
。 - 如果
return
返回的是一个原始类型,则忽略。
换句话说,带有对象的 return
返回该对象,在所有其他情况下返回 this
。
通常构造器没有 return
语句。这里我们主要为了完整性而提及返回对象的特殊行为。
JavaScript 为许多内置的对象提供了构造函数:比如日期 Date
、集合 Set
以及其他我们计划学习的内容。
*思考:*是否可以创建像 new A()==new B()
这样的函数 A
和 B
?
let obj = {};
function A() { return obj; }
function B() { return obj; }
alert( new A() == new B() ); // true
可选链"?."
(新特性,就浏览器可能不支持)可选链 ?.
语法有三种形式:
obj?.prop
—— 如果obj
存在则返回obj.prop
,否则返回undefined
。obj?.[prop]
—— 如果obj
存在则返回obj[prop]
,否则返回undefined
。obj.method?.()
—— 如果obj.method
存在则调用obj.method()
,否则返回undefined
。
我们可以使用 ?.
来安全地读取或删除,但不能写入
正如我们所看到的,这些语法形式用起来都很简单直接。?.
检查左边部分是否为 null/undefined
,如果不是则继续运算。?.
链使我们能够安全地访问嵌套属性。
但是,我们应该谨慎地使用 ?.
,仅在当左边部分不存在也没问题的情况下使用为宜。以保证在代码中有编程上的错误出现时,也不会对我们隐藏。
Symbol类型
用的少,温故而知新。
// id 是 symbol 的一个实例化对象
let id1 = Symbol("id");
let id2 = Symbol("id");
alert(id1 == id2); // false
//输出Symbol类型
let id = Symbol("id");
alert(id.toString()); // Symbol(id)
let id = Symbol("id");
alert(id.description); // id
//Symbol类型作为对象的键
let user = { // 属于另一个代码
name: "John"
};
let id = Symbol("id");
user[id] = 1;
alert( user[id] ); // 我们可以使用 Symbol 作为键来访问数据
//如果是再字面量中:
let user = {
name: "John",
[id]: 123 // 而不是 "id":123
};
Symbol 在 for…in 中会被跳过。Object.keys(user) 也会忽略它们。这是一般“隐藏符号属性”原则的一部分。如果另一个脚本或库遍历我们的对象,它不会意外地访问到符号属性。相反,Object.assign 会同时复制字符串和 symbol 属性。
全局Symbol
// 从全局注册表中读取
let id = Symbol.for("id"); // 如果该 Symbol 不存在,则创建它
// 再次读取(可能是在代码中的另一个位置)
let idAgain = Symbol.for("id");
// 相同的 Symbol
alert( id === idAgain ); // true
// 通过 name 获取 Symbol
let sym = Symbol.for("name");
let sym2 = Symbol.for("id");
// 通过 Symbol 获取 name
alert( Symbol.keyFor(sym) ); // name
alert( Symbol.keyFor(sym2) ); // id
Symbol 有两个主要的使用场景:
“隐藏” 对象属性。 如果我们想要向“属于”另一个脚本或者库的对象添加一个属性,我们可以创建一个 Symbol 并使用它作为属性的键。Symbol 属性不会出现在 for…in 中,因此它不会意外地被与其他属性一起处理。并且,它不会被直接访问,因为另一个脚本没有我们的 symbol。因此,该属性将受到保护,防止被意外使用或重写。
因此我们可以使用 Symbol 属性“秘密地”将一些东西隐藏到我们需要的对象中,但其他地方看不到它。
JavaScript 使用了许多系统 Symbol,这些 Symbol 可以作为 Symbol.* 访问。我们可以使用它们来改变一些内置行为。例如,在本教程的后面部分,我们将使用 Symbol.iterator 来进行 迭代 操作,使用 Symbol.toPrimitive 来设置 对象原始值的转换 等等。
从技术上说,Symbol 不是 100% 隐藏的。有一个内置方法 Object.getOwnPropertySymbols(obj) 允许我们获取所有的 Symbol。还有一个名为 Reflect.ownKeys(obj) 的方法可以返回一个对象的 所有 键,包括 Symbol。所以它们并不是真正的隐藏。但是大多数库、内置方法和语法结构都没有使用这些方法。
对象 — 原始值转换
对象到原始值的转换,是由许多期望以原始值作为值的内建函数和运算符自动调用的。
这里有三种类型(hint):
"string"
(对于alert
和其他需要字符串的操作)"number"
(对于数学运算)"default"
(少数运算符)
规范明确描述了哪个运算符使用哪个 hint。很少有运算符“不知道期望什么”并使用 "default"
hint。通常对于内建对象,"default"
hint 的处理方式与 "number"
相同,因此在实践中,最后两个 hint 常常合并在一起。
转换算法是:
调用
obj[Symbol.toPrimitive](hint)
如果这个方法存在,否则,如果 hint 是
"string"
- 尝试
obj.toString()
和obj.valueOf()
,无论哪个存在。
- 尝试
否则,如果 hint 是
"number"
或者"default"
- 尝试
obj.valueOf()
和obj.toString()
,无论哪个存在。
- 尝试
在实践中,为了便于进行日志记录或调试,对于所有能够返回一种“可读性好”的对象的表达形式的转换,只实现以 obj.toString()
作为全能转换的方法就够了。
数据类型
原始类型的方法
“对象包装器”对于每种原始类型都是不同的,它们被称为 String
、Number
、Boolean
和 Symbol
。因此,它们提供了不同的方法。
以下是 str.toUpperCase()
中实际发生的情况:
- 字符串
str
是一个原始值。因此,在访问其属性时,会创建一个包含字符串字面值的特殊对象,并且具有有用的方法,例如toUpperCase()
。 - 该方法运行并返回一个新的字符串(由
alert
显示)。 - 特殊对象被销毁,只留下原始值
str
。
所以原始类型可以提供方法,但它们依然是轻量级的。
数字类型
要写有很多零的数字:
- 将
"e"
和 0 的数量附加到数字后。就像:123e6
与123
后面接 6 个 0 相同。 "e"
后面的负数将使数字除以 1 后面接着给定数量的零的数字。例如123e-6
表示0.000123
(123
的百万分之一)。
对于不同的数字系统:
- 可以直接在十六进制(
0x
),八进制(0o
)和二进制(0b
)系统中写入数字。 parseInt(str,base)
将字符串str
解析为在给定的base
数字系统中的整数,2 ≤ base ≤ 36
。num.toString(base)
将数字转换为在给定的base
数字系统中的字符串。
要将 12pt
和 100px
之类的值转换为数字:
- 使用
parseInt/parseFloat
进行“软”转换,它从字符串中读取数字,然后返回在发生 error 前可以读取到的值。(使用加号+
或Number()
的数字转换是严格的。如果一个值不完全是一个数字,就会失败)
小数:
- 使用
Math.floor
,Math.ceil
,Math.trunc
,Math.round
或num.toFixed(precision)
进行舍入。 - 请确保记住使用小数时会损失精度。
测试:isFinite 和 isNaN、Object.is
isNaN(value)
将其参数转换为数字,然后测试它是否为NaN
isFinite(value)
将其参数转换为数字,如果是常规数字,则返回true
,而不是NaN/Infinity/-Infinity
:let num = +prompt("Enter a number", ''); // 结果会是 true,除非你输入的是 Infinity、-Infinity 或不是数字 alert( isFinite(num) );
请注意,在所有数字函数中,包括
isFinite
,空字符串或仅有空格的字符串均被视为0
。有一个特殊的内建方法
Object.is
,它类似于===
一样对值进行比较,但它对于两种边缘情况更可靠:- 它适用于
NaN
:Object.is(NaN,NaN)=== true
,这是件好事。 - 值
0
和-0
是不同的:Object.is(0,-0)=== false
,从技术上讲这是对的,因为在内部,数字的符号位可能会不同,即使其他所有位均为零。
在所有其他情况下,
Object.is(a,b)
与a === b
相同。这种比较方式经常被用在 JavaScript 规范中。当内部算法需要比较两个值是否完全相同时,它使用
Object.is
(内部称为 SameValue)。- 它适用于
字符串
反引号的使用:模板字符串
有 3 种类型的引号。反引号允许字符串跨越多行并可以使用
${…}
在字符串中嵌入表达式。JavaScript 中的字符串使用的是 UTF-16 编码。
我们可以使用像
\n
这样的特殊字符或通过使用\u...
来操作它们的 unicode 进行字符插入。获取字符时,使用
[]
。方括号是获取字符的一种现代化方法,而charAt
是历史原因才存在的。它们之间的唯一区别是,如果没有找到字符,[]
返回undefined
,而charAt
返回一个空字符串获取子字符串,使用
slice
或substring
。方法 选择方式…… 负值参数 slice(start, end)
从 start
到end
(不含end
)允许 substring(start, end)
start
与end
之间(包括start
,但不包括end
)负值代表 0
substr(start, length)
从 start
开始获取长为length
的字符串允许 start
为负数字符串的大/小写转换,使用:
toLowerCase/toUpperCase
。查找子字符串时,使用
indexOf
或includes/startsWith/endsWith
进行简单检查。根据语言比较字符串时使用
localeCompare
,否则将按字符代码进行比较。
还有其他几种有用的字符串方法:
str.trim()
—— 删除字符串前后的空格 (“trims”)。str.repeat(n)
—— 重复字符串n
次。- ……更多内容细节请参见 手册。
数组
数组是一种特殊的对象,适用于存储和管理有序的数据项。
声明:
// 方括号 (常见用法) let arr = [item1, item2...]; // new Array (极其少见) let arr = new Array(item1, item2...);
调用
new Array(number)
会创建一个给定长度的数组,但不含有任何项。length
属性是数组的长度,准确地说,它是数组最后一个数字索引值加一。它由数组方法自动调整。如果我们手动缩短
length
,那么数组就会被截断。数组有自己的
toString
方法的实现,会返回以逗号隔开的元素列表。数组没有Symbol.toPrimitive
,也没有valueOf
,它们只能执行toString
进行转换
我们可以通过下列操作以双端队列的方式使用数组:
push(...items)
在末端添加items
项。pop()
从末端移除并返回该元素。shift()
从首端移除并返回该元素。unshift(...items)
从首端添加items
项。
遍历数组的元素:
for (let i=0; i<arr.length; i++)
— 运行得最快,可兼容旧版本浏览器。for (let item of arr)
— 现代语法,只能访问 items。for (let i in arr)
— 永远不要用这个。它获取的是索引,而且没有针对数组的优化
比较数组时,不要使用 ==
运算符(当然也不要使用 >
和 <
等运算符),因为它们不会对数组进行特殊处理。它们通常会像处理任意对象那样处理数组,这通常不是我们想要的。
但是,我们可以使用 for..of
循环来逐项比较数组。
数组的方法
数组方法备忘单:
- 添加/删除元素:
push(...items)
—— 向尾端添加元素,pop()
—— 从尾端提取一个元素,shift()
—— 从首端提取一个元素,unshift(...items)
—— 向首端添加元素,splice(pos, deleteCount, ...items)
—— 从pos
开始删除deleteCount
个元素,并插入items
。slice(start, end)
—— 创建一个新数组,将从索引start
到索引end
(但不包括end
)的元素复制进去。concat(...items)
—— 返回一个新数组:复制当前数组的所有元素,并向其中添加items
。如果items
中的任意一项是一个数组,那么就取其元素。
- 搜索元素:
indexOf/lastIndexOf(item, pos)
—— 从索引pos
开始搜索item
,搜索到则返回该项的索引,否则返回-1
。includes(value)
—— 如果数组有value
,则返回true
,否则返回false
。find/filter(func)
—— 通过func
过滤元素,返回使func
返回true
的第一个值/所有值。findIndex
和find
类似,但返回索引而不是值。
- 遍历元素:
forEach(func)
—— 对每个元素都调用func
,不返回任何内容。
- 转换数组:
map(func)
—— 根据对每个元素调用func
的结果创建一个新数组。sort(func)
—— 对数组进行原位(in-place)排序,然后返回它。reverse()
—— 原位(in-place)反转数组,然后返回它。split/join
—— 将字符串转换为数组并返回。reduce/reduceRight(func, initial)
—— 通过对每个元素调用func
计算数组上的单个值,并在调用之间传递中间结果。
- 其他:
Array.isArray(arr)
检查arr
是否是一个数组。
请注意,sort
,reverse
和 splice
方法修改的是数组本身。
这些是最常用的方法,它们覆盖 99% 的用例。
Iterable object (可迭代对象)
可以应用 for..of
的对象被称为 可迭代的。
技术上来说,可迭代对象必须实现
Symbol.iterator
方法。obj[Symbol.iterator]()
的结果被称为 迭代器(iterator)。由它处理进一步的迭代过程。- 一个迭代器必须有
next()
方法,它返回一个{done: Boolean, value: any}
对象,这里done:true
表明迭代结束,否则value
就是下一个值。
Symbol.iterator
方法会被for..of
自动调用,但我们也可以直接调用它。内置的可迭代对象例如字符串和数组,都实现了
Symbol.iterator
。字符串迭代器能够识别代理对(surrogate pair)。(译注:代理对也就是 UTF-16 扩展字符。)
可迭代(iterable)和类数组(array-like):
- Iterable 如上所述,是实现了
Symbol.iterator
方法的对象。 - Array-like 是有索引和
length
属性的对象,所以它们看起来很像数组。 Array.from(obj[, mapFn, thisArg])
将可迭代对象或类数组对象obj
转化为真正的数组Array
,然后我们就可以对它应用数组的方法。可选参数mapFn
和thisArg
允许我们将函数应用到每个元素。
/*
可迭代对象
*/
let range = {
from: 1,
to: 5
};
// 1. for..of 调用首先会调用这个:
range[Symbol.iterator] = function() {
// ……它返回迭代器对象(iterator object):
// 2. 接下来,for..of 仅与此迭代器一起工作,要求它提供下一个值
return {
current: this.from,
last: this.to,
// 3. next() 在 for..of 的每一轮循环迭代中被调用
next() {
// 4. 它将会返回 {done:.., value :...} 格式的对象
if (this.current <= this.last) {
return { done: false, value: this.current++ };
} else {
return { done: true };
}
}
};
};
// 现在它可以运行了!
for (let num of range) {
alert(num); // 1, 然后是 2, 3, 4, 5
}
// 用Array.from()方法转化成数组
let arr = Array.from(range, num => num * num);//(*)
alert(arr); // 1,4,9,16,25
/*
类数组对象
*/
let arrayLike = {
0: "Hello",
1: "World",
length: 2
};
let arr = Array.from(arrayLike); // (*)
//在 (*) 行的 Array.from 方法接受对象,检查它是一个可迭代对象或类数组对象,然后创建一个新数组,并将该对象的所有元素复制到这个新数组。
Map and Set (映射和集合)
Map 是一个带键的数据项的集合,就像一个 Object
一样。 但是它们最大的差别是 Map
允许任何类型的键(key)。
Map
—— 是一个带键的数据项的集合。
方法和属性如下:
new Map([iterable])
—— 创建 map,可选择带有[key,value]
对的iterable
(例如数组)来进行初始化。map.set(key, value)
—— 根据键存储值。map.get(key)
—— 根据键来返回值,如果map
中不存在对应的key
,则返回undefined
。map.has(key)
—— 如果key
存在则返回true
,否则返回false
。map.delete(key)
—— 删除指定键的值。map.clear()
—— 清空 map 。map.size
—— 返回当前元素个数。Object.entries:从对象创建 Map
// 键值对 [key, value] 数组 let map = new Map([ ['1', 'str1'], [1, 'num1'], [true, 'bool1'] ]); let obj = { name: "John", age: 30 }; let map = new Map(Object.entries(obj));
Object.fromEntries:从 Map 创建对象
let prices = Object.fromEntries([ ['banana', 1], ['orange', 2], ['meat', 4] ]); // 现在 prices = { banana: 1, orange: 2, meat: 4 } let map = new Map(); map.set('banana', 1); map.set('orange', 2); map.set('meat', 4); let obj = Object.fromEntries(map.entries()); // obj = { banana: 1, orange: 2, meat: 4 } // 或是 let obj = Object.fromEntries(map); /*因为 Object.fromEntries 期望得到一个可迭代对象作为参数,而不一定是数组。并且 map 的标准迭代会返回跟 map.entries() 一样的键/值对。*/
与普通对象 Object
的不同点:
- 任何键、对象都可以作为键。
- 迭代的顺序与插入值的顺序相同。与普通的
Object
不同,Map
保留了此顺序。 - 有其他的便捷方法,如
size
属性。
Set
—— 是一组唯一值的集合。
方法和属性:
new Set([iterable])
—— 创建 set,可选择带有iterable
(例如数组)来进行初始化。set.add(value)
—— 添加一个值(如果value
存在则不做任何修改),返回 set 本身。set.delete(value)
—— 删除值,如果value
在这个方法调用的时候存在则返回true
,否则返回false
。set.has(value)
—— 如果value
在 set 中,返回true
,否则返回false
。set.clear()
—— 清空 set。set.size
—— 元素的个数。
优点:
Set
的替代方法可以是一个用户数组,用 arr.find 在每次插入值时检查是否重复。但是这样性能会很差,因为这个方法会遍历整个数组来检查每个元素。Set
内部对唯一性检查进行了更好的优化。
在 Map
和 Set
中迭代总是按照值插入的顺序进行的,所以我们不能说这些集合是无序的,但是我们不能对元素进行重新排序,也不能直接按其编号来获取元素。
WeakMap和WeakSet
WeakMap
和 Map
的第一个不同点就是,WeakMap
的键必须是对象,不能是原始值。不支持迭代以及 keys()
,values()
和 entries()
方法。所以没有办法获取 WeakMap
的所有键或值。只有以下的方法:
weakMap.get(key)
weakMap.set(key, value)
weakMap.delete(key)
weakMap.has(key)
如果我们在 weakMap 中使用一个对象作为键,并且没有其他对这个对象的引用 —— 该对象将会被从内存(和map)中自动清除。
WeakSet
的表现类似:
- 与
Set
类似,但是我们只能向WeakSet
添加对象(而不能是原始值)。 - 对象只有在其它某个(些)地方能被访问的时候,才能留在 set 中。
- 跟
Set
一样,WeakSet
支持add
,has
和delete
方法,但不支持size
和keys()
,并且不可迭代。
WeakMap
和 WeakSet
被用作“主要”对象存储之外的“辅助”数据结构。一旦将对象从主存储器中删除,如果该对象仅被用作 WeakMap
或 WeakSet
的键,那么它将被自动清除。
Object.keys(), Objects.values(), Object.entries()
对于普通对象,下列这些方法是可用的:
- Object.keys(obj) —— 返回一个包含该对象所有的键的数组。
- Object.values(obj) —— 返回一个包含该对象所有的值的数组。
- Object.entries(obj) —— 返回一个包含该对象所有 [key, value] 键值对的数组。
区别:
Map | Object | |
---|---|---|
调用语法 | map.keys() | Object.keys(obj) ,而不是 obj.keys() |
返回值 | 可迭代项 | “真正的”数组,而不只是一个可迭代项。 |
Object.keys/values/entries 会忽略 symbol 属性
(如果我们也想要 Symbol 类型的键,那么这儿有一个单独的方法 Object.getOwnPropertySymbols,它会返回一个只包含 Symbol 类型的键的数组。另外,还有一种方法 Reflect.ownKeys(obj),它会返回 所有 键。)
对象缺少数组存在的许多方法,例如 map
和 filter
等。如果我们想应用它们,那么我们可以使用 Object.entries
,然后使用 Object.fromEntries
:
- 使用
Object.entries(obj)
从obj
获取由键/值对组成的数组。 - 对该数组使用数组方法,例如
map
。 - 对结果数组使用
Object.fromEntries(array)
方法,将结果转回成对象。
解构赋值
直接赋值:
let arr = ["Ilya", "Kantor"]
//1.
let [firstName, surname] = arr;
//2.
let [firstName, surname] = "Ilya Kantor".split(' ');
//3.不需要第二个元素
let [firstName, , title] = ["Julius", "Caesar", "Consul", "of the Roman Republic"];
//4.等号右侧可以是任何可迭代对象
let [a, b, c] = "abc"; // ["a", "b", "c"]
let [one, two, three] = new Set([1, 2, 3]);
//5.左侧使用任何“可以被赋值的”东西
let user = {};
[user.name, user.surname] = "Ilya Kantor".split(' ');
//6.与 .entries() 方法进行循环操作,例如循环遍历键—值对
for (let [key, value] of Object.entries(user)) {
alert(`${key}:${value}`); // name:John, then age:30
}
//7.遍历map对象
let user = new Map();
user.set("name", "John");
user.set("age", "30");
for (let [key, value] of user) {
alert(`${key}:${value}`); // name:John, then age:30
}
//8.用于交换变量值的典型技巧
let guest = "Jane";
let admin = "Pete";
[guest, admin] = [admin, guest];
剩余的’…'
let [name1, name2, ...rest] = ["Julius", "Caesar", "Consul", "of the Roman Republic"];
// 请注意,`rest` 的类型是数组
alert(rest[0]); // Consul
默认值
未成功赋值的变量被认为是 undefined
,也可以设置默认值:
let [name = "Guest", surname = "Anonymous"] = ["Julius"];
//默认值可以是更加复杂的表达式甚至可以是函数调用,这些表达式或函数只会在这个变量未被赋值的时候才会被计算。
let [name = prompt('name?'), surname = prompt('surname?')] = ["Julius"];
对象解构
//变量的顺序并不重要:
let options = {
title: "Menu",
width: 100,
height: 200
};
let {height, width, title} = options;
alert(title); // Menu
alert(width); // 100
alert(height); // 200
//只取所需
let { title } = options;
alert(title); // Menu
// { 什么值(sourceProperty): 赋值给谁(targetVariable) }
let {width: w, height: h, title} = options;
alert(title); // Menu
alert(w); // 100
alert(h); // 200
//默认值
let options = {
title: "Menu"
};
let {width = prompt("width?"), title = prompt("title?")} = options;
let {width: w = 100, height: h = 200, title} = options;
剩余模式 pattern "…"
let options = {
title: "Menu",
height: 200,
width: 100
};
let {title, ...rest} = options;
// 现在 title="Menu", rest={height: 200, width: 100}
alert(rest.height); // 200
alert(rest.width); // 100
不使用 let 时的陷阱:
let title, width, height;
// 这一行发生了错误,因为 JavaScript 把主代码流(即不在其他表达式中)的 {...} 当做一个代码块。
{title, width, height} = {title: "Menu", width: 200, height: 100};
// 现在就可以了,可以把整个赋值表达式用括号 (...) 包起来
({title, width, height} = {title: "Menu", width: 200, height: 100});
嵌套解构
let options = {
size: {
width: 100,
height: 200
},
items: ["Cake", "Donut"],
extra: true
};
// 为了清晰起见,解构赋值语句被写成多行的形式
let {
size: { // 把 size 赋值到这里
width,
height
},
items: [item1, item2], // 把 items 赋值到这里
title = "Menu" // 在对象中不存在(使用默认值)
} = options;
智能函数参数:通过对象传递参数
// 我们传递一个对象给函数
let options = {
title: "My menu",
items: ["Item1", "Item2"]
};
// ……然后函数马上把对象展开成变量
function showMenu({title = "Untitled", width = 200, height = 100, items = []}) {
// title, items – 提取于 options,
// width, height – 使用默认值
alert( `${title} ${width} ${height}` ); // My Menu 200 100
alert( items ); // Item1, Item2
}
showMenu(options);
日期和时间
创建一个Date对象:
传入的整数参数代表的是自 1970-01-01 00:00:00 以来经过的毫秒数,该整数被称为 时间戳。
/*1.不传参*/
let now = new Date();
/*2.传入时间戳*/
// 0 表示 01.01.1970 UTC+0
let Jan01_1970 = new Date(0);
// 现在增加 24 小时,得到 02.01.1970 UTC+0
let Jan02_1970 = new Date(24 * 3600 * 1000);
/*3.传入字符串*/
let date = new Date("2017-01-26"); // 该时间未被设定,因此被假定为格林尼治标准时间(GMT)的午夜(midnight)
/*4.分别传入年、月、日、时、分、秒、毫秒等*/
new Date(2011, 0, 1, 0, 0, 0, 0); // 1 Jan 2011, 00:00:00
new Date(2011, 0, 1); // 同样,时分秒等均为默认值 0
其中4.new Date(year, month, date, hours, minutes, seconds, ms)
使用当前时区中的给定组件创建日期。只有前两个参数是必须的。
year
必须是四位数:2013
是合法的,98
是不合法的。month
计数从0
(一月)开始,到11
(十二月)结束。date
是当月的具体某一天,如果缺失,则为默认值1
。- 如果
hours/minutes/seconds/ms
缺失,则均为默认值0
。
Date对象方法
- getFullYear():获取年份(4 位数),不要写成getYear
- getMonth():获取月份,从 0 到 11。
- getDate():具体日期,从 1 到 31
- getHours(),getMinutes(),getSeconds(),getMilliseconds():
- getDay():获取一周中的第几天,从
0
(星期日)到6
(星期六) - getTime():返回日期的时间戳
- …
下列方法可以设置日期组件:
- setFullYear(year,[month], [date])
- setMonth(month,[date])
- setDate(date)
- setHours(hour, [min], [sec], [ms])
- setMinutes(min, [sec], [ms])
- setSeconds(sec, [ms])
- setMillieseconds(ms)
- setTime(milliseconds)
let today = new Date();
today.setHours(0);// 日期依然是今天,但是小时数被改为了 0
自动校准 是 Date对象的一个非常方便的特性。我们可以设置超范围的数值,它会自动校准。
let date = new Date(2016, 1, 28); // 28 Feb 2016
date.setDate(date.getDate() + 2); // 1 Mar 2016
/*可以设置0或负值*/
let date = new Date(2016, 0, 2); // 2016 年 1 月 2 日
date.setDate(1); // 设置为当月的第一天
date.setDate(0); // 天数最小可以设置为 1,所以这里设置的是上一月的最后一天
Date.now()
返回当前时间戳而不创建Date对象,它相当于 new Date().getTime()
,但它更快。
计算日期差值时,使用getTime()方法会比直接日期相减进行类型转换快
Date.parse(str) 方法可以从一个字符串中读取日期。
字符串的格式应该为:
YYYY-MM-DDTHH:mm:ss.sssZ
,其中:YYYY-MM-DD
—— 日期:年-月-日。- 字符
"T"
是一个分隔符。 HH:mm:ss.sss
—— 时间:小时,分钟,秒,毫秒。- 可选字符
'Z'
为+-hh:mm
格式的时区。单个字符Z
代表 UTC+0 时区。
JSON方法, toJSON
JSON.stringify(value[, replacer, space])
将对象转换为 JSON。value
: 要编码的值。replacer
: 要编码的属性数组或映射函数function(key, value)
。space: 用于格式化的空格数量
JSON.parse(str, [reviver])
将 JSON 转换回对象。str
: 要解析的 JSON 字符串。reviver
:可选的函数 function(key,value),该函数将为每个(key, value)
对调用,并可以对值进行转换。
let student = {
name: 'John',
age: 30,
isAdmin: false,
courses: ['html', 'css', 'js'],
wife: null
};
let json = JSON.stringify(student);
alert(typeof json); // string
/* JSON 编码的对象:
{
"name": "John",
"age": 30,
"isAdmin": false,
"courses": ["html", "css", "js"],
"wife": null
}
*/
支持嵌套对象转换,并且可以自动对其进行转换。
- JSON 是一种数据格式,具有自己的独立标准和大多数编程语言的库。
- JSON 支持 object,array,string,number,boolean 和
null
。(详情看MDN?) - JavaScript 提供序列化(serialize)成 JSON 的方法 JSON.stringify 和解析 JSON 的方法 JSON.parse。
- 这两种方法都支持用于智能读/写的转换函数。
- 如果一个对象具有
toJSON
,那么它会被JSON.stringify
调用。
函数进阶内容
递归可以使代码更短,更易于理解和维护。递归深度等于执行上下文堆栈的最大数量。但是循环算法通常更节省内存,任何递归都可以用循环来重写。通常循环变体更有效。…但有时重写很难,尤其是函数根据条件使用不同的子调用,然后合并它们的结果,或者分支比较复杂时。而且有些优化可能没有必要,完全不值得。
递归遍历
当我们需要遍历一个数组或对象的所有values,其中这个数组或对象中可能同时包含数组或对象。例如我们计算一个公司所有人的工资:
let company = { // 是同一个对象,简洁起见被压缩了
sales: [{name: 'John', salary: 1000}, {name: 'Alice', salary: 1600 }],
development: {
sites: [{name: 'Peter', salary: 2000}, {name: 'Alex', salary: 1800 }],
internals: [{name: 'Jack', salary: 1300}]
}
};
// 用来完成任务的函数
function sumSalaries(department) {
if (Array.isArray(department)) { // 情况(1)
return department.reduce((prev, current) => prev + current.salary, 0); // 求数组的和
} else { // 情况(2)
let sum = 0;
for (let subdep of Object.values(department)) {
sum += sumSalaries(subdep); // 递归调用所有子部门,对结果求和
}
return sum;
}
}
alert(sumSalaries(company)); // 7700
链表
let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };
list.next.next.next.next = null;
Rest参数与Spread语法
Rest参数...
function sumAll(...args) { // 数组名为 args
let sum = 0;
for (let arg of args) sum += arg;
return sum;
}
alert( sumAll(1, 2, 3) ); // 6
//也可以将前面几个参数作为变量,剩下的收集起来
function showName(firstName, lastName, ...titles) {
//TODO
}
Rest 参数必须放到参数列表的末尾
“argument”变量
function showName() {
alert( arguments.length );
alert( arguments[0] );
alert( arguments[1] );
// 它是可遍历的
// for(let arg of arguments) alert(arg);
}
// 依次显示:2,Julius,Caesar
showName("Julius", "Caesar");
// 依次显示:1,Ilya,undefined(没有第二个参数)
showName("Ilya");
尽管 arguments
是一个类数组,也是可迭代对象,但它终究不是数组。它不支持数组方法,因此我们不能调用 arguments.map(...)
等方法。它始终包含所有参数,我们不能像使用 rest 参数那样只截取入参的一部分。箭头函数是没有"arguments"
Spread语法...
任何可迭代对象都可以使用Spread语法:
let arr = [3, 5, 1];
alert( Math.max(...arr) ); // 5
let arr = [3, 5, 1];
let arr2 = [8, 9, 15];
let merged = [0, ...arr, 2, ...arr2]; // 0,3,5,1,2,8,9,15
let str = "Hello";
alert( [...str] ); // H,e,l,l,o
alert( Array.from(str) ); // H,e,l,l,o
不过 Array.from(obj)
和 [...obj]
存在一个细微的差别:
Array.from
适用于类数组对象也适用于可迭代对象。- Spread 语法只适用于可迭代对象。
应用:获得一个array/Object的副本:
let arr = [1, 2, 3];
let arrCopy = [...arr]; // 将数组 spread 到参数列表中,然后将结果放到一个新数组
// 两个数组中的内容相同吗?
alert(JSON.stringify(arr) === JSON.stringify(arrCopy)); // true
// 两个数组相等吗?
alert(arr === arrCopy); // false(它们的引用是不同的)
let obj = { a: 1, b: 2, c: 3 };
let objCopy = { ...obj }; // 将对象 spread 到参数列表中,然后将结果返回到一个新对象
// 两个对象中的内容相同吗?
alert(JSON.stringify(obj) === JSON.stringify(objCopy)); // true
// 两个对象相等吗?
alert(obj === objCopy); // false (not same reference)
这种方式比使用 let arrCopy = Object.assign([], arr);
来复制数组,或使用 let objCopy = Object.assign({}, obj);
来复制对象写起来要短得多。因此,只要情况允许,我们更喜欢使用它。
变量作用域、闭包
嵌套函数
当一个函数是在另一个函数中创建的时,那么该函数就被称为“嵌套”的。
function sayHiBye(firstName, lastName) {
// 辅助嵌套函数使用如下
function getFullName() {
return firstName + " " + lastName;
}
alert( "Hello, " + getFullName() );
alert( "Bye, " + getFullName() );
}
可以返回一个嵌套函数:作为一个新对象的属性或作为结果返回。之后可以在其他地方使用。不论在哪里调用,它仍然可以访问相同的外部变量:
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2
词法环境
每个运行的函数,代码块 {...}
以及整个脚本,都有一个被称为 词法环境(Lexical Environment) 的内部(隐藏)的关联对象。
- 环境记录(Environment Record) —— 一个存储所有局部变量作为其属性(包括一些其他信息,例如
this
的值)的对象。 - 对 外部词法环境 的引用,与外部代码相关联。
变量、函数声明、内部和外部的词法环境:
当创建了一个词法环境(Lexical Environment)时,函数声明会立即变为即用型函数(不像 let 那样直到声明处才可用)。
在一个函数运行时,在调用刚开始时,会自动创建一个新的词法环境以存储这个调用的局部变量和参数。当代码要访问一个变量时——首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。如果在任何地方都找不到这个变量,那么在严格模式下就会报错(在非严格模式下,为了向下兼容,给未定义的变量赋值会创建一个全局变量)。
当返回嵌套函数时:
可以看到上面makeCounter
的例子,在每次 makeCounter()
调用的开始,都会创建一个新的词法环境对象,以存储该 makeCounter
运行时的变量。所有的函数在“诞生”时都会记住创建它们的词法环境,而不是在被调用的时候!!!
闭包
闭包是指内部函数总是可以访问其所在的外部函数中声明的变量和参数,即使在其外部函数被返回(寿命终结)了之后。在某些编程语言中,这是不可能的,或者应该以特殊的方式编写函数来实现。但是如上所述,在 JavaScript 中,所有函数都是天生闭包的(只有一个例外,将在 “new Function” 语法 中讲到)。
也就是说:JavaScript 中的函数会自动通过隐藏的 [[Environment]]
属性记住创建它们的位置,所以它们都可以访问外部变量。
在面试时,前端开发者通常会被问到“什么是闭包?”,正确的回答应该是闭包的定义,并解释清楚为什么 JavaScript 中的所有函数都是闭包的,以及可能的关于 [[Environment]]
属性和词法环境原理的技术细节。
垃圾收集和实际开发中的优化
与 JavaScript 中的任何其他对象一样,词法环境仅在可达时才会被保留在内存中。
理论上当函数可达时,它外部的所有变量也都将存在。
但在实际中,JavaScript 引擎会试图优化它。它们会分析变量的使用情况,如果从代码中可以明显看出有未使用的外部变量,那么就会将其删除。
在 V8(Chrome,Edge,Opera)中的一个重要的副作用是,此类变量在调试中将不可用。
//如果在这里声明了变量value,则第7行代码将会访问到这个value而不是预期的value
//let value = "Surprise!";
function f() {
let value = Math.random();
function g() {
debugger; // 在 Console 中:输入 alert(value); No such variable!
}
return g;
}
let g = f();
g();
正如你所见的 —— No such variable! 理论上,它应该是可以访问的,但引擎把它优化掉了。这可能会导致有趣的(如果不是那么耗时的)调试问题。其中之一 —— 我们可以看到的是一个同名的外部变量,而不是预期的变量。
旧时的"var"
"var"没有块级作用域
var
只有全局作用域和函数作用域。"var"允许重复声明
可以重复声明一个变量,不管多少次都行,但新的声明语句会被忽略。
"var"声明的变量会被提升到函数顶部
声明会被提升,但是赋值不会。
let没有变量提升与暂时性死区:用
let
声明的变量,不存在变量提升。而且要求必须 等let
声明语句执行完之后,变量才能使用,不然会报Uncaught ReferenceError
错误。
全局对象
全局对象包含应该在任何位置都可见的变量。
其中包括 JavaScript 的内建方法,例如 “Array” 和环境特定(environment-specific)的值,例如
window.innerHeight
— 浏览器中的窗口高度。全局对象有一个通用名称
globalThis
,在任何环境下都可以使用。……但是更常见的是使用“老式”的环境特定(environment-specific)的名字,例如
window
(浏览器)和global
(Node.js)。仅当值对于我们的项目而言确实是全局的时,才应将其存储在全局对象中。并保持其数量最少。
在浏览器中,除非我们使用 modules,否则使用
var
声明的全局函数和变量会成为全局对象的属性。
在浏览器中,使用 var
(而不是 let/const
!)声明的全局函数和变量会成为全局对象的属性。
var gVar = 5;
alert(window.gVar); // 5(成为了全局对象的属性)
let gLet = 5;
alert(window.gLet); // undefined(不会成为全局对象的属性)
如果一个值非常重要,以至于你想使它在全局范围内可用,那么可以直接将其作为属性写入:
// 将当前用户信息全局化,以允许所有脚本访问它
window.currentUser = {
name: "John"
};
// 代码中的另一个位置
alert(currentUser.name); // John
// 或者,如果我们有一个名为 "currentUser" 的局部变量
// 从 window 显示地获取它(这是安全的!)
alert(window.currentUser.name); // John
- 为了使我们的代码面向未来并更易于理解,我们应该使用直接的方式访问全局对象的属性,如
window.x
。
函数对象,NFE
函数就是对象。我们介绍了它们的一些属性:
name
—— 函数的名字。通常取自函数定义,但如果函数定义时没设定函数名,JavaScript 会尝试通过函数的上下文猜一个函数名(例如把赋值的变量名取为函数名)。length
—— 函数定义时的入参的个数。Rest 参数不参与计数。自定义属性:函数属性有时会用来替代闭包。两者最大的不同就是如果
count
的值位于外层(函数)变量中,那么外部的代码无法访问到它,只有嵌套的函数可以修改它。而如果它是绑定到函数的,那么就很容易function makeCounter() { // 不需要这个了 // let count = 0 function counter() { return counter.count++; }; counter.count = 0; return counter; } let counter = makeCounter(); alert( counter() ); // 0 alert( counter() ); // 1
很多知名的 JavaScript 库都充分利用了自定义属性。它们创建一个“主”函数,然后给它附加很多其它“辅助”函数。例如,jQuery 库创建了一个名为
$
的函数。lodash 库创建一个_
函数,然后为其添加了_.add
、_.keyBy
以及其它属性(想要了解更多内容,参查阅 docs)。实际上,它们这么做是为了减少对全局空间的污染,这样一个库就只会有一个全局变量。这样就降低了命名冲突的可能性。
命名函数表达式NFE
如果函数是通过函数表达式的形式被声明的(不是在主代码流里),并且附带了名字,那么它被称为命名函数表达式(Named Function Expression)。这个名字可以用于在该函数内部进行自调用,例如递归调用等。
let sayHi = function func(who) {
if (who) {
alert(`Hello, ${who}`);
} else {
func("Guest"); // 使用 func 再次调用函数自身
}
};
sayHi(); // Hello, Guest
// 但这不工作:
func(); // Error, func is not defined(在函数外不可见)
关于名字 func
有两个特殊的地方,这就是添加它的原因:
- 它允许函数在内部引用自己。
- 它在函数外是不可见的。
我们为什么使用 func
呢?为什么不直接使用 sayHi
进行嵌套调用?
//当我们进行这样的操作时:如果使用sayHi就会出错:
let welcome = sayHi;
sayHi = null;
welcome(); // Error,嵌套调用 sayHi 不再有效!
"new Function"语法
当我们需要向 new Function
创建出的新函数传递数据时,我们必须显式地通过参数进行传递。
let func = new Function ([arg1, arg2, ...argN], functionBody);
该函数是通过使用参数 arg1...argN
和给定的 functionBody
创建的。
let sum = new Function('a', 'b', 'return a + b');
alert( sum(1, 2) ); // 3
let sayHi = new Function('alert("Hello")');
sayHi(); // Hello
new Function
允许我们将任意字符串变为函数。
在闭包中:如果我们使用 new Function
创建一个函数,那么该函数的 [[Environment]]
并不指向当前的词法环境,而是指向全局环境。因此,此类函数无法访问外部(outer)变量,只能访问全局变量。
在将 JavaScript 发布到生产环境之前,需要使用 压缩程序(minifier) 对其进行压缩 —— 一个特殊的程序,通过删除多余的注释和空格等压缩代码 —— 更重要的是,将局部变量命名为较短的变量。即使我们可以在 new Function
中访问外部词法环境,我们也会受挫于压缩程序。
调度:setTimeout & setInterval
setTimeout(func, delay, ...args)
和setInterval(func, delay, ...args)
方法允许我们在delay
毫秒之后运行func
一次或以delay
毫秒为时间间隔周期性运行func
。function sayHi(phrase, who) { alert( phrase + ', ' + who ); } setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John setTimeout(() => alert('Hello'), 1000);
要取消函数的执行,我们应该调用
clearInterval/clearTimeout
,并将setInterval/setTimeout
返回的值作为入参传入。let timerId = setTimeout(...); clearTimeout(timerId);
嵌套的
setTimeout
比setInterval
用起来更加灵活(可以根据每次函数调用的情况对下一次调用做修改,例如delay的时间等),允许我们更精确地设置两次执行之间的时间。嵌套的setTimeout
就能确保延时的固定(这里是 100 毫秒)。/** instead of: let timerId = setInterval(() => alert('tick'), 100); */ let timerId = setTimeout(function tick() { alert('tick'); timerId = setTimeout(tick, 2000); // (*) }, 100);
零延时调度
setTimeout(func, 0)
(与setTimeout(func)
相同)用来调度需要尽快执行的调用,但是会在当前脚本执行完成后进行调用。浏览器会将
setTimeout
或setInterval
的五层或更多层嵌套调用(调用五次之后)的最小延时限制在 4ms。这是历史遗留问题。
请注意,所有的调度方法都不能 保证 确切的延时。
例如,浏览器内的计时器可能由于许多原因而变慢:
- CPU 过载。
- 浏览器页签处于后台模式。
- 笔记本电脑用的是电池供电(译注:使用电池供电会以降低性能为代价提升续航)。
所有这些因素,可能会将定时器的最小计时器分辨率(最小延迟)增加到 300ms 甚至 1000ms,具体以浏览器及其设置为准。
装饰器模式&转发, call/apply
1.透明缓存
下面的代码中cachingDecorator
是一个 装饰器(decorator):一个特殊的函数,它接受另一个函数并改变它的行为。
function slow(x) {
// 这里可能会有重负载的 CPU 密集型工作
alert(`Called with ${x}`);
return x;
}
function cachingDecorator(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) { // 如果缓存中有对应的结果
return cache.get(x); // 从缓存中读取结果
}
let result = func(x); // 否则就调用 func
cache.set(x, result); // 然后将结果缓存(记住)下来
return result;
};
}
slow = cachingDecorator(slow);
alert( slow(1) ); // slow(1) 被缓存下来了
alert( "Again: " + slow(1) ); // 一样的
从外部代码来看,包装的 slow
函数执行的仍然是与之前相同的操作。它只是在其行为上添加了缓存功能。总而言之,使用分离的 cachingDecorator
而不是改变 slow
本身的代码有几个好处:
cachingDecorator
是可重用的。我们可以将它应用于另一个函数。- 缓存逻辑是独立的,它没有增加
slow
本身的复杂性(如果有的话)。 - 如果需要,我们可以组合多个装饰器(其他装饰器将遵循同样的逻辑)。
2.对象方法:使用"func.call"设定上下文
它允许调用一个显式设置this
的函数。
func.call(context, arg1, arg2, ...)
它运行func
,提供的第一个参数作为this
,后面的作为参数(arguments)。
3.使用func.apply代替func.call
func.apply(context, args)
call
和 apply
之间唯一的语法区别是,call
期望一个参数列表,而 apply
期望一个包含这些参数的类数组对象。
因此,这两个调用几乎是等效的:
func.call(context, ...args); // 使用 spread 语法将数组作为列表传递
func.apply(context, args); // 与使用 call 相同
这里只有很小的区别:
- Spread 语法
...
允许将 可迭代对象args
作为列表传递给call
。 apply
仅接受 类数组对象args
。
函数绑定
1.丢失this的问题
一旦方法被传递到与对象分开的某个地方 —— this
就丢失。
需求:将一个对象方法传递到别的地方(这里 —— 传递到调度程序),然后在该位置调用它。确保在正确的上下文中调用它!
使用包装器
bind绑定
// 基本的语法 let boundFunc = func.bind(context); //用例: let user = { firstName: "John" }; function func() { alert(this.firstName); } let funcUser = func.bind(user); funcUser(); // John //对象方法: let user = { firstName: "John", sayHi() { alert(`Hello, ${this.firstName}!`); } }; let sayHi = user.sayHi.bind(user); // (*) // 可以在没有对象(译注:与对象分离)的情况下运行它 sayHi(); // Hello, John! setTimeout(sayHi, 1000); // Hello, John! // 即使 user 的值在不到 1 秒内发生了改变 // sayHi 还是会使用预先绑定(pre-bound)的值,该值是对旧的 user 对象的引用 user = { sayHi() { alert("Another user in setTimeout!"); } };
bindAll批量绑定
for (let key in user) { if (typeof user[key] == 'function') { user[key] = user[key].bind(user); } }
JavaScript 库还提供了方便批量绑定的函数,例如 lodash 中的 _.bindAll(object, methodNames)。
2.偏函数(partial functions):绑定参数
bind
的完整语法:
let bound = func.bind(context, [arg1], [arg2], ...);
绑定参数示例:
function mul(a, b) {
return a * b;
}
let double = mul.bind(null, 2);
alert( double(3) ); // = mul(2, 3) = 6
alert( double(4) ); // = mul(2, 4) = 8
alert( double(5) ); // = mul(2, 5) = 10
没有上下文this的情况:
function partial(func, ...argsBound) {
return function(...args) { // (*)
return func.call(this, ...argsBound, ...args);
}
}
// 用法:
let user = {
firstName: "John",
say(time, phrase) {
alert(`[${time}] ${this.firstName}: ${phrase}!`);
}
};
// 添加一个带有绑定时间的 partial 方法
user.sayNow = partial(user.say, new Date().getHours() + ':' + new Date().getMinutes());
user.sayNow("Hello");
// 类似于这样的一些内容:
// [10:00] John: Hello!
深入理解箭头函数
JavaScript 的精髓在于创建一个函数并将其传递到某个地方。在这样的函数中,我们通常不想离开当前上下文。这就是箭头函数的主战场啦。
1.箭头函数没有 this
。如果访问 this
,则会从外部获取。
let group = {
title: "Our Group",
students: ["John", "Pete", "Alice"],
showList() {
this.students.forEach(
student => alert(this.title + ': ' + student)
);
}
};
group.showList();
不具有 this
自然也就意味着另一个限制:箭头函数不能用作构造器(constructor)。不能用 new
调用它们。
箭头函数 VS bind
箭头函数 =>
和使用 .bind(this)
调用的常规函数之间有细微的差别:
.bind(this)
创建了一个该函数的“绑定版本”。- 箭头函数
=>
没有创建任何绑定。箭头函数只是没有this
。this
的查找与常规变量的搜索方式完全相同:在外部词法环境中查找。
2.箭头函数也没有 arguments
变量
当我们需要使用当前的 this
和 arguments
转发一个调用时,这对装饰器(decorators)来说非常有用。
function defer(f, ms) {
return function() {
setTimeout(() => f.apply(this, arguments), ms)
};
}
function sayHi(who) {
alert('Hello, ' + who);
}
let sayHiDeferred = defer(sayHi, 2000);
sayHiDeferred("John"); // 2 秒后显示:Hello, John
对象属性配置
属性标志和属性描述符
属性标志
对象属性(properties),除 value
外,还有三个特殊的特性(attributes),也就是所谓的“标志”:
writable
— 如果为true
,则值可以被修改,否则它是只可读的。enumerable
— 如果为true
,则会被在循环中列出,否则不会被列出。configurable
— 如果为true
,则此特性可以被删除,这些属性也可以被修改,否则不可以。
- 查询有关属性的完整信息:Object.getOwnPropertyDescriptor
let descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);
- 修改标志,我们可以使用 Object.defineProperty
Object.defineProperty(obj, propertyName, descriptor)
如果该属性存在,defineProperty
会更新其标志。否则,它会使用给定的值和标志创建属性;在这种情况下,如果没有提供标志,则会假定它是 false
。所以,对于新属性,通常我们需要明确的列出哪些是true。
let user = {};
Object.defineProperty(user, "name", {
value: "John"
});
let descriptor = Object.getOwnPropertyDescriptor(user, 'name');
alert( JSON.stringify(descriptor, null, 2 ) );
/*
{
"value": "John",
"writable": false,
"enumerable": false,
"configurable": false
}
*/
属性标志的作用:
- 例如我们将一个属性设置为只读
let user = {
name: "John"
};
Object.defineProperty(user, "name", {
writable: false
});
user.name = "Pete"; // Error: Cannot assign to read only property 'name'
在非严格模式下,在对不可写的属性等进行写入操作时,不会出现错误。但是操作仍然不会成功。在非严格模式下,违反标志的行为(flag-violating action)只会被默默地忽略掉。
- 不可枚举
let user = {
name: "John",
toString() {
return this.name;
}
};
Object.defineProperty(user, "toString", {
enumerable: false
});
// 现在我们的 toString 消失了:
for (let key in user) alert(key); // name
不可枚举也会被Object.keys()
排除:
alert(Object.keys(user)); // name
- 不可配置
例如,Math.PI
是只读的、不可枚举和不可配置的
let descriptor = Object.getOwnPropertyDescriptor(Math, 'PI');
alert( JSON.stringify(descriptor, null, 2 ) );
/*
{
"value": 3.141592653589793,
"writable": false,
"enumerable": false,
"configurable": false
}
*/
不可配置性对 defineProperty
施加了一些限制:
- 不能修改
configurable
标志。 - 不能修改
enumerable
标志。 - 不能将
writable: false
修改为true
(反过来则可以)。 - 不能修改访问者属性的
get/set
(但是如果没有可以分配它们)。
一次定义多个属性: Object.defineProperties(obj, descriptors)
Object.defineProperties(user, {
name: { value: "John", writable: false },
surname: { value: "Smith", writable: false },
// ...
});
一次获取所有属性描述符: Object.getOwnPropertyDescriptors(obj)
它与 Object.defineProperties
一起可以用作克隆对象的“标志感知”方式:
let clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(obj));
通常,当我们克隆一个对象时,我们使用赋值的方式来复制属性,像这样:
for (let key in user) {
clone[key] = user[key]
}
……但是,这并不能复制标志。所以如果我们想要一个“更好”的克隆,那么 Object.defineProperties
是首选。
另一个区别是 for..in
会忽略 symbol 类型的属性,但是 Object.getOwnPropertyDescriptors
返回包含 symbol 类型的属性在内的 所有 属性描述符。
关于设定一个全局密封属性还有许多方法:
有许多限制访问 整个 对象的方法:Object.preventExtensions(obj),Object.seal(obj),Object.freeze(obj)…
属性的getter和setter(访问器属性)
当读取 obj.propName
时,getter 起作用,当 obj.propName
被赋值时,setter 起作用。
let user = {
name: "John",
surname: "Smith",
get fullName() {
return `${this.name} ${this.surname}`;
},
set fullName(value) {
[this.name, this.surname] = value.split(" ");
}
};
// set fullName 将以给定值执行
user.fullName = "Alice Cooper";
alert(user.name); // Alice
alert(user.surname); // Cooper
对于访问器属性,没有 value
和 writable
,但是有 get
和 set
函数。
所以访问器描述符可能有:
get
—— 一个没有参数的函数,在读取属性时工作,set
—— 带有一个参数的函数,当属性被设置时调用,enumerable
—— 与数据属性的相同,configurable
—— 与数据属性的相同。
let user = {
name: "John",
surname: "Smith"
};
Object.defineProperty(user, 'fullName', {
get() {
return `${this.name} ${this.surname}`;
},
set(value) {
[this.name, this.surname] = value.split(" ");
}
});
alert(user.fullName); // John Smith
for(let key in user) alert(key); // name, surname
一个属性要么是访问器(具有 get/set
方法),要么是数据属性(具有 value
),但不能两者都是。如果我们试图在同一个描述符中同时提供 get
和 value
,则会出现错误
兼容性
访问器的一大用途是,它们允许随时通过使用 getter 和 setter 替换“正常的”数据属性,来控制和调整这些属性的行为。
原型、继承
原型继承(Prototypal inheritance)
在 JavaScript 中,对象有一个特殊的隐藏属性 [[Prototype]]
(如规范中所命名的),它要么为 null
,要么就是对另一个对象的引用。该对象被称为“原型”:当我们从 object
中读取一个缺失的属性时,JavaScript 会自动从原型中获取该属性。在编程中,这种行为被称为“原型继承”。
设置原型:即设置
[[prototype]]
属性利用特殊字符
__proto__
(注意前后是两条下划线),__proto__
是[[Prototype]]
的 getter/setterlet animal = { eats: true }; let rabbit = { jumps: true }; rabbit.__proto__ = animal; // 设置 rabbit.[[Prototype]] = animal // 现在这两个属性我们都能在 rabbit 中找到: alert( rabbit.eats ); // true (**) alert( rabbit.jumps ); // true
长原型链:
let animal = { eats: true, walk() { alert("Animal walk"); } }; let rabbit = { jumps: true, __proto__: animal }; let longEar = { earLength: 10, __proto__: rabbit }; // walk 是通过原型链获得的 longEar.walk(); // Animal walk alert(longEar.jumps); // true(从 rabbit)
两个限制:
引用不能形成闭环。如果我们试图在一个闭环中分配
__proto__
,JavaScript 会抛出错误。__proto__
的值可以是对象,也可以是null
。而其他的类型都会被忽略。一个对象只能有一个
[[Prototype]]
原型仅用于读取属性,对于写入/删除操作可以直接在对象上进行。
关于“this”的值
this
根本不受原型的影响。无论在哪里找到方法:在一个对象还是在原型中。在一个方法调用中,this
始终是点符号 .
前面的对象。方法是共享的,但对象状态不是。
在for…in循环中过滤掉继承属性
obj.hasOwnProperty(key):如果 obj
具有自己的(非继承的)名为 key
的属性,则返回 true
。
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
for(let prop in rabbit) {
let isOwn = rabbit.hasOwnProperty(prop);
if (isOwn) {
alert(`Our: ${prop}`); // Our: jumps
} else {
alert(`Inherited: ${prop}`); // Inherited: eats
}
}
除了for...in
外,几乎所有其他键/值获取方法,例如 Object.keys
和 Object.values
等,都会忽略继承的属性。
F.prototype
new F()
这样的构造函数可以创建一个新对象。如果 F.prototype
是一个对象,那么 new
操作符会使用它为新对象设置 [[Prototype]]
。
let animal = {
eats: true
};
function Rabbit(name) {
this.name = name;
}
Rabbit.prototype = animal;
let rabbit = new Rabbit("White Rabbit"); // rabbit.__proto__ == animal
alert( rabbit.eats ); // true
设置 Rabbit.prototype = animal
的字面意思是:“当创建了一个 new Rabbit
时,把它的 [[Prototype]]
赋值为 animal
”。
F.prototype
属性仅在 new F
被调用时使用,它为新对象的 [[Prototype]]
赋值。
**每个函数都有 "prototype"
属性,即使我们没有提供它。**默认的 "prototype"
是一个只有属性 constructor
的对象,属性 constructor
指向函数自身。
function Rabbit() {}
// by default:
// Rabbit.prototype = { constructor: Rabbit }
alert( Rabbit.prototype.constructor == Rabbit ); // true
let rabbit = new Rabbit(); // inherits from {constructor: Rabbit}
alert(rabbit.constructor == Rabbit); // true (from prototype)
我们可以使用 constructor
属性来创建一个新对象,该对象使用与现有对象相同的构造器。
function Rabbit(name) {
this.name = name;
alert(name);
}
let rabbit = new Rabbit("White Rabbit");
let rabbit2 = new rabbit.constructor("Black Rabbit");
为了确保正确的 "constructor"
,我们可以选择添加/删除属性到默认 "prototype"
,而不是将其整个覆盖:
function Rabbit() {}
// 不要将 Rabbit.prototype 整个覆盖
// 可以向其中添加内容
Rabbit.prototype.jumps = true
// 默认的 Rabbit.prototype.constructor 被保留了下来
或者,也可以手动重新创建 constructor
属性:
Rabbit.prototype = {
jumps: true,
constructor: Rabbit
};
// 这样的 constructor 也是正确的,因为我们手动添加了它
总结:!!!
F.prototype
属性(不要把它与[[Prototype]]
弄混了)在new F
被调用时为新对象的[[Prototype]]
赋值。F.prototype
的值要么是一个对象,要么就是null
:其他值都不起作用。"prototype"
属性仅在设置了一个构造函数(constructor function),并通过new
调用时,才具有这种特殊的影响。
在常规对象上,prototype
没什么特别的:
let user = {
name: "John",
prototype: "Bla-bla" // 这里只是普通的属性
};
默认情况下,所有函数都有 F.prototype = {constructor:F}
,所以我们可以通过访问它的 "constructor"
属性来获取一个对象的构造器。
原生的原型
Object.prototype
表达式 obj = {}
和 obj = new Object()
是一个意思,其中 Object
就是一个内建的对象构造函数,其自身的 prototype
指向一个带有 toString
和其他方法的一个巨大的对象。
let obj = {};
alert(obj.__proto__ === Object.prototype); // true
alert(obj.toString === obj.__proto__.toString); //true
alert(obj.toString === Object.prototype.toString); //true
像 Array
、Date
、Function
及其他,都在 prototype 上挂载了方法。例如,当我们创建一个数组 [1, 2, 3]
,在内部会默认使用 new Array()
构造器。因此 Array.prototype
变成了这个数组的 prototype,并为这个数组提供数组的操作方法。这样内存的存储效率是很高的。浏览器内的工具,像 Chrome 开发者控制台也会显示继承性(可能需要对内置对象使用 console.dir([1,2,3])
)。函数 是内建构造器 Function
的对象,并且它们的方法(call
/apply
及其他)都取自 Function.prototype
。
基本数据类型
当我们试图访问基本数据类型(number,string,boolean…)的属性时,那么临时包装器对象会通过内建的构造器String,Number,Boolean被创建。他们给我们提供操作数字、字符串、布尔值的方法然后消失。
更改原生的原型
原型是全局的,容易造成冲突,通常不建议修改。在现代编程中,只有一种情况下允许修改原生原型。那就是 polyfilling。
if (!String.prototype.repeat) { // 如果这儿没有这个方法
// 那就在 prototype 中添加它
String.prototype.repeat = function(n) {
// 重复传入的字符串 n 次
// 实际上,实现代码比这个要复杂一些(完整的方法可以在规范中找到)
// 但即使是不够完美的 polyfill 也常常被认为是足够好的
return new Array(n + 1).join(this);
};
}
alert( "La".repeat(3) ); // LaLaLa
重原型中借用
如果我们要创建类数组对象,则可能需要向其中复制一些 Array
方法。
let obj = {
0: "Hello",
1: "world!",
length: 2,
};
//方法1:直接借用原型方法
obj.join = Array.prototype.join;
//方法2:直接继承原型的prototype
obj.__proto__ = Array.prototype; //那么obj就可以使用原型的所有方法
alert( obj.join(',') ); // Hello,world!
原型方法,没有__proto__
的对象
JavaScript规范中规定,proto必须仅在浏览器环境下才能得到支持。
现代的方法有:
Object.create(proto, [descriptors])
—— 利用给定的proto
作为[[Prototype]]
和可选的属性描述来创建一个空对象。Object.getPrototypeOf(obj)
—— 返回对象obj
的[[Prototype]]
。Object.setPrototypeOf(obj, proto)
—— 将对象obj
的[[Prototype]]
设置为proto
。
应该使用这些方法来代替 __proto__
。
let animal = {
eats: true
};
// 创建一个以 animal 为原型的新对象
let rabbit = Object.create(animal);
alert(rabbit.eats); // true
alert(Object.getPrototypeOf(rabbit) === animal); // true
Object.setPrototypeOf(rabbit, {}); // 将 rabbit 的原型修改为 {}
使用 Object.create
来实现比复制 for..in
循环中的属性更强大的对象克隆方式:
let clone = Object.create(Object.getPrototypeOf(obj),Object.getOwnPropertyDescriptors(obj));
此调用可以对 obj
进行真正准确地拷贝,包括所有的属性:可枚举和不可枚举的,数据属性和 setters/getters —— 包括所有内容,并带有正确的 [[Prototype]]
。
从技术上来讲,我们可以在任何时候 get/set [[Prototype]]
。但是通常我们只在创建对象的时候设置它一次,自那之后不再修改:rabbit
继承自 animal
,之后不再更改。更改原型是一个非常缓慢的操作,因为它破坏了对象属性访问操作的内部优化。
“Very plain” objects
对象可以用作关联数组(associative arrays)来存储键值对,但我没无法给键__proto__
正常赋值。
let obj = {};
let key = prompt("What's the key?", "__proto__");
obj[key] = "some value";
alert(obj[key]); // [object Object],并不是 "some value"!
如果用户输入 __proto__
,那么赋值会被忽略!__proto__
属性很特别:它必须是对象或者 null
。字符串不能成为 prototype。
为默认情况下为函数的 toString
以及其他内建方法执行赋值操作,也会出现意想不到的结果。
__proto__
不是一个对象的属性,只是 Object.prototype
的访问器属性。如果 obj.__proto__
被读取或者赋值,那么对应的 getter/setter 会被从它的原型中调用,它会 set/get [[Prototype]]
。__proto__
是一种访问 [[Prototype]]
的方式,而不是 [[prototype]]
本身。
我们想要将一个对象用作关联数组,并且摆脱此类问题,我们可以使用一些小技巧:
let obj = Object.create(null);
let key = prompt("What's the key?", "__proto__");
obj[key] = "some value";
alert(obj[key]); // "some value"
我们可以把这样的对象称为 “very plain” 或 “pure dictionary” 对象,因为它们甚至比通常的普通对象(plain object){...}
还要简单。
类
当我们需要创建许多相同类型的对象,我们可以使用构造器+new的模。在现代JavaScript中,有一个更高级的“类”构造方式,它引入了许多对面向对象变成很有用的功能。
class MyClass {
prop = value; // 属性
constructor(...) { // 构造器
// ...
}
method(...) {} // method
get something(...) {} // getter 方法
set something(...) {} // setter 方法
[Symbol.iterator]() {} // 有计算名称(computed name)的方法(此处为 symbol)
// ...
}
技术上来说,MyClass
是一个函数(我们提供作为 constructor
的那个),而 methods、getters 和 settors 都被写入了 MyClass.prototype
。
Class基本语法
class MyClass {
// class 方法
constructor() { ... }
method1() { ... }
method2() { ... }
method3() { ... }
...
}
//实例:
class User {
constructor(name) {
this.name = name;
}
sayHi() {
alert(this.name);
}
}
// 用法:
let user = new User("John");
user.sayHi();
然后使用 new MyClass()
来创建具有上述列出的所有方法的新对象。new
会自动调用 constructor()
方法,因此我们可以在 constructor()
中初始化对象。
类的方法之间没有逗号
原理
在JavaScript中,类其实是一种函数。
class User {...}
构造实际上做了如下的事儿:
- 创建一个名为
User
的函数,该函数成为类声明的结果。该函数的代码来自于constructor
方法(如果我们不编写这种方法,那么它就被假定为空)。 - 存储类中的方法,例如
User.prototype
中的sayHi
。
当 new User
对象被创建后,当我们调用其方法时,它会从原型中获取对应的方法,正如我们在 F.prototype 一章中所讲的那样。因此,对象 new User
可以访问类中的方法。
class User {
constructor(name) { this.name = name; }
sayHi() { alert(this.name); }
}
// class 是一个函数
alert(typeof User); // function
// ...或者,更确切地说,是 constructor 方法
alert(User === User.prototype.constructor); // true
// 方法在 User.prototype 中,例如:
alert(User.prototype.sayHi); // alert(this.name);
// 在原型中实际上有两个方法
alert(Object.getOwnPropertyNames(User.prototype)); // constructor, sayHi
class是语法糖吗?
如果不使用class:
// 用纯函数重写 class User
// 1. 创建构造器函数
function User(name) {
this.name = name;
}
// 函数的原型(prototype)默认具有 "constructor" 属性,
// 所以,我们不需要创建它
// 2. 将方法添加到原型
User.prototype.sayHi = function() {
alert(this.name);
};
// 用法:
let user = new User("John");
user.sayHi();
直接使用构造函数声明可以得到和class基本相同的结果,所以有人会说class是一个语法糖而已。但实际上它们存在着一些重大的差异:
首先,通过
class
创建的函数具有特殊的内部属性标记[[FunctionKind]]:"classConstructor"
。因此,它与手动创建并不完全相同。编程语言会在许多地方检查该属性,例如必须使用new
来调用它。多数 JavaScript 引擎中的类构造器的字符串表示形式都以 “class…” 开头
类的方法不可枚举:类定义将
prototype
中所有方法的enumerable
标志设置为false
类总是使用
use strict
。 在类构造中的所有代码都将自动进入严格模式。……
类表达式
就像函数一样,类可以在另外一个表达式中被定义,被传递,被返回,被赋值等。
let User = class {
sayHi() {
alert("Hello");
}
};
Getters/setters
class User {
constructor(name) {
// 调用 setter
this.name = name;
}
get name() {
return this._name;
}
set name(value) {
if (value.length < 4) {
alert("Name is too short.");
return;
}
this._name = value;
}
}
let user = new User("John");
alert(user.name); // John
user = new User(""); // Name is too short.
计算属性名称
class User {
['say' + 'Hi']() {
alert("Hello");
}
}
new User().sayHi();
class 字段
类字段重要的不同之处在于,它们会在每个独立对象中被设好,而不是设在 User.prototype
class User {
name = "John";
sayHi() {
alert(`Hello, ${this.name}!`);
}
}
let user = new User();
alert(user.name); // John
alert(User.prototype.name); // undefined
丢失"this"
一个对象方法被传递到某处,或者在另一个上下文中被调用,则 this
将不再是对其对象的引用:
class Button {
constructor(value) {
this.value = value;
}
click() {
alert(this.value);
}
}
let button = new Button("hello");
setTimeout(button.click, 1000); // undefined
有两种可以修复它的方式:
- 传递一个包装函数,例如
setTimeout(() => button.click(), 1000)
。 - 将方法绑定到对象,例如在 constructor 中。
class Button {
constructor(value) {
this.value = value;
}
click = () => {
alert(this.value);
}
}
let button = new Button("hello");
setTimeout(button.click, 1000); // hello
类字段 click = () => {...}
是基于每一个对象被创建的,在这里对于每一个 Button
对象都有一个独立的方法,在内部都有一个指向此对象的 this
。
类继承
扩展另一个类的语法是:class Child extends Parent
。
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} runs with speed ${this.speed}.`);
}
stop() {
this.speed = 0;
alert(`${this.name} stands still.`);
}
}
let animal = new Animal("My animal");
class Rabbit extends Animal {
hide() {
alert(`${this.name} hides!`);
}
}
let rabbit = new Rabbit("White Rabbit");
rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.hide(); // White Rabbit hides!
重写方法
class Rabbit extends Animal {
stop() {
// ……现在这个将会被用作 rabbit.stop()
// 而不是来自于 class Animal 的 stop()
}
}
通常来说,我们不希望完全替换父类的方法,而是希望在父类方法的基础上进行调整或扩展其功能。Class 为此提供了 "super"
关键字。
- 执行
super.method(...)
来调用一个父类方法。 - 执行
super(...)
来调用一个父类 constructor(只能在我们的 constructor 中)。
//例如这样改写Rabbit类
class Rabbit extends Animal {
hide() {
alert(`${this.name} hides!`);
}
stop() {
super.stop(); // 调用父类的 stop
this.hide(); // 然后 hide
}
}
箭头函数没有super
,如果被访问,它会从外部函数获取:
class Rabbit extends Animal {
stop() {
setTimeout(() => super.stop(), 1000); // 1 秒后调用父类的 stop
}
}
箭头函数中的super
和stop()
中的super
是一样的
重写constructor
如果一个类扩展了另一个类并且没有 constructor
,那么将生成下面这样的“空” constructor
:
class Rabbit extends Animal {
// 为没有自己的 constructor 的扩展类生成的
constructor(...args) {
super(...args);
}
}
继承类的 constructor 必须调用 super(...)
,并且 (!) 一定要在使用 this
之前调用。
class Rabbit extends Animal {
constructor(name, earLength) {
super(name);
this.earLength = earLength;
}
// ...
}
重写class字段(棘手!难点!)
类字段是这样初始化的:
- 对于基类(还未继承任何东西的那种),在构造函数调用前初始化。
- 对于派生类,在
super()
后立刻初始化。
class Animal {
name = 'animal';
constructor() {
alert(this.name); // (*)
}
}
class Rabbit extends Animal {
name = 'rabbit';
}
new Animal(); // animal
new Rabbit(); // animal
**所以说, 父类构造器总是会使用它自己字段的值,而不是被重写的那一个。**而对于方法,当父类构造器在派生的类中被调用时,它会使用被重写的方法。所以如果出问题了,我们可以通过使用方法或者 getter/setter 替代类字段,来修复这个问题。
[深入:内部探究和 [HomeObject]]
(暂时略2021.01.25)
静态属性和静态方法
静态方法被用于实现属于整个类的功能。它与具体的类实例无关。
举个例子, 一个用于进行比较的方法 Article.compare(article1, article2)
或一个工厂(factory)方法 Article.createTodays()
。
在类生命中,它们都被用关键字 static
进行了标记。
静态属性被用于当我们想要存储类级别的数据时,而不是绑定到实例。
语法如下所示:
class MyClass {
static property = ...;
static method() {
...
}
}
从技术上讲,静态声明与直接给类本身赋值相同:
MyClass.property = ...
MyClass.method = ...
静态属性和方法是可被继承的。
对于 class B extends A
,类 B
的 prototype 指向了 A
:B.[[Prototype]] = A
。因此,如果一个字段在 B
中没有找到,会继续在 A
中查找。
私有的or受保护的属性
就面向对象编程(OOP)而言,内部接口与外部接口的划分被称为 封装。
它具有以下优点:
- 保护用户,使他们不会误伤自己
- 可支持性
- 隐藏复杂性
为了隐藏内部接口,我们使用受保护的或私有的属性:
- 受保护的字段以
_
开头。这是一个众所周知的约定,不是在语言级别强制执行的。程序员应该只通过它的类和从它继承的类中访问以_
开头的字段。
class CoffeeMachine {
_waterAmount = 0;
set waterAmount(value) {
if (value < 0) throw new Error("Negative water");
this._waterAmount = value;
}
get waterAmount() {
return this._waterAmount;
}
constructor(power) {
this._power = power;
}
}
// 创建咖啡机
let coffeeMachine = new CoffeeMachine(100);
// 加水
coffeeMachine.waterAmount = -10; // Error: Negative water
- 私有字段以
#
开头。JavaScript 确保我们只能从类的内部访问它们,由语言本身强制执行。如果我们继承自CoffeeMachine
,那么我们将无法直接访问#waterAmount
。我们需要依靠waterAmount
getter/setter==私有字段不能通过 this[name] 访问==。
class CoffeeMachine {
#waterAmount = 0;
get waterAmount() {
return this.#waterAmount;
}
set waterAmount(value) {
if (value < 0) throw new Error("Negative water");
this.#waterAmount = value;
}
}
let machine = new CoffeeMachine();
machine.waterAmount = 100;
alert(machine.#waterAmount); // Error
目前,各个浏览器对私有字段的支持不是很好,但可以用 polyfill 解决。
扩建内建类
内建的类,例如 Array
,Map
等也都是可以扩展的(extendable)。
// 给 PowerArray 新增了一个方法(可以增加更多)
class PowerArray extends Array {
isEmpty() {
return this.length === 0;
}
}
let arr = new PowerArray(1, 2, 5, 10, 50);
alert(arr.isEmpty()); // false
let filteredArr = arr.filter(item => item >= 10);
alert(filteredArr); // 10, 50
alert(filteredArr.isEmpty()); // false
内建的方法例如 filter
,map
等 — 返回的正是子类 PowerArray
的新对象。它们内部使用了对象的 constructor
属性来实现这一功能。arr.constructor === PowerArray
如果你希望返回的仍然是Array对象,可以通过给这个类添加一个特殊的静态 getter Symbol.species
:
y,就像这样:
class PowerArray extends Array {
isEmpty() {
return this.length === 0;
}
// 内建方法将使用这个作为 constructor
static get [Symbol.species]() {
return Array;
}
}
let arr = new PowerArray(1, 2, 5, 10, 50);
alert(arr.isEmpty()); // false
// filter 使用 arr.constructor[Symbol.species] 作为 constructor 创建新数组
let filteredArr = arr.filter(item => item >= 10);
// filteredArr 不是 PowerArray,而是 Array
alert(filteredArr.isEmpty()); // Error: filteredArr.isEmpty is not a function
其他集合,例如 Map
和 Set
的工作方式类似。它们也使用 Symbol.species
。
内建类没有静态方法继承
通常,当一个类扩展另一个类时,静态方法和非静态方法都会被继承。这已经在 静态属性和静态方法 中详细地解释过了。但内建类却是一个例外。它们相互间不继承静态方法。与我们所了解的通过 extends
获得的继承相比,这是内建对象之间继承的一个重要区别。
类检查instanceof
它可以被用来构建一个 多态性(polymorphic) 的函数,该函数根据参数的类型对参数进行不同的处理。
class Rabbit {}
let rabbit = new Rabbit();
alert( rabbit instanceof Rabbit ); // true
// 这里是构造函数,而不是 class
function Rabbit() {}
alert( new Rabbit() instanceof Rabbit ); // true
let arr = [1, 2, 3];
alert( arr instanceof Array ); // true
alert( arr instanceof Object ); // true
通常,instanceof
在检查中会将原型链考虑在内。此外,我们还可以在静态方法 Symbol.hasInstance
中设置自定义逻辑。
//设置 instanceof 检查
// 并假设具有 canEat 属性的都是 animal
class Animal {
static [Symbol.hasInstance](obj) {
if (obj.canEat) return true;
}
}
let obj = { canEat: true };
alert(obj instanceof Animal); // true:Animal[Symbol.hasInstance](obj) 被调用
大多数 class 没有 Symbol.hasInstance
。在这种情况下,标准的逻辑是:使用 obj instanceOf Class
检查 Class.prototype
是否等于 obj
的原型链中的原型之一。
方法objA.isPrototypeOf(objB)
如果objA在objB的原型链中,则返回true。
使用Object.prototype.toString()揭示类型
let s = Object.prototype.toString;
let arr = [];
alert( s.call(arr) ); // [object Array]
alert( s.call(123) ); // [object Number]
alert( s.call(null) ); // [object Null]
alert( s.call(alert) ); // [object Function]
可以使用特殊的对象属性 Symbol.toStringTag
自定义对象的 toString
方法的行为。
let user = {
[Symbol.toStringTag]: "User"
};
alert( {}.toString.call(user) ); // [object User]
对于大多数特定于环境的对象,都有一个这样的属性。下面是一些特定于浏览器的示例:
// 特定于环境的对象和类的 toStringTag:
alert( window[Symbol.toStringTag]); // Window
alert( XMLHttpRequest.prototype[Symbol.toStringTag] ); // XMLHttpRequest
alert( {}.toString.call(window) ); // [object Window]
alert( {}.toString.call(new XMLHttpRequest()) ); // [object XMLHttpRequest]
让我们总结一下我们知道的类型检查方法:
用于 | 返回值 | |
---|---|---|
typeof | 原始数据类型 | string |
{}.toString | 原始数据类型,内建对象,包含 Symbol.toStringTag 属性的对象 | string |
instanceof | 对象 | true/false |
正如我们所看到的,从技术上讲,{}.toString
是一种“更高级的” typeof
。
当我们使用类的层次结构(hierarchy),并想要对该类进行检查,同时还要考虑继承时,这种场景下 instanceof
操作符确实很出色。
Mixin模式
Mixin — 是一个通用的面向对象编程术语:一个包含其他类的方法的类。
一些其它编程语言允许多重继承。JavaScript 不支持多重继承,但是可以通过将方法拷贝到原型中来实现 mixin。Mixin 可以在自己内部使用继承。
let sayMixin = {
say(phrase) {
alert(phrase);
}
};
let sayHiMixin = {
__proto__: sayMixin, // (或者,我们可以在这儿使用 Object.create 来设置原型)
sayHi() {
// 调用父类方法
super.say(`Hello ${this.name}`); // (*)
},
sayBye() {
super.say(`Bye ${this.name}`); // (*)
}
};
class User {
constructor(name) {
this.name = name;
}
}
// 拷贝方法
Object.assign(User.prototype, sayHiMixin);
// 现在 User 可以打招呼了
new User("Dude").sayHi(); // Hello Dude!
请注意,在 sayHiMixin
内部对父类方法 super.say()
的调用(在标有 (*)
的行)会在 mixin 的原型中查找方法,而不是在 class 中查找。
Eventmixins
(浏览器事件,看不懂,先略)
我们可以使用 mixin 作为一种通过添加多种行为(例如上文中所提到的事件处理)来扩充类的方法。
如果 Mixins 意外覆盖了现有类的方法,那么它们可能会成为一个冲突点。因此,通常应该仔细考虑 mixin 的命名方法,以最大程度地降低发生这种冲突的可能性。
错误处理
try..catch
结构允许我们处理执行过程中出现的 error。从字面上看,它允许“尝试”运行代码并“捕获”其中可能发生的错误。
语法如下:
try {
// 执行此处代码
} catch(err) {
// 如果发生错误,跳转至此处
// err 是一个 error 对象
} finally {
// 无论怎样都会在 try/catch 之后执行
}
这儿可能会没有 catch
部分或者没有 finally
,所以 try..catch
或 try..finally
都是可用的。
try..catch
仅对运行时的 error 有效,意思就是不会捕获语法错误啦
try…catch是同步工作的,如果需要在异步函数中使用,需要放在回调函数内部:
setTimeout(function() {
try {
noSuchVariable; // try..catch 处理 error 了!
} catch {
alert( "error is caught here!" );
}
}, 1000);
Error 对象包含下列属性:
message
— 人类可读的 error 信息。name
— 具有 error 名称的字符串(Error 构造器的名称)。stack
(没有标准,但得到了很好的支持)— Error 发生时的调用栈。
try {
lalala; // error, variable is not defined!
} catch(err) {
alert(err.name); // ReferenceError
alert(err.message); // lalala is not defined
alert(err.stack); // ReferenceError: lalala is not defined at (...call stack)
// 也可以将一个 error 作为整体显示出来as a whole
// Error 信息被转换为像 "name: message" 这样的字符串
alert(err); // ReferenceError: lalala is not defined
}
如果我们不需要 error 对象,我们可以通过使用 catch {
而不是 catch(err) {
来省略它。
我们也可以使用 throw
操作符来生成自定义的 error。从技术上讲,throw
的参数可以是任何东西,但通常是继承自内建的 Error
类的 error 对象。下一章我们会详细介绍扩展 error。
再次抛出(rethrowing)是一种错误处理的重要模式:catch
块通常期望并知道如何处理特定的 error 类型,因此它应该再次抛出它不知道的 error。
即使我们没有 try..catch
,大多数执行环境也允许我们设置“全局”错误处理程序来捕获“掉出(fall out)”的 error。在浏览器中,就是 window.onerror
。
自定义Error,拓展Error
- 我们可以正常地从
Error
和其他内建的 error 类中进行继承,。我们只需要注意name
属性以及不要忘了调用super
。 - 我们可以使用
instanceof
来检查特定的 error。但有时我们有来自第三方库的 error 对象,并且在这儿没有简单的方法来获取它的类。那么可以将name
属性用于这一类的检查。
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
// 用法
function readUser(json) {
let user = JSON.parse(json);
if (!user.age) {
throw new ValidationError("No field: age");
}
if (!user.name) {
throw new ValidationError("No field: name");
}
return user;
}
// try..catch 的工作示例
try {
let user = readUser('{ "age": 25 }');
} catch (err) {
if (err instanceof ValidationError) {
alert("Invalid data: " + err.message); // Invalid data: No field: name
} else if (err instanceof SyntaxError) { // (*)
alert("JSON Syntax Error: " + err.message);
} else {
throw err; // 未知的 error,再次抛出 (**)
}
}
补充:关于类的this.constructor:
在控制台创建一个空的类,观察输出。
class User {}
console.dir(User);
每一个类都默认有arguments
, caller
, length
, name
, prototype
, [[protptype]]
…
这其中的User.prototype.constructor === User
,所以当我们let user = new User()
时,有user.constructor === User
,那么我们就可以通过user.constructor.name
访问到原型User
的name
属性。所以我们不需要每次都为派生类命名:
class MyError extends Error {
constructor(message) {
super(message);
this.name = this.constructor.name;
}
}
class ValidationError extends MyError { }
class PropertyRequiredError extends ValidationError {
constructor(property) {
super("No property: " + property);
this.property = property;
}
}
// name 是对的
alert( new PropertyRequiredError("field").name ); // PropertyRequiredError
包装异常是一项广泛应用的技术:用于处理低级别异常并创建高级别 error 而不是各种低级别 error 的函数。在上面的示例中,低级别异常有时会成为该对象的属性,例如
err.cause
,但这不是严格要求的。下面的代码定义了
ReadError
,并在readUser
和try..catch
中演示了其用法:class ReadError extends Error { constructor(message, cause) { super(message); this.cause = cause; this.name = 'ReadError'; } } class ValidationError extends Error { /*...*/ } class PropertyRequiredError extends ValidationError { /* ... */ } function validateUser(user) { if (!user.age) { throw new PropertyRequiredError("age"); } if (!user.name) { throw new PropertyRequiredError("name"); } } function readUser(json) { let user; try { user = JSON.parse(json); } catch (err) { if (err instanceof SyntaxError) { throw new ReadError("Syntax Error", err); } else { throw err; } } try { validateUser(user); } catch (err) { if (err instanceof ValidationError) { throw new ReadError("Validation Error", err); } else { throw err; } } } try { readUser('{bad json}'); } catch (e) { if (e instanceof ReadError) { alert(e); // Original error: SyntaxError: Unexpected token b in JSON at position 1 alert("Original error: " + e.cause); } else { throw e; } }
异步
Promise
Promise 是将“生产者代码”和“消费者代码”连接在一起的一个特殊的 JavaScript 对象。用我们的类比来说:这就是就像是“订阅列表”。“生产者代码”花费它所需的任意长度时间来产出所承诺的结果,而 “promise” 将在它(译注:指的是“生产者代码”,也就是下文所说的 executor)准备好时,将结果向所有订阅了的代码开放。
Promise 对象的构造器(constructor)语法如下:
let promise = new Promise(function(resolve, reject) {
// executor(生产者代码,“歌手”)
});
传递给 new Promise
的函数被称为 executor。当 new Promise
被创建,executor 会自动运行。它包含最终应产出结果的生产者代码。
executor 只能调用一个 resolve 或一个 reject。任何状态的更改都是最终的,所有其他的再对 resolve 和 reject 的调用都会被忽略。当 executor 获得了结果,无论是早还是晚都没关系,它应该调用以下回调之一:(resolve/reject
只需要一个参数(或不包含任何参数),并且将忽略额外的参数。)
resolve(value)
— 如果任务成功完成并带有结果value
。reject(error)
— 如果出现了 error,error
即为 error 对象。
由 new Promise
构造器返回的 promise
对象具有以下内部属性:
state
— 最初是"pending"
,然后在resolve
被调用时变为"fulfilled"
,或者在reject
被调用时变为"rejected"
。result
— 最初是undefined
,然后在resolve(value)
被调用时变为value
,或者在reject(error)
被调用时变为error
。
Promise 对象的 state
和 result
属性都是内部的。我们无法直接访问它们。但我们可以对它们使用 .then
/.catch
/.finally
方法。
//成功案例
let promise = new Promise(function(resolve, reject) {
// 当 promise 被构造完成时,自动执行此函数
// 1 秒后发出工作已经被完成的信号,并带有结果 "done"
setTimeout(() => resolve("done"), 1000);
});
//失败案例
let promise = new Promise(function(resolve, reject) {
// 1 秒后发出工作已经被完成的信号,并带有 error
setTimeout(() => reject(new Error("Whoops!")), 1000);
});
总而言之,executor 应该执行一项工作(通常是需要花费一些时间的事儿),然后调用 resolve
或 reject
来改变对应的 promise 对象的状态。与最初的 “pending” promise 相反,一个 resolved 或 rejected 的 promise 都会被称为 “settled”。
消费者.then/.catch/.finally
promise.then(
function(result) { /* handle a successful result */ },
function(error) { /* handle an error */ }
);
1.对于resolved和rejected的情况的反应都注明:
let promise = new Promise(function(resolve, reject) {
setTimeout(() => resolve("done!"), 1000);
});
// resolve 运行 .then 中的第一个函数
promise.then(
result => alert(result), // 1 秒后显示 "done!"
error => alert(error) // 不运行
);
2.只对成功的情况作出反应:(忽略.then的第二个参数)
let promise = new Promise(resolve => {
setTimeout(() => resolve("done!"), 1000);
});
promise.then(alert); // 1 秒后显示 "done!"
3.只对失败的结果作出反应:
我们可以使用 null
作为第一个参数:.then(null, errorHandlingFunction)
。或者我们也可以使用 .catch(errorHandlingFunction)
,效果是一样的:
let promise = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("Whoops!")), 1000);
});
// .catch(f) 与 promise.then(null, f) 一样
promise.catch(alert); // 1 秒后显示 "Error: Whoops!"
.catch(f)
调用是 .then(null, f)
的完全的模拟,它只是一个简写形式。
.finally()
.finally(f)
调用与 .then(f, f)
类似,在某种意义上,f
总是在 promise 被 settled 时运行:即 promise 被 resolve 或 reject。
new Promise((resolve, reject) => {
/* 做一些需要时间的事儿,然后调用 resolve/reject */
})
// 在 promise 为 settled 时运行,无论成功与否
.finally(() => stop loading indicator)
// 所以,加载指示器(loading indicator)始终会在我们处理结果/错误之前停止
.then(result => show result, err => show error)
promise 相较于基于回调的模式的一些好处:
Promises | Callbacks |
---|---|
Promises 允许我们按照自然顺序进行编码。首先,我们运行 loadScript 和 .then 来处理结果。 | 在调用 loadScript(script, callback) 时,在我们处理的地方(disposal)必须有一个 callback 函数。换句话说,在调用 loadScript 之前,我们必须知道如何处理结果。 |
我们可以根据需要,在 promise 上多次调用 .then 。每次调用,我们都会在“订阅列表”中添加一个新的“分析”,一个新的订阅函数。在下一章将对此内容进行详细介绍:Promise 链。 | 只能有一个回调。 |
Promise链
promise.then
的调用会返回了一个 promise,所以我们可以在其之上调用下一个 .then
。
new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000); // (*)
}).then(function(result) { // (**)
alert(result); // 1
return result * 2;
}).then(function(result) { // (***)
alert(result); // 2
return result * 2;
}).then(function(result) {
alert(result); // 4
return result * 2;
});
如果 .then
(或 catch/finally
都可以)处理程序(handler)返回一个 promise,那么链的其余部分将会等待,直到它状态变为 settled。当它被 settled 后,其 result(或 error)将被进一步传递下去。
新手常犯的一个经典错误:从技术上讲,我们也可以将多个 .then 添加到一个 promise 上。但这并不是 promise 链(chaining)。why?因为返回的新的promise并没有被接收。
这是一个完整的流程图:
使用Promise进行错误处理
.catch
处理 promise 中的各种 error:在reject()
调用中的,或者在处理程序(handler)中抛出的(thrown)error。注意异步操作中的错误不会被catch
捕获,如果需要处理,要在.then()
中进行处理:new Promise(function(resolve, reject) { setTimeout(() => { throw new Error("Whoops!"); }, 1000); }).catch(alert);
我们应该将
.catch
准确地放到我们想要处理 error,并知道如何处理这些 error 的地方。处理程序应该分析 error(可以自定义 error 类来帮助分析)并再次抛出未知的 error(可能它们是编程错误)。如果没有办法从 error 中恢复的话,不使用
.catch
也可以。JavaScript 引擎会跟踪此类 rejection,在这种情况下会生成一个全局的 error。如果你运行上面这个代码,你可以在控制台(console)中看到。在任何情况下我们都应该有
unhandledrejection
事件处理程序(用于浏览器,以及其他环境的模拟),以跟踪未处理的 error 并告知用户(可能还有我们的服务器)有关信息,以使我们的应用程序永远不会“死掉”。
Promise API
Promise
类有 5 种静态方法:
Promise.all(promises)
—— 等待所有 promise 都 resolve 时,返回存放它们结果的数组。如果给定的任意一个 promise 为 reject,那么它就会变成Promise.all
的 error,所有其他 promise 的结果都会被忽略。Promise.all([ new Promise(resolve => setTimeout(() => resolve(1), 3000)), // 1 new Promise(resolve => setTimeout(() => resolve(2), 2000)), // 2 new Promise(resolve => setTimeout(() => resolve(3), 1000)) // 3 ]).then(alert); // 1,2,3 当上面这些 promise 准备好时:每个 promise 都贡献了数组中的一个元素 //如果有rejected的Promise: Promise.all([ new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)), new Promise((resolve, reject) => setTimeout(() => reject(new Error("Whoops!")), 2000)), new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)) ]).catch(alert); // Error: Whoops!
Promise.allSettled(promises)
(ES2020 新增方法)—— 等待所有 promise 都 settle 时,并以包含以下内容的对象数组的形式返回它们的结果:
status
:"fulfilled"
或"rejected"
value
(如果 fulfilled)或reason
(如果 rejected)。
如果浏览器不支持
Promise.allSettled
,很容易进行 polyfill:if (!Promise.allSettled) { const rejectHandler = reason => ({ status: 'rejected', reason }); const resolveHandler = value => ({ status: 'fulfilled', value }); Promise.allSettled = function (promises) { const convertedPromises = promises.map(p => Promise.resolve(p).then(resolveHandler, rejectHandler)); return Promise.all(convertedPromises); }; }
Promise.race(promises)
—— 等待第一个 settle 的 promise,并将其 result/error 作为结果。Promise.resolve(value)
—— 使用给定 value 创建一个 resolved 的 promise。Promise.reject(error)
—— 使用给定 error 创建一个 rejected 的 promise。
微任务
Promise 处理始终是异步的,因为所有 promise 行为都会通过内部的 “promise jobs” 队列,也被称为“微任务队列”(ES8 术语)。
因此,.then/catch/finally
处理程序(handler)总是在当前代码完成后才会被调用。
如果我们需要确保一段代码在 .then/catch/finally
之后被执行,我们可以将它添加到链式调用的 .then
中。
Asyn/Await
让我们以 async
这个关键字开始。它可以被放置在一个函数前面,如下所示:
async function f() {
return 1;
}
f().then(alert); // 1
在函数前面的 “async” 这个单词表达了一个简单的事情:即这个函数总是返回一个 promise。其他值将自动被包装在一个 resolved 的 promise 中。
// 只在 async 函数内工作
let value = await promise;
关键字 await
让 JavaScript 引擎等待直到 promise 完成(settle)并返回结果。
async function f() {
let promise = new Promise((resolve, reject) => {
setTimeout(() => resolve("done!"), 1000)
});
let result = await promise; // 等待,直到 promise resolve (*)
alert(result); // "done!"
}
f();
这个函数在执行的时候,“暂停”在了 (*)
那一行,并在 promise settle 时,拿到 result
作为结果继续往下执行。所以上面这段代码在一秒后显示 “done!”。
让我们强调一下:await
实际上会暂停函数的执行
不能在普通函数中使用await
【总结】
函数前面的关键字 async
有两个作用:
- 让这个函数总是返回一个 promise。
- 允许在该函数内使用
await
。
Promise 前的关键字 await
使 JavaScript 引擎等待该 promise settle,然后:
- 如果有 error,就会抛出异常 — 就像那里调用了
throw error
一样。 - 否则,就返回结果。
这两个关键字一起提供了一个很好的用来编写异步代码的框架,这种代码易于阅读也易于编写。
有了 async/await
之后,我们就几乎不需要使用 promise.then/catch
,但是不要忘了它们是基于 promise 的,因为有些时候(例如在最外层作用域)我们不得不使用这些方法。并且,当我们需要同时等待需要任务时,Promise.all
是很好用的。
Generator
Generator函数
Generator 函数与常规函数的行为不同。在此类函数被调用时,它不会运行其代码。而是返回一个被称为 “generator object” 的特殊对象,来管理执行流程。
一个 generator object的主要方法就是 next()
。当被调用时(译注:指 next()
方法),它会恢复上图所示的运行,执行直到最近的 yield <value>
语句(value
可以被省略,默认为 undefined
)。然后函数执行暂停,并将产出的(yielded)值返回到外部代码。
next()
的结果始终是一个具有两个属性的对象:
value
: 产出的(yielded)的值。done
: 如果 generator 函数已执行完成则为true
,否则为false
。
function* generateSequence() {
yield 1;
yield 2;
return 3;
}
let generator = generateSequence();
let one = generator.next();
alert(JSON.stringify(one)); // {value: 1, done: false}
Generator是可迭代的
因为generator对象有next()
方法,所以可以作iterator
使用,用for...of
循环遍历所有值。注意当done:true
时,for...of
循环会忽略最有一个value
,因此如果要循环显示所有结果,我们必须都用yield
返回它们:
function* generateSequence() {
yield 1;
yield 2;
yield 3;
}
let generator = generateSequence();
for(let value of generator) {
alert(value); // 1,然后是 2,然后是 3
}
//iterator所有的相关功能都可以使用:
let sequence = [0, ...generateSequence()];
alert(sequence); // 0, 1, 2, 3
使用Generator进行迭代:
let range = {
from: 1,
to: 5,
*[Symbol.iterator]() { // [Symbol.iterator]: function*() 的简写形式
for(let value = this.from; value <= this.to; value++) {
yield value;
}
}
};
alert( [...range] ); // 1,2,3,4,5
之所以代码正常工作,是因为 range[Symbol.iterator]()
现在返回一个 generator,而 generator 方法正是 for..of
所期望的:
- 它具有
.next()
方法 - 它以
{value: ..., done: true/false}
的形式返回值
Generator的组合
我们可以将多个generator组合成一个,Generator 组合(composition)是 generator 的一个特殊功能,它允许透明地(transparently)将 generator 彼此“嵌入(embed)”到一起:
function* generateSequence(start, end) {
for (let i = start; i <= end; i++) yield i;
}
function* generatePasswordCodes() {
// 0..9
yield* generateSequence(48, 57);
// A..Z
yield* generateSequence(65, 90);
// a..z
yield* generateSequence(97, 122);
}
let str = '';
for(let code of generatePasswordCodes()) {
str += String.fromCharCode(code);
}
alert(str); // 0..9A..Za..z
yield*
指令将执行 委托 给另一个 generator。这个术语意味着 yield* gen
在 generator gen
上进行迭代,并将其产出(yield)的值透明地(transparently)转发到外部。就好像这些值就是由外部的 generator yield 的一样。
“yield” 是一条双向路
yield
是一条双向路(two-way street):它不仅可以向外返回结果,而且还可以将外部的值传递到 generator 内。
function* gen() {
// 向外部代码传递一个问题并等待答案
let result = yield "2 + 2 = ?"; // (*)
console.log(result);
}
let generator = gen();
console.log(generator.next().value); // <-- yield 返回的 value
generator.next(4); // --> 将结果传递到 generator 中
generator.throw
要向 yield
传递一个 error,我们应该调用 generator.throw(err)
。在这种情况下,err
将被抛到对应的 yield
所在的那一行:
function* gen() {
try {
let result = yield "2 + 2 = ?"; // (1)
alert("The execution does not reach here, because the exception is thrown above");
} catch(e) {
alert(e); // 显示这个 error
}
}
let generator = gen();
let question = generator.next().value;
generator.throw(new Error("The answer is not found in my database")); // (2)
在现代 JavaScript 中,generator 很少被使用。但有时它们会派上用场,因为函数在执行过程中与调用代码交换数据的能力是非常独特的。而且,当然,它们非常适合创建可迭代对象。
并且,在下一章我们将会学习 async generator,它们被用于在 for await ... of
循环中读取异步生成的数据流(例如,通过网络分页提取 (paginated fetches over a network))。
在 Web 编程中,我们经常使用数据流,因此这是另一个非常重要的使用场景。
异步迭代和Generator
最常见的场景是,对象需要发送一个网络请求以传递下一个值,稍后我们将看到一个它的真实示例。
要使对象异步迭代:
- 使用
Symbol.asyncIterator
取代Symbol.iterator
。 next()
方法应该返回一个``promise(带有下一个值,并且状态为
fulfilled`)。- 关键字
async
可以实现这一点,我们可以简单地使用async next()
。
- 关键字
- 我们应该使用
for await (let item of iterable)
循环来迭代这样的对象。- 注意关键字
await
。
- 注意关键字
let range = {
from: 1,
to: 5,
[Symbol.asyncIterator]() { // (1)
return {
current: this.from,
last: this.to,
async next() { // (2)
// 注意:我们可以在 async next 内部使用 "await"
await new Promise(resolve => setTimeout(resolve, 1000)); // (3)
if (this.current <= this.last) {
return { done: false, value: this.current++ };
} else {
return { done: true };
}
}
};
}
};
(async () => {
for await (let value of range) { // (4)
alert(value); // 1,2,3,4,5
}
})()
Spread 语法...
无法异步工作
在一个常规的 generator 中,我们使用 result = generator.next()
来获得值。但在一个异步 generator 中,我们应该添加 await
关键字,像这样:
result = await generator.next(); // result = {value: ..., done: true/false}
这就是为什么异步 generator 可以与 for await...of
一起工作。
(实例略:获取分页数据)
异步 iterator 与常规 iterator 在语法上的区别:
Iterable | 异步 Iterable | |
---|---|---|
提供 iterator 的对象方法 | Symbol.iterator | Symbol.asyncIterator |
next() 返回的值是 | {value:…, done: true/false} | resolve 成 {value:…, done: true/false} 的 Promise |
异步 generator 与常规 generator 在语法上的区别:
Generator | 异步 generator | |
---|---|---|
声明方式 | function* | async function* |
next() 返回的值是 | {value:…, done: true/false} | resolve 成 {value:…, done: true/false} 的 Promise |
在 Web 开发中,我们经常会遇到数据流,它们分段流动(flows chunk-by-chunk)。例如,下载或上传大文件。
我们可以使用异步 generator 来处理此类数据。值得注意的是,在一些环境,例如浏览器环境下,还有另一个被称为 Streams 的 API,它提供了特殊的接口来处理此类数据流,转换数据并将数据从一个数据流传递到另一个数据流(例如,从一个地方下载并立即发送到其他地方)。