首页 / 文章

JavaScript 如何执行代码:栈、堆、事件循环与生成器

JavaScript运行时导览:调用栈与帧、堆引用、Node.js事件循环、DOM、回调函数、Promise、async/await以及生成器。

发布者:REACTAPP.TOP阅读约 13 分钟2982 词

来源: JavaScript

异步 JavaScript 的行为常常让开发者感到意外:日志信息似乎“过早”出现,await 语句好像跳过了某些步骤,生成器只输出了部分内容。一旦你掌握了运行时执行代码的清晰模型,这些意外几乎都会消失。本文将逐步构建这一模型,从调用栈和堆开始,再到 Node.js 事件循环、DOM,以及 promises、async/await 和生成器,这样你就能在阅读代码之前就预测出其输出结果。

运行时的组成部分

JavaScript 在单个主线程上运行,但程序在等待计时器、网络响应和文件时并不会卡住,这是因为引擎并非独自工作:

  • 调用栈,用于记录当前正在执行的函数
  • 堆内存,对象及其他动态分配的数据存储于此
  • 宿主 API(浏览器中的Web API、服务器端的Node.js API),负责处理定时器、文件及网络相关操作
  • 回调(任务)队列,已完成的任务在此等待执行
  • 微任务队列,主要由 promise 使用
  • 事件循环,决定下一步要执行的内容
  • 你的代码在调用栈上执行,而运行时则负责处理耗时的操作。当某个操作完成后,其对应的回调会被加入队列,一旦调用栈空出,事件循环就会将其放入栈中执行。

    Node.js 事件循环概览

    该图展示了 Node.js 的执行周期。首先运行同步代码,接着是排队的微任务,然后是循环的各个阶段:定时器(setTimeout、setInterval)、待处理的回调函数、轮询阶段(大多数文件和网络相关回调在此运行)、检查阶段(setImmediate)以及关闭回调函数。

    ┌───────────────────┐
    │    CALL STACK     │
    │    JavaScript     │
    └─────────┬─────────┘
              │
              │ Synchronous code finishes
              ▼
    ┌───────────────────┐
    │  MICROTASK QUEUE  │
    │                   │
    │  Promise.then()   │
    │  Promise.catch()  │
    │  queueMicrotask() │
    └─────────┬─────────┘
              │
              ▼
    ╔═════════════════════════════════════════════╗
    ║                EVENT LOOP                   ║
    ║                                             ║
    ║  ┌───────────────┐                          ║
    ║  │     TIMERS    │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │    PENDING    │                          ║
    ║  │    CALLBACKS  │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     POLL      │                          ║
    ║  │      I/O      │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     CHECK     │                          ║
    ║  │  setImmediate │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     CLOSE     │                          ║
    ║  │    CALLBACKS  │                          ║
    ║  └───────┬───────┘                          ║
    ║          └───────────────────────────────┐  ║
    ║                                          │  ║
    ╚══════════════════════════════════════════╪══╝
                                               │
                                               ▼
                                      ┌───────────────────┐
                                      │    CALL STACK     │
                                      │    Execute JS     │
                                      └───────────────────┘
    

    有两点需要特别注意。微任务并非在每次循环中只处理一次:Node会在每个回调之后清空微任务队列(以及其前的process.nextTick队列),因此Promise的.then处理函数会在下一个定时器回调之前执行。此外,浏览器虽然遵循任务与微任务共存的原则,但没有setImmediate或libuv相关机制,且会交错进行渲染操作。关于Node特有的细节,请参阅libuv、事件循环与线程池如何协同工作。

    调用栈

    调用栈记录了当前正在运行的函数以及哪些函数正在等待它返回。它采用后进先出结构:最新调用的函数必须执行完毕,其下方的函数才能继续运行。

    在下面的示例中,func1 调用 func2,func2 又调用 func3,而 func3 会输出一条消息:

    function func1() {
      func2();
    }
    function func2() {
      func3();
    }
    function func3() {
      console.log("Hello");
    }
    func1();
    

    调用以这样的顺序依次叠加:

    func1()
       ↓
    func2()
       ↓
    func3()
       ↓
    console.log()
    

    就像一叠盘子一样,console.log 最后被执行且最先完成,接着 func3、func2 和 func1 依次返回。只有当调用栈为空时,排队的回调函数才能执行,这就是为什么漫长的同步循环会阻塞其他所有操作的原因。

    堆内存

    堆内存用于存储那些生命周期不依赖于单次调用的数据,例如对象、数组和闭包。

    创建一个对象字面量时,它会被分配到堆内存中:

    const user = {
      name: "Rayane",
      age: 20
    };
    

    该变量仅保存对存储在堆内存中的对象的引用:

    CALL STACK                  HEAP
    ┌───────────────┐          ┌────────────────────┐
    │ user variable │─────────▶│ {                  │
    │               │          │   name: "Rayane",  │
    └───────────────┘          │   age: 20          │
                               │ }                  │
                               └────────────────────┘
    

    将user赋值给另一个变量只是复制了引用而非对象本身,因此对任一变量的修改都会在另一变量中体现出来。一旦没有可用的引用指向某个对象,垃圾回收器就可以回收它;而那些捕获了该对象的长期存在的闭包则会使其保持存活状态。

    事件循环的协调作用

    事件循环将你的代码与异步任务的完成情况联系在一起。在Node.js中,V8负责执行你的代码;对Node API的调用,比如读取文件,会传递给libuv,再由libuv将其交给操作系统或线程池处理。当任务完成后,回调函数就会准备好,最终由事件循环在栈上执行它。

    JavaScript code
          │
          ▼
       V8 Engine
          │
          ▼
      Call Stack
          │
          ▼
    Node.js APIs
          │
          ▼
        libuv
          │
          ├───────────────┐
          ▼               ▼
         OS           Thread Pool
          │               │
          └───────┬───────┘
                  ▼
         Asynchronous operation
                  │
                  ▼
         Callback becomes ready
                  │
                  ▼
             Event Loop
                  │
                  ▼
           Callback Queue
                  │
                  ▼
             Call Stack
    

    路由的处理方式取决于操作类型:套接字通常由操作系统通过epoll或kqueue来处理,而文件系统操作以及某些加密和DNS调用则由线程池来处理。

    栈帧

    每次函数调用都会压入一个栈帧,其中保存着函数运行时所需的信息:

    • 函数的参数
    • 函数的局部变量
    • 返回地址,即执行在调用者处继续的位置
    • 引擎的其他管理信息

    以一个用于计算两个数之和的函数为例:

    function add(a, b) {
      const result = a + b;
      return result;
    }
    add(2, 3);
    

    当执行 add(2, 3) 时,其栈帧中包含参数、局部计算结果以及指向调用者的链接:

         CALL STACK
    ┌─────────────────────┐
    │   stack frame: add  │
    │                     │
    │   a = 2             │
    │   b = 3             │
    │   result = 5        │
    │   return → caller   │
    └─────────────────────┘
    

    函数返回后,该栈帧就会被丢弃:

         CALL STACK
    ┌─────────────────────┐
    │   frame removed     │
    └─────────────────────┘
    

    整个生命周期非常简洁:

    Function call
          ↓
    Stack Frame created
          ↓
    Function executes
          ↓
    Function returns
          ↓
    Stack Frame removed
    

    如果递归过深,栈帧会不断累积,最终导致引擎因超过最大调用栈大小而抛出 RangeError 错误。

    DOM

    文档对象模型是浏览器基于HTML文档构建的动态节点树结构。通过它,JavaScript可以:

    • 读取元素
    • 更改其内容
    • 插入新元素
    • 删除现有元素
    • 修改样式和属性

    DOM是浏览器API,并非语言本身的组成部分,因此纯Node.js环境中不存在document。

    以下是HTML中的一个标题元素:

    <h1 id="title">Hello</h1>
    

    脚本可以通过元素的id获取对该元素的引用:

    const title = document.getElementById("title");
    

    然后替换其文本内容:

    title.textContent = "Hello Rayane!";
    

    在修改之前,该元素的子文本节点中保存着原始字符串:

    document
       │
       ▼
    <h1 id="title">
       │
       ▼
    "Hello"
    

    执行完相同的赋值操作后:

    title.textContent = "Hello Rayane!";
    

    节点树会反映出新的文本内容:

    document
       │
       ▼
    <h1 id="title">
       │
       ▼
    "Hello Rayane!"
    

    浏览器随后会重新绘制页面。在实际代码中,当没有匹配项时,需检查getElementById返回的null值;对于不可信的字符串,建议使用textContent而非innerHTML,因为前者不会解析HTML。

    回调函数

    回调函数是指传递给另一个函数的函数,以便日后调用。在这个例子中,greet函数被传递给execute函数,后者会传入一个姓名来调用它:

    function greet(name) {
      console.log(`Hello ${name}`);
    }
    function execute(callback) {
      callback("Ajami");
    }
    execute(greet);
    

    程序会输出:

    Hello Ajami
    

    在execute函数内部,名为callback的参数指的是greet函数,这一行代码会调用它:

    callback("Ajami");
    

    回调函数本身并非异步的;这个例子中的回调会立即执行。只有当接收方在某个事件发生之后触发它时,比如定时器触发,它才会变成异步的。

    Promise对象

    Promise代表了异步操作的最终结果。你启动该操作后可以继续执行其他代码,等到Promise状态确定后再处理其结果。

    fetch是一个典型的返回Promise的API:

    const promise = fetch("https://api.example.com/data");
    

    你可以通过绑定处理函数来响应结果:

    • .then()用于处理成功的结果
    • .catch()用于处理失败的情况
    • .finally()用于在无论何种情况下都需执行的清理操作
    fetch("https://api.example.com/data")
      .then((response) => {
        console.log("Request finished");
      })
      .catch((error) => {
        console.log("Something went wrong:", error.message);
      })
      .finally(() => {
        console.log("Request completed");
      });
    

    启动请求不会造成阻塞;处理函数会作为微任务在稍后执行。注意,fetch仅在网络故障时才会拒绝Promise。404或500状态仍然会使Promise满足,因此需要自行检查response.ok。

    Promise状态

    Promise始终处于以下三种状态之一:

    PENDING
       │
       ├───────────────┐
       ▼               ▼
    FULFILLED       REJECTED
    
    • 待处理:操作尚未完成。
    • 已完成:操作成功完成并有了结果值。
    • 已被拒绝:操作失败且有原因,通常是错误。

    一个承诺只会被处理一次,之后添加的处理函数仍然能收到已处理的结果。

    Async/await

    async函数总是返回一个承诺,而await则允许你按顺序编写异步步骤。该函数会先输出日志,等待请求完成后再输出一次日志,调用结束后还会再输出一条日志:

    async function leetfinish() {
      console.log("The function starts");
    const res = await fetch("https://api.example.com/finish");
      console.log("The request finished");
    }
    leetfinish();
    console.log("Let's review our code");
    

    可能的输出结果是:

    The function starts
    Let's review our code
    The request finished
    

    为什么外层的日志会第二次输出

    函数先打印第一条消息,然后执行到这一行:

    const res = await fetch("https://api.example.com/finish");
    

    fetch会返回一个待处理的承诺对象,而await会暂停函数执行并将控制权交还给调用者,同时不会冻结线程。调用者会先输出其消息;只有当响应到达后,函数才会继续执行。因此:

    The function starts
    Let's review our code
    The request finished
    

    await暂停的是函数,而非线程。而且“很可能”这一点很重要:如果请求失败,最后那行代码将永远不会被执行,未被捕获的错误就会变成未处理的拒绝状态。

    使用try、catch和finally处理错误

    在async/await模式下,可以使用普通的try/catch/finally结构。这个示例用于获取数据、验证响应及预算信息,并始终重置加载状态标志。其中的注释指出了两个要点:HTTP错误不会导致拒绝,而且response.json()也会返回一个承诺对象。

    let isLoading = true;
    async function checkBudget() {
      try {
        // fetch() returns a Promise.
        // await pauses this function until the Promise settles.
        const response = await fetch(
          "https://api.example.com/khouribga/business/budget"
        );
        // fetch() does NOT reject automatically for HTTP errors
        // such as 404 or 500.
        if (!response.ok) {
          throw new Error("Failed to fetch business data");
        }
        // response.json() also returns a Promise.
        const business = await response.json();
        if (business.budget < 10) {
          throw new Error("Budget is too low");
        }
        console.log("Budget is sufficient:", business.budget);
      } catch (error) {
        console.log("Something went wrong:", error.message);
      } finally {
        // This runs whether the request succeeds or fails.
        isLoading = false;
        console.log("Loading finished:", isLoading);
      }
    }
    checkBudget();
    

    逐步说明:

    1. fetch()用于发送请求,而await则会使函数暂停执行。
    2. 当响应到达时,函数会继续执行。
    3. HTTP状态码出错时会转换为异常并抛出。
    4. JSON数据会被解析,同时预算也会被验证。
    5. try块中的任何错误,包括网络故障,都会被捕获到catch块中。
    6. finally块始终会被执行,用于清除加载状态。

    练习:并行运行异步函数

    一个不错的练习是LeetCode第2721题,该题要求你同时启动多个操作而非依次等待,本质上就是自己实现一个Promise.all函数。

    生成器

    生成器函数可以暂停和恢复执行,从而逐步产生值。它使用function*声明,暂停点则用yield标记:

    function* generatorAjami() {
      // Instructions ...
      yield i;
      // More instructions ...
      yield i;
      // More instructions ...
    }
    

    比较 yield 和 return:

    • return 会返回一个值并结束函数。
    • yield 会返回一个值并暂停函数执行,同时保留其状态。

    逐步执行生成器

    这个生成器在三个 yield 语句之前都会打印一条消息:

    function* generatorAjami() {
      console.log("First born in casa");
      yield 10;
      console.log("Second move to Khouribga");
      yield 20;
      console.log("Third live in Khouribga");
      yield 30;
    }
    

    调用它时不会执行任何代码块内容:

    const generator = generatorAjami();
    

    你会得到一个生成器对象,即可以通过 .next() 获取值的迭代器:

    console.log(generator.next());
    

    第一次调用会执行到第一个 yield 语句,并打印第一条消息:

    First born in casa
    { value: 10, done: false }
    

    执行会在该语句处停止:

    yield 10;
    

    并返回 10。每次 .next() 调用的结果都有两个属性:

    • value,即被 yield 出来的值
  • done,表示生成器是否已完成
  • 因此结果如下:

    { value: 10, done: false }
    

    意为“这里是10,还有更多内容即将输出”。

    再次调用 .next():

    console.log(generator.next());
    

    会产生:

    Second move to Khouribga
    { value: 20, done: false }
    

    第二条消息现在才出现,是因为生成器在之前的 yield 之后立即继续执行。

    第三次调用:

    console.log(generator.next());
    

    会打印第三条消息并输出 30:

    Third live in Khouribga
    { value: 30, done: false }
    

    第四次调用则:

    console.log(generator.next());
    

    提示已没有剩余内容:

    { value: undefined, done: true }
    

    由于没有显式的 return,最终的 value 为 undefined。

    生成器究竟是什么

    生成器不会多次返回值:一次执行只会开始并结束一次,但可以通过暂停和继续来输出多个值。第一次调用会执行到 yield 10:

    generator.next()
           ↓
    Execute instructions
           ↓
    yield 10
           ↓
    Pause and return { value: 10, done: false }
    

    第二次调用会从那里继续执行,直到 yield 20 才停止:

    generator.next()
           ↓
    Resume after yield 10
           ↓
    yield 20
           ↓
    Pause and return { value: 20, done: false }
    

    第三次调用会继续执行,直到 yield 30 才停止:

    generator.next()
           ↓
    Resume after yield 20
           ↓
    yield 30
           ↓
    Pause and return { value: 30, done: false }
    

    第四次调用时找不到更多语句,便会报告执行完毕:

    generator.next()
           ↓
    No more instructions
           ↓
    Return { value: undefined, done: true }
    

    for...of 语句以及展开运算符会自动调用 .next(),并在 done: true 时停止执行。更多相关模式可参阅使用生成器实现可暂停函数与惰性迭代。 实践:无限斐波那契数列生成器

    LeetCode第2648题非常适合用此方法。生成器会先输出当前值,然后同时推进两个运行中的值。while (true)循环是安全的,因为每次迭代都会在yield处暂停。

    var fibGenerator = function* () {
      let a = 0;
      let b = 1;
      while (true) {
        yield a;
        const next = a + b;
        a = b;
        b = next;
      }
    };
    

    状态的变化过程如下:

    a = 0, b = 1
         ↓
    yield 0
         ↓
    a = 1, b = 1
         ↓
    yield 1
         ↓
    a = 1, b = 2
         ↓
    yield 1
         ↓
    a = 2, b = 3
         ↓
    yield 2
    

    值是按需计算的,每次调用.next()才会计算一个值;切勿在没有限制的情况下进行展开或迭代操作。

    整合模型

    在Node.js中:V8利用其栈和堆来运行代码,Node API会将耗时的任务交给带有事件循环和线程池的libuv处理,而操作系统则负责实际的网络操作。

    ┌─────────────────────────────────────────────┐
    │                  NODE.JS                    │
    │                                             │
    │   ┌─────────────┐                           │
    │   │     V8      │                           │
    │   │             │                           │
    │   │ Call Stack  │                           │
    │   │ Heap        │                           │
    │   └──────┬──────┘                           │
    │          │                                  │
    │          ▼                                  │
    │   ┌───────────────┐                         │
    │   │  Node.js APIs │                         │
    │   └───────┬───────┘                         │
    │           │                                 │
    │           ▼                                 │
    │   ┌───────────────┐                         │
    │   │     libuv     │                         │
    │   │               │                         │
    │   │  ┌─────────┐  │                         │
    │   │  │ Event   │  │                         │
    │   │  │  Loop   │  │                         │
    │   │  └─────────┘  │                         │
    │   │               │                         │
    │   │  Thread Pool  │                         │
    │   └───────┬───────┘                         │
    │           │                                 │
    └───────────┼─────────────────────────────────┘
                │
                ▼
          ┌───────────────┐
          │      OS       │
          │               │
          │ Networking    │
          │ Kernel        │
          │ epoll/kqueue  │
          └───────────────┘
    

    关键要点

    • 只要调用栈不为空,任何排队等待的任务都不会执行,因此长时间的同步操作会阻塞所有进程。
    • 变量保存的是对堆对象的引用;无法访问的对象会被回收。
  • 运行时会执行耗时的操作;Promise 处理函数会在下一次回调之前作为微任务运行。
  • fetch 仅在网络错误时才会拒绝请求,因此需要检查 response.ok 的值。
  • await 会暂停函数执行,而非线程。
  • 生成器在遇到 yield 时会暂停,在调用 .next() 时继续执行,从而实现延迟处理甚至无限序列的生成。