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

显示模式

登录
ARCHIVE DOCUMENTJS

译 发现 JavaScript 中闭包的强大威力

所属分类
JavaScript
文件格式
Markdown
原始路径
JavaScript/11-[译]发现 JavaScript 中闭包的强大威力
本文目录12 个章节
  1. 一、什么是闭包
  2. 二、作用域
  3. 三、嵌套作用域与词法作用域
  4. 四、外部函数执行完毕后,闭包仍然可以工作
  5. 五、闭包与循环
  6. 六、闭包与封装性
  7. 七、闭包与私有状态对象
  8. 八、闭包在函数式编程中的应用
  9. 九、闭包与垃圾回收
  10. 十、避免不必要的全局变量
  11. 十一、总结
  12. 参考资料

译 发现 JavaScript 中闭包的强大威力

Category(分类): JavaScript Status: 已更新

原文主题是“闭包如何帮助实现词法作用域、私有状态和模块封装”。本文保留原文的示例顺序和历史背景,修正抓取噪声、无效 Fetch 地址、TodoStore 变量错误以及 Object.freeze() 的过度描述,并补充现代模块、垃圾回收和事件生命周期说明。

原文标题:Discover the power of closures in JavaScript(原 Medium 地址已失效)

历史译文:掘金文章 译者:wcflmy

一、什么是闭包

闭包可以从两个层面理解:

  1. 函数在创建时会关联定义位置的词法环境;
  2. 当函数在定义位置之外被调用,仍然可以访问那个环境中的绑定时,我们能明显观察到闭包的效果。

因此,闭包不是“外部函数执行完后才生成的一种特殊对象”,也不等同于“所有内部函数”。更准确的说法是:函数与它所关联的词法环境一起构成了闭包语义。

function createLogger() {
  const message = 'hello closure'

  return function log() {
    console.log(message)
  }
}

const log = createLogger()
log() // hello closure

createLogger() 已经返回,但 log() 仍能访问 message。只要 log 仍然可达,运行环境就必须保留读取 message 所需的环境信息。

二、作用域

作用域决定名称在哪里可见。JavaScript 中常见的作用域包括:

  • 函数作用域;
  • 块作用域;
  • 模块作用域;
  • 脚本和全局环境。

var 绑定到最近的函数或脚本全局环境,不受普通代码块限制;let 和 const 绑定到最近的词法环境,包括块、函数体和模块。

(function autorun() {
  const x = 1

  function log() {
    console.log(x)
  }

  log()
})()

log() 可以访问 autorun() 的 x,这是词法作用域的结果。函数在定义时确定外部作用域,而不是根据调用位置动态寻找变量。

块作用域示例

{
  const message = 'from block'

  setTimeout(() => {
    console.log(message)
  }, 0)
}

虽然代码块已经执行完毕,定时器回调仍然引用 message。这不是说变量必然保留到某个固定时间,而是说只要回调仍可达,读取它所需的环境就不能被提前回收。

三、嵌套作用域与词法作用域

(function autorun() {
  const x = 1

  function log() {
    console.log(x)
  }

  function run(fn) {
    const x = 100
    fn()
  }

  run(log) // 1
})()

log 是在 autorun 中定义的,所以它解析到的是 autorun 的 x,而不是 run 中同名的 x。这就是词法作用域:

闭包的外部作用域由定义位置决定,而不是由调用位置决定。

作用域链

每个词法环境都可以关联外层环境。查找变量时,JavaScript 会从当前环境开始,沿环境链向外查找:

const x0 = 0

(function autorun1() {
  const x1 = 1

  ;(function autorun2() {
    const x2 = 2

    ;(function autorun3() {
      const x3 = 3
      console.log(x0, x1, x2, x3) // 0 1 2 3
    })()
  })()
})()

autorun3 可以访问自己的变量、外层函数的变量以及模块/脚本环境中的变量。若同名变量出现在更内层环境,内层绑定会遮蔽外层绑定。

四、外部函数执行完毕后,闭包仍然可以工作

常见场景包括:

  • 定时器回调;
  • DOM 事件监听器;
  • Promise 或 Fetch 的回调;
  • 返回的函数或对象方法;
  • Worker、WebSocket、requestAnimationFrame() 等异步 API 的回调。

1. Timer

function startTimer() {
  const message = 'timer finished'

  return setTimeout(() => {
    console.log(message)
  }, 1000)
}

const timerId = startTimer()

setTimeout() 注册的任务通常会保持对回调的可达引用,回调执行或调用 clearTimeout(timerId) 后,相关引用才可能减少。但“可能被垃圾回收”不等于“会立即被回收”,垃圾回收时间由引擎决定。

2. Event

现代 DOM 代码可以保存 handler 引用,以便组件销毁时移除:

function mountButton(button) {
  const message = 'button clicked'

  function handleClick() {
    console.log(message)
  }

  button.addEventListener('click', handleClick)

  return () => {
    button.removeEventListener('click', handleClick)
  }
}

const button = document.querySelector('#button')
const unmount = button ? mountButton(button) : () => {}
// 生命周期结束时:unmount()

事件监听器不一定要等到显式移除才有机会回收:如果目标、监听器和相关闭包整体都不可达,垃圾回收器可以回收它们。但在长期存活的目标对象上,主动移除监听器仍是清晰、可靠的生命周期管理方式。

3. Fetch

原文的 fetch('http://') 是无效 URL,而且只写成功回调会遗漏网络拒绝。可以使用有效的相对地址:

function loadUser() {
  const message = 'loading user'

  return fetch('/api/user')
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }
      return response.json()
    })
    .then(user => {
      console.log(message, user)
      return user
    })
    .catch(error => {
      console.error('request failed', error)
      throw error
    })
}

Fetch Promise 通常在响应头可用时兑现;读取响应体的 response.json() 仍然是另一个异步步骤。网络失败、Abort 或解析失败都会走拒绝路径。

五、闭包与循环

闭包观察的是词法绑定的当前值,而不是创建函数时自动复制一份值。var 在循环中通常只有一个共享绑定:

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

如果点击发生在循环结束之后,所有回调通常都会看到最终的 i。

1. 使用 let 的逐次迭代绑定

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

for (let ...) 会为每次迭代提供对应的绑定,因此回调可以看到 0、1、2 等不同值。

如果 let 在循环外声明,它仍然只有一个共享绑定:

let index = 0
const handlers = []

while (index < 3) {
  handlers.push(() => index)
  index += 1
}

handlers.map(handler => handler()) // [3, 3, 3]

如果必须使用 var,可以使用 IIFE 或显式传参创建独立绑定;现代代码优先使用 let。

六、闭包与封装性

封装的目标是限制状态的直接访问,只暴露必要操作。闭包可以把状态放在外部无法直接命名的词法环境中。

1. 工厂与共享的私有原型

原文先使用全局原型对象:

const todoPrototype = {
  toString() {
    return `${this.id} ${this.userName}: ${this.title}`
  }
}

function createTodo(todo) {
  const newTodo = Object.create(todoPrototype)
  Object.assign(newTodo, todo)
  return newTodo
}

可以把原型放到 IIFE 闭包中,避免把它作为公共模块变量暴露:

const createTodo = (() => {
  const todoPrototype = {
    toString() {
      return `${this.id} ${this.userName}: ${this.title}`
    }
  }

  return function createTodo(todo) {
    return Object.assign(Object.create(todoPrototype), todo)
  }
})()

const todo = createTodo({
  id: 1,
  title: 'A title',
  userName: 'Cristi',
  completed: false
})

这里的 todoPrototype 不能通过模块外部的同名变量直接访问,但实例仍可以通过 Object.getPrototypeOf(todo) 找到它。闭包隐藏了绑定,不等于实现了不可访问的内存或绝对私有原型。

2. 私有构造函数与浅冻结实例

const createFrozenTodo = (() => {
  function Todo(spec) {
    Object.assign(this, spec)
  }

  return function createTodo(spec) {
    return Object.freeze(new Todo(spec))
  }
})()

const frozenTodo = createFrozenTodo({
  title: 'A description'
})

Object.freeze() 只冻结对象自身的属性,是浅冻结;嵌套对象仍可能被修改。严格模式下对冻结属性赋值会抛错,非严格模式下通常静默失败:

'use strict'

const todo = createFrozenTodo({
  title: 'A description',
  meta: { priority: 1 }
})

// todo.title = 'Another description' // TypeError
// todo.meta.priority = 2 // 仍然可以修改,因为 freeze 是浅层的

如果需要真正的不可变数据,应使用不可变数据结构、复制更新或经过审计的深冻结方案,不要只根据 Object.freeze() 的名字作出深度不可变的结论。

原文:工厂对象的内存快照

3. 私有翻译表

const translate = (() => {
  const translations = new Map([
    ['yes', 'oui'],
    ['no', 'non']
  ])

  return key => translations.get(key)
})()

translate('yes') // 'oui'

使用 Map 可以避免普通对象原型属性和特殊键名带来的额外考虑。如果确实需要字典对象,也可以使用 Object.create(null)。

4. 自增生成器函数

function createGenerator(count, increment) {
  return function generateNext() {
    count += increment
    return count
  }
}

const generateNextNumber = createGenerator(0, 1)
console.log(generateNextNumber()) // 1
console.log(generateNextNumber()) // 2
console.log(generateNextNumber()) // 3

const generateMultipleOfTen = createGenerator(0, 10)
console.log(generateMultipleOfTen()) // 10
console.log(generateMultipleOfTen()) // 20
console.log(generateMultipleOfTen()) // 30

每次调用 createGenerator 都会创建独立的 count 绑定,因此两个生成器互不影响。

七、闭包与私有状态对象

一个对象可以由多个闭包方法共享同一份状态:

function createTodoStore() {
  const todos = []

  function add(todo) {
    todos.push(todo)
  }

  function isPriorityTodo(todo) {
    return todo.type === 'RE' && !todo.completed
  }

  function toTodoViewModel(todo) {
    return { id: todo.id, title: todo.title }
  }

  function get() {
    return todos
      .filter(isPriorityTodo)
      .map(toTodoViewModel)
  }

  return Object.freeze({ add, get })
}

const store = createTodoStore()
store.add({ id: 1, type: 'RE', title: 'Review', completed: false })
store.add({ id: 2, type: 'RE', title: 'Done', completed: true })
store.get() // [{ id: 1, title: 'Review' }]

原文的 isPriorityTodo() 使用了未声明的 task,会在数据不为空时抛出 ReferenceError;这里已改为使用传入的 todo。

Object.freeze({ add, get }) 只冻结返回对象本身,闭包里的 todos 仍然可以通过 add() 修改。暴露哪些操作、是否提供 remove() 或 clear(),取决于模块的设计。

闭包与纯函数不是互斥概念

闭包描述的是函数与词法环境的关系;纯函数描述的是相同输入是否得到相同结果以及是否产生可观察副作用。一个函数可以同时是闭包和纯函数,例如它捕获了不会改变的配置值:

function createMultiplier(factor) {
  return value => value * factor
}

const double = createMultiplier(2)
double(4) // 8

double 是闭包,因为它捕获 factor;在 factor 不变且乘法无外部副作用的前提下,它也可以作为纯函数使用。

八、闭包在函数式编程中的应用

高阶函数接收函数并返回函数,装饰器通常通过闭包保存被装饰的函数:

function not(fn) {
  return function decorator(...args) {
    return !fn.apply(this, args)
  }
}

const isPositive = value => value > 0
const isNotPositive = not(isPositive)

isNotPositive(-1) // true

decorator 捕获了 fn,同时使用 this 和参数把调用转发给原函数。真实装饰器还要考虑函数名、返回值、异常、异步函数和取消语义。

九、闭包与垃圾回收

局部变量并不是函数一返回就必然消失。只要仍有可达对象需要访问它,相关环境就可能保留;当闭包和环境都不可达时,垃圾回收器才有资格回收它们。

下面的例子创建了一个闭包数组:

function createAdder() {
  const values = []

  return value => {
    values.push(value)
    return values.length
  }
}

let add = createAdder()

add({ id: 1 })
add({ id: 2 })

如果应用不再需要这个闭包,可以切断外部引用:

add = null

这只是让闭包及其数组在没有其他引用时有资格被回收,并不保证下一行代码就发生垃圾回收。不能用普通业务逻辑依赖 GC 的精确时间,也不应只凭内存快照一次变化断言某个对象已被回收。

如果是事件监听器或定时器造成的长期引用,更清晰的做法是提供清理函数:

function createPollingTask(callback) {
  const timer = setInterval(callback, 1000)

  return () => {
    clearInterval(timer)
  }
}

const stopPolling = createPollingTask(() => {
  console.log('poll')
})

// 组件卸载时:stopPolling()

原文:闭包持有大量对象时的内存快照

原文:清理引用后的内存快照

十、避免不必要的全局变量

在浏览器 classic script 中,顶层声明与全局环境有关;在 ES Module 中,顶层声明属于模块作用域,不会自动变成 globalThis 属性。现代项目应优先使用模块导出,而不是把所有工厂函数挂到全局对象上。

// module.js
export function createDataService() {
  return {}
}

如果需要演示“应用启动时统一注册模块”的思路,可以用闭包实现一个小型 Loader:

function createLoader() {
  const modules = Object.create(null)
  let started = false

  function getNamespace(namespace) {
    let parent = modules

    for (const part of namespace ? namespace.split('.') : []) {
      if (!Object.hasOwn(parent, part)) {
        parent[part] = Object.create(null)
      }
      parent = parent[part]
    }

    return parent
  }

  function factory(namespaceOrFunction, maybeFunction) {
    if (started) {
      throw new Error('不能在应用启动后注册模块')
    }

    if (typeof namespaceOrFunction === 'string') {
      return function register(fn) {
        if (typeof fn !== 'function' || !fn.name) {
          throw new TypeError('模块必须是有名称的函数')
        }
        getNamespace(namespaceOrFunction)[fn.name] = fn
      }
    }

    if (typeof namespaceOrFunction !== 'function' || maybeFunction !== undefined) {
      throw new TypeError('factory 参数不正确')
    }

    getNamespace('')[namespaceOrFunction.name] = namespaceOrFunction
    return namespaceOrFunction
  }

  function start(startApplication) {
    if (started) {
      throw new Error('App can be started only once')
    }
    if (typeof startApplication !== 'function') {
      throw new TypeError('startApplication 必须是函数')
    }

    // 先设置状态,避免启动回调重入 start()
    started = true
    startApplication(Object.freeze(modules))
  }

  return Object.freeze({ factory, start })
}

const app = createLoader()
app.factory('tools')(function Timer() {
  return {}
})
app.factory(function DataService() {
  return {}
})
app.start(modules => {
  const dataService = modules.DataService()
  const timer = modules.tools.Timer()
  console.log(dataService, timer)
})

这个 Loader 只演示闭包和注册入口,不是完整模块系统:Object.freeze(modules) 是浅冻结,嵌套命名空间仍可能修改;生产项目应使用原生 ESM、构建工具或成熟的模块加载方案。

十一、总结

  • 闭包是函数与其定义处词法环境的关联;
  • 词法作用域由定义位置决定,不由调用位置决定;
  • var 循环通常共享绑定,for (let ...) 可以获得逐次迭代绑定;
  • 闭包可以封装状态、实现工厂、装饰器、事件回调和模块私有数据;
  • 变量何时被回收取决于可达性,不能把定时器或监听器简单描述成固定生命周期;
  • Object.freeze() 是浅冻结,不等于深度不可变,也不等于原型私有;
  • 现代项目优先使用 ES Module 管理作用域,使用清理函数处理事件、定时器和订阅生命周期。

参考资料

807 DOCUMENTS · 12 COLLECTIONS
ARCHIVE SEARCH807 篇文章