• 作者:老汪软件技巧
  • 发表时间:2024-09-05 15:01
  • 浏览量:

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

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

JavaScript执行三阶段与变量提升

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

console.log(a) // 输出结果 undefined
var a = '赋值成功'
console.log(a) // 输出结果 赋值成功

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

graph LR
语法检查 --> 预编译 --> 执行

语法检查阶段

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

预编译阶段

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

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

console.log(a) // 输出undefined
console.log(b) // 输出[Function: b]
// console.log(c) // 语法错误
// console.log(d) // 语法错误
var a = 1
function b() {
  console.log('函数执行了')
}
// let c = 2
// const d = 3

执行阶段

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

GO、AO与作用域链

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

GO与AO全局对象GO

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

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

举个例子:

console.log(a) // output:[Function: a]
var a = 1
console.log(a) // output:1
function a(){} 
// a() // 这里a已经是数字型,出现语法错误
console.log(a) // output:1

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

graph LR
创建GO --> 变量声明a:undefined --> 函数声明a:function --> 输出a --> 赋值a:1 --> 输出1 --> 再次输出1

激活对象AO

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

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

举个例子:

function func(a) {
  console.log(a) // output:[Function: a]
  var a = 1
  console.log(a) // output:1
  function a() {}
  console.log(a) // output:1
  var b = function () {} //函数表达式
  console.log(b) // output:[Function: b]
  function c() {}
  var c = a
  console.log(c) // output:1
}
func(2)

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

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

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

graph LR
创建GO --> 函数调用 --> 创建AO --> 执行函数代码 --> 销毁AO

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

域的使用__首先我们要记住域的使用方法

作用域链

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

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

graph LR
当前函数AO --> 父级函数AO --> GO

我们直接看图,这里展示的是test2执行前一刻的作用域链:

函数的作用域链可以干成一个数组,自身AO排在数组的最前面,越往下这是父级函数的AO,直到最后到达全局对象GO。

步入正题——闭包闭包到底是什么

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

看图更容易理解,还是这个图,当test1()被执行完后,其AO对象本该被销毁,但其还在test2的作用域链上,可以被test2操控,形成闭包了,不会被销毁。

来段代码看看,这里,我们通过返回的add和reduce方法操作了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() // 101
test.add() // 102
test.reduce() // 101

可见,我们常用的定时器、事件监听器、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())
// output:
// Private function called
// Private data

柯里化(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)) // 3

节流和防抖

// 节流
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() // 101
test.add() // 102
test.reduce() // 101
// 解除闭包
test = null
console.log(test) // null