首页 » 前端 » JavaScript » 正文

(二)JavaScript 变量、作用域、内存

1. 介绍

按 ECMA-262 的定义,JavaScript 的变量与其他语言的变量有很大的区别。因 JavaScript 变量是松散类型变量的值及其数据类型可以在脚本的生命周期内改变,从某种角度看,这既有趣又强大,同时又容易出问题的特性,但实际复杂的程度还远远不止如此。

2. 基本类型和引用类型的值

ECMAScript 变量可以包含两种不同数据类型的值:(基本类型引用类型

  • 基本类型:简单的数据段(Undefined、Null、Boolean、Number、String)这五种类型是按值访问的,因为可以操作保存在变量中的实际的值。
  • 引用类型:由多个值构成的对象(Object),引用类型的值时保存在内存中的对象。与其他语言不同,JavaScript 不允许直接操作对象的内存空间。在操作对象时,实际上在操作对象引用,而不是实际的对象。引用类型的值时按引用访问的。

在将值赋给变量时,解析器必须确定这个值是基本类型还是引用类型

提示:很多语言中,字符串以对象的形式表示,因此被认为是引用类型的。ECMAScrip放弃了这一传统。

2.1. 动态属性

基本类型和引用类型定义方式类似的,但当创建的值保存到变量中以后,对不同类型值可执行的操作可能不一样。

如下列两种:

  1. 基本类型
    // 基本类型无法添加属性,尽管这样做不会导致任何错误。
    var name = "Nicholas";
    name.age = 27;
    alert(name.age); // undefined
    
  2. 引用类型
    // 但引用类型却可以动态的添加属性,如果对象不销毁或属性不被删除,则这个属性一直存在。
    var person = new Object();
    person.name = "Nicholas";
    alert(person.name);
    

2.2. 复制变量值

  • 除了保存方式不同,从一个变量复制另一个变量的基本类型值引用类型的值时,也存在不同。

    基本类型复制值会出现这种情况:

    var num1 = 5;    // num1 在栈中开辟空间存值 5
    var num2 = num1; // num2 在栈中开辟空间存值(从 num1 空间复制过来的 5)
    // 两个变量的空间都是独立存在的并没有任何关联。
    // 相当于 num1开一间房,num2开一间房,num2 叫了一份和 num1 一样的外卖。
    

    引用类型复制值会出现这种情况:

    var obj1 = new Object(); // obj1 在栈中开辟空间存对象的地址,对象存放在堆内存中。
    var obj2 = obj1;         // obj2 在栈中开辟空间存 obj1保存的地址,可操作 obj1 的对象。
    obj1.name = "Nicholas";  
    alert(obj2.name); // Nicholas
    // 当 obj2 复制 obj1 变量时,两个变量同时指向一个 对象,相当于两家人有一个共同的仓库。
    

2.3. 传递参数

  • ECMAScript 中所有函数的参数都是按值传递的,也就是把函数外部的值复制一份给函数内部的参数。

    函数参数接收基本参数时:

    function addTen(num) {
    num += 10;
    return num;
    }
    
    var count = 20;
    var result = addTen(count);
    alert(count); // 20,没有变化
    alert(result);// 30
    
    // 简单理解:result 叫了addTen函数的参数 叫了一份 count一样的外卖,加了特技,返回给他
    // 而 addTen函数并没有修改 count 所以没任何变化
    

    函数参数接收引用类型时:

    function setName(obj){
    obj.name = "Nicholas";
    obj = new Object();
    obj.name = "Greg";
    }
    var person = new Object();
    setName(person);
    alert(person.name); // "Nicholas"
    /*
    可以看上面的 复制引用变量值,自行领会。 
    我发现我竟然无法言语解释。
    */
    

2.4. 检测类型

要检测一个变量是不是基本数据类型,tyepof 操作符是最佳的工具,但如果变量是对象或者null,typeof返回的是object对象。

虽然检测基本类型是非常得力的助手,但在检测引用类型的值这个操作符用处不大。但我们想知道某个值是什么类型的对象。为此 ECMAScript 提供了 instanceof 操作符

result = variable instanceof constructor
// 如果变量是给定引用类型
alert(person instanceof Object);  // 变量 person  是 Object 吗?
alert(colors instanceof Array);   // 变量 colors  是 Array  吗?
alert(pattern instanceof RegExp); // 变量 pattern 是 RegExp 吗?

根据规定,所有引用类型的值都是 Object 的实例.。

  • instanceof 检测 Object 和 引用类型时,始终返回 true
  • instanceof 检测 基本类型值时,始终返回 false

3. 执行环境及作用域

执行环境(execution context)有时也叫环境,是JavaScript中最为重要的一个概念。执行环境定义了变量函数有权访问其他数据,决定了它们各自的行为。每个执行环境都有与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。虽然编写时无法访问这个对象,但解析器在处理数据时会在后台使用它。

var color = "blue";
function changeColor(){
  var anotherColor = "red";
  function swapColors(){
    var tempColor = anotherColor;
    anotherColor = color;
    color = tempColor;

    // 这里可以访问 color、anotherColor 和 tempColor
  }

  // 这里可以访问 color 和 anotherColor 但不能访问 tempColor
  swapColors();
}

// 这里只能访问 color
changeColor();
  • 内部环境可以通过作用域链访问所有的外部环境但外部环境不能访问内部环境中任何变量和函数。
  • 环境之间的联系是线性、有次序的。
  • 每个环境都可以向上搜索作用域链,以查询变量和函数;
  • 但任何环境都不能通过向下搜索作用域链而进入另一个执行环境。
  • 函数参数也被当做变量来对待,因此其访问规则与执行环境中的其他变量相同。

3.1. 延长作用域链

执行环境的类型共有两种:(全局局部(函数)),但还是有其他办法来延长作用域链。

例如,有些语句可以在作用域链的前端临时增加一个变量对象,该变量对象会在代码执行后被移除。

有两种情况会发生这种现象:

// 当执行流进入下列任何一个语句时,作用域就会得到加长
try-catch 语句的 catch 块;
with 语句

这两个语句都会在作用域的前端添加一个变量对象。

  • 对 with 语句来说,会将指定的对象添加到作用域链中。

  • 对 catch 语句来说,会创建一个新的变量对象,其中包含的是被抛出的错误对象的申明。

// 例如
function buildUrl(){
  var qs = "?debug=true";

  with(location){
    var url = href + qs;
  }

  return url;
}
  1. With 语句接受的是 location 对象,因此其变量对象中就包含了 location 对象的所有属性和方法,而这个变量对象被添加到了作用域链的前端。
  2. buildURL()函数中定义了一个变量 qs。 当with语句中引用变量 href 时(实际引用的是 location.href),可以在当前执行环境的变量对象中找到。
  3. 当引用变量 qs 时,引用的则是在 buildUrl()中定义的那个变量,而该变量位于函数环境的变量对象中。
  4. 至于 with 语句内部,则定义了一个名为 url 的变量,因而 url 就成了函数执行环境的一部分,所以可以作为函数的值返回。

3.2. 没有块级作用域

  1. JavaScript 没有块级作用域经常会导致理解上的困惑。在其他 C 的语言中,由花括号封闭的代码块都有自己的作用域(如果用ECMAScript 的话来讲,就是他们自己的执行环境),因而支持根据条件来定义变量。
    // 例如,下面代码在 JavaScript 中并不会得到想象中的结构:
    if (true) {
    var color = "blue";
    }
    
    alert(color); // "blue"
    

    在 if 语句中定义了变量 color ,如果在 C、C++或Java中,color 会在 if 语句执行完毕后被销毁。

    但在JavaScript中,if 语句中的变量声明会将变量添加到当前的执行环境(在这里是全局环境中)。

  2. 在使用 for 语句尤其要牢记这一差异,例如:

    for (var i=0;i < 10; i++){
    doSomething(i);
    }
    alert(i); // 10
    

    对于有块级作用域来说, for 语句初始化变量的表达式所定义的变量,只会存在于循环的环境之外中。

    而对于 JavaScript 来说,由 for 语句创建的变量 i 即使在 for 循环执行结束后,也依旧会存在于循环外部的执行环境中。

  3. 声明变量

    在使用 var 声明的变量会自动被添加到最近的环境中。在函数内部,最接近的环境就是函数的局部环境;在with语句中,最接近的环境就是函数环境。如果初始化变量时没有使用 var 声明,该变量自动被添加到全局环境。

    function add(num1, num2){
    var sum = num1 + num2; // sum添加到函数的局部环境中
    return sum;
    }
    var result = add(10, 20); //30
    alert(sum);  // 由于 sum 不是有效的变量,因此会导致错误。
    

    以上代码中的函数 add() 定义了一个名为 sum 的局部变量,虽然结果只从函数中返回了,但变量 sum 在函数外部是访问不到的。如果省略这个例子中的 var 关键字,那么当add 函数 执行完毕后sum 也可以访问到

    function add(num1, num2){
    sum = num1 + num2; // sum添加到全局环境中
    return sum;
    }
    var result = add(10, 20); //30
    alert(sum);               //30
    

    上面例子中的变量 sum 在被初始化时没有被使用 var 关键字。于是,当调用完 add() 之后,添加到全局环境中变量 sum 将继续存在;即使函数执行完毕,后面代码依然可以访问它。

  4. 查询标识符

    在某个环境中为了读取写入引用一个标识符时,必须通过搜索来确定该标识符实际代表什么

    搜索过程从作用域链的前端开始,向上逐级查询与给定名字匹配的标识符。如果局部环境中找到了该标识符,搜索过程停止,变量就绪。如果在局部环境中没有找到该变量名,则继续沿着作用域链向上搜索。搜索过程将一直追溯到全局环境的变量对象。如果在全局环境中也没有找到这个标识符,则意味着该变量尚未声明。

    // 通过下面的示例,可以理解标识符的过程
    var color = "blue";
    
    function getColor(){
    return color;
    }
    
    alert(getColor()); // "blue"
    

    这个查询过程采用就近原则,局部环境中找到变量,用局部的;没找到就往上找 全局环境的,还没找到就代表未定义。

    // 通过下面的示例,可以理解标识符的过程
    var color = "blue";
    
    function getColor(){
     var color = "red";
    return color;
    }
    
    alert(getColor()); // "red"
    

提示:变量查询也不是没有代价的。很明显,访问局部变量要比访问全局变量更快,因为不用向上搜索作用域链。JavaScript 引擎在优化标识符查询方面做得不错,因此这个差别将来恐怕可以忽略不计了。

4. 垃圾收集

JavaScript 具有自动垃圾收集机制,也就是执行环境会负责管理代码执行过程中使用的内存

而 C 和 C++之类的语言中,开发人员的一项基本任务就是手工跟踪内存的使用情况,这是造成许多问题的一个根源。在编写 JavaScript 程序时,开发人员不用再关心内存使用问题,所需内存的分配以及无用内存的回收完全实现了自动管理。

原理:JavaScript 垃圾收集机制原理很简单,找出那些不再继续使用的变量,然后是释放其占用的内存。为此,垃圾收集器会按照固定的时间间隔(或代码执行中预定的收集时间)周期性地执行这一操作。

简单分析:函数中局部变量的正常声明周期,局部变量只在函数执行的过程中存在,这个过程中,会为局部变量在栈(或堆)内存上分配相应的空间,以便存储它们的值。然后在函数中使用这些变量,直到函数执行结束。此时,局部变量就没必要存在了,因此可以释放它们的内存以供将来使用。在这种情况下,很容易判断变量是否还有存在的必要;但并非所有情况都这么容易就能得出结论。垃圾收集器必须跟踪哪个变量有用哪个变量没用,对于不再有用的变量打上一个标记,以备将来收回其占用的内存。用于标识无用变量的策略可能会因实现而异,但具体到浏览器中的实现,则通常有两个策略。

4.1. 标记清除

JavaScript 中最常用的垃圾收集方式是标记清除(mark-and-sweep)。

  • 当变量进入环境(例如,在函数中声明一个变量)时,就这个变量标记为”进入环境”。

  • 而当变量离开环境时,则将其标记为”离开环境”。

首先,垃圾收集器在运行的时候会给存储在内存中的所有变量都打上标记(可使用任何标记方式)。

然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记。而在此之后再被打上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。

最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。

4.2. 引用计数

另一种不太常见的垃圾收集策略叫做引用计数(reference counting)。引用计数的含义是跟踪记录每个值被引用的次数。

  1. 当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1.
  2. 如果同一个值又被赋给另一个变量,则该值的引用次数加1.
  3. 相反,如果包含对这个值引用的变量又取得了另一个值,则这个值的引用次数减1。当这个值引用次数变成0时,则说明没有办法再访问这个值了,然后可以将其占用的内存空间回收。
  4. 这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为0的值所占的内存。

Netscape Navigator 3.0 是最早使用引用计数策略的浏览器,但很快它就遇到一个严重的问题:循环引用。循环引用指对象 A 中包含对象 B 的指针,而对象 B 中也包含一个指向对象 A 的引用。

// 循环引用例子:
function problem(){
    var objectA = new Object();
    var objectB = new Object();

    objectA.someOtherObject = objectB;
    objectB.anotherObject = objectA;
}

为此,Netscape 在 Navigator 4.0 中放弃了引用计数方式,转而采用标记清除来实现其垃圾收集机制。可,引用计数导致的麻烦并未就此终结。

  1. 我们知道,IE中有一部分对象并不是原生的 JavaScript 对象。

  2. 例如 BOM 和 DOM 对象就是 C++以 COM(Component Object Model,组件对象模型)对象的形式实现的,而COM对象的垃圾收集机制采用的就是引用计数策略。

  3. 因此,即使 IE 的 JavaScript 引擎是使用标记清除策略来实现的,但 JavaScript 访问的 COM 对象依然基于引用计数策略的。简单说,就是 IE 涉及 COM 对象,就会存在循环引用的问题。

    看下面例子:

    // 当出现循环引用,即使例子中的 DOM 从页面中移除,它也永远不会被回收。
    var element = document.getElementById('some_element');
    var myObject = new Object();
    myObject.element = element;
    element.someObject = myObject;
    
    // 为了避免循环引用问题,将变量设置为 null 意味着切断了变量与它此前引用的值之间的连接。
    myObject.element = null;
    element.someObject = null;
    
  4. 为了解决上述问题,IE9 把 BOM 和 DOM 对象都转成了真正意义的 JavaScript 对象。这样,就避免两种垃圾收集算法并存导致的问题,也消除了常见的内存泄漏现象。

4.3. 性能问题

垃圾收集器是周期性运行的,而且如果为变量分配的内存数量很客观,那么回收工作量也是相当大的。

咋你这种情况下,确定垃圾收集的时间间隔是一个非常重要的问题。说道垃圾收集器多长时间运行一次,不禁让人联想到 IE 因此而狼藉的性能问题。

  • IE 的垃圾收集器是根据内存分配量运行的,具体一点说就是 256 个变量、4096个对象(或数组)字面量和数组元素(slot)或者 64KB 的字符串。
  • 达到上述任何一个临界值,垃圾收集器就会运行。这种实现方式的问题在于,如果一个脚本中包含那么多变量,那么该脚本可能会在其生命周期中一直保有那么多的变量。而这样一来,垃圾收集器就不得不频繁地运行。结果,由此引发的严重性能问题促使 IE7 重写了其他垃圾收集例程。
  • 随着 IE7 的发布,其 JavaScript 引擎的垃圾收集例程改变了工作方式:触发垃圾收集的变量分配、字面量和(或)数组元素的临界值被调整为动态修正。IE7 中的各项临界值在初始时与 IE6 相等。如果垃圾收集例程回收的内存分配量低于 15%,则变量、字面量和(或)数组元素的临界值就会加倍。如果例程回收了 85% 的内存分配量,则将各种临界值重置回默认值。这一看似简单的调整,极大地提升了 IE 在运行包含大量 JavaScript 的页面时性能。

提示:事实上,在有的浏览器中可以触发垃圾收集过程,但我们不推荐读者这样做。在 IE 中,调用 window.CollectGarbage() 方法会立即执行垃圾收集。在 Opera7 及更高版本中,调用 window.opera.collect() 也会启动垃圾收集例程。

4.4. 管理内存

使用具备垃圾收集机制的语言编写程序,开发人员一般不必操作内存管理的问题。但是,JavaScript 在进行内存管理及垃圾收集时面临的问题还是有点与众不同。

其中最主要的一个问题,就是分配给 Web 浏览器的可用内存数量通常要比分配给桌面应用程序的少。这样做的目的主要是处于安全方面的考虑,目的就是防止运行 JavaScript 的网页耗尽全部系统内存而导致系统崩溃。

内存限制问题不仅会影响给变量分配内存,同时还会影响调用栈以及在一个线程中能够同时执行的语句数量。

因此,确保占用最少的内存可以让页面获得更好的性能。而优化内存的占用的最佳方式,就是为执行中的代码只保存必要的数据。一旦数据不再有用,最好通过将其值设置为 null 来释放其引用–这个做法叫做接触引用(dereferencing)。

这一做法适用于大多数全局变量和全局对象的属性。局部变量会在它们离开执行环境时自动被接触引用,例如:

function createPerson(name){
    var localPerson = new Object();
    localPerson.name = name;
    return localPerson;
}

var globalPerson = createPerson("nicholas");
// 手工解除 globalPerson 的引用
globalPerson = null;
  • 当函数执行完毕后就离开其执行环境,因此无需我们显式地去为它接触引用。

  • 但全局变量,则需要我们在不使用它的时候手工为它接触引用,这也是上面例子中最后一行代码的目的。

  • 不过,解除一个值的引用并不意味着自动回收该值所占用的内存。解除引用的真正作用的是让值脱离执行环境,以便垃圾收集器下次运行时将其回收。

5. 小结

  1. JavaScript 变量可以用来保存两种类型的值:
  • 基本类型(Undefined、Null、Boolean、Number、String)
  • 引用类型(Object)
  1. 基本类型值引用类型值具备以下特点:
  • 基本类型值在内存中占据固定大小的空间,因此被保存在栈内存中;
  • 从一个变量向另一变量复制基本类型值,会创建这个值的一个副本;
  • 引用类型的值是对象,保存在堆内存中;
  • 包含引用类型值的变量实际上包含的并不是对象本身,而是一个指向该对象的指针;
  • 从一个变量向另一个变量复制引用类型的值,复制的其实是指针,因此两个变量最终都指向同一对象;
  • 确定一个值是哪种基本类型可以使用 typeof 操作符,而确定一个值时哪种引用类型可以使用 instanceof 操作符
  1. 所有变量(包括基本类型和引用类型)都存在于一个执行环境(也称为作用域)当中,这个执行环境决定了变量的生命周期,以及哪一部分代码可以访问其中的变量。
    以下就是关于执行环境的几点总结
  • 执行环境有全局执行环境(也称全局环境)和函数执行环境之分;

  • 每次进入一个新执行环境,都会创建一个用于搜索变量和函数的作用域链;

  • 函数的局部环境不仅有权访问函数作用域中的变量,而且有权访问其包含(父)环境,乃至全局环境;

  • 全局环境只能访问在全局环境中定义的变量和函数,而不能直接访问局部环境中的任何数据;

  • 变量的执行环境有助于确定应该何时释放内存;

  1. JavaScript 是一门具有自动垃圾收集机制的编程语言,开发人员不必关心内存分配和回收问题。

    以下对 JavaScript 的垃圾收集例程作如下总结:

  • 离开作用域的值将被自动标记为可以回收,因此将在垃圾收集期间被删除。
  • “标记清除”是目前主流的垃圾收集算法,这种算法的思想是给当前不使用的值加上标记,然后再回收其内存。
  • 另一种垃圾收集算法是“引用计数”,这种算法的思想是跟踪记录所有值被引用的次数。JavaScript 引擎目前都不再使用这种算法;但在 IE 中访问非原生 JavaScript 对象(如DOM对象)时,这种算法仍然可能会导致问题。
  • 当代码中存在循环引用现象,“引用计数”算法就会导致问题。
  • 解除变量的引用不仅有助于消除循环引用现象,而且对垃圾收集也有好处。为了确保有效地回收内存,应该及时解除不再使用的全局对象、全局对象属性以及循环引用变量的引用。

发表评论