← 返回全部文章

技术笔记

JavaScript之代码执行分析

技术/前端
文章目录

顺序执行

关于JavaScript 的代码执行,我们都知道它是顺序执行 的,那么先看看下面的代码

say() //“hello”

var say = function () {
    console.log("hi")
}

say() //"hi"

function say() {
    console.log("hello")
}

say() //“hi”

如果按照顺序执行,那完全不能解释上面的现象呢,为什么第一个say() 会打印出值,为什么第三个say() 不是打印的hello,这哪里是顺序执行了 。

所以有请JavaScript 的特性—变量提升,函数提升:JavaScript 引擎会将变量和函数声明提升至作用域内的顶部,然后再按照顺序执行,所以也还是顺序执行。

可是为什么JavaScript 引擎会知道将变量和函数声明进行提升呢?

这是因为JavaScript 引擎并非一行一行地分析执行,而是一段一段地分析执行,按照‘“段落”顺序执行。“段落”内又分为“小节”,会进行一个“准备工作”,比如上面的变量提升和函数提升,“准备工作”完成后,就顺序执行这段重排后的代码。

可是这个“段落”是怎么划分的呢?怎么进行“准备工作”呢?

作用域

作用域是指程序源代码中定义变量可用,有效的区域,确定当前执行代码对变量的访问权限。

作用域分两种:静态作用域与动态作用域。

静态作用域表示函数的作用域在函数定义的时候就确定;

动态作用域表示函数的作用域在函数调用的时候才决定的。

那么JavaScript 是哪种呢?

var value = 1

function say() {
    console.log(value)
}

function toSay() {
    var value = 2
    say()
}

toSay() //1

从这个结果来看,打印的结果是1 。而say() 执行时,即在toSay() 内部,value 被赋值为2。如果是动态作用域,按照定义,打印的结果应该是2 ,而这个结果却是1,所以JavaScript 采用的是静态作用域 。

那么接下来,看一看《JavaScript》权威指南 中的例子

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

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

这是犀牛书上给出的解释,好像是这样,但也没说清楚上面两段代码为什么是一样的结果?其中提到了作用域链,函数的执行是基于作用域链的,那么这个作用域链到底是怎么创建的呢,其中是什么?

执行上下文栈

我们知道了JavaScript 是“一段一段”顺序执行,而且其采用的是静态作用域,函数作用域是在函数定义时确定的。

所谓“段落”其实就是按照作用域划分,将所有可执行代码按照作用域嵌套和隔断,划分出一个个“段落”,而所用作用域中有一个最顶级的,那就是全局作用域。按照这个划分,对应的“段落”就形成了全局代码,函数代码(通过函数进行作用域的划分)。

我们通过函数来进行作用域的划分,而函数的执行依赖作用域链,这个好像有点矛盾呢,怎么既通过函数进行作用域的划分,而它的执行又要依赖作用域链,到底是先有鸡还是先有蛋啊?

前面我们说过,“段落”执行时会先有一个“准备工作”,然后才是执行代码。作用域链就是在“”准备工作“中完成的,专业一点,这里的”准备工作“是创建执行上下文 。

那么一个“段落“即函数就会有一个执行上下文。函数那么多,有的还层层嵌套,JavaScript 引擎怎么识别和管理这些执行上下文呢?因此JavaScript 引擎创建了执行上下文栈(ECS) 来管理这些执行上下文。

执行上下文栈ECS

当JavaScript 引擎解释执行代码过程中,首先必然是全局代码,所以首先就会向ECS栈压入全局执行上下文 ,直到整个代码全部执行完毕,才会弹出。所以在代码运行过程中,栈低永远有一个全局执行上下文。

在全局代码中,经过变量提升和函数提升,所有的代码已经按顺序排排坐好,等待引擎执行的到来。

当执行到一个函数时,就会创建一个执行上下文,并且压入ECS栈,当函数执行完毕后,再将执行上下文从栈中弹出。

因为作用域是通过函数进行划分的,所有,执行上下文通过函数执行去创建。我们知道了ECS栈是如何处理执行上下文的了,我们在看看前文引用犀牛书上的例子

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()();

两段代码最主要的区别就是在checkscope 的执行,简单的看,一个只执行了一次,一个执行了2次,但真的是这样吗?

其实第2段代码中checkscope 并不是执行了两次,是因为其返回的是一个f函数,其并不是真正想要的返回值,所以再执行了一次f 函数。为什么第一段不需要呢,因为第一段代码中checkscope 的返回值是f 的结果,而不是f本身 。说到这里,前后两段代码主要的区别就是checkscope 和f 的执行顺序。

联系到执行上下栈,第一段代码应该是:

ECS.push(checkscope_context)
ECS.push(f_context)
ECS.pop()
ECS.pop()

第二段代码应该是:

ECS.push(checkscope_context)
ECS.pop()
ECS.push(f_context)
ECS.pop()

执行上下文

在解释执行上下文之前,我将函数的分为两种情景:定义和执行

其实函数上下文和执行上下文说的是一回事,只不过是不同阶段解读。、

函数的上下文在定义时创建叫做函数上下文,执行时基于函数上下文创建的叫做执行上下文。它们都有三个重要属性:变量对象 ,作用域链 ,this

变量对象

变量对象是该函数内所有形参,变量,函数声明的集合,存储了在上下文中定义的变量和函数声明:

变量对象在函数执行的过程中也是在不断产生和变化的。

举个例子

var m = 1;
function add(x, y) {
    var result = 0;
    function count() {};
    result = x + y;
    return result;
}

add(1, 2);
//定义时   只有arguments对象
VO = {
    arguments: {
        0: undefined,
        1: undefined,
        length: 2
    }
}

//执行期  解析时   初始化内部变量,函数声明
AO = {
    arguments: {
        0: undefined,
        1: undefined,
        length: 2
    },
    count: function count() {}
    result: undefined  
}
//执行  
AO = {
    arguments: {
        0: 1,
        1: 2,
        length: 2
    },
    count: function count() {}
    result: 3
}

作用域链

作用域链是保存所有父变量对象的层级链,因为它有关父变量对象的部分,是在函数定义时就确定的,所以函数的作用域也就是静态作用域。

作用域链在函数的不同阶段也有变化:

function a() {
    return function b() {
        return function c() {
            //code
        }
    }
}

a.scope = [GLOBAL]
b.scope = [a.VO, GLOBAL]
c.scope = [b.VO, a.VO, GLOBAL]

a()

a.scope = [a.AO, GLOBAL]
b.scope = [b.AO, a.VO, GLOBAL]
c.scope = [c.AO, b.VO, a.VO, GLOBAL]

this

在JavaScript 世界中,this 真的是一个善变的东西。通俗的说,它跟作用域链不一样,它反而依赖函数执行时的环境,而不是函数定义时的环境。

let o = {
    get: function () {
        console.log(this)
    }
}

let copyGet = o.get

o.get() // o

copyGet() // window

上面的说法好像是对的,但这种说法是最终的真相吗?我们回到规范去看看this 到底是怎么定义的。

ECMAScript 5.1 规范

11.1.1 this关键字

this 关键字执行为当前执行环境的 ThisBinding

11.2.3 函数调用

  1. 令 ref 为解释执行 MemberExpression 的结果 .
  2. 令 func 为 GetValue(ref).
  3. 令 argList 为解释执行 Arguments 的结果 , 产生参数值们的内部列表 (see 11.2.4).
  4. 如果 Type(func) is not Object ,抛出一个 TypeError 异常 .
  5. 如果 IsCallable(func) is false ,抛出一个 TypeError 异常 .
  6. 如果 Type(ref) 为 Reference,那么 如果 IsPropertyReference(ref) 为 true,那么 令 thisValue 为 GetBase(ref). 否则 , ref 的基值是一个环境记录项 令 thisValue 为调用 GetBase(ref) 的 ImplicitThisValue 具体方法的结果
  7. 否则 , 假如 Type(ref) 不是 Reference. 令 thisValue 为 undefined.
  8. 返回调用 func 的 Call 内置方法的结果 , 传入 thisValue 作为 this 值和列表 argList 作为参数列表

要搞清楚this的指向,首先要了解Reference 类型

Reference

ECMAScript 的类型分为语言类型和规范类型,语言类型是指常用的,我们可操作的Undefined , Null, Boolen, String, Number, Obejct 等。规范类型指底层用来描述ECMAScript 语言底层行为逻辑的。Reference 是其中一种。

Reference 是用来解释delete, typeof以及赋值等操作的,主要关注属性的状态

获取Reference 两种方法

获取Reference 类型属性对应值的方法

//伪代码
Reference = {
    base: undefined, an Object, a Boolean, a String, a Number, or an environment record,
    name: "name",
    strict: false
}
function GetBase(Reference) {
    return Reference.base;
}
function isPropertyReference(Reference) {
    return Reference.base === "object";
}

了解了Reference 后,我们再回来跟着规范去判断this

我们最主要的需要得出MemberExpression 的结果,那么MemberExpression 又是什么呢?

MemberExpression 即表达式,其主要由以下六种表达式,通过查阅规范,分别得出每种表达式返回值的类型

类型 表达式 Reference
原始表达式 true, false, null ... ×
初始化表达式 var x = 1 ×
函数表达式 function a() {} ×
属性访问表达式 a.b √
对象创建表达式 new Object() √`

跟着规范转了半天,好像没什么用哦,还是要进入实际应用,回头看看前面的例子

let o = {
    get: function () {
        console.log(this)
    }
}

let copyGet = o.get

o.get()

copyGet()

首先看看o.get():

  1. o.get 属于属性访问表达式,ref 返回一个baseReference

  2. baseReference = { base: o, name: "get" }

  3. isPropertyReference(baseReference) => true

  4. GetBase(baseReference) => o

  5. this = o

bingo!,正确

再看看copyGet()

  1. copyGet 为函数定义表达式,ref 为一个函数对象,而不是Reference

  2. ref !== Reference

  3. this = undefined

这个结果和前面浏览器运行的结果不一样啊,浏览器的运行结果是window ,即全局对象。

根据规范来看,无论怎么判断,this 的指向都只可能是undefined 或 实际对象,不会是全局对象的。

其实,这里是undefined 的隐式类型转换,转换成了全局对象。

虽然这里根据规范寻找this 的脉络,没什么太大的意思,但其实提供了更加深入理解this 和执行上下文的方法

从执行上下文看闭包

闭包是指那些能够访问自由变量的函数。

自由变量是指在函数中使用,但既不是函数参数也不是函数的局部变量的变量。

直接看代码来捋一捋

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

这里打印的都是1,这个毫无疑问,那么我们从执行上下文来解释一下为什么

data[0]()执行之前,此时的ECS 栈底为全局执行上下文

ECS = [GLOBAL]
GLOBAL = {
    VO: {
        data: [func, func, func],
        i: 3
    }
}

执行data[0]函数,将data[0]的执行上下文压入栈

ECS = [data_0_context, GLOBAL]
data_0_context = {
    VO: {
        arguments: {
            length: 0
        }
    }
    scope: [VO, GLOBAL.VO]
}

执行data[0] 时,其执行上下文的VO 并没有关于i 的信息,所以去作用域链查找,其上一级就是全局执行上下文的变量对象,i=3 ,所以直接打印为3

那么,我们一般的闭包解决方案呢

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = (function (i) {
        return function(){
            console.log(i);
        }
  })(i);
}

在这个闭包程序中,for 循环每次执行时,都执行过一次匿名函数,所以在data[0] 执行时,执行环节如下

ECS = [data_0_context, GLOBAL]
data_0_context = {
    VO: {
        arguments: {
            length: 0
        }
    }
    scope: [VO, Anonymous.VO, GLOBAL.VO]
}
Anonymous = {
    VO: {
        arguments: {
            0: 0,
            length: 1
        },
        i: 0
    },
    scope: [GLOBAL.VO]
}

执行data[0] 时,其执行上下文的VO 仍然并没有关于i 的信息,所以去作用域链查找,但这次其执行上下文是Anonymous, i 是作为形参传入的,所以其变量对象上是有记录的i: 0 ,所以直接返回i = 0 。

由上面两个例子可以看出,所谓闭包,主要是由于执行上下文自动维护的作用域链,由于作用域链的存在,data[0] 依然可以读取到匿名函数的中的变量对象的值,即虽然匿名函数已经执行完毕了,匿名函数的指针被销毁了,但其变量对象依然存在内存中,所以data[0] 等依然可以依靠作用域链找到它。