[JS]深入作用域与闭包

484 阅读8分钟

最近想重新系统地整理一下前端的知识,因此写了这个专栏。我会尽量写一些业务相关的小技巧和前端知识中的重点内容,核心思想。

前言

执行环境,作用域这些都是javascript执行的机制,也许我们在写代码时不会关注这个东西,但我相信大家都知道这些东西的存在。也一定有人为了找工作背过这些概念,工作一段时间后又忘了。本文尝试用我自己的理解,再对这些概念作描述,希望可以对大家的理解有所帮助。

执行环境 exection context

首先,执行环境(exection context)也就是我们说的【执行上下文】,指一段合法JS代码在执行时,机制会产生一个空间,供这些代码的运行。

举个例子:

var f = function(){
	console.log('hello world');
};
f();

我们都知道js是逐行运行的,在上述例子中1-3行做的事是在声明变量f,创建function存在【堆】中,把function的地址赋值给f。执行到第四行的时候,虽然function已经在堆中有数据了,但执行时实际上是会又创建一个执行上下文的(EC)。它的内容就是function的代码,然后在这个上下文中执行这些代码。

执行环境类型

在JS中一共有3种执行环境:

  • 全局执行环境
  • 函数执行环境
  • Eval执行环境

作用域 Scope

也许有的人会把执行环境和作用域混淆,他们确实很像。但准确来说,作用域(Scope)是运行时代码中的某些特定部分中变量,函数和对象的可访问性。也就是说作用域就是一个执行上下文中的一个独立的空间,他保存着当前上下文的变量,并确保变量不会外泄、暴露出去。

举个例子:

var fun1 = function(){
  var a = 1;console.log(a)
};
var fun2 = function(){
  var a=2;console.log(a)
};

fun1(); // 1
fun2(); // 2

在上述例子中fun1和fun2中的a是在他们各自的作用域下的,因此输出的内容不同。

作用域类型

作用域类型有:

  • 全局作用域
  • 函数作用域
  • ES6之后let,const为我们带来了块级作用域

块作用域 Block scope

ES6之后我们有了let和const,他们最大的好处就是为js带来了块级作用域。它指的是在代码执行到大括号{}内之后,(如if,switch,for之类的命令)在括号内部用let或者const声明变量,这个变量会被锁定在这个括号内。在外部是访问不到的,即使是在同一个作用域。

var name = 'john';

if(true){
  	let name = 'tony';
  	console.log(name); // tony
  	name = 'tim';
}

console.log(name) // john

上述例子中,if条件括号内我用了一个let声明变量name,因此后续的console.log和重新赋值,修改的都是括号内的name。外部的name没有被影响。这个括号内部就是【块作用域】。

可是它的实现是怎样的呢?我们前面说的是一个上下文中会有一个作用域,显然块级作用域的变量不可能存在这个作用域中,同时括号内的内容又不足以开辟一个新的上下文。原来JS的做法是在当前上下文创建一个新的"作用域"。同时,严格来说一般的作用域可以叫做'变量环境',而存放块级作用域的可以叫‘词法环境’。大致如下:

这样代码在访问变量时,如果处于块作用域中,会先访问对应的词法环境,如果找到不到,再访问变量环境。

同时,如果一个上下文中有多个块作用域。词法环境中会再分出一个区域,让块级作用域之间的变量不会污染。大致如下图:

作用域链 Scope Chain

作用域链是js在访问变量时运行的一套规律。

var name = 'john';
function fun(){
		console.log(name);
}
fun(); // john

我们前面有说到作用域分为全局作用域和函数作用域,这里的例子中,显然刚好存在这2个作用域。在fun函数内部并没有name这个变量,可是却能正常打印出内容,同时打印的内容是全局作用域的name变量。说明fun函数是知道自己的上层作用域的,在访问name变量的时候,实际上找到上一层的全局作用域去了。

原来EC在创建的时候是会记录自己的上层EC的——outer,在访问变量的时候,会顺着outer往外找。大致如下图:

由此我们找到一条规律:js在访问变量的时候,会在当前作用域先找,如果找不到就往上一层找,如果找到了就用最近的变量。如果一直找到全局都没有找到找到则报错'not defined';

而这个在作用域直接访问变量的路径就是【作用域链】。

闭包!

相信在了解了上述内容之后,大家已经知道js的一部分运行机制了。不仅如此,其实我们已经可以解释闭包这个出镜率极高的问题了。

js垃圾回收机制

为什么要提到js的垃圾回收机制?因为他是我们产生闭包的一个逻辑。js很强大的自带了一套回收没用变量的机制,可以让我们不用手动跟踪变量的去向。文本不打算详细展开这部分,大家只要知道,js本身会记录变量的引用关系,当一个变量不再被其他内容引用时,js就会把他回收

什么是闭包?

在此之前,我们先来看看究竟什么是闭包。

function fun1(){
	var name =  'john';
  var age = 18;
  return {
  	setAge:function (newAge){
    	age = newAge;
  	},
    getAge:function(){
    	console.log(name+' is '+age+' now');
    }
  }
}

var john = fun1();

john.getAge();  // john is 18 now
john.setAge(19);
john.getAge();  // john is 19 now

上述代码中,我们只要每次执行setAge和getAge函数,就可以读写fun1中的name和age变量。同时这2个函数也成了这2个变量的唯一访问方式。我们来总结一下:在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

像这个例子中name和age就是fun1的闭包。

图解闭包

为了帮助大家的理解,我们再来看图。

  • 在fun1执行的时候,js创建了对应的上下文EC fun1,
  • 声明变量age,name并存放在变量环境中。
  • 创建对象O地址为xxx,同时创建函数setAge和getAge把地址记录
  • 把对象O的地址返回。
  • 全局执行环境中的变量john执行fun1之后赋值得到了对象O的地址。
  • 此时由于对象O中的函数引用着变量name,age,因此本应被回收的EC fun1却保留了。
  • 此后对象O的2个方法可以顺着作用域链访问name和age。

正常每次js执行完代码之后,就会把上下文销毁,这样作用域内的变量就被回收了。可是如果这个作用域中的变量还被引用着,js就无法完全销毁这个EC,这EC便成了闭包。值得注意的是,EC并不是完全真个保留下来的。而是只保留被引用了的变量,变成了闭包(Closure).在浏览器中debugger可以看到:

闭包优缺点

这个可能已经大家都很熟悉了,这里既然说到就顺道提一下。

优点:

  • 防止变量污染,控制变量的访问途径。
  • 在某些业务场景中,可以在内存中维护变量并缓存,提高执行效率。

缺点:

  • 消耗内存:通常来说,函数的活动对象会随着上下文环境一起被销毁,但是由于闭包引用的是外部函数的活动对象,因此这个活动对象无法被销毁,因为闭包比一般函数消耗更多内存。
  • 内存泄漏风险:由于闭包内的变量访问途径受控,很多时侯可能会被忽视,假如程序逻辑不当可能会出现一直新增闭包,导致内存泄漏的风险。

总结

本文尝试从笔者的角度跟大家深入了解作用域,作用域链的关系,以及由此机制导致的闭包问题。如果有不当的地方,欢迎大家指出。如果你正为这些概念感到困惑,希望这篇文章可以帮到你。

参考

juejin.cn/post/684490…

zhuanlan.zhihu.com/p/43462607

《浏览器工作原理和实践》——李兵