🗒️函数的执行过程

整理的笔记,来源详见文末

1. 闭包

闭包,closure,在编程语言领域它表示一种函数。闭包,其实只是一个绑定了执行环境的函数

1964 年的《The Computer Journal》里第一次提到闭包,它指出闭包包含两部分:环境部分(环境、标识符列表)和表达式部分。JavaScript 中的函数完全符合闭包的定义:

  1. 环境部分

    • 环境 ≈ 函数词法环境

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

  2. 表达式部分 ≈ 函数体

和普通函数相比,JavaScript 函数的主要复杂性来自于它所携带的环境部分。

2. 执行上下文

发展到今天, JavaScript 所定义的环境部分,已经比当初的经典定义复杂了很多,比如还需处理 this、变量声明、with 等一系列复杂语法。所以在 JavaScript 的设计中,词法环境只是执行上下文的一部分。

JavaScript 标准把一段代码(包括函数)执行所需要的所有信息定义为执行上下文

发展
执行上下文

ES3

  1. scope, 作用域, 又称作用域链

  2. variable object, 变量对象, 用于存储变量的对象

  3. this value, this

ES5

  1. lexical environment, 词法环境, 获取变量时用

  2. variable environment, 变量环境, 声明变量时用

  3. this value, this 值

ES2018

  1. lexical environment, 词法环境, 获取变量或 this 值时用

  2. variable environment, 变量环境, 声明变量时用

  3. code evaluation state, 用于恢复代码执行位置

  4. Function, 执行的任务是函数时用, 表示正在被执行的函数

  5. ScriptOrModule, 执行的任务是脚本或者模块时使用, 表示正在被执行的代码

  6. Realm, 使用的基础库和内置对象实例

  7. Generator, 仅生成器有该属性, 表示当前生成器

通过代码,来分析函数的执行过程中需要哪些信息,以及它们对应着执行上下文中的哪些部分。

var b = {};
let c = 1;
this.a = 2;
  1. var 把 b 声明到哪里?b 表示哪个变量?b 的原型是哪个对象

  2. let 把 c 声明到哪里?

  3. this 指向哪个对象?

这些信息就需要执行上下文来给出了,这段代码出现在不同的位置,甚至在每次执行中,会关联到不同的执行上下文,所以,同样的代码会产生不一样的行为。

2.1 词法作用域

var 声明的作用域是:作用域函数执行的作用域。在还没有 let 时,有个技巧就是立即执行的函数表达式(IIFE),即通过创建一个函数并立即执行,来构造一个新的域,从而控制 var 的范围。

// 加分号是为了防止如果上一行代码不写分号,那()就会被解释成上一行代码最末的函数调用
;(function(){
    var a;
}());

;(function(){
    var a;
})();

// 更推荐用 void 关键字,既能避免语法问题,又具有合适的语义(忽略后面表达式的值)
void function(){ // void 任何表达式都会返回 undefined
    var a;
}();

需要注意的是,当使用 with 的时候,代码如下:

var b;
void function(){
    var env = {b:1};
    b = 2;
    console.log("In function b:", b);
    with(env) {
        var b = 3;
        console.log("In with b:", b);
    }
}();
console.log("Global b:", b);

with 内的 var b 作用到了 function 这个环境当中。一句对两个域产生了作用,从语言的角度是个非常糟糕的设计,这也是一些人坚定地反对在任何场景下使用 with 的原因之一。

2.2 变量作用域

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

  1. for

  2. if

  3. switch

  4. try/catch/finally

  5. {}

也就是说,在 let 出现之前,JavaScript 的 if、for 等语句皆不产生作用域。

2.3 Realm

在 ES2016 之前的版本中,标准中甚少提及 {} 的原型问题。但在实际的前端开发中,通过 iframe 等方式创建多 window 环境并非罕见的操作,所以,这才促成了新概念 Realm 的引入。

Realm 中包含一组完整的内置对象,而且是复制关系。

对不同 Realm 中的对象操作,会有一些需要格外注意的问题,比如 instanceOf 几乎是失效的。比如在浏览器环境中获取来自两个 Realm 的对象,它们跟本土的 Object 做 instanceOf 时会产生差异:

var iframe = document.createElement('iframe')
document.documentElement.appendChild(iframe)
iframe.src="javascript:var b = {};"

var b1 = iframe.contentWindow.b;
var b2 = {};

console.log(typeof b1, typeof b2); //object object

console.log(b1 instanceof Object, b2 instanceof Object); //false true

可以看到,由于 b1 和 b2 由同样的代码 {} 在不同的 Realm 中执行,所以表现出了不同的行为。

3. 函数调用

一旦执行上下文被切换,整个语句的效果可能都会发生改变。那么,切换上下文的时机就显得非常重要了。在 JavaScript,切换上下文最主要的场景是函数调用。

函数家族:

  1. 普通函数:用 function 关键字定义

  2. 箭头函数:用 => 运算符定义

  3. 方法:在 class 中定义的函数

  4. 生成器函数:用 function * 定义的

  5. 类:用 class 定义的类实际上也是函数

  6. 异步函数:async + 普通函数/箭头函数/生成器函数

对普通变量而言,这些函数没有本质区别,都遵循了“继承定义时环境”的规则。它们的一个行为差异在于 this 关键字。this 是执行上下文中很重要的一个组成部分。

通过 new 调用以上函数(只有普通函数和类可以),和直接调用 this 值还是有明显区别的。

3.1 this 的行为

3.1.1 普通函数

普通函数的 this 值由“调用它所使用的引用”决定。当我们获取函数的表达式时,它实际上返回的并不是函数本身,而是一个 Reference 类型,它由两部分组成:一个对象和一个属性值。比如 o.showThis 产生的 Reference 类型,就是由对象 o 和属性 showThis 构成的。

当做一些算术运算或其他运算时,Reference 类型会被解引用,即获取真正的值(被引用的内容)来参与运算,而类似函数调用、delete 等操作,都需要用到 Reference 类型中的对象。

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

var o = {
    showThis: showThis
}
// 分别使用两个引用来调用同一个函数,得到的 this 值会不同
showThis(); // global
o.showThis(); // o

在上面的例子中,Reference 类型中的对象被当作 this 值,传入了执行函数时的上下文当中。也就是说,调用函数时使用的引用,决定了函数执行时刻的 this 值。

实际上,从运行时的角度来看,this 和面向对象毫无关联,它是和函数调用时使用的表达式相关的。这个设计来自 JavaScript 早年,通过这样的方式巧妙地模仿了 Java 的语法,但是 JavaScript 仍然保持了纯粹的“无类”运行时设施。

3.1.2 箭头函数

如果,我们把上面的例子换成箭头函数,结果就不一样了:

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

var o = {
    showThis: showThis
}

showThis(); // global
o.showThis(); // global

改为箭头函数后,不论用什么引用来调用它,都不影响它的 this 值。

3.1.3 类的方法

我们把对象的方法赋值给一个变量,当用该变量去调用方法时,this 的值是 undefined。

class C {
    showThis() {
        console.log(this);
    }
}
var o = new C();
var showThis = o.showThis; // 把对象 o 的方法赋值给了变量 showThis

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

3.1.4 其它

用上面的方法继续验证,可以知道:

  • 生成器函数、异步生成器函数和异步普通函数跟普通函数行为是一致的

  • 异步箭头函数与箭头函数行为是一致的

3.2 this 的机制

函数能够引用定义时的变量,也能记住定义时的 this,因此函数内部必定有一个机制来保存这些信息。在 JavaScript 标准中,为函数规定了用来保存定义时上下文的私有属性 [[Environment]]

函数在定义时会创建一个定义时的环境记录(词法环境),在执行时会创建一个新的执行环境记录(执行时的词法环境)。在执行的同时,会把记录的外层词法环境(outer lexical environment)设置成函数的执行环境,这个动作就是切换上下文了。

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

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

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

  1. lexical:表示从上下文中找 this。比如箭头函数

  2. global:表示当 this 为 undefined 时,就取全局对象。比如普通函数

  3. strict:当严格模式时,this 会严格按照调用时传入的值,可能为 null 或者 undefined。比如类的方法,class 默认按 strict 模式执行

"use strict"
function showThis(){
    console.log(this);
}

var o = {
    showThis: showThis
}

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

函数创建新的执行上下文中的词法环境记录时,会根据 [[thisMode]] 来标记新纪录的 [[ThisBindingStatus]] 私有属性。代码执行遇到 this 时,会逐层检查当前词法环境记录中的 [[ThisBindingStatus]],当找到有 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

3.3 操作 this 的内置函数

JavaScript 还提供了一系列函数的内置方法来操纵 this 值。

Function.prototype.call 和 Function.prototype.apply 可以指定函数调用时传入的 this 值,比如:

function foo(a, b, c){
    console.log(this);
    console.log(a, b, c);
}
// 这里 call 和 apply 作用是一样的,只是传参方式有区别
foo.call({}, 1, 2, 3);
foo.apply({}, [1, 2, 3]);

此外,还有 Function.prototype.bind 它可以生成一个绑定过的函数,这个函数的 this 值固定了参数:

function foo(a, b, c){
    console.log(this);
    console.log(a, b, c);
}
foo.bind({}, 1, 2, 3)();

call、bind 和 apply 用于不接受 this 的函数类型,比如箭头函数、class,此时它们无法实现改变 this 的能力,但是可以实现传参。

4. 参考

Last updated