# 理解 JavaScript 中的执行上下文和执行栈
执行上下文是当前 JavaScript 代码被解析和执行时所在环境的抽象概念
# 执行上下文的类型
执行上下文总共有三种类型
- 全局执行上下文:只有一个,浏览器的全局对象就是 window 对象,this 指向这个全局对象;
- 函数执行上下文:存在无数个,只有在函数被调用的时候才会被创建,每次调用函数都会创建一个新的执行上下文;
- eval 函数执行上下文:指的是运行在 eval 函数中的代码,很少用且不建议使用。
# 执行栈
执行栈,也叫调用栈,具有先进后出结构,用于存储在代码执行期间创建的所有执行上下文。
首次运行 JS 代码时,会创建一个 全局执行上下文 并推到当前执行栈中。每当发生函数调用时,引擎都会为该函数创建一个 新的函数执行上下文 并推到当前执行栈的栈顶。
根据执行栈后进先出的规则,当栈顶函数完成后,其对应的函数执行上下文会从栈顶被推出,上下文控制权移交到当前执行栈的下一个执行上下文。
# 执行上下文的创建
执行上下文问两个阶段创建:
- 创建阶段
- 执行阶段
# 创建阶段
- 确定 this 的值,这也被称为 This Binding;
- LexicalEnvironment(词法环境)组件被创建;
- VariableEnvironment(变量环境)组件被创建。
直接看伪代码可能更加直观
ExecutionContext = {
ThisBinding = <this value>, // 确定this
LexicalEnvironment = { ... }, // 词法环境
VariableEnvironment = { ... }, // 变量环境
}
# This Binding
- 全局执行上下文中,this 的值指向全局对象,在浏览器中 this 的值指向 window 对象,而在 nodejs 中指向这个文件的 module 对象;
- 函数执行上下文中,this 的值取决于函数的调用方式,具体有:默认绑定、隐式绑定、显式绑定、new 绑定、箭头函数。
# 词法环境(LexicalEnvironment)
此法环境有两个组成部分
- 环境记录:存储变量和函数声明的实际位置;
- 对外部环境的引用:可以访问其外部词法环境。
此法环境有两种类型:
- 全局环境:是一个没有外部环境的词法环境,其外部环境引用为 null。拥有一个全局对象(window 对象)及其关联的方法和属性(例如数组方法)以及任何用户自定义的全局变量,this 的值指向这个全局对象;
- 函数环境:用户在函数中定义的变量被存储在函数环境中,包含了 arguments 对象。对外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境。
直接看伪代码可能更加直观
GlobalExectionContext = { // 全局执行上下文
LexicalEnvironment: { // 词法环境
EnvironmentRecord: { // 环境记录
Type: "Object", // 全局环境
// 标识符绑定在这里
outer: <null> // 对外部环境的引用
}
}
FunctionExectionContext = { // 函数执行上下文
LexicalEnvironment: { // 词法环境
EnvironmentRecord: { // 环境记录
Type: "Declarative", // 函数环境
// 标识符绑定在这里 // 对外部环境的引用
outer: <Global or outer function environment reference>
}
}
# 变量环境(VariableEnvironment)
变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。
在 ES6 中,词法环境和变量环境的区别在于前者用于存储函数声明和变量(let 和 const)绑定,而后者仅用于存储变量(var)绑定。
使用例子进行介绍
let a = 20;
const b = 30;
var c;
function multiply(e, f) {
var g = 20;
return e * f * g;
}
c = multiply(20, 30);
执行上下文如下所示
GlobalExectionContext = {
ThisBinding: <Global Object>,
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 标识符绑定在这里
a: < uninitialized >,
b: < uninitialized >,
multiply: < func >
}
outer: <null>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 标识符绑定在这里
c: undefined,
}
outer: <null>
}
}
FunctionExectionContext = {
ThisBinding: <Global Object>,
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 标识符绑定在这里
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 标识符绑定在这里
g: undefined
},
outer: <GlobalLexicalEnvironment>
}
}
变量提升 的原因:在创建阶段,函数声明存储在环境中,而变量会被设置为 undefined
(在 var
的情况下)或保持未初始化(在 let
和 const
的情况下)。所以这就是为什么可以在声明之前访问 var
定义的变量(尽管是 undefined
),但如果在声明之前访问 let
和 const
定义的变量就会提示引用错误的原因。这就是所谓的变量提升。
# 执行阶段
此阶段完成对所有变量的分配,最后执行代码。
如果 JavaScript 引擎在源代码中声明的实际位置找不到 let 变量的值,那么将为其分配 undefined 值。
# JavaScript 深入只执行上下文栈和变量对象
JS 是单线程的语言,执行顺序肯定是顺序执行,但是 JS 引擎并不是一行一行分析和执行程序,而是一段一段地执行,会先进行编译阶段然后才是执行阶段。
例子一:变量提升
foo; // undefined
var foo = function () {
console.log('foo1');
}
foo(); // foo1,foo赋值
var foo = function () {
console.log('foo2');
}
foo(); // foo2,foo重新赋值
例子二:函数提升
foo(); // foo2
function foo() {
console.log('foo1');
}
foo(); // foo2
function foo() {
console.log('foo2');
}
foo(); // foo2
例子三:声明优先级,函数 > 变量
foo(); // foo2
var foo = function() {
console.log('foo1');
}
foo(); // foo1,foo重新赋值
function foo() {
console.log('foo2');
}
foo(); // foo1
上面三个例子中,第一个例子是变量提升,第二个例子是函数提升,第三个例子是函数声明优先级高于变量声明。
需要注意 的是同一作用域下存在多个同名函数声明,后面的会替换前面的函数声明。
# 执行上下文栈
因为JS引擎创建了很多的执行上下文,所以JS引擎创建了执行上下文 栈(Execution context stack,ECS)来 管理 执行上下文。
当 JavaScript 初始化的时候会向执行上下文栈压入一个 全局 执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,执行栈才会被清空,所以程序结束之前, 执行栈最底部永远有个 globalContext。
ECStack = [ // 使用数组模拟栈
globalContext
];
# 找不同
有如下两段代码,执行的结果是一样的,但是两段代码究竟有什么不同?
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();
答案是 执行上下文栈的变化不一样。
第一段代码:
ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();
第二段代码:
ECStack.push(<checkscope> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();
# 函数上下文
在函数上下文中,用活动对象(activation object,AO)来表示变量对象
活动对象和变量对象的区别:
- 变量对象(VO)是规范上或者是 JS 引擎上实现的,并不能在 JS 环境中直接访问;
- 当进入到一个执行上下文后,这个变量对象才会被 激活,所以叫活动对象(AO),这时候活动对象上的各种属性才能被访问。
调用函数时,会为其创建一个 Arguments 对象,并自动初始化局部变量 arguments,指代该 Arguments 对象。所有所谓参数的值都会成为 Arguments 对象的数组元素。
# 执行过程
执行上下文的代码会氛围两个阶段进行处理:
- 进入执行上下文
- 代码执行
# 进入执行上下文
很明显,这时候还没有执行代码
此时的变量对象会包括(如下顺序初始化):
- 函数的所有形参(只有当前函数上下文):没有实参,属性值设为 undefined;
- 函数声明:如果变量对象已经存在相同名称的属性,则完全 替换 这个属性;
- 变量声明:如果变量名称和已经声明的形参或函数相同,则变量声明 不会干扰 已经存在的这类属性。
上代码就直观了
function foo(a) {
var b = 2;
function c() {}
var d = function() {};
b = 3;
}
foo(1);
对于上面的代码,这个时候的AO是
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
c: reference to function c(){},
d: undefined
}
形参arguments这时候已经有赋值了,但是变量还是undefined,只是初始化的值
# 代码执行
这个阶段会顺序执行代码,修改变量对象的值,执行完成后 AO 如下
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 3,
c: reference to function c(){},
d: reference to FunctionExpression "d"
}
总结如下:
- 全局上下文的变量对象初始化为全局对象;
- 函数上下文的变量对象初始化只包括 Arguments 对象;
- 在进入执行上下文是会给变量对象 添加形参、函数声明、变量声明 等初始的属性值;
- 代码执行阶段会再次修改变量对象的属性值。
# JavaScript 深入内存空间详细图解
在某些情况下,调用堆栈中函数调用的数量超出了调用堆栈的实际大小,浏览器会抛出一个错误终止运行。
对于下面的递归就会无限制的执行下去,直到超出调用堆栈的实际大小,这个是浏览器定义的。
function foo() {
foo();
}
foo();
# 栈数据结构
栈的结构就是先进后出,文中使用乒乓球盒子的结构来解释。
处于盒子中最顶层的乒乓球5,它一定是最后被放进去的,但可以最先被使用。而我们想要使用底层的乒乓球1,就必须将上面 4 个乒乓球取出来,让乒乓球1处于盒子顶层。
# 堆数据结构
堆数据结构是一种树状结构,它的存取数据的方式与书架和书非常相似。我们只需要知道书的名字就可以直接取出书了,并不需要把上面的书取出来。JSON 格式的数据中,我们存储 key-value
可以是无序的,因为顺序的不同并不影响我们的使用,我们只需要关心书的名字。
# 队列
队列是一种先进先出(FIFO)的数据结构,这是事件循环(Event Loop)的基础机构。
# 变量的存储
我们知道内存中有栈和堆,那么变量都存放在哪里呢?
# 基本类型
基本类型保存在 栈 内存中,因为这些类型在内存中分别占有固定大小的空间,通过按值来访问。基本类型一共有 7 种:Undefined、Null、Boolean、Number、String、BigInt 和 Symbol。
# 引用类型
引用类型保存在 堆 内存中,因为这种值的大小不固定,因此不能把它们保存在栈内存中,但是内存地址的大小是固定的,因此将值保存在堆内存中,在栈内存中存放的只是该对象的访问地址。当查询引用类型的变量时,先从 栈中读取内存地址,然后再通过地址 找到堆中的值。对于这种,我们把它叫做按引用访问。
在计算机的数据结构中,栈比堆的运算速度快,Object 是一个复杂的数据结构且可以扩展:数组可扩充,对象可添加属性,都可以增删改查。将它们放在堆中是为了不影响栈的效率,而是通过引用的方式查找到堆中实际的对象再进行操作。所以查找引用类型值的时候先去 栈 查找再去 堆 查找。
# 几个问题
问题1:
var a = 20;
var b = a;
b = 30;
// 这时a的值是多少?
问题2:
var a = { name: '前端开发' }
var b = a;
b.name = '进阶';
// 这时a.name的值是多少
问题3:
var a = { name: '前端开发' }
var b = a;
a = null;
// 这时b的值是多少
现在来解答一下,三个问题的答案分别是20
、‘进阶’
、{ name: '前端开发' }
- 对于问题1,a、b都是基本类型,它们的值是存储在栈中的,a、b分别有各自独立的栈空间,所以修改了b的值以后,a的值并不会发生变化。
- 对于问题2,a、b都是引用类型,栈内存中存放地址指向堆内存中的对象,引用类型的复制会为新的变量自动分配一个新的值保存在变量对象中,但只是引用类型的一个地址指针而已,实际指向的是同一个对象,所以修改
b.name
的值后,相应的a.name
也就发生了改变。 - 对于问题3,首先要说明的是
null
是基本类型,a = null
之后只是把a存储在栈内存中地址改变成了基本类型null,并不会影响堆内存中的对象,所以b的值不受影响。
# 内存空间管理
JavaScript 的内存生命周期是
- 分配你所需要的内存
- 使用分配到的内存(读、写)
- 不需要时将其释放、归还
JavaScript 有自动垃圾收集机制,最常用的是通过 标记清除 的算法来找到哪些变量是不再继续使用的,使用 a = null
其实仅仅只是做了一个释放引用的操作,让 a 原本对应的值失去引用,脱离执行环境,这个值会在下一次垃圾收集齐执行操作时被找到并释放。
在局部作用域中,当函数执行完毕,局部变量也就没有存在的必要,因此垃圾收集器很容易做出判断并回收。但是全局变量什么时候需要自动释放内存空间很难判断,因此在实际开发中,应尽量避免使用全局变量。
# 思考题
var a = {n: 1};
var b = a;
a.x = a = {n: 2};
a.x // 这时 a.x 的值是多少
b.x // 这时 b.x 的值是多少
# JavaScript 深入之带你走近内存机制
JS 内存空间分为 栈(stack)、堆(heap)、池(pool,一般也归类为栈)。其中 栈 存放变量,堆 存放复杂对象,池 存放常量,所以也叫常量池。
上一章我们说到了基本类型存在栈内存中,引用类型存在堆内存中。
其实还有一种特殊情况,就是闭包中的变量并不保存在栈内存中,而是保存在 堆内存中,这也就解释了为什么函数上下文被移出执行栈之后闭包还能引用到函数内部的变量。
function A() {
let a = 1
function B() {
console.log(a)
}
return B
}
函数 A 弹出调用栈后,函数 A 中的变量这时候是存放在堆中,所以函数 B 依旧能够引用到函数 A 中的变量。现在 JS 引擎可以通过逃逸分析分辨出哪些变量需要存储在栈中,那些需要存储在堆中。
本章节主要讲 内存回收 和 内存泄漏。
# 内存回收
JavaScript 有自动垃圾收集机制,垃圾收集器会每隔一段时间就执行一次释放操作,找出那些不再继续使用的值,然后是放弃内存占用。
- 局部变量和全局变量的销毁
- 局部变量:局部作用域中,当函数执行完毕,局部变量也就没有存在的必要,因此垃圾收集器很容易做出判断并回收;
- 全局变量:全局变量什么时候需要自动释放内存空间则很难判断,所以开发中应尽量避免使用全局变量;
- 以 Google 的 V8 引擎为例,V8 中所有的 JS 对象都是通过 堆 来进行内存分配的
- 初始分配:当声明并赋值时,V8 就会在堆内存中分配给这个变量;
- 继续申请:当已申请的内存不足以存储这个变量时,V8 就会继续申请内存,直到堆的大小达到了 V8 的内存上限为止;
- V8 对堆内存中的 JS 对象进行 分代管理
- 新生代:存活周期较短的 JS 对象,如临时变量、字符串等;
- 老生代:经过多次垃圾回收仍然存活,存活周期较长的对象,如主控制器、服务器对象等。
# 垃圾回收算法
对垃圾回收算法来说,核心思想就是如何判断内存已经不再使用,常用垃圾回收算法有以下两种:
- 引用计数(不再使用)
- 标记清除(现代浏览器常用)
# 引用计数
引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用,如果没有其他对象指向它,说明该对象已经不再需要了。
// 创建一个对象person,他有两个指向属性age和name的引用
var person = {
age: 12,
name: 'aaaa'
};
person.name = null; // 虽然name设置为null,但因为person对象还有指向name的引用,因此name不会回收
var p = person;
person = 1; //原来的person对象被赋值为1,但因为有新引用p指向原person对象,因此它不会被回收
p = null; //原person对象已经没有引用,很快会被回收
引用计数有一个致命的问题,那就是循环引用
如果两个对象相互引用,尽管他们已不再使用,但是垃圾回收器不会进行回收,最终可能会导致内存泄露。
function cycle() {
var o1 = {};
var o2 = {};
o1.a = o2;
o2.a = o1;
return "cycle reference!"
}
cycle();
cycle
函数执行完成之后,对象o1
和o2
实际上已经不再需要了,但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收。所以现代浏览器不再使用这个算法。
但是IE依旧使用。
var div = document.createElement("div");
div.onclick = function() {
console.log("click");
};
上面的写法很常见,但是上面的例子就是一个循环引用。
变量div有事件处理函数的引用,同时事件处理函数也有div的引用,因为div变量可在函数内被访问,所以循环引用就出现了。
# 标记清除
标记清除算法将“不再使用的对象”定义为”无法到达的对象“,即从根部(在 JS 中就是全局对象)出发定时扫描内存中的对象,凡是能从根部到达的对象,保留。那些从根部出发无法触及到的对象被标记为 不再使用,稍后进行回收。
无法触及的对象包含了没有引用的对象这个概念,但反之未必成立。
所以上面的例子就可以正确被垃圾回收处理了。
所以现在对于主流浏览器来说,只需要切断需要回收的对象与根部的联系。最常见的内存泄漏一般都与 DOM 元素绑定有关:
email.message = document.createElement(“div”);
displayList.appendChild(email.message);
// 稍后从displayList中清除DOM元素
displayList.removeAllChildren();
上面代码中,div
元素已经从DOM树中清除,但是该div
元素还绑定在email对象中,所以如果email对象存在,那么该div
元素就会一直保存在内存中。
# 内存泄漏
对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。对于不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。
# 内存泄露识别方法
# 浏览器方法
- 打开开发者工具,选择 Memory;
- 在右侧的 Select profiling type 字段里面勾选 timeline;
- 点击左上角的录制按钮;
- 在页面上进行各种操作模拟用户使用情况;
- 在一段时间后,点击左上角的 stop 按钮,面板上就会显示这段时间的内存占用情况。
# 命令行方法
使用 Node 提供的 process.memoryUsage
方法。
console.log(process.memoryUsage());
// 输出
{
rss: 27709440, // resident set size,所有内存占用,包括指令区和堆栈
heapTotal: 5685248, // "堆"占用的内存,包括用到的和没用到的
heapUsed: 3449392, // 用到的堆的部分
external: 8772 // V8 引擎内部的 C++ 对象占用的内存
}
判断内存泄漏,以heapUsed
字段为准。
# WeakMap
ES6 新出的两种数据结构:WeakSet
和 WeakMap
,表示这是弱引用,它们对于值的引用都是不计入垃圾回收机制的。
const wm = new WeakMap();
const element = document.getElementById('example');
wm.set(element, 'some information');
wm.get(element) // "some information"
先新建一个 Weakmap
实例,然后将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap
里面。这时,WeakMap
里面对element的引用就是弱引用,不会被计入垃圾回收机制。
# JavaScript 深入之四类常见内存泄漏及如何避免
# 垃圾回收算法
常用垃圾回收算法叫做 标记清除(Mark-and-sweep),算法由以下几部组成:
- 垃圾回收器创建了一个“roots”列表。roots 通常是代码中全局变量的引用。JavaScript 中,“window”对象是一个全局变量,被当作 root。window 对象总是存在,因此垃圾回收器可以检查它和它的所有子对象是否存在(即不是垃圾);
- 所有的 roots 被检查和标记为激活(即不是垃圾)。所有的子对象也被递归检查。从 root 开始所有的对象如果是可达的,它就不被当作垃圾;
- 所有未被标记的内存会被当作垃圾,收集器现在可以释放内存,归还给操作系统。
现代的垃圾回收器改良了算法,但是本质是相同的:可达内存被标记,其余的被当作垃圾回收。
# 四种常见的 JS 内存泄漏
这是重点考点
# 意外的全局变量
未定义的变量会在全局对象创建一个新变量,如下。
function foo(arg) {
bar = "this is a hidden global variable";
}
函数 foo
内部忘记使用 var
,实际上JS会把bar挂载到全局对象上,意外创建一个全局变量。
function foo(arg) {
window.bar = "this is an explicit global variable";
}
另一个意外的全局变量可能由 this
创建。
function foo() {
this.variable = "potential accidental global";
}
// Foo 调用自己,this 指向了全局对象(window)
// 而不是 undefined
foo();
解决方法:
在 JavaScript 文件头部加上 'use strict'
,使用严格模式避免意外的全局变量,此时上例中的this指向undefined
。如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义。
# 被遗忘的计时器或回调函数
计时器 setInterval
代码很常见
var someResource = getData();
setInterval(function() {
var node = document.getElementById('Node');
if(node) {
// 处理 node 和 someResource
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);
上面的例子表明,在节点node或者数据不再需要时,定时器依旧指向这些数据。所以哪怕当node节点被移除后,interval 仍旧存活并且垃圾回收器没办法回收,它的依赖也没办法被回收,除非终止定时器。
var element = document.getElementById('button');
function onClick(event) {
element.innerHTML = 'text';
}
element.addEventListener('click', onClick);
对于上面观察者的例子,一旦它们不再需要(或者关联的对象变成不可达),明确地移除它们非常重要。老的 IE 6 是无法处理循环引用的。因为老版本的 IE 是无法检测 DOM 节点与 JavaScript 代码之间的循环引用,会导致内存泄漏。
但是,现代的浏览器(包括 IE 和 Microsoft Edge)使用了更先进的垃圾回收算法(标记清除),已经可以正确检测和处理循环引用了。即回收节点内存时,不必非要调用 removeEventListener
了。
# 脱离 DOM 的引用
如果把DOM 存成字典(JSON 键值对)或者数组,此时,同样的 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。那么将来需要把两个引用都清除。
var elements = {
button: document.getElementById('button'),
image: document.getElementById('image'),
text: document.getElementById('text')
};
function doStuff() {
image.src = 'http://some.url/image';
button.click();
console.log(text.innerHTML);
// 更多逻辑
}
function removeButton() {
// 按钮是 body 的后代元素
document.body.removeChild(document.getElementById('button'));
// 此时,仍旧存在一个全局的 #button 的引用
// elements 字典。button 元素仍旧在内存中,不能被 GC 回收。
}
如果代码中保存了表格某一个 <td>
的引用。将来决定删除整个表格的时候,直觉认为 GC 会回收除了已保存的 <td>
以外的其它节点。实际情况并非如此:此 <td>
是表格的子节点,子元素与父元素是引用关系。由于代码保留了 <td>
的引用,导致整个表格仍待在内存中。所以保存 DOM 元素引用的时候,要小心谨慎。
# 闭包
闭包的关键是匿名函数可以访问父级作用域的变量。
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);
每次调用 replaceThing
,theThing
得到一个包含一个大数组和一个新闭包(someMethod
)的新对象。同时,变量 unused
是一个引用 originalThing
的闭包(先前的 replaceThing
又调用了 theThing
)。someMethod
可以通过 theThing
使用,someMethod
与 unused
分享闭包作用域,尽管 unused
从未使用,它引用的 originalThing
迫使它保留在内存中(防止被回收)。
解决方法:
在 replaceThing
的最后添加 originalThing = null
。