Skip to main content

JS词法作用域、执行上下文与闭包

2021-05-28|
词法作用域 执行上下文 闭包 作用域链

词法作用域#

  JS 使用的是词法作用域(或称为静态作用域)函数的作用域在定义的时候就决定了,与词法作用域相对的是动态作用域,动态作用域会在运行时确定的。

  一个《JS权威指南》中的例子:

var scope = "global scope";function checkscope(){    var scope = "local scope";    function f(){        return scope;    }    return f();}checkscope();
var scope = "global scope";function checkscope(){    var scope = "local scope";    function f(){        return scope;    }    return f;}checkscope()();

  这也表明了JS 的作用域是静态作用域。

  引用《JavaScript权威指南》的回答就是:

  JavaScript 函数的执行用到了作用域链,这个作用域链是在函数定义的时候创建的。嵌套的函数 f() 定义在这个作用域链里,其中的变量 scope 一定是局部变量,不管何时何地执行函数 f(),这种绑定在执行 f() 时依然有效。

执行上下文与词法环境#

  JS 中有一个执行调用栈和执行上下文的概念,执行栈是一个先进后出的数据结构,JS在执行每个函数的时候都会创建一个函数执行上下文,并将其压入执行栈中,当函数执行完后才将其从执行栈中弹出。执行栈最开始压入的是全局执行上下文,可以看作是最外层的 JS 代码环境。

函数执行上下文是在函数被执行的时候才创建的。

执行上下文的生命周期可以分为两个阶段:

  1. 创建阶段
  2. 执行阶段

一个执行上下文会由几部分组成:

  1. 词法环境(Lexical Environment)
  2. 变量环境(Variable Environment),也是一种词法环境
  3. this 绑定(This Binding)

词法环境 & 变量环境中又包含了:

  1. 环境记录(EnvironmentRecord),一个存储所有局部变量作为其属性的对象。
  2. 指向外部环境的指针(outer),全局环境执行上下文中的 outer 为 null

  词法环境和变量环境保存了函数中定义的变量和函数的标识。

  在执行阶段,这三个部分都会被确定,词法环境和变量环境中的变量会被初始化,直到执行阶段才会被真正赋值。

网上文章中经常看到的变量对象(Variable Object,AO)和活跃对象(Activation Object,AO) 实际上是 ES1/ES3 中的内容,在 ES5 及以后的版本中已经不存在 AO 及一系列相关概念了,取而代之的是一个叫词法环境(Lexical Environment)的定义。

关于词法环境,大可以参阅:

  在 ES6 中,词法环境和变量环境的区别在于 词法环境用于存储函数和使用 letconst 声明的变量,而变量环境仅用于存储使用 var 声明的变量。

  对于函数执行上下文来说,函数传入的参数也会被保存在词法环境中。

举个例子:

let a = 20;  const b = 30;  var c;
function multiply(e, f) {   var g = 20;   return e * f * g;  }
c = multiply(20, 30);

  执行上下文在创建阶段就绑定的函数和变量也就是我们观察到的变量提升/函数提升的原因。

  注意到词法环境和变量环境中变量的初始化分别是 uninitializedundefined,因此对于 letconst 声明的变量,若在赋值之前使用的话会报 Reference Error ,而对于 var 声明的变量则会是 undefined

  而函数的变量提升优先级会高于变量提升,这是因为在执行上下文创建阶段:

  1. 对函数的所有形参(若是函数上下文)
    • 由名称和对应值组成的一个变量对象的属性被创建
    • 若没有实参,属性值设为 undefined
  2. 对函数声明
    • 由名称和对应值(函数对象)组成的一个变量对象的属性被创建
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  3. 对变量声明
    • 由名称和对应值(undefineduninitialized)组成的一个变量对象的属性被创建
    • 对于 var 声明的变量,如果变量名称和已经声明的形式参数或函数相同,则变量声明不会干扰已存在的这类属性;而若是 letconst 声明的对象,若已存在同名对象则会报错。

  可以使用几个例子来进行验证:

  1. 函数提升优先级大于变量提升:
console.log(a)  // 打印函数 a
function a() {}
var a = 3;
  1. 对函数声明,若已存在同名属性则会完全替换:
console.log(a)  // 打印第二个定义的函数 a
function a() {}
function a() {    console.log(a)}
console.log(a)  // 打印第二个定义的函数 a
  1. 对于使用 var 的变量声明,若已存在同名属性,则不会进行干扰:
console.log(a)
function a() {}
var a = 3;var a = 4;
// 代码可以正常运行...

再看一道题:

function foo() {    console.log(a);    a = 1;}
foo(); // ???
function bar() {    a = 1;    console.log(a);}bar(); // ???

  另外,对于这类在函数中没有使用关键字声明的变量,会被设置为全局变量,导致全局环境污染以及内存泄漏:

function bar() {    a = 1;    console.log(a);}bar();
console.log(window.a);  // 1

闭包#

  引用红皮书上对闭包的陈述:

闭包是指有权访问另一个函数作用域中的变量的函数。

  有两个要点:

  • 闭包是函数
  • 它可以访问另一个函数的作用域中的变量

  根据这个定义,其实 JS 中的所有函数都是闭包,不过我们通常说闭包指的是在一个函数中返回另一个函数的情况,这个被返回的函数我们就叫它闭包。

  这种情况下,即使外部函数的调用已结束,但闭包仍能访问到其内部的变量和参数。

来看一个例子:

function makeCounter() {  let count = 0;
  return function() {    return count++;  };}
let counter = makeCounter();

  在这个例子中,counter 就是我们一般所指的闭包,且每次调用 counter,就能获得 count 值,然后 count 会自增。

  在每次 makeCounter() 调用的开始,都会创建一个新的词法环境对象,以存储该 makeCounter 运行时的变量。

  根据我们上面对执行上下文的介绍可以知道,对于这个例子,它有两层嵌套的词法环境:

image-20210528192626093

  当执行 makeCounter 函数时,创建了一个匿名函数,此时这个匿名函数还未运行。

  而所有函数在创建时都会有一个指针指向创建它的外部环境:[[Environment]](也就是我们上面说的 outer),即使 makeCounter 结束,这个引用也仍然存在。

image-20210528192917079

  现在,当 counter() 中的代码查找 count 变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后是外部 makeCounter() 的词法环境,并且在哪里找到就在哪里修改。即在变量所在的词法环境中更新变量。

image-20210528193440752
闭包与垃圾回收机制

  闭包所指向的词法环境能够存在与 JS 引擎的垃圾回收机制也有关系:

  通常,函数调用完成后,会将词法环境和其中的所有变量从内存中删除。因为现在没有任何对它们的引用了。与 JavaScript 中的任何其他对象一样,词法环境仅在可达时才会被保留在内存中。

  但是,如果有一个嵌套的函数在函数结束后仍可达,则它将具有引用词法环境的 [[Environment]] 属性。

闭包在实际开发中的优化

  关于闭包所访问的外部环境中的变量,在实际中,JavaScript 引擎会试图优化它。它们会分析变量的使用情况,如果从代码中可以明显看出有未使用的外部变量,那么就会将其删除。

  因此,在 V8(Chrome,Edge,Opera)中的一个重要的副作用是,此类变量在调试中将不可用。

  具体可看:实际开发中的优化

闭包练习#

  1. Counter 是独立的吗?
function makeCounter() {  let count = 0;
  return function() {    return count++;  };}
let counter = makeCounter();let counter2 = makeCounter();
alert( counter() ); // 0alert( counter() ); // 1
alert( counter2() ); // ?alert( counter2() ); // ?
  1. Counter 对象

  这里通过构造函数创建了一个 counter 对象。

  它能正常工作吗?它会显示什么呢?

function Counter() {  let count = 0;
  this.up = function() {    return ++count;  };  this.down = function() {    return --count;  };}
let counter = new Counter();
alert( counter.up() ); // ?alert( counter.up() ); // ?alert( counter.down() ); // ?
  1. sum

  编写一个像 sum(a)(b) = a+b 这样工作的 sum 函数。

sum(1)(2) = 3sum(5)(-1) = 4
  1. filter 方法

  编写 inBetweeninArray 方法,使得 Array.prototype.filter 能够像下面这样工作:

  • arr.filter(inBetween(3,6)) —— 只挑选范围在 36 的值。
  • arr.filter(inArray([1,2,3])) —— 只挑选与 [1,2,3] 中的元素匹配的元素。
/* .. inBetween 和 inArray 的代码 */let arr = [1, 2, 3, 4, 5, 6, 7];
alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6
alert( arr.filter(inArray([1, 2, 10])) ); // 1,2
  1. 函数数组

  我们想使用一个数组保存一些函数,当调用这些函数时输出我们函数定义时想要保存的值,不过下面的代码出了问题,将它们修复:

function makeArmy() {  let shooters = [];
  let i = 0;  while (i < 10) {    let shooter = function() { // 创建一个 shooter 函数,      alert( i ); // 应该显示其编号    };    shooters.push(shooter); // 将此 shooter 函数添加到数组中    i++;  }
  // 返回函数数组  return shooters;}
let army = makeArmy();
// ……所有的 shooter 显示的都是 10,而不是它们的编号 0, 1, 2, 3...army[0](); // 编号为 0 的 shooter 显示的是 10army[1](); // 编号为 1 的 shooter 显示的是 10army[2](); // 10,其他的也是这样。

作用域链#

  当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

  作用域链的查找方式和原型链有点类似,不同点是:原型链查找若找不到指定的变量,则返回 undefined,而作用域链查找,未找到变量值的话则会报错:ReferenceError

  JS 中使用的是词法作用域,在函数定义的时候(还未执行)就会确定了一个初始的作用域链,然后在函数执行的时候会再次进行更新。

  函数内部有一个属性 [[scope]],用于保存函数所能访问到的作用域,作用域的顶端是全局作用域。

  当函数执行进入执行上下文时,会将当前环境对象加入作用域链顶端。

  用伪代码来表示就是:

Scope = [curentEnv].concat([[scope]]);

  另外,引用一个回答帮助理解:

  在源代码中当你定义(书写)一个函数的时候(并未调用),js引擎也能根据你函数书写的位置,函数嵌套的位置,给你生成一个[[scope]],作为该函数的属性存在(这个属性属于函数的)。即使函数不调用,所以说基于词法作用域(静态作用域)。

  然后进入函数执行阶段,生成执行上下文,执行上下文你可以宏观的看成一个对象,(包含vo,scope,this),此时,执行上下文里的scope和之前属于函数的那个[[scope]]不是同一个,执行上下文里的scope,是在之前函数的[[scope]]的基础上,又新增一个当前的AO对象构成的。

  函数定义时候的[[scope]]和函数执行时候的scope,前者作为函数的属性,后者作为函数执行上下文的属性。

  其中,AO 可以看作是我们上面所说的 词法环境

REF#