JavaScript的迷惑行为大赏

今天来聊一聊 JavaScript 中让人摸不着头的设计失误。

Brendan Eich 在 1995 年加入 Netscape 公司,当时 Netscape 和 Sun 合作开发一个可运行在浏览器上的编程语言,当时 JavaScript 的开发代号是 Mocha。Brendan Eich 花了 10 天完成了第一版的 JavaScript。

由于设计时间太短,语言的一些细节考虑得不够严谨,一些因不可抗因素而无法修复的 bug,加之后来填坑过程中新挖的坑,总之开发者表示很烦...

一起看下 JavaScript 设计的“坑”有哪些?

一、typeof null === 'object'

这是一个众所周知的失误。

对于刚接触 JavaScript 的朋友,有可能会直觉性地、错误地认为 typeof null === 'null',这是不对的。

typeof null === 'object' 的 “bug” 其实是第一版 JavaScript 就存在了,随着 JavaScript 的流行,很多人提议修复这个 bug,但被拒绝了,因为修改它意味着会破坏现有的代码。历史原因可以看下这篇文章:The history of “typeof null”。

在 JavaScript 中,数据类型在底层都是以二进制形式表示的。在初版 JavaScript 中,以 32 位为单位存储一个值,其中包括一个类型标记(1-3 位)和该值的实际数据。类型标记存储在单元的低位。其中有五个:

  • 000:object,数据是一个对象的引用。
  • 1:int,数据是一个 31 位有符号整数。
  • 010:double,数据是一个双精度浮点数。
  • 100:string,数据是一个字符串
  • 110:boolean,数据是一个布尔值。

也就是说,最低位如果是 1,那么类型标记长度只有 1 位;如果最低位是 0,那么类型标记长度为 3 位,为四种类型提供两个附加位。

有两个特殊的值:

  • undefined(JSVAL_VOID)是整数 −230(整数范围之外的数字)
  • null(JSVAL_NULL)是机器码空指针。或:一个对象类型标记加上一个零的引用。(null 二进制表示全是 0)

现在我们知道为什么 typeof 会认为 null 是一个对象了,它检查了 null 的类型标记,且类型标记表示 object。以下是该引擎的 typeof 代码。

JS_PUBLIC_API(JSType)
JS_TypeOfValue(JSContext *cx, jsval v)
{
  JSType type = JSTYPE_VOID;
  JSObject *obj;
  JSObjectOps *ops;
  JSClass *clasp;

  CHECK_REQUEST(cx);
  if (JSVAL_IS_VOID(v)) { // (1)
    type = JSTYPE_VOID;
  } else if (JSVAL_IS_OBJECT(v)) { // (2)
    obj = JSVAL_TO_OBJECT(v);
    if (obj &&
      (ops = obj -> map -> ops,
        ops == & js_ObjectOps
          ? (clasp = OBJ_GET_CLASS(cx, obj),
            clasp -> call || clasp == & js_FunctionClass) // (3,4)
          : ops -> call != 0)) { // (3)
      type = JSTYPE_FUNCTION;
    } else {
      type = JSTYPE_OBJECT;
    }
  } else if (JSVAL_IS_NUMBER(v)) {
    type = JSTYPE_NUMBER;
  } else if (JSVAL_IS_STRING(v)) {
    type = JSTYPE_STRING;
  } else if (JSVAL_IS_BOOLEAN(v)) {
    type = JSTYPE_BOOLEAN;
  }
  return type;
}

上面的代码执行的步骤是:

  • 在(1)首先检查值 v 是否 undefined(VOID)。通过 == 比较值是否相同:
#define JSVAL_IS_VOID(v)  ((v) == JSVAL_VOID)
  • 下一个检查(2)是该值是否具有对象标记。如果它另外可以调用(3)或它的内部属性 [[Class]] 将其标记为一个函数(4),则 v 是一个函数。否则,它是一个对象。这是由 typeof null 产生的结果。
  • 随后的检查是数字,字符串和布尔值。甚至没有显式的 null 检查,可以由以下 C 宏执行。
#define JSVAL_IS_NULL(v)  ((v) == JSVAL_NULL)

这似乎是一个非常明显的错误,但请不要忘记,只有很少的时间来完成 JavaScript 的第一个版本。

Brendan Eich 在 Twitter 表示这是一个 abstraction leak,可理解为变相承认这是代码的 bug。

null means "no object", undefined =>"no value". Really it's an abstraction leak: null and objects shared a Mocha type tag.

下面列出各种数据类型 typeof 对应的结果:

Operand Result
undefinded "undefined"
null "object"
Boolean value "boolean"
Number value "number"
BigInt value (ES11) "bigint"
String value "string"
Symbol value (ES6) "symbol"
宿主对象(由 JS 环境提供) 取决于具体实现
Function "function"
All other values "object"

typeof returning "object" for null is a bug. It can’t be fixed, because that would break existing code. Note that a function is also an object, but typeof makes a distinction. Arrays, on the other hand, are considered objects by it.

某文章表示:

在 JavaScript V8 引擎中,针对 typeof null === 'object' 这种“不规范”情况,对 null 提前做了一层判断。假设在 V8 中把这行代码删掉, typeof null 会返回 undefined

GotoIf(InstanceTypeEqual(instance_type, ODDBALL_TYPE), &if_oddball);

好了,关于 typeof null === 'object' 的话题告一段落。

二、typeof NaN === 'number'

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

关于 NaN,还有一些很有趣的知识点,推荐一个 Slide,非常值得一看:Idiosyncrasies of NaN v2。

三、NaN、isNaN()、Number.isNaN()

在 JavaScript 中,NaN 是一个看起来很莫名其妙的存在。当然 NaN 不是只有 JavaScript 才存在的。其他语言也是有的。

我觉得应该是这样:"NaN" actually stands for "Not a NaN".

1. NaN

NaN 是一个全局对象属性,其属性的初始值就是 NaN,和 Number.NaN的值一样。

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

NaN == NaN // false
NaN === NaN // false
Number.NaN === NaN // false
2. isNaN()

isNaN() 是全局对象提供的一个方法,它的命名和行为非常让人费解:

  • 它并不只是用来判断一个值是否为 NaN,因为所有对于所有非数字类型的值它也返回 true
  • 但也不能说它是用来判断一个值是否为数值的,因为根据前文,NaN 的类型是 number,应当被认为是一个数值。

isNaN() 方法,当参数值是 NaN 或者将参数转换为数字的结果为 NaN,则返回 true,否则返回 false。因此,它不能用来判断是否严格等于 NaN

isNaN(NaN) // true
isNaN('hello world') // true
3. Number.isNaN()

ES6 提供了 Number.isNaN() 方法,用于判断一个值是否严格等于 NaN,终于是拨乱反正了。

和全局函数 isNaN() 相比,Number.isNaN() 不会自行将参数转换成数组,它会先判断参数是否为数字类型,如不是数字类型则直接返回 false,接着判断参数值是否为 NaN,若是则返回 true

Number.isNaN(NaN) // true
Number.isNaN(Number.NaN) // true
Number.isNaN(0 / 0) // true
Number.isNaN('hello world') // false
Number.isNaN(undefined) // false
4. 总结几种判断值是否为 NaN 的方法
// 1. 利用 NaN 的特性,JavaScript 中唯一一个不等于自身的值
function myIsNaN(v) {
  return v !== v
}

// 2. 利用 ES5 的 isNaN() 全局方法
function myIsNaN(v) {
  return typeof v === 'number' && isNaN(v)
}

// 3. 利用 ES6 的 Number.isNaN() 方法
function myIsNaN(v) {
  return Number.isNaN(v)
}

// 4. 利用 ES6 的 Object.is() 方法
function myIsNaN(v) {
  return Object.is(v, NaN)
}

四、==、=== 与 Object.is()

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

[] == ![] // true
2 == '2' // true

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

但事实上, === 也并不总是靠谱,它至少存在两类例外情况。(Stricter equality in JavaScript)

// 1. 前文提到的 NaN
NaN === NaN // false

// 2. +0 与 -0 两者其实是不相等的值 
+0 === -0 // true
// 因为
1 / +0 === Infinity // true
1 / -0 === -Infinity // true
Infinity === -Infinity // false


// ES6 是提供的方法
Object.is(NaN, NaN) // true
Object.is(+0, -0) // false

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

如果 ES6 以下,这样实现 Object.is()

function myObjectIs (x, y) {
  if (x === y) {
    // x === 0 => compare via infinity trick
    return x !== 0 || (1 / x === 1 / y)
  }

  // x !== y => return true only if both x and y are NaN
  return x !== x && y !== y
}

关于 ===== 部分值的比较,可以看下 JavaScript-Equality-Table。

Always use 3 equals unless you have a good reason to use 2.(除非您有充分的理由 ==,否则始终使用 ===

五、分号自动插入机制(ASI)

此前还专门针对 ASI 内容写了一篇文章:JavaScript ASI 机制详解,不用再纠结分号问题。

1. Restricted Productions

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

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

最典型的是 return 语句:

// returns undefined
return
{
  name: 'Frankie'
}

// returns { name: 'Frankie' }
return {
  name: 'Frankie'
}

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

2. 漏加分号的问题

有好几种情况要注意(更多 ASI 详情看上面推荐的文章),比如:

// 假设源码是这样的
var a = function (x) { console.log(x) }
(function () {
  console.log('do something')
})()

// 在 JS 解析器的眼里却是这样的,所以这段代码会报错
var a = function (x) { console.log(x) }(function () {
  console.log('do something')
})()
3. semicolon-less

由于以上这些已经是语言特性了,并且无法绕开,无论怎样我们都需要去学习掌握。

对于使用 semicolon-less 风格的朋友,注意一下 5 种情况就可以了:

如果一条语句是以 ([/+- 开头,那么就要注意了。根据 JavaScript 解析器的规则,尽可能读取更多 token 来构成一个完整的语句,而以上这些 token 极有可能与前一个 token 可组成一个合法的语句,所以它不会自动插入分号。

实际项目中,以 /+-作为行首的代码其实是很少的,([ 也是较少的。当遇到这些情况时,通过在行首手动键入分号 ; 来避免 ASI 规则产生的非预期结果或报错。这样的记忆成本和出错概率远低于强制分号风格。

还有,ESLint 中有一条规则 no-unexpected-multiline 哦,这样就几乎没有什么负担了。

六、Falsy values

在 JavaScript 中至少有七种假值(在条件表达式中与 false 等价):00nnullundefinedfalse'' 以及 NaN。(其中 0n 是 BigInt 类型的值)

以上六种假值均可通过 Double Not 运算符(!!)来显示转换成 Boolean 类型的 false 值。

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

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

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

注意: + 两侧只要有一侧是字符串,另一侧的数字则会自动转换成字符串,因为其中存在隐式转换。

八、null、undefined 以及数组的 “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 对象用上 Array.prototype.shift() 方法,我们往往需要先写这样一条语句,非常不便。

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

在 ES6 中,arguments 对象不再被建议使用,我们可以用 Rest parameters(const fn = (...args) => {}),这样拿到的对象(args)就直接是数组了。

不过在语言标准之外,DOM 标准中也定义了不少 Array-like 的对象,比如 NodeList 和 HTMLCollection。对于这些对象,在 ES6 中我们可以用 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)下,对 argument 赋值会改变对应的形参

可以看下这篇文章:JavaScript 严格模式详解(8-2 小节)

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

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

foo(1)
bar(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 声明在被分配空间的同时也会被初始化成 undefined(ES5 中的 CreateMutableBinding),这就相当于把 var 声明的变量提升到了函数作用域的开头,也就是所谓的 “hoisting”。

ES6 中引入的 letconst 则实现了 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),这一点对于很多人来说也是反直觉的。

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

for...in

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

在 ES6+ 中,使用 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 fn(foo, length) {
  with(foo) {
    console.log(length)
  }
}
fn([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"。

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

  • 在 ES1 时代,eval 调用并没有直接和间接的区分;
  • 然后在 ES2 中,加入了直接调用(direct call)的概念。根据 Dmitry Soshnikov 后来的说法,区分这两种调用可能是处于安全考虑。此时唯一合法的 eval 使用方式是 直接调用,如果 eval 被间接调用了或者被赋值给其他变量了,JavaScript 引擎 可以选择 报一个 Runtime Error(ECMA-262 2nd Edition, p.63)。
  • 但是浏览器厂商们在试图实现这个特性时,发现这会让一些旧网站不兼容。
  • 考虑到这毕竟是可选的特性,他们最后就选择了不报错,转而让所有间接调用的 eval 都在全局作用域下执行。 这样一来,既保持了对旧网站的兼容性,也保证了一定程度的安全性。
  • 到了 ES5 时期,标准制定者们希望能够和当前约定俗成的实现保持一直并规范化,所以去掉了之前标准里的可选实现,转而规定了间接调用 eval 时的行为

直接调用和间接调用最大的区别在于他们的作用域不同: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 becausexis undefined }

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

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

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

Value Properties of the Global Object

平常我们使用到的 NaN,Infinity、undefined 并不是作为原始值被使用的,而是定义在全局对象上的属性名。

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

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

(function () {
  var undefined = 'foo'
  console.log(undefined, typeof undefined) // "foo" "string"
})()
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,那时候并没有想到这么多。

未完待续...

十、参考

  • JavaScript 的设计失误
  • The history of “typeof null”
  • typeof MDN
  • Stricter equality in JavaScript

你可能感兴趣的:(JavaScript的迷惑行为大赏)