MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript事件循环与变量作用域分析

2023-08-251.9k 阅读

JavaScript 事件循环与变量作用域分析

一、JavaScript 的单线程特性

JavaScript 是一门单线程语言,这意味着它在同一时间只能执行一个任务。这种特性源于 JavaScript 的设计初衷,它最初被设计用于浏览器端,主要处理与用户界面的交互以及操作 DOM 等任务。如果 JavaScript 是多线程的,在同时操作 DOM 时就可能会出现冲突,比如一个线程在删除某个元素,另一个线程却在向该元素添加内容,这会导致不可预测的结果。

例如,假设有如下简单代码:

console.log('开始');
for (let i = 0; i < 100000000; i++) {
    // 模拟一个耗时操作
}
console.log('结束');

在执行这段代码时,浏览器的 JavaScript 引擎会先打印出 “开始”,然后执行那个耗时的循环,在循环执行期间,浏览器界面会处于卡顿状态,直到循环结束后才会打印出 “结束”。这就是因为 JavaScript 的单线程特性,在执行循环这个任务时,无法同时处理其他任务,比如用户点击按钮、滚动页面等操作。

二、事件循环机制的引入

由于 JavaScript 的单线程特性,如果遇到一些耗时较长的任务(如网络请求、定时器等),就会阻塞整个线程,导致用户体验变差。为了解决这个问题,JavaScript 引入了事件循环(Event Loop)机制。

事件循环的核心思想是,JavaScript 引擎将任务分为两类:同步任务和异步任务。同步任务会在主线程上按照顺序依次执行,而异步任务会被放入任务队列(Task Queue)中。当主线程上的同步任务执行完毕,栈为空时,事件循环机制就会从任务队列中取出一个任务放入主线程执行,如此反复,这个过程就叫做事件循环。

下面通过一个简单的定时器例子来理解:

console.log('同步任务1');
setTimeout(() => {
    console.log('异步任务(定时器)');
}, 0);
console.log('同步任务2');

在这段代码中,“同步任务1” 和 “同步任务2” 是同步任务,它们会在主线程上依次执行。而 setTimeout 是一个异步任务,虽然设置的延迟时间为 0,但它并不会立即执行,而是被放入任务队列中。主线程先打印 “同步任务1”,然后遇到 setTimeout,将其回调函数放入任务队列,接着打印 “同步任务2”。当主线程上的同步任务执行完毕,栈为空时,事件循环从任务队列中取出 setTimeout 的回调函数放入主线程执行,最后打印出 “异步任务(定时器)”。

三、任务队列的分类

在 JavaScript 中,任务队列其实分为两种:宏任务队列(Macro Task Queue)和微任务队列(Micro Task Queue)。

常见的宏任务有 setTimeoutsetIntervalsetImmediate(Node.js 环境)、I/O 操作、UI rendering 等。而常见的微任务有 Promise.thenprocess.nextTick(Node.js 环境)、MutationObserver(浏览器环境)等。

事件循环的执行顺序是,先执行完主线程上的同步任务,然后检查微任务队列,如果微任务队列中有任务,就依次执行微任务队列中的所有任务,直到微任务队列为空。之后再从宏任务队列中取出一个宏任务放入主线程执行,执行完这个宏任务后,又会再次检查微任务队列,如此循环。

以下面代码为例:

console.log('同步任务');
setTimeout(() => {
    console.log('宏任务(定时器)');
}, 0);
Promise.resolve().then(() => {
    console.log('微任务(Promise.then)');
});

首先,打印 “同步任务”。然后,setTimeout 的回调函数被放入宏任务队列,Promise.resolve().then 的回调函数被放入微任务队列。主线程同步任务执行完毕后,检查微任务队列,发现有任务,于是打印 “微任务(Promise.then)”,此时微任务队列已空。接着从宏任务队列中取出 setTimeout 的回调函数放入主线程执行,打印 “宏任务(定时器)”。

四、JavaScript 的变量作用域

变量作用域是指变量的有效范围,在 JavaScript 中,主要有两种作用域:全局作用域和函数作用域。

  1. 全局作用域:在 JavaScript 代码的最外层定义的变量具有全局作用域,这些变量在整个脚本中都可以访问。例如:
var globalVar = '全局变量';
function printGlobalVar() {
    console.log(globalVar);
}
printGlobalVar(); // 输出:全局变量
console.log(globalVar); // 输出:全局变量

在这个例子中,globalVar 是在全局作用域定义的变量,在函数 printGlobalVar 内部以及全局代码中都可以访问到。

  1. 函数作用域:在函数内部定义的变量具有函数作用域,这些变量只能在函数内部访问,在函数外部无法访问。例如:
function functionScope() {
    var localVar = '函数内变量';
    console.log(localVar);
}
functionScope(); // 输出:函数内变量
console.log(localVar); // 报错:localVar 未定义

functionScope 函数内部定义的 localVar 变量,只能在该函数内部访问,在函数外部尝试访问会报错。

五、块级作用域的引入(ES6 之前与之后的区别)

在 ES6 之前,JavaScript 并没有真正的块级作用域。例如:

for (var i = 0; i < 5; i++) {
    console.log(i);
}
console.log(i); // 输出:5

这里使用 var 声明变量 i,虽然 i 是在 for 循环块内使用,但它并没有块级作用域,在循环结束后,i 仍然存在于函数作用域(如果是在全局代码中,就是全局作用域)中,所以在循环外部仍然可以访问到 i,并且值为循环结束时的值。

ES6 引入了 letconst 关键字,它们具有块级作用域。例如:

for (let j = 0; j < 5; j++) {
    console.log(j);
}
console.log(j); // 报错:j 未定义

使用 let 声明的 j 变量具有块级作用域,只在 for 循环块内有效,在循环外部无法访问,尝试访问会报错。

const 同样具有块级作用域,而且一旦声明,值就不能被改变(对于对象和数组,是指不能重新赋值整个对象或数组,但可以修改对象的属性或数组的元素)。例如:

{
    const PI = 3.14159;
    console.log(PI);
}
console.log(PI); // 报错:PI 未定义

这里在块级作用域内使用 const 声明了 PI,在块外部无法访问。

六、作用域链

当在 JavaScript 中访问一个变量时,会先在当前作用域中查找,如果找不到,就会向上一级作用域查找,直到找到该变量或者到达全局作用域。这种由内向外层层查找变量的链条就叫做作用域链。

例如:

var outerVar = '外部变量';
function outerFunction() {
    var innerVar = '内部变量';
    function innerFunction() {
        console.log(innerVar); // 输出:内部变量
        console.log(outerVar); // 输出:外部变量
    }
    innerFunction();
}
outerFunction();

innerFunction 中,先查找自身作用域,找到了 innerVar 并输出。然后查找 outerFunction 的作用域,没找到 outerVar,继续向上查找全局作用域,找到了 outerVar 并输出。

七、闭包与作用域

闭包是 JavaScript 中一个非常重要的概念,它与作用域密切相关。闭包是指函数可以访问并操作其外部作用域的变量,即使外部函数已经执行完毕。

例如:

function outer() {
    var outerValue = 10;
    function inner() {
        console.log(outerValue);
    }
    return inner;
}
var closureFunction = outer();
closureFunction(); // 输出:10

在这个例子中,outer 函数返回了 inner 函数。当 outer 函数执行完毕后,按照常理,其内部的变量 outerValue 应该被销毁。但由于 inner 函数形成了闭包,它持有对 outerValue 的引用,所以 outerValue 不会被销毁。当调用 closureFunction(即 inner 函数)时,仍然可以访问到 outerValue 并输出其值。

闭包在实际应用中有很多场景,比如封装私有变量、实现模块模式等。例如,可以通过闭包实现一个简单的计数器:

function counter() {
    var count = 0;
    return function() {
        count++;
        return count;
    };
}
var myCounter = counter();
console.log(myCounter()); // 输出:1
console.log(myCounter()); // 输出:2

这里 counter 函数返回的内部函数形成了闭包,它可以访问并修改 counter 函数内部的 count 变量,实现了一个简单的计数器功能。

八、事件循环与变量作用域的综合案例分析

下面通过一个复杂一点的案例来综合分析事件循环与变量作用域:

var globalValue = '全局值';
function outerFunction() {
    var outerValue = '外部值';
    setTimeout(() => {
        var setTimeoutValue = '定时器内值';
        console.log(globalValue);
        console.log(outerValue);
        console.log(setTimeoutValue);
    }, 0);
    Promise.resolve().then(() => {
        var promiseThenValue = 'Promise.then 内值';
        console.log(globalValue);
        console.log(outerValue);
        console.log(promiseThenValue);
    });
    console.log('外部函数执行中');
}
outerFunction();
  1. 事件循环角度

    • 首先,主线程执行 outerFunction 函数内的同步任务。打印 “外部函数执行中”。
    • 然后,setTimeout 的回调函数被放入宏任务队列,Promise.resolve().then 的回调函数被放入微任务队列。
    • 主线程同步任务执行完毕,检查微任务队列,执行 Promise.then 回调函数。在这个回调函数内,根据作用域链,先查找自身作用域找到 promiseThenValue,再查找 outerFunction 作用域找到 outerValue,最后查找全局作用域找到 globalValue 并依次打印。
    • 微任务队列执行完毕后,从宏任务队列中取出 setTimeout 回调函数放入主线程执行。同样根据作用域链,依次查找并打印 globalValueouterValuesetTimeoutValue
  2. 变量作用域角度

    • globalValue 是全局变量,在任何地方都可以通过作用域链访问到。
    • outerValueouterFunction 函数作用域内的变量,在 outerFunction 内部以及其内部函数(如 setTimeoutPromise.then 的回调函数)中可以通过作用域链访问。
    • setTimeoutValuesetTimeout 回调函数作用域内的变量,只能在该回调函数内访问。
    • promiseThenValuePromise.then 回调函数作用域内的变量,只能在该回调函数内访问。

通过这样的综合案例分析,可以更深入地理解 JavaScript 中事件循环与变量作用域是如何相互配合,影响代码的执行和变量的访问的。在实际开发中,准确把握这两个概念对于编写高效、正确的 JavaScript 代码至关重要。无论是处理复杂的异步操作,还是进行模块化开发、封装变量等,都离不开对事件循环和变量作用域的深刻理解。