Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JavaScript执行机制:执行上下文 #38

Open
campcc opened this issue May 7, 2022 · 0 comments
Open

JavaScript执行机制:执行上下文 #38

campcc opened this issue May 7, 2022 · 0 comments

Comments

@campcc
Copy link
Owner

campcc commented May 7, 2022

你是否对 JavaScript 中的执行上下文词法环境变量环境变量对象活动对象作用域链闭包提升this执行栈等概念有很多困惑,或者了解其中的一些但总觉得一知半解?那是因为脱离了环境,零散的概念本身就晦涩难懂,一位不知名的前端攻城狮说过,理解零散概念的最好方式就是想办法建立知识体系,把它们全部串联在一起,而上述的概念都是围绕下面这个问题,

一段 JavaScript 代码到底是如何执行的 ? 🤔

var name = 'Monch Lee~'

var sayName = function () {
  console.log(name)
}

function sayName() {
  console.log(name)
  var name = 'Pony Ma~'
}

我们知道 JavaScript 代码需要在某种环境中托管和运行,大多数情况下,这个 “环境” 可能是浏览器,以浏览器为例,

《浏览器原理:渲染流程》中我们介绍过,JavaScript 代码最终会由 引擎(JavaScript Engine) 解释并执行,问题变成了,

引擎是如何执行 JavaScript 代码的? 需要先编译吗?

编译(Compile)

了解过其他编译语言(如 C、C++、Golang、Pascal、汇编等)编译原理的同学应该知道,

一段代码在执行前通常要经历三个步骤:词法分析语法分析代码生成

image

  • 词法分析:将字代码分解为词法单元(Token),生成词法单元流数组
  • 语法分析:将词法单元流数组转换为 抽象语法树(Abstract Syntax Tree,AST)
  • 代码生成:将 AST 转换为可执行代码

AST 转换的工具有很多(如 esprima、traceur、acorn、shift),你可以点击这个 esprima 的例子直观的感受下。

JavaScript 也是一门编译语言,任何 JavaScript 代码在执行前都要进行编译,

image

那么编译阶段 JavaScript 引擎具体会做哪些事情呢?

执行上下文(Execution Context)

首先,为了编译和执行 JavaScript 代码,引擎会创建一个特殊的运行环境,这个环境就是执行上下文

简单来说,执行上下文就是一段代码(包括函数)执行所需的所有信息

从类型上,执行上下文可以分为,

  • 全局执行上下文(GEC):引擎编译和执行全局代码时创建,在浏览器中,全局执行上下文的 VO 就是 window 对象
  • 函数执行上下文(FEC):每一个函数调用都会创建函数执行上下文,调用结束后从执行栈弹出并销毁
  • eval的执行上下文:不推荐也不常见的一种,但 eval 函数调用时引擎会创建 eval 的执行上下文

一段代码执行到底需要哪些信息呢,换言之,引擎创建的执行上下文到底包含了哪些东西呢? 🤔

我们知道 ECMAScript 标准几乎每年都在更新,一些术语经历了比较多的版本和社区的演绎,而且比较遗憾的是,网上的文章对这些术语的定义往往都比较混乱,这里我觉得有必要从标准的角度帮你重新梳理一遍,

首先,在 ES3 中,执行上下文包含三个部分,

image

  • Variable Object:变量对象,存储执行上下文中定义的所有变量和函数
  • Scope Chain:作用域链,用于代码执行阶段的标识符查找
  • This Value:this 值

你可能还听说过活动对象(Activation Object),它是针对函数而言,在函数执行上下文中,活动对象就是变量对象,只不过相较于全局执行上下文中的变量对象,它还额外拥有一个 arguments 属性,

function hello(name) {
  console.log(arguments)
}

hello('Monch Lee~') // Arguments ['Monch Lee~', callee: ƒ, Symbol(Symbol.iterator): ƒ]

arguments 是一个对应于传递给函数的参数的类数组对象,它是除了箭头函数外,所有函数中都可用的一个局部变量

ES5 中,上面一些术语发生了变化,执行上下文改为了由下面三部分组成,

image

  • Lexical Environment:词法环境,用于获取变量和函数
  • Variable Environment:变量环境,存储声明的变量和函数
  • This Value:this 值

在较新的 ES2018 中,this 值被归入了词法环境,同时增加了一些新的内容,

image

  • Lexical Environment:词法环境
  • Variable Environment:变量环境
  • Code Evaluation State:用于恢复代码执行位置
  • Function:执行的任务是函数时使用,表示正在被执行的函数
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码
  • Realm:使用的基础库和内置对象实例
  • Generator:生成器上下文特有的这个属性,表示当前生成器

尽管经历了多个版本的迭代,执行上下文的一些核心东西还是不变的,这里我们需要重点关注是:词法环境变量环境

变量环境(Variable Environment)

在编译阶段,引擎首先会确定变量环境,这个阶段会创建变量对象(Variable Object)用来存储所有声明的变量和函数

提升(Hosting)就是发生在这一阶段。

提升(Hosting)

var 声明的变量或函数会被提升到当前作用域顶端(作用域我们在后面词法环境中会详细介绍),并且函数提升优先于变量

console.log(a) // ƒ a() {}

var a = 2

function a() {}

由于存在提升,var 的声明和赋值其实是两个阶段,可以把上面的代码看成,

function a() {}

var a

console.log(a)

a = 2

此外,var 还会穿透 for、if 等语句,你可能发现在只有 var,没有 let 的旧 JavaScript 时代,我们经常通过创建一个函数,并立即执行,来构造一个新的域,从而控制 var 的范围,但由于语法规定 function 关键字开头的是函数声明,我们一般会加上括号让函数变成一个函数表达式,也就是我们常说的立即函数表达式(IIFE),

(function() { 
  var a;
  //code
})();

最常见的做法是直接加括号让函数变成一个表达式,但如果上一行代码不写分号,括号会被解释为上一行代码最末的函数调用,所以你可能发现一些推荐不加分号的代码风格规范,会要求在括号前面加上分号,

;(function() {
  var a;
  //code
}())

我们也可以用 void 关键字,语义上 void 运算表示忽略后面表达式的值,变成 undefined,我们确实不关心 IIFE 的返回值,

void function() {
  var a;
  //code
}();

变量对象创建完成后,引擎会创建作用域链(Scope Chain),它是我们接下来要介绍的词法环境的一部分。

词法环境(Lexical Environment)

词法环境被用于 JavaScript 引擎获取变量或者 this 值

在编译和执行 JavaScript 代码前,引擎会收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限,这个过程会生成作用域链(Scope Chain),确定 this 指向,

作用域(Scope)和作用域链(Scope Chain)可以看成一个东西,什么是作用域呢?

作用域(Scope)

前面我们介绍到,编译器在语法分析阶段后会将 AST 转换为可执行代码,然后引擎会执行代码,我们以一个简单的代码片段为例,

var a = 2

这里看似一段简单的变量赋值操作实际上会经历两个阶段:编译时处理运行时处理

首先为了确定在何处以及如何查找变量(标识符),引擎会创建当前代码的作用域(Scope),

你可以简单地把作用域理解为一个集合,它存储了我们所有声明的标识符,并提供了一系列查询来访问这些标识符

  • 编译时处理:编译器在遇到上述代码片段后,会在当前作用域中声明一个变量 a ,如果当前作用域已经存在同名的变量 a,则会忽略该声明;真实的场景中,执行上下文可能有多个且可以嵌套,所以这里最终会创建多个嵌套的作用域,也就是我们常说的作用域链(Scope Chain)

  • 运行时处理:代码执行阶段稍有不同,引擎在遇到变量 a 时,会先在当前作用域中查找变量 a,如果找到就会使用这个变量,否则引擎会继续沿着作用域链查找,查找的过程遵循 “属性遮蔽”(Property Shadowing) 原则,也就是查找到的最近的属性会被优先应用;作用域查找会一直向上,直到找到变量或抵达全局作用域后停止。

根据查找的类型,作用域查找又可以分为 LHS 查询RHS 查询,分别代表赋值操作的左侧和右侧

LHS & RHS

这里的赋值操作并不简单意味着 "=" 操作符,JavaScript 中存在譬如隐式赋值等多种赋值操作,以下面的代码片段为例,

function foo(a) {
  var b = a
  return a + b
}
var c = foo( 2 )

这里函数的调用 foo() 是一次 RHS 查询,因为引擎需要去查找 foo 到底是什么,我们可以把 RHS 查询理解为 retrieve his source value(取到它的源值),所以上述代码会执行 4 次 RHS 查询,

  • foo(2...,foo 函数调用,对 foo 的查询
  • = a,a 赋值给 b 时,对变量 a 的查询
  • a ...,对 + 运算左侧变量 a 的查询
  • ... b,对 + 运算右侧变量 b 的查询

LHS 查询的目的是为了对变量进行赋值,需要注意隐式变量分配也是一次 LHS 查询,上述代码会执行 3 次 LHS 查询,

  • c = ...,对变量 c 的赋值查询
  • a = 2,隐式变量分配
  • b = ...,对变量 b 的赋值查询

这里理解 LHS 查询和 RHS 查询其实是有必要的,因为它对应着 JavaScript 中两种常见的异常类型。

异常

对于未声明的变量,LHS 查询和 RHS 查询导致的行为是不一样的,具体来说,

RHS 查询在所有嵌套作用域(整个作用域链)中找不到变量时,引擎会抛出 ReferenceError 异常,

console.log(a) // Uncaught ReferenceError: a is not defined

LHS 查询如果找不到变量,在严格和非严格模式下会有不同表现,

  • 非严格模式下,引擎会在全局作用域中创建一个同名变量
  • 严格模式下,引擎会抛出 ReferenceError 异常
a = 2
console.log(a) // 2,a 未声明,对变量 a 的 LHS 查询,引擎会在全局作用域创建一个同名变量 a
"use strict"
a = 2 // Uncaught ReferenceError: a is not defined,严格模式下,禁止自动或隐式地创建全局变量,这时会抛出 ReferenceError 异常

你可能还遇到过 TypeError 异常,这是因为 RHS 查询找到了变量,但我们尝试对这个变量的值进行不合理的操作时导致的

什么是不合理的操作呢?比如常见的有,

  • 试图对一个非函数类型的值进行函数调用
  • 引用 null 或 undefined 类型的值中的属性
var a = 2
a() // Uncaught TypeError: a is not a function
a.foo.bar // Uncaught TypeError: Cannot read properties of undefined (reading 'bar')

总的来说,ReferenceError 和作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是不合理的

了解了词法环境和变量环境后,接下来我们来看 JavaScript 中比较晦涩的一个概念: 闭包

闭包(Closure)

闭包的翻译自英文单词 Closure,在计算机科学的不同领域它可能代表不同的东西,

  • 编译原理中,它是处理语法产生式的一个步骤;
  • 计算几何中,它表示包裹平面点集的凸多边形(翻译作凸包)
  • 编程语言领域,它表示一种函数

闭包的概念最早可以追溯到 1964 年的《The Computer Journal》(牛津大学出版社出版的最古老的计算机科学研究期刊之一),

P. J. Landin《The mechanical evaluation of expressions》 一文中第一次提及了 closure 的概念,

image

在这个最古典的闭包定义中,closure 表示 “带有一系列信息的一个 λ 表达式”,我们知道对于函数式语言而言,λ 表达式其实就是函数,所以上述的定义可以简单理解为:一个绑定了执行环境的函数,具体来说,一个闭包包含了以下两个部分,

  • 环境部分:环境和标识符列表
  • 表达式部分

然而纵观 JavaScript 的所有标准,似乎都未提及闭包(Closure)这个术语,但我们却不难根据古典定义,结合前面介绍的词法环境,在 JavaScript 中找到对应的闭包组成部分,

  • 环境部分:函数执行上下文中的词法环境,以及函数中用到的变量组成的标识符列表
  • 表达式部分:函数体

所以,闭包在 JavaScript 就是函数,只不过这个函数携带了标识符列表和词法环境

我希望通过上述的古典定义,帮你理清楚 “JavaScript 中闭包就是函数” 这个概念,它和普通的函数并没有本质的区别,

// 你完全可以把函数 foo 看成闭包,闭包没有什么特殊的魔法,就是一个携带环境的函数,普通函数也会携带环境
function foo() {}

只不过与普通函数相比,闭包可能会携带更多的环境信息,

function foo() {
  var a = 2
  return function bar() {
    console.log(a)
  }
}

var baz = foo()
baz() // 2,foo 函数从执行栈中弹出后,我们仍然可以访问其内部变量 a

比如上面的例子中,我们通过调用一个外部函数返回了一个内部函数,根据词法作用域的规则,内部函数可以访问包含它自身及外部函数的词法环境,所以即使我们的外部函数 foo 已经执行结束(从执行栈中弹出)了,但内部函数引用外部函数的变量 a 依然保存在内存中,导致这一内部函数 bar 携带了额外的环境信息(外部函数 foo 的词法环境)。

利用闭包携带的环境信息,我们可以搭配 IIFE 模拟实现块级作用域,你可能在一些早期的 JavaScript 库中见到过类似用法。

好了,让我们把视角拉回到词法环境,前面我们介绍到,引擎确定作用域链后,接着会进行 this 值的绑定。

this

this 是 JavaScript 中的一个关键字,它的使用方法类似于一个变量,全局执行上下文中,this 的值跟当前是否处于严格模式有关,

  • 非严格模式下,this 的值为全局对象,对应到浏览器中就是 window
  • 严格模式下,this 的值为 undefined

这没有什么魔法,真正让大多数人困惑的是 this 在函数执行上下文中的行为,所以我们接下来重点介绍函数中的 “this”。

在 JavaScript 标准中,为函数规定了用来保存定义时上下文的私有属性 [[Environment]],当一个函数执行时,会创建一条新的执行环境记录,记录的外层词法环境(outer lexical environment)会被设置成函数的 [[Environment]],这个动作就是我们说的 执行上下文切换

我们知道 JavaScript 用一个栈(执行栈,Execution Stack)来管理执行上下文,这个栈中的每一项又包含一个链表,

image

当函数调用时,会入栈一个新的执行上下文,函数调用结束时,执行上下文被弹出。

JavaScript 标准定义了 [[thisMode]] 私有属性来处理函数执行上下文中的 this 值,[[thisMode]] 有三个取值,

  • lexical:表示从上下文中寻找 this,这对应了箭头函数
  • global:表示当 this 为 undefined 时,取全局对象,对应了普通函数
  • strict:当严格模式时使用,this 严格按照调用时传入的值,可能为 null 或者 undefined

global 和 strict 呼应了我们前面介绍的全局执行上下文中的 this,这里比较难理解的是 lexical,如何从上下文中寻找 this

对于普通函数而言,this 值由函数的调用方式决定,具体来说是由 “调用它所使用的引用” 决定,

function showThis() {
  console.log(this);
}

var o = {
  showThis: showThis,
};

showThis(); // global
o.showThis(); // o

我们获取函数的表达式,它实际上返回的并非函数本身,而是一个 Reference 类型,Reference 由三部分组成,

  • base,一个对象
  • reference name,一个属性值
  • strict mode flag

在函数调用,delete 等算术运算时,Reference 类型会被解引用,以获取真正的值(被引用的内容)来参与运算,上述例子中,Reference 类型中的对象 o 被当作 this 值,传入了执行函数时的上下文中,所以对于普通函数的 this,我们已经非常清晰了,

调用函数时使用的引用,决定了函数执行时刻的 this 值

上面的方式被网上一些文章称为 “隐式绑定”,从运行时角度来看,this 跟面向对象毫无关联,它只与函数调用时使用的表达式相关,这里也有例外,对于箭头函数和通过 new 实例化一些场景来说,this 的指向有所不同。

我们先来说比较特殊的箭头函数(Arrow Function),

函数创建新的执行上下文中的词法环境记录时,会根据 [[thisMode]] 来标记新纪录的 [[ThisBindingStatus]] 私有属性,然后在代码执行遇到 this 时,会逐层检查当前词法环境记录中的 [[ThisBindingStatus]],这有点类似我们前面介绍的作用域查找,由于箭头函数在创建词法环境记录时并没有定义 this,所以导致了箭头函数无论嵌套多少层,其内部的 this 都指向了最外层普通函数的 this

var o = {};
o.foo = function foo() {
  console.log(this);
  return () => {
    console.log(this);
    return () => console.log(this);
  };
};

o.foo()()(); // o, o, o

箭头函数特殊的 this 绑定方式是优先于前面提到的引用绑定的,比如我们把前面的例子改为箭头函数,

const showThis = () => {
  console.log(this);
};

var o = {
  showThis: showThis,
};

showThis(); // global
o.showThis(); // global

可以发现,不论用什么引用来调用它,都不影响它的 this 值了

接着我们看绑定到 “类” 的 this,

class C {
  a = 1

  showThis() {
    console.log(this)
  }
}
var o = new C()
var showThis = o.showThis

showThis() // undefined
o.showThis() // o
o.a // 1

我们创建了一个类 C,并实例化出对象 o,再把 o 的方法赋值给了变量 showThis,当我们用 showThis 这个引用去调用方法时,得到了 undefined,导致这一行为的原因是 class 被设计成了默认按 strict 模式执行,所以 this 严格按照调用时传入的值进行绑定,这也解释了为什么我们下一行通过 o 去调用时 this 被绑定到了 o 这个引用上。

当然,new 实例化的方式本身也会绑定 this,而且这种优先级是最高的,这主要与 new 关键字的实现原理有关,

我们简单回顾下 new 的实现原理,大致可以分为以下几个步骤,

  1. 获得构造函数
  2. 链接到原型
  3. 绑定 this,执行构造函数
  4. 返回 new 出来的对象
// 模拟 new 实现
function _new() {
  var constructor = [].shift.call(arguments)
  var obj = Object.create(constructor.prototype)
  var result = constructor.apply(obj, arguments)
  return typeof result === 'object' ? result : obj
}

// 调用
function foo() {
  this.bar = 'bar'
}

var obj = _new(foo)

console.log(obj) // foo {bar: 'bar'}
console.log(obj.bar) // 'bar'

new 关键字的实现机制里,会将我们传入的构造函数的原型作为 this 绑定到 new 出来的目标对象上。

我们在上述模拟实现 new 的方法里用到了 apply,实际上,JavaScript 中提供了一系列函数的内置方法(call, apply, bind)来操纵 this 值,这种直接绑定 this 值的方式也被称为 “显式绑定”,当然,如果你只是想确认 this 的指向而不关注其背后的机制,可以参考我之前的文章 可能是最好的 this 解析了... 里提供的一个框架思路,

根据绑定规则和优先级,我们可以总结出 this 判断的通用模式,

  1. 函数是否通过 new 调用?
  2. 是否通过 call,apply,bind 调用?
  3. 函数的调用位置是否在某个上下文对象中?
  4. 是否是箭头函数?
  5. 函数调用是在严格模式还是非严格模式下?

至此,我们已经介绍完执行上下文中最重要的变量环境和词法环境,包括比较晦涩的 This value,闭包等概念,相信你已经对这个 JavaScript 代码执行的基础设施:执行上下文 有了一定的了解,接下来的文章我们会基于执行上下文,执行栈等概念,了解代码执行阶段另一个重要的机制:事件循环(Event Loop)。

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

@campcc campcc changed the title JavaScript执行机制 JavaScript执行机制(一):执行上下文 Jun 6, 2022
@campcc campcc changed the title JavaScript执行机制(一):执行上下文 JavaScript执行机制:执行上下文 Jun 6, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant