JavaScript: 从入门到放弃 | 第三讲: 条件和循环语句

大家好~经过漫长的等待,青色旋律终于决定继续填 JavaScript 教程的大坑了。上次的 第二讲 之后过了这么长时间,相信大家都差不多已经放弃了 JS 吧?如果还没有的话,这么长的时间,上次的 作业 做完了吗?总之,请各位先提交作业,然后请继续看这次的内容吧。

JS 的控制语句本身并没有太多难点,和 C 语言一系列的很像。如果各位之前没有学习过编程的话,可能会需要一点时间理解一下语句执行机制和控制流。但如果已经学习过的话,应该只需要熟悉下语法即可。 ES2015 (ES6) 以来, JavaScript 也加入了一些新的语句和语法,比如一个新的循环语法,也可以适当熟悉一下。

大家,和青色旋律一起继续愉快的 JavaScript 旅途吧~

(此文经过了彻底重写,和之前学习小组的讨论记录完全不同,看过讨论记录的建议重新读一次本文,相信一定会有收获。这次的各语句介绍更详细,且增加了 ES2015 等内容。)

目录

对了,这次青色旋律特别加了贴心的目录功能,点击即可跳到对应章节。系列之前几篇也加上啦,再也不怕找不到内容在哪里啦!就算整篇太长不看,也不妨跳着看自己想知道的。

上回的作业分析

上次的作业 中首先布置了预习或者复习控制语句,相信大家应该都已经完成了吧?如果没预习的话也没关系,接下去会有详细的讲解。其次,是抽奖机、 G+ 圈子系统、和金珂拉相关内容。如果大家已经做完了的话,请在 上次文章的评论区 提交作业。

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

作业解答 1: 谢谢惠顾!

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

解答:首先,我们需要一个数组来存放所有可能的奖项。

1
const results = ['一等奖', '二等奖', '谢谢惠顾', '谢谢惠顾', '谢谢惠顾'];

这个数组可以任意长,根据各位的恶意程度,可以放置 1~100 个'谢谢惠顾'

然后正如上次提示的一样,使用 const num = Math.floor(Math.random() * 8); 可以得到一个 0 到 7 之间的随机数。抽取到哪个数,就取出第几个元素。但数组的长度不一定是 8 ,比如以上一共只有 5 个元素。怎样做到无论数组长度为多少,都可以正常运行呢?这个就要使用 上次提到的 数组 .length 属性来获取数组长度,然后根据此长度来随机了。例如:

1
const selectedIndex = Math.floor(Math.random() * results.length);

此时 selectedIndex 会是大于等于 0 且小于数组长度之间的一个随机整数。此处 Math.random() 用于生成一个 [0, 1) 之间的实数,包括 0 但不包括 1. 然后例如 *8 后为 [0, 8), 再使用 Math.floor(x) 向下取整。

此后,使用数组的 [] 语法取得下标为 selectedIndex 的元素。注意数组元素从 0 开始,所以之前生成的随机数是 0 开始,小于长度。

1
2
const outcome = results[selectedIndex];
console.log('您抽奖的结果为: ' + outcome);

总之,这个题目并不是太困难,适合用于复习数组操作。

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

解答:这个并没有难度,如果注意使用 results.length 来获取长度的话,修改结果数量时就不需要再更改下面的代码了。刚接触编程的各位,青色旋律在此推荐大家能获取的值不要写死在程序里,尽量使用比较灵活的方式,这样代码也会相对可靠一些。

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

提示:可以把一开始的数组当做抽奖箱,每次抽取后,把抽取的那个结果从箱子中移除,这样下次就抽不到了。这个涉及到数组中删除一个元素的操作。由于 JS 里并没有一个函数直接对应删除元素,所以会需要上网搜索一下等,看看别人的回答。适当利用搜索引擎也是学习中很重要的一部分,甚至说是自学的必备技能之一也不为过,因此如果各位还不清楚要怎么做,不妨先搜索下试试看,然后再看答案。

提示 2: 由于是我们自己抽出的,下标 selectedIndex 是已知的,因此我们只需要根据数组下标来移除元素即可。试试看搜索关键词 JS 根据数组下标移除元素!或者,也可以试试看在 Google 搜索英文 ,例如 JS remove element from array by index. 由于英文社区比较活跃,提问的人也比较多,一般情况下更容易找到优质内容。注意搜索结果繁多,打开网页后也会有各种琳琅满目的答案,需要自行培养过滤信息的能力。

解答: 使用 results.splice(selectedIndex, 1); 可以移除那一个元素。其中 selectedIndex 是要移除的下标(开始位置),然后 1 指定删除一个元素。 splice 的用法可以参考 MDN 上的文档 。 特别注意如果省略第二个参数,会从把那里开始到数组结尾的元素全部删掉(又称为“腰斩”),是我们不需要的。由于 splice 函数名字比较怪,而且不止能删除一个,还能删除多个、插入、替换等,所以定位到这个函数会比较难。但适当搜索的情况下,总会有结果提到这个函数的。整体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
const results = ['一等奖', '二等奖', '谢谢惠顾', '谢谢惠顾', '谢谢惠顾'];
const selectedIndex = Math.floor(Math.random() * results.length);
const outcome = results[selectedIndex];
// 重点:删除刚抽出的元素:
results.splice(selectedIndex, 1);
console.log('您首次抽奖的结果为: ' + outcome);
// 下次抽奖重复操作一遍:
const selectedIndex2 = Math.floor(Math.random() * results.length);
const outcome2 = results[selectedIndex2];
results.splice(selectedIndex2, 1);
console.log('您第二次抽奖的结果为: ' + outcome2);

由于第二次数组长度已经发生了变化,所以需要用 results.length 再获取一次新的长度。当然也别忘了重新生成随机数。但是,第二次还要换不同的变量再写一次非常麻烦,而且难道抽 N 次就要复制和修改 N 遍吗?关于这一点,可以看接下来介绍的内容,使用循环语句连续抽奖 N 次。或者在《JavaScript: 从入门到放弃》系列的下一篇中学习函数,然后把抽奖的代码逻辑放到函数里,随时可以再次抽取。

作业解答 2: 土豪、菊苣和我

总之题目太长了,这里就不再贴一次了。请新来的各位自行查看 上次题目的内容 吧。

总之,需要设计一个数据结构保存以下人际关系,并且针对获取所有土豪、获取某人被多少人认为是土豪,和一些圈子操作做优化。

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

注意实际的操作中,很可能需要条件语句或者循环语句才能达成。因此这里先设计一下数据结构,然后在这次学习过语句后再编写操作吧。

首先是最简单的,最贴近原始数据的设计:

1
2
3
4
5
6
7
8
const relationships = [
{from: 'Alice', to: 'Bob', circleName: '土豪'},
{from: 'Alice', to: 'Carol', circleName: '菊苣'},
{from: 'Bob', to: 'Alice', circleName: '土豪'},
{from: 'Bob', to: 'Alice', circleName: '菊苣'},
{from: 'Bob', to: 'Carol', circleName: '土豪'},
{from: 'Carol', to: 'Bob', circleName: '菊苣'},
];

这里把每一对人际关系都存储为一个对象,然后整体数据结构就是一个数组,里面有各种关系。注意此处 Bob 认为 Alice 是土豪也是菊苣,所以这里使用了两个关系对象。当然,也可以只使用一个对象,比如 {from: 'Bob', to: 'Alice', circleNames: ['土豪', '菊苣']}. 但是假如这样设计,那么其他的关系元素也应该使用同样的格式,即使只有一个圈子。相同的数据结构有助于使用循环语句统一处理,减少条件判断等,这个后面会讲述。

这个设计方案的优点是直接明了,且插入数据非常方便,只需要 relationships.push(...) 即可。缺点是要进行任何检索、删除等操作,都必须检查一遍整个数组。比如计算 Bob 被多少人认为是土豪,需要逐个元素检查然后计数。

需要注意的是,很多时候假如处理的数据量并不大,可能最直接的方式才是最有效的,不要为了可能无关紧要的性能而忽视了代码复杂性。针对未来可能处理的数据量做出正确预估,然后再确定合适的方案,或者在实际情况下检测性能瓶颈和关键路径然后再优化,这样更为可取。这也是青色旋律在实际项目中汲取的经验教训。

那么,具体怎么样遍历数组和计数呢?其他又有哪些设计方案,支持什么样的操作,各有怎样的优缺点呢?请看后文 使用循环进行数据处理 一节。

作业解答 3: 我们都要支援他

JS 掺了金珂拉,码农日薪一万八!语言特性再也不向 PHP 进口啦!

同理,题目太长不贴,新来的各位请看 上次题目的内容 吧。

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

总之输出类似上面的顺口溜内容,但要求 金珂拉, 非洲, 日本 三个关键词可以替换, 以适应米国圣地亚鸽公司发布新产品时请不同长相的滑稽演员的需求。

首先,举一个输入的例子:

1
2
3
const product = '眼镜蛤';
const place1 = '法国';
const place2 = '米国';

根据原始题目要求,新产品名字不一定是三个字,因此需要做一些预处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
let nickname;
if (product.length < 2) {
console.log('新产品名太短,无法完成顺口溜!');
} else if (product.length === 2) {
// "切糕" => "切糕糕"
nickname = product + product[1];
} else if (product.length === 3) {
nickname = product;
} else {
// "圣地亚鸽" => "圣地鸽"
nickname = product.substr(0, 2) + product[product.length - 1];
}

需要特别注意的是,以上的代码取第 X 个字符等操作,对于某些 Unicode 字符 并不能正常工作。这里为了简单起见不再多介绍,有兴趣请阅读链接的文章。

这里使用了 if .. else 条件语句来保证代码清晰可读。具体请看 后文相关章节 。但实际上只使用 ?: 表达式也可以实现,只是语句会相对而言长一点,而且没办法处理错误情况。如果不记得 ?: 操作符了,请复习下上次 布尔值 相关的操作。

1
2
3
4
5
6
7
8
// "切糕" => "切糕糕"
const repeatedName = product + product.substr(1, 1);
// "圣地亚鸽" => "圣地鸽"
const shortName = product.substr(0, 2) + product[product.length - 1];
const nickname = (product.length === 2) ? repeatedName :
(product.length >= 4) ? shortName :
product;

为了让语句稍微精简一点,特意把一些计算放在了其他变量中先执行。这个例子中,由于用于获取子字符串的函数 product.substr(start, len) 即使超出范围也不会出错,只会返回空字符串之类的,所以提前计算也没关系。

有了产品的昵称 nickname 后,就可以输出顺口溜了。最简单的方式是直接用 模版字符串 来直接替换输出。注意 环境必须支持 ES2015 模版字符串 , 因此在一些过时的浏览器或者环境中无法使用。

1
2
3
4
5
6
console.log(`
${place1}农业不发达,必须要有${nickname}
肥料搀了${nickname},一袋能顶两袋撒。
${place2}国土太缺乏,必须要有${nickname}
肥料搀了${nickname},小麦亩产一千八。
`);

模版字符串有一个比较好的特点是可以包括换行,但换行后的代码不能缩进(否则空格也会进入到最终的字符串中),必须顶格,经常会使得代码变得很丑。

另外一种方案是直接拼接字符串,也并不是很难。这次我们也可以试试看分四次输出,就可以避开换行的问题了。

1
2
3
4
console.log(place1 + '农业不发达,必须要有' + nickname + '。');
console.log('肥料搀了' + nickname + ',一袋能顶两袋撒。');
console.log(place2 + '国土太缺乏,必须要有' + nickname + '。');
console.log('肥料搀了' + nickname + ',小麦亩产一千八。');

最后的办法就是用字符串替换,可以直接把 "非洲" 换成 "法国" 之类的。注意这个对原始的字符串有一定的要求,在其他例子中使用可能会替换到不该替换的内容,所以建议原始字符串里用比较特殊的字符串来作为替换目标。以下代码中为了对齐工整,使用了 甲地, 乙地, 新产品 三个词,但实战推荐更独特的字符串,例如 "_PLACE1_"

1
2
3
4
5
6
7
8
9
10
11
12
13
const sourceStr = [
'甲地农业不发达,必须要有新产品。',
'肥料搀了新产品,一袋能顶两袋撒。',
'乙地国土太缺乏,必须要有新产品。',
'肥料搀了新产品,小麦亩产一千八。',
].join('\n');
const result = sourceStr
.replace(/甲地/g, place1)
.replace(/乙地/g, place2)
.replace(/新产品/g, product);
console.log(result);

这个方案中用到一个小技巧,先用一个数组保存各行元素,然后用 .join 把四行连接在一起,中间以换行符 '\n' 隔开。这种使用 \ 开头的字符序列来代替难以输入在代码里的特殊字符方式,称为转义,具体参见 MDN 上文档 。注意如果不加换行符分隔的话,不会自动换行。

由于 JavaScript 不支持全局替换功能,比较简单的方式是使用 正则表达式 来进行替换。正则表达式的 g 参数指定该正则表达式为全局匹配,从而可以实现全局替换。关于如何编写正则表达式,请查看相关教程。

总之,作业的解答就到这里了。大家明白了吗?如果还有疑惑,可以在下方评论区给青色旋律留言。

tl;dr

如果您已经比较熟悉 C 系列的语言,那么 if 语句, switch 语句, for 语句都可以跳过不看,直接从 for .. of 循环 开始看即可。但请注意 letconst 变量作用域为块作用域,条件会 隐式转换为布尔值

条件语句

if 语句

首先是 if 语句,其基本语法如下:

1
2
3
4
5
if (condition) {
statements_1;
} else {
statements_2;
}

其中 condition 可以是一个计算结果为 布尔值 的表达式,比如 a > 5 等等。如果值为 true, 执行 statements_1; (称为真-分支),如果为 false 执行 statements_2; (假-分支). 值如果是其他的话,会 转换为布尔值 然后再执行。两个分支都可以是零条到多条语句,例如 console.log(a); 之类的,甚至可以嵌套其他 if 语句。 JS 语言运行的时候,正常情况一定会执行两个分支其中之一,不会执行另一个,所以称为分支。

注意 if (...) 的圆括号是语法的一部分,不能省略。如果条件不成立时不需要执行任何语句,那么 else 开始的部分可以全部省略,变成 if (condition) { statements_1; }. 换行不影响语法。

如果某个分支语句只有一条,那么可以省略花括号,但青色旋律强烈反对这种写法,除了 if (aaa) bbb; 这种极为简单,可以写在一行内且没有 else 分支的时候。有的项目更严格,这种情况也必须换行写花括号,所以在参与项目时请尽可能先了解其编码规范。

理论上空格和换行全省, if(a)b;else c 也可以执行,但对人类太不友好,不可取。在源代码中少按几下键盘,或者说为了减少传输的字节量之类的理由是不成立的,因为正常情况下如果有这个需求,都是通过自动工具来最小化代码 (例如 UglifyJS ),然后再经过网页传输压缩等再到达客户端的。至于不需要在网络上传输的代码,例如 Node.js 等场合,一般而言并不需要最小化。如果您看到其他网站有类似这种完全没有格式的代码,那么您可以试着用工具来 格式化代码 然后再分析。不过注意最小化的过程中,很可能变量名也被改成单字符的 a, b 之类的了,会很难阅读,所以还是尽量建议找源代码来看。

if .. else 语句一种常见的嵌套形式如下,可用于判断多个条件。(此处使用了 上次作业题的解答 为例。)

1
2
3
4
5
6
7
8
9
10
11
12
13
let nickname;
if (product.length < 2) {
console.log('新产品名太短,无法完成顺口溜!');
} else if (product.length === 2) {
// "切糕" => "切糕糕"
nickname = product + product[1];
} else if (product.length === 3) {
nickname = product;
} else {
// "圣地亚鸽" => "圣地鸽"
nickname = product.substr(0, 2) + product[product.length - 1];
}

这里实际上就是依次判断第一个条件,如果满足执行第一段,否则继续判断第二个条件,依此类推,最后如果都不满足就执行最后一个 else 之后的内容。常用于比较复杂的逻辑。这里实际上就是省略了前几个 else 语句的花括号,如果加上的话类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let nickname;
if (product.length < 2) {
console.log('新产品名太短,无法完成顺口溜!');
} else {
if (product.length === 2) {
// "切糕" => "切糕糕"
nickname = product + product[1];
} else {
if (product.length === 3) {
nickname = product;
} else {
// "圣地亚鸽" => "圣地鸽"
nickname = product.substr(0, 2) + product[product.length - 1];
}
}
}

但这样太繁琐了,而且后面的要缩进好多层,所以一般不这样写。但初学者可以把上面的想像成这样,这样对嵌套关系和执行逻辑的理解会更清楚一些。

switch 语句

如果大部分判断都是针对同一个表达式的不同可能值的话,还有一种 switch 语句会更简洁一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let nickname;
switch (product.length) {
case 0:
case 1:
console.log('新产品名太短,无法完成顺口溜!');
break;
case 2:
// "切糕" => "切糕糕"
nickname = product + product[1];
break;
case 3:
nickname = product;
break;
default:
// "圣地亚鸽" => "圣地鸽"
nickname = product.substr(0, 2) + product[product.length - 1];
break;
}

可以看到 switch 语句不关注布尔值,而是只针对括号内的表达式的可能取值进行判断。如果表达式计算后值等于 0, 那么 case 0: 后的内容会执行。否则继续判断下一个 case 是否相等……如果都不成立,那么 default: 后的内容会执行。由于只会检查相等不相等,自然没法写出 product.length < 2 这个逻辑,所以这里使用了 case 0:case 1: 两条语句来分别判断长度为 01

执行时,遇到 break; 语句才会终止,直接跳到 switch 语句之后。如果没有写 break;, 那么即使遇到其他 case xxx: 也不会做任何判断,而是继续执行下去。例如注意到 case 0: 后面紧接着 case 1:, 这样使得 01 情况下都输出错误。这个用法没什么问题,但如果在 case 2: 或者 case 3: 之后忘记写 break; 了,那么就很危险了,代码会继续执行到 default: 下面的语句,从而导致最终结果和一眼看上去的不一样。这个现象称为 fallthrough, 如果不小心写错会导致 bug, 而且滥用会导致代码不可读。青色旋律推荐使用代码风格检查工具来 检查这样的问题

能用 switch 表达的逻辑,都能使用类似 if (x === 1) { ... } else if (x === 2) { ... } else { ... } 的方式来表达,运行结果完全相同(除了 fallthough 的情况)。但反过来,用 if .. else 能表达的,不一定都能用 switch 表达,尤其是和相等无关的判断。实际编码时,根据实际情况,选用比较简明的方式吧。

循环语句

while 语句

最简单的循环,是 while (condition) { ... } 类型的循环。以抽奖为例,以下代码可以不断地抽取奖项,直到所有奖项都抽取完为止:

1
2
3
4
5
6
7
8
const results = ['一等奖', '二等奖', '谢谢惠顾', '谢谢惠顾', '谢谢惠顾'];
while (results.length > 0) {
const selectedIndex = Math.floor(Math.random() * results.length);
const outcome = results[selectedIndex];
results.splice(selectedIndex, 1);
console.log('抽出了: ' + outcome);
}

这里遇到 while 时,首先判断条件是否为 true 或者能转换为 true,如果不成立直接跳到结束。如果成立,会执行花括号内的所有代码,然后再跳回到条件那里,再次判断和执行,如此反复。这里的条件和强制转换和 if 相同。

注意由于开始之前就先判断,如果条件一开始就不是 true, 那么一次都不会执行,直接跳过整段。但假如条件一直是 true, 那么程序就停不下来了,称为死循环。例如 while (true) { console.log("Let's make the world a better place!"); } 会不停地打印,不会终止。这种时候需要用外部手段停掉 JS 解释器本身,比如关闭网页、关闭浏览器,在 Node.js 等环境使用 Ctrl-C 组合键,关闭终端,使用任务管理器等方式结束进程,甚至关机、拔电源等。打印大量内容本身也很可能会造成设备卡顿,妨碍操作。

但总的来说,只要执行的操作不是很危险,那不必太害怕死机等,假如出现了再处理就好。我们都曾是新手,通过犯错来学习也没什么问题。

while 函数体内使用 break; 语句可以中断执行,不进行任何条件判断,直接跳到结束。通常是配合 if 语句来使用,例如:

1
2
3
4
5
6
console.log('You cannot leave here until you pass the exam!');
while (true) {
const score = Math.random() * 100;
if (score >= 60) break;
console.log('Sorry. Try again!');
}

以上示例中,如果运气不好,随机不到 60 以上的分数,就无法退出循环。但假如随机到了,执行到 if (score >= 60) break; 就会退出了。此时是直接跳到结束,所以不会再多打印一次 'Sorry. Try again!'.

同样,滥用 break; 语句也会降低代码可读性。一般而言,把循环条件写在一开始会让人类更容易理解终止条件,但也不可一概而论,还是需要酌情判断。

最后,需要注意的是 const score 这个变量只在 { ... } 之内有效,而且每次执行时会创建一个新变量,只是名字相同,所以不是改变了变量值。 { ... } 称为一个 语句块 , 而变量的这个现象称为块作用域。其他用到语句块的场合,比如上述的 if (x) { ... }, 也适用这个规则。 let 语句声明的可再次赋值的变量,也遵守块作用域。语句块执行完毕后,这样声明的变量名就不能访问了,所以如果需要把值带出来,需要在块之外提前声明变量,例如:

1
2
3
4
5
6
7
let score;
while (true) {
score = Math.random() * 100;
if (score >= 60) break;
console.log('Sorry. Try again!');
}
console.log('Your final score is: ' + score);

注意 var 不同,使用的是“函数作用域”,也就是说每次函数执行中有效,具体情况在本系列下一篇函数相关的内容中详细叙述。

do .. while 语句

do .. while 语句与 while 语句类似,但主要区别是判断条件写在最后,所以是先执行再判断。例如上面那个例子如果用 do .. while 语句来写的话:

1
2
3
4
5
let score;
do {
score = Math.random() * 100;
} while (score >= 60);
console.log('Your final score is: ' + score);

注意这里省略了打印 "Try again" 的部分。可以看到同样的逻辑使用 do .. while 方式表达,无论如何都会先进入一次,生成随机数然后再进行判断,这大大简化了代码书写。如果需要先执行再判断的场合,可以考虑这种写法。

特别注意,由于块作用域的变量只在 { ... } 中有效,执行完毕后就不能再访问了,所以以下写法的最后一行会找不到 score 变量,无法编译执行。

1
2
3
do {
const score = Math.random() * 100;
} while (score >= 60); // ReferenceError: score is not defined

for 语句

for 语句可以看做是一种比较特殊的 while 循环,一般用于限定次数的场合。例如:

1
2
3
for (let i = 0; i < 5; i++) {
console.log('Hello ' + i);
}

这个例子会输出 "Hello 0", "Hello 1", "Hello 2", "Hello 3", "Hello 4".

对于没有接触过 C 系列语言的人来说,可能 for 语句看起来很复杂,但实际上还算比较简单,语法是 for (initial; condition; next) { body; }。首先,在循环前一定会执行 initial 语句,而且只执行一次,后续不再执行。这一部分通常用于声明和初始化变量。 condition 部分是循环条件,通常是布尔表达式,与 while (condition) 的情况相同,为 true 或者能转换为 true 则继续循环。接下去执行 body; 部分的一条或者多条语句。最后的 next 部分则是在每次循环 body; 执行完毕后执行,通常用于前进到下一步。这个例子中我们使用了 i++, 表示将变量 i 的值增加 1 (等同于 i = i + 1), 然后再判断 condition, 开始下一步。

也就是说,for (initial; condition; next) { body; } 可以粗略当成下面的 while 循环写法(除了 continue; 的处理以外,后述):

1
2
3
4
5
6
7
{
initial;
while (condition) {
body;
next;
};
}

如果 initial 部分有变量声明,作用域按照特殊规则,变量只在 for 语句内(包括 condition, body, next 三部分)才能访问,for 结束之后的下一条语句就无法访问这个变量了。这个特性非常方便,使得 initial 部分的变量可以真正做到用完就丢,完全无污染。如果改用 while 语句,可以看到以上的代码需要整个用 { ... } 包起来,才能实现同样的效果。另外, for 循环的 let 变量对于其中声明的函数也有特殊处理,这个在《JavaScript: 从入门到放弃》的下一篇,关于函数部分会进行说明。

注意 initial, condition, next 三部分都可以省略。其中 initialnext 省略就是什么都不执行了,而 condition 省略的话相当于 true, 也就是不退出,这种时候需要在里面使用 break 退出以免造成死循环。省略时不能省略分号,所以假如三部分都没有,就是写作 for ( ; ; ) 或者 for(;;) (不推荐,不如用 while (true) 意图更清楚)。如果 body; 只有一条语句,花括号可以省略,但除非很短可以写在同一行,否则不推荐这样写。

另外,在 for 循环中使用 continue; 语句时,会中断本次循环,而是直接执行 next; 部分,然后判断 condition 进行下一次循环。这个常用于需要跳过这次的处理而直接开始处理下一次的情况。要注意在 for 循环中 continue; 还是会执行 next;, 这一点上和前一个示例使用 while 实现的代码不相同。

for 语句常见用于 for (let i = 0; i < 5; i++) 这样限定次数的循环。或者说,只要是事先知道循环多少次的情况, JS 程序员大多会用这样的 for 语句实现。这个范式当然也可以用来遍历数组,比如以下代码打印所有可能的抽奖结果:

1
2
3
4
5
const results = ['一等奖', '二等奖', '谢谢惠顾', '谢谢惠顾', '谢谢惠顾'];
console.log('根据公平公正的原则,现在公开本次抽奖所有可能的结果:');
for (let i = 0; i < results.length; i++) {
console.log('结果 ' + i + ': ' + results[i]);
}

for .. of 循环

但如果遍历数组的时候只需要数组元素,而不需要下标 i 的话,还有一种更简便的写法,使用 for .. of 循环。注意此语法在 支持 ES2015 语法的环境 方可使用,在不支持的环境可能会导致语法错误。

1
2
3
for (const result of results) {
console.log(result);
}

这样会输出 results 中每个元素。优点是非常简单直观,缺点是循环中没有数组的下标,比如说这个例子中无法输出是第几个结果。这个语法不仅对数组有用,对 Map, Set 等集合,以及所有其他遵守 iterable 协议的对象都可以使用。

如果声明了新的变量,这个变量每次循环的时候都会相当于重新声明了一次,而且遵守块作用域,只有当前循环的 { ... } 中有效,下次循环的时候会是一个名字相同但其他都不同的新变量。因此可以用也建议用 const 来声明。这一点和 for 使用同一个变量但改变值的行为完全不同。当然,也不一定非要声明新变量,也可以用外面已经有的变量,不过不推荐。

例如,字符串也是可以遍历的,其中的“元素”是每一个符号:

1
2
3
for (const c of 'abc') {
console.log(c);
}

这个例子依次输出字符串 "a", "b", "c". 由于 JavaScript 没有字符类型,所以每个“元素”是一个只含有一个“符号”的字符串。这个例子的一个显著优点是能正确处理 Unicode 符号,包括 U+010000 以上的符号。它会整个符号当做一个元素一起,而不像是大部分其他处理方式会把某些符号拆成一对 surrogate pair 当做两个字符处理。

遍历 Map 对象时,每次循环会获得一个数组,有且仅有两个元素,分别是键和值。

1
2
3
4
5
6
7
const map = new Map();
map.set('a', 1);
map.set('b', 2);
for (const pair of map) {
console.log('Key: ' + pair[0] + ', Value:' + pair[1]);
}

利用 解构表达式 ,上面的例子也可以更简洁地写成:

1
2
3
for (const [key, value] of map) {
console.log('Key: ' + key + ', Value:' + value);
}

但假如有一个对象 const obj = {a: 1, b: 2};, 怎么遍历对象的属性和值呢?也很简单,先想办法找到对象的所有属性,然后遍历。查阅文档或者搜索后发现 Object.keys(obj) 可以返回所有对象自身的可枚举属性,于是直接用:

1
2
3
4
const obj = {a: 1, b: 2};
for (const key of Object.keys(obj)) {
console.log('Key: ' + key + ', Value:' + obj[key]);
}

这里顺便复习下 obj[key] 可以返回属性 key 对应的值。 ES2017 还新增了 Object.values(obj) 只获取值,或者 Object.entries(obj) 来获取键值对。在用最新的功能前,请务必 检查运行环境是否兼容

特别注意,对象的 有些属性不可枚举 ,不会出现在这些函数的返回结果里。

for .. in 循环

for .. in 循环也可以用于遍历对象的属性,但注意与 Object.keys(obj) 不同,这个方法会 遍历所有可枚举属性,包括原型链上继承的属性 。关于原型链,请关注青色旋律博客,此教程的后续系列篇。此处只需要知道,有时候会出现“不属于自己”的属性,从而会比 Object.keys(obj) 返回的属性多。

由于 MapSet 的使用,新写的 JS 代码不应该再把对象当做字典使用了,因此 for .. in 循环的使用频率也会降低很多。如果一定要这样用,请三思 。如果是为了打印或者拷贝对象的话,也请最好不要自己强行做,而是找个比较可靠的库来使用。

如果一定要用,建议过滤掉不属于自己的属性:

1
2
3
4
5
6
var obj = {a: 1, b: 2};
for (var key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
console.log('Key: ' + key + ', Value:' + obj[key]);
}
}

基本上来说,只有在没有 ES2015 的环境比较有用。注意即使没有 for .. of 支持,遍历数组也不应该使用 for .. in, 而是应该使用最典型的 for 循环,例如 for (var i = 0; i < arr.length; i++).

使用循环进行数据处理

给定一个由对象和数组组成的数据结构,就可以轻松地使用各种循环语句来处理了。以之前 作业解答 中设计的数据结构为例:

1
2
3
4
5
6
7
8
const relationships = [
{from: 'Alice', to: 'Bob', circleName: '土豪'},
{from: 'Alice', to: 'Carol', circleName: '菊苣'},
{from: 'Bob', to: 'Alice', circleName: '土豪'},
{from: 'Bob', to: 'Alice', circleName: '菊苣'},
{from: 'Bob', to: 'Carol', circleName: '土豪'},
{from: 'Carol', to: 'Bob', circleName: '菊苣'},
];

首先,最简单的数据结构中最外层是数组,使用 for .. of 即可轻松遍历。让我们先来完成作业要求的第一个操作,获取所有被人认为是土豪的用户。

1
2
3
4
5
for (const rel of relationships) {
if (rel.circleName === '土豪') {
console.log(rel.to + ' 是土豪!');
}
}

接下来,第二个任务是给定一个人,例如 const user = 'Carol';, 快速获取有多少人认为这位用户是土豪,而多少人认为这位人是菊苣。先来试试看最直接的做法。

1
2
3
4
5
6
7
8
9
10
11
12
13
const user = 'Carol';
let tuhaoCount = 0;
let jujuCount = 0;
for (const rel of relationships) {
if (rel.to === user && rel.circleName === '土豪') {
tuhaoCount++;
}
if (rel.to === user && rel.circleName === '菊苣') {
jujuCount++;
}
}
console.log(tuhaoCount + ' 人认为 ' + user + ' 是土豪');
console.log(jujuCount + ' 人认为 ' + user + ' 是菊苣');

很简单吧?但是,时代在变化,目前大触、学霸等多种超级生物也加入了 G+, 假如要统计所有的种类,恐怕代码会严重膨胀。让我们试试看用更精巧的设计,让代码可以处理所有圈子的类型。

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
const user = 'Carol';
// 记录所有发现的圈子名,以及对应的人数。
const circleNames = [];
const circleCounts = [];
for (const rel of relationships) {
// 如果不是对于 user 的印象,我们不需要处理,直接跳到下一个关系。
if (rel.to !== user) continue;
// 标记是否是否已经见过这种圈子。
let foundCircle = false;
for (let i = 0; i < circleNames.length; i++) {
if (rel.circleName === circleNames[i]) {
// 找到了见过的圈子,对应人数 +1.
circleCounts[i]++;
foundCircle = true;
// 既然已经找到了就不需要继续找了,直接终止寻找。
break; // 注意嵌套循环的情况下,只会终止内循环。
}
}
if (!foundCircle) {
// 没找到的话新记录下圈子名称,并且设置人数为1.
circleNames.push(rel.circleName);
circleCounts.push(1);
}
}
// 最后循环输出所有印象。
for (let i = 0; i < circleNames.length; i++) {
console.log(circleCounts[i] + ' 人认为 ' + user + ' 是 ' + circleNames[i]);
}

这样就没问题了!不过这次的代码有些复杂,让我们首先看看整体思路吧。首先这个程序使用 circleNames 记录所有发现的圈子名称,不重复,然后 circleCounts 与其一一对应(下标相同),记录各个圈子的关系数量。然后对于每个关系,如果是见过的圈子就更新关系数量,没见过的就新增记录下来。最后一并打印输出。这里用到了嵌套循环,如果各位还不适应的话请尽快理解和适应,因为以后可能会很常用。总之嵌套循环也是按照顺序严格执行的,内层的先循环完才会循环外层的,并不存在两个变量一起动之类的事情。

注意里面还用到了 continue;break; 来简化逻辑。前者结束此次处理而直接进行下一个处理,而后者直接终止循环。具体用途见代码注释。

但是维护 circleNamescircleCounts 两个不同的数组实在是太麻烦了,还需要用下标进行对应。我们能不能合而为一呢?答案当然是能,但需要把两个值合在一起塞到一个数组里。这时就可以使用对象来表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const user = 'Carol';
const circles = []; // Each element is a seen circle: {name: 'abc', count: 42}
for (const rel of relationships) {
if (rel.to !== user) continue;
let foundCircle = false;
for (const circle of circles) {
if (rel.circleName === circle.name) {
circle.count++;
foundCircle = true;
break;
}
}
if (!foundCircle) {
circles.push({name: rel.circleName, count: 1}); // 1
}
}
for (const circle of circles) {
console.log(circle.count + ' 人认为 ' + user + ' 是 ' + circle.name);
}

思路和上述相同,但这次只用一个数组,数组中每个元素是一个对象,形如 {name: '菊苣', count: 1}. 见标记 // 1 的那一行。注意由于 JS 本身是动态类型语言,不需要事先声明对象的结构即可使用。但这里使用者如果不看到比较后面的 // 1 那一行,就不知道这个对象的结构是什么样的。如果结构很重要,可以提前在数组那里就加上注释说明结构,如以上代码第二行所示。

那么,有没有办法干脆不用数组来表示圈子名,从而彻底避免嵌套循环呢?当然也是可以的,这就需要使用字典结构,例如 Map.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const user = 'Carol';
const countOfCircle = new Map();
for (const rel of relationships) {
if (rel.to !== user) continue;
const oldCount = countOfCircle.get(rel.circleName);
if (oldCount) {
countOfCircle.set(rel.circleName, oldCount + 1);
} else {
countOfCircle.set(rel.circleName, 1);
}
}
for (const [name, count] of countOfCircle) {
console.log(count + ' 人认为 ' + user + ' 是 ' + name);
}

字典结构在这种需要查询和去重复的场合有明显的优势,比如可以直接用 countOfCircle.get("菊苣") 直接获取之前的数量,避免了一层循环。由于此函数在不存在时返回 undefined, 而 undefined 转换为布尔值false, 所以会进入 else 分支,新设置一个值。存在的场合当然就是重新设置为 (以前的值 + 1) 啦。注意最后用到 for .. of 遍历字典,每次返回一个数组,第一个元素是键,第二个是值,这里用 解构表达式 直接拆成两个变量。

注:如果您的目标执行环境没有 Map, 建议找一个带字典功能的 JS 库,而不要自己强行去用对象来实现。否则万一有一个用户真的起名叫 toString, __proto__ 之类的,和继承的隐藏属性发生冲突,代码会被坑得很惨。

可以看到,经过几次重构,最终的代码因为使用了合适的数据结构而变得非常简洁易懂。顺带性能也提高了一些,但在这么小的数据集看不出差别。可读性和可维护性差距更显著。

有没有办法干脆不用循环,直接获取到想要的结果?在这个例子中,因为需要动态处理各种不同的圈子,所以是没办法的。但如果只限“土豪”和“菊苣”,那么我们完全可以设计一种这样的数据结构:

1
2
3
4
const countsOfUser = new Map();
countsOfUser.set('Alice', new Map([['土豪', 1], ['菊苣', 4]]));
countsOfUser.set('Bob', new Map([['土豪', 2], ['菊苣', 5]]));
countsOfUser.set('Carol', new Map([['土豪', 3], ['菊苣', 6]]));

这样的话,只需 countsOfUser.get('Carol').get('土豪') 即可获得 3, 免除了循环的烦恼。但前提是——数据在创建的时候就必须按照这个结构来存储。因此,我们就需要修改添加和删除圈子的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 添加圈子输入
const from = 'Alice';
const to = 'Bob';
const circleName = '土豪';
// 添加圈子实现
let counts = countsOfUser.get(to);
if (!counts) {
counts = new Map();
countsOfUser.put(to, counts);
}
const oldCount = counts.get(circleName);
if (oldCount) {
counts.set(circleName, oldCount + 1);
} else {
counts.set(circleName, 1);
}
1
2
3
4
5
6
7
8
// 删除圈子输入
const from = 'Alice';
const to = 'Bob';
const circleName = '土豪';
// 删除圈子实现
const counts = countsOfUser.get(to);
const oldCount = counts.get(circleName);
counts.set(circleName, oldCount - 1);

也就是说,必须在修改操作的时候做更多操作,来使得查询操作的时候更简单更快。根据具体的业务不同,假如查询很多很多,但修改很少,那么这样的取舍也许是值得的。总之,数据结构和访问的设计也并非总是有明确的优劣之分,很多时候必须耐心分析做取舍。如果性能上很难做决定或者差距不大,那么建议选比较简单易懂的方式,以便之后维护。

作业

好的,终于到了激动人心的作业环节啦!各位,上次的作业做了吗?如果没做的话,真是太遗憾了呢,不过不要灰心,从这次开始做也没有问题的啦。考虑到大家都学业/工作繁忙,这次作业量比较少,花不了多少时间。如果有空余时间的话,请试着预习下函数吧?

嗯哼,总之作业内容全部为可选,供您自学时作为参考。如果您对于以上的概念是第一次接触,建议还是尽量完成作业。

作业完成后,可以上传至 https://gist.github.com/ ,并将链接贴在下方评论区中。青色旋律会对作业进行批改和反馈。如果有任何问题也可以使用下方评论区提问。

作业并不是一定要完成的,也没有截止日期,但作业的参考答案将会在下一次的系列博文中公布,您可以在那时自行核对答案,检验自己的学习成果。

零、请继续复习控制语句

请复习或补习 JS 的所有控制语句。有不清楚的地方可以在评论区问,也可以自行查找 MDN 等相关资料进行学习。

对于没有学习过编程或者不了解控制语句的各位,建议在 Codecademy 进行简单的训练。 Codewars 也有很多不错的 关于控制语句的题目,可以用于训练。

一、洗牌时间

对于给定的任意数组,编写代码对数组中的元素进行随机排序,就像洗牌一样。

可以上网搜索洗牌(随机排序)的实现思路,也可以自己考虑。但是青色旋律当然不建议大家搜索现成的代码,那样起不到任何练习的效果。

如果自己考虑了很久也没有思路的话……那用最简单的方法,随机交换多次就行了。

最后,多运行几次自己的程序,进行下测试。

二、土豪、菊苣和我 ~Extra~

针对 上次题目 中您设计的数据结构,撰写一些处理代码,实现以下功能:

查询功能:

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

更新操作:

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

如果您上次没有设计数据结构,请结合今天学到的内容,现在做一下设计。反正很快的。假如您设计的结构碰巧和今天举例的完全一样,那么您也可以试试看再设计一种不同的结构然后再开始写处理代码。

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


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