最近想重新系统地整理一下前端的知识,因此写了这个专栏。我会尽量写一些业务相关的小技巧和前端知识中的重点内容,核心思想。
前言
执行环境,作用域这些都是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可以看到:
闭包优缺点
这个可能已经大家都很熟悉了,这里既然说到就顺道提一下。
优点:
- 防止变量污染,控制变量的访问途径。
- 在某些业务场景中,可以在内存中维护变量并缓存,提高执行效率。
缺点:
- 消耗内存:通常来说,函数的活动对象会随着上下文环境一起被销毁,但是由于闭包引用的是外部函数的活动对象,因此这个活动对象无法被销毁,因为闭包比一般函数消耗更多内存。
- 内存泄漏风险:由于闭包内的变量访问途径受控,很多时侯可能会被忽视,假如程序逻辑不当可能会出现一直新增闭包,导致内存泄漏的风险。
总结
本文尝试从笔者的角度跟大家深入了解作用域,作用域链的关系,以及由此机制导致的闭包问题。如果有不当的地方,欢迎大家指出。如果你正为这些概念感到困惑,希望这篇文章可以帮到你。
参考
《浏览器工作原理和实践》——李兵