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)是整数 −2^30^(整数范围之外的数字)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
等价):0
、0n
、null
、undefined
、false
、''
以及 NaN
。(其中 0n
是 BigInt 类型的值)
以上六种假值均可通过 Double Not 运算符(
!!
)来显示转换成Boolean
类型的false
值。
七、+、- 操作符相关的隐式类型转换
大致可以这样记:作为二元操作符的 +
会尽可能地把两边的值转为字符串,而 -
和作为一元操作符的 +
则会尽可能地把值转为数字。
('foo' + + 'bar') === 'fooNaN' // true '3' + 1 // '31' '3' - 1 // 2 '222' - - '111' // 333
注意:
+
两侧只要有一侧是字符串,另一侧的数字则会自动转换成字符串,因为其中存在隐式转换。
八、null、undefined 以及数组的 holes
在一个语言中同时有 null
和 undefined
两个表示空值的原生类型,乍看起来很难理解,不过这里有一些讨论可以一看:
* 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-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 中引入的 let
、const
则实现了 temporal dead zone,虽然进入作用域时用 let
和 const
声明的变量也会被分配空间,但不会被初始化。在初始化语句之前,如果出现对变量的引用,会抛出 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
然而,let
和 const
的引入也带来了一个坑。主要是这两个关键词的命名不够精确合理。
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
和间接调用的区别。事实上,但是「直接调用」的概念就足以让人迷糊了。
但是,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 because
xis 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,那时候并没有想到这么多。
十二、参考
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论