从这一章开始,不再是枯燥的概念,事情慢慢变得有意思了(阴险~·~)
我们现在知道了,不同于其他语言,ECMAScript中的变量只是一个毫无感情的盒子,用来装特定值。不管这个值的类型是什么,只要给它它都能装;也不管它曾经装过什么,它都能满足你的需求。虽然这个特性很好用,但是也容易出问题,我们现在就来深究深究。
1 基本类型和引用类型
我们在前面讲过,ECMAScript中的变量可以包含两种数据类型的值,即基本类型和引用类型。基本类型包括Undefined, Null, String, Boolean, Number,这五种基本数据类型是按值访问的。基本类型的值在内存中占据固定大小的空间,所以被保存在栈中。
而引用类型的值是保存在内存中的对象,它保存在堆内存中。与其他语言不同,JavaScript不允许不允许直接访问内存中的位置,即不能直接操作对象的内存空间。我们在操作对象时,实际上是在操作对象的引用而不是实际的对象。因此,引用类型的值是按引用访问的。
1.1 动态的属性
我们只能给引用类型的值添加属性和方法。如下例子,动态给两个对象添加属性:
var obj1 = new Object();
var obj2 = {
name: 'John';
}
obj1.name = 'Sarah';
obj2.age = 25;
console.log(obj1); // { name: 'Sarah' }
console.log(obj1.name); // 'Sarah'
console.log(obj2.age); // 25
但是,如果我们给基本类型的值添加属性,我们可以看到下面的结果:
var name = 'Li Lei';
name.age = 25;
console.log(name); // 'Li Lei'
console.log(name.age); // undefined
可以看到,给基本类型赋值,虽然不会报错,但是是没用的。
所以,动态添加属性和方法只适合引用类型。
1.2 复制变量值
基本类型和引用类型在变量复制的时候也有很大的区别。首先,我们需要知道的是,我们每声明一个变量,内存都会给这个变量分配一块空间。当变量之间复制基本类型的值时,会在变量对象上创建一个新值,然后把该值复制到为新变量分配的位置上。举个栗子:
var num1 = 1;
var num2 = num1;
上面的代码流程是这样的:
- 声明num1, 然后在num1中保存一个数值1;
- 声明num2,在num2中也保存一个数值1。但是该数值1和num1中的数值1完全独立,num2中的数值1是从num1中复制过来的,是num1中值的副本。
如图:
但是,当变量之间复制引用类型的值时,虽然也会将变量中对象的值复制一份给新变量,但其实我们这里实际复制的是一个指针,这个指针指向存储在堆中的一个对象。所以,实际上,这两个变量引用的是同一个对象。修改一个变量中的值,另一个变量中的值也会发生变化。举个栗子:
var obj1 = {
name: 'Mark',
age: 18
};
var obj2 = obj1;
obj1.name = 'Lily';
console.log(obj2.name); // 'Lily'
从上面的代码我们能看出来,obj1将值赋给obj2后,修改obj1中的值,obj2中的值也跟着改变了。这是因为它们引用的都是同一个变量。下面这张图展示的就是保存在变量中的"对象"和保存在内存中的对象的关系:
1.3 传递参数
ECMAScript中所有函数的参数都是按值传递的。也就是说,把需要传递的值复制一份给函数内部的参数(即命名参数,或者说是arguments中的一个元素)。基本类型值的传递和基本类型值复制一样,引用类型的值传递也和引用类型的值复制一样。
我们在上面一小节说过,引用类型的值是按引用访问的,但参数只能按值传递。
基本类型的值传递比较简单,在这里就略过,我们直接看引用类型的传递。如下例一:
function setName(obj) {
obj.name = 'Sarah';
}
var person = {
name: 'Mike'
};
setName(person);
console.log(person.name); // 'Sarah'
上面例一中,对象person传递给函数setName后复制给了obj。在这个函数内部,obj和person引用的是同一个对象。但是这里还是要强调一下,尽管在函数内部修改obj,函数外的person对象也会做相应的修改,但参数并不是按引用传递的,而是按值传递的。我们来验证一下,看下面的例二:
function setName(obj) {
obj.name = 'Sarah';
obj = new Object();
obj.name = 'Li Lei';
}
var person = {
name: 'Mike'
};
setName(person);
console.log(person.name); // 'Sarah'
结果会输出'Sarah',如果是按引用传递的,那么person的name也会更新成'Li Lei',但这里仍然是'Sarah'。这说明,在函数内部修改了参数值,但实际引用并没有被修改。在例二的代码中,函数重写obj时,变量引用的就是一个局部变量了,这个变量在函数执行完成后会立即销毁。
1.4 检测类型
要检测一个变量是否为基本数据类型,最佳方案是直接使用typeof操作符,以确定一个变量是字符串,数值,布尔值,还是undefined。但是,若一个变量的值是null或者一个对象,typeof会直接返回object。如下:
var str = 'hello world';
console.log(typeof str); // string
var num = 6;
console.log(typeof num); // number
var flag = true;
console.log(typeof flag); // boolean
var a = undefined;
console.log(typeof a); // undefined
var b = null;
console.log(typeof b); // object
var c = {
name: 'Mike'
};
console.log(typeof c); // object
var d = function() {
var test = 1
};
console.log(typeof d); // function
var e = [1, 2, 3];
console.log(typeof e); // object
注意:typeof返回的结果是一个字符串。引用类型是函数时,typeof会返回'function'。
从上面我们能看出来,当引用类型是数组,对象,null时,用typeof操作符返回的都是object。所以,若我们想检测一个变量是什么类型的变量,可以用instanceof操作符,如下:
var test1 = { name: 'Jack' };
var test2 = [1, 2, 3, 4];
var test3 = function() {console.log(123);};
console.log(test1 instanceof Object); // true
console.log(test2 instanceof Array); // true
console.log(test3 instanceof Function); // true
若变量为基本类型的值,则用instanceof,始终返回false;若变量是引用类型值时,使用instanceof Object,始终返回true,接着上面的代码我们可以验证:
console.log(test2 instanceof Object); // true
console.log(test3 instanceof Object); // true
这涉及到原型链的知识,到后面我们再详细讲。
2 执行环境及作用域
执行环境呢,就可以理解为活动范围。你在某个范围内能做一些事情,也能拿到一些东西用。比如睡觉要在卧室睡,那卧室就是执行环境。放在代码中,就是定义了变量或函数有权访问的其他数据,决定了他们各自的行为。
而作用域链呢,就是为了防止你在你的活动范围内乱跑,给你规定了一条活动路线,这条活动路线是有方向有轨道的。也就是。是保证变量或者函数有序地访问他们有权访问的执行环境。
我们来看一段代码:
var name = "Joe";
function getName() {
var age = 18;
function getAge() {
var country = 'China';
console.log('My name is ', name);
console.log('I am ', age);
console.log('I come from ', country);
}
getAge();
}
getName();
最后输出结果:
'My name is Joe'
'I am 18'
'I come from China'
这里我们分析一下上段代码的执行环境和作用域:
首先,上面代码有3个执行环境:全局环境、getName()的局部环境和getAge()的局部环境。用一张图来显示整个执行环境如下:
蓝色字体为各执行环境中的变量。上图中完整地展示了一条作用域链:内部环境可以通过作用域链访问所有外部环境的变量,但外部环境不能访问内部环境中的任何变量和函数。这些环境之间的联系是线性,有序的。
2.1 延长作用域链
对于下面两种情况,作用域链会加长。也就是会在作用域链的前端临时增加一个变量对象。
首先是with。
with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。用法如下:
var obj = {
a: 1,
b: 2,
c: 3
};
// 单调乏味的重复 "obj"
obj.a = 7;
obj.b = 8;
obj.c = 9;
// 简单的快捷方式
with (obj) {
a = 7;
b = 8;
c = 9;
}
那下面给一段代码,来解释with具体是怎么延长作用域链的:
function step() {
var card = {
name: 'Joe',
age: 18
};
with (card) {
name = 'Li Lei';
country = 'China';
}
console.log(card.name); // 'Li Lei'
console.log(card.country); // undefined
console.log(country); // 'China'
}
step()
console.log(card); // 报错 "card is not defined"
我们再前面知道,使用with可以便捷地修改对象中的属性值,如上代码,成功在with中修改name属性为'Li Lei'。
但是,再往下看,with里面想给card对象增加一条属性country,但是因为原card对象中并不存在country属性。所以,注意这里,with将country变量直接放在了step()执行环境中,也就是说,country现在是step()执行环境中的一个变量。这就好似在一个函数中,不使用var直接声明变量,变量会保存至全局环境中。
所以说。country就是加在作用域前端的那个延长变量。
然后是try catch
请看下面代码:
try{
fn();
} catch (ex) {
handleError(ex);
}
同上面的道理类似,在catch(e){}中的错误对象组成了一个新的变量对象然后被加到了作用域的最前端。
2.2 没有块级作用域
不像其他类C语言,JavaScript没有块级作用域。这里的块一般指的是由花括号封闭的代码块。如下:
if (true) {
var test = 1;
}
console.log(test); // 1
在ECMAScript中,用var声明的变量会自动添加到最接近的环境中(即如果在函数中,则最接近的环境就是函数的局部环境)。初始化时若没有使用var,则变量将会添加到全局环境中。
至于变量的访问方式,就是按照作用域链,一层一层的往上找。若找到变量,则停止往上查询。
3 垃圾收集
JavaScript具有垃圾自动收集的功能,不像类C语言那样需要开发者手动跟踪管理内存。JavaScript的垃圾管理器会定时周期性地释放管理内存。
我们来看看函数局部变量的正常生命周期。局部变量只在函数的执行的过程中存在,这个过程中,对应的栈或堆会给局部变量分配相应的内存空间来存储它们,函数执行时会使用这些局部变量。函数执行完,局部变量就没有存在的意义了。这时候应该释放内存。所以垃圾管理器会标记出不再有用的局部变量,以便垃圾回收。至于怎么识别哪些变量有用哪些没用,大致有两种策略:
3.1 标记清除
标记清除是JavaScript中最常用的垃圾处理方式。垃圾处理器在运行的时候会给存储在内存中的所有变量加上一个标记,若变量正在环境中或被环境中的变量所引用时,那么将删除这个变量的标记。所以最后,带有标记的变量就是没被访问的变量或者不会被使用的变量,可以被删除销毁,释放其占用的内存。到2008年为止,很多浏览器都是使用标记清除的垃圾收集战略,只是垃圾收集的时间间隔不同。
3.2 引用计数
不太常见的一种策略是引用计数。引用计数就是跟踪每个值被引用的次数。
当声明了一个变量,并将一个引用类型的值A赋值给这个变量时,那么这个值(A)的引用次数就是1;如果A又被赋给了另一个变量,那它的引用次数就+1;如果一个变量本来的值是A,现在又被赋予了另一个值,那么A的引用次数就-1。当A的引用次数变成0时,就说明没有变量引用它啦,可以将占用的内存回收啦。当垃圾收集器下次再运行时,就会释放这个值占用的内存。
但是仔细想想,这样做有一个很严重的问题。即循环引用,如下:
var a = new Object();
var b = new Object();
a.hasB = b;
b.hasA = a;
a和b都被各自引用,互相抓着不放有什么办法。
对于标记清除,在函数中这样,函数一旦结束双双都被释放;但是用引用计数的方法,若函数被多次调用,引用数只会越来越多,永远不会变成0。
IE中有一些对象并不是原生的,如BOM,DOM,这两个是使用C++,以COM(组件对象模型)对象的形式实现的。而COM的垃圾收集机制就是引用计数策略。因此,即使IE的JavaScript的垃圾收集机制是标记清除,但访问的COM对象,依旧是使用引用计数策略进行垃圾收集。如图,就会出现循环引用:
var element = document.getElementById('id');
var obj = new Object();
obj.hasElement = element;
element.hasObj = obj;
所以,为了避免这种情况,不使用这个引用数据时,我们要手动断掉其相互的连接,如下:
obj.hasElement = null;
element.hasObj = null;
3.3 性能问题&内存管理
IE的垃圾收集器是根据内存分配量运行的,以前会设定一个临界值,到达这个临界值会运行垃圾收集器,但这样固定的分配方式会导致一些性能问题。后来随着IE7的发布改变了这一工作方式,触发垃圾收集的变量分配,字面量或数组元素的临界值被调整为动态修正。这样极大地提升了性能。
我们怎么确保占用最少的内存且让页面获取更好的性能呢?
优化的最佳方式,就是保证执行的代码只保存必要的数据。一旦数据不再使用,用null释放其引用。这个做法叫做解除引用,上面的例子中我们已经使用过了。
这一方法适于大多全局变量和全局对象的属性,因为局部变量会在离开执行环境时自动被解除引用。
不过,解除一个值的引用并不意味着自动回收该值所占的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾收集器下次运行时将其回收。
又看完一章啦,继续加油呀。
(刚刚总览了一遍,发现自己写得真的很啰嗦啊。从下一章起就只捡重要的和经常被忽略的问题讲啦~~~)