首页 / 文章

面试中讲解浏览器JavaScript:队列、堆、内存泄漏与闭包

为面试准备而设计的浏览器运行时结构化学习指南:调用栈、任务队列与微任务队列、堆内存与垃圾回收、内存泄漏、委托机制、原型对象及闭包。

发布者:REACTAPP.TOP阅读约 10 分钟2346 词

来源: #1 FrontEnd Concepts For Interviews — Javascript Fundament

前端面试很少看重死记硬背的现成答案。面试官会考察你能否解释代码中的引擎是如何工作的:回调函数在何处等待执行,为什么某个对象的生命周期会长于其对应的函数,以及为何容器上的一个监听器就能替代数百个。本指南按照这些概念的递进关系进行讲解,帮助你通过逻辑思考来应对陌生问题,而非机械地背诵答案。一个不错的自我测试方法:每读完一个章节后,就仿佛在向从未接触过该概念的同事讲解一样,大声说出来。

调用栈:代码的执行场所

每个 JavaScript 引擎都在调用栈上运行代码,这是一种后进先出的结构,可以想象成只能在其两端进行插入和删除操作的数组。调用函数时会为其执行上下文推入一个帧,而函数返回时则会弹出该帧。主线程上只有一个这样的栈,因此当某个帧正在运行时,其他任何操作都无法进行。

为何网络请求不会冻结页面

如果一次只能运行一个操作,那为什么在等待后端响应时页面不会卡住呢?因为等待的不是 JavaScript 本身。想象一下,屏幕先处理大量数据集,然后依次调用多个 API。fetch() 函数会将任务交给浏览器提供的 Web API,由它在调用栈之外处理网络请求。你的函数会立即返回一个待处理的承诺对象,这样用户就可以继续与页面交互。

当响应到达时,Promise 会进入完成状态,其 .then() 回调会被作为微任务安排,在任务栈为空时执行。严格来说,网络请求的完成是通过浏览器的任务机制来实现的,而进入微任务队列的是 Promise 的状态变化。需要记住的关键点是,这些回调会被排队处理,而非立即执行。

任务队列与微任务队列

定时器则遵循不同的路径:setTimeout() 的回调会被放入任务队列,也即宏任务队列。事件循环的规则是,在当前任务执行完毕且任务栈为空后,它会先处理完整个微任务队列,然后再选择下一个任务。该机制会分别为这两种任务各安排一个执行时机;消息中的数字则反映了它们的执行顺序。

function triggerBothQueues() {
  console.log("1. Synchronous: Code starts on Call Stack");

  // Enqueue a Macrotask (Task Queue)
  setTimeout(() => {
    console.log("4. Task Queue (Macrotask): setTimeout callback executed");
  }, 0);

  // Enqueue a Microtask (Microtask Queue)
  Promise.resolve().then(() => {
    console.log("3. Microtask Queue: Promise.then callback executed");
  });

  console.log("2. Synchronous: Code ends on Call Stack");
}

triggerBothQueues();

首先执行这两行同步代码,接着是承诺回调函数,最后才是定时器。0的延迟时间表示“尽快入队”,而非“立即执行”,而且微任务仍然会优先运行。一个常见的后续情况是:一个调度另一个微任务的微任务也会加入同一队列,并在所有定时器之前执行,这就是为什么无止境的微任务链会阻碍渲染的原因。

堆:对象存储的地方

栈中存储着执行上下文,而堆中则存放这些上下文所引用的数据。对象、数组、函数以及任何通过引用处理的元素都存在于堆中,而数字和布尔值等本地原始类型在概念上则与函数的执行上下文一同存在。如果在面试中这么说,那只是简化了表述:实际上编译器通常会将字符串分配在堆上并允许它们共享,而作为对象属性存储的原始类型也会与该对象一起存在于堆中。这个模型只是辅助理解的工具,并非正式规范。

垃圾回收与可达性

堆的空间并非无限。后台的垃圾回收器会依据“可达性”这一原则来回收内存:

  • 根节点按定义是可到达的:包括全局变量,以及当前在栈中的函数的参数和局部变量。
  • 可访问值指的是从根节点出发通过引用能够到达的任何内容。
  • 任何无法从任何根节点到达的内容都属于垃圾数据。收集器并不关心你是否还打算使用该对象;只要存在可访问的引用,它就会保留下来。内存泄漏就是由此产生的。如需针对工具层面的深入探讨,请参阅利用可访问性和保留机制排查 JavaScript 内存泄漏。

    两种常见的内存泄漏方式

    永不停止的计时器

    在这个仪表板追踪工具中,函数内部会创建一个大型数组,然后通过间隔回调每秒读取该数组的长度。

    function startDashboardTracker() {
      const hugeDataPayload = new Array(1000000).fill("data");
    
      // Timer is launched
      setInterval(() => {
        // Because hugeDataPayload is referenced here,
        // it stays trapped in memory forever!
        console.log("Tracker pinging... Payload length:", hugeDataPayload.length);
      }, 1000);
    }
    
    startDashboardTracker();
    // Even after startDashboardTracker finishes executing and leaves the Call Stack,
    // hugeDataPayload CANNOT be garbage collected because the timer's closure holds a reference to it.
    

    当 startDashboardTracker 返回后,你可能会认为该数组已被废弃。但实际上并非如此:浏览器会保留正在运行的定时器,该定时器保存着对应的回调函数,而回调函数的闭包则持有 hugeDataPayload。请保存 setInterval() 返回的 ID,在任务完成或相关 UI 组件被卸载时调用 clearInterval();断开这一关联就能释放整个数据链。

    全局对象上的监听器

    这种情况更为隐蔽,因为看起来并没有任何代码在运行。某个搜索组件会构建一个庞大的索引,并在 window 上注册一个尺寸变化处理函数。

    function setupSearchWidget() {
      // Heavy data tied to the widget scope
      const heavySearchIndex = new Array(1000000).fill("search_index_item");
    
      function handleResize() {
        console.log("Window resized. Index size:", heavySearchIndex.length);
      }
    
      // Listener attached to the global 'window' object (a Root)
      window.addEventListener("resize", handleResize);
    }
    
    setupSearchWidget();
    
    // Later, user navigates away and the Search Widget DOM element is deleted.
    // BUT 'window' still holds a reference to 'handleResize',
    // which holds a reference to 'heavySearchIndex'.
    

    当用户离开页面且该组件的 DOM 被移除时,作为根节点的 window 仍然会列出指向 handleResize 的引用,而该函数又引用了 heavySearchIndex。在组件被销毁时,应调用 removeEventListener,并传入与注册时相同的函数引用;新的箭头函数属于不同的对象,因此无法实现移除监听器的功能。

    事件冒泡与委托

    React 和 Angular 隐蔽了大部分这类管理逻辑。其原因之一就是事件委托:React 不会在每个节点上都设置监听器,而是在根容器处监听事件,随着事件向上冒泡再将它们传递给相应的处理函数;同时,其合成事件系统会在组件挂载和卸载时自动添加或移除处理函数。

    委托机制依赖于事件冒泡:点击事件首先被发送到目标元素,然后逐级向上传递给其各个祖先元素。此页面嵌套了三个彩色框,每个框都设置了点击监听器。

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Event Bubbling Example</title>
        <style>
            #grandparent {
                background-color: lightcoral;
                padding: 20px;
            }
            #parent {
                background-color: lightblue;
                padding: 20px;
            }
            #child {
                background-color: lightgreen;
                padding: 20px;
            }
        </style>
    </head>
    <body>
    
    <div id="grandparent">
        <h1>Grandparent - only one event listener</h1>
        <div id="parent">
            Parent
            <div id="child">
                Child
            </div>
        </div>
    </div>
    
    <script>
        document.getElementById('grandparent').addEventListener('click', function(event) {
            console.log('Grandparent clicked!');
            console.log('target: ', event.target.getAttribute("id"));
            // if (event.target.getAttribute("id") === 'child') {
            //     //
            // }
        });
    
        document.getElementById('parent').addEventListener('click', function(event) {
            console.log('Parent clicked!');
            // event.stopPropagation();
        });
    
        document.getElementById('child').addEventListener('click', function(event) {
            console.log('Child clicked!');
            // event.stopPropagation();
        });
    </script>
    
    </body>
    </html>
    

    点击最内层的框时,会依次记录子元素、父元素以及祖父元素的信息。祖父元素通过读取event.target来确定实际被点击的是哪个元素,而注释中的if语句则展示了如何通过ID来区分不同的元素,从而无需为子元素单独设置监听器。这便是微型化的委托机制:树结构顶层的单个监听器即可覆盖所有后代元素,包括后来添加的元素,且所需内存极少。

    带有注释的 event.stopPropagation() 调用展示了相反的控制逻辑:在子元素中启用该函数后,它会在事件传达到父元素之前将其阻止(详见 stopPropagation 的文档说明)。应谨慎使用此函数,因为它还会使上层的分派处理程序无法正常工作,包括框架的根级监听器。直接测试该文件是值得的;在接触框架抽象之前,先熟悉普通的 addEventListener 会更容易理解。

    函数式编程简述

    函数式编程(FP)常被想象成由独立机器组成的流水线:每台机器接收输入、生成输出,且不与其他机器发生任何交互。而面向对象代码往往倾向于修改对象状态,函数式编程则避免状态变更,并基于以下五项原则:

    1. 纯函数:输出仅取决于输入,且不会产生任何外部副作用。
  • 不可变性:转换会生成新值,而不会修改原有值。
  • 一等函数:函数是可以存储和传递的值。
  • 高阶函数:能够接收或返回函数的函数,从而实现函数组合(可参阅高阶函数概述)。
  • 声明式风格:通过map和filter等方式描述期望的结果,而非手动管理计数器与状态。
  • 类与原型

    自ES2022起,类支持使用#前缀定义真正的私有字段。此处#balance仅能在类体内部访问。

    // 1. PURE JAVASCRIPT (Native ES2022+)
    class BankAccount {
      #balance = 0; // Native JS private field (using #)
    
      constructor(owner, initialBalance) {
        this.owner = owner;
        this.#balance = initialBalance;
      }
    
      deposit(amount) {
        this.#balance += amount;
      }
    }
    
    const account = new BankAccount("Alex", 1000);
    // account.#balance; ❌ Uncaught SyntaxError (Hard runtime privacy)
    

    从外部读取account.#balance会引发SyntaxError,因此隐私性是由语言本身强制保障的,而非依赖惯例。

    在底层,JavaScript仍然采用原型继承机制:对象会将属性查询委托给原型。在ES6出现之前,类似类的结构是通过构造函数手动实现的,比如这个带有储蓄子类的账户版本。

    // 1. BASE CONSTRUCTOR FUNCTION (Parent Class)
    function BankAccount(owner, initialBalance) {
      this.owner = owner; // Public property
    
      // PRIVATE VARIABLE (Data Privacy via Closure)
      // Lives in function scope, completely hidden from the outside world!
      var balance = initialBalance;
    
      // Public method privileged to access the private 'balance' variable
      this.deposit = function (amount) {
        balance += amount;
        this._logTransaction("Deposited quot; + amount + ". New Balance: quot; + balance);
      };
    }
    
    // PROTECTED / SHARED METHODS (Prototypal Inheritance)
    // Attached to the prototype so methods aren't re-created for every single instance
    BankAccount.prototype._logTransaction = function (action) {
      console.log("[" + this.owner + "]: " + action);
    };
    
    // 2. DERIVED CONSTRUCTOR FUNCTION (Child Class)
    function SavingsAccount(owner, initialBalance, interestRate) {
      // Call Parent Constructor with 'this' context (Super Call)
      BankAccount.call(this, owner, initialBalance);
    
      this.interestRate = interestRate;
    }
    
    // Inherit parent prototype methods on the child
    SavingsAccount.prototype = Object.create(BankAccount.prototype);
    SavingsAccount.prototype.constructor = SavingsAccount; // Reset constructor reference
    
    // Child Public Method
    SavingsAccount.prototype.addInterest = function () {
      // Calls the inherited prototype method
      this._logTransaction("Applied " + this.interestRate + "% interest");
    };
    
    // 3. USAGE
    var mySavings = new SavingsAccount("Alex", 1000, 5);
    mySavings.deposit(200);   // -> [Alex]: Deposited $200. New Balance: $1200
    mySavings.addInterest();  // -> [Alex]: Applied 5% interest
    

    注意,balance通过闭包实现了私有化:它存在于构造函数的作用域中,只有deposit能够访问它。_logTransaction位于原型上,因此所有实例共享同一个函数,其下划线仅表示按照惯例它是“受保护的”。BankAccount.call(this, ...)相当于手动实现的super(),而Object.create则在恢复被覆盖的constructor属性之前建立原型链接。

    现代前端代码本来就更倾向于组合而非继承。如果需要更严格的面向对象检查,比如访问修饰符和接口,TypeScript会更合适;这篇较长的讲解会进一步深入探讨这些内容。

    闭包

    还有许多值得探索的内容,从Canvas和其他浏览器API到模块、迭代器和生成器,但闭包几乎出现在每一场面试中,而且你已经在那些泄漏示例中接触过它们。闭包是指与创建它的词法环境绑定在一起的函数。由于内部函数会保留对该环境的引用,因此即使外部函数已经返回,它仍然可以读取和修改外部函数的变量。

    function createCounter() {
      let count = 0; // Outer variable
    
      return function() {
        count++; // Inner function accesses 'count'
        return count;
      };
    }
    
    const counter = createCounter();
    
    console.log(counter()); // 1
    console.log(counter()); // 2
    console.log(counter()); // 3
    

    count 的值会在多次调用之间保持不变,因为返回的函数能够持续访问其所在的环境,而这恰恰与垃圾回收规则相关。每次调用 createCounter 都会生成一个独立的计数器。

    实用的闭包应用:防抖功能

    一个实际的应用场景是防抖处理:只有在经过指定延迟后输入停止时才执行函数,比如实时搜索功能。

    function debounce(func, delay = 300) {
      let timer; // 1. The closure variable that holds the timeout reference
    
      return function (...args) {
        // 2. If the function is called again before the delay finishes,
        // cancel the previous scheduled run
        clearTimeout(timer);
    
        // 3. Schedule a new run after the delay
        timer = setTimeout(() => {
          func.apply(this, args);
        }, delay);
      };
    }
    
    // --- Usage Example ---
    
    function handleSearch(query) {
      console.log(`Searching API for: ${query}`);
    }
    
    // Wrap our search function in the debounce function
    const debouncedSearch = debounce(handleSearch, 500);
    
    // Simulating fast typing into an input field:
    debouncedSearch("a");    // Timer starts...
    debouncedSearch("ap");   // Cancels "a", resets timer...
    debouncedSearch("app");  // Cancels "ap", resets timer...
    debouncedSearch("apple");// Cancels "app", resets timer...
    
    // Only "Searching API for: apple" will log to the console
    // 500ms after the last keypress!
    

    封闭的 timer 即为状态。每次调用都会清除即将到期的超时并安排新的定时任务,因此只会有连续调用中的最后一次触发:快速输入“a”、“ap”、“app”、“apple”,则会在最后一次按键后500毫秒执行一次对“apple”的搜索。func.apply(this, args) 会传递参数和调用上下文。关于取消操作及边缘情况,请参阅从零开始编写防抖工具函数。

    关键要点

    • 代码由一个调用栈执行;浏览器API负责等待并通过队列返回结果。
    • 所有待处理的微任务都会在下一个任务(如定时器回调)之前执行。
    • 收集器会保留从根节点可访问的所有内容,无论其是否必要。
  • 全局变量上未被清除的间隔函数和监听器是典型的内存泄漏问题,需用原始引用将其清理。
  • 冒泡机制支持委托处理,这也是框架能够高效处理事件的方式。
  • 类是基于语法而非原型实现的,而闭包则解释了私有状态、防抖机制以及诸多内存泄漏问题。