第一章:什么是作用域?
编译器理论
js是一种编译型语言,它 不是 像许多传统意义上的编译型语言那样预先被编译好,编译的结果也不能在各种不同的分布式系统间移植。
传统编译型语言处理,一块代码执行之前的处理分为三个步骤,成为“编译”:
- 词法分析
- 语法分析
- 代码生成
和大多数其他语言的编译器一样,JavaScript 引擎要比这区区三步复杂太多了。
其一,javaScript 引擎没有(像其他语言的编译器那样)大把的时间去优化,因为 javaScript 的编译和其他语言不同,不是提前发生在一个构建的步骤中。对 javaScript 来说,在许多情况下,编译发生在代码被执行前的仅仅几微秒之内(或更少!)。为了确保最快的性能,JS 引擎将使用所有的招数(比如 JIT,它可以懒编译甚至是热编译,等等),而这远超出了我们关于“作用域”的讨论。
理解作用域
以var a = 2;
为例,用一个对话情形了解释作用域。
演员
1、引擎:负责从始至终的编译和执行javaScript程序。
2、编译器:引擎的朋友之一,负责解析和代码生成。
3、作用域:引擎的有一个朋友,收集并维护含有所有被声明标识符(变量)的一张表,并对当前执行中的代码如何访问这些代码制定严格的规则。
反复
引擎会看到两个语句:一个是编译器需要处理的语句,一个是引擎在执行期间处理的语句。
编译器对于var a = 2
的处理过程:
1、遇到 var a
,编译器 让 作用域 去查看对于这个特定的作用域集合,变量 a
是否已经存在了。如果是,编译器 就忽略这个声明并继续前进。否则,编译器 就让 作用域 去为这个作用域集合声明一个称为 a
的新变量。
2、然后 编译器 为 引擎 生成稍后要执行的代码,来处理赋值 a = 2
。引擎 运行的代码首先让 作用域 去查看在当前的作用域集合中是否有一个称为 a
的变量可以访问。如果有,引擎 就使用这个变量。如果没有,引擎 就查看 其他地方。如果 引擎 最终找到一个变量,它就将值 2
赋予它。如果没有,引擎 将会举起它的手并喊出一个错误!
总结来说:对于一个变量赋值,发生了两个不同的动作:第一,编译器 声明一个变量(如果先前没有在当前作用域中声明过),第二,当执行时,引擎 在 作用域 中查询这个变量并给它赋值,如果找到的话。
编译器术语
RHS(right hand side)查询 & LHS(left hand side)查询
换言之,当一个变量出现在赋值操作的左手边时,会进行 LHS 查询,当一个变量出现在赋值操作的右手边时,会进行 RHS 查询。更简单的理解RHS:意味着“取得他/她的源(值)”,暗示着 RHS 的意思是“去取……的值“。例:
console.log( a );
这个指向 a
的引用是一个 RHS 引用,因为这里没有东西被赋值给 a
。而是我们在查询 a
并取得它的值,这样这个值可以被传递进 console.log(..)
。
作为对比:
a = 2;
这里指向 a
的引用是一个 LHS 引用,因为我们实际上不关心当前的值是什么,我们只是想找到这个变量,将它作为 = 2
赋值操作的目标。
引擎 & 作用域对话
这是解释RHS和LHS更具体的一个栗子:
|
|
让我们将上面的(处理这个代码段的)交互想象为一场对话。这场对话将会有点儿像这样进行:
引擎:嘿 作用域,我有一个
foo
的 RHS 引用。听说过它吗?作用域;啊,是的,听说过。编译器 刚在一秒钟之前声明了它。它是一个函数。给你。
引擎:太棒了,谢谢!好的,我要执行
foo
了。引擎:嘿,作用域,我得到了一个
a
的 LHS 引用,听说过它吗?作用域:啊,是的,听说过。编译器 刚才将它声明为
foo
的一个正式参数了。给你。引擎:一如既往的给力,作用域。再次感谢你。现在,该把
2
赋值给a
了。引擎:嘿,作用域,很抱歉又一次打扰你。我需要 RHS 查询
console
。听说过它吗?作用域:没关系,引擎,这是我一天到晚的工作。是的,我得到
console
了。它是一个内建对象。给你。引擎:完美。查找
log(..)
。好的,很好,它是一个函数。引擎:嘿,作用域。你能帮我查一下
a
的 RHS 引用吗?我想我记得它,但只是想再次确认一下。作用域:你是对的,引擎。同一个家伙,没变。给你。
引擎:酷。传递
a
的值,也就是2
,给log(..)
。…
小测验
检查你到目前为止的理解。确保你扮演 引擎,并与 作用域 “对话”:
|
|
- 找到所有的 LHS 查询(有3处!)。
- 找到所有的 RHS 查询(有4处!)。
答案
找出所有的 LHS 查询(有3处!)。
c = .., a = 2(隐含的参数赋值)和 b = ..
找出所有的 RHS 查询(有4处!)。
foo(2.., = a;, a + .. 和 .. + b
嵌套的作用域
引擎 在查找一个变量,如果在直接作用域上找不到的话,引擎 就会咨询下一个外层作用域,以此类推, 直到找到这个变量或者到达最外层作用域,即全局作用域。
例:
|
|
该例中在查找b
值时,要使用RHS查询。这个过程描述为一个简单的对话:
引擎:“嘿,
foo
的 作用域,听说过b
吗?我得到一个它的 RHS 引用。”作用域:“没有,从没听说过。问问别人吧。”
引擎:“嘿,
foo
外面的 作用域,哦,你是全局 作用域,好吧,酷。听说过b
吗?我得到一个它的 RHS 引用。”作用域:“是的,当然有。给你。”
错误(?)
为什么我们区别 LHS 和 RHS 那么重要?
因为在变量还没有被声明(在所有被查询的 作用域 中都没找到)的情况下,这两种类型的查询的行为不同。
考虑如下代码:
|
|
当 b
的 RHS 查询第一次发生时,它是找不到的。它被说成是一个“未声明”的变量,因为它在作用域中找不到。
如果 RHS 查询在嵌套的 作用域 的任何地方都找不到一个值,这会导致 引擎 抛出一个 ReferenceError
。必须要注意的是这个错误的类型是 ReferenceError
。
相比之下,如果 引擎 在进行一个 LHS 查询,但到达了顶层(全局 作用域)都没有找到它,而且如果程序没有运行在“Strict模式”[^note-strictmode]下,那么这个全局 作用域 将会在 全局作用域中创建一个同名的新变量,并把它交还给 引擎。(能不能举一个具体的栗子?)
“不,之前没有这样的东西,但是我可以帮忙给你创建一个。”
在 ES5 中被加入的“Strict模式”[^note-strictmode],有许多与一般/宽松/懒惰模式不同的行为。其中之一就是不允许自动/隐含的全局变量创建。在这种情况下,将不会有全局 作用域 的变量交回给 LHS 查询,并且类似于 RHS 的情况, 引擎 将抛出一个 ReferenceError
。
现在,如果一个 RHS 查询的变量被找到了,但是你试着去做一些这个值不可能做到的事,比如将一个非函数的值作为函数运行,或者引用 null
或者 undefined
值的属性,那么 引擎 就会抛出一个不同种类的错误,称为 TypeError
。
ReferenceError
是关于 作用域 解析失败的,而 TypeError
暗示着 作用域 解析成功了,但是试图对这个结果进行了一个非法/不可能的动作。
复习
作用域是一组规则,它决定了一个变量(标识符)在哪里和如何被查找。这种查询也许是为了向这个变量赋值,这时变量是一个 LHS(左手边)引用,或者是为取得它的值,这时变量是一个 RHS(右手边)引用。
手动划重点:未被满足的 RHS 引用会导致 ReferenceError
被抛出。未被满足的 LHS 引用会导致一个自动的,隐含地创建的同名全局变量(如果不是“Strict模式”[^note-strictmode]),或者一个 ReferenceError
(如果是“Strict模式”[^note-strictmode])。
第二章: 词法作用域
作用域的工作方式有两种占统治地位的模型。其中的第一种是最最常见,在绝大多数的编程语言中被使用的。它称为 词法作用域,我们将深入检视它。另一种仍然被一些语言(比如 Bash 脚本,Perl 中的一些模式,等等)使用的模型,称为 动态作用域。
词法分析时
示例代码:
|
|
查询
当引擎执行console.log(...)
语句时,它需要查找三个变量a
,b
,c
。
查找三个变量的方式都为一层一层从内到外的查找,一旦找到第一个匹配,作用域查询就停止了。
当相同的标识符在作用域的多个层中指定时,内部的标识符会“遮蔽”外层的标识符。
全局变量自动的是全局对象(在浏览器中是window
等等)的属性,所以对全局变量的引用可以不直接通过词法名称,可以通过将它作为全局对象的一个属性来间接的引用。如下:
|
|
欺骗词法作用域
欺骗词法作用域即修改词法作用域。欺骗词法作用域会导致性能低下。
js中欺骗词法作用域有两种机制。
eval
javaScript中的eval(...)
函数接收一个字符串作为参数值,并把这个字符串的内容转换为代码。从而改变函数的作用域。
|
|
上面的代码中,变量str
的值"var b = 2"
的值会转换成一行代码。这个时候整个代码就可以解读为。
|
|
因此控制台中输出的b
值就为3
而不是2
。
注意:当eval
操作的作用域内为strict模式时,eval(...)
内部做出的声明不会修改作用域。
|
|
with
with
现在已经被废弃了。
以下为with
的一种使用方法:
|
|
然而以下代码才是with
带来的麻烦:
|
|
在上面的代码中,o1
对象有a
属性,而o2
对象没有a
属性。这就是产生问题的原因啦,foo(...)
分别将o1
和o2
作为参数。因为o1
对象有a
属性,所以console.log( o1.a )
的输出值为2
。但是o2
对象没有a
属性,当运行with(...)
时,a = 2
会按照LSH标识符查询规则,因为这不是在strict模式下,所有会在全局内声明一个变量a
,并赋值为2
。
如果 eval(..)
函数接收一个含有一个或多个声明的代码字符串,它就会修改现存的词法作用域,而 with
语句实际上是从你传递给它的对象中凭空制造了一个 全新的词法作用域。
注意: 除了使用它们是个坏主意以外,eval(..)
和 with
都受Strict模式的影响(制约)。with
干脆就不允许使用,而虽然 eval(..)
还保有其核心功能,但各种间接形式的或不安全的 eval(..)
是不允许的。
性能
如果 eval(..)
或 with
出现,那么它做的所有的优化几乎都会变得没有意义,所以引擎就会简单地根本不做任何优化。
你的代码几乎肯定会趋于运行的更慢,只因为你在代码的任何地方引入了一个了 eval(..)
或 with
。无论引擎将在努力限制这些悲观臆测的副作用上表现得多么聪明,都没有任何办法可以绕过这个事实:没有优化,代码就运行的更慢。
第三章:函数与块作用域
函数中的作用域
示例代码:
|
|
在全局作用域中可访问foo(...)
,在foo(...)
作用域中可访问b
,bar()
,c
。
隐藏于普通作用域
隐藏作用域就相当于把代码封装到一个函数中,然后在另一个函数中调用这个函数,这样函数中很多细节就隐藏在了被调用的函数中。
示例代码:
|
|
但在上述的代码中,doSomethingElse
很可能被其他函数使用,改变doSomethingElse
内容,违背了doSomethingElse
为doSomething
私有细节这一点,所以可以将代码变更为下面的形式:
|
|
这是doSomethingElse
完全为doSomething
内部私有,外部无法更改。这种将私有细节保持为私有的做法是被提倡的。
避免冲突
在同一个函数中会发生使用相同的标识符,从而产生了值被覆盖的这种情况。
例如:
|
|
其中i = 3
会该表for
循环中i
的值,从而使for
循环进入无线循环的状态。但如果将i = 3
改为var i = 3
就不会产生这种情况。这是因为bar(...)
函数中,未声明变量i
,所以在非严格模式下,根据LSH标识符查询原则,在foo(...)
中i
会被声明为一个全局变量。
全局“名称空间”(不是很理解)
变量冲突很有可能发生在全局作用域中。当多个库被加载到程序中时,如果没有适当的隐藏私有变量和函数,就很容易发生冲突。
所以,这样的库通常在全局作用域中使用一个独特的名称来声明一个对象,然后这些库的名称和内容组成对象中的键值对。
例如:
|
|
(这样就能回避冲突????为啥????)
模块管理(喵?)
另一种回避冲突的选择是通过任意一种依赖管理器,使用更加现代的“模块”方式。使用这些工具,没有库可以向全局作用域添加任何标识符,取而代之的是使用依赖管理器的各种机制,要求库的标识符被明确地导入到另一个指定的作用域中。
应该可以看到,这些工具并不拥有可以豁免于词法作用域规则的“魔法”功能。它们简单地使用这里讲解的作用域规则,来强制标识符不会被注入任何共享的作用域,而是保持在私有的,不易冲突的作用域中,这防止了任何意外的作用域冲突。
因此,如果你选择这样做的话,你可以防御性地编码,并在实际上不使用依赖管理器的情况下,取得与使用它们相同的结果。关于模块模式的更多信息参见第五章。
(那我们就第五章再说吧)
函数作为作用域
区别下面两段代码:
第一段:
|
|
第二段:
|
|
两段代码都调用了foo
函数,在第一段代码中foo
是一个函数声明,而在第二段代码中foo
是一个函数表达式。
区分函数声明和函数表达式:判断语句中(不仅仅是一行,而是一个独立的语句)“function”一词的位置。如果“function”是这个语句中的第一个东西,那么它就是一个函数声明。否则,它就是一个函数表达式。
在第一段代码中:名称foo
被绑定在外围作用域中,我们可以用foo()
直接调用。
在第二段代码中:名称foo
没有被帮绑定在外围作用域中,而是被绑定在它的函数内部。
匿名与命名
匿名函数表达式:
|
|
优点:匿名函数表达式能更快的键入。
缺点:1、在栈轨迹上匿名函数没有名称,这可能会使调试更加困难。
2、没有名称的情况下,如果函数想要再次引用自己,就需要使用被遗弃了的arguments.callee
。
3、匿名函数省略的名称有利于标识函数的具体作用。
命名函数表达式:
|
|
内联函数表达式 很强大且很有用 —— 匿名和命名的问题并不会贬损这一点。给你的函数表达式提供一个名称就可以十分有效地解决这些缺陷,而且没有实际的坏处。最佳的方法是总是命名你的函数表达式:
栈轨迹(自己补充部分)
(函数)调用栈
函数调用栈工作方式:后进先出(LIFO:last in, first out)。
举个栗子:
|
|
在上述栗子中,a
首先被运行,这是a
就进入了堆栈的顶部。当a
中运行b
时,b
被添加到堆栈的顶部。当b
中运行c
时,c
被添加到堆栈的顶部。当c
运行时,堆栈中就包含了a
,b
,c
。当c
运行完后,就会从堆栈的顶部被移除,然后b
,a
运行完后也同样。
为了更好的观察,可以使用console.trace()
在控制台输出当前的堆栈数据。
|
|
立即调用函数表达式(IIFE)(?)
传统IIFE两种书写方式:
|
|
|
|
两种书写方式在功能上完全相同,纯粹根据个人喜好选择。
一种十分常见的变种:
|
|
传入window
对象引用,将参数命名为global
。这样就能通过global
参数访问全局变量a
。
另一种变种:
|
|
这种变种将事情的顺序倒了过来,要被执行的函数在调用和传递给它的参数 之后 给出。这种模式被用于 UMD(Universal Module Definition —— 统一模块定义)项目。一些人发现它更干净和易懂一些,虽然有点儿繁冗。
块儿作为作用域
两段常见的代码:
|
|
|
|
上述两段代码中的的i
和bar
虽然分别声明在for
和if
块儿内,但是还是属于全局变量。
with
它是一个块儿作用域的例子,它从对象中创建的作用域仅存在于这个 with
语句的生命周期中,而不在外围作用域中。
try/catch
在 try/catch
的 catch
子句中声明的变量,是属于 catch
块儿的块儿作用域的。
栗如:
|
|
但是当catch
里面也含有catch
时,参数如果都为err
许多 linter 依然会报警。所以为了避免这一问题出现,我们会用err1
和err2
来区分他们,或者关掉linter
。
let
let
关键字将变量声明附着在它所在的任何块儿(通常是一个 { .. }
)的作用域中。换句话说,let
为它的变量声明隐含地劫持了任意块儿的作用域。
我们可以在一个语句是合法文法的任何地方,通过简单地引入一个 { .. }
来为 let
创建一个任意的可以绑定的块儿。
|
|
使用 let
做出的声明将 不会 在它们所出现的整个块儿的作用域中提升。即不能先使用后声明变量。
|
|
垃圾回收
栗子:
|
|
在上面的代码中,当运行完process( someReallyBigData )
后,后面的代码就不需要someReallyBigData
变量了。所以当运行click
事件时,js引擎就不需要继续保持someReallyBigData
变量了。为了解决这一问题可以用let
。
|
|
let循环(*)
|
|
let
循环的特点是:每一次循环都会重新绑定i
,然后再对它进行赋值。上述代码等价于:
|
|
const
const
声明的变量也为块作用域,只不过他声明后的值是不可改变的。
|
|
第四章:提升
先考虑以下代码:
|
|
|
|
编译器再次来袭(?)
引擎将会在解释执行代码之前先对它进行编译。编译的部分过程就是找到所有声明,并将它们关联在合适的作用域上。
所以在代码执行之前,所有函数和变量的声明都会被首先处理。
对于var a = 2;
这段代码,js实际认为它是两个语句:var a;
和a = 2;
。第一个语句:声明,是在编译阶段处理的,是编译期的任务。第二个语句:赋值,为了执行阶段留在原处,是执行期的任务。
所以上面的第一段代码被解释为:
|
|
|
|
第二段代码被解释为
|
|
|
|
变量和函数声明被从它们在代码流中出现的位置“移动”到代码的顶端。这就产生了“提升”这个名字。
|
|
上述的代码可以解释为:
|
|
函数声明会被提升,但函数表达式不会。
|
|
这段代码可以解释为:
|
|
函数优先
函数会比变量先提升。
|
|
这段代码被引擎解释为:
|
|
var foo;
被省略了,因为在函数声明之后这一步是重复操作了。即便它出现在 function foo()...
声明之前,因为函数声明是在普通变量之前被提升的。
对于多个声明,后续声明会覆盖前一个:
|
|
在普通的块儿内部出现的函数声明一般会被提升至外围的作用域,而不是像这段代码暗示的那样有条件地被定义:
|
|
上面这种行为是不可靠的,而且是未来版本的 JavaScript 将要改变的对象,所以避免在块儿中声明函数可能是最好的做法。
第五章:作用域闭包
事实真相
闭包的定义:
闭包就是函数能够记住并访问它的词法作用域,即使当这个函数在它的词法作用域之外执行时。
一个闭包的栗子:
|
|
在foo
中,声明了一个bar
函数,它可以访问外围作用域的a
变量。通过return bar;
可以成功的将bar
这个函数传给其他任意变量。将foo()
赋值给baz
变量,这时直接调用baz()
就能成功的在控制台输出变量a
。
在上述的代码中,foo()
按理执行完之后就会被引擎启用垃圾回收器 来回收foo()
内部的内容。但是闭包不会让这一事件发生,内部的作用域依然可以使用。
bar() 依然拥有对那个作用域的引用,而这个引用称为闭包。
而且,函数也可以作为参数传递,在其他地方使用这个传递进去的参数,也是闭包的一种实现形式。
|
|
也可以将函数传给外部作用域的变量:
|
|
现在我能看到了
举一个特殊栗子:
|
|
setTimeout
内的timer
拥有覆盖wait
作用域的闭包,保持着对message
变量的引用。
正是因为闭包的存在,才能在函数执行了1000ms后,在wait()
内部作用域已经消失的情况下,输出变量message
的值。
循环+闭包(*)
用来展示闭包最常见最权威的例子是老实巴交的 for 循环。
|
|
当执行上述代码,会发现每隔一秒会输出一个6。
setTimeout下的循环和闭包(自己补充部分)
对于上述代码的理解:结合异步、闭包和作用域的知识。
对setTimeout的理解
首先要理清对setTimeout的理解:
setTimeout的延迟不是绝对精确的;
setTimeout的意思是传递一个函数,延迟一段时候把该函数添加到队列当中,并不是立即执行;
所以说如果当前正在运行的代码没有运行完,即使延迟的时间已经过完,该函数会等待到函数队列中前面所有的函数运行完毕之后才会运行;
也就是说所有传递给setTimeout的回调方法都会在整个环境下的所有代码运行完毕之后执行。
比如:
|
|
上面的代码运行时,会先输出test,2秒后再输出here。
为什么是一秒
因为根据上述的理解,setTimeout
会在for
循环结束后运行,所以队列中会依次加入延迟1秒、2秒、3秒…的setTimeout
函数,所以在运行的时候延迟1秒的会先运行,然后对于延迟2秒的在延迟1秒的setTimeout
运行的同时也跟着延迟了1秒,所以当延迟1秒的setTimeout
运行结束后,只需要延迟1秒就可以开始运行函数。 延迟3秒、4秒…的同理。
为什么每次都显示6
可以将循环转化为:
|
|
首先说明对于闭包的理解:对函数类型的值进行传递时,保留对它被声明的位置所处的作用域的引用。
timer函数是在setTimeout中声明的,当运行console.log( i )
时,这个i
在timer
里面没有声明,所以向外层作用域找,这时候可以找到全局作用域上的i
。当timer
执行时,循环已经结束了,所以i
的值为6。
改进后输出1,2,3,4,5
第一种方式:
|
|
函数在每次迭代时,持有一个i
值的拷贝。
第二种方式:
|
|
这种方式知识上面形式的一种改写,他们都会利用IIFE来解决这一问题,都是用立即执行函数表达式创造了新的函数作用域将timer函数包裹了起来,并用j捕获了每次循环时的i。只不过第二种方式将j
作为形参,i
作为实参。
第三种方式:
|
|
let
声明的变量每次都会创建一个块作用域,将上面的代码经过babel转码为ES5我们可以看到:
|
|
它为每一次循环都创建了一个块作用域。
第四种方式:
|
|
这种方式原理和第三种方式相同。
模块
闭包的另一种运用是在模块中。
|
|
这段代码就是闭包在模块中的一个运用。实现模块的一个最常用方法经常被称为“揭示模块”。
对于上述代码需要注意的是:
第一:coolModule()
只是一个函数,它只有被调用之后才能称为一个被创建的模块实例。没有外部函数的执行,内部作用域的创建和闭包都不会发生。
第二:CoolModule()
函数返回一个对象,通过对象字面量语法 { key: value, ... }
标记。可以很恰当地认为这个返回值对象实质上是一个 我们模块的公有API。
行使模块模式有两个“必要条件”:
- 必须有一个外部的外围函数,而且它必须至少被调用一次(每次创建一个新的模块实例)。
- 外围的函数必须至少返回一个内部函数,这样这个内部函数才拥有私有作用域的闭包,并且可以访问和/或修改这个私有状态。
一个仅带有一个函数属性的对象不是 真正 的模块。从可观察的角度来说,一个从函数调用中返回的对象,仅带有数据属性而没有闭包的函数,也不是 真正 的模块。
只需要一个实例的模式:
|
|
使用IIFE实现。
同时,模块是函数,所以它们可以接受参数。
|
|
同时,我们还可以通过模块内部函数修改公有API。
|
|
现代的模块
未来的模块
附录A:动态作用域
|
|
在 foo()
的词法作用域中指向 a
的 RHS 引用将被解析为全局变量 a
,它将导致输出结果为值 2
。
相比之下,动态作用域本身不关心函数和作用域是在哪里和如何被声明的,而是关心 它们是从何处被调用的。换句话说,它的作用域链条是基于调用栈的,而不是代码中作用域的嵌套。