Java?JavaScript!
JavaScript
大部分抄自现代js指南QwQ
引
JavaScript 引擎
浏览器中嵌入了 JavaScript 引擎,有时也称作“JavaScript 虚拟机”。
不同的引擎有不同的“代号”,例如:
- V8 —— Chrome、Opera 和 Edge 中的 JavaScript 引擎。
- SpiderMonkey —— Firefox 中的 JavaScript 引擎。
- “Chakra” 用于 IE,“JavaScriptCore”、“Nitro” 和 “SquirrelFish” 用于 Safari,等等。
引擎是如何工作的?
引擎很复杂,但是基本原理很简单。
- 引擎(如果是浏览器,则引擎被嵌入在其中)读取(“解析”)脚本。
- 然后,引擎将脚本转化(“编译”)为机器语言。
- 然后,机器代码快速地执行。
- 引擎会对流程中的每个阶段都进行优化。它甚至可以在编译的脚本运行时监视它,分析流经该脚本的数据,并根据获得的信息进一步优化机器代码。
浏览器中的JavaScript
js是一种“安全的”编程语言,不提供对内存或CPU的底层访问。
- 网页中的 JavaScript 不能读、写、复制和执行硬盘上的任意文件。它没有直接访问操作系统的功能。
- 现代浏览器允许 JavaScript 做一些文件相关的操作,但是这个操作是受到限制的。仅当用户做出特定的行为,JavaScript 才能操作这个文件。例如,用户把文件“拖放”到浏览器中,或者通过
<input>
标签选择了文件。 - 有很多与相机/麦克风和其它设备进行交互的方式,但是这些都需要获得用户的明确许可。因此,启用了 JavaScript 的网页应该不会偷偷地启动网络摄像头观察你,并把你的信息发送到 美国国家安全局。(lol)
- 不同的标签页/窗口之间通常互不了解。有时候,也会有一些联系,例如一个标签页通过 JavaScript 打开的另外一个标签页。但即使在这种情况下,如果两个标签页打开的不是同一个网站(域名、协议或者端口任一不相同的网站),它们都不能相互通信。
- 这就是所谓的“同源策略”。为了解决“同源策略”问题,两个标签页必须 都 包含一些处理这个问题的特定的 JavaScript 代码,并均允许数据交换。本教程会讲到这部分相关的知识。
- 这个限制也是为了用户的信息安全。例如,用户打开的
http://anysite.com
网页必须不能访问http://gmail.com
(另外一个标签页打开的网页)也不能从那里窃取信息。 - JavaScript 可以轻松地通过互联网与当前页面所在的服务器进行通信。但是从其他网站/域的服务器中接收数据的能力被削弱了。尽管可以,但是需要来自远程服务器的明确协议(在 HTTP header 中)。这也是为了用户的信息安全。
当然在浏览器环境外,比如服务器上,不存在此类限制。
JavaScript “上层”语言
这些语言在浏览器中执行之前,都会被编译(转化)成 JavaScript。
- CoffeeScript 是 JavaScript 的一种语法糖。它引入了更加简短的语法,使我们可以编写更清晰简洁的代码。通常,Ruby 开发者喜欢它。
- TypeScript 专注于添加“严格的数据类型”以简化开发,以更好地支持复杂系统的开发。由微软开发。
- Flow 也添加了数据类型,但是以一种不同的方式。由 Facebook 开发。
- Dart 是一门独立的语言。它拥有自己的引擎,该引擎可以在非浏览器环境中运行(例如手机应用),它也可以被编译成 JavaScript。由 Google 开发。
- Brython 是一个 Python 到 JavaScript 的转译器,让我们可以在不使用 JavaScript 的情况下,以纯 Python 编写应用程序。
- Kotlin 是一个现代、简洁且安全的编程语言,编写出的应用程序可以在浏览器和 Node 环境中运行。
规范
ECMA-262 规范 包含了大部分深入的、详细的、规范化的关于 JavaScript 的信息。这份规范明确地定义了这门语言。
但正因其规范化,对于新手来说难以理解。所以,如果你需要关于这门语言细节最权威的信息来源,这份规范就很适合你(去阅读)。但它并不适合日常使用。
每年都会发布一个新版本的规范。最新的规范草案请见 https://tc39.es/ecma262/。
想了解最新最前沿的功能,包括“即将纳入规范的”(所谓的 “stage 3”),请看这里的提案 https://github.com/tc39/proposals。
手册
- MDN(Mozilla)JavaScript 索引 是一个带有用例和其他信息的主要的手册。它是一个获取关于个别语言函数、方法等深入信息的很好的信息来源。你可以在 此处阅读
不过,利用互联网搜索通常是最好的选择。只需在查询时输入“MDN [关键字]”,例如 https://google.com/search?q=MDN+parseInt 搜索parseInt
函数。
开发者控制台 F12!
多行输入
通常,当我们向控制台输入一行代码后,按 Enter,这行代码就会立即执行。
如果想要插入多行代码,请按 Shift+Enter 来进行换行。这样就可以输入长片段的 JavaScript 代码了。
JavaScript 基础
<script> 标签
如果设置了
src
特性,script
标签内容将会被忽略。
一个单独的<script>
标签不能同时有src
特性和内部包裹的代码。
分号
省流:给我加分号
可以不加分号,JavaScript 将换行符理解成“隐式”的分号。这也被称为 自动分号插入。
在大多数情况(不总是)下,换行意味着一个分号。
alert(3 +
1
+ 2);
JavaScript 并没有在这里插入分号。
HOWEVER
存在 JavaScript 无法确定是否真的需要自动插入分号的情况。
alert("Hello");
[1, 2].forEach(alert);
//正常运行,输出Hello 1 2
alert("Hello")
[1, 2].forEach(alert);
//只输出Hello,并有报错
alert("Hello")[1, 2].forEach(alert);
//在引擎看来是这样的
注释
//单行
/*
多行
*/
使用快捷键!
在大多数的编辑器中,一行代码可以使用 Ctrl+/ 快捷键进行单行注释,诸如 Ctrl+Shift+/ 的快捷键可以进行多行注释(选择代码,然后按下快捷键)。
不支持注释嵌套!
不要在 /*...*/
内嵌套另一个 /*...*/
。
/*
/*错误*/
*/
alert('world')
可以看到最后一个*/被识别到注释外。
现代模式 "use strict"
省流:欢迎将
"use strict";
写在脚本的顶部。当你的代码全都写在了 class 和 module 中时,则可以将"use strict";
省略掉。
ES5 规范增加了新的语言特性并且修改了一些已经存在的特性。为了保证旧的功能能够使用,大部分的修改是默认不生效的。你需要一个特殊的指令 —— "use strict"
来明确地激活这些特性。
这个指令看上去像一个字符串 "use strict"
或者 'use strict'
。当它处于脚本文件的顶部时,则整个脚本文件都将以“现代”模式进行工作。
确保 “use strict” 出现在最顶部
请确保"use strict"
出现在脚本的最顶部,否则严格模式可能无法启用。
只有注释可以出现在"use strict"
的上面。
没有回头路,没有办法取消
use strict
没有类似于"no use strict"
这样的指令可以使程序返回默认模式。
一旦进入了严格模式,就没有回头路了。
如何在浏览器中使用。
开发者控制台默认不启动use strict。
使用 Shift+Enter 按键去输入多行代码,然后将 use strict
放在代码最顶部。
很丑但可靠的启用 use strict
的方法。like this
(function() {
'use strict';
// ...你的代码...
})()
现代 JavaScript 支持 “class” 和 “module” —— 高级语言结构,它们会自动启用
use strict
。因此,如果我们使用它们,则无需添加"use strict"
指令。
变量 let
老版本中存在var,大体相同,后续介绍
函数式语言
有趣的是,也存在禁止更改变量值的 函数式 编程语言。比如 Scala 或 Erlang。
在这种类型的语言中,一旦值保存在盒子中,就永远存在。如果你试图保存其他值,它会强制创建一个新盒子(声明一个新变量),无法重用之前的变量。
虽然第一次看上去有点奇怪,但是这些语言有很大的发展潜力。不仅如此,在某些领域,比如并行计算,这个限制有一定的好处。研究这样的一门语言(即使不打算很快就用上它)有助于开阔视野。
命名
- 变量名称必须仅包含字母、数字、符号
$
和_
。 - 首字符必须非数字。
大小写敏感
支持其他语言,但不建议
保留字。。。
无严格模式
变量不需要提前声明
常量 const
普遍做法:大写命名,记住那些在执行之前就已知的难以记住的值。
有些在执行期间被“计算”出来,但初始赋值之后就不会改变。
const pageLoadTime = /* 网页加载所需的时间 */;
pageLoadTime
的值在页面加载之前是未知的,所以采用常规命名。但是它仍然是个常量,因为赋值之后不会改变。
换句话说,大写命名的常量仅用作“硬编码(hard-coded)”值的别名。
重用还是新建?
最后一点,有一些懒惰的程序员,倾向于重用现有的变量,而不是声明一个新的变量。
结果是,这个变量就像是被扔进不同东西盒子,但没有改变它的贴纸。现在里面是什么?谁知道呢。我们需要靠近一点,仔细检查才能知道。
这样的程序员节省了一点变量声明的时间,但却在调试代码的时候损失数十倍时间。
额外声明一个变量绝对是利大于弊的。
现代的 JavaScript 压缩器和浏览器都能够很好地对代码进行优化,所以不会产生性能问题。为不同的值使用不同的变量可以帮助引擎对代码进行优化。
数据类型
我们可以将任何类型的值存入同一变量。
允许这种操作的编程语言,例如 JavaScript,被称为“动态类型”(dynamically typed)的编程语言,意思是虽然编程语言中有不同的数据类型,但是你定义的变量并不会在定义后,被限制为某一数据类型。
Number
“特殊数值(“special numeric values”)”也属于这种类型:Infinity、-Infinity 和 NaN。
Infinity(无限):除0得到或直接使用
NaN:计算错误,不正确的或者一个未定义的数学操作所得到的结果,NaN
是粘性的。任何对 NaN
的进一步数学运算都会返回 NaN
如果在数学表达式中有一个 NaN
,会被传播到最终结果(只有一个例外:NaN ** 0
结果为 1
)。
数学运算是安全的
在 JavaScript 中做数学运算是安全的。我们可以做任何事:除以 0,将非数字字符串视为数字,等等。
脚本永远不会因为一个致命的错误(“死亡”)而停止。最坏的情况下,我们会得到NaN
的结果。
Bigint
在 JavaScript 中,“number” 类型无法安全地表示大于 (2^53-1)
(即 9007199254740991
),或小于 -(2^53-1)
的整数。
更准确的说,“number” 类型可以存储更大的整数(最多 1.7976931348623157 * 10^308
),但超出安全整数范围 ±(2^53-1)
会出现精度问题,因为并非所有数字都适合固定的 64 位存储。因此,可能存储的是“近似值”。
BigInt
类型是最近被添加到 JavaScript 语言中的,用于表示任意长度的整数。
可以通过将 n
附加到整数字段的末尾来创建 BigInt
值。
// 尾部的 "n" 表示这是一个 BigInt 类型
const bigInt = 1234567890123456789012345678901234567890n;
String
在 JavaScript 中,有三种包含字符串的方式。
- 双引号:
"Hello"
. - 单引号:
'Hello'
. - 反引号:
`Hello`
.
双引号和单引号都是“简单”引用,在 JavaScript 中两者几乎没有什么差别。
反引号是 功能扩展 引号。它们允许我们通过将变量和表达式包装在${…}
中,来将它们嵌入到字符串中。
let name = "Levin";
alert(`Hello, ${name}!`);
JavaScript 中没有 character 类型。
在一些语言中,单个字符有一个特殊的 “character” 类型,在 C 语言和 Java 语言中被称为 “char”。
在 JavaScript 中没有这种类型。只有一种string
类型,一个字符串可以包含零个(为空)、一个或多个字符。
Boolean
布尔值也可作为比较的结果
let isGreater = 4 > 1;
Null
特殊的 null
值不属于上述任何一种类型。
它构成了一个独立的类型,只包含 null
值
相比较于其他编程语言,JavaScript 中的 null
不是一个“对不存在的 object
的引用”或者 “null 指针”。
JavaScript 中的 null
仅仅是一个代表“无”、“空”或“值未知”的特殊值。
Undefined
特殊值 undefined
和 null
一样自成类型。
undefined
的含义是 未被赋值
。
如果一个变量已被声明,但未被赋值,那么它的值就是 undefined
let age;
alert(age); // 弹出 "undefined"
从技术上讲,可以显式地将 undefined
赋值给变量……但是不建议这样做。通常,使用 null
将一个“空”或者“未知”的值写入变量中,而 undefined
则保留作为未进行初始化的事物的默认初始值。
Object Symbol
object
类型是一个特殊的类型。
其他所有的数据类型都被称为“原始类型”,因为它们的值只包含一个单独的内容(字符串、数字或者其他)。相反,object
则用于储存数据集合和更复杂的实体。
symbol
类型用于创建对象的唯一标识符。我们在这里提到 symbol
类型是为了完整性,但我们要在学完 object
类型后再学习它。
typeof 运算符
typeof x | typeof(x)
typeof null
的结果为"object"
。这是官方承认的typeof
的错误,这个问题来自于 JavaScript 语言的早期阶段,并为了兼容性而保留了下来。null
绝对不是一个object
。null
有自己的类型,它是一个特殊值。typeof
的行为在这里是错误的。
typeof alert
的结果是"function"
,因为alert
在 JavaScript 语言中是一个函数。在 JavaScript 语言中没有一个特别的 “function” 类型。函数隶属于object
类型。但是typeof
会对函数区分对待,并返回"function"
。这也是来自于 JavaScript 语言早期的问题。从技术上讲,这种行为是不正确的,但在实际编程中却非常方便。
交互 alert prompt confirm
alert
它会显示一条信息,并等待用户按下 “OK”。
例如:
alert("Hello");
弹出的这个带有信息的小窗口被称为 模态窗。“modal” 意味着用户不能与页面的其他部分(例如点击其他按钮等)进行交互,直到他们处理完窗口。在上面示例这种情况下 —— 直到用户点击“确定”按钮。
prompt
prompt
函数接收两个参数:
result = prompt(title, [default]);
浏览器会显示一个带有文本消息的模态窗口,还有 input 框和确定/取消按钮。
title
显示给用户的文本
default
可选的第二个参数,指定 input 框的初始值。
上述语法中
default
周围的方括号表示该参数是可选的,不是必需的。
访问者可以在提示输入栏中输入一些内容,然后按“确定”键。然后我们在 result
中获取该文本。或者他们可以按取消键或按 Esc 键取消输入,然后我们得到 null
作为 result
。
IE 浏览器会提供默认值
第二个参数是可选的。但是如果我们不提供的话,Internet Explorer 会把"undefined"
插入到 prompt。
所以,为了 prompt 在 IE 中有好的效果,我们建议始终提供第二个参数:
let test = prompt("test", '');
confirm
语法:
result = confirm(question);
confirm
函数显示一个带有 question
以及确定和取消两个按钮的模态窗口。
点击确定返回 true
,点击取消返回 false
。
这些方法都是模态的:它们暂停脚本的执行,并且不允许用户与该页面的其余部分进行交互,直到窗口被解除。
上述所有方法共有两个限制:
- 模态窗口的确切位置由浏览器决定。通常在页面中心。
- 窗口的确切外观也取决于浏览器。我们不能修改它。
类型转换
大多数情况下,运算符和函数会自动将赋予它们的值转换为正确的类型。
值得注意:
- 对
undefined
进行数字型转换时,输出结果为NaN
,而非0
。- 对
"0"
和只有空格的字符串(比如:" "
)进行布尔型转换时,输出结果为true
。
字符串
alert()
会将自动进行字符串转换。
value = String(value);
false
变成 "false"
,null
变成 "null"
数字
在算术函数和表达式中,会自动进行 number 类型转换。
num = Number(str);
如果该字符串不是一个有效的数字,转换的结果会是NaN
。
转换规则
值 | 变成…… |
---|---|
undefined |
NaN |
null |
0 |
true 和 false |
1 and 0 |
string |
去掉首尾空白字符(空格、换行符 \n 、制表符 \t 等)后的纯数字字符串中含有的数字。如果剩余字符串为空,则转换结果为 0 。否则,将会从剩余字符串中“读取”数字。当类型转换出现 error 时返回 NaN 。 |
alert( Number(" 123 ") ); // 123
// NaN(从字符串“读取”数字,读到 "z" 时出现错误,返回NaN)
alert( Number("123z") );
布尔型
发生在逻辑运算中(条件判断和其他类似的东西)
转换规则
- 直观上为“空”的值(如
0
、空字符串、null
、undefined
和NaN
)将变为false
。 - 其他值变成
true
。
注意!
字符串"0"," "是true,在 JavaScript 中,非空的字符串总是true
。
运算符
和python差不多,
取余%
,求幂 **
。
+
可以连接字符串(只要有一边是string另一边也会转换为string,仅加法会这样转换)(*不可以用于重复字符串)
+
可以用于转换数字
y = +true; // 1
根据以上特性,有些情况下须这样使用:
// apples:"2" oranges:"3"
+apples + +oranges
等号运算符,返回右边的值(知道即可,少写)
let a = 1;
let b = 2;
let c = 3 - (a = b + 1);
//c = 0
链式赋值
a = b = c = 2 + 2;//a = b = c = 4
位运算符把运算元当做 32 位整数,并在它们的二进制表现形式上操作。
这些运算符不是 JavaScript 特有的。大部分的编程语言都支持这些运算符。
下面是位运算符:
- 按位与 (
&
) - 按位或 (
|
) - 按位异或 (
^
) - 按位非 (
~
) - 左移 (
<<
) - 右移 (
>>
) - 无符号右移 (
>>>
)
这些运算符很少被使用
逗号运算符只返回最后一个表达式的结果
请注意逗号运算符的优先级非常低,比 =
还要低
逻辑运算符
||
result = value1 || value2 || value3;
或运算符 ||
做如下的事情:
- 从左到右依次计算操作数。
- 处理每一个操作数时,都将其转化为布尔值。如果结果是
true
,就停止计算,返回这个操作数的初始值。 - 如果所有的操作数都被计算过(也就是,转换结果都是
false
),则返回最后一个操作数。
返回的值是操作数的初始形式,不会做布尔转换。
根据以上特性,诞生了许多有趣的用法
1. 获取变量列表或者表达式中的第一个真值
let firstName = "";
let lastName = "";
let nickName = "SuperCoder";
alert( firstName || lastName || nickName || "Anonymous");
// SuperCoder
2. 短路求值(Short-circuit evaluation)
或运算符 ||
的另一个用途是所谓的“短路求值”。
这指的是,||
对其参数进行处理,直到达到第一个真值,然后立即返回该值,而无需处理其他参数。
如果操作数不仅仅是一个值,而是一个有副作用的表达式,例如变量赋值或函数调用,那么这一特性的重要性就变得显而易见了。
true || alert("not printed");
false || alert("printed");
&&
类似地,与运算符寻找第一个假值
与运算 &&
做如下的事:
- 从左到右依次计算操作数。
- 在处理每一个操作数时,都将其转化为布尔值。如果结果是
false
,就停止计算,并返回这个操作数的初始值。 - 如果所有的操作数都被计算过(例如都是真值),则返回最后一个操作数。
与运算
&&
在或运算||
之前进行
最好不要用
||
或&&
来取代if
有时候,有人会将与运算符&&
作为“简化if
”的一种方式。
!
两个非运算 !!
有时候用来将某个值转化为布尔类型
空值合并运算符 '??'
当一个值既不是 null
也不是 undefined
时,我们将其称为“已定义的(defined)”。
??
返回第一个 已定义的 值。一种获得两者中的第一个“已定义的”值的不错的语法。
??
的常见使用场景是提供默认值。
||
返回第一个 真 值。??
返回第一个 已定义的 值。
出于安全原因,JavaScript 禁止将 ??
运算符与 &&
和 ||
运算符一起使用,除非使用括号明确指定了优先级。会触发语法错误。
比较(条件判断)
try
5 > 4 → true
"apple" > "pineapple" → false
"2" > "12" → true
undefined == null → true
undefined === null → false
null == "\n0\n" → false
null === +"\n0\n" → false
throw
- 数字间比较大小,显然得 true。
- 按词典顺序比较,得 false。
"a"
比"p"
小。 - 与第 2 题同理,首位字符
"2"
大于"1"
。 null
只与undefined
互等。- 严格相等模式下,类型不同得 false。
- 与第 4 题同理,
null
只与undefined
相等。 - 不同类型严格不相等。
memes:
有点东西|没了东西
没放东西|啥东西?
和c系代码类似
字符串比较,字典序(准确说是Unicode 编码顺序)
不同类型比较,将会转换为number
一个有趣的现象
有时候,以下两种情况会同时发生:
- 若直接比较两个值,其结果是相等的。
- 若把两个值转为布尔值,它们可能得出完全相反的结果,即一个是
true
,一个是false
。- 例如:
let a = 0;
alert( Boolean(a) ); // false
let b = "0";
alert( Boolean(b) ); // true
alert(a == b); // true!
对于 JavaScript 而言,这种现象其实挺正常的。因为 JavaScript 会把待比较的值转化为数字后再做比较(因此
"0"
变成了0
)。若只是将一个变量转化为Boolean
值,则会使用其他的类型转换规则。
严格(不)相等
//无法区分空字符串和0和false,因为都被转换为了0
0 == false // true
'' == false // true
// 若属于不同的数据类型,那么不会做任何的类型转换而立刻返回 `false`。
0 === false // false
严格相等运算符 ===
在进行比较时不会做任何的类型转换。
**同样地:严格不相等运算符 !==
**
null 与 undefined
null === undefined // false
null == undefined // true
使用数学式或其他比较方法 < > <= >=
时:
null/undefined
会被转化为数字:null
被转化为 0
,undefined
被转化为 NaN
。
奇怪的结果 null vs 0
null > 0 // false(1)
null == 0 // false(2)
null >= 0 // true(3)
what happened?
因为相等性检查 ==
和普通比较符 > < >= <=
的代码逻辑是相互独立的。进行值的比较时,null
会被转化为数字,因此它被转化为了 0
。这就是为什么(3)中 null >= 0
返回值是 true,(1)中 null > 0
返回值是 false。
另一方面,undefined
和 null
在相等性检查 ==
中不会进行任何的类型转换,它们有自己独立的比较规则,所以除了它们之间互等外,不会等于任何其他的值。这就解释了为什么(2)中 null == 0
会返回 false。
特立独行的 undefined
undefined
不应该被与其他值进行比较:
undefined > 0 // false (1)
undefined < 0 // false (2)
undefined == 0 // false (3)
为何它看起来如此厌恶 0?
(1)
和(2)
都返回false
是因为undefined
在比较中被转换为了NaN
,而NaN
是一个特殊的数值型值,它与任何值进行比较都会返回false
。(3)
返回false
是因为这是一个相等性检查,而undefined
只与null
相等,不会与其他值相等。
条件分支
if (condition)
{
command;
}
类c
?运算符,可套娃
let result = condition ? value1 : value2;
?的非常规使用
let company = prompt('Which company created JavaScript?', '');
(company == 'Netscape') ?
alert('Right!') : alert('Wrong.');
switch:
任何表达式都可以成为 switch/case
的参数
switch 的相等判断是严格相等(===)
循环
类c
一些特点
禁止
break/continue
在 ‘?’ 的右边
请注意非表达式的语法结构不能与三元运算符?
一起使用。特别是break/continue
这样的指令是不允许这样使用的。
if (i > 5) {
alert(i);
} else {
continue;
}
(i > 5) ? alert(i) : continue; // continue 不允许在这个位置
break/continue多层循环只能跳一级的问题,标签诞生了!
labelName: for (...) {
...
}
outer: for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
let input = prompt(`Value at coords (${i},${j})`, '');
// 如果是空字符串或被取消,则中断并跳出这两个循环。
if (!input) break outer; // (*)
// 用得到的值做些事……
}
}
alert('Done!');
标签并不允许“跳到”所有位置
标签不允许我们跳到代码的任意位置。
for...of/for...in
#TODO
函数
SIMPLE
function func(parameter1, parameter1 = xxx/func()) {
...
return xxx;
}
func(args)
只有在没有局部变量的情况下才会使用外部变量。
如果在函数内部声明了同名变量,那么函数会 遮蔽(外部变量跟局部变量重名,不应该称之屏蔽外部变量,而是js变量解析机制,从最近的局部变量->往上寻找,直到全局变量,找到即为结束) 外部变量。
- 参数(parameter)是函数声明中括号内列出的变量(它是函数声明时的术语)。
- 参数(argument)是调用函数时传递给函数的值(它是函数调用时的术语)。
默认值:如果一个函数被调用,但有参数(argument)未被提供,那么相应的值就会变成 undefined
。
我们可以使用 =
为函数声明中的参数指定所谓的“默认”(如果对应参数的值未被传递则使用)值。
返回值:空值的return
(等效return undefined)或没有return
的函数返回值为undefined
。
不要在
return
与返回值之间添加新行
对于return
的长表达式,可能你会很想将其放在单独一行,如下
return
(some + long + expression + or + whatever * f(a) + f(b))
但这不行,因为 JavaScript 默认会在
return
之后加上分号。
return;
(some + long + expression + or + whatever * f(a) + f(b))
因此,实际上它的返回值变成了空值。
如果我们想要将返回的表达式写成跨多行的形式,那么应该在return
的同一行开始写此表达式。或者按照如下的方式放上左括号:
return (
some + long + expression
+ or +
whatever * f(a) + f(b)
)
命名
以 "show"
开头的函数通常会显示某些内容。
函数以 XX 开始……
"get…"
—— 返回一个值,"calc…"
—— 计算某些内容,"create…"
—— 创建某些内容,"check…"
—— 检查某些内容并返回 boolean 值,等。
函数表达式
函数也是值,允许我们在任何表达式的中间创建一个新函数。
//正常声明的函数名也是和下面一样的值
let func = function(parameter1, parameter1 = xxx/func()) {
...
return xxx;
};
函数表达式允许省略函数名。
注意最后的分号
调用alert(func)时,输出函数源码(不会运行函数),调用alert(func())时才会运行函数。
回调函数 & 匿名函数:
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);
//--------------------
function ask(question, yes, no) {
if (confirm(question)) yes()
else no();
}
ask(
"Do you agree?",
function() { alert("You agreed."); },
function() { alert("You canceled the execution."); }
);
ask
的两个参数值 showOk
和 showCancel
可以被称为 回调函数 或简称 回调。
主要思想是我们传递一个函数,并期望在稍后必要时将其“回调”。在我们的例子中,showOk
是回答 “yes” 的回调,showCancel
是回答 “no” 的回调。
第二种写法:直接在 ask(...)
调用内进行函数声明。这两个函数没有名字,所以叫 匿名函数。这样的函数在 ask
外无法访问(因为没有对它们分配变量),不过这正是我们想要的。
这样的代码在我们的脚本中非常常见,这正符合 JavaScript 语言的思想。
一个函数是表示一个“行为”的值
字符串或数字等常规值代表 数据。
函数可以被视为一个 行为(action)。
我们可以在变量之间传递它们,并在需要时运行。
函数表达式是在代码执行到达时被创建,并且仅从那一刻起可用。
函数声明在函数声明被定义之前,它就可以被调用。
严格模式下,当一个函数声明在一个代码块内时,它在该代码块内的任何位置都是可见的。但在代码块外不可见。
箭头函数
let func = (parameter1, parameter2) => expression;
let func = function(parameter1, parameter2)
{
return expression;
}
//
let func = parameter1 => expression;
let func = () => expression;
//
let func = (parameter1, parameter2) => {
other...
return expression;
};
JavaScript 特性总结
代码质量
介绍在开发中进一步使用的编码实践
开发者工具-在浏览器中调试
开发人员工具中的选项比本文介绍的多得多。完整的手册请点击这个链接查看:https://developers.google.com/web/tools/chrome-devtools。
资源(Sources)面板
资源(Sources)面板包含三个部分:
- 文件导航(File Navigator) 区域列出了 HTML、JavaScript、CSS 和包括图片在内的其他依附于此页面的文件。Chrome 扩展程序也会显示在这。
- 代码编辑(Code Editor) 区域展示源码。
- JavaScript 调试(JavaScript Debugging) 区域是用于调试的,我们很快就会来探索它。
控制台(Console)
输入命令执行,在下一行显示返回值
断点(Breakpoints)
点击行号即可设置断点,
我们总是可以在右侧的面板中找到断点的列表。当我们在数个文件中有许多断点时,这是非常有用的。它允许我们:
- 快速跳转至代码中的断点(通过点击右侧面板中的对应的断点)。
- 通过取消选中断点来临时禁用对应的断点。
- 通过右键单击并选择移除来删除一个断点。
- ……等等。
条件断点
在行号上 右键单击 允许你创建一个 条件 断点。只有当给定的表达式(你创建条件断点时提供的表达式)为真时才会被触发。
当我们需要在特定的变量值或参数的情况下暂停程序执行时,这种调试方法就很有用了。
debugger 命令
function hello(name) {
let phrase = `Hello, ${name}!`;
debugger; // 调试器会在此暂停
alert(phrase);
}
这种命令只有在开发者工具打开时才有效,否则浏览器会忽略它。
暂停并查看
[[First floor - 3 JavaScript#资源(Sources)面板]]
设置好断点后,即可刷新页面
请打开右侧的信息下拉列表(箭头指示出的地方)。这里允许你查看当前的代码状态:
察看(Watch)
—— 显示任意表达式的当前值。
你可以点击加号+
然后输入一个表达式。调试器将显示它的值,并在执行过程中自动重新计算该表达式。调用栈(Call Stack)
—— 显示嵌套的调用链。
此时,调试器正在hello()
的调用链中,被index.html
中的一个脚本调用(这里没有函数,因此显示 “anonymous”)
如果你点击了一个堆栈项,调试器将跳到对应的代码处,并且还可以查看其所有变量。作用域(Scope)
—— 显示当前的变量。
Local
显示当前函数中的变量,你还可以在源代码中看到它们的值高亮显示了出来。
Global
显示全局变量(不在任何函数中)。
跟踪执行
“恢复(Resume)”:继续执行,快捷键 F8。
继续执行。如果没有其他的断点,那么程序就会继续执行,并且调试器不会再控制程序。
“下一步(Step)”:运行下一条(即当前行)指令,快捷键 F9。
运行下一条语句。如果我们现在点击它,alert
会被显示出来。
一次接一次地点击此按钮,整个脚本的所有语句会被逐个执行。
“跨步(Step over)”:运行下一条(即当前行)指令,但 不会进入到一个函数中,快捷键 F10。
跟上一条命令“下一步(Step)”类似,但如果下一条语句是函数调用则表现不同。这里的函数指的是:不是内建的如 alert
函数等,而是我们自己写的函数。
如果我们对比一下,“下一步(Step)”命令会进入嵌套函数调用并在其第一行暂停执行,而“跨步(Step over)”对我们不可见地执行嵌套函数调用,跳过了函数内部。
执行会在该函数调用后立即暂停。
如果我们对该函数的内部执行不感兴趣,这命令会很有用。
“步入(Step into)”,快捷键 F11。
和“下一步(Step)”类似,但在异步函数调用情况下表现不同。如果你刚刚才开始学 JavaScript,那么你可以先忽略此差异,因为我们还没有用到异步调用。
至于之后,只需要记住“下一步(Step)”命令会忽略异步行为,例如 setTimeout
(计划的函数调用),它会过一段时间再执行。而“步入(Step into)”会进入到代码中并等待(如果需要)。详见 DevTools 手册。
“步出(Step out)”:继续执行到当前函数的末尾,快捷键 Shift+F11。
继续执行当前函数内的剩余代码,并暂停在调用当前函数的下一行代码处。当我们使用 偶然地进入到一个嵌套调用,但是我们又对这个函数不感兴趣时,我们想要尽可能的继续执行到最后的时候是非常方便的。
启用/禁用所有的断点。
这个按钮不会影响程序的执行。只是一个批量操作断点的开/关。
启用/禁用出现错误时自动暂停脚本执行。
当启动此功能,如果开发者工具是打开着的时候,任何脚本执行错误都会导致该脚本执行自动暂停。然后我们可以在调试器中分析变量来看一下什么出错了。因此如果我们的脚本因为错误挂掉的时候,我们可以打开调试器,启用这个选项然后重载页面,查看一下哪里导致它挂掉了和当时的上下文是什么。
Continue to here
在代码中的某一行上右键,在显示的关联菜单(context menu)中点击一个非常有用的名为 “Continue to here” 的选项。
当你想要向前移动很多步到某一行为止,但是又懒得设置一个断点时非常的方便。
日志记录
console.log
代码风格
没有什么规则是“必须”的
没有什么规则是“刻在石头上”的。这些是风格偏好,而不是宗教教条。
注释
“如果代码不够清晰以至于需要一个注释,那么或许它应该被重写。”
代码表示HOW,注释表示WHY
“解释性”注释:
// 这里的代码会先做这件事(……)然后做那件事(……)
// ……谁知道还有什么……
very;
complex;
code;
我们不能完全避免“解释型”注释。例如在一些复杂的算法中,会有一些出于优化的目的而做的一些巧妙的“调整”。
专门用于记录函数的语法 JSDoc:用法、参数和返回值。
/**
* 返回 x 的 n 次幂的值。
*
* @param {number} x 要改变的值。
* @param {number} n 幂数,必须是一个自然数。
* @return {number} x 的 n 次幂的值。
*/
function pow(x, n) {
...
}
为什么不这样做?
通常注释给出了,一种实现功能的方法,但是有多种方法时,为什么选择这种而不是其他的往往更加重要。
没有这样的注释的话,就可能会发生下面的情况:
- 你(或者你的同事)打开了前一段时间写的代码,看到它不是最理想的实现方式。
- 你会想:“我当时是有多蠢啊,现在我真是太聪明了”,然后用“更显而易见且正确的”方式重写了一遍。
- ……重写的这股冲动劲是好的。但是在重写的过程中你发现“更显而易见”的解决方案实际上是有缺陷的。你甚至依稀地想起了为什么会这样,因为你很久之前就已经尝试过这样做了。于是你又还原了那个正确的实现,但是时间已经浪费了。
忍者代码
不多说,看就完了
忍者代码
自动化测试(浅浅了解)
使用 Mocha 进行自动化测试 (javascript.info)
自动化测试意味着测试是独立于代码的。它们以各种方式运行我们的函数,并将结果与预期结果进行比较。
describe("pow", function() {
it("raises to n-th power", function() {
assert.equal(pow(2, 3), 8);
});
});
规范有三种使用方式:
- 作为 测试 —— 保证代码正确工作。
- 作为 文档 ——
describe
和it
的标题告诉我们函数做了什么。 - 作为 案例 —— 测试实际工作的例子展示了一个函数可以被怎样使用。
Polyfill 和 转译器
如何让我们现代的代码在还不支持最新特性的旧引擎上工作?
有两个工作可以做到这一点:
- 转译器(Transpilers)。
- 垫片(Polyfills)。
转译器
转译器是一种可以将源码转译成另一种源码的特殊的软件。它可以解析(“阅读和理解”)现代代码,并使用旧的语法结构对其进行重写,进而使其也可以在旧的引擎中工作。
例如,在 ES2020 之前没有“空值合并运算符” ??
。所以,如果访问者使用过时了的浏览器访问我们的网页,那么该浏览器可能就不明白 height = height ?? 100
这段代码的含义。
转译器会分析我们的代码,并将 height ?? 100
重写为 (height !== undefined && height !== null) ? height : 100
。
通常,开发者会在自己的计算机上运行转译器,然后将转译后的代码部署到服务器。
说到名字,Babel 是最著名的转译器之一。
现代项目构建系统,例如 webpack,提供了在每次代码更改时自动运行转译器的方法,因此很容易将代码转译集成到开发过程中。
Polyfills
新的语言特性可能不仅包括语法结构和运算符,还可能包括内建函数。
例如,Math.trunc(n)
是一个“截断”数字小数部分的函数,例如 Math.trunc(1.23)
返回 1
。
在一些(非常过时的)JavaScript 引擎中没有 Math.trunc
函数,所以这样的代码会执行失败。
由于我们谈论的是新函数,而不是语法更改,因此无需在此处转译任何内容。我们只需要声明缺失的函数。
更新/添加新函数的脚本被称为“polyfill”。它“填补”了空白并添加了缺失的实现。
JavaScript 是一种高度动态的语言。脚本可以添加/修改任何函数,甚至包括内建函数。
两个有趣的 polyfill 库:
- core js 支持了很多特性,允许只包含需要的特性。
- polyfill.io 提供带有 polyfill 的脚本的服务,具体取决于特性和用户的浏览器。
对象
基础
类似字典
let user = new Object(); //“构造函数”的语法
let user = {}; //“字面量”的语法
let user = {
name: "Levin",
age: 20,
"Likes something": true, // 多词属性名加引号
key: value, //应以逗号结尾
// 尾随(trailing)或悬挂(hanging)逗号。这样便于我们添加、删除和移动属性,因为所有的行都是相似的。
};
console.log(user.name); // Levin
console.log(user.age); // 20
user.isAdmin = true; // add
delete user.age; // del
// 点运算符要求key是有效的变量标识符:包含空格,不以数字开头,也不包含特殊字符(允许使用 `$` 和 `_`)
// user.Likes something -- error
console.log(user["Likes something"])
// 方括号适用任何字符串
let key = "Likes something";
user[key] = true;
// 方括号优点
//计算属性
let fruit = prompt("Which fruit to buy?", "apple");
let bag = {
[fruit]: 5,
};
alert(bag.apple);
//属性值简写,将已存在的变量当做属性名
function makeUser(name, age) {
return {
name, // 等同于 name: name
age, // 等同于 age: age
};
}
//属性名称限制,通常变量名不能是编程语言的保留字,如"for"等
//但对象的属性名不受限制,属性命名没有限制。属性名可以是任何字符串或者 symbol,其他类型会被自动地转换为字符串
let obj = {
for: 1,
let: 2,
return: 3,
};
// a little trap: 名为__proto__的属性。不能将其设置为一个非对象的值
let obj = {};
obj.__proto__ = 5; // 此操作将被忽略
alert(obj.__proto__); // [object Object]
// 将在后续学习
// 属性存在性测试,in操作符,"key" in object
// 相比于其他语言,JavaScript 的对象能够被访问任何属性。即使属性不存在也不会报错,只会返回undefined!
let user = { name: "Levin" };
alert("name" in user) // true
alert("age" in user) // false
let name = "name" // 不加引号为变量名
alert(name in user) // true
// 为什么会存在in操作符,将属性与undefined比较不行吗,考虑以下情况:
let obj = {
test: undefined
};
// 属性存在,但是undefined,这种情况很少发生,通常给变量赋值null而不是undefined
// for...in循环,循环变量可用key,prop
for (let key in user) {
alert(key);
alert(user[key]);
}
// 对象有顺序吗?
// “有特别的顺序”:整数属性会被进行排序,其他属性则按照创建的顺序显示。
let codes = {
"49": "Germany",
"41": "Switzerland",
"44": "Great Britain",
// ..,
"1": "USA"
};
code["+86"] = "China";
code["-114"] = "514";
for(let code in codes) {
alert(code); // 1, 41, 44, 49, +86, -114
}
// 整数属性?指的是一个可以在不做任何更改的情况下与一个整数进行相互转换的字符串。
// 如"49" 是一个整数属性名,因为我们把它转换成整数,再转换回来,它还是一样的。
String(Math.trunc(Number("49"))) == "49"
引用与复制
对象与原始类型的根本区别之一是,对象是“通过引用”存储和复制的,而原始类型:字符串、数字、布尔值等 —— 总是“作为一个整体”复制
let message = "Hello!";
let phrase = message;
let user = {
name: "John"
};
let user = { name: "John" };
let admin = user; // 复制引用
let user = { name: 'John' };
let admin = user;
admin.name = 'Pete'; // 通过 "admin" 引用来修改
alert(user.name); // 'Pete',修改能通过 "user" 引用看到
通过引用比较
let a = {};
let b = a; // 复制引用
alert( a == b ); // true,都引用同一对象
alert( a === b ); // true
let a = {};
let b = {}; // 两个独立的对象
alert( a == b ); // false
克隆与合并 Object.assign(浅层拷贝)
let user = {
name: "John",
age: 30
};
let clone = {}; // 新的空对象
// 将 user 中所有的属性拷贝到其中
for (let key in user) {
clone[key] = user[key];
}
// 现在 clone 是带有相同内容的完全独立的对象
clone.name = "Pete"; // 改变了其中的数据
alert( user.name ); // 原来的对象中的 name 属性依然是 John
or
let user = {
name: "John",
age: 30
};
let clone = Object.assign({}, user);
合并多个对象
// Object.assign(dest, [src1, src2, src3...])
let user = { name: "John" };
let permissions1 = { canView: true };
let permissions2 = { canEdit: true };
// 将 permissions1 和 permissions2 中的所有属性都拷贝到 user 中
Object.assign(user, permissions1, permissions2);
// 现在 user = { name: "John", canView: true, canEdit: true }
深层克隆
let user = {
name: "John",
sizes: {
height: 182,
width: 50
}
};
alert( user.sizes.height ); // 182
let clone = Object.assign({}, user);
alert( user.sizes === clone.sizes ); // true,同一个对象
// user 和 clone 分享同一个 sizes
user.sizes.width++; // 通过其中一个改变属性值
alert(clone.sizes.width); // 51,能从另外一个获取到变更后的结果
为了解决这个问题,并让 user
和 clone
成为两个真正独立的对象,我们应该使用一个拷贝循环来检查 user[key]
的每个值,如果它是一个对象,那么也复制它的结构。这就是所谓的“深拷贝”。
我们可以使用递归来实现它。或者为了不重复造轮子,采用现有的实现,例如 lodash 库的 _.cloneDeep(obj)。
使用 const 声明的对象也是可以被修改的
通过引用对对象进行存储的一个重要的副作用是声明为const
的对象 可以 被修改。
const user = {
name: "John"
};
user.name = "Pete"; // (*)
alert(user.name); // Pete
看起来
(*)
行的代码会触发一个错误,但实际并没有。user
的值是一个常量,它必须始终引用同一个对象,但该对象的属性可以被自由修改。
换句话说,只有当我们尝试将user=...
作为一个整体进行赋值时,const user
才会报错。
也就是说,如果我们真的需要创建常量对象属性,也是可以的,但使用的是完全不同的方法。
垃圾回收
JavaScript 的内存管理是自动的、无形的。我们创建的原始值、对象、函数……这一切都会占用内存。
当我们不再需要某个东西时会发生什么?JavaScript 引擎如何发现它并清理它?
类似java
自动内存管理(垃圾回收)阵营:
JavaScript、Java、Go、Python、PHP、Ruby、C#
手动内存管理阵营:
C、C++、Rust
可达性
- 这里列出固有的可达值的基本集合,这些值明显不能被释放。
- 当前执行的函数,它的局部变量和参数。
- 当前嵌套调用链上的其他函数、它们的局部变量和参数。
- 全局变量。
- (还有一些内部的)
这些值被称作 根(roots)。
- 如果一个值可以通过引用链从根访问任何其他值,则认为该值是可达的。
比方说,如果全局变量中有一个对象,并且该对象有一个属性引用了另一个对象,则 该 对象被认为是可达的。而且它引用的内容也是可达的。
在 JavaScript 引擎中有一个被称作 垃圾回收器 的东西在后台执行。它监控着所有对象的状态,并删除掉那些已经不可达的。
算法 “mark-and-sweep”
定期执行以下“垃圾回收”步骤:
- 垃圾收集器找到所有的根,并“标记”(记住)它们。
- 然后它遍历并“标记”来自它们的所有引用。
- 然后它遍历标记的对象并标记 它们的 引用。所有被遍历到的对象都会被记住,以免将来再次遍历到同一个对象。
- ……如此操作,直到所有可达的(从根部)引用都被访问到。
- 没有被标记的对象都会被删除。
这是垃圾收集工作的概念。JavaScript 引擎做了许多优化,使垃圾回收运行速度更快,并且不会对代码执行引入任何延迟。
一些优化建议: - 分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”。在典型的代码中,许多对象的生命周期都很短:它们出现、完成它们的工作并很快死去,因此在这种情况下跟踪新对象并将其从内存中清除是有意义的。那些长期存活的对象会变得“老旧”,并且被检查的频次也会降低。
- 增量收集(Incremental collection)—— 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。因此,引擎将现有的整个对象集拆分为多个部分,然后将这些部分逐一清除。这样就会有很多小型的垃圾收集,而不是一个大型的。这需要它们之间有额外的标记来追踪变化,但是这样会带来许多微小的延迟而不是一个大的延迟。
- 闲时收集(Idle-time collection)—— 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。
对象方法 "this"
方法简写
let user = {
sayHi: function() {
alert("hello");
}
}
// |
// V
let user = {
sayHi() {
alert("hello");
},
}
方法中的 "this"
通常,对象方法需要访问对象中存储的信息才能完成其工作。
为了访问该对象,方法中可以使用 this
关键字。
let user = {
name: "Levin",
sayHi() {
alert("hello" + this.name);
}
};
user.sayHi();
如果不用this用外部变量名来引用,是不可靠的,如果将该变量复制给另一个变量,将会访问到一个错误的对象。
到目前为止,this和大多数编程语言的this类似。
"this" 不受限制
JavaScript 中的 this
可以用于任何函数,即使它不是对象的方法。
// 无语法错误
function sayHi() {
alert(this.name);
}
let user = { name: "Levin" };
let admin = { name: "Admin" };
user.f = sayHi;
admin.f = sayHi;
// 这两个调用有不同的 this 值
// 函数内部的 "this" 是“点符号前面”的那个对象
user.f(); //(this == user)
admin.f(); //(this == admin)
在没有对象的情况下调用:
this == undefined
function sayHi() {
alert(this);
}
sayHi(); // undefined
在这种情况下,严格模式下的
this
值为undefined
。如果我们尝试访问this.name
,将会报错。
在非严格模式的情况下,this
将会是 全局对象(浏览器中的window
,我们稍后会在 全局对象 一章中学习它)。这是一个历史行为,"use strict"
已经将其修复了。
通常这种调用是程序出错了。如果在一个函数内部有this
,那么通常意味着它是在对象上下文环境中被调用的。
解除
this
绑定的后果
如果你经常使用其他的编程语言,那么你可能已经习惯了“绑定this
”的概念,即在对象中定义的方法总是有指向该对象的this
。
在 JavaScript 中,this
是“自由”的,它的值是在调用时计算出来的,它的值并不取决于方法声明的位置,而是取决于在“点符号前”的是什么对象。
在运行时对this
求值的这个概念既有优点也有缺点。一方面,函数可以被重用于不同的对象。另一方面,更大的灵活性造成了更大的出错的可能。
箭头函数 没有自己的this
箭头函数有些特别:它们没有自己的 this
。如果我们在这样的函数中引用 this
,this
值取决于外部“正常的”函数。
举个例子,这里的 arrow()
使用的 this
来自于外部的 user.sayHi()
方法:
let user = {
firstName: "Ilya",
sayHi() {
let arrow = () => alert(this.firstName);
arrow();
}
};
user.sayHi(); // Ilya
// 和上面相同
let user = {
firstName: "Ilya",
sayHi() {
alert(this.firstName);
}
};
// 错误的结果
let user = {
firstName: "Ilya",
sayHi(): () => alert(this.firstName)
};
例
设置 this
的规则不考虑对象定义。只有调用那一刻才重要。
function makeUser() {
return {
name: "John",
ref: this
};
}
let user = makeUser();
alert( user.ref.name ); // 结果是什么?
Error: Cannot read property 'name' of...
以下产生相同的结果
function makeUser(){
return this; // 这次这里没有对象字面量
}
alert( makeUser().name );
// Error: Cannot read property 'name' of...
以下产生正常的结果
function makeUser() {
return {
name: "John",
ref() {
return this;
}
};
}
let user = makeUser();
alert( user.ref().name ); // John
现在正常了,因为 user.ref()
是一个方法。this
的值为点符号 .
前的这个对象。
构造器和操作符 "new"
构造函数
在技术上构造函数就是常规函数,不过有两个约定:
- 它们的命名以大写字母开头。
- 它们只能由
"new"
操作符来执行。
function User(name) {
this.name = name;
this.isAdmin = false;
}
let user = new User("Jack");
alert(user.name); // Jack
alert(user.isAdmin); // false
当一个函数被使用 new
操作符执行时,它按照以下步骤:
- 一个新的空对象被创建并分配给
this
。 - 函数体执行。通常它会修改
this
,为其添加新的属性。 - 返回
this
的值。
换句话说,new User(...)
做的就是类似的事情:
function User(name) {
// this = {};(隐式创建)
// 添加属性到 this
this.name = name;
this.isAdmin = false;
// return this;(隐式返回)
}
构造器的主要目的 —— 实现可重用的对象创建代码。
任何函数(除了箭头函数,它没有自己的 this
)都可以用作构造器。即可以通过 new
来运行,它会执行上面的算法。“首字母大写”是一个共同的约定,以明确表示一个函数将被使用 new
来运行。
new function() { … }
如果我们有许多行用于创建单个复杂对象的代码,我们可以将它们封装在一个立即调用的构造函数中,像这样:
// 创建一个函数并立即使用 new 调用它
let user = new function() {
this.name = "John";
this.isAdmin = false;
// ……用于用户创建的其他代码
// 也许是复杂的逻辑和语句
// 局部变量等
};
这个构造函数不能被再次调用,因为它不保存在任何地方,只是被创建和调用。因此,这个技巧旨在封装构建单个对象的代码,而无需将来重用。
构造器模式测试 new.target
我们可以使用 new.target
属性来检查它是否被使用 new
进行调用了。
对于常规调用,它为 undefined,对于使用 new
的调用,则等于该函数:
function User() {
alert(new.target);
}
// 不带 "new":
User(); // undefined
// 带 "new":
new User(); // function User { ... }
可以被用在函数内部,来判断该函数是被通过 new
调用的“构造器模式”,还是没被通过 new
调用的“常规模式”。
function User(name) {
if (!new.target) { // 如果你没有通过 new 运行我
return new User(name); // ……我会给你添加 new
}
this.name = name;
}
let john = User("John"); // 将调用重定向到新用户
alert(john.name); // John
这种方法有时被用在库中以使语法更加灵活。这样人们在调用函数时,无论是否使用了 new
,程序都能工作。
构造器的 return
通常,构造器没有 return
语句。它们的任务是将所有必要的东西写入 this
,并自动转换为结果。
但是,如果这有一个 return
语句,那么规则就简单了:
- 如果
return
返回的是一个对象,则返回这个对象,而不是this
。 - 如果
return
返回的是一个原始类型,则忽略。
换句话说,带有对象的return
返回该对象,在所有其他情况下返回this
。
通常构造器没有 return
语句。
省略括号
btw,如果没有参数,我们可以省略new
后的括号:
let user = new User; // <-- 没有参数
// 等同于
let user = new User();
这里省略括号不被认为是一种“好风格”,但是规范允许使用该语法。
构造器中的方法
function User(name) {
this.name = name;
this.sayHi = function() {
alert( "My name is: " + this.name );
};
}
let john = new User("John");
john.sayHi(); // My name is: John
/*
john = {
name: "John",
sayHi: function() { ... }
}
*/
可选链 ?.
可选链 ?.
是一种访问嵌套对象属性的安全的方式。即使中间的属性不存在,也不会出现错误。
“不存在的属性”
let user = {};
alert(user.address ? user.address.street : undefined);
// 不够优雅?
let user = {}; // user 没有 address 属性
alert( user.address && user.address.street && user.address.street.name ); // undefined(不报错)
// 还不优雅?
如果可选链 ?.
前面的值为 undefined
或者 null
,它会停止运算并返回 undefined
。
换句话说,例如 value?.prop
:
- 如果
value
存在,则结果与value.prop
相同, - 否则(当
value
为undefined/null
时)则返回undefined
。
let user = {}; // user 没有 address 属性
alert( user?.address?.street ); // undefined(不报错)
?.
语法使其前面的值成为可选值,但不会对其后面的起作用。
不要过度使用可选链
我们应该只将?.
使用在一些东西可以不存在的地方。
例如,如果根据我们的代码逻辑,user
对象必须存在,但address
是可选的,那么我们应该这样写user.address?.street
,而不是这样user?.address?.street
。
那么,如果user
恰巧为 undefined,我们会看到一个编程错误并修复它。否则,如果我们滥用?.
,会导致代码中的错误在不应该被消除的地方消除了,这会导致调试更加困难。
?.
前的变量必须已声明
如果未声明变量user
,那么user?.anything
会触发一个错误:
// ReferenceError: user is not defined
user?.address;
?.
前的变量必须已声明(例如let/const/var user
或作为一个函数参数)。可选链仅适用于已声明的变量。
短路效应
如果 ?.
左边部分不存在,就会立即停止运算(“短路效应”)。
因此,如果在 ?.
的右侧有任何进一步的函数调用或操作,它们均不会执行。
其他变体 ?.() ?.[]
?.()
用于调用一个可能不存在的函数。
?.[]
允许从一个可能不存在的对象上安全地读取属性。
我们还可以将 ?.
跟 delete
一起使用:
delete user?.name; // 如果 user 存在,则删除 user.name
我们可以使用
?.
来安全地读取或删除,但不能写入
可选链?.
不能用在赋值语句的左侧。
let user = null;
user?.name = "John"; // Error,不起作用
// 因为它在计算的是:undefined = "John"
symbol 类型
根据规范,只有两种原始类型可以用作对象属性键:
- 字符串类型
- symbol 类型
否则,如果使用另一种类型,例如数字,它会被自动转换为字符串。所以obj[1]
与obj["1"]
相同,而obj[true]
与obj["true"]
相同。
到目前为止,我们一直只使用字符串。
symbol
“symbol” 值表示唯一的标识符。
// 描述为“2”的symbol
let id = Symbol("2");
symbol 保证是唯一的。即使我们创建了许多具有相同描述的 symbol,它们的值也是不同。描述只是一个标签,不影响任何东西。
例如,这里有两个描述相同的 symbol —— 它们不相等:
let id1 = Symbol("id");
let id2 = Symbol("id");
alert(id1 == id2); // false
symbol 不会被自动转换为字符串
JavaScript 中的大多数值都支持字符串的隐式转换。例如,我们可以alert
任何值,都可以生效。symbol 比较特殊,它不会被自动转换。
例如,这个alert
将会提示出错:
let id = Symbol("id");
alert(id); // 类型错误:无法将 symbol 值转换为字符串。
这是一种防止混乱的“语言保护”,因为字符串和 symbol 有本质上的不同,不应该意外地将它们转换成另一个。
如果我们真的想显示一个 symbol,我们需要在它上面调用.toString()
,或者获取symbol.description
属性,只显示描述(description)
let id = Symbol("id");
alert(id.toString()); // Symbol(id),现在它有效了
alert(id.description); // id
隐藏属性
symbol 允许我们创建对象的“隐藏”属性,代码的任何其他部分都不能意外访问或重写这些属性。
例如,如果我们使用的是属于第三方代码的 user
对象,我们想要给它们添加一些标识符。
我们可以给它们使用 symbol 键:
let user = { // 属于另一个代码
name: "John"
};
let id = Symbol("id");
user[id] = 1;
alert( user[id] ); // 我们可以使用 symbol 作为键来访问数据
使用 Symbol("id")
作为键,比起用字符串 "id"
来有什么好处呢?
由于 user
对象属于另一个代码库,所以向它们添加字段是不安全的,因为我们可能会影响代码库中的其他预定义行为。但 symbol 属性不会被意外访问到。第三方代码不会知道新定义的 symbol,因此将 symbol 添加到 user
对象是安全的。
另外,假设另一个脚本希望在 user
中有自己的标识符,以实现自己的目的。
那么,该脚本可以创建自己的 Symbol("id")
我们的标识符和它们的标识符之间不会有冲突,因为 symbol 总是不同的,即使它们有相同的名字。
……但如果我们处于同样的目的,使用字符串 "id"
而不是用 symbol,那么 就会 出现冲突
使用
let id = Symbol("id");
let user = {
name: "John",
[id]: 123 // 而不是 "id":123
};
symbol 在 for...in 中会被跳过
Object.keys(user) 也会忽略它们。这是一般“隐藏符号属性”原则的一部分。如果另一个脚本或库遍历我们的对象,它不会意外地访问到符号属性。
相反,Object.assign 会同时复制字符串和 symbol 属性
这里并不矛盾,就是这样设计的。这里的想法是当我们克隆或者合并一个 object 时,通常希望 所有 属性被复制(包括像 id
这样的 symbol)。
全局 symbol
通常所有的 symbol 都是不同的,即使它们有相同的名字。但有时我们想要名字相同的 symbol 具有相同的实体。例如,应用程序的不同部分想要访问的 symbol "id"
指的是完全相同的属性。
为了实现这一点,这里有一个 全局 symbol 注册表。我们可以在其中创建 symbol 并在稍后访问它们,它可以确保每次访问相同名字的 symbol 时,返回的都是相同的 symbol。
要从注册表中读取(不存在则创建)symbol,请使用 Symbol.for(key)
。
该调用会检查全局注册表,如果有一个描述为 key
的 symbol,则返回该 symbol,否则将创建一个新 symbol(Symbol(key)
),并通过给定的 key
将其存储在注册表中。
// 从全局注册表中读取
let id = Symbol.for("id"); // 如果该 symbol 不存在,则创建它
// 再次读取(可能是在代码中的另一个位置)
let idAgain = Symbol.for("id");
// 相同的 symbol
alert( id === idAgain ); // true
注册表内的 symbol 被称为 全局 symbol。如果我们想要一个应用程序范围内的 symbol,可以在代码中随处访问 —— 这就是它们的用途。
Symbol.keyFor
对于全局 symbol,Symbol.for(key)
按名字返回一个 symbol。相反,通过全局 symbol 返回一个名字,我们可以使用 Symbol.keyFor(sym)
:
// 通过 name 获取 symbol
let sym = Symbol.for("name");
let sym2 = Symbol.for("id");
// 通过 symbol 获取 name
alert( Symbol.keyFor(sym) ); // name
alert( Symbol.keyFor(sym2) ); // id
Symbol.keyFor
内部使用全局 symbol 注册表来查找 symbol 的键。所以它不适用于非全局 symbol。如果 symbol 不是全局的,它将无法找到它并返回 undefined
。
但是,所有 symbol 都具有 description
属性。
系统 symbol
JavaScript 内部有很多“系统” symbol,我们可以使用它们来微调对象的各个方面。
它们都被列在了 众所周知的 symbol 表的规范中:
Symbol.hasInstance
Symbol.isConcatSpreadable
Symbol.iterator
Symbol.toPrimitive
- ……等等。
例如,Symbol.toPrimitive
允许我们将对象描述为原始值转换。我们很快就会看到它的使用。
当我们研究相应的语言特征时,我们对其他的 symbol 也会慢慢熟悉起来。
对象 - 原始值转换
不像c++等,JavaScript 不允许自定义运算符对对象的处理方式。这是一个重要的限制:因为 obj1 + obj2
(或者其他数学运算)的结果不能是另一个对象!
hint
obj -> string,如alert(obj)
obj -> number,如做运算和比较时
obj -> ?,如+,不确定该转换为字符串还是数字转换为default
像 <
和 >
这样的小于/大于比较运算符,也可以同时用于字符串和数字。不过,它们使用 “number” hint,而不是 “default”。这是历史原因。
为了进行转换,JavaScript 尝试查找并调用三个对象方法:
- 调用
obj[Symbol.toPrimitive](hint)
—— 带有 symbol 键Symbol.toPrimitive
(系统 symbol)的方法,如果这个方法存在的话, - 否则,如果 hint 是
"string"
—— 尝试调用obj.toString()
或obj.valueOf()
,无论哪个存在。 - 否则,如果 hint 是
"number"
或"default"
—— 尝试调用obj.valueOf()
或obj.toString()
,无论哪个存在。
Symbol.toPrimitive
有一个名为 Symbol.toPrimitive
的内建 symbol,它被用来给转换方法命名,像这样:
obj[Symbol.toPrimitive] = function(hint) {
// 这里是将此对象转换为原始值的代码
// 它必须返回一个原始值
// hint = "string"、"number" 或 "default" 中的一个
}
如果 Symbol.toPrimitive
方法存在,则它会被用于所有 hint,无需更多其他方法。
let user = {
name: "John",
money: 1000,
[Symbol.toPrimitive](hint) {
alert(`hint: ${hint}`);
return hint == "string" ? `{name: "${this.name}"}` : this.money;
}
};
// 转换演示:
alert(user); // hint: string -> {name: "John"}
alert(+user); // hint: number -> 1000
alert(user + 500); // hint: default -> 1500
根据转换的不同,user
变成一个自描述字符串或者一个金额。user[Symbol.toPrimitive]
方法处理了所有的转换情况。
toString/valueOf
如果没有 Symbol.toPrimitive
,那么 JavaScript 将尝试寻找 toString
和 valueOf
方法:
- 对于
"string"
hint:调用toString
方法,如果它不存在,则调用valueOf
方法(因此,对于字符串转换,优先调用toString
)。 - 对于其他 hint:调用
valueOf
方法,如果它不存在,则调用toString
方法(因此,对于数学运算,优先调用valueOf
方法)。
toString
和valueOf
方法很早己有了。它们不是 symbol(那时候还没有 symbol 这个概念),而是“常规的”字符串命名的方法。它们提供了一种可选的“老派”的实现转换的方法。
这些方法必须返回一个原始值。如果toString
或valueOf
返回了一个对象,那么返回值会被忽略(和这里没有方法的时候相同)。
默认情况下,普通对象具有toString
和valueOf
方法: toString
方法返回一个字符串"[object Object]"
。valueOf
方法返回对象自身。
let user = {name: "John"};
alert(user); // [object Object]
alert(user.valueOf() === user); // true
所以,如果我们尝试将一个对象当做字符串来使用,例如在 alert
中,那么在默认情况下我们会看到 [object Object]
。
这里提到的默认的 valueOf
只是为了完整起见,以避免混淆。正如你看到的,它返回对象本身,因此被忽略。别问我为什么,这是历史原因。所以我们可以假设它根本就不存在。
让我们实现一下这些方法来自定义转换。
例如,这里的 user
执行和前面提到的那个 user
一样的操作,使用 toString
和 valueOf
的组合(而不是 Symbol.toPrimitive
):
let user = {
name: "John",
money: 1000,
// 对于 hint="string"
toString() {
return `{name: "${this.name}"}`;
},
// 对于 hint="number" 或 "default"
valueOf() {
return this.money;
}
};
alert(user); // toString -> {name: "John"}
alert(+user); // valueOf -> 1000
alert(user + 500); // valueOf -> 1500
执行的动作和前面使用 Symbol.toPrimitive
的那个例子相同。
通常我们希望有一个“全能”的地方来处理所有原始转换。在这种情况下,我们可以只实现 toString
let user = {
name: "John",
toString() {
return this.name;
}
};
alert(user); // toString -> John
alert(user + 500); // toString -> John500
如果没有 Symbol.toPrimitive
和 valueOf
,toString
将处理所有原始转换。
转换可以返回任何原始类型
关于所有原始转换方法,有一个重要的点需要知道,就是它们不一定会返回 “hint” 的原始值。
没有限制 toString()
是否返回字符串,或 Symbol.toPrimitive
方法是否为 "number"
hint 返回数字。
唯一强制性的事情是:这些方法必须返回一个原始值,而不是对象。
历史原因
由于历史原因,如果toString
或valueOf
返回一个对象,则不会出现 error,但是这种值会被忽略(就像这种方法根本不存在)。这是因为在 JavaScript 语言发展初期,没有很好的 “error” 的概念。
相反,Symbol.toPrimitive
更严格,它 必须 返回一个原始值,否则就会出现 error。
进一步的转换
如果我们将对象作为参数传递,则会出现两个运算阶段:
- 对象被转换为原始值(通过前面我们描述的规则)。
- 如果还需要进一步计算,则生成的原始值会被进一步转换。
数据类型
原始类型的方法
原始类型和对象之间的关键区别。
一个原始值:
- 是原始类型中的一种值。
- 在 JavaScript 中有 7 种原始类型:
string
,number
,bigint
,boolean
,symbol
,null
和undefined
。
一个对象: - 能够存储多个值作为属性。
- 可以使用大括号
{}
创建对象,例如:{name: "John", age: 30}
。JavaScript 中还有其他种类的对象,例如函数就是对象。
许多内建对象已经存在,例如那些处理日期、错误、HTML 元素等的内建对象。它们具有不同的属性和方法。
但是,这些特性(feature)都是有成本的!
对象比原始类型“更重”。它们需要额外的资源来支持运作。
当作对象的原始类型
以下是 JavaScript 创建者面临的悖论:
- 人们可能想对诸如字符串或数字之类的原始类型执行很多操作。最好使用方法来访问它们。
- 原始类型必须尽可能的简单轻量。
而解决方案看起来多少有点尴尬,如下:
- 原始类型仍然是原始的。与预期相同,提供单个值
- JavaScript 允许访问字符串,数字,布尔值和 symbol 的方法和属性。
- 为了使它们起作用,创建了提供额外功能的特殊“对象包装器”,使用后即被销毁。
“对象包装器”对于每种原始类型都是不同的,它们被称为String
、Number
、Boolean
、Symbol
和BigInt
。
例如,字符串方法str.toUpperCase()返回一个大写化处理的字符串。
let str = "Hello";
alert( str.toUpperCase() ); // HELLO
很简单,对吧?以下是 str.toUpperCase()
中实际发生的情况:
- 字符串
str
是一个原始值。因此,在访问其属性时,会创建一个包含字符串字面值的特殊对象,并且具有可用的方法,例如toUpperCase()
。 - 该方法运行并返回一个新的字符串(由
alert
显示)。 - 特殊对象被销毁,只留下原始值
str
。
所以原始类型可以提供方法,但它们依然是轻量级的。
JavaScript 引擎高度优化了这个过程。它甚至可能跳过创建额外的对象。但是它仍然必须遵守规范,并且表现得好像它创建了一样。
数字有其自己的方法,例如,toFixed(n)将数字舍入到给定的精度:
let n = 1.23456;
alert( n.toFixed(2) ); // 1.23
构造器
String/Number/Boolean
仅供内部使用
像 Java 这样的一些语言允许我们使用new Number(1)
或new Boolean(false)
等语法,明确地为原始类型创建“对象包装器”。
在 JavaScript 中,由于历史原因,这也是可以的,但极其不推荐。因为这样会出问题。
alert( typeof 0 ); // "number"
alert( typeof new Number(0) ); // "object"!
//对象在 `if` 中始终为真,所以此处的 alert 将显示:
let zero = new Number(0);
if (zero) { // zero 为 true,因为它是一个对象
alert( "zero is truthy?!?" );
}
另一方面,调用不带
new
(关键字)的String/Number/Boolean
函数是可以的且有效的。它们将一个值转换为相应的类型:转成字符串、数字或布尔值(原始类型)。
null/undefined 没有任何方法
特殊的原始类型null
和undefined
是例外。它们没有对应的“对象包装器”,也没有提供任何方法。从某种意义上说,它们是“最原始的”。
尝试访问这种值的属性会导致错误。
数字类型
编写数字的更多方法
let billion = 1000000000;
// 使用_做分隔符
let billion = 1_000_000_000;
// 语法糖,仅增强可读性,不影响执行
// 1x10^9
let billion = 1e9;
// 十六进制
let ox = 0xff // 255
// 八进制(少)
let oo = 0o377 // 255
// 二进制(少)
let ob = 0b1111_1111 // 255
toString
// toString(base) 返回指定 base(2-36) 进制中的字符串表示
let num = 255;
num.toString(16) // ff
num.toString(2) // 1111_1111
// 常见用例
// base=16 用于十六进制颜色,字符编码等,数字可以是 0-9 或 A-F。
// base=2 主要用于调试按位操作,数字可以是 0 或 1。
// base=36 是最大进制,数字可以是 0-9 或 A-Z。所有拉丁字母都被用于了表示数字。对于36进制来说,一个有趣且有用的例子是,当我们需要将一个较长的数字标识符转换成较短的时候,例如做一个短的 URL。可以简单地使用基数为 `36` 的数字系统表示:
使用两个点来调用一个方法
请注意123456..toString(36)
中的两个点不是打错了。如果我们想直接在一个数字上调用一个方法,比如上面例子中的toString
,那么我们需要在它后面放置两个点..
。
如果我们放置一个点:123456.toString(36)
,那么就会出现一个 error,因为 JavaScript 语法隐含了第一个点之后的部分为小数部分。如果我们再放一个点,那么 JavaScript 就知道小数部分为空,现在使用该方法。
也可以写成(123456).toString(36)
。
舍入
// 向下取整
Math.floor() // 3.1 -> 3 | -1.1 -> -2
// 向上取整
Math.ceil() // 3.1 -> 4 | -1.1 -> -1
// 四舍五入
Math.round() // 3.1 -> 3 | 3.5 -> 4
// 保留整数 (IE 不支持)
Math.trunc() // 3.1 -> 3 | -1.1 -> -1
如果我们想将数字舍入到小数点后 n
位,该怎么办?
例如,我们有 1.2345
,并且想把它舍入到小数点后两位,仅得到 1.23
。
有两种方式可以实现这个需求:
- 乘除法,要将数字舍入到小数点后两位,我们可以将数字乘以
100
,调用舍入函数,然后再将其除回。 - 函数 toFixed(n) 将数字舍入到小数点后
n
位,并以字符串形式返回结果。(以Math.round舍入)
注意
toFixed
的结果是一个字符串。如果小数部分比所需要的(n)短,则在结尾添加零。
不精确的计算
在内部,数字是以 64 位格式 IEEE-754 表示的,所以正好有 64 位可以存储一个数字:其中 52 位被用于存储这些数字,其中 11 位用于存储小数点的位置,而 1 位用于符号。
如果一个数字真的很大,则可能会溢出 64 位存储,变成一个特殊的数值 Infinity
alert( 0.1 + 0.2 == 0.3 ); // false
alert( 0.1 + 0.2 ); // 0.30000000000000004
一个数字以其二进制的形式存储在内存中,一个 1 和 0 的序列。但是在十进制数字系统中看起来很简单的 0.1
,0.2
这样的小数,实际上在二进制形式中是无限循环小数。
不仅仅是 JavaScript
许多其他编程语言也存在同样的问题。
PHP,Java,C,Perl,Ruby 给出的也是完全相同的结果,因为它们基于的是相同的数字格式。
最可靠的方法是借助方法toFixed(n)对结果进行舍入
可以将数字临时乘以 100(或更大的数字),将其转换为整数,进行数学运算,然后再除回。当我们使用整数进行数学运算时,误差会有所减少,乘/除法可以减少误差,但不能完全消除误差。
有趣的事儿
尝试运行下面这段代码:
// Hello!我是一个会自我增加的数字!
alert( 9999999999999999 ); // 显示 10000000000000000
出现了同样的问题:精度损失。有 64 位来表示该数字,其中 52 位可用于存储数字,但这还不够。所以最不重要的数字就消失了。
JavaScript 不会在此类事件中触发 error。它会尽最大努力使数字符合所需的格式,但不幸的是,这种格式不够大到满足需求。
两个零
数字内部表示的另一个有趣结果是存在两个零:0
和-0
。
这是因为在存储时,使用一位来存储符号,因此对于包括零在内的任何数字,可以设置这一位或者不设置。
在大多数情况下,这种区别并不明显,因为运算符将它们视为相同的值。
isFinite 和 isNaN
Infinity
(和-Infinity
)是一个特殊的数值,比任何数值都大(小)。NaN
代表一个 error。
它们属于number
类型,但不是“普通”数字,因此,这里有用于检查它们的特殊函数:isNaN(value)
将其参数转换为数字(number("str") == NaN),然后测试它是否为NaN
但是我们需要这个函数吗?我们不能只使用=== NaN
比较吗?很不幸,这不行。值 “NaN” 是独一无二的,它不等于任何东西,包括它自身isFinite(value)
将其参数转换为数字,如果是常规数字而不是NaN/Infinity/-Infinity
,则返回true
有时isFinite
被用于验证字符串值是否为常规数字
请注意,在所有数字函数中,包括isFinite
,空字符串或仅有空格的字符串均被视为0
。
与
Object.is
进行比较
有一个特殊的内建方法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)。
parseInt 和 parseFloat
使用加号 +
或 Number()
的数字转换是严格的。如果一个值不完全是一个数字,就会失败
alert( +"100px" ); // NaN
唯一的例外是字符串开头或结尾的空格,因为它们会被忽略。
但在现实生活中,我们经常会有带有单位的值,例如 CSS 中的 "100px"
或 "12pt"
。并且,在很多国家,货币符号是紧随金额之后的,所以我们有 "19€"
,并希望从中提取出一个数值。
这就是 parseInt
和 parseFloat
的作用。
它们可以从字符串中“读取”数字,直到无法读取为止。如果发生 error,则返回收集到的数字。函数 parseInt
返回一个整数,而 parseFloat
返回一个浮点数。
alert( parseInt('100px') ); // 100
alert( parseFloat('12.5em') ); // 12.5
alert( parseInt('12.3') ); // 12,只有整数部分被返回了
alert( parseFloat('12.3.4') ); // 12.3,在第二个点出停止了读取
某些情况下,parseInt/parseFloat
会返回 NaN
。当没有数字可读时会发生这种情况:
alert( parseInt('a123') ); // NaN,第一个符号停止了读取
parseInt(str, radix) 的第二个参数
parseInt()
函数具有可选的第二个参数。它指定了数字系统的基数,因此parseInt
还可以解析十六进制数字、二进制数字等的字符串:
alert( parseInt('0xff', 16) ); // 255
alert( parseInt('ff', 16) ); // 255,没有 0x 仍然有效
alert( parseInt('2n9c', 36) ); // 123456
其他数学函数
JavaScript 有一个内建的 Math 对象,它包含了一个小型的数学函数和常量库。
// 0-1随机数,不包括1
Math.random();
// 从任意数量的参数中返回最大/最小值
Math.max(a, b, c...);
Math.min(a, b, c...);
// power次幂
Math.pow(2, 10);
Math
对象中还有更多函数和常量,包括三角函数,你可以在 Math 对象文档 中找到这些内容。
在处理小数时避免相等性检查。
字符串
在 JavaScript 中,文本数据被以字符串形式存储,单个字符没有单独的类型。
字符串的内部格式始终是UTF-16,它不依赖于页面编码。
引号
单引号和双引号基本相同。但是,反引号允许我们通过 ${…}
将任何表达式嵌入到字符串中,使用反引号的另一个优点是它们允许字符串跨行。
反引号还允许我们在第一个反引号之前指定一个“模版函数”。语法是:func`string`
。函数 func
被自动调用,接收字符串和嵌入式表达式,并处理它们。你可以在 docs 中阅读更多关于它们的信息。这叫做 “tagged templates”。此功能可以更轻松地将字符串包装到自定义模版或其他函数中,但这很少使用。
特殊字符
字符 | 描述 |
---|---|
\n |
换行 |
\r |
在 Windows 文本文件中,两个字符 \r\n 的组合代表一个换行。而在非 Windows 操作系统上,它就是 \n 。这是历史原因造成的,大多数的 Windows 软件也理解 \n 。 |
\' , \" |
引号 |
\\ |
反斜线 |
\t |
制表符 |
\b , \f , \v |
退格,换页,垂直标签 —— 为了兼容性,现在已经不使用了。 |
\xXX |
具有给定十六进制 Unicode XX 的 Unicode 字符,例如:'\x7A' 和 'z' 相同。 |
\uXXXX |
以 UTF-16 编码的十六进制代码 XXXX 的 Unicode 字符,例如 \u00A9 —— 是版权符号 © 的 Unicode。它必须正好是 4 个十六进制数字。 |
\u{X…XXXXXX} (1 到 6 个十六进制字符) |
具有给定 UTF-32 编码的 Unicode 符号。一些罕见的字符用两个 Unicode 符号编码,占用 4 个字节。这样我们就可以插入长代码了。 |
alert( "\u00A9" ); // ©
alert( "\u{20331}" ); // 佫,罕见的中国象形文字(长 Unicode)
alert( "\u{1F60D}" ); // 😍,笑脸符号(另一个长 Unicode)
字符串长度
string.length '\n'也属于一个字符
length
是一个属性
掌握其他编程语言的人,有时会错误地调用str.length()
而不是str.length
。这是行不通的。
请注意str.length
是一个数字属性,而不是函数。后面不需要添加括号。
获取字符
要获取在 pos
位置的一个字符,可以使用方括号 [pos]
或者调用 str.charAt(pos) 方法。第一个字符从零位置开始
方括号是获取字符的一种现代化方法,而 charAt
是历史原因才存在的。
它们之间的唯一区别是,如果没有找到字符,[]
返回 undefined
,而 charAt
返回一个空字符串
我们也可以使用 for..of
遍历字符
字符串不可变
在 JavaScript 中,字符串不可更改。改变字符是不可能的。
let str = 'Hi';
str[0] = 'h'; // error
alert( str[0] ); // 无法运行
通常的解决方法是创建一个新的字符串,并将其分配给 str 而不是以前的字符串。
改变大小写
alert( 'Interface'.toUpperCase() ); // INTERFACE
alert( 'Interface'.toLowerCase() ); // interface
alert( 'Interface'[0].toLowerCase() ); // 'i'
查找子字符串
string.indexOf(substr,pos)
它从给定位置 pos
开始,在 str
中查找 substr
,如果没有找到,则返回 -1
,否则返回匹配成功的位置。
可选的第二个参数允许我们从一个给定的位置开始检索。
如果我们对所有存在位置都感兴趣,可以在一个循环中使用 indexOf
。每一次新的调用都发生在上一匹配位置之后:
let str = "As sly as a fox, as strong as an ox";
let target = "as";
let pos = -1;
while ((pos = str.indexOf(target, pos + 1)) != -1) {
alert( pos );
}
str.lastIndexOf(substr, pos)
还有一个类似的方法str.lastIndexOf(substr, position),它从字符串的末尾开始搜索到开头。
它会以相反的顺序列出这些事件。
在 if
测试中 indexOf
有一点不方便。我们不能像这样把它放在 if
中,应该与-1检查。
let str = "Widget with id";
if (str.indexOf("Widget")) {
alert("We found it"); // 不工作!
}
按位not技巧
这里使用的一个老技巧是 bitwise NOT ~
运算符。它将数字转换为 32-bit 整数(如果存在小数部分,则删除小数部分),然后对其二进制表示形式中的所有位均取反。
实际上,这意味着一件很简单的事儿:对于 32-bit 整数,~n
等于 -(n+1)
。
alert( ~2 ); // -3,和 -(2+1) 相同
alert( ~1 ); // -2,和 -(1+1) 相同
alert( ~0 ); // -1,和 -(0+1) 相同
alert( ~-1 ); // 0,和 -(-1+1) 相同
正如我们看到这样,只有当 n == -1
时,~n
才为零(适用于任何 32-bit 带符号的整数 n
)。
因此,仅当 indexOf
的结果不是 -1
时,检查 if ( ~str.indexOf("...") )
才为真。换句话说,当有匹配时。
人们用它来简写 indexOf
检查:
let str = "Widget";
if (~str.indexOf("Widget")) {
alert( 'Found it!' ); // 正常运行
}
通常不建议以非显而易见的方式使用语言特性,但这种特殊技巧在旧代码中仍被广泛使用,所以我们应该理解它。
只要记住:if (~str.indexOf(...))
读作 “if found”。
确切地说,由于 ~
运算符将大数字截断为 32 位,因此存在给出 0
的其他数字,最小的数字是 ~4294967295=0
。这使得这种检查只有在字符串没有那么长的情况下才是正确的。
现在我们只会在旧的代码中看到这个技巧,因为现代 JavaScript 提供了 .includes
方法(见下文)。
str.includes(substr,pos), str.startsWith(substr), str.endsWith(substr)
更现代的方法 str.includes(substr, pos) 根据 str
中是否包含 substr
来返回 true/false
。
如果我们需要检测匹配,但不需要它的位置,那么这是正确的选择
str.includes
的第二个可选参数是开始搜索的起始位置
方法 str.startsWith和str.endsWith的功能与其名称所表示的意思相同
获取子字符串
JavaScript 中有三种获取字符串的方法:substring
、substr
和 slice
。
str.slice(start [, end])
返回字符串从 start
到(但不包括)end
的部分。
let str = "stringify";
alert( str.slice(0, 5) ); // 'strin',从 0 到 5 的子字符串(不包括 5)
alert( str.slice(0, 1) ); // 's',从 0 到 1,但不包括 1,所以只有在 0 处的字符
//如果没有第二个参数
let str = "stringify";
alert( str.slice(2) ); // 从第二个位置直到结束
//`start/end` 也有可能是负值。它们的意思是起始位置从字符串结尾计算
let str = "stringify";
// 从右边的第四个位置开始,在右边的第一个位置结束
alert( str.slice(-4, -1) ); // 'gif'
str.substring(start [, end])
返回字符串从 start
到(但不包括)end
的部分。
这与 slice
几乎相同,但它允许 start
大于 end
。
let str = "stringify";
// 这些对于 substring 是相同的
alert( str.substring(2, 6) ); // "ring"
alert( str.substring(6, 2) ); // "ring"
// ……但对 slice 是不同的:
alert( str.slice(2, 6) ); // "ring"(一样)
alert( str.slice(6, 2) ); // ""(空字符串)
不支持负参数(不像 slice),它们被视为 0
。
str.substr(start [, length])
返回字符串从 start
开始的给定 length
的部分。
与以前的方法相比,这个允许我们指定 length
而不是结束位置
let str = "stringify";
alert( str.substr(2, 4) ); // 'ring',从位置 2 开始,获取 4 个字符
// 第一个参数可为负数
let str = "stringify";
alert( str.substr(-4, 2) ); // 'gi',从第 4 位获取 2 个字符
使用哪一个?
它们都可用于获取子字符串。正式一点来讲,substr
有一个小缺点:它不是在 JavaScript 核心规范中描述的,而是在附录 B 中。附录 B 的内容主要是描述因历史原因而遗留下来的仅浏览器特性。因此,理论上非浏览器环境可能无法支持substr
,但实际上它在别的地方也都能用。
相较于其他两个变体,slice
稍微灵活一些,它允许以负值作为参数并且写法更简短。因此仅仅记住这三种方法中的slice
就足够了。
比较字符串
正如我们从 值的比较 一章中了解到的,字符串按字母顺序逐字比较。
不过,也有一些奇怪的地方。
- 小写字母总是大于大写字母
- 带变音符号的字母存在“乱序”的情况:
alert( 'Österreich' > 'Zealand' ); // true
如果我们对这些国家名进行排序,可能会导致奇怪的结果。通常,人们会期望 Zealand
在名单中的 Österreich
之后出现。
所有的字符串都使用 UTF-16 编码。即:每个字符都有对应的数字代码。有特殊的方法可以获取代码表示的字符,以及字符对应的代码。
str.codePointAt(pos)
返回在 pos
位置的字符代码
// 不同的字母有不同的代码
alert( "z".codePointAt(0) ); // 122
alert( "Z".codePointAt(0) ); // 90
String.fromCodePoint(code)
通过数字 code
创建字符
alert( String.fromCodePoint(90) ); // Z
// 我们还可以用 \u 后跟十六进制代码,通过这些代码添加 Unicode 字符
// 在十六进制系统中 90 为 5a
alert( '\u005a' ); // Z
// 现在我们看一下代码为 `65..220` 的字符(拉丁字母和一些额外的字符)
let str = '';
for (let i = 65; i <= 220; i++) {
str += String.fromCodePoint(i);
}
alert( str );
// ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~
// ¡¢£¤¥¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜ
看到没?先是大写字符,然后是一些特殊字符,然后是小写字符,而 Ö
几乎是最后输出。
现在很明显为什么 a > Z
。
字符通过数字代码进行比较。越大的代码意味着字符越大。a
(97)的代码大于 Z
(90)的代码。
- 所有小写字母追随在大写字母之后,因为它们的代码更大。
- 一些像
Ö
的字母与主要字母表不同。这里,它的代码比任何从a
到z
的代码都要大。
正确的比较
执行字符串比较的“正确”算法比看起来更复杂,因为不同语言的字母都不相同。
因此浏览器需要知道要比较的语言。
幸运的是,所有现代浏览器(IE10- 需要额外的库 Intl.JS) 都支持国际化标准 ECMA-402。
它提供了一种特殊的方法来比较不同语言的字符串,遵循它们的规则。
调用 str.localeCompare(str2) 会根据语言规则返回一个整数,这个整数能指示字符串 str
在排序顺序中排在字符串 str2
前面、后面、还是相同:
- 如果
str
排在str2
前面,则返回负数。 - 如果
str
排在str2
后面,则返回正数。 - 如果它们在相同位置,则返回
0
。
alert( 'Österreich'.localeCompare('Zealand') ); // -1
这个方法实际上在 文档 中指定了两个额外的参数,这两个参数允许它指定语言(默认语言从环境中获取,字符顺序视语言不同而不同)并设置诸如区分大小写,或应该将 "a" 和 "á" 作相同处理等附加的规则。
内部,unicode
进阶内容
这部分会深入字符串内部。如果你计划处理 emoji、罕见的数学或象形文字或其他罕见的符号,这些知识会对你有用。
代理对
所有常用的字符都是一个 2 字节的代码。大多数欧洲语言,数字甚至大多数象形文字中的字母都有 2 字节的表示形式。
但 2 字节只允许 65536 个组合,这对于表示每个可能的符号是不够的。所以稀有的符号被称为“代理对”的一对 2 字节的符号编码。
这些符号的长度是 2
alert( '𝒳'.length ); // 2,大写数学符号 X
alert( '😂'.length ); // 2,笑哭表情
alert( '𩷶'.length ); // 2,罕见的中国象形文字
注意,代理对在 JavaScript 被创建时并不存在,因此无法被编程语言正确处理!
我们实际上在上面的每个字符串中都有一个符号,但 length
显示长度为 2
。
String.fromCodePoint
和 str.codePointAt
是几种处理代理对的少数方法。它们最近才出现在编程语言中。在它们之前,只有 String.fromCharCode 和 str.charCodeAt。这些方法实际上与 fromCodePoint/codePointAt
相同,但是不适用于代理对。
获取符号可能会非常麻烦,因为代理对被认为是两个字符
alert( '𝒳'[0] ); // 奇怪的符号……
alert( '𝒳'[1] ); // ……代理对的一块
请注意,代理对的各部分没有任何意义。因此,上述示例中的 alert 显示的实际上是垃圾信息。
技术角度来说,代理对也是可以通过它们的代码检测到的:如果一个字符的代码在 0xd800..0xdbff
范围内,那么它是代理对的第一部分。下一个字符(第二部分)必须在 0xdc00..0xdfff
范围中。这些范围是按照标准专门为代理对保留的。
// charCodeAt 不理解代理对,所以它给出了代理对的代码
alert( '𝒳'.charCodeAt(0).toString(16) ); // d835,在 0xd800 和 0xdbff 之间
alert( '𝒳'.charCodeAt(1).toString(16) ); // dcb3, 在 0xdc00 和 0xdfff 之间
本章节后面的 Iterable object(可迭代对象) 章节中,你可以找到更多处理代理对的方法。可能有专门的库,这里没有什么足够好的建议了。
变音符号与规范化
在许多语言中,都有一些由基本字符组成的符号,在其上方/下方有一个标记。
例如,字母 a
可以是 àáâäãåā
的基本字符。最常见的“复合”字符在 UTF-16 表中都有自己的代码。但不是全部,因为可能的组合太多。
为了支持任意组合,UTF-16 允许我们使用多个 Unicode 字符:基本字符紧跟“装饰”它的一个或多个“标记”字符。
例如,如果我们 S
后跟有特殊的 “dot above” 字符(代码 \u0307
),则显示 Ṡ。
alert( 'S\u0307' ); // Ṡ
如果我们需要在字母上方(或下方)添加额外的标记 —— 没问题,只需要添加必要的标记字符即可。
例如,如果我们追加一个字符 “dot below”(代码 \u0323
),那么我们将得到“S 上面和下面都有点”的字符:Ṩ
。
alert( 'S\u0307\u0323' ); // Ṩ
这在提供良好灵活性的同时,也存在一个有趣的问题:两个视觉上看起来相同的字符,可以用不同的 Unicode 组合表示。
let s1 = 'S\u0307\u0323'; // Ṩ,S + 上点 + 下点
let s2 = 'S\u0323\u0307'; // Ṩ,S + 下点 + 上点
alert( `s1: ${s1}, s2: ${s2}` );
alert( s1 == s2 ); // false,尽管字符看起来相同(?!)
为了解决这个问题,有一个 “Unicode 规范化”算法,它将每个字符串都转化成单个“通用”格式。
它由 str.normalize() 实现。
alert( "S\u0307\u0323".normalize() == "S\u0323\u0307".normalize() ); // true
有趣的是,在实际情况下,normalize()
实际上将一个由 3 个字符组成的序列合并为一个:\u1e68
(S 有两个点)。
alert( "S\u0307\u0323".normalize().length ); // 1
alert( "S\u0307\u0323".normalize() == "\u1e68" ); // true
事实上,情况并非总是如此,因为符号 Ṩ
是“常用”的,所以 UTF-16 创建者把它包含在主表中并给它了对应的代码。
如果你想了解更多关于规范化规则和变体的信息 —— 它们在 Unicode 标准附录中有详细描述:Unicode 规范化形式,但对于大多数实际目的来说,本文的内容就已经足够了。
数组
对象允许存储键值集合,这很好。
但很多时候我们发现还需要 有序集合,里面的元素都是按顺序排列的。
这里使用对象就不是很方便了,因为对象不能提供能够管理元素顺序的方法。我们不能在已有的元素“之间”插入一个新的属性。这种场景下对象就不太适用了。
这时一个特殊的数据结构数组(Array
)就派上用场了,它能存储有序的集合。
声明
let arr = new Array();
let arr = [];
let arr = ["a", "b", "c"];
arr[0]; // a
arr.length; // 3
arr[3] = "d";
arr.length; // 4
//可以存储任意类型
// 混合值
let arr = [
'Apple',
{ name: 'John' },
true,
function() { alert('hello'); } , //可以以逗号结尾
];
// 获取索引为 1 的对象然后显示它的 name
alert( arr[1].name ); // John
// 获取索引为 3 的函数并执行
arr[3](); // hello
at 获取最后一个元素
假设我们想要数组的最后一个元素。
一些编程语言允许我们使用负数索引来实现这一点,例如 fruits[-1]
。
但在 JavaScript 中这行不通。结果将是 undefined
,因为方括号中的索引是被按照其字面意思处理的。
我们可以显式地计算最后一个元素的索引,然后访问它:fruits[fruits.length - 1]
。
有点麻烦,不是吗?我们需要写两次变量名。
幸运的是,这里有一个更简短的语法 fruits.at(-1)
换句话说,arr.at(i)
:
- 如果
i >= 0
,则与arr[i]
完全相同。 - 对于
i
为负数的情况,它则从数组的尾部向前数。
pop/push,shift/unshift
队列(queue)是最常见的使用数组的方法之一。在计算机科学中,这表示支持两个操作的一个有序元素的集合:
push
在末端添加一个元素.shift
取出队列首端的一个元素,整个队列往前移,这样原先排第二的元素现在排在了第一。
栈(stack)它支持两种操作:push
在末端添加一个元素.pop
从末端取出一个元素.
对于栈来说,最后放进去的内容是最先接收的,也叫做 LIFO(Last-In-First-Out),即后进先出法则。而与队列相对应的叫做 FIFO(First-In-First-Out),即先进先出。
JavaScript 中的数组既可以用作队列,也可以用作栈。它们允许你从首端/末端来添加/删除元素。
这在计算机科学中,允许这样的操作的数据结构被称为双端队列(deque)。
末端方法
pop
取出并返回数组的最后一个元素,与某些语言pop只删除元素不同,pop会返回最后一个元素,然后取出
fruits.pop()
和 fruits.at(-1)
都返回数组的最后一个元素,但 fruits.pop()
同时也删除了数组的最后一个元素,进而修改了原数组。
push
在数组末端添加元素,可以一次添加多个元素
调用 fruits.push(...)
与 fruits[fruits.length] = ...
是一样的。
首端方法
shift
取出数组的第一个元素并返回它
unshift
在数组的首端添加元素,可以一次添加多个元素
内部
数组是一种特殊的对象。使用方括号来访问属性 arr[0]
实际上是来自于对象的语法。它其实与 obj[key]
相同,其中 arr
是对象,而数字用作键(key)。
它们扩展了对象,提供了特殊的方法来处理有序的数据集合以及 length
属性。但从本质上讲,它仍然是一个对象。
记住,在 JavaScript 中只有 8 种基本的数据类型(详见 数据类型 一章)。数组是一个对象,因此其行为也像一个对象。
例如,它是通过引用来复制的
但是数组真正特殊的是它们的内部实现。JavaScript 引擎尝试把这些元素一个接一个地存储在连续的内存区域,就像本章的插图显示的一样,而且还有一些其它的优化,以使数组运行得非常快。
但是,如果我们不像“有序集合”那样使用数组,而是像常规对象那样使用数组,这些就都不生效了。
例如,从技术上讲,我们可以这样做
let fruits = []; // 创建一个数组
fruits[99999] = 5; // 分配索引远大于数组长度的属性
fruits.age = 25; // 创建一个具有任意名称的属性
这是可以的,因为数组是基于对象的。我们可以给它们添加任何属性。
但是 Javascript 引擎会发现,我们在像使用常规对象一样使用数组,那么针对数组的优化就不再适用了,然后对应的优化就会被关闭,这些优化所带来的优势也就荡然无存了。
数组误用的几种方式:
- 添加一个非数字的属性,比如
arr.test = 5
。 - 制造空洞,比如:添加
arr[0]
,然后添加arr[1000]
(它们中间什么都没有)。 - 以倒序填充数组,比如
arr[1000]
,arr[999]
等等。
请将数组视为作用于 有序数据 的特殊结构。它们为此提供了特殊的方法。数组在 JavaScript 引擎内部是经过特殊调整的,使得更好地作用于连续的有序数据,所以请以正确的方式使用数组。如果你需要任意键值,那很有可能实际上你需要的是常规对象{}
。
性能
push/pop
方法运行的比较快,而 shift/unshift
比较慢。
shift/unshift 只获取并移除索引 0
对应的元素是不够的。其它元素也需要被重新编号。
shift
操作必须做三件事:
- 移除索引为
0
的元素。 - 把所有的元素向左移动,把索引
1
改成0
,2
改成1
以此类推,对其重新编号。 - 更新
length
属性。
数组里的元素越多,移动它们就要花越多的时间,也就意味着越多的内存操作。
unshift
也是一样:为了在数组的首端添加元素,我们首先需要将现有的元素向右移动,增加它们的索引值。
那push/pop
是什么样的呢?它们不需要移动任何东西。如果从末端移除一个元素,pop
方法只需要清理索引值并缩短length
就可以了。
pop
方法不需要移动任何东西,因为其它元素都保留了各自的索引。这就是为什么pop
会特别快。
push
方法也是一样的。
循环
let arr = ["Apple", "Orange", "Pear"];
// 传统for
for (let i = 0; i < arr.length; i++) {
arr[i];
}
// for of
for (let element of arr) {
element;
}
// for in 不建议
for (let key in arr) {
arr[key];
}
但这其实是一个很不好的想法。会有一些潜在问题存在:
for..in
循环会遍历 所有属性,不仅仅是这些数字属性。
在浏览器和其它环境中有一种称为“类数组”的对象,它们 看似是数组。也就是说,它们有length
和索引属性,但是也可能有其它的非数字的属性和方法,这通常是我们不需要的。for..in
循环会把它们都列出来。所以如果我们需要处理类数组对象,这些“额外”的属性就会存在问题。for..in
循环适用于普通对象,并且做了对应的优化。但是不适用于数组,因此速度要慢 10-100 倍。当然即使是这样也依然非常快。只有在遇到瓶颈时可能会有问题。但是我们仍然应该了解这其中的不同。
通常来说,我们不应该用for..in
来处理数组。
关于length
当我们修改数组的时候,length
属性会自动更新。准确来说,它实际上不是数组里元素的个数,而是最大的数字索引值加一。
例如,一个数组只有一个元素,但是这个元素的索引值很大,那么这个数组的 length
也会很大
let fruits = [];
fruits[123] = "Apple";
alert( fruits.length ); // 124
要知道的是我们通常不会这样使用数组。
length
属性的另一个有意思的点是它是可写的。
如果我们手动增加它,则不会发生任何有趣的事儿。但是如果我们减少它,数组就会被截断。该过程是不可逆的
所以,清空数组最简单的方法就是:arr.length = 0;
new Array()
这是创建数组的另一种语法:
let arr = new Array("Apple", "Pear", "etc");
它很少被使用,因为方括号 []
更短更简洁。而且,这种语法还有一个棘手的特性。
如果使用单个参数(即数字)调用 new Array
,那么它会创建一个 指定了长度,却没有任何项 的数组。
让我们看看如何搬起石头砸自己的脚:
let arr = new Array(2); // 会创建一个 [2] 的数组吗?
alert( arr[0] ); // undefined!没有元素。
alert( arr.length ); // length 2
为了避免这种意外情况,我们通常使用方括号,除非我们真的知道自己在做什么。
可用以下方法填充元素为0
let arr = new Array(2).fill(0)
多维数组
数组里的项也可以是数组。我们可以将其用于多维数组,例如存储矩阵
let matrix = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
];
alert( matrix[1][1] ); // 最中间的那个数
有时候我们不会指定全部元素只想创建一个多维数组,当然可以使用for,考虑以下方法
可以使用Array.from(),创建一个m*n的数组
let matrix = Array.from(Array(m), () => new Array(n));
toString
数组有自己的 toString
方法的实现,会返回以逗号隔开的元素列表。
let arr = [1, 2, 3];
alert( arr ); // 1,2,3
alert( String(arr) === '1,2,3' ); // true
数组没有 Symbol.toPrimitive
,也没有 valueOf
,它们只能执行 toString
进行转换,所以这里 []
就变成了一个空字符串,[1]
变成了 "1"
,[1,2]
变成了 "1,2"
。
当 "+"
运算符把一些项加到字符串后面时,加号后面的项也会被转换成字符串,所以下一步就会是这样
alert( [] + 1 ); // "1"
alert( [1] + 1 ); // "11"
alert( [1,2] + 1 ); // "1,21"
// |
// V
alert( "" + 1 ); // "1"
alert( "1" + 1 ); // "11"
alert( "1,2" + 1 ); // "1,21"
不要使用 == 比较数组
JavaScript 中的数组与其它一些编程语言的不同,不应该使用 ==
运算符比较 JavaScript 中的数组。
该运算符不会对数组进行特殊处理,它会像处理任意对象那样处理数组。
让我们回顾一下规则:
- 仅当两个对象引用的是同一个对象时,它们才相等
==
。 - 如果
==
左右两个参数之中有一个参数是对象,另一个参数是原始类型,那么该对象将会被转换为原始类型,转换规则如 对象 —— 原始值转换 一章所述。 - ……
null
和undefined
相等==
,且各自不等于任何其他的值。
严格比较===
更简单,因为它不会进行类型转换。
所以,如果我们使用==
来比较数组,除非我们比较的是两个引用同一数组的变量,否则它们永远不相等。
从技术上讲,这些数组是不同的对象。所以它们不相等。==
运算符不会进行逐项比较。
与原始类型的比较也可能会产生看似很奇怪的结果
alert( [] == [] ); // false
alert( [0] == [0] ); // false
alert( 0 == [] ); // true
alert('0' == [] ); // false
在这里的两个例子中,我们将原始类型和数组对象进行比较。因此,数组 []
被转换为原始类型以进行比较,被转换成了一个空字符串 ''
。
然后,接下来的比较就是原始类型之间的比较,''被转换为0,而'0'没有后续转换,故第一个为true,第二个为false
我们应该如何对数组进行比较呢?
很简单,不要使用 ==
运算符。而是,可以在循环中或者使用下一章中我们将介绍的迭代方法逐项地比较它们。
数组方法
添加/移除数组元素
从数组的首端或尾端添加和删除元素的方法
arr.push(...items)
—— 从尾端添加元素,arr.pop()
—— 从尾端提取元素,arr.shift()
—— 从首端提取元素,arr.unshift(...items)
—— 从首端添加元素。
splice
数组是对象,可以使用delete
let arr = [1,2,3];
delete arr[1];
arr[1] // undefined
arr.length // 3
arr.splice 可以添加,删除和插入元素
arr.splice(start[, deleCount, elem1, ..., elemN])
从索引 start
开始修改 arr
:删除 deleteCount
个元素并在当前位置插入 elem1, ..., elemN
。最后返回被删除的元素所组成的数组。
let arr = [1, 2, 3, 4, 5];
arr.splice(1, 1); // [1, 3, 4, 5]
arr.splice(1); // [1]
arr.splice(0, 3, 1.5, 2.5); // [1.5, 2.5, 4, 5]
arr.splice(2, 0, 3.5) // [1, 2, 3, 3.5, 4, 5]
允许负数索引
slice
arr.slice([start], [end])
返回一个新数组,将所有从索引 start
到 end
(不包括 end
)的数组项复制到一个新的数组。start
和 end
都可以是负数,在这种情况下,从末尾计算索引。
let arr = [1, 2, 3, 4, 5];
arr.slice(1, 3) // [2, 3]
arr.slice(-2) // [4, 5]
我们也可以不带参数地调用它:arr.slice()
会创建一个 arr
的副本。其通常用于获取副本,以进行不影响原始数组的进一步转换。
concat
创建一个新数组,其中包含来自于其他数组和其他项的值。
arr.concat(arg1, arg2...)
它接受任意数量的参数 —— 数组或值都可以。
结果是一个包含来自于 arr
,然后是 arg1
,arg2
的元素的新数组。
如果参数 argN
是一个数组,那么其中的所有元素都会被复制。否则,将复制参数本身。
let arr = [1, 2];
// 从 arr 和 [3,4] 创建一个新数组
alert( arr.concat([3, 4]) ); // 1,2,3,4
// 从 arr、[3,4] 和 [5,6] 创建一个新数组
alert( arr.concat([3, 4], [5, 6]) ); // 1,2,3,4,5,6
// 从 arr、[3,4]、5 和 6 创建一个新数组
alert( arr.concat([3, 4], 5, 6) ); // 1,2,3,4,5,6
通常,它只复制数组中的元素。其他对象,即使它们看起来像数组一样,但仍然会被作为一个整体添加:
let arr = [1, 2];
let arrayLike = {
0: "something",
length: 1
};
alert( arr.concat(arrayLike) ); // 1,2,[object Object]
……但是,如果类数组对象具有 Symbol.isConcatSpreadable
属性,那么它就会被 concat
当作一个数组来处理:此对象中的元素将被添加:
let arr = [1, 2];
let arrayLike = {
0: "something",
1: "else",
[Symbol.isConcatSpreadable]: true,
length: 2
};
alert( arr.concat(arrayLike) ); // 1,2,something,else
遍历 forEach
允许为数组的每个元素都运行一个函数。
arr.forEach(function(item, index, array) {
//do something
});
[1, 2, 4].forEach(alert);
["Bilbo", "Gandalf", "Nazgul"].forEach((item, index, array) => {
alert(`${item} is at index ${index} in ${array}`);
});
该函数的结果(如果它有返回)会被抛弃和忽略。
搜索
indexOf lastIndexOf includes
arr.indexOf和arr.includes方法语法相似,并且作用基本上也与字符串的方法相同,只不过这里是对数组元素而不是字符进行操作
arr.indexOf(item, from)
—— 从索引from
开始搜索item
,如果找到则返回索引,否则返回-1
。arr.includes(item, from)
—— 从索引from
开始搜索item
,如果找到则返回true
(译注:如果没找到,则返回false
)。
通常使用这些方法时只会传入一个参数:传入item
开始搜索。默认情况下,搜索是从头开始的。
let arr = [1, 0, false];
alert( arr.indexOf(0) ); // 1
alert( arr.indexOf(false) ); // 2
alert( arr.indexOf(null) ); // -1
alert( arr.includes(1) ); // true
请注意,indexOf
和 includes
使用严格相等 ===
进行比较。所以,如果我们搜索 false
,它会准确找到 false
而不是数字 0
。
如果我们想检查数组中是否包含元素 item
,并且不需要知道其确切的索引,那么 arr.includes
是首选。
方法arr.lastIndexOf与 indexOf
相同,但从右向左查找。
let fruits = ['Apple', 'Orange', 'Apple'];
alert( fruits.indexOf('Apple') ); // 0(第一个 Apple)
alert( fruits.lastIndexOf('Apple') ); // 2(最后一个 Apple)
方法
includes
可以正确的处理NaN
方法includes
的一个次要但值得注意的特性是,它可以正确处理NaN
,这与indexOf
不同:
const arr = [NaN];
alert( arr.indexOf(NaN) ); // -1(错,应该为 0)
alert( arr.includes(NaN) );// true(正确)
这是因为
includes
是在比较晚的时候才被添加到 JavaScript 中的,并且在内部使用了更新了的比较算法。
find findIndex findLastIndex
我们有一个对象数组。我们如何找到具有特定条件的对象?
这时可以用arr.find方法。
let result = arr.find(function(item, index, array) {
// 如果返回 true,则返回 item 并停止迭代
// 对于假值(false)的情况,则返回 undefined
});
依次对数组中的每个元素调用该函数:
item
是元素。index
是它的索引。array
是数组本身。
如果它返回true
,则搜索停止,并返回item
。如果没有搜索到,则返回undefined
。
let users = [
{id: 1, name: "John"},
{id: 2, name: "Pete"},
{id: 3, name: "Mary"}
];
let user = users.find(item => item.id == 1);
alert(user.name); // John
arr.findIndex方法(与 arr.find
)具有相同的语法,但它返回找到的元素的索引,而不是元素本身。如果没找到,则返回 -1
。
arr.findLastIndex方法类似于 findIndex
,但从右向左搜索,类似于 lastIndexOf
。
filter
find
方法搜索的是使函数返回 true
的第一个(单个)元素。
如果需要匹配的有很多,我们可以使用 arr.filter(fn)。
语法与 find
大致相同,但是 filter
返回的是所有匹配元素组成的数组:
let results = arr.filter(function(item, index, array) {
// 如果 true item 被 push 到 results,迭代继续
// 如果什么都没找到,则返回空数组
});
let users = [
{id: 1, name: "John"},
{id: 2, name: "Pete"},
{id: 3, name: "Mary"}
];
// 返回前两个用户的数组
let someUsers = users.filter(item => item.id < 3);
alert(someUsers.length); // 2
转换数组
map
map 方法是最有用和经常使用的方法之一。
它对数组的每个元素都调用函数,并返回结果数组。
let result = arr.map(function(item, index, array) {
// 返回新值而不是当前元素
})
在这里我们将每个元素转换为它的字符串长度
let lengths = ["Bilbo", "Gandalf", "Nazgul"].map(item => item.length);
alert(lengths); // 5,7,6
sort
sort 方法对数组进行 原位(in-place) 排序,更改元素的顺序。(译注:原位是指在此数组内,而非生成一个新数组。)
它还返回排序后的数组,但是返回值通常会被忽略,因为修改了 arr
本身。
let arr = [ 1, 2, 15 ];
// 该方法重新排列 arr 的内容
arr.sort();
alert( arr ); // 1, 15, 2
这些元素默认情况下被按字符串进行排序。
从字面上看,所有元素都被转换为字符串,然后进行比较。对于字符串,按照词典顺序进行排序,实际上应该是 "2" > "15"
。
要使用我们自己的排序顺序,我们需要提供一个函数作为 arr.sort()
的参数。
该函数应该比较两个任意值并返回
function compareNumeric(a, b) {
if (a > b) return 1;
if (a == b) return 0;
if (a < b) return -1;
}
let arr = [ 1, 2, 15 ];
arr.sort(compareNumeric);
alert(arr); // 1, 2, 15
现在结果符合预期了。
我们思考一下这儿发生了什么。arr
可以是由任何内容组成的数组,对吗?它可能包含数字、字符串、对象或其他任何内容。我们有一组 一些元素。要对其进行排序,我们需要一个 排序函数 来确认如何比较这些元素。默认是按字符串进行排序的。
arr.sort(fn)
方法实现了通用的排序算法。我们不需要关心它的内部工作原理(大多数情况下都是经过 快速排序 或 Timsort 算法优化的)。它将遍历数组,使用提供的函数比较其元素并对其重新排序,我们所需要的就是提供执行比较的函数 fn
。
顺便说一句,如果我们想知道要比较哪些元素 —— 那么什么都不会阻止 alert 它们
[1, -2, 15, 2, 0, 8].sort(function(a, b) {
alert( a + " <> " + b );
return a - b;
});
该算法可以在此过程中,将一个元素与多个其他元素进行比较,但是它会尝试进行尽可能少的比较。
比较函数可以返回任何数字
实际上,比较函数只需要返回一个正数表示“大于”,一个负数表示“小于”。
通过这个原理我们可以编写更短的函数
let arr = [ 1, 2, 15 ];
arr.sort(function(a, b) { return a - b; });
alert(arr); // 1, 2, 15
箭头函数最好
你还记得 箭头函数 吗?这里使用箭头函数会更加简洁:
arr.sort( (a, b) => a - b );
使用
localeCompare
for strings
你记得 字符串比较 算法吗?默认情况下,它通过字母的代码比较字母。
对于许多字母,最好使用str.localeCompare
方法正确地对字母进行排序,例如Ö
。
例如,让我们用德语对几个国家/地区进行排序:
let countries = ['Österreich', 'Andorra', 'Vietnam'];
alert( countries.sort( (a, b) => a > b ? 1 : -1) ); // Andorra, Vietnam, Österreich(错的)
alert( countries.sort( (a, b) => a.localeCompare(b) ) ); // Andorra,Österreich,Vietnam(对的!)
reverse
reverse 方法用于颠倒 arr
中元素的顺序,会改变arr。
let arr = [1, 2, 3, 4, 5];
arr.reverse(); // [5, 4, 3, 2, 1]
它也会返回颠倒后的数组 arr
。
split join
举一个现实生活场景的例子。我们正在编写一个消息应用程序,并且该人员输入以逗号分隔的接收者列表:John, Pete, Mary
。但对我们来说,名字数组比单个字符串舒适得多。怎么做才能获得这样的数组呢?
str.split(delim) 方法可以做到。它通过给定的分隔符 delim
将字符串分割成一个数组。
在下面的例子中,我们用“逗号后跟着一个空格”作为分隔符:
let names = 'Bilbo, Gandalf, Nazgul';
let arr = names.split(', ');
for (let name of arr) {
alert( `A message to ${name}.` ); // A message to Bilbo(和其他名字)
}
split
方法有一个可选的第二个数字参数 —— 对数组长度的限制。如果提供了,那么额外的元素会被忽略。但实际上它很少使用
let arr = 'Bilbo, Gandalf, Nazgul, Saruman'.split(', ', 2);
alert(arr); // Bilbo, Gandalf
拆分为字母
调用带有空参数s
的split(s)
,会将字符串拆分为字母数组:
let str = "test";
alert( str.split('') ); // t,e,s,t
arr.join(glue) 与 split
相反。它会在它们之间创建一串由 glue
粘合的 arr
项。
let arr = ['Bilbo', 'Gandalf', 'Nazgul'];
let str = arr.join(';'); // 使用分号 ; 将数组粘合成字符串
alert( str ); // Bilbo;Gandalf;Nazgul
reduce reduceRight
当我们需要遍历一个数组时 —— 我们可以使用 forEach
,for
或 for..of
。
当我们需要遍历并返回每个元素的数据时 —— 我们可以使用 map
。
arr.reduce 方法和 arr.reduceRight 方法和上面的种类差不多,但稍微复杂一点。它们用于根据数组计算单个值。
语法是:
let value = arr.reduce(function(accumulator, item, index, array) {
// ...
}, [initial]);
该函数一个接一个地应用于所有数组元素,并将其结果“搬运(carry on)”到下一个调用。
参数:
accumulator
—— 是上一个函数调用的结果,第一次等于initial
(如果提供了initial
的话)。item
—— 当前的数组元素。index
—— 当前索引。arr
—— 数组本身。
应用函数时,上一个函数调用的结果将作为第一个参数传递给下一个函数。
因此,第一个参数本质上是累加器,用于存储所有先前执行的组合结果。最后,它成为reduce
的结果。
听起来复杂吗?
掌握这个知识点的最简单的方法就是通过示例。
在这里,我们通过一行代码得到一个数组的总和:
let arr = [1, 2, 3, 4, 5];
let result = arr.reduce((sum, current) => sum + current, 0);
alert(result); // 15
传递给 reduce 的函数仅使用了 2 个参数,通常这就足够了。
让我们看看细节,到底发生了什么。
在第一次运行时,sum 的值为初始值 initial(reduce 的最后一个参数),等于 0,current 是第一个数组元素,等于 1。所以函数运行的结果是 1。
在第二次运行时,sum = 1,我们将第二个数组元素(2)与其相加并返回。
在第三次运行中,sum = 3,我们继续把下一个元素与其相加,以此类推……
省略初始值结果是一样的。这是因为如果没有初始值,那么 reduce
会将数组的第一个元素作为初始值,并从第二个元素开始迭代。
但是这种使用需要非常小心。如果数组为空,那么在没有初始值的情况下调用 reduce
会导致错误。
建议始终指定初始值。
arr.reduceRight和arr.reduce方法的功能一样,只是遍历为从右到左。
Array.isArray
数组是基于对象的,不构成单独的语言类型。
所以 typeof
不能帮助从数组中区分出普通对象
……但是数组经常被使用,因此有一种特殊的方法用于判断:Array.isArray(value)。如果 value
是一个数组,则返回 true
;否则返回 false
。
大多数方法都支持 thisArg
几乎所有调用函数的数组方法 —— 比如 find
,filter
,map
,除了 sort
是一个特例,都接受一个可选的附加参数 thisArg
。
上面的部分中没有解释该参数,因为该参数很少使用。但是为了完整性,我们需要讲讲它。
arr.find(func, thisArg);
arr.filter(func, thisArg);
arr.map(func, thisArg);
// ...
// thisArg 是可选的最后一个参数
thisArg
参数的值在 func
中变为 this
。
例如,在这里我们使用 army
对象方法作为过滤器,thisArg
用于传递上下文(passes the context):
let army = {
minAge: 18,
maxAge: 27,
canJoin(user) {
return user.age >= this.minAge && user.age < this.maxAge;
}
};
let users = [
{age: 16},
{age: 20},
{age: 23},
{age: 30}
];
// 找到 army.canJoin 返回 true 的 user
let soldiers = users.filter(army.canJoin, army);
alert(soldiers.length); // 2
alert(soldiers[0].age); // 20
alert(soldiers[1].age); // 23
如果在上面的示例中我们使用了 users.filter(army.canJoin)
,那么 army.canJoin
将被作为独立函数调用,并且这时 this=undefined
,从而会导致即时错误。
可以用 users.filter(user => army.canJoin(user))
替换对 users.filter(army.canJoin, army)
的调用。前者的使用频率更高,因为对于大多数人来说,它更容易理解。
Iterable object 可迭代对象
可迭代(Iterable) 对象是数组的泛化。这个概念是说任何对象都可以被定制为可在 for..of
循环中使用的对象。
数组是可迭代的。但不仅仅是数组。很多其他内建对象也都是可迭代的。例如字符串也是可迭代的。
如果从技术上讲,对象不是数组,而是表示某物的集合(列表,集合),for..of
是一个能够遍历它的很好的语法,因此,让我们来看看如何使其发挥作用。
Symbol.iterator
比如一个 range
对象,它代表了一个数字区间:
let range = {
from: 1,
to: 5
};
// 我们希望 for..of 这样运行:
// for(let num of range) ... num=1,2,3,4,5
为了让 range
对象可迭代(也就让 for..of
可以运行)我们需要为对象添加一个名为 Symbol.iterator
的方法(一个专门用于使对象可迭代的内建 symbol)。
- 当
for..of
循环启动时,它会调用这个方法(如果没找到,就会报错)。这个方法必须返回一个 迭代器(iterator) —— 一个有next
方法的对象。 - 从此开始,
for..of
仅适用于这个被返回的对象。 - 当
for..of
循环希望取得下一个数值,它就调用这个对象的next()
方法。 next()
方法返回的结果的格式必须是{done: Boolean, value: any}
,当done=true
时,表示循环结束,否则value
是下一个值。
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
}
请注意可迭代对象的核心功能:关注点分离。
range
自身没有next()
方法。- 相反,是通过调用
range[Symbol.iterator]()
创建了另一个对象,即所谓的“迭代器”对象,并且它的next
会为迭代生成值。
因此,迭代器对象和与其进行迭代的对象是分开的。
从技术上说,我们可以将它们合并,并使用range
自身作为迭代器来简化代码。
let range = {
from: 1,
to: 5,
[Symbol.iterator]() {
this.current = this.from;
return this;
},
next() {
if (this.current <= this.to) {
return { done: false, value: this.current++ };
} else {
return { done: true };
}
}
};
for (let num of range) {
alert(num); // 1, 然后是 2, 3, 4, 5
}
现在 range[Symbol.iterator]()
返回的是 range
对象自身:它包括了必需的 next()
方法,并通过 this.current
记忆了当前的迭代进程。这样更短,对吗?是的。有时这样也可以。
但缺点是,现在不可能同时在对象上运行两个 for..of
循环了:它们将共享迭代状态,因为只有一个迭代器,即对象本身。但是两个并行的 for..of
是很罕见的,即使在异步情况下。
无穷迭代器(iterator)
无穷迭代器也是可能的。例如,将range
设置为range.to = Infinity
,这时range
则成为了无穷迭代器。或者我们可以创建一个可迭代对象,它生成一个无穷伪随机数序列。也是可能的。
next
没有什么限制,它可以返回越来越多的值,这是正常的。
当然,迭代这种对象的for..of
循环将不会停止。但是我们可以通过使用break
来停止它。
字符串是可迭代的
数组和字符串是使用最广泛的内建可迭代对象。
对于一个字符串,for..of
遍历它的每个字符:
for (let char of "test") {
// 触发 4 次,每个字符一次
alert( char ); // t, then e, then s, then t
}
对于代理对(surrogate pairs),它也能正常工作!(译注:这里的代理对也就指的是 UTF-16 的扩展字符)
let str = '𝒳😂';
for (let char of str) {
alert( char ); // 𝒳,然后是 😂
}
显式调用迭代器
为了更深层地了解底层知识,让我们来看看如何显式地使用迭代器。
我们将会采用与 for..of
完全相同的方式遍历字符串,但使用的是直接调用。这段代码创建了一个字符串迭代器,并“手动”从中获取值。
let str = "Hello";
// 和 for..of 做相同的事
// for (let char of str) alert(char);
let iterator = str[Symbol.iterator]();
while (true) {
let result = iterator.next();
if (result.done) break;
alert(result.value); // 一个接一个地输出字符
}
很少需要我们这样做,但是比 for..of
给了我们更多的控制权。例如,我们可以拆分迭代过程:迭代一部分,然后停止,做一些其他处理,然后再恢复迭代。
可迭代(iterable)和类数组(array-like)
这两个官方术语看起来差不多,但其实大不相同。请确保你能够充分理解它们的含义,以免造成混淆。
- Iterable 如上所述,是实现了
Symbol.iterator
方法的对象。 - Array-like 是有索引和
length
属性的对象,所以它们看起来很像数组。
当我们将 JavaScript 用于编写在浏览器或任何其他环境中的实际任务时,我们可能会遇到可迭代对象或类数组对象,或两者兼有。
例如,字符串即是可迭代的(for..of
对它们有效),又是类数组的(它们有数值索引和length
属性)。
但是一个可迭代对象也许不是类数组对象。反之亦然,类数组对象可能不可迭代。
例如,上面例子中的range
是可迭代的,但并非类数组对象,因为它没有索引属性,也没有length
属性。
可迭代对象和类数组对象通常都 不是数组,它们没有push
和pop
等方法。如果我们有一个这样的对象,并想像数组那样操作它,那就非常不方便。例如,我们想使用数组方法操作range
,应该如何实现呢?
Array.from
有一个全局方法 Array.from 可以接受一个可迭代或类数组的值,并从中获取一个“真正的”数组。然后我们就可以对其调用数组方法了。
let arrayLike = {
0: "Hello",
1: "World",
length: 2
};
let arr = Array.from(arrayLike); // (*)
alert(arr.pop()); // World(pop 方法有效)
在 (*)
行的 Array.from
方法接受对象,检查它是一个可迭代对象或类数组对象,然后创建一个新数组,并将该对象的所有元素复制到这个新数组。
如果是可迭代对象,也是同样:
// 假设 range 来自上文的例子中
let arr = Array.from(range);
alert(arr); // 1,2,3,4,5 (数组的 toString 转化方法生效)
Array.from
的完整语法允许我们提供一个可选的“映射(mapping)”函数:
Array.from(obj[, mapFn, thisArg])
可选的第二个参数 mapFn
可以是一个函数,该函数会在对象中的元素被添加到数组前,被应用于每个元素,此外 thisArg
允许我们为该函数设置 this
。
Array.from
将一个字符串转换为单个字符的数组与 str.split
方法不同,它依赖于字符串的可迭代特性。……但 Array.from
精简很多。
我们甚至可以基于 Array.from
创建代理感知(surrogate-aware)的slice
方法(译注:也就是能够处理 UTF-16 扩展字符的 slice
方法):
function slice(str, start, end) {
return Array.from(str).slice(start, end).join('');
}
let str = '𝒳😂𩷶';
alert( slice(str, 1, 3) ); // 😂𩷶
// 原生方法不支持识别代理对(译注:UTF-16 扩展字符)
alert( str.slice(1, 3) ); // 乱码(两个不同 UTF-16 扩展字符碎片拼接的结果)
Map and Set,映射与集合
学到现在,我们已经了解了以下复杂的数据结构:
- 对象,存储带有键的数据的集合。
- 数组,存储有序集合。
但是还不足以应对现实情况
Map
map 是一个带键的数据项的集合,就像一个 Object
一样。 但是它们最大的差别是 Map
允许任何类型的键(key)。
它的方法和属性如下:
new Map()
—— 创建 map。map.set(key, value)
—— 根据键存储值。map.get(key)
—— 根据键来返回值,如果map
中不存在对应的key
,则返回undefined
。map.has(key)
—— 如果key
存在则返回true
,否则返回false
。map.delete(key)
—— 删除指定键的值。map.clear()
—— 清空 map。map.size
—— 返回当前元素个数。
let map = new Map();
map.set('1', 'str1'); // 字符串键
map.set(1, 'num1'); // 数字键
map.set(true, 'bool1'); // 布尔值键
// 还记得普通的 Object 吗? 它会将键转化为字符串
// Map 则会保留键的类型,所以下面这两个结果不同:
alert( map.get(1) ); // 'num1'
alert( map.get('1') ); // 'str1'
alert( map.size ); // 3
map[key]
不是使用Map
的正确方式
虽然map[key]
也有效,例如我们可以设置map[key] = 2
,这样会将map
视为 JavaScript 的 plain object,因此它暗含了所有相应的限制(仅支持 string/symbol 键等)。
所以我们应该使用map
方法:set
和get
等。
map还可以使用对象作为键
let john = { name: "John" };
// 存储每个用户的来访次数
let visitsCountMap = new Map();
// john 是 Map 中的键
visitsCountMap.set(john, 123);
alert( visitsCountMap.get(john) ); // 123
使用对象作为键是 Map
最值得注意和重要的功能之一。在 Object
中,我们则无法使用对象作为键。在 Object
中使用字符串作为键是可以的,但我们无法使用另一个 Object
作为 Object
中的键。
Map
是怎么比较键的?
Map
使用 SameValueZero 算法来比较键是否相等。它和严格等于===
差不多,但区别是NaN
被看成是等于NaN
。所以NaN
也可以被用作键。
这个算法不能被改变或者自定义。
链式调用
每一次map.set
调用都会返回 map 本身,所以我们可以进行“链式”调用:
let recipeMap = new Map([
['cucumber', 500],
['tomatoes', 350],
['onion', 50]
]);
// 遍历所有的键(vegetables)
for (let vegetable of recipeMap.keys()) {
alert(vegetable); // cucumber, tomatoes, onion
}
// 遍历所有的值(amounts)
for (let amount of recipeMap.values()) {
alert(amount); // 500, 350, 50
}
// 遍历所有的实体 [key, value]
for (let entry of recipeMap) { // 与 recipeMap.entries() 相同
alert(entry); // cucumber,500 (and so on)
}
map迭代
如果要在 map
里使用循环,可以使用以下三个方法:
map.keys()
—— 遍历并返回一个包含所有键的可迭代对象,map.values()
—— 遍历并返回一个包含所有值的可迭代对象,map.entries()
—— 遍历并返回一个包含所有实体[key, value]
的可迭代对象,for..of
在默认情况下使用的就是这个。
let recipeMap = new Map([
['cucumber', 500],
['tomatoes', 350],
['onion', 50]
]);
// 遍历所有的键(vegetables)
for (let vegetable of recipeMap.keys()) {
alert(vegetable); // cucumber, tomatoes, onion
}
// 遍历所有的值(amounts)
for (let amount of recipeMap.values()) {
alert(amount); // 500, 350, 50
}
// 遍历所有的实体 [key, value]
for (let entry of recipeMap) { // 与 recipeMap.entries() 相同
alert(entry); // cucumber,500 (and so on)
}
使用插入顺序
迭代的顺序与插入值的顺序相同。与普通的Object
不同,Map
保留了此顺序。
除此之外,Map
有内建的 forEach
方法,与 Array
类似:
// 对每个键值对 (key, value) 运行 forEach 函数
recipeMap.forEach( (value, key, map) => {
alert(`${key}: ${value}`); // cucumber: 500 etc
});
Object.entries:从对象创建 Map
当创建一个 Map
后,我们可以传入一个带有键值对的数组(或其它可迭代对象)来进行初始化
// 键值对 [key, value] 数组
let map = new Map([
['1', 'str1'],
[1, 'num1'],
[true, 'bool1']
]);
alert( map.get('1') ); // str1
如果我们想从一个已有的普通对象(plain object)来创建一个 Map
,那么我们可以使用内建方法 Object.entries(obj),该方法返回对象的键/值对数组,该数组格式完全按照 Map
所需的格式。
所以可以像下面这样从一个对象创建一个 Map:
let obj = {
name: "John",
age: 30
};
let map = new Map(Object.entries(obj));
alert( map.get('name') ); // John
这里,Object.entries
返回键/值对数组:[ ["name","John"], ["age", 30] ]
。这就是 Map
所需要的格式。
Object.fromEntries:从 Map 创建对象
刚刚已经学习了如何使用 Object.entries(obj)
从普通对象(plain object)创建 Map
。
Object.fromEntries
方法的作用是相反的:给定一个具有 [key, value]
键值对的数组,它会根据给定数组创建一个对象:
let prices = Object.fromEntries([
['banana', 1],
['orange', 2],
['meat', 4]
]);
// 现在 prices = { banana: 1, orange: 2, meat: 4 }
alert(prices.orange); // 2
我们可以使用 Object.fromEntries
从 Map
得到一个普通对象(plain object)。
例如,我们在 Map
中存储了一些数据,但是我们需要把这些数据传给需要普通对象(plain object)的第三方代码。
let map = new Map();
map.set('banana', 1);
map.set('orange', 2);
map.set('meat', 4);
let obj = Object.fromEntries(map.entries()); // 创建一个普通对象(plain object)(*)
let obj = Object.fromEntries(map); // 省掉 .entries()
// 完成了!
// obj = { banana: 1, orange: 2, meat: 4 }
alert(obj.orange); // 2
Object.fromEntries
期望得到一个可迭代对象作为参数,而不一定是数组。并且 map
的标准迭代会返回跟 map.entries()
一样的键/值对。因此,我们可以获得一个普通对象(plain object),其键/值对与 map
相同。
set 集合
Set
是一个特殊的类型集合 —— “值的集合”(没有键),它的每一个值只能出现一次。
它的主要方法如下:
new Set(iterable)
—— 创建一个set
,如果提供了一个iterable
对象(通常是数组),将会从数组里面复制值到set
中。set.add(value)
—— 添加一个值,返回 set 本身set.delete(value)
—— 删除值,如果value
在这个方法调用的时候存在则返回true
,否则返回false
。set.has(value)
—— 如果value
在 set 中,返回true
,否则返回false
。set.clear()
—— 清空 set。set.size
—— 返回元素个数。
它的主要特点是,重复使用同一个值调用set.add(value)
并不会发生什么改变。这就是Set
里面的每一个值只出现一次的原因。
例如,我们有客人来访,我们想记住他们每一个人。但是已经来访过的客人再次来访,不应造成重复记录。每个访客必须只被“计数”一次。
Set
可以帮助我们解决这个问题:
let set = new Set();
let john = { name: "John" };
let pete = { name: "Pete" };
let mary = { name: "Mary" };
// visits,一些访客来访好几次
set.add(john);
set.add(pete);
set.add(mary);
set.add(john);
set.add(mary);
// set 只保留不重复的值
alert( set.size ); // 3
for (let user of set) {
alert(user.name); // John(然后 Pete 和 Mary)
}
Set
的替代方法可以是一个用户数组,用 arr.find 在每次插入值时检查是否重复。但是这样性能会很差,因为这个方法会遍历整个数组来检查每个元素。Set
内部对唯一性检查进行了更好的优化。
set 迭代
我们可以使用 for..of
或 forEach
来遍历 Set:
let set = new Set(["oranges", "apples", "bananas"]);
for (let value of set) alert(value);
// 与 forEach 相同:
set.forEach((value, valueAgain, set) => {
alert(value);
});
注意一件有趣的事儿。forEach
的回调函数有三个参数:一个 value
,然后是 同一个值 valueAgain
,最后是目标对象。没错,同一个值在参数里出现了两次。
forEach
的回调函数有三个参数,是为了与 Map
兼容。当然,这看起来确实有些奇怪。但是这对在特定情况下轻松地用 Set
代替 Map
很有帮助,反之亦然。
Map
中用于迭代的方法在 Set
中也同样支持:
set.keys()
—— 遍历并返回一个包含所有值的可迭代对象,set.values()
—— 与set.keys()
作用相同,这是为了兼容Map
,set.entries()
—— 遍历并返回一个包含所有的实体[value, value]
的可迭代对象,它的存在也是为了兼容Map
。
WeakMap WeakSet
引
我们从前面的 垃圾回收 章节中知道,JavaScript 引擎在值“可达”和可能被使用时会将其保持在内存中。
通常,当对象、数组之类的数据结构在内存中时,它们的子元素,如对象的属性、数组的元素都被认为是可达的。
例如,如果把一个对象放入到数组中,那么只要这个数组存在,那么这个对象也就存在,即使没有其他对该对象的引用。
就像这样:
let john = { name: "John" };
let array = [ john ];
john = null; // 覆盖引用
// 前面由 john 所引用的那个对象被存储在了 array 中
// 所以它不会被垃圾回收机制回收
// 我们可以通过 array[0] 获取到它
类似的,如果我们使用对象作为常规 Map
的键,那么当 Map
存在时,该对象也将存在。它会占用内存,并且不会被(垃圾回收机制)回收。
let john = { name: "John" };
let map = new Map();
map.set(john, "...");
john = null; // 覆盖引用
// john 被存储在了 map 中,
// 我们可以使用 map.keys() 来获取它
WeakMap
在这方面有着根本上的不同。它不会阻止垃圾回收机制对作为键的对象(key object)的回收。
让我们通过例子来看看这指的到底是什么。
WeakMap
WeakMap
和 Map
的第一个不同点就是,WeakMap
的键必须是对象,不能是原始值
现在,如果我们在 weakMap 中使用一个对象作为键,并且没有其他对这个对象的引用 —— 该对象将会被从内存(和map)中自动清除。
let john = { name: "John" };
let weakMap = new WeakMap();
weakMap.set(john, "...");
john = null; // 覆盖引用
// john 被从内存中删除了!
与上面常规的 Map
的例子相比,现在如果 john
仅仅是作为 WeakMap
的键而存在 —— 它将会被从 map(和内存)中自动删除。
WeakMap
不支持迭代以及 keys()
,values()
和 entries()
方法。所以没有办法获取 WeakMap
的所有键或值。
WeakMap
只有以下的方法:
weakMap.get(key)
weakMap.set(key, value)
weakMap.delete(key)
weakMap.has(key)
为什么会有这种限制呢?这是技术的原因。如果一个对象丢失了其它所有引用(就像上面示例中的john
),那么它就会被垃圾回收机制自动回收。但是在从技术的角度并不能准确知道 何时会被回收。
这些都是由 JavaScript 引擎决定的。JavaScript 引擎可能会选择立即执行内存清理,如果现在正在发生很多删除操作,那么 JavaScript 引擎可能就会选择等一等,稍后再进行内存清理。因此,从技术上讲,WeakMap
的当前元素的数量是未知的。JavaScript 引擎可能清理了其中的垃圾,可能没清理,也可能清理了一部分。因此,暂不支持访问WeakMap
的所有键/值的方法。
使用案例
- 额外的数据
假如我们正在处理一个“属于”另一个代码的一个对象,也可能是第三方库,并想存储一些与之相关的数据,那么这些数据就应该与这个对象共存亡 —— 这时候WeakMap
正是我们所需要的利器。
我们将这些数据放到WeakMap
中,并使用该对象作为这些数据的键,那么当该对象被垃圾回收机制回收后,这些数据也会被自动清除。
📁//文件夹hhh
- 缓存
我们可以存储(“缓存”)函数的结果,以便将来对同一个对象的调用可以重用这个结果。我们用WeakMap
当对象被垃圾回收时,对应缓存的结果也会被自动从内存中清除。
WeakSet
WeakSet
的表现类似:
- 与
Set
类似,但是我们只能向WeakSet
添加对象(而不能是原始值)。 - 对象只有在其它某个(些)地方能被访问的时候,才能留在
WeakSet
中。 - 跟
Set
一样,WeakSet
支持add
,has
和delete
方法,但不支持size
和keys()
,并且不可迭代。
变“弱(weak)”的同时,它也可以作为额外的存储空间。但并非针对任意数据,而是针对“是/否”的事实。WeakSet
的元素可能代表着有关该对象的某些信息。
Object.keys,values,entries
在前面的章节中,我们认识了 map.keys()
,map.values()
和 map.entries()
方法。
这些方法是通用的,有一个共同的约定来将它们用于各种数据结构。如果我们创建一个我们自己的数据结构,我们也应该实现这些方法。
它们支持:
Map
Set
Array
普通对象也支持类似的方法,但是语法上有一些不同。
对于普通对象,下列这些方法是可用的:
- Object.keys(obj) —— 返回一个包含该对象所有的键的数组。
- Object.values(obj) —— 返回一个包含该对象所有的值的数组。
- Object.entries(obj) —— 返回一个包含该对象所有 [key, value] 键值对的数组。
……但是请注意区别(比如说跟 map 的区别):
Map | Object | |
---|---|---|
调用语法 | map.keys() |
Object.keys(obj) ,而不是 obj.keys() |
返回值 | 可迭代对象 | “真正的”数组 |
第一个区别是,对于对象我们使用的调用语法是 Object.keys(obj) ,而不是 obj.keys() 。 |
||
为什么会这样?主要原因是灵活性。请记住,在 JavaScript 中,对象是所有复杂结构的基础。因此,我们可能有一个自己创建的对象,比如 data ,并实现了它自己的 data.values() 方法。同时,我们依然可以对它调用 Object.values(data) 方法。 |
||
第二个区别是 Object.* 方法返回的是“真正的”数组对象,而不只是一个可迭代对象。这主要是历史原因。 |
let user = {
name: "John",
age: 30
};
// 遍历所有的值
for (let value of Object.values(user)) {
alert(value); // John, then 30
}
Object.keys/values/entries 会忽略 symbol 属性
就像for..in
循环一样,这些方法会忽略使用Symbol(...)
作为键的属性。
通常这很方便。但是,如果我们也想要 Symbol 类型的键,那么这儿有一个单独的方法 Object.getOwnPropertySymbols,它会返回一个只包含 Symbol 类型的键的数组。另外,还有一种方法 Reflect.ownKeys(obj),它会返回 所有 键。
转换对象
对象缺少数组存在的许多方法,例如 map 和 filter 等。
如果我们想应用它们,那么我们可以使用 Object.entries,然后使用 Object.fromEntries:
使用 Object.entries(obj) 从 obj 获取由键/值对组成的数组。
对该数组使用数组方法,例如 map,对这些键/值对进行转换。
对结果数组使用 Object.fromEntries(array) 方法,将结果转回成对象。
解构赋值
JavaScript 中最常用的两种数据结构是 Object
和 Array
。
- 对象是一种根据键存储数据的实体。
- 数组是一种直接存储数据的有序列表。
但是,当我们把它们传递给函数时,函数可能不需要整个对象/数组,而只需要其中一部分。
解构赋值 是一种特殊的语法,它使我们可以将数组或对象“拆包”至一系列变量中。有时这样做更方便。
解构操作对那些具有很多参数和默认值等的函数也很奏效。下面有一些例子。
数组解构
// 我们有一个存放了名字和姓氏的数组
let arr = ["John", "Smith"]
// 解构赋值
// 设置 firstName = arr[0]
// 以及 surname = arr[1]
let [firstName, surname] = arr;
alert(firstName); // John
alert(surname); // Smith
我们可以使用这些变量而非原来的数组项了。
当与 split
函数(或其他返回值为数组的函数)结合使用时,看起来更优雅
let [firstName, surname] = "John Smith".split(' ');
alert(firstName); // John
alert(surname); // Smith
“解构”并不意味着“破坏”
这种语法被叫做“解构赋值”,是因为它“拆开”了数组或对象,将其中的各元素复制给一些变量。原来的数组或对象自身没有被修改。
换句话说,解构赋值只是写起来简洁一点。
忽略使用逗号的元素
可以通过添加额外的逗号来丢弃数组中不想要的元素
// 不需要第二个元素
let [who, , content] = ["I", "never", "do that", "!!!"];
alert( who + " " + content ); // I do that
在上面的代码中,数组的第二个元素被跳过了,第三个元素被赋值给了
title
变量。数组中剩下的元素也都被跳过了(因为在这没有对应给它们的变量)。
等号右侧可以是任何可迭代对象
……实际上,我们可以将其与任何可迭代对象一起使用,而不仅限于数组
let [a, b, c] = "abc"; // ["a", "b", "c"]
let [one, two, three] = new Set([1, 2, 3]);
这种情况下解构赋值是通过迭代右侧的值来完成工作的。这是一种用于对在
=
右侧的值上调用for..of
并进行赋值的操作的语法糖。
赋值给等号左侧的任何内容
我们可以在等号左侧使用任何“可以被赋值的”东西。
例如,一个对象的属性:
let user = {};
[user.name, user.surname] = "John Smith".split(' ');
alert(user.name); // John
alert(user.surname); // Smith
与 .entries() 方法进行循环操作
在前面的章节中我们已经见过了 Object.entries(obj) 方法。
我们可以将 .entries() 方法与解构语法一同使用,来遍历一个对象的“键—值”对:
let user = {
name: "John",
age: 30
};
// 使用循环遍历键—值对
for (let [key, value] of Object.entries(user)) {
alert(`${key}:${value}`); // name:John, then age:30
}
用于
Map
的类似代码更简单,因为 Map 是可迭代的
let user = new Map();
user.set("name", "John");
user.set("age", "30");
// Map 是以 [key, value] 对的形式进行迭代的,非常便于解构
for (let [key, value] of user) {
alert(`${key}:${value}`); // name:John, then age:30
}
交换变量值的技巧
使用解构赋值来交换两个变量的值是一个著名的技巧:
let guest = "Jane";
let admin = "Pete";
// 让我们来交换变量的值:使得 guest = Pete,admin = Jane
[guest, admin] = [admin, guest];
alert(`${guest} ${admin}`); // Pete Jane(成功交换!)
这里我们创建了一个由两个变量组成的临时数组,并且立即以颠倒的顺序对其进行了解构赋值。
我们也可以用这种方式交换两个以上的变量。
剩余的...
通常,如果数组比左边的列表长,那么“其余”的数组项会被省略。
如果我们还想收集其余的数组项 —— 我们可以使用三个点 "..."
来再加一个参数以获取其余数组项
let [name1, name2, ...rest] = ["Julius", "Caesar", "Consul", "of the Roman Republic"];
// rest 是包含从第三项开始的其余数组项的数组
alert(rest[0]); // Consul
alert(rest[1]); // of the Roman Republic
alert(rest.length); // 2
只要确保它前面有三个点,并且在解构赋值的最后一个参数位置上就行了
默认值
如果数组比左边的变量列表短,这里不会出现报错。缺少对应值的变量都会被赋 undefined
如果我们想要一个“默认”值给未赋值的变量,我们可以使用 =
来提供
// 默认值
let [name = "Guest", surname = "Anonymous"] = ["Julius"];
alert(name); // Julius(来自数组的值)
alert(surname); // Anonymous(默认值被使用了)
默认值可以是更加复杂的表达式,甚至可以是函数调用。不过,这些表达式或函数只会在这个变量未被赋值的时候才会被计算。
举个例子,我们使用了 prompt
函数来提供两个默认值:
// 只会提示输入姓氏
let [name = prompt('name?'), surname = prompt('surname?')] = ["Julius"];
alert(name); // Julius(来自数组)
alert(surname); // 你输入的值
请注意:prompt
将仅针对缺失值(surname
)运行。
对象解构
同样适用于对象
let {var1, var2} = {var1:…, var2:…}
在等号右侧是一个已经存在的对象,我们想把它拆分到变量中。等号左侧包含了对象相应属性的一个类对象“模式(pattern)”。在最简单的情况下,等号左侧的就是 {...}
中的变量名列表。
等号左侧的模式(pattern)可以更加复杂,指定属性和变量之间的映射关系。
如果我们想把一个属性赋值给另一个名字的变量,比如把 options.width
属性赋值给名为 w
的变量,那么我们可以使用冒号来设置变量名称:
let options = {
title: "Menu",
width: 100,
height: 200
};
// { sourceProperty: targetVariable }
let {width: w, height: h, title} = options;
// width -> w
// height -> h
// title -> title
alert(title); // Menu
alert(w); // 100
alert(h); // 200
冒号的语法是“从对象中什么属性的值:赋值给哪个变量”。上面的例子中,属性 width
被赋值给了 w
,属性 height
被赋值给了 h
,属性 title
被赋值给了同名变量。
对于可能缺失的属性,我们可以使用 "="
设置默认值
就像数组或函数参数一样,默认值可以是任意表达式甚至可以是函数调用。它们只会在未提供对应的值时才会被计算/调用。
还可以将冒号和等号结合起来
let options = {
title: "Menu"
};
let {width: w = 100, height: h = 200, title} = options;
alert(title); // Menu
alert(w); // 100
alert(h); // 200
剩余模式...
我们可以使用剩余模式(pattern),与数组类似。
let options = {
title: "Menu",
height: 200,
width: 100
};
// title = 名为 title 的属性
// rest = 存有剩余属性的对象
let {title, ...rest} = options;
// 现在 title="Menu", rest={height: 200, width: 100}
alert(rest.height); // 200
alert(rest.width); // 100
不使用
let
时的陷阱
在上面的示例中,变量都是在赋值中通过正确方式声明的:let {…} = {…}
。当然,我们也可以使用已有的变量,而不用let
,但这里有一个陷阱。
let title, width, height;
// 这一行发生了错误
{title, width, height} = {title: "Menu", width: 200, height: 100};
问题在于 JavaScript 把主代码流(即不在其他表达式中)的
{...}
当做一个代码块。这样的代码块可以用于对语句分组,如下所示:
{
// 一个代码块
let message = "Hello";
// ...
alert( message );
}
因此,这里 JavaScript 假定我们有一个代码块,这就是报错的原因。我们需要解构它。
为了告诉 JavaScript 这不是一个代码块,我们可以把整个赋值表达式用括号(...)
包起来
let title, width, height;
// 现在就可以了
({title, width, height} = {title: "Menu", width: 200, height: 100});
alert( title ); // Menu
嵌套解构
如果一个对象或数组嵌套了其他的对象和数组,我们可以在等号左侧使用更复杂的模式(pattern)来提取更深层的数据。
在下面的代码中,options
的属性 size
是另一个对象,属性 items
是另一个数组。赋值语句中等号左侧的模式(pattern)具有相同的结构以从中提取值:
let options = {
size: {
width: 100,
height: 200
},
items: ["Cake", "Donut"],
extra: true
};
// 为了清晰起见,解构赋值语句被写成多行的形式
let {
size: { // 把 size 赋值到这里
width,
height
},
items: [item1, item2], // 把 items 赋值到这里
title = "Menu" // 在对象中不存在(使用默认值)
} = options;
alert(title); // Menu
alert(width); // 100
alert(height); // 200
alert(item1); // Cake
alert(item2); // Donut
最终,我们得到了 width
、height
、item1
、item2
和具有默认值的 title
变量。
注意,size
和 items
没有对应的变量,因为我们取的是它们的内容。
智能函数参数
有时,一个函数有很多参数,其中大部分的参数都是可选的。对用户界面来说更是如此。想象一个创建菜单的函数。它可能具有宽度参数,高度参数,标题参数和项目列表等。
下面是实现这种函数的一个很不好的写法:
function showMenu(title = "Untitled", width = 200, height = 100, items = []) {
// ...
}
在实际开发中,记忆如此多的参数的位置是一个很大的负担。通常集成开发环境(IDE)会尽力帮助我们,特别是当代码有良好的文档注释的时候,但是…… 另一个问题就是,在大部分的参数只需采用默认值的情况下,调用这个函数时会需要写大量的 undefined。
// 在采用默认值就可以的位置设置 undefined
showMenu("My Menu", undefined, undefined, ["Item1", "Item2"])
这太难看了。而且,当我们处理更多参数的时候可读性会变得很差。
解构赋值可以解决这些问题。
我们可以用一个对象来传递所有参数,而函数负责把这个对象解构成各个参数
// 我们传递一个对象给函数
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);
我们也可以使用带有嵌套对象和冒号映射的更加复杂的解构
let options = {
title: "My menu",
items: ["Item1", "Item2"]
};
function showMenu({
title = "Untitled",
width: w = 100, // width goes to w
height: h = 200, // height goes to h
items: [item1, item2] // items first element goes to item1, second to item2
}) {
alert( `${title} ${w} ${h}` ); // My Menu 100 200
alert( item1 ); // Item1
alert( item2 ); // Item2
}
showMenu(options);
请注意,这种解构假定了 showMenu()
函数确实存在参数。如果我们想让所有的参数都使用默认值,那我们应该传递一个空对象:
showMenu({}); // 不错,所有值都取默认值
showMenu(); // 这样会导致错误
我们可以通过指定空对象 {}
为整个参数对象的默认值来解决这个问题:
function showMenu({ title = "Menu", width = 100, height = 200 } = {}) {
alert( `${title} ${width} ${height}` );
}
showMenu(); // Menu 100 200
日期和时间
让我们来学习一个新的内建对象:日期(Date)。该对象存储日期和时间,并提供了日期/时间的管理方法。
我们可以使用它来存储创建/修改时间,测量时间,或者仅用来打印当前时间。
创建
调用 new Date()
来创建一个新的 Date
对象。在调用时可以带有一些参数,如下所示:
// new Date()
// 不带参数 —— 创建一个表示当前日期和时间的 `Date` 对象
let now = new Date();
alert( now ); // 显示当前的日期/时间
// new Date(milliseconds)
// 创建一个 `Date` 对象,其时间等于 1970 年 1 月 1 日 UTC+0 之后经过的毫秒数(1/1000 秒)
// 0 表示 01.01.1970 UTC+0
let Jan01_1970 = new Date(0);
alert( Jan01_1970 );
// 现在增加 24 小时,得到 02.01.1970 UTC+0
let Jan02_1970 = new Date(24 * 3600 * 1000);
alert( Jan02_1970 );
// new Date(datestring)
// 如果只有一个参数,并且是字符串,那么它会被自动解析。该算法与 `Date.parse` 所使用的算法相同,将在下文中进行介绍。
let date = new Date("2017-01-26");
alert(date);
// 未指定具体时间,所以假定时间为格林尼治标准时间(GMT)的午夜零点
// 并根据运行代码时的用户的时区进行调整
// 因此,结果可能是
// Thu Jan 26 2017 11:00:00 GMT+1100 (Australian Eastern Daylight Time)
// 或
// Wed Jan 25 2017 16:00:00 GMT-0800 (Pacific Standard Time)
// new Date(year, month, date, hours, minutes, seconds, ms)
// 使用当前时区中的给定组件创建日期。只有前两个参数是必须的。
// - `year` 应该是四位数。为了兼容性,也接受 2 位数,并将其视为 `19xx`,例如 `98` 与 `1998` 相同,但强烈建议始终使用 4 位数。
// - `month` 计数从 `0`(一月)开始,到 `11`(十二月)结束。
// - `date` 是当月的具体某一天,如果缺失,则为默认值 `1`。
// - 如果 `hours/minutes/seconds/ms` 缺失,则均为默认值 `0`。
new Date(2011, 0, 1, 0, 0, 0, 0); // 1 Jan 2011, 00:00:00
new Date(2011, 0, 1); // 同样,时分秒等均为默认值 0
// 时间度量最大精确到 1 毫秒(1/1000 秒)
new Date(2011, 0, 1, 2, 3, 4, 567);
传入的整数参数代表的是自 1970-01-01 00:00:00 以来经过的毫秒数,该整数被称为 时间戳。
这是一种日期的轻量级数字表示形式。我们通常使用 new Date(timestamp)
通过时间戳来创建日期,并可以使用 date.getTime()
将现有的 Date
对象转化为时间戳(下文会讲到)。
在 01.01.1970 之前的日期带有负的时间戳
访问日期组件
从 Date
对象中访问年、月等信息有多种方式:
getFullYear()
获取年份(4 位数)
getMonth()
获取月份,从 0 到 11。
getDate()
获取当月的具体日期,从 1 到 31,这个方法名称可能看起来有些令人疑惑。
getHours(),getMinutes(),getSeconds(),getMilliseconds()
获取相应的时间组件。
不是
getYear()
,而是getFullYear()
很多 JavaScript 引擎都实现了一个非标准化的方法getYear()
。不推荐使用这个方法。它有时候可能会返回 2 位的年份信息。永远不要使用它。要获取年份就使用getFullYear()
。
getDay()
获取一周中的第几天,从 0
(星期日)到 6
(星期六)。第一天始终是星期日,在某些国家可能不是这样的习惯,但是这不能被改变。
以上的所有方法返回的组件都是基于当地时区的。
当然,也有与当地时区的 UTC 对应项,它们会返回基于 UTC+0 时区的日、月、年等:getUTCFullYear(),getUTCMonth(),getUTCDay()。只需要在 "get"
之后插入 "UTC"
即可。
除了上述给定的方法,还有两个没有 UTC 变体的特殊方法:
getTime()
返回日期的时间戳 —— 从 1970-1-1 00:00:00 UTC+0 开始到现在所经过的毫秒数。
getTimezoneOffset()
返回 UTC 与本地时区之间的时差,以分钟为单位
设置日期组件
下列方法可以设置日期/时间组件:
setFullYear(year, [month], [date])
setMonth(month, [date])
setDate(date)
setHours(hour, [min], [sec], [ms])
setMinutes(min, [sec], [ms])
setSeconds(sec, [ms])
setMilliseconds(ms)
setTime(milliseconds)
(使用自 1970-01-01 00:00:00 UTC+0 以来的毫秒数来设置整个日期)
以上方法除了setTime()
都有 UTC 变体,例如:setUTCHours()
。
我们可以看到,有些方法可以一次性设置多个组件,比如setHours
。未提及的组件不会被修改。
自动校准
自动校准 是 Date
对象的一个非常方便的特性。我们可以设置超范围的数值,它会自动校准。
let date = new Date(2013, 0, 32); // 32 Jan 2013 ?!?
alert(date); // ……是 1st Feb 2013!
超出范围的日期组件将会被自动分配。
假设我们要在日期 “28 Feb 2016” 上加 2 天。结果可能是 “2 Mar” 或 “1 Mar”,因为存在闰年。但是我们不需要考虑这些,只需要直接加 2 天,剩下的 Date
对象会帮我们处理
let date = new Date(2016, 1, 28);
date.setDate(date.getDate() + 2);
alert( date ); // 1 Mar 2016
这个特性经常被用来获取给定时间段后的日期。例如,我们想获取“现在 70 秒后”的日期
let date = new Date();
date.setSeconds(date.getSeconds() + 70);
alert( date ); // 显示正确的日期信息
我们还可以设置 0 甚至可以设置负值。例如
let date = new Date(2016, 0, 2); // 2016 年 1 月 2 日
date.setDate(1); // 设置为当月的第一天
alert( date );
date.setDate(0); // 天数最小可以设置为 1,所以这里设置的是上一月的最后一天
alert( date ); // 31 Dec 2015
日期转换为数字
当 Date
对象被转化为数字时,得到的是对应的时间戳,与使用 date.getTime()
的结果相同
有一个重要的副作用:日期可以相减,相减的结果是以毫秒为单位时间差。
这个作用可以用于时间测量
let start = new Date(); // 开始测量时间
// do the job
for (let i = 0; i < 100000; i++) {
let doSomething = i * i * i;
}
let end = new Date(); // 结束测量时间
alert( `The loop took ${end - start} ms` );
Date.now()
如果我们仅仅想要测量时间间隔,我们不需要 Date
对象。
有一个特殊的方法 Date.now()
,它会返回当前的时间戳。
它相当于 new Date().getTime()
,但它不会创建中间的 Date
对象。因此它更快,而且不会对垃圾回收造成额外的压力。
这种方法很多时候因为方便,又或是因性能方面的考虑而被采用,例如使用 JavaScript 编写游戏或其他的特殊应用场景。
let start = Date.now(); // 从 1 Jan 1970 至今的时间戳
// do the job
for (let i = 0; i < 100000; i++) {
let doSomething = i * i * i;
}
let end = Date.now(); // 完成
alert( `The loop took ${end - start} ms` ); // 相减的是时间戳,而不是日期
基准测试Benchmarking
在对一个很耗 CPU 性能的函数进行可靠的基准测试(Benchmarking)时,我们需要谨慎一点。
例如,我们想判断以下两个计算日期差值的函数:哪个更快?
这种性能测量通常称为“基准测试(benchmark)”。
// 我们有 date1 和 date2,哪个函数会更快地返回两者的时间差?
function diffSubtract(date1, date2) {
return date2 - date1;
}
// or
function diffGetTime(date1, date2) {
return date2.getTime() - date1.getTime();
}
这两个函数做的事情完全相同,但是其中一个函数使用显式的 date.getTime()
来获取毫秒形式的日期,另一个则依赖于“日期 — 数字”的转换。它们的结果是一样的。
那么,哪个更快呢?
首先想到的方法可能是连续运行两者很多次,并计算所消耗的时间之差。就这个例子而言,函数过于简单,所以我们必须执行至少 100000 次。
function diffSubtract(date1, date2) {
return date2 - date1;
}
function diffGetTime(date1, date2) {
return date2.getTime() - date1.getTime();
}
function bench(f) {
let date1 = new Date(0);
let date2 = new Date();
let start = Date.now();
for (let i = 0; i < 100000; i++) f(date1, date2);
return Date.now() - start;
}
alert( 'Time of diffSubtract: ' + bench(diffSubtract) + 'ms' );
alert( 'Time of diffGetTime: ' + bench(diffGetTime) + 'ms' );
看起来使用 getTime()
这种方式快得多,这是因为它没有进行类型转换,对引擎优化来说更加简单。
我们得到了结论,但是这并不是一个很好的度量的例子。
想象一下当运行 bench(diffSubtract)
的同时,CPU 还在并行处理其他事务,并且这也会占用资源。然而,运行 bench(diffGetTime)
的时候,并行处理的事务完成了。
对于现代多进程操作系统来说,这是一个非常常见的场景。
比起第二个函数,第一个函数所能使用的 CPU 资源更少。这可能导致错误的结论。
为了得到更加可靠的度量,整个度量测试包应该重新运行多次。
function diffSubtract(date1, date2) {
return date2 - date1;
}
function diffGetTime(date1, date2) {
return date2.getTime() - date1.getTime();
}
function bench(f) {
let date1 = new Date(0);
let date2 = new Date();
let start = Date.now();
for (let i = 0; i < 100000; i++) f(date1, date2);
return Date.now() - start;
}
let time1 = 0;
let time2 = 0;
// 交替运行 bench(diffSubtract) 和 bench(diffGetTime) 各 10 次
for (let i = 0; i < 10; i++) {
time1 += bench(diffSubtract);
time2 += bench(diffGetTime);
}
alert( 'Total time for diffSubtract: ' + time1 );
alert( 'Total time for diffGetTime: ' + time2 );
现代的 JavaScript 引擎的先进优化策略只对执行很多次的 “hot code” 有效(对于执行很少次数的代码没有必要优化)。因此,在上面的例子中,第一次执行的优化程度不高。我们可能需要增加一个预热步骤:
// 在主循环中增加预热环节
bench(diffSubtract);
bench(diffGetTime);
// 开始度量
for (let i = 0; i < 10; i++) {
time1 += bench(diffSubtract);
time2 += bench(diffGetTime);
}
进行微型基准测试时要小心
现代的 JavaScript 引擎执行了很多优化。与正常编写的代码相比,它们可能会改变“人为编写的专用于测试的代码”的执行流程,特别是在我们对很小的代码片段进行基准测试时,例如某个运算符或内建函数的工作方式。因此,为了深入理解性能问题,请学习 JavaScript 引擎的工作原理。在那之后,你或许再也不需要进行微型基准测试了。
http://mrale.ph 提供了很多 V8 引擎相关的文章。
对字符串调用Date.prase
Date.parse(str) 方法可以从一个字符串中读取日期。
字符串的格式应该为:YYYY-MM-DDTHH:mm:ss.sssZ,其中:
YYYY-MM-DD —— 日期:年-月-日。
字符 "T" 是一个分隔符。
HH:mm:ss.sss —— 时间:小时,分钟,秒,毫秒。
可选字符 'Z' 为 +-hh:mm 格式的时区。单个字符 Z 代表 UTC+0 时区。
简短形式也是可以的,比如 YYYY-MM-DD 或 YYYY-MM,甚至可以是 YYYY。
Date.parse(str) 调用会解析给定格式的字符串,并返回时间戳(自 1970-01-01 00:00:00 起所经过的毫秒数)。如果给定字符串的格式不正确,则返回 NaN。
我们可以通过时间戳来立即创建一个 new Date
对象
let date = new Date( Date.parse('2012-01-26T13:51:50.417-07:00') );
alert(date);
Json
假设我们有一个复杂的对象,我们希望将其转换为字符串,以通过网络发送,或者只是为了在日志中输出它。
当然,这样的字符串应该包含所有重要的属性。
在开发过程中,会新增一些属性,旧的属性会被重命名和删除。每次更新这种 toString
都会非常痛苦。我们可以尝试遍历其中的属性,但是如果对象很复杂,并且在属性中嵌套了对象呢?我们也需要对它们进行转换。
JSON.stringify
JSON(JavaScript Object Notation)是表示值和对象的通用格式。在 RFC 4627 标准中有对其的描述。最初它是为 JavaScript 而创建的,但许多其他编程语言也有用于处理它的库。因此,当客户端使用 JavaScript 而服务器端是使用 Ruby/PHP/Java 等语言编写的时,使用 JSON 可以很容易地进行数据交换。
JavaScript 提供了如下方法:
JSON.stringify
将对象转换为 JSON。JSON.parse
将 JSON 转换回对象。
例如,在这里我们JSON.stringify
一个student
对象
let student = {
name: 'John',
age: 30,
isAdmin: false,
courses: ['html', 'css', 'js'],
spouse: null
};
let json = JSON.stringify(student);
alert(typeof json); // we've got a string!
alert(json);
/* JSON 编码的对象:
{
"name": "John",
"age": 30,
"isAdmin": false,
"courses": ["html", "css", "js"],
"spouse": null
}
*/
方法 JSON.stringify(student)
接收对象并将其转换为字符串。
得到的 json
字符串是一个被称为 JSON 编码(JSON-encoded) 或 序列化(serialized) 或 字符串化(stringified) 或 编组化(marshalled) 的对象。我们现在已经准备好通过有线发送它或将其放入普通数据存储。
请注意,JSON 编码的对象与对象字面量有几个重要的区别:
- 字符串使用双引号。JSON 中没有单引号或反引号。所以
'John'
被转换为"John"
。 - 对象属性名称也是双引号的。这是强制性的。所以
age:30
被转换成"age":30
。
JSON.stringify
也可以应用于原始(primitive)数据类型。
JSON 支持以下数据类型: - Objects
{ ... }
- Arrays
[ ... ]
- Primitives:
- strings,
- numbers,
- boolean values
true/false
, null
。
// 数字在 JSON 还是数字
alert( JSON.stringify(1) ) // 1
// 字符串在 JSON 中还是字符串,只是被双引号扩起来
alert( JSON.stringify('test') ) // "test"
alert( JSON.stringify(true) ); // true
alert( JSON.stringify([1, 2, 3]) ); // [1,2,3]
JSON 是语言无关的纯数据规范,因此一些特定于 JavaScript 的对象属性会被 JSON.stringify
跳过。
即:
- 函数属性(方法)。
- Symbol 类型的键和值。
- 存储
undefined
的属性。
let user = {
sayHi() { // 被忽略
alert("Hello");
},
[Symbol("id")]: 123, // 被忽略
something: undefined // 被忽略
};
alert( JSON.stringify(user) ); // {}(空对象)
通常这很好。如果这不是我们想要的方式,那么我们很快就会看到如何自定义转换方式。
最棒的是支持嵌套对象转换,并且可以自动对其进行转换。
重要的限制:不得有循环引用。
let room = {
number: 23
};
let meetup = {
title: "Conference",
participants: ["john", "ann"]
};
meetup.place = room; // meetup 引用了 room
room.occupiedBy = meetup; // room 引用了 meetup
JSON.stringify(meetup); // Error: Converting circular structure to JSON
排除和转换 replacer
JSON.stringify
的完整语法是
let json = JSON.stringify(value[, replacer, space])
value
要编码的值。
replacer
要编码的属性数组或映射函数 function(key, value)
。
space
用于格式化的空格数量。
大部分情况,JSON.stringify
仅与第一个参数一起使用。但是,如果我们需要微调替换过程,比如过滤掉循环引用,我们可以使用 JSON.stringify
的第二个参数。
如果我们传递一个属性数组给它,那么只有这些属性会被编码。
let room = {
number: 23
};
let meetup = {
title: "Conference",
participants: [{name: "John"}, {name: "Alice"}],
place: room // meetup 引用了 room
};
room.occupiedBy = meetup; // room 引用了 meetup
alert( JSON.stringify(meetup, ['title', 'participants']) );
// {"title":"Conference","participants":[{},{}]}
这里我们可能过于严格了。属性列表应用于了整个对象结构。所以 participants
是空的,因为 name
不在列表中。
让我们包含除了会导致循环引用的 room.occupiedBy
之外的所有属性
但是如果将其他内容包含进入,属性的列表太长了。
幸运的是,我们可以使用一个函数代替数组作为 replacer
。
该函数会为每个 (key,value)
对调用并返回“已替换”的值,该值将替换原有的值。如果值被跳过了,则为 undefined
。
let room = {
number: 23
};
let meetup = {
title: "Conference",
participants: [{name: "John"}, {name: "Alice"}],
place: room // meetup 引用了 room
};
room.occupiedBy = meetup; // room 引用了 meetup
alert( JSON.stringify(meetup, function replacer(key, value) {
alert(`${key}: ${value}`);
return (key == 'occupiedBy') ? undefined : value;
}));
/* key:value pairs that come to replacer:
: [object Object]
title: Conference
participants: [object Object],[object Object]
0: [object Object]
name: John
1: [object Object]
name: Alice
place: [object Object]
number: 23
occupiedBy: [object Object]
*/
请注意 replacer
函数会获取每个键/值对,包括嵌套对象和数组项。它被递归地应用。replacer
中的 this
的值是包含当前属性的对象。
第一个调用很特别。它是使用特殊的“包装对象”制作的:{"": meetup}
。换句话说,第一个 (key, value)
对的键是空的,并且该值是整个目标对象。这就是上面的示例中第一行是 ":[object Object]"
的原因。
这个理念是为了给 replacer
提供尽可能多的功能:如果有必要,它有机会分析并替换/跳过整个对象。
格式化 space
JSON.stringify(value, replacer, spaces)
的第三个参数是用于优化格式的空格数量。
以前,所有字符串化的对象都没有缩进和额外的空格。如果我们想通过网络发送一个对象,那就没什么问题。space
参数专门用于调整出更美观的输出。
这里的 space = 2
告诉 JavaScript 在多行中显示嵌套的对象,对象内部缩进 2 个空格
let user = {
name: "John",
age: 25,
roles: {
isAdmin: false,
isEditor: true
}
};
alert(JSON.stringify(user, null, 2));
/* 两个空格的缩进:
{
"name": "John",
"age": 25,
"roles": {
"isAdmin": false,
"isEditor": true
}
}
*/
/* 对于 JSON.stringify(user, null, 4) 的结果会有更多缩进:
{
"name": "John",
"age": 25,
"roles": {
"isAdmin": false,
"isEditor": true
}
}
*/
第三个参数也可以是字符串。在这种情况下,字符串用于缩进,而不是空格的数量。
spaces
参数仅用于日志记录和美化输出。
自定义 toJSON
像 toString
进行字符串转换,对象也可以提供 toJSON
方法来进行 JSON 转换。如果可用,JSON.stringify
会自动调用它。
let room = {
number: 23
};
let meetup = {
title: "Conference",
date: new Date(Date.UTC(2017, 0, 1)),
room
};
alert( JSON.stringify(meetup) );
/*
{
"title":"Conference",
"date":"2017-01-01T00:00:00.000Z", // (1)
"room": {"number":23} // (2)
}
*/
在这儿我们可以看到 date (1) 变成了一个字符串。这是因为所有日期都有一个内建的 toJSON 方法来返回这种类型的字符串。
现在让我们为对象 room 添加一个自定义的 toJSON
let room = {
number: 23,
toJSON() {
return this.number;
}
};
let meetup = {
title: "Conference",
room
};
alert( JSON.stringify(room) ); // 23
alert( JSON.stringify(meetup) );
/*
{
"title":"Conference",
"room": 23
}
*/
正如我们所看到的,toJSON
既可以用于直接调用 JSON.stringify(room)
也可以用于当 room
嵌套在另一个编码对象中时。
JSON.parse
let value = JSON.parse(str, [reviver]);
str
要解析的 JSON 字符串。
reviver
可选的函数 function(key,value),该函数将为每个 (key, value) 对调用,并可以对值进行转换。
let userData = '{ "name": "John", "age": 35, "isAdmin": false, "friends": [0,1,2,3] }';
let user = JSON.parse(userData);
alert( user.friends[1] ); // 1
JSON 可能会非常复杂,对象和数组可以包含其他对象和数组。但是它们必须遵循相同的 JSON 格式。
以下是手写 JSON 时的典型错误(有时我们必须出于调试目的编写它)
let json = `{
name: "John", // 错误:属性名没有双引号
"surname": 'Smith', // 错误:值使用的是单引号(必须使用双引号)
'isAdmin': false // 错误:键使用的是单引号(必须使用双引号)
"birthday": new Date(2000, 2, 3), // 错误:不允许使用 "new",只能是裸值
"friends": [0,1,2,3] // 这个没问题
}`;
此外,JSON 不支持注释。向 JSON 添加注释无效。
还有另一种名为 JSON5 的格式,它允许未加引号的键,也允许注释等。但这是一个独立的库,不在语言的规范中。
标准 JSON 格式之所以如此严格,并不是因为它的制定者们偷懒,而是为了能够简单,可靠且快速地实现解析算法。
使用reviver
想象一下,我们从服务器上获得了一个字符串化的 meetup
对象。
它看起来像这样
// title: (meetup title), date: (meetup date)
let str = '{"title":"Conference","date":"2017-11-30T12:00:00.000Z"}';
……现在我们需要对它进行 反序列(deserialize),把它转换回 JavaScript 对象。
让我们通过调用 JSON.parse
来完成
let str = '{"title":"Conference","date":"2017-11-30T12:00:00.000Z"}';
let meetup = JSON.parse(str);
alert( meetup.date.getDate() ); // Error!
啊!报错了!
meetup.date
的值是一个字符串,而不是 Date
对象。JSON.parse
怎么知道应该将字符串转换为 Date
呢?
让我们将 reviver 函数传递给 JSON.parse
作为第二个参数,该函数按照“原样”返回所有值,但是 date
会变成 Date
let str = '{"title":"Conference","date":"2017-11-30T12:00:00.000Z"}';
let meetup = JSON.parse(str, function(key, value) {
if (key == 'date') return new Date(value);
return value;
});
alert( meetup.date.getDate() ); // 现在正常运行了!
Btw,这也适用于嵌套对象
let schedule = `{
"meetups": [
{"title":"Conference","date":"2017-11-30T12:00:00.000Z"},
{"title":"Birthday","date":"2017-04-18T12:00:00.000Z"}
]
}`;
schedule = JSON.parse(schedule, function(key, value) {
if (key == 'date') return new Date(value);
return value;
});
alert( schedule.meetups[1].date.getDate() ); // 正常运行了!