ECMAScript(下文简称 ES)的变量类型是松散类型,可以用来保存任何类型的数据。

变量基本点

变量声明

定义变量时使用 var 关键字,后面跟变量名(即一个标识符)。

var message; //定义一个名为message的变量,变量未初始化时,其值为undefined

ES 支持定义变量的同时初始化变量(给变量赋值):

var message = "hi"; //定义变量同时为其赋值

由于变量时松散类型的,因此可以在修改变量值同时修改变量类型,但不推荐这么使用:

var message = "hi";
message = 100; // 有效,但不建议修改变量类型

可以使用一条语句定义多个变量,初始化或不初始化均可,变量之间用逗号分隔开:

var message = "hi",
    found = false,
    age = 28; //换行和缩进不是必需的,这样做只是为了提高可读性

变量命名原则

  • 变量名区分大小写;允许包含字母、数字、美元符号($)和下划线,但第一个字符不允许是数字,不允许包含空格和其他标点符号。
  • 变量名应当总是遵守驼峰大小写命名法,并且名称应使用名词作为前缀。
    //推荐的写法
    var count = 10;
    var myName = "Minhui";
    var found = false;
    

    //不推荐的写法:动词和名字结合,看起来像函数
    var getName = "Minhui";
    var isFound = false;

  • 命名长度应该尽可能短,并抓住要点,尽量在变量名中体现出值得类型。比如,命名 count、length、size 表明数据类型是数字; name、title、message 表明数据类型是字符串;诸如 i、j、k 的单个字符命名通常在循环中使用。
  • 避免使用没有意义的命名。诸如 foo、bar、tmp 之类的命名也应当避免。
### 重复声明和遗漏声明 使用 var 语句重复声明变量是合法且无害的。如果重复声明带有初始化器,那就和赋值语句没什么两样。

读取一个没有声明的变量的值时,JavaScript 会报错。

变量作用域(scope)

一个变量的作用域是源代码中定义这个变量的区域。全局变量拥有全局作用域,在代码的任何地方都有定义。函数内声明的变量只在函数体内有定义,是局部变量,作用域是局部性的。函数参数也是变量,只在函数体内有定义。

for(var k=0; k<10; k++) {
    console.log(k);
}
console.log(k); //=>10 for循环创建的变量在for循环结束后也依旧存在于循环外部环境中

在函数体内,局部变量的优先级高于同名全局变量。如果函数内声明的一个局部变量或函数参数中带有的变量和全局变量重名,则全局变量会被局部变量遮盖。

var scope = "global";

function checkScope() {
  var scope = "local"; //声明同名局部变量
  return scope;
}

console.log(checkScope()); // 返回局部变量的值而不是全局变量 => local
console.log(scope);        // 全局变量中没有受到影响 => global

声明局部变量时必须使用 var 语句,如果缺少 var 语句,则会给全局对象创建一个同名属性。ES5 严格模式中,给一个没有声明的变量赋值会报错。

var scope = "global";

function checkScope() {
  scope = "local"; // 不使用var语句,将修改全局变量
  myScope = "myScope"; // 没有加 var 语句,创建了一个全局变量; ES5 严格模式中将会报错
  return [scope,myScope];
}

console.log(checkScope()); // 返回 => local,myScope
console.log(scope);        // 全局变量被修改 => local
console.log(myScope);      // 新的全局变量 => myScope

函数作用域和申明提前

在一些类C编程语言中,花括号内的代码有各自的作用域,而且在变量在声明它们的代码段外是不可见的,这称为块级作用域(block scope)。JavaScript 中没有块级作用域,而是使用函数作用域(function scope),即变量在申明它的函数及这个函数嵌套的任意函数内都是可访问的。

for(var k=0; k<10; k++) {
    console.log(k);
}
console.log(k); // 10,for 语句创建的变量for循环结束后,依然会存在于循环外部的环境中

JavaScript 函数里声明的所有变量(不涉及赋值)都被提前至函数顶部,即声明提前(hoisting)。(声明提前在 JavaScript 引擎“预编译”时进行,细节参考 JavaScript Engine

var scope = "global";

function f() {
    console.log(scope); //输出global?不,undefined。 why?
    var scope = "local"; //在这里声明变赋值,但变量在函数体内任何地方是有定义的
    console.log(scope); // => local
}
//上面的函数执行时顺序如下:
function f() {
    var scope; //变量声明提前至函数体顶部
    console.log(scope); //变量没有初始化,所以undefined
    scope = "local"; //变量初始化(赋值)保留在原来的位置
    console.log(scope);
}

在有块级作用域的编程语言中,让变量声明和使用变量的代码尽量靠近,可以提高程序性能。JavaScript 没有块级作用域,因此可以把所有变量都放在函数体顶部,直观反映变量作用域。

作为对象属性的变量

声明 JavaScript 全局变量时,实际上就是定义全局对象的一个属性(对于浏览器而言,全局对象就是 window),ES 中规定全局变量是全局对象的属性。

  • 使用 var 声明全局变量时,创建一个不可配置的属性,无法用 delete 运算符删除
  • 非严格模式下给未声明变量赋值创建的全局变量,是全局对象的可配置属性,可以删除
  • 可以使用 this 关键字引用全局对象
var var1 = 1; // 不可配置全局属性
var2 = 2; // 没有使用 var 声明,可配置全局属性

console.log(this.var1); // 1
console.log(window.var1); // 1

delete var1; // false 无法删除
console.log(var1); //1

delete var2; 
console.log(delete var2); // true
console.log(var2); // 已经删除 报错变量未定义

局部变量可以当做跟调用函数相关的某个对象的属性,ES3 称该对象为调用对象(call object),ES5 称为 声明上下文对象(declarative environment record),JavaScript 目前没有方法可以引用局部变量中存放的对象。

变量值的数据类型

ES 变量值分为两种类型:

  • 基本类型,指的是简单的数据段。5种基本数据类型:Undefined、Null、Boolean、Number、String,这5中基本类型都是按值访问的,因为可以操作保存在变量中的实际值。
  • 引用类型,指可能有多个值构成的对象。引用类型的值时保存在内存中的对象,JavaScript 不能直接操作对象的内存空间,操作对象时,实际上是操作对象的引用而不是实际的对象。引用类型的值是按引用访问的。

引用类型值的动态属性

定义基本类型和引用类型的方式类似:创建一个变量并赋值。区别在于引用类型的值,可以添加属性和方法,也可以改变和删除属性。基本类型的值不能添加属性。

var person = new Object(); // 引用类型
person.name = "Minhui";   // 添加属性
console.log(person.name); // => "Minhui"

var name = "Minhui";
name.age = 28; // 为基本类型添加属性不会导致错误,但没用
console.log(name.age);// => undefined

变量值复制

从一个变量向另一个变量复制基本类型的值,会在变量对象上创建一个新值,然后把该值复制到位新变量分配的位置上。复制完成后,改变其中一个变量不会影响到另一个。

从一个变量向另一个变量复制引用类型的值时,同样会将存储在变量的值复制一份放到为新变量分配的空间中。区别是,这个值的副本实际上是一个指针,指针指堆内存中的一个对象。复制完成后,两个变量引用同一个对象。改变其中一个变量,就会影响到另一个变量。

var obj1 = new Object();
var obj2 = obj1;
obj1.name = "Minhui";
console.log(obj2.name); // => "Minhui"

obj2.name = "Luo"; //修改obj2的属性值会影响obj1
obj2.firstName = "LUO"; // 添加属性

console.log(obj1.name); // => Luo
console.log(obj1.firstName); // => LUO

变量作为函数参数传递

ES 中函数参数都是按值传递的。参数传递的过程和变量复制过程一样。

向参数传递基本类型的值时,被传递的值会被复制一个给局部变量(即命名参数,ES 的概念就是 arguments 对象的一个元素),函数内部的变化不会影响函数外部的变量。

function addTen(num) {
  num += 10;
  return num;
}

var count = 10;
    result = addTen(count);
console.log(count); // 20,不会受到函数内部操作影响
console.log(result); // 30

向参数传递引用类型的值时,会把这个值在内存中的地址复制给一个局部变量,这个局部变量的的变化会反映在函数外部。

function setName(obj) {
  obj.name = "Minhui"; //函数内部,obj和penson引用同一个对象
}

var person = new Object();
setName(person);
console.log(person.name); // => "Minhui",函数内部的变化函数外部也会有反映,因为堆内存中只有一个对象

上面的例子很容易让开发者误以为参数是按引用传递的,下面经过修改的例子证明对象是按值传递的:

function setName(obj) {
  obj.name = "Minhui";
  obj = new Object(); //函数内部重写obj时,这个变量的引用就是一个局部对象,函数执行完毕后就被销毁
  obj.name = "Django";
}
var person = new Object();
setName(person);
console.log(person.name); // => "Minhui",如果按引用传递,name属性应该被改为 Django

变量类型检测

  • 基本数据类型检测:使用 typeof,注意对象和 null 都返回 object
  • 引用类型:instanceof,语法为 variable instanceof constructor;使用instanceof 检测基本类型值时始终返回 false
    var person = new Object(),
        colors = ["red", "blue"],
        pattern = "";
    

    console.log(person instanceof Object); // true
    console.log(colors instanceof Array); // true
    console.log(pattern instanceof RegExp); //false

执行环境和作用域链

执行环境(execution context,简称环境)定义变量和函数有权访问的其他数据,决定他们各自的行为。每个执行环境都有一个与之关联的变量对象(variable object),用来保存环境中定义的所有变量和函数。全局执行环境是最外围的一个执行环境,在 Web 浏览器中被认为是 window 对象,因为所有全局变量和函数都作为 window 对象的属性和方法创建。某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数也随之销毁;全局执行环境在应用程序退出(关闭网页或浏览器)时销毁数据。

当代码在环境中执行时,会创建变量对象的一个作用域链(scope chain)。这个作用域链是一个对象列表或者链表,这组对象定义了代码“作用域中”的变量。作用域链保证对执行环境有权访问的变量和函数有序访问。全局执行环境的变量对象始终都是作用域链中的最后一个对象。

当 JavaScript 需要查找变量x值的时候,首先从链中第一个对象开始,如果第一个对象存在x属性,则直接使用这个属性的值;如果没有,继续查找链的下一个对象,直到找到为止。搜索过程将一直追溯到全局环境的变量对象。如果在全局环境中也没有找到x,则意味着x不存在,抛出引用错误异常。这个查找变量x值得过程称之为变量解析(variable resolution)。

  • 在 JavaScript 的最顶层代码中(不包含在任何函数定义内的代码),作用域链由一个全局对象(全局环境)组成;
  • 在不包含嵌套的函数体内,作用域链上有两个对象,一个是定义函数参数和局部变量的对象,第二个是全局对象(函数内部环境和全局环境);
  • 在嵌套函数体内,作用域链上至少有三个对象。
当定义一个函数时,会保存一个作用域链。当调用这个函数式,它创建新的对象来存储它的局部变量,并将这个对象添加至保存的那个作用域链上,同时创建一个新的更长的表示函数调用作用域的“链”。内部环境可以通过作用域链访问外部环境,但外部环境不能访问内部环境中的任何变量和函数。每个函数都可以向上搜索作用域链,查询变量和函数名;但不能向下搜索作用域链而进入另一个环境。函数参数的访问规则与执行环境中的其他变量相同。

延长作用域链

下面两个语句都会在作用域链前端添加一个变量对象,作用域链就会得到延长:

  • try - catch 语句中的catch 块:创建一个新的变量对象,其中包含的是被抛出错误对象的声明;
  • with 语句:将指定的对象添加到作用域中。
function buildUrl() {
  var qs = "?debug=true";
  with(location) {
    var url = href + qs; //location对象被添加到作用域链前端,可以直接饮用href
  }
  return url;
}

垃圾收集与性能优化

JavaScript 具有自动垃圾收集机制,执行环境会负责管理代码执行过程中使用的内存。这种垃圾收集机制的原理很简单:找出不再继续使用的变量,释放其占用内存。这个操作周期性执行。

垃圾收集的两种实现策略

  • 标记清除(mark-and-sweep)。垃圾收集器在运行的时候给内存中的所有变量都加上标记,然后去掉执行环境中的变量以及被执行环境中变量引用的变量的标记。最后,浏览器定期执行内存清理,销毁那些带标记的值,释放占用内存。到2008年为止,IE、Firefox、Opera、Chrome、Safari 的 JavaScript 引擎都使用标记清除垃圾收集策略(或类似策略),不过周期长短有差异。
  • 引用计数(reference counting)。引用计数即跟踪记录每个值被引用次数。声明一个变量并将一个引用类型赋值给该变量时,这个值得引用次数就加1;同一个值又被赋给另一个变量时,该值的引用次数再加1;如果包含对这个值引用的变量又取得另外一个值,则这个值的引用次数减1。当某个值的引用次数变为0时,就会被垃圾收集器销毁、释放占用内存。这个策略遇到循环引用时会发生严重问题,已被浏览器产商弃用。
IE 的JavaScript引擎使用标记清除策略,但 IE 中有一部分对象并不是原生 JavaScript 对象,其 BOM 和 DOM 中的对象就是使用 C++ 以 COM 对象的形式实现。COM 对象的垃圾收集机制采用引用计数策略,IE 中只要涉及 COM 对象,就会存在循环引用问题。
var element = document.getElementById("some_el");
var myObj = new Object();
myObj.element = element;
element.someObj = myObj;

// DOM 和 JavaScript 原生对象之间创建循环引用,即使 DOM 从页面中移除,也永远不会被回收

//为避免类似的循环引用问题,最好在不使用时手工断开连接
myObj.element = null;
element.someObj = null;

IE 9 把 DOM 、BOM 对象都换成了真正的 JavaScript 对象,避免两种垃圾收集算法并存导致的问题,消除了常见的内存泄露问题。

IE 的性能问题

垃圾收集器是周期性运行的,而且如果为变量分配的内存数量很客观,那么回收的工作量也是相当大的。此时,确定垃圾收集的时间间隔就是一个非常重要的问题。

IE6 (及以前版本?)的垃圾收集器是根据内存分配量运行的,具体说是256个变量、4096个对象(或数组)字面量和数组元素(slot)或者 64KB 的字符串。达到上述任何一个临界值时,垃圾收集器就会运行。如果一个脚本生命周期中保有那么多变量,垃圾收集器就不断运行,产生性能问题。

IE7 重写了垃圾收集例程:触发垃圾收集的变量分配、字面量和(或)数组元素的临界值被调整为动态修正。IE7 中各项临界值在初始时与 IE6 相等。如果垃圾收集例程回收的内存分配量低于 15%,则临界值加倍;如果例程回收了 85% 的内存分配量,则临界值恢复默认值。这个调整极大提升了 IE 在运行包含大量 JavaScript 的页面时的性能。

5.3 优化内存占用

虽然 JavaScript 具备垃圾收集机制,但是为了防止运行 JavaScript 的网页耗尽全部系统内存导致系统崩溃,系统分配给 Web 浏览器的可用内存通常比其他桌面应用程序少。因此,确保占用最少的内存可以让页面获得更好的性能。

优化内存占用的最佳方式,就是执行中的代码指保存必要的数据。一旦数据不在使用,最好将其设置为 null 释放引用,即解除引用(dereferenceing)。这一做法适用于大多数全局变量和全局对象的属性。局部变量则会在离开执行环境是自动解除引用。解除引用的作用是让值脱离执行环境,以便垃圾收集器下次运行时将其回收。

一个面试题

var v = 1;
function f1() {
  console.log(v);
}

function f2() {
  v = 2;
  console.log(v);
}

function f3(v) {
  var a = function() {
      console.log(v);
  }
  return a;
}

function f4() {
  //alert(v);
  v = 4;
  var v = 5;
  console.log(v);
}

var f = f3(v);

f1(); // 1 没问题
f();  // 1 没问题
f2(); // 2 很容易理解,全局变量v被修改为2
f1(); // 2 没问题
f(); // 2? -> 错,1
f4(); // 5 没问题:函数体内,局部变量优先级大于同名全局变量
f1(); // 4? -> 错:声明提前,2
f(); //4? -> 错,1

注释中有问号的三个地方是最容易出错的。

先来看倒数第二个,如果没有理解声明提前,就会认为 f4() 执行的时候将全局变量v的值覆盖了。实际不是,将上面 f4 函数体内的 alert 前面的注释删了试试,弹出来的是 2 吗? 不是!f4 执行时其实被「预编译」成下面的样子:

function f4() {
  var v; // 声明提前至最前面,甚至在alert之前
  alert(v); // undefined
  v = 4; // 局部变量,全局变量中的 v 没有被修改
  v = 5;
  console.log(v);
}

关于 f 的值始终是 1,我刚开始看的时候也有点迷惑。事实上,f3(v) 中的 v 和全局变量 v 并不是同一个东西,给变量 f 赋值时,其实相当于 var f = f3(1) ,传入的是全局变量 v 的值,而不是 v 本身(按值传递?)。赋完值的函数 f 其实等同于:

f = function() {
  console.log(1); // v 再怎么变跟我有一毛钱关系
}