那时来聊聊聊 JavaScript 中让人看不见头的结构设计误判。
Brendan Eich 在1995年重新加入 Netscape 公司,彼时 Netscape 和 Sun 联合合作开发三个可运转在应用程序上的编程词汇,彼时 JavaScript 的合作开发SS是 Mocha。Brendan Eich 花了10天完成了第三版的 JavaScript。
由于结构设计时间太长,词汇的许多技术细节考量得不如细致,许多因不容抗因素而难以复原的 bug,以致后来填坑过程唐淼挖的坑,总而言之合作开发人员则表示好些…
一起瞧瞧 JavaScript 结构设计的坑 有什么样?
一、typeof null ===object
这是三个不容否认的误判。
对于刚接触 JavaScript 的好友,有可能会Beine地、严重错误地认为 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(JSVALVOID)是有理数[gf]2212[/gf]2^30^(有理数覆盖范围以外的位数)null(JSVALNULL)是机器词汇空操作符。或:三个第一类类别记号加之三个零的提及。(null 十进制则表示尽是0)
现在我们知道为什么 typeof 会认为 null 是三个第一类了,它检查了 null 的类别记号,且类别记号则表示 object。以下是该引擎的 typeof 标识符。
JSPUBLICAPI(JSType)JSTypeOfValue(JSContext *cx, jsval v){ JSType type = JSTYPEVOID; JSObject *obj; JSObjectOps *ops; JSClass *clasp; CHECKREQUEST(cx); if (JSVALISVOID(v)){ //(1) type = JSTYPEVOID;} else if (JSVALISOBJECT(v)){ //(2) obj = JSVALTOOBJECT(v); if (obj &&(ops = obj -> map -> ops, ops ==& jsObjectOps ?(clasp = OBJGETCLASS(cx, obj), clasp -> call clasp ==& jsFunctionClass)//(3,4): ops -> call !=0)){ //(3) type = JSTYPEFUNCTION;} else { type = JSTYPEOBJECT;} } else if (JSVALISNUMBER(v)){ type = JSTYPENUMBER;} else if (JSVALISSTRING(v)){ type = JSTYPESTRING;} else if (JSVALISBOOLEAN(v)){ type = JSTYPEBOOLEAN;} return type;}
上面的标识符执行的步骤是:
在(1)首先检查值 v 是否 undefined(VOID)。通过==比较值是否相同:
#define JSVALISVOID(v)((v)== JSVALVOID)
下三个检查(2)是该值是否具有第一类记号。假如它另外可以调用(3)或它的内部属性[[Class]]将其记号为三个函数(4),则 v 是三个函数。否则,它是三个第一类。这是由 typeof null 产生的结果。随后的检查是位数,数组和常量。甚至没有显式的 null 检查,可以由以下 C 宏执行。
#define JSVALISNULL(v)((v)== JSVALNULL)
这似乎是三个非常明显的严重错误,但请不要忘记,只有很少的时间来完成 JavaScript 的第三个版本。
Brendan Eich 在 Twitter 则表示这是三个 abstraction leak,可理解为变相承认这是标识符的 bug。null means “no object”, undefined =>”no value”. Really its an abstraction leak: null and objects shared a Mocha type tag.
下面列出各种统计数据类别 typeof 对应的结果:
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(instancetype, ODDBALLTYPE),&ifoddball);
好了,关于 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 // falseNaN === NaN // falseNumber.NaN === NaN // false
2. isNaN()
isNaN()是全局第一类提供的三个方法,它的命名和犯罪行为非常让人费解:
它并不只是用来判断三个值是否为 NaN,因为所有对于所有非位数类别的值它也返回 true;但也不能说它是用来判断三个值是否为数值的,因为根据前文,NaN 的类别是 number,应当被认为是三个数值。
isNaN()方法,当参数值是 NaN 或者将参数转换为位数的结果为 NaN,则返回 true,否则返回 false。因此,它不能用来判断是否严格等于 NaN。
isNaN(NaN)// trueisNaN(hello world)// true
3. Number.isNaN()
ES6提供了 Number.isNaN()方法,用于判断三个值是否严格等于 NaN,终于是拨乱反正了。
和全局函数 isNaN()相比,Number.isNaN()不会自行将参数转换成数组,它会先判断参数是否为位数类别,如不是位数类别则直接返回 false,接着判断参数值是否为 NaN,若是则返回 true。
Number.isNaN(NaN)// trueNumber.isNaN(Number.NaN)// trueNumber.isNaN(0/ 0)// trueNumber.isNaN(hello world)// falseNumber.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 是一种弱类别词汇,存在隐式类别转换。因此,==的犯罪行为非常令人费解。
[]==![]// true2==2// true
所以,各种 JavaScript 书籍都推荐使用===替代==(仅在 null checking 之类的情况除外)。
但事实上,===也并不总是靠谱,它至少存在两类例外情况。(Stricter equality in JavaScript)
//1.前文提到的 NaNNaN === NaN // false//2.+0与 -0两者只不过是不相等的值+0===-0// true//因为1/ +0=== Infinity // true1/ -0===-Infinity // trueInfinity ===-Infinity // false// ES6是提供的方法Object.is(NaN, NaN)// trueObject.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 undefinedreturn{ name:Frankie}// returns { name:Frankie}return { name:Frankie}
这导致了 JavaScript 社区写标识符时花括号都不换行,这在其他C词汇社区是难以想象的。
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 等价):、0n、null、undefined、false、以及 NaN。(当中0n 是 BigInt 类型的值)
以上六种假值均可通过 Double Not 运算符(!!)来显示转换成 Boolean 类别的 false 值。
七、+、-操作符相关的隐式类别转换
大致可以这样记:作为二元操作符的+ 会尽可能地把两边的值转为数组,而- 和作为一元操作符的+ 则会尽可能地把值转为位数。
(foo+ +bar)===fooNaN// true3+ 1//313- 1//2222- -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 iteration and holes in JavaScriptECMAScript 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 problemfor (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 TDZconsole.log(a)// undefinedvar a =1// with TDZconsole.log(b)// ReferenceErrorlet 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 所
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-2622nd 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 x is undefined }
间接调用 eval 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/gconsole.log(re.test(foo bar))// trueconsole.log(re.test(foo bar))// false
这使得这些方法难以调试,难以做到线程安全。
Brendan Eich 的说法是这些方法来自于90年代的 Perl 4,那时候并没有想到这么多。
十二、参考
JavaScript 的结构设计误判The history of “typeof null”typeof MDNStricter equality in JavaScript