概念点
编译 --> 词法分析 --> 词法作用域 --> 作用域链
函数作用域 --> 块作用域 --> 作用域闭包 --> 垃圾回收 --> 内存管理
发生在代码执行前的事情(预编译):
-
明确所有词法作用域,并将其关联起来 —— 明确作用域(创建全局执行上下文[GO] 或 函数活动对象[AO])
-
查找变量声明及函数形参赋值为
undefined,作为上下文对象的属性 —— 变量提升 -
实参赋值给形参
-
将函数体赋值给函数声明
编译原理
尽管通常将JavaScript归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。 —— 《你不知道的JavaScript》
与传统编译语言不同,它不是提前编译的。编译结果也不能在分布式系统中进行移植。
名词解释
-
引擎
从头到尾负责整个JS程序的编译及执行过程
-
编译器
负责语法分析及代码生成
-
作用域
负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限
在传统编译语言的流程中,程序中的一段源代码在执行之前会经理三个步骤:
-
分词/词法分析
这个过程会将由字符串组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元
-
解析/语法分析
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树(AST)”
-
代码生成
将AST转换为可执行代码的过程被称为代码生成。(这个过程与语言、目标平台等息息相关)
大部分标准语言编译器的第一个工作阶段叫做词法化,这个概念是理解词法作用域及其名称来历的基础。
比起那些编译过程只有三个步骤的语言编译器,JS引擎要复杂的多。例如在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等
首先,JS引擎不会有大量的(像其他语言编译器那么多的)时间用来进行优化,因为JS的编译过程不是发生在构建之前,而是大部分情况下发生在代码执行前的几微妙(甚至更短)的时间内。
在我们所讨论的作用域背后,JS引擎用尽了各种办法(比如JIT)来保证性能最佳。
作用域是什么
在JS中,作用域是可访问变量的集合(又或者可以理解为当前的执行上下文)
一套设计良好的 用来存储变量,并且之后可以方便地找到这些变量 的规则被称为作用域 —— 引自《你的不知道的JavaScript》
作用域共有两种主要的工作模型:
- 词法作用域
第一种是最为普遍的,被大多数编程语言所采用的词法作用域。
词法作用域就是定义在词法阶段的作用域。(JS中的作用域就是词法作用域,我们后面讨论的作用域都是词法作用域)
简单来说,词法作用域意味着作用域是由书写代码时函数声明的位置来决定的,且当词法分析器处理代码时会保持作用域不变(如果不特殊处理的话,比如eval()包裹或者使用with)
编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能预测在执行过程中如何对他们进行查找。(作用域的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置)
还有需要明确的一点是,词法作用域只会查找一级标识符,例如代码中引用了foo.bar.baz,词法作用域查找只会试图查找foo标识符,找到这个变量后,对象属性访问规则会分别接管对bar和baz属性的访问
全局变量会自动成为全局对象的属性。
我们在文件中直接定义一个变量(该变量就会被挂载在全局对象上),例如
var a = 2;,当我们访问它时,实际上就是访问的全局对象(Global,在浏览器中是window)上的a变量,这时它的作用域就是全局对象注意,ES6之后的
let和const即便是在最外层定义的变量,也不会挂载到window上了如果定义一个
var obj = { a: 2 },当我们再想获取a的值时,它的(词法)作用域就已经变成obj了那么
function() { var a = 2; }呢?是的,它的作用域就是function()后面{}括起来的范围
所以如果理解作用域有点困难,我们不妨先简单的理解成{}括起来的范围就代表一层作用域
JS中有两个机制可以“欺骗”词法作用域,他们是
eval()和with
eval()函数接收一个字符串,并将其内容视为好像在书写时就存在于这个位置一样。(严格模式下,eval()在运行时有自己的词法作用域,意味着其中的声明无法修改所在的作用域)
setTomeout()和setInterval()当第一个参数是字符串时,字符串也可以被解释为一段动态生成的函数代码,和eval很相似,还有new Function()函数的最后一个参数可以接受代码字符串,也能将其转化为动态生成的函数
with通常被当做重复引用同一个对象中的多个属性的快捷方式这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的
使用这其中任何一个机制都将导致代码运行变慢
在程序中动态生成代码的使用场景非常罕见,因为它所带来的好处无法抵消性能上的损失
- 动态作用域(JS中的this机制)
另外一种叫做动态作用域。动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心他们从何处调用。换句话说,作用域链是基于调用站的,而是代码中的作用域嵌套
而词法作用域则是,无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数声明时所处的位置决定。
需要明确的是,事实上JS并不具有动态作用域,它只有词法作用域。只不过this机制某种程度上很像动态作用域
上下文
-
表达式上下文
let p = {name: 'echo'}大括号表示对象字面量开始。在ECMAScript中,表达式上下文指的是期待返回值的上下文
-
语句上下文
if(...) { ... }语句上下文,表示一个语句块的开始
理解编译过程
从简单的例子开始
const a = 2;
解析上面这行代码:
1、遇到const a,编译器会询问作用域是否已经有一个该名称的变量存在于当前作用域的集合中。如果没有,它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a
2、接下来编译器会为引擎生成运行时所需要的代码,这些代码被用来处理a = 2这个赋值操作,引擎会先在作用域寻找变量a,如果有则返回变量a
3、如果引擎最终找到了变量a,则会将2赋值给它,否则抛出异常
在引擎执行查找时,会根据情况执行
LHS和RHS查询。那么这两个查询又有什么区别呢?可以把
RHS(理解成“非左侧”更准确一点)理解为简单地查找某个变量的值,或者“谁是赋值操作的源头”;而LHS查询则是试图找到变量的容器本身(从而可以对其赋值),即“赋值操作的目标是谁”。非严格模式下,执行
LHS查询,如果找到全局作用域中也没有该变量时,全局作用域中就会创建一个具有该名称的变量,并将其返给引擎
ReferenceError错误同作用域判别失败相关
TypeError错误则代表作用域判别成功了,但是对结果的操作不合理
作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套(也就是我们所谓的作用域链)。
因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达全局作用域为止。
函数作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(这种设计方案能充分利用JS变量,可以根据需要改变值类型的“动态”类型)
每个函数调用都有自己的上下文。当代码执行流 进入函数时,函数的上下文被推倒一个上下文栈上。上下文在其所有代码都执行完毕后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。而这个刚刚被弹出的上下文会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。
“隐藏内部实现”,可以理解为不影响功能性和最终效果,但设计上将具体代码提取与封装,进行内容私有化。他们大都是从最小特权原则中引申出来的,也叫最小授权或最小暴露原则,这个原则是指在软件设计中,应该最小限度地暴露必要内容。比如某个模块或对象的API设计
隐藏内部实现虽然可以解决一些问题(比如避免多个引用中的命名标识符冲突),但它并不理想,原因如下:
-
首先,必须声明一个具名函数(这个函数本身就污染了所在作用域)
-
其次,必须显式地通过函数名调用这个函数才能运行其中的代码
关于函数名称:
函数声明和函数表达式之间最重要的区别是他们的名称标识符将会绑定在何处。函数表达式可以是匿名的,而函数声明则不可以省略函数名。
始终给函数表达式命名是一个最佳实践,使用匿名函数时需要考虑的几个缺点:
匿名函数在栈追踪中不会显示出有意义的函数名,使得调试困难
如果没有函数名,当函数需要引用自身时(比如递归,或解绑自身),只能使用已经过期的
argument.callee引用函数名对代码可读性/可理解性很重要
这时候,“立即执行函数”登场了,它能同时解决这两个问题
const foo = (function() { ... })()
立即执行函数表达式(IIFE)
(function a() {})() 和 (function a() {}())这两种形式都可以定义立即执行函数
它类似于函数声明,但由于被包含在括号中,所以会被解释为函数表达式。
立即执行函数的适用场景:
-
避免污染全局作用域,
(function IIFE(global) {...})(window)使用IIFE可以模拟块级作用域,即在一个函数表达式内部声明变量,然后立即调用这个函数。这样位于函数体作用域的变量就像是在块级作用域中一样。
-
解决
undefined标识符的默认值被错误覆盖导致的异常 -
倒置代码运行顺序(这种模式在UMD项目中被广泛适用)
(function IIFE(def) { def(window); })(function def(global) { let a = 3; console.log(a); })
在ES5及以前,IIFE可以防止变量定义外泄,也不会导致闭包相关的内存问题
在ES6以后,IIFE就没有那么必要了,因为块级作用域中的变量无须IIFE就可以实现同样的隔离
块作用域
块作用域是一个用来对之前的最小授权原则进行扩展的工具(将代码从在函数中隐藏信息扩展为在块中隐藏信息)
所以在ES6之后,更建议使用
let和const代替var(let为其声明的变量隐式地劫持了所在的块作用域)
JS中try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效
另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关
变量提升
养成良好的开发习惯,有助于自动规避一些意想不到的问题。
包含变量和函数在内的所有声明都会在任何代码被执行前首先被处理,当他们从在代码中出现的位置被“移动”到所在作用域的最上面的过程就叫做提升。
或者可以这样理解,代码正在执行的 上下文的变量对象 始终位于作用域链的最前端
如果上下文是函数,则其活动对象(AO)用作变量对象。活动对象最初只有一个定义变量 -- arguments(全局上下文中没有这个变量)。
作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再下一个包含上下文。以此类推至全局上下文;全局上下文的变量对象始终是作用域链的最后一个变量对象
只有声明本身会被提升,而赋值或其他运行逻辑会留在原地等待执行
函数声明会被提升,但函数表达式不会
foo(); // TypeError
var foo = function bar() {...}
例如var a = 2;,JS实际上将其看成两个声明:var a和a = 2;第一个定义声明在编译阶段进行,第二个赋值声明被留在原地等待执行阶段
函数优先
函数声明和变量声明都会被提升,但是函数会首先被提升,然后才是变量
foo(); // 1
var foo;
function foo() {
console.log(1);
}
foo = function() {
console.log(2);
}
作用域闭包
理解作用域链创建和使用的细节对理解闭包非常重要
闭包是基于词法作用域书写代码时所产生的自然结果
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行
通常函数执行完毕后,其内部作用域会被销毁(因为引擎有垃圾回收器来释放不再使用的内存空间),而闭包的“神奇”之处正是可以阻止垃圾回收
垃圾回收过程:
函数中的局部变量会再函数执行时存在。此时,栈(或堆)内存会分配空间以保存响应的值。
函数在内部使用了变量,然后退出。此时,就不再需要那个局部变量了,它占用的内存可以释放,供后面使用
可是,不是所有的情况都这么简单和明显,垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收内存。
而闭包让这个函数的内部变量可以在其他地方进行访问,垃圾回收就无法对其进行释放
简言之,闭包使得函数可以继续访问定义时的词法作用域
当然,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包
function foo() {
var a=2;
function baz() {
console.log(a);
}
bar(baz);
}
function bar(fn) {
fn(); // 这就是闭包
}
在引擎内部,内置的工具函数setTimeout()持有对一个函数参数的引用,引擎会调用这个参数,而词法作用域在这个过程中保持完整,这就是闭包
在定时器、事件监听器、Ajax请求、跨窗口通信、web workers或任何其他的异步(或同步)任务中,只要使用了回调函数,实际上就是在使用闭包
通常认为IIFE是典型的闭包例子,尽管IIFE本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建可以被封闭起来的闭包的工具
闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。
过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用
垃圾回收
JS是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存,通过自动内存管理实现内存分配和闲置资源回收。
基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存。
这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。
垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的
在浏览器的发展史上,用到过两种主要的标记策略:
-
标记清理
JS最常用的垃圾回收策略是标记清理。
当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放他们的内存。因为只要上下文中的代码在运行,就有可能用到他们。
当变量离开上下文时,也会被加上离开上下文的标记
给变量加标记的方式有很多种。标记过程的实现并不重要,关键是策略
垃圾回收程序运行的时候,会标记内存中存储的所有变量,然后,他们会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此后再被加上标记的变量就是待删除的了。
原因是任何在上下文中的变量都访问不到他们了。
随后垃圾回收程序做一次内存清理。销毁带标记的所有值并收回他们的内存
-
引用计数
其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值,该值的引用数为1。如果同一个值又被赋给另一个变量,那么引用数加1。
类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减1。
当一个值的引用数为0时,就说明没办法再访问到这个值了,此时就可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为0的值的内存。
引用计数策略的问题 —— 循环引用
如果两个对象互相引用,且程序多次运行时,会导致大量内存永远不会被释放
现代垃圾回收程序会基于对JS运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的。
在某些浏览器中是有可能(但不推荐)主动触发垃圾回收的。
在IE中,window.CollectGarbage()方法会立即触发垃圾回收
在Opera7及更高版本中,调用window.opera.collect()也会启动垃圾回收程序
内存管理
在使用垃圾回收的编程环境中,开发者通常无须关心内存管理。不过,JS运行在一个内存管理与垃圾回收都很特殊的环境。
分配给浏览器的内存通常比分配给桌面软件的要少很多,分配给移动浏览器的就更少了。
这更多处于安全考虑而不是别的,就是为了避免运行大量js的网页耗尽系统内存而导致操作系统崩溃。
优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。
如果数据不再必要,那么把它设置为null,从而释放其引用。这也可以叫作 解除引用。
这个建议最适合全局变量和全局对象的属性,局部变量在超出作用域后会被自动解除引用
不过要注意,解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收
ES6新增的
let和const不仅有助于改善代码风格,同样有助于改进垃圾回收的过程。因为他们都以块(而非函数)为作用域,所以相对于var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。
内存泄漏
JS中的内存泄漏大部分是由不合理的引用导致的。
意外声明全局变量是最常见但也最容易修复的内存泄漏问题
定时器和闭包也可能会悄悄地导致内存泄漏
性能
为了提升JS性能,最后要考虑的一点往往就是压榨浏览器了——如何减少浏览器执行垃圾回收的次数。
开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件
浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度。如果有很多对象被初始化,然后一下子又都超出了作用域,那么浏览器就会采用更激进的方式调度垃圾回收程序运行,这样当然影响性能
function addVector(a, b) {
let res = new Vector();
res.x = a.x + b.x;
res.y = a.y + b.y;
return res;
}
调用这个函数时,会在堆上创建一个新对象,然后修改它,最后再把它返回给调用者。
如果这个矢量对象的生命周期很短,那么它会很快失去所有对它的引用,成为可以被回收的值。假如这个适量加法函数频繁被调用,那么垃圾回收调度程序会发现这里对象更替的速度很快,从而会更频繁的安排垃圾回收
该问题的解决方案是不要动态创建矢量对象,如果可以修改上面的函数,让它使用一个已有的矢量对象
function addVector(a, b, res) {
res.x = a.x + b.x;
res.y = a.y + b.y;
return res;
}
那么问题来了,在哪里创建矢量对象可以不让垃圾回收调度程序盯上呢?
一个策略是使用对象池。在初始化的某一刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象,设置其属性,使用他,然后在操作完成后再把它还给对象池
// 假设v是已有的对象池
let v1 = v.allocate();
let v2 = v.allocate();
let v3 = v.allocate();
v1.x = 10;
v1.y = 5;
v2.x = -3;
v2.y = -6;
addVector(v1, v2, v3);
// 释放
v.free(v1);
v.free(v2);
v.free(v3);
// 如果对象有属性引用了其他对象,那么这里也需要把属性设置为Null
v1 = null;
v2 = null;
v3 = null;
如果对象池只按分配矢量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个实现本质上是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构维护所有对象,数组是比较好的选择。
不过,使用数组来实现,必须留意不要招致额外的垃圾回收,比如:
let vList = new Array(100);
let v = new Vector();
vList.push(v);
由于JS数组的大小是动态可变的,引擎会删除大小为100的数组,再创建一个新的大小为200的数组。垃圾回收程序看到删除操作,说不定会因此很快跑来收一次垃圾。
要避免这种动态分配操作,可以再初始化时就创建一个大小够用的数组,从而避免上述先删除再创建的操作
静态分配是优化的一种极端形式。 如果你的应用程序被垃圾回收严重地拖了后腿,可以利用它提升性能。不过,大多情况下,这都属于过早优化