JavaScript: 从入门到放弃 | 第二讲: 数组、对象和其他类型

大家好~青色旋律又和大家又见面了。这一次带来的是 JavaScript 教程的第二篇。嗯,经过 第一次的讲解 ,相信已经有不少人放弃了 JS. 这一次为大家带来的是 JS 各种类型的讲解,可能会更加困难呢,希望大家做好准备~

JS 的类型机制一直以来都被人认为是最坑的几个领域之一。这次的内容是数组、对象和其他类型,并着重介绍 JavaScript 与其他语言不同的地方,讲解语言规范的行为和规避误区。假如各位已经学过了其他语言,可以放心阅读。如果没有学过的话,可能需要自己适当进行补充。

不过在此之前,同学们请把 上次的作业 交上来~这次会首先讲解上次的作业。大家,和青色旋律一起继续愉快的 JavaScript 旅途吧~

(此文经过了彻底重写,和之前学习小组的讨论记录完全不同,看过讨论记录的建议重新读一次本文,相信一定会有收获。主要加入了类型的详细说明和 ES2015 新特性。)

目录

上回的作业分析

上次的作业 中布置了摄氏度转换为华氏度、交换两个变量的值以及 JS 作为计算器三项内容。不知道大家都完成了吗?如果还没有完成的话,请在 上次文章的评论区 提交作业。

已经提交的作业,均已经在原先的评论下面进行了点评。作业并没有截至时间,各位仍然可以继续提交上次的作业,青色旋律仍然会进行评阅。但是,请不要照抄下面的解答哦。

作业解答 1: 摄氏度转换为华氏度

编写程序计算 39 摄氏度等于多少华氏度。要求将摄氏度的值保存到变量中,然后在计算公式中使用变量。输出的结果范例:

39 摄氏度等于 ? 华氏度。

其中 ? 的地方需要替换为正确答案。可以看到,需要连接字符串和数字以实现这样的文字输出。

解答:首先,需要一个计算的公式。上网随便搜索一下,可以得到类似这样的公式:

°C  x  9/5 + 32 = °F

之后,使用 JS 的算术表达式实现这个公式:

c * 9 / 5 + 32

当然,我们需要使用变量来代表摄氏度和华氏度的值,并且最后需要输出,这样就得到:

1
2
3
const tempC = 39;
const tempF = tempC * 9 / 5 + 32;
console.log('39 摄氏度等于 ' + tempF + ' 华氏度。');

由于 JavaScript 本身的运算符具有 优先级 ,乘法除法会优先运算,所以 32 + tempC * 9 / 5 也可以得到正确的结果。并不是像老式计算器一样完全是从左到右运算。

输出的场合,也可以使用 模版字符串 来代替字符串的 + 连接操作。比如:

console.log(`39 摄氏度等于 ${tempF} 华氏度。`);

这里, ${} 之间可以是任意表达式, JavaScript 会将表达式的值计算完毕并转换成字符串再插入到对应的位置。这个例子中和 + 操作没有区别,至于实际使用中使用哪种写法可以全凭喜好。但是,要注意模版字符串在只支持 ES5 的旧环境中不可用哦。比如过时的浏览器、浏览器旧版、低版本 Node.js 等。

此外,有一些同学的计算公式就是错的。请参考上面正确的公式订正。

作业解答 2: 交换两个变量的值

假设 alice 有 8 个苹果,而 bob 只有 5 个。他们互相交换所有的苹果,然后请问 alicebob 分别有几个苹果?

这个作业要求用 alicebob 两个变量存放苹果数量,然后输出初始的各自苹果数量。在程序运行的时候交换两个变量的值,然后再输出一次。

解答:首先,这样是不行的。

1
2
3
4
let alice = 8;
let bob = 5;
alice = bob;
bob = alice;

JavaScript 执行顺序语句会有先后。先遇到的语句先执行,所以在第三行 alice = bob; 时就已经把 bob 的值赋值给 alice 了。此时 alicebob 都为 5, 变量 alice 不再保存有原先的值。此后再执行 bob = alice; 相当于 bob = 5;, 并不会获取 alice 原先的值。所以这样交换失败了。

对于已经学过编程语言的各位来说,这个应该是常识了。对于刚接触编程不久的各位,可能要花点时间理解下变量、赋值等概念。但有一点是可以确定的, JavaScript 语言会忠实地执行输入的任何指令,哪怕结果不是我们想要的。实在想不到的情况下,可以上网搜索下。

常见的办法之一是使用额外的变量来保存值。

1
2
3
4
5
6
let alice = 8;
let bob = 5;
const carol = alice;
alice = bob;
bob = carol;

这里可以理解成让第三位朋友 carol 先拿着 alice 的苹果,然后依次传递。这个传递有点像是按一个圈来传递,回避了 alice 被覆盖,值消失的问题。这个方法,对于所有类型的值都有效,字符串也是相同的道理。由于这里不是讲计算机原理或者编程入门的课,所以更详细的解释就跳过吧,如果仍然不理解的话可以让朋友帮忙解释。

如果 alicebob 都是数,那么也有以下解法:

1
2
3
alice = alice + bob;
bob = alice - bob;
alice = alice - bob;

这个方法可以类比成 alice 先拿着所有水果,然后再分给 bob 一些, 剩下的留给自己。这是属于相对“聪明”一些的方案,回避了使用额外的变量。然而,这个也有其局限性,例如字符串的场合无法处理(没有合适的 - 操作)等。当然,最大的问题是这个计算并不总是会得出正确的结果,受限于精度等情况,有时交换结果会发生变化。当然最坑的还是其中一方是 NaNInfinity, 虽然也是数类型,但计算结果……各位自己试试看就知道了。

只是这个题目的话怎么样都好啦,但是正常写代码的时候,太过于“聪明”的这类方法是不推荐的,因为这给别人理解代码造成困难。此外,还要考虑上述的情况……与其相比,多用一个变量也没什么坏处。可能有人会误以为多用一个变量会降低效率或者内存占用会上升,青色旋律在这里只能回答说未必。实际的性能需要在实际的程序里进行测试,并且假如这个代码在关键路径上或者成为瓶颈才需要优化,更何况实际测试的结果还未必哪个好呢。

此外,还有一个常见错误是使用 const alice = 8;. 使用 const 声明的变量不可再次赋值,因此后续的代码会没办法正常执行。

在支持 ES2015 的环境下,还有一个方法可以使用:

1
[alice, bob] = [bob, alice];

这个方法的原理是把 bobalice 放到一个数组里,然后 解构赋值 到相反的变量里。那么问题就来了,什么是数组,又怎么用呢?请看这次的教程介绍吧。

在支持 ES2015 的环境下,青色旋律比较推荐最后的方法,因为语句简洁明了,也避免了引入额外的变量。当然,也是要在理解数组和解构赋值的前提下啦。无论如何,实际编码中遇到只需要交换两个变量的情况也比较少,此处仅仅作为范例啦。

作业解答 3: JS 作为计算器

这个实际上属于开放问题。之前也说过,选题不一定要是编码相关的问题,也可以是别的。

无论选了什么样的计算,都需要注意变量名的选取是否明确、计算本身的正确性,以及代码整体是否可读。如果表达式过于复杂的情况,建议拆成几条语句分别计算,中间的值用变量来保存。这种类型的计算比较适合使用 const 变量,并且有意识地回避让一个变量表示多个值的情况,可以有效提高代码可读性。

一个做的比较好的例子可以参见 CatLemon 的解答 。在这里也顺便表扬一下,这份解答整体质量都比较高,前两道题目至少正确性也是没什么问题的。

关于使用中文作为变量名,其实在 JS 里确实是没什么大问题的,也很少会遇到不兼容的执行环境。然而,必须要注意的是文件的编码,容易给自己添些奇怪的麻烦,所以一般来说并不推荐。此外,在目前日益国际化的开发者社区,感觉不写英文比较难办,至少在开源项目里是这样的。注释和文档同理也请尽量使用英文。除非有人故意不想让不会中文的人读不懂,或者预计不会中文的人不可能需要读这份代码(可能性太低,忽略)。

以上是作业解答,各位明白了吗?还是更困惑了呢?嘛,还有不懂的再留言追问吧。

布尔值

上次介绍了 JS 的两大类型, number (数)和 string (字符串)。这两种值在 JavaScript 中被称为原始值 (primitive values), 相应的类型称为原始类型。原始类型还有 boolean, symbol, undefined, null 四种。

此处先介绍 boolean 这一类型。这个类型只有两种值, truefalse, 对应真和假。比较运算等会返回布尔值。

例如, a > b, a <= b, a === b 等运算返回值是 true 或者 false. 其中 === 运算称为精确相等,具体运算规则请参考 相同与相等 一节的详细描述,此处只要记住 a === b 大致可以认为是类型相同且值相同则为 true 即可,但 NaN 和自己不相等。 == 称为模糊相等,具体判断规则极为复杂,请参考 本文最后的说明,青色旋律不推荐各位使用。相等运算也有对应的不等于运算符 !== 和模糊不等 !=, 其结果与对应的运算符相反。

>, >=, <, <= 四种运算符称为关系比较。 JavaScript 中关系比较有两种比较模式,分别是数值比较和字符串比较。在比较前,会先把两侧都 转换为原始值, 然后如果两侧都是字符串,则会使用字符串比较模式,会按 字典序 进行比较。如果至少一侧不是字符串,则会将两侧都转换为 number 然后再比较其数值。数值比较中如有 NaN 参与,一律返回 false, 其他的比较基本上都是符合数学规律的。不考虑 NaN 的情况下, JavaScript 满足 a <= ba > b 的结果相反, a >= ba < b 的结果相反。

根据上述比较模式判定规则, "12" < "2" (字符串比较), 12 > 2 (数值比较)。此外特别注意转换为原始值后,只要有一侧不是字符串就会强制都转换为数值比较,所以 "12" > 2 会转换为 12 > 2 (数值比较),运算结果为 true.

有三种逻辑运算常用于布尔值,分别是 ! (逻辑非), && (逻辑与)和 || (逻辑或)。如果 a 是布尔值 !a 返回相反的值, atrue 则返回 false, atrue 则返回 false. 如果不是布尔值的情况下会隐式转换为布尔值,然后再取非,具体转换规则见本文 转换为布尔值 一节。

a && b 是逻辑与操作。如果两侧都是 boolean 值,那么 ab 都为 true 时返回 true, 否则返回 false. a || b 是逻辑或操作,如果两侧都是 boolean 值,那么 ab 都为 false 时,返回 false, 否则返回 true.

常见的用法是两个条件的组合,比如 (a > 3) && (a < 7) 表示 a3~7 之间(不含两端),或者是 a === 3 || b === 3 (a, b 其中至少一个为 3). 由于运算符优先级规则,比较运算符优先于逻辑运算符,所以此处两侧括号可以省略。

此外, JS 还有一个 a ? b : c 的三元操作符,其行为是计算 a 并转换为布尔值,如果是 true 时计算并返回 b, false 时计算并返回 c. 注意没有选择的那个分支是不进行计算的,这个行为称为短路。这里 a 就像是信号灯,决定了接下去走哪条路。学过 if-else 语句的各位会发现,这个语句可以作为 if-else 语句的一种简化。比如 let discount = onSale ? 0.5 : 0.1; 表示甩卖时打 5 折,平时打 9 折。这等同于:

1
2
3
4
5
6
let discount;
if (onSale) {
discount = 0.5;
} else {
discount = 0.1;
}

a && b 类似于 a ? a : b, 唯一的不同点在于表达式 a 只会计算一次,不会重复计算。 a || b 类似于 a ? b : a, 选择正好相反,同样地 a 也只会计算一次。虽然这两种操作常用于布尔值,但实际上对于非布尔值也能使用。通过上述的类比,可以很明确地看到返回值一定是 a 或者 b 其中之一,但不一定是 boolean 值。要注意 a && ba || b 并不含有任何强制转换的成分,而是只是在判定选取哪个时,临时计算了一下 a 所对应的布尔值作为依据。

也可以看出,这两个操作左侧和右侧地位不完全相同,左侧表达式一定会计算,然后就像是一扇门一样,假如不满足特定条件,直接返回左侧值,右侧表达式不再计算(短路)。满足条件才会计算并返回右侧表达式。 因此, 0 && 2 结果是 0 (因为 0 转换为 boolean 结果为 false ),而 33 && "hello" 结果是 "hello", 返回了右侧值。类似地, 0 || 2 结果是 2, 33 || "hello" 结果是 33, 选取的那一侧正好相反。

因此, || 也可以用于处理默认值。比如 let discount = specialDiscount || defaultDiscount; 这个计算,假如 specialDiscount 是不为 0NaN 的数,则使用 specialDiscount, 否则使用 defaultDiscount. 这个结果与 specialDiscount ? specialDiscount : defaultDiscount; 相同,如果各位学过 if 语句,就会明白这个例子相当于以下 if-else 语句:

1
2
3
4
5
6
let discount;
if (specialDiscount) {
discount = specialDiscount;
} else {
discount = defaultDiscount;
}

不过无论上述的哪种写法, specialDiscount 只要能转换为布尔值 false, 就会选择另一个分支。这包括了 false, 0, NaN, "" 以及后述的 null, undefined 几种值,有时候可能会覆盖面太广,导致一些问题。如果要精准判断并取默认值的话,可以用 (specialDiscount !== 0) ? specialDiscount : defaultDiscount 这样的写法,来明确地表示究竟如何选取。

undefined 和 null

undefined 类型只有一种可能的值,为 undefined. null 类型只有一种可能的值,其值为 null. 这两种值是 JavaScript 语言中意义上最接近“空值”的存在。试图在这两个值上获取、赋值、删除属性(后述)会直接导致 TypeError 错误,有点像是其他语言的 NullPointerException 或者空指针。

undefined 通常意义上是表示“没有值”。未赋值的变量比如 let a; 会具有初始值 undefined. 没有返回值的函数(见后篇)默认返回 undefined. 访问不存在的属性(后述)会获得值 undefined. 此外,在代码中也可以使用 undefined 全局变量来获得这个值。请注意这是一个变量,不是一个关键字,所以理论上自己也可以弄一个变量叫这个名字……但是,请不惜一切代价避免。此外,void 0 这个表达式也返回 undefined, 而且因为 void 是关键字,所以没有上述问题。如果看到其他人写 void xxx 或者编译器编译出来的结果是 void xxx, 请自动脑补成 undefined.

null 通常意义上是表示有值,但值为空。因此,经常会用于一些接口的返回值,本来应该返回对象的,但没有找到对象这种感觉。这个情况在 DOM 或者其他浏览器 API 中尤其常见。 JavaScript 语言本身用的比较少,但原型链的顶端是 null (青色旋律可能会在其他文章中描述原型链,此处暂时不提。)null 没有默认值的概念,而是更接近于其他语言的“空指针”。这从 typeof null 返回 "object" (后述)也可以看出,其实 null 设计之初是当做对象的空引用的。(实际上不是对象,是原始值,只是概念上接近。) null 是一个关键字。

由于 undefinednull 转换为布尔值时false, 所以经常也会用 || 来为可能为空的值提供默认值,比如 const myObj = specialObj || defaultObj;. 当然,正如上面所说的,这个也会误判 specialObj === 0 等情况,所以还是建议使用 specialObj === undefined 之类的方式进行精准判断。而且在设计接口的时候,应该只把 undefined 认为是默认值,而 null 不应该作为默认值处理。比如说, JavaScript 语言内置的默认参数(见后篇)就是这样的设计。

symbol

symbol 是 ES2015 引入的新原始类型, 符号类型 。每个符号值是唯一的,不可修改,但是可以像其他值一样传递,比如赋值。每次新创建的符号值都是不同的,获取相同符号的唯一方式是互相传递。符号类型的内部结构在 JS 语言中并不可见,没有数值或者字符串表示之类的。

symbol 值使用 Symbol() 创建,每个新创建的 symbol 都不同。创建时可以提供一个备注字符串,但备注字符串仅供调试使用,并不是 symbol 的身份。例如:

1
2
3
4
5
6
7
const foo = Symbol();
const bar = Symbol('hello');
console.log(foo); // prints Symbol()
console.log(bar); // prints Symbol(hello)
console.log(Symbol() === Symbol()); // prints false
console.log(Symbol('hello') === Symbol('hello')); // prints false

symbol 类型常用作对象属性的键,见后述对象部分。

数组

以上就是关于原始值的介绍了。但如果要表示一组原始值,则需要数组 Array 和对象 object. 严格而言数组属于对象的一种,但这里只讨论数组正常使用的情况和用 object 表示简单数据结构的情况。关于对象类型的说明也请参见后文的介绍。

一个数组类似于一个列表,可以存储多个值。比如 const items = ["a", "b", "c", 9]; 这里 = 右侧的表达式是一个数组。 items 变量指向这个新创建的数组,数组中含有四个元素。数组表达式从 [ 开始到 ] 结束,中间可以含有 0 到多项元素,以逗号分隔。每个元素是一个值。空数组用 [] 表示,长度为 0. 变量本身并没有什么特别的,仍然只是一般的一个变量,只是现在指向的值是一个数组值,假如是 let 声明的变量,那么将来还能指向些别的什么东西。

使用 items[0] 可以取得 items 所指向的数组中第一个元素。下标从 0 开始。赋值的语法类似,类似 items[0] = 3;. 也就是说,下标表达式的用法和变量的用法类似。这样就可以方便地存储一组相关的值。数组元素的下标必须是整数,或者是整数对应的字符串。 items["0"]items[0] 效果相同。

console.log([1, 2, 3]); 可以输出一个数组。大部分环境都是支持的。 Node.js 可能会显示文字 [1,2,3], 而开发者工具等可能会支持样式、高亮甚至交互操作。

数组的每个元素都是一个值,值的类型没有任何限制,可以混合各种东西,比如 ["a", 1, [2, 3]] 是三个元素的数组,第三个元素本身又是一个数组。

JS 的数组大小可以变化,比如 items.push(10); 会在最后新增一个元素,值为 10, 这样数组会变成 ["a", "b", "c", 9, 10], 长度变为 5. 删除元素的情况例如 items.pop(); 会删除最后一个元素,长度变回 4. 数组的长度可以用 items.length 获取。

在学习了循环语句之后,我们就可以实现对于数组的多个元素进行批量操作,那个时候就能发挥出数组真正的威力了。

青色旋律在这里提醒各位, const 声明的变量 items 指向一个数组, 其元素和长度仍然可以改变。 const 声明的变量只是不能再次赋值(这个例子中,不能指向这个数组以外的东西了),并不是“不变”或者“固定”,更不是“只读”. 此外,说成是“常量”严格来说也是不对的, JavaScript 没有常量。在以上的例子中, items 指向的目标没有变,一直是同一个数组,只是数组内部的结构有调整,可以想像成变量指向盒子,而盒子里面的物品仍然可以随意变动。

字符串和数组

字符串在某种意义上有点像是数组。比如,假设 const name = "Alice";, 那么可以用 name.length 来取得字符串长度(这里是 5)。此外,还可以使用 name[2] 来获取第三个字符,返回的是字符串 "i". (JavaScript 没有表示单独字符的类型。)

但是,字符串是不变的值,不能对字符串进行修改。 name[2] = 'o'; 并不能修改这个字符串的值,而且 name.length 也是只读的。也不能增加或者删除字符。如果要连接、替换、截取字符串时,必须要生成新的字符串。比如 const li = name.substr(1, 2); 取字符串从第二个位置 (1) 开始的两个字符 (2) 来形成一个新字符串。

除了 .length 等少量共同点,字符串与数组支持的操作截然不同。字符串有独有的查找、替换、取子字符串等操作。注意替换和取子字符串等都是生成新的字符串而非修改。

执行 a += "abc"; 的时候,实际上执行的是 a = a + "abc";, 生成新的字符串再赋值给原先的变量,并不改变原先的字符串长度。注意 JS 引擎可能可以优化这个,让性能比复制一遍要高一些,但语义本身不会有任何变化,字符串仍然是不可变。

因为字符串是不可变的,所以当然也就没有所谓的字符串修改,也不存在 a = b; 后修改 a 会不会对 b 有影响这种问题。即使再执行 a = c;, 也只会让 a 指向一个新的字符串,而不是“修改”。所有的原始值都是不可变的,这也是字符串和数组最大的区别。

如果需要把字符串转换为数组,可以使用 const chars = name.split(); 这样的写法。这里 name.split()name 中的每个字符取出来,组成一个新的数组。如果 nameAlice, 返回的 chars 就是 ["A", "l", "i", "c", "e"], 每个元素是一个长度为 1 的字符串,只含有一个字符。

对象

JavaScript 的对象是一种类似于字典的数据结构。对象可以有多个属性,每个简单属性包括一个键和一个值。(对象还有很多其他功能和特性,见后述和系列的其他文章。)

一种简单的创建对象的方式是 const obj = {"a": 9};. 这个例子中 {"a": 9} 创建了一个新的对象,有一个属性 "a" 值为 9. 这里,这个属性的键是字符串 "a", 9 是这个属性的值。要取得这个属性,可以使用语法 obj["a"]. 这里和数组不同,方括号里是一个字符串,而不是一个整数。通过这个语法,也可以给这个属性重新赋值,更改这个属性对应的值,例如: obj["a"] = 44;.

如果碰巧属性名是一个合法的标识符,那么也可以使用 obj.a 的简便写法。创建的时候,如果属性名是标识符或者数字,也可以省略引号,例如 const obj = {a: 9};. 但注意无论是否省略引号,用方括号还是点表达式,效果是等价的。属性名本身仍然还是字符串 "a", 而不是变量 a 之类的。一般为了简便,人们常常使用不带引号的创建方式以及点操作符的引用方式,但假如属性名不是标识符的情况,比如 const obj = {"foo-bar": 0};, 创建时必须使用引号,并且只能用 obj["foo-bar"] 的方括号形式获取了。否则 obj.foo-bar 会解析成 obj.foo - bar 的减法表达式之类的。其他的字符串同理,比如 {"a^@#sdfp": 9}, 可以是任意字符串。(青色旋律提示: "__proto__" 这个字符串除外,有特殊含义必须避免。无论是否加引号都要避免。)

对象的创建语法中, {a: b} 左边是键,右边是值,值可以是表达式。在这里例子中,左边是字符串 "a": 的简写,右边是变量 b 作为表达式,表示取变量 b 的值,千万不要混淆了。多个属性之间用逗号隔开,比如 {foo: 1, bar: 2}. 空对象是 {}. 使用 {...} 的形式创建对象,叫做对象字面量表达式,每次执行会创建一个新的对象。

如果访问的键需要计算,那么只能用方括号语法,假如 const field = "id"; 的话, obj["student_" + field] 可以用于获取 "student_id" 属性。 ES2015 以来,对象字面量也可以使用方括号来计算键,比如 const obj = {["student_" + field]: 1};. 这个语法消除了以往只能 const obj = {}; obj["student_" + field] = 1; 的不便。

试图用数作为属性的时候,会强制转换为字符串再操作。例如 obj[1] 等于 obj["1"].

console.log(obj); 可以用于输出对象。至于输出的结果仍然是和环境有关,各位自己试试看就知道了。这个可以作为一种调试的手段。

obj.foo = "Bob"; 假如 obj 上没有 "foo" 属性的情况下,会在 obj 上创建 "foo" 属性,然后赋值为 "Bob". 已经有的情况下会更新(覆盖)属性值。用方括号表达式的效果相同,以下如果不特别说明,两种语法都可以使用。

obj.foo 的时候,比如 console.log(obj.foo); 的情况,假如 obj 上没有 "foo" 属性,则会返回 undefined 这一原始值。

但返回 undefined 不代表属性一定不存在。也可能属性存在,但值是 undefined 之类的,例如 const obj = {foo: undefined};. 要判断属性是否存在,可以使用 obj.hasOwnProperty("foo") 然后检查结果是 true 还是 false.

要删除一个属性,可以使用 delete obj.foo; 或者 delete obj["foo"]. 成功删除的属性就变成不存在了,和没有添加过的效果一样。

ES2015 起,对象的属性键除了是字符串,还可以是 symbol 类型的值。 symbol 类型是一种 ES2015 新加入的原始类型,使用 Symbol() 创建,每个新创建的 symbol 都不同,如前文所述。使用 symbol 作为属性键的例子:

1
2
3
4
5
6
7
const foo = Symbol();
const obj = {[foo]: 33};
console.log(obj[foo]); // Outputs 33.
obj[foo] = 44;
console.log(obj[foo]); // Outputs 44.
const bar = Symbol();
console.log(obj[bar]); // Outputs undefined.

由于 symbol 值不是字符串,所以没有简写法,只能用方括号表达式,不能用点表达式。 obj.foo 的意思是 obj["foo"], 不是 obj[foo], 请勿混淆。由于 symbol 值不可仿造,所以如果没有这个 symbol 就访问不到这个属性了,可以用于避免名字冲突。但是,一个对象的所有属性的键仍然属于公开信息,可以用各种方式获得,所以用来做访问控制并不是绝对安全的。 Object.getOwnPropertyNames(obj) 返回一个数组,表示对象 obj 自身的所有字符串键,而 Object.getOwnPropertySymbols(obj) 返回对象自身所有 symbol 键,通过这个方式就能获得那些 symbol 的引用了,所以并非外部不能访问。

注意数组也是一种特殊的对象,所以对象的上述所有功能数组也可以用。数组的 items[0] 属性实际上就是字符串键 "0". 这里可以理解成数组和普通对象类似,只是对于某些键(例如 "0", "1", "10000" 等)做了特殊处理,根据赋值和删除自动更新 .length 属性,而普通的对象没有这个功能。此外数组还有继承的 .push(x), .pop() 等方法,具体的原理青色旋律会在今后博文中,关于原型链的部分单独讲述。

当然,青色旋律不推荐各位把数组当做对象用,只会让代码更混乱……也不推荐把普通对象当数组用,因为普通对象没有特殊的 .length 属性和数组的方法,会比较难用。此外,虽然对象可以使用字符串来存取值,很像是一个字典或者说哈希表数据结构,但直接把普通对象当字典用会面临继承、 __proto__ 带来的很多问题,不推荐。 ES2015 以上有专门的 Map 对象 可以使用,支持任何类型的键,且没有奇怪的特殊行为,只支持正常的存取操作。即使您没有 ES2015 环境,也建议去找一个 字典库 或者至少读一下 为什么这么做有很多坑

组合数据结构

就像刚说的一样,普通对象是不适合当做哈希表用的。普通对象更适合当做类似其他语言的结构体实例或者类实例那样用,存储确定的几个属性和值就足够了。当然, JS 的一大优势是不用像很多静态语言那样先声明类的结构然后就遵循那个固定的结构,而是可以动态创建和改变结构。但这也是坏处,滥用容易导致代码难以维护之类的。此外,如果记错了属性名,最多只会拿到一个 undefined, 而类型检查工具之类的很难检测出这种问题,所以记得多写单元测试或者勤测试。对于初学者来说,建议每种业务对象使用一个固定的结构即可。

例如,假如在 JS 中加载博客系统的文章列表,则可以做如下的数据结构设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const articles = [
{
title: "Sky's Melody, A New Journey",
url: 'https://skysmelody.org/2017/04/14/skys-melody-a-new-journey/',
comments: [
{
author: 'John Doe',
content: '青色旋律加油!',
},
],
},
{
title: 'JavaScript: 从入门到放弃 | 第一讲: 变量、值和类型',
url: 'https://skysmelody.org/2017/04/14/javascript-guide-01/',
comments: [
{
author: 'John Doe',
content: 'JS 好坑啊,我放弃了……',
},
{
author: 'Jane Doe',
content: '交作业啦: http://example.com/jane/homework/hw1.js',
},
],
},
{
title: 'JavaScript: 从入门到放弃 | 第二讲: 数组、对象和其他类型',
url: 'https://skysmelody.org/2017/04/14/javascript-guide-01/',
comments: [],
},
];

这里 articles 是一个数组,里面每个元素表示一篇文章。每篇文章是一个对象,拥有相同的结构,都有 "title", "url", "comments" 三个属性,但属性值各自不同。其中每篇文章的 "comments" 又是一个数组,里面装入评论对象。

像这样的数据结构,就很容易用循环遍历所有的文章,然后找出哪一篇评论最多,或者进行类似的数据处理任务,比起使用单独的变量更加高效。如果博客发表了新文章,也可以使用 articles.push({title: 'foo', url: 'bar', comments: []}) 的方式加入到这个数组最后。如果有新的访客评论,也可以使用 articles[2].comments.push({...}) 来在某一篇文章里新增评论。这样合理使用 JavaScript 的数组和对象,可以实现维护特定的数据结构。

这个例子的数据结构类似于其他基于类的语言的如下构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Definition:
class Article {
string title;
string url;
List<Comment> comments;
}
class Comment {
string author;
string content;
}
// Usage:
List<Article> articles = ...;

区别是 JS 这个例子没有强制类型也没有类型检查。优点是加入新的属性比较便利,比如每篇文章加入 date 可以随时添加,甚至有的文章有这个属性,有的没有。缺点是 articles[2].comments.push 等语句在早期难以进行类型检查,因为类型检查工具一般很难推断这个语句是否合法,因为 articles[2] 可能有 comments 属性也可能没有,运行时可以修改的。所以重要代码各位一定要多测试,不要认为可以运行了就没问题了。

此外,用序号来查找文章是不稳定的,因为增加、删除文章等操作可能会变更原有的序号。如果想要通过唯一标识符 (ID) 来定位文章,就需要 Map 对象 等特殊的构造,例如:

1
2
3
4
5
6
7
8
9
const articleById = new Map();
articleById.set('skys-melody-a-new-journey', {/* ... */});
articleById.set('javascript-guide-01', {/* ... */});
articleById.set('javascript-guide-02', {/* ... */});
// Usage:
const id = 'javascript-guide-02'
console.log(articleById.has('javascript-guide-02')); // true
console.log(articleById.get('javascript-guide-02').title);

(每个文章对象的具体结构请参考上一个例子,这里不再重复。)

Map 对象支持 .set, .get, .has 等操作,实现了字典或者说映射数据结构。具体用法请参阅上方链接的文档。

有了基本类型、数组、对象和 Map, 大部分的数据结构也就可以在 JavaScript 中表达了。更为复杂的数据结构,例如队列、栈、树、图等都可以基于上述类型来实现。

typeof 表达式

关于原始值和对象的讲解就暂时到这里了。此外还有函数类型也属于对象范畴,青色旋律在这个系列的下一期会进行介绍。值得注意的是 JavaScript 并没有其他的基本类型了,并不像很多语言一样有表示单个字符的类型、枚举、指针等等。

在 JavaScript 中,使用 typeof exp 可以看到表达式 exp 计算结果的值属于何种类型。对于原始值,这个表达式根据值的类型,返回字符串 "undefined", "boolean", "number", "string", "symbol" 共五种。唯一的例外是 typeof null (或者任何计算结果为 null 的表达式)会返回 "object", 这是一个坑,因为 null 属于原始值而不是对象,和对象没有什么共同点。但考虑到 web 和其他平台的需要向后兼容,所以这个结果估计是不会被修复了。不存在 typeof 返回值为 "null" 的值。

typeof 还有一种特殊行为,假如表达式的右侧只有一个未声明的变量 foo, 那么 typeof foo 直接返回 "undefined", 不会求值. 但这只是 typeof 的一种特殊行为,并不说明 foo 存在并且有值 undefined。在其他场合取未声明变量的值,比如 foo + 1 还是会报错,因为这个变量确实不存在。(例外:用 let 或者 const 声明的变量,不能在同一作用域下,声明语句 之前 访问,哪怕是 typeof 也不行。青色旋律博客之后会另写文章详细说明。)

用这个特殊行为可以判断变量是否存在,但假如存在但值为 undefined, 返回结果也完全相同,所以无法区分这种情况。比如说,判断 JS 运行环境是否支持 Symbol, 如果直接写 Symbol !== undefined ,假如环境不支持的话执行时找不到 Symbol 这个变量。但如果用 typeof Symbol !== 'undefined' 就可以回避这个问题。

从 JS 语言规范的角度,不是原始值的都是对象。 typeof 只区分三种对象:宿主对象, functionobject. typeof 的算法类似于下面列出来的这些步骤:

  1. 如果是宿主对象可以返回任何字符串,标准没有定义。
  2. 如果是 null, 返回 "object".
  3. 如果是 null 以外的原始值, 返回对应的原始类型字符串。
    • 可能的返回值: "undefined", "boolean", "number", "string", "symbol".
  4. 如果可以调用,返回 "function".
  5. 否则返回 "object".

可见 function 也只是一种可以调用的 object 而已。关于函数的话题要到下一次才能讨论,但这里只要明白上述的大部分针对对象的内容也适用于函数就好。

相同的对象是相同且相等的,不同的对象是不同且不相等的。 JavaScript 里赋值是传递引用,并不是拷贝或者克隆什么的。所以 const a = {}; const b = a; 只会让 ab 指向同一个对象,结果 a === b. 但 const c = {}; const d = {}; 是两个不同的对象,尽管看起来没什么区别,但每次执行 {} 的时候都会创建一个新对象,如上文所述, 所以最后 c !== d. 原始类型的相等判断则比较复杂,见后文所述。

原始值和对象的区别

JS 里,原始值被认为是数据的最小单元。原始值不可变。原始值没有“内部”可修改的部分。对象可能可变。

nullundefined 没有属性。试图获取、添加、删除属性会导致 TypeError. 对其他原始值使用属性会将产生一个对应的箱对象. 然后属性操作等会在箱对象上进行。原始值本身无法添加属性。

任何两个不同的对象是不相同的,对象和原始值也不相同。不同类型的原始值不相同,但同类型的原始值,根据类型,分别有不同的相同判断规则:

  • undefined 与自身相同。
  • null 与自身相同。
  • truetrue 相同, falsefalse 相同, truefalse 不同。
  • 具有相同内部数值表示的两个 number 相同,不同数值的不同。
  • 字符编码序列相同的两个 string 相同,不同序列的不同。 (UTF-16)
  • 两个分别创建的 symbol 总是不同的,同一个 symbol 赋值、传递后仍然相同。

(简记:undefined, null, true, false 是单例,只和自己相同。symbol 值和对象一样,每个都是唯一的。number 值和 string 值都是内容相同则相同。)

此外,没有任何办法把相同的原始值区分开来。比如 1 + 2 产生的 34 - 1 产生的 3 在各种意义上都是相同的,没有任何区别。也没有办法找一个 3 出来,给它做上特殊的标记,让它和其他的 3 有不同的行为。特别地,由于无法使用属性,所以没办法在原始值上增加属性来进行区分。

相同与相等

ES2015 加入了 Object.is(a, b) 这个函数,专门用于判断“相同”这一概念。简单来说,不同类型的值不相同,不同的对象不相同,而同类型的原始值可能相同,见上述判断规则。特别注意的是,NaNNaN 相同, +0-0 不同。请牢记相同类型相同值这一判断依据。

如前述 ES2015 新加入的 Map 对象 支持任何类型作为键。判断两个值是否为同一个键的标准正是“相同”这个概念,除了 +0-0 指向同一个键。原因嘛……大概是怕程序员们被计算结果的正负零坑?

a === b 可以用于判断两个值是否相等。相等的算法和相同类似,除了 NaN+0, -0 需要单独讨论之外,其他是一致的。这个对于原始值和对象都成立。嗯,这里有三个等于号,三个等于号会禁用类型转换直接进行比较,结果也比较好预测,大方向仍然是相同类型相同值,比较推荐使用。

至于两个特例: NaN !== NaN (相同却不相等)以及 +0 === -0 (相等却不相同)。

至于为什么有 +0-0, 以及 NaN 为什么这么坑,详情请参阅 IEEE 浮点数

最后,还有一个 == 运算符,大致是试图转换两边为相同类型然后再比较是否相等。本来这个不算是一件坏事,但 转换规则过于复杂 正常人都记不住,并且有一些令人惊讶的边界情况……

青色旋律在此推荐尽量使用 === 进行比较。如果需要同时转换为字符串后再进行比较,请手动写 String(a) === String(b). 如果需要同时转换为数进行比较,也请手动写 Number(a) === Number(b) 或者 Object.is(Number(a), Number(b)) (根据应该如何处理NaN, +0-0 来选择其中一种), 这样代码的意图清晰一点,也没什么令人惊讶的情况。

青色旋律认为唯一可以接受的 == 用法是用 a == null 来判断 aundefined 或者是 null. ESLint 可以 设置成 禁用 == 表达式,除了这种和其他两种绝对安全的情况。

转换为布尔值

另外一个很容易混淆的转换是 !!x 或者 if (x) { ... } 的情况下 x 如何隐式转换为 boolean 值。首先,任何对象都转换为 true, 其中包括空数组和空对象。原始值中,一共只有 7 个值会转换为 false, 分别是 null, undefined, false, +0, -0, NaN"", 其他的值都转换为 true. 这里基本上只有 0, NaN 和空字符串需要特别去记忆,其他的都比较自然。空字符串是指长度为 0 的字符串,只有一种可能就是"". 含有空格的字符串例如 " " 长度不为 0, 转换为 true.

如果 x 转换为 boolean 结果为 true, x 俗称为 truthy, 否则称为 falsy.

作业

这次的讲解就到这里了哦。各位明白了吗?其实 JavaScript 的类型系统基本已经讲完啦。哪怕这次的内容确实有点多,里面有一些特殊情况和坑,但也请暂时忍受一下吧, 以后应该不会再这么坑了……大概吧?

[[Prototype]]: 哈?有人叫我? this: (咳嗽)

……呜,好吧,差点把您两位给忘了。但是,至少类型的坑已经基本解决了。大家看过以后感想怎么样呢?也没想象的那么坑吧?嘛,如果有任何问题请在评论区留言好了。

以下是一些作业,仅供参考。作业并不是必须的,但如果有人完成了,可以发布到 https://gist.github.com 上面,然后把链接贴到下方的评论区,青色旋律会逐一批改并点评的。万幸,这次的作业并不是坑,而是比较正常的数据处理类的东西,可以用于实践数组、对象等的用法。作业没有截至日期,但下次教程的时候会公布分析和参考答案。

零、请预习或复习控制语句

下次教程会讲控制语句,并且会着重讲解 JS 特有的几种控制语句以及和其他语言的区别,但不会花太多篇幅讲解控制语句本身的基础。如果各位是第一次接触编程,那么请尽可能自行掌握条件语句、循环语句等概念,然后再阅读下一次的内容。必要的话可以参考流程图之类的解释说明。如果已经学过了其他语言的话,简单复习下即可。

一、谢谢惠顾!

请编写一个抽奖程序,从一组预设的奖项中任意抽取一个结果。当然,玩过抽奖的各位都知道,大部分预设的结果都应该是“谢谢惠顾”,少量其他奖项。

将所有可能的结果作为字符串保存在数组中,然后随机取出一个元素来进行抽取吧。提示:用 const num = Math.floor(Math.random() * 8); 可以获得一个 0 到 7 之间的随机数。

最后,将抽取的结果打印出来。可以多抽取几次作为测试。

改进 1: 实际抽奖的时候,大奖的概率总是低一些的。为了达到这个效果,可以在数组里只写一次”一等奖”,而写十次”谢谢惠顾”。多抽几次,试试看自己的运气吧~

改进 2: 请做成每次抽取后不返还。也就是说,抽取过的结果不能再次被抽取。

二、土豪、菊苣和我

现在假设你要来帮助谷歌做 G+ 。你作为一个资深用户,深刻地体会到 G+ 是由土豪、菊苣和你自己组成的,因此在设计社交圈时,你需要充分考虑到这一点。

首先你改革了 G+ 的圈子系统,要求圈子必须包含”土豪”、”菊苣”两个分类。所有的好友,必须至少处在这两个圈子其中之一。而自己不能添加自己为好友。(背景知识:圈子是 G+ 上对于好友的分类。一个人可以创建任意数量的圈子,每位好友可以放在一个或多个圈子中。)

接下去,请设计一个数据结构,用于存放所有的人际关系数据,包括所有用户、每位用户创建的所有圈子和每个圈子中各自有哪些好友。请活用 JS 的各种数据类型来实现这个数据结构。

设计的数据结构必须支持以下查询场景:

  • 获取所有被人认为是土豪的用户。只要这位用户在另一位用户的”土豪”圈中,那么就说 明这位用户被人认为是土豪。
  • 给定某一位用户,快速获取有多少人认为这位用户是土豪,而多少人认为这位人是菊苣。

并且,还需要支持以下的数据更新操作:

  • 新建圈子。比如用户 a 新建一个圈子,名字为 "foo".
  • 添加到圈子。比如用户 a 将用户 b 添加到 "土豪" 圈。
  • 从圈子中删除。比如用户 b 原先在 a"土豪" 圈中,然后用户 a 将其移除。

注意到如果每次统计有多少人认为 a 是土豪可能需要遍历整个数组,效率可能会受到影响。可以考虑对于每位用户进行缓存,保存认为该人是土豪 / 菊苣 的人数。然后,每次有人把此人加入土豪圈,需要把这个数增加 1, 而移除需要减少 1. 假如设计了缓存或者多个数据结构的情况下,要保证以上操作时数据一致。

如果已经掌握了循环和控制语句,那么也可以开始进行这些操作的编码实现。如果没有的话,只需要在设计数据结构的时候考虑一下如何进行实现即可。

如果觉得处理了菊苣再处理土豪要写两遍太浪费时间,可以只写其中之一。但记得把代码写得通用一点,使得这个代码今后稍微做一点修改也能处理新的一类生物——学霸。

提交作业时请写一个范例,表示设计的数据结构如何保存以下这样的人际关系:

  • Alice 认为 Bob 是土豪
  • Alice 认为 Carol 是菊苣
  • Bob 认为 Alice 是土豪也是菊苣
  • Bob 认为 Carol 是土豪
  • Carol 认为 Bob 是菊苣

三、我们都要支援他

金珂拉好处都有啥?谁说对了就给他!

米国圣地亚鸽公司研发了一款新产品,正在征集电视广告词。脍炙人口的“金珂拉”顺口溜成为了一项热门的选择。已知顺口溜的原文如下:

1
2
3
4
非洲农业不发达,必须要有金珂拉。
肥料搀了金珂拉,一袋能顶两袋撒。
日本国土太缺乏,必须要有金珂拉。
肥料搀了金珂拉,小麦亩产一千八。

公司请您为他们的新产品填写广告词,但新产品的名称是宇宙核心机密,您无法得知。但您可以编写一段程序,从变量 product 中获取新产品的名称,然后产生新的广告词。

例如,假设 const product = "眼镜蛤";, 程序需要输出:

1
2
3
4
非洲农业不发达,必须要有眼镜蛤。
肥料搀了眼镜蛤,一袋能顶两袋撒。
日本国土太缺乏,必须要有眼镜蛤。
肥料搀了眼镜蛤,小麦亩产一千八。

(注:产品名称应该是“眼镜蛇”,但来自东方的神秘力量导致输入法出了一点小小的问题,结果错了一个部首,导致广告报道出了偏差,没有钦定的意思!青色旋律没有水表,域名注册商在米国不同意 clientHold, 博客服务器在米国不接受删帖断电拔网线,不欢迎 DDoS. )

此外,公司此次邀请了全新的配音和演出阵容,因此广告词中涉及到的两个国家名称必须根据演员长相特点来进行调整。假设两个地名分别是变量 place1, place2. 例如 const place1 = "法国"; 以及 const place2 = "米国";.

给定 product, place1, place2 三个变量,请输出最终广告词。请注意, product 名字未必是三个汉字,所以请判断 product 长度,并输出不同的结果。如果 product 超过四个字,取前两个字和最后一个字,例如 "圣地亚鸽" => "圣地鸽". 如果 product 是两个字,进行叠音处理,比如 "切糕" => "切糕糕". 假如是一个字,请输出错误信息,表明顺口溜无法正常完成。

对于 product 不同长度的处理,各位也可以发挥自己的想象力进行其他的文字处理,甚至根据长度不同,打印不同风格的顺口溜,甚至是其他成句或者语录。复杂的处理可能需要 if 语句,如果还未掌握请自行学习。

改进 1: 为了保证广告词不断推陈出新,请尽量不要手动拼接字符串,而是做成输入一个模版字符串,然后通过字符串替换、截取等操作输出一个新的字符串。替换的位置不能写死。比如,模版可以是 "PLACE1农业不发达,必须要有PRODUCT。...", 然后通过字符串替换 PLACE1"非洲", PRODUCT"金珂拉" 来生成。

各位,作业请加油~如果完成了别忘记在评论区提交作业哦。


知识共享许可协议 本作品是青色旋律原创作品,采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。转载请注明来自 青色旋律,链接至 此页面 并使用相同授权协议。如需更宽松的授权协议,请联系青色旋律。