Skip to content

闭包与作用域

词法作用域 vs 动态作用域

JavaScript 采用词法作用域(Lexical Scope),也称为静态作用域。作用域在代码编写阶段就已经确定,与函数在何处调用无关,只与函数在何处定义有关。

js
const value = 1

function foo() {
  console.log(value)
}

function bar() {
  const value = 2
  foo()
}

bar()

输出结果是 1,而非 2。因为 foo 定义在全局作用域中,它的外层作用域是全局作用域,查找 value 时沿着定义时的作用域链向上查找,找到全局的 value = 1

如果 JavaScript 采用动态作用域,foo 中的 value 将在调用栈中向上查找,找到 bar 中的 value = 2,输出 2。Bash 脚本就是典型的动态作用域语言。

深度原理:词法作用域的本质是函数在创建时会通过内部属性 [[Scope]] 保存其所在的词法环境引用链。这条链在函数定义时就已经固化,不会因为调用位置的变化而改变。ECMAScript 规范中将这一行为定义为:函数对象创建时,[[Environment]] 内部槽被设置为当前运行的执行上下文的词法环境(LexicalEnvironment)。


执行上下文与执行上下文栈

什么是执行上下文

执行上下文(Execution Context)是 JavaScript 引擎执行代码时创建的一个抽象环境。每当引擎进入一段可执行代码,就会创建对应的执行上下文。可执行代码分为三种类型:

  • 全局代码:脚本最外层的代码
  • 函数代码:函数体内的代码
  • Eval 代码:传递给 eval() 的代码

执行上下文栈(Call Stack)

JavaScript 引擎使用一个执行上下文栈(Execution Context Stack) 来管理执行上下文。栈底始终是全局执行上下文,栈顶是当前正在执行的上下文。

js
function third() {
  console.log("third")
}

function second() {
  third()
}

function first() {
  second()
}

first()

执行上下文栈的变化过程:

[全局EC]
[全局EC, first EC]
[全局EC, first EC, second EC]
[全局EC, first EC, second EC, third EC]   ← 执行 console.log
[全局EC, first EC, second EC]              ← third EC 弹出
[全局EC, first EC]                         ← second EC 弹出
[全局EC]                                   ← first EC 弹出

创建阶段与执行阶段

每个执行上下文都经历两个阶段:

1. 创建阶段(Creation Phase)

在代码执行之前,引擎做三件事:

  • 创建变量对象(Variable Object):扫描函数声明和变量声明,进行提升
  • 建立作用域链(Scope Chain):将当前变量对象与 [[Scope]] 属性串联
  • 确定 this 的指向

2. 执行阶段(Execution Phase)

逐行执行代码,对变量进行赋值,执行函数调用等。

js
function showName() {
  var myName = "极客时间"
  function innerShow() {
    return myName
  }
  return innerShow()
}

showName()

showName 被调用时:

创建阶段:

showNameEC = {
  VO: {
    arguments: {},
    innerShow: <function reference>,
    myName: undefined
  },
  ScopeChain: [showNameEC.VO, globalEC.VO],
  this: window
}

执行阶段:

showNameEC = {
  VO -> AO: {
    arguments: {},
    innerShow: <function reference>,
    myName: "极客时间"
  },
  ScopeChain: [showNameEC.AO, globalEC.VO],
  this: window
}

变量对象(VO)与活动对象(AO)

变量对象(Variable Object)

变量对象是与执行上下文关联的数据作用域,存储了在上下文中定义的变量和函数声明。对于全局上下文,变量对象就是全局对象(浏览器中是 window)。

活动对象(Activation Object)

在函数上下文中,变量对象被称为活动对象。它在函数被调用时创建,最初只包含 arguments 对象。活动对象本质上和变量对象是同一个对象,只是处于不同生命周期阶段的称呼不同——进入执行上下文时叫 VO(此时不可被代码直接访问),开始执行代码后叫 AO(此时被激活,可被访问)。

变量对象的创建遵循以下规则(按优先级排序):

  1. 函数的形参:以形参名为属性名,实参值为属性值;未传入的实参,属性值为 undefined
  2. 函数声明:以函数名为属性名,函数体引用为属性值;若已存在同名属性,直接覆盖
  3. 变量声明(var):以变量名为属性名,undefined 为属性值;若已存在同名属性(含形参或函数),则跳过不覆盖
js
function test(a, b) {
  var c = 10
  function d() {}
  var e = function () {}
  b = 20
}

test(1)

创建阶段的 AO:

AO = {
  arguments: { 0: 1, length: 1 },
  a: 1,
  b: undefined,
  c: undefined,
  d: <function d reference>,
  e: undefined
}

执行阶段的 AO:

AO = {
  arguments: { 0: 1, length: 1 },
  a: 1,
  b: 20,
  c: 10,
  d: <function d reference>,
  e: <function expression reference>
}

全局上下文中的 VO

全局上下文的变量对象就是全局对象本身。在浏览器中,全局对象是 window,这意味着全局中声明的 var 变量和函数都成为 window 的属性:

js
var globalVar = "hello"
function globalFunc() {}

console.log(window.globalVar)
console.log(window.globalFunc)

作用域链的形成机制

作用域链的形成分为两个关键步骤:

第一步:函数创建时保存 [[Scope]]

函数在定义时,会将当前执行上下文的作用域链保存到自身的内部属性 [[Scope]] 中:

js
function outer() {
  function inner() {}
}
outer.[[Scope]] = [globalEC.VO]

inner.[[Scope]] = [outerEC.AO, globalEC.VO]

第二步:函数调用时构建完整作用域链

函数被调用时,创建执行上下文,将自身的 AO 放在 [[Scope]] 的前端,形成完整的作用域链:

Scope Chain = [currentEC.AO, ...[[Scope]]]

通过一个完整的例子来追踪整个过程:

js
const scope = "global scope"

function checkScope() {
  const scope = "local scope"
  function f() {
    return scope
  }
  return f()
}

checkScope()

1. 全局上下文创建,checkScope 函数被定义:

checkScope.[[Scope]] = [globalEC.VO]

2. checkScope 被调用,创建其执行上下文:

checkScopeEC = {
  AO: { scope: undefined, f: <function reference> },
  ScopeChain: [checkScopeEC.AO, globalEC.VO]
}

3. f 函数被定义,保存作用域链:

f.[[Scope]] = [checkScopeEC.AO, globalEC.VO]

4. f 被调用,创建其执行上下文:

fEC = {
  AO: {},
  ScopeChain: [fEC.AO, checkScopeEC.AO, globalEC.VO]
}

5. 在 f 中查找 scope

沿作用域链查找:fEC.AO → 没有 → checkScopeEC.AO → 找到 "local scope" → 返回。

作用域链的本质是一个有序列表,变量查找时从链的头部(当前 AO)开始,逐级向外查找,直到全局 VO。若到全局仍未找到,则抛出 ReferenceError


变量提升(Hoisting)的完整规则

var 的提升规则

var 声明的变量在创建阶段被提升到当前函数作用域顶部,初始值为 undefined

js
console.log(a)
var a = 10
console.log(a)

等价于:

js
var a
console.log(a)
a = 10
console.log(a)

function 声明的提升规则

函数声明整体提升,包括函数名和函数体,且优先级高于 var

js
console.log(foo)
function foo() {
  return "hello"
}
var foo = 1
console.log(foo)

执行结果为 function foo()...1

原理:创建阶段先处理函数声明(foo 指向函数体),然后处理 var foo,但由于 foo 已存在于 VO 中,var 声明被跳过(不会覆盖)。执行阶段 foo = 1 赋值将 foo 重新指向 1

函数声明覆盖规则——后定义的同名函数声明会覆盖先定义的:

js
console.log(foo)

function foo() {
  return "first"
}

function foo() {
  return "second"
}

输出的 foo 指向第二个函数声明。

let/const 的提升规则

letconst 同样存在提升,但与 var 的行为有本质区别。它们在创建阶段被提升到块级作用域顶部,但不会被初始化。从作用域顶部到声明语句之间的区域称为暂时性死区(Temporal Dead Zone),在此区间内访问变量会抛出 ReferenceError

js
console.log(x)
let x = 10

三种变量声明的提升对比:

声明方式提升创建阶段初始化作用域重复声明
varundefined函数作用域允许
let✅(不初始化)❌(TDZ)块级作用域不允许
const✅(不初始化)❌(TDZ)块级作用域不允许
function✅(含函数体)函数引用函数作用域后者覆盖

综合提升示例

js
var a = 1
function a() {}
console.log(a)

if (true) {
  console.log(typeof b)
  let b = 2
}

第一段输出 1:函数声明先被提升,a 指向函数;然后 var a 被跳过(已存在);执行阶段 a = 1 赋值覆盖函数引用。

第二段抛出 ReferenceErrorlet b 存在提升但处于 TDZ,在声明前访问直接报错。


暂时性死区(TDZ)的原理

什么是 TDZ

暂时性死区是 letconst 声明的变量从块级作用域起始位置声明语句所在位置之间的一段区域。在这个区域中,变量已经存在于当前词法环境中(已绑定),但尚未完成初始化,任何对其的访问操作都会抛出 ReferenceError

js
{
  console.log(x)
  console.log(y)

  let x = 1
  const y = 2
}

TDZ 的底层实现机制

从 ECMAScript 规范角度来看,变量的生命周期分为三个阶段:

  1. 创建(Create Binding):在词法环境中注册变量名
  2. 初始化(Initialize Binding):为变量分配内存并赋予初始值
  3. 赋值(Set Binding):执行代码中的赋值操作

不同声明方式在这三个阶段的表现:

  • var:创建 → 初始化为 undefined → 赋值(创建和初始化在创建阶段同步完成)
  • let:创建 → (TDZ 区间) → 初始化 → 赋值(创建和初始化分离)
  • const:创建 → (TDZ 区间) → 初始化+赋值(必须在声明时赋值)
  • function:创建 → 初始化 → 赋值(三步在创建阶段全部完成)

TDZ 的隐蔽场景

1. 函数参数默认值中的 TDZ:

js
function foo(a = b, b = 1) {
  console.log(a, b)
}

foo()

参数从左到右求值,求值 a = bb 尚未初始化,触发 TDZ。

2. typeof 不再安全:

对于未声明的变量,typeof 返回 "undefined" 不会报错。但在 TDZ 中,typeof 同样会抛出 ReferenceError

js
typeof undeclaredVar

typeof tdzVar
let tdzVar = 1

第一行输出 "undefined",第二行抛出 ReferenceError

3. class 声明也存在 TDZ:

js
const instance = new MyClass()
class MyClass {
  constructor() {
    this.name = "test"
  }
}

闭包的定义与形成条件

定义

闭包是指一个函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行。换言之,当一个函数引用了其外部函数作用域中的变量,并且这个内部函数被传递到外部函数作用域之外时,就形成了闭包。

从引擎视角看,闭包的本质是:当外部函数执行结束后,其执行上下文从调用栈弹出,但由于内部函数仍然持有对外部函数 AO 的引用(通过 [[Scope]]),导致外部函数的 AO 无法被垃圾回收,依然存活于内存中。

形成条件

  1. 存在函数嵌套
  2. 内部函数引用了外部函数的变量
  3. 内部函数被返回或以其他方式传递到外部函数之外
js
function createCounter() {
  let count = 0
  return function () {
    count++
    return count
  }
}

const counter = createCounter()
console.log(counter())
console.log(counter())
console.log(counter())

执行流程的深度分析:

1. createCounter 被定义:

createCounter.[[Scope]] = [globalEC.VO]

2. createCounter() 被调用:

createCounterEC = {
  AO: { count: 0 },
  ScopeChain: [createCounterEC.AO, globalEC.VO]
}

匿名函数被定义:

anonymousFunc.[[Scope]] = [createCounterEC.AO, globalEC.VO]

3. createCounter 执行完毕,返回匿名函数:

createCounterEC 从调用栈弹出,但 createCounterEC.AO 不会被回收——因为匿名函数的 [[Scope]] 仍然引用着它。

4. 调用 counter()(即匿名函数):

anonymousFuncEC = {
  AO: {},
  ScopeChain: [anonymousFuncEC.AO, createCounterEC.AO, globalEC.VO]
}

查找 count → 自身 AO 没有 → 在 createCounterEC.AO 中找到 → 执行 count++

这就是闭包的核心——内部函数持有外部 AO 的引用,使得变量 countcreateCounter 返回后依然存活。


闭包的经典应用场景

数据封装(私有变量)

JavaScript 没有原生的私有变量支持(ES2022 的 # 之前),闭包是实现数据封装的经典手段:

js
function createBankAccount(initialBalance) {
  let balance = initialBalance

  return {
    deposit(amount) {
      if (amount <= 0) throw new Error("Amount must be positive")
      balance += amount
      return balance
    },
    withdraw(amount) {
      if (amount > balance) throw new Error("Insufficient balance")
      balance -= amount
      return balance
    },
    getBalance() {
      return balance
    },
  }
}

const account = createBankAccount(1000)
console.log(account.getBalance())
account.deposit(500)
account.withdraw(200)
console.log(account.getBalance())
console.log(account.balance)

balance 变量被完全封装在闭包内部,外部只能通过暴露的方法操作它,无法直接访问或篡改,这提供了类似面向对象语言的访问控制能力。

柯里化(Currying)

柯里化将一个接受多个参数的函数转化为一系列接受单个参数的函数:

js
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args)
    }
    return function (...moreArgs) {
      return curried.apply(this, args.concat(moreArgs))
    }
  }
}

function add(a, b, c) {
  return a + b + c
}

const curriedAdd = curry(add)
console.log(curriedAdd(1)(2)(3))
console.log(curriedAdd(1, 2)(3))
console.log(curriedAdd(1)(2, 3))

每次调用 curried 返回的新函数都闭包捕获了已收集的 args,直到参数数量满足原函数要求时才真正执行计算。

模块模式(Module Pattern)

在 ES Modules 之前,闭包是 JavaScript 实现模块化的核心机制:

js
const EventBus = (function () {
  const listeners = {}

  function on(event, callback) {
    if (!listeners[event]) {
      listeners[event] = []
    }
    listeners[event].push(callback)
  }

  function emit(event, ...args) {
    if (listeners[event]) {
      listeners[event].forEach(function (cb) {
        cb(...args)
      })
    }
  }

  function off(event, callback) {
    if (listeners[event]) {
      listeners[event] = listeners[event].filter(function (cb) {
        return cb !== callback
      })
    }
  }

  return { on, emit, off }
})()

EventBus.on("data", function (payload) {
  console.log("Received:", payload)
})
EventBus.emit("data", { id: 1 })

IIFE 执行后,listeners 被封装在闭包内部,外部完全无法访问,只能通过 onemitoff 三个公共接口操作。这正是 揭示模块模式(Revealing Module Pattern) 的经典形态。

防抖与节流

防抖(Debounce):事件触发后等待一段时间才执行,若在等待期间再次触发则重新计时。

js
function debounce(fn, delay) {
  let timer = null
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, delay)
  }
}

const handleSearch = debounce(function (query) {
  console.log("Searching for:", query)
}, 300)

timer 变量通过闭包在多次调用间持久存在,使得每次调用都能访问并清除上一次的定时器。

节流(Throttle):限制函数在一段时间内只执行一次。

js
function throttle(fn, interval) {
  let lastTime = 0
  return function (...args) {
    const now = Date.now()
    if (now - lastTime >= interval) {
      lastTime = now
      fn.apply(this, args)
    }
  }
}

const handleScroll = throttle(function () {
  console.log("Scroll position:", window.scrollY)
}, 200)

lastTime 通过闭包在每次调用间持久记忆上一次执行的时间戳。


闭包的内存泄漏问题与解决方案

为什么闭包可能导致内存泄漏

闭包延长了外部函数作用域中变量的生命周期。只要闭包函数存在,其引用的外部 AO 就不会被垃圾回收。如果闭包持有了大量数据的引用且闭包本身长期存在,就会造成内存泄漏。

js
function processData() {
  const hugeData = new Array(1000000).fill("*".repeat(1000))

  return function summary() {
    return hugeData.length
  }
}

const getSummary = processData()

summary 函数仅需要 hugeData.length,但由于闭包机制,整个 hugeData 数组(约 1GB)都无法被回收。

经典内存泄漏场景

1. 被遗忘的事件监听器:

js
function setup() {
  const element = document.getElementById("button")
  const heavyResource = new Array(1000000).fill("data")

  element.addEventListener("click", function () {
    console.log(heavyResource.length)
  })
}

setup()

即使 DOM 元素被移除,如果事件监听器未被清理,闭包仍然持有 heavyResource 的引用。

2. 被遗忘的定时器:

js
function startPolling() {
  const cache = []

  setInterval(function () {
    cache.push(new Array(10000).fill("data"))
    console.log("Cache size:", cache.length)
  }, 1000)
}

startPolling()

cache 通过闭包不断被填充,且没有清理机制,内存持续增长。

3. 闭包中的意外引用(V8 优化相关):

js
function outer() {
  const unused = new Array(1000000).fill("x")
  const used = "hello"

  return function inner() {
    return used
  }
}

现代 V8 引擎会对此进行优化——如果 inner 函数中没有使用 evalwithdebugger 等动态作用域操作,V8 只会在闭包中保留实际被引用的变量(used),unused 会被回收。但如果 inner 中使用了 eval,V8 将无法做静态分析优化,会保留整个 AO:

js
function outer() {
  const unused = new Array(1000000).fill("x")
  const used = "hello"

  return function inner() {
    eval("")
    return used
  }
}

此时 unused 也不会被回收。

解决方案

1. 主动解除引用:

js
function processData() {
  let hugeData = new Array(1000000).fill("*".repeat(1000))
  const length = hugeData.length

  hugeData = null

  return function summary() {
    return length
  }
}

2. 清理事件监听器和定时器:

js
function setup() {
  const element = document.getElementById("button")
  const heavyResource = new Array(1000000).fill("data")

  function handleClick() {
    console.log(heavyResource.length)
  }

  element.addEventListener("click", handleClick)

  return function cleanup() {
    element.removeEventListener("click", handleClick)
  }
}

const teardown = setup()
teardown()

3. 使用 WeakRef 和 FinalizationRegistry(ES2021):

js
function createCache() {
  const cache = new Map()

  return {
    set(key, value) {
      cache.set(key, new WeakRef(value))
    },
    get(key) {
      const ref = cache.get(key)
      if (ref) {
        const value = ref.deref()
        if (value === undefined) {
          cache.delete(key)
        }
        return value
      }
      return undefined
    },
  }
}

4. 缩小闭包捕获范围:

js
function processData() {
  const result = (function () {
    const hugeData = new Array(1000000).fill("*".repeat(1000))
    return { length: hugeData.length, avg: hugeData.reduce((a, b) => a + b.length, 0) / hugeData.length }
  })()

  return function summary() {
    return result
  }
}

通过 IIFE 在内部完成计算并只暴露结果,大数据在 IIFE 执行后即可被回收。


经典面试题解析

题目一:循环中的闭包

js
for (var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i)
  }, i * 1000)
}

输出:每隔一秒输出一次 5,共输出 5 次。

原理分析

var 声明的 i 是函数作用域级别的变量(这里是全局变量),循环中创建的 5 个 setTimeout 回调函数全部闭包引用同一个 i。循环结束后 i 的值为 5,当定时器依次触发时,回调函数沿作用域链找到的 i 都是 5

这里不存在 5 个独立的 i 副本,只有一个共享的 i

解法一:IIFE 创建独立作用域

js
for (var i = 0; i < 5; i++) {
  ;(function (j) {
    setTimeout(function () {
      console.log(j)
    }, j * 1000)
  })(i)
}

每次迭代通过 IIFE 创建一个新的函数作用域,j 作为形参接收当前 i 的值,回调闭包捕获的是各自独立的 j

解法二:let 块级作用域

js
for (let i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i)
  }, i * 1000)
}

let 在每次循环迭代中创建一个独立的块级作用域,每个回调捕获各自迭代的 i。从规范角度看,for 循环的每次迭代都会创建一个新的词法环境(LexicalEnvironment),并将上一轮的 i 值复制到新环境中。

解法三:setTimeout 第三个参数

js
for (var i = 0; i < 5; i++) {
  setTimeout(
    function (j) {
      console.log(j)
    },
    i * 1000,
    i
  )
}

setTimeout 的第三个及后续参数会作为回调函数的实参传入,每次传入的 i 是当时的值副本。

题目二:闭包与对象方法

js
var name = "The Window"

var object = {
  name: "My Object",
  getNameFunc: function () {
    return function () {
      return this.name
    }
  },
}

console.log(object.getNameFunc()())

输出"The Window"(非严格模式下)

原理分析

object.getNameFunc() 返回一个匿名函数,这个匿名函数随后被直接调用——此时调用方式是独立调用(plain call),而非作为对象的方法调用。在非严格模式下,独立调用的函数其 this 指向全局对象(window),因此 this.name 是全局的 "The Window"

这里的关键是:闭包保留的是作用域链,不会保留 thisthis 是在函数调用时动态确定的,与闭包无关。

修正方案一:通过变量保存 this

js
var object = {
  name: "My Object",
  getNameFunc: function () {
    var that = this
    return function () {
      return that.name
    }
  },
}

console.log(object.getNameFunc()())

that 在外层函数的作用域中,闭包可以正确捕获它。

修正方案二:箭头函数

js
var object = {
  name: "My Object",
  getNameFunc: function () {
    return () => {
      return this.name
    }
  },
}

console.log(object.getNameFunc()())

箭头函数没有自己的 this,它继承定义时所在上下文的 this,即 getNameFunc 调用时的 thisobject)。

题目三:闭包与变量共享

js
function createFunctions() {
  var result = []
  for (var i = 0; i < 3; i++) {
    result.push(function () {
      return i
    })
  }
  return result
}

var funcs = createFunctions()
console.log(funcs[0]())
console.log(funcs[1]())
console.log(funcs[2]())

输出3, 3, 3

原理分析

三个函数都闭包引用同一个 createFunctions 的 AO,其中 i 在循环结束后值为 3。这是循环闭包问题的数组变体。

createFunctionsEC.AO = { result: [...], i: 3 }

funcs[0].[[Scope]] → createFunctionsEC.AO (i === 3)
funcs[1].[[Scope]] → createFunctionsEC.AO (i === 3)
funcs[2].[[Scope]] → createFunctionsEC.AO (i === 3)

题目四:闭包的作用域链深度分析

js
var a = 1
function outer() {
  var a = 2
  function inner() {
    var a = 3
    console.log(a)
  }
  inner()
  console.log(a)
}
outer()
console.log(a)

输出3, 2, 1

原理分析

  • inner() 执行时,自身 AO 中 a = 3,直接使用,输出 3
  • inner 执行完毕弹出调用栈,回到 outer 的执行上下文,outer 的 AO 中 a = 2,输出 2
  • outer 执行完毕弹出调用栈,回到全局执行上下文,全局 VO 中 a = 1,输出 1

每层函数都有自己的 AO,变量查找遵循就近原则——优先在自身 AO 中查找,找到即停止,不再向上查找。这就是作用域链的遮蔽效应(Variable Shadowing)

题目五:IIFE 与闭包的综合题

js
var fn = []

for (var i = 0; i < 3; i++) {
  fn[i] = (function (n) {
    return function () {
      console.log(n)
    }
  })(i)
}

fn[0]()
fn[1]()
fn[2]()

console.log(i)

输出0, 1, 2, 3

原理分析

每次迭代中 IIFE 立即执行,创建一个独立的函数执行上下文,形参 n 分别接收 012。返回的内部函数各自闭包捕获不同 IIFE 的 AO,因此 n 是独立的。

IIFE_0_EC.AO = { n: 0 }  → fn[0] 闭包引用此 AO
IIFE_1_EC.AO = { n: 1 }  → fn[1] 闭包引用此 AO
IIFE_2_EC.AO = { n: 2 }  → fn[2] 闭包引用此 AO

最后 console.log(i) 访问全局的 i,循环结束后 i3

这道题同时考察了 IIFE 的作用域隔离能力、闭包对独立 AO 的捕获、以及 var 的函数作用域特性。

用心学习,用代码说话 💻