深入理解Javascript中的作用域链和闭包

首先我们回顾下之前一篇关于介绍数组遍历的文章:

请先看上一篇中提到的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循环. 我们将在后面讲到, 请耐心阅读.

作用域

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

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变量来的快些.

闭包

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

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

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

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

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从定义到执行的过程为例阐述这几个概念。

  • 当定义函数 f 的时候, js解释器会将函数a的作用域链(scope chain)设置为定义 f 时 a 所在的”环境”, 如果 f 是一个全局函数,则scope chain中只有window对象。
  • 当执行函数 f 的时候, f 会进入相应的执行环境(excution context).
  • 在创建执行环境的过程中, 首先会为 f 添加一个scope属性, 即a的作用域, 其值就为第1步中的scope chain. 即a.scope=f 的作用域链.
  • 然后执行环境会创建一个活动对象(call object). 活动对象也是一个拥有属性的对象, 但它不具有原型而且不能通过JavaScript代码直接访问. 创建完活动对象后, 把活动对象添加到 f 的作用域链的最顶端. 此时a的作用域链包含了两个对象: f 的活动对象和window对象.
  • 下一步是在活动对象上添加一个arguments属性, 它保存着调用函数 f 时所传递的参数.
  • 最后把所有函数 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 中访问一个变量的时候, 搜索顺序是:

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

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作能带来一定的帮助,如果有疑问大家可以留言交流,谢谢大家对我们的支持。

(0)

相关推荐

  • 深入理解javascript作用域和闭包

    作用域 作用域是一个变量和函数的作用范围,javascript中函数内声明的所有变量在函数体内始终是可见的,在javascript中有全局作用域和局部作用域,但是没有块级作用域,局部变量的优先级高于全局变量,通过几个示例来了解下javascript中作用域的那些"潜规则"(这些也是在前端面试中经常问到的问题). 1. 变量声明提前 示例1: var scope="global"; function scopeTest(){ console.log(scope); v

  • JavaScript中的作用域链和闭包

    作用域 全局作用域 局部作用域 作用域链 执行上下文 活动对象 闭包 闭包优化 JavaScript中出现了一个以前没学过的概念--闭包.何为闭包?从表面理解即封闭的包,与作用域有关.所以,说闭包以前先说说作用域. 作用域(scope) 通常来说一段程序代码中使用的变量和函数并不总是可用的,限定其可用性的范围即作用域,作用域的使用提高了程序逻辑的局部性,增强程序的可靠性,减少名字冲突. 全局作用域(Global Scope) 在代码中任何地方都能访问到的对象拥有全局作用域,以下几种情形拥有全局作

  • javascript中的作用域和闭包详解

    一.JavaScript作用域 JavaScript变量实际上只有两种作用域,全局变量和函数的内部变量.在函数内部任何一个地方定义的变量(var scope)其作用域都是整个函数体. 全局变量:指的是window对象下的对象属性. 作用域划分:基于上下文,以函数进行划分的,而不是由块划分的. 强调两点: 1. 在同一作用域中,JavaScript是允许变量的重复定义,并且后一个定义将覆盖前一个定义. 2. 函数内部如果不加关键字var而定义的变量,默认为全局变量. var scope="glob

  • 浅谈JavaScript作用域和闭包

    作用域和闭包在JavaScript里非常重要.但是在我最初学习JavaScript的时候,却很难理解.这篇文章会用一些例子帮你理解它们. 我们先从作用域开始. 作用域 JavaScript的作用域限定了你可以访问哪些变量.有两种作用域:全局作用域,局部作用域. 全局作用域 在所有函数声明或者大括号之外定义的变量,都在全局作用域里. 不过这个规则只在浏览器中运行的JavaScript里有效.如果你在Node.js里,那么全局作用域里的变量就不一样了,不过这篇文章不讨论Node.js. `const

  • javascript作用域和闭包使用详解

    作用域的嵌套将形成作用域链,函数的嵌套将形成闭包.闭包与作用域链是 JavaScript 区别于其它语言的重要特性之一. 作用域JavaScript 中有两种作用域:函数作用域和全局作用域. 在一个函数中声明的变量以及该函数的参数享有同一个作用域,即函数作用域.一个简单的函数作用域的例子: 复制代码 代码如下: function foo() {    var bar = 1;    {        var bar = 2;    }    return bar; // 2} 不同于C等其它有块

  • JavaScript 变量作用域及闭包第1/2页

    实例一: 复制代码 代码如下: <script type="text/javascript"> var i = 1; // 弹出内容为 1 true 的提示框 alert(window.i + ' ' + (window.i == i)); </script> 分析: 在全局定义的变量其实就是 window 对象的属性. 上面的例子可以看到,我们定义全局变量的同时,window 对象会产生一个相应的属性,如何让我们的代码避免产生这个属性呢,看下面的例子. 实例二

  • 浅谈JavaScript中的作用域和闭包问题

    JavaScript的作用域以函数为界,不同的函数拥有相对独立的作用域.函数内部可以声明和访问全局变量,也可以声明局部变量(使用var关键字,函数的参数也是局部变量),但函数外部无法访问内部的局部变量: function test() { var a = 0; // 局部变量 b = 1; // 全局变量 } a = ?, b = ? // a为undefined,b为1 同名的局部变量会覆盖全局变量,但本质上它们是两个独立的变量,一方发生变化不会影响另一方: a = 5; // 函数外a的值为

  • 深入理解Javascript中的作用域链和闭包

    首先我们回顾下之前一篇关于介绍数组遍历的文章: 请先看上一篇中提到的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();

  • javascript从作用域链谈闭包

    神马是闭包 关于闭包的概念,是婆说婆有理. 闭包是指有权访问另外一个函数作用域中的变量的函数 这概念有点绕,拆分一下.从概念上说,闭包有两个特点: 1.函数 2.能访问另外一个函数作用域中的变量 在ES 6之前,Javascript只有函数作用域的概念,没有块级作用域(但catch捕获的异常 只能在catch块中访问)的概念(IIFE可以创建局部作用域).每个函数作用域都是封闭的,即外部是访问不到函数作用域中的变量. function getName() { var name = "美女的名字&

  • Javascript中的作用域和上下文深入理解

    概述 Javascript中的作用域和上下文的实现是Javascript语言独有的特性,从某种程度上来说,Javascript语言是十分灵活的.Javascript中的函数可以采用各种各样的上下文,作用域也可以被封装和保存.正是由于这些特性,Javascript中也提供了很多很有用的设计模式.然而,作用域和上下文也是Javascript程序员在开发中经常迷惑的地方. 下面会向大家介绍Javascript中作用域和上下文的概念,以及它们的不同. 作用域 VS 上下文 首先要说明的很重要的一点是作用

  • 深入浅析JavaScript中的作用域和上下文

    javascript中的作用域(scope)和上下文(context)是这门语言的独到之处,这部分归功于他们带来的灵活性.每个函数有不同的变量上下文和作用域.这些概念是javascript中一些强大的设计模式的后盾.然而这也给开发人员带来很大困惑.下面全面揭示了javascript中的上下文和作用域的不同,以及各种设计模式如何使用他们. 上下文(Context)和作用域(Scope) 首先需要知道的是,上下文和作用域是两个完全不同的概念.多年来,我发现很多开发者会混淆这两个概念(包括我自己),

  • javascript中的作用域和上下文使用简要概述

    javascript中的作用域(scope)和上下文(context)是这门语言的独到之处,这部分归功于他们带来的灵活性.每个函数有不同的变量上下文和作用域.这些概念是javascript中一些强大的设计模式的后盾.然而这也给开发人员带来很大困惑.下面全面揭示了javascript中的上下文和作用域的不同,以及各种设计模式如何使用他们. 上下文 vs 作用域 首先需要澄清的问题是上下文和作用域是不同的概念.多年来我注意到许多开发者经常将这两个术语混淆,错误的将一个描述为另一个.平心而论,这些术语

  • Javascript中的作用域及块级作用域

    一.块级作用域的说明 在学习JavaScript的变量作用域之前,我们应当明确几点: a.JavaScript的变量作用域是基于其特有的作用域链的. b.JavaScript没有块级作用域. c.函数中声明的变量在整个函数中都有定义. javascript的变量作用域,与平时使用的类C语言不同,例如C#中的代码: static void Main(string[] args) { if(true) { int number=10; } Console.WriteLine(number); } 这

  • JavaScript 中的作用域与闭包

    目录 一.JavaScript 是一门编译语言 1.1 传统编译语言的编译步骤 1.2 JavaScript 与传统编译语言的区别 二.作用域(Scope) 2.1 LHS查询 和 RHS查询 2.2 作用域嵌套 2.3 ReferenceError 和 TypeError (1)ReferenceError (2)TypeError (3)ReferenceError 和 TypeError 的区别 小结 三.词法作用域 3.1 词法阶段 3.2 词法作用域 查找规则 3.3 欺骗词法 ——

  • Javascript的作用域、作用域链以及闭包详解

    一.javascript中的作用域 ①全局变量-函数体外部进行声明 ②局部变量-函数体内部进行声明 1)函数级作用域 javascript语言中局部变量不同于C#.Java等高级语言,在这些高级语言内部,采用的块级作用域中会声明新的变量,这些变量不会影响到外部作用域. 而javascript则采用的是函数级作用域,也就是说js创建作用域的单位是函数. 例如: 在C#当中我们写如下代码: static void Main(string[] args) { for (var x = 1; x < 1

  • 全面理解JavaScript中的继承(必看)

    JavaScript中我们可以借助原型实现继承. 例如 function baz(){ this.oo=""; } function foo(){ } foo.prototype=new baz(); var myFoo=new foo(); myFoo.oo; 这样我们就可以访问到baz里的属性oo啦.在实际使用中这个样不行滴,由于原型的共享特点(数据保存在了堆上), 所有实例都使用一个原型,一但baz的属性有引用类型就悲剧了,一个实例修改了其他实例也都跟着变了...wuwuwu 自

随机推荐