首页/文章/javascript

从JavaScript执行原理到作用域与闭包

2024-09-03
314311 分钟
...

闭包是JavaScript中非常重要的一个概念,MDN中提到闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。看起来非常晦涩难懂,不易理解。今天写下这篇文章就是想从原理上深入探索,彻底弄明白什么是闭包。

JavaScript执行三阶段与变量提升

先来看看这段代代码,明明console.log(a)在声明赋值之前,却没有出现语法错误而终止执行,JavaScript作为一种解释性语言,它并不只是简单的读取一行代码就执行一行代码。

console.log(a) 
var a = '赋值成功'
console.log(a) 

整个JavaScript的执行过程,可以简单归纳为三个步骤:语法检查、预编译、执行

语法检查阶段

当JavaScript代码被加载到浏览器环境或Node.js环境中时,首先进行语法检查,检查代码是否符合 JavaScript 的语法规则,比如括号是否正确闭合,发现并报告语法错误,阻止代码的进一步执行

预编译阶段

预编译是发生在函数执行的前一刻,‌或全局代码执行前,这里值得注意⚠️全局代码执行前和每个函数执行前都会经历预编译阶段,而不仅仅是只执行一次全局预编译。预编译阶段JavaScript引擎会创建函数作用域,‌分配变量、‌函数名以及参数等内存空间,‌并赋予默认值,此阶段涉及变量提升和函数提升。‌到这里可以解释上面的代码为什么输出会是undefined了。这个阶段的主要任务如下:

  • 函数声明提升:函数声明会被提升到它们所在作用域的顶部。这意味着即使函数在代码中被调用的位置在声明之前,调用也是有效的。
  • 变量声明提升:使用var声明的变量也会被提升到它们所在作用域的顶部,但只有声明被提升,赋值不会提升。使用letconst声明的变量不会被提升,而是被放入一个叫暂时性死区(Temporal Dead Zone, TDZ)的特殊区域,直到它们的声明被执行。
  • 作用域确定‌:为代码中的变量和函数确定其作用域。

看看这段代码,函数和var声明的变量被提升了,letconst声明的变量无法被提升:

console.log(a) 
console.log(b) 



var a = 1
function b() {
  console.log('函数执行了')
}


执行阶段

此阶段JavaScript引擎会按照代码的顺序,‌逐行解释执行代码,‌完成代码的实际功能‌,使用 varletconst 声明的变量将被赋予实际的值

GO、AO与作用域链

搞懂了JavaScript执行的三个阶段,接下来我们来看看作用域是怎么确定的。

GO与AO

全局对象GO

全局对象GO(Global Object) 是在全局作用域中自动创建的,它代表了全局作用域。在浏览器中是window对象;在Node.js中是global对象。它包含了全局变量、内置函数和用户定义的全局变量和函数。它在代码执行之前就已经存在,其确定主要分为以下3个步骤:

  1. 创建GO对象
  2. 找变量声明,将变量声明作为GO对象的属性名,值赋予undefined
  3. 找全局里的函数声明 ,将函数名作为GO对象的属性名,值赋予函数体

举个例子:

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

console.log(a) 

这段代码中GO的创建和代码执行的过程是这样的:

创建GO

变量声明a:undefined

函数声明a:function

输出a

赋值a:1

输出1

再次输出1

激活对象AO

激活对象AO(Activation Object) 是在函数被调用时创建的,它代表了函数作用域。它包含了函数的局部变量、参数、this关键字的引用以及函数内部声明的函数。当一个函数被调用时,一个新的执行上下文被创建,其中包括一个新的AO。这个AO会随着函数的执行而存在,函数执行完毕后,AO通常会被销毁,除非函数形成了闭包。其确定主要分为以下4个步骤:

  1. 创建AO对象
  2. 到函数体作用域里找形参和变量声明,将形参和变量声明作为AO对象的属性名,值为undefined
  3. 将实参赋值给形参
  4. 在函数体内找函数声明,将函数名作为AO对象的属性名,值赋予函数体

举个例子:

function func(a) {
  console.log(a) 
  var a = 1
  console.log(a) 
  function a() {}
  console.log(a) 
  var b = function () {} 
  console.log(b) 
  function c() {}
  var c = a
  console.log(c) 
}

func(2)

AO的创建和代码执行的过程是这样的:

AO、GO和函数代码的执行总顺序

在这里,我们可以理解为JavaScript在全局代码预编译阶段创建GO,在函数调用后、函数内部代码执行前创建AO,执行完毕后决定销毁AO与否。整个流程是这样的:

这里AO的创建晚于GO,也就是说AO中的新值,在其作用域内会覆盖掉GO

作用域链

从上面的学习,我们知道,对于每个函数,JavaScript引擎会创建一个与之关联的作用域对象AO,这个作用域对象包含函数内部声明的所有局部变量、函数参数以及声明的变量(⚠️注意:letconst声明的变量具有块级作用域,而var声明的变量具有函数级作用域,这里只拿var做分析)

此外,JavaScript引擎还会构建一个作用域链(Scope Chain),它是一个从当前作用域向上查找父级作用域的链表。当在函数内部访问一个变量时,JavaScript会首先在当前作用域中查找,如果找不到,则继续向上在作用域链中查找,直到找到全局作用域。我们可以把作用域链理解为一个保存了当前函数AO、父级函数AO和GO对象的链表。

我们直接看图,这里展示的是test2执行前一刻的作用域链: 函数的作用域链可以干成一个数组,自身AO排在数组的最前面,越往下这是父级函数的AO,直到最后到达全局对象GO。

步入正题——闭包

闭包到底是什么

说白了,闭包就是一个内部函数访问了它外部函数的变量。闭包会创建一个包含外部函数作用域变量的环境,并将其保存在内存中,这意味着,即使外部函数已经执行完毕,闭包仍然可以访问和使用外部函数的变量。

看图更容易理解,还是这个图,当test1()被执行完后,其AO对象本该被销毁,但其还在test2的作用域链上,可以被test2操控,形成闭包了,不会被销毁。 来段代码看看,这里,我们通过返回的addreduce方法操作了calculateOne的局部变量num,形成闭包:


function calculateOne(num) {
  var n = num
  
  function add() {
    n++
    console.log(n)
  }
  
  function reduce() {
    n--
    console.log(n)
  }
  return {
    add: add,
    reduce: reduce,
  }
}


var test = calculateOne(100)
test.add() 
test.add() 
test.reduce() 

可见,我们常用的定时器、事件监听器、Ajax请求这些都是闭包,它们使用了回调函数访问了外部作用域。

闭包的优缺点

闭包的优点

变量封装和隐私保护JavaScript本身不支持变量私有化,通过闭包可以将变量封装在函数内部,避免全局污染,保护变量不被外部访问和修改。
变量生命周期延长闭包让函数内部的变量在函数执行完后仍然存在,可以在函数外部继续使用。
实现模块化闭包可以创建私有变量和私有方法,通过返回函数来暴露接口,隐藏内部实现细节,实现模块化的封装和隐藏,提高代码的可维护性和安全性。
保持状态闭包可以捕获外部函数的变量,并在函数执行时保持其状态。在事件处理、回调函数等场景中经常用到。
柯里化通过闭包实现函数柯里化,创建函数更加灵活。

闭包带来的问题

内存占用:闭包会导致外部函数的变量无法被垃圾回收,从而增加内存占用。如果滥用闭包,会导致内存泄漏问题。
性能损耗:闭包涉及到作用域链的查找过程,会带来一定的性能损耗。
增加代码的复杂性和调试难度‌闭包内部可以访问外部作用域的变量,这可能会增加代码复杂度,使得代码的调试变得更加困难,特别是在大型项目中,需要仔细跟踪变量的作用域和生命周期。

闭包的常见应用场景

结合自动执行函数使用: 结合使用可以用来创建私有变量、私有函数、实现模块化、配置对象等,比如下面实现了模块的封装:

var myModule = (function () {
  var privateVar = 'Private data'
  function privateFunction() {
    console.log('Private function called')
  }
  return {
    publicFunction: function () {
      privateFunction()
      return privateVar
    },
  }
})()

console.log(myModule.publicFunction())




柯里化(Currying) 柯里化是一种函数的转换,它是指将一个函数从可调用的 f(a, b, c) 转换为可调用的 f(a)(b)(c)。柯里化不会调用函数。它只是对函数进行转换。

function curry(f) {
  
  return function (a) {
    return function (b) {
      return f(a, b)
    }
  }
}


function sum(a, b) {
  return a + b
}

let curriedSum = curry(sum)

console.log(curriedSum(1)(2)) 

节流和防抖


function throttle(func, delay) {
  let timer = null
  return function () {
    if (!timer) {
      timer = setTimeout(() => {
        func.apply(this, arguments)
        timer = null
      }, delay)
    }
  }
}

function debounce(func, delay) {
  let timer = null
  return function () {
    clearTimeout(timer)
    timer = setTimeout(() => {
      func.apply(this, arguments)
    }, delay)
  }
}

另外,闭包在事件回调处理、迭代器封装、发布订阅者模式等都有着广泛的应用。

解除闭包

闭包在不需要的时候需要解除,否则会占用内存,影响性能,通常我们可以通过解除引用的方法来解除闭包:


function calculateOne(num) {
  var n = num
  
  function add() {
    n++
    console.log(n)
  }
  
  function reduce() {
    n--
    console.log(n)
  }
  return {
    add: add,
    reduce: reduce,
  }
}


var test = calculateOne(100)
test.add() 
test.add() 
test.reduce() 


test = null
console.log(test) 

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
javascript2025-09-18
JavaScript 的多线程能力:Worker
如果你写过一些计算量稍大的 JavaScript 代码,比如图像处理、大量数据排序或者复杂的算法,你几乎肯定遇到过浏览器“卡死”的现象。点击页面没反应,动画也停了,就像整个世界都静止了。 这就是主线程被阻塞的典型后果。因为主线程既要负责执行 JavaScript,又要负责渲染页面、响应用户操作,一旦它被繁重的计算任务占满,就无暇顾及其他,用户体验便直线下降。 这个问题的根源,正是“主线程是单线程的”。那么,如何解决呢? 答案很简单:把这...
学习面试
javascript2025-09-10
手把手教你实现一个EventEmitter
你是不是也遇到过这种情况:代码里各种事件监听和触发,回调函数套了一层又一层,最后自己都理不清哪个事件先触发、哪个后触发了? 别担心,今天我就带你从零开始实现一个自己的事件触发器(EventEmitter),让你彻底掌握事件驱动的精髓,代码清晰度直接提升200%! 为什么要懂EventEmitter? 先说说为什么我们要关心这个。Node.js的核心就是事件驱动,比如文件读写、网络请求,都是通过事件来处理的。不会EventEmitter,...
学习面试
javascript2025-08-09
Web Worker 多线程魔法
你是否曾遇到过网页因为复杂计算而卡顿的情况?今天,让我们一起探索Web Worker的神奇世界,看看如何用多线程技术解决性能瓶颈,并实现流畅的图片压缩功能! 为什么需要Web Worker? 想象一下,你在一个繁忙的餐厅里,只有一位服务员(主线程)负责点餐、上菜、结账所有工作。当客人突然暴增时,这位服务员就会手忙脚乱,其他客人只能干等着… 这就是JavaScript的现状—— 单线程 !所有任务都在一个线程中排队执行,一旦遇到复杂计算(...
学习面试
javascript2025-08-07
🌐AST抽象语法树
AST 是前端开发中一个非常重要的概念,它就像是代码的“编译器”,能够帮助我们理解和操作代码。本文将深入探讨 AST 的原理和应用,带你从零开始了解 AST。 一、什么是 AST? AST(抽象语法树)是一种树形结构,用于表示程序代码的语法结构。它将代码分解为一系列的节点,每个节点代表代码中的一个元素,如变量、函数、表达式等。AST 是编译器和解释器的基础,也是代码分析和转换的关键工具。 1\. AST 的结构 AST 的结构通常包括以...
学习面试
javascript2025-08-05
打字机效果是如何实现的
分析效果 使用 AI 对话的时候,文字是一个字一个字蹦出来的?它不是等所有内容生成完毕后一次性展示,而是以一种流畅、实时的“打字机”效果呈现,这种体验的背后,隐藏着一种高效的Web技术。它就是 服务器推送事件(Server Sent Events,简称 SSE) 什么是 SSE 从根本上说,SSE 是一种允许服务器 单向 、 持续 地向客户端(浏览器)推送数据的Web技术。 可以把它想象成一个 电台广播 : 服务器 是广播电台,持续不断...
学习面试
javascript2025-08-04
Promise 从原理到实现
前言 是 异步编程的核心概念之一,它为我们提供了一种更优雅的方式来处理异步操作。 本文将全面剖析 的工作原理,并逐步实现一个符合 规范的 。 正文 一、Promise 基础概念 代表一个异步操作的最终完成(或失败)及其结果值。它有 种状态: 1. pending : ,既不是成功,也不是失败状态 2. fulfilled :意味着操作 完成 3. rejected :意味着操作 状态一旦改变就不能再变,只能从 变为 ,或从 变为 。 二...
学习面试

评论

请登录后发表评论

去登录
加载评论中...

目录