本文记录我在 2020 年作为初学者学习 JavaScript 的一些感想。

本文初稿写作于 2020-08-21,之后因故没有完成。在 2020 年结束前,我还是把这份残稿发出来吧。

一、学习背景

根据 StackOverflow 2019 年度调查,JavaScript 以 67.8% 的比例,当之无愧成为了使用人数最多的编程语言。在 /r/ProgrammerHumor 上,有关 JavaScript 的 meme 也是出现频率最高的话题之一。

近年来,有关 JavaScript / ECMAScript 的新标准、新技术、新框架层出不穷,ECMA 更是在 ES6 / ES2015 之后每年都更新一版标准,因此有个 meme 是说网购 JavaScript 的书,还没寄到呢,书里的内容就已经过时了。在 COVID-19 瘟疫期间,我决定从头开始学习 JavaScript。

在学习 JavaScript 之前,我使用最多的编程语言是 Python,因此免不了会对 Python 有一些「先入为主」的感觉,并因此难以接受 JavaScript 的相关设定。

我阅读的教材是 2020 年出版的 JavaScript: The Definitive Guide, 7th Edition,其内容更新至 ES2020 标准,在 ES2021 出现之前,尚未过时。

二、关于标准

严格来说,ECMAScript (ES) 是标准的名字,而 JavaScript (JS) 是 ES 的一种实现。但是当今很多情况下,大家往往把这两个词混用。本文将使用 ECMAScript 指代标准,使用 JavaScript 指代所有的 ECMAScript 实现。

通过查阅 ECMAScript 的历史,我了解到:在 2009-12 发布的 ES5 之前,JavaScript 是一门垃圾语言:它本身就只是为了在 Netscape 里做有限的网页互动而研发出来的,功能很局限,实现上也有很多糟糕的地方,要想写出无错的 JavaScript 代码是件很困难的事情。ES5 的发布,带来了 strict mode 和 JSON,让 JavaScript 的可用性上了一个台阶,许多糟糕的设计终于可以在启用 strict mode 之后屏蔽掉了。2015-06 发布的 ES6 (ES2015) 则是一次巨大且重要的更新,增加了很多新功能,使得 JavaScript 更像是一门现代语言了。从 2016 年开始,ECMA 每年发布一个新标准,并以年份命名。这些新标准带来的变化不如 ES5 / ES6 那么巨大,因此大家往往以是否支持 ES6 作为 JavaScript 引擎(以及包含这些引擎的浏览器)的分水岭。

2009 年起,Node.js 引擎使得 JavaScript 可以方便地在浏览器以外的地方运行。通过实现文件系统、网络接口等 API,Node.js 让 JavaScript 也能当一般的编程语言来使用。

三、弱类型

众所周知,JavaScript 是弱类型语言,这也是许多 JavaScript meme 的创作源泉。比如

javascript-meme-math-sin-undefined

正经地学习了 JavaScript 的数据类型,我发现……弱类型还真是能把人气笑。

比如 Array 是能够区分 Number 和 String 的,但是其默认的排序操作却是全部转成 String 进行排序:

> ['foo', 'bar', 'hello', 'world'].sort()
[ 'bar', 'foo', 'hello', 'world' ]  // 嗯,没啥问题。
> [1111, 222, 33, 4].sort()
[ 1111, 222, 33, 4 ]  // 嗯?

我觉得吧,.sort() 默认使用 String 排序无可厚非,哪怕默认不支持 Number 排序也没啥问题,但是把 Number 转成 String,排完序之后再转回来,这就非常令人不解了。

要想按数字大小排序?对不起,请手动比大小:

> [1111, 222, 33, 4].sort((a, b) => a - b)
[ 4, 33, 222, 1111 ]

可能是因为先学习了强类型语言,再学习了弱类型语言,这种不同类型之间的隐式转换让我觉得很反直觉——两个 String 相减/相除是什么未定义行为?然而在 JavaScript 里这都是合法的:

> '123' + '23'
'12323'  // 嗯,很合理。
> '123' - '23'
100  // 嗯?你咋不说是 '1' 呢?
> '10' / '2'
5  // ……不知道怎么吐槽好

也许对于把 JavaScript 作为第一门编程语言来学习的人来说,隐式类型转换才是符合直觉的。但这样真的不是很容易犯错吗?

四、[object Object]

在 JavaScript 里,除了 Number、String 等基础数据类型,所有的容器类型都是 object。起初看到 let obj = {'foo': 'bar'} 这样的定义方法,以及 obj.fooobj['foo'] 这样使用方法,让我觉得很亲切,以为这就是个普通的 mapping 类型,然而事情并没有这么简单。我很快就发现 ES6 新增的 shorthand 对我来说非常反直觉:

let foo = '123';
let bar = '456';
let obj = {
  foo,
  bar,
};

因为 key 和 value 长得一样,所以就可以只写一遍了?可是它们一个是变量,一个是字符串啊。说好 object 的 property 只能是 String 或 Symbol 呢?怎么拿个变量名就直接推导出 property 的字符串名字了?

由于 Array 本质也是个 object,所以用 for...in 去循环一个 Array 会循环到它那隐藏的 key 上去:

> let array = ['foo', 'bar', 'baz'];
> for (let i in array) {
... console.log(i);
... }
0
1
2

总算在 ES6 里增加了 for...of 语法,可以达到「正确」的效果:

> for (let i of array) {
... console.log(i);
... }
foo
bar
baz

ES6 新增的 .forEach() 也挺不错的,可惜不能 break,用途有限。

因为 Array 的本质是把 index 当 key 的 object,所以也可以增加 non-index key:

> array[0];  // 一个纯血 Array……
'foo'
> array['hello'] = 'world';  // ……被插入了一个 non-index key……
'world'
> array
[ 'foo', 'bar', 'baz', hello: 'world' ]  // ……就变成混血了!

如果插入的 String key 恰好长得像个数字的话,还会被当成 index key 来处理:

> array['100'] = 'hundred';
'hundred'
> array
[ 'foo', 'bar', 'baz', <97 empty items>, 'hundred', hello: 'world' ]

在 ES6 里还有一个新的结构叫 Map,用来完成一些 object 做不到的事情,比如记住 key 插入的顺序、允许 String 和 Symbol 以外的类型作为 key 等等(顺便 object 居然不能拿 Number 当 key)。然而 Map 的本质也是 object 啊!于是按照一般 object mapping 的惯性去使用 Map 就会有意想不到的效果:

> let map = new Map();
> map['foo'] = 'hello';
> map['bar'] = 'world';
> map
Map(0) { foo: 'hello', bar: 'world' }  // 看起来挺正常的。
> map.set('foo', 'HELLO');
> map.set('bar', 'WORLD');
> map
Map(2) {
  'foo' => 'HELLO',
  'bar' => 'WORLD',
  foo: 'hello',
  bar: 'world'
}  // 这是什么缝合怪?

为了让旧代码能在新引擎中运行,ES 标准在引入新功能的时候,会很谨慎地维护兼容性。但是至少在 Map 这个 ES6 新增的功能里,屏蔽或 overload 掉 object 本来的语法会更加好一些吧。比如执行 map['foo'] = 'hello' 的时候就在引擎里转换成执行 map.set('foo', 'hello'),这样这些志在解决历史遗留问题的新功能就没有那么令人迷惑的行为了。

五、静默错误

Python 讲究「Errors should never pass silently.」,但是 JavaScript 里却有不少操作是 fail silently 的:

> let obj = {};
> obj.foo = 'hello';
> Object.defineProperty(obj, 'foo', {writable: false})  // 将 .foo 改成不可写
> obj.foo
'hello'  // obj.foo 现在是 'hello'
> obj.foo = 'world';  // 试图改写
'world'  // 成功了?
> obj.foo
'hello'  // 并没有。

不仅 fail silently,还假惺惺地返回一下设定值,假装自己成功了,但其实并没有。总算,在 strict mode 下,这一行为是会报 TypeError 的。

更过分的是,函数定义时的 parameters 和调用时的 arguments 数量不匹配也是不会报错的:

> const add = (a, b) => a + b;  // 定义一个有两个 parameters 的函数
> add(1, 2);  // 正常调用,传递两个 arguments
3
> add(1, 2, 3);  // 多传个 argument 并不会报错
3
> add(1);  // 少传个 argument 也不会
NaN

这一点配合隐式类型转换,很容易招致各种难以排查的 bug。


欢迎留下评论。评论前,请先阅读《隐私声明》。