彻底理解 JavaScript 执行上下文

blueice
发布于 2020-10-13 11:22
浏览
0收藏

彻底理解 JavaScript 执行上下文-鸿蒙开发者社区前面我们已经学习了JavaScript的事件循环机制,了解了一段代码是如何被JavaScript引擎执行的,这是粒度最粗的执行单位。接下来,我们开始学习粒度较小的单位:函数的执行机制,以及和函数执行过程相关的所有问题。

 

闭包closure

 

在计算机领域,闭包closure有三个完全不同的意义:

  1. 在编译原理中,它是处理语法产生式的一个步骤;
  2. 在计算几何中,它表示包裹平面点集的凸多边形;
  3. 在编程语言领域,它表示一种特殊的函数。

 

上个世界60年代,主流编程语言是基于lambda演算的函数式编程语言,所以最初的闭包定义,使用了大量的函数式术语。一个比较模糊的定义是“带有一系列信息的λ表达式”。其实,λ表达式就是函数。

 

我们可以这样简单理解一下,闭包其实只是一个绑定了执行环境的函数,这个函数并不是印在书本里的一条简单的表达式,闭包与普通函数的区别是,它携带了执行的环境,就像人在外星中需要自带吸氧的装备一样,这个函数也带有在程序中生存的环境。

普通的函数就是一系列表达式的集合,只要给定参数,就会得到确定的结果。而闭包还可以携带大量上下文信息,这函数的执行有时候很难理解,但这也是闭包之所以强大的地方。

 

最初的闭包定义中,包含两部分内容:

  • 环境部分
     - 环境

 - 标识符列表

  • 表达式部分

 

这个定义对应到JavaScript标准中,则是:

  • 环境部分
     - 环境:函数的词法环境(执行上下文的一部分)

 - 标识符列表:函数中用到的未声明变量

  • 表达式部分:函数体

我们可以认为,JavaScript函数完全符合闭包的定义。它的环境部分是由函数词法环境部分组成,标识符列表是函数中用到的未声明变量,表达式部分就是函数体。

 

变量作用域

 

每个编程语言中都有作用域这个概念,JavaScript也不例外。

 

JavaScript中的作用域无非两种:全局变量和局部变量,函数内部可以直接读取全局变量,而函数外部无法获取内部的局部变量。这里有一个地方需要注意,函数内部声明变量的时候,一定要使用var命令。如果不用的话,你实际上声明了一个全局变量!

    function f1(){
       n = 999;
    }

    f1();

    alert(n); // 999

 

如果非要在函数外部获取函数内的局部变量,通过在函数里面定义一个函数,将要返回的局部变量返回,再返回新定义的函数也能实现。

 

  function f1(){

    var n=999;

    function f2(){
      alert(n);
    }

    return f2;

  }

  var result=f1();

  result(); // 999

 

闭包作用

闭包的作用主要有两个,一个是前面说的读取函数内部的局部变量,另一个是让某些变量始终保存在内存中。

  function f1(){

    var n=999;

    nAdd = function(){n+=1}   //没有使用var关键字,所以定义了一个全局变量

    function f2(){
      alert(n);
    }

    return f2;

  }

  var result=f1();

  result(); // 999

  nAdd();

  result(); // 1000

 

在上面的代码中,f1运行之后,就会将局部变量n保存在内存中,同时在全局作用域中加入nAdd,运行nAdd保存在内存中的局部变量n就会被加1,从而得到1000。

 

执行上下文

执行一段JavaScript代码,不光需要全局变量和局部变量,还需要处理this、with等特殊语法,这些信息让JavaScript代码的执行变得更加复杂。

 

JavaScript标准把一段代码,执行所需要的一切信息定义为“执行上下文”。

 

版本演变


执行上下文的定义经历了多个版本的演变。

 

ES3


执行上下文在ES3中,包含三个部分。

 

  • scope:作用域,也常常被叫做作用域链。
  • variable object:变量对象,用于存储变量的对象。
  • this value:this值。

 

ES5


在ES5中,改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。

 

  • lexical environment:词法环境,当获取变量时使用。
  • variable environment:变量环境,当声明变量时使用。
  • this value:this值。


ES2018

 

在ES2018中,执行上下文又变成了这个样子,this值被归入lexical environment,但是增加了不少内容。

 

  • lexical environment:词法环境,当获取变量或者this值时使用。
  • variable environment:变量环境,当声明变量时使用
  • code evaluation state:用于恢复代码执行位置。
  • Function:执行的任务是函数时使用,表示正在被执行的函数。
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
  • Realm:使用的基础库和内置对象实例。
  • Generator:仅生成器上下文有这个属性,表示当前生成器。

 

如果从实现语言的角度去分析这些内容,这些内容确实不容易理解。但从实际代码出发,去一步一步分析在代码执行过程中,需要哪些信息,然后再思考,有助于理解这些内容。比如这些代码:

    var b = {}
    let c = 1
    this.a = 2;

要执行它,我们需要知道以下信息:

 

  1. var 把 b 声明到哪里;
  2. b 表示哪个变量;
  3. b 的原型是哪个对象;
  4. let 把 c 声明到哪里;
  5. this 指向哪个对象。

 

var

var是用于声明变量的关键字,它最大的缺陷就是会穿透当前作用域,让变量跑到到上层作用域中。例如下面的代码:

 

    if (true) {
        var test = true;
    }

    alert(test); // true

    for (var i = 0; i < 10; i++) {
        console.log(i)
    }

    alert(i);   // 10

 

所以,var会穿透if、for等代码块,进入更上层的作用域。但是,如果在function内定义的变量,则不会影响函数外部。

 

    function sayHi() {
        if (true) {
            var phrase = "Hello";
        }

        alert(phrase); // works
    }

    sayHi();
    alert(phrase); // Error: phrase is not defined

 

针对var的这种问题,在没有let的时代,用立即执行的函数表达式(IIFE),通过创建一个函数,并立即执行,就像上面的例子一样,可以完美解决变量提升的缺陷。

 

    (function() {
        var message = "Hello";
        alert(message); // Hello
    })();

 

let

let是从ES6开始引入的新的变量声明方式,比起var的诸多弊病,let做了非常明确的梳理和规定。

 

为了实现let,JavaScript在运行时引入了块级作用域。以下语句中,都会产生let使用的作用域:

  • for
  • if
  • switch
  • try/catch/finally

 

另外,如果用let重新变量,会报错。如下所示:

let user;
let user; // Uncaught SyntaxError: Identifier 'user' has already been declared

 

为了减少不必要的麻烦,建议使用多使用 let,甚至全用 let 声明变量。

 

函数Function

执行上下文是JavaScript代码执行所需要的一切信息。也就是说,一段代码的执行结果依赖于执行上下文的内容,如果执行上下文不一样了,相同的代码很可能产生不同的结果。在JavaScript中,切换执行上下文最重要的场景就在函数调用。下面,我们先来认识一下,JavaScript中一共有多少种函数。

 

函数类型


普通函数

 

普通函数是用function关键字定义的函数。

    function foo() {
        // code
    }

 

箭头函数

箭头函数使用 => 运算符定义的函数。

const foo = () => {
    // code
}

 

class中定义的函数

class中定义的函数,也就是类的访问器属性。

    class C {
        foo(){
            // code
        }
    }

 

生成器函数

用 function * 定义的函数。

    function foo*(){
        // code
    }

 

类(构造器)


用class定义的类,实际上也是函数。

    class Foo {
        constructor(){
            // code
        }
    }

 

异步函数

普通函数、箭头函数、生成器函数加上async关键字。

    async function foo(){
        // code
    }
    const foo = async () => {
        // code
    }
    async function foo*(){
        // code
    }

 

总共8种函数类型,它们的执行上下文,对于普通变量没有什么特殊之处,都是遵循了“继承定义时环境”的规则,主要差异来自 this 关键字。

 

this


普通函数的this


 this 关键字是JavaScript执行上下文中非常重要的一个组成部分,同一个函数调用方式不同,得到的this值也不同。例如下面这段代码:

    function showThis(){
        console.log(this);
    }

    var o = {
        showThis: showThis
    }

    showThis();     // global

    o.showThis();   // o

 

对此现象,一般认为普通函数的 this 指向函数运行所在的环境。例如,在上面的例子中,showThis()运行在全局环境中,而 o.showThis() 运行在 o 这一对象中,因此才得出这样的结果。

 

 更准确的理解是,this 是由调用它所使用的引用决定的。

 

 我们获取函数的表达式,实际上返回的并非函数本身,而是一个Reference类型(JavaScript七种标准类型之一)。

 

 Reference类型由两部分组成:一个对象和一个属性值。在上面的例子中,showThis() 产生的Reference类型便是全局对象global 或者 window,和属性showThis构成;o.showThis() 产生的Reference类型又是对象o和属性 showThis 构成。

 

所以,this 的真正含义:调用函数时使用的引用Reference,决定了函数执行时刻的 this 值。

 

箭头函数的this

把上面的函数改成箭头函数,执行之后发现不管用什么调用,它的值都不变。

		var showThis = () => {
        console.log(this);
    }

    var o = {
        showThis: showThis
    }

    showThis();     // global

    o.showThis();   // global

    var o = {}

    o.foo = function foo(){
        console.log(this);
        return () => {
            console.log(this);
            return () => console.log(this);
        }
    }

    o.foo()()();    // o, o, o

 

访问器属性的this

    class C {
        showThis() {
            console.log(this);
        }
    }
    var o = new C();
    var showThis = o.showThis;

    showThis();     // undefined
    o.showThis();   // o

 

在类中的“方法”,结果又不太一样,使用showThis这个引用去调用方法时,得到了undefined,在对象上调用得到对象本身。

 

按照上面的方法,可以验证得出:生成器函数、异步生成器函数和异步普通函数跟普通函数行为是一致的,异步箭头函数与箭头函数行为是一致的。

 

this的机制

 

如上文所示,函数不但能够记住定义时的变量,而且还能记住this。

 

为什么能记住这些值?实际上,JavaScript标准中,为函数规定了用于保存定义时上下文信息的私有属性[[Environment]]。当一个函数执行时,会创建一条执行环境记录,记录的外层词法环境会被设置为函数的[[Environment]]。这就是在切换执行上下文。

 

JavaScript用一个栈来管理执行上下文,这个栈中的每一项又包含一个链表。如下图所示:

彻底理解 JavaScript 执行上下文-鸿蒙开发者社区

调用函数时,会入栈一个新的执行上下文;调用结束时,此执行上下文会被弹出栈。

 

 this 是一个更为复杂的机制,JavaScript标准定义了 [[thisMode]] 私有属性。它有三个取值:

  • lexical:表示从上下文中找this,这对应了箭头函数。
  • global:表示当this为undefined时,取全局对象,对应了普通函数。
  • strict:当严格模式时使用,this严格按照调用时传入的值,可能为null或者undefined。

 

在上面的代码中,对象方法和普通函数的 this 有差异,就是因为class被设计为默认按照strict模式执行。

 

上面说函数执行时,会创建一条新的环境记录,将外层词法环境设置为函数的[[Environment]]。除此之外,还会根据this关键字的[[thisMode]]来标记此记录的[[ThisBindingStatus]]私有属性。

 

当代码执行遇见this关键字时,会逐层检查当前环境中的[[ThisBindingStatus]],当找到有this的环境记录时,便可获取当前的this值。

 

这种规则的导致的结果就是,嵌套的箭头函数中的this都指向外层this值。

    var o = {}
    o.foo = function foo(){
        console.log(this);
        return () => {
            console.log(this);
            return () => console.log(this);
        }
    }

    o.foo()()(); // o, o, o

 

操作this的内置函数

JavaScript提供了两个函数:Function.prototype.call 和 Function.prototype.apply 可以指定函数调用时传入的this值。

    function foo(a, b, c){
        console.log(this);
        console.log(a, b, c);
    }
    foo.call({}, 1, 2, 3);
    foo.apply({}, [1, 2, 3]);

 

call 和 apply 的作用是一样的,只是传参的方式不一样。前者传入分开的参数,后者传入一个参数数组。

 

此外,还有 Function.prototype.bind,它可以生成一个绑定过的函数。

 

总结

要想彻底理解JavaScript的代码执行机制,就必须理解执行上下文、this、闭包和函数,它们共同构成JavaScript最常用的代码执行单元。

 

  • 执行上下文:一段代码执行所需要的所有信息,包括变量、this等,经历了多个版本的更迭,内容越来越丰富;
  • 函数:一段逻辑上相关的代码组织形式,是最基本的代码执行单元,JavaScript 其中总共有8种函数;
  • this:指向函数运行所在的环境,由调用它所使用的的引用Reference决定,里面有更加复杂的内在机制;
  • 闭包:其实就是绑定了执行环境信息的函数,这让函数的执行变得复杂,同时也是强大的原因。

 

 

 

 

 

版权声明: 本文为 InfoQ 作者【Walker】的原创文章。

 

 

分类
收藏
回复
举报
回复
    相关推荐