javasrcipt的作用域和闭包(二)续篇之:函数内部提升机制与Variable Object
一个先有鸡还是先有蛋的问题,先看一段代码:
a = 2; var a; console.log(a);
通常我们都说JavaScript代码是由上到下一行一行执行,但实际这段代码输出的结果是2。但这段代码并不能为我们要讨论的问题提供完整的参考意义,所以再看一下代码:
console.log(a) var a = 2;
这段代码的测试结果输出了undefined。
这两段代码打破了我们常说的JavaScript代码从上往下执行的说法,那到底是变量声明在前还是赋值在前呢?
函数预编译:
还记得我在上篇博客中分析词法作用域,如有代码是var a = 2;引擎会把这段代码分成两个声明来进行编译,第一次编译的是var a,第二次编译的是a = 2,在前面的所有内容我并没有对编译逻辑做任何解释,通常情况下我们也都认为这两个编译环节是一个先后相邻的编译逻辑过程,但是真实情况显然不是,如果是这样的话,上面两段代码的执行结果就会有很大的不同了,这是显而易见。
这就是JavaScript特有的一种编译机制,函数预编译的提升机制。
那这个机制到底在代码执行的时候干了什么呢?可以通过对上面两个示例采用内部机制的方式做一个人为的显示修改,来模仿内部提升机制处理程序。
第一段代码可以如下形式处理:
var a; a = 2; console.log(a);
第二段代码可以如下形式处理:
var a; console.log(a); a = 2;
由修改的代码可以看到一个规律,就是变量声明操作会被提升到程序的最上方。是的,这就是JavaScript的内部编译的提升机制。
而且这个机制不止适应于变量声明提升,还适应与函数声明提升。请看以下代码:
function foo(){ fn(a);//输出undefined var a = 2; fn(a);//输出2 function fn(sum){ console.log(sum); } }
foo();
这段代码不仅说明了函数声明也适应与提升机制,而且还说明了变量声明适应于提升机制,但是赋值还是会留在原地。
但是这并没有结束,请看以下代码:
function foo(){ var a = 2; function a(){ console.log("aaa"); }
console.log(a)//2 a();//TypeError: a is not a function } foo();
别急,我再把这段代码稍微修改以下,你会发现惊喜的。
function foo(){ a();//aaa var a = 2; a();//TypeError: a is not a function function a(){ console.log("aaa"); } } foo();
是不是感觉这两段代码瞬间摧毁了我们之前通过提升机制理解的内部机制,之前的提升机制好像在正常的情况下(命名不冲突的情况下)能为我们解决函数的内部执行问题,但是当遇到变量声明与函数声明冲突时就会让这个机制变得脆弱不堪,可能有的人会说,我们可开始规范点就好了,不要把命名混淆着用就是了。但是,在实际的开发中,我们有时候会需要利用同一个声明同时做变量和函数的载体,而且遇到问题就解决问题是我们开发人员的价值所在,所以我们有必要理解这种情况下,函数内部的编译到底发生了什么?
Variable Object:
很明显变量提升机制已经不足以解释我们的疑惑,通常我们都知道数据信息在编译的时候就是向内存写入数据,再在调用参数和方法时从内存中读取出来,也就是说变量和函数的声明都是在内存中开辟一个内存空间,然后在赋值的时候根据引擎提供的物理地址,将值保存到对应的内存空间里。然后在程序需要引用变量的值或者执行某个函数时再去到对应的内存地址取出这些数据,提供给引擎来执行。那么这里就会有一套管理这种数据读写的机制,这种机制成为变量对象(Variable Object)。
变量对象是一种特殊的对象,这个对象用来保存和管理函数的内部变量和函数,以及与内外嵌套作用域的关系。我们暂且不管它为什么特殊,先通过对象这个特性来理解函数的内部数据的读写机制。用下面这段代码来理解变量对象:
function foo(){ a();//aaa var a = 2; function a(){ console.log("aaa"); } var b; function b(){ console.log("bbb"); } b();//bbb b = 4; console.log(a);//2 console.log(b);//4 } foo();
在这里,我想重申一次,函数内部的提升机制任然存在,前面的代码出现的混乱情况只是内存管理机制所导致的,我们可以先模仿变量对象机制来处理变量声明。
var VO = { a:undefined, b:undefined }
当变量提升后,上面的示例代码就会进行下一步操作,函数声明提升,变量对象的内部就会发生如下变化:
var VO = { a:function(){...}, b:function(){...} }
函数声明的内部提升与函数体被保存到内存可以看做是同步进行的,当函数名与之前提升的变量名相同时,变量会被覆盖成函数。然后就到了代码执行阶段。
函数执行的第一条代码就是a()执行,这时候执行的是被提升的a函数,所以打印出字符串aaa。
然后紧接着执行第二行代码var a = 2,而实际上因为变量名提升的机制,这行代码在引擎看来只是a = 2这样的声明存在了。所以VO会发生如下变化:
var VO = { a:2, b:function(){...} }
因为提升机制,后面的a函数声明和b变量声明再到b函数声明都会跳过,因为这三行代码在之前的提升机制中被提升到了函数的最上方。所以会直接执行b()函数,打印出字符串bbb,然后紧接着又执行b = 4 赋值操作,变量对象内部的b属性的值会被修改成4,所以后面打印a,b的结果分别是2,4。
到这里应该就会很清楚之前代码的报错原因了,但是上面的例子中还缺了一点东西,就是如果函数带有参数怎么办?
其实有了上面的数据读写机制的流程解释就很好理解了,当函数带有参数时,因为js的参数没有严格的限制,形参和实参会有很大的区别,但是js的编译器的容错性很强,参数不对称并不会发生错误。而是会被统一看成是变量,如果有实际传入参数就相当于声明变量并赋值的操作。而这个赋值会在函数提升前操作,所以如果出现同名的函数声明也会被覆盖。
最后函数的内部提升机制和变量对象的读写机制做一个浏览总结,可以总体上分为四个步骤:
1.当函数执行时(准确说是执行的前一刻),内部会创建一个变量对象;
2.然后将形参和变量声明提升,作为VO的属性名,并赋值undefined
3.将实参的参数赋给对应的形参(实际上赋给变量对象对应变量的属性)
4.在函数体里面找到函数声明提升,然后将函数体作为值赋给在变量对象内对应的属性。
接下来就是函数真正执行的时刻了,再执行的时候就是对VO进行修改和查询操作了。