学习JavaScript的执行上下文、作用域和闭包

705 阅读10分钟

执行上下文

执行上下文 ( execution context ) 就是当前 JavaScript 代码被解析和执行时所在环境的抽象概念。

  • 全局执行上下文:默认、最底层、全局唯一;
  • 函数执行上下文:每次调用函数时,都会为该函数创建一个新的执行上下文,包括调用自己;
  • Eval 函数执行上下文:运行在 eval 函数中的代码拥有自己的执行上下文。

执行上下文堆栈

在一个 JavaScript 程序中,必定会产生多个执行上下文,JavaScript 引擎会以栈的方式来处理它们,这个栈我们称其为函数调用栈 ( call stack )。栈底永远都是全局上下文,而栈顶就是当前正在执行的上下文。

当浏览器首次载入脚本,默认进入全局执行上下文。每当发生函数调用,将创建一个新的执行上下文,并将新创建的上下文压入执行栈的顶部。浏览器将总会执行栈顶的执行上下文,一旦当前上下文函数执行结束,它将被从栈顶弹出,并将上下文控制权交给当前的栈。

执行上下文的创建与执行

在任意的 JavaScript 代码被执行前,执行上下文处于创建阶段。在创建阶段中总共发生了三件事情:

  1. 确定 this 的值,也被称为 This Binding。
  2. LexicalEnvironment(词法环境) 组件被创建。
  3. VariableEnvironment(变量环境) 组件被创建。

因此,执行上下文可以在概念上表示如下:

ExecutionContext = {  
  ThisBinding = <this value>,  
  LexicalEnvironment = { ... },  
  VariableEnvironment = { ... },  
}

This Binding

  • 全局执行上下文中,this 的值指向全局对象,在浏览器中,this 的值指向 window 对象
  • 函数执行上下文中,this 的值取决于函数的调用方式。如果它被一个对象引用调用,那么 this 的值被设置为该对象,否则 this 的值被设置为全局对象或 undefined(严格模式下)
  • 在构造函数模式中,类中 (函数体中) 出现的 this.xxx=xxx 中的 this 是当前类的一个实例
  • call、apply 和 bind:this 是第一个参数
  • 箭头函数没有自己的 this,看其外层的是否有函数,如果有,外层函数的 this 就是内部箭头函数的 this,如果没有,则 this 是 window

词法环境

词法环境由两部分组成:

  • 环境记录(EnvironmentRecord):存储变量和函数声明的实际位置
  • 对外部环境的引用(outer):访问外部词法环境

词法环境分为两种类型:

  • 全局词法环境:
    • EnvironmentRecord 叫对象环境记录,包含一个全局对象(window 对象)及其关联的方法和属性(例如数组方法)以及任何用户自定义的全局变量,this 的值指向这个全局对象。
    • outer 为 null
  • 函数词法环境:
    • EnvironmentRecord 叫声明性环境记录, 除了相应的变量和函数声明,还包含了一个 arguments 对象,该对象包含了索引和传递给函数的参数之间的映射以及传递给函数的参数的长度(数量);
    • outer 可以是全局环境,也可以是包含内部函数的外部函数环境。

抽象地说,词法环境在伪代码中看起来像这样:

GlobalExectionContext = {  
  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  
      // 标识符绑定在这里 
    outer: <null>  
  }  
}

FunctionExectionContext = {  
  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  
      // 标识符绑定在这里 
    outer: <Global or outer function environment reference>  
  }  
}

变量环境

变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。

在 ES6 中,LexicalEnvironment 组件和 VariableEnvironment 组件的区别在于前者用于存储函数声明和变量( let 和 const )绑定,而后者仅用于存储变量( var )绑定。

举个例子,代码:

let a = 20;  
const b = 30;  
var c;

function multiply(e, f) {  
 var g = 20;  
 return e * f * g;  
}

c = multiply(20, 30);

相应的执行上下文:

GlobalExectionContext = {

  ThisBinding: <Global Object>,

  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  
      // 标识符绑定在这里  
      a: < uninitialized >,    // 全局变量声明(let 或 const 定义的变量,创建时未初始化,声明前使用报错,执行阶段会置为 undefined)
      b: < uninitialized >,  
      multiply: < func >  // 全局函数声明
    }  
    outer: <null>  
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  
      // 标识符绑定在这里  
      c: undefined,  // 全局变量声明(var 定义的变量,声明前访问不报错)
    }  
    outer: <null>  
  }  
}

FunctionExectionContext = {  
   
  ThisBinding: <Global Object>,

  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  
      // 标识符绑定在这里  
      Arguments: {0: 20, 1: 30, length: 2},  // 函数参数
    },  
    outer: <GlobalLexicalEnvironment>  
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  
      // 标识符绑定在这里  
      g: undefined  // 函数内部变量声明
    },  
    outer: <GlobalLexicalEnvironment>  
  }  
}

变量和函数的声明提升

了解了执行上下文的两个阶段,声明提升的概念就很简单了。

举个例子:

(function() {

    console.log(typeof foo); // 函数指针
    console.log(typeof bar); // undefined

    var bar = function() {
            return 'world';
        };

    function foo() {
        return 'hello';
    }
    
    var foo = 'hello';
    console.log(foo); // hello
}());
  1. 为什么我们能在 foo 声明之前访问它? 创建阶段,变量和函数在创建阶段即被定义,因此可以访问,但是没有具体的值。

  2. foo 被声明了两次,为什么上面 foo 显示为函数而不是 undefined 或字符串?最后显示 foo 又是字符串? 创建阶段函数声明先被创建,并指向该函数在内存中的引用,之后再遇到同名函数声明,则指针重写,遇到同名变量声明,则直接跳过。即函数声明的优先级比较高。 但是函数变量可以被重新赋值,因此 foo = 'hello' 被执行,foo 值改变。

  3. 为什么 bar 的值是 undefined? bar 实际上还是一个变量,但变量的值是函数,变量在创建阶段被创建并被初始化为 undefined。

注意:只有 var 和 function 的声明会提升,let、const、class 等不会。

作用域

作用域就是变量与函数的可访问范围,即作用域控制着变量与函数的可见性和生命周期。

作用域的分类

同样的,作用域也有全局作用域局部作用域之分。

全局作用域包含以下几种情境:

  1. 最外层函数和在最外层函数外面定义的变量拥有全局作用域
  2. 所有末定义直接赋值的变量自动声明为拥有全局作用域
  3. 所有window对象的属性拥有全局作用域

注意:尽量不将变量定义在全局作用域中,否则会污染全局命名空间, 容易引起命名冲突,造成变量污染。

局部作用域又叫做函数作用域,是指声明在函数内部的变量。只能在局部访问。

举个例子:

var a=1;
function fn(){
    var sum=0;
    alert(a); // 1   局部访问全局
}
fn(); // 先执行
alert(sum); // 报错  全局访问局部

作用域和执行上下文的区别

  • 作用域在函数定义时确定,而不是在函数调用时确定;
  • 作用域中没有变量,要通过作用域对应的执行上下文环境来获取变量的值;
  • 同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值;
  • 作用域中变量的值是在执行过程中产生的确定的;

作用域链

JavaScript里一切都是对象,函数也是对象。函数对象和其它对象一样,拥有可以通过代码访问的属性和一系列仅供JavaScript引擎访问的内部属性。其中一个内部属性是[[Scope]],该内部属性包含了函数被创建的作用域中对象的集合,这个集合被称为函数的作用域链,它决定了哪些数据能被函数访问。

即,作用域的集合就是作用域链。

函数在执行的过程中,先从自己内部寻找变量,如果找不到,再从创建当前函数所在的作用域去找,从此往上,也就是向上一级找,直到找到全局作用域。

注意:标识符所在的位置越深,读写速度就会越慢,从这个角度来说,也应尽量少使用全局变量。

最后,举个例子解释一下自由变量创建函数的作用域

var x = 10;
function fn() {
    console.log(x);
}

function show1(f) {
    var x = 20;
    f(); // 10, 而不是20,x 的取值,要到创建 fn 函数的作用域中找,不管在哪调用
}
function show2() {
    var y = 20;
    console.log(x + y); // x 是自由变量,即不在当前作用域中声明的变量叫自由变量
}
show1(fn);

闭包

什么是闭包

闭包的概念:能够读取其他函数内部变量的函数。

举例说明闭包的两种情况:函数作为返回值,函数作为参数传递。

var x = 10;
function fn() {
    var x = 20;
    return function f() {
        console.log(x);
    };
}

var show = fn();
// 执行 fn() 时,返回的是一个函数。函数的特别之处在于可以创建一个独立的作用域。
// 而正巧合的是,返回的这个函数体中,还有一个自由变量 x 要引用 fn 作用域下的上下文环境中的 x。
// 因此到这里 fn() 的执行上下文不被销毁,全局上下文变为活跃状态

show(); // 函数作为返回值,显示 20
var x = 10;
function fn() {
    console.log(x);
}

(function (f) {
    var x = 20;
    f(); 
})(fn); // 函数作为参数传递,x 取值 10,到创建 fn 函数的作用域中找

【总结】 闭包形成的条件:存在函数嵌套,同时内部函数存在对外部函数的引用;闭包的意义和作用:使函数在当前作用域以外依然能够执行。

注意:使用闭包会增加内存开销,或引起内存泄漏

闭包的应用场景

  1. 封装私有变量和方法,避免全局污染
var counter = (function(){
    var privateCounter = 0; // 私有变量
    function change(val){
        privateCounter += val;
    }
    return {
        increment:function(){   // 三个闭包共享一个词法环境
            change(1);
        },
        decrement:function(){
            change(-1);
        },
        value:function(){
            return privateCounter;
        }
    };
})();
//共享的环境创建在一个匿名函数体内,立即执行。
//环境中有一个局部变量一个局部函数,通过匿名函数返回的对象的三个公共函数访问。

console.log(counter.value());//0
counter.increment();
counter.increment();//2
  1. 模拟块级作用域(匿名自执行函数)
function fn(num){
    for(var i=0;i<num;i++){}
    console.log(i);//在for外部i不会失败
}
fn(2);

if(true){
    var a=13;
}
console.log(a);//在if定义的变量在外部可以访问

// 修改后
(function(){  //i在外部就不认识啦
    for(var i=0;i<count;i++){}
})();
console.log(i);  //报错,无法访问

注意:循环使用let定义变量时,每次迭代都会声明,let有自己的块级作用域。

  1. 定时器、事件监听、ajax请求等很多使用了回调函数的地方,实际上就是在使用闭包
// 定时器
function sayHello(name){
    setTimeout(function(){
	console.log('hello '+name);
    },1000);
}
sayHello(‘你好');
// 事件监听
function changeSize(size){
    return function(){
        document.body.style.fontSize = size + 'px';
    };
}

var size12 = changeSize(12);
var size16 = changeSize(16);

document.getElementById('size-12').onclick = size12;
document.getElementById('size-16').onclick = size16;

参考

  1. 了解JavaScript的执行上下文
  2. 【译】理解 Javascript 执行上下文和执行栈
  3. 深入理解JavaScript作用域
  4. 深入理解 javascript 作用域链(Scope Chain)
  5. 深入理解javascript原型和闭包(完结)
  6. 关于JavaScript中的闭包及应用场景
  7. 面试官:谈谈对JS闭包的理解及常见应用场景(闭包的作用)