JavaScript 的设计失误

9 月份的时候在腾讯的 IMWeb Conf 上就这个话题做过一次分享,如果嫌文章太长太乱的话可以去 GitHub 上看 slides

typeof null === 'object'

这是一个众所周知的失误。这个问题其实源于初版 JavaScript 实现中的一个 bug

注:上述文章引用的代码其实已经不算是最初版本的实现了,但 Brendan Eich 自己也在 Twitter 上表示,这是一个 abstraction leak,可以理解为变相承认这是代码 bug。

typeof NaN === 'number'

不太确定这个算不算一个设计失误,但毫无疑问这是反直觉的。

说到 NaN,其实还有更多有趣的知识点,这个 13 分钟的 talk 非常值得一看:Idiosyncrasies of NaN

NaN, isNaN(), Number.isNaN()

NaN 是 JavaScript 中唯一一个不等于自身的值。虽然这个设计其实理由很充分(参见前面推荐的那个 talk,在 IEE 754 规范中有非常多的二进制序列都可以被当做 NaN,所以任意计算出两个 NaN,它们在二进制表示上很可能不同),但不管怎样,这个还是非常值得吐槽……

isNaN() 这个函数的命名和行为非常让人费解: 1. 它并不只是用来判断一个值是否为 NaN,因为所有对于所有非数值型的值它也返回 true; 2. 但也不能说它是用来判断一个值是否为数值的,因为根据前文,NaN 的类型是 number,应当被认为是一个数值。

还好,ES2015 引入了 Number.isNaN() 来拨乱反正:它只对参数为 NaN 的情况返回 true

其他一些资料:

分号自动插入(Automatic Semicolon insertion,ASI)机制

Restricted Productions

据 Brendan Eich 称,JavaScript 最初被设计出来时,上级要求这个语言的语法必须像 Java。所以,跟 Java 一样,JavaScript 的语句在解析时,是 需要分号 分隔的。但是后来出于降低学习成本,或者提高语言的容错性的考虑,他在语法解析中加入了分号自动插入的纠正机制。

这个做法的本意当然是好的,有不少其他语言也是这么处理的(比如 Swift)。但是问题在于,JavaScript 的语法设计得不够安全,导致 ASI 有不少特殊情况无法处理到,在某些情况下会错误地加上分号(在标准文档里这些被称为 Restricted Productions)。 最典型的是 return 语句

// returns undefined
return
{
    status: true
};

// returns { status: true }
return {
    status: true
};

这导致了 JavaScript 社区写代码时花括号都不换行,这在其他编程语言社区是无法想象的。

漏加分号的情况

还有另外一种 ASI 不会纠正的问题:

var a = function(x) { console.log(x) }
(function() {
    // do something
})()

这类问题通常出现在两个文件被压缩后再拼接到一起时。

semicolon-less 风格

Restricted Productions 的问题已经是语言的特性了并且无法绕开,无论如何我们都需要去学习掌握。

而前面提到的第二类问题,ASI 失灵的情况,采用强制分号的代码风格其实很难规避(现在有了 ESLint 的 no-unexpected-multiline 规则会后会容易点)。更好的办法是实践 semicolon-less 的代码风格,不在行末写分号,而是当行首出现 + - [ ( / 这五个操作符之一时再往前加分号,这样的记忆成本和出错概率远低于强制分号风格。

==, ===Object.is()

JavaScript 是一种弱类型语言,存在隐式类型转换。因此,== 的行为非常令人费解:

[] == ![]   // true
3 == '3'    // true

所以各种 JavaScript 书籍都推荐使用 === 替代 == (仅在 null checking 之类的情况时可以除外)

但事实上,=== 也并不总是靠谱,它至少存在两类例外情况: 1. 前文提到过, NaN === NaN 会返回 false 2. +0 === -0 会返回 true,然而这两个其实是不相等的值(1 / +0 === Infinity; 1 / -0 === -Infinity

一直到 ES2015,我们才有了一个可以比较两个值是否严格相等的方法:Object.is(),它对于 === 的这两种例外都做了正确的处理。

Falsy values

JavaScript 中至少有六种假值(在条件表达式中与 false 等价):0, null, undefined, false, '' 以及 NaN

+- 操作符相关的隐式类型转换

大致可以这样记:作为二元操作符的 + 会尽可能地把两边的值转为字符串,而 - 和作为一元操作符的 + 则会尽可能地把值转为数字。

("foo" + + "bar") === "fooNaN" // true
'3' + 1 // '31'
'3' - 1 // 2
'222' - - '111' // 333

nullundefined 以及数组的 "holes"

在一个语言中同时有 nullundefined 两个表示空值的原生类型,乍看起来很难理解,不过这里有一些讨论可以一看: - Java has null but only for reference types. With untyped JS, the uninitialized value should not be reference-y or convert to 0. - GitHub 上的一些讨论 - Null for Objects and undefined for primitives

不过数组里的 "holes" 就非常难以理解了。

产生 holes 的方法有两种:一是定义数组字面量时写两个连续的逗号:var a = [1, , 2];二是使用 Array 对象的构造器,new Array(3)

数组的各种方法对于 holes 的处理非常非常非常不一致,有的会跳过(forEach),有的不处理但是保留(map),有的会消除掉 holes(filter),还有的会当成 undefined 来处理(join)。这可以说是 JavaScript 中最大的坑之一,不看文档很难自己理清楚。

具体可以参考这两篇文章: - Array iteration and holes in JavaScript - ECMAScript 6: holes in Arrays

Array-like objects

JavaScript 中,类数组但不是数组的对象不少,这类对象往往有 length 属性、可以被遍历,但缺乏一些数组原型上的方法,用起来非常不便。比如在为了能让 arguments 对象用上 shift 方法,我们往往需要先写这样一条语句:

var args = Array.prototype.slice.apply(arguments)

非常不便。

在 ES2015 中,arguments 对象不再被建议使用,我们可以用 rest parameter (function f(...args) {})代替,这样拿到的对象就直接是数组了。

不过在语言标准之外,DOM 标准中也定义了不少 Array-like 的对象,比如 NodeListHTMLCollection。 对于这些对象,在 ES2015 中我们可以用 spread operator 处理:

const nodeList = document.querySelectorAll('div')
const nodeArray = [...nodeList]

console.log(Object.prototype.toString.call(nodeList))   // [object NodeList]
console.log(Object.prototype.toString.call(nodeArray))   // [object Array]

arguments

在非严格模式(sloppy mode)下,对 arguments 赋值会改变对应的 形参

function a(x) {
    console.log(x === 1);
    arguments[0] = 2;
    console.log(x === 2);// true
}

function b(x) {
    'use strict';
    console.log(x === 1);
    arguments[0] = 2;
    console.log(x === 2);// false
}

a(1);
b(1);

函数级作用域 与 变量提升(Variable hoisting)

函数级作用域

蝴蝶书上的例子想必大家都看过:

// The closure in loop problem
for (var i = 0; i !== 10; ++i) {
  setTimeout(function() { console.log(i) }, 0)
}

函数级作用域本身没有问题,但是如果如果只能使用函数级作用域的话,在很多代码中它会显得非常 反直觉,比如上面这个循环的例子,对程序员来说,根据花括号的位置确定变量作用域远比找到外层函数容易得多。

在以前,要解决这个问题,我们只能使用闭包 + IIFE 产生一个新作用域,代码非常难看(其实 with 以及 catch 语句后面跟的代码块也算是块级作用域,但这并不通用)。

幸而现在 ES2015 引入了 let / const,让我们终于可以用上真正的块级作用域。

变量提升

JavaScript 引擎在执行代码的时候,会先处理作用域内所有的变量声明,给变量分配空间(在标准里叫 binding),然后再执行代码。

这本来没什么问题,但是 var 声明在被分配空间的同时也会被初始化成 undefinedES5 中的 CreateMutableBinding),这就相当于把 var 声明的变量提升到了函数作用域的开头,也就是所谓的 "hoisting"。

ES2015 中引入的 let / const 则实现了 temporal dead zone,虽然进入作用域时用 letconst 声明的变量也会被分配空间,但不会被初始化。在初始化语句之前,如果出现对变量的引用,会报 ReferenceError

// without TDZ
console.log(a)  // undefined
var a = 1

// with TDZ
console.log(b)  // ReferenceError
let b = 2

在标准层面,这是通过把 CreateMutableBing 内部方法分拆成 CreateMutableBinding 和 InitializeBinding 两步实现的,只有 VarDeclaredNames 才会执行 InitializeBinding 方法。

let / const

然而,letconst 的引入也带来了一个坑。主要是这两个关键词的命名不够精确合理。

const 关键词所定义的是一个 immutable binding(类似于 Java 中的 final 关键词),而非真正的常量( constant ),这一点对于很多人来说也是反直觉的。

ES2015 规范的主笔 Allen Wirfs-Brock 在 ESDiscuss 的一个帖子里 表示,如果可以从头再来的话,他会更倾向于选择 let var / let 或者 mut / let 替代现在的这两个关键词,可惜这只能是一个美好的空想了。

for...in

for...in 的问题在于它会遍历到原型链上的属性,这个大家应该都知道的,使用时需要加上 obj.hasOwnProperty(key) 判断才安全。

在 ES2015+ 中,使用 for (const key of Object.keys(obj)) 或者 for (const [key, value] of Object.entries()) 可以绕开这个问题。

顺便提一下 Object.keys()Object.getOwnPropertyNames()Reflect.ownKeys() 的区别:我们最常用的一般是 Object.keys() 方法,Object.getOwnPropertyNames() 会把 enumerable: false 的属性名也加进来,而 Reflect.ownKeys() 在此基础上还会加上 Symbol 类型的键。

with

最主要的问题在于它依赖于运行时语义,影响优化。

此外还会降低程序可读性、易出错、易泄露全局变量。

function f(foo, length) {
  with (foo) {
    console.log(length)
  }
}
f([1, 2, 3], 222)   // 3

eval

eval 的问题不在于可以动态执行代码,这种能力无论如何也不能算是语言的缺陷。

Scope

它的第一个坑在于传给 eval 作为参数的代码段能够接触到当前语句所在的闭包。

而用 new Function 动态执行的代码就不会有这个问题,因为 new Function 所生成的函数是确保执行在最外层作用域下的(严格来说标准里不是这样定义的,但实际效果基本可以看作等同,除了 new Function 中可以获取到 arguments 对象)。

function test1() {
    var a = 11
    eval('(a = 22)')
    console.log(a)      // 22
}

function test2() {
    var a = 11
    new Function('return (a = 22)')()
    console.log(a)      // 11
}

直接调用 vs 间接调用(Direct Call vs Indirect Call)

第二个坑是直接调用 eval 和间接调用的区别。

事实上,但是「直接调用」的概念就足以让人迷糊了。

首先,eval 是全局对象上的一个成员函数

但是,window.eval() 这样的调用 不算是 直接调用,因为这个调用的 base 是全局对象而不是一个 "environment record"

接下来的就是历史问题了。

直接调用和间接调用最大的区别在于他们的作用域不同: javascript function test() { var x = 2, y = 4 console.log(eval("x + y")) // Direct call, uses local scope, result is 6 var geval = eval; console.log(eval("x + y")) // Indirect call, uses global scope, throws ReferenceError because `x` is undefined }

间接调用 eval 最大的用处(可能也是唯一的实际用处)是在任意地方获取到全局对象(然而 Function('return this')() 也能做到这一点): javascript // 即使是在严格模式下也能起作用 var global = ("indirect", eval)("this");

未来,如果 Jordan Harband 的 System.global 提案能进入到标准的话,这最后一点用处也用不到了……

非严格模式下,赋值给未声明的变量会导致产生一个新的全局变量

Value Properties of the Global Object

我们平时用到的 NaN, Infinity, undefined 并不是作为 primitive value 被使用(而 null 是 primitive value),而是定义在全局对象上的属性名

在 ES5 之前,这几个属性甚至可以被覆盖,直到 ES5 之后它们才被改成 non-configurable、non-writable。

然而,因为这几个属性名都不是 JavaScript 的保留字,所以可以被用来当做变量名使用。即使全局变量上的这几个属性不可被更改,我们仍然可以在自己的作用域里面对这几个名字进行覆盖。

// logs "foo string"
(function(){ var undefined = 'foo'; console.log(undefined, typeof undefined); })();

Stateful RegExps

JavaScript 中,正则对象上的函数是有状态的:

const re = /foo/g
console.log(re.test('foo bar')) // true
console.log(re.test('foo bar')) // false

这使得这些方法难以调试、无法做到线程安全。

Brendan Eich 的说法是这些方法来自于 90 年代的 Perl 4,那时候并没有想到这么多

weird syntax of import

现在的语法是 import x from 'y',但是改成 from y import x 的话,会更自然、更方便触发 IDE / 编辑器的自动补全。

Brendan Eich 也在 ESDiscuss 的一篇帖子中对此表达过后悔之情。

另外,尽管很多人认为 ES2015 的模块系统是借鉴了 python,但事实上,根据 ES2015 模块系统的设计者 Dave Herman 的说法,这个模块系统的理念主要是参考了 Racket,跟 python 半毛钱关系都没有(除了最后定下来的语法恰好有点相似)。

Array constructor inconsistency

这是 API 设计的失误。

// <https://github.com/DavidBruant/ECMAScript-regrets/issues/21>
Array(1, 2, 3); // [1, 2, 3]
Array(2, 3); // [2, 3]
Array(3); // [,,] WAT

Primitive type wrappers

JavaScript 中的 primitive type wrappers (Boolean / Number / String...)绝对是臭名昭著,各种合理或不合理的比较规则和类型转换能把人折腾疯,这里就不详述了(其实是太懒了写不动了

Brendan Eich 在 JS2/ES4 中曾经试图用激进的强类型方案一劳永逸地解决掉这个问题,不过后来 ES4 不了了之了,这个提案也就被搁置在一边了。

Date Object

JavaScript 里的 Date 对象是直接抄的 Java Date 类,所以这些问题其实都继承自 Java(其实不少方法在 Java 里都已经 deprecated 了,只是 JavaScript 演进了这么多年,一直没有加进 Date 的替代品)。

Date.getMonth()

Date.getMonth() 的返回值是从 0 开始计的。

const d = new Date('2016-07-14')
d.getDate()     // 14
d.getYear()     // 116 (2016 - 1900)
d.getMonth()    // 6

Date comparison

$ node
> d1 = new Date('2016-01-01')
Fri Jan 01 2016 08:00:00 GMT+0800 (CST)
> d2 = new Date('2016-01-01')
Fri Jan 01 2016 08:00:00 GMT+0800 (CST)
> d1 <= d2
true
> d1 >= d2
true
> d1 == d2
false
> d1 === d2
false

原因是抽象关系比较算法中,左右值在一定情况下会先 ToNumber,而抽象相等比较时则不会做转换,所以造成了这种情况。

prototype

prototype 有两个槽点。

第一点是它的命名不合理。

There are only two hard things in Computer Science: cache invalidation and naming things -- Phil Karlton

JavaScript 中的各种词不达意的命名已经让人无力吐槽了……

作为对象属性的 prototype,其实根本就不是我们讨论原型继承机制时说的「原型」概念。 fallbackOfObjectsCreatedWithNew would be a better name.

而对象真正意义上的原型,在 ES5 引入 Object.getPrototypeOf() 方法之前,我们并没有常规的方法可以获取。

不过很多浏览器都实现了非标准的 __proto__(IE 除外),在 ES2015 中,这一扩展属性也得以标准化了。

Object destructuring syntax

解构赋值时给变量起别名的语法有点让人费解:

// <https://twitter.com/Louis_Remi/status/748816910683283456>
// 这里解构出来的新变量是 y,它等价于 z.x
// 冒号可以读作 'as',方便记忆
let { x: y } = z

虽然这并不能算作是设计失误(毕竟很多其他语言也这么做),但毕竟不算直观。

其他参考文献

https://esdiscuss.org/topic/10-biggest-js-pitfalls#content-5 https://esdiscuss.org/topic/excluding-features-from-sloppy-mode http://wtfjs.com/ http://bonsaiden.github.io/JavaScript-Garden/ https://www.nczonline.net/blog/2012/07/24/thoughts-on-ecmascript-6-and-new-syntax/