作用域和闭包

11 篇文章 0 订阅

目录

1.作用域

1.1 全局作用域

 1.2 函数作用域

1.3 块级作用域

2.作用域链

3.作用域实现机制

4.作用域模型

4.1 词法作用域和动态作用域

4.2 修改词法作用域

4.21 eval函数

4.22 with函数

5. 闭包

5.1 闭包的定义

5.2 经典的 for 循环问题

5.2.1 ES6 之前的解决方案:

5.3 模块

5.4 实现迭代器

5.5 闭包的优点

5.6 闭包的缺点

5.7 闭包不会引起内存泄漏

5.8 闭包的范例

5.8.1返回匿名闭包

5.8.2各自独立的闭包

5.8.3闭包的链式调用

 5.8.4留意父函数执行过一次!

参考资料


1.作用域

每一种编程语言,它最基本的功能都是存储变量的值,并对这个值进行使用和修改。有了变量之后,应该把它放到哪里,我们如何使用,这时候就需要一套规则,这套规则就是作用域,即作用域就是变量起作用的范围和区域。 作用域的目的是隔离变量,保证不同作用域下同名变量不会冲突。

在JS中分为作用域分三种:

  • 全局作用域
  • 函数作用域
  • 块作用域

1.1 全局作用域

以下三种情形拥有全局作用域。第一种:最外层变量以及函数

var num = 10;   //最外层变量
function f1(){   //最外层函数
  var num2 = 20;    //函数内变量
  function f2(){     //内层函数
    console.log(num2);
  }
  console.log(num)
}

console.log(num); // global
f1(); // global

console.log(num2);  // not defined
console.log(f2);    // not defined

从上面例子中可以看出num变量和f1函数在任何地方都可以访问到,反之不在全局作用域下的变量只能在当前作用域下使用。

第二种:不使用var声明的变量

function f1(){
  num = 10;
  var num2 = 20
}
f1();
console.log(num); // global
console.log(num2); // not defined

从这个例子我们看出,不使用var声明的变量会进行变量提升(提到全局作用域下),所以num变量在任何作用域下可以访问到。 有时候,我们也将不使用var声明的变量我们称作隐式全局变量

第三种:window对象所有属性和函数拥有全局作用域。

window对象代表的是整个浏览器窗口,我们尝试着在浏览器打印window对象。

window对象具有双重角色,一是上图中JS访问浏览器的一个接口。window对象下的所有属性和函数都拥有全局作用域,例如我们经常用到过的:window.innerHeight 、window.alert() 、 window.setTimeout()等等。

二是ECMAScript中规定的Global对象。 在全局作用域中使用var所创建的变量都会作为window对象的属性保存;全局作用域中所有的函数都会作为window对象的方法保存(如下图)。

var a =10
function a1(){
    console.log('hello')
}

 注:全局作用域在网页打开时创建,在网页关闭时销毁。

全局作用域有个弊端,就是如果我们在全局作用域中写了很多变量,如果命名冲突,后面的变量会覆盖前面同名变量,从而污染全局命名空间。

 1.2 函数作用域

在函数内部定义的变量,拥有函数作用域。

var num = '小明';//name全局变量

function sayHi(){
  // str是函数中的局部变量
  var str ='hi word'
  console.log(str)
}

 function showName(myName){
  // 函数的形参也是局部变量
  console.log(myName);
}

sayHi(); // 输出'hi word'
showName(num); // 输出'小明'

console.log(str); // 抛出错误:str在全局作用域未定义
console.log(myName); // 抛出错误:myName 在全局作用域未定义

在这个例子中,str和myName都是函数内部定义的变量,他们的作用域也就仅限于函数内部,全局作用域中不会访问到。

注:函数调用时创建,调用结束作用域随之销毁。 每调用一次产生一个新的作用域,之间相互独立。

1.3 块级作用域

使用let或const声明的变量,如果被一个大括号括住,那么这个大括号括住的变量就形成了一个块级作用域。

{
    let num = 10;
    console.log(num);
}
console.log(num); // 报错

在这个例子中,我们可以看出:块级作用中定义的变量只在当前块中生效,这和函数作用域类似,他们都是只在自己的地盘内生效。

2.作用域链

以上,我们对作用域有了基本认识,我们不难发现,只要是代码,就至少拥有一个作用域。写在函数内部的函数作用域,如果函数中还有函数,那么这个作用域中就又可以诞生一个作用域。比如这样:

var num = 10 ;

function fn(){  // 外部函数
  var num = 20;
      function fun(){ // 内部函数
          console.log(num)
      }
      fun()
}
fn()

在这个例子中,有三个作用域,全局作用域、fn的函数作用域、fun的函数作用域。

当我们试图在 fun 这个函数里访问变量 num 的时候,此时函数作用域内没有num变量,当前作用域找不到。要想找到num,根据作用域链的查找规则,我们需要去上层作用域(fn函数作用域),在这里我们找到了 num ,就可以拿来使用了。

我们把作用域层层嵌套,形成的关系叫做作用域链,作用域链也就是查找变量的过程。 查找变量的过程::当前作用域 --》上一级作用域 --》上一级作用域 .... --》直到找到全局作用域 --》还没有,报错。

3.作用域实现机制

要想理解作用域实现的机制,我们需要结合JS编译原理来看,我们先来看一个简单的声明语句:

var name = '小明'

在这段代码中,有两个阶段:

编译阶段:编译器在当前作用域中声明一个变量name
执行阶段:JS引擎在当前作用域中查找该变量,找到name变量并为其赋值
证明以上说法:

console.log(name); // undefined
var name = '小明'

我们直接输出name变量,此时并没有报错,而是输出undefined,说明输出的时候改变量已经存在了,只是没有赋值而已。

其实,上面这段代码包含两种变量查找方式:如果查找的目的是对变量进行赋值,那么就会使用LHS查询;如果目的是获取变量的值,就会使用RHS查询LHS(Left-hand Side)RHS(Right-hand Side)是JS引擎执行代码的时候,查询变量的两种方式。这里的“Left”和“Right”,是相对于赋值操作来说,当变量出现在赋值操作左侧时,执行LHS操作。

4.作用域模型

4.1 词法作用域和动态作用域

我们说过,作用域本质是一套规则,而这个规则的底层遵循的就是词法作用域模型,简单来说,“词法作用域”就是作用域的成因。
从语言的层面来说,作用域模型分两种:

词法作用域:也称静态作用域,是最为普遍的一种作用域模型
动态作用域:相对“冷门”,bash脚本、Perl等语言采纳的是动态作用域

要想理解这种模型区别,我们看以下例子:

var num = 10;
function f1(){
  console.log(num)
}
function f2(){
  var num  = 20;
  f1()
}
f2();

因为JS基于的是词法作用域,不难得出它的运行结果是10。这段代码经历了这样的执行过程:

f2函数调用,f1函数调用
在f1函数作用域内查找是否有局部变量num
发现没找到,于是根据书写位置,向上一层作用域(全局作用域)查找,发现num,打印num=10

这里我们作用域的划分遵循的就是词法作用域,即在词法分析时生成的作用域,词法分析阶段,也可以理解为代码书写阶段,当你把函数(块级作用域同理)书写到某个位置,不用执行,它的作用域就已经确定了。


与之相对应的动态作用b域,我们也分析这段代码的的执行过程:

f2函数调用,f1函数调用
在f1函数作用域内查找是否有局部变量num
发现没找到,于是沿着调用栈,在调用f1函数地方继续找,也就是在f2函数中查找,刚好,f2函数中有num,此时就会打印20

我们总结一下。词法作用域和动态作用域最根本区别在于生成作用域的时机:
词法作用域:在代码书写时完成划分,作用域沿着它定义的位置往外延伸
动态作用域:在代码运行时完成划分,作用域链沿着他的调用栈往外延伸

4.2 修改词法作用域

修改词法作用域也又叫做“欺骗词法作用域”,词法作用域意味着作用域是由书写代码时函数声明的位置来决定的,那么为什么在运行过程中将划分好的词法作用域改掉呢?怎样才能在运行时"修改"(欺骗)词法作用域呢?

在JS中有两个函数来实现这个目的,分别是evalwith

4.21 eval函数

我们知道,eval函数入参是一个字符串。当eval拿到一个字符串入参后,它会把这段字符串的内容当做js代码(不管它是不是一段代码),插入到自己被调用的那个位置.

我们先看以下代码:

function f1(str){
   eval(str);
   console.log(num);
 }

 var num = 10;
 var str ="var num = 20"
 f1(str)

上面代码,被eval“改造后”,就会变成:

function f1(str){
    var num =20
    console.log(num);
  }

  var num = 10;
  f1(str)

这时当再我们打印num时,函数作用域内的num已经被eval传入的这行代码给修改掉了,所以打印结果就由10变成了20。 eval它成功修改了词法作用域规则,在书写阶段就划分好的作用域。

4.22 with函数

with函数是引用对象的一种简写方式。当我们去引用一个对象中的多个属性时,可不用重复引用对象本身。

var obj={
   a:1
 }

// 打印属性
console.log(obj.a);

// 使用with简写
with(obj){
    console.log(a);
}

接下来我们来看with是如何改变词法作用域的,请看看例子

function fn(obj){
  with(obj){
    a = 2
  }
}
var f1 = {a:3}
var f2 = {b:3}

fn(f1)
console.log(f1.a) // 3

fn(f2)
console.log(f2.a)  // 输出undefined
console.log(a) // 2

当fn函数第一次调用时,with会为f1这个对象凭空创造出一个新的作用域,这使得我们在这个作用域内可以直接访问a对象属性。

当第二次调用fn函数时,with也会为f2这个对象创造出一个新的作用域,使得我们可以在这个作用域内直接访问b这个对象属性,此时a属性已不存在。

当我们直接打印a时,会打印全局变量2。这是为什么呢?事实上这是因为我们使用with,在非严格模式下,使用with声明的a因为没有var,所以是一个隐式全局变量,隐式全局变量在任何位置都能访问到.

我们总结下with改变作用域的方式:
with 会原地创建一个全新的作用域,这个作用域内的变量集合,其实就是传入 with 的目标对象的属性集合。
因为 “创建” 这个动作,是在 with 代码实际已经被执行后发生的,因此with实现了对书写阶段就划分好的作用域进行修改。

事实上,在我们实际开发中很少用到这两个函数,因为存在性能问题,它会导致我们的代码变得很慢,而且还会像上面例子一样“横空出世全局变量”。以上,eval和with不建议实际开发使用.

5. 闭包

5.1 闭包的定义

书中给出的定义时:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

通俗说:闭包就是从函数外部访问函数内部的变量,函数内部的变量可以持续存在的一种实现。
还有一种理解就是:闭包就是函数内部定义的函数,被返回了出去并在外部调用。
在了解了词法作用域和变量的查询方式之后,我们看看一个简单的闭包的实现逻辑:

function foo() {
    let num = 1 // 里面的变量
    function add() {
        num += 1
    }
    function log() {
        console.log(num)
    }
    return { add, log } // 我要到外面去了
}

const { add, log } = foo()

log() // 1 我从里面来,我在外面被调用,还是可以获得里面的变量
add()
log() // 2
  • 首先定义一个 f 函数,函数内部维护一个变量 num,然后定义两个函数 add 和 log
  • add 函数每次调用会增加 num 的值
  • log 函数每次调用会打印 num 的值
  • 然后我们将两个函数通过 return 方法返回
  • 紧接着先调用外部的 log 方法打印 f 方法维护的 num,此时为 1
  • 然后调用外部的 add 方法增加 num 的值
  • 最后再次调用 log 方法打印 num,此时则为 2

在foo()执行后,通常会期待foo()的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去foo()的内容不会再被使用,所以很自然的会考虑对其进行回收。

而闭包的神奇之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收,拜add()和log()所声明的位置所赐,它们拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供add()和log()在之后任何时间进行引用。
add()和log()依然持有对该作用域的引用,而这个引用就叫做闭包。

让我们再看一个例子:

function wait(message){

  setTimeout(function timer() {
    console.log(message);
  },1000);

}

wait("Hello,Amiy");

将一个内部函数(名为timer)传递给setTimeout(...),time具有涵盖wait(...)作用域的闭包,因此还保有对变量message的引用。
wait(...)执行1000毫秒后,它的内部作用域并不会消失,timer函数依然保有wait(...)作用域的闭包
在引擎内部,内置的工具函数setTimeout(...)持有对一个参数的引用,这个参数也许叫做fn或者func,或者其他类似的名字。引擎会调用这个函数,在例子中就是内部的timer函数,而词法作用域在这个过程中保持完整

这就是闭包。

本质上无论何时何地,如果将(访问它们各自词法作用域的)函数当做第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信、Web workers或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

5.2 经典的 for 循环问题

arr = []
for (var i = 0; i < 10; i ++) {
    arr[i] = function() {
        console.log(i)
    }
}
arr[2]() // 10

首先我们知道 for 循环体内的 i 实际上会被定义在全局作用域中
每次循环我们都将 function 推送到一个 arr 中,for 循环执行完毕,
随后我们执行代码 arr2 此时 arr[2] 对应的函数 function(){ console.log(i) } 会被触发
函数尝试搜索函数局部作用域中的 i 变量,搜索不到则会继续向外层搜索,i 被定义到了外层,因此会直接采用外层的 i,就是这里的全局作用域中的 i,等到这个时候调用这个函数,i 早已变成 10 了
那么有什么方法能够避免出现这种情况吗?

5.2.1 ES6 之前的解决方案:

了解了闭包我们就知道了闭包内的变量可以持续存在,所以修改代码将 arr 中的每一项改为指向一个闭包:

了解了闭包我们就知道了闭包内的变量可以持续存在,所以修改代码将 arr 中的每一项改为指向一个闭包:

arr = []
for (let i = 0; i < 10; i ++) { // 使用 let
    arr[i] = function() {
        console.log(i)
    }
}

 在使用 let 之后,我们每次定义 i 都是通过 let i 的方法定义的,这个时候 i 不再是被定义到全局作用域中了,而是被绑定在了 for 循环的块级作用域中
因为是块级作用域所以对应 i 的 arr 每一项都变成了一个闭包,arr 每一项都在不同的块级作用域中因此不会相互影响

5.3 模块

还有其他的代码模式利用闭包的强大威力,下面让我们来研究最强大的一个:模块

function foo(){
   var something = "cool";
   var another = [1,2,3];

   function doSomething(){
     console.log(something);
   }

   function doAnother(){
     console.log(another.join("!");
   }

}

正如在这段代码中所看到的,这里并没有明显的闭包,只有两个私有数据变量somethinganother,以及doSomething()doAnother()两个内部函数,他们的词法作用域(而这就是闭包)也就是foo()的内部作用域。

带var的私有作用域变量提升阶段,都声明为私有变量,和外界没有任何关系。

接下来考虑以下代码:

function CoolMoudle(){
   var something ="cool";
   var another = [1,2,3];

   function doSomething(){
     console.log(something);
   }

   function doAnother(){
     console.log(another.join("!"));
   }

  return {
   doSomething:doSomething,
    doAnother:doAnother
  }
}

var foo = CoolMoudle();
foo.doSomething();  //cool
foo.doAnother(); //1!2!3!

这个模式在JavaScript中被称为模块

我们仔细研究以下这些代码:

首先,CoolMoudle()只是一个函数,必须要通过调用它来创建一个模块实例。如果不执行外部函数,内部作用域和闭包都无法被创建。

其次,CoolMoudle()返回一个用对象字面量语法{key:value,....}来表示的对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用,我们保持内部数据变量是隐藏且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共API

这个对象类型的返回值最终被赋值给外部的变量foo,然后就可以通过它来访问API中的属性方法,比如foo.doSomething()

模块模式要具备两个必要条件。
1.必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例。
2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

一个具有函数属性的对象本身并不是真正的模块,从方便观察的角度看一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。

5.4 实现迭代器

function setup(x) {
 var i = 0;
 return function(){
   return x[i++];
 };
}
var next = setup(['a', 'b', 'c']);

执行效果:

> next();
"a"
> next();
"b"
> next();
"c"

5.5 闭包的优点

  1. 可以减少全局变量的定义,避免全局变量的污染
  2. 能够读取函数内部的变量
  3. 在内存中维护一个变量,可以用做缓存

5.6 闭包的缺点

1)造成内存泄露

闭包会使函数中的变量一直保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题。

解决方法——使用完变量后,手动将它赋值为null;
将不再使用的变量,

2)闭包可能在父函数外部,改变父函数内部变量的值。

3)造成性能损失

由于闭包涉及跨作用域的访问,所以会导致性能损失。

解决方法——通过把跨作用域变量存储在局部变量中,然后直接访问局部变量,来减轻对执行速度的影响

5.7 闭包不会引起内存泄漏

由于IE9 之前的版本对JScript 对象和COM 对象使用不同的垃圾收集。因此闭包在IE 的这些版本中会导致一些特殊的问题。具体来说,如果闭包的作用域链中保存着一个HTML 元素,那么就意味着该元素将无法被销毁请看例子:

function assignHandler(){
    var element = document.getElementById("someElement");
    element.onclick = function(){
        alert(element.id);
    };
}

以上代码创建了一个作为element 元素事件处理程序的闭包,而这个闭包则又创建了一个循环引用。由于匿名函数保存了一个对assignHandler()的活动对象的引用,因此就会导致无法减少element 的引用数。只要匿名函数存在,element 的引用数至少也是1,因此它所占用的内存就永远不会被回收,这是IE的问题,所以闭包和内存泄漏没半毛钱关系。

解决办法前言已经提到过,把element.id 的一个副本保存在一个变量中,从而消除闭包中该变量的循环引用同时将element变量设为null

function assignHandler(){
    var element = document.getElementById("someElement");
    var id = element.id;
    element.onclick = function(){
        alert(id);
    };
    element = null;
}

总结:闭包并不会引起内存泄漏,只是由于IE9 之前的版本对JScript对象和COM对象使用不同的垃圾收集,从而导致内存无法进行回收

5.8 闭包的范例

5.8.1返回匿名闭包

function funA(){
  var a = 10;  // funA的活动对象之中;
  return function(){   //匿名函数的活动对象;
        alert(a);
  }
}
var b = funA();
b();  //10

5.8.2各自独立的闭包

function outerFn(){
  var i = 0;
  function innerFn(){
      i++;
      console.log(i);
  }
  return innerFn;
}
var inner = outerFn();  //每次外部函数执行的时候,都会开辟一块内存空间,外部函数的地址不同,都会重新创建一个新的地址
inner();
inner();
inner();
var inner2 = outerFn();
inner2();
inner2();
inner2();   //1 2 3 1 2 3

5.8.3闭包的链式调用

var add = function (x) {
    var sum = 1;
    var tmp = function (x) {
        console.log('执行tmp')
        sum = sum + x;
        return tmp;
    }
    tmp.toString = function () {
        return sum;
    }
    return tmp;
}
console.log(add(1)(2)(3).toString())

控制台输出结果:

执行tmp
执行tmp
6

add(1) 时执行的是最外面的匿名函数,从(2) 开始,才执行tmp
所以第一个参数无论是几,最终结果都是6

console.log(add(8)(2)(3).toString()) // 最终结果还是 6

 5.8.4留意父函数执行过一次!

function love1(){
     var num = 223;
     var me1 = function() {
           console.log(num);
     }
     num++;
     return me1;
}
var loveme1 = love1();
loveme1();   //输出224

参考资料

1.你不知道的JavaScript 上
2.作用域:【JavaScript】深入理解JS中的词法作用域与作用域链 - 掘金
3.js详解闭包:js 【详解】闭包_朝阳39的博客-CSDN博客_js闭包
4.闭包会造成内存泄漏吗:闭包会造成内存泄漏吗? - yancyenough - 博客园

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值