JavaScript 到底是怎样执行的?

摘要: 理解 JS 引擎运行原理。javascript

Fundebug经受权转载,版权归原做者全部。前端

一些名词

JS 引擎 — 一个读取代码并运行的引擎,没有单一的“JS 引擎”;每一个浏览器都有本身的引擎,如谷歌有 V。java

做用域 — 能够从中访问变量的“区域”。编程

词法做用域— 在词法阶段的做用域,换句话说,词法做用域是由你在写代码时将变量和块做用域写在哪里来决定的,所以当词法分析器处理代码时会保持做用域不变。小程序

块做用域 — 由花括号{}建立的范围segmentfault

做用域链 — 函数能够上升到它的外部环境(词法上)来搜索一个变量,它能够一直向上查找,直到它到达全局做用域。微信小程序

同步 — 一次执行一件事, “同步”引擎一次只执行一行,JavaScript 是同步的。浏览器

异步 — 同时作多个事,JS 经过浏览器 API模拟异步行为缓存

事件循环(Event Loop) - 浏览器 API 完成函数调用的过程,将回调函数推送到回调队列(callback queue),而后当堆栈为空时,它将回调函数推送到调用堆栈。安全

堆栈 —一种数据结构,只能将元素推入并弹出顶部元素。 想一想堆叠一个字形的塔楼; 你不能删除中间块,后进先出。

— 变量存储在内存中。

调用堆栈 — 函数调用的队列,它实现了堆栈数据类型,这意味着一次能够运行一个函数。 调用函数将其推入堆栈并从函数返回将其弹出堆栈。

执行上下文 — 当函数放入到调用堆栈时由 JS 建立的环境。

闭包 — 当在另外一个函数内建立一个函数时,它“记住”它在之后调用时建立的环境。

垃圾收集 — 当内存中的变量被自动删除时,由于它再也不使用,引擎要处理掉它。

变量的提高— 当变量内存没有赋值时会被提高到全局的顶部并设置为undefined

this —由 JavaScript 为每一个新的执行上下文自动建立的变量/关键字。

调用堆栈(Call Stack)

看看下面的代码:

var myOtherVar = 10;

function a() {
    console.log("myVar", myVar);
    b();
}

function b() {
    console.log("myOtherVar", myOtherVar);
    c();
}

function c() {
    console.log("Hello world!");
}

a();

var myVar = 5;

有几个点须要注意:

  • 变量声明的位置(一个在上,一个在下)
  • 函数a调用下面定义的函数b, 函数 b 调用函数c

当它被执行时你指望发生什么? 是否发生错误,由于ba以后声明或者一切正常? console.log 打印的变量又是怎么样?

如下是打印结果:

"myVar" undefined
"myOtherVar" 10
"Hello world!"

来分解一下上述的执行步骤。

1. 变量和函数声明(建立阶段)

第一步是在内存中为全部变量和函数分配空间。 但请注意,除了undefined以外,还没有为变量分配值。 所以,myVar在被打印时的值是undefined,由于 JS 引擎从顶部开始逐行执行代码。

函数与变量不同,函数能够一次声明和初始化,这意味着它们能够在任何地方被调用。

因此以上代码看起来像这样子:

var myOtherVar = undefined
var myVar = undefined

function a() {...}
function b() {...}
function c() {...}

这些都存在于 JS 建立的全局上下文中,由于它位于全局空间中。

在全局上下文中,JS 还添加了:

  1. 全局对象(浏览器中是 window 对象,NodeJs 中是 global 对象)
  2. this 指向全局对象

2. 执行

接下来,JS 引擎会逐行执行代码。

myOtherVar = 10`在全局上下文中,`myOtherVar`被赋值为`10

已经建立了全部函数,下一步是执行函数 a()

每次调用函数时,都会为该函数建立一个新的上下文(重复步骤 1),并将其放入调用堆栈。

function a() {
    console.log("myVar", myVar);
    b();
}

以下步骤:

  1. 建立新的函数上下文
  2. a 函数里面没有声明变量和函数
  3. 函数内部建立了 this 并指向全局对象(window)
  4. 接着引用了外部变量 myVarmyVar 属于全局做用域的。
  5. 接着调用函数 b ,函数b的过程跟 a同样,这里不作分析。

下面调用堆栈的执行示意图:

  1. 建立全局上下文,全局变量和函数。
  2. 每一个函数的调用,会建立一个上下文,外部环境的引用及 this
  3. 函数执行结束后会从堆栈中弹出,而且它的执行上下文被垃圾收集回收(闭包除外)。
  4. 当调用堆栈为空时,它将从事件队列中获取事件。

做用域及做用域链

在前面的示例中,全部内容都是全局做用域的,这意味着咱们能够从代码中的任何位置访问它。 如今,介绍下私有做用域以及如何定义做用域。

函数/词法做用域

考虑以下代码:

function a() {
    var myOtherVar = "inside A";

    b();
}

function b() {
    var myVar = "inside B";

    console.log("myOtherVar:", myOtherVar);

    function c() {
        console.log("myVar:", myVar);
    }

    c();
}

var myOtherVar = "global otherVar";
var myVar = "global myVar";
a();

须要注意如下几点:

  1. 全局做用域和函数内部都声明了变量
  2. 函数c如今在函数b中声明

打印结果以下:

myOtherVar: "global otherVar";
myVar: "inside B";

执行步骤:

  1. 全局建立和声明 - 建立内存中的全部函数和变量以及全局对象和 this
  2. 执行 - 它逐行读取代码,给变量赋值,并执行函数 a
  3. 函数 a建立一个新的上下文并被放入堆栈,在上下文中建立变量myOtherVar,而后调用函数 b
  4. 函数 b 也会建立一个新的上下文,一样也被放入堆栈中

5,函数b 的上下文中建立了 myVar 变量,并声明函数 c

上面提到每一个新上下文会建立的外部引用,外部引用取决于函数在代码中声明的位置。

  1. 函数 b试图打印myOtherVar,但这个变量并不存在于函数 b中,函数 b 就会使用它的外部引用上做用域链向上找。因为函数 b是全局声明的,而不是在函数 a内部声明的,因此它使用全局变量 myOtherVar
  2. 函数 c执行步骤同样。因为函数 c自己没有变量myVar,因此它它经过做用域链向上找,也就是函数 b,由于myVar函数 b内部声明过。

下面是执行示意图:

请记住,外部引用是单向的,它不是双向关系。例如,函数 b不能直接跳到函数 c的上下文中并从那里获取变量。

最好将它看做一个只能在一个方向上运行的链(范围链)。

  • a -> global
  • c -> b -> global

在上面的图中,你可能注意到,函数是建立新做用域的一种方式。(除了全局做用域)然而,还有另外一种方法能够建立新的做用域,就是块做用域

块做用域

下面代码中,咱们有两个变量和两个循环,在循环从新声明相同的变量,会打印什么(反正我是作错了)?

function loopScope() {
    var i = 50;
    var j = 99;

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

    console.log("i =", i);

    for (let j = 0; j < 10; j++) {}

    console.log("j =", j);
}

loopScope();

打印结果:

i = 10;
j = 99;

第一个循环覆盖了var i,对于不知情的开发人员来讲,这可能会致使 bug。

第二个循环,每次迭代建立了本身做用域和变量。 这是由于它使用let关键字,它与var相同,只是let有本身的块做用域。 另外一个关键字是const,它与let相同,但const常量且没法更改(指内存地址)。

块做用域由大括号 {} 建立的做用域

再看一个例子:

function blockScope() {
    let a = 5;
    {
        const blockedVar = "blocked";
        var b = 11;

        a = 9000;
    }

    console.log("a =", a);
    console.log("b =", b);
    console.log("blockedVar =", blockedVar);
}

blockScope();

打印结果:

a = 9000
b = 11
ReferenceError: blockedVar is not defined
  1. a是块做用域,但它在函数中,而不是嵌套的,本例中使用var是同样的。
  2. 对于块做用域的变量,它的行为相似于函数,注意var b能够在外部访问,可是const blockedVar不能。
  3. 在块内部,从做用域链向上找到 a 并将let a更改成9000

使用块做用域可使代码更清晰,更安全,应该尽量地使用它。

事件循环(Event Loop)

接下来看看事件循环。 这是回调,事件和浏览器 API 工做的地方

咱们没有过多讨论的事情是,也叫全局内存。它是变量存储的地方。因为了解 JS 引擎是如何实现其数据存储的实际用途并很少,因此咱们不在这里讨论它。

来个异步代码:

function logMessage2() {
    console.log("Message 2");
}

console.log("Message 1");

setTimeout(logMessage2, 1000);

console.log("Message 3");

上述代码主要是将一些 message 打印到控制台。 利用setTimeout函数来延迟一条消息。 咱们知道 js 是同步,来看看输出结果

Message 1
Message 3
Message 2
  1. 打印 Message 1
  2. 调用 setTimeout
  3. 打印 Message 3
  4. 打印 Message 2

它记录消息 3

稍后,它会记录消息 2

setTimeout是一个 API,和大多数浏览器 API 同样,当它被调用时,它会向浏览器发送一些数据和回调。咱们这边是延迟一秒打印 Message 2

调用完setTimeout 后,咱们的代码继续运行,没有暂停,打印 Message 3 并执行一些必须先执行的操做。

浏览器等待一秒钟,它就会将数据传递给咱们的回调函数并将其添加到事件/回调队列中( event/callback queue)。 而后停留在队列中,只有当**调用堆栈(call stack)**为空时才会被压入堆栈。

代码示例

要熟悉 JS 引擎,最好的方法就是使用它,再来些有意义的例子。

简单的闭包

这个例子中 有一个返回函数的函数,并在返回的函数中使用外部的变量, 这称为闭包

function exponent(x) {
    return function(y) {
        //和math.pow() 或者x的y次方是同样的
        return y ** x;
    };
}

const square = exponent(2);

console.log(square(2), square(3)); // 4, 9

console.log(exponent(3)(2)); // 8

块代码

咱们使用无限循环将将调用堆栈塞满,会发生什么,回调队列被会阻塞,由于只能在调用堆栈为空时添加回调队列。

function blockingCode() {
    const startTime = new Date().getSeconds();

    // 延迟函数250毫秒
    setTimeout(function() {
        const calledAt = new Date().getSeconds();
        const diff = calledAt - startTime;

        // 打印调用此函数所需的时间
        console.log(`Callback called after: ${diff} seconds`);
    }, 250);

    // 用循环阻塞堆栈2秒钟
    while (true) {
        const currentTime = new Date().getSeconds();

        // 2 秒后退出
        if (currentTime - startTime >= 2) break;
    }
}

blockingCode(); // 'Callback called after: 2 seconds'

咱们试图在250毫秒以后调用一个函数,但由于咱们的循环阻塞了堆栈所花了两秒钟,因此回调函数实际是两秒后才会执行,这是 JavaScript 应用程序中的常见错误

setTimeout不能保证在设置的时间以后调用函数。相反,更好的描述是,在至少通过这段时间以后调用这个函数。

延迟函数

setTimeout 的设置为 0,状况是怎么样?

function defer() {
    setTimeout(() => console.log("timeout with 0 delay!"), 0);
    console.log("after timeout");
    console.log("last log");
}

defer();

你可能指望它被当即调用,可是,事实并不是如此。

执行结果:

after timeout
last log
timeout with 0 delay!

它会当即被推到回调队列,但它仍然会等待调用堆栈为空才会执行。

用闭包来缓存

Memoization是缓存函数调用结果的过程。

例如,有一个添加两个数字的函数add。调用add(1,2)返回3,当再次使用相同的参数add(1,2)调用它,此次不是从新计算,而是记住 1 + 2是3的结果并直接返回对应的结果。 Memoization能够提升代码运行速度,是一个很好的工具。

咱们可使用闭包实现一个简单的memoize函数。

// 缓存函数,接收一个函数
const memoize = func => {
    // 缓存对象
    // keys 是 arguments, values are results
    const cache = {};

    // 返回一个新的函数
    // it remembers the cache object & func (closure)
    // ...args is any number of arguments
    return (...args) => {
        // 将参数转换为字符串,以便咱们能够存储它
        const argStr = JSON.stringify(args);

        // 若是已经存,则打印
        console.log("cache", cache, !!cache[argStr]);

        cache[argStr] = cache[argStr] || func(...args);

        return cache[argStr];
    };
};

const add = memoize((a, b) => a + b);

console.log("first add call: ", add(1, 2));

console.log("second add call", add(1, 2));

执行结果:

cache {} false
first add call:  3
cache { '[1,2]': 3 } true
second add call 3

第一次 add 方法,缓存对象是空的,它调用咱们的传入函数来获取值3.而后它将args/value键值对存储在缓存对象中。

在第二次调用中,缓存中已经有了,查找到并返回值。

对于add函数来讲,有无缓存看起来可有可无,甚至效率更低,可是对于一些复杂的计算,它能够节省不少时间。这个示例并非一个完美的缓存示例,而是闭包的实际应用。

关于Fundebug

Fundebug专一于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了10亿+错误事件,付费客户有阳光保险、核桃编程、荔枝FM、掌门1对一、微脉、青团社等众多品牌企业。欢迎你们免费试用

img

版权声明

转载时请注明做者 Fundebug以及本文地址:https://blog.fundebug.com/2019/06/24/how-does-javascript-execute/

相关文章
相关标签/搜索