今天来聊一聊 JavaScript 中让人摸不着头的设计失误。
Brendan Eich 在 1995 年加入 Netscape 公司,当时 Netscape 和 Sun 合作开发一个可运行在浏览器上的编程语言,当时 JavaScript 的开发代号是 Mocha。Brendan Eich 花了 10 天完成了第一版的 JavaScript。
由于设计时间太短,语言的一些细节考虑得不够严谨,一些因不可抗因素而无法修复的 bug,加之后来填坑过程中新挖的坑,总之开发者表示很烦...
一起看下 JavaScript 设计的 坑 有哪些?
这是一个众所周知的失误。
对于刚接触 JavaScript 的朋友,有可能会直觉性地、错误地认为 typeof null === 'null',这是不对的。
typeof null === 'object' 的 bug 其实是第一版 JavaScript 就存在了,随着 JavaScript 的流行,很多人提议修复这个 bug,但被拒绝了,因为修改它意味着会破坏现有的代码。历史原因可以看下这篇文章:The history of “typeof null”。
在 JavaScript 中,数据类型在底层都是以二进制形式表示的。在初版 JavaScript 中,以 32 位为单位存储一个值,其中包括一个类型标记(1-3 位)和该值的实际数据。类型标记存储在单元的低位。其中有五个:
也就是说,最低位如果是 1,那么类型标记长度只有 1 位;如果最低位是 0,那么类型标记长度为 3 位,为四种类型提供两个附加位。
有两个特殊的值:
现在我们知道为什么 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;
- }
上面的代码执行的步骤是:
- #define JSVAL_IS_VOID(v) ((v) == JSVAL_VOID)
- #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' 的话题告一段落。
不确定这个算不算一个设计失误,但毫无疑问这是反直觉的。
关于 NaN,还有一些很有趣的知识点,推荐一个 Slide,非常值得一看:Idiosyncrasies of NaN v2。
在 JavaScript 中,NaN 是一个看起来很莫名其妙的存在。当然 NaN 不是只有 JavaScript 才存在的。其他语言也是有的。
我觉得应该是这样:"NaN" actually stands for "Not a NaN".
NaN 是一个全局对象属性,其属性的初始值就是 NaN,和 Number.NaN的值一样。
NaN 是 JavaScript 中唯一一个不等于自身的值。虽然这个设计其实理由很充分(参照前面推荐的那个 Slide,在 IEEE 754 规范中有非常多的二进制序列都可以被当做 NaN,所以任意计算出两个 NaN,它们在二进制表示上很可能不同),但不管怎样,这个还是非常值得吐槽...
- NaN == NaN // false
- NaN === NaN // false
- Number.NaN === NaN // false
isNaN() 是全局对象提供的一个方法,它的命名和行为非常让人费解:
isNaN() 方法,当参数值是 NaN 或者将参数转换为数字的结果为 NaN,则返回 true,否则返回 false。因此,它不能用来判断是否严格等于 NaN。
- isNaN(NaN) // true
- isNaN('hello world') // true
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
- // 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)
- }
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 内容写了一篇文章:JavaScript ASI 机制详解,不用再纠结分号问题。
据 Brendan Eich 称,JavaScript 最初被设计出来时,上级要求这个语言的语法必须像 Java。所以跟 Java 一样,JavaScript 的语句在解析时,是需要分号分隔的。但是后来出于降低学习成本,或者提高语言的容错性的考虑,他在语法解析中加入了分号自动插入的纠正机制。
这个做法的本意当然是好的,有不少其他语言也是这么处理的(比如 Swift)。但是问题在于,JavaScript 的语法设计得不够安全,导致 ASI 有不少特殊情况无法处理到,在某些情况下会错误地加上分号(在标准文档里这些被称为 Restricted Productions)。
最典型的是 return 语句:
- // returns undefined
- return
- {
- name: 'Frankie'
- }
-
- // returns { name: 'Frankie' }
- return {
- name: 'Frankie'
- }
这导致了 JavaScript 社区写代码时花括号都不换行,这在其他编程语言社区是无法想象的。
有好几种情况要注意(更多 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')
- })()
由于以上这些已经是语言特性了,并且无法绕开,无论怎样我们都需要去学习掌握。
对于使用 semicolon-less 风格的朋友,注意一下 5 种情况就可以了:
如果一条语句是以 (、[、/、+、- 开头,那么就要注意了。根据 JavaScript 解析器的规则,尽可能读取更多 token 来构成一个完整的语句,而以上这些 token 极有可能与前一个 token 可组成一个合法的语句,所以它不会自动插入分号。
实际项目中,以 /、+、-作为行首的代码其实是很少的,(、[ 也是较少的。**当遇到这些情况时,通过在行首手动键入分号 ; 来避免 ASI 规则产生的非预期结果或报错。**这样的记忆成本和出错概率远低于强制分号风格。
还有,ESLint 中有一条规则 no-unexpected-multiline 哦,这样就几乎没有什么负担了。
在 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 两个表示空值的原生类型,乍看起来很难理解,不过这里有一些讨论可以一看:
* 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 中最大的坑之一,不看文档很难自己理清楚。
具体可以参考这两篇文章:
在 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]
在非严格模式下(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)
蝴蝶书上的例子想必大家都看过:
- // 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 的引入也带来了一个坑。主要是这两个关键词的命名不够精确合理。
const 关键词所定义的是一个 immutable binding(类似于 Java 的 final 关键词),而非真正的常量(constant),这一点对于很多人来说也是反直觉的。
ES6 规范的主笔 Allen Wirfs-Brock 在 ESDiscuss 的一个帖子里表示,如果可以从头再来的话,他会更倾向于选择 let var / let 或者 mut / let 替代现在的这两个关键词,可惜这只能是一个美好的空想了。
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 类型的键。
最主要的问题在于它依赖运行时语义,影响优化。此外还会降低程序可读性、易出错、易泄露全局变量。
- function fn(foo, length) {
- with(foo) {
- console.log(length)
- }
- }
- fn([1, 2, 3], 222) // 3
eval 的问题不在于可以动态执行代码,这种能力无论如何也不能算是语言的缺陷。
它的第一个坑在于传给 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
- }
第二个坑是直接调用 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 becausexis undefined }
间接调用 eval 最大的用处(可能也是唯一的实际用处)是在任意地方获取到全局对象(然而 Function('return this')() 也能做到这一点): javascript // 即使是在严格模式下也能起作用 var global = ("indirect", eval)("this");
未来,如果 Jordan Harband 的 System.global 提案能进入到标准的话,这最后一点用处也用不到了……
平常我们使用到的 NaN,Infinity、undefined 并不是作为原始值被使用的,而是定义在全局对象上的属性名。
在 ES5 之前,这几个属性甚至可以被覆盖,直到 ES5 之后它们才被改成 non-configurable、non-writable。
然而,因为这几个属性名都不是 JavaScript 的保留字,所以可以被用来当做变量名使用。即使全局变量上的这几个属性不可被更改,我们仍可以在自己的作用域里面对这几个名字进行覆盖。
- (function () {
- var undefined = 'foo'
- console.log(undefined, typeof undefined) // "foo" "string"
- })()
JavaScript 中,正则对象上的函数是有状态的:
- const re = /foo/g
- console.log(re.test('foo bar')) // true
- console.log(re.test('foo bar')) // false
这使得这些方法难以调试,无法做到线程安全。
Brendan Eich 的说法是 这些方法来自于 90 年代的 Perl 4,那时候并没有想到这么多。