janlay’s blog

悠悠人生路,翩翩少年情

JavaScript 奇技淫巧背后的秘密

毫无疑问,JavaScript 是当前最流行脚本语言,不过我认为它获此境遇是历史原因造成的,而非源自优秀的设计。相反,JavaScript 的设计过于灵活和随意,以致坊间流传着各种“杂技”,其中不乏冠之以“高性能”的技巧。而初学者和 JavaScript “熟手” 面对这样的耍杂代码无所适从,不求甚解之下就往往忽略了它。

但是,为什么这些奇技淫巧的代码没有导致语法错误?为什么它也能运行?如何举一反三?解开这些问题,需要了解被大多数人忽略的 JavaScript 基础知识。这里试图解析一些 tricks, 告诉你它们背后的秘密。

提示:

  • 要尝试下面的代码,你需要使用 JavaScript 控制台,或一个交互式 JavaScript 解析器。
  • 下面代码中的变量如无特别注明,均假定其已通过 var 定义。

短路语法:3 个为什么

1 || alert('ok')

先上甜点,这是大家司空见惯的短路语法。这里后面的代码不会被执行,因此它不是我要说的重点。但是,它为什么不会报错?因为它是普通的运算。

1 && foo = 1

这行代码什么会报错?经工友指出,是因为 = 运算符优先级比 && 低,它实现执行等价于 (1 && foo) = 1,显示赋值给另一个值是不允许的。

1 && (foo = 1)

这行代码为什么能运行?因为 () 运算符优先级比 && 高。

更多优先级问题

  • 1 && 1, foo = 2 可以运行,因为 , 优先级最低,表达式等级于 (1 && 1), foo = 2
  • 1 && function() { foo = 2 }() 也没有问题,因为 前面说了 () 优先级比 && 高。

    PS: 使用 () 求值即获得 JavaScript 内部可以接受的值。例如 (2.0000000000000001) 在一些机器上可能会返回 2。感谢工友 hanyee & vapour 对运算符优先级问题的指正。

运算符技巧:把字符串转换为数值类型

先从一个单目运算符开始。什么是单目?只有一只眼睛,也就是说,运算符只接收一个参数。

转型:+'1'

我们知道 - 作为减法运算符时接收两个参数,而作为求负运算符时,接收一个参数。JavaScript 支持另一个不多见的“求正”的 + 运算符。显然,+ 会尝试把任何接收到的参数转换为数值型。如此,我们就有了一个廉价转型方法,考虑一下:parseInt('123') vs +'123'.

转型并取整:'123.4' | 0

这里使用“或运算”将左边的字符串隐式转换为数值型,再与 0 或。所有位运算都要求使用 32 位整数参与运算。所以这又是一个廉价的转型取整方法。考虑一下 Math.floor('1234.5') vs '1234.5' | 0.

但是,32 位整数表示的数据范围是有限的,因此这一招在数值超过 231 - 1 时不适用,考虑一下 '12345678912.3' ^ 0

如何举一反三?

  • '1234.5' ^ 0 完成转型并取整,与 0 异或,得到它本身,还记得这个规则吗?
  • ~~'1234.5' 同样可以完成转型并取整,因为两次取反得到相同的值。
  • --'1234.5' 会失败。虽然我们说负负得正,但在 C 语系中,-- 运算符优先级高于单个 -。解决办法很简单,把 -- 写成 - - 就好啦,增加一个空格避免运算符被错误地识别。同理,++ 也要这样处理。

渐入佳境哈?接下来,我们把前面的知识综合运用,试图解析闭包中代码中括号的秘密。

懒人的闭包

闭包即 function() {} 代码块。通常需要控制 JavaScript 变量作用域时,我们把代码放在这个块中运行:

1
2
3
4
(function() {
    var foo = 1, bar = 2;
    sth(foo, bar);
})();

失败的闭包

上面的代码中要写两对括号,如果代码块太长的话,上下的括号不方便对照。于是,你可能会有意无意漏掉 function 周围的括号:

1
2
3
4
function() {
  var foo = 1, bar = 2;
  sth(foo, bar);
}();

很不幸,运行这段代码会报语法错误(不同 JavaScript 执行程序抛出的异常信息可能会不一样):

Exception: SyntaxError: Unexpected token '('

背景知识:()只能用来求值、定义参数列表或调用函数表达式(expression。本来,function() {} 定义的一个函数字面量(如同数组字面量 [])表达式是可以拿来调用的,但是由于设计上的原因,function 有两种表达形式:

  • function fn() {};: 这是函数声明(declaration)的语句(statement);
  • var foo = function() {}: 这是函数字面量(literal)表达式。与上面雷同的写法 var foo = function fn() {} 也是合法的表达式,不过有一点小区别

即是说,JavaScript 需要有足够的上下文(context)才能判断 function 的使用属于语句还是表达式

所以,加上一对括号,就排除了 function 作为语句声明的目的,既然不是语句,那就是函数表达式咯。

对于单独(在语句前后加上分号将其表达为独立语句)的 ;function() {}(); 来说,JavaScript 无法区分其中的 function 是表达式还是语句。此时,JavaScript 选择了传统的语句识别,于是它被识别为两个语句————两个有问题的语句:前者缺少函数名称声明,后者不允许使用空的 () 进行求值。

于是懒人们行动了,网上流传了一些不写第一组括号也能正确运行的闭包。

懒人的解决方案

这是比较常见的懒人闭包:

1
2
3
4
+function() {
    var foo = 1, bar = 2;
    return 10;
}();

为什么它能执行?因为它很好地“营造”了一个让 JavaScript 将其中的 function 识别为表达式的环境————通过单目求正运算符,让 JavaScript 知道这里的 function 不可能是语句。哪有对语句进行运算的啊。

开始举一反三吧

既然写个 + 运算符就行,那就好玩了:

  • -function() { return -1; }() 求负也行哈。PS: 如果你运行这个语句,会得到 1, 因为函数返回的 -1 被你求负了一次。
  • !function() { return -1; }() 取非也没问题。提问:运行它会返回什么?
  • 1 + function() { return -1; }() 非常标准的表达式,当然 OK 啦…
  • void function() { return -1; }() 亲,void 也是运算符哦,delete 能用吗?当然可以!
  • 1, function() { return -1; }() 别把逗号不当运算符!
  • ~function() { return -1; }() 位运算符也来凑热闹了哈…
  • 还可以写很多,随便怎么玩,只要组成表达式就行,自由发挥吧…

嗯,先解析到这里吧。后续还有新的 trick, 我也会在这里继续更新。

最后,我想说的是,上面这些 tricks 大多都是 JavaScript 不好的设计导致的滥用,一些变种的代码让团队协作更困难。学会运用这些并不会显著增长你的技能,但了解背后的原理则会让你更深入地理解 JavaScript.

Comments