浅谈javascript代码执行机制

378 阅读11分钟

javascript代码执行顺序

我们假设所有的 JS 都是这样工作的:

let a = '1';
console.log(a);

let b = '2';
console.log(b);

然而,实际上 JS 是这样的:

setTimeout(function () {
    console.log('定时器开始了')
});
new Promise(function (resolve) {
    console.log('即将执行for循环');
    for (let i = 0; i < 10000; i++) {
        i == 99 && resolve();
    }
}).then(function () {
    console.log('执行then函数')
});
console.log('代码执行结束');

遵循 JavaScript 按语句顺序执行的概念,我自信地写下了输出:

  1. 定时器开始了。
  2. 即将执行for循环。
  3. 执行then函数。
  4. 代码执行结束。

然而,在 Chrome 中验证时,结果完全错误,瞬间迷惑,代码从上往下编写,执行循序不也是由上而下的吗?为何我后面的代码会比前面的代码先执行?我们需要彻底理解 JS 的执行机制!接下来我们一起探讨!

关于JavaScript

JavaScript是一种单线程语言。尽管在最新的 HTML5 中引入了 Web Worker,但 JavaScript 的单线程核心没有改变。因此,JavaScript 中的所有“多线程”都是使用单线程模拟的,所有的多线程都是欺骗性的!

JavaScript 事件循环

由于 JavaScript 是单线程的,就像上公交车,乘客需要一个接一个地排队刷卡或者扫码。同样,JavaScript 任务也需要一个接一个地执行。如果一个任务花费太长时间,那么下一个任务就必须等待。所以问题来了:如果我们想浏览网页,但网页中的高清图片加载缓慢,我们的网页是否必须一直卡住,直到图片完全显示?因此,程序员将 JS 任务分为两类:

  • 同步任务
  • 异步任务

当我们打开一个网站时,网页的渲染过程由一堆同步任务组成,如渲染页面骨架和页面元素。那些消耗资源多、耗时长的任务,如加载图片或音乐文件,则是异步任务。

  • 同步任务和异步任务进入不同的执行“场所”,同步任务进入主线程,异步任务进入事件表并注册函数。
  • 当指定任务完成时,事件表会将这个函数移动到事件队列中。
  • 主线程中的任务执行完毕后,会从事件队列中读取相应的函数并在主线程中执行。
  • 上述过程会不断重复,这通常被称为事件循环(Event Loop)。

那么,如何知道主线程执行栈是否为空?JavaScript 引擎有一个监视过程,持续检查主线程执行栈是否为空。一旦为空,它就会去事件队列中检查是否有等待调用的函数。

经过以上描述,一段代码可能会更直观

let data = [];
axios({
    url: 'www.javascript.com',
    data: data,
}).then(success)
function success() {
    console.log('发送成功');
}
console.log('代码执行结束');

上面是一段简单的 axios 请求代码:

  • axios 进入事件表并注册回调函数 success。
  • 执行 console.log('代码执行结束')。
  • axios 事件完成,回调函数 success 进入事件队列。
  • 主线程从事件队列中读取并执行回调函数 success。

通过以上的文字和代码,相信你对 JavaScript 的执行顺序有了初步的了解。接下来,让我们研究一个高级话题:setTimeout

对 setTimeout 的爱恨情仇

众所周知,setTimeout 无需过多介绍。我们对它的第一印象是它可以在延迟之后异步执行。我们经常使用它来实现 3 秒延迟执行:

setTimeout(() => {
    task();
}, 3000)
console.log('执行 console');

随着 setTimeout 的使用逐渐增多,问题也随之而来。有时,即使在代码中指定了 3 秒的延迟,函数也会在 5 或 6 秒后执行。这可能是什么原因造成的呢? 我们先看一个例子:

setTimeout(() => {
    task();
}, 3000)
console.log('执行 console');

根据我们之前的结论,setTimeout 是异步的,所以同步任务 console.log 应该先执行。因此,我们的结论是:

  1. 执行 console
  2. task()

为了验证,结果是正确的!然后让我们对之前的代码做一些修改:

setTimeout(() => {
    task();
}, 3000)

sleep(10000000)

乍一看,这似乎类似,但当我们在 Chrome 中执行这段代码时,发现 task 的执行时间远远超过 3 秒。为什么现在需要这么长时间呢?

此时,我们需要重新定义 setTimeout。让我们来讨论上面代码的执行过程:

  1. task() 进入事件表并注册,计时开始。
  2. 执行非常缓慢的 sleep 函数,计时继续。
  3. 3 秒钟过去,计时事件 timeout 完成。task() 进入事件队列。但是,sleep 太慢,还没有执行完毕;所以我们必须等待。
  4. 最后,sleep 执行完毕。task() 终于从事件队列移动到主线程执行。

经过上述过程,我们了解到 setTimeout 函数会在指定时间后将任务(在这个例子中是 task())添加到事件队列中。由于任务在单线程环境中一个接一个地执行,如果前面的任务执行时间过长,执行时间将显著超过 3 秒。

我们经常遇到类似 setTimeout(fn, 0) 的代码。0 秒后执行意味着什么?它能立即执行吗?

答案是否定的。setTimeout(fn, 0) 的意思是指定某个任务在主线程最早的空闲时间执行,不需要等待任何额外的秒数,一旦所有同步任务在栈中完成并且栈变为空。例如:

// 代码 1
console.log('先执行这里');
setTimeout(() => {
    console.log('执行了')
}, 0);

// 代码 2
console.log('先执行这里');
setTimeout(() => {
    console.log('执行了')
}, 3000);

代码 1 的输出结果是:

  1. 先执行这里
  2. 执行了

代码 2 的输出结果是:

  1. 先执行这里
  2. ... 3 秒后
  3. 执行了

关于 setTimeout 需要注意的是,即使主线程空闲,0 毫秒也无法实现。根据 HTML 标准,最小值为 4 毫秒。感兴趣的同学可以自行探索。

双胞胎兄弟 setInterval

谈到 setTimeout,我们不能错过它的双胞胎兄弟 setInterval。它们很相似,只不过后者是循环执行的。从执行顺序来看,setInterval 会在每个指定的间隔时间将注册的函数放入事件队列。如果前一个任务花费太长时间,它也需要等待。

需要注意的是,对于 setInterval(fn, ms),我们已经知道 fn 不会每 ms 秒执行一次,而是在每 ms 秒将一个新的 fn 实例放入事件队列。如果 setInterval 的回调函数(fn)花费的时间超过了延迟时间(ms),那么将不会有明显的时间间隔。请仔细思考这句话。

值得一提的是,浏览器为了节省资源和提高性能而采取的一种优化策略。当一个网页标签不在前台时(即用户没有直接查看这个标签),浏览器可能会调整该页面上setTimeoutsetInterval函数的行为。

具体来说:

  • 在Chrome浏览器中,如果页面标签不是活动状态,定时器的最小间隔时间会被调整为1秒。这意味着即使你设置了更短的时间间隔,比如setInterval(() => {}, 100),在标签页不活跃的情况下,回调函数也会大约每1秒执行一次。
  • 对于其他浏览器,它们可能有不同的实现细节,但大多数现代浏览器都遵循类似的优化策略,可能会将非活动标签页中的定时器间隔延长到4秒或更长。

这种行为对于用户体验通常是透明的,并且有助于减少后台标签页对系统资源的消耗,尤其是电池寿命和CPU使用率。如果您正在开发的应用程序依赖于精确的定时器行为,那么需要考虑到这一点,并可能需要实现补偿机制来处理这种情况。

痛苦根源 Promise

我们已经研究了传统的定时器,接下来,我们将探索更加痛苦的Promise的表现。

除了同步任务和异步任务的广义定义外,我们还有更精细的任务定义:

  • 宏任务(macro-task):包括整体代码、setTimeoutsetInterval
  • 微任务(micro-task):Promise 的回调

不同类型的任务将进入相应的事件队列;例如,setTimeout 和 setInterval 将进入同一个事件队列。

事件循环中的事件顺序决定了 JavaScript 代码的执行顺序。在进入整体代码(宏任务)后,它开始其第一次循环。然后,它执行所有的微任务。接下来,它再次从宏任务开始,直到一个任务队列完成,再次执行所有的微任务。听起来有点复杂;让我们用本文前面的一个代码片段来说明:

setTimeout(function() {
    console.log('setTimeout');
})

new Promise(function(resolve) {
    console.log('promise');
    resolve()
}).then(function() {
    console.log('then');
})

console.log('console');
  1. 这段代码作为宏任务进入主线程。
  2. 遇到 setTimeout,它的回调函数被注册并分派到宏任务事件队列中。
  3. 接下来,遇到 Promise,new Promise 立即执行,并将 then 函数分派到微任务事件队列中。
  4. 遇到 console.log(),立即执行。
  5. 在作为第一个宏任务执行整体代码后,我们看看有哪些微任务。我们发现 then 在微任务事件队列中,并执行它。
  6. 事件循环的第一轮结束。让我们从宏任务事件队列开始第二轮循环。我们发现这个队列中对应于 setTimeout 的回调函数立即执行。
  7. 结束。

我们分析一段更复杂的代码,看看您是否理解了 JavaScript 的执行机制:

        console.log('1');

        setTimeout(function () {
            console.log('2');
            new Promise(function (resolve) {
                console.log('3');
                resolve();
            }).then(function () {
                console.log('4')
            })
        })

        new Promise(function (resolve) {
            console.log('5');
            resolve();
        }).then(function () {
            console.log('6')
        })

        setTimeout(function () {
            console.log('7');
            new Promise(function (resolve) {
                setTimeout(function () {
                    console.log('8');
                })
                console.log('9');
                resolve();
            }).then(function () {
                console.log('10')
            })
        })

事件循环第一轮过程分析如下:

  • 整体代码作为第一个宏任务进入主线程,遇到 console.log 并输出 1。
  • 遇到 setTimeout,它的回调函数被分派到宏任务事件队列中,我们暂时称之为 setTimeout1
  • 遇到 Promise,new Promise 直接执行并输出 5,then 方法分派到微任务事件队列中,我们称之为 then1。
  • 再次遇到 setTimeout,它的回调函数被分派到宏任务事件队列中,我们称之为 setTimeout2
  • 在第一轮事件循环宏任务结束时,输出 1 和 5。
  • 我们发现一个微任务: then1
  • 执行 then1 输出 6。

第一轮事件循环正式结束,结果输出为 1, 5, 6。宏任务事件队列剩余任务setTimeout1, setTimeout2

第二轮事件循环从 setTimeout1 宏任务开始:

  • 遇到 console.log 并输出 2。
  • 遇到 Promise,new Promise,直接执行并输出 3,then 方法分派到setTimeout1的微任务事件队列中,我们称之为 then2
  • 到此,在setTimeout1宏任务结束,输出 2 和 3。
  • 我们发现一个微任务:then2
  • 执行 then2 输出 4。

第二轮事件循环结束,结果输出为 2, 3, 4。宏任务事件队列剩余任务setTimeout2

第三轮事件循环从 setTimeout2 宏任务开始:

  • 遇到 console.log 并输出 7。
  • 遇到 Promise,new Promise,直接执行:
  • 遇到 setTimeout,它的回调函数被分派到宏任务事件队列中,我们暂时称之为 setTimeout3
  • then 方法分派到setTimeout2的微任务事件队列中,我们称之为 then3
  • 遇到 console.log 并输出 9。
  • Promise被解决
  • 到此,在setTimeout2宏任务结束,输出 7 和 9。
  • 我们发现一个微任务:then3
  • 执行 then3 输出 10。

第三轮事件循环结束,结果输出为7, 9, 10,宏任务事件队列剩余任务setTimeout3

第四轮事件循环从 setTimeout3 宏任务开始:

  • 执行setTimeout3宏任务,输出8,第四轮事件循环结束
  • 至此,四轮事件循环全面结束,宏任务事件队列无剩余任务

整个代码段经过了四轮事件循环,完整输出为1,5,6,2,3,4,7,9,10,8

总结

JavaScript 的异步性:从一开始,我们就说过 JavaScript 是单线程语言。无论使用什么新框架或语法糖来实现所谓的异步性,都是通过同步方法模拟的。牢牢把握单线程这一点非常重要。

事件循环:事件循环是 JavaScript 实现异步操作的方法,也是其执行机制。