本篇博客来小结一下作用域相关的一些进阶知识点,分执行环境以及作用域、词法作用域和闭包三部分。
执行环境及作用域
执行环境有两种:全局和局部(函数),其中全局执行环境是最外围的一个执行环境。
执行环境决定了变量或函数有权访问的其他数据以及它们各自的行为。
每个函数都有自己的执行环境,当执行流进入一个函数时,函数的环境就会被推入一个环境栈中,而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。
每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。如果这个环境是函数,则将其活动对象作为变量对象,活动对象在最开始时只包含一个变量,即arguments对象(注:这个对象在全局环境中是不存在的)。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台用到它。
当代码在一个环境中执行时,会利用这些变量对象创建变量对象的一个作用域链,以保证对执行环境有权访问的所有变量和函数的有序访问。在作用域链的前端,始终都是当前执行环境的代码所在环境的变量对象。 作用域链中的下一个变量对象来自当前环境的外部环境(即包含函数的执行环境),而再下一个变量则来自下一个外部环境,直到延续到全局执行环境。全局执行环境的变量对象始终都是作用域链中的最后一个对象。
作用域链的本质就是一个指向变量对象的指针列表,它只引用但不实际包含变量对象。
处于内部的执行环境可以通过作用域链访问所有外部环境,但是外部环境不能访问内部环境中的任何变量和函数。标识符解析就是沿着作用域链一级一级地搜索标识符的过程,从作用域链的前端开始逐级向后回溯直到找到标识符。
因为有些语句可以在作用域链的前端临时增加一个变量对象,这类变量对象会在代码执行后被移除。
有两种语句会发生这种情况:
这里只提一下 with ,它会将指定的对象添加到作用域链中,下面会继续讲解。
词法作用域
上面讲了JavaScript的执行环境以及作用域,下面我们来更深入来分析一下作用域。
JavaScript 使用的是词法作用域,大部分语言都是使用词法作用域,只有少部分是使用动态作用域(非本文讨论内容)。
一般JavaScript的编译过程可以分为:
词法作用域指的是定义在词法阶段的作用域,即作用域是由你在写代码时将变量和块级作用域写在哪里来决定的。
无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。
所以函数的作用域链也是在一开始写下代码的时候就已经定下来了,而非后面的编译阶段才定下来的。
如果说词法作用域完全由写代码期间内容声明的位置来定义,那如何在运行的时候来欺骗(修改)词法作用域呢?
可以使用 eval 和 with 这两种机制。
详情看文档。
具体就是可以给 eval() 函数传入一个字符串参数,然后字符串的内容会被视作是在书写代码时就存在于 eval 这个位置的代码。
换句话说,就好像代码本来就是写在 eval 的位置上一样,以此修改词法作用域的环境。
如下,“var b = 3”这句代码会被当做本来就在eval位置那里一样来处理。
function foo(str, a) {
eval(str)
console.log(a, b);
}
var b = 2
foo("var b = 3", 1) //1,3
详情看文档。
with通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复应用对象本身。
有一个比较特别的地方在这里需要讲一讲。一般我们给某个普通对象添加属性可以这么写:
var obj = {}
obj.name = 'jenny'
console.log(obj); //{ name:'jenny'}
然后我们再看一段使用with的代码:
定义一个函数foo,给foo传入一个obj参数,函数内对obj的a属性进行修改。因为o1本身含有a属性,所以o1.a被顺利修改为2。
但是o2中没有a属性,它这里也并没有像普通对象那样子直接给o2增加一个a属性,而是让o2.a保持原来的undefined,并且最终还在全局作用域新增了一个a,值为2。
function foo(obj) {
with(obj) {
a = 2
}
}
var o1 = {
a: 3
}
var o2 = {
b: 3
}
foo(o1)
console.log(o1.a); //2
foo(o2)
console.log(o2.a); //undefined
console.log(a); //2 a变量泄漏到全局作用域
这是为什么呢?
为什么普通对象可以通过点运算符来新增属性,而这里用with来对对象本身不具有的属性进行操作,却不能达到“新增”的效果呢?
原来是它们本来就不是同一个层面的问题,前者算是一种语法,后者就涉及到了更深入的知识。
因为with会将一个对象处理为一个完全隔离的词法作用域,也就是说这个对象会临时变成一个作用域,因此这个对象的属性也会被处理为定义在这个作用域中的标识符。
所以上面这段代码对o2的操作可以理解为,先在with生成的o2作用域中查找a,发现该作用域中并无a;继续往外层的foo作用域去找,也没有;最后在全局作用域查找,而全局作用域也没有a,所以便在全局作用域中新声明一个a,然后把a赋值为2,而o2.a保持原来的 undefined。具体可以去了解一下LHS和RHS引用,这里的查找过程属于LHS引用。
由此可见,eval( ) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而 with 声明实际上是根据你传递给它的对象凭空创建了一个新的词法作用域。
这两种方式看似很灵活,但其实有着很大的缺点:性能消耗。
因为javascript引擎会在编译阶段进行数项的性能优化,其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
但如果使用了eval或with,引擎不能在词法分析阶段明确知道eval会接受到什么代码,也无法知道传递给with用来创建新词法作用域的对象的内容到底是什么,所以只能简单地假设自己关于标识符位置的判断都是无效的,或者干脆对这些代码完全不做任何优化。
总的来说,就是大量使用eval和with会让代码运行变慢。
闭包
一般当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域(全局执行环境的变量对象),但是闭包的情况却有所不同。
闭包是指有权访问另一个函数作用域中的变量的函数。
当函数可以记住并访问所在的词法作用域时就产生了闭包(即使函数是在当前词法作用域之外被执行)。
按正常来说函数执行完毕,它的局部活动对象会被销毁。但是因为闭包的存在,所以闭包本身的作用域链得以保留,即它的作用域链往上的(所有)活动对象并没有被销毁,而是保留了下来。不过注意,只是保留了闭包(所有)外部函数的活动对象,而并非保留闭包(所有)外部函数的作用域链,这是不同的概念。直到闭包也消失了,这些活动对象就会被销毁了。
另外,我曾经以为 “闭包算是扩充了作用域”,现在想想觉得这种说法不太恰当。因为闭包只是保留了对自身词法作用域的引用,而并非对其进行扩展。
面试会被问能否判断闭包或者说出闭包的多种形式,所以自己也要记住闭包不是只有 return 函数 这种形式的。
创建闭包最常见的形式就是在一个函数内部创建另一个函数并直接 return。
var F = function () {
var b = 'local'
var N = function () {
return b
}
return N
}
console.log(F()()); //local
将内部函数作为参数传出。
foo将内部函数baz作为参数,传入外部函数bar,所以在foo外部的bar中依然可以对foo内部的baz进行调用。
function bar(fn) {
fn()
}
function foo() {
var a = 2
function baz() {
console.log(a);
}
bar(baz)
}
foo() //2
当然,也可以将内部函数作为值传递。
var fn
function bar() {
fn()
}
function foo() {
var a = 2
function baz() {
console.log(a);
}
fn = baz
}
foo()
bar() //2
使用回调函数。
这里timer具有涵盖wait作用域的闭包。
当把函数作为第一级的值类型传递,就能看到闭包在这些函数中的应用。
所以,在定时器、事件监听器、Ajax请求等任务中,只要使用了回调函数,实际上就是在使用闭包。
function wait(msg) {
setTimeout(function timer() {
console.log(msg);
}, 1000)
}
wait('Hello,closure') //Hello,closure
常常会用IIFE来创建一个作用域,以建立一个私有作用域,然后配合上面的形式使用。
很经典的一个例子,实现每秒一次,分别输出数字1~5。
要实现这个功能,可以用let声明i,来创建块级作用域;也可以用立即执行函数来创建闭包;还可以用箭头函数。
这里用立即执行函数来创建了一个作用域,从而记录了每次迭代时的i值,以便setTimeout的回调函数使用。
for (var i = 1; i <= 5; i++) {
(function (j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000)
})(i)
}
由于闭包会携带它外部函数的作用域(本该执行完便销毁),因此会比其他函数占用更多的内存,过度使用闭包可能会导致内存占用过多,所以还是要慎用。