等了很久的作用域专题终于来了,最近好忙!sorry,立个flag,周更!
作用域专场~来咯
这里是目录哦
作用域的单独solo
提到作用域,如果是一些有语言经验的人,能够想到的作用域应该是可以在作用域之内读取该作用域内的变量对象等,在作用域之外,我们无法访问作用域的变量。当然这只是一些口头的表述。了解这些,其实是远远不够的,甚至是不准确的。所以这篇文章呢首先为我们介绍了什么是作用域,作用域是干什么的!其次介绍了js中的作用域!!
作用域比较合理的一套解释是将其定义为一套规则,这套规则用来管理引擎如何在当前作用域及其嵌套的子作用域根据变量名进行变量的查找。
词法作用域和动态作用域
作用域有两种主要的工作模型:词法作用域和动态作用域!
这里我们主要介绍词法作用域,因为我们的主角js作用域就是一种词法作用域的工作模型!
动态作用域仅仅用来做一个对比,以加深对词法作用域的认识!
词法(静态)作用域
词法阶段
词法作用域的名称来自于编译阶段的词法分析或者也叫词法检查!会将一条语句分成很多个单词,所以也叫分词!
简单来说,词法作用域是定义在词法阶段的作用域。
如果不懂,再换句话说,词法分析的作用域是在你写代码时就决定了!!作用域是由你这个变量写在哪里决定的!
它不是在执行在过程中决定的。所以代码写完不论你是否运行,作用域都已经确定了!!
作用域链
上例子:
我们现在假设最外层是一个作用域,每个函数可以创建一个作用域(事实上创建作用域不只这一种方式)
那么上图中一共有三个嵌套的作用域!
为了帮助理解,我们把作用域想像成一个家族,每一个作用域都是一个孩子,他有且仅有一个确定的父级作用域。也就是说每一个作用域都属于且仅属于一个确定的父级作用域,不可能同时属于两个作用域!!
作用域的结构有点类似于树的结构,如下图,每个作用域相当于一个节点,每个节点都代表了一个作用域,它有且仅有唯一的双亲节点。
不可能一个节点有两个或者两个以上的双亲。
同样,他的双亲节点也同样有且仅有一个双亲节点。
因此我们可以顺着节点的双亲节点一直向上延伸,就形成了一条唯一的确定的双亲链。
那么按照我们的代码示例,我们可以了画出我们的作用域树结构图。
作用域1往下连着作用域2,作用域2往下连着作用域3。
作用域的树结构或者说作用域的家族链由作用域的代码块写在哪里决定的,并且他们是逐级包含的。
在上文中我们也提到了,作用域规则用来管理引擎如何在当前作用域及其嵌套的子作用域根据变量名进行变量的查找。
既然要在作用域上查找变量名,那么反推变量名(标识符)一定是属于某个作用域的!确实如此!
那么再来看看之前的代码 :
当执行到foo的声明时,作用域会按照编译器给的声明的指示,在作用域1 中创建foo变量,并指向该函数!
随之会创建foo的作用域——作用域2。再作用域2中声明了参数a和变量b还有函数bar。
编译继续往下,又会创建bar的作用域——作用域3。在作用域3中声明了参数c。
也就是说:
作用域1,也就是全局作用域,因为他没有父级作用域了!!其中包含foo一个标识符!
作用域2包含a,b,bar三个标识符也就是变量。
作用域3包含c一个变量!
至于怎么查找变量也就是标识符,一个小诀窍:作用域中声明的变量名和函数名!
也就是var let function 等声明语句中的标识符,也就是名字。
所以到目前为止,我们知道了作用域可以根据唯一的父级作用域来形成一条链,这条链上每一个作用域都有属于自己当前作用域的标识符。
因此节点之间的结构和位置关系可以给引擎提供足够的位置信息,可以用这些信息查找标识符!
主要是沿着这样一个作用域链,也就是上文说的双亲链向上查找!
如果看过之前一二集的同学,应该以应有了一个大概的认识,作用域的作用在于接收编译器或者引擎的指令,进行创建变量或者查找变量。没看的去看看哦
查找规则
接下来还是以上述代码为例,介绍一下作用域是如何查找变量的!!
作用域1中:
当执行到foo(2)
时,作用域会在当前的作用域也就是作用域1中进行查找,找见了foo,于是调用该函数!
进入作用域2:
此时我们要将2赋值给a,于是,我们要查找变量a,我们在当前作用域也就是作用域2中找见了变量a,给其赋值2。
当执行到var b = a+1
时,此时我们同样查找变量a,发现其在当前作用域2中是2,执行加法指令,赋值给b,同样可以在作用域2中找到。
再调用bar(b*2)
进入作用域3
当执行到console.log(a,b,c)
时,我们需要查找a,b,c
在当前作用域查找a,发现没有,重头戏来了,这个时候,我们沿着作用域3(当前作用域)的双亲链,也就是作用域链向上查找,找到其父亲节点,也就是父级作用域,在本例中就是作用域2,我们发现作用域2中存在变量a,这个时候我们就找到了变量a。
同理b也一样。
然后就到了变量c,直接可以在当前作用域找到返回!
总结一下下:
作用域查找变量时,总是会首先在当前作用域中进行查找,如果找到则返回不再向上查找。如果找不到,会沿着其作用域链查找父级作用域中有没有该变量,如果在父级作用域也没有找到则继续沿着作用域链向上查找父级的父级,直到找到或者到达顶级作用域,也就是全局作用域。
使用官方的总结:
无论函数在那里被调用,也无论他如何被调用,他的词法作用域都由他声明时的位置决定。也就是他的作用域链由声明的位置决定。
作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者向上进行,直到遇见第一个匹配的标识符为止。
词法作用域意味着作用域是由书写代码时函数声明的位置决定的,编译的词法分析阶段基本可以知道全部的标识符在哪里声明的,因此可以知道从哪里进行查找
一些注意:
词法作用域只会查找一级标识符,如果是foo.a,那么词法作用域只负责查找foo而后边的a由对象属性访问规则接管!!
动态作用域
从上文中我们得知,词法作用域是一套关于引擎如何寻找变量以及在何处找到变量的规则。他最重要的特征就是他的定义过程发生在代码的书写阶段而非运行阶段。
动态作用域与之相反,它是在运行时动态确定作用域的,在代码书写时我们并不能判断作用域。
换句话说,动态作用域并没有关心你在何处声明,他们只关心你在何处如何调用的!他的作用域链是基于调用栈的!js中的this机制也是一种基于调用栈的动态模式。可以参考这篇文章~但是也不是动态作用域哦。
上一个动态作用域的例子:
function a(){
alert(m)
}
function b(){
var m = 3
a();
}
var m = 2;
b();//3
按照词法作用域,应该输出2,然而动态作用域输出3。因为a()的上一个调用栈是b因此会在b中寻找m。
因此动态作用域主打的是在何处调用。
而词法作用域关注的是在何处声明。
函数作用域和块级作用域
在上一节中我们介绍了如何在嵌套的作用域中进行查找变量,但是我们没有介绍什么情况下会产生一个作用域。
我们在上文中只是假设函数会生成一个作用域,其实是举了个例子。
尽管我们常常看到js是基于函数的作用域,事实上,不仅仅是函数可以创建作用域。
基于函数的作用域
本文词法作用域的介绍部分都是以函数的作用域为例进行的,不再赘述。
另外,也有一套说法帮助大家理解函数作用域:
他说函数作用域的含义就是,属于这个函数的全部变量都可以在函数范围内进行使用及复用,包括函数作用域内潜逃的作用域~
不知道大家有没有更加理解一些,其实我觉得之前已经解释的很清楚了,这个解释反而掩盖了作用域的嵌套以及查找规则。
对函数的认知
对大多数人而言,提起函数,我们就是声明一个函数,在函数体中写好逻辑,然后调用。
其实反过来想,函数也可以是把一段代码拿过来,把它进行一个包裹。用函数的方式将其封装起来~
这种封装其实可以理解为用一个作用域将其封装起来,使得内部的实现被隐藏。
有过编程实践的人都知道封装的好处就是模块化和复用,再有就是隐藏内部实现,对外暴露接口~
其实隐藏内部的实现就是一种封装,它遵循着最小授权原则和最小暴露原则。
在软件设计中我们常常有意无意的在使用这种原则。
尽可能少的暴露不必要的内容,尽可能的让别人通过提供的接口来访问和修改自身。
做到必要的数据私有化。
隐藏作用域中的变量或者函数还有一个好处:有效规避冲突!
如果不是作用域将内部的变量和函数封装起来,那么产生同名标识符的概率会大大增加且不利于调试。
现实中我们常常利用一些方式来规避冲突。例如在全局变量中声明一个独特的变量,作为一个命名空间,将该空间内的变量作为该对象的属性来使用。一般我们用到的库就是这样。另一种方式就是模块管理。选择一个模块管理工具,通过依赖管理器的机制将库的标识符显示的倒入另外一个特定的作用域。其实都利用了作用域的功能。
函数作用域
上文中,我们了解到:函数可以将一段代码包装起来并创建一个作用域。
这个作用域会将变量和函数隐藏起来,使得父级作用域无法访问当前作用域的内容。(具体实现,作用域的查找规则是必须沿着双亲链向上查找,无法向下查找,实现了数据隐藏)
但是随之而来的就有一个问题,我们必须通过创建函数和通过函数名调用函数的方式来达到隐藏,也就是下边注释标明的三行~
上代码 :
var a = 2
function foo(){ //这一行
var a = 3;
alert(a)
} //这一行
foo()//3//这一行
这时候就想了,如果我这段代码只在这一出运行,不需要封装多次调用,那我能不能有更简单的方式直接调用他。其次因为名字会污染所在的作用域,能不能不用名字直接创建作用域执行就好了。
javaScript 为我们提供了解决这两个问题的方案:
var a = 2
(function foo(){ //这一行
var a = 3;
alert(a)
})() //这一行
这种情况大家可能听说过,就是立即执行的函数表达式(IIFE)~
由于函数被包含在一对括号内部,因此成为了一个表达式。
通过在末尾再加上一个括号,可以立即执行这个函数,因此叫立即执行的函数,执行之后立即销毁。
显然立即执行的函数表达式的名字不是必须的,因为不存在调用的情况。
所以通常会省略函数名~
按照上边不匿名的做法,尽管foo名字还在,但是由于函数被处理成了表达式而不是函数声明,所以foo并不会污染当前作用域~
函数声明和函数表达式的区别是函数会被绑定在何处。
第一种实现foo绑定在当前作用域
第二种实现foo绑定在函数表达式自身的函数中而不是当前的作用域,它属于当前作用域的子作用域,所以当前作用域自然无法访问到子作用域的foo。
在本例中当前作用域是全局作用域。
其实上述函数表达式的代码还可以有另一种书写方式:
var a = 2
(function foo(){
var a = 3;
alert(a)
}()) //将函数的括号移在外层
这两种书写方式不影响功能,完全看自己的喜好。
此外其实还有一种常见的用法:传参。
这里的参数不排除函数哟。并且我们常常也会这样做~
var a = 2
(function foo(m){
alert(m)
}(a)) //将a传给参数m
这种传参的场景也会用来解决undefined值被错误的覆盖导致的异常。
var undefined = 2
(function foo(undefined){
alert(undefined)
}())
这样可以确保undefined的值不是2,而是真正的undefined。
补充:
对于函数是否匿名的情况,分为两种:函数声明不可以匿名,函数表达式可以~
匿名的函数表达式书写方便,但是也有一些缺点需要考虑:
1、匿名函数在栈追踪时没有一个具体的函数名,导致了调试困难
2、函数引用自身时,会出现问题。因为arguments.callee已经过期。
3、名字可以见名知义,但是没有名字会削弱代码的可读性和可理解性所以,始终给函数一个名字是一个比较好的实践方式。
基于块的作用域
上一章我们介绍了最常见的作用域单元,基于函数的作用域,其实作用域的产生不仅仅是函数可以,还有基于块的,也就是说在某些情况下可以生成一个块级作用域。
那么什么情况下就会生成块级作用域呢?
其实如果有其他语言经验的同学对这个回很熟悉~
介绍一下~
其实块级作用域的概念是ES6才专门提出来的。在ES6之前,虽然说也有可以生成块级作用域的办法,但是开发中并不常用。
例如:with关键字、try…catch…的catch内部都会创建块级作用域。
也就是说如果我们在ES6之前的作用域中使用块级作用域可以使用这两种办法。
其实Google维护的一个叫Traceur的项目。这个项目的目的是将ES6的代码转换成兼容ES6之前环境的代码 。
那他是怎么做的呢,像下面这样:
{
try(){
throw undefined;
}catch(a){
a = 2;
console.log(a)
}
}
console.log(a) //referenceError
这样就达到了可以在ES6及之前的环境中都可以使用了。
那么问题来了,为什么不用IIFE呢?原因很简单,其实IIFE毕竟是函数包裹,有些情况下也不适用呢!
但是总的来说,ES6之前都是函数作用域为主。
let
let是ES6中新增的声明变量的关键字。
在之前,我们声明变量只能用var。
补充:
var的声明方式会有一个变量提升的过程,会提升到当前函数作用域的最上层。
他会绑定到离他最近的函数作用域上
而let不会提升,它可以绑定自己的作用域,可以自己生成一个作用域进行绑定
let 可以为它声明的变量隐式的创建并绑定作用域~
但是我们推荐利用{…}包裹起来显式的创建作用域,避免代码混乱或者开发这无意识的修改代码~
最典型的例子:
let循环:
for(let a = 1;a<10;a++){
console.log(i)
}
console.log(i) //referenceError
这个例子看不懂也没关系,下文的闭包会重点讲。
const
除了let之外,ES6还引入了const ,const用来声明一个常量,他也可以创建块级作用域。
块级作用域和函数作用域可以根据自己的需要进行合理的选择。
欺骗词法
如果词法作用域完全由函数声明的位置来定义,那么在运行时如何修改(欺骗)此法作用域呢?
有一些方法在词法分析之后依然可以修改作用域,尽管他们也可以实现一些功能,但我们并不认为这是一个好的实践。
下面看两个欺骗词法的典型例子。
eval
js中,evel()函数可以接受一个字符串为参数。并将其中的字符串看作就像是写代码时就写在这里一样运行。
如果是写代码时就已经在这里,那他一定会经过词法作用阶段生成对应的词法作用域。
而这一段代码其实是在运行的时候才执行的。
所以说他是在运行的时候修改了词法作用域。
这样我们就理解他是怎么个欺骗法。
也就是说我们执行这个函数时,对于引擎而言,他也不知到这段代码是动态插入的,更不知到这个此法作用于被修改了。
他是和正常执行一段代码一样的。
上个例子:
function foo(str,a){
eval(str);
console.log(a,b);
}
var b = 2;
foo('var b = 3',1) //输出3,1 而不是 2,1
这段代码中,var b = 3 会被当作原本就在那里一样。所以输出时b不会去沿着作用域链再向上找,而是找到了作用域中执行evel函数的b。
在实际的使用上,eval经常被传入一串动态生成的代码来执行。
如果这串代码中,有对对象或者函数的声明,那么就会对该函数所处的作用于进行修改。
在js中,也有类似的功能的代码,比如setTimeout或者new Function 的某参数,都可以实现修改作用域,但这些都是要废弃的,不提倡的使用方法。
虽然在严格模式下,eval有自己的词法作用域,不会修改当前作用域,但是这种方式本身也不提倡。
with
如果说eval是在运行过程中修改了作用域,那么with就是在运行过程中新创建了作用域。
with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身,可以通过参数传入。
说起来晦涩,上代码:
var obj = {
a: 1,
b: 2,
c: 3
};
// 单调乏味的重复 "obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// with提供的简单的快捷方式
with (obj) {
a = 3;
b = 4;
c = 5;
}
但实际上这不仅仅是为了方便地访问对象属性。
让我们考虑以下代码:
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 被泄漏到全局作用域上了!
这个例子中创建了 o1 和 o2 两个对象。其中一个具有 a 属性,另外一个没有。
foo函数接受一个 obj 参数,该参数是一个对象引用,并对这个对象引用执行了 with(obj) {…}。
在 with 块内部,我们写的代码看起来只是对变量 a 进行简单的词法引用,实际上就是一个 LHS 引用并将 2 赋值给它。
当我们将 o1 传递进去,a=2 赋值操作找到了 o1.a 并将 2 赋值给它,这在后面的 console. log(o1.a) 中可以体现。
而当 o2 传递进去,o2 并没有 a 属性,因此不会创建这个属性, o2.a 保持 undefined。
但是我们却注意到一个奇怪的副作用,实际上 a = 2 赋值操作创建了一个全局的变量 a。
这是怎么回事?
with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。
尽管如此,这个块内部正常的 var 声明并不会被限制在这个块的作用域中,而是被添加到 with 所处的函数作 用域中。
而with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。
可以这样理解,当我们传递 o1 给 with 时,with 所声明的作用域是 o1,而这个作用域中含 有一个同 o1.a 属性相符的标识符。
但当我们将 o2 作为作用域时,其中并没有 a 标识符, 因此进行了正常的 LHS 标识符查找。
o2 的作用域、foo(…) 的作用域和全局作用域中都没有找到标识符 a,因此当 a=2 执行 时,自动创建了一个全局变量(因为是非严格模式)。
eval和with 性能考虑
eval 和 with 会在运行时修改或创建新的作用域,以此来欺骗其他在书写时定义的词法作用域。
你可能会问,那又怎样呢?如果它们能实现更复杂的功能,并且代码更具有扩展性,难道不是非常好的功能吗?答案是否定的。
JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的 词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
但如果引擎在代码中发现了 eval 或 with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道 eval(…) 会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底是什么。
最悲观的情况是如果出现了 eval 或 with,所有的优化可能都是无意义的,因此最简单的做法就是完全不做任何优化。
如果代码中大量使用 eval(…) 或 with,那么运行起来一定会变得非常慢。无论引擎多聪明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代码会运行得更慢这个事实。
作用域闭包
接下来我们考虑一个概念:闭包~
如果你明白了词法作用域,那闭包的理解是无需多言的。
现在如果你对它还不是很了解,麻烦你把之前内容再好好梳理一遍哦~