JS执行环境、作用域链和this指向
Posted by Mars . Modified at
作用域(Scope)、闭包(Closure)、作用域链和上下文(Context) JS函数词法环境和作用域问题,还有var和let的区别问题。
一、执行环境(Execution Context)
执行环境,也叫执行上下文,是JavaScript在运行时,用于控制程序执行流程的一种手段。
执行环境记录了当前执行流条件下,可访问的变量空间、访问顺序(作用域链)和上下文(this指向)。
只有以下三种情况会创建新的执行环境:
- 全局执行环境: 程序开始运行时,创建全局执行环境Global;
- 函数执行环境(局部执行环境): 函数开始被执行时,会创建自己的执行环境,也叫本地执行环境;
- 使用
eval()
函数,也会创建自己的执行环境。(比较少用,不建议使用)
每个执行环境中,都存在一个变量对象VO(或活动对象AO),它的内部属性包含三部分:
- 当前执行环境内部的各变量
(local variables:形参、内部声明变量等)
; - 当前执行环境的this属性指向;
- 当前执行环境的作用域链
(栈结构数据,全局作用域在最底层0位置)
;
因此,在当前执行环境下声明、修改变量,实际上是在修改当前执行环境下VO对象的属性值,或通过作用域链找到的闭包或全局执行环境对象内的属性值。
执行环境的VO对象中,记录了当前环境的作用域链:
- 在某一JS执行环境的VO对象上,存有当前环境的作用域链属性。任何执行环境下,作用域链的最底端是全局变量对象window(global);
- 如果在当前环境执行过程中,遇见函数声明,会对声明的函数func内部进行词法分析(静态分析),分析其内部是否引用了当前环境变量对象VO的属性,或当前环境下作用域链上可访问的某个对象的属性:
- 如果没有引用任何外部变量:当前函数的
[[Scopes]]
属性上只有一个全局变量对象Global,位于栈最底部0位置;- 如果引用了一个或多个外部变量:则按照当前环境作用域链的先后顺序(最前方是当前执行环境变量对象),以作用域链节点为单位,分别打包成闭包Closure,按作用域链先后顺序压入当前声明函数的
[[Scopes]]
属性中;- 当func被在某个执行环境执行时,会为他创建新的函数执行环境,压入执行环境栈:
- 取出存放在func内部的
[[Scopes]]
属性值(栈结构,全局变量对象在最下方0位置),作为当前func函数执行环境的VO对象的作用域链,func内部声明、赋值的属性都在当前环境的VO对象上添加、修改;- 当前执行环境下,如查找某一变量var1,先在当前VO对象属性中查找,如果找不到,沿着作用域链(栈)从上到下的顺序一致查找到全局变量对象,如果找不到,则报错找不到变量。
JS引擎会把内存分为栈内存和堆内存。其中:
- 栈内存:包含全局作用域和函数执行环境栈。
- 堆内存:存放引用类型数据本体。栈内存中如果有变量引用了堆内存中的引用类型数据(比如对象),在栈内存中实际上是保存了这个对象的内存地址。
执行环境在内存中以栈的形式存储(执行环境栈):
JS执行流每进入一个函数,就创建一个新的执行环境,将其压入函数执行环境栈顶部。函数执行完毕,再将执行环境弹出,恢复上层执行环境继续执行。
创建执行环境栈AO的实际操作步骤:
创建AO对象;
寻找形参和函数体内声明的变量名,设置为AO键名,键值为
undefined
;传入实参值,替换掉AO中形参的键值
undefined
;寻找函数体内直接声明的函数
function A(){}...
,设置函数名为AO键名,值为对应声明的函数体。
二、作用域 Scope、作用域链、闭包
作用域是针对变量访问这一操作而言的,它是一个变量集合。
如果一个作用域在当前执行环境的作用域链上,那么该作用域中的变量都可以在当前执行环境,沿着作用域链被访问(前提是不与作用域链上方的其他作用域中变量重名)。
JS使用词法作用域。也就是说,作用域是根据代码的静态书写顺序决定的,与如何执行、在哪执行无关。
JS中存在四种作用域:
- 全局作用域Global: JS程序开始执行就生成的初始作用域;
- 函数作用域Closure: 函数内部生成的作用域;
- 块级作用域Block: 使用
{}
括起来的部分,如果不是函数,形成的作用域是块级作用域; - 模块作用域Module: 使用ES6模块引入的变量汇总在一起,单独存在于一个作用域中。
作用域链通过函数声明时内部记载的[[Scopes]]
内部属性(一个栈),和函数执行环境中变量对象VO记载的作用域链属性来实现,它的原理是:
- JS程序执行过程中,如果遇见函数声明或函数表达式(具名或匿名):
- 如果当前执行环境是全局:则函数的
[[Scopes]]
内部属性被设置为只有全局变量对象window/global; - 否则,新声明的函数可能存在对其他函数作用域内变量的引用:
- 对新创建的函数内部进行静态词法分析,找到它引用的外部作用域变量(LHS查询或RHS查询都有效);
- 除最末端全局作用域外,对当前执行环境作用域链内其他作用域中变量进行一轮筛选,只保留被新声明函数引用的变量;
- 将筛选后的被引用外部变量,按各自作用域分组,分别组成新的对象叫做闭包Closure(保持原链中的位置关系),放入堆内存中;
- 将闭包按原作用域链顺序(栈顺序),组成新的作用域链,保存在新声明函数的内部
[[Scopes]]
属性中。
- 如果没有任何对外部变量的引用,新声明的函数
[[Scopes]]
属性上只有全局变量对象window/global;
- 如果当前执行环境是全局:则函数的
- 当声明的函数fn被执行:
- 创建新的函数执行环境,包含新的活动对象AO;
- 将函数fn的内部
[[Scopes]]
属性值取出,作为当前执行环境变量对象VO的初始作用域链; - 将当前函数执行环境,压入函数执行栈,继续执行;
- 执行过程中,当前函数作用域始终在作用域链的最顶部。查找变量先在当前变量对象VO上查找,如果找不到,则沿着作用域链向下在各作用域中查找,一直到全局作用域,找不到则报错。
2.1 块级作用域
一对花括号会创建一个块级作用域。if{}、for(){}、while(){}和普通的{}都会创建块级作用域。
for{}和while{}循环,相当于多次执行了块级作用域创建,并在新的块作用域内修改变量。
函数内部是函数作用域,全局环境是全局作用域。
2.1.1 for、while循环内声明函数
for{}和while{}循环,相当于多次创建了块级作用域。如果使用let声明变量,每次是在新的块作用域内声明、修改变量。
因此每一个在for、while循环内声明的函数,都通过[[Scopes]]记录下当前的外部作用域,也就是不同的块级作用域,因此当它们在执行的时候,引用的也是不同的块级作用域内的不同变量。这也就解释了如下的代码:
for (let i = 0; i < 10; i++){
setTimeout(() => {
console.log(i)
})
}
// 0,1,2,3,4,5,6,7,8,9
// let声明的i,绑定在块级作用域内,相当于有如下块级作用域{i:0}、{i:1}、{i:2}、{i:3}...
// setTimeout内部声明的箭头函数的[[Scopes]],记录了每次迭代时产生的外部的块级作用域里的i。
// 当后续这个箭头函数按序执行的时候,创建函数执行环境,作用域链引用的是创建时[[Scopes]]记录的各自外部块级作用域,因此log的是每个块级作用域下的不同i,也就是0-9
// 详细解释如下 —— Mars 2021.08.31:
// ----------------------------------
// 1. 函数是引用类型,匿名箭头函数在作为参数传递的时候,相当于在外部先声明、再引用(传递的是内存地址);
// 2. 在外部声明的时候,[[Scopes]]属性里记录了箭头函数的作用域链:Block{i: 0/1/2...} -> Global;
// 3. for循环相当于是多次创建了块级作用域{},并在每一个块级作用域中使用let单独声明了变量i,并在每个块级作用域中独立执行内部函数setTimeout;
//-----------------------------------
// Arrow function is declared outside of setTimeout func.
for (let i=0; i<2; i++) {
let arrow = function () {
console.log(i);
}
setTimeout(arrow, 0);
}
// same to
for (let i=0; i<2; i++) {
setTimeout(() => {
console.log(i);
},0);
}
// same to
{
// Block Scope 0
let i = 0;
setTimeout(() => {
console.log(i);
},0);
}
{
// Block Scope 1
let i = 1;
setTimeout(() => {
console.log(i);
},0);
}
三、上下文 Context: this属性指向
对于当前执行环境,它的上下文,指的是当前执行环境中变量对象VO的this属性值
3.1 哪里有this? 什么时候才会改变this?
this存在于执行环境的变量对象VO中。
也就是说,以下两种执行环境内部,都存在this属性。
- 全局执行环境:this指向全局对象window(或global);
- 非严格模式,浏览器全局执行环境中this 指向 window对象(Node是global对象);
- 严格模式,全局作用域下this为undefined。
- 函数执行环境:函数被执行,为它新建了执行环境压入环境栈。其中的this属性指向,与调用函数的对象或new操作符等有关。
函数作用域中的this指向,只有函数被调用的时候才被决定。
改变函数fn执行时的执行环境内this指向的几种情况:
- 函数直接裸执行;
- 函数作为对象的方法,被执行;
- 使用了new 操作符;
- 使用了fn.call、fn.apply等显式设置this的方法;
- 使用了fn.bind强行绑定fn的this。
3.2 普通函数内部的this
使用函数声明或函数表达式声明的函数,内部的this:
指向将这个函数作为方法调用的对象,也就是函数作为方法被调用时,点号前面的对象。
如果函数被直接裸调用,而不是作为对象的方法(不使用’obj.func’形式,而是func()形式),那么浏览器下默认情况函数的this为window,严格模式为undefined。
3.3 箭头函数内的this
箭头函数内部作用域的没有自己的this,它始终引用它被声明的时刻,执行环境变量对象的this。
翻译成人话:
-
全局作用域中定义的箭头函数,this值始终指向window,永远不会改变。
-
一个函数A中定义的箭头函数,箭头函数里的this就等同于函数A执行时的this。 只要A的this不变,箭头函数在任何地方以任何形式调用this都不变。
-
call、apply和bind对箭头函数都无效。
function outer(){
// 这里inner是箭头函数,它内部的this和它定义上下文outer的this是一致的。
let inner = ()=>{
console.log(this)
}
return inner
}
// 这里outer()被裸调用,outer的this是window。
// 返回内部箭头函数inner,this与outer一致,因此也为window,赋值给了变量a。
let a = outer()
a() // window
3.4 new 操作符会改变函数执行环境this指向
使用new操作符 + 构造函数语法,会改变新创建的构造函数执行环境内部的this指向。
构造函数执行环境内的this,被修改为指向当前环境 new 操作符左侧被赋值的变量(新创建的实例);
即使是被
bind(context)
绑定过上下文的函数,在使用new
的时候,其内部的this仍然会被修改指向为新创建的实例。
class People {
constructor (name) {
this.name = name;
}
};
let p1 = new People('Mars');
// People函数执行环境,被修改为p1;
// 因此,People中进行的this.name = name操作,相当于是p1.name = name操作。
3.5 call、apply和bind修改this
call、apply和bind只对普通函数有效,箭头函数无效。
它们的作用是显式修改函数执行时,执行环境中this的指向。
bind的修改是长期的,call和apply是一次性调用。
四、var和let的区别
4.1 是否有块级作用域
var声明的变量只能存在于函数作用域和全局作用域,没有块级作用域。在块级作用域内部声明的var,会穿透块级作用域泄漏到外部。
let声明的变量可以存在于块级作用域,块级作用域内部用let声明的变量,只能在块内部访问,外部无法访问。
4.2 变量提升、暂时性死区
var声明的变量,声明会被提升到块级作用域的头部,而赋值还是在本地。
// other js code..
var a = 2;
其实是发生了下面的事情:
var a; //此时a是undefined.
// other js code..
a = 2;
因此var声明的变量可以提前使用,只是此时为undefined。
而let声明的变量,在当前代码块开始位置直到let声明之前区域,叫做暂时性死区,不可访问,否则报错。
4.3 var可以重复声明
var可以重复声明同名的变量,后面的覆盖前面的。而let不可以(报错)。
4.4 是否会变成全局变量
let 在全局作用域下声明变量,不会变成全局变量,也就是不会出现在window的属性中;
var在全局作用域下声明变量,会默认变成window的属性。
五、箭头函数和普通函数的区别
箭头函数:
- 执行环境中没有自己的this,this引用定义时执行环境的this;
- 不能使用new操作符;
- 没有prototype属性;
- 内部没有arguments对象;
- call和apply、bind无效;
- 内部不能使用
yield
关键字,因此不能用作generator。
普通函数
与之相反。
箭头函数不适用的场景
箭头函数本身不能用作:
- 构造函数;
- Generator函数;
如果箭头函数内部含有this
,它不适用于:
- 定义对象的方法;
- 动态上下文的回调函数;