前端需要了解的闭包和js幕后工作原理

539 阅读34分钟

一 闭包

先搬一下mdn官方对闭包的定义:

闭包(closure)是一个函数以及其捆绑的周边环境状态的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。

比如下面代码就是个简单的闭包:

var f;//创建一个全局变量
function foo(params) {
//创建了两个超大的数组
let arr = new Array(10000000).fill('/../mndffj/.hj')
let arr2 = new Array(10000000).fill('/../mndffj/.hj')
return function (k) {
  arr.push('1')
}
}
setTimeout(() => {
f = foo()
}, 3000);
setTimeout(() => {
f = null
}, 6000);

正是因为关于上面的代码在编译执行过程有对下面两个问题的好奇,所以写了这篇文章:

问题1: js何时给变量arr和arr2分配内存的

问题2: arr指向的数组何时被gc回收掉?arr2又是何时。

实践是检验真理的唯一标准,带着这两个问题,通过浏览器控制台performence面板可以直观看到页面栈内存实时变化情况。好奇的bro一起自己动手来看下

image.png

tips: 录制的时候最好多等一会,虽然第二个定时器才六秒,但是gc回收是周期回收 每个周期多长时间不确定,有时候会几秒 十几秒 几十秒 没想象中那么快。 我想可能跟两个大数组需要的内存80M左右新生代存不下(通常1~8M左右),老生代区用的标记清除算法标记比较耗时间有关系,这个不是目前的重点,暂不深究。

下面看录制的栈内存曲线图:

image.png

上图可以直观的看到一些信息:红色箭头指向的定时器执行时,绿色箭头这时候定时器调用 foo()执行内存暴涨,耗时162.4毫秒,然后内存状态保持一段时间后直线跌落回初始状态 。

分析可知道:

1.第一个内存平地起高楼的地方正是第一个定时器调用 foo函数执行的时候,这时候其实第一个问题的答案就是:foo执行会触发分配内存arr和arr2的内存。

2.另外细心点能发现暴涨的曲线有两个突兀的折点。第一个折点其实是刚开辟完arr的内存,正准备开启arr2的内存。第二个折点猜测是定时器调用完回收了定时器上下文。

3.这个水平线持续了很长时间 说明arr和arr2数组在foo()执行后内存是一直常驻的.

再看下内存断崖暴跌的这个点

image.png

可看到在七秒左右就执行了第二个定时器 ,给f赋值null。只是在13秒左右才回收掉垃圾。 这个点内存是从80701132直线下降到六百多k也就是foo函数执行之前的状态

所以 对于第二个问题答案是:在f=null后 arr和arr2的内存一起被回收掉了

闭包执行过程中 浏览器内存变化的过程我们清楚了,但是为什么arr2在retrun的function中没有用到却也没被释放?关于闭包关于作用域这需要了解How JS Works Behind The Scenes

涉及关键词: 词法环境、 执行上下文、 执行栈、 可执行代码、 作用域链、 this、 MemberExpression、 Reference

二 js是如何执行的

JS是一种解释型语言,一段js代码的执行过程中,前几微秒先是会进行编译阶段,js引擎会将代码进行编译,再进入执行阶段

关于V8引擎

我们先了解一下V8中JS编译执行过程,同时采用了解释执行和编译执行这两种方式,也就是在运行时进行编译,这种方式称为JIT (Just in Time) 即时编译。V8在执行JavaScript源码时,会先通过解析器将源码解析成AST,解释器会将AST转化为字节码,一边解释一遍执行。

解释器同时会记录某一代码片段的执行次数,如果执行次数超过了某个阈值,这段代码便会被标记为热代码(Hot Code),同时将运行信息反馈给优化编译器TurboFan,TurboFan根据反馈信息,会优化并编译字节码,最后生成优化的机器码。

image.png

1 浏览器Blink将js代码交给V8引擎,

2 Stream获取到源码并且进行编码转换;

3 Scanner进行词法分析 将字符流转换成tokens并放入缓存。

Scanner的V8官方文档:v8.dev/blog/scanne…

维基百科对词法分析定义:en.wikipedia.org/wiki/Lexica…

image.png 举个例子,比如 var a = 1; 这行代码,经过词法分析后的 tokens 就是下面这样:

[    {        "type": "Keyword",        "value": "var"    },    {        "type": "Identifier",        "value": "a"    },    {        "type": "Punctuator",        "value": "="    },    {        "type": "Numeric",        "value": "1"    },    {        "type": "Punctuator",        "value": ";"    }]

4 经过Parser和PreParser输出AST(抽象语法树):

Parse会将JavaScript代码转换成AST(抽象语法树)

这个过程涉及延迟解析,并不是所有 Js 都需要在初始化时就被执行,因此也不需要在初始化时就解析所有的 Js!因为编译 Js 会带来三个成本问题:

  1. 编译不必要的代码会占用 CPU 资源。
  2. 在 GC 前会占用不必要的内存空间。
  3. 编译后的代码会缓存在磁盘,占用磁盘空间。

因此所有主流浏览器都实现了 Lazy Parsing(延迟解析),它会将不必要的函数进行预解析,也就是只解析出外部函数需要的内容,而全量解析在调用这个函数时才发生。

进行预解析时,只快速查看内部函数是否引用了外部的变量、验证函数语法是否有效、解析函数声明、确定函数作用域,预解析不生成 ast,不生成作用域,这种执行速度非常快这项工作由Pre-Parser预解析器完成。 tips:eval 没办法提前解析,会造成将栈中的数据复制到堆中的情况,这种情况效率低下

5 Ignition是一个解释器,会将AST转换成ByteCode(字节码) 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);如果函数只调用一次,Ignition会执行解释执行ByteCode;一开始的 V8 是会直接将 AST 转位机器码。但是随着 Chrome 在手机上的普及,特别是运行在 512M 内存的手机上,内存占用问题就会暴露明显。V8需要消耗大量的内存来存放转换后的机器吗。 V8 进行了大幅度的重构了引擎架构,引入字节码,并且抛弃了之前的编译器。实现了现在的架构。字节码:就是介于 AST 和机器码之间的一种代码。字节码需要通过解释器将其转为机器码后才能执行

Ignition的V8官方文档:v8.dev/blog/igniti…

6 TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码; 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能;但是机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;关于这里有机会可以写个例子自己测下。 TurboFan的V8官方文档:v8.dev/blog/turbof…

关于v8看个例子:

function foo(a, b) {
    var res = a + b;
    return res;
}

var a = 1;
var c = 2;
foo(1, 2);

由于 Scanner 是按字节流从上往下一行行读取代码的,所以 V8 解析器也是从上往下解析代码。当 V8 解析器遇到函数声明 foo 时,发现它不是立即执行,所以会用 Pre-Parser 解析器对其预解析,过程中只会解析函数声明,不会解析函数内部代码,不会为函数内部代码生成 AST

然后 Ignition 解释器会把 AST 编译为字节码并执行,解释器会按照自上而下的顺序执行代码,先执行 var a = 1;  和 var a = 2; 两个赋值表达式,然后执行函数调用 foo(1, 2) ,这时 Parser 解析器才会继续解析函数内的代码、生成 AST,再交给 Ignition 解释器编译执行。

关于v8引擎的东西过于多了还有比如脚本流 字节码缓存 饥饿解析 函数优化 对象优化 垃圾回收等等

先浅尝辄止 回头继续看上层一些的js的东西

关于js层面

(注:以下讲述的大多基于在v8 parser解析这个环节 上下文 词法环境等相关衍生的东西 ,下面的东西大多也是基于es5版本在讲,按照 选新不选旧 原则,本文应该以 ES2022 为切入点展开,最次也要 ES2018,但网上主流的解释执行上下文甚至都以 ES1/ES3为例,想想还是选择了5为基础渐进增强)

首先都知道js的作用域是词法作用域(静态作用域),而其中词法环境( Lexical Environment)正是js作用域的实现机制。

对于 词法环境 官方的定义: Lexical Environment is a specification type used to define the association of Identifiers to specific variables and functions based upon the lexical nesting structure of ECMAScript code. A Lexical Environment consists of an Environment Record and a possibly null reference to an outer Lexical Environment. Usually a Lexical Environment is associated with some specific syntactic structure of ECMAScript code such as a FunctionDeclaration, a WithStatement, or a Catch clause of a TryStatement and a new Lexical Environment is created each time such code is evaluated. 词法 环境是一种规范类型,用于 根据 ECMAScript 代码的词法嵌套结构定义标识符与特定变量和函数的关联。词法环境由 环境记录和对外部词法环境的可能为空的引用组成 。通常,词法环境与 ECMAScript 代码的某些特定句法结构相关联,例如 FunctionDeclaration 、 WithStatement或TryStatement的Catch 子句, 并且每次评估此类代码时都会创建一个新的词法环境。

中国话讲词法环境就是每当一个函数被调用时, 都会为该函数创建一个新的上下文,在JavaScript 引擎创建一个执行上下文时,创建的用来存储变量和函数声明的环境,它可以使代码在执行期间,访问到存储在其内部的变量和函数,而当函数完成时,它的执行上下文将从堆栈中删除,但它的词法环境就不一定了,可能会从内存中删除,也可能不会从内存中删除,这取决于该词法环境是否被其外部词法环境属性中的任何其他词法环境引用。典型的例子就是闭包

上面提到的v8过程就是在第三步sacne词法分析 第四步解析生成AST。就是这个阶段创建的词法环境。你可以先把执行上下文、词法环境就单纯当成对象,AST抽象语法树其实只也是个对象,第三步做词法分析做扫描、评估将源代码拆分为token 第四步生成名叫ast的对象,进行语法分析生成AST。有了AST之后就会生成执行上下文。

另外网上文章常常会看到es1/3的AV VO GO 这种活跃对象 变量对象 全局对象的概念,不过在es202+的年代 ECMAScript3这个1999年的规范过于过时了。在 ES5 及之后的 ES 版本,已经不存在活跃对象(AO)及一系列周边内容的概念了。被词法环境取而代之。

先简述下js大致流程

JavaScript 可执行代码类型有四种:

  • global code:整个js文件。
  • function code:函数代码。
  • module:模块代码
  • eval code:放在eval的代码。

当 JavaScript 代码执行一段可执行代码(executable code)时,JavaScript引擎先会对其进行编译,并创建对应的执行上下文(execution context)。此外在js解释器运行阶段还会维护一个调用栈,当执行流进入一个函数时,函数的上下文就会被压入调用栈,当函数执行完后会将其上下文弹出,并将控制权返回前一个执行上下文。调用栈的顶端始终是当前正在执行的上下文。

本文主要看编译阶段,编译阶段总结来说主要完成两件事情:创建执行上下文和生成可执行代码

执行上下文有两个阶段:创建阶段 、 执行阶段。创建阶段:主要负责三件事:

  1. this 值的决定,即我们所熟知的 This 绑定
  2. 创建词法环境组件(LexicalEnvironment)。
  3. 创建变量环境组件(VariableEnvironment)。

执行阶段:完成对上下文中记录的所有标识符与对应变量值的分配绑定,最后执行代码。在执行阶段,如果 JavaScript 引擎 let 在源代码中声明的实际位置找不到变量的值,那么它将分配给它的值 undefined

先简单看下执行上下文结构:

//执行上下文结构
ExecutionContextES5 = {
  ThisBinding: <this value>,
  VariableEnvironment: { ... },//变量环境
  LexicalEnvironment: { ... },//词法环境
}

词法环境 词法环境有两个组成部分

  • 1:环境记录(Environment Record) ,这个就是真正登记变量的地方。

    • 1.1:声明式环境记录(Declarative Environment Record)声明式环境记录用于处理出现在函数作用域中的变量、函数、形式参数等(在这种情况下,这是我们从 ES3 系列中知道的AO(activation object)]和catch子句. *Declarative environment records are used to handle variables, functions, formal parameters, etc. appeared in function scopes (in this case this is very activation object which we know from ES3 series) and catch clauses.
    • 1.2:对象式环境记录(Object Environment Record) :主要用于with和global的词法环境。In contrast, an object environment record is used to define association of variables and functions appeared in the global context and inside the with-statements. These are exactly those inefficient variable storage implemented as simple objects which we’ve just mentioned above. In this case bindings are the properties of the objects.
  • 2:对外部词法环境的引用(outer) ,它是作用域链能够连起来的关键。我们可以看到,外部引用用于将当前环境与父环境链接起来。父环境当然可能有自己的外部链接。并且全局环境的外部链接设置为 nullThe outer reference as we can see is used to chain the current environment with the parent one. The parent environment of course may have its own outer link. And the outer link of the global environment is set to null

对于声明式环境记录人话就是就是如果你在函数体中遇到诸如var const let class module import 函数声明,那么环境记录就是declarative类型的;

对于声明式环境记录有必要再细看下: 每个声明性环境记录都与包含变量和/或函数声明的 ECMAScript 程序作用域相关联。声明性环境记录绑定由其范围内包含的声明定义的标识符集。

除了所有环境记录支持的可变绑定之外,声明性环境记录还提供不可变绑定。不可变绑定是标识符和值之间的关联一旦建立就不能修改的绑定。不可变绑定的创建和初始化是不同的步骤,因此此类绑定可能以已初始化或未初始化状态存在。除了环境记录抽象规范方法之外,声明性环境记录还支持表 18 中列出的方法:

对于outer

  1. 全局环境的外部词法环境引用为null
  2. 一个词法环境可以作为多个词法环境的外部环境。例如全局声明了多个函数,则这些函数词法环境的外部词法环境引用都指向全局环境。 外部词法环境的引用将一个词法环境和其外部词法环境链接起来,外部词法环境又拥有对其自身的外部词法环境的引用。这样就形成一个链式结构,这里我们称其为环境链(即ES6之前的作用域链),全局环境是这条链的顶端。

环境链的存在是为了标识符的解析,通俗的说就是查找变量。首先在当前环境查找变量,找不到就去外部环境找,还找不到就去外部环境的外部环境找,以此类推,直到找到,或者到环境链顶端(全局环境)还未找到则抛出ReferenceError

词法环境与我们自己写的代码结构相对应,也就是我们自己代码写成什么样子,词法环境就是什么样子。词法环境是在代码定义的时候决定的,跟代码在哪里调用没有关系。所以说JavaScript采用的是词法作用域(静态作用域)。

先来分析一个例子:

var a = 2;
let x = 1;
const y = 5;

function foo() {
    console.log(a);

    function bar() {
        var b = 3;
        console.log(a * b);
    }

    bar();
}
function baz() {
    var a = 10;
    foo();
}
baz();

它的词法环境关系图如下:

image.png 我们可以用伪代码来模拟上面代码的词法环境:

// 全局词法环境
GlobalEnvironment = {
    outer: null, //全局环境的外部环境引用为null
    GlobalEnvironmentRecord: {
        //全局this绑定指向全局对象
        [[GlobalThisValue]]: ObjectEnvironmentRecord[[BindingObject]],
        //声明式环境记录,除了全局函数和var,其他声明都绑定在这里
        DeclarativeEnvironmentRecord: {
            x: 1,
            y: 5
        },
        //对象式环境记录,绑定对象为全局对象
        ObjectEnvironmentRecord: {
            a: 2,
            foo:<< function>>,
            baz:<< function>>,
            isNaNl:<< function>>,
            isFinite: << function>>,
            parseInt: << function>>,
            parseFloat: << function>>,
            Array: << construct function>>,
            Object: << construct function>>
            ...
            ...
        }
    }
}
//foo函数词法环境
fooFunctionEnviroment = {
    outer: GlobalEnvironment,//外部词法环境引用指向全局环境
    FunctionEnvironmentRecord: {
        [[ThisValue]]: GlobalEnvironment,//this绑定指向全局环境
        bar:<< function>> 
    }
}
//bar函数词法环境
barFunctionEnviroment = {
    outer: fooFunctionEnviroment,//外部词法环境引用指向foo函数词法环境
    FunctionEnvironmentRecord: {
        [[ThisValue]]: GlobalEnvironment,//this绑定指向全局环境
        b: 3
    }
}

//baz函数词法环境
bazFunctionEnviroment = {
    outer: GlobalEnvironment,//外部词法环境引用指向全局环境
    FunctionEnvironmentRecord: {
        [[ThisValue]]: GlobalEnvironment,//this绑定指向全局环境
        a: 10
    }
}

我们可以看到词法环境和我们代码的定义一一对应,每个词法环境都有一个outer指向上一层的词法环境,当运行上面代码,函数bar的词法环境里没有变量a,所以就会到它的上一层词法环境(foo函数词法环境)里去找,foo函数词法环境里也没有变量a,就接着去foo函数词法环境的上一层(全局词法环境)去找,在全局词法环境里var a=2,沿着outer一层一层词法环境找变量的值就是作用域链。在沿着作用域链向上找变量的时候,找到第一个就停止往上找,如果到全局词法环境里还是没有找到,因为全局词法环境里的outer是null,没办法再往上找,就会报ReferenceError。

标识符解析:在作用域链中解析变量(绑定)的过程,先上知识点,标识符的扫描分为快解析和慢解析,一旦出现Ascii编码大于127的字符或者转义字符,会进入慢解析,略微影响速度,所以最好不要用中文、特殊字符来做变量名(不过现在代码压缩后基本不会有这种情况了)。

我们使用伪代码来模拟一下标识符解析的过程。 使用伪代码来模拟一下标识符解析的过程:

ResolveBinding(name[, LexicalEnvironment]) {
    // 如果传入词法环境为null(即一直解析到全局环境还未找到变量),则抛出ReferenceError
    if (LexicalEnvironment === null) {
        throw ReferenceError(`${name} is not defined`)
    }
    // 首次查找,将当前词法环境设置为解析环境
    if (typeof LexicalEnvironment === 'undefined') {
        LexicalEnvironment = currentLexicalEnvironment
    }
    // 检查环境的环境记录中是否有此绑定
    let isExist = LexicalEnvironment.EnviromentRecord.HasBinding(name)
    // 如果有则返回绑定值,没有则去外层环境查找
    if (isExist) {
        return LexicalEnvironment.EnviromentRecord[name]
    } else {
        return ResolveBinding(name, LexicalEnvironment.outer)
    }
}

对于词法环境也进行伪代码分析:

var x = 10
let y = 20
const z = 30
class Person {}
function foo() {
    var a = 10
}
foo()

对于全局标识符解析:

由于全局环境记录是声明式环境记录和对象式环境记录的封装,所以全局标识符的解析与其他环境的标识符解析有所不同,下面介绍全局标识符解析的步骤(伪代码):

function GetGlobalBingingValue(name) {
    // 全局环境记录
    let rec = Global Environment Record
    // 全局环境记录的声明式环境记录
    let DecRec = rec.DeclarativeRecord
    // HasBinding用来检查环境记录上是否绑定给定标识符
    if (DecRec.HasBinding(name) === true) {
        return DecRec[name]
    }
    let ObjRec = rec.ObjectRecord
    if (ObjRec.HasBinding(name) === true) {
        return ObjRec[name]
    }
    throw ReferenceError(`${name} is not defined`)
}

可以看到读取全局变量时,先检索声明式环境记录,再检索对象式环境记录。这样就会出现一些有趣的现象:letconstclass等声明的变量如果存在同名var变量或同名函数声明,就会报错(之后的文章中会具体介绍)。但是如果我们使用letconstclass声明变量,然后直接通过给全局对象添加一个同名属性,则可以绕过此类报错。

image.png

此时全局环境记录的声明式环境记录和对象式环境记录内都有此标识符的绑定,但是我们访问时由于先检索声明式环境记录,所以对象式环境记录内的绑定会被遮蔽,要想访问只能通过访问全局对象属性的方法访问。

变量提升vs函数提升

在前面我们提到过,V8引擎执行代码的大致可以分为三步,先做词法分析,然后解析生成AST,最后生成机器码执行代码。在生成AST会生成词法环境登记变量,对于变量声明和函数声明,词法环境的处理是不一样的。

在parse的时候:

  • 对于变量声明var a=2; let x=1;,给变量分配内存并初始化为undefined,赋值语句是在第三步生成机器码真正执行代码的时候才执行。
  • 对于函数声明function foo(){...},会在内存里创建函数对象,并且直接初始化为该函数对象。

这就是JS的变量提升和函数提升,我们来看个例子;

var c;

function functionDec() {
    console.log(c)
    c = 30;
}

functionDec();

最后运行结果是:undefined; 从词法分析到代码执行,变量提升和变量赋值变化如下:

image.png

如果整个变量就没有定义 运行代码,会有ReferenceError,运行结果如下:

image.png 对于未使用var关键字 隐形声明一个全局对象属性如:b='b',则在预解析阶段不会挂到全局词法环境上 而是在执行的时候遇到这赋值语句才会去添加上该属性。比如下面的例子,未使用隐性声明变量 a ,不存在变量声明提升,所以执行到 console.log(a) 语句时,foo 函数执行上下文中的激活对象中没有声明变量 a,所以报错a is not defined

function foo() { 
    console.log(a); 
    a = 1; 
} 
foo(); // a is not defined

对于同名的变量和函数 js采用忽略原则: 在编译阶段提升时,函数声明整体提升到环境记录中,所以在执行时 变量赋值靠后,在赋值前得到的都是函数fofo,赋值后得到的是赋的值

image.png

比如以下代码:

var a;
function foo() {
    a = "hi, i am foo";
    console.log(a);
}
function baz() {
    foo();
}
baz();

执行的时候 执行栈的情况:

image.png 图中的蓝色方块就是执行上下文(Execution Context),包在蓝色方块的灰色区域就是执行栈(Call Stack),整个执行栈遵循后进先出的原则:

  • 在开始执行任何代码之前,都会创建全局上下文压入栈底。
  • 创建词法环境,登记变量声明和函数声明。
  • 引擎运行到baz()的时候,把baz执行上下文压入执行栈。
  • baz调用foo,把foo执行上下文压入执行栈顶。
  • foo调用console.log,把console.log执行上下文压入执行栈顶。
  • console.log执行上下文是当前正在运行的执行上下文,在console执行完以后,console.log执行上下文被弹出执行栈。
  • foo执行上下文是当前正在运行的执行上下文,在foo执行完以后,foo执行上下文被弹出执行栈。
  • baz执行上下文是当前正在运行的执行上下文,在baz执行完以后,baz执行上下文被弹出执行栈

代码执行

先再复习下前面提到的执行上下文结构:

//执行上下文结构
ExecutionContextES5 = {
  ThisBinding: <this value>,
  VariableEnvironment: { ... },
  LexicalEnvironment: { ... },
}

JS引擎是按照可执行代码来执行代码的,每次执行步骤如下:

  • 1:创建一个新的执行上下文(Execution Context)
  • 2:创建一个新的词法环境(Lexical Environment)
  • 3:把LexicalEnvironmentVariableEnvironment指向新创建的词法环境
  • 4:把这个执行上下文压入执行栈并成为正在运行的执行上下文
  • 5:执行代码
  • 6:执行结束后,把这个执行上下文弹出执行栈

前面的代码在执行完1-4步以后,整个环境看起来是这样的:

每个function都会新创建一个词法环境,function的词法环境中的scope,就是词法环境中的outer,作用域链就是沿着outer往上一层的词法环境里找变量/方法。

执行第五步,会先给变量a赋值,然后执行console.log(a):

对上面暂时做个总结:在ECMAScript 6之后,新的概念“环境(Environment)”替代了作用域。而环境,则是“执行上下文(Execute Context)”的一部分。作用域的概念淡化、标准化了之后,原来的一些旧规范也被扔进了“非严格模式”中,这才使得ECMAScript成为一个成熟的、根基扎实的规范,这为ES6之后的持续发展打下了新的、良好的基础。

那么,有哪些“环境”呢?从功用上来说,有“变量环境”和“词法环境”两种;从执行逻辑的角度上来说,4种可执行结构都有它们对应的环境(函数、模块、全局、Eval);从与作用域的对照关系来看呢,则有函数、模块、全局、块和对象,等等。——总之,“环境”是在ES5开始提出,并在ES6之后发挥得淋漓尽致的一个东西。几乎所有ECMAScript的实现细节,都要追溯到它的设计与实现上来。 之所以每个执行上下文需要两个环境(Environments),就是因为ES6之后需要考虑如何兼容JavaScript早期版本中关于“var变量”的一些特殊处理。这其中要解决的,既包括大家熟知的“变量提升”,也包括之前提到过的“eval在哪里执行”和“语句/块中的函数声明”等等特殊问题。 正如我们前面说的,引擎需要环境的原因,就是它需要找到源代码中的“名字(Names)”。也因此,事实上EC.lexEnv和EC.varEnv这两个环境,也就只提供通过环境记录(Environment Records)来找到名字的能力。——这个能力,在早期的JavaScript中就是通过“作用域(Scope)”来实现的,也因此,二者是新旧替代的关系。至于环境记录,则是更进一步的、细化的名字对照表,它也只提供唯一的一个查询接口,就是“找到标识符/名字所对应的引用(GetIdentifierReference)”。

接下来讲讲this

this是什么?就是当前执行上下文的 ThisBinding 的值

The this keyword evaluates to the value of the ThisBinding of the current execution context.

有四种可执行代码可以创建执行上下文,分别是global code function code moduleeval code。接下来分别介绍这global code function code可执行代码中的this(ThisBinding)到底指的是什么

global code的this

在JS引擎运行global code之前,会创建一个全局执行上下文压入执行栈的栈底,这个全局执行上文的ThisBinding绑定的是全局对象,在浏览器里指的就是window

function code的this

在前面提到过JavaScript是静态作用域,词法环境是由代码结构决定的,开发把代码写成什么样,词法环境就是怎么样,跟方法在哪里调用没有关系。但是对于函数的this刚好反过来,跟代码在哪里定义没有关系,而跟代码在哪里调用有关系。一般我们调用函数有以下五种方式:

  • 默认绑定普通函数调用 foo()或者(functon(){})()
  • 隐式绑定 作为对象方法调用比如obj.foo()
  • 显式(硬)绑定 使用call、apply、bind等方法调用
  • new绑定
  • ES6箭头函数绑定

其中:

call、apply、bind调用,可以显示传递对象给函数的thisArg,默认这几个函数的第一个形参是thisArg:

Function.prototype.apply( thisArg, argArray )
Function.prototype.call( thisArg , arg1, [ arg2, ... ] )
Function.prototype.bind( thisArg , [ arg1, [ arg2, ... ] ] )

从规范#### 10.4.3 Entering Function Code里看提供thisArg时候函数的执行上下文:

image.png 需要注意的是当thisArg为null或者undefined,在非严格模式下,this是全局对象。

另外讲讲箭头函数的this: 对箭头函数而言,在执行上下文的创建阶段,没有做 thisBinding 和 建立相应的 arguments 对象。因此箭头函数要获取 this 只能通过作用域链获取最近词法环境的 this,也就是定义箭头函数的执行上下文的 this.

再来看看两个看似和this不相关的内容 Type(类型),Reference(引用类型)

Types are further subclassified into ECMAScript language types and specification types. image.png 简单总结下就是:

在ECMA规范的第八节中:类型又分为语言类型和规范类型

语言类型:字符串(String),数值(Number),对象(Object),布尔值(Boolean) 等等 规范类型:是描述 ECMA 语言构造与 ECMA 语言类型语意的算法所有的元值对应的类型。规范类型包括引用类,列表,完结,属性描述式,属性标示,词法环境,环境记录。规范类型可用来描述 ECMA 表示运算的中途结果,但是这些值不能存为对象的变量或是 ECMA 语言变量的值 从规范上可以知道,语言类型是我们使用 JS 的过程中可以声明及获取到的类型,规范类型是存在与 ECMA 中的,是为了维持 JS 引擎的运行而存在的,不能外部直接获取

我们只要知道在 ECMAScript 规范中还有⼀种只存在于规范中的类型,它们的作用是用来描述语言语底层行为逻辑。

Reference

那什么又是 Reference ?

用尤雨溪大大的话,就是:

Reference 是一个 Specification Type,也就是 “只存在于规范里的抽象类型”。它们是为了更好地描述语言的底层行为逻辑才存在的,但并不存在于实际的 js 代码中

再看接下来的这段具体介绍 Reference 的内容:

image.png Reference 类型就是用来解释诸如 delete、typeof等操作符 以及赋值等行为的。例如,赋值的左侧操作数被期望产生一个Reference。但是,赋值的行为可以完全根据对赋值运算符左侧操作数的句法形式的案例分析来解释,但有一个困难:允许函数调用返回引用。纯粹为了宿主对象而允许这种可能性。本规范定义的内置 ECMAScript 函数不返回引用,也没有规定用户定义的函数返回引用。(不使用句法案例分析的另一个原因是它会冗长且笨拙,影响规范的许多部分。)

这段讲述了 Reference 的构成,由三个组成部分,分别是:

  • base value
  • referenced name
  • strict reference

可是这些到底是什么呢?

我们简单的理解的话:base value 就是属性所在的对象或者就是 EnvironmentRecord,它的值只可能是 undefined, an Object, a Boolean, a String, a Number, or an environment record 其中的一种。referenced name 就是属性的名称。举个例子:

var foo = 1;
var obj = {
    bar: function () {
        return this;
    }
};

// foo对应的Reference是:
fooReference = {
    base: EnvironmentRecord,
    name: 'foo',
    strict: false
};
// bar对应的Reference是:
BarReference = {
    base: obj,
    propertyName: 'bar',
    strict: false
};

而且规范中还提供了获取 Reference 组成部分的方法,比如 GetBase 和 IsPropertyReference。

这两个方法很简单,简单看一看:

1.GetBase

GetBase(V). Returns the base value component of the reference V.

返回 reference 的 base value。

2.IsPropertyReference

IsPropertyReference(V). Returns true if either the base value is an object or HasPrimitiveBase(V) is true; otherwise returns false.

简单的理解:如果 base value 是一个对象,就返回true否则都是false。

GetValue

除此之外,紧接着在 8.7.1 章规范中就讲了一个用于从 Reference 类型获取对应值的方法: GetValue。

简单模拟 GetValue 的使用:

var foo = 1;

var fooReference = {
    base: EnvironmentRecord,
    name: 'foo',
    strict: false
};

GetValue(fooReference) // 1;

GetValue 返回对象属性真正的值,但是要注意:

调用 GetValue,返回的将是具体的值,而不再是一个 Reference

这个很重要,这个很重要,这个很重要。

如何确定this的值

关于 Reference 讲了那么多,为什么要讲 Reference 呢?到底 Reference 跟本文的主题 this 有哪些关联呢?如果你能耐心看完之前的内容,以下开始进入高能阶段:

看规范 11.2.3 Function Calls:

这里讲了当函数调用的时候,如何确定 this 的取值。再了解一个概念 MemberExpression 成员表达式 什么是 MemberExpression?看规范 11.2 Left-Hand-Side Expressions:

MemberExpression :

  • PrimaryExpression // 原始表达式 可以参见《JavaScript权威指南第四章》
  • FunctionExpression // 函数定义表达式
  • MemberExpression [ Expression ] // 属性访问表达式
  • MemberExpression . IdentifierName // 属性访问表达式
  • new MemberExpression Arguments // 对象创建表达式

举个例子:

function foo() {
    console.log(this)
}

foo(); // MemberExpression 是 foo

function foo() {
    return function() {
        console.log(this)
    }
}

foo()(); // MemberExpression 是 foo()

var foo = {
    bar: function () {
        return this;
    }
}

foo.bar(); // MemberExpression 是 foo.bar

Member Expression 还能构成 Call Expression(函数调用表达式)。它的基本形式是 Member Expression 后加一个括号里的参数列表,或者可以用上 super 关键字代替 Member Expression。

var obj = {
    name:'yyk',
    bar(){
        console.log(this.name)
    }
}

//这里面obj.bar就是MemberExpression也可以写成obj[bar] 
//obj.bar()就是**CallExpression 函数调用表达式**
obj.bar()

表达式暂且了解到这 简单理解 MemberExpression 其实就是()左边的部分。

关于MemberExpression看下面第一步、第六步、第七步:

image.png 让我们描述一下:

1.计算 MemberExpression 的结果赋值给 ref

2.判断 ref 是不是一个 Reference 类型

2.1 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)

2.2 如果 ref 是 Reference,并且 base value 值是 Environment Record, 那么this的值为 ImplicitThisValue(ref)

2.3 如果 ref 不是 Reference,那么 this 的值为 undefined

具体分析

关键就在于看规范是如何处理各种 MemberExpression,返回的结果是不是一个Reference类型。

举最后一个例子:

var value = 1;

var foo = {
  value: 2,
  bar: function () {
    return this.value;
  }
}

//示例1
console.log(foo.bar());
//示例2
console.log((foo.bar)());
//示例3
console.log((foo.bar = foo.bar)());
//示例4
console.log((false || foo.bar)());
//示例5
console.log((foo.bar, foo.bar)());

foo.bar()

在示例 1 中,MemberExpression 计算的结果是 foo.bar,那么 foo.bar 是不是一个 Reference 呢?

查看规范 11.2.1 Property Accessors,这里展示了一个计算的过程,什么都不管了,就看最后一步:

Return a value of type Reference whose base value is baseValue and whose referenced name is propertyNameString, and whose strict mode flag is strict.

我们得知该表达式返回了一个 Reference 类型!

根据之前的内容,我们知道该值为:

var Reference = {
  base: foo,
  name: 'bar',
  strict: false
};

接下来按照 2.1 的判断流程走:

2.1 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)

该值是 Reference 类型,那么 IsPropertyReference(ref) 的结果是多少呢?

前面我们已经铺垫了 IsPropertyReference 方法,如果 base value 是一个对象,结果返回 true。

base value 为 foo,是一个对象,所以 IsPropertyReference(ref) 结果为 true。

这个时候我们就可以确定 this 的值了:

this = GetBase(ref),

GetBase 也已经铺垫了,获得 base value 值,这个例子中就是foo,所以 this 的值就是 foo ,示例1的结果就是 2!

唉呀妈呀,为了证明 this 指向foo,真是累死我了!但是知道了原理,剩下的就更快了。

(foo.bar)()

看示例2:

console.log((foo.bar)());

foo.bar 被 () 包住,查看规范 11.1.6 The Grouping Operator

直接看结果部分:

Return the result of evaluating Expression. This may be of type Reference.

NOTE This algorithm does not apply GetValue to the result of evaluating Expression.

实际上 () 并没有对 MemberExpression 进行计算,所以其实跟示例 1 的结果是一样的。

(foo.bar = foo.bar)()

看示例3,有赋值操作符,查看规范 11.13.1 Simple Assignment ( = ):

计算的第三步:

3.Let rval be GetValue(rref).

因为使用了 GetValue,所以返回的值不是 Reference 类型,

按照之前讲的判断逻辑:

2.3 如果 ref 不是Reference,那么 this 的值为 undefined

this 为 undefined,非严格模式下,this 的值为 undefined 的时候,其值会被隐式转换为全局对象。

(false || foo.bar)()

看示例4,逻辑与算法,查看规范 11.11 Binary Logical Operators:

计算第二步:

2.Let lval be GetValue(lref).

因为使用了 GetValue,所以返回的不是 Reference 类型,this 为 undefined

(foo.bar, foo.bar)()

看示例5,逗号操作符,查看规范11.14 Comma Operator ( , )

计算第二步:

2.Call GetValue(lref).

因为使用了 GetValue,所以返回的不是 Reference 类型,this 为 undefined

揭晓结果

所以最后一个例子的结果是:

var value = 1;

var foo = {
  value: 2,
  bar: function () {
    return this.value;
  }
}

//示例1
console.log(foo.bar()); // 2
//示例2
console.log((foo.bar)()); // 2
//示例3
console.log((foo.bar = foo.bar)()); // 1
//示例4
console.log((false || foo.bar)()); // 1
//示例5
console.log((foo.bar, foo.bar)()); // 1

注意:以上是在非严格模式下的结果,严格模式下因为 this 返回 undefined,所以示例 3 会报错。

补充

最最后,忘记了一个最最普通的情况:

function foo() {
    console.log(this)
}

foo(); 

MemberExpression 是 foo,解析标识符,查看规范 10.3.1 Identifier Resolution,会返回一个 Reference 类型的值:

var fooReference = {
    base: EnvironmentRecord,
    name: 'foo',
    strict: false
};

接下来进行判断:

2.1 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)

因为 base value 是 EnvironmentRecord,并不是一个 Object 类型,还记得前面讲过的 base value 的取值可能吗? 只可能是 undefined, an Object, a Boolean, a String, a Number, 和 an environment record 中的一种。

IsPropertyReference(ref) 的结果为 false,进入下个判断:

2.2 如果 ref 是 Reference,并且 base value 值是 Environment Record, 那么this的值为 ImplicitThisValue(ref)

base value 正是 Environment Record,所以会调用 ImplicitThisValue(ref)

查看规范 10.2.1.1.6,ImplicitThisValue 方法的介绍:对于声明环境变量该函数始终返回 undefined。

image.png

所以最后 this 的值就是 undefined。

多说一句

尽管我们可以简单的理解 this 为调用函数的对象,如果是这样的话,如何解释下面这个例子呢?

var value = 1;

var foo = {
  value: 2,
  bar: function () {
    return this.value;
  }
}
console.log((false || foo.bar)()); // 1

尽管从这个角度写起来和读起来都比较吃力,但是一旦多读几遍,明白原理,绝对会给你一个全新的视角看待 this 。而你也就能明白,尽管 foo() 和 (foo.bar = foo.bar)() 最后结果都指向了 undefined,但是两者从规范的角度上却有着本质的区别。

能看到这的也太有耐心了,本文写的有些过于匆忙杂乱,知识点之间没有很好成体系,刚开始其实是顺着AV VO GO这种ES5之前的概念在梳理,写的过程真中发现let const的块级作用域情况难以去解释理顺,接着搜集参考过程中大佬们的文章更新了词法环境的概念,文章摘录参考总结了很多大佬的文章,表示深深感谢。最开始只是好奇一个闭包问题,写到最后我也没去花篇幅解释它,不过看完通篇我想应该可以有个比较清晰的认识。

碍于篇幅本文关于catch with 变量提升 函数提升 作用域 块级作用域 LexicalEnvironment 和 VariableEnvironment的关系等等本来写了很多,回头看下来这些东西之间交叉互相影响不好分章节讲读下来一团乱麻,同时又要浏览器因为考虑兼容问题并未完全遵守ECMAScript规范 最新规范也对比ES5其实也有很大变化上下文结构也有更新比如Realm函数参数赋值默认值之类的,作用域的概念也一直再被弱化替代为词法环境,导致用es规范去检验浏览器行为发现并不符合预期。下次再看了

也许通过对ECMAScript规范的学习,我们对JS的设计思路或者编译器的工作方式理解得更加透彻,从而用一种更近贴合编译器设计思路的方式编写代码。接触到这部分概念时间比较短认识还比较浅,总结分析过程中可能也有很多纰漏,希望大佬们评论多交流指教,最后感谢各位耐心本文,希望对各位工作学习有些帮助。

参考链接:

dmitrysoshnikov.com/ecmascript/…

www.freecodecamp.org/news/execut…

medium.com/@valentinog…

zhuanlan.zhihu.com/p/25428739

es5.github.io/#x11.2

yanhaijing.com/es5/#161

www.tr0y.wang/2021/04/01/…

limeii.github.io/2019/05/js-…

blog.csdn.net/m0_60703077…

github.com/mqyqingfeng…