重学JavaScript系列——(四)变量、作用域与内存

重学JavaScript系列——(四)变量、作用域与内存

博主以扎实JavaScript基础为目的,以《JavaScript高级程序设计(第四版)》为核心参考资料,以一个“复习者”的角度有针对性地来创作这期专栏。文章加入了博主的很多思考和开发经验,关注初学JavaScript时容易忽略的地方,着重总结了ECMAScript新标准知识点的特性和应用场景。最终,本专栏将覆盖完整的JavaScript知识体系,以辅佐各路豪杰在开发路上的稳步前进。

专栏传送门:https://blog.csdn.net/huoyihengyuan/category_10586561.html



JavaScript的变量是松散类型的,而且没有规定必须包含什么类型,变量的值和类型在生命周期内可以改变。相对于其他语言,JavaScript的变量可谓独树一帜,很强大,但也会带来一些问题,本章来剖析错综复杂的变量。

4.1 原始值和引用值

ECMAScript变量可以包含两种类型的值:原始值和引用值。原始值(primitive value)就是我们最简单的数据(即那些基本数据类型的值),引用值(reference value)则是多个值构成的对象。

引用值是保存在内存中的对象。与其他语言不通,JavaScript不允许直接访问内存位置,因此不能直接操作对象所在的内存空间。操作对象时,实际上操作的是对该对象的引用(可以理解成地址或指针)而非对象本身。因此保存引用值的变量是按引用(by reference)访问的。

在很多其他语言中,字符串是使用对象表示的,因此被认为是引用类型,JavaScript打破了这个惯例。

4.1.1 动态属性

引用类型的值是可以动态添加、修改和删除的。

let person = new Object()
person.age = 12
console.log(person.age)//12

原对象person本身并没有age属性,但我们可以直接对它的age属性赋值,就相当于创建并赋值的过程。这样,我们接下来就可以获取到person.age这个属性的值了。

其实,我们可以尝试对一个基本类型的变量(例如number、string)添加属性,但这是无效的,而且系统不会报错。基本类型,不能被赋予属性。

4.1.2 复制值

基本类型值是保存在栈内存中的,拷贝一个基本类型的值相当于是在栈内存中开辟了一个新的空间,并保存了值的副本,二者独立使用,互不干扰。

引用类型的对象本身是保存在堆内存中,并在栈内存中保存相应的引用(地址)和变量名一一匹配。这样,当我们对一个引用类型的值进行拷贝时,实际上拷贝的是对象的地址而非对象本身,当我们对拷贝后的变量进行操作时,实际上是顺着地址找到了源对象,所以二者指向同一个对象。

4.1.3 传递参数

在函数调用的时候,参数是按值传递的,对于引用类型,这个值就是对象的地址,所以我们可能会在函数内操作obj时而改变函数外的对象。

ECMAScript中的参数就是局部变量。

4.1.4 确定类型

使用typeof最适合判断一个变量是否是原始类型,比如number、string、undefined、boolean,但如果typeof null会返回object,因为它代表“不是一个对象”。

typeof对于原始值很有用,但对于引用值用处不大。因此,ECMAScript提供了instanceof操作符,来判断是变量时什么类型的对象。

let person = {}
let colors = []
let pattern = new RegExp();
console.log(person instanceof Object)//true
console.log(colors instanceof Array)//true
console.log(pattern instanceof RegExp)//true

typeof在检测函数时会返回“function”。在Safari 5到Chrome 7用于检测正则表达式时,会返回function,这是应为在ECMA-262中规定,任何内部实现[[Call]]方法的对象都应该在typeof检测时返回“function”。正是因为上述浏览器中的正则表达式实现了这个方法,所以typeof正则表达式也会返回function。在IE和Firefox中,typeof对正则表达式返回“object”。

4.2 执行上下文与作用域

变量和函数的执行上下文(简称上下文,又被称为“作用域”)决定了他们可以访问哪些数据,这个上下文可以理解成他们的“代码区域”。

每个上下文开头都默认创建有一个不可见的变量对象(variable object),我们声明的函数和变量都会被添加到这个变量对象上。当我们在当前上下文中查找某个变量时,会先在当前上下文中的变量对象上寻找,如果找不到,就顺着他的外层上下文中的变量对象寻找,一直找到全局上下文为止。把内部上下文变量对象和外层上下文中的变量连接起来的这条链我们称之为作用域链(scope chain)。

在函数上下文中,活动对象(activation object)作为变量对象,最初它定义了一个变量arguments(全局上下文中没有这个变量)。

函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同的规则。

4.2.1 作用域链增强

虽然执行上下文主要有全局上下文和函数上下文两种(eval()调用内部存在第三种上下文)。但有其他方式来增强作用域链。某些语句会在作用域链最前端临时添加一个上下文,在代码执行完毕后被删除:

  • try/catch语句的catch块
  • with语句

4.2.2 变量声明

(1)var

在ES6之前,var是唯一的声明关键字,它只能应用于函数作用域和全局作用域,不像let和const一样拥有块级作用域。

不仅如此,它的“提升”(hoisting)机制,让统一作用域中的代码不必考虑变量是否已经声明就能够直接使用,这其实是一个弊端,有时候可能会让我们少注意到一些Reference Error报错。

(2)let

ES6新增的let跟var相似,但他的作用域是块级的,由一对花括号{}界定。

严格来讲,let在JavaScript运行时也会被提升,但由于“暂时性死区”(tenproal dead zone,简称TDZ)的机制,是我们不能在声明之前使用let变量。

在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称TDZ)。

暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

(3)const的常量声明

const和let一样同样是块级作用域,除了常量规则,其他规则都是一样的。

使用const声明的变量必须同时初始化为某个值,一旦声明,在其生命周期的任何时候都不能再重新赋予新值。注意,对于引用类型,即使是const声明,也可以操作对象的键。

(4)标识符查找

当需要查找标识符对应的值时,首先在局部上下文中查找,如果找不到就顺着作用域链查找。

标识符的查找并非没有代价,访问局部变量比访问全局变量要快,因为不用切换作用域。不过JavaScript引擎在优化标识符查找上做了很多工作,将来这个差异可能就微不足道了。

4.3 垃圾回收

JavaScript是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。在C语言和C++语言中,跟踪内存使用对开发者而言是很大负担。

JavaScript通过自动内存管理实现内存分配和闲置资源回收。基本思路很简单:周期性地判断哪些变量不再使用,然后释放他们的内存。

在浏览器的发展史上,用到过两种重要的标记策略:标记清理引用计数

JS的垃圾回收机制是为了以防内存泄漏。变量生命周期结束后会被释放内存,全局变量的生命周期持续到浏览器关闭页面,局部变量的生命周期在函数执行后就结束了。

内存泄漏的含义就是当已经不需要某块内存时这块内存还存在着,垃圾回收机制就是间歇的不定期的寻找到不再使用的变量,并释放掉它们所指向的内存。

4.3.1 标记清理

JavaScript最常用的垃圾回收机制是标记清理(mark-and-sweep)

标记清理的两个阶段:

  • 标记阶段:垃圾回收器从根对象开始遍历,每一个可以从根对象访问到的对象都会被添加一个可到达对象的标识。
  • 清理阶段:垃圾回收器会对堆内存从头到尾进行线性遍历,如果有对象没有被标识为可到达对象,那么就将对应的内存回收,并清除可到达对象的标识,以便下次垃圾回收。

4.3.2 引用计数

低版本的IE使用这种方式,但常常会引起内存泄露。原理是跟踪一个值的引用次数,当声明一个变量并将一个引用类型赋值给该变量时引用次数就是1,同一个值又被赋给另一个变量,引用次数+1,包含这个值当引用的变量取得新值,则引用次数-1,当垃圾回收器下次运行时,就会释放那些引用次数0的值占用的内存。

4.3.3 性能

垃圾回收程序会周期性运行,因此时间调度很重要,尤其在内存有限的移动设备上,垃圾回收有可能明显拖慢渲染速度。

现代垃圾回收程序会基于JavaScript运行时环境的探测来决定合适运行,一般参考的是已分配对象的大小和数量。比如V8团队在2016年的一篇文章中的说法:“在一次完整的垃圾回收之后,V8的堆增长策略会根据活跃对象的属性外加一些余量来确定何时再次垃圾回收。”

警告:在某些浏览器中,可以主动通过代码来触发垃圾回收,但不推荐。

4.3.4 内存管理

在使用垃圾回收的编程环境中,开发者通常并不需要关心内存管理。但毕竟分配给浏览器的内存通常比分配给桌面软件的要少很多,分配给移动浏览器的就更少了,为了避免运行大量JavaScript的网页耗尽系统内存导致操作系统崩溃。

将内存占用量保持在一个较小的值可以让页面性能更好,优化性能的最佳手段就是保证在执行代码时至保存必要的值。如果数据不再必要,那么把它设置成null,从而释放其引用,这也叫“解除引用”。这个建议最适合全局变量和全局对象的属性,因为局部变量在超出作用域后会被自动解除引用。

(1)通过const和let声明提升性能

const和let都以块为作用域,相对于var,可以更早地让垃圾回收程序介入,尽早回收应该回收的内存。

(2) 隐藏类和删除操作

根据JavaScript的运行环境,有时候需要根据浏览器使用的JavaScript引擎来采取不同的优化策略。截止于2017年,chrome是最流行的浏览器,使用V8 JavaScript引擎,V8将解释后的JavaScript代码编译为实际的机器码时会利用“隐藏类”。如果你的代码非常注重性能,那么这一点可能对你很重要。

运行期间,V8会将创建的对象和隐藏类关联起来,以跟踪他们的属性特征。所以,能够共享隐藏类的对象性能更好,V8会针对这种情况进行优化,但不一定总能做到。比如下面的代码:

function Article(){
	this.title = 'title'
}
let a1 = new Article();
let a2 = new Article();

V8会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享一个构造函数和原型。假设之后又增加了下面的代码:

a2.autor = 'tom'

此时两个实例就会对应了两个不同的隐藏类。

当然,解决方案就是避免JavaScript的"先创建再补充"(ready-fire-aim)式的动态属性赋值,并在构造函数中一次性声明所有属性,如:

function Article(autor){
	this.title = 'title'
	this.autor = autor
}

let a1 = new Article();
let a2 = new Article('tom')

这样,两个实例基本上就一样了,因此可以共享一个隐藏类,进而带来潜在的性能提升。不过要记住,使用delete关键字会导致生成相同的隐藏类片段。

funciton Article(){
	this.title = 'title'
	this.autor = 'tom'
}

let a1 = new Article()
let a2 = new Aritlce()

delete a1.author

代码结束之后,即使两个实例使用了同一个构造函数,它们也不再共享一个隐藏类。动态删除属性和动态添加属性的后果一样。最佳实践时把不想要的属性设置为null。这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收程序的结果。

(3)内存泄漏

内存泄漏是指是当已经不需要某块内存时这块内存还存在着。写得不好的JavaScript可能出现难以察觉的且有害的内存泄漏问题。

意外声明全局变量是最常见也是最容易修复的内存泄漏问题。

function setName(){
	name = 'tom'
}

此时,解释器会把变量name当作window的属性来创建。可想而知,在window对象上创建属性,只要window本身不被清理,就不会消失。

使用闭包很容易在不知不觉间造成内存泄露:

let outer = function(){
	let name = 'tom'
	return function(){
		return name
	}
}

这回导致分配给name的内存被泄漏。以上代码创建了一个内部闭包,只要outer函数存在,就不能清理name,以为闭包在一直引用它。假如name的内容很大(而不是一个小字符串),那可能就是大问题了。

(4)静态分配和对象池

为了提升浏览器性能,最后要考虑的一点就是要压榨浏览器了,一个关键的问题就是如何减少浏览器执行垃圾回收的次数。

知己知彼,方能百战不殆。浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度,如果有很多对象被初始化,而后又都超出了作用域,那么浏览器就会采用更激进的方式调度垃圾回收程序运行。

举个例子,如果在一函数中new一个新对象,并进行动态赋值,这个函数的声明周期很短。如果这个函数又被频繁调用,那么垃圾回收调度程序就会发现这里对象更替速度很快,从而会更频繁的触发垃圾回收。一个比较好的策略是,创建一个对象池,用来管理一组可回收的对象。由于没有发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁的运行。

如果对象池是按需分配矢量(对象不存在时创建新的,存在是复用存在的),那么这个实现本质上是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构来维护所有对象,数组就是比较好的选择,不过要留意别招致额外的垃圾回收。

由于JavaScript数组的大小是动态可变的,变化的过程中说不定因此很快就会跑来回收一次垃圾。为了避免这种情况,可以在初始化时就创建一个大小够用的数组,不过必须要事先想好这个数组有多大。

静态分配是优化的一种极端形式,如果你的应用程序被垃圾回收严重地拖了后腿,可以利用它提升性能。但这种情况并不多见,大多数情况下,这都属于过早优化,因此不用考虑。

小结

本章节主要在梳理在JavaScript中,变量、作用域和内存的关系,并对性能优化提出了一些方案。

  • JavaScript变量可以保存原始值和引用值,并且具有一些不同的特点。
  • 任何变量都存在于某个执行上下文中(也称之作用域),这个作用域决定了变量的生命周期,以及可以访问代码的那些部分。
  • JavaScript的垃圾回收机制使开发者不需要额外操心内存分配和回收,但我们可以利用一些机制做一些性能优化。不要忘记,标记清理是最常用的垃圾回收机制。

理解这一章节的内容,可以使我们对代码执行的原理有更透彻的理解,进而提升我们代码的质量。

恒者行远,思者常新,博观约取,厚积薄发。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值