无法从静态上下文中引用非静态_JavaScript 怎么学 - 执行上下文, 执行栈, 事件循环

498afc4451534220456f8e6f2ee35951.png

# 前言

作为还在漫漫前端学习路上的一位自学者。我以学习分享的方式来整理自己对于知识的理解,同时也希望能够给大家作为一份参考。希望能够和大家共同进步,如有任何纰漏的话,希望大家多多指正。感谢万分!


在这一篇文章中, 我希望能够用最简单明了的语言去解释 JavaScript 代码是如何执行的.

# 基础概念解析

## 编译原理

首先我们要知道, 我们写的代码是给人看的, 机器是看不懂的. 为了让代码可以被机器执行, 需要将代码转换为机器语言. "编译型语言" 是先将所有代码编译完成之后, 才开始执行. JavaScript 是 "解释型语言", 它会在程序的运行过程中进行编译. 在代码执行前极短的时间内将其转换成机器语言.

JavaScript 的编译过程分为两个阶段:编译期 & 执行期

在 "编译期" 阶段, 由解释器完成, 它主要分为下面几个步骤:

  1. 词法分析: 将由代码分解成(对编程语言来说)有意义的代码块,这些代码块被称为 "词法单元". 例如,var a = 2; 这段程序通常会被分解成为下面这些词法单元: var, a, =, 2, ;.
  2. 语法分析: 将 "词法单元" 转换为一代表了程序语法结构的树结构, 被称为 "抽象语法树" .
  3. 生成可执行代码: 将抽象语法树转换成机器可以执行的代码.

在 "执行期" 阶段, 由 JavaScript 引擎完成, 主要分成以下步骤:

  1. 创建执行上下文: 执行上下文用以描述代码执行时所处的环境. 在后文我会详细讲述;
  2. 执行代码: 执行上下文创建完之后, 处于内部的代码会被引擎逐句执行;

## 作用域

作用域可以理解为一套规则, 它定义了变量和函数的可访问范围,控制着变量和函数的可见性与生命周期.

作用域可分为, 静态作用域, 或者动态作用域. JavaScript 采用词法作用域 (lexical scoping), 也就是静态作用域。

  • 静态 (词法) 作用域: 静态作用域在代码的 "词法分析" 阶段就确定了. 变量的可访问范围取决于源代码, 与程序的执行流程没关系. 作用域的确定不需要程序运行, 只通过静态分析就可以.
  • 动态作用域: 动态作用域是根据程序的运行动态确定的. 动态作用域并不关心变量和函数是如何声明以及在何处声明的, 它只关心他们是在何处被调用的.

## 执行上下文

执行上下文 (execution context), 是一个抽象概念, 用于描述代码执行时所处的作用域环境. 它定义代码语句对变量或函数的访问权.

在代码的 "执行期", JavaScript 引擎会创建执行上下文. 在 JavaScript 中, 它表现为一个内部对象. 每当 Javascript 代码在运行的时候,它都是在执行上下文中运行。

JavaScript 中有三种执行上下文:

  • 全局执行上下文: 默认的代码运行环境,一旦代码被载入执行,引擎最先创建的就是这个环境. 不写在函数内的代码, 被执行时处于全局执行上下文.
  • 函数执行上下文: 写在函数内的代码运行时, 处于函数执行上下文.
  • eval 执行上下文: 作为 eval 函数参数的代码, 运行时处于 eval 执行上下文. 这里略过不讲.

在函数被调用之前, 函数的执行上下文会被创建. 在创建过程中, 主要做如下三件事:

  1. 创建变量对象
  2. 创建作用域链;
  3. 确定 this 指向;

在下文里, 我会逐一介绍.


## 调用栈 (执行环境栈)

JavaScript 引擎用以追踪函数执行流的一种机制

当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到哪个函数正在执行,执行的函数体中又调用了哪个函数。

它遵循 "先进后出" 的栈结构. 第一个被创建, 并推入栈的一定为 "全局执行上下文". 之后, 当一个函数要被调用之前, JavaScript 引擎会为它创建 "函数执行上下文", 然后压入调用栈中。

当函数执行完毕, 函数的执行环境被从栈中弹出销毁, 把控制权交给之前的执行环境. 即使是同一个函数, 它每次被调用时, 都会创建一个单独的执行上下文.

到最后, 全部代码执行结束, "全局执行上下文" 被弹出栈销毁.

举例说明:

var a = "Hello World!";

function first() {
  console.log("1");
  second();
  console.log("1 again");
}

function second() {
  console.log("2");
}

first();
console.log("0");

// 结果: 1 -> 2 -> 1 again -> 0

上面代码里, 执行上下文在执行栈中被推入和销毁顺序为:

d11a6f54ef738d95acc07e41095ad185.png

## 变量对象 / 活动对象

### 变量对象 (Variable Object)

在创建执行上下文的时候, 变量对象会被创建. 执行上下文中的所有变量声明, 函数声明都会被扫描出来, 然后在变量对象上创建同名属性. 如果是在函数执行上下文中的话, 变量对象里还包括了函数的形参集合.

通过变量对象, 执行上下文就可以知道自己有哪些数据. 这个对象是给 JavaScript 引擎用的, 开发者不可以访问到.

函数执行上下文中, 变量对象的创建,依次经历了以下几个过程:

  1. 创建 arguments 对象. 检测函数调用时所处上下文传入的参数, 在该对象下创建属性, 和初始化属性值;
  2. 扫描函数内的所有函数声明:
  3. 为每一个函数声明,在变量对象上创建一个同名属性, 属性值为函数在内存中的引用;
  4. 如果已有同名属性存在, 则属性值被重写覆盖为新函数的引用;
  5. 扫描函数内的变量声明:
  6. 为每一个变量声明, 在变量对象创建一个同名属性, 属性值初始化为 undefined;
  7. 如果已有同名属性存在, 为防止同名函数被重写为 undefined. 变量声明会被跳过, 原属性值不会被修改;

等函数中的代码被 JavaScript 引擎执行时, 具体的变量赋值才会进行.

举例说明:

function a() {
  console.log(b); // function b() {}

  var b = 123;

  function b() {}

  console.log(b); // 123
}

a();

在执行 b = 123 这句赋值语句之前, 变量对象中的 b 属性的值为函数. 但赋值语句让 b 属性的值被改写成了 123. 在创建变量对象阶段里, fucntion b 声明被先处理, var b 声明被跳过. 请一定要分清执行上下文的创建阶段, 和代码执行阶段.

### 活动对象 (Activation Object)

前文说, 执行上下文被创建完后, 会被推入执行栈的顶部, 然后 JavaScript 引擎开始逐行执行里面的代码.

活动对象, 和变量对象其实指的都是同一个对象, 但只有在执行栈顶部的执行上下文中的变量对象里的属性才可以被访问, 它也就被称为 "活动对象".

### 变量提升

在了解了变量对象的创建流程之后, 变量提升就很容易被理解啦. 因为在代码被执行之前, 变量声明, 函数声明已经先被扫描出来, 并在变量对象中创建同名属性了. 所以在代码执行阶段, 即使在变量声明之前去获取变量也是可以的. 只不过那个时候, 变量赋值还没被执行, 变量的值为 undefined.


## 作用域链

作用域链, 由当前执行上下文和它上层的执行上下文的 "变量对象" 组成, 它保证了当前执行环境对符合访问权限的变量和函数的有序访问。

在创建函数执行上下文的时候, 作用域链会被建立.

举例说明:

var a = 5;

function fun() {
  var b = a + 1;

  function innerFun() {
    var c = 10;
    console.log(b + c);
  }

  innerFun();
}

fun();

上面的代码运行时, 全局执行上下文, fun 函数执行上下文, innerFun 执行上下文被依次创建, 推入执行栈. 设定他们的变量对象分别为 VO(global),VO(fun), VO(innerFun). 那么 innerFun 的作用域链, 同时包含这三个变量对象.

我们可以直接用一个数组来表示 innerFun 的作用域链:

[VO(innerFun), VO(fun), VO(global)];

作用域链会保存在函数的内部属性 [[Scope]] 上. 内部属性供 JavaScript 引擎使用, 开发者是访问不到这个属性的.


## this

this 指向是函数执行时所在的环境对象. 在函数被调用前, 创建执行上下文的过程中被确定. 之后在函数执行的过程中, this 的指向已经被确定,就不可更改了.

### 全局中的 this

在全局执行上下文中, this 指向它自身, 也就是全局对象. 在浏览器中为 window 对象, 在 Node 中为 global 对象

### 函数中的 this

前面说, this 指向是函数执行时所在的环境对象. 简单来说, 函数的 this 指向它的调用者. 如果函数被一个对象所拥有, 该函数被对象调用时, this 指向该对象. 如果函数独立调用, this 的值为 undefined. 非严格模式下, 当 this 的值为 undefined 时, 它会被自动指向全局对象.

举例说明:

var val = 0;

var obj = {
  val: 1,
  fn: function() {
    console.log(this.val);
  }
};

var fn_2 = obj.fn;

obj.fn(); // 1, this 指向 obj
fn_2(); // 0, this 指向 window

上例中, 即使 fn_2fn 指向同一个函数. 但是 fn_2 为独立调用, this 的值为 undefined, 在非严格模式下, 指向 window.

### 显式改变 this 指向

通过 call, apply, bind, 我们可以显式的执行函数执行上下文的 this 指向.

var val = 0;

var obj = {
  val: 1
};

function fn() {
  console.log(this.val);
}

fn(); // 0
fn.call(obj); // 1

这里就不再赘述各种方法的使用细节了, 大家可以自行查阅文档.

### 构造函数 & 原型方法的 this

前面所说的 this 绑定都是在直接调用函数的情况下. 当使用 new 操作符调用构造函数创建对象实例的时候, this 绑定又是怎么样的呢? 构造函数的原型方法中的 this 又指向何处呢?

function Animal(name, sound) {
  this.name = name;
  this.sound = sound;
}

Animal.prototype.yell = function() {
  return this.sound;
};

var Cat = new Animal("猫", "喵喵");
console.log(Cat.name); // 猫
console.log(Cat.yell()); // 喵喵

在通过 new 操作符调用构造函数时, 会经历以下四个阶段:

  1. 创建一个新的对象;
  2. 构造函数的 this 指向新对象;
  3. 为这个新对象添加构造函数中的属性和方法;
  4. 返回新对象

也就是说, 构造函数中的 this 指向这个新创建的实例对象.

而原型方法做为一个函数, 它被实例对象调用, 那它的 this 也就指向这个实例对象.


# 事件循环

下面所讲的是浏览器当中的事件循环

## 单线程

首先我们要知道, JavaScript 的最大特点就是 "单线程". 也就是说同一时间只能处理一个操作.

那么为什么要这样设计呢? JavaScript 作为浏览器的脚本语言, 主要用途是来处理用户交互, 以及操作 DOM. 这使得多线程的设计会导致很复杂的同步问题. 举例说, 如果 JavaScript 可以同时操纵两个线程. 一个线程添加在某个 DOM 节点上添加内容, 另一个线程在这个 DOM 节点下删除内容. 那么浏览器, 该听谁的呢? 所以 JavaScript 被设计成了单线程的.


## 同步任务 & 异步任务

单线程就意味着任务必须要排队, 一个一个得等待被执行. 那很明显的一个问题是, 如果有一个任务耗时过长, 那后面的任务就必须要等待. 如果是任务的计算量太大, 设备 CPU 处理能力不够, 必须耗时很长, 那还可以理解. 但如果任务是从网络中读取数据, 因为网速慢, 或其他原因导致等待响应时间过长, 那必然会导致程序运行效率, 和 CPU 利用率非常低下.

所以 JavaScript 的另一个特点就是 "非阻塞 I/O", 也称 "异步式 I/O". 当主线程遇到 I/O 操作时 (磁盘读写, 网络通信),不会以阻塞的方式等待 I/O 操作的完成, 或数据的返回. 而只是将 I/O 操作交给浏览器,然后自己继续执行下一条语句。 当浏览器完成 I/O 操作时,会将用以处理 I/O 操作结果的处理函数推入到一个任务队列, 等待主线程后续进行处理.

于是任务就分成, "同步任务", 和 "异步任务" 两种.

  • 同步任务: 在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
  • 异步任务: 主线程交给浏览器去执行. 执行完毕后, 将用以处理异步操作结果的处理函数, 推入 "任务队列", 等待主线程处理.

## 任务队列 & 事件循环

前面说过执行上下文创建完, 会被推入调用栈. JavaScript 引擎会逐句执行最顶部的执行上下文中的代码.

在执行过程中, 同步任务逐句被执行. 当遇到了异步任务, JavaScript 引擎会将它们交给浏览器上对应的 Web API 去处理. 比如 Ajax 请求, 会被交给 Network 模块. 浏览器处理完毕之后, 会将用以处理结果的处理函数 (回调函数), 推入到任务队列中.

当调用栈中只剩全局执行上下文的时候, 主线程就会去查询任务队列了. 任务队列中的任务会被逐一取出放入调用栈执行. 当处理任务的时候, 又遇到了新的异步任务, 则会重复之前的操作. 也就是, 调用对应 Web API, 处理完毕后, 回调函数添加到任务队列末尾.

上面的步骤会一直重复, 直到任务队列完全清空了, 至此程序执行完毕. 而这个循环过程就被叫做 "事件循环".

1bf3050f78decf39d8c4a43a638c73e1.png

## 宏任务 & 微任务

上面只是笼统的说了下 "异步任务" 和 "任务队列" 的概念. 在浏览器中, 异步任务分成 "宏任务" (macro-task) 和 "微任务" (micro-task) 两种. 这两种任务也都各自有一条任务队列.

  • 宏任务: 包括 script(整体代码), setTimeout, setInterval, setImmediate,requestAnimationFrame, I/O, UI rendering.
  • 微任务: 包括 process.nextTick, Promise, Object.observe, MutationObserver

第一次事件循环从宏任务 (macro-task) 开始. 我们看到整体的 script 代码也算是一个宏任务. 那么从读取整体 script 代码开始算第一次循环.

之后全局执行上下文被创建, 推入执行栈. 直到最后执行栈只剩全局执行上下文时, 线程然后执行所有的 micro-task 队列中的任务. 清空后, 线程从 macro-task 队列首部取一个任务, 然后到最后再清空 micro-task 队列.

之后再去 macro-task 队列去下一个任务. 这样一直循环, 直到 macro-task, micro-taks 队列都清空了, 全局执行上下文出栈, 程序结束.

举例说明:

// 同步任务
console.log("0");

setTimeout(function() {
  // 宏任务
  console.log("1");

  new Promise(function(resolve, reject) {
    // 同步任务
    console.log("2");
    resolve();
  }).then(() => {
    // 微任务
    console.log("3");
  });
}, 0);

new Promise(function(resolve, reject) {
  // 同步任务
  console.log("4");
  resolve();
}).then(() => {
  // 微任务
  console.log("5");
});

// 同步任务
console.log("6");

// 最后结果: 0, 4, 6, 5, 1, 2, 3

# 参考

  • 你不知道的 JavaScript (上卷)
  • 掘金 - polkYu - 浅析 JavaScript 的事件循环机制
  • JavaScript 作用域、上下文、执行期上下文、作用域链、闭包
  • 掘金 - 子非 - [译] 理解 JavaScript 中的执行上下文和执行栈
  • 深入理解 JavaScript 执行上下文、函数堆栈、提升的概念
  • 深入了解 JavaScript,从作用域链开始(1)
  • 九死蚕传人 bo - 前端基础进阶(四):详细图解作用域链与闭包
  • 九死蚕传人 bo - 前端基础进阶(十二):深入核心,详解事件循环机制
  • RuGuo_09 - 为什么 javascript 是单线程?
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值