详解JavaScript作用域与闭包

112 阅读11分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

1.什么是作用域

作用域是什么? 一套设计良好的规则 ,用于存储变量,并且可以方便地找到需要的变量。一个程序中有许多变量,程序中的代码可以访问到这些变量并修改或获取到变量所存储的值,但事实上,我们的代码并不能在任何时候都能访问到任何我们想要的变量,如代码1-1所示,我们执行fn()函数定义了变量a=1,然后在外部打印变量a,发现并没有输出1,表明该打印语句并没有访问到之前所定义的变量a

function fn() {
  var a = 1;
}
fn();
console.log(a); //undefined

因此可以把作用域看作是容器+规则组合,容器有多个,将所有变量的标识符分门别类的保存下来;规则给定了JS引擎访问变量时的查找机制以及变量的保存机制。JS引擎执行代码需要访问某个变量时,就会询问作用域,作用域依据规则从容器中找到该变量并把变量的“地址”告诉JS引擎让它去访问获取。如果没有找到该变量,引擎则会抛出错误ReferenceError

2.函数作用域和块作用域

前面说到作用域是容器+规则的组合,且容器有多个,不同的容器生成不同的作用域,分为全局作用域、函数作用域块作用域

  • 全局作用域

由解释器创建,一个程序的所有代码都属于全局作用域

  • 函数作用域

函数作用域由函数声明生成,函数体内的代码属于一个作用域

  • 块作用域

块作用域由块包裹({})生成,块中的代码属于一个作用域

3.作用域嵌套与作用域链

前面提到所有编写的代码都属于全局作用域,全局作用域中的代码里又可以定义函数/块,因此全局作用域中又生成了函数/块作用域;而函数和块内部又可以定义函数和块,从而函数/块作用域内部又生成了新的函数/块作用域。如此循环往复,可以生成许多互相嵌套的作用域,而这些互相嵌套的作用域就构成了一条作用域链。

前面提到作用域看作是容器+规则组合,规则给定了JS引擎访问变量时的查找机制以及变量的保存机制,保存机制我们在下一节说明 ,而查找机制就和作用域链相关。

当代码访问某个变量时,会从当前所处的作用域中查找是否有该变量的标识符,如果没有,则去上层作用域(外层嵌套)中查找,如果还是没有,则逐级访问,直到全局作用域。如代码1-2所示,包含三个作用域,分别是全局作用于①,fn1的函数作用域②,fn2的函数作用域③,嵌套关系为①{ ②{ ③ } }。查找机制如下所示:

  1. fn1执行打印a时需要获取a的值,
    1. 先从作用域②中查找标识符a
    2. 没有,则去外层嵌套作用域①中查找标识符a,找到,则使用该值;
  1. 同理fn2执行打印a+b+c
    1. 先从作用域③中查找标识符a,
    2. 没有,则去外层嵌套作用域②中查找查找标识符a,
    3. 也没有,则再去其外层嵌套的外层嵌套的作用域①中查找,找到,则使用该值,
    4. 然后查找标识符b
    5. 先从作用域③中查找标识符b,
    6. 没有,则去外层嵌套作用域②中查找标识符b,找到,则使用该值,
    7. 最后查找标识符c,就在当前作用域当中,找到,使用改该值

let a = 10; //①
function fn1() {  //②
  console.log(a); // 20
  let b = 20;
  function fn2(c) {//③
    console.log(a + b + c); //60
  };
  fn2(30);
};
fn1(); 
//10
//60

4.作用域生成与销毁

作用域分为两类:1. 词法作用域 2. 动态作用域。JS采用的是词法作用域,当然this机制可以看做是动态作用域的亲戚。

预编译

词法作用域是在编译过程中生成的,JS虽然是一门解释性语言,但仍然会执行预编译,预编译分为全局预编译和局部预编译

  • 全局预编译发生在代码加载到JS引擎时执行,
  • 局部预编译发生在函数执行的前一刻

全局预编译分为三步

  1. 创建全局变量对象(Global Variable Object)
  2. 找到全局的变量声明,将变量名作为全局变量对象的属性名,值为undefined
  3. 找到全局的函数声明,作为全局变量对象的属性名,值赋予函数体

局部预编译有四部

  1. 创建活动对象(Activation Object)执行期上下文。
  2. 找形参和变量声明,将变量和形参名作为活动对象属性名,值为undefined
  3. 将实参值和形参统一。
  4. 在函数体里面找函数声明,值赋予函数体。

作用域与作用域链生成

下面以代码1-3详解整个作用域与作用域链生成的过程:

let a = 5;
let b = 10;
let fn1 = function() {
  let a1 = 15;
  let b1 = 20;
  let fn2 = function() {
    let a2 = 25;
    let b2 =30;
  } 
  fn2();
}
let fn3 = function() {
  let a3 = 35;
  let b3 = 40;
}
fn1();
fn3();
  1. 首先全局预编译,创建一个全局变量对象,这个全局变量对象可以认为是全局作用域
  2. 执行代码,创建一个全局执行上下文,推入上下文栈,当执行到fn1函数体定义时,为fn1函数对象生成一个内部的 [[scope]] 对象,这个函数对象是一个预装载的作用域链,包含了其外层作用域,即全局作用域的全局变量对象
  3. 当执行到fn3函数体定义时,为fn3函数对象也生成一个内部的 [[scope]] 对象,这个函数对象也是一个预装载的作用域链,包含了其外层作用域,即全局作用域的全局变量对象
  4. 执行函数fn1(),对其进行局部预编译,创建一个fn1函数执行上下文推入执行栈,生成一个活动对象AO_fn1活动对象AO_fn1为函数fn1的函数作用域,然后复制第二步生成的fn1()内部的 [[scope]] 对象, 并将活动对象AO_fn1推入复制的 [[scope]] 对象 的前端 由此生成函数fn1()作用域链并赋给fn1函数执行上下文

实际上 [[scope]] 对象内部是一连串的指针,指向对应的全局变量对象和活动对象

  1. 执行函数fn1(),执行到fn2函数体定义时,为fn2函数对象生成一个内部的 [[scope]] 对象,这个函数对象也是一个预装载的作用域链,包含了其外层作用域活动对象AO_fn1以及其外层作用域的外层作用域全局变量对象
  2. 执行函数fn2()前,对其进行局部预编译,创建一个fn2函数执行上下文推入执行栈,生成一个活动对象AO_fn2活动对象AO_fn2为函数fn2的函数作用域,然后复制第5步生成的fn2()内部的 [[scope]] 对象, 并将活动对象AO_fn2推入复制的 [[scope]] 对象 的前端 由此生成函数fn2()作用域链并赋给fn2函数执行上下文
  3. 执行fn2()
  4. 执行函数fn3(),对其进行局部预编译,创建一个fn3函数执行上下文推入执行栈,生成一个活动对象AO_fn3活动对象AO_fn3为函数fn3的函数作用域,然后复制第3步生成的fn3()内部的 [[scope]] 对象, 并将活动对象AO_fn3推入复制的 [[scope]] 对象 的前端 由此生成函数fn3()作用域链并赋给fn3函数执行上下文
  5. 执行fn3()

整个过程生成的作用域链如下图所示

最后需要注意的是, 无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

作用域销毁

函数执行完毕后,函数执行上下文会被弹出执行栈,则对其作用域链的引用也不再存在,作用域链被垃圾回收程序回收,则其作用域链对其活动对象的引用也不存在,则活动对象被垃圾回收程序回收,因此作用域被销毁。

注意,存在一种特殊情况作用域不会被销毁,即闭包,此时其活动对象会被另一个 [[scope]] 对象给引用(注意是内部的 [[scope]] 对象,不是复制的,只有内部的 [[scope]] 对象才会一直存在在函数体定义时的对象中)

作用域改变

JS中是词法作用域,也就是说,作用域在块/函数定义时就已经决定了,但也有一些可以修改词法作用域的情况,即使用eval()with

  • eval()

eval()函数可以接受一个字符串作为参数,并将调用该函数的位置将该字符串当做就好像写在这里的代码来执行,并且虽然这些代码是在eval()函数中被执行的,但这些代码表现起来与eval() 函数的外部作用域是同一个作用域。如代码1-4所示,在eval函数中执行定义的变量b,就好像在foo函数中定义,可以直接被foo函数作用域的代码使用。

严格模式 ,不会 再有上述的表现,此时打印a会报ReferenceError错误

function foo(str, a) {
  eval( str ); // 欺骗!
  console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

  • with语句

with语句可以直接把一个对象设定为一段代码的作用域,针对的场景是要反复操作某个对象时,可以不写对象名,直接写对象属性。如代码1-5所示,上半部分的代码可以替换为下半部分的代码。

严格模式不允许使用 with 语句,否则会抛出错误 ;

需要注意的是,定义的qs只会在with包括的块作用域中(使用let关键字),qs并不会作为location对象的属性,

let qs = location.search.substring(1); 
let hostName = location.hostname; 
let url = location.href;
//**************************
with(location) { 
 let qs = search.substring(1); 
 let hostName = hostname; 
 let url = href; 
}

eval()和with都会严重影响性能,慎用,因为JS引擎会在编译阶段做优化,而这两个是动态变化的,JS无法做优化

5.闭包

闭包概念

前面有说到一个作用域(活动对象)不会被销毁的情况,这是由[[scope]]对象引起的,这种现象也被称为闭包。闭包指的是在函数体定义时,该函数引用了另一个函数作用域中或者说它的外层函数作用域。如代码1-6所示,fn2函数就是一个闭包,该函数的[[scope]]对象引用了fn1函数的活动对象(函数作用域),因此该函数能访问到fn1中定义的变量a

let a = 5;
function fn1() {
  let a = 10;
  function fn2() {
    console.log(a);
  };
  return fn2;
};
fn = fn1();
fn(); // 10

整个执行过程如下图所示,可以看到,第二步执行fn1函数时定义fn2函数体并返回给变量fnfn2函数被fn引用,没有被回收,因此fn2函数对象中的[[scope]]对象也存在,即使fn1函数执行完毕,由于函数fn1的作用域AO_fn1fn2的[[scope]]引用,所以没有被回收。

由此也可见,如果想让AO_fn1被回收,则取消fn2的[[scope]]对它的引用,而[[scope]]被保存在fn2(现在被赋给了fn)的函数体对象,因此,fn=null即可。


当然,不要忘记,块作用域也是一个局部作用域,同样能产生闭包的效果,如代码1-7所示

let fn;
{
  let a = 1;
  fn = function() {
    a++;
    console.log(a);
  };
};
fn(); //2
fn(); //3

定时器输出问题

如代码1-8所示, 我们设定五个定时器,期望每隔1秒分别打印1到5,但事实上最终会全部打印6。

其原因与作用域相关,因为var定义的i不受块作用域限制,所以i属于全局作用域,且只有这一个变量i。虽然5次循环创建了5个回调函数,但这5个回调函数实际引用的都是全局作用域中的同一个变量i。同步代码执行完毕,全局作用域中的i变为6,随后执行异步回调timer(),在当前作用域没有查找到i,然后去上层即全局作用于中查找到i=6并打印。

for (var i=1; i<=5; i++) {
  setTimeout(function timer() {
  console.log(i);
  }, i*1000 );
}
// 6
// 6
// 6
// 6
// 6

为解决上述问题,如果不适用let关键字,我们可以使用立即执行函数(Immediately-invoked function expression, IIFE)形成闭包来实现,如代码1-9所示,匿名的立即执行函数形成一个作用域,并将形参i作为了该作用域中的一个变量,且形参i保存了即时的实参传入的i值,此时匿名的立即执行函数内的timer函数形成一个闭包,因此当它执行时会先查找匿名的立即执行函数的作用域,查找到i,直接打印。

for (var i=1; i<=5; i++) {
  (function(i){
    setTimeout( function timer() {
    console.log(i);
    }, i*1000 );
  })(i);
}
// 1
// 2
// 3
// 4
// 5

6.提升

提升(hoist)指的是:在编译器的预编译阶段,会将所有的变量和函数声明提升到当前作用域的代码最开头预先执行,变量声明被赋值为undefined,函数声明被赋值为函数体(如代码1-8所示),且函数声明的优先级更高,即变量声明被函数声明覆盖(如代码1-9所示)

需要注意的是,提升仅限于var关键字声明的变量,虽然let关键字声明在预编译阶段也能被发现,但在真正被声明之间不能被引用,这称为暂时性死区。

console.log(foo()); // 2
console.log(a); //undefined
var a = 1;  //仅限于var关键字
function foo() {
  return 2;
}
var foo;
//没有报错,表明变量声明提升且被函数声明覆盖
console.log(foo()); // 2
function foo() {
  return 2;
}

1.7动态作用域

JS使用的是词法作用域, 词法作用域最重要的特征是它的定义过程发生在代码的书写阶段,而动态作用域是在运行时被动态确定的, 动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调 用。 假如JS是动态作用域,如下图所示,foo()在bar()函数中被调用,就会打印bar()函数中的a(如果是词法作用域,就会打印全局作用域的a

需要明确的是, JavaScript 并不具有动态作用域。它只有词法作用域,简单明了。 但是 this 机制某种程度上很像动态作用域, 因为this就是在调用时被确定的(箭头函数除外)。

参考资料:

blog.csdn.net/qq_44771388…

www.zhihu.com/question/36…

《你不知道的JavaScript》

《JavaScript高级程序设计》