作用域 & 词法作用域
-
什么是作用域
几乎在我们学习每一门语言时都会涉及作用域这个概念。那究竟什么是作用域呢?
几乎所有编程语言最基本的功能之一就是能够储存变量当中的值,并且能在之后对这个值进行访问或修改。 但是将变量引入程序会引起几个很有意思的问题,这些变量住在哪里?,换句话说,它们储存在哪里?,最重要的是,程序需要时如何找到它们?[1]
这些问题正需要一套设计良好的规则来储存变量,且之后可以方便快捷地找到这些变量。这套规则就是作用域。
事实上当我们写下
var a = 2
时,在JavaScript程序中经历了如下旅程:- 编译器会问询作用域是否已经有一个名为
a
的变量存在于同一个作用域集合中?- 如果是,编译器会忽略这个
var
声明,继续编译。 - 如果否,编译器会要求作用域在当前作用域集合中声明一个新的变量
a
。
- 如果是,编译器会忽略这个
- 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理
a = 2
这个赋值行为。 - 运行时,引擎也会进行问询作用域在当前的作用域集合中是否存在变量
a
?- 如果是,就会将
2
赋值给a
。 - 如果否,且引擎最终也没有查找到变量
a
,就会抛出异常。
- 如果是,就会将
⚠️这里有一项非常重要的区分⚠️
即在引擎运行时的查询方式。
编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量a来判断它是否已声明过。查找的过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查找结果。
查询方式分为
LHS
和RHS
两种,这里简单的区分一下两种查询方式的区别:- 如果查询的目的是对变量进行赋值,那么会使用
LHS
查询,eg.a = 2
- 如果目的是获取变量的值,那么会使用
RHS
查询,eg.console.log(a)
- 编译器会问询作用域是否已经有一个名为
-
作用域嵌套
在JavaScript中,作用域是可以嵌套的。
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。引擎执行的所有查询都会在当前作用域中开始,如果没有在当前作用域中找到需要的标识符,就会向上一级的作用域继续查找目标标识符,直到抵达全局作用域,这时无论是否找到目标变量的标识符都会停止。
-
词法作用域
词法作用域是JavaScript中所使用的作用域类型,也被叫做静态作用域。顾名思义,词法作用域意味着作用域是由书写代码时函数声明的位置来决定的,和它的执行位置没有关系。
⚠️这里可以简单的延伸到一个概念⚠️
我们阅读各类文档时都会看到的 执行上下文 到底是什么?我的理解下,我们谈论作用域嵌套、甚至作用域时,常常使用的是文字描述上的定义,这是我们能“读懂”的语言,具有普适性,因为每一种语言都有作用域。而在JavaScript中,它真正能够“读懂”的就是执行上下文,这是抽象出来的代码执行环境,它们往往由三部分组成:
-
变量对象(VO)
每个执行环境都有一个与之关联的变量对象,环境中声明的所有变量和函数都保存在这个对象中。变量对象在函数执行上下文中被称为活动对象(AO),生成于执行上下文创建的阶段。我们在上面举例的
var a
就属于声明了一个储存在变量对象中的变量a
。 -
作用域链
当代码在一个环境中执行时,会创建变量对象的一个作用域链。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。
即我们在上一小节所描述的作用域嵌套规则。
-
this(它值得单独一篇文章,指路👉🏻《关于JavaScript this》)
更多关于执行上下文的细节在这里就不多赘述了,笔者后续也会再写一篇执行上下文之我见,这里只浅显地写了些自己的理解,如有错误还请包含指正。
-
-
变量提升
联系第一小节中我们提到的
var a = 2
处理流程,我们不难发现JavaScript引擎将其视作var a
与a = 2
两个单独的任务。这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为 提升 。
而在JavaScript中,函数是当之无愧的“一等公民”。所以在提升中,函数会被优先提升,然后才是变量。
函数作用域
-
函数的作用域
函数是JavaScript中最常见的作用域单元,但不是唯一的作用域单元。声明在一个函数内部的变量或函数会在所处的作用域中“隐藏🫥”起来,外部作用域无法访问“包装在”函数内部的任何内容,这是有意而为之的良好设计原则。
在ES6中引入的
let
关键字可以用来在任意代码块中声明变量,并隐式劫持了所在的作用域。但是使用let
进行的声明不会在块作用域中进行提升。声明的代码在运行之前,并不“存在”。
闭包
-
究竟什么是闭包?
现在看到了这里,那么究竟什么是闭包呢?
确实众说纷纭。这里我引用一本书中的阐释:
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。[2]
闭包好像是JavaScript世界中的一朵巨大疑云,但其实如果了解了JavaScript的作用域工作原理,就会发现闭包是一件普通的、甚至随处可见的“自然结果”,是的,它可能只是JavaScript作用域的副产品。
-
看,是闭包!
如果用纯文字来描述闭包,一定是苍白无力的。
1 2 3 4 5 6
function foo(){ let str = '明天吃海鲜炒饭!' function bar(){ console.log(str) } }
我们暂时不需要执行函数
foo()
,仅仅依赖我们之前学到的函数作用域知识,bar()
引用了一个在“它的身体里”找不到的变量str
,于是按照规则,它会向上一级作用域(也就是foo()
的作用域)查找这个变量str
。好在它找到了!
1 2 3 4 5 6 7 8 9 10
function foo(){ let str = '明天吃海鲜炒饭!' function bar(){ console.log(str) } bar() //执行函数才能得到结果 } foo() //执行函数才能得到结果 //明天吃海鲜炒饭!
这是闭包吗?从我个人的角度来说,是的。但是在我阅读的书籍《你不知道的JavaScript·上》中给出了一个更严谨的说法,是也不是。
究其原因是作者认为这种处理方式完全可以使用作用域链来解释,虽然这是闭包非常重要的一部分,但是并不能完全的体现出闭包的全貌供给我们观察。
1 2 3 4 5 6 7 8 9 10 11
function foo(){ let str = '明天吃海鲜炒饭!' function bar(){ console.log(str) } return bar //返回bar函数 } const anoFoo = foo() anoFoo()//相当于执行了bar(),得到结果 明天吃海鲜炒饭!
foo()
执行后,将其返回值(也就是内部的bar
函数)赋值给常量anoFoo
并调用anoFoo()
, 实际上只是通过不同的标识符引用调用了内部的函数bar()
。在
foo()
被执行后,通常会期待foo()
的整个内部作用域被销毁。但是因为bar()
正在有意无意地被间接使用着,所以整个作用域并不会回收。它覆盖着整个foo()
函数内部作用域的闭包,以便随后在任何时间引用。bar()
依然持有对foo()
作用域的引用,这个引用就叫做闭包。而书中这种制造新的变量/常量来间接引用
foo()
内部函数的方法,也只是为了证明闭包这个“幽灵”无论被怎么间接地引用都会产生:无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。 -
闭包可以用来做什么?
闭包在许多地方都有应用,它的最大用处莫非两个:
- 访问函数内部变量
- 将变量保存在内存中
访问函数内部变量
正如我们在前面函数作用域的内容中提到的那样,我们无法从函数外部访问内部,而这种做法是处于安全的考虑,并不想把所有的变量都暴露在外部(往往它们只会暴露出可供使用的API)。
考虑下面代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
function CoolModule(){ let something = " cool "; let another = [ 1,2,3 ]; function doSomething(){ console.log( something ); } function doAnother(){ console.log( another.join("!") ); } return { doSomething: doSomething, doAnother: doAnother }; } let foo = CoolModule(); foo.doSomething(); // cool foo.doAnother(); // 1 ! 2 ! 3
这种类似的模式在JavaScript中被称为 模块 。首先
CoolModule()
只是一个函数,必须要通过调用它来创建一个模块实例,如果不执行外部函数,那么内部作用域和闭包都无法创建。其次CoolModule()
返回一个{ key : value, ... }
对象。这个返回的对象中含有对内部函数(而非内部变量)的引用,保持了内部变量是隐藏且私有的状态。我们可以将这个对象类型的返回值看作是 模块公共的API 。严苛来讲,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。
将变量保存在内存中
同样考虑如下代码
1 2 3 4 5 6 7 8
function add(){ let n = 1 return n+=1 } add(); // 2 add(); // 2 add(); // 2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
function add(){ let n = 1 function counter(){ return n+=1 } return counter } let Add = add() Add(); // 2 Add(); // 3 Add(); // 4
我们都知道JavaScript提供自动垃圾回收机制,即每隔一段时间会查看是否有需要回收的变量,判别依据就是该变量是否存在于当前执行上下文中以及是否在其他地方被引用。而JS函数执行完成后,作用域就会被清理,内存也随之回收,而在第二段代码中,我们可以看到
n
并没有被回收。这是因为第二段代码中
let Add = add()
实际上就是将add()
执行后的返回值函数counter
赋给了全局变量Add
,全局变量只会在程序退出或网页关闭时被回收,而函数counter
又引用了函数add
中的变量n
,所以函数add
的上下文就不会随着调用结束而回收。⚠️但是如果直接执行
add()()
仍旧会得到与第一段代码相同的结果:1 2 3
add()(); // 2 add()(); // 2 add()(); // 2
这是因为
add()()
即执行counter()
,函数执行完成后作用域与内存会被回收,再一次执行add()()
是重新调用函数add
与内部的counter
。这说明声明一个变量let Add = add()
来承载是必须的。 -
闭包的注意事项⚠️
- 由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。
- 闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。
即 不要滥用,不要随意修改👍
文章参考自🥰