console.log(a) //undefined
console.log(b) //ReferenceError: Cannot access 'b' before initialization
var a
let b
变量提升,相信大家都很熟悉,用var声明的变量,会出现的上述现象。那变量提升的原理是什么呢?
答:执行上下文=>创建阶段=>词法环境与变量环境的区别。
执行上下文和执行栈
执行上下文
执行上下文
是当前 JavaScript 代码被解析和执行时所在环境的抽象概念。
执行上下文的类型
- 全局执行上下文:只有一个,浏览器的全局对象就是 window 对象,this 指向这个全局对象;
- 函数执行上下文:存在无数个,只有在函数被调用的时候才会被创建,每次调用函数都会创建一个新的执行上下文;
- eval 函数执行上下文:指的是运行在 eval 函数中的代码,很少用且不建议使用。
执行栈
执行栈,也叫调用栈,具有先进后出结构,用于存储在代码执行期间创建的所有执行上下文。
首次运行 JS 代码时,会创建一个 全局执行上下文 并推到当前执行栈中。每当发生函数调用时,引擎都会为该函数创建一个 新的函数执行上下文 并推到当前执行栈的栈顶。
根据执行栈后进先出的规则,当栈顶函数完成后,其对应的函数执行上下文会从栈顶被推出,上下文控制权移交到当前执行栈的下一个执行上下文。
因为JS引擎创建了很多的执行上下文,所以JS引擎创建了执行栈
(Execution context stack,ECS)来 管理 执行上下文。
执行上下文的创建
执行上下文分两个阶段创建:
- 创建阶段
- 执行阶段
创建阶段主要做以下三件事:
- 确定 this 的值,这也被称为 This Binding;
- LexicalEnvironment(词法环境)组件被创建;
- VariableEnvironment(变量环境)组件被创建。
伪代码:
ExecutionContext = {
ThisBinding = <this value>, // 确定this
LexicalEnvironment = { ... }, // 词法环境
VariableEnvironment = { ... }, // 变量环境
}
This Binding
- 全局执行上下文中,this 的值指向全局对象,在浏览器中 this 的值指向 window 对象,而在 nodejs 中指向这个文件的 module 对象;
- 函数执行上下文中,this 的值取决于函数的调用方式,具体有:默认绑定、隐式绑定、显式绑定、new 绑定、箭头函数。
梳理(个人理解):在介绍词法环境与变量环境前,我们先梳理一下逻辑,首先词法环境与变量环境都是执行环境(执行上下文)下的
属性
,主要是全局上下文
与函数上下文
。下边可阅读伪代码理解。
词法环境(LexicalEnvironment)
词法环境有两个组成部分:
- 环境记录:存储变量和函数声明的实际位置;
- 对外部环境的引用:可以访问其外部词法环境。
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 值。
补充: 其实除了变量提升,还有函数提升,感兴趣的可以自行了解。
function A() {
let a = 1
function B() {
console.log(a)
}
return B
}
A()() //1
最常见的闭包形式,嵌套函数,内部函数引用外部函数的变量;return出去后能在函数外部访问到内部变量a。可能大家都习以为常了,但是结合上边介绍的执行上下文与执行栈知识,函数上下文被移出执行栈之后,为什么闭包还能引用到函数内部的变量?其实闭包是一个特殊情况,里边还涉及到了JavaScript内存空间
方面的知识。
JavaScript内存空间
变量的存储
基本类型
基本类型保存在 栈 内存中,因为这些类型在内存中分别占有固定大小
的空间,通过按值来访问。基本类型一共有 7 种:Undefined、Null、Boolean、Number、String、BigInt 和 Symbol。
引用类型
引用类型保存在 堆 内存中,因为这种值的大小不固定
,因此不能把它们保存在栈内存中,但是内存地址的大小是固定的,因此将值保存在堆内存中,在栈内存中存放的只是该对象的访问地址。当查询引用类型的变量时,先从 栈中读取内存地址,然后再通过地址 找到堆中的值。对于这种,我们把它叫做按引用访问。
在计算机的数据结构中,栈比堆的运算速度快,Object 是一个复杂的数据结构且可以扩展:数组可扩充,对象可添加属性,都可以增删改查。将它们放在堆中是为了不影响栈的效率,而是通过引用的方式查找到堆中实际的对象再进行操作。所以查找引用类型值的时候先去 栈 查找再去 堆查找。
JavaScript内存生命周期:
- 分配你所需要的内存
- 使用分配到的内存(读、写)
- 不需要时将其释放、归还
在局部作用域中,当函数执行完毕,局部变量也就没有存在的必要,因此垃圾收集器很容易做出判断并回收。但是全局变量
什么时候需要自动释放内存空间很难判断,因此在实际开发中,应尽量避免使用全局变量。
垃圾回收算法的选择也是旨在解决全局变量回收的难题。
先填一下刚刚提到的闭包示例的坑,其实闭包是一种特殊情况,就是闭包中的变量并不保存在栈内存中,而是保存在 堆内存中,这也就解释了为什么函数上下文被移出执行栈之后闭包还能引用到函数内部的变量。 函数 A 弹出调用栈后,函数 A 中的变量这时候是存放在堆中,所以函数 B 依旧能够引用到函数 A 中的变量。现在 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
实际上已经不再需要了,但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收。所以现代浏览器不再使用这个算法。
标记清除
标记清除算法将“不再使用的对象”定义为”无法到达的对象“,即从根部(在 JS 中就是全局对象)出发定时扫描内存中的对象,凡是能从根部到达的对象,保留。那些从根部出发无法触及到的对象被标记为 不再使用,稍后进行回收。
无法触及的对象包含了没有引用的对象这个概念,但反之未必成立。
所以上面的例子就可以正确被垃圾回收处理了。
所以现在对于主流浏览器来说,只需要切断需要回收的对象与根部的联系。
标记清除(Mark-and-sweep) 算法由以下几部组成:
- 垃圾回收器创建了一个“roots”列表。roots 通常是代码中全局变量的引用。JavaScript 中,“window”对象是一个全局变量,被当作 root。window 对象总是存在,因此垃圾回收器可以检查它和它的所有子对象是否存在(即不是垃圾);
- 所有的 roots 被检查和标记为激活(即不是垃圾)。所有的子对象也被递归检查。从 root 开始所有的对象如果是可达的,它就不被当作垃圾;
- 所有未被标记的内存会被当作垃圾,收集器现在可以释放内存,归还给操作系统。
现代的垃圾回收器改良了算法,但是本质是相同的:可达内存被标记,其余的被当作垃圾回收。
var补充
for循环中的var与let:
for (var i = 0; i < 5; ++i) {
// 循环逻辑
}
console.log(i); // 5
for 循环定义的迭代变量(使用var声明)会渗透到循环体外部;
for (let i = 0; i < 5; ++i) {
// 循环逻辑
}
console.log(i); // ReferenceError: i没有定义
改成使用 let 之后,这个问题就消失了,因为迭代变量的作用 域仅限于 for 循环块内部;
for (var i = 0; i < 5; ++i) {
setTimeout(() => console.log(i), 0)
}
// 你可能以为会输出0、1、2、3、4
// 实际上会输出5、5、5、5、5
for (let i = 0; i < 5; ++i) {
setTimeout(() => console.log(i), 0)
}
// 会输出0、1、2、3、4
在使用 var 的时候,最常见的问题就是对迭代变量的奇特声明和修改,之所以会这样,是因为在退出循环时,迭代变量保存的是导致循环退出的值:5。在之后执行超时逻辑时,所有的i都是同一个变量,因而输出的都是同一个最终值。而在使用 let声明迭代变量时,JavaScript引擎在后台会为每个迭代循环声明一个新的迭代变量。每个 setTimeout 引用的都是不同的变量实例,所以 console.log输出的是我们期望的值,也就是循环执行过程中每个迭代变量的值。
文章内容为自身学习总结,借阅了多篇文章的内容。