代码已经关联到github: 链接地址 文章有更新也会优先在这,觉得不错可以顺手点个star,这里会持续分享自己的开发经验(:
在说明执行上下文和作用域之前,我们需要了解一下LHS和RHS查询,这样才能更好的理解为什么JavaScript存在执行上下文和作用域。
LHS 和 RHS
我们都知道JavaScript是一门解释型语言/动态/脚本语言,他会在执行之前一刻进行编译,然后交给JS引擎执行。
编译
编译器负责把代码解析成机器指令,通常会有三个步骤:
- 分词/词法解析:将JavaScript字符串分解为
词法单元(token)
,如var a = 2
=>var
、a
、=
和2
。 - 解析/语法分析:将一个个
token
的流(数组)转为抽象语法树(AST)
- 代码生成:将
AST
转为机器指令,等待执行。
执行
JS引擎拿到一堆机器指令开始执行,这个时候需要查询变量,LHS
和RHS
就登场了。
- LHS (Left-hand Side) :查询目的是变量赋值,如
a=1
,是为了将值1
赋给变量a
。 - RHS (Right-hand Side) :查询的目的就是查询实际值,如
foo()
,查找foo
是函数,才能执行;如果不是函数就会抛出TypeError
异常;找不到则会抛出ReferenceError
异常。
而两种查询方法获取变量的方式,叫做 作用域(Scope)链, 变量存储的地方叫 环境记录(Environment Record) ,而执行上下文(Execution Context) 包含作用域链、环境记录。
通俗来说:作用域告诉我们在某个执行上下文中可以访问哪些变量,而这些变量正是存储在相应的词法环境的环境记录中。 下面我们分别介绍他们:
执行上下文
什么是执行上下文
执行上下文是代码在运行时创建的抽象环境,它封装了代码执行所需的所有信息。每当全局代码运行、函数被调用或 eval
执行时,都会创建一个对应的执行上下文。
每个执行上下文通常包含:
-
环境记录(Environment Records),包含一系列环境:变量环境(Variable Environment)、词法环境(Lexical Environment).... :存储当前上下文中定义的变量和函数声明。
-
作用域(Scope) :一系列词法环境的引用,保证在当前上下文中可以访问到所有外层环境的变量。
-
this 值:当前上下文中 this 的绑定值。
所有的 JavaScript 代码在运行时都是在执行上下文中进行的,每创建一个执行上下文,就会将当前执行上下文放到一个栈顶,这就就是我们常说的执行栈。
JavaScript 中有三种情形会创建新的执行上下文:
- 全局执行上下文,进入全局代码的时候,也就是执行全局代码之前。
- 函数执行上下文,函数被调用之前。
- Eval 执行上下文,eval 函数调用之前。
执行上下文的组成
1. 环境记录(Environment Records)
是一个抽象的数据结构,用于存储标识符与其对应值的映射,它决定了变量和函数在代码中的查找规则。每个执行上下文都有一个关联的词法环境,词法环境由两部分组成:
- 变量环境(Variable Environment) :
-
- 存储
var
声明的变量和函数声明(函数提升)。- 在创建阶段初始化(变量值为
undefined
)。
- 在创建阶段初始化(变量值为
- 存储
- 词法环境(Lexical Environment) :
-
- 存储
let
/const
声明的变量(存在暂时性死区)。 - 与块级作用域(
{}
)绑定。
- 存储
- 外部环境记录的引用([outer env]) :指向外层的环境,形成了作用域的关键。
在 ECMAScript 规范中,“环境记录”就是对变量绑定的一种抽象表示,用于实现词法作用域规则。
2. 作用域
作用域是一个抽象概念,它描述了程序中哪些部分可以访问某个变量或函数。作用域的形成基于词法环境和它们之间的链式连接(作用域链)。
-
- 词法作用域(Lexical Scope) :在代码书写阶段确定,即变量在源代码中的位置决定了它的可见性。ECMAScript 采用词法作用域,意味着在函数定义时就确定了其作用域,而非在运行时动态确定。
- 作用域链:在执行上下文中,作用域链是由当前词法环境及其外部引用构成的链表。每当 JavaScript 引擎尝试访问变量或函数时,会首先在当前环境中查找,如果找不到就沿着作用域链向外查找,直到找到标识符或到达全局作用域。如果在任何上下文中都找不到标识符,则会引发 ReferenceError。
3. this
当前的代码在哪个对象下被调用,如果没有则默认是window
(严格模式、箭头函数除外...)
执行上下文的生命周期
运行 JavaScript 代码时,当代码执行进入一个环境时,就会为该环境创建一个执行上下文,它会在你运行代码前做一些准备工作。
创建阶段
执行上下文的创建大体步骤如下:
- 创建环境记录 (Environment Record),其中包含变量环境和词法环境,建立内存空间和变量的初始化,其中:
-
- 字面量形式声明的函数:会被分配内存空间并赋值
var
定义的变量、函数:会被分配内容空间并被变量提升(初始值为undefined)let
、const
定义的变量:会被分配内容空间并被变量提升(未被赋予值!)- 函数环境会初始化创建
arguments
对象并赋值**
- 确定作用域(链)
词法环境决定,哪里声明定义,就在哪里确定
- 确定 this 指向
this 通常由调用者确定,但有两个特例(排除我们自定绑定this的方式):
-
- 箭头函数是词法决定
- eval如果是间接调用,指向全局
var name = 'window ni'
var msg = 'hao'
function test(){
this.name = 'hello'
var msg = '好'
var _myEval = eval
eval(`console.log(name, msg)`)//hello 好
_myEval(`console.log(name, msg)`)//window ni hao
}
伪代码:
executionContextObj = {
variableObject : {}, // 变量对象,里面包含 Arguments 对象,形式参数,函数和局部变量
scopeChain : {},// 作用域链,包含内部上下文所有变量对象的列表
this : {}// 上下文中 this 的指向对象
}
执行阶段
-
逐行执行代码,变量赋值、函数调用
-
遇到函数调用时创建新的函数执行上下文并压入调用栈
举个例子:
const foo = function(num){
var a = "Hello";
var b = function varB(){};
function c(){}
let d = "World"
}
foo(10);
- 创建阶段
executionContextObj = {
variableObject : {
num: 1, //确定形参并且赋值
arguments: {0:10, length:1}, //确定argumens
c: function c(){}, //确定字面变量定义的函数
a: undefined,// var 定义的局部变量,初始值为undefined
b: undefined,// var 定义的局部变量,初始值为undefined
// let 定义的变量只会记录,到执行赋值的阶段这里为暂死区
},
scopeChain : {},//词法解释阶段已经确定了作用域,该阶段相当于引用
this : {}
}
- 执行阶段
executionContextObj = {
variableObject : {
num: 1, //确定形参并且赋值
arguments: {0:10, length:1}, //确定argumens
c: function c(){}, //确定字面变量定义的函数
a: "hello",// var 定义的局部变量,赋值
b: function varB(){},// var 定义的局部变量,赋值
d: 'world' //let 定义的局部变量,声明并赋值
},
scopeChain : {},//词法解释阶段已经确定了作用域,该阶段相当于引用
this : window //假定是全局定义
}
作用域
在MDN中,我们可以找到定义:
The scope is the current context of execution in which value and expressions are "visible" or can be referenced.
翻译一下:作用域(Scope)指的是在执行上下文中可见(或者说是可用)变量的范围 。
也就是指在JS执行过程中变量、函数、对象等标识符(名字)能够被访问的区域。
- 词法作用域(Lexical Scope) :在代码编写时就确定了作用域关系,即变量在源代码中出现的位置决定了它的可见范围。
- 块级作用域:用大括号
{}
包含的区域(如if
、for
、function
内部),ES6 之后使用let
和const
定义的变量拥有块级作用域。
作用域链取值说明
当访问一个变量时,解释器会首先在当前作用域查找标示符,如果找到了,则使用当前作用域下的变量,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不在父作用域中,这就是作用域链。
那这个父作用域又是那个呢?实际上是要到创建这个函数的那个域。 作用域中取值,这里强调的是“创建”,而不是“调用” ,切记切记——这种类型的作用域又称为静态作用域,也被称为词法作用域,因为在词法分析时就确定了查找关系(这也是和 this
最大的不同!)。
静态作用域取值
function foo(){
console.log(a)
}
function bar(){
var a = 3;
foo();
}
var a = 1;
bar();
上面代码会打印 1
,为什么呢?因为此处foo
的函数是在全局作用域window
上定义的,所以查找时现在foo
函数中查找a
,找不到会去window
上查找,所以此处a=1
。
顺便在看下this
的,感受下其中的不同,虽然这个与作用域无关...
function foo(){
console.log(this.a)
}
function bar(){
var a = 2
foo();
}
var a = 1;
bar() // 1
bar.call({a:3}); //1
此处的两个输出会打印 1
,第一个大家可能容易理解,为什么第二个也是1
呢?此处foo
被调用时,其执行上下文指向的依然是全局执行上下文,所以这里的this
也指向window
,所以此处a=1
。
闭包的作用域
闭包其实就是函数握住了外部的词法环境:
const arr = []
function addPlus(a){
var i = 0 //这里定义成let也一样,因为这个词法
for(;i<3;i++){
arr[i] = ()=>{
console.log(a * i)
}
}
console.log('end i', i)
}
addPlus(1)
arr[0]()//3
arr[1]()//3
arr[2]()//3
所以输出都是3
,因为最终 i = 3
变量提升
上面说过,JavaScript中,存在函数声明和变量声明总是会被解释器悄悄地被"提升"到方法体的最顶部,这就是我们常说的变量提升,注意:
- 只有声明的变量会提升,值不会。
- 严格模式下不存在变量提升。
let
和const
也存在变量提升,但是let
和const
定义的变量会造成暂时性死区,定义变量后一开始就形成了封闭作用域,凡是在声明之前就使用这些变量,就会报错。
var
和let
的声明提升:
console.log(a) //undefined
var a = 1
var b = 1
{
//报错,如果没有提升,不是应该显示成1?,所以是有提升
console.log(b)
let b = 2
}
当前作用域下只要存在变量,就算是变量提升得到,也相当于找到了,不会再去父作用域中找:
var a = 1
function foo(){
console.log(a)
var a = 2
}
foo()//undefined
函数定义也存在变量提升,而且是整体提升,如果是函数变量则看定义的关键字是var
还是其他,这和上文保持一致:
console.log(age);
var age = 20
console.log(age);
// 1.提升到最前面
function age() {}
// 2.将function改成匿名函数的方式,则不会提升
//var age = function(){}
console.log(age);
// 1. 函数提升输出
//f age(){}
// 20
// 20
//2. 将function改成匿名函数的方式,则不会提升(注意要重新在一个新的环境下运行)
//undefined
//20
//ƒ age(){}