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的实际操作步骤:

  1. 创建AO对象;

  2. 寻找形参和函数体内声明的变量名,设置为AO键名,键值为undefined

  3. 传入实参值,替换掉AO中形参的键值undefined

  4. 寻找函数体内直接声明的函数function A(){}...,设置函数名为AO键名,值为对应声明的函数体。

执行环境栈

二、作用域 Scope、作用域链、闭包

作用域是针对变量访问这一操作而言的,它是一个变量集合

如果一个作用域在当前执行环境的作用域链上,那么该作用域中的变量都可以在当前执行环境,沿着作用域链被访问(前提是不与作用域链上方的其他作用域中变量重名)。

JS使用词法作用域。也就是说,作用域是根据代码的静态书写顺序决定的,与如何执行、在哪执行无关。

JS中存在四种作用域:

  • 全局作用域Global: JS程序开始执行就生成的初始作用域;
  • 函数作用域Closure: 函数内部生成的作用域;
  • 块级作用域Block: 使用{}括起来的部分,如果不是函数,形成的作用域是块级作用域;
  • 模块作用域Module: 使用ES6模块引入的变量汇总在一起,单独存在于一个作用域中。

作用域链通过函数声明时内部记载的[[Scopes]]内部属性(一个栈),和函数执行环境中变量对象VO记载的作用域链属性来实现,它的原理是:

  1. JS程序执行过程中,如果遇见函数声明函数表达式(具名或匿名):
    • 如果当前执行环境是全局:则函数的[[Scopes]]内部属性被设置为只有全局变量对象window/global;
    • 否则,新声明的函数可能存在对其他函数作用域内变量的引用:
      1. 对新创建的函数内部进行静态词法分析,找到它引用的外部作用域变量(LHS查询或RHS查询都有效);
      2. 除最末端全局作用域外,对当前执行环境作用域链内其他作用域中变量进行一轮筛选,只保留被新声明函数引用的变量;
      3. 将筛选后的被引用外部变量,按各自作用域分组,分别组成新的对象叫做闭包Closure(保持原链中的位置关系),放入堆内存中;
      4. 将闭包按原作用域链顺序(栈顺序),组成新的作用域链,保存在新声明函数的内部[[Scopes]]属性中。
    • 如果没有任何对外部变量的引用,新声明的函数[[Scopes]]属性上只有全局变量对象window/global;
  2. 当声明的函数fn被执行:
    1. 创建新的函数执行环境,包含新的活动对象AO;
    2. 将函数fn的内部[[Scopes]]属性值取出,作为当前执行环境变量对象VO的初始作用域链;
    3. 将当前函数执行环境,压入函数执行栈,继续执行;
    4. 执行过程中,当前函数作用域始终在作用域链的最顶部。查找变量先在当前变量对象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。

普通函数

与之相反。

箭头函数不适用的场景

箭头函数本身不能用作:

  1. 构造函数;
  2. Generator函数;

如果箭头函数内部含有this,它不适用于:

  1. 定义对象的方法;
  2. 动态上下文的回调函数;
Keywords: JavaScript
previousPost nextPost
已经有 1000000 个小伙伴看完了这篇推文。