技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

JavaScript 闭包的底层运行机制

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/37-JavaScript闭包的底层运行机制
本文目录14 个章节
  1. 一、什么是闭包
  2. 二、词法作用域和环境记录
  3. 三、全局环境
  4. 四、函数创建时保存什么
  5. 五、函数调用与环境链
  6. 六、嵌套函数与闭包保留
  7. 七、闭包实现“私有变量”
  8. 八、经典的循环回调陷阱
  9. 九、闭包与垃圾回收
  10. 十、this 不是普通标识符绑定
  11. 十一、模块也是闭包边界
  12. 十二、总结
  13. 图片与来源
  14. 参考资料

JavaScript 闭包的底层运行机制

Category(分类): JavaScript Status: 已整理(2026)

本文保留原译文关于词法作用域、作用域链、闭包共享状态、var 循环陷阱和垃圾回收的教学主线。原文使用了 [[scope]]、Scope object 和“作用域对象在堆中”等较早的实现示意;这些内容已标注为历史模型,并按现代 ECMAScript 的 Environment Record、函数 [[Environment]] 和可达性模型重新表述。

原文译自:How do JavaScript closures work under the hood

一、什么是闭包

闭包通常可以理解为:函数与它定义时能够访问的词法环境结合在一起的结果。当函数被传递、返回或保存到定义它的外层作用域之外时,它仍然可以访问那些外层绑定,这就是闭包最容易观察到的行为。

function createCounter(initial = 0) {
  let value = initial

  return {
    increment(step = 1) {
      value += step
    },
    getValue() {
      return value
    }
  }
}

const counter = createCounter(100)
console.log(counter.getValue()) // 100
counter.increment(5)
console.log(counter.getValue()) // 105

createCounter 已经返回,但 incrementgetValue 仍然能访问 value。外部代码不能直接读写这个词法绑定,只能通过返回的方法操作它,因此闭包常被用于实现模块私有状态。

1.1 不是所有对象都是闭包

原文总结“所有 JavaScript 对象都是闭包”,这是不准确的。普通对象、数组、字符串包装对象并不会因为是对象就拥有一个外层词法环境。更准确的说法是:函数对象会保存与其定义环境相关的内部状态;当这个函数被保留下来时,相关环境也可能继续可达。

闭包是一个语义概念,不要求引擎真的创建一个名为“闭包”的对象,更不要求每个环境都对应一个可观察的 JavaScript 对象。

二、词法作用域和环境记录

JavaScript 使用词法作用域:变量能否被访问,主要由代码的书写位置决定,而不是由函数最终在哪里调用决定。

const globalValue = 'global'

function outer() {
  const outerValue = 'outer'

  function inner() {
    const innerValue = 'inner'
    return [globalValue, outerValue, innerValue]
  }

  return inner
}

const innerFunction = outer()
console.log(innerFunction()) // ['global', 'outer', 'inner']

调用 innerFunction() 时,标识符查找大致从当前函数的环境开始,再沿外部环境链接向上查找:

  1. inner 自己的参数和局部绑定;
  2. outer 创建的词法环境;
  3. 全局环境;
  4. 如果仍然找不到,抛出 ReferenceError

这与原型链查找有“逐层向上”的相似之处,但两者不是同一条链:

  • 词法环境链用于解析标识符,例如 outerValue
  • 原型链用于查找对象属性,例如 object.toString
  • 词法环境中找不到变量会得到 ReferenceError
  • 原型链中找不到普通属性通常得到 undefined

2.1 规范术语与历史术语

原文多次使用 Scope object 和函数内部属性 [[scope]]。这套说法可以帮助初学者画图,但需要明确:

  • [[scope]] 不是可从 JavaScript 访问的标准内部属性;
  • 对相关 ECMAScript function object,现代规范用内部槽 [[Environment]] 表示函数创建时关联的环境;这不是所有可调用对象都以同样方式拥有的公开属性;
  • 规范中的 Environment Record 是用于描述绑定的抽象机制,不要求引擎实现成普通 JavaScript 对象;
  • LexicalEnvironmentVariableEnvironment 和执行上下文是规范模型,不等同于“每个变量都放在堆对象属性上”。

因此,下面的图示应当看作历史/实现示意,不是引擎内存布局或可以通过反射读取的对象结构。

三、全局环境

原文用 var foovar bar 说明全局作用域。现代代码需要区分经典脚本、ES module 和不同宿主:

// 在浏览器经典脚本中,var 全局声明通常创建全局对象属性。
var scriptVar = 1

// let/const/class 的顶层绑定不等同于 globalThis 属性。
let lexicalValue = 2
const constantValue = 3

console.log(scriptVar, lexicalValue, constantValue)

在浏览器经典脚本中,var、函数声明和全局对象存在特殊关系;全局 let/const/class 则属于声明式绑定。ES module 顶层代码自动处于严格模式,并拥有 Module Environment Record:

// module-example.js
export const moduleValue = 1

const privateValue = 2
export function readModuleValue() {
  return moduleValue + privateValue
}

模块变量不会自动成为 globalThis 属性。不要把“全局环境”简单等同于一个 Global Object。

历史图示:全局代码与环境

图片来源:原文图示,已本地化;图中的 [[scope]] 应按本文现代说明理解。

四、函数创建时保存什么

当普通 ECMAScript 函数声明或函数表达式被创建时,函数对象会关联它定义位置的环境。绑定函数、Proxy 等其他可调用对象可能遵循不同的内部结构。用规范化的伪代码表示,可以理解为:

FunctionCreate(parameters, body, scope)
  → 创建函数对象
  → 将函数的 [[Environment]] 设置为 scope

这并不意味着可以运行 function.[[Environment]],它仍然是规范内部槽。函数执行时,会以该外部环境为起点创建当前调用的执行上下文。

const topLevel = 'top'

function readTopLevel() {
  return topLevel
}

console.log(readTopLevel()) // top

历史图示:函数对象与定义环境

图片来源:原文图示,已本地化。

五、函数调用与环境链

普通函数被调用时,会创建一个新的函数环境,其中可能包含参数、局部变量、arguments、函数声明和 this 相关信息:

function myFunc(input) {
  const localValue = input * 2
  return localValue
}

console.log(myFunc(3)) // 6

执行 myFunc(3) 时,inputlocalValue 属于这次调用的环境。函数返回后,如果没有任何可达函数或对象继续引用它们,相关存储就可能在未来被回收。

历史图示:函数调用创建新的环境

图片来源:原文图示,已本地化。

5.1 环境不是一定在堆或栈

原文说 JavaScript 的作用域对象“在堆中创建”,这可以作为解释闭包生命周期的直觉,但不是 ECMAScript 的实现要求。引擎可以把没有逃逸的局部变量优化到寄存器或栈,也可以把逃逸变量提升到其他存储结构;只要可观察行为符合规范即可。

因此应说:闭包使某些绑定在语义上保持可访问,具体存储位置和优化由引擎决定。

六、嵌套函数与闭包保留

下面是修复原文语法后的嵌套函数示例:

function makeGreeting(prefix) {
  function greet(name) {
    return `${prefix}, ${name}`
  }

  return greet
}

const greet = makeGreeting('Hello')
console.log(greet('Ada')) // Hello, Ada

greet 被返回后仍然可以读取 prefix。从外部看不到这个绑定,只能调用函数得到结果。

更完整的计数器示例:

function createCounter(initial) {
  let counter = initial

  function increment(value) {
    counter += value
  }

  function get() {
    return counter
  }

  return { increment, get }
}

const myCounter = createCounter(100)
console.log(myCounter.get()) // 100
myCounter.increment(5)
console.log(myCounter.get()) // 105

历史图示:createCounter 调用后的环境

图片来源:原文图示,已本地化。

返回对象中的两个方法都能访问同一份 counter 绑定,因此 increment 修改后,get 读取到的是修改后的值:

历史图示:返回函数继续引用外层环境

图片来源:原文图示,已本地化。

调用 myCounter.increment(5) 时,会新增一个 increment 调用环境。value 在当前环境中找到,counter 则沿外部环境链接找到:

历史图示:嵌套函数访问外层绑定

图片来源:原文图示,已本地化。

6.1 作用域链不是每次复制

函数调用不会把整个外部环境复制一份。多个闭包可以引用同一个外部绑定,因此一个闭包修改变量,其他闭包能够看到变化:

function createPair() {
  let value = 0

  return {
    add() {
      value += 1
    },
    read() {
      return value
    }
  }
}

const pair = createPair()
pair.add()
console.log(pair.read()) // 1

如果想要相互隔离的状态,需要分别执行外层工厂函数:

function createCounter(initial) {
  let value = initial
  return {
    increment(step) {
      value += step
    },
    get() {
      return value
    }
  }
}

const counter1 = createCounter(100)
const counter2 = createCounter(200)

counter1.increment(3)
console.log(counter1.get()) // 103
console.log(counter2.get()) // 200

两个返回函数的源代码可以相同,但它们关联的是不同的外层环境:

历史图示:两次工厂调用拥有不同环境

图片来源:原文图示,已本地化。

七、闭包实现“私有变量”

闭包可以隐藏状态,但它不是安全边界:如果返回了对象引用,外部仍可能通过该引用修改内部对象。

function createStore() {
  const state = { count: 0 }

  return {
    increment() {
      state.count += 1
    },
    getState() {
      return { ...state }
    }
  }
}

const store = createStore()
store.increment()
const snapshot = store.getState()
snapshot.count = 100
console.log(store.getState().count) // 1:返回了浅复制快照

如果状态中含有嵌套对象,应根据领域需求使用深复制、不可变数据或只返回必要字段。闭包只是封装方式之一,现代 class 还可以使用语言级私有字段:

class Counter {
  #value = 0

  increment() {
    this.#value += 1
  }

  get value() {
    return this.#value
  }
}

八、经典的循环回调陷阱

原文使用 var 循环注册点击事件:

const elements = document.getElementsByClassName('item')

for (var i = 0; i < elements.length; i += 1) {
  elements[i].addEventListener('click', function () {
    console.log(i)
  })
}

这些回调共享同一个函数作用域中的 i。循环结束时 i 通常已经等于 elements.length,所以点击时不会得到各自的索引。

现代写法优先使用 let,每次循环迭代会形成独立的词法绑定:

for (let i = 0; i < elements.length; i += 1) {
  elements[i].addEventListener('click', () => {
    console.log(i)
  })
}

也可以使用 forEach

Array.from(elements).forEach((element, index) => {
  element.addEventListener('click', () => {
    console.log(index)
  })
})

旧环境中常见的 IIFE 修复方式仍然有效,但属于历史兼容写法:

for (var i = 0; i < elements.length; i += 1) {
  (function (index) {
    elements[index].addEventListener('click', function () {
      console.log(index)
    })
  })(i)
}

事件监听器还应在不需要时移除,或者使用 AbortController

const controller = new AbortController()

elements[0].addEventListener('click', () => {
  console.log('clicked')
}, { signal: controller.signal })

controller.abort()

九、闭包与垃圾回收

ECMAScript 不规定具体的垃圾回收算法和时间。更准确的表述是:

  • 仍可从根对象、活动执行上下文、全局对象、事件监听器、定时器或其他可达对象访问的函数和环境,不能被安全回收;
  • 当相关引用都不可达时,存储空间具备被回收的条件;
  • “具备回收条件”不代表下一行代码就会立即回收;
  • 闭包本身不是内存泄漏,长期保留不必要的大对象引用才可能造成问题。

例如:

function createHandler(largeData) {
  return () => largeData.id
}

let handler = createHandler({ id: 1, payload: new Array(1000).fill('data') })
console.log(handler())

// 当不再需要时解除引用,之后是否及何时回收由引擎决定。
handler = null

浏览器中的全局事件监听器、Node.js 中的定时器、缓存和模块级变量都可能延长闭包的生命周期。排查内存问题应使用 DevTools heap snapshot、allocation timeline 等工具,而不要仅凭“函数返回了”下结论。

十、this 不是普通标识符绑定

闭包保存的是词法变量,this 通常由函数的调用形式决定。规范中的 Function Environment Record 可以保存 [[ThisValue]],但 this 不是通过普通标识符解析规则取得的变量;普通函数和箭头函数的规则不同:

const object = {
  value: 1,
  method() {
    return this.value
  },
  arrow: () => this.value
}

console.log(object.method()) // 1
console.log(object.arrow()) // 通常不是 1:箭头函数捕获定义处的 this

如果需要在回调中保留对象接收者,可以使用箭头闭包或 bind

const objectWithCallback = {
  value: 1,
  start() {
    setTimeout(() => {
      console.log(this.value)
    }, 0)
  }
}

不要把 this 简化成“作用域链中的一个普通变量”。它由 ResolveThisBinding 等调用/执行语义处理;箭头函数会从外部词法上下文捕获 this,但这仍不等同于普通变量查找。

十一、模块也是闭包边界

ES module 的顶层变量位于模块环境中,导出的函数可以继续访问它们:

// counter-module.js
let value = 0

export function increment() {
  value += 1
}

export function read() {
  return value
}
// app.js
import { increment, read } from './counter-module.js'

increment()
console.log(read()) // 1

模块导入是 live binding:如果导出方修改了绑定,导入方在读取时可以看到更新。模块状态通常只在同一模块图中共享;不同 Worker、iframe、Realm 或 Node loader 不应假定它们拥有同一份状态。

十二、总结

  • 闭包是函数与其定义处词法环境结合后的可观察语义;
  • 词法作用域由代码书写位置决定,函数调用会新增当前执行环境;
  • 函数的 [[Environment]] 是规范内部槽,旧文中的 [[scope]] 只是历史实现示意;
  • Environment Record 不是必须存在的普通 JavaScript 对象,具体堆/栈位置由引擎决定;
  • 多个闭包可以共享外层绑定,分别执行工厂函数则拥有独立绑定;
  • let 可以解决经典 var 循环回调问题,IIFE 是旧环境的替代方案;
  • GC 依据可达性,函数返回并不意味着环境会立即销毁;
  • 闭包不是内存泄漏,但事件监听器、定时器和缓存可能让闭包长期可达;
  • this 的绑定规则与词法变量不同,箭头函数捕获外部 this
  • ES module 也形成模块级封装,但模块缓存边界取决于宿主、模块图和 Realm。

图片与来源

原文图示已下载到 tech_article/JavaScript/images/

原目录中的 JavaScript闭包的底层运行机制/image.png 也保留为历史附件;它不是正文中的运行示意图。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS