第四章 变量、作用域与内存

91 阅读16分钟

很久没有写过文章了,最近的一年里,发生了好多好多事儿。另一方面我自己变得不自律了。废话不多敲,接着书说上回,我继续记录。

WechatIMG3.jpeg

本章学习内容

  • 理解原始值与引用值
  • 理解什么是执行上下文
  • 理解什么是垃圾回收机制

1、原始值和引用值

原始值: 就是最简单的数据,保存了原始值的变量是按值访问的,因为操作的就是存储在变量中的实际值。

引用值: 就是由多个值构成的对象,引用值是保存在内存中的对象里,javascript不允许直接访问内存位置,所以不能直接操作对象所在的内存空间。保存引用值的变量是按引用访问的,在操作对象时,实际上是操作对象的引用,并不是对象本身。

1.1、动态属性

let person = new Object();
person.name = "Nicholas";
console.log(person.name); // "Nicholas"

let name = "Nicholas";
name.age = 27;
console.log(name.age); // undefined

上面的代码分别声明了一个引用值和一个原始值,首先new了一个对象并且保存在person变量中,然后给这个对象添加了name属性并赋值为"Nicholas",然而原始值不能有属性,尽管强行添加属性,也会报错!

只有引用值可动态添加属性

let name1 = "Nicholas";
let name2 = new String("Matt");
name1.age = 27;
name2.age = 26;

console.log(name1.age); // undefined
console.log(name2.age); // 26
console.log(typeof name1); // string
console.log(typeof name2); // object

上面代码声明了一个原始值和一个字符串对象(在使用new关键字时,javascript会创建一个Object类型的实例),由于name2并不是一个原始值,所以age属性可以被动态添加。

1.2、复制值

除了存储方式不同,原始值和引用值的复制也有不同。

原始值: 通过一个变量将原始值赋值到另一个变量时,原始值会被复制到新的变量。

let num1 = 5;
let num2 = num1;
num1 = 6
console.log(num1) //6
console.log(num2) //5

这里,num1 包含数值 5。当把 num2 初始化为 num1 时,num2 也会得到数值 5。这个值跟存储在 num1 中的 5 是完全独立的,因为它是那个值的副本。 这两个变量可以独立使用,互不干扰。

image.png

引用值: 把引用值从一个变量赋值给另一个变量时,存储在变量中的值也会被复制到一个新的变量所在的位置,区别是这里实际上是复制了一个指针,指向堆内存中的对象地址。两个变量实际上指向的是同一个对象。

image.png

1.3 传递参数

在ECMAScript中所有函数的参数都是按值传递的,不论参数是原始值还是引用值。

function addTen(num) {
    num += 10;
    return num;
}
let count = 20;
let result = addTen(count);
console.log(count); // 20,没有变化
console.log(result); // 30

当addTen()被调用时,传入一个count变量值为原始值,函数执行时,会将count变量值复制到函数内部的num变量。此时的num和count互不干扰,所以函数内部的num变化并不会影响到函数外部的count

function setName(obj) {
    obj.name = "Nicholas";
}
let person = new Object();
setName(person);
console.log(person.name); // "Nicholas"

这里创建了一个对象,并且保存到person变量中。调用setName()时,将person对象传入,并且复制给了函数内部的obj变量,执行函数时,为obj对象的name属性设置了"Nicholas"。当person访问name属性时,值也是"Nicholas",这里就很奇怪了,明明函数的参数是按值访问的,可为什么引用值的复制会造成函数外部的引用值也受到影响?因为引用值是存在堆内存中,而引用值的复制只是将“指针”指向了堆内存中的引用值,这个地址是保存在栈队列当中的。所以函数的参数实际上是将传入变量为引用值的堆内存地址,复制给了内部变量,因为指向的都是同一个对象,所以函数内部的变化能影响到函数外部。看下边的例子就明白了。

function setName(obj) {
    obj.name = "Nicholas";
    obj = new Object();
    obj.name = "Greg";
}
let person = new Object();
setName(person);
console.log(person.name); // "Nicholas"

这里只比上面代码多了两行,在函数内部,为obj对象设置name属性后,将obj设置为一个新的对象,并且设置了新的name属性"Greg",如果person是按引用传递的,那么函数里的obj对象始终和person对象一样指向同一个地址,这样的话person.name因该是"Greg"。显然js并不想这样,实际上obj在函数内部重写时指向的是本地的指针,在函数执行结束时就会被销毁。 Tips:函数的参数就是局部变量,在函数内部属于全局变量。

1.4 确定类型

上一章里讲到了typeof操作符最适合用来判断一个变量是否为原始类型,可以精确的判断一个变量是否为字符串、数值、布尔值、undefined。

let s = "Nicholas";
let b = true;
let i = 22;
let u;
let n = null;
let o = new Object();
console.log(typeof s); // string
console.log(typeof i); // number
console.log(typeof b); // boolean
console.log(typeof u); // undefined
console.log(typeof n); // object
console.log(typeof o); // object

如果值是对象或 null,那么 typeof返回"object",原因:在 JavaScript 最初的实现中,JavaScript 中的值是由一个表示类型的标签和实际数据值表示的。对象的类型标签是 0。由于 null 代表的是空指针(大多数平台下值为 0x00),因此,null 的类型标签是 0,typeof null 也因此返回 "object"。(参考来源

image.png

typeof虽然对原始值很有用但是对引用值用处不大,ECMAScript提供了instanceof操作符,如果变量是引用类型的实例,则会返回true(由于与圆形链有关,将在第八章详细介绍) 按照定义,所有引用值都是 Object 的实例,因此通过 instanceof 操作符检测任何引用值和 Object 构造函数都会返回 true。类似地,如果用 instanceof 检测原始值,则始终会返回 false,因为原始值不是对象。

Tips:typeof 操作符在用于检测函数时也会返回"function"。当在 Safari(直到 Safari 5) 和 Chrome(直到 Chrome 7)中用于检测正则表达式时,由于实现细节的原因,typeof 也会返回"function"。ECMA-262 规定,任何实现内部[[Call]]方法的对象都应该在 typeof 检测时返回"function"。因为上述浏览器中的正则表达式实现了这个方法,所 以 typeof 对正则表达式也返回"function"。在 IE 和 Firefox 中,typeof 对正则表达式 返回"object"。

2、执行上下文与作用域

执行上下文的概念在JavaScript中是很重要的,变量或者函数的上下文决定了可以访问哪些数据以及行为。每个上下文都有一个关联的变量对象,而这个上下文中定义的所有变量和函数都存在这个对象上,虽然无法通过代码去访问这个变量对象,但是后台处理数据时会用到的。

全局上下文是最外层的上下文。根据ECMAScript实现的宿主环境,表示全局上下文的对象可能不一样,在浏览器中全局上下文就是window对象,所以所有var关键字定义的全局变量和函数都会成为window对象的属性或方法。而使用let和const的顶级声明不会定义在全局上下文中,但作用域链解析上效果是一样的。上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的变量和函数,但是全局上下文在应用程序退出前才会被销毁,比如关闭网页、退出浏览器。

每个函数调用都有自己的上下文 。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。 在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript 程序的执行流就是通过这个上下文栈进行控制的。 上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定 了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域 链的最前端。如果上下文是函数,则其活动对象(activation object)用作变量对象。活动对象最初只有 一个定义变量:arguments。(全局上下文中没有这个变量。)作用域链中的下一个变量对象来自包含上 下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终 是作用域链的最后一个变量对象。 代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称完成的。搜索过程始终从作用域链 的最前端开始,然后逐级往后,直到找到标识符。(如果没有找到标识符,那么通常会报错。)

var color = "blue"; 
function changeColor() { 
    //这里可访问全局的color
 if (color === "blue") { 
 color = "red"; 
 } else { 
 color = "blue"; 
 } 
} 
changeColor();
console.log(color);  //red 

上面的函数changeColor()的作用域链包含两个对象①函数自身的arguments对象,②全局上下文的变量对象。这个函数之所以能够访问到color是因为在作用域链中能找到它。

此外局部作用域中定义的变量可用于在局部上下文中替换全局变量:

var color = "blue";
function changeColor() {
    let anotherColor = "red";
    function swapColors() {
        let tempColor = anotherColor;
        anotherColor = color;
        color = tempColor;
        // 这里可以访问 color、anotherColor 和 tempColor
    }
    // 这里可以访问 color 和 anotherColor,但访问不到 tempColor
    swapColors();
}
// 这里只能访问 color
changeColor();

上面代码里存在三个上下文分别是全局上下文、changeColor()的局部上下文、swapColor()的局部上下文。

  1. 现在全局上下文中有一个color变量还有changeColor()函数。
  2. 在changeColor()局部上下文中有anoutherColor变量和swapColor()函数,在此可以访问全局上下文的color变量。
  3. swapColors()的局部上下文中有一个tempColor变量,这个变量只能在当前上下文中可以访问,这里还可以访问全局上下文和changeColor()的局部上下文。

image.png

如图所示,矩形块来表示不同的上下文,线条表示作用域链,上下文之间的连线是线性且有序的,每个上下文都可以访问父级的上下文,但是访问不了下级上下文。

Tips: 函数参数被认为是当前上下文中的变量,所以跟上下文中的其他变量遵循相同访问规则

2.1 作用域链增强

虽然执行上下文只要有全局上下文和函数上下文两种,但是有其他方式来增强作用域链。try/catch的catch块和with语句会导致在作用域链前端临时增加一个上下文,此上下文在代码执行后就会被删除。不同的是with语句会指定一个对象去添加,catch是创建一个新的变量对象,去抛出错误。

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 块之外没有定义。

Tips:IE 的实现在 IE8 之前是有偏差的,即它们会将 catch 语句中捕获的错误添加到执 行上下文的变量对象上,而不是 catch 语句的变量对象上,导致在 catch 块外部都可以 访问到错误。IE9 纠正了这个问题。

2.2 变量声明

敲黑板划重点了~

在ES6之前的声明变量关键字只有唯一的var,ES6不仅增加了let和const关键字,而且还让这两个关键字压倒性的成为了声明变量的首选。接下来我们看看他们是怎么胜出的。

2.2.1 使用var的函数作用域声明

在使用var声明变量时,变量会自动被添加到最近的上下文中,在函数中最接近上下文的就是函数的局部上下文,在with语句中最接近上下文的也是函数上下文。如果未声明就被初始化了,那么他会自动被添加到全局上下文中:

function add(num1, num2) {
    var sum = num1 + num2;
    return sum;
}
let result = add(10, 20); // 30
console.log(sum); // 报错:sum 在这里不是有效变量

因为sum是add()的局部变量,外部无法访问,add()内部保存了加法操作并把结果返回。当add()执行结束后局部变量sum也会被删除,如果sum在局部上下文中没有使用var关键字声明,那么sum就变成了可访问的了:

function add(num1, num2) {
    sum = num1 + num2;
    return sum;
}
let result = add(10, 20); // 30
console.log(sum); // 30

这次sum被添加到了全局上下文中,即使add()执行完毕,sum仍然存在,并且可访问。 Tips: 未经声明而初始化变量是 JavaScript 编程中一个非常常见的错误,会导致很多问题。 为此,在初始化变量之前一定要先声明变量。在严格模式下,未经声明就初始化变量 会报错。 var声明会导致变量在函数或者全局作用域的顶部,在所有代码之前,这个现象就是变量声明提升。

var name = "Jake";
// 等价于:
var name;
name = 'Jake';

//下面是两个等价的函数:
function fn1() {
    var name = 'Jake';
}
// 等价于:
function fn2() {
    var name;
    name = 'Jake';
}

//没有使用var关键字声明变量,直接初始化
name='Jake';
//等价于
var name;
name = 'Jake';

通过在声明之前打印变量,可以验证变量会被提升。

console.log(name); // undefined
var name = 'Jake';
console.log(name); // Jake
//在第一行代码中控制台输出了name变量,相当于访问了name变量,等价于
var name;
console.log(name); // undefined
var name;
name = 'Jake';
console.log(name); // Jake
function () {
    console.log(name); // undefined 
    name = 'Jake';
    console.log(name); // Jake
}
// 等价于
function () {
    var name;
    console.log(name); // undefined 
    name = 'Jake';
    console.log(name); // Jake
}

2.2.2 使用 let 的块级作用域声明

ES6新增的关键字,let和var相似,但是let有块级作用域,一对花括号就是一个块级作用域(“{}”)

if (true) {
    let a;
}
console.log(a); // ReferenceError: a is not defined


function foo() {
let c;
}
console.log(c); // ReferenceError: c is not defined

let在同一个块里不可以重复声明,但是var会被忽略。

var a = 1;
var a = 2;
console.log(a); // 2

{
let a = 1;
let a = 2;
console.log(a); // SyntaxError: Identifier 'a' has already been declared
}

let的特性非常适合在循环中声明迭代变量。因为var会让变量泄漏到循环外部。

for (var i = 0; i < 10; ++i) {} 
console.log(i); // 10

for (let i = 0; i < 10; ++i) {} 
console.log(i); // ReferenceError: j 没有定义

严格来讲,let 在 JavaScript 运行时中也会被提升,但由于“暂时性死区”(temporal dead zone)的 缘故,实际上不能在声明之前使用 let 变量。因此,从写 JavaScript 代码的角度说,let 的提升跟 var 是不一样的。

2.2.3 使用 const 的常量声明

ES6也增加了const关键字,使用const声明变量时必须初始化,一经声明,就可能再重新赋值,其他地方和let一样

const a; // SyntaxError: 常量声明时没有初始化
const b = 3;
console.log(b); // 3
b = 4; // TypeError: 给常量赋值

// const 除了要遵循以上规则,其他方面与 let 声明是一样的:
if (true) {
    const a = 0;
}
console.log(a); // ReferenceError: a 没有定义

while (true) {
    const b = 1;
}
console.log(b); // ReferenceError: b 没有定义

function foo() {
    const c = 2;
}
console.log(c); // ReferenceError: c 没有定义

{
    const d = 3;
}
console.log(d); // ReferenceError: d 没有定义

const声明只应用到顶级原语或者对象,也就是说const声明的对象不能再被赋值,但是这个对象的键不受限制。

const o1 = {};
o1 = {}; // TypeError: 给常量赋值
const o2 = {};
o2.name = 'Jake';
console.log(o2.name); // 'Jake'

如果想让整个对象都不能修改,可以使用 Object.freeze(),这样再给属性赋值时虽然不会报错, 但会静默失败

const o3 = Object.freeze({});
o3.name = 'Jake';
console.log(o3.name); // undefined

由于const声明暗示变量的值是单一类型且不可更改,JavaScript运行时编译器可以将其所有实例都替换成实际值,而不会通过查询表进行变量查找。V8引擎就执行这种优化。

2.2.4 标识符查找

在特定的上下文中为读取或写入而引用一个标识符时,必须通过搜索确定这个标识符表示什么。搜索开始于作用域链前端,找到则停止(当前作用域没有则会向父级继续查找)变量确定,没找到则代表未声明。

var color = 'blue';
function getColor() {
    return color;
}
console.log(getColor()); // 'blue'

在这个例子中调用getColor时会引用变量color。为确定color的值会进行两步搜索。①搜索getColor()的变量对象有没有color的标识符,并没找到,②则在全局上下文中的变量对象上搜索。找到所以搜索结束。

对整个搜索过程而言,引用局部变量会让搜索自动停止,也就是说如果getColor()的变量对象有color的标识符,就不会搜索全局上下文的变量对象。

var color = 'blue';
function getColor() {
    let color = 'red';
    return color;
}
console.log(getColor()); // 'red'

// 使用块级作用域声明并不会改变搜索流程,但可以给词法层级添加额外的层次:
var color = 'blue';
function getColor() {
    let color = 'red';
    {
        let color = 'green';
        return color;
    }
}
console.log(getColor()); // 'green'

3、垃圾回收

JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。JavaScript 为开发者卸下 了这个负担,通过自动内存管理实现内存分配和闲置资源回收。基本思路很简单:确定哪个变量不会再 使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执 行过程中某个预定的收集时间)就会自动运行。

垃圾回收过程是一个近似完美的方案,因为某块内存是否还有用属于“不可判定的“问题。我们以函数中局部变量的正常生命周期为例。函数中的局部变量会在函数执行时存在。此时,栈(或 堆)内存会分配空间以保存相应的值。函数在内部使用了变量,然后退出。此时,就不再需要那个局部 变量了,它占用的内存可以释放,供后面使用。这种情况下显然不再需要局部变量了,但并不是所有时 候都会这么明显。垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收 内存。如何标记未使用的变量也许有不同的实现方式。不过,在浏览器的发展史上,用到过两种主要的 标记策略:标记清理和引用计数。

3.1、标记清理

JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep)。当变量进入上下文,比如在函数 内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永 远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时, 也会被加上离开上下文的标记。

给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护“在上下 文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。标记过程的实现 并不重要,关键是策略。

垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它 会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记 的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内 存清理,销毁带标记的所有值并收回它们的内存。

到了 2008 年,IE、Firefox、Opera、Chrome 和 Safari 都在自己的 JavaScript 实现中采用标记清理(或 其变体),只是在运行垃圾回收的频率上有所差异。

3.2、引用计数

这是一个不常用的垃圾回收策略,思路就是对每个值都记录下引用次数,比如一个变量被声明并赋值,此时该值的引用计数为1,如果同时复制给另一个变量了,那引用计数+1,如果当前的变量保存了其他值,那么刚才的引用计数则-1,当一个值的引用计数为0时,就说明没办法访问到这个值了,则安全回收其内存。

function problem() {
    let objectA = new Object();
    let objectB = new Object();
    
    objectA.someOtherObject = objectB;
    objectB.anotherObject = objectA;
}

在这个例子中objectA和objectB相互引用,意味着他们两个的引用计数都为2,但是在函数结束后objectA和objectB还会存在,因为引用计数不会变成0,如果多次调用该函数,则会导致大量的内存不会被释放,这个问题在标记清理策略下不是问题

3.3、内存泄漏

写不好的js会出现一些难以察觉且有害的内存泄漏问题,在内存有限的设备商,或者函数会被调用很多次的情况下,内存泄漏可能是个大问题,js中的内存泄漏大部分是由于不合理的引用所导致的。 大致有以下三种情况

function setName() {
    name = 'Jake';
}

函数内部有一个没有使用关键字声明的变量,解释器会把name当做window对象的属性来创建,只要window对象本身不被清理,name属性就不会被清理,只要在变量前加一个关键字(var、let、const)即可

let name = 'Jake';
setInterval(() => {
    console.log(name);
}, 100);

定时器也可能导致内存泄漏,定时器的回调是通过闭包已用了外部变量,只要定时器一直运行,回调函数中的name就会一直占用内存,因此这个外部变量就不会被清理,如果离开页面或者不需要定时器时,使用clear方法清除定时器即可

let outer = function() {
    let name = 'Jake';
    return function() {
        return name;
    };
};

在调用outer()时,会导致name变量一直被闭包所引用,只要返回的函数存在就无法清理name变量,如果name的内容很大,那可能就会是个棘手的问题。当outer不再需要的时候将其赋值为null即可清理这些问题

总结

javascript变量可以保存两种类型的值:原始值和引用值。原始值有:Undefined、Null、Boolean、Number、String、Symbol。原始值和引用值的特点

  • 原始值大小固定,为此保存在栈内存上
  • 从一个变量到另一个变量复制原始值会创建该值的副本
  • 引用值是对象,存在堆内存上
  • 包含引用值的变量实际上只包含只想对应对象的一个指针,而不是对象本身。
  • 从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量指向同一个对象
  • typeof可以确定原始类型,instanceof用于确保的引用类型任何变量都存在于某个上下文中,这个上下文决定了变量的生命周期,以及他们可以访问代码的哪一部分,执行上下文可以总结如下。
  • 执行上下文分全局上下文、函数上下文、块级上下文
  • 代码执行流每进入一个新上下文,,都会创建一个作用域链,用于搜索变量和函数。
  • 函数或者块级的聚不上锡咋问不仅可以访问自己的作用域内的变量,也可以访问任何包含上下文和全局上下文的变量。
  • 全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部作用域的上下文数据。
  • 变量的执行上下文用于确定什么时候释放内存,js是使用垃圾回收的编程语言,开发者不需要关心内存的分配和回收。
  • 离开作用域的值会自动标记为可回收,然后在垃圾回收期间被删除。
  • 主流的垃圾回收是标记清理,即先给当前不使用的值加上标记,再回收内存
  • 引用计数是另一个垃圾回收策略,需要记录值被引用的次数,js引擎不再使用这种算法,但某些旧版本的IE浏览器仍然会受这种算法的影响,原因是js会访问非原生js对象(例如dom对象)
  • 引用计数在代码中存在循环引用时不会被清除的问题
  • 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助,为促进内存回收,全局对象、全局对象属性和循环引用都应该在不需要时解除引用