【JS】作用域、执行上下文和闭包

343 阅读7分钟

参考

2020年前端面试复习必读精选文章

作用域

概念

作用域是变量和函数在代码运行时的可访问性。譬如,有一套被出租出去、用于合租的房,被划分为公共区域和若干个单间,公共区域可以被所有的租户访问,但各个单间只能被其所属的租户访问。变量和函数亦是,有些能被任意范围的代码访问(如在最外层声明的变量和函数),有些只能被特定范围的代码访问(如在函数内部声明的变量和函数)。

分类

作用域分为以下几类:

  • 全局作用域:拥有全局作用域的变量被称为全局变量
  • 局部作用域:拥有局部作用域的变量被称为局部变量。局部作用域又分为以下 2 类:
    • 函数作用域
    • 块级作用域

适用规则

作用域的可访问性和适用情况见下:

// 【全局作用域】
// 可访问性:能被任意范围的代码访问
// 适用情况:
//   1.在最外层声明的变量和函数,如a和bar
//   2.未经声明就直接赋值的变量,如b
var a = 1;
function bar() {
  b = 2;
  console.log(a, b);
  console.log(b);
}
bar(); // 1, 2
console.log(a); // 1
console.log(b); // 2

// 【函数作用域】
// 可访问性:只能被当前函数内部的代码访问
// 适用情况:在函数内部声明的变量,如bar中的a和b,foo中的a
function bar() {
  var a = 1;
  var b = 2;
  console.log(a, b);
}
function foo() {
  var a = 3;
  console.log(a);
  console.log(b);
}
bar(); // 1, 2
foo(); // 3, b is not defined
console.log(a); // a is not defined
console.log(b); // b is not defined

// 【块级作用域】
// 可访问性:只能被当前代码块内部的代码访问
// 适用情况:在代码块内部,使用const和let声明的变量,如j
for (var i = 0; i < 1; i++) {
  console.log(i);
  console.log(j); // j is not defined
}
for (let j = 0; j < 1; j++) {
  console.log(i);
  console.log(j);
}
console.log(i);
console.log(j); // j is not defined

作用域链

作用域是有层级的:

// 全局作用域 - 最外层
function foo() {
  // foo的函数作用域 - 中间层
  function bar() {
    // bar的函数作用域 - 最内层
  }
}

内层作用域可以访问外层作用域,从内层作用域向外层作用域查找变量过程中形成的链条被称为作用域链。而全局作用域是最外层的作用域,正因如此,全局变量才能被任意范围的代码访问。需要注意的是,如果内层作用域和外层作用域中有同名变量,那么外层作用域中的该变量会被遮蔽掉。具体见下:

// 内层作用域可以访问外层作用域
var a = "global";
function outer() {
  var b = "outer";
  function inner() {
    var c = "inner";
    console.log(a, b, c);
  }
  inner();
}
outer(); // 'global', 'outer', 'inner'

// 内层作用域会遮蔽掉外层作用域的同名变量
var a = "global";
function outer() {
  var a = "outer";
  function inner() {
    var a = "inner";
    console.log(a);
  }
  inner();
  console.log(a);
}
outer(); // 'inner'、'outer'
console.log(a); // 'global'

静态作用域

在 JS 中,一个变量拥有哪类作用域,是在代码书写时就已经确定好了的。以这种方式被确定的作用域被称为静态作用域。与之相对的是动态作用域,即一个变量拥有哪类作用域,得在代码执行时才能被确定。两者区别见下:

var val = "global";
function foo() {
  console.log(val);
}
function bar() {
  var val = "bar";
  foo();
}
bar();
// 如果是静态作用域,会输出'global'
// 如果是动态作用域,会输出'bar'

执行上下文

概念

代码执行时,其所在的环境称之为运行环境。然而,运行环境作为一个抽象概念,需要借由其他事物具象化。譬如,一个地方的自然环境水平,如果用“差”、“一般”或“好”来描述,会显得主观和抽象,但如果借由“人均绿地面积”和“绿地覆盖率”等指标来分析和评估的话,自然环境水平这个概念就变得很清晰了。执行上下文就是这么一种,用来清晰、具象地描述运行环境的事物,其在语法上,可以被看作一个对象。

分类

运行环境分为以下几类:

  • 全局环境。
  • 函数环境。
  • eval函数环境()。

故执行上下文分为以下几类:

  • 全局执行上下文。
  • 函数执行上下文。
  • eval函数执行上下文。

eval 函数环境和 eval 函数执行上下文一般不使用,故下文不做说明。

生命周期和结构

以以下代码为例,对执行上下文的生命周期及其结构进行说明:

ES3 中

var a = 1;
function foo(x, y) {
  var b = a + x + y;
  console.log(b);
}
var c = 2;
var d = function bar() {};
foo(3, 4);

// 1.代码开始执行:
//   进入全局环境,并创建全局执行上下文
var GlobalEC = {
  // 变量对象(VO):
  // 存储当前环境中所有的变量声明,初始值为undefined;
  // 存储函数声明及其内容,而不会存储函数表达式。
  VariableObject: {
    a: undefined,
    foo: f(x, y),
    c: undefined,
    d: undefined,
  },
  // 作用域链:
  // 存储当前环境中的作用域链,
  // 当前仅包含全局作用域GlobalScope,
  // 其指向GlobalEC的活动对象。
  ScopeChain: [GlobalScope],
  // this指向:
  // 存储当前环境的this指向。
  ThisValue: Window,
};
// 2.全局执行上下文创建完成后:
//   进入其执行阶段
GlobalEC = {
  // 活动对象(VO->AO):
  // 随着代码逐行执行,变量们的值被获取、存储到其中,
  // 如执行完'var a = 1;'这一行后,a的值由undefined变为1。
  ActivationObject: {
    a: 1,
    foo: f(x, y),
    c: 2,
    d: f(),
  },
  ScopeChain: [GlobalScope],
  ThisValue: Window,
};
// 3.调用foo:
//   进入foo的函数环境,并创建其对应的函数执行上下文
var FooFunctionEC = {
  // 变量对象(VO):
  // 作为函数执行上下文,其中还存储着参数信息。
  VariableObject: {
    x: 3,
    y: 4,
    arguments: {
      0: 3,
      1: 4,
      callee: f(x, y),
    },
    b: undefined,
  },
  // 作用域链:
  // 首部添加了FooFunctionScope,
  // 因为包含了GlobalScope,所以能访问全局作用域,
  // 如能在"var b = a + x + y;"中访问全局变量a。
  ScopeChain: [FooFunctionScope, GlobalScope],
  ThisValue: Window,
};
// 4.foo的函数执行上下文创建完成后:
//   同理,进入其执行阶段
// 5.foo函数内代码执行完毕后:
//   退出foo的执行环境,同时,进入其函数执行上下文的销毁阶段
// 6.所有代码执行外币后:
//   退出全局环境,同时,进入全局执行上下文的销毁阶段

E5S 中

ES5 中加入了letconst关键字的同时,也对执行上下文的结构进行了调整。ES3 中的变量对象和活动对象被去除,取而代之的是 ES5 中的词法环境组件变量环境组件。具体见下:

// 全局执行上下文
var GlobalEC = {
  // 词法环境组件
  LexicalEnvironment: {
    // 声明式环境记录器:
    // 存储当前环境中的let、const声明,初始值为uninitialized;
    // 存储函数声明及其内容,而不会存储函数表达式。
    EnvironmentRecord: {
      Type: "Object",
      a: uninitialized,
      foo: f(x, y),
    },
    // 外部环境引用:
    // 指向父级词法环境,即null。
    outer: null,
  },
  // 变量环境组件
  VariableEnvironment: {
    // 声明式环境记录器:
    // 存储当前环境中的var声明,初始值为undefined。
    EnvironmentRecord: {
      Type: "Object",
      b: undefined,
    },
    // 外部环境引用:
    // 指向父级变量环境,即null。
    outer: null,
  },
  // This指向:
  // 同ES3中的ThisValue。
  ThisBinding: "",
};
// 函数执行上下文
var FunctionEC = {
  // 词法环境组件
  LexicalEnvironment: {
    // 声明式环境记录器:
    // 同全局执行上下文,但Type值不同,且存储着参数信息。
    EnvironmentRecord: {
      Type: "Declarative",
      x: 3,
      y: 4,
      arguments: {
        0: 3,
        1: 4,
        callee: f(x, y),
      },
    },
    // 外部环境引用:
    // 指向父级词法环境,如GlobalEC.LexicalEnvironment。
    outer: GlobalEC.LexicalEnvironment,
  },
  // 变量环境组件
  VariableEnvironment: {
    // 声明式环境记录器:
    // 同全局执行上下文,但Type值不同。
    EnvironmentRecord: {
      Type: "Declarative",
      b: undefined,
    },
    // 外部环境引用:
    // 指向父级变量环境,如GlobalEC.VariableEnvironment。
    outer: GlobalEC.VariableEnvironment,
  },
  ThisBinding: "",
};
// 随后,在代码逐行执行时分配变量值

总结

执行上下文的生命周期:

  • 创建阶段:进入执行环境后。
  • 执行阶段:当前环境中的代码执行时。
  • 销毁阶段:当前环境中的代码执行完毕后、退出当前环境后

根据执行上下文的结构,可以知晓:

  • 函数和var变量提升发生在执行上下文的创建阶段。
  • 函数表达式、constlet变量不存在变量提升。
  • 执行上下文是实现作用域机制的媒介(ES3 中的AO,ES5 中的outer)。

调用栈

调用函数后,会创建该函数的函数执行上下文。因为可以在一个函数中调用另一个函数,所以可能在调用某个函数后,同时存在多个执行上下文。JS 使用栈来对这些执行上下文进行管理,这个栈被称为执行栈。在代码开始执行时,全局执行上下文入栈,在调用函数时,函数的函数执行上下文入栈,在函数执行完毕后出栈,在所有代码执行完毕时,全局执行上下文出栈。因此,执行栈中,栈底为全局执行上下文,栈顶为当前执行上下文。

闭包

概念

对于闭包,有一种常见的说法:声明在一个函数中的函数叫做闭包。这种说法浮于表面,要解释闭包,离不开作用域和执行上下文。实际上,函数和对其外部作用域的引用,捆绑在一起才构成闭包。通过闭包,能访问到函数外部作用域中的变量。

原理

闭包的原理见下:

// 在全局环境中调用f,却能访问到:
// 1.bar作用域中的c
// 2.foo作用域中的a、b
function foo(a) {
  var b = 0;
  return function bar() {
    var c = 5;
    b++;
    return a + b + c;
  };
}
var f = foo(10);
f(); // 16
f(); // 17

// 第1点是因为:
// 1.f指向bar,调用f,实际上就是调用bar
// 2.调用bar,访问bar的作用域,所以能访问到c
// 第2点是因为:
// 1.因为f保持了对bar的引用,而bar的作用域链上又包含foo的作用域,即包含foo的AO/词法环境/变量环境
// 2.调用f时,通过访问bar的作用域,间接地访问foo的作用域,所以能访问到a、b

带来问题

闭包会保持对外部作用域的引用,使其无法被回收,造成资源浪费。可以通过解除对闭包函数的引用,以释放内存。