写在前面
在JS中,作用域(Scope)的知识一直是抽象并且难以理解的,笔者是一名刚刚毕业的前端小白,在学习作用域与作用域链的过程中也是饱受折磨,本文将尽可能地用通俗的话来解释作用域与作用域链,希望对在作用域中苦苦挣扎的小伙伴有所帮助,如果有理解错误或者不到位的地方也请指出,不胜感激~(图中未注明出处的图片均来源网络,侵删)
1. 编译原理
这篇文章明明是要讲作用域,为什么要先讲编译原理呢?相信我,理解了编译原理对于理解作用域有着莫大的帮助。
在传统的编译语言中,程序执行代码一般分为以下三个阶段:
- 分词/词法分析
- 解析/语法分析
- 代码生成
词法分析
或者叫分词阶段,首先你得明白,浏览器是无法读懂你的代码的,它能读懂的只有二进制码,因此它只能将你的代码拆分。 举个栗子:
var a = 2;
浏览器是怎么执行的呢?浏览器会先将这段代码分解为一个个的词法单元:[var、a、=、2、;]。
语法分析
语法分析的过程就是将词法单元流(也就是上一步最后得出的数组)转换成一个由元素逐级嵌套所组成的代表了程序语法解构的树,这个树被称为“抽象语法树(AST,Abstract Syntax Tree)”。(人话???)别慌,这里给大家推荐一个网站Jointjs,如图(我自己截的):
代码生成
这里我们不讲具体细节(我不会),简单的说就是将“var a = 2;”这行代码的AST转化为机器能运行的机器码。如图就是chrome的v8工作原理图:
感兴趣的朋友们可以去看看李兵老师的《浏览器工作原理与实践》
2. 什么是作用域
作用域是变量与函数的可访问范围,或者说限制其可用范围,作用域决定了代码区块中变量和其他资源的可见性,作用域的使用可有效提高程序逻辑的局部性,增强程序的可靠性,减少名字冲突。(人话???)
有没有一种每一个字都认识,但是连在一起就看不懂了...
举个栗子:
function fn (){
var b = 0
console.log(b) // 0
}
fn()
console.log(b) //b is not defined
从上面的例子可以体会到作用域的基本概念,变量b是在函数fn中的一个变量, 并没有在全局作用域中声明, 因此只能在fn的函数作用域中引用, 在全局作用域中引用则会报错。
换句话说: b住在fn小区, 如果你在fn小区里大喊一声:"b你给我出来!", 嗓门大一点他应该就听到然后出来了, 但是你在小区外面喊的话那就会有好心的路人来告诉你这儿没这个人 (error!)
在es6之前js中没有块级作用域的概念, 只有全局作用域和函数作用域, 而es6中新增的let和const则为我们了提供了"块级作用域"的概念。
1. 全局作用域和函数作用域
全局作用域顾名思义, 拥有全局作用域的对象可以从任何地方调用到, 可以理解为一个老好人, 你找他他就来了。
举个栗子:
var a1 = 1; // 老好人1号
function fn1 () {
a2 = 2; // 老好人2号
var b1 = 11 // 我不是老好人
function fn2 () {
window.a3 = 3 // 老好人3号
function fn3 () {
console.log(b1) // 11
}
fn3()
}
fn2()
}
fn1()
fn2() // fn2 is not defined
console.log(a1) // 1
console.log(a2) // 2
console.log(a3) // 3
console.log(b1) //b1 is not defined
在这个例子中的a1,a2,a3都可以在全局中访问到, 即使a2 a3 分别定义在了函数fn1 fn2中, 这也是定义全局变量的三种方法, 不过还是建议小伙伴们在编程的过程中尽量少定义全局变量, 因为在我们的项目中尤其是大型项目, 我们要定义的变量太多了, 全局变量太多的话就很容易产生变量名称冲突, 污染命名空间。
准确的说老好人2号并不是一个全局变量, 而是一个全局变量的属性, 他并不是一个声明操作, 而是一个赋值操作, 当执行到a2 = 2 时, 他会尝试在函数fn1中查找a2, 没找到则会一直沿着作用域链向上查找, 直到全局作用域, 全局作用域中依然没找到则会创建一个a2属性并赋值为2。如果要详细讲其中的区别的话所需要的篇幅太大了,感兴趣的小伙伴可以自行去了解。(等笔者理解透彻之后或许也会单独开一篇文章解释)
值得一提的是:即使在全局作用域中用 let 和 const 定义变量,它仍然不是全局变量,这里我们用chrome自带的调试功能展示一下:
var a = 1
let b = 2
console.log(window.a,window.b) // 1, undefind
如图(也是我自己截的):
说回到前面的例子中, 函数作用域是指声明在函数内部, 在函数内部才能访问到, 如b1和fn2, 他们在全局作用域中都无法访问到, 但是在 fn3 内部却可以查找到 b1,这是要查找一个变量的话, 是从函数内部向外查找的, 如例子中的 'console.log(b1)' 的过程, 查找过程为: fn3 -> fn2 -> fn1,这也就是我们后面要聊到的作用域链了。
2.块级作用域
在es6之前是没有块级作用域这个概念的, 随着 es6 新增的 let 和 const 才引入了这个概念, 我们先简单的说一下let与const的区别, let声明的是一个变量, 而const声明的是一个不可变的常量(初步了解一下,本节例子中只采取let 和var进行比较)。
块级作用域可以在以下几种情况下创建:
- 函数内部
- { }内部就是一个块级作用域
- for循环的条件中
我们先通过一个简单的例子来看一下 var 与 let 的区别:
{
var a = 1
let b = 2
}
console.log(a) // 1
console.log(b) // b is not defined
可以看出, var直接无视了{ }的限制, 从全局作用域中仍然可以调用, 而 let 所声明的 b 则很乖巧的待在了 { } 中。因为 var 没有块级作用域的概念, 它根本不认识 { }, 它唯一能理解的 { } 只有出现在函数声明中, 这里要注意: 很多小伙伴都知道var没有块级作用域的概念, 但是忽略了var是有函数作用域的概念这一点 , 我们对上面的例子进行一下小修改:
function fn () {
var a = 1
}
console.log(a) // a is not defined
可以看到这样声明的话 var 还是被{ }老老实实的包了起来。
再来聊一聊for循环中的作用域, 老规矩, 举个栗子:
for(var i=0;i<5;i++){
setTimeout(() => console.log(i),500)
}
猜猜看输出结果是什么, 0 1 2 3 4 ? 天真! var是没有块级作用域的 , 当500毫秒之后这个 for 循环早就执行结束了, 因此在输出的时候所查找到的都是同一个结束了所有循环的 i ,最终输出结果是 5 5 5 5 5。我们再来看看let:
for(let i=0;i<5;i++){
setTimeout(() => console.log(i),500)
}
这一段代码的输出结果才是大家所想的0 1 2 3 4, 因为没一次循环的 i 都会在一个独立的块级作用域中被输出, 而独立的块级作用域中的 i ,依然是那个循环的时候的 i。
3. 欺骗作用域
本节属于开阔视野,强烈不推荐使用(会导致性能下降)。
按照我们的理解,作用域在我们写代码的时候就已经决定了,那怎么会有“欺骗”这个说法呢?在JavaScript中有以下两种方法来实现:
eval
eval(...)函数接受一个字符串为参数,并将字符串中的内容视为书写在程序中的代码执行,换句话说,eval(...)所接受的参数将顶替eval(...),成为程序中将要执行的代码。 举个栗子:
function fn (a,str){
eval(str)
console.log(a,b) // 0 2
}
var b = 1
fn(0,'b=2')
其中字符串'b=2'在eval(str)处执行,因此输出了"0 , 2"而不是"0 , 1"。
with
with()则是一个可以用来“偷懒”的方式,他可以重复引用同一个对象中的多个属性,可以不重复引用对象本身。 举个栗子:
var obj = {
a: 1,
b: 2,
c: 3,
}
// 多次引用obj
obj.a = 11
obj.b = 22
obj.c = 33
// 使用with一次搞定
with (obj) {
a = 111
b = 222
c = 333
}
是不是觉得with也太方便了,但是!我们通过上面对全局作用域的理解之后,应该能够看出with中的a、b、c已经泄露到全局中了,它们是不使用var创建的全局变量(全局变量属性),没有发现的小伙伴要好好再看看前面讲的知识了。因为eval和with都会让我们程序的性能下降,在这里只是作为开阔视野,就不详细举例了,感兴趣的小伙伴可以自己去试一下~
3. 作用域链
其实在前面的文章中我们已经或多或少涉及到了作用域链的知识,我们先记住一句话作用域在代码书写时就已经决定了,与调用位置无关,本章讲对作用域链进行详细解读。
1. 自由变量
什么是作用域呢?简单的说就是在当前作用域中没有定义,需要向外查找的变量。 举个栗子:
var a = 0
function fn1 () {
console.log(a) // 0
}
function fn2 () {
var a = 1
function fn3 () {
fn1()
}
fn3()
}
fn2()
在这里fn1()中要输出的a就是自由变量,因为fn1()中并没有变量a,它需要沿着作用域向外查找,可能有小伙伴会说“它在fn3中被调用,向外查找到fn2,输出的应该是1呀。”我们再来看看本章开头的那句话:作用域在代码书写时就已经决定了,与调用位置无关。因此,即使是在fn3中调用fn1(),它的查找顺序是: fn1 -> 全局。因此它所查找到的也是全局作用域中的 a。
2. LHS和RHS
这一小节本来应该放在编译原理中讲的,但是想了想觉得还是放在作用域后面讲比较好。 先来了解一下概念:
LHS(Left Hand Search)的含义是对变量进行赋值或者写入内存,即操作的目标。
RHS(Right Hand Search)的含义是变量查找或从内存中读取,即操作源头。
对于没有了解过这方面知识的小伙伴们可能很难理解,其实非常简单,举个栗子:
var a = 2
在这行代码中,我们是对a进行操作,将它赋值为2,那么a就是操作目标,2就是操作源头,也就是说我们对a进行了LHS引用,对2进行了RHS引用。是不是非常简单,但是在我们的编码过程中,我们的操作往往不是一个“=”这么简单,但是这其实并不影响我们对于LHS和RHS的判断,我们可以将“=”想象成一个函数,不论函数的内容是什么 (它甚至可以什么都不做) 我们都是在对a进行LHS引用和对2进行RHS引用。
隐式引用
举个栗子:
function foo (a) {
console.log(a)
}
foo (2)
在这一段中一共有一次LHS引用和三次RHS引用,或许小伙伴们会想,这里明明之后foo(2)、和console.log(a)两次RHS引用啊,但是但是,我们可以预想到输出的结果是2,那么a是在什么时候被赋值为2了呢?答案就是在参数传递的过程中,函数foo()被调用,它的形参a被赋值为2,没有理解的小伙伴不用急,下面一段会对这个例子进行详细解读。
截取书中的两个段例子
下面这段来自于《你不知道的JavaScript(上卷)》,这本书对于作用域和this的讲解非常细致,强烈推荐小伙伴们去看看。
还是那个栗子:
function foo (a) {
console.log(a)
}
foo (2)
书中的解释:
function foo (a) {
console.log( a + b )
}
var b = 2
foo (2)
书中的解释:
4. 执行上下文
这个可以说是本文中最抽象最难懂的内容...从它的名字来看就很难懂,从我们小学的语文知识来看,这明明是一个动宾短语,但是它偏偏是个名词(???)。
那么什么是执行上下文呢?在JS引擎解析到可以执行的代码的时候,就会先做一些执行前的准备工作来预估JS的执行环境。这个概念就是执行上下文(Execution Context)。
1. 执行上下文的创建
执行上下文的创建有两个阶段:创建阶段和执行阶段:
执行阶段言简意赅,就是代码的执行过程,我们要详细讲的是创建阶段。
创建阶段发生的时间段是在代码执行前,在创建阶段中所创建的内容为:
- this值
- 词法环境组件
- 变量环境组件
1. this值
这就是我们所熟知的this绑定,在有call、apply、bind的时候根据参数决定,否则根据函数调用方式决定,此处篇幅有限就不做细讲。
2.词法环境组件
词法环境内部包含两个组件:
- 环境记录器:存储函数和变量声明的实际位置。
- 外部环境引用:可访问的父级词法环境。
由于全局环境是顶层作用域,因此全局环境的外部环境引用组件为null,而环境记录器也分为两种情况:
- 在全局环境中:环境记录器为对象环境记录器,用来定义出现在全局上下文中的变量与函数的关系。
- 在函数环境中:环境记录器为声明式环境记录器,用来存储函数、变量、参数。
简单来讲,在函数环境中环境记录器多存储了一个 arguement 对象以及 arguement 的个数。
3. 变量环境组件
变量环境组件和词法环境组件比较类似,他们的不同之处在于:
- let 和 const 声明的对象保存在词法环境组件中
- var 和 function 声明的对象保存在变量环境组件中
这一点在本文 2.1节 中其实有一个简单的例子。
2. 执行上下文栈
相信大部分小伙伴对栈还是有一点基础概念的,这里用一张图来解释一下:
var scope = 'global scope';
function first() {
var scope = 'first EC';
function second() {
var scope = 'second EC'
}
function third() {
var scope = 'third EC'
}
second()
third()
}
first()
在这个栗子中:
首先创建全局上下文,全局上下文入栈;
创建函数first上下文并压入栈中,执行函数first;
在first中先创建second上下文并压入栈中,执行函数second;
second执行完毕,second上下文出栈;
创建函数third上下文并压入栈中,执行函数third;
third执行完毕,third上下文出栈;
first执行完毕,first上下文出栈;
全局上下文出栈。
总结
这几天写作用域的知识越写越觉得作用域的内容是真的多,发现了很多知识盲区,也查了很多资料,发现要么讲的太过于模糊,要么就是极其深奥(我太菜了!),写这篇文章的时候分目录的贼伤脑筋,很难分清楚一些小知识属于哪一块内容,也就导致了写出来的东西有点乱,有不足的地方也请各位小伙伴们指出,如果对给位帮助的话也请点个赞呗~,不胜感激!