Javascript.info-数据类型
对象允许存储键值集合,这很好。
对象允许存储键值集合,这很好。
但很多时候我们发现还需要 有序集合,里面的元素都是按顺序排列的。例如,我们可能需要存储一些列表,比如用户、商品以及 HTML 元素等。
这里使用对象就不是很方便了,因为对象不能提供能够管理元素顺序的方法。我们不能在已有的元素“之间”插入一个新的属性。这种场景下对象就不太适用了。
这时一个特殊的数据结构数组(Array)就派上用场了,它能存储有序的集合。
声明
创建一个空数组有两种语法:
let arr = new Array();
let arr = [];绝大多数情况下使用的都是第二种语法。我们可以在方括号中添加初始元素:
let fruits = ["Apple", "Orange", "Plum"];可以替换元素:
fruits[2] = 'Pear'; // 现在变成了 ["Apple", "Orange", "Pear"]……或者向数组新加一个元素:
fruits[3] = 'Lemon'; // 现在变成 ["Apple", "Orange", "Pear", "Lemon"]length 属性的值是数组中元素的总个数:
let fruits = ["Apple", "Orange", "Plum"];
alert( fruits.length ); // 3也可以用 alert 来显示整个数组。
let fruits = ["Apple", "Orange", "Plum"];
alert( fruits ); // Apple,Orange,Plum以逗号结尾
数组就像对象一样,可以以逗号结尾:
let fruits = [
"Apple",
"Orange",
"Plum",
];因为每一行都是相似的,所以这种以“逗号结尾”的方式使得插入/移除项变得更加简单。
使用 “at” 获取最后一个元素
假设我们想要数组的最后一个元素。
一些编程语言允许我们使用负数索引来实现这一点,例如 fruits[-1]。
但在 JavaScript 中这行不通。结果将是 undefined,因为方括号中的索引是被按照其字面意思处理的。
我们可以显式地计算最后一个元素的索引,然后访问它:fruits[fruits.length - 1]。
let fruits = ["Apple", "Orange", "Plum"];
alert( fruits[fruits.length-1] ); // Plum有点麻烦,不是吗?我们需要写两次变量名。
幸运的是,这里有一个更简短的语法 fruits.at(-1):
let fruits = ["Apple", "Orange", "Plum"];
// 与 fruits[fruits.length-1] 相同
alert( fruits.at(-1) ); // Plumpop/push, shift/unshift 方法
作用于数组末端的方法:
-
pop取出并返回数组的最后一个元素
-
push在数组末端添加元素
作用于数组首端的方法:
-
shift取出数组的第一个元素并返回它
-
unshift
push 和 unshift 方法都可以一次添加多个元素:
let fruits = ["Apple"];
fruits.push("Orange", "Peach");
fruits.unshift("Pineapple", "Lemon");
// ["Pineapple", "Lemon", "Apple", "Orange", "Peach"]
alert( fruits );array
数组是一种特殊的对象。使用方括号来访问属性 arr[0] 实际上是来自于对象的语法。它其实与 obj[key] 相同,其中 arr 是对象,而数字用作键(key)。
它们扩展了对象,提供了特殊的方法来处理有序的数据集合以及 length 属性。但从本质上讲,它仍然是一个对象。
记住,在 JavaScript 中只有 8 种基本的数据类型。数组是一个对象,因此其行为也像一个对象。
……但是数组真正特殊的是它们的内部实现。JavaScript 引擎尝试把这些元素一个接一个地存储在连续的内存区域,就像本章的插图显示的一样,而且还有一些其它的优化,以使数组运行得非常快。
但是,如果我们不像“有序集合”那样使用数组,而是像常规对象那样使用数组,这些就都不生效了。
例如,从技术上讲,我们可以这样做:
let fruits = []; // 创建一个数组
fruits[99999] = 5; // 分配索引远大于数组长度的属性
fruits.age = 25; // 创建一个具有任意名称的属性这是可以的,因为数组是基于对象的。我们可以给它们添加任何属性。
但是 Javascript 引擎会发现,我们在像使用常规对象一样使用数组,那么针对数组的优化就不再适用了,然后对应的优化就会被关闭,这些优化所带来的优势也就荡然无存了。
循环
遍历数组最古老的方式就是 for 循环:
let arr = ["Apple", "Orange", "Pear"];
for (let i = 0; i < arr.length; i++) {
alert( arr[i] );
}但对于数组来说还有另一种循环方式,for..of:
let fruits = ["Apple", "Orange", "Plum"];
// 遍历数组元素
for (let fruit of fruits) {
alert( fruit );
}for..of 不能获取当前元素的索引,只是获取元素值,但大多数情况是够用的。而且这样写更短。
技术上来讲,因为数组也是对象,所以使用 for..in 也是可以的:
let arr = ["Apple", "Orange", "Pear"];
for (let key in arr) {
alert( arr[key] ); // Apple, Orange, Pear
}但这其实是一个很不好的想法。会有一些潜在问题存在:
-
for..in循环会遍历 所有属性,不仅仅是这些数字属性。在浏览器和其它环境中有一种称为“类数组”的对象,它们 看似是数组。也就是说,它们有
length和索引属性,但是也可能有其它的非数字的属性和方法,这通常是我们不需要的。for..in循环会把它们都列出来。所以如果我们需要处理类数组对象,这些“额外”的属性就会存在问题。 -
for..in循环适用于普通对象,并且做了对应的优化。但是不适用于数组,因此速度要慢 10-100 倍。当然即使是这样也依然非常快。只有在遇到瓶颈时可能会有问题。但是我们仍然应该了解这其中的不同。
通常来说,我们不应该用 for..in 来处理数组。
关于 “length”
当我们修改数组的时候,length 属性会自动更新。准确来说,它实际上不是数组里元素的个数,而是最大的数字索引值加一。
例如,一个数组只有一个元素,但是这个元素的索引值很大,那么这个数组的 length 也会很大:
let fruits = [];
fruits[123] = "Apple";
alert( fruits.length ); // 124length 属性的另一个有意思的点是它是可写的。
如果我们手动增加它,则不会发生任何有趣的事儿。但是如果我们减少它,数组就会被截断。该过程是不可逆的,下面是例子:
let arr = [1, 2, 3, 4, 5];
arr.length = 2; // 截断到只剩 2 个元素
alert( arr ); // [1, 2]
arr.length = 5; // 又把 length 加回来
alert( arr[3] ); // undefined:被截断的那些数值并没有回来所以,清空数组最简单的方法就是: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为了避免这种意外情况,我们通常使用方括号
toString
数组有自己的 toString 方法的实现,会返回以逗号隔开的元素列表。
例如:
let arr = [1, 2, 3];
alert( arr ); // 1,2,3
alert( String(arr) === '1,2,3' ); // true不会对数组进行特殊处理,它会像处理任意对象那样处理数组。
数组方法
数组提供的方法有很多。为了方便起见,在本章中,我们将按组讲解。
添加/移除数组元素
我们已经学了从数组的首端或尾端添加和删除元素的方法:
arr.push(...items)—— 从尾端添加元素,arr.pop()—— 从尾端提取元素,arr.shift()—— 从首端提取元素,arr.unshift(...items)—— 从首端添加元素。
这里还有其他几种方法。
splice
如何从数组中删除元素?
数组是对象,所以我们可以尝试使用 delete:
let arr = ["I", "go", "home"];
delete arr[1]; // remove "go"
alert( arr[1] ); // undefined
// now arr = ["I", , "home"];
alert( arr.length ); // 3元素被删除了,但数组仍然有 3 个元素,我们可以看到 arr.length == 3。
这很正常,因为 delete obj.key 是通过 key 来移除对应的值。对于对象来说是可以的。但是对于数组来说,我们通常希望剩下的元素能够移动并占据被释放的位置。我们希望得到一个更短的数组。
所以应该使用特殊的方法。
arr.splice 方法可以说是处理数组的瑞士军刀。它可以做所有事情:添加,删除和插入元素。
语法是:
arr.splice(start[, deleteCount, elem1, ..., elemN])它从索引 start 开始修改 arr:删除 deleteCount 个元素并在当前位置插入 elem1, ..., elemN。最后返回被删除的元素所组成的数组。
通过例子我们可以很容易地掌握这个方法。
让我们从删除开始:
let arr = ["I", "study", "JavaScript"];
arr.splice(1, 1); // 从索引 1 开始删除 1 个元素
alert( arr ); // ["I", "JavaScript"]简单,对吧?从索引 1 开始删除 1 个元素。(译注:当只填写了 splice 的 start 参数时,将删除从索引 start 开始的所有数组项)
在下一个例子中,我们删除了 3 个元素,并用另外两个元素替换它们:
let arr = ["I", "study", "JavaScript", "right", "now"];
// 删除数组的前三项,并使用其他内容代替它们
arr.splice(0, 3, "Let's", "dance");
alert( arr ) // 现在 ["Let's", "dance", "right", "now"]在这里我们可以看到 splice 返回了被删除的元素所组成的数组:
let arr = ["I", "study", "JavaScript", "right", "now"];
// 删除前两个元素
let removed = arr.splice(0, 2);
alert( removed ); // "I", "study" <-- 被从数组中删除了的元素我们可以将 deleteCount 设置为 0,splice 方法就能够插入元素而不用删除任何元素:
let arr = ["I", "study", "JavaScript"];
// 从索引 2 开始
// 删除 0 个元素
// 然后插入 "complex" 和 "language"
arr.splice(2, 0, "complex", "language");
alert( arr ); // "I", "study", "complex", "language", "JavaScript"允许负向索引
在这里和其他数组方法中,负向索引都是被允许的。它们从数组末尾计算位置,如下所示:
let arr = [1, 2, 5];
// 从索引 -1(尾端前一位)
// 删除 0 个元素,
// 然后插入 3 和 4
arr.splice(-1, 0, 3, 4);
alert( arr ); // 1,2,3,4,5slice
arr.slice 方法比 arr.splice 简单得多。
语法是:
arr.slice([start], [end])它会返回一个新数组,将所有从索引 start 到 end(不包括 end)的数组项复制到一个新的数组。start 和 end 都可以是负数,在这种情况下,从末尾计算索引。
它和字符串的 str.slice 方法有点像,就是把子字符串替换成子数组。
例如:
let arr = ["t", "e", "s", "t"];
alert( arr.slice(1, 3) ); // e,s(复制从位置 1 到位置 3 的元素)
alert( arr.slice(-2) ); // s,t(复制从位置 -2 到尾端的元素)我们也可以不带参数地调用它:arr.slice() 会创建一个 arr 的副本。其通常用于获取副本,以进行不影响原始数组的进一步转换。
concat
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 方法允许为数组的每个元素都运行一个函数。
语法:
arr.forEach(function(item, index, array) {
// ... do something with item
});例如,下面这个程序显示了数组的每个元素:
// 对每个元素调用 alert
["Bilbo", "Gandalf", "Nazgul"].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 并停止迭代
// 对于假值(falsy)的情况,则返回 undefined
});依次对数组中的每个元素调用该函数:
item是元素。index是它的索引。array是数组本身。
如果它返回 true,则搜索停止,并返回 item。如果没有搜索到,则返回 undefined。
例如,我们有一个存储用户的数组,每个用户都有 id 和 name 字段。让我们找到 id == 1 的那个用户:
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在现实生活中,对象数组是很常见的,所以 find 方法非常有用。
注意在这个例子中,我们传给了 find 一个单参数函数 item => item.id == 1。这很典型,并且 find 方法的其他参数很少使用。
arr.findIndex 方法(与 arr.find)具有相同的语法,但它返回找到的元素的索引,而不是元素本身。如果没找到,则返回 -1。
arr.findLastIndex 方法类似于 findIndex,但从右向左搜索,类似于 lastIndexOf。
这是一个例子:
let users = [
{id: 1, name: "John"},
{id: 2, name: "Pete"},
{id: 3, name: "Mary"},
{id: 4, name: "John"}
];
// 寻找第一个 John 的索引
alert(users.findIndex(user => user.name == 'John')); // 0
// 寻找最后一个 John 的索引
alert(users.findLastIndex(user => user.name == 'John')); // 3filter
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
arr.map 方法是最有用和经常使用的方法之一。
它对数组的每个元素都调用函数,并返回结果数组。
语法:
let result = arr.map(function(item, index, array) {
// 返回新值而不是当前元素
})例如,在这里我们将每个元素转换为它的字符串长度:
let lengths = ["Bilbo", "Gandalf", "Nazgul"].map(item => item.length);
alert(lengths); // 5,7,6sort(fn)
arr.sort 方法对数组进行 原位(in-place) 排序,更改元素的顺序。(译注:原位是指在此数组内,而非生成一个新数组。)
它还返回排序后的数组,但是返回值通常会被忽略,因为修改了 arr 本身。
语法:
let arr = [ 1, 2, 15 ];
// 该方法重新排列 arr 的内容
arr.sort();
alert( arr ); // 1, 15, 2你有没有注意到结果有什么奇怪的地方?
顺序变成了 1, 15, 2。不对,但为什么呢?
这些元素默认情况下被按字符串进行排序。
从字面上看,所有元素都被转换为字符串,然后进行比较。对于字符串,按照词典顺序进行排序,实际上应该是 "2" > "15"。
要使用我们自己的排序顺序,我们需要提供一个函数作为 arr.sort() 的参数。
该函数应该比较两个任意值并返回:
function compare(a, b) {
if (a > b) return 1; // 如果第一个值比第二个值大
if (a == b) return 0; // 如果两个值相等
if (a < b) return -1; // 如果第一个值比第二个值小
}例如,按数字进行排序:
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) 方法实现了通用的排序算法。我们不需要关心它的内部工作原理。它将遍历数组,使用提供的函数比较其元素并对其重新排序,我们所需要的就是提供执行比较的函数 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
arr.reverse 方法用于颠倒 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,tarr.join(glue) 与 split 相反。它会在它们之间创建一串由 glue 粘合的 arr 项。
例如:
let arr = ['Bilbo', 'Gandalf', 'Nazgul'];
let str = arr.join(';'); // 使用分号 ; 将数组粘合成字符串
alert( str ); // Bilbo;Gandalf;Nazgulreduce/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 个参数,通常这就足够了。
我们也可以省略初始值:
let arr = [1, 2, 3, 4, 5];
// 删除 reduce 的初始值(没有 0)
let result = arr.reduce((sum, current) => sum + current);
alert( result ); // 15结果是一样的。这是因为如果没有初始值,那么 reduce 会将数组的第一个元素作为初始值,并从第二个元素开始迭代。
但是这种使用需要非常小心。如果数组为空,那么在没有初始值的情况下调用 reduce 会导致错误。
例如:
let arr = [];
// Error: Reduce of empty array with no initial value
// 如果初始值存在,则 reduce 将为空 arr 返回它(即这个初始值)。
arr.reduce((sum, current) => sum + current);所以建议始终指定初始值。
Array.isArray
数组是基于对象的,不构成单独的语言类型。
所以 typeof 不能帮助从数组中区分出普通对象:
alert(typeof {}); // object
alert(typeof []); // object(相同)……但是数组经常被使用,因此有一种特殊的方法用于判断:Array.isArray(value)。如果 value 是一个数组,则返回 true;否则返回 false。
alert(Array.isArray({})); // false
alert(Array.isArray([])); // true大多数方法都支持 “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); // 23Iterable object(可迭代对象)
可迭代(Iterable) 对象是数组的泛化。这个概念是说任何对象都可以被定制为可在 for..of 循环中使用的对象。
数组是可迭代的。但不仅仅是数组。很多其他内建对象也都是可迭代的。例如字符串也是可迭代的。
如果从严格意义上讲,对象不是数组,而是表示某物的集合(列表,集合),for..of 是一个能够遍历它的很好的语法,因此,让我们来看看如何使其发挥作用。
Symbol.iterator
通过自己创建一个对象,我们就可以轻松地掌握可迭代的概念。
例如,我们有一个对象,它并不是数组,但是看上去很适合使用 for..of 循环。
比如一个 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是下一个值。
这是带有注释的 range 的完整实现:
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 (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 属性)。
下面这个对象则是类数组的,但是不可迭代:
let arrayLike = { // 有索引和 length 属性 => 类数组对象
0: "Hello",
1: "World",
length: 2
};
// Error (no Symbol.iterator)
for (let item of arrayLike) {}可迭代对象和类数组对象通常都 不是数组,它们没有 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。
例如:
// 假设 range 来自上文例子中
// 求每个数的平方
let arr = Array.from(range, num => num * num);
alert(arr); // 1,4,9,16,25现在我们用 Array.from 将一个字符串转换为单个字符的数组:
let str = '𝒳😂';
// 将 str 拆分为字符数组
let chars = Array.from(str);
alert(chars[0]); // 𝒳
alert(chars[1]); // 😂
alert(chars.length); // 2与 str.split 方法不同,它依赖于字符串的可迭代特性。因此,就像 for..of 一样,可以正确地处理代理对(surrogate pair)。
技术上来讲,它和下面这段代码做的是相同的事:
let str = '𝒳😂';
let chars = []; // Array.from 内部执行相同的循环
for (let char of str) {
chars.push(char);
}
alert(chars);……但 Array.from 精简很多。
Map and Set(映射和集合)
学到现在,我们已经了解了以下复杂的数据结构:
- 对象,存储带有键的数据的集合。
- 数组,存储有序集合。
但这还不足以应对现实情况。这就是为什么存在 Map 和 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 最值得注意和重要的功能之一。
我们来尝试一下:
let john = { name: "John" };
let ben = { name: "Ben" };
let visitsCountObj = {}; // 尝试使用对象
visitsCountObj[ben] = 234; // 尝试将对象 ben 用作键
visitsCountObj[john] = 123; // 尝试将对象 john 用作键,但我们会发现使用对象 ben 作为键存下的值会被替换掉
// 变成这样了!
alert( visitsCountObj["[object Object]"] ); // 123Map 是怎么比较键的?
Map 使用 SameValueZero 算法来比较键是否相等。它和严格等于 === 差不多,但区别是 NaN 被看成是等于 NaN。所以 NaN 也可以被用作键。
这个算法不能被改变或者自定义。
链式调用
每一次 map.set 调用都会返回 map 本身,所以我们可以进行“链式”调用:
map.set('1', 'str1')
.set(1, 'num1')
.set(true, 'bool1');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)(*)
// 完成了!
// obj = { banana: 1, orange: 2, meat: 4 }
alert(obj.orange); // 2调用 map.entries() 将返回一个可迭代的键/值对,这刚好是 Object.fromEntries 所需要的格式。
我们可以把带 (*) 这一行写得更短:
let obj = Object.fromEntries(map); // 省掉 .entries()上面的代码作用也是一样的,因为 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 迭代(iteration)
我们可以使用 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 and WeakSet(弱映射和弱集合)
JavaScript 引擎在值“可达”和可能被使用时会将其保持在内存中。
例如:
let john = { name: "John" };
// 该对象能被访问,john 是它的引用
// 覆盖引用
john = null;
// 该对象将会被从内存中清除通常,当对象、数组之类的数据结构在内存中时,它们的子元素,如对象的属性、数组的元素都被认为是可达的。
例如,如果把一个对象放入到数组中,那么只要这个数组存在,那么这个对象也就存在,即使没有其他对该对象的引用。
就像这样:
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 的键必须是对象,不能是原始值:
let weakMap = new WeakMap();
let obj = {};
weakMap.set(obj, "ok"); // 正常工作(以对象作为键)
// 不能使用字符串作为键
weakMap.set("test", "Whoops"); // Error,因为 "test" 不是一个对象现在,如果我们在 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),那么它就会被垃圾回收机制自动回收。但是在从技术的角度并不能准确知道 何时会被回收。
使用案例:额外的数据
WeakMap 的主要应用场景是 额外数据的存储。
假如我们正在处理一个“属于”另一个代码的一个对象,也可能是第三方库,并想存储一些与之相关的数据,那么这些数据就应该与这个对象共存亡 —— 这时候 WeakMap 正是我们所需要的利器。
我们将这些数据放到 WeakMap 中,并使用该对象作为这些数据的键,那么当该对象被垃圾回收机制回收后,这些数据也会被自动清除。
weakMap.set(john, "secret documents");
// 如果 john 消失,secret documents 将会被自动清除让我们来看一个例子。
例如,我们有用于处理用户访问计数的代码。收集到的信息被存储在 map 中:一个用户对象作为键,其访问次数为值。当一个用户离开时(该用户对象将被垃圾回收机制回收),这时我们就不再需要他的访问次数了。
下面是一个使用 Map 的计数函数的例子:
// 📁 visitsCount.js
let visitsCountMap = new Map(); // map: user => visits count
// 递增用户来访次数
function countUser(user) {
let count = visitsCountMap.get(user) || 0;
visitsCountMap.set(user, count + 1);
}下面是其他部分的代码,可能是使用它的其它代码:
// 📁 main.js
let john = { name: "John" };
countUser(john); // count his visits
// 不久之后,john 离开了
john = null;现在,john 这个对象应该被垃圾回收,但它仍在内存中,因为它是 visitsCountMap 中的一个键。
当我们移除用户时,我们需要清理 visitsCountMap,否则它将在内存中无限增大。在复杂的架构中,这种清理会成为一项繁重的任务。
我们可以通过使用 WeakMap 来避免这样的问题:
// 📁 visitsCount.js
let visitsCountMap = new WeakMap(); // weakmap: user => visits count
// 递增用户来访次数
function countUser(user) {
let count = visitsCountMap.get(user) || 0;
visitsCountMap.set(user, count + 1);
}现在我们不需要去清理 visitsCountMap 了。当 john 对象变成不可达时,即便它是 WeakMap 里的一个键,它也会连同它作为 WeakMap 里的键所对应的信息一同被从内存中删除。
使用案例:缓存
另外一个常见的例子是缓存。我们可以存储(“缓存”)函数的结果,以便将来对同一个对象的调用可以重用这个结果。
为了实现这一点,我们可以使用 Map(非最佳方案):
// 📁 cache.js
let cache = new Map();
// 计算并记住结果
function process(obj) {
if (!cache.has(obj)) {
let result = /* calculations of the result for */ obj;
cache.set(obj, result);
}
return cache.get(obj);
}
// 现在我们在其它文件中使用 process()
// 📁 main.js
let obj = {/* 假设我们有个对象 */};
let result1 = process(obj); // 计算完成
// ……稍后,来自代码的另外一个地方……
let result2 = process(obj); // 取自缓存的被记忆的结果
// ……稍后,我们不再需要这个对象时:
obj = null;
alert(cache.size); // 1(啊!该对象依然在 cache 中,并占据着内存!)对于多次调用同一个对象,它只需在第一次调用时计算出结果,之后的调用可以直接从 cache 中获取。这样做的缺点是,当我们不再需要这个对象的时候需要清理 cache。
如果我们用 WeakMap 替代 Map,便不会存在这个问题。当对象被垃圾回收时,对应缓存的结果也会被自动从内存中清除。
// 📁 cache.js
let cache = new WeakMap();
// 计算并记结果
function process(obj) {
if (!cache.has(obj)) {
let result = /* calculate the result for */ obj;
cache.set(obj, result);
}
return cache.get(obj);
}
// 📁 main.js
let obj = {/* some object */};
let result1 = process(obj);
let result2 = process(obj);
// ……稍后,我们不再需要这个对象时:
obj = null;
// 无法获取 cache.size,因为它是一个 WeakMap,
// 要么是 0,或即将变为 0
// 当 obj 被垃圾回收,缓存的数据也会被清除WeakSet
WeakSet 的表现类似:
- 与
Set类似,但是我们只能向WeakSet添加对象(而不能是原始值)。 - 对象只有在其它某个(些)地方能被访问的时候,才能留在
WeakSet中。 - 跟
Set一样,WeakSet支持add,has和delete方法,但不支持size和keys(),并且不可迭代。
变“弱(weak)”的同时,它也可以作为额外的存储空间。但并非针对任意数据,而是针对“是/否”的事实。WeakSet 的元素可能代表着有关该对象的某些信息。
例如,我们可以将用户添加到 WeakSet 中,以追踪访问过我们网站的用户:
let visitedSet = new WeakSet();
let john = { name: "John" };
let pete = { name: "Pete" };
let mary = { name: "Mary" };
visitedSet.add(john); // John 访问了我们
visitedSet.add(pete); // 然后是 Pete
visitedSet.add(john); // John 再次访问
// visitedSet 现在有两个用户了
// 检查 John 是否来访过?
alert(visitedSet.has(john)); // true
// 检查 Mary 是否来访过?
alert(visitedSet.has(mary)); // false
john = null;
// visitedSet 将被自动清理(即自动清除其中已失效的值 john)WeakMap 和 WeakSet 最明显的局限性就是不能迭代,并且无法获取所有当前内容。那样可能会造成不便,但是并不会阻止 WeakMap/WeakSet 完成其主要工作 —— 为在其它地方存储/管理的对象数据提供“额外”存储。
Object.keys,values,entries
对各个数据结构的学习至此告一段落,下面让我们讨论一下如何迭代它们。
在前面的章节中,我们认识了 map.keys(),map.values() 和 map.entries() 方法。
这些方法是通用的,有一个共同的约定来将它们用于各种数据结构。如果我们创建一个我们自己的数据结构,我们也应该实现这些方法。
它们支持:
MapSetArray
普通对象也支持类似的方法,但是语法上有一些不同。
Object.keys,values,entries
对于普通对象,下列这些方法是可用的:
- 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
};Object.keys(user) = ["name", "age"]Object.values(user) = ["John", 30]Object.entries(user) = [ ["name","John"], ["age",30] ]
这里有一个使用 Object.values 来遍历属性值的例子:
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)方法,将结果转回成对象。
例如,我们有一个带有价格的对象,并想将它们加倍:
let prices = {
banana: 1,
orange: 2,
meat: 4,
};
let doublePrices = Object.fromEntries(
// 将价格转换为数组,将每个键/值对映射为另一对
// 然后通过 fromEntries 再将结果转换为对象
Object.entries(prices).map(entry => [entry[0], entry[1] * 2])
);
alert(doublePrices.meat); // 8乍一看,可能感觉有点困难,但是使用一两次之后就很容易理解了。我们可以通过这种方式建立强大的转换链。
解构赋值
JavaScript 中最常用的两种数据结构是 Object 和 Array。
- 对象是一种根据键存储数据的实体。
- 数组是一种直接存储数据的有序列表。
但是,当我们把它们传递给函数时,函数可能不需要整个对象/数组,而只需要其中一部分。
解构赋值 是一种特殊的语法,它使我们可以将数组或对象“拆包”至一系列变量中。有时这样做更方便。
解构操作对那些具有很多参数和默认值等的函数也很奏效。下面有一些例子。
数组解构
这是一个将数组解构到变量中的例子:
// 我们有一个存放了名字和姓氏的数组
let arr = ["John", "Smith"]
// 解构赋值
// 设置 firstName = arr[0]
// 以及 surname = arr[1]
let [firstName, surname] = arr;
alert(firstName); // John
alert(surname); // Smith“解构”并不意味着“破坏”
这种语法被叫做“解构赋值”,是因为它“拆开”了数组或对象,将其中的各元素复制给一些变量。原来的数组或对象自身没有被修改。
换句话说,解构赋值只是写起来简洁一点。以下两种写法是等价的:
// let [firstName, surname] = arr;
let firstName = arr[0];
let surname = arr[1];忽略使用逗号的元素
可以通过添加额外的逗号来丢弃数组中不想要的元素:
// 不需要第二个元素
let [firstName, , title] = ["Julius", "Caesar", "Consul", "of the Roman Republic"];
alert( title ); // Consul等号右侧可以是任何可迭代对象
……实际上,我们可以将其与任何可迭代对象一起使用,而不仅限于数组:
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] = ["Julius", "Caesar", "Consul", "of the Roman Republic"];
alert(name1); // Julius
alert(name2); // Caesar
// 其余数组项未被分配到任何地方如果我们还想收集其余的数组项 —— 我们可以使用三个点 "..." 来再加一个参数以获取其余数组项:
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); // 2rest 的值就是数组中剩下的元素组成的数组。
默认值
如果数组比左边的变量列表短,这里不会出现报错。缺少对应值的变量都会被赋 undefined:
let [firstName, surname] = [];
alert(firstName); // undefined
alert(surname); // 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)”。在最简单的情况下,等号左侧的就是 {...} 中的变量名列表。
如下所示:
let options = {
title: "Menu",
width: 100,
height: 200
};
let {title, width, height} = options;
alert(title); // Menu
alert(width); // 100
alert(height); // 200属性 options.title、options.width 和 options.height 值被赋给了对应的变量。
变量的顺序并不重要,下面这个代码也是等价的:
// 改变 let {...} 中元素的顺序
let {height, width, title} = { title: "Menu", height: 200, width: 100 }等号左侧的模式(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 = 100, height = 200, title} = options;
alert(title); // Menu
alert(width); // 100
alert(height); // 200就像数组或函数参数一样,默认值可以是任意表达式甚至可以是函数调用。它们只会在未提供对应的值时才会被计算/调用。
在下面的代码中,prompt 提示输入 width 值,但不会提示输入 title 值:
let options = {
title: "Menu"
};
let {width = prompt("width?"), title = prompt("title?")} = options;
alert(title); // Menu
alert(width); // (prompt 的返回值)我们还可以将冒号和等号结合起来:
let options = {
title: "Menu"
};
let {width: w = 100, height: h = 200, title} = options;
alert(title); // Menu
alert(w); // 100
alert(h); // 200如果我们有一个具有很多属性的复杂对象,那么我们可以只提取所需的内容:
let options = {
title: "Menu",
width: 100,
height: 200
};
// 仅提取 title 作为变量
let { title } = options;
alert(title); // Menu剩余模式(pattern)“…”
如果对象拥有的属性数量比我们提供的变量数量还多,该怎么办?我们可以只取其中的某一些属性,然后把“剩余的”赋值到其他地方吗?
我们可以使用剩余模式(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对象 options 的所有属性,除了 extra 属性在等号左侧不存在,都被赋值给了对应的变量:
最终,我们得到了 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);完整语法和解构赋值是一样的:
function({
incomingProperty: varName = defaultValue
...
})对于参数对象,属性 incomingProperty 对应的变量是 varName,默认值是 defaultValue。
请注意,这种解构假定了 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对象: -
new Date(milliseconds)创建一个
Date对象,其时间等于 1970 年 1 月 1 日 UTC+0 之后经过的毫秒数(1/1000 秒)。 -
new Date(datestring)如果只有一个参数,并且是字符串,那么它会被自动解析。该算法与
Date.parse所使用的算法相同,将在下文中进行介绍。 -
new Date(year, month, date, hours, minutes, seconds, ms)使用当前时区中的给定组件创建日期。只有前两个参数是必须的。
year应该是四位数。为了兼容性,也接受 2 位数,并将其视为19xx,例如98与1998相同,但强烈建议始终使用 4 位数。month计数从0(一月)开始,到11(十二月)结束。date是当月的具体某一天,如果缺失,则为默认值1。
访问日期组件
从 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 有偏移,那么下面代码会显示不同的小时数:
// 当前日期
let date = new Date();
// 在 UTC+0 时区的小时数(非夏令时的伦敦时间)
alert( date.getUTCHours() );//3
// 当地时区的小时数
alert( date.getHours() );//11 UTC+8除了上述给定的方法,还有两个没有 UTC 变体的特殊方法:
-
getTime()
返回日期的时间戳 —— 从 1970-1-1 00:00:00 UTC+0 开始到现在所经过的毫秒数。
-
getTimezoneOffset()
返回 UTC 与本地时区之间的时差,以分钟为单位:
js// 如果你在时区 UTC-1,输出 60 // 如果你在时区 UTC+3,输出 -180 alert( new Date().getTimezoneOffset() );//-48
设置日期组件
下列方法可以设置日期/时间组件:
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)时间戳
以上方法除了 setTime() 都有 UTC 变体,例如:setUTCHours()。
我们可以看到,有些方法可以一次性设置多个组件,比如 setHours。未提及的组件不会被修改。
举个例子:
let today = new Date();
today.setHours(0);
alert(today); // 日期依然是今天,但是小时数被改为了 0
today.setHours(0, 0, 0, 0);
alert(today); // 日期依然是今天,时间为 00:00:00。自动校准(Autocorrection)
自动校准 是 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 date = new Date();
alert(+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 对象。因此它更快,而且不会对垃圾回收造成额外的压力。
因此这样做可能会更好:
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` ); // 相减的是时间戳,而不是日期对字符串调用 Date.parse
Date.parse(str) 方法可以从一个字符串中读取日期。
字符串的格式应该为:YYYY-MM-DDTHH:mm:ss.sssZ,其中:
YYYY-MM-DD—— 日期:年-月-日。- 字符
"T"是一个分隔符。 HH:mm:ss.sss—— 时间:小时,分钟,秒,毫秒。- 可选字符
'Z'为+-hh:mm格式的时区。单个字符Z代表 UTC+0 时区。(中国使用+08:00)
简短形式也是可以的,比如 YYYY-MM-DD 或 YYYY-MM,甚至可以是 YYYY。
Date.parse(str) 调用会解析给定格式的字符串,并返回时间戳(自 1970-01-01 00:00:00 起所经过的毫秒数)。如果给定字符串的格式不正确,则返回 NaN。

JSON 方法,toJSON
假设我们有一个复杂的对象,我们希望将其转换为字符串,以通过网络发送,或者只是为了在日志中输出它。
当然,这样的字符串应该包含所有重要的属性。
我们可以像这样实现转换:
let user = {
name: "John",
age: 30,
toString() {
return `{name: "${this.name}", age: ${this.age}}`;
}
};
alert(user); // {name: "John", age: 30}……但在开发过程中,会新增一些属性,旧的属性会被重命名和删除。每次更新这种 toString 都会非常痛苦。我们可以尝试遍历其中的属性,但是如果对象很复杂,并且在属性中嵌套了对象呢?我们也需要对它们进行转换。
幸运的是,不需要编写代码来处理所有这些问题。这项任务已经解决了。
JSON.stringify
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 meetup = {
title: "Conference",
room: {
number: 23,
participants: ["john", "ann"]
}
};
alert( JSON.stringify(meetup) );
/* 整个结构都被字符串化了
{
"title":"Conference",
"room":{"number":23,"participants":["john","ann"]},
}
*/重要的限制:不得有循环引用。
例如:
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在这里,转换失败了,因为循环引用:room.occupiedBy 引用了 meetup,meetup.place 引用了 room:
排除和转换:replacer
JSON.stringify 的完整语法是:
let json = JSON.stringify(value[, repllacer, 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 之外的所有属性:
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', 'place', 'name', 'number']) );
/*
{
"title":"Conference",
"participants":[{"name":"John"},{"name":"Alice"}],
"place":{"number":23}
}
*/现在,除 occupiedBy 以外的所有内容都被序列化了。但是属性的列表太长了。
幸运的是,我们可以使用一个函数代替数组作为 replacer。
该函数会为每个 (key,value) 对调用并返回“已替换”的值,该值将替换原有的值。如果值被跳过了,则为 undefined。
在我们的例子中,我们可以为 occupiedBy 以外的所有内容按原样返回 value。对于 occupiedBy,下面的代码返回 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
要解码 JSON 字符串,我们需要另一个方法 JSON.parse。
语法:
let value = JSON.parse(str, [reviver]);-
str
要解析的 JSON 字符串。
-
reviver
可选的函数 function(key,value),该函数将为每个
(key, value)对调用,并可以对值进行转换。
例如:
// 字符串化数组
let numbers = "[0, 1, 2, 3]";
numbers = JSON.parse(numbers);
alert( numbers[1] ); // 1对于嵌套对象:
let userData = '{ "name": "John", "age": 35, "isAdmin": false, "friends": [0,1,2,3] }';
let user = JSON.parse(userData);
alert( user.friends[1] ); // 1JSON 可能会非常复杂,对象和数组可以包含其他对象和数组。但是它们必须遵循相同的 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() ); // 现在正常运行了!顺便说一下,这也适用于嵌套对象:
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() ); // 正常运行了!评论
评论加载中……