1. 前言
JS 这门语言有很多 核心特性 ,例如 闭包、作用域链、变量提升、this指向 等等,如果你对他们的 底层运行机制 缺乏系统认知,只是依赖表面规则记忆,遇到复杂一点的情况就会无从下手,并且 机械记忆表面规则 难以持久。但若从它们 形成的最本质原因 进行理解你会发现它们底层原理都是相通的,学习了这些底层知识后你不仅具备了 预测任何代码执行结果 的能力,更是从原来的表面规则记忆转为 逻辑推导记忆 ,更加牢固不会忘记~。
本文总共7000字,文字和配图都是自己手打和制作的,只为了更准确的展示思路,但也难免会有我没发现的疏漏和错误,如果有欢迎指出,大家一起学习。
1.1. 两道题检验你是否需要阅读本篇文章~
题一:this指向
const obj = {
name: "Object",
regularFunc: function () {
console.log(this.name);
return () => {
console.log(this.name);
};
},
arrowFunc: () => {
console.log(this.name);
},
};
const regularFunc = obj.regularFunc;
const arrowFunc = obj.arrowFunc;
regularFunc()(); // 输出什么?为什么?
arrowFunc(); // 输出什么?为什么?
题二:闭包
function createFunctions() {
var result = [];
for (var i = 0; i < 5; i++) {
result.push(function () {
return i;
});
}
return result;
}
var functions = createFunctions();
console.log(functions[0]()); // 输出什么?为什么?
console.log(functions[1]()); // 输出什么?为什么?
//如何修改代码使每个函数返回对应的索引值?
如果你认为自己说不清上面两段代码的执行过程或者无法准确预测执行结果,那就可以继续往下看啦
2. 如何正确分析 JS 代码执行过程?
那就先让我们知道 JS 引擎是如何执行我们的 JS 代码的
2.1. JS 是怎么执行的
当 JS 引擎执行代码时,会经历两个核心阶段:编译阶段 和 执行阶段。这两个阶段共同决定了变量的访问规则、作用域链的形成 以及 代码的实际运行结果 ,接下来我们就一起剖析这两个阶段的 JS 引擎到底对我们的代码做了什么。
2.2. 编译阶段
在这个阶段,JS 引擎首先会做以下工作:
2.2.1. 变量和函数声明提升
这一步引擎所做的工作让我们能在 变量赋值 和 函数声明(只有声明式函数) 前就可以访问变量和调用函数,就好像变量和函数声明被 “提升” 了一样。例如下面的代码:
var a = 1;
function b() {
a = 10;
return;
function a() {}
}
b();
console.log(a); // 输出: 1
⬆️函数 b 内部对 a 变量的赋值并没有赋值到全局的 a ,而是赋值到了函数 b 内部的函数 a
那么为什么会出现这种现象呢?其实非常简单。就是因为 JS 引擎在编译阶段做了下面的操作:
环境记录的创建
在编译阶段 JS 引擎在代码执行前会先 “扫描” 一遍代码,并为 所有作用域 生成一张 “变量-值 映射表” ,这个映射表你可以理解为是一个 Map 的 映射数据结构 (实际上你后续在 执行阶段 读取变量 或者为某个 变量赋值 ,都是对这个表进行操作 ),它内部用来 存储你在该作用域内声明的变量和函数 。而这个映射表有一个专属的名字,叫做 环境记录(Environment Record) 例如下面的代码:
// 编译阶段示例
const blockConst = 1;
let globalLet = 2;
会在 编译阶段 构建下面的 环境记录
// 编译后环境记录示意
全局作用域的环境记录: {
globalConst: <uninitialized> // const绑定
globalLet: <uninitialized> // let绑定
}
PS1: 对于 环境记录 这个 变量映射表 的实现不同引擎可能采用不同结构,如 哈希表或数组
PS2: 为什么要在 编译阶段 进行 环境记录 的创建?答: 1. 代码在 执行阶段 需要变量信息,但执行时再收集就太慢了,所以在 编译阶段 提前将变量进行收集,后续执行时就只需进行赋值操作 减少运行时间了。2. 能够进行提前确定并 固定作用域层级关系,避免执行时才动态解析作用域,提升性能。 3. 变量提升 实现基础、闭包 实现基础(后面会说)
不同声明类型的处理差异
在上面我们为所有 作用域 创建了一个用于 记录变量 的 环境记录, 那么在 “扫描” 过程中发现的 所有在该作用域内声明的变量和函数 都会按照一定 规则 被加入进相应的 环境记录 中。
具体规则为:
- 只有
let和const声明的变量会绑定到 块级作用域 的 环境记录,而var声明的变量会 忽略块级作用域。 直接绑定到 外层的函数或全局作用域的环境记录。 - 如果是用
var类型的变量则在设置时赋一个初始值undefined,如果是一个函数声明则赋值为 完整的函数对象 (所以你可以在函数声明前正常调用函数),如果是let或const声明的则被标记为 未初始化 ,并且无法访问,这就叫 “暂时性死区”(Temporal Dead Zone, TDZ) 。
分开存储不同类型变量
通过上面的描述我们明白了 var 、 声明式函数与 let 、 const 类型的差异:
- 只有 声明式函数 和
var类型的变量能够在声明前进行访问,而let和const类型的变量不能; - 只有
let和const类型的变量拥有 块级作用域,也就是如果你在块级作用域内定义了 var 类型的变量,它也不会绑定到这个块级的 环境记录 内,而是绑定到外层的 函数或全局作用域 的 环境记录 内。
既然 var 类型、函数声明 与 let、const类型变量有这么多区别,引擎为了减少在 变量赋值 与 变量查找 时对他们进行 类型区分的开销 ,就干脆将他们 分开存储 ,一个作用域内可以同时存在 var 和 let、const 变量,但它们分别存储在两个不同的 环境记录 内,之后无论是 查找变量 还是 赋值变量 都会容易很多,因为可以 直接根据表类型 进行区分操作逻辑,比如在 块级作用域 内如果遇到对 var 类型的变量进行赋值,我就不会找当前的 环境记录,而是寻找更外层的函数或全局作用域的环境记录,因为只有那里才会存储 var 类型的变量。例如下面的代码:
// 编译阶段示例
{
let blockLet = 1;
var blockVar = 2;
function func() {}
}
let globalLet = 3;
会在 编译阶段 构建下面的 环境记录
// 编译后环境记录示意
块级作用域内的环境记录: {
blockLet: <uninitialized> // let绑定
}
全局作用域的环境记录(记录 var 和 函数声明): {
blockVar: undefined, // var绑定
func: <function object> // 函数声明
}
全局作用域的环境记录(记录 let 和 const): {
globalLet: <uninitialized> // let绑定
}
PS: 在之前的内容中,我们提到要对
let、const和var以及函数声明的变量进行区分,将它们分别存储在不同的 环境记录 中,但实际上这种说法并不准确,因为 环境记录 其实是另一个数据结构的一部分,即 词法环境 和 变量环境,你可以把 环境记录 当作是它们的一个属性(而另一个属性 outer 我会在下一段进行解释,它是作用域链的核心实现),其实也非常简单,词法环境 和 变量环境 的区别在于 词法环境 只存储let、const类型的 环境记录,而 变量环境 则只存储var类型和函数声明的 环境记录。
小结
以上全部就是 变量提升 的本质,也是为什么在某些情况下你可以在变量声明前对它进行访问的原因。对于var声明的变量,你可以在声明前访问它,但它的值会是undefined;对于通过 声明式定义 的函数,你可以在 它声明之前正常调用和使用它 ;对于let和const声明的变量,在声明前访问会导致引用错误,因为它们处于暂时性死区。
示例
var i = 1;
let j = 2;
function foo() {
if(i === undefined){
var c = 0;
let i = 4;
}
console.log(i); // 输出 undefined
var i = 2;
}
foo();
以下是上面代码在编译阶段执行操作后各作用域 词法环境 和 变量环境 的变量收集情况和赋值情况:
重要❗❗看这里⬇️
注:严格来说,词法环境、变量环境 及其内部的 环境记录 实例是在 运行时 动态创建的(进入作用域时进行创建,也就是进入 全局、函数和块级作用域 还没开始执行内部代码的时候)。但它们的 结构和行为 完全由 编译阶段的静态分析结果 决定,包括 变量绑定规则、作用域链的 outer 指向 等。为了 简化理解 ,前文将 编译阶段 描述为 “生成” 这些结构,实际是 “预定义其结构”。运行时实例化时,引擎会严格按 编译阶段 的规则 分配内存并初始化变量(如 var 置为 undefined , let 标记为未初始化)。
2.2.2. 变量访问规则的确立
我们都知道,JavaScript 的变量 作用域特性 允许我们在 当前作用域 中查找不到某个变量时,自动向上查找外层作用域中的变量 。这一功能的 实现 和 固定 是在编译阶段完成的,具体来说,是通过构建和固定作用域链来实现的。
作用域链的构建
JavaScript 引擎会对代码进行静态分析,构建每个作用域的 词法环境 和 变量环境。每个环境都有一个 outer 属性,它指向外部作用域的环境的地址。这个 outer 属性的指向在代码执行前就已经确定,并且在运行时不会改变。这意味着,你的作用域规则完全由你书写代码时的静态物理嵌套结构决定,与运行时的动态行为无关。
区分词法环境与变量环境
- 词法环境:存储
let和const声明的变量。 - 变量环境:存储
var声明的变量和函数声明。
这两个环境的 outer 属性分别指向外部作用域的对应环境,形成了作用域链。
示例
function createCounter() {
let count = 0; // 局部变量
return function () {
count += 1; // 内部函数访问外部函数的局部变量
console.log(count);
};
}
const counter = createCounter();
counter(); // 输出 1
counter(); // 输出 2
counter(); // 输出 3
在编译阶段,JavaScript 引擎会为 createCounter 函数和其内部的匿名函数分别创建 词法环境 和 变量环境,并设置它们的 outer 属性指向外部作用域的环境。具体来说:
createCounter函数的 词法环境 和 变量环境 的outer属性指向 全局作用域 的环境。- 内部匿名函数的 词法环境 和 变量环境 的
outer属性指向createCounter函数的环境。
这样,当匿名函数访问变量count时,它会首先在自己的 词法环境 中查找,如果找不到,就会通过outer属性向上查找,直到找到count变量为止。
2.2.3. for 循环中的块级作用域
让我们先从一个反直觉的例题开始讲起,以更好地理解 for 循环中的块级作用域 和条件作用域。
for (var i = 0; i < 3; i++) {
console.log(i);
}
console.log(i); // 输出 3
在上面的代码中,我们使用 var 声明了循环变量 i 。由于 var 声明的变量会被提升到外层函数或全局作用域中,因此在 for 循环外部 仍然可以访问变量 i 。这可能会导致一些意外的行为,因为循环变量在整个外层作用域中都是可访问的,而不仅仅是在循环体内。
使用 let 或 const 声明循环变量
为了避免上述问题,我们可以使用 let 或 const 来声明循环变量。使用 let 或 const 声明的变量具有 块级作用域 ,这意味着它们只能在循环体内访问,一旦离开循环体,变量就会失效。
for (let i = 0; i < 3; i++) {
console.log(i);
}
console.log(i); // ReferenceError: i is not defined
在上面的代码中,我们使用 let 声明了循环变量 i 。由于 let 声明的变量具有 块级作用域 ,因此在 for 循环外部 无法访问变量 i ,这避免了变量泄露到外层作用域的问题。
循环条件的独立词法环境
在 for 循环 中,循环条件部分(即 for 语句的初始化部分)会独立生成一个词法环境。这意味着循环条件中的变量声明会在一个独立的作用域中进行,而不会影响到循环体内部的作用域。
for (let i = 0; i < 3; i++) {
let i = 10;
console.log(i); // 输出 10
}
在上面的代码中,我们在 for 循环的初始化部分 使用 let 声明了变量 i ,然后在循环体内部又使用 let 声明了一个同名的变量 i 。由于循环条件部分和循环体内部分别具有独立的词法环境,因此这两个变量不会相互影响。循环体内部的 console.log(i) 会输出循环体内部声明的变量 i 的值,即 10 。
一个重要的细节是,每次 for 循环迭代 时,循环条件部分都会重新创建一个新的词法环境。这意味着每次迭代时,循环条件部分的变量都会被重新声明和初始化。
for (let i = 0; i < 3; i++) {
console.log(i); // 输出 0, 1, 2
let i = 10;
console.log(i); // 输出 10
}
在上面的代码中,每次迭代时, for 循环的初始化部分 let i = 0 都会重新创建一个新的词法环境,并重新声明和初始化变量 i 。因此,每次迭代时,循环条件部分的变量 i 都是独立的,不会受到循环体内部变量 i 的影响。
2.3. 执行阶段
在编译阶段完成所有准备工作后,JS引擎进入执行阶段 ,这是代码实际运行并产生结果的关键环节。接下来我们将一步步分析执行阶段的主要流程。
2.3.1. 前置关键概念解释
执行上下文(Execution Context)
执行上下文是 JS引擎执行代码时的上下文环境 ,包含变量环境、词法环境和 this 变量等信息。
- 变量环境 :存储由
var声明的变量和函数声明。 - 词法环境 :存储由
let、const声明的变量。 - 全局执行上下文 :执行全局代码前创建的执行上下文,用于执行全局的代码。
- 函数执行上下文 :在函数被调用时创建的执行上下文,用于执行函数内部的代码。
eval执行上下文 :在调用eval函数时创建的执行上下文,较少使用。 执行上下文确保代码能够正确访问变量、执行函数,并确定this的指向 。
执行上下文栈(Execution Context Stack)
执行上下文栈是一个后进先出的数据结构 ,用于管理函数调用过程中的执行上下文。当调用一个函数时,一个新的函数执行上下文会被压入栈顶,成为当前正在执行的上下文。当函数执行完毕,该上下文会被弹出,恢复到之前的执行上下文继续执行。
例如:
function functionA() {
console.log('A');
functionB();
}
function functionB() {
console.log('B');
}
在这个例子中,执行上下文栈 的顺序是:
- 全局执行上下文
functionA的执行上下文functionB的执行上下文- 当
functionB执行完毕,其上下文被弹出 - 恢复到
functionA的上下文继续执行。
2.3.2. 具体执行流程
1. 词法环境和变量环境的创建
词法环境 和 变量环境 ,它们分别存储了由 let、const 声明的变量以及由 var 声明的变量和 函数声明。在执行阶段,当进入一个新的执行上下文(进入全局代码、进行函数调用或 eval 代码)时,会首先为该上下文创建对应的 词法环境 和 变量环境实例。这些实例在运行时 动态生成 ,但其结构和行为遵循编译阶段预定义的规则。
2. 执行上下文的进入与变量环境的激活
当进入一个 新的执行上下文时, JS 引擎会根据 编译阶段预定义 的规则,将对应的 变量环境 和 词法环境 实例化。对于 var 声明的变量,会初始化为 undefined;对于 函数声明 ,则会将其对应的 函数对象 赋值给变量。此时,变量环境 和 词法环境 中的 环境记录(存储变量和函数的映射关系的结构)开始生效,引擎可以开始对这些 变量 进行 读取 和 写入 操作。
3. 代码执行与值的查找
引擎开始 逐行执行代码。当遇到对 变量 或 函数 的引用时,会在当前 执行上下文 的 词法环境 和 变量环境 中按照以下顺序进行查找:
- 首先在当前 词法环境 的 环境记录 中查找。如果找到,则使用该变量的值。
- 如果在当前 词法环境 的 环境记录 中找不到,则沿着 词法环境 的
outer链(outer 属性指向外部作用域的词法环境)继续向上查找,直到找到 全局词法环境。如果在整个查找过程中都没有找到该变量,则会抛出引用错误(ReferenceError)。
示例
function outer() {
var outerVar = "I'm outer";
function inner() {
console.log(outerVar); // 查找outerVar
}
inner();
}
outer();
- 当执行
inner函数中的console.log(outerVar)时 - 引擎首先在
inner函数的 词法环境 的 环境记录 中查找outerVar,找不到 - 然后沿着
outer链查找outer函数的 词法环境 的 环境记录 ,找到outerVar并输出其值。
4. 变量赋值与更新
当对 变量 进行 赋值 操作时,引擎会在当前 变量环境 的 环境记录 中找到对应的 变量 ,并更新其值。如果变量是 let 或 const 声明的,则在 词法环境 的 环境记录 中进行更新;如果是 var 声明的,则在 变量环境 的 环境记录 中更新。需要注意的是,对于 const 声明的 变量,一旦 赋值 后就不能再改变其值,否则会报错。
5. 函数调用与执行上下文的切换
当调用一个函数时,会创建一个新的执行上下文,并将其压入 执行上下文栈 中。此时,引擎会暂停当前执行上下文的代码执行,转而开始执行新函数的代码。新函数的 执行上下文 会创建自己的 词法环境 和 变量环境,并按照上述过程进行 变量查找 和 赋值。在函数执行,完成后其 执行上下文从栈 中弹出,引擎恢复到之前的执行上下文继续执行代码。
6. this 指向的确定
在执行阶段,非箭头函数 的函数的 this 指向根据函数的 调用方式 来确定,总共只有以下四种情况:
- 全局调用 :在非严格模式下,
this指向全局对象(如浏览器中的window);在严格模式下,this为undefined。 - 作为对象方法调用 :
this指向该对象。 - 构造函数调用 :
this指向新创建的对象。 call、apply或bind方法调用 :this指向由这些方法指定的对象。
示例
const obj = {
name: "Object",
regularFunc: function () {
console.log(this.name); // this 指向 obj
}
};
obj.regularFunc();
function sayHello() {
console.log(this);
}
sayHello(); // 非严格模式下this指向window,严格模式下为undefined
- 当执行
obj.regularFunc()和sayHello()时,都会分别创建新的 执行上下文 并压入 执行上下文栈。在每个 执行上下文 中,都会建立对应的 变量环境 和 词法环境。 - 对于
obj.regularFunc(),它是作为 对象方法 被 调用 的,因此在regularFunc函数内部,this指向调用它的对象obj,所以console.log(this.name)输出的是obj.name的值,即"Object"。而对于sayHello(),它是以全局调用的方式执行的,在非严格模式下,this指向全局对象(如浏览器中的window);在严格模式下,this为undefined,因此console.log(this)的输出结果取决于当前的模式。
箭头函数 this 指向
箭头函数的 this 特性 :箭头函数没有自己的 this 变量,this 变量只存在于 普通函数执行上下文 以及 全局执行上下文 。箭头函数内部使用的 this 变量实际是外层函数或全局执行上下文中的 this 变量。也就是说,你可以将 this 看作是一个普通函数执行上下文和全局执行上下文中特有的在执行时动态创建的变量,你在箭头函数中使用 this 实际就是对这个变量进行了引用。
示例一
const obj = {
name: "Object",
arrowFunc: () => {
console.log(this.name); // this 指向全局对象或 undefined(严格模式)
}
};
obj.arrowFunc();
- 当定义
arrowFunc箭头函数时(也就是在编译阶段构建词法环境收集变量时),它会捕获定义时所在上下文的this值。在这里,arrowFunc是在全局作用域中定义的(虽然它被赋值给了对象obj的属性),所以它的this值在非严格模式下指向全局对象(如window),在严格模式下指向undefined。 - 当调用
obj.arrowFunc()时,尽管看起来好像是作为对象方法调用,但由于箭头函数的特性(没有this),它内部的this并不会指向obj,而是保持定义时捕获的this值。所以在非严格模式下,console.log(this.name)输出的是全局对象的name属性(如果存在),通常为undefined(因为全局对象一般没有name属性);在严格模式下,由于this是undefined,也会输出undefined。
示例二
const obj = {
name: "Object",
getThis: function () {
return () => this;
}
};
const getThisArrow = obj.getThis();
console.log(getThisArrow.name); // 输出 "Object"(非严格模式下)或报错(严格模式下,因为 this 是 undefined)
obj.getThis()返回的是一个箭头函数。这个箭头函数在定义时,外层最近的普通函数执行上下文或全局执行上下文是getThis函数的执行上下文,其中this指向obj(因为getThis是作为对象方法调用的)。所以箭头函数中的this实际上就是引用的getThis函数的执行上下文中的this, 所以也指向obj。- 当将返回的箭头函数赋值给
getThisArrow,执行getThisArrow.name时,实际上是在访问箭头函数中的this.name。由于箭头函数的this指向obj,所以在非严格模式下输出"Object";在严格模式下,由于this是undefined,访问undefined.name会报错。
7.闭包的处理
闭包 是指函数能够访问其外层函数作用域中的变量。在执行阶段,当内部函数被调用时,它会沿着自己的词法环境的 outer 链向上查找变量,从而访问到外层函数中的变量。即使外层函数已经执行完毕,其词法环境依然会被内部函数的词法环境所引用(并且这个引用规则是从编译阶段就固定的),不会被垃圾回收机制回收,以保证 闭包 能够正常访问外层变量。
示例
function createCounter() {
let count = 0;
return function () {
count += 1;
console.log(count);
};
}
const counter = createCounter();
counter(); // 输出1
counter(); // 输出2
当 createCounter 函数执行完毕后,其返回的内部函数依然能够访问 count变量,这是因为内部函数的词法环境的 outer 链指向 createCounter 函数的词法环境,从而保证了对 count 变量的访问。
3. 检验学习成果~
来一起做一道融合了闭包、this 指向等多个知识点的题目
const obj = {
name: "Object",
init: function () {
const self = this;
this.regularFunc = function () {
console.log(this.name);
return () => {
console.log(self.name);
};
};
this.arrowFunc = () => {
console.log(this.name);
};
},
};
obj.init();
const regularFunc = obj.regularFunc;
const arrowFunc = obj.arrowFunc;
regularFunc()(); // 输出什么?为什么?
arrowFunc(); // 输出什么?为什么?
编译阶段
1.为所有作用域构建 词法环境、变量环境结构
2.确定箭头函数 this 指向
下面是 编译阶段 完成结果
执行阶段
- 创建全局执行上下文,根据编译阶段构建的词法环境结构创建词法环境实例并为收集的变量分配内存并初始化,这里为
obj、regularFunc、arrowFunc分配变量内存,并将outer设置为null,最后创建this变量并指向全局对象(浏览器为window)。 - 从头开始逐行执行代码。
obj赋值操作:创建了一个对象,内部包含name属性和init属性,其中init是一个函数,创建完对象后,赋值给变量obj,从当前词法环境内查找obj变量,发现存在该变量,将对象的地址赋值给词法环境中的obj变量。- 调用
obj.init():调用函数前,首先创建该函数的执行上下文:根据编译阶段确定好的词法环境结构创建词法环境,创建this变量并赋值为obj。 - 逐行执行
init函数体内部代码:读取this变量并赋值给self,创建函数对象赋值给this(也就是obj)中的regularFunc属性,创建一个箭头函数对象赋值给this(也就是obj)中的arrowFunc属性。 - 执行完
init函数体内部的代码后,将当前函数执行上下文弹出执行上下文栈,继续逐行运行后续的全局代码。 - 读取全局词法环境中的
obj变量的regularFunc属性的值赋值给全局词法环境中的regularFunc变量。 - 读取全局词法环境中的
obj变量的arrowFunc属性的值赋值给全局词法环境中的arrowFunc变量。 - 调用
regularFunc():调用函数前,首先创建该函数的执行上下文:根据编译阶段确定好的词法环境结构创建词法环境,在调用regularFunc()时,regularFunc是作为普通函数在全局环境中被调用的,因此this指向全局对象(非严格模式下是window,严格模式下是undefined)。 console.log打印当前this变量的name属性值,而当前全局对象window并没有name这个属性,所以输出undefined,接着返回一个箭头函数。- 调用上一步返回的箭头函数,该箭头函数内部获取
self变量的name属性值,开始在当前词法环境中寻找self变量,发现没有,顺着outer指向来到返回该箭头函数的外部函数的词法环境,发现也没有self变量,再顺着outer来到init函数的词法环境,发现了self变量,读取它的name属性,由于在第五步这个self变量已经被赋值为this变量的值(也就是obj对象),所以这里的结果为打印obj中的name属性,也就是 "Object"。 - 执行完函数体内部的代码后,将当前函数执行上下文弹出执行上下文栈,继续逐行运行后续的全局代码。
- 调用全局词法环境的
arrowFunc函数,该函数是一个箭头函数,内部this在编译时已经根据物理结构确定,为外层函数init的this(这里你可以看作是一个闭包,this是init函数内的一个变量,而init函数体内创建的赋值给this.arrowFunc的箭头函数保持了对该函数this的引用,所以这个this还可以被读取,它就不会被回收),那么这里的this指向obj,打印 "Object"。
如果觉得这篇文章对你有帮助请点个 赞 鼓励鼓励我👍,谢谢你