JavaScript高级程序设计(第4版)-第4章 变量、作用域与内存

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

根据ECMA-262所规定,JavaScript变量是松散类型的。

而且变量不过就是特定时间点一个特定值的名称而已。

变量的值和数据类型在脚本的生命期内可以改变。

4.1 原始值与引用值

ECMAScript变量包含两种不同类型的数据:

  • 原始值(primitive value)
    • 即最简单的数据
    • 保存原始值的变量按值访问(by value)
    • 大小固定,保存在栈内存中
    • 一般使用typeof确定类型
  • 引用值(reference value)
    • 由多个值构成的对象
    • 引用值是保存在内存中的对象,由于JS不允许直接访问内存位置,因此对象的操作都是通过引用(reference)的方式
    • 保存引用值的变量按引用访问(by reference)
    • 存储在堆内存中
    • 一般使用风格instanceof去确定引用类型

4.1.1 动态属性

对于引用值,可以随时添加、修改、删除其属性和方法

let person = new Object();
person.name = "Nick";
console.log(person);  //Object{name:'Nick'}

而对于原始值则不能有属性,但是给它添加属性时也不会报错

let name = "Nick";
name.age = 22;
console.log(name.age);  //undefined

因此只有引用值才可以动态添加后面可以使用的属性。

注意:原始属性的初始化可以只使用原始字面量形式,但是如果使用的是new关键字,JS会创建一个Object类型的实例,但是其行为类似于原始值:

let name = new String('Nick');
name.age = 22;
console.log(name.age);  //22
console.log(typeof name); //object

4.1.2 复制值

除了存储方式不同,原始值和引用值在通过变量复制时也有所不同

  • 原始值:被复制到新变量的位置,即复制值和被复制值完全独立,互不干扰

  • 引用值:存储在变量中的值也会被复制到新变量所在的位置,但是复制的值实际上是一个指针,指向存储在堆内存中的对象。因此一个对象上面的变化会在另一个对象上反映出来

let person = {
  name: 'Nick',
}
let person2 = person;
person2.age = 22;
console.log(person);  //{name:'Nick', age:22}

4.1.3 传递参数

ECMAScript中的所有参数都是按值传递的。因此函数外的值会被复制到函数内部的参数中。

注意:ECMAScript的变量有按值和按引用访问的,但是传参就只有按值传递的。只不过对于按引用访问的变量传递的参数实际上是指向内存的指针,即指向的对象保存在全局作用域的堆内存上。

一个很容易误解的点就是:很多人认为挡在局部作用域中修改对象而变化反映到全局时就意味着参数是按照引用传递的。

function setName(obj) { 
  obj.name = "Nick";
  obj = new Object();
  obj.name = "Mark";
}
let person = {};
setName(person);
console.log(person);   //{name:'Nick'}

4.1.4 确定类型

虽然typeof可以检测出一个变量的类型,对于引用值会返回object

但是我们通常不关心一个值是不是对象(真实),更想知道它是什么类型的对象

这时可以使用instanceof操作符

result = variable instanceof constructor

如果使用instanceof检测原始值,始终返回false,因为原始值不是对象

let name = "Nick";
console.log(name instanceof String);  //false

注意:ECMA-262规定,任何实现内部[[Call]]方法的对象都应该在typeof检测时返回function,因此对于有些在正则表达式中实现了这个方法的浏览器,typeof对正则表达式返回的是function,而对其余的返回值则是object

4.2 执行上下文与作用域

变量或函数的上下文决定了它们可以访问哪些数据和函数。

每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。

  • 全局上下文
    • 最外层的上下文
    • 根据宿主环境的差异,全局上下文对象可能不一样
    • 通常是我们常说的window对象
    • var定义的全局变量和函数都会称为window对象的属性和方法
    • let和const的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的
    • 全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器,其内部的所有变量和函数也都随之销毁
  • 函数上下文:
    • 当代码执行流进入函数时,函数的上下文被推倒一个上下文栈中,在函数执行完后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。
    • 上下文代码执行时,会创建变量对象的一个作用域链(scope chain)
    • 上下文函数的活动对象(activation object)用作变量对象
    • 活动对象最初只有一个定义变量arguments
    • 内部上下文可以通过作用域链访问外部上下文中的一切,但反之不成立

4.2.1 作用域链增强

有两种方式可以在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除,即增强作用域链:

  • try/catch语句中的catch块:创建一个新的变量对象,包含要抛出的错误对象的声明
  • with语句:会向作用域链前端增加指定对象

4.2.2 变量声明

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

使用var声明变量,变量会被自动添加到最接近的上下文

如果变量未经声明就被初始化了,则会被自动添加到全局上下文中(不推荐)

变量提升(hosting):var声明会被拿到函数或全局作用域顶部,位于作用域中所有代码之前。

function add1(num1, num2) { 
  var sum = num1 + num2;  //局部变量sum
  return sum;
}
add1(10, 20);
console.log(sum);  //ReferenceError

function add2(num1, num2) { 
  sum = num1 + num2;  //全局变量sum
  return sum;
}
add2(10, 20);
console.log(sum); //30

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

ES6新增的let具有如下特性:

  • 作用域是块级的
  • 同一作用域内不能声明两次,否则会报SyntaxError(语法错误)
  • 很适合在循环中声明迭代变量
  • 不能变量提升

4.2.2.3 使用const的常量声明

ES6新增的const具有如下特性:

  • 声明的同时必须初始化
  • 声明只应用到顶级原语或者对象,即赋值为对象的const变量不能再被重新赋值为其他引用值,但对象的键不受限制
  • 如果想让这行个对象都不可修改,可使用Object.freeze(),这样给属性赋值时不会报错,但会静默失败
const person = {
  name: "Nick"
}
person.name = "Mart";
console.log(person);  //{name:'Mart'};

const person2 = Object.freeze({
  name:"Nick",
})
person2.name = "Mart";
console.log(person2); //{name:'Nick'}
  • JavaScript运行时编译器会将所有const声明的实例替换成实际值,不会通过查询表进行变量查找,这样可以优化代码运行效率
  • 如果开发流程不受影响,尽可能多实用cosnt声明

4.2.2.4 标识符查找

JavaScript标识符查找的原理就在于作用域链,搜索开始于作用域链前端,一直沿作用域链搜索,中途如果找到该变量名就终止,如果没找到就一个持续搜索至全局上下文的变量对象。

访问局部变量比访问全局变量要快,但是JavaScript引擎在这方面的优化上做了很多工作,因此差距被缩小了。

4.3 垃圾回收

JavaScript是使用垃圾回收的语言,即执行环境负责在代码执行时管理内存

JavaScritp通过自动内存管理实现内存分配和闲置资源回收

基本思路:确定哪个变量不再使用,然后释放它的内存

这个过程是周期性的,即回收程序每隔一定时间(或在代码执行过程中某个预定的收集时间)就会自动执行。

垃圾回收程序必须跟踪记录哪个变量还会使用,哪个变量不会使用。

因此在浏览器中,基本有两种标记未使用变量的方式:

  • 标记清理
  • 引用计数

4.3.1 标记清理(mark-and-sweep)

这也是最常使用的垃圾回收策略。即根据被声明的变量是否存在与上下文的实际情况打上相应标记。

标记的方法有很多,比如反转变量的某一位、把变量分两种列表存储啦等等

垃圾回收程序运行时,会将所有带销毁标记的变量进行内存清理回收。

4.3.2 引用计数(reference counting)

思路:对每个值都记录被引用的次数

  • 声明变量并给它赋一个引用值时,该值引用数=1
  • 同一个值又被赋给另一个变量,引用数+1
  • 保存对该值引用的变量被其他值覆盖了,引用数-1
  • 引用次数=0时,会在垃圾回收程序运行时被回收

这种方法有一个很严重的问题:循环引用

即对象A有一个指针指向对象B,对象B也引用了对象A

let classA = new Object();
let classB = new Object();
classA.next = classB;
classB.next = classA;

循环引用会导致该变量占用的内存永远不会被释放。只有通过手动切断的方式才能销毁

classA.next = null;
classB.next = null;

一般的JS引擎不会使用这种算法,但是在旧版本的IE中因为JS需要访问非原生JS对象(COM),因此仍要用这种算法

4.3.3 性能

因为垃圾回收会周期性的执行,因此垃圾回收的时间调度尤其重要,

垃圾回收不合理会拖慢渲染的速度和帧速率,

因此最好的策略就是:无论什么时候开始手机垃圾,都能让它尽快结束工作

现代垃圾回收程序基于对JavaScript运行时的环境的探测来决定合适运行。

大多数引擎都是根据已分配对象的大小和数量来判断的。

2016年V8团队的一篇博文中有这种说法:

再一次完整的垃圾回收之后,V8的堆增长策略会根据活跃对象的数量外加一些余量来确定合适再次垃圾回收

IE7以前的IE回收策略是:根据分配数,如分配了256个变量、4096个对象/数组字面量和数组槽位(slot),或者64KB字符串,只要满足其中某个条件,垃圾回收程序就会执行,但这种方法明显会导致某些特殊的脚本频繁的运行垃圾回收程序,对性能造成严重影响。

IE7之后,JavaScritp引擎的垃圾回收程序被调优为动态改变分配变量、字面量或数组槽位等会触发垃圾回收的阈值。

不同的浏览器也提供不同的主动触发垃圾回收的方法,但是不推荐使用

4.3.4 内存管理

尽管在使用了垃圾回收的编程环境中,开发者往往无须关心内存的管理,但是JavaScript运行的环境具有以下特点:

  • JavaScript运行在一个内存管理与垃圾回收都很特殊的环境(浏览器)
  • 分配给浏览器的内存通常比分配给桌面软件的内存要少很多(特别是移动端)
  • 原因就是需要避免运行大量JavaScritp的网页耗尽系统内存而导致操作系统崩溃
  • 这种内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量

优化的重点在于以下几点:

  • 将内存占用量保持在一个较小的值可以让页面性能更好
  • 最佳手段就是保证在执行代码时只保存必要的数据
  • 最好对全局变量和全局对象属性进行解除引用:即将不再需要的数据设置为null
  • 解除引用关键在于确保相关的值已经不在上下文里了

4.3.4.1 通过const和let声明提升性能

因为const和let都是块作用域声明,因此相比于var,使用这两个关键字可能会更早的让垃圾回收程序介入,尽早回收应该回收的内存。

4.3.4.2 隐藏类和删除操作 

Chrome采用的V8 JavaScript引擎将解释后的JavaScritp代码编译为实际的机器码时会利用隐藏类。

V8会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征,能够共享相同隐藏类的对象性能会更好。

但是注意,动态的对实例添加属性会破坏实例之间的隐藏类共享关系

function Article() { 
  this.title = "Inauguration Ceremony Features Kazoo Band";
}
let a1 = new Article();
let a2 = new Article();
a2.author = "Mike";    //a2和a1不再共享同一个类

 对于这种问题,解决方案就是避免JavaScript先创建再补充(ready-fire-amin)式的动态属性复制,在构造函数中一次性声明所有属性:

function Article(opt_author) { 
  this.title = "Inauguration Ceremony Features Kazoo Band";
}
let a1 = new Article();
let a2 = new Article("Mike");

但是如果这时使用delete关键字手动删除实例的属性,也会破坏实例之间的隐藏类共享关系,

动态添加属性和动态删除属性都会破坏这种共享关系

因此动态删除实例属性的最好方式是将其设为null(解除引用)

function Article(opt_author) { 
  this.title = "Inauguration Ceremony Features Kazoo Band";
  this.author = opt_author;
}
let a1 = new Article();
let a2 = new Article("Mike");
// delete a2.author;
a2.author = null;

4.3.4.3 内存泄漏

内存泄漏大部分是由不合理的引用导致的,如下:

  • 意外声明全局变量:全局变量只能随着window的销毁被销毁
function setName() { 
  name = "Nick";    //全局变量
}
  • 定时器:只要定时器一直运行,回调函数中的变量就会一直占用内存
let name = "Nick";  //占用
setInterval(() => {
  console.log(name);
}, 100);
  • 闭包:只要闭包内返回的函数存在就不能清理内部变量
let outer = function () { 
  let name = 'Nick';  //无法被回收程序销毁
  return function () { 
    return name;
  }
}

4.3.4.4 静态分配与对象池

 提升Js性能的最后一步往往是压榨浏览器,其中比较关键的一点就是如何减少浏览器执行垃圾回收的次数,即间接控制触发垃圾回收的条件,重点在于:

  • 合理使用分配内存
  • 避免多余的垃圾回收

浏览器决定合时进行垃圾回收的一个标准就是对象更替的速度。

当很多对象被初始化,一下子又都超出了作用域,浏览器自然就会采取比较激进的方式调度垃圾回收程序运行。

如下面的一个二维矢量加法函数

function addVector(a, b) { 
  let resultant = new Vector();
  resultant.x = a.x + b.x;
  resultant.y = a.y + b.y;
  return resultant;
}

 由于局部对象resultant的频繁调用会导致垃圾回收程序调用的很频繁,因此尽量就不要采取这种动态创建占用内存较大的中间对象变量,而是让它使用已有的对象

function addVector(a, b, resultant) { 
  resultant.x = a.x + b.x;
  resultant.y = a.y + b.y;
  return resultant;
}

对象池就是为这种优化方式服务的,它用来管理一组可回收的对象。

应用程序向对象池请求一个对象,设置其属性、使用它,然后在操作完成后再归还给对象池,避免频繁的初始化。

//假设vectorPool是已有的对象池
let v1 = vectorPool.allocate();
let v2 = vectorPool.allocate();
let v3 = vectorPool.allocate();

v1.x = 10; v1.y = 5;
v2.x = -4; v2.y = -19;

addVector(v1, v2, v3);

console.log([v3.x, v3.y]);

//手动销毁无用的对象
v1 = null;
v2 = null;

如果对象池只按需分配矢量,即当对象不存在时创建新的,在对象存在时则复用存在的

这种实现本质上是一种贪婪算法,有单调增长但为静态的内存,

这个对象池必须使用某种结构维护所有对象,一般使用数组。

但同时也要注意对象池的静态分配中数组操作不要招致额外的垃圾回收机制:

let vectorList = new Array(100);
let vector = new Vector();
vectorList.push(vector);

由于JavaScript的数组大小动态可变机制,在这里对大小为100的数组会进行删除重创建,这种大规模内存操作很有可能引起额外的垃圾回收调用。

因此在初始化时尽量创建一个大小够用就行的动态池数组。

静态分配是优化的一种计算形式,大多数情况下都属于过早优化。

 

posted @ 2022-06-27 17:30  Electric-Duck  阅读(58)  评论(0)    收藏  举报