作用域是什么?
这里涉及到几个概念,作用域,词法作用域 ,动态作用域
作用域,可以理解为引擎寻找变量的范围,其通常包含全局作用域,函数作用域。注意作用域是一个抽象的概念,并不是具体的实现,我们在不同的标准中,如ES3的VO ES5的Lexical Environemt来具体实现作用域的查找机制。
作用域如何确定自己的查找范围?这里有词法作用域和动态作用域的概念:
词法作用域和动态作用域是相对的概念,其中词法作用域的范围是由 书写代码的位置决定 ,而动态作用域的范围由代码被调用运行的位置决定。
需要注意,在Javascript中,没有动态作用域,js采用的是词法作用域,而js中的this其实类似于动态作用域的概念。
《你不知道的Javascript》中提到 “词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。词法作用域最重要的特征是它的定义过程发生在代码的书写阶段”
可能有点抽象,我们可以举个例子:
当采用词法作用域时(这里也是真实js的情况),foo函数查找a的路径在书写代码是就确定了,也就是现在foo的函数作用域内找,找不到就到全局作用域,因为foo是声明在全局中的,所以在全局找到a=2 输出
// 采用词法作用域的情况 - js中的真实情况
var a = 2;
function foo() {
console.log( a ); // 2
}
function bar() {
var a = 3;
foo();
}
bar();
当采用动态作用域时,这里虚拟一下,假如js采用的是动态作用域查找机制,此时由于foo函数由bar函数调用,此时的作用域查找应该是 foo的函数作用域,其调用者bar函数的函数作用域,最后是调用bar函数的全局作用域,此时的a应该为3,但是着并不是js的实际查找情况!
// 假设采用动态作用域
var a = 2;
function foo() {
console.log( a ); // 3
}
function bar() {
var a = 3;
foo();
}
bar();
当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做 「作用域链」
执行上下文 EC 和 执行上下文栈 ECS
执行上下文 (Execution Context)是js代码的一个运行环境,其存放了js代码执行需要的变量,变量查找的作用域链,this指向等等
执行上下文一般分为
- 全局执行上下文 GEC
- 函数执行上下文
- Eval执行上下文 (不做讨论)
在js代码执行之前会进行预解析,这个过程就会创建一个执行上下文EC,并且放到执行上下文栈ECS中,这个执行上下文栈,也被称为调用栈。
当开始运行全局代码之前,就会创建一个GEC,并且加入到ECS中,然后开始运行全局代码,当碰到函数调用时,此时会创建一个函数执行上下文EC,并且压入(PUSH )ECS顶, 之后开始运行函数代码,当函数运行完成后,POP弹出当前函数EC,当js完全执行结束之后(关闭浏览器),GEC被弹出ECS(执行上下文栈顶为当前正在执行代码的执行上下文)
如图所示:
function foo () {
function bar () {
return 'bar';
}
return bar();
}
foo();
其调用过程如下:
我们刚说,执行上下文是js的执行环境,其中包含了js代码的变量,作用域,this指向等等,但是具体其内部结构是什么呢? 我们下面详细讨论
ES3 & ES5 中的
我们谈论执行上下文一般说两个版本
一个是早先的ECMAScript 3中定义的版本,也就是你经常听到的 VO GO AO的概念。
另外一个是ECMAscipt 5 中定义的版本,也就是经常听到的 词法环境,变量环境,环境记录的概念,其中新版本更应该仔细研究并且掌握
除此以外 ES2018还有更新的版本,有兴趣的小伙伴可以自行查看
执行上下文是个抽象的概念,其本质是一个C++结构,我们在理解时可以将其当成是一个对象,但是要知道,其本质不是个js对象,我们也无法通过js直接进行访问。只有其设计暴露出来的一些部分我们可以通过js直接访问到(如: GO/全局环境记录器)
ES3中的执行上下文
ES3中的执行上下文包含以下的内容
- 变量对象VO(
variable object
)其包含GO和AO - 作用域链scope chain
- this
变量对象(variable object
):包含了当前上下文中所有的变量,函数等
- 在全局上下文中,其变量对象VO也称为GO 即全局对象Global Object, 全局对象在全局代码执行之前就创建,并且会加入一些默认属性如: Date Function Array 也包含宿主环境提供的一些属性 如document history Settimeout setInterval 还会包含所有var function声明的变量和函数 (注意在es3中,还没有let const 的概念)
- 在函数上下文中,其变量对象也称为AO 即Activation Object 活动对象,在函数执行之前被创建,默认包含参数arguments,以及函数内的var和function
我们要知道,VO本质上也是C++对象,只不过对于全局中的GO,宿主环境给我们开放了一个访问的方式,在浏览器中为window,在node.js中为global。而AO是没有提供访问的,我们可以在debugger调试的过程中,看到其包含的内容
作用域链scope chain 也是一个无法直接访问的C++对象,可以理解为是一个对象列表,用于查找变量的值,可以理解为其包含了当前VO和其所有parent的Vo的列表,其具体创建流程如下,
var a = 100
function Fn(){
var a = 200
function Fn1(){
var a = 300
console.log(a)
}
Fn1()
}
Fn()
当函数对象被创建时,如扫描到Fn时,会创建一个函数对象,此时还没执行函数
函数对象中有 [[scope]]隐藏属性,会记录了当前作用域的scope chain
当执行Fn函数前,其AO的scope chain为当前AO+函数对象的[[scope]]
以此类推,最好Fn1 AO的scope chain如下图:
这里只是简单描述,但是具体如何实现,使用什么样的数据结构是技术实现层面的事情,ECMAScript只是一个规范,每一个厂商对这份规范的实现都不尽相同,
所以这是一个抽象的概念,我们只需要用最简单的方式去理解这个概念就行,但要注意的是,无论是哪种阶段,作用域链都是有层级的,就好像执行上下文一样具有优先级的。
有一个例外: 使用 new Function() 创建函数对象时,其[[scope]]指向全局!
this就是记录了调用者,在全局EC中,this默认指向GO
用伪代码简单描述一下就是:
<script>
// 执行上下文
ExecutionContext: {
// 变量对象 | 活动对象
[variable object | activation object]: {
arguments: [...], // 在全局执行上下文中没有 arguments
variable: [...],
function: [...]
},
// 作用域链
scopeChain: variable object + all parents scopes,
// this 指向
thisValue: context object
}
</script>
SCAN和提升
在代码执行之前的预解析过程中,函数声明function 和 var定义的变量会被加入到VO中(全局情况下加入到GO),并且函数优先,也就是如果函数和var变量同名,则函数会覆盖掉var变量。
同时需要注意 var变量虽然会被提升 但是其默认值为undefined,但是function提升时,会创建函数对象,并且将其[[scope]]设置为当前作用域的scope chain
看一个例子:
console.log(a) // 这里是函数,因为函数优先
var a
function a(){
console.log(a)
}
var a
console.log(a) // 这里是函数,因为函数优先
function a(){
console.log(a)
}
var a = undefined
console.log(a) // 这里是undefined 因为重新给a赋值了
function a(){
console.log(a)
}
注意! var a 和 var a = undefined
var a 是声明一个变量a,但是没有赋值,其value是在预解析阶段赋予的undefined初始值
var a = undefined 在预解析阶段赋undefined 但是后面的执行阶段右赋了undefined 相当于又执行了
a = undefined
所以即使a在预解析阶段被function a覆盖了,但是在执行阶段,a又被覆盖为了undefined,最后输出undefined
EC生命周期
- 函数被调用
- 在执行具体的函数代码之前,创建了执行上下文(创建期)
- 初始化作用域链
- 如果是AO,创建arguments对象,接受实参,挂载到AO
- 注意如果参数 函数 var名称相同 则 函数>参数> var
- 扫描,提升
- 确定this指向
- 进入执行阶段:(执行期) 运行函数代码,并在代码逐行执行时分配变量值。
- 函数执行完毕,执行上下文出栈,等待垃圾回收将其销毁(回收期)
我们看一个例子来分析整个流程:
function xx(x) {
console.log(x)
var a = 100;
var b = 200;
var x = 300;
function c() {
console.log(a,b);
var b = 400
console.log(x);
};
c()
};
xx(111);
1. 创建全局GEC,并且创建GO,提升函数并且创建函数对象,函数对象的scope指向GO,this指向GO
2. 执行到xx(111) 执行函数之前,先创建EC和AO 并且处理参数,创建arguments,提升变量和函数,this指向GO 如图所示
由于参数>var 所以这里x为传入的111 a b 未赋值 默认undefined c赋函数对象c并且其[[scope]]为当GO
3. 逐步执行,输出x为111 替换a为100 b为200 x重新赋300 如图
4. 执行c函数,执行之前创建EC和创建xx的EC类似,GO是直接调用的 this为GO
此时a在C的AO中没有 顺着scope chain找到xx的AO 输出a为100,b被提升,但是还没有赋值,所以不会去作用域链找,会直接输出undefined
5. 为b赋值并且顺着作用域链找到xx的AO 输出x为300
6. c结束 弹出EC xx结束 弹出xx的EC,最后剩下GEC,并且在浏览器关闭时,弹出GEC,在没有闭包等引用的情况下,垃圾回收掉不需要的内容
ES5中的执行上下文
相比于ES3,ES5的执行上下文要更重要一些,同时ES6加入了let const 新版本的执行上下文包含了对于块作用域的处理
ES5中没有VO GO AO的概念,取而代之的是一个叫词法环境的概念(LE Lexical Environment)其是一个基于代码词法嵌套结构用来记录标识符和具体变量或函数映射关系
词法环境包含一个环境记录器 Environment Record和一个outer,在全局作用域中 outer为null,在函数作用域中,outer指向当前函数的[[scope]] 创建函数的时候 其[[scope]] 指向当前的词法环境(词法环境是作用域的实现方式). 词法环境分为:
- 全局词法环境 Global Lexical Environment 在js执行之前被创建,通常包含所有全局变量和函数,outer为null
- 函数词法环境 Function Lexical Environment 函数被调用时创建,包含函数参数,函数体内变量函数声明等,outer指向函数对象的[[SCOPE]] 也就是创建函数对象的词法环境
- 块此法环境 Block Lexical Environement 进入块时创建,如if语句,for循环,catch、with,{},其outer指向创建这个块词法环境的词法环境
环境记录器 Environment Record用来记录标识符和变量或者函数的关系 包含
Declarative environment record
(声明式环境记录)用类似于Map的方式记录key value之间的关系 存储如 var function (ES6中的let const等) 类型的数据,一般用于函数或者块的词法环境 一般可以直接通过标识符访问内容- Function environment record是特殊的声明式环境记录 会包含函数所需的this 参数arguments等等
Object environment record
(对象环境记录)使用对象的方式记录key value之间的关系。由对象创建,一般用于with catch 以及全局环境中的全局对象,访问一般需要使用对象.属性的方式访问- 全局环境记录 用于存储全局作用域中的变量和函数声明,在js代码运行之前的预解析阶段被创建,其包含两部分:
- 对象环境记录: 也就是GO (window/global)
- 声明环境记录:存储全局变量,通过var function声明的变量函数,以及ES6中的let const都会被添加到这个声明式环境记录中
我们可以通过 window.变量名称 的方式向对象环境记录中添加映射关系,也可以通过var function 的方式向声明式环境记录中加映射关系,但是这种方式也会顺带加入到对象环境记录中,如我们使用var a = 100 也会自动把a加入到window中,可以通过window.a的方式获取。
补充一点,当我们使用 var a = 10 时 和 window.a = 10 有什么区别
- 使用var a = 10 之后 无法在用const a =10 声明
- window.a 可以删除 delete window.a 而 var a =10 会对window上的a的描述符号 configurable配置为false
最后,对于ES6中的const let 会加入到声明环境记录中,但是不会加到对象环境记录中,所以在window中找不到。
举例说明:
// 创建环境记录器
function EnvironmentRecord(obj) {
if(全局作用域) {
// 对象环境记录器 object Environment record
this.bindings = object;
this.type = 'Object';
}else if(函数作用域){
// 声明环境记录器 declearative Environent record
this.bindings = new Map();
this.type = 'Declarative';
}
}
// 注册value
EnvironmentRecord.prototype.register = function(name) {
if (this.type === 'Declarative')
this.bindings.set(name,undefined)
this.bindings[name] = undefined;
}
// 赋值
EnvironmentRecord.prototype.initialize = function(name,value) {
if (this.type === 'Declarative')
this.bindings.set(name,value);
this.bindings[name] = value;
}
// 查询
EnvironmentRecord.prototype.getValue = function(name) {
if (this.type === 'Declarative')
return this.bindings.get(name);
return this.bindings[name];
}
在全局词法环境中,其环境记录器就是GO,同ES3中的GO一样,也被暴露,可以用window或者global(访问)但是对于函数词法环境的delearative Envrionment Record 就无法直接通过js访问了!
ES5中的上下文
ES5中的执行上下文包含
- 两个指针
- VariableEnvironment 变量环境指针
- LexicalEnvironment 词法环境指针
- this
如下图所示,需要区分 指针LexicalEnvironment&指针VariableEnvionment和 词法环境 Lexical Environment
在创建EC的时候会同时创建词法环境 Lexical Environment 同时在EC中声明两个指针,名字刚好和词法环境类似 称为 指针LexicalEnvironment 和 指针VariableEnvionment 这两个指针默认都指向词法环境
这两个指针的作用是什么呢?
ECMA 5对这两个指针的解释:
The LexicalEnvironment and VariableEnvironment components of an execution context are always Lexical Environments. When an execution context is created its LexicalEnvironment and VariableEnvironment components initially have the same value. The value of the VariableEnvironment component never changes while the value of the LexicalEnvironment component may change during execution of code within an execution context
我们一般关注在ES6中对这两个指针的区别
变量环境指针(VariableEnvironment)是用来登记var
function
变量声明,词法环境指针(LexicalEnvironment)是用来处理catch with 以及ES6登记let
const
class
变量声明。
VariableEnvironment(VE)是不可变的 它指向执行上下文对应的词法环境,LexicalEnvironment(LE) 默认也是指向执行上下文对应的词法环境,但是LE扫动态的,可变化的,随着函数的执行,LE的指向可能是变化的。
我们可以看到ECMA规范中10.2.1.1 准备普通调用(F,newTarget)
LE和VE都指向这个localEnv,一般情况下,这两个指针都指向对应的词法环境,是相等的
如图所示:
你可以理解VE和LE为两个想环境记录器中添加映射关系的方法,通过VE可以将var function等添加到环境记录器中,通过LE可以将let const class声明的变量添加到环境记录器中
动态的LE指针,到底干嘛用?
我们知道,起初javascript是没有块级作用域的,这就导致js和其他很多支持块级作用于的程序写起来,相同逻辑的代码结果不同,如:
var a = 100
if(true){
var a = 200
console.log('inner',a)
}
console.log('outer',a)
这段代码的执行结果为
inner 200
outer 200
由于var没有块作用域,所以在if块结束之后,外面的a也会收到块内影响,这和大部分由块级作用域的语言结果不同。对于c语言,java python这种包含块级作用域的语言,块内部的内容将会在推出块是被回收,不会影响块外内容,但是显然js不具备这个特点。
在早先ES5中,由于catch,with块内是由块级作用域的,所以经常使用这个特性,上面的例子可以改写为:
// 利用catch
var a = 100
try{
throw 200
}catch(a){
console.log('inner',a) // inner 200
}
console.log('outer',a) //outer 100
// 利用with
var a = 100
with({a:200}){
console.log('inner',a) //inner 200
}
console.log('outer',a) //outer 100
我们模拟一下with的运行过程
起初,LE和VE都指向全局词法环境,并且全局环境记录器中的a为100,如图所示
由于使用with进入块,为了让块中的内容不影响当前词法环境的内容,所以会创建一个块基词法环境 Block Lexical Environment ,其outer指向创建该词法环境的全局词法环境,同时让LE动态的指向这个环境,如图
此时在块内输出a的内容为200 块执行结束后,LE会返回到全局词法环境中,同时垃圾回收块词法环境,此时全局词法环境中的a不受影响,还是100。
用这样的方式,就实现了块级作用域,当然这种方式不太优雅,为了一个块级作用域还需要抛出异常,所以在ES6中,提出了let和const的概念。
ES6中 let const 与 LE
看个例子:
var a = 100
var func
if(true){
let a = 200
console.log(a)
func = function () {
console.log(a)
}
}
func()
执行到if(true)时,要进入块之前,如图所示,会创建新的Lexical Environment并且让LexicalEnvironment指向这个词法环境,同时预解析阶段会收集let const 但是未初始化,这里和var初始化为undefined不同,这里如果在赋值之前使用,会出现TDZ问题
运行阶段,给A赋值200 并且输出a为200
对func赋值,顺着作用域链找,找到GO中的func,并且赋函数对象,这里函数对象的scope为LexicalEnvironment指向的词法环境
此时块执行完成,恢复LexicalEnvironment,但是由于func的scope还引用这个Lexical Environment,所以不会被垃圾回收
直到执行func函数,会顺着作用域链找到a=200 并且输出 而不是找到Go中的a=100 此时执行完成。
块中的兼容性处理
上面的例子,如果我们不用func将块内的函数对象导出来,那么我们在外面可以调用吗?
var a = 100
console.log(func) undefined
if(true){
let a = 200
console.log(func) // f()
function func() {
console.log(a) 200
}
}
func()
我们会发现,在调用func的时候不会报错,反而成功输出了!?而且前面的console.log(func)并没有报错,我们知道,非顶级的函数声明不会提升,那么这里的func应该是 “func is not defined” 才对,这什么鬼??
为什么会这样? 这个其实是一个兼容性操作,为了兼容以前的写法。我们知道,非顶级的函数声明不会被提升,但是这里面对于非顶级的函数,会在当前函数/全局作用域中声明一个foo标识符,并且赋undefined ,所以我们在外卖呢输出func时undefiend,并不是func is not defined 报错,当进入块内,function会被提升,顺利赋值
同时,如果我们想先声明一个func,再让后面的function func给func赋值,会报错
VM2251:13 Uncaught TypeError: func is not a function
at <anonymous>:13:1
var a = 100
let func
console.log(func)
if(true){
let a = 200
console.log(a)
function func() {
console.log(a)
}
}
func()
因为let和function不能同名,当在外面用let声明func了,就不会有这个兼容性操作了,当使用var 声明func,则没有问题
[注意 严格模式下没有这个兼容性操作]
最后补充一点查找顺序:
当查找某个变量,其顺序是先查找LE在查找VE
如 window.a =100 let a =200 由于不是用var a =100声明,所以不会报错,此时a为200
描述js执行过程:
全局代码的执行过程
预解析:
全局代码执行之前,会先创建一个词法环境Lexical Environment 并且将其outer = null
创建EC,让LE和VE都指向Lexical Environment
1. 收集所有的全局var和全局顶级的函数声明 通过VE放到词法环境的环境记录器中
注意,什么是全局的var?
只要在当前作用域的var都收集 如下,由于没有块作用域这个概念,所以if块中的var a也属于全局作用域
if(true){
var a = 10
}
但是,对于函数内部的var,就不属于全局了,属于函数内部作用域,不收集
function foo(){
var a = 10
}
注意 什么是 全局顶级的函数声明
全局刚刚说了,全局顶级会将在块内的函数声明也排除出去,如下
if(true){
function foo(){
}
}
什么是函数声明,需要区分函数表达式:函数声明是以function开头的,其余的都是表达式,例如 (function(){}) +function(){} 都是表达式
2. 收集所有的全局 let const 通过LE放到词法环境的环境记录器中
3. 检查 let const之间,是否有重复,检查let const 和 function var之间是否有重复的,var和function之间不检查,如果有则报错,此时是预解析状态,函数都还没开始执行
4. 初步赋值,
- 给var变量赋undefined
- 给函数声明创建函数对象,并且将其[[scope]] 指向当前的词法环境
- let const 不赋值,并且在代码运行到声明之前,都不能使用也不能赋值 TDZ
所以,let const也会提升,只不过在运行到声明代码之前无法使用,同时收集时函数优先,如果相同名的函数声明和var,会忽略掉var
5. 设置this,指向词法环境的环境记录器,也就是GO
此时全局的预处理结束,将EC加入ECS,执行代码。
函数代码的执行过程
当遇到函数调用,在函数代码之前,同样会生成函数的EC,并且生成一个词法环境
让这个词法环境的outer 指向当前函数对象的[[scope]] (执行函数之前,一定完成了函数对象的创建,创建函数对象时,会将函数的[[scope]]指向创建函数对象的词法环境)
将EC中的LE VE指向函数的词法环境
和全局中类似,收集函数下的var 顶级的函数表达式,收集const let 通过LE VE赋值给词法环境的环境记录器,并且判读重复,初步赋值
给EC中的this设置为调用者
预处理结束,执行代码
循环中的块作用域
看一段熟悉的代码,我们期望其异步输出0 1 2 3 4 但是最终结果是 5 5 5 5 5
for(var i =0 ;i<5;i++){
setTimeout(()=>{
console.log(i)
},10)
}
我们熟知的解决方式有:
for(var i =0 ;i<5;i++){
const record = i
setTimeout(()=>{
console.log(record)
},10)
}
for(let i =0 ;i<5;i++){
setTimeout(()=>{
console.log(i)
},10)
}
但是这些方法到底是怎么解决的呢? 我们具体来看一下
预解析阶段:此时i被提升,放到环境记录器中,准备执行代码
for循环给var赋值0,并且进入函数体,创建块文本环境,并且用LE指向 如下
块文本环境内调用异步任务,交给计时器执行,此时块执行完成,由于没有闭包引用,所以LE指回全局词法环境,并垃圾回收块词法环境,如图所示
继续执行循环体,给var i + 1 全局环境记录器中的i为1,创建块,修改LE如下:
启动计时器,执行完成,以此类推5次,此时i=5时,结束循环,同步代码执行完成,执行异步代码,此时输出5个5结束
由于使用var,导致变量被放到全局词法环境中,块没有起作用,如果改成let i=0的形式,则会将let i变量放到每一个块词法环境的记录器中,这样就可以实现闭包,即settimeout中callback引用这些词法环境中的变量,这些词法环境不会被垃圾回收,最终可以达到依次输出 0 1 2 3 4 的目的