Where is JavaScript?

JavaScript入坑之作用域和闭包

本文为《你不知道的JavaScript》- [作用域和闭包]部分读后总结和提炼

要深入理解JavaScript的作用域和闭包等原理,需要从编译、语法、构建、执行等方面进行仔细的分析。

编译原理

JavaScript引擎构建于一种专门用于解释和执行JavaScript代码的流程虚拟机

请注意区分浏览器的页面引擎和编译、执行JavaScript代码的引擎,参考How Browsers Work: Behind the scenes of modern web browsers

JavaScript引擎采用先编译后执行的过程,其编译过程大体与传统的编译语言相同

  • 词法分析
    将源代码分解成有意义的词法单元,关键字、变量名、基本数据类型等
  • 语法分析
    将词法单元按照语法组成架构化的抽象语法树(Abstract Syntax Tree,AST)
  • 生成代码
    将AST转换为机器识别的可执行二进制代码

JavaScript引擎在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等
JavaScript引擎的目标是在代码执行前用最短的时间内生成最优化的代码

作用域

作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限
JavaScript采用词法作用域(lexical scoping),也就是编译过程已经确定了标识符的作用范围
此外JavaScript的作用链规定了执行代码对变量的一级一级向上查找的机制

函数作用域

函数的执行依赖于函数定义的时候所产生(而不是函数调用的时候产生的)的变量作用域

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
var value = 1; function foo() { console.log(value); } function bar() { var value = 2; foo(); } bar();

结果一定是1,因为函数的作用域在函数定义的时候就决定了,foo 的函数作用域内没有 value 的定义,向上找到全局作用域

块作用域

ES6 之前 JavasScript没有块作用域

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
var arr = [1, 2, 3]; for (var i = 0, j; j = arr[i++];) { console.log(j); } console.log(i); console.log(j);

初始化 i=0, j=undifined
第一次执行时,j=arr[0],之后 i=1console.log(j) 输出 1
第二次执行时,j=arr[1],之后 i=2console.log(j) 输出 2
第三次执行时,j=arr[2],之后 i=3console.log(j) 输出 3
第四次(不符合 for 条件),j=arr[3]undefined
最后 i=4

ES6 引入了新的 let 关键字,提供了除 var 以外的另一种变量声明方式。 let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说,let为其声明的变量隐式地了所在的块作用域。

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
var arr = [1, 2, 3]; for (let i = 0, j; j = arr[i++];) { console.log(j); } console.log(i); console.log(j);

其他输出都一样,但是 for 循环之外的 console.log(i) 会发生 ReferenceError

提升

JavaScript 解析器会在自身作用域内将变量和函数声明提前

  1. 无论声明出现在作用域的什么位置,都会被移动到各自作用域的顶部,这个过程被称为提升。
  2. 声明被提升,而包括函数表达式的赋值在内的赋值操作并不会提升,而是留在原地等待执行。

变量提升

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
var name = 'World!'; (function () { if (typeof name === 'undefined') { var name = 'Jack'; console.log('Goodbye ' + name); } else { console.log('Hello ' + name); } })(); //Goodbye Jack

等价为==>

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
var name = 'World!'; (function () { var name; if (typeof name === 'undefined') { name = 'Jack'; console.log('Goodbye ' + name); } else { console.log('Hello ' + name); } })();

执行的时候有个变量查找的过程,如果在当前函数体内没找到,就会到定义的函数体的外层函数中去寻找,一直向上到全局对象中寻找,还是找不到就会报 TypeError 错误

函数同名情况提升

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
foo(); //打印last function foo() { console.log("first"); } foo(); //打印last function foo() { console.log("last"); }

变量名和函数同名的情况

函数提升的优先级最高,将首先被提升

              
  • 1
  • 2
  • 3
  • 4
foo(); function foo(){ console.log(1); } var foo = function(){ console.log(2); } foo();

等价为==>

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
var foo ; //重复的声明将被忽略 function foo(){ console.log(1); } foo(); //1 foo = function(){ console.log(2); } foo(); //2

闭包

JavaScript 函数对象的内部状态不仅包含函数逻辑的代码,除此之外还包含当前作用域链的引用。函数对象可以通过这个作用域链相互关联起来,如此,函数体内部的变量都可以保存在函数的作用域内,这在计算机的文献中被称之为闭包。

从技术的角度去将,所有的 JavaScript 函数都是闭包:他们都是对象,他们都有一个关联到他们的作用域链。绝大多数函数在调用的时候使用的作用域链和他们在定义的时候的作用域链是相同的,但是这并不影响闭包。当调用函数的时候闭包所指向的作用域链和定义函数时的作用域链不是同一个作用域链的时候,闭包become interesting。这种interesting的事情往往发生在这样的情况下: 当一个函数嵌套了另外的一个函数,外部的函数将内部嵌套的这个函数作为对象返回。一大批强大的编程技术都利用了这类嵌套的函数闭包,当然,javascript也是这样。可能你第一次碰见闭包觉得比较难以理解,但是去明白闭包然后去非常自如的使用它是非常重要的。

通俗点说,在程序语言范畴内的闭包是指函数把其的变量作用域也包含在这个函数的作用域内,形成一个所谓的“闭包”,这样的话外部的函数就无法去访问内部变量。所以按照第二段所说的,严格意义上所有的函数都是闭包。

需要注意的是:我们常常所说的闭包指的是让外部函数访问到内部的变量,也就是说,按照一般的做法,是使内部函数返回一个函数,然后操作其中的变量。这样做的话一是可以读取函数内部的变量,二是可以让这些变量的值始终保存在内存中。

JavaScript 利用闭包的这个特性,就意味着当前的作用域总是能够访问外部作用域中的变量。

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
function counter (start) { var count = start; return { add: function () { count ++; }, get: function () { return count; }, }; } var foo = counter (4); foo.add(); foo.get() //5

上面的代码中,counter 函数返回的是两个闭包(两个内部嵌套的函数),这两个函数维持着对他们外部的作用域 counter 的引用,因此这两个函数没有理由不可以访问 count

JavaScript 中没有办法在外部访问 countJavaScript 不可以强行对作用域进行引用或者赋值),唯一可以使用的途径就是以这种闭包的形式去访问。

对于闭包的使用,最长见的可能是下面的这个例子:

              
  • 1
  • 2
  • 3
  • 4
  • 5
for (var i = 0; i < 10; i++) { setTimeout (function (i) { console.log (i); //10 10 10 .... }, 1000); }

在上面的例子中,当 console.log 被调用的时候,匿名函数保持对外部变量的引用,这个时候 for 循环早就已经运行结束,输出的 i 值也就一直是10。但这在一般的意义上并不是我们想要的结果。

setTimeout 中的 i 都共享的是这个函数中的作用域, 也就是说,他们是共享的。这样的话下一次循环就使得 i 值进行变化,这样共享的这个 i 就会发生变化。这就使得输出的结果一直是10

为了获得我们想要的结果,我们一般是这样做:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
for (var i = 0; i < 10; i++) { (function (e) { setTimeout (function () { console.log (e); }, 1000); })(i); }

外部套着的这个函数不会像setTimeout一样延迟,而是直接立即执行,并且把i作为他的参数,这个时候e就是对i的一个拷贝。当时间达到后,传给setTimeout的时候,传递的是e的引用。这个值是不会被循环所改变的。

除了上面的写法之外,这样的写法显然也是没有任何问题的:

              
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
for (var i = 0; i < 10; i++) { setTimeout((function(e) { return function() { console.log (e); } })(i), 1000); }

参考

本文于 2015-11-24  发布在  编程  分类下, 当前已被围观 361 次

并被添加「 JavaScript 前端 」标签

本站使用「 署名 4.0 国际」创作共享协议