JavaScript作用域及内存理解
JavaScript作用域
作用域概念:
作用域(scope),程序设计概念,通常来说,一段程序代码中所用到的名字并不总是有效/可用的,而限定这个名字的可用性的代码范围就是这个名字的作用域。
作用域的使用提高了程序逻辑的局部性,增强程序的可靠性,减少名字冲突。
--摘自百度百科。
JavaScript两种作用域:
函数作用域、全局作用域。
函数拥有自己的作用域,而块(如while、if和for语句)则没有。
JavaScript中变量作用域的工作方式:
在js中,所有全局变量实际上是作为window对象的属性存在的。
//设置全局变量f1; var f1 = '1'; //在if块中 if (true) { //再次设置f1 var f1 = '2'; //由于块没有作用域,此时f1位于全局作用域 } //查看f1的值:2 f1; //创建函数修改f1的值 function modify(){ var f1= '3'; console.log('该函数作用域中,f1='+f1); } //查看函数作用域f1的值:3 modify(); //查看全局作用域f1的值:2 console.log('全局作用域中,f1='+f1);
|
作用域链:
js作用域内部可以访问外部,但外部的不能访问内部的:如果函数体内还包含着函数,只有这个内函数才可以访问外一层的函数的变量;
内部环境可以通过作用域链访问所有的外部环境,但外部环境不能访问内部环境中的任何变量和函数;
var box = 'blue'; function setBox(){ function setColor(){ var b = 'orange'; console.log(box); console.log(b); } //setColor()的执行环境在setBox()内; setColor(); } setBox(); |
var a=10; function aaa(){ console.log(a); }; function bbb(){ var a=20; aaa(); } bbb(); //结果为10,因为aaa()函数不能访问到bbb()里面的局部变量,所以访问到的是a=10,这个全局变量。 |
js隐式全局变量声明:
当变量没有没有明确声明作用域时(使用var),他会被定义为全局作用域(即使他在函数中声明时)
//定义函数,设置变量 function test1(){ f2 = 'aa'; } //调用函数 test1(); //查看f2的值 console.log(window.f2);
|
所以尽量在希望的作用域使用var来初始化变量,避免出现不需要的全局变量
变量提升:
看一个代码:3-1
var scope = "global";//声明全局变量 function fn(){ console.log(scope); var scope = "local";//声明局部变量 console.log(scope); } fn(); |
函数中所有变量声明都会提升到作用域顶部。3-2
function test3(){ f3 = 'bb'; //变量f3此时并没有声明; console.log(f3); var f3 } //执行函数; test3();//’bb’ |
f3虽然在函数test3()最底部声明,但会提升到test3()顶部。f3就成了test3作用域内的变量。函数声明和变量声明都会被解释器"提升"到方法体(函数和全局)的最顶部。
变量提升不包括初始值:3-3
function test4(){ //f4虽然在后面声明了,但初始化值并没有提升 console.log(f4); //声明变量并初始化 var f4 = 'dd'; //现在才有初始化值 console.log(f4); } //执行函数test4(); test4(); |
所以3-1代码最终是按照这个来执行的:3-4
var scope = "global"; function fn(){ var scope;//提前声明了局部变量 console.log(scope); scope = "local"; console.log(scope); } fn(); |
总结:js有两种作用域;隐式全局变量声明;变量提升;
作用域链:
每个函数在第一次被调用时,会创建一个执行环境,随之创建作用域链、变量对象。
执行环境Execution Context:
每个函数在调用时都会创建并进入自己的一个执行环境,而执行环境会被压入一个逻辑上的环境栈。在函数执行后,环境栈将其环境弹出,把控制权返回给之前的执行环境。
当一个函数被调用时,该函数环境的变量对象就被压入一个环境栈中。而在函数执行之后,栈将该函数的变量对象弹出,把控制权交给之前的执行环境变量对象。
var scope = "global"; function fn1(){ return scope; } function fn2(){ return scope; } fn1(); fn2(); |
全局执行环境是最外围的一个执行环境。当浏览器第一次加载js脚本程序的时候, 默认进入全局执行环境, 此次的全局环境变量对象为window,因此所有全局变量和函数都是作为window对象的属性和方法创建的。
变量对象Variable Object:
每个执行环境会关联一个变量对象。该执行环境中定义的所有变量和函数都存放在这个变量对象中,对于全局执行环境,变量对象为window。对于函数,变量对象叫做活动对象,此时变量对象是不可通过代码来访问的。
作用域链:
作用域会被链赋值给执行环境中一个特殊的内部属性[scope]。作用域链中保存的是所有执行环境的变量对象引用列表,而当前执行环境的变量对象引用总是在最前面的,然后是外部函数一层层执行环境的变量对象,最后是全局变量对象的引用。
可以看到在执行函数fn1时,首先进入fn1的执行环境,此时需要返回scope的值,于是沿着作用域链从头到尾开始寻找scope,在自己的变量对象中并没有发现scope,于是来到第二个变量对象中找,就在全局变量中找到了属性scope的值。
执行环境的创建:
分为两个阶段:进入阶段(解析阶段)和执行阶段。
当解析器进入执行环境时,变量对象就会添加执行环境中声明的变量和函数作为它的属性,变量值为undefined,这就是变量和函数声明提升(Hoisting)的原因,与此同时作用域链和this确定,此过程为解析阶段。然后解析器开始执行代码,为变量添加相应值的引用,得到执行结果,此过程为执行阶段。
(1)解析阶段:发生在函数调用时,但在执行具体代码之前。具体完成创建作用域链;创建变量、函数和参数以及this的值。
(2)执行阶段:主要完成变量赋值、函数引用和解释/执行其他代码 。
例1:在全局环境中有如下代码:
var a=123;
var b="abc";
function c(){
alert('11');
}
解析器在进入该全局环境时有以下两个阶段:
例2:某函数有如下代码;
function testFn(a){
var b="123";
function c(){
alert("abc");
}
}
testFn(10);
当解析器进入函数执行环境时,则会创建一个活动对象作为变量对象,活动对象还会创建一个Arguments对象,arguments对象是一个参数集合,用来保存参数。
作用域链特性原理:
全局执行环境中有如下代码:
var a='123'; function testFn(b){ var c='abc'; function testFn2(){ var d='efg'; console.log(a); } testFn2(); } testFn(10);
|
由于作用域链的特性,testFn2可以访问到全局执行环境中的变量a.
其过程如下;
创建全局执行环境-->创建testFn执行环境-->创建testFn2执行环境
当解析器进入testFn2函数执行环境时,函数内部属性[[scope]]首先填入父级的作用域链,然后再将当前的testFn2活动对象添加到作用域链的前端,形成一个新的作用域链。
testFn2调用变量a时,首先在当前的testFn2活动对象中查找,如果没有找到就顺着作用域链向上,在testFn活动对象中查找变量a,如果没有找到再顺着作用域链向上查找,直到在最后Global对象中找到为止,否则报错。所以函数内部可以调用外部环境的变量,外部环境不能调用函数内部的变量,这就是作用域特性的原理。
JavaScript内存空间
JavaScript没有严格意义上区分栈内存堆内存。首先我们可以通俗的认为JavaScript所有数据都是保存在堆内存中,但在某些时候我们又要用到堆栈数据结构的思路。
比如刚才说到的执行环境,逻辑上说它就实现了栈这样的结构。同样对于刚才讲的作用域链,它也总是把当前环境的活动对象添加到头部,可以说也是一种栈结构。
引用和值:
JavaScript保存数据采用引用和值来保存数据。对于字符串、数字、布尔值、null、undefined这样的数据我们称之为原始值(基础数据类型)。使用他们的时候,都是将原始值直接复制到变量中。
而对于没有保存原始值的变量,都是保存的对象的引用(内存位置)。实际的对象(数组、日期等)被称为指称对象。
前面说到,JavaScript的建立执行环境时,会创建一个叫做变量对象的特殊对象,JavaScript的基础数据类型往往都会保存在变量对象中,而引用对象会保存在堆内存中。
var a1 = 0; // 变量对象 var a2 = 'this is string'; // 变量对象 var a3 = null; // 变量对象 var b = { m: 20 }; // 变量b存在于变量对象中,{m: 20} 作为对象存在于堆内存中 var c = [1, 2, 3]; // 变量c存在于变量对象中,[1, 2, 3] 作为对象存在于堆内存中 |
因此当我们要访问堆内存中的引用数据类型时,实际上我们首先是从变量对象中获取了该对象的地址引用(或者地址指针),然后再从堆内存中取得我们需要的数据。
理解内存中基础数据和引用对象:看以下几个例子
//将item设置为新的字符串对象 var item = "test"; //将ref指向同一个字符串对象 var ref = item; //将item拼接为新的字符串 item += "ing"; //输出item和ref console.log(item); console.log(ref); |
可以看到两者输出并不一样:为什么呢?
原因在于,字符串test是原始值,使用var ref = item 时,实际上是将test的值直接复制给了变量ref,此时的ref已和item是两个相互独立不影响的变量了。
//创建一个数组 var items = ['1','2','3']; //创建数组的引用 var ref = items; //在原数组中添加元素 items.push('4'); //输出两数组 console.log(items); console.log(ref); |
这里的数组是指称对象,当使用var ref = items给ref赋值时,实际上是把数组的引用复制给了ref,所以ref指向的仍是['1','2','3']这个值。同时,数组是自修改对象,当使用push添加元素时,实际上是改变了自己的值,并不会像字符串拼接那样会产生新的对象。所以一旦修改items指称对象的值,也就修改了ref指称对象的值。
另外注意:引用只能指向指称目标,不能指向另一个引用。
//创建一个数组 var items = ['1','2','3']; //创建数组的引用 var ref = items; //改变items的引用 items = ['4','5','6']; //输出两数组 console.log(items); console.log(ref); |
items和ref现在指向了不同的两个数组。
JavaScript内存回收:
js是具有自动垃圾收集机制的,这种机制会自动的跟踪每一个变量的动向,并判断当前的变量是否还有存在的必要,然后将不必要的变量所占用的内存进行收回。对于这样的收回机制,实际真正的运用起来是有两种不同的方法:
1.方法一:标记清除算法
这一算法是为进入环境中的变量标记一个“进入环境”的标记。逻辑上讲,当我们的变量进入环境的时,变量实际上是不应该被删除的,因为上下文中可能会用到当前的变量进行相关的逻辑演算,而当变量离开环境的时候,竟会为其标记成为“离开环境”的状态。
2.方法二:引用计数
引用对象是放在堆中的,而这一内存清理方法是对这一值的引用次数进行统计,当我们声明了一个变量,并且将引用对象的值赋值给了这一变量,则引用对象的引用计数加 一,反之当我们的引用对象的相关引用变量其指向的内容发生了变化,则引用对象的引用计数减一。当引用对象的引用计数为0的时候这表明,此对象值可回收。
以上其实就是最为常用的内存回收机制,当然我们的内存回收机制是在一定的时间间隔后,自动的运行的,每次都会搜寻是否有变量可以收回,并回收内存。
JavaScript内存泄露:
全局变量:
不小心创建了全局变量,但并不需要全局。(隐式声明,this)
闭包:
闭包时,内部函数的作用域链仍然保持着对父函数活动对象的引用,但其参数和变量不会被垃圾回收机制回,常驻内存,会增大内存使用量,使用不当很容易造成内存泄露。