1.NaN
不等于自己
NaN
在数字运算中的主要来历,有0/0
、Infinity*0
、Infinity/Infinity
、Infinity-Infinity
、1**Infinity
以及任一成员是NaN
的数学运算 ①。
它的用意很简单,就是任何情况下,如果表达式在之前的运算中已经失去准确度了,继续运算下去也不会得到有意义的结果。为了防止进一步的比较结果相等,所以让它不等于任何值,包括自己,而且继续任何数学计算只会依然得到这个值。语义可以理解为“算坏掉的数字”,或者“强行变成数字类型的无效值”。
相反,如果不搞出NaN
这个特殊的值,让Infinity-Infinity
等于0
,那么1e500-1e600
就会等于1e500-1e700
。如果让NaN
等于自身,那么1e500/1e400
就会等于1e600/1e400
。当然,在语言设计中,另一种可行的做法,是到了要算出NaN
值时,就报错。
① number**0
的结果一定是 1
,因此哪怕这个 number
是 NaN
也不会算出 NaN
。可见 NaN
也不是计算黑洞,而真的是为了防止失去准确度的中间计算结果对后续结果产生不良影响。
2.this
是谁
this
的逻辑其实没有那么复杂,作为从面向对象语言中借鉴的语法关键词,日常好好用的话,其实就构造函数和实例方法两种情况。
new
时,函数作为面向对象语言的类的构造函数使用,此时this
一定是新实例。()
时,如果存在调用者,函数作为面向对象语言的实例方法使用,此时this
是调用者(调用者.函数()
)。()
时,如果不存在调用者(函数()
),函数作为函数,本来没有this
什么事,但最开始语言设计者觉得也不能浪费了这个关键字,可能是觉得运行全局函数和运行全局对象的方法有些相似,所以就用全局对象充数作为this
,后来觉得不好,就在 ECMAScript 5 时规定了严格模式下,这种情况this
应该是undefined
。- 在函数外,同样处于“不浪费”的目的,
this
和第3种情况最初的设计一样,代表全局对象。
其中后两种情况,也是this
令人费解的来历:两个几乎不会被用到的“不浪费”情况的搅局,使得其它两个原本很理性、真正实用而常用的规则变得模糊。
至于其它情况,都是以上()
情况的变种:
函数.call()
、函数.apply()
和函数.bind()
是用来强制指定调用者的,用来作为this
的值。而它们叠加使用时(函数.bind().call()
或函数.bind().apply()
),简单地说,是以bind
时指定的值为准,或者严谨地说,bind
返回了一个新函数,这个新函数的调用者无论是什么,它内部进一步执行原函数时,都是用的bind
时指定的值。- 非严格模式下,
null
或undefined
作为调用者会被忽略,相当于没有,并自动替换成全局对象。而非严格模式下,boolean
、number
、string
、symbol
、bigint
这些非object
、function
等的基础类型,则会变成对应的Boolean
、Number
、String
、Symbol
、BigInt
对象,来作为this
。 - 在
with(对象){}
代码块中执行对象的方法(方法()
),看上去没有调用者,实际上这只是语法糖,相当于对象.方法()
(当然前提是方法
存在于对象
上)。 - 据资料显示,ECMAScript 3 中,
try { throw function () {}; } catch (error) { error(); }
,其实相当于with ({ error: function () {} }) { error(); }
,换言之这里有个自动创建的临时对象作为调用者。ECMAScript 5 开始已经废除,无关启用严格模式与否。这个我没有实际测出来,要么是 IE 旧版本模拟不完全,要么是这并不是在 ECMAScript 3 容器中普遍实现的行为。
而 ECMAScript 6 新增的箭头函数,那玩意儿根本就没有属于自身的this
值,与其把上述所有情况✕2来分别讨论(而且其实是不可能的),不如简单粗暴地说:看看它外面的this
是啥,它里面的this
就是啥。
3.什么==
什么
很多使用我们都尽量避免使用==
,而建议使用===
,这是因为它的规则太匪夷所思和难记,似乎全是特例,不直接看着穷举表格,根本用不了,而那个穷举表大得没法看。更糟的是,这次就连看规范原文都帮不了了,因为和实验的结果不一致。
而实际上它背后的设计思想却很简单,用一句话就能说得很清楚:两个不同类型的值,有没有可能在变成number
的情况下,尽可能相等。把它和===
放在一起讨论是不合适的,它更像和<
、>
、<=
、>=
是一套东西。
具体而言:
- 两个值类型相同,或都不是基础类型(或者称为引用型值,包括
object
、function
等),则按照===
来比较 ①,并返回结果; - 其中一个是
null
或undefined
②(不存在方法),则返回另一个是否也是null
或typeof
为"undefined"
②,作为结果; - 其中一个不是基础类型,就会尝试获取其基础类型 ③,如果得到的值是
null
或undefined
,返回结果false
;否则用得到的值作为替代,继续尝试后续步骤: - 把其中不是
number
类型的转化成number
类型 ⑤(除了symbol
类型不存在原生内置的变成number
的方法,保留原样),然后用===
比较,并返回结果。
① 存在一个我在规范层无法解释的特例,就是在 IE 8- 中 window!==self
,而 window==self
(在这类环境下,self.window
、document.parentWindow
都是self
而不是window
)。更神奇的是,这类环境中,window==document
、self==document
,但是左右不能交换,document!=window
、document!=self
。我能理解这样设计的意图,实现上也可能和 document.all
类似,强行做了手脚,但是我完全无法不把它作为特例进行解释。总之,在现代或者说正常浏览器中,由于window===self
,所以其它情况下上述规则是没有问题的。
② 现代浏览器(IE 11+)对document.all
做了规范无法解释的处理,typeof
和真假判断时算作undefined
,其它情况下依然算函数。不过不必深究,因为浏览器明确表示这是有意的,知道就好了。
③ 依次尝试自身或原型链中存在的[Symbol.toPrimitive]('default')
(如果该属性不是null
或undefined
,则必须是函数,而且必须返回基础类型,否则报错)、.valueOf()
(不是函数则跳过 ④)、.toString()
(不是函数则跳过 ④)方法,直到返回结果是基础类型。如果最终得到的仍不是基础类型,或不存在上述任何方法,则报错。
④ IE 8- 中如果不是基础类型还不可执行,则报错(未确认是否是 ECMAScript 3 规范规定的);旧 IE 中 alert
等原生函数虽然typeof
不是function
,但这里算可执行的;旧 Webkit 中正则的 typeof
是 function
怎么算没有测试。
⑤ 基础类型值之间的类型转化,用的是原生内置方法,不会理会prototype
中的自定义覆盖。手动行为中,与之最接近的是Number(value)
。+value
和Number(value)
的最大区别是,Number(value)
对bigint
有效。
不过心里还是有些虚,而且没有使用的必要,最关键的是完全没有可遵循的成文规范来确证和固定,所以实践中,除==null
这种还有些意义的情况,不要用==
比较符;
如果真的有人敢用,那么使用时如果发现上面的总结有误,感谢告知指正。
4.什么情况下会合并没用分号分隔的多行语句
为此我专门写过一篇文章。简单来说:
- 那只要下一行的第一个
token
能合并到上一行,就会尽可能合并,哪怕报错也要合并(只有一个特例,就是上一行可以结束,而下一行以++
或--
运算符开始); return
、throw
、break
、continue
、yield
(生成器函数中)、async
(函数前)几个语法关键词根本就不是语句,如果要完成结合,必须和后面的内容同行,但后面的语句内容,依然遵循以上规则。
顺便一提,多行注释标记中如果有换行,就相当于换行,没有换行,就相当于空格。
换行符除了<U+000A>
、<U+000D>
、<U+000D><U+000A>
,还有<U+2028>
和<U+2029>
。
5.变量提前
var a = 'outer';
( function () {
console.log(a);// undefined
var a = 'inner';
} )();
通常我们看一个程序代码的过程,是按照执行顺序线性地排着看,所以会觉得这样的结果有些违背直觉。
但实际上,JavaScript 中变量的有效范围是不管前后、而只看内外层的。只有整个内层都没有声明同名的变量,这个内层中该变量才指向外层的同名变量,最终层层上溯到全局。
如果写得更直观一些,引擎看到的其实是:
var a;
a = 'outer';
( function () {
var a;
console.log(a);// undefined
a = 'inner';
} )();
因此在层的意义上,并不存在什么提升,所谓的提升只是对事情存在误解后又二次纠正时才存在的概念。
即便现在有了let
和const
,理解这个根本机制依然是必要的。因为它们首先遵循的是层规则(只不过对于var
这个“层”只有函数块,对于let
和const
这个“层”是任何块),然后顺便把当前层中声明位置之前的区域锁死,避免误用而已。如果真的按照前后顺序去理解let
和const
,那么下面的代码就会成为新的误区,并最终陷入理解混乱:
let a = 1;
{
console.log(a);// 误以为是 1,其实已经报错了
let a = 2;
}
console.log(a);// 误以为是 2,其实是 1
实际上,引擎运行代码时有两遍,第一遍只看结构声明,有语法错误就直接报错什么都不运行,没有语法错误就按照内外层把所有的变量声明和函数声明生效(这也是为什么它们的手能伸那么长、“提前”生效),在这个工作完全结束以后,再真的从头开始排着执行,并在出现运行时错误时终止。这也是为什么下述代码从一开始就会报错,而不会运行完第一行、到出现错误的第二行再报错:
console.log(1);
~!@#$%^&*()_+
因为它的错误是语法结构错误,在预扫描时就已经发现了,于是整个都报废了。
6.什么能放到表达式中,什么不能
初学者很可能经历过写这样代码的过程,并不理解为什么这是不行的:
console.log( if ( a ) { 1 } else { 2 } );
console.log('start'), var i=5, while(--i){console.log(i)}, console.log('end');
因为语法其实有几个层次,它们是逐层包含、不可逆的:结构体->语句->表达式。
无论if
还是var
、while
等,都是结构体,它们不能放到更低层次的语句层中,而且它们是没有像表达式那样的运算结果返回值一说的。
同理,表达式中也不能出现;
,因为那是语句层面的东西。
这也是很多初级入门教程中,为什么开始讲解立即执行函数以及对象语法时,会在外面嵌套一个()
:
( function f () {} )(2);
( { k: 1 } )['k'];// 1
这是因为函数体和{}
在语法上有多重含义。在由()
明确限制出来的表达式上下文中,函数体是一个返回值为该函数的表达式,{}
则是对象字面量,而如果直接暴露在结构体上下文中,函数体成了函数声明,{}
则代表一个层级块(和if
、while
、switch
、function
等的{}
类似,只不过由于label
具名块功能很少用,ECMAScript 6 之前也没有块级变量声明,初学者会费解这种设计上的歧义有什么意义),它们都不再有返回值,其上下文对其的理解方式也将发生重大变化。去掉()
的上述代码:
function f () {}(2);
{ k: 1 }['k'];
实际上在计算机眼中是:
function f () {}
( 2 );
k: 1;
[ 'k' ];
进而相当于:
function f () {}
2;
1;
[ 'k' ];
另外,唯一能够让层次重新变大的语法,就是函数表达式。对外它是一个最低层级的表达式,但对内,它重新开启了最高层级的结构体上下文(这里不讨论更顶级的模块语法),从头开始:
结构体: if ( 表达式 ) {
语句一;
语句二表达式一, 语句二表达式二点一 || 语句二表达式二点二, function 语句二表达式三 () {
现在又可以用结构体了: while ( 表达式 ) {
语句一;
语句二;
}
};
}
7. typeof null==='object'
……人总有犯错的时候。这个就是 bug。
当年 JavaScript 是为无关痛痒的场景中一知半解的用户使用而设计的,倾向于尽可能自行消化错误。而现在 JavaScript 成长为支柱性语言,所有自作聪明的行为,都成了高精尖用户精确控制代码行为的坑。
语言设计者可能的心路历程:
咦?这种组合好像没有什么意义,与其空着,不如给它一个有意义的默认行为吧~
纯函数中没有this
……那就是全局变量吧!parseInt(n, 0)
的第二个参数是0
好像没有意义……那就把0
、""
、false
、null
等所有假值当作undefined
,也就是缺省值10
吧!
当然,没有天生完美的东西,JavaScript 的底子其实是非常不错的,而这些年的进化又一直注重兼容,实属难能可贵。
如果还有别的黑暗记忆,欢迎提出来大家一起开心一下。