详解JS作用域链及闭包

目录
  1. 导读
  2. 回顾上篇
  3. 作用域
    1. js中只有函数作用域
    2. 使用 var 声明变量
  4. JS中的作用域链
  5. 闭包
    1. 闭包内的微观世界
    2. 闭包有可能导致IE浏览器内存泄漏
  6. for循环问题分析
  7. 踩个作用域的坑
  8. this作用域
  9. with语句

导读

上一篇我们讲到数组遍历, 本文我们更进一步, 讲讲如何提高遍历的效率. 欢迎阅读本篇详解JS作用域链及闭包.

回顾上篇

请先看上一篇中提到的for循环代码:

var array = [];
array.length = 10000000;//(一千万)
for(var i=0,length=array.length;i<length;i++){
  array[i] = 'hi';
}
var t1 = +new Date();
for(var i=0,length=array.length;i<length;i++){
}
var t2 = +new Date();
console.log(t2-t1);
//以下是连续5次的运行时间
//168+158+170+159+165 = 820(ms)

我们再看下面一段代码, 测试环境为 chrome 52.0.2743.116 (64-bit):

var t1 = +new Date();
(function(){//闭包
  for(var i=0,length=array.length;i<length;i++){
      //array.push(i);
  }
})();
var t2 = +new Date();
console.log(t2-t1);
//以下是连续5次的运行时间:
//8+6+8+7+6 = 35(ms)

计算一下: 820/35 = 23 效率提升大致20倍. 实际上, 在 Firefox 及 Safari 对 for有做底层优化的情况下, 仍然有4~6倍的性能提升. 这是为什么呢?

我们注意到两段代码最大的区别就是, 第二段代码使用了匿名函数包裹for循环. 我们将在后面讲到, 请耐心阅读.

作用域

所谓作用域, Skycrab 有句解释还不错, 指的是, 变量在声明它们的函数体以及这个函数体嵌套的任意函数体内都是有定义的.

js中只有函数作用域

众所周知, JS中并没有块作用域, 只有函数作用域. 如下:

for(var i=0;i<10;i++){
  ;
}
console.log(i);//10
function f(){
  var a = 123;
}
f();
console.log(a);//a is not defined

因此 js 中只有一种局部作用域, 即函数作用域.

使用 var 声明变量

通常我们知道, js 作为一种弱类型语言, 声明一个变量只需要var保留字, 如果在函数中不使用 var 声明变量, 该变量将提升为全局变量, 进而脱离函数作用域, 如下:

function f(){
  b = 123;
}
f();
console.log(b);//123

此时相对于前面使用var声明的 a 变量, b 变量被提升为全局变量, 在函数作用域外依然可以访问.

既然在函数作用域内不使用 var 声明变量, 会将变量提升为全局变量, 那么在全局下, 不使用var, 会怎么样呢?

//全局下不使用var声明,该变量依然是全局变量
c = "hello scope";
console.log(c);//hello scope
console.log(window.c);//hello scope

//查看c变量的属性
console.log(Object.getOwnPropertyDescriptor(window, 'c'));//Object {value: "hello scope", writable: true, enumerable: true, configurable: true} ,此时c变量可赋值,可列举,可配置

//试着删除c变量
delete c;//true 表示c变量被成功删除
console.log(c);//c is not defined
console.log(window.c);//undefined

//使用var声明后再删除d变量
var d = 1;
console.log(Object.getOwnPropertyDescriptor(window, 'd'));//Object {value: 1, writable: true, enumerable: true, configurable: false} ,此时d变量可赋值,可列举,但不可配置
delete d;//false 表示d变量删除失败
console.log(d);//1 
console.log(window.d);//1

综上, 有如下规律:

  • 不使用var保留字声明变量, 变量提升为全局变量, 而不论变量处于哪种作用域;
  • 如果不使用var声明, 该变量便可配置, 即可被 delete 保留字删除, 删除后该变量便不可访问; 如果使用var声明, 该变量便不可配置, 即不能被 delete 保留字删除;
  • 只要是全局变量都可以直接访问, 也可使用 “window.变量名” 来访问, 不管该变量是不是通过var来声明的;

JS中的作用域链

函数对象和其它对象一样,拥有可以通过代码访问的属性和一系列仅供JavaScript引擎访问的内部属性。其中一个内部属性是[[Scope]],由ECMA-262标准第三版定义,该内部属性包含了函数被创建的作用域中对象的集合,这个集合被称为函数的作用域链,它决定了哪些数据能被函数访问。

我们先看一个栗子:

var e = "hello";
function f(){
  e = "scope chain";
  var g = = "good";
}

以上作用域链的图如下所示:

函数执行时, 在函数 f 内部会生成一个 active object 和 scope chain. JavaScript引擎内部对象会放入 active object中, 外部的 e 变量处于scope chain的第二层, index=1, 而内部的g变量处于scope chain的顶层, index=0, 因此访问g变量总比访问e变量来的快些.

闭包

聊到作用域, 就不得不说闭包, 那么, 什么是闭包?

“官方”的解释是:闭包是一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分。

这是什么意思呢, 简单来说就是:

  1. 函数执行时返回内部私有函数, 或者通过其他方式将内部私有函数保留在外(比如说通过将其内部私有函数的引用赋值外部变量), 从而阻止该函数内部作用域等被执行引擎回收.
  2. 在函数外部通过访问暴露在外的函数内部私有函数, 从而具有访问函数内部私有作用域的效果, 就是闭包.

ES6之前, 通常我们实现的模块就是利用了闭包. 闭包依赖的结构有个鲜明的特点, 即: 一个函数在词法作用域之外执行. 如下, f2是闭包的关键, 它的词法作用域便是函数f的内部私有作用域, 且它在f的作用域外部执行.

var h = 1;
function f(){
  var i = 2;
  return function f2(){
      var j = 3 + i + h;
    console.log(j);
  }
}
var ff = f();
ff();//6

由于定义时 f2 处于 f 的内部, 因此 f2 内可以访问到 f 的内部私有作用域, 这样通过返回 f2 就能保证在 f 函数外部也能访问到 i 变量.

当f2执行时, 变量 j 处于scope chain的 index0的位置上, 变量 i 和变量 h 分别处于 scope chain 的 index1 index2 的位置上. 因此 j 的赋值过程其实就是沿着 scope chain 第二层 第三层 依次找到 i 和 h 的值, 然后将它们和3一起求和, 最终赋值给 j .

浏览器沿着 scope chain 寻找变量总是需要耗费CPU时间, 越是 scope chain 的 外层(或者离f2越远的变量), 浏览器查找起来越是需要时间, 因为 scope chain 需要历经更多次遍历. 因此全局变量(window)总是需要最多的访问时间.

闭包内的微观世界

  如果要更加深入的了解闭包以及函数 f 和嵌套函数 f2 的关系,我们需要引入另外几个概念:函数的执行环境(excution context)、活动对象(call object)、作用域(scope)、作用域链(scope chain)。以函数a从定义到执行的过程为例阐述这几个概念。

  1. 定义函数 f 的时候, js解释器会将函数a的作用域链(scope chain)设置为定义 f 时 a 所在的”环境”, 如果 f 是一个全局函数,则scope chain中只有window对象。
  2. 执行函数 f 的时候, f 会进入相应的执行环境(excution context).
  3. 在创建执行环境的过程中, 首先会为 f 添加一个scope属性, 即a的作用域, 其值就为第1步中的scope chain. 即a.scope=f 的作用域链.
  4. 然后执行环境会创建一个活动对象(call object). 活动对象也是一个拥有属性的对象, 但它不具有原型而且不能通过JavaScript代码直接访问. 创建完活动对象后, 把活动对象添加到 f 的作用域链的最顶端. 此时a的作用域链包含了两个对象: f 的活动对象和window对象.
  5. 下一步是在活动对象上添加一个arguments属性, 它保存着调用函数 f 时所传递的参数.
  6. 最后把所有函数 f 的形参和内部的函数 f2 的引用也添加到 f 的活动对象上. 在这一步中, 完成了函数 f2 的定义, 因此如同第3步, 函数 f2 的作用域链被设置为 f2 所被定义的环境, 即 f 的作用域.

到此, 整个函数 f 从定义到执行的步骤就完成了. 此时 f 返回函数 f2 的引用给 ff, 又函数 f2 的作用域链包含了对函数 f 的活动对象的引用, 也就是说 f2 可以访问到 f 中定义的所有变量和函数. 函数 f2 被 ff 引用, 函数 f2又依赖函数 f , 因此函数 f 在返回后不会被GC回收.

当函数 f2 执行的时候亦会像以上步骤一样. 因此, 执行时 f2 的作用域链包含了3个对象: f2 的活动对象、f 的活动对象和window对象, 如下图所示:

如图所示, 当在函数 f2 中访问一个变量的时候, 搜索顺序是:

  1. 先搜索自身的活动对象, 如果存在则返回, 如果不存在将继续搜索函数 f 的活动对象, 依次查找, 直到找到为止.
  2. 如果函数 f2 存在prototype原型对象, 则在查找完自身的活动对象后先查找自身的原型对象, 再继续查找. 这就是Javascript中的变量查找机制.
  3. 如果整个作用域链上都无法找到, 则返回undefined.

小结, 本段中提到了两个重要的词语: 函数的定义执行. 文中提到函数的作用域是在定义函数时候就已经确定, 而不是在执行的时候确定(参看步骤1和3).用一段代码来说明这个问题:

function f(x) { 
  var g = function () { return x; }
  return g;
}
var h = f(1);
alert(h());

这段代码中变量h指向了f中的那个匿名函数(由g返回).

  • 假设函数h的作用域是在执行alert(h())确定的, 那么此时h的作用域链是: h的活动对象->alert的活动对象->window对象.
  • 假设函数h的作用域是在定义时确定的, 就是说h指向的那个匿名函数在定义的时候就已经确定了作用域. 那么在执行的时候, h的作用域链为: h的活动对象->f的活动对象->window对象.

如果第一种假设成立, 那输出值就是undefined; 如果第二种假设成立, 输出值则为1。

运行结果证明了第2个假设是正确的,说明函数的作用域确实是在定义这个函数的时候就已经确定了.

闭包有可能导致IE浏览器内存泄漏

先看一个栗子:

function f(){
  var div = document.createElement("div");   
  div.onclick = function(){
    return false;
  }
}

上述div的click事件就是一个闭包, 由于该闭包的存在使得 f 函数内部的 div 变量对DOM元素的引用将一直存在.

而早期IE浏览器( IE9之前 ) js 对象和 DOM 对象使用不同的垃圾收集方法, DOM对象使用计数垃圾回收机制, 只要匿名函数( 比如说onclick事件 )存在, DOM对象的引用便至少为1,因此它所占用的内存就永远不会被销毁.

有趣的是,不同的IE版本将导致不同的现象:

  • 如果是IE 6, 内存泄漏,直到关闭IE进程为止;
  • 如果是IE 7,内存泄漏, 直到离开当前页面为止;
  • 如果是IE 8, GC回收器回收他们的内存,无论当前是不是compatibility模式.

总结一下, 闭包的优点: 共享函数作用域, 便于开放一些接口或变量供外部使用;

注意事项: 由于闭包可能会使得函数中变量被长期保存在内存中, 从而大量消耗内存, 影响页面性能, 因此不能滥用, 并且在IE浏览中可能导致内存泄露. 解决方法是,在退出函数之前,将不使用的局部变量全部删除.

for循环问题分析

我们再来看看开篇的for循环问题, 增加匿名函数后, for循环内部的变量便处于匿名函数的局部作用域下, 此时访问 length 属性, 或者访问 i 属性, 都只需要在匿名函数作用域内查找即可, 因此查询效率大大提升(测试数据发现提升有两百多倍).

使用匿名函数后, 不止是作用域查询更快, 作用域内的变量还与外部隔离, 避免了像 i , length 这样的变量对后续代码产生影响. 可谓一举两得.

踩个作用域的坑

下面我们来踩一个作用域经典的坑.

var div = document.getElementsByTagName("div");
for(var i=0,len=div.length;i<len;i++){
  div[i].onclick = function(){
      console.log(i);
  }
}

上述代码的本意是每次点击div, 打印div的索引, 实际上打印的却是 len 的值. 我们来分析下原因.

点击div时, 将会执行 console.log(i) 语句, 显然 i 变量不在 click 事件的局部作用域内, 浏览器将沿着 scope chain 寻找 i 变量, 在 index1 的地方, 即 for循环开始的地方, 此处定义了一个 i 变量, 又 js 没有块作用域, 故 i 变量并不会在 for循环块执行完成后被销毁,又 i的最后一次自加使得 i = len, 于是浏览器在scope chain index=1索引的地方停下来了, 返回了i的值, 即len的值.

为了解决这个问题, 我们将根据症结, 对症下药, 从作用域入手, 改变click事件的局部作用域, 如下:

var div = document.getElementsByTagName("div");
for(var i=0,len=div.length;i<len;i++){
  (function(n){
      div[n].onclick = function(){
      console.log(n);
    }
  })(i);
}

由于 click 事件被闭包包裹, 并且闭包自执行, 因此闭包内 n 变量的值每次都不一样, 点击div时, 浏览器将沿着 scope chain 寻找 n 变量, 最终会找到闭包内的 n 变量, 并且打印出div 的索引.

this作用域

前面我们学习了作用域链, 闭包等基础知识, 下面我们来聊聊神秘莫测的this作用域.

熟悉OOP的开发人员都知道, this是对象实例的引用, 始终指向对象实例. 然而 js 的世界里, this随着它的执行环境改变而改变, 并且它总是指向它所在方法的对象. 如下,

function f(){
  alert(this);
}
var o = {};
o.func = f;
f();//[object Window]
o.func();//[object Object]
console.log(f===window.f);//true

当f单独执行时, 其内部this指向window对象, 但是当f成为o对象的属性func时, this指向的是o对象, 又f === window.f, 故它们实际上指向的都是this所在方法的对象.

下面我们来应用下

Array.prototype.slice.call([1,2,3],1);//[2,3],正确用法
Array.prototype.slice([1,2,3],1);//[], 错误用法,此时slice内部this仍然指向Array.prototype
var slice = Array.prototype.slice;
slice([1,2,3],1);//Uncaught TypeError: Array.prototype.slice called on null or undefined
//此时slice内部this指向的是window对象,离开了原来的Array.prototype对象作用域,故报错~~

总结下, this的使用只需要注意一点:

this 总是指向它所在方法的对象.

with语句

聊到作用域链就不得不说with语句了, with语句可以用来临时改变作用域, 将语句中的对象添加到作用域的顶部.

语法: with (expression){statement} 例如:

var k = {name:"daicy"};
with(k){
  console.log(name);//daicy
}
console.log(name);//undefined

with 语句用于对象 k, 作用域第一层为 k 对象内部作用域, 故能直接打印出 name 的值, 在with之外的语句不受此影响.

再看一个栗子:

var l = [1,2,3];
with(l) {
  console.log(map(function(i){
      return i*i;
  }));//[1,4,9]
}

在这个例子中,with 语句用于数组,所以在调用 map() 方法时,解释程序将检查该方法是否是本地函数。如果不是,它将检查伪对象 l,看它是否为该对象的方法, 又map是Array对象的方法, 数组l继承了该方法, 故能正确执行.

注意: with语句容易引起歧义, 由于需要强制改变作用域链, 它将带来更多的cpu消耗, 建议慎用 with 语句.


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2015/12/22/scopeChain/

参考文章

Fork me on GitHub