Starkwang.log
首发于Starkwang.log
合理使用IIFE优化JS引擎的性能

合理使用IIFE优化JS引擎的性能

说起立即执行函数(IIFE,Immediately-invoked function expression)大家应该都不陌生,在 JavaScript 中可以声明一个函数然后立即执行它:
(function(){/* 函数体 */})()

!function(){/* 函数体 */}()

IIFE 通常用于实现私有变量、实现独立模块等等地方,比如喜闻乐见的 jQuery 最顶层的结构就是这样的:

(function(global, factory) {
    //......
})(typeof window !== "undefined" ? window : this, function(window, noGlobal) {
    //......
})

但我们今天要说的不是 IIFE 怎么用,而是关于它针对 JS 引擎的一处性能优化。

先从一个小问题说起吧,想实现一个立即执行的函数,我们有很多种写法,比如下面这两种:

// 方法一,传入一个匿名函数
function run(f){
    return f();
}
run(function(){
    //......
});

// 方法二,使用IIFE
(function(){
    //......
})()

是的,这两种写法完全是等价的,无论怎么看都不会有什么区别,但是在一些 JavaScript 引擎中,它们其实性能相差甚远。

我使用 Node 分别对两种情况运行了十万次,方法一的运行时间平均在360毫秒左右,方法二平均是 50 毫秒左右,性能相差7倍还多。

为什么使用 IIFE 之后性能会提升那么多呢?这就要从 JS 引擎(比如V8、SpiderMonkey)对于函数的优化上说起了。

现在的 JS 引擎都是十分聪明的,它们在真正执行代码之前会对代码中函数声明做一遍 pre-parse(预解析),为啥要做 pre-parse 呢?因为实际情况中大多数的函数都不是立即被使用的(甚至完全没被调用过),不需要对它们做一次完整的解析,只需要做一次性能开销更小的 pre-parse(比如检查一下语法错误),等函数真正被调用时,再进行完整的 full-parse。

// 下面的函数会先进行pre-parse
function foo() {
    //......
}

// 2秒之后函数被执行,又会进行一遍full-parse
setTimeout(foo, 2000);

但是有个问题!对于立即执行函数这种奇葩来说,它不适用于上面的规则,应该直接进行 full-parse。现在的大多数引擎也完全考虑到了这一点:

// 只会进行一次full-parse
(function() {
   //......
})();

但是还有个问题!!现在的大多数引擎检测 IIFE 的时候都不完全,大部分都是通过判别函数声明前有没有类似『 ( 』或者『 ! 』这样的字符来实现的,比如下面这种情况就被忽略掉了:

// 这里要进行pre-parse和full-parse,而前者是多余的
function run(f){
    return f();
}
run(function(){
    //......
})

所以我们可以通过一个小 trick 来优化这里的性能(加了一对括号),这样引擎就会把这里识别为立即执行函数,然后只做一次 full-parse:

// 只进行一次full-parse
function run(f){
    return f();
}
run((function(){
    //......
}));

所以针对这个问题,有一个专门的小工具来解决:

nolanlawson/optimize-js: Optimize a JavaScript file for faster initial load by wrapping eagerly-invoked functions

还有一个相关的讨论:

Turn off negate_iife by default as it hurts V8 performance. · Issue #886 · mishoo/UglifyJS2

这个看似不起眼的 trick 实际对于性能有很显著的提升:

这个问题本质上来讲是 JS 引擎对于立即执行函数的识别有遗漏导致的,比如在 Safari 10 中这个问题基本不会发生,而 Chrome 的 V8 中就经常出现。不过感觉随着引擎版本的迭代,这个问题应该会得到修复。

编辑于 2016-11-13

文章被以下专栏收录