第4章 变量、作用域与内存
一、原始值和引用值
原始值就是最简单的数据
引用值则是由多个值构成的对象。
在把一个值赋给变量时,JavaScript引擎必须确定这个值是原始值还是引用值。
Undefined 、Null 、Boolean、Number 、String 和Symbol 。保存原始值的变量是按值访问的,因为我们操作的就是存储在变量中的实际值。
引用值是保存在内存中的对象。JavaScript不允许直接访问内存位置,因此也就不能直接操作对象所在的内存空间。在操作对象时,实际上操作的是对该对象的引用 (reference)而非实际的对象本身。为此,保存引用值的变量是按引用 (by reference)访问的。
1.动态属性
原始值和引用值的定义方式很类似,都是创建一个变量,然后给它赋一个值。不同的是,引用值可以随时添加、修改和删除其属性和方法。
举个例子:
let person = new Object();
person.name = "Lihua";
console.log(person.name);
运行一哈
分析一下:首先创建了一个对象,并把它保存在变量person 中。然后,给这个对象添加了一个名为name 的属性,并给这个属性赋值了一个字符串"Lihua" 。在此之后,就可以访问这个新属性,直到对象被销毁或属性被显式地删除。
原始值不能有属性,尽管尝试给原始值添加属性不会报错。
let name = "Nicholas";
name.age = 27;
console.log(name.age);
运行一哈
注意:如果使用的是new 关键字,则JavaScript会创建一个Object 类型的实例,但其行为类似原始值。
let name1 = "Nicholas";
let name2 = new String("Matt");
name1.age = 27;
name2.age = 26;
console.log(name1.age);
console.log(name2.age);
console.log(typeof name1);
console.log(typeof name2);
运行一哈
2.复制值
除了存储方式不同,原始值和引用值在通过变量复制时也有所不同。
举个例子:
let num1 = 5;
let num2 = num1;
console.log(num2)
运行一哈
这两个变量可以独立使用,互不干扰。
在把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。
let obj1 = new Object();
let obj2 = obj1;
obj1.name = "Lihua";
console.log(obj2.name);
运行一哈:
分析一下:变量obj1 保存了一个新对象的实例。然后,这个值被复制到obj2 ,此时两个变量都指向了同一个对象。在给obj1 创建属性name 并赋值后,通过obj2 也可以访问这个属性,因为它们都指向同一个对象。
3. 传递参数
ECMAScript中所有函数的参数都是按值传递的。函数外的值被复制到函数内部的参数中。如果是原始值,跟原始值变量的复制一样,如果是引用值,跟引用值变量的复制一样。变量按值和按引用访问,传参只有按值传递。
按值传递参数时,值会被复制到一个局部变量
引用传递参数时,值在内存中的位置会被保存在一个局部变量,意味着本地变量的修改会反映到函数外部。
下面看一个例子:
function addTen(num) {
num += 10;
return num;
}
let count = 20;
let result = addTen(count);
console.log(count);
console.log(result);
运行一哈:
addTen()有一个参数num,它是一个局部变量。在调用时,变量count作为参数传入。count值是20,值被复制到参数num在addTen()内部使用。在函数内部,参数num的值加上10,不会影响到函数外部的原始变量count。如果num是按照引用传递的,count值会被修改为30。
如果变量中传递的是对象,看这个例子。
function setName(obj) {
obj.name = "Nicholas";
}
let person = new Object();
setName(person);
console.log(person.name);
我们创建了一个对象并把它保存在变量person 中。然后,这个对象被传给setName() 方法,并被复制到参数obj 中。在函数内部,obj 和person 都指向同一个对象。结果就是,即使对象是按值传进函数的,obj 也会通过引用访问对象。当函数内部给obj 设置了name属性时,函数外部的对象也会反映这个变化,因为obj 指向的对象保存在全局作用域的堆内存上。
很多人错误地认为:当在局部作用域中修改对象而变化反映到全局时,就意味着参数是按引用传递的。
再看下面的例子:
function setName(obj) {
obj.name = "Nicholas";
obj = new Object();
obj.name = "Greg";
}
let person = new Object();
setName(person);
console.log(person.name);
当person传入setName()时,name属性被设置为“Nicholas”。变量obj被设置为一个新对象且name属性被设置为“Greg”。如果person按引用传递,那么person应该自动将指针改为指向name为“Greg”的对象当我们再次访问person.name 时,它的值是"Nicholas" ,这表明函数中参数的值改变之后,原始的引用仍然没变。当obj 在函数内部被重写时,它变成了一个指向本地对象的指针。而那个本地对象在函数执行结束时就被销毁了。
4. 确定类型
typeof操作符用来一个变量是否为字符串、数值、布尔值或undefined的最好方式。如果值是对象或null,那么typeof返回“object”
例子:
let s = "Nicholas";
let b = true;
let i = 22;
let u;
let n = null;
let o = new Object();
console.log(typeof s);
console.log(typeof i);
console.log(typeof b);
console.log(typeof u);
console.log(typeof n);
console.log(typeof o);
想知道它是什么类型的对象,用instanceof操作符
看下面的例子:
console.log(person instanceof Object);
console.log(colors instanceof Array);
console.log(pattern instanceof RegExp);
按照定义,所有引用值都是Object实例,因此通过instanceof操作符检测任何引用值和Object构造函数都会返回true。如果instanceof检测原始值,始终返回false,因为原始值不是对象
二、执行上下文与作用域
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为
全局上下文是最外层的上下文,在浏览器中,全局上下文就是我们常说的window对象,因此通过var定义的全局变量和函数都会成为window对象的属性和方法。使用let和const的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。上下文在其所有代码都执行完毕后被销毁,包括定义在它上面的所有变量和函数
上下文中的代码在执行的时候,会创建变量对象的一个作用域链。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。
这时候要说说面试常问的问题了,面试官:来说说作用域链,我:巴拉巴拉一堆
作用域链:代码执行时标识符解析通过作用域逐级搜索标识符名称完成。搜索过程始终从作用域链的最前端开始,逐级往后,直到找到标识符。如果找不到标识符,通常会报错。
举个例子:
var color = "blue";
function changeColor() {
if (color === "blue") {
color = "red";
} else {
color = "blue";
}
}
changeColor();
console.log(color)
函数内部之所以能够访问变量color,是因为可以在作用域链中找到它
再看一个例子:
var color = "blue";
function changeColor() {
let anotherColor = "red";
function swapColors() {
let tempColor = anotherColor;
anotherColor = color;
color = tempColor;
}
swapColors();
}
changeColor();
console.log(color)
以上代码涉及3个上下文:全局上下文changeColor() 的局部上下文和swapColors() 的局部上下文。全局上下文中有一个变量color 和一个函数changeColor() 。changeColor() 的局部上下文中有一个变量anotherColor 和一个函数swapColors() ,但在这里可以访问全局上下文中的变量color 。swapColors() 的局部上下文中有一个变量tempColor ,只能在这个上下文中访问到。全局上下文和changeColor() 的局部上下文都无法访问到tempColor 。而在swapColors() 中则可以访问另外两个上下文中的变量,因为它们都是父上下文。
上图
1.作用域链增强
虽然执行上下文主要有全局上下文和函数上下文两种(eval()调用内部存在第三种上下文),有其他方式来增强作用域链。某些执行语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。
通常在两种情况下出现这个现象。
例子:
function buildUrl() {
let qs = "?debug=true";
with(location){
let url = href + qs;
}
return url;
}
with语句将location对象作为上下文,因此location会被添加到作用域链前端。buildUrl() 函数中定义了一个变量qs 。当with语句中的代码引用变量href 时,实际上引用的是location.href ,也就是自己变量对象的属性。在引用qs 时,引用的则是定义在buildUrl() 中的那个变量,它定义在函数上下文的变量对象上。而在with 语句中使用var 声明的变量url 会成为函数上下文的一部分,可以作为函数的值被返回;但像这里使用let 声明的变量url ,因为被限制在块级作用域,所以在with 块之外没有定义。
2.变量声明
(1)使用var函数作用域声明
使用var声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。在with语句中,最接近的上下文也是函数上下文。如果变量未经声明就初始化了,它就会自动被添加到全局上下文。
function add(num1, num2) {
var sum = num1 + num2;
return sum;
}
let result = add(10, 20);
console.log(sum);
函数add()定义了一个局部变量sum,保存加法操作的结果。这个值作为函数的值被返回,但变量sum在函数外部是访问不到的。如果省略关键字var,那么sum在add()被调用之后就变成可以访问的了。
function add(num1, num2) {
sum = num1 + num2;
return sum;
}
let result = add(10, 20);
console.log(sum);
var声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这个现象叫做“提升”。
因为变量提升导致合法但是看起来奇奇怪怪的现象,比如:
var name = "Jake";
name = 'Jake';
var name;
function fn1() {
var name = 'Jake';
}
function fn2() {
var name;
name = 'Jake';
}
通过在声明之前打印变量,可以验证变量会被提升。声明的提升意味着输出undefiend而不是Reference Error
console.log(name);
var name = 'Jake';
function() {
console.log(name);
var name = 'Jake';
}
2.使用let的块级作用域声明
let关键字和var很相似,但它的作用域是块级的。
{}界定。换句话说,if块、while块、function块,和单独的块也是let声明变量的作用域
if (true) {
let a;
}
console.log(a);
while (true) {
let b;
}
console.log(b);
function foo() {
let c;
}
console.log(c);
{
let d;
}
console.log(d);
let同一作用域内不能声明两次。重复的var声明会被忽略,重复的let声明会抛出SyntaxError
var a;
var a;
{
let b;
let b;
}
let的行为非常适合在循环中声明迭代变量。使用var声明的迭代变量会泄露到循环外部,这种情况应该避免。
for (var i = 0; i < 10; ++i) {}
console.log(i);
for (let j = 0; j < 10; ++j) {}
console.log(j);
严格来讲,let在js运行时会被提升,但由于“暂时性死区”的缘故,实际上不能在声明变量前使用let变量。
3.使用const常量声明
除了let,ES6同时还增加了const关键字。使用const声明的变量必须同时初始化为某个值。一经声明,在其生命周期的任何时候都不能再重新赋值
const a;
const b = 3;
console.log(b);
b = 4;
const除了要遵循以上规则,其他方面和let声明是一样的
if (true) {
const a = 0;
}
console.log(a);
while (true) {
const b = 1;
}
console.log(b);
function foo() {
const c = 2;
}
console.log(c);
{
const d = 3;
}
console.log(d);
赋值为对象的const变量不能再被重新赋值为其他引用值,但对象的键则不受限制
const o1 = {};
o1 = {};
const o2 = {};
o2.name = 'Jake';
console.log(o2.name);
如果想让整个对象都不能修改,可以使用Object.freeze(),这样再给属性赋值时虽然不会报错,但会静默失败
const o3 = Object.freeze({});
o3.name = 'Jake';
console.log(o3.name);
4.标识符查找
当在特定上下文中为读取或写入而引用一个标识符时,必须通过搜索确定这个标识符表示什么。搜索开始于作用域链前端,以给定的名称搜索对应的标识符。如果局部上下文中找到该标识符,则搜索停止,变量确定。如果没有找到变量名,则继续沿作用域链搜索。如果没有找到标识符,通常报错。
例子:
var color = 'blue';
function getColor() {
return color;
}
console.log(getColor());
分析:调用函数getColor()时会引用变量color。第一步:搜索getColor()的变量对象,查找名为color的标识符。没有找到,继续搜索下一个变量对象(来自全局上下文),然后就找到了名为color的标识符。因为全局变量对象上有color的定义,所以搜索结束。
对于整个搜索过程,引用局部变量会让搜索自动停止,不继续搜索下一级变量对象。也就是说,如果局部上下问中有一个同名的标识符,那就不能在该上下文中引用父上下文中的同名标识符。
var color = 'blue';
function getColor() {
let color = 'red';
{
let color = 'green';
return color;
}
}
console.log(getColor());
分析:getColor()内部声明了一个名为color的局部变量。在调用这个函数的时候,变量会被声明。在执行到函数返回语句时,代码引用了变量color。于是开始在局部上下文中搜索这个标识符,找到了值“green”的变量color。因为变量已经找到,搜索随即停止,所以就使用这个局部变量。意味着函数会返回“green”。在局部变量color声明之后的任何代码都无法访问全局变量color,除非使用完全限定的写法window.color
三、垃圾回收
1.标记清理
js最常用的垃圾回收策略是标记清理。当变量进入上下文,比如在函数内部生米给一个变量时,这个变量会被加上存在于上下文中的标记。在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,会被加上离开上下文的标记。
给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位,或者可以维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。标记过程的实现不重要,关键是策略。
垃圾回收程序运行的时候,会标记内存中存储的所有变量。然后它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。
2.引用计数(我觉得知道了解一下就行)
3.性能
垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。开发者不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。
4.内存管理
js运行在一个内存管理和垃圾回收都很特殊的环境,分配给浏览器的内存通常比分配给桌面软件的少很多,分配给移动浏览器的更少。出于安全考虑,为了避免运行大量js的网页耗尽系统内存而导致操作系统崩溃。内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量
将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为null ,从而释放其引用。这也可以叫作解除引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用
举个例子:
function createPerson(name){
let localPerson = new Object();
localPerson.name = name;
return localPerson;
}
let globalPerson = createPerson("Nicholas");
globalPerson = null;
分析:globalPerson保存着createPerson()函数调用返回的值。在createPerson()内部,localPerson创建了一个对象并给它添加了一个name属性。然后,localPerson作为函数值被返回,并被赋值给globalPerson。localPerson在createPerson()执行完成超出上下文后会自动被解除引用,不需要显式处理。
但globalPerson是一个全局变量,应该在不再需要时手动解除其引用,最后一行是这个作用。
注意:解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收
以下部分了解了解: #### 1.通过const和let声明提升性能
ES6增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为const 和let 都以块(而非函数)为作用域,所以相比于使用var ,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。
2.隐藏类和删除操作
根据JavaScript所在的运行环境,有时候需要根据浏览器使用的JavaScript引擎来采取不同的性能优化策略。截至2017年,Chrome是最流行的浏览器,使用V8 JavaScript引擎。V8在将解释后的 JavaScript代码编译为实际的机器码时会利用“隐藏类”。如果你的代码非常注重性能,那么这一点可能对你很重要。运行期间,V8会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8会针对这种情况进行优化,但不一定总能够做到。比如下面的代码:
function Article() {
this.title = 'Inauguration Ceremony Features Kazoo Band';
}
let a1 = new Article();
let a2 = new Article();
V8会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原型。假设之后又添加了下面这行代码:
a2.author = 'Jake';
此时两个Article 实例就会对应两个不同的隐藏类。根据这种操作的频率和隐藏类的大小,这有可能对性能产生明显影响。
当然,解决方案就是避免JavaScript的“先创建再补充”(ready-fireaim)式的动态属性赋值,并在构造函数中一次性声明所有属性,如下所示:
function Article(opt_author) {
this.title = 'Inauguration Ceremony Features Kazoo Band';
this.author = opt_author;
}
let a1 = new Article();
let a2 = new Article('Jake');
这样,两个实例基本上就一样了(不考虑hasOwnProperty 的返回值),因此可以共享一个隐藏类,从而带来潜在的性能提升。不过要记住,使用delete 关键字会导致生成相同的隐藏类片段。看一下这个例子:
function Article() {
this.title = 'Inauguration Ceremony Features Kazoo Band';
this.author = 'Jake';
}
let a1 = new Article();
let a2 = new Article();
delete a1.author;
在代码结束后,即使两个实例使用了同一个构造函数,它们也不再共享一个隐藏类。动态删除属性与动态添加属性导致的后果一样。最佳实践是把不想要的属性设置为null 。这样可以保持隐藏类不 变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果。比如:
function Article() {
this.title = 'Inauguration Ceremony Features Kazoo Band';
this.author = 'Jake';
}
let a1 = new Article();
let a2 = new Article();
a1.author = null;
3.内存泄漏
写得不好的JavaScript可能出现难以察觉且有害的内存泄漏问题。在内存有限的设备上,或者在函数会被调用很多次的情况下,内存泄漏可能是个大问题。JavaScript中的内存泄漏大部分是由不合理的引用导致的。
意外声明全局变量是最常见但也最容易修复的内存泄漏问题。下面的代码没有使用任何关键字声明变量:
function setName() {
name = 'Jake';
此时,解释器会把变量name 当作window 的属性来创建(相当于window.name = 'Jake' )。可想而知,在window 对象上创建的属性,只要window 本身不被清理就不会消失。这个问题很容易解决,只要在变量声明前头加上var 、let 或const 关键字即可,这样变量就会在函数执行完毕后离开作用域。
定时器也可能会悄悄地导致内存泄漏。下面的代码中,定时器的回调通过闭包引用了外部变量:
let name = 'Jake';
setInterval(() => {
console.log(name);
}, 100);
只要定时器一直运行,回调函数中引用的name 就会一直占用内存。垃圾回收程序当然知道这一点,因而就不会清理外部变量。
使用JavaScript闭包很容易在不知不觉间造成内存泄漏。请看下面的例子:
let outer = function() {
let name = 'Jake';
return function() {
return name;
};
};
调用outer() 会导致分配给name 的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理name ,因为闭包一直在引用着它。假如name 的内容很大(不止是一个小字符串),那可能就是个大问题了。
4.静态分配与对象池
为了提升JavaScript性能,最后要考虑的一点往往就是压榨浏览器了。此时,一个关键问题就是如何减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制 触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。
浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度。如果有很多对象被初始化,然后一下子又都超出了作用域,那么浏览器就会采用更激进的方式调度垃圾回收程序运行,这样当然会影响性能。看一看下面的例子,这是一个计算二维矢量加法的函数:
function addVector(a, b) {
let resultant = new Vector();
resultant.x = a.x + b.x;
resultant.y = a.y + b.y;
return resultant;
}
调用这个函数时,会在堆上创建一个新对象,然后修改它,最后再把它返回给调用者。如果这个矢量对象的生命周期很短,那么它会很快失去所有对它的引用,成为可以被回收的值。假如这个矢量加法函数频繁被调用,那么垃圾回收调度程序会发现这里对象更替的速度很快,从而会更频繁地安排垃圾回收。
该问题的解决方案是不要动态创建矢量对象,比如可以修改上面的函数,让它使用一个已有的矢量对象:
function addVector(a, b, resultant) {
resultant.x = a.x + b.x;
resultant.y = a.y + b.y;
return resultant;
}
当然,这需要在其他地方实例化矢量参数resultant ,但这个函数的行为没有变。那么在哪里创建矢量可以不让垃圾回收调度程序盯上呢?
一个策略是使用对象池。在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。下面是一个对象池的伪实现:
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]);
vectorPool.free(v1);
vectorPool.free(v2);
vectorPool.free(v3);
v1 = null;
v2 = null;
v3 = null;
如果对象池只按需分配矢量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个实现本质上是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构维护所有对象,数组是比较好的选择。不过,使用数组来实现,必须留意不要招致额外的垃圾回收。比如下面这个例子:
let vectorList = new Array(100);
let vector = new Vector();
vectorList.push(vector);
由于JavaScript数组的大小是动态可变的,引擎会删除大小为100的数组,再创建一个新的大小为200的数组。垃圾回收程序会看到这个删除操作,说不定因此很快就会跑来收一次垃圾。要避免这种动态分配操作,可以在初始化时就创建一个大小够用的数组,从而避免上述先删除再创建的操作。不过,必须事先想好这个数组有多大。
|