作用域
一门语言需要一套设计良好的规则来存储变量,并且之后可以方便的找到这些变量,这逃规则被称为作用域。
这也意味着当我们访问一个变量的时候,决定这个变量能否访问到的依据就是这个作用域。
词法作用域
作用域共有两种主要的工作模型,第一种是最为普通的,被大多数编程语言(包括javascript)采用的词法作用域
,另一种叫做动态作用域
。而我们平时所提及的作用域,就是这里所说的词法作用域
。
要了解词法作用域,必须要了解javascript引擎以及编译器的大概工作方式。一般程序中的源码在执行前会进行编译三步骤。
- 分词/语法分析
- 解析/语法分析
- 代码生成
而在分词/词法分析这个步骤,就已经确定了词法作用域。也就说作用域在我们书写代码的时候就已经确定了,引用书中的文字
词法作用域就是定义在词法阶段的作用域,换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的。
具体结合编译器
、作用域
、引擎
来讲,编译器在分词阶段,针对特定的环境就会生成一个词法作用域,然后对源代码中的var a = 3;
类似的声明进行识别,当遇到var a
,编译器会询问作用域中是否有a变量,若无,则在作用域中新增一个a变量。编译完成之后,引擎执行编译后的代码,引擎在执行的过程中遇到a
变量,会去作用域中查找是否有a
变量,若有,则将a
赋值2。对于var a = 2;
一条语句会在两个过程中操作,正是变量提升现象的原因。(稍后讲到)
那什么时候会生成一个词法作用域呢?
函数作用域
这幅图所展示的三个气泡,就代表了三个作用域,而编译器遇到一个函数定义,就会生成一个作用域。例如当编译器遇到foo函数
,会创建一个作用域,再将这个函数内部的标识符(a/b/bar)放到词法作用域中。这个步骤在编译阶段就完成了。当js引擎执行foo函数
的时候,遇到a
变量,就会去询问早就创建好的作用域是否有a
变量存在。
在作用域外,是无法访问作用域内的变量的。
例如
|
|
正是这个特性,可以被用来实现隐藏内部变量
将重要变量声明放入一个函数声明的作用域中,可以防止被作用域外部的语句所引用甚至更改。
根据函数作用域,可以引申出如何判断一个函数是函数声明还是一个函数表达式。
最重要的区别是他们的名称标识符将会绑定在何处。
先声明一点,任何匿名函数都是可以添加名称标识符的。例如
|
|
- 对于函数声明,名称标识符是绑定在当前作用域上的。即可在函数当前作用域调用这个名称标识符。
- 而函数表达式,名称标识符是绑定在自身的函数作用域中的。
按照这个区别,来看以下几个函数。
|
|
|
|
|
|
以上的函数就只有foo1
是函数声明。
块作用域
在js语言中,除了函数,创建作用域的方式还可以通过块作用域。对于js而言,循环、ifelse块并没有创建块作用域的功能。
通过ES3规范的try/catch的catch语句可以创建一个块作用域,其中声明的变量仅在catch中有效。
而try-catch
也正是let
关键字的向前兼容方。
|
|
ES6引入了let关键字,提供了除var以外的另一种变量声明方式,let为其声明的变量隐式地劫持了所在的块作用域。
|
|
作于的一个中括号起到划分块作用域的作用,显示的区别于var
等变量。我们可能在之后会修改代码,看到这个中括号会直白的认识到这个是一个块作用域。
变量提升
在第一节我已经提到了,对于var a = 3;
这样一条语句,编译器通过分词、解析、最后生成机器可以读的代码。
而javascript实际上会将其看成两个声明:
var a
、a = 3
。第一个声明在编译阶段进行,第二个赋值声明会留在原地等待执行。
所以在引擎工作去执行代码时,进入到函数作用域内时,首先会执行var a
操作,而这个过程就好像变量从原先的位置被移动作用域最上面一样。
|
|
相当于
|
|
另外函数声明也会发生变量提升的现象(连实际函数值也提升,即可以在函数声明前调用)。而行数表达式var a = function foo1() {}
发生提升的是a变量,函数本身不会发生提升。
|
|
ReferenceError TypeError
这是两个错误标记,第一个错误标记是查询变量时,若在作用域中查找不到这个变量则发出,第二个标记是能查找到变量(即使是endefined),但是这个变量被错误的调用(比如对null,undefined进行调用),发出。
作用域闭包
经典的闭包
闭包是基于词法作用域书写代码时所产生的自然结果。
基于词法作用域产生的结果,这有点类似于词法作用域的产生条件。这也意味着闭包在书写代码的时候就已经形成了。
看一个最经典的闭包例子
|
|
基于这个经典的例子,结合书中的话
一个函数在定义时的词法作用域以外的地方被调用,可以记住并访问原先所在的词法作用域时,就产生了闭包。也即被返回出去的函数被调用时依然持有对该作用域的引用。这个引用就是闭包。
先确定一点,javascript中函数是可以作为值被传递的。基于这个特性,有多种方法可以行成闭包。只要在一个作用域中,将函数作为值传递到另一个词法作用域中并调用,就会形成闭包。
|
|
|
|
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
回调 == 闭包
再看上一节,回调中传递函数的例子。
|
|
是将函数当做值并作为参数传递给函数。再来看
|
|
setTimeout
作为js内置的工具函数,将timer 函数
当做值传进去,在setTimeout定义函数内对传进来的timer
进行了调用。类似于
|
|
回调函数timer
在另一个词法作用域内调用,但是能访问原先作用域内的参数(message)。
类似jquery中的事件绑定,涉及到传递回调函数,就都有闭包的产生!
闭包在循环中的表现
最令人困惑的闭包表现就是在循环中了。像我们刚刚提及到的setTimeout、事件绑定等回调函数都会产生闭包。
|
|
这个循环的本意是想间隔1秒打印1、2、3、4、5,结果却每隔1秒输出了5次6!
结合在第二节中对setTimeout函数的解析,这个误区将很快解开。
首先要明白for循环没有块作用域的概念,即在这个循环中5次迭代都是在同一个作用域中进行的。
要清楚timer
函数不是在这个作用域中被调用的,它作为参数在其他的作用域中调用。
|
|
这个函数包括其中的形式参数i
原原本本的被传递,在迭代过程中i
不会被赋值。
而五次迭代完成后,共用的作用域中的i
的值已经变成了6 。在其他作用域中的timer
函数调用过程中需要查询i
,因为产生了闭包,i
的值会去原始的作用域中查找,即全是6
。
得不到预期效果的错其实都在于for循环中共用一个作用域。想改进也很简单,即在迭代的过程中,创建对应的作用域。另外值得注意的一点是需要把每次迭代的i
值传到作用域内。
|
|
闭包的垃圾回收
本来一个变量被使用完之后就可以利用垃圾回收机制进行垃圾回收,但因为闭包的产生,阻止了这一行为。
|
|
这个例子中就是因为事件绑定机制中的传入了clicker
回调函数,产生了闭包,引用着clicker所在的作用域,所以此处的someReallyBigData数据无法从内存中释放。
解决办法也有,声明一个块作用域,让引擎清楚的知道没有必要保存someReallyBigData饿了。
|
|