JS执行上下文和调用栈

执行上下文在 JavaScript 是非常重要的基础知识,想要理解 JavaScript 的执行过程,执行上下文 是你必须要掌握的知识。否则只能是知其然不知其所以然。
理解执行上下文有什么好处呢?
它可以帮助你更好的理解代码的执行过程,作用域,闭包等关键知识点。特别是闭包它是 JavaScript 中的一个难点,当你理解了执行上下文在回头看闭包时,应该会有豁然开朗的感觉。
这篇文章我们将深入了解 执行上下文,读完文章之后你应该可以清楚的了解到 JavaScript 解释器到底做了什么,为什么可以在一些函数和变量之前使用它,以及它们的值是如何确定的。


什么是执行上下文

在 JavaScript 中运行代码时,代码的执行环境非常重要,通常是下列三种情况:

  • Global code:代码第一次执行时的默认环境。
  • Function code:函数体中的代码
  • Eval code:eval 函数内执行的文本(实际开发中很少使用,所以见到的情况不多)

在网上你可以读到很多关于作用域的文章,为了便于理解本文的内容,我们将 执行上下文 当作代码的 执行环境/作用域。现在就让我们看一个例子:它包括 全局和函数/本地执行上下文。



上面的例子我们看到,紫色的框代表全局上下文,绿色、蓝色、橙色代表三个不同的函数上下文。全局上下文执行有一个,它可以被其他上下文访问到。

你可以有任意数量的函数上下文,每个函数在调用时都会创建一个新的上下文,它是一个私有范围,函数内部声明的所有东西都不能在函数作用域外访问到。

上面的例子中,函数内部可以访问当前上下文之外声明的变量,但是外部却不能访问函数内部的变量/函数。这到底是为什么?其中的代码是如何执行的?


执行上下文栈

浏览器中的 JavaScript 解释器是单线程实现的。这意味着在浏览器中一次只能做一件事情。而其他的行为或事件都会在执行栈中排队等待。如图:



我们知道,当浏览器第一次加载脚本时,默认情况下,它会进入全局上下文。如果在全局代码中调用了一个函数,则代码的执行会进入函数中,此时会创建一个新的执行上下文,它会被推到执行上下文栈中。

如果在这个过程中函数内部调用了另一个函数,会发生同样的事情,代码的执行会进入函数中,然后创建一个新的执行上下文,它会被推到上下文栈 的顶部。浏览器始终执行栈顶部的执行上下文。

一旦函数完成执行,当前的执行上下文将从栈的顶部弹出,然后继续执行下面的,下面程序演示了一个递归函数的执行上下文情况。

(function foo(i) {
    if (i === 3) {
        return;
    }
    else {
        foo(++i);
    }
}(0));

自己调用自己三次,每次将 i 递增 1,每次函数 foo 被调用的时候,就会创建一个新的执行上下文。一旦当前上下文执行完毕之后,它就会从栈中弹出并转移到下面的上下文中,直到全局上下。
执行上下文栈的 5 个关键点:

  • 单线程
  • 同步执行
  • 只有一个全局上下文
  • 任意数量的函数上下文
  • 每个函数调用都会创建一个新的执行上下文,包括自己调用自己
详解执行上下文

到此,我们知道每次调用一个函数时,都会创建一个新的执行上下文。但是在 JavaScript 解释器中,每次调用执行上下文会有两个阶段:

创建阶段
  • 创建作用域链
  • 创建变量,函数,arguments列表。
  • 确定 this 的指向
执行阶段
  • 赋值,寻找函数引用,解释/执行代码

执行上下文可以抽象为一个对象它具备三个属性:

executionContextObj = {
    'scopeChain': { /* variableObject + all parent execution context's variableObject */ },
    'variableObject': { /* function arguments / parameters, inner variable and function declarations */},
    'this': {}
}
活动/变量对象[AO/VO]

executionContextObj对象在函数调用时创建,但它是在函数真正执行之前就创建的,这就是我们所说的第一个阶段 创建阶段,此时解释器通过扫描函数的传入参数,arguments,本地函数声明,局部变量声明来创建executionContextObj 对象。将结果变成 variableObject放入 executionContextObj 中。
解释器执行代码时的大致描述:

  • 调用函数
  • 在执行代码时,创建执行上下文
  • 进入创建阶段:
    1 初始化作用域链
    2 创建变量对象(variableObject)
    3 创建参数对象(arguments object),检查参数的上下文,初始化名称和值,并创建引用副本
    4 扫描上下文中的函数声明
    5 每发现一个函数,就会在 variableObject 中创建一个名称,保存函数的引用
    6 如果名称已经存在,则覆盖引用
    7 扫描上下文中的变量声明
    8 每发现一个变量,就在 variableObject 中创建一个名称,并初始化值为 undefined
    9 如果变量名已经存在,什么都不做,继续扫描
    10 确定上下文中的 this 指向
  • 执行代码阶段:
    1 在上下文中执行/解释代码,在代码逐行执行时进行变量复赋值

让我们看一个例子:

function foo(i) {
    var a = 'hello';
    var b = function privateB() {};
    function c() {}
}
foo(22);

foo(22) 函数执行的时候,创建阶段如下:

fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: undefined,
        b: undefined
    },
    this: { ... }
}

如上所述,除了形参 i 和 arguments外,在创建阶段我们只把变量进行声明而不进行赋值。

在创建阶段完成后,程序会进入函数中执行代码,如下所示:

fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: 'hello',
        b: pointer to function privateB()
    },
    this: { ... }
}
声明提前

网上很多关于声明提前的内容,它是用来解释变量和函数在声明时会被提前到作用域的顶部。但是并没有人详细解释为什么会发生这种情况,有了刚才关于解释器如何创建活动对象(AO)的认知,我们将很容易看出原因。例如:

(function() {
    console.log(typeof foo); // function pointer
    console.log(typeof bar); // undefined
    var foo = 'hello',
        bar = function() {
            return 'world';
        };
    function foo() {
        return 'hello';
    }
}())

我们现在可以回答如下问题:

为什么我们可以在声明之前访问foo

在执行阶段之前,我们已经完成了创建阶段,此时变量/函数已经被创建,所以当函数执行的时候foo 可以被访问到。

foo 被声明了两次,为什么 foo 显示的是 function 而不是 undefined 或者 string

虽然 foo 被声明了两次,但是我们在创建阶段中说到,函数是在变量之前创建在变量对象中,当变量对象中名称已经存在时,变量声明什么也不做。

因此 foo 会被先创建为函数 function foo() 的引用,当执行到 var foo 时发现变量对象中已将存在了,所以此时什么也不做,而是继续扫描。

为什么 barundefined

bar 实际上是一个变量只不过它的值是函数,而变量在创建阶段的值为 undefined


总结

我们再来梳理下重要的知识点:

  • 首先在程序执行时会创建一个全局的执行上下文,有且只有一个。
  • 函数在每次调用时就会创建一个函数上下文,可以有很多。
  • 函数上下文可以访问全局上下文的内容,反之则不行。
  • 创建的上下文会被推入到上下文栈中,然后从顶部开始依次执行。
  • 执行上下文会分为两个阶段:创建阶段和执行阶段。
  • 创建阶段会先进行函数声明和变量声明提前。
  • 创建阶段会先进行函数声明,然后进行变量声明,同时会被放入变量对象中,如果变量对象中已经存在:函数则进行引用的覆盖,变量则什么都不做。
  • 执行阶段才会进行赋值和运行。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,576评论 6 515
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,515评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,017评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,626评论 1 296
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,625评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,255评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,825评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,729评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,271评论 1 320
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,363评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,498评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,183评论 5 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,867评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,338评论 0 24
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,458评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,906评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,507评论 2 359

推荐阅读更多精彩内容