第4章 变量 作用域 内存
第4章 变量、作用域、内存
原始类型和引用类型
原始类型只是单个的数据片段,引用类型是多种值组成的对象。六大原始类型:Undefined、Bigint、Boolean、String、Number、Symbol。这些类型是按值访问的,操纵的是变量中存储的实际值。
引用值是存储在内存中的对象,JS不允许直接访问内存位置。所以引用类型是按引用访问的。只有引用类型可增加、修改、删除属性和方法。
通过new创建的一些原始类型可以像引用类型一样使用。
let name1 = 'Ciri';
let name2 = new String('Geralt');
name1.age = 18;
name2.age = 108;
name2.say =function(){return 'hello';};
console.log(name1.age);//undefined
console.log(name2.age);//108
console.log(name2.say());//hello
ECMAScript里所有的函数参数都按值传递,引用类型复制的是指针。
let a = 6;
let b = 6;
console.log(a === b);//true
let x = {};
let y = {};
console.log(x === y);//false
let a = {x:1};
function test(o){o.x = 2;};
test(a);
console.log(a.x);//2
执行环境和作用域
执行环境决定了变量或函数可以访问的其他数据及其行为方式,每个执行环境都有一个与之关联的变量对象,这个对象包含了所有已定义的变量和函数。当执行环境执行完所有代码后会被销毁(全局执行环境的变量对象会在应用程序退出时销毁,如关闭网页或浏览器),包括其中定义的变量和函数。
全局执行坏境是最外面的那个,取决于宿主环境的ECMAScript实现。在web浏览器中全局执行环境是window对象,所以使用var定义的全局变量和函数是window对象的属性和方法;而使用const和let定义的变量不会添加到全局执行环境,但在作用域链中可以访问到。
每次函数调用都有自己的执行环境,代码执行到达函数时,函数的执行环境被推入执行环境栈,函数执行完成后弹出,将控制权交还给之前的执行环境。
当代码在执行环境内执行时,将创建以变量对象为基础的作用域链。作用域链的目的是提供对执行环境可以访问的所有变量和函数的有序访问。作用域链的最前端始终是当前执行环境的变量对象。如果执行环境是函数时,使用叫激活对象的变量对象,激活对象以一个叫arguments的变量开头(全局执行环境中没有)。链中的下一个变量对象来自父执行环境,以此类推,链尾为全局执行环境的变量对象。
标识符从作用域链的前端开始向后查找,若未找到,通常会抛出异常。如下所示:
let color = "blue";
function changeColor() {
if (color === "blue") {
color = "red";
console.log(color);
} else {
color = "dark";
}
}
changeColor(); //red
在这个例子中,函数changeColor()的作用域链有两个变量对象:其自身的变量对象(上面有arguments对象)和全局执行环境的变量对象。变量color可在函数内访问,因为该变量在其作用域链中。
作用域链增强
有两种基本的执行环境:全局执行环境和函数执行环境(第三种在eval()内)。还有其他方式加长作用域链,某些语句会导致在作用域链前端添加临时变量对象随后移除之。主要有两种情况:
try-catch语句中的catch块
with语句
with语句会添加特定的对象到作用域链,catch语句的话,会创建新的变量对象,该对象包含抛出的异常对象的声明。如下所示:
function buildUrl() {
let qs = "?debug=true";
with(location) {
let url = href + qs;
}
return url;
}
在这个例子中,location对象会被添加到作用域链前端。当引用href时,实际引用的是location.href。href在自己的变量对象上。
变量声明
函数作用域中使用var声明
当使用var声明变量时,会添加到最直接的执行环境中。在函数中是函数的局部执行环境;在with语句中是最直接的函数执行环境。 如果一个变量初始化的地方不是声明的地方, 将会添加到全局执行环境 :
function add(num1, num2) {
var sum = num1 + num2;
return sum;
}
let result = add(10, 20); // 30
console.log(sum); // causes an error: sum is not a valid variable.
function add(num1, num2) {
sum = num1 + num2; //严格模式将报错
return sum;
}
let result = add(10, 20); // 30
console.log(sum); // 30
const作用域同let。
作用域链上的对象也有原型链,所以搜索可能包含原型链。
垃圾回收
Javascript是垃圾回收语言,意味着代码执行期间执行环境负责内存管理。基本原理很简单:找出不使用的变量在内存中释放掉它们。这个过程是周期性的,垃圾回收器以指定的间隔运行。垃圾回收的过程是一个近似且不完善的解决方案,因为知道是否需要某些内存的一般问题是“不确定的”,这意味着无法用算法解决。
函数局部变量的生命周期只在函数执行期间存在,此时,在栈(或堆)上为变量分配内存空间,函数执行完成后,这些变量就不再需要了,其内存可被重新分配使用。但事实上情况并非如此,垃圾回收器必须跟踪哪些变量可以使用,哪些不能使用,以便识别可能的回收对象。识别不再使用的变量的策略主要有两种:标记和擦除,引用计数。
标记和擦除
当一个变量在函数内声明时,会将其标记为在执行环境中;当一个变量不在执行环境中时,会被标记为在执行环境外。
变量标记有很多种方式,如当一个变量位于执行环境中时,将一个特定的位翻转。或当变量在执行环境中或移除时将其放在一个"in-context"变量列表或"out-of-context"变量列表中。
当垃圾回收器运行时,将通过各种方式标记内存中的变量,然后清除变量的标记,无论是执行环境中的变量还是执行环境中变量引用的变量。随后准备删除这些变量,然后垃圾回收器执行"memory sweep",销毁这些值和恢复相关联的内存。
引用计数
当声明一个变量并赋一个引用值时,其引用数是一,如果另一个变量赋予相同引用值时,其引用数加一。当引用数为零时,可以安全的回收相应内存。引用计数有一个问题,那就是循环引用:
function problem() {
let objectA = new Object();
let objectB = new Object();
objectA.someOtherObject = objectB;
objectB.anotherObject = objectA;
// objectA = null;
// objectB = null;
}
这个例子中objectA和objectB通过各自的属性相互引用彼此,这导致它们的引用数始终为 2 。当函数多次调用时,这将导致大量内存不能被重新分配。
性能
垃圾回收器周期的运行可能是个昂贵的过程,如果在内存中有大量的变量分配的话。所以垃圾回收的时间很重要,特别是在系统内存有限的移动设备上,垃圾回收会显著降低渲染速度和帧速率。
现代垃圾回收器何时运行取决于JavaScript运行时环境。这些方法因引擎而异,但它们大都取决于已分配对象的大小和数量。
内存管理
引用型变量不使用时可将其设置为null:
function createPerson(name) {
let localPerson = new Object();
localPerson.name = name;
return localPerson;
}
let globalPerson = createPerson("Ciri");
// 其他事情...
globalPerson = null;
多使用let和const以提升性能。
内存泄漏
编写不当的JavaScript可能会产生一些隐患。在内存有限的设备上,或者在多次调用函数的执行环境中,这可能会导致大问题。绝大多数情况下,JavaScript中的内存泄漏是由不需要的引用引起的。
最常见且容易修复的内存泄漏是不小心声明了全局变量:
function setName() {
name = 'Ciri';
}
如果window对象不清理的话这些属性会一直存在。
间隔计时器也可以悄悄地导致内存泄漏,如下所示,其变量通过闭包访问:
let name = 'Ciri';
setInterval(() => {
console.log(name);
}, 100);
只要间隔计时器在运行,处理器函数就保持对name属性的引用,所以垃圾回收器无法清除此变量。
Javascript闭包非常容易导致内存泄漏:
let outer = function() {
let name = 'Ciri';
return function() {
return name;
};
};
这段代码创建了一个内部闭包,只要outer函数存在,name变量就不能被清除,因为该变量会一直通过闭包引用,如果name的内容非常大会导致问题。
静态分配和对象池
如果创建大量对象很快又不再使用,垃圾回收将会更频繁。如下所示:
function addVector(a, b) {
let resultant = new Vector();
resultant.x = a.x + b.x;
resultant.y = a.y + b.y;
return resultant;
}
此函数调用时将在堆上创建一个新对象,并修改之,然后返回给调用者。如果vector对象的生命周期很短,将很快失去引用且可被垃圾回收。如果多次调用此函数将导致更频繁的垃圾回收。可使用存在的vector对象:
function addVector(a, b,resultant) {
resultant.x = a.x + b.x;
resultant.y = a.y + b.y;
return resultant;
}
当然,这要求resultant向量参数必须是新鲜的并在其他地方实例化,但是此函数的行为与上面是相同的。一种优化的方案是使用对象池,在初始化的某些时刻,可创建一个对象池来管理可回收的对象。应用可从池子中重新获取对象,设置其属性并使用,用完之久再扔到池子中。因为没有对象实例化发生,所以垃圾回收频率会低很多。对象池伪代码如下:
// vectorPool为存在的对象池
let v1 = vectorPool.allocate();
let v2 = vectorPool.allocate();
let v3 = vectorPool.allocate();
v1.x = 10;
v1.y = 5;
v2.x = -3;
v2.y = -6;
addVector(v1, v2, v3);
console.log([v3.x, v3.y]); // [7, -1]
vectorPool.free(v1);
vectorPool.free(v2);
vectorPool.free(v3);
//如果对象有属性引用其他对象,可将它们设置为null
v1 = null;
v2 = null;
v3 = null;
如果对象池只分配vector(不存在则创建存在就重用),这种实现实质上是一种贪婪算法,这将单调的增加其静态内存。该池子必须用某种结构来维持这些vector,数组是个不错的选择。使用数组实现必须非常小心以防导致垃圾回收,如下所示:
let vectorList = new Array(100);
let vector = new Vector();
vectorList.push(vector);